Skip to content

DuckLake: detect commit conflicts against concurrent writers, retry only when safe - #15

Open
fuziontech wants to merge 2 commits into
masterfrom
james/ducklake-commit-retry
Open

DuckLake: detect commit conflicts against concurrent writers, retry only when safe#15
fuziontech wants to merge 2 commits into
masterfrom
james/ducklake-commit-retry

Conversation

@fuziontech

Copy link
Copy Markdown
Member

Summary

  • The connector already replayed a commit that lost the snapshot-id race (SERIALIZABLE transaction, retry on 40001/40P01/23505), but it re-based unconditionally: it never checked whether a snapshot committed after the statement's read snapshot conflicts with what this commit writes. An INSERT whose table was concurrently ALTERed committed data files that no longer matched the columns.
  • Adds DuckLakeSnapshotChanges: parses ducklake_snapshot_changes for every snapshot newer than the statement's pinned read snapshot and applies DuckLake's conflict rules (from ducklake_transaction_state.cpp CheckForConflicts). Same-table insert vs insert and any change on other objects re-base and retry; insert/delete vs drop/alter/compact of the same table, and alter vs alter/drop, fail with a new DUCKLAKE_COMMIT_CONFLICT and are not retried (the statement must be replanned).
  • Unknown change tokens from a newer DuckLake writer fail with DUCKLAKE_UNSUPPORTED_CHANGE_TYPE naming the token and the remedy (upgrade the connector); corrupt rows fail with DUCKLAKE_INVALID_METADATA. Neither is retried.
  • New catalog properties ducklake.commit.max-retries (default 10, DuckDB's ducklake_max_retry_count) and ducklake.commit.retry-backoff (default 20 ms, doubled per attempt, capped at 32×).

Design notes

  • Read set = write set. Tables a statement only read are not protected; that is snapshot isolation, and DuckDB accepts the same write skew. Protecting read tables would fail every INSERT INTO mart SELECT FROM raw while an ingest pipeline writes raw, which is the production workload.
  • Delete vs delete stays at file granularity through the pre-existing verifyDataFilesUnchanged/verifyDeleteFilesUnchanged, matching DuckDB.
  • Tables and views share one id space, so their dropped/altered sets are unioned; this also covers altered_table:<viewId> written for view comments.
  • Name-keyed changes (created_*) need no rule: the replay re-resolves names inside the fresh transaction and fails with the ordinary ALREADY_EXISTS/NOT_FOUND.

Tests

  • TestDuckLakeConcurrentCommits (4): a foreign DuckDB snapshot is committed from inside the connector's commit action so attempt 1 deterministically loses the race — re-based onto an unrelated table (2 attempts, one data-file row, DuckDB reads it); re-based onto a concurrent insert into the same table (both rows land); DUCKLAKE_COMMIT_CONFLICT with zero data-file rows when the target table was altered; DUCKLAKE_UNSUPPORTED_CHANGE_TYPE when a foreign snapshot carries teleported_table:99.
  • TestDuckLakeSnapshotChanges (11): the matrix, token parsing, unknown and corrupt entries.
  • Module: 148 tests, 0 failures; -P ci checks clean.
  • Reviewed by a logic-review pass that compared the matrix row by row against the DuckDB source; its one note (typed error for unknown tokens) is the second commit.

Docs

ducklake.md gains a "Concurrent writers" subsection and the two properties. (A separate PR rewrites the Limitations / SQL support sections; the two are meant to merge cleanly.)

🤖 Generated with Claude Code

https://claude.ai/code/session_01WCY5Jf2BQPCVKJTZU1TpEe

fuziontech and others added 2 commits September 3, 2026 23:39
A DuckLake catalog orders every change on one chain of snapshots, so a
commit has to claim the snapshot after the newest one. With a DuckDB
ingest pipeline writing to the same catalog, Trino loses that race often.

The connector already replayed the action when the database reported the
race, but it never asked whether replaying was allowed: it re-based onto
the newer snapshot whatever the other writer had done. An insert whose
target table was altered underneath it registered data files that no
longer described the table's columns.

Apply the DuckLake conflict rules before the snapshot is written. The
commit compares the changes it is about to record against the changes of
every snapshot committed since the one the statement read, and refuses to
land when the other writer touched an object the result depends on:

  inserted into T  vs  T dropped, altered, or deleted from
  deleted from T   vs  T dropped, altered, inserted into, or compacted
  altered T        vs  T dropped or altered
  dropped T        vs  T dropped

These are DuckDB's rules for the same catalog, so a statement is accepted
or rejected here exactly as it would be there. Two writers inserting into
one table is not a conflict, which is the case that matters for an ingest
pipeline running beside a transform.

Changes recorded by name -- creating a schema, table, or view -- carry no
rule, because the replay re-resolves the name against the newer catalog
and reports the ordinary "already exists" or "not found" error there.

A refusal now carries its own error code, DUCKLAKE_COMMIT_CONFLICT, so
that a client such as SQLMesh can tell a lost race apart from a broken
catalog. The retry bound and backoff become catalog properties,
ducklake.commit.max-retries and ducklake.commit.retry-backoff.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCY5Jf2BQPCVKJTZU1TpEe
Parsing a foreign snapshot's changes threw IllegalArgumentException. That
is neither JdbiException nor ConcurrentModificationFailure, so it escaped
the commit retry loop and reached Trino as an unclassified internal error.

It is reachable without corruption: a newer DuckLake writer commits a
change type this connector does not know, and every commit whose read
snapshot precedes that row hits it.

Throw a TrinoException instead, and separate the two cases, which mean
different things to whoever reads the error:

  DUCKLAKE_UNSUPPORTED_CHANGE_TYPE  a newer DuckLake wrote a change type
                                    this connector cannot interpret, so it
                                    cannot tell whether committing on top
                                    is safe. The message names the token
                                    and says to upgrade the connector.
  DUCKLAKE_INVALID_METADATA         the row is not shaped like a change at
                                    all, which is corruption.

Failing stays the right direction: ignoring an unknown change could drop
the other writer's work. Neither failure is retried, because reading the
row again reaches the same conclusion.

The malformed-entry path had the same defect and is fixed with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCY5Jf2BQPCVKJTZU1TpEe
@fuziontech
fuziontech force-pushed the james/ducklake-commit-retry branch from 23cf907 to 29508b5 Compare September 3, 2026 23:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant