Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bundle-2.2/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<name>Spline Spark Agent Bundle for Spark 2.2</name>
<parent>
<groupId>za.co.absa.spline.agent.spark</groupId>
<artifactId>spline-spark-agent_2.12</artifactId>
<artifactId>spline-spark-agent_2.11</artifactId>
<relativePath>../pom.xml</relativePath>
<version>2.3.0-SNAPSHOT</version>
</parent>
Expand Down
2 changes: 1 addition & 1 deletion bundle-2.3/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<name>Spline Spark Agent Bundle for Spark 2.3</name>
<parent>
<groupId>za.co.absa.spline.agent.spark</groupId>
<artifactId>spline-spark-agent_2.12</artifactId>
<artifactId>spline-spark-agent_2.11</artifactId>
<relativePath>../pom.xml</relativePath>
<version>2.3.0-SNAPSHOT</version>
</parent>
Expand Down
4 changes: 2 additions & 2 deletions bundle-2.4/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>spark-2.4-spline-agent-bundle_2.12</artifactId>
<artifactId>spark-2.4-spline-agent-bundle_2.11</artifactId>
<packaging>jar</packaging>
<name>Spline Spark Agent Bundle for Spark 2.4</name>
<parent>
<groupId>za.co.absa.spline.agent.spark</groupId>
<artifactId>spline-spark-agent_2.12</artifactId>
<artifactId>spline-spark-agent_2.11</artifactId>
<relativePath>../pom.xml</relativePath>
<version>2.3.0-SNAPSHOT</version>
</parent>
Expand Down
4 changes: 2 additions & 2 deletions commons/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<artifactId>agent-commons_2.12</artifactId>
<artifactId>agent-commons_2.11</artifactId>
<packaging>jar</packaging>

<name>Spline Spark Agent Commons</name>

<parent>
<groupId>za.co.absa.spline.agent.spark</groupId>
<artifactId>spline-spark-agent_2.12</artifactId>
<artifactId>spline-spark-agent_2.11</artifactId>
<relativePath>../pom.xml</relativePath>
<version>2.3.0-SNAPSHOT</version>
</parent>
Expand Down
4 changes: 2 additions & 2 deletions core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<artifactId>agent-core_2.12</artifactId>
<artifactId>agent-core_2.11</artifactId>
<packaging>jar</packaging>

<name>Spline Spark Agent Core</name>

