Skip to content

Commit 739b49a

Browse files
committed
feat: add basic openlineage support
1 parent 6478113 commit 739b49a

10 files changed

Lines changed: 773 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
88

99
- BREAKING: Add required CLI argument and env var to set the image repository used to construct final product image names: `IMAGE_REPOSITORY` (`--image-repository`), eg. `oci.example.org/my/namespace` ([#684]).
1010
- Support for Spark `4.1.2` ([#708])
11+
- Add `spec.openLineage` to enable OpenLineage lineage emission: injects the OpenLineage Spark listener, HTTP transport (endpoint resolved from a discovery ConfigMap), a stable job name, and the required `--add-opens` JVM flag ([#XXXX]).
1112

1213
### Fixed
1314

@@ -44,6 +45,7 @@ All notable changes to this project will be documented in this file.
4445
[#705]: https://github.com/stackabletech/spark-k8s-operator/pull/705
4546
[#708]: https://github.com/stackabletech/spark-k8s-operator/pull/708
4647
[#716]: https://github.com/stackabletech/spark-k8s-operator/pull/716
48+
[#XXXX]: https://github.com/stackabletech/spark-k8s-operator/pull/XXXX
4749

4850
## [26.3.0] - 2026-03-16
4951

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
= OpenLineage
2+
3+
https://openlineage.io/[OpenLineage] is an open standard for data lineage collection.
4+
The Spark operator can automatically emit lineage events for a `SparkApplication` to an OpenLineage-compatible backend such as https://marquezproject.github.io/marquez/[Marquez], without any manual `sparkConf` wiring.
5+
6+
When enabled, the operator injects everything required to make the https://openlineage.io/docs/integrations/spark/[OpenLineage Spark listener] work:
7+
8+
* the OpenLineage Spark listener (appended to `spark.extraListeners`, so any listener you configure yourself is preserved),
9+
* the OpenLineage jar baked into the Spark image, referenced via `spark.jars` so it shares the same classloader as connectors pulled in through xref:usage-guide/job-dependencies.adoc[`deps.packages`] (for example Apache Iceberg),
10+
* an HTTP transport pointing at the backend endpoint,
11+
* a stable lineage namespace and job name (see <<job-name>>), and
12+
* the `--add-opens java.base/java.security=ALL-UNNAMED` JVM flag the listener requires on the driver and executors.
13+
14+
== Enabling OpenLineage
15+
16+
The backend endpoint is not configured on the `SparkApplication` directly.
17+
Instead, mirroring the xref:usage-guide/logging.adoc[vector aggregator discovery], the operator reads it from a discovery ConfigMap referenced by `configMapName`:
18+
19+
[source,yaml]
20+
----
21+
apiVersion: spark.stackable.tech/v1alpha1
22+
kind: SparkApplication
23+
metadata:
24+
name: orders-by-nation
25+
spec:
26+
openLineage:
27+
enabled: true # <1>
28+
configMapName: marquez-discovery # <2>
29+
# namespace: orders-lineage # <3>
30+
# appName: orders-by-nation # <4>
31+
...
32+
----
33+
<1> Enable OpenLineage event emission. Defaults to `false`, in which case the operator emits nothing OpenLineage-related and behaviour is unchanged.
34+
<2> Name of a discovery ConfigMap holding the backend endpoint (see below).
35+
Must be in the same namespace as the `SparkApplication`.
36+
<3> Optional. The OpenLineage namespace events are reported under.
37+
Defaults to the application's Kubernetes namespace.
38+
<4> Optional. The stable OpenLineage job name (see <<job-name>>).
39+
40+
The discovery ConfigMap holds the backend base URL under the `ADDRESS` key:
41+
42+
.Example discovery ConfigMap
43+
[source,yaml]
44+
----
45+
apiVersion: v1
46+
kind: ConfigMap
47+
metadata:
48+
name: marquez-discovery
49+
data:
50+
ADDRESS: http://marquez:5000 # <1>
51+
----
52+
<1> Base URL of the OpenLineage backend, for example the Marquez API service.
53+
54+
Alternatively, you can set the endpoint directly through `sparkConf` (`spark.openlineage.transport.url`).
55+
If `enabled: true` and no endpoint can be resolved from either the discovery ConfigMap or `sparkConf`, reconciliation fails with a clear error rather than emitting a config that silently drops events.
56+
57+
[#job-name]
58+
== Job name
59+
60+
The OpenLineage job name is resolved in the following order:
61+
62+
. `spec.openLineage.appName`, if set.
63+
. `spark.app.name` from `sparkConf`, if set.
64+
. `metadata.name` as a last resort.
65+
66+
The last case additionally emits a Kubernetes warning event.
67+
Falling back to `metadata.name` is discouraged: if that name carries a timestamp or other run-specific suffix (as generated names often do), every run becomes a separate job in the backend and the run history fragments.
68+
Set `spec.openLineage.appName` (or `spark.app.name`) to a stable value to keep all runs of the same logical job together.
69+
70+
== Overriding injected configuration
71+
72+
Every injected value is a default that goes into the submit configuration *before* your xref:usage-guide/overrides.adoc[`sparkConf`] is merged, so any key you set yourself wins.
73+
`spark.extraListeners` and `spark.jars` are the exception: the operator *appends* to any value you provide (comma-merged) rather than overwriting it, so your own listeners and jars are kept alongside OpenLineage's.

docs/modules/spark-k8s/partials/nav.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
** xref:spark-k8s:usage-guide/app_templates.adoc[]
1010
** xref:spark-k8s:usage-guide/security.adoc[]
1111
** xref:spark-k8s:usage-guide/logging.adoc[]
12+
** xref:spark-k8s:usage-guide/openlineage.adoc[]
1213
** xref:spark-k8s:usage-guide/history-server.adoc[]
1314
** xref:spark-k8s:usage-guide/spark-connect.adoc[]
1415
** xref:spark-k8s:usage-guide/examples.adoc[]

extra/crds.yaml

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1957,6 +1957,42 @@ spec:
19571957
- cluster
19581958
- client
19591959
type: string
1960+
openLineage:
1961+
description: |-
1962+
Emit [OpenLineage](https://openlineage.io/) lineage events for this application.
1963+
The OpenLineage Spark listener runs on the driver and describes the whole application,
1964+
so this is application-scoped config (not per driver/executor role).
1965+
See the [OpenLineage usage guide](https://docs.stackable.tech/home/nightly/spark-k8s/usage-guide/openlineage).
1966+
nullable: true
1967+
properties:
1968+
appName:
1969+
description: |-
1970+
A stable OpenLineage job/application name. Setting this prevents fragmented run history
1971+
(and the intermittent `unknown` job-name bug). If unset, the operator resolves it from
1972+
`spark.app.name`, falling back to `metadata.name` (with a warning event).
1973+
nullable: true
1974+
type: string
1975+
configMapName:
1976+
description: |-
1977+
Name of the OpenLineage backend [discovery ConfigMap](https://docs.stackable.tech/home/nightly/concepts/service_discovery).
1978+
It must contain the key `ADDRESS` with the base URL of the OpenLineage backend
1979+
(e.g. `http://marquez:5000`). Mirrors the `vectorAggregatorConfigMapName` field on this CRD.
1980+
maxLength: 253
1981+
minLength: 1
1982+
nullable: true
1983+
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
1984+
type: string
1985+
enabled:
1986+
default: false
1987+
description: Enable OpenLineage event emission. Defaults to `false` (nothing is injected).
1988+
type: boolean
1989+
namespace:
1990+
description: |-
1991+
The OpenLineage namespace lineage is reported under.
1992+
Defaults to the application's Kubernetes namespace (`metadata.namespace`).
1993+
nullable: true
1994+
type: string
1995+
type: object
19601996
s3connection:
19611997
description: |-
19621998
Configure an S3 connection that the SparkApplication has access to.
@@ -6563,6 +6599,42 @@ spec:
65636599
- cluster
65646600
- client
65656601
type: string
6602+
openLineage:
6603+
description: |-
6604+
Emit [OpenLineage](https://openlineage.io/) lineage events for this application.
6605+
The OpenLineage Spark listener runs on the driver and describes the whole application,
6606+
so this is application-scoped config (not per driver/executor role).
6607+
See the [OpenLineage usage guide](https://docs.stackable.tech/home/nightly/spark-k8s/usage-guide/openlineage).
6608+
nullable: true
6609+
properties:
6610+
appName:
6611+
description: |-
6612+
A stable OpenLineage job/application name. Setting this prevents fragmented run history
6613+
(and the intermittent `unknown` job-name bug). If unset, the operator resolves it from
6614+
`spark.app.name`, falling back to `metadata.name` (with a warning event).
6615+
nullable: true
6616+
type: string
6617+
configMapName:
6618+
description: |-
6619+
Name of the OpenLineage backend [discovery ConfigMap](https://docs.stackable.tech/home/nightly/concepts/service_discovery).
6620+
It must contain the key `ADDRESS` with the base URL of the OpenLineage backend
6621+
(e.g. `http://marquez:5000`). Mirrors the `vectorAggregatorConfigMapName` field on this CRD.
6622+
maxLength: 253
6623+
minLength: 1
6624+
nullable: true
6625+
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
6626+
type: string
6627+
enabled:
6628+
default: false
6629+
description: Enable OpenLineage event emission. Defaults to `false` (nothing is injected).
6630+
type: boolean
6631+
namespace:
6632+
description: |-
6633+
The OpenLineage namespace lineage is reported under.
6634+
Defaults to the application's Kubernetes namespace (`metadata.namespace`).
6635+
nullable: true
6636+
type: string
6637+
type: object
65666638
s3connection:
65676639
description: |-
65686640
Configure an S3 connection that the SparkApplication has access to.

rust/operator-binary/src/config/jvm.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ use stackable_operator::crd::s3;
22

33
use crate::crd::{
44
constants::{
5-
JVM_SECURITY_PROPERTIES_FILE, STACKABLE_TLS_STORE_PASSWORD, STACKABLE_TRUST_STORE,
6-
VOLUME_MOUNT_PATH_LOG_CONFIG,
5+
JVM_SECURITY_PROPERTIES_FILE, OPENLINEAGE_ADD_OPENS, STACKABLE_TLS_STORE_PASSWORD,
6+
STACKABLE_TRUST_STORE, VOLUME_MOUNT_PATH_LOG_CONFIG,
77
},
88
logdir::ResolvedLogDir,
99
tlscerts::tls_secret_names,
@@ -36,6 +36,18 @@ pub fn construct_extra_java_options(
3636
]);
3737
}
3838

39+
// OpenLineage on Spark 4.x needs `java.base/java.security` opened to the unnamed module,
40+
// otherwise the driver throws a non-fatal `InaccessibleObjectException` and silently degrades
41+
// extension-interface lineage (MVP §7). Added to both driver and executor.
42+
if spark_application
43+
.spec
44+
.open_lineage
45+
.as_ref()
46+
.is_some_and(|open_lineage| open_lineage.enabled)
47+
{
48+
jvm_args.push(OPENLINEAGE_ADD_OPENS.to_string());
49+
}
50+
3951
// The role's `jvmArgumentOverrides` are applied on top of the operator-generated arguments
4052
// above. Note this is not purely additive: a role may also remove or replace operator-set
4153
// arguments (e.g. a `removeRegex` dropping the `-Djava.security.properties` default) — see the
@@ -132,4 +144,30 @@ mod tests {
132144
"-Dhttps.proxyHost=from-executor"
133145
);
134146
}
147+
148+
#[test]
149+
fn test_construct_jvm_arguments_openlineage_add_opens() {
150+
let input = r#"
151+
apiVersion: spark.stackable.tech/v1alpha1
152+
kind: SparkApplication
153+
metadata:
154+
name: spark-example
155+
spec:
156+
mode: cluster
157+
mainApplicationFile: test.py
158+
sparkImage:
159+
productVersion: 1.2.3
160+
openLineage:
161+
enabled: true
162+
"#;
163+
164+
let deserializer = serde_yaml::Deserializer::from_str(input);
165+
let spark_app: SparkApplication =
166+
serde_yaml::with::singleton_map_recursive::deserialize(deserializer).unwrap();
167+
let (driver_extra_java_options, executor_extra_java_options) =
168+
construct_extra_java_options(&spark_app, &None, &None);
169+
170+
assert!(driver_extra_java_options.contains(OPENLINEAGE_ADD_OPENS));
171+
assert!(executor_extra_java_options.contains(OPENLINEAGE_ADD_OPENS));
172+
}
135173
}

rust/operator-binary/src/crd/constants.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,30 @@ pub const DEFAULT_LISTENER_CLASS: &str = "cluster-internal";
103103

104104
pub const DEFAULT_SUBMIT_JOB_RETRY_ON_FAILURE_COUNT: u16 = 0;
105105

106+
// --- OpenLineage (see the OpenLineage usage guide) ---
107+
108+
/// The OpenLineage Spark listener, appended to `spark.extraListeners` when OpenLineage is enabled.
109+
pub const OPENLINEAGE_LISTENER_CLASS: &str = "io.openlineage.spark.agent.OpenLineageSparkListener";
110+
111+
/// `local://` URI of the OpenLineage jar baked into the Spark image, referenced via `spark.jars` so
112+
/// it shares the (child) classloader with `--packages` connectors. Delivering it this way — rather
113+
/// than on `extraClassPath` (system classloader) — is what lets OpenLineage's connector probes
114+
/// succeed; see the classpath discussion in the OpenLineage usage guide.
115+
///
116+
/// IMPORTANT: the version MUST stay in sync with the `openlineage-spark-version` build-argument in
117+
/// `docker-images/spark-k8s/boil-config.toml` (the image is what places the jar at this path).
118+
pub const OPENLINEAGE_JAR_LOCAL_URI: &str =
119+
"local:///stackable/spark/openlineage/openlineage-spark_2.13-1.51.0.jar";
120+
121+
/// The key the OpenLineage discovery ConfigMap must hold, carrying the backend base URL. Mirrors the
122+
/// `ADDRESS` contract of the existing `vectorAggregatorConfigMapName` discovery ConfigMap.
123+
pub const OPENLINEAGE_CONFIG_MAP_ADDRESS_KEY: &str = "ADDRESS";
124+
125+
/// Java module-system flag OpenLineage requires on Spark 4.x: without it the driver throws a
126+
/// non-fatal `InaccessibleObjectException` and silently degrades extension-interface lineage.
127+
/// Appended to both driver and executor `extraJavaOptions`.
128+
pub const OPENLINEAGE_ADD_OPENS: &str = "--add-opens java.base/java.security=ALL-UNNAMED";
129+
106130
/// The JVM `security.properties` entries the operator sets by default (DNS cache TTLs).
107131
pub fn default_jvm_security_properties() -> BTreeMap<String, String> {
108132
[

0 commit comments

Comments
 (0)