Skip to content

feat: support Spark Structured Streaming writes#564

Open
LuciferYang wants to merge 2 commits into
lance-format:mainfrom
LuciferYang:feat/structured-streaming-write
Open

feat: support Spark Structured Streaming writes#564
LuciferYang wants to merge 2 commits into
lance-format:mainfrom
LuciferYang:feat/structured-streaming-write

Conversation

@LuciferYang

@LuciferYang LuciferYang commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Closes #246.

Summary

Adds a Structured Streaming sink for Lance. Each non-empty micro-batch issues one Lance transaction stamped with lance.streaming.queryId and lance.streaming.epochId in its transaction properties; replay dedupe scans recent transaction history via DatasetDelta.listTransactions for a matching (queryId, epochId) pair and skips the commit on a hit.

SparkWriteBuilder implements SupportsStreamingUpdateAsAppend and LanceDataset advertises TableCapability.STREAMING_WRITE (on both the plain and blob-v2 capability sets). Append / Complete / Update output modes flow through the existing V2 plumbing. CTAS and staged commits are rejected at toStreaming() with a clear error.

streamingQueryId is a required option — the idempotency key for the dedupe scan; it must be globally unique across concurrent queries on the same table. lance.streaming.dedupe.lookback.versions (default 100, max 10 000) caps the per-commit scan depth.

Output modes & empty epochs

Spark mode Lance behavior
append (default) one Append per epoch
complete one Overwrite per epoch (truncate + write the full result)
update append-only via SupportsStreamingUpdateAsAppend (delta rows appended, not merged)

The streaming overwrite signal is driven solely by Spark's output mode (truncate()), never by a write_mode option — so a stray write_mode=overwrite cannot turn an Append stream into a per-epoch truncate.