<parent>
<groupId>za.co.absa.spline.agent.spark</groupId>
<artifactId>spline-spark-agent_2.12</artifactId>
<artifactId>spline-spark-agent_2.11</artifactId>
<relativePath>../pom.xml</relativePath>
<version>2.3.0-SNAPSHOT</version>
</parent>
Expand Down
14 changes: 9 additions & 5 deletions core/src/main/scala/za/co/absa/spline/agent/SplineAgent.scala
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ object SplineAgent extends Logging {
val readCommandExtractor = new PluggableReadCommandExtractor(pluginRegistry, dataSourceFormatResolver)

new SplineAgent {
def handle(funcName: FuncName, qe: QueryExecution, result: Either[Throwable, Duration]): Unit = withErrorHandling {
def handle(funcName: FuncName, qe: QueryExecution, result: Either[Throwable, Duration]): Unit = withErrorHandling() {
val idGenerators = new IdGeneratorsBundle(execPlanUUIDGeneratorFactory)
val harvestingContext = new HarvestingContext(funcName, qe.analyzed, Some(qe.executedPlan), session, idGenerators)
val postProcessor = new PostProcessor(filters, harvestingContext)
Expand All @@ -91,17 +91,21 @@ object SplineAgent extends Logging {
.harvest(result)
.foreach({
case (plan, event) =>
lineageDispatcher.send(plan)
lineageDispatcher.send(event)
withErrorHandling("execution plan dispatch") {
lineageDispatcher.send(plan)
}
withErrorHandling("execution event dispatch") {
lineageDispatcher.send(event)
}
})
}

private def withErrorHandling(body: => Unit): Unit = {
private def withErrorHandling(stage: String = "lineage processing")(body: => Unit): Unit = {
try body
catch {
case NonFatal(e) =>
val ctx = session.sparkContext
logError(s"Unexpected error occurred during lineage processing for application: ${ctx.appName} #${ctx.applicationId}", e)
logError(s"Unexpected error occurred during $stage for application: ${ctx.appName} #${ctx.applicationId}", e)
}
}
Comment on lines +103 to 110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Search for other unprotected `session.sparkContext` accesses in catch blocks.
ast-grep run --pattern 'catch { $$$ case $_ => $$$ session.sparkContext $$$ }' --lang scala

Repository: AbsaOSS/spline-spark-agent

Length of output: 164


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and inspect the relevant section.
ast-grep outline core/src/main/scala/za/co/absa/spline/agent/SplineAgent.scala --view expanded

echo '--- lines around withErrorHandling ---'
sed -n '80,130p' core/src/main/scala/za/co/absa/spline/agent/SplineAgent.scala

echo '--- all session.sparkContext references in the file ---'
rg -n 'session\.sparkContext|sparkContext' core/src/main/scala/za/co/absa/spline/agent/SplineAgent.scala

echo '--- all withErrorHandling references in the repository ---'
rg -n 'withErrorHandling\(' core/src/main/scala

Repository: AbsaOSS/spline-spark-agent

Length of output: 2282


🌐 Web query:

SparkContext applicationId IllegalStateException after stop sparkContext access docs

💡 Result:

The IllegalStateException: Cannot call methods on a stopped SparkContext occurs because Spark requires that you do not access the SparkContext, or attempt to call methods on it (such as accessing its applicationId), after the stop method has been invoked [1][2][3]. The SparkContext class contains an internal state check, assertNotStopped, which is triggered by most public methods in the context [1][2]. If the context has been stopped, this check throws the IllegalStateException [2][3]. This error is commonly encountered when: 1. Code Execution Order: Your application logic, such as a read or write operation or an access to the applicationId, is accidentally triggered after a call to spark.stop or sc.stop [4]. 2. Premature Shutdown: In scripts or notebooks, a stop command may be positioned where it executes before the intended tasks are completed (e.g., due to incorrect indentation or control flow) [4]. 3. Re-initialization Issues: Spark allows only one active SparkContext per JVM [3][5]. If you stop a context and attempt to perform operations without correctly re-initializing or ensuring the previous instance is fully cleaned up, you may encounter this error when accessing stale references [6][5]. To resolve or prevent this error: - Check for unintended calls to stop: Ensure that your SparkContext is stopped only after all processing is complete [4]. - Verify Context State: Before calling methods that rely on the SparkContext, you can use the isStopped method to check its status [1][5]. - Manage Context Lifecycle: In interactive environments like notebooks or when running multiple jobs in the same JVM, ensure you are not holding onto a reference to a SparkContext that has already been shut down [6]. If you are encountering this when trying to access the applicationId, confirm that the SparkContext instance you are querying is still active and has not been closed by a shutdown hook or an explicit stop call [2][4].

Citations:


Guard the error logging path session.sparkContext can throw after Spark shutdown, so this catch block can fail while reporting the original error. Add a fallback log message when app metadata is unavailable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/src/main/scala/za/co/absa/spline/agent/SplineAgent.scala` around lines
103 - 110, Update withErrorHandling so error reporting does not depend solely on
session.sparkContext: safely attempt to obtain the application name and ID, and
fall back to a generic stage-specific error message when Spark metadata is
unavailable. Always pass the original exception to logError, including in the
fallback path.

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,19 @@ class CompositeLineageDispatcher(val delegatees: Seq[LineageDispatcher], failOnE
}

private def delegate(call: LineageDispatcher => Unit): Unit = {
delegatees.foreach(withErrorHandling(call))
}
val failures = delegatees.flatMap { disp =>
try {
call(disp)
None
} catch {
case NonFatal(e) =>
logWarning(s"Proceeding after an error occurred in an underlying dispatcher: ${disp.getClass.getName}", e)
Some(e)
}
}

private def withErrorHandling(call: LineageDispatcher => Unit): LineageDispatcher => Unit = { disp =>
try call(disp)
catch {
case NonFatal(e) =>
if (failOnErrors) throw e
else logWarning(s"Proceeding after an error occurred in an underlying dispatcher: ${disp.getClass.getName}", e)
if (failOnErrors) {
failures.headOption.foreach(e => throw e)
}
Comment on lines +47 to 60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check that `CompositeLineageDispatcherSpec` still expects no further interactions on failure.
rg 'verifyNoMoreInteractions' core/src/test/scala/za/co/absa/spline/harvester/dispatcher/CompositeLineageDispatcherSpec.scala -B 3

Repository: AbsaOSS/spline-spark-agent

Length of output: 742


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the implementation and the relevant spec sections.
sed -n '1,140p' core/src/main/scala/za/co/absa/spline/harvester/dispatcher/CompositeLineageDispatcher.scala
printf '\n---- SPEC ----\n'
sed -n '1,220p' core/src/test/scala/za/co/absa/spline/harvester/dispatcher/CompositeLineageDispatcherSpec.scala

Repository: AbsaOSS/spline-spark-agent

Length of output: 5808


Restore fail-fast dispatching when failOnErrors is enabled. delegatees.flatMap still evaluates every dispatcher, so later delegates run after the first failure and mockDispatcher2 can be touched even though the spec expects no further interactions. Split the tolerant and fail-fast paths so the first error aborts dispatching.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/src/main/scala/za/co/absa/spline/harvester/dispatcher/CompositeLineageDispatcher.scala`
around lines 47 - 60, Update the dispatch logic around delegatees and
failOnErrors so the failOnErrors=true path invokes dispatchers sequentially and
immediately propagates the first NonFatal failure, preventing later delegatees
from running. Preserve the existing tolerant behavior and failure collection
when failOnErrors is false, including warning logs for recovered dispatcher
errors.

}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,23 @@ class FallbackLineageDispatcher(primaryDispatcher: LineageDispatcher, fallbackDi
catch {
case e if NonFatal(e) =>
logError("Error when sending ExecutionPlan, will try to use fallback dispatcher next", e)
fallbackDispatcher.send(plan)
try fallbackDispatcher.send(plan)
catch {
case fallbackError if NonFatal(fallbackError) =>
logError("Fallback dispatcher also failed when sending ExecutionPlan. Spark job result is not affected.", fallbackError)
}
}

override def send(event: ExecutionEvent): Unit =
try primaryDispatcher.send(event)
catch {
case e if NonFatal(e) =>
logError("Error when sending ExecutionEvent, will try to use fallback dispatcher next", e)
fallbackDispatcher.send(event)
try fallbackDispatcher.send(event)
catch {
case fallbackError if NonFatal(fallbackError) =>
logError("Fallback dispatcher also failed when sending ExecutionEvent. Spark job result is not affected.", fallbackError)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,11 @@
import za.co.absa.spline.producer.model.{ExecutionEvent, ExecutionPlan}

import java.net.URI
import java.nio.charset.StandardCharsets
import java.util.UUID
import scala.concurrent.blocking
import scala.util.control.NonFatal

/**
* A port of https://github.com/AbsaOSS/spline/tree/release/0.3.9/persistence/hdfs/src/main/scala/za/co/absa/spline/persistence/hdfs
*
* Note:
* This class is unstable, experimental, is mostly used for debugging, with no guarantee to work properly
* for every generic use case in a real production application.
*
* It is NOT thread-safe, strictly synchronous assuming a predefined order of method calls: `send(plan)` and then `send(event)`
*/
@Experimental
class HDFSLineageDispatcher(filename: String, permission: FsPermission, bufferSize: Int)
extends LineageDispatcher
Expand All @@ -62,39 +56,158 @@
}

override def send(event: ExecutionEvent): Unit = {
// check state
if (this._lastSeenPlan == null || this._lastSeenPlan.id.get != event.planId)
throw new IllegalStateException("send(event) must be called strictly after send(plan) method with matching plan ID")

try {
val path = s"${this._lastSeenPlan.operations.write.outputSource.stripSuffix("/")}/$filename"
val sparkContext = SparkContext.getOrCreate()

val lineageBaseDir = sparkContext.getConf.get(
"spark.spline.lineageDispatcher.hdfs.directory",
"file:///C:/tmp" // Default to local storage on Windows
)
Comment on lines +65 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Hardcoded Windows-only default path in cross-platform library code.

Defaulting lineageBaseDir to "file:///C:/tmp" means any deployment (typically Linux Spark clusters) that forgets to set spark.spline.lineageDispatcher.hdfs.directory gets a nonsensical path. Since dispatch errors are swallowed by the caller (withErrorHandling in SplineAgent.scala), this fails silently — lineage is just never written, with no obvious signal why.

Proposed fix: use an OS-agnostic default
-      val lineageBaseDir = sparkContext.getConf.get(
-        "spark.spline.lineageDispatcher.hdfs.directory",
-        "file:///C:/tmp" // Default to local storage on Windows
-      )
+      val lineageBaseDir = sparkContext.getConf.get(
+        "spark.spline.lineageDispatcher.hdfs.directory",
+        s"file://${System.getProperty("java.io.tmpdir")}/spline-lineage"
+      )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val lineageBaseDir = sparkContext.getConf.get(
"spark.spline.lineageDispatcher.hdfs.directory",
"file:///C:/tmp" // Default to local storage on Windows
)
val lineageBaseDir = sparkContext.getConf.get(
"spark.spline.lineageDispatcher.hdfs.directory",
s"file://${System.getProperty("java.io.tmpdir")}/spline-lineage"
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/src/main/scala/za/co/absa/spline/harvester/dispatcher/HDFSLineageDispatcher.scala`
around lines 65 - 68, Replace the Windows-specific fallback in the
lineageBaseDir configuration lookup with an OS-agnostic default suitable for all
supported deployments, while preserving the configured
spark.spline.lineageDispatcher.hdfs.directory value when provided. Update only
the default argument to SparkContext.get.


val executionPlanID = this._lastSeenPlan.id.getOrElse("unknown_plan")
val executionPlanName = this._lastSeenPlan.name.replaceAll("[^a-zA-Z0-9_\\-]", "_")
val runID = event.extra.get("appId").map(_.toString).getOrElse("unknown_runID")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Unconditional cross-run cleanup can delete another concurrently-running job's lineage data.

cleanupOldRunFolders recursively deletes every subdirectory of appDir that isn't the current run's appId-derived folder — every single time an event is dispatched. appDir is keyed only by the sanitized execution-plan name, not by anything guaranteeing exclusivity. Two overlapping executions of the same job (retry, back-to-back scheduled runs, blue/green rollout) will each see the other's run folder as "old" and delete it — potentially while the other run is actively writing into it. This turns what should be a persistent lineage audit trail into something that can lose or corrupt a peer application's output.

Consider making this cleanup opt-in (config flag) and/or scoping deletion more conservatively (e.g. age-based retention rather than "everything but mine").

Also applies to: 97-124

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/src/main/scala/za/co/absa/spline/harvester/dispatcher/HDFSLineageDispatcher.scala`
at line 72, Update HDFSLineageDispatcher cleanupOldRunFolders so dispatching an
event does not unconditionally delete every run folder except the current
appId-derived folder. Make cleanup opt-in via configuration and/or replace the
cross-run deletion with conservative age-based retention, ensuring concurrently
running or recently completed runs remain intact; preserve normal lineage writes
and current-run handling.


val appDir = s"$lineageBaseDir/$executionPlanName"
val runDir = s"$appDir/$runID"

// Best-effort cleanup of previous runs
cleanupOldRunFolders(appDir, runID)

val fileName = s"lineage_${executionPlanID}.json"

// Build JSON payload
val planWithEvent = Map(
"executionPlan" -> this._lastSeenPlan,
"executionEvent" -> event
)

import HarvesterJsonSerDe.impl._
persistToHadoopFs(planWithEvent.toJson, path)
val content = planWithEvent.toJson

// Atomically materialize the run folder & file
persistRunFolderAtomically(runDir, fileName, content)
Comment on lines +70 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Configured fileName is now dead — output naming is fully hardcoded.

The class still accepts/requires a filename field via its Configuration constructor (unchanged lines 39-47), but send(event) never references this.filename; the file is always named lineage_<planId>.json. Any user setting fileName (defaulted to _LINEAGE in spline.default.yaml) has zero effect, which is misleading for anyone tuning that config.

Either wire this.filename into the computed name (e.g. as a prefix) or drop the now-unused parameter/config key to avoid a misleading public contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/src/main/scala/za/co/absa/spline/harvester/dispatcher/HDFSLineageDispatcher.scala`
around lines 70 - 91, Update HDFSLineageDispatcher so the configured filename
field from Configuration is used when constructing the output file name in
send(event), rather than always hardcoding the lineage_ prefix. Preserve the
plan ID suffix and JSON extension, and ensure the existing filename
configuration meaningfully controls the generated name.

} finally {
this._lastSeenPlan = null
}
}

private def persistToHadoopFs(content: String, fullLineagePath: String): Unit = blocking {
val (fs, path) = pathStringToFsWithPath(fullLineagePath)
logDebug(s"Opening HadoopFs output stream to $path")
/**
* Deletes all runID folders except the current one
* @param appDirPath Path to the app directory
* @param currentRunID The current runID to keep
*/
private def cleanupOldRunFolders(appDirPath: String, currentRunID: String): Unit = {
try {
val (fs, appPath) = pathStringToFsWithPath(appDirPath)

if (fs.exists(appPath) && fs.getFileStatus(appPath).isDirectory) {
val statuses = fs.listStatus(appPath)
statuses.foreach { status =>
if (status.isDirectory && status.getPath.getName != currentRunID) {
try {
logInfo(s"Deleting old runID folder: ${status.getPath}")
fs.delete(status.getPath, true) // recursive delete
} catch {
case NonFatal(e) =>
logWarning(s"Failed to delete old runID folder: ${status.getPath}", e)
}
}
}
}
} catch {
case NonFatal(e) =>
logWarning(s"Failed to cleanup old run folders in $appDirPath", e)
}
}

val replication = fs.getDefaultReplication(path)
val blockSize = fs.getDefaultBlockSize(path)
val outputStream = fs.create(path, permission, true, bufferSize, replication, blockSize, null)
/**
* Atomically create a run directory with its lineage JSON file inside.
* Strategy:
* 1) Ensure parent exists (mkdirs is idempotent)
* 2) Create a hidden temp directory next to the target
* 3) Write JSON to a temp file, then rename temp file -> final name inside temp dir
* 4) Rename temp dir -> final run dir (atomic on HDFS)
*/
private def persistRunFolderAtomically(finalRunDirStr: String, fileName: String, content: String): Unit = blocking {
val (fs, finalRunDir) = pathStringToFsWithPath(finalRunDirStr)

val umask = FsPermission.getUMask(fs.getConf)
FsPermission.getFileDefault.applyUMask(umask)
// Ensure final run directory exists
if (!fs.exists(finalRunDir)) {
fs.mkdirs(finalRunDir)
try fs.setPermission(finalRunDir, permission) catch { case _: Throwable => () }
}

logDebug(s"Writing lineage to $path")
using(outputStream) {
_.write(content.getBytes("UTF-8"))
// Write the file atomically inside the run directory
val finalFileInRunDir = new Path(finalRunDir, fileName)
writeFileAtomically(fs, finalFileInRunDir, content.getBytes(StandardCharsets.UTF_8))
}

/**
* Write a single file atomically via temp-sibling + rename.
* If the final file already exists, it is removed first so repeated attempts
* for the same Spark application can refresh the lineage payload.
* The rename is retried up to [[RenameRetries]] times with a short delay to handle
* transient HDFS failures (e.g. a bad datanode causing a slow or failed pipeline ack).
*/
private def writeFileAtomically(fs: FileSystem, finalPath: Path, bytes: Array[Byte]): Unit = {
val parent = finalPath.getParent
if (!fs.exists(parent)) {
fs.mkdirs(parent)
}

val tmpFile = new Path(parent, s".${finalPath.getName}.tmp-${UUID.randomUUID().toString}")
val replication = fs.getDefaultReplication(finalPath)
val blockSize = fs.getDefaultBlockSize(finalPath)

logDebug(s"Creating temp file $tmpFile")
val out = fs.create(tmpFile, permission, true, bufferSize, replication, blockSize, null)
try {
out.write(bytes)
// Best-effort durability hints; on HDFS these are meaningful.
out.hflush()
out.hsync()
} finally {
out.close()
}

try fs.setPermission(tmpFile, permission) catch { case _: Throwable => () }

if (fs.exists(finalPath) && !fs.delete(finalPath, false)) {
try fs.delete(tmpFile, false) catch { case _: Throwable => () }
throw new RuntimeException(s"Failed to delete existing lineage file before rewrite: $finalPath")
}

logDebug(s"Renaming $tmpFile -> $finalPath (atomic on HDFS)")

@scala.annotation.tailrec
def renameWithRetry(attemptsLeft: Int): Unit = {
if (fs.rename(tmpFile, finalPath)) {
logDebug(s"Rename succeeded: $tmpFile -> $finalPath")
} else if (attemptsLeft > 0) {
logWarning(s"Rename failed for $tmpFile -> $finalPath, retrying in ${RenameRetryDelayMs}ms ($attemptsLeft attempt(s) left)")
Thread.sleep(RenameRetryDelayMs)
renameWithRetry(attemptsLeft - 1)
} else {
try fs.delete(tmpFile, false) catch { case _: Throwable => () }
throw new RuntimeException(
s"Failed to atomically rename $tmpFile to $finalPath after ${RenameRetries + 1} attempt(s)")
}
}

renameWithRetry(RenameRetries)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Kept for compatibility: atomic-at-file-level write for a direct full path
private def persistToHadoopFs(content: String, fullLineagePath: String): Unit = blocking {

Check warning on line 204 in core/src/main/scala/za/co/absa/spline/harvester/dispatcher/HDFSLineageDispatcher.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused private "persistToHadoopFs" method.

See more on https://sonarcloud.io/project/issues?id=AbsaOSS_spline-spark-agent&issues=AZ9glI1V2uA7cBpzdmML&open=AZ9glI1V2uA7cBpzdmML&pullRequest=921
val (fs, path) = pathStringToFsWithPath(fullLineagePath)
val parentDir = path.getParent
if (!fs.exists(parentDir)) {
fs.mkdirs(parentDir)
}
writeFileAtomically(fs, path, content.getBytes(StandardCharsets.UTF_8))
}
}

Expand All @@ -105,12 +218,14 @@
private val FilePermissionsKey = "filePermissions"
private val BufferSizeKey = "fileBufferSize"

private val RenameRetries = 3
private val RenameRetryDelayMs = 1000L

/**
* Converts string full path to Hadoop FS and Path, e.g.
* `s3://mybucket1/path/to/file` -> S3 FS + `path/to/file`
* `/path/on/hdfs/to/file` -> local HDFS + `/path/on/hdfs/to/file`
*
* Note, that non-local HDFS paths are not supported in this method, e.g. hdfs://nameservice123:8020/path/on/hdfs/too.
* `file:///path/to/file` -> local FS + `/path/to/file`
* `/path/on/hdfs/to/file` -> HDFS + `/path/on/hdfs/to/file`
*
* @param pathString path to convert to FS and relative path
* @return FS + relative path
Expand All @@ -119,14 +234,21 @@
pathString.toSimpleS3Location match {
case Some(s3Location) =>
val s3Uri = new URI(s3Location.asSimpleS3LocationString) // s3://<bucket>
val s3Path = new Path(s"/${s3Location.path}") // /<text-file-object-path>

val s3Path = new Path(s"/${s3Location.path}") // /<text-file-object-path>
val fs = FileSystem.get(s3Uri, HadoopConfiguration)
(fs, s3Path)

case None => // local hdfs location
val fs = FileSystem.get(HadoopConfiguration)
(fs, new Path(pathString))
case None =>
// Check if it's an explicit file:// URI
if (pathString.startsWith("file://") || pathString.startsWith("file:/")) {
val uri = new URI(pathString)
val fs = FileSystem.get(uri, HadoopConfiguration)
(fs, new Path(uri.getPath))
} else {
// Default HDFS location
val fs = FileSystem.get(HadoopConfiguration)
(fs, new Path(pathString))
}
}
}
}
Loading