Repository navigation
Checkpoint V2 — Design Proposal #6446
Replies: 15 comments 12 replies
|
Coming from the conversation on #6520 — the V2 design is a cleaner answer to the problem I was working around with Would it be okay to take a stab at an S3-backed |
|
The Given the design's core assumption — one task per input partition, task completion implies a successful sink write — the checkpoint is fundamentally tracking completed partitions, not processed rows. If that holds, row-level keys feel like the wrong abstraction: since a task is the atomic unit, there's no meaningful partial-partition state to record. The natural key would then be partition identity — something each connector derives from the partition itself, rather than something the user specifies from their data schema:
One practical implication: rather than a user-facing Row-level checkpointing is genuinely valuable, but it likely requires deeper infrastructure work that seems better suited to a follow-on design rather than the current scope. Happy to be corrected if there are use cases driving the |
|
The "What This Doesn't Handle" section covers source-side limitations, but there's a sink-side constraint worth adding explicitly. Checkpoint rerun creates a natural expectation of idempotency. This holds for most sinks — Iceberg, Delta, Paimon, and native Lance all write-then-atomic-commit, so failed tasks leave no visible trace. Two current sinks may not satisfy this, though:
The concern is silent data corruption: checkpoint would mark the task incomplete and retry it, but the sink has already partially written. No error is surfaced to the user. Explicitly flagging these sinks as not yet compatible with checkpoint — until they adopt transaction or upsert semantics — might help users feel more confident in the correctness guarantee. |
|
Thanks for your thoughtful comments, chenghuichen. Much appreciated. |
|
One concern about the crash recovery table: the scenario "After sink commit, before mark_committed()" carries a hidden assumption that catalog commits are idempotent. In practice, they're not. If a crash occurs between a successful catalog commit and For Iceberg and Paimon, re-committing the same Safe recovery in this window would require either checking catalog state before re-committing, or using format-native idempotency mechanisms (e.g., Iceberg's |
|
One nice side-effect of this design: since the checkpoint store tracks both source keys and output file metadata, we effectively have a 2PC layer that works for any sink — including plain Parquet. The checkpoint store knows which files were produced by each task. After all tasks complete, the head node can treat the checkpointed file list as the authoritative output, and clean up any orphaned files from failed/partial tasks. Parquet writes become atomic without needing a catalog. |
|
@chenghuichen @everySympathy Pushdown guards on
|
Checkpointing v2 — Feature MatrixLast updated: 2026-04-20. Tracks where each dimension of checkpoint support stands as of PR #6725 (in review). Legend
Source connectors (where
|
| Connector | Status | Notes |
|---|---|---|
| Parquet | ✅ | Primary focus; MinIO integration tests cover full cycle |
| CSV / JSON | 🔬 | Should work via the same ScanOperator path — untested |
| Iceberg (read) | ✅ | Read side works; write side needs idempotent-commit follow-up |
| Delta Lake (read) | ✅ | Same |
| Hudi | 🔬 | Daft's own Hudi support is limited |
| Paimon | 🔬 | Untested end-to-end |
| Lance | 🔬 | Untested |
| SQL / JDBC | ❌ | Row-level on= doesn't map cleanly; partition-range checkpointing is a future design |
| Kafka / streaming | ❌ | Out of scope — offset-based semantics are orthogonal |
Sink compatibility
Two guarantees are separable: (G1) don't redo expensive work, and (G2) don't write duplicate output rows. G1 holds for any sink where Daft defers externally-visible commits to task-end. G2 requires either framework-level 2PC, sink-native atomicity, or user-level idempotency (upserts, keyed writes).
Category 1 — Framework-atomic (Daft controls the commit boundary)
G1 holds; G2 is a framework/sink property.
| Sink | Status | Notes |
|---|---|---|
| Iceberg | ⏳ | G2 via daft.checkpoint-store + daft.epoch-id snapshot tags (follow-up) |
| Delta Lake | ⏳ | G2 via {appId: store_path, version: epoch_id} txn action (follow-up) |
| Paimon | 🔬 | Snapshot tags like Iceberg; untested end-to-end |
| Lance (native 2PC) | 🔬 | Atomic-per-task; untested but structurally compatible |
| Parquet / CSV | 🟨 | G2 via checkpoint store as 2PC layer + orphan cleanup (follow-up) |
| Hudi | 🔬 | Untested |
Category 2 — Side-effect endpoints (data leaves Daft mid-pipeline)
G1 depends on whether the sink defers commits to task-end; G2 is the user's or the external system's responsibility.
| Endpoint | G1: don't redo work | G2: exactly-once output | Notes |
|---|---|---|---|
write_sql (append mode, no PK) |
❌ | ❌ | Per-micropartition commits, no rollback — mid-task crash leaves partial rows, rerun duplicates them |
write_sql (upsert target / ON CONFLICT UPDATE / MERGE) |
🟨 | ✅ | Rows may be re-written on crash, but upsert semantics collapse to correct output |
| Lance REST | ❌ | ❌ | Same pattern as write_sql append |
| UDF external effects (LLM / Kafka / HTTP) | ✅ (whole-task granularity) | user's responsibility | CheckpointTerminusNode drives store.checkpoint() at task completion; framework can't unwind external effects, so mid-task crash re-sends rows that already left the boundary |
Object store backends
| Backend | Status | Notes |
|---|---|---|
| S3 / S3-compatible | ✅ | MinIO integration tests |
Local FS (file://) |
✅ | Used for local dev + testing |
| GCS | 🔬 | Should work via daft-io, unvalidated |
| Azure Blob | ⏳ | validation pending |
Pipeline operators allowed between source and sink
| Operator | Allowed? | Reason |
|---|---|---|
| Filter | ✅ | Core use case; filtered-out keys still get checkpointed |
Project / with_column |
✅ | |
| UDF (map) | ✅ | The canonical expensive-work case |
| Explode | ✅ | Per-task granularity (documented trade-off) |
| Unpivot (melt) | ✅ | Map-only (wide→long row transformation, no shuffle) |
| IntoBatches | ✅ | |
| Limit | ❌ | Reads from anti-join output — "first N rows" drifts across runs |
| Offset | ❌ | Checkpoints rows the user intended to drop |
| Sample | ❌ | Non-deterministic, shrinks coverage each run |
| Sort / TopN | ❌ | Shuffle |
| GroupBy / Aggregate | ❌ | Shuffle |
| Distinct | ❌ | Shuffle |
| Join / AsofJoin | ❌ | Shuffle |
| Window | ❌ | Shuffle |
| Pivot | ❌ | Shuffle — has group_by + aggregation |
| Repartition | ❌ | Shuffle |
| Concat / Union / Intersect | ❌ | multi-source design deferred |
Key types
| Type | Status | Notes |
|---|---|---|
| String (utf8) | ✅ | Primary |
| Integer | 🔬 | Key type follow-up |
| Binary | 🔬 | Key type follow-up |
| Composite (multi-column) | ❌ | design work needed |
Capabilities
| Capability | Status | Notes |
|---|---|---|
Row-level checkpointing (on=) |
✅ | |
| Task-level (partition) checkpointing | ✅ | Natural boundary — task completion = checkpoint |
| Sink-less pipelines | ✅ | CheckpointTerminusNode |
| Crash recovery — mid-task | ✅ | Staged work invisible until checkpoint() |
| Crash recovery — after checkpoint, before head commit | ✅ | Store is source of truth; get_checkpointed_files returns what needs committing |
Crash recovery — after commit, before mark_committed() |
⏳ | Safe only with idempotent commits (follow-up) |
Multi-source (concat/union) pipelines |
❌ | deferred |
File-level filter (connector-aware — intercept to_scan_tasks()) |
❌ | Future optimization — complementary to row-level |
| Partition-level task-skip (entire ScanTask skipped if all files checkpointed) | ❌ | Future optimization |
| Orphan file cleanup (staged-but-never-checkpointed artifacts) | ❌ | Future |
| KFJ anti-join parameters configurable | ⏳ | currently hardcoded |
| Canonical checkpointed-keys column name (vs source-matching) | ⏳ | follow-up |
| In-flight write tracking (fixes empty-source edge) | ⏳ | follow-up |
Deployment
| Environment | Status | Notes |
|---|---|---|
| Flotilla (Ray multi-worker) | ✅ | Primary target |
| Native runner | ❌ | Gated with ValueError + remediation guidance |
Validation state
| Area | Status | Notes |
|---|---|---|
| Rust unit tests | ✅ | 14 in daft-checkpoint, 252 in daft-logical-plan |
| MinIO (S3-compatible) integration tests | ✅ | tests/integration/checkpoint/test_checkpoint_s3.py |
| Azure validation | ⏳ | pending |
| Large-dataset functional tests | ⏳ | Week of 2026-04-20 goal |
| Performance benchmarks | ❌ | Not started |
| Iceberg / Delta end-to-end with real catalog | ❌ | Coverage lands with the idempotent-commit follow-up |
|
@chenghuichen, copying this from #6725.
|
|
@rohitkulshreshtha Thanks for the openness to a follow-up — and for the two concrete questions (where does filtering occur, how do we register coverage). I've spent some time working through the details, and this is what I landed on. File-Path Identity Checkpoint — Design ProposalPositioningFile-path identity is a fast-path that activates when: (1) the source is file-based, (2) the user does not specify df.checkpoint(store) # file-path mode
df.checkpoint(store, on="req_id") # row-level modepub enum CheckpointKeyMode {
RowLevel { key_column: String },
FilePath,
}
pub struct CheckpointConfig {
pub store: CheckpointStoreConfig,
pub key_mode: CheckpointKeyMode,
}Source Atom KeysThe checkpoint key granularity is the source atom — the smallest indivisible unit of source data. For unsplit files, the atom is the file itself. For split files, each chunk defined by fn source_atom_keys(source: &ScanSource) -> Vec<String> {
let path = source.get_path();
match source.chunk_spec() {
None => vec![path.clone()],
Some(spec) => spec.atom_keys(path),
}
}
impl ChunkSpec {
fn atom_keys(&self, path: &str) -> Vec<String> {
match self {
Self::Parquet(rgs) =>
rgs.iter().map(|rg| format!("{path}#rg={rg}")).collect(),
Self::Bytes { start, end } =>
vec![format!("{path}#bytes={start}-{end}")],
}
}
}Why per-atom, not per-file or per-chunk:
Optimizer Rule: Dual-Mode Dispatch
fn rewrite(&self, source: &Source, cfg: &CheckpointConfig) -> ... {
validate_map_only_downstream(&plan, &node)?;
match &cfg.key_mode {
CheckpointKeyMode::RowLevel { key_column } =>
self.rewrite_row_level(source, key_column, cfg),
CheckpointKeyMode::FilePath =>
self.rewrite_file_path(source, cfg),
}
}
fn rewrite_file_path(&self, source: &Source, cfg: &CheckpointConfig) -> ... {
// 1. Validate source is file-based
// 2. Strip checkpoint from Source (prevent re-match)
// 3. Wrap in StageCheckpointKeys(key_mode=FilePath)
// No anti-join. No SCKO. No BlobStoreKeysScan. No pushdown guards.
}Plan tree comparison: Pipeline Builder: Mode Branch
The checkpointed-key HashSet is loaded lazily downstream — in Filtering and StagingIn distributed mode, filtering operates at two layers: Layer 1 — Driver-side coarse pruning ( Layer 2 — Worker-side fine-grained filtering ( Layer 1 is a pure performance optimization; removing it does not affect correctness. Layer 2 is the correctness mechanism. End-to-End FlowShared: Independent only in: key source (row data vs. scan task metadata), filtering mechanism (anti-join vs. HashSet lookup), pushdown behavior (guarded vs. unrestricted). Benefits
Open Items
Happy to iterate on any of this. If the direction looks right I can start on a PR against your branch. |
|
Hey @chenghuichen — sorry for the long delay on this. After reading through the file-path identity proposal carefully, I'd like to surface three concerns to the wider team before we move forward:
I want to discuss these with the team first before responding on PR #6869. Will report back after our sync. Thanks for the very thorough writeup. |
|
Hey @chenghuichen — quick update on where my thinking is. I haven't looked at the PR code yet; still chewing on the design and wanted to share where I've landed so you can push back. Most of this is loud thinking, not conclusions. What I'm convinced on:
What I'm still chewing on:
Will get to the PR code separately — just wanted to surface the live design questions first. |
|
@rohitkulshreshtha Thanks for sharing where your thinking is — really helpful to see what's settled vs. still open. On byte-range-split formats: Fully agreed. Clean separation. On source immutability: I've been thinking about this too — in fact some of my earlier commits on the PR experimented with addressing it. My current thinking is that Daft can actually detect file changes without baking in an immutability The approach: encode file_size + etag (object stores) or file_size + mtime (local FS) into the atom key (e.g. path#rg=N#sz=12345#etag=abc123). Both come from the listing/stat call itself — S3's list_objects_v2 already returns e_tag() per The property is the same: if a file is replaced between runs, its atom key naturally differs from what's in the checkpoint store → no match → reprocessed. Users don't need to reason about immutability; the system handles it. In the rare case On sub-row-group splitting: I hadn't thought about this before your message, so thanks for raising it. My thinking here is still rough given how abstract this scenario is, but my intuition is that the checkpoint granularity can stay at the row-group level even if execution goes finer. If Daft ever splits a single row group across workers, the checkpoint contract would be: all sub-splits of that row group must complete before the row-group-level key is sealed, same pattern your design already uses per task. If any sub-split fails, the entire row group reruns. This adds coordination complexity, but as far as I can tell, the design would not structurally prevent sub-row-group splitting from being layered on later. Does that framing sit right with you? |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Checkpoint V2 — Design Proposal (Revised)
*Revised 2026-04-20 against the implementation in PR #6725.
Changelog since the original proposal
Substantive changes to the design, in priority of user-visible impact:
PushDownFilter/PushDownProjection. Filters and projections cannot push past a checkpointed source — trades scan-time cost for the "filtered-out keys still get checkpointed" contract.CheckpointTerminusNode. Plans ending in.collect()or pure-side-effect UDFs (LLMs, Kafka, HTTP) now drivestore.checkpoint()at task end.write_sql/Lance-REST/UDFs-with-external-effects). User responsibility for idempotency in the second bucket.concat/union_allrejected at optimizer time. Closes the "multiple sources" open question as deferred — awaiting concrete use cases.CheckpointIdviaCheckpointIdMap. Correction to "one UUID per task" — under Flotilla's shared compiled pipeline, a per-input map is required.Limit/Offset/Sampleadded to the reject list. Expands "map-only" from "no shuffles" to "no shuffles and no row-selectors." Each has a worked example of how it breaks semantics.Section-level additions: Pipeline Operators, Sink Compatibility, Commit Idempotency, Sink-less Pipelines. Section-level updates: Assumptions, Glossary, CheckpointStore (format), Crash Recovery (idempotency row), What This Handles, What This Doesn't Handle, Open Questions.
The Problem
Pipelines with expensive operations — UDFs, FFMPEG, embeddings, API calls — shouldn't redo completed work on failure or re-run. We need a way to track what's been processed and skip it next time.
Additionally, 2PC sinks (Iceberg, Delta Lake, Lance) have a gap between writing data files and committing them to the catalog. A crash in that gap means orphaned files and lost work. We need a way to recover from this without re-running the entire pipeline.
Summary
Track progress at the source, not the sink. The user associates a checkpoint store with a source and specifies a key column (e.g.,
file_id). On each run, rows with keys already in the checkpoint store are skipped. The pipeline between source and sink is a black box — we don't care what happens inside. When a task finishes (including writing to the sink), its source keys are checkpointed.Track what was produced alongside what was processed. The checkpoint store records both the source keys (what went in) and the output file metadata (what came out). This means:
The checkpoint store is the source of truth. It tracks both keys and files, so the system can always recover — either by skipping already-processed keys or by committing files that were written but never made it to the catalog. No data loss. No silent corruption — for sinks where Daft controls the commit boundary (see Sink Compatibility).
Explodes are handled, with a known trade-off. Progress is tracked at the source level (e.g., per input file), not per exploded row. If a crash happens mid-explode, the entire input file is re-processed on the next run — but fully completed files are skipped. A second-level checkpoint filter after the explode (tracking at per-row granularity) could be added in a similar fashion if customers need it.
Assumptions
Limit,Offset,Sample) that use a checkpoint store fail at plan construction time. See Pipeline Operators.checkpoint(), the head node must be able to see the checkpointed keys and files.Glossary
StageCheckpointKeysOperatorRewriteCheckpointSourceabove the anti-join and below user ops so filtered-out rows still stageRewriteCheckpointSourceSource{checkpoint=Some(_)}into the anti-join +StageCheckpointKeysOperatorsubtreeCheckpointTerminusNodestore.checkpoint()at task completionCheckpointIdMapInputId→CheckpointIdmap, shared betweenStageCheckpointKeysOperatorand the blocking sink viaBuilderContext. Replaces the "one UUID per task" single-cell designBlobStoreCheckpointedKeysScanOperatorThe Proposal: Source-Oriented Checkpointing
A checkpoint store is associated with a source. It tracks which source inputs have been fully processed. On re-run, the source skips them.
The
onparameter specifies the checkpoint key — the column that uniquely identifies a source input. This must be a column present in the source data (e.g., a document ID or file hash).On re-run, the
RewriteCheckpointSourceoptimizer rule rewrites the source node into:StageCheckpointKeysOperatorsits below user ops and above the anti-join. Every row that emerges from the anti-join (i.e., every not-yet-checkpointed source row) has its key staged before user operators run. This is what makes "filtered-out keys still get checkpointed" a hard guarantee — a user filter downstream cannot drop keys that haven't already been staged.Pushdown guards
Daft's optimizer normally pushes filters and projections into the Source node. For checkpointed sources, that would let rows drop at the Parquet scan before
StageCheckpointKeysOperatorsees them, breaking the guarantee above. TheRewriteCheckpointSourcepass runs early; pushdown rules now check forcheckpoint.is_some():PushDownFilterunconditionally skips checkpointed sources.PushDownProjectionis narrower — it only skips when pushing the projection would prune theon=key column. Non-key columns still get pruned normally.Trade-off (user-facing). Opting into checkpointing means the source materializes every row and retains the key column up to
StageCheckpointKeysOperator, even if a downstream filter will drop most of them. Checkpointing ⇒ reduced pushdown, paid at scan time. This is the expected cost of the "filtered keys still get checkpointed" contract.Why this trade is OK (current assumption). Checkpointing is aimed at workloads where the per-row processing cost (the expensive UDF, the API call, the embedding generation) dominates the per-row reading cost. Wasting a scan on rows that a downstream filter will drop is cheap relative to re-running the expensive operation on those rows on every rerun. If/when we encounter workloads where this assumption breaks — cheap processing, costly scans — the trade-off needs revisiting.
Per-input
CheckpointIdUnder Flotilla, the pipeline translator compiles the operator set once per query, and every task that runs the pipeline reuses those same operator instances. A single pre-allocated
CheckpointIdwould cause the second task to fail because the checkpoint was already finalized by the first.CheckpointIdMapis a lazy per-InputIdmap held onBuilderContext;StageCheckpointKeysOperatorand the blocking sink both read it via the shared context so each task lands under its ownCheckpointId.Why Source-Oriented?
The alternative is sink-oriented: query the sink to find what's already been written, anti-join against it. This breaks in several important cases.
The filter problem. An expensive filter (e.g., a UDF that classifies rows and drops the ones that don't qualify) runs on every input row. Rows that get filtered out never reach the sink. On resume, the sink has no record of them — so they get re-processed every single run. The expensive classification runs again, reaches the same conclusion (drop), and the work is wasted. With source-oriented tracking, the task completed for that input, so it's marked done regardless of whether any rows survived to the sink.
The explode problem. A PDF gets exploded into 1000 pages. 200 are written to the sink before a crash. On resume, the sink sees rows with
file_id=Ain the output and concludes "file A has been processed" — but only 200 of 1000 pages were written. The remaining 800 are silently lost. You can't determine from the sink alone whether an input file was fully processed, only that some output exists for it. Source-oriented tracking doesn't have this problem — the file is only marked done when the task completes, meaning all pages made it through.Non-queryable sinks. Some sinks have no public API for reading uncommitted data. Plain file formats (Parquet, CSV) have no key semantics — you'd have to scan everything. Source-oriented tracking is independent of the sink — it works with any sink.
Eventually consistent sinks. Many sinks — particularly vector databases — are eventually consistent by design. A write may return success before the data is indexed or queryable. Sink-oriented tracking can't safely read its own writes in these cases. Source-oriented tracking avoids this entirely.
Pipeline Operators
The checkpoint rule validates the subtree between source and sink at optimizer time. Two categories of operators are rejected:
Rejected: shuffles (original design)
Sort,TopN,Repartition,Shuffle,Distinct,Aggregate,Pivot,Window,Join,AsofJoin,Concat,Union,Intersect. These reorder or redistribute rows in ways that break the one-task-per-input-partition fusion the design relies on.Rejected: row-selectors (added during implementation)
Limit,Offset,Sample. These aren't shuffles but still break checkpoint semantics in non-obvious ways:Limitpicks rows from the anti-join output, not the original source. On a source that grows between runs,source.limit(2)returns[a, b]on run 1 and[e, f]on run 2 — the meaning of "first 2 rows" silently drifts.Offsethas the dual problem.source.offset(2)intends to drop the first 2 rows. After run 1 checkpoints rows 3+, run 2's anti-join returns just rows 1-2; offset drops them. The rows the user said "never process" are now checkpointed as if processed — the user's intent is violated.Sampleis non-deterministic across runs. Checkpointed keys shrink the sample universe each run, sosample(0.3)draws from a dwindling set — silently regressing coverage until it returns empty.Allowed
Filter,Project/with_column, UDF (map),Explode,Unpivot(melt),IntoBatches. These are map-only at the physical level: row-in → 0..N rows-out, no cross-partition dependencies.CheckpointStore
The CheckpointStore is the core abstraction — a durable store that tracks both what was processed (source keys) and what was produced (output files), enabling both progress tracking and crash recovery from a single interface.
erDiagram CheckpointStore ||--o{ Checkpoint : contains Checkpoint ||--o{ SourceKey : "staged keys" Checkpoint ||--o{ FileMetadata : "staged files" Checkpoint { CheckpointId id QueryId query_id Status status "checkpointed | committed" } SourceKey { string value } FileMetadata { string file_path }Why track both keys and files?
A simple progress store could just track source keys — mark them done at task completion. This works for non-2PC sinks (plain Parquet, CSV) where the sink write is final.
But for 2PC sinks (Iceberg, Delta Lake, Lance), "task completes" ≠ "data committed." A task writes data files to storage, but the catalog commit happens later on the head node. If we only track keys and the head node crashes before commit, the keys say "done" but the files are orphaned — data loss.
By tracking both keys and files together:
On recovery: read the checkpoint store → skip the keys → commit the files → done.
This merges the progress store and the transaction manager into one abstraction. One store, both concerns resolved. A useful side-effect: the same mechanism gives plain Parquet/CSV a 2PC-like capability (orphan cleanup by reconciling on-disk files against the checkpoint store's authoritative file list — follow-up work).
Checkpointed-key file format: Parquet
Checkpointed source keys are persisted as Parquet files under
<store>/keys/*.parquet. The right side of the anti-join is literally aread_parquetscan over this path — reusing Daft's first-class Parquet scanner, same code path, same perf, same operational surface. An Arrow IPC variant would have required maintaining a parallel reader.Interface
Lifecycle
checkpointed → committed
StageCheckpointKeysOperator, before user ops). Files are staged late (after writer finalize). These are separate calls because the key column may not even survive the pipeline — projections, filters, or transforms may strip it before the sink.checkpoint()called at task completion. Keys and files are coupled and visible. The head node reads from this state.mark_committed()called after catalog commit. Indicates that downstream data is durable in the external catalog (Iceberg snapshot, Delta log, etc.).One
CheckpointIdper task, resolved per-InputIdat runtime viaCheckpointIdMap.Sink Compatibility
Sinks fall into two buckets. Category 1 sinks get exactly-once output from the framework. Category 2 sinks require the user or the external system to handle idempotency.
Category 1 — Framework-atomic
Daft controls the commit boundary. The framework can offer exactly-once output on rerun-after-crash.
Category 2 — Externally-visible side effects
The write leaves Daft's control mid-pipeline. Daft cannot unwind the effect, so duplicate writes on rerun will produce duplicate external state unless the user or the downstream system handles it.
write_sql(append mode, no PK) — each micropartition commits independently; no task-level rollback. Mid-task crash → partial rows committed → rerun → duplicates.write_sql(upsert target, keyed writes) — safe. The user's write pattern handles idempotency.write_sql(per-micropartition commits, no staging).CheckpointTerminusNodeexists specifically to drivestore.checkpoint()for these pipelines, but it cannot undo the external effect — it just bounds rerun to whole-task granularity.What checkpointing offers in Category 2
Even when exactly-once output isn't achievable, checkpointing still provides "don't redo expensive work at task granularity." Checkpointed keys from past completed tasks are always skipped on rerun. A crash within a task does cause that task's work to be redone, but completed-and-checkpointed tasks stay checkpointed. For most workloads this is the valuable guarantee — the exactly-once output contract is a bonus available when the sink (or the user) cooperates.
How to get exactly-once output in Category 2
Pick any of:
INSERT ... ON CONFLICT UPDATE,MERGE INTO, keyed overwrite).Commit Idempotency
For Category 1 sinks with native catalogs (Iceberg, Delta, Paimon), "data committed" ≠ "
mark_committed()returned." A head-node crash between the catalog commit andmark_committed()means the next run sees the same files inget_checkpointed_files()and re-commits them. Without idempotency at the catalog layer, this creates duplicate snapshots (Iceberg/Paimon) or duplicate rows (Delta withouttxn).Daft's responsibility here:
daft.checkpoint-store(store path) +daft.epoch-id(monotonic counter permark_committed). On recovery, walk snapshot history; if the tag already exists, skip the re-commit.txnaction with{appId: store_path, version: epoch_id}. Delta's transaction protocol dedups at the log level.Implementation follow-up.
Sink-less Pipelines
Not every pipeline ends in a Daft-native write sink. Pipelines that end in
.collect()after a UDF with external side effects (LLMs, embeddings APIs, Kafka publishes) have no place to attach the checkpoint hook on the write arm of the translator. In the original design these would silently no-op — rows anti-join correctly, but nothing ever gets checkpointed, so every re-run redoes everything. That's the worst possible failure mode.CheckpointTerminusNodeA terminal wrapper injected at the plan root when a checkpoint source is present with no native write sink downstream. It drives
store.checkpoint()on task completion at the same per-task checkpoint granularity as the sink case.Trade-off. Checkpoint granularity is per-task. A task that processes 5 rows, writes to an external system for each, and crashes on row 6 will have all 6 source keys not checkpointed on rerun — and the external system may have already received rows 1-5. This is consistent with Category 2 semantics: the framework can't unwind external effects. Users who want row-level recovery against external systems need to either make the external writes idempotent or structure the pipeline differently (e.g., write to Parquet first, then drive external writes in a second idempotent pass).
Crash Recovery
checkpoint()checkpoint(), before worker reports to head nodemark_committed()txn, Parquet orphan cleanup) → safe. Category 1 without those mechanisms → catalog duplication on rerun. Category 2 → depends on user's write pattern (see Sink Compatibility)mark_committed()At every crash point for Category 1 sinks with commit idempotency configured, the system either redoes work (correct) or rolls forward (correct). Category 2 sinks require user-side or system-side idempotency for the "after commit" window.
What This Handles
StageCheckpointKeysOperatorstages keys before user filters see the rows.store.checkpoint()at task end viaCheckpointTerminusNode.Limit,Offset,Sample).What This Doesn't Handle (Yet)
Limit,Offset,Sample) — rejected at optimizer time. Supporting them would require a separate design for "partially processed partitions."concat,union_all,intersect) — rejected at optimizer time. The design space (one store per source vs shared store; key-space collision handling) depends on concrete use cases we haven't seen yet.write_sql/ Lance REST in non-idempotent write patterns — these commit per-micropartition with no rollback. Exactly-once output requires the user to write idempotently (upsert,ON CONFLICT UPDATE,MERGE) or accept duplicates.Open Questions / Follow-ups
on=is the general fallback but forces the source to materialize and retain the key column. For file-addressable connectors (Parquet, Iceberg, Delta, Hudi, Paimon), a file-level filter — intercepting each connector'sto_scan_tasks()and skipping files whose paths are already checkpointed — would be more efficient. Complementary to row-level, not a replacement. Open design question: do we pursue this as an optimization, or is row-level sufficient?num_workers,cpus_per_worker, concurrency are hardcoded. Needs to either scale with the cluster or be configurable viaCheckpointConfig.on=column name. A canonical name ("key") would decouple checkpointed-key file stability from schema renames.stage_keys/stage_filesblock the pipeline on I/O. If the store tracked outstanding writes andcheckpoint()became the sync point, the pipeline would be non-blocking. This also fixes the empty-source edge case where a task processes 0 rows andcheckpoint()errors with "checkpoint not found."Related Work / References
skip_existingdesign — Discussion #5868skip_existingimplementation — PR #5931daft-checkpointcrate + CheckpointStore trait — PR #6511Feature Matrix
Last updated: 2026-04-20. Tracks where each dimension of checkpoint support stands as of PR #6725 (in review).
Legend
Source connectors (where
checkpoint=can be attached)on=doesn't map cleanly; partition-range checkpointing is a future designSink compatibility
Two guarantees are separable: (G1) don't redo expensive work, and (G2) don't write duplicate output rows. G1 holds for any sink where Daft defers externally-visible commits to task-end. G2 requires either framework-level 2PC, sink-native atomicity, or user-level idempotency (upserts, keyed writes).
Category 1 — Framework-atomic (Daft controls the commit boundary)
G1 holds; G2 is a framework/sink property.
daft.checkpoint-store+daft.epoch-idsnapshot tags (follow-up){appId: store_path, version: epoch_id}txnaction (follow-up)Category 2 — Side-effect endpoints (data leaves Daft mid-pipeline)
G1 depends on whether the sink defers commits to task-end; G2 is the user's or the external system's responsibility.
write_sql(append mode, no PK)write_sql(upsert target /ON CONFLICT UPDATE/MERGE)write_sqlappendCheckpointTerminusNodedrivesstore.checkpoint()at task completion; framework can't unwind external effects, so mid-task crash re-sends rows that already left the boundaryObject store backends
file://)daft-io, unvalidatedPipeline operators allowed between source and sink
with_columngroup_by+aggregationKey types
Capabilities
on=)CheckpointTerminusNodecheckpoint()get_checkpointed_filesreturns what needs committingmark_committed()concat/union) pipelinesto_scan_tasks())Deployment
ValueError+ remediation guidanceValidation state
daft-checkpoint, 252 indaft-logical-plantests/integration/checkpoint/test_checkpoint_s3.pyAll reactions