-
Notifications
You must be signed in to change notification settings - Fork 102
Spline oct 29414 qa #921
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Spline oct 29414 qa #921
Changes from all commits
91fd65a
657fd4a
1c5e616
ade070b
d499ab7
690b504
f7d8ece
ef9b970
27cfcd4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 3Repository: 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.scalaRepository: AbsaOSS/spline-spark-agent Length of output: 5808 Restore fail-fast dispatching when 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| 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") | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
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 |
||||||||||||||||||
|
|
||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Configured The class still accepts/requires a Either wire 🤖 Prompt for AI Agents |
||||||||||||||||||
| } 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) | ||||||||||||||||||
| } | ||||||||||||||||||
|
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
|
||||||||||||||||||
| val (fs, path) = pathStringToFsWithPath(fullLineagePath) | ||||||||||||||||||
| val parentDir = path.getParent | ||||||||||||||||||
| if (!fs.exists(parentDir)) { | ||||||||||||||||||
| fs.mkdirs(parentDir) | ||||||||||||||||||
| } | ||||||||||||||||||
| writeFileAtomically(fs, path, content.getBytes(StandardCharsets.UTF_8)) | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||
|
|
@@ -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)) | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
There was a problem hiding this comment.
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:
Repository: AbsaOSS/spline-spark-agent
Length of output: 164
🏁 Script executed:
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.sparkContextcan 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