Empty epochs: an empty append epoch issues no transaction; an empty complete epoch still commits one empty Overwrite to truncate the table to the new (empty) result (skipping it would leave the prior epoch's rows on disk).

Why one transaction per epoch

PR #399 used a two-transaction Append + UpdateConfig design, which doubled manifest growth and per-epoch latency at scale. Stamping the identity inside the Append moves the dedupe signal into transaction history and keeps writes to one manifest update per epoch.

Why the sink doesn't pin the dataset version

LanceBatchWrite pins dataset.version() in its constructor because it commits exactly once. Streaming reuses one sink across many epochs, so a pinned version would go stale immediately — the dedupe scan would point at the wrong range and the transaction's readVersion would lag. Every commit opens at the current latest instead, and toStreaming() strips any pinned version to enforce this. testMultipleEpochsOnSameSinkAdvanceVersionMonotonically regresses this path.

Robustness

  • Vended credentials. The streaming commit merges namespace-vended initialStorageOptions (from describeTable) into the CommitBuilder write params, mirroring LanceBatchWrite, so writes to credential-vending namespace / object-store tables authenticate.
  • Bounded at-least-once. The dedupe scan covers the last lookback versions; a duplicate can slip through once lookback-or-more unrelated commits intervene before a replay. If a version inside the window is removed by VACUUM/cleanup (making listTransactions throw), the scan degrades to that bounded at-least-once fallback (logs a WARN and proceeds) rather than permanently wedging the query.
  • streamingQueryId is trimmed once and used verbatim as the stamped key (no whitespace asymmetry).

Compatibility

Supported on Spark 3.4 / 3.5 / 4.0 / 4.1. On 3.4, StreamingWrite.useCommitCoordinator is absent from the interface so the override is inert there; Lance commits each epoch atomically via CommitBuilder, making the commit coordinator immaterial on every version. A concrete TestStreamingWrite runs the shared suite on each version.

Tests

  • BaseStreamingWriteTest (runs on 3.4/3.5/4.0/4.1) — 10 cases: append happy-path, missing & whitespace streamingQueryId rejection, empty-epoch no-op, real non-empty replay dedupe, lookback-window expiry, multi-epoch monotonic version, overwrite replace + empty truncate, and write_mode=overwrite-does-not-truncate-append.
  • LanceSparkWriteOptionsTest — lookback default / round-trip / bounds and non-numeric parse-error cases.
  • SparkWriteTesttoStreaming returns LanceStreamingWrite when configured, throws IllegalArgumentException without streamingQueryId, rejects staged commits.

Local verification: make lint clean; full base + Spark 3.5 suite green (464 + 1064, 0 failures); streaming tests 10/10 on Spark 3.4 and 3.5.

Out of scope

  • Streaming reads (MicroBatchStream) — follow-up.
  • Row-level UPDATE / DELETE on the streaming path.
  • Blob v2 source-context copy-through (a batch-only optimization) does not fire on the streaming path; direct blob-data streaming works.
  • In-memory dedupe cache to skip the per-commit DatasetDelta scan.
  • High-frequency (< 30s trigger) and Spark 4.1 Real-Time Mode writes — MemWAL-backed sink follow-up (design + Java API gap analysis pending). Docs section "Trigger cadence, fragment count & compaction" documents the fragment-count cost and Spark's own rejection behavior for these modes.

Docs at docs/src/streaming.md (wired into the site nav).

@github-actions github-actions Bot added the enhancement New feature or request label May 27, 2026
@LuciferYang
LuciferYang force-pushed the feat/structured-streaming-write branch from 9c57066 to 29a0fcd Compare June 2, 2026 06:09
@LuciferYang
LuciferYang marked this pull request as ready for review June 2, 2026 06:10
@LuciferYang
LuciferYang force-pushed the feat/structured-streaming-write branch 2 times, most recently from c5b059f to 54c47a2 Compare June 2, 2026 06:27
Comment thread pom.xml Outdated
@LuciferYang
LuciferYang marked this pull request as draft June 2, 2026 08:53
@LuciferYang
LuciferYang force-pushed the feat/structured-streaming-write branch 3 times, most recently from 5629631 to 61a2775 Compare June 25, 2026 15:32
@LuciferYang
LuciferYang marked this pull request as ready for review June 25, 2026 15:55
Closes lance-format#246.

Adds a Spark Structured Streaming sink for Lance. Each non-empty
micro-batch produces a single Lance transaction stamped with
(streamingQueryId, epochId) in its transaction properties; replay
dedupe scans recent transaction history via
DatasetDelta.listTransactions for an existing pair and skips the
commit if it finds one.

Append, Complete, and Update output modes are routed through
SparkWriteBuilder (also implementing SupportsStreamingUpdateAsAppend).
Complete maps to a Lance Overwrite per epoch; Update is append-only
per Spark's marker contract. The streaming overwrite signal is driven
solely by Spark's output mode (truncate()), never by a write_mode
option, so a stray write_mode=overwrite cannot turn an Append stream
into a per-epoch truncate. CTAS / staged-commit flows are rejected
with an actionable error since per-epoch commits are incompatible with
the single-shot staged commit cadence.

Empty epochs: an empty Append epoch issues no transaction; an empty
Complete (overwrite) epoch still commits one empty Overwrite to
truncate the table to the new (empty) result.

User surface:

- `streamingQueryId` (required) — globally unique idempotency key,
  trimmed and used verbatim as the stamped key. Two queries sharing
  the same id would dedupe each other's epochs.
- `lance.streaming.dedupe.lookback.versions` (default 100, max 10000)
  — how many recent versions the dedupe scan reads on every commit.
  Raise on high-churn tables; lower to cut per-commit scan cost.

Transaction-property keys `lance.streaming.queryId` and
`lance.streaming.epochId` are stamped on every commit and are part of
the stability contract — external tooling can read them straight from
Lance transaction history.

Robustness:

- The streaming commit merges namespace-vended credentials
  (initialStorageOptions) into the CommitBuilder write params, matching
  the batch path, so writes to credential-vending namespace /
  object-store tables authenticate correctly.
- The sink never pins the dataset version: toStreaming() strips any
  pinned `version`, so every epoch opens at the current latest and the
  dedupe scan window and the transaction's readVersion both reflect
  on-disk reality.
- If a version inside the lookback window is removed by VACUUM/cleanup,
  the dedupe scan degrades to the documented bounded at-least-once
  fallback (logs a WARN and proceeds) rather than wedging the query.

Compatibility / non-goals:

- Supported on Spark 3.4+ (3.4 / 3.5 / 4.0 / 4.1). On 3.4,
  StreamingWrite.useCommitCoordinator is absent from the interface, so
  it is inert there; Lance commits each epoch atomically via
  CommitBuilder, making the coordinator immaterial on every version.
- Uses the DatasetDelta JNI binding (lance-format/lance#6963); the pom
  is on lance.version 8.0.0-beta.9.
- Streaming reads (MicroBatchStream) are not implemented yet — tracked
  separately.
- Row-level UPDATE/DELETE via position-delta is not exposed on the
  streaming path.
- Blob v2 tables can stream (STREAMING_WRITE capability), but the
  batch-only blob source-context copy-through optimization does not
  fire on the streaming path.
- The target Lance table must exist before the query starts; the sink
  does not auto-create.

Test coverage:

- BaseStreamingWriteTest (runs on Spark 3.4/3.5/4.0/4.1): 10 cases —
  append happy path, missing/whitespace streamingQueryId rejection,
  empty-epoch no-op, real non-empty replay dedupe, lookback-window
  expiry, multi-epoch monotonic version, overwrite replace + empty
  truncate, and write_mode=overwrite-does-not-truncate-append.
- LanceSparkWriteOptionsTest: lookback default / round-trip / bounds
  and non-numeric parse-error cases.
- SparkWriteTest: toStreaming returns LanceStreamingWrite when
  streamingQueryId is provided, throws IAE without it, rejects staged
  commits.
- integration-tests/test_lance_spark.py (docker, parameterized over
  local / MinIO / Azurite / Glue): writeStream append round-trip and
  dedupe-on-replay through the bundled jar against a real object store —
  the credential-bearing path the JUnit dir-catalog suite cannot reach.

User-facing doc at docs/src/streaming.md (wired into the nav) covers
semantics, output modes, the exactly-once contract, the bounded
at-least-once fallback (lookback window + VACUUM), and OPTIMIZE cadence.
@LuciferYang
LuciferYang force-pushed the feat/structured-streaming-write branch from 61a2775 to cad1d35 Compare June 26, 2026 02:56
@LuciferYang

Copy link
Copy Markdown
Collaborator Author

cc @hamersaw @jackye1995 @fangbo

@fangbo

fangbo commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

@LuciferYang I'm not familiar with Spark Structured Streaming. I'm very sorry that I cannot review this PR.

@hamersaw

hamersaw commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

@LuciferYang I'm curious about your use-case here? By appending a new fragment per epoch the "many small fragments" problem is going to cause issues. Even at a 10s epoch interval this means 8640 fragments per day. Is this not a concern for your use case? I'm wondering if it would make sense to build on top of the WAL implementation in OSS for this instead?

@LuciferYang

LuciferYang commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Good to pin down the scope of this PR and how alternatives fit.

1. Target workload: coarse-grained micro-batch, not sub-second.
This sink targets Trigger.ProcessingTime in the tens-of-seconds-to-minutes range, Trigger.AvailableNow backfills, and Kafka / CDC pipelines that already batch upstream. Using a 1-minute trigger as a reference (Spark schedules the next batch at max(triggerInterval, batchCompletionTime), so busy pipelines produce fewer than the nominal count), that's ~1440 fragments/day — the same order of magnitude as a daily batch job, well within normal OPTIMIZE cadence. Under-30-second triggers do hit real manifest growth; I'll document that as not recommended, but I don't plan to enforce it with a hard error in this PR — legitimate edge cases exist (short experiments, mixing with AvailableNow), and a code-level threshold is easy to get wrong.

Relative to hand-rolled foreachBatch, this sink adds epoch idempotency ((queryId, epochId) dedupe scan), automatic Append/Complete/Update routing per Spark output mode, and credential-vending namespace pass-through. A user willing to buffer records across epochs and hand-roll idempotency in foreachBatch could avoid the per-epoch fragment growth, but the logic is error-prone.

2. Spark's engine already gates most sub-second paths.

  • Trigger.Continuous requires the source to declare TableCapability.CONTINUOUS_READ; the Lance source doesn't, so Lance→Lance continuous queries fail at planning. A Kafka→Lance continuous query passes source-side validation and would reach Write.toStreaming() — but that combination produces catastrophic fragment counts for this sink, and I'll flag it as discouraged in the docs.
  • Spark 4.1's Real-Time Mode uses RealTimeModeAllowlist — only Kafka / ForeachWriter / Console / Memory sinks are allowlisted; other DSv2 sinks (including Lance) fail at query start. I don't think we should work around that.
  • Hand-rolled foreachBatch isn't an escape hatch — it invokes a batch write per epoch and produces the same manifest growth.

3. MemWAL is the natural foundation for a follow-up sink, not a replacement for this PR.
The OSS WAL implementation you're pointing at is, I believe, MemWAL (MemTable + WAL LSM) in lance-format/lance — its LSM staging (WAL append + MemTable buffering + background flush) is the right substrate for high-frequency writes. MemWAL has both Rust core and a Java module (lance/java/src/main/java/org/lance/memwal/, 17 classes covering ShardWriter.put, LsmScanner, initializeMemWal, sharding, stats), which gives us a foundation to build on. But whether Java exposes everything a Spark exactly-once sink needs — a way to stamp / query epoch identity on a WAL entry, WAL replay from an offset, explicit memtable seal / rotate, crash-consistent shard-state queries — I haven't verified end to end yet. That gap analysis is exactly what the follow-up issue is for, alongside the Spark-side design questions: where does the epoch idempotency key live (WAL entry, shard manifest, or dataset config)? What's the shard-to-Spark-task mapping? What's the consistency protocol between WAL replay and memtable-flush checkpoints on recovery? Non-trivial protocol choices — worth their own PR and review cycle.

4. Why not make MemWAL the only streaming path?
Because Lance tables don't have a MemWAL index by default. Existing tables and users who don't need high-frequency writes shouldn't be forced to provision MemWAL just to use the streaming API. This sink works with any Lance table; a MemWAL sink would target tables that explicitly opt into high-frequency semantics. Complementary, not overlapping.

Concretely for this PR I'll:

  1. Add a "Trigger cadence, fragment count & compaction" section to docs/src/streaming.md with the trigger→fragments/day table, Spark's rejection behavior for Trigger.Continuous (source-side) and Trigger.RealTime (sink allowlist), a call-out that Kafka→continuous→Lance-sink is discouraged, and an explicit "< 30s trigger not recommended — use the MemWAL sink when it lands" note.
  2. Add "High-frequency (<30s) and Real-Time Mode writes — MemWAL-backed sink follow-up" to the PR description's "Out of scope" list.
  3. Open a follow-up issue tracking the MemWAL-backed sink. Scope of that issue: (a) audit which primitives the current Java API covers vs. what a Spark exactly-once sink needs, (b) the exactly-once design questions above, (c) whether missing pieces belong to lance-java, lance Rust core, or lance-spark.

The Spark Structured Streaming users I've worked with are all in the coarse-grained micro-batch regime, which is what this sink targets. If you'd prefer code-level enforcement beyond docs + follow-up (e.g., a WARN at a trigger-interval threshold), happy to add it — but I'd lean on Spark's own allowlist for the hard boundary and use docs + PR scope in lance-spark.

Does this address the scope concern for this PR? @hamersaw

Adds a "Trigger cadence, fragment count & compaction" section that:

- lays out the fragments-per-day cost for common Spark trigger types
- documents that Spark rejects `Trigger.Continuous` (source-side
  `CONTINUOUS_READ` gate) and `Trigger.RealTime` (sink allowlist) for
  this sink, and calls out Kafka→Lance continuous as not recommended
- calls `foreachBatch` out as not an escape hatch (same manifest
  growth per epoch) and points high-frequency workloads at the
  MemWAL-backed sink follow-up

Also lists sub-30s triggers and Real-Time Mode as explicit
limitations, and rewrites the OPTIMIZE cadence guidance to fit the
trigger table.

Addresses PR lance-format#564 scope discussion with @hamersaw.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Spark structured streaming

3 participants