fix(sink): resolve architecture-review must-fix items for upsert sink (#76 follow-up) - #77
fightBoxing wants to merge 5 commits into
Conversation
Add docs/src/ following the lance-spark docs template, covering: - Welcome, Install, Config, Performance - Operations: DDL (create-catalog, create-table), DQL (select, vector-search, time-travel), DML (insert-into) Unimplemented capabilities (ALTER TABLE, CREATE INDEX, UPDATE, DELETE) are explicitly noted as unsupported / in progress.
Primary-key aware sink: - declare +I/+U/-D changelog mode and keyBy(PrimaryKeySelector) for ordered upsert - mergeInsert (UpdateAll/InsertAll) for upsert; Dataset.delete(OR-of-AND) for -D - persist the primary key via dataset config Catalog / schema evolution: - CREATE TABLE materializes an empty dataset immediately (matching Spark/Trino) - ALTER TABLE supports ADD/DROP/RENAME COLUMN and SET/UNSET TBLPROPERTIES - type change is rejected: Lance Java SDK 7.0.0 castTo is verified a silent no-op - SchemaDiff detects add/drop/type-change/rename and rejects unsafe drop+add
…currency and replay tests Fix: - LanceUpsertSink.createDataset re-checks dataset existence and falls back to merge-insert when a peer subtask created it first (regression: multi-subtask first write silently dropped rows via Overwrite) Tests: - replay upsert/delete batches are idempotent (checkpoint replay) - two subtasks concurrently write distinct keys to an existing dataset - two subtasks concurrently first-write distinct keys without data loss
…d LanceSink LanceSink.flush re-checks dataset existence BEFORE Fragment.write (which itself creates the dataset data directory), so a peer subtask's earlier first-write is detected and followed by Append instead of a clobbering Overwrite. Regression: two append subtasks concurrently first-writing distinct rows silently dropped one subtask's data.
Address the A/B-level items from the architecture review of the upsert-delete-alter-table PR: A1+A3+A5: rewrite LanceUpsertSink first-write path - Replace the createDataset (Overwrite) branch with an open-or-create strategy (Dataset.open, fallback Dataset.create) that is atomic in the Lance native layer, eliminating multi-subtask Overwrite clobbering. - Remove the Files.exists() checks that silently misclassified remote paths (s3://, tbdsfs://) as non-existent. - OVERWRITE mode now explicitly rejects remote storage in the sink and documents that truncation must happen at DDL time. A2: tighten checkpoint / consistency model - Apply deletes before upserts within a flush so a half-failed flush never leaves a stale row that should have been superseded. - Drop the implicit flush() from close(); persistence boundary is now strictly the checkpoint, matching at-least-once semantics. - Reject NULL primary-key values and non-finite float PK values in the DELETE predicate (previously they produced silently no-op predicates). - Escalate OVERWRITE local-directory delete failures from LOG.warn to IOException (was B6). B2: bound the in-memory buffer - invoke() now triggers an early flush() once the collapsed key count reaches write.batch-size, preventing unbounded heap growth between checkpoints. Happens between events, so the delete-before-upsert invariant is preserved. B5: protect foreign-namespaced dataset config during ALTER - applyTableProperties() no longer UNSETs config keys that look namespaced (contain a dot), preserving metadata written by sister engines (Spark, Trino, Ray) across a Flink ALTER TABLE ... RESET. Engineering-quality - LanceDynamicTableSink assigns a stable operator UID + name to the upsert sink so state mapping survives job upgrades / savepoints. - Extract PrimaryKeySelector.project() and have LanceUpsertSink. extractKey() delegate to it, so the keyBy routing key and the buffer key can never drift. - Reject comma-containing primary-key column names in PrimaryKeyPersistence.persist() to keep the comma-delimited encoding unambiguous. - Narrow ArrowArrayStreams from public to package-private. - LanceUpsertSinkITCase.twoSubtasksConcurrentFirstWrite() is now truly concurrent (CountDownLatch double-barrier + ExecutorService). - New LanceUpsertSinkITCase.closeDoesNotImplicitlyFlush() regression test for the A2 close() semantics. Not addressed in this commit (tracked in .gh-comments/): - A4 typed DELETE encoding (replace SQL string with mergeInsert WhenMatched.Delete or IN-list). - A6 consume TableChange in LanceCatalog.alterTable instead of SchemaDiff heuristic. - A7 single source of truth for connector option classification. - B1 hot-key metrics; B3 RootAllocator cap (needs cross-component coordination); B4 cross-engine PK metadata key. mvn -o clean test-compile: BUILD SUCCESS (0 errors) on 1.18/1.19/1.20.
|
ACTION NEEDED The PR title and description are used as the merge commit message. Please update your PR title and description to match the specification. For details on the error please inspect the "PR Title Check" action. |
|
Follow-up issues (deliberately deferred, tracking):
Each has context, options considered, and acceptance criteria. |
fightBoxing
left a comment
There was a problem hiding this comment.
Review: 请勿合并——本 PR 引入了 6 个 ITCase 回归,且 CI 看不到
重构方向我是认同的:用 openOrCreate 统一首写与稳态路径、消除 Overwrite 对同伴 subtask 的覆盖风险,这是对的。close() 不再隐式 flush 也是正确的判断。
但当前状态不能合并。本 PR 让 LanceUpsertSinkITCase 从全绿变成 6 个失败,而这个变化在 CI 里完全不可见。
实测对照
*ITCase 不被 surefire 的默认 include 匹配,而 pom.xml 至今没有配 failsafe(只有第 25 行一句注释提到它),所以这批用例在 CI 中从未执行。我在本地用 -Dtest='*ITCase' 强制跑,三方对照如下:
| 版本 | LanceUpsertSinkITCase 结果 |
|---|---|
#76 head (88b8c42) |
8 run, 0 failed |
本 PR head (257a99c) |
9 run, 6 failed |
| 修复后(本地) | 9 run, 0 failed |
失败清单:
LanceUpsertSinkITCase.insertThenDeleteSameFlushLeavesKeyAbsent:146
LanceUpsertSinkITCase.deleteThenInsertSameFlushLeavesKeyPresent:170
LanceUpsertSinkITCase.updateAfterReplacesPreviousValue:195
LanceUpsertSinkITCase.replaySameUpsertBatchIsIdempotent:227
LanceUpsertSinkITCase.replayDeleteBatchIsIdempotent:246
LanceUpsertSinkITCase.twoSubtasksConcurrentFirstWrite:326
这些正是本 PR 声称要保证的语义:同 flush 内的折叠、replay 幂等、并发首写。
根因一:mergeInsert 返回新句柄,被丢弃了(前 5 个失败)
mergeInsert 不修改传入的 Dataset,它提交一个新版本并通过 MergeInsertResult#dataset() 返回新句柄。本 PR 在 open() 里一次性取得 this.dataset,之后每次 mergeInsertRows / deleteRows 都丢弃返回值,所以 this.dataset 永远钉在 open() 时的快照上。
后果是写不丢、读陈旧:Lance 提交时按最新表状态解析,所以数据落盘是对的;但任何用这个旧句柄做的读(包括测试里的 countRows)看到的都是变更前的状态。insertThenDeleteSameFlushLeavesKeyAbsent 报 expected: 0L but was: 1L 就是这个原因。
修法是在两处调用点接住返回值:
MergeInsertResult result = ArrowArrayStreams.mergeInsert(dataset, params, allocator, root);
this.dataset = result.dataset();这一条同时解释了为什么 #76 版反而没问题:#76 在 flush() 里按 dataset == null 分支走 createDataset(),每轮重新拿句柄,掩盖了这个语义。
根因二:PrimaryKeyPersistence.persist 在并发首写时必冲突(第 6 个失败)
open() 第 190 行注释写的是 "Idempotent; safe to call on every open",这个论断不成立。Dataset#updateConfig 是版本化事务,不是幂等 put。两个 subtask 同时 open() 时,第二个提交会被 Lance 的冲突解析器拒绝:
java.lang.RuntimeException: Incompatible transaction: This UpdateConfig transaction is
incompatible with concurrent transaction UpdateConfig at version 2.,
rust/lance/src/io/commit/conflict_resolver.rs:1132:34
把 persist 从 flush() 的首写分支提到 open(),恰好把这个调用从"只有一个 subtask 执行"变成"每个 subtask 都执行",所以是这次重构放大了它。
建议两道防线:写前先读当前值,一致就不发起事务;冲突时先 checkoutLatest() 把句柄推进到最新版本再重读校验——这一步不能省,因为 getConfig() 读的是 open() 时的旧快照,不推进就永远看不到对端的提交。
前置建议
这两个根因都属于"SDK 方法存在但语义与直觉不符",靠读代码评审很难发现,只能靠集成测试拦住。所以建议先把 failsafe 的 <executions> 绑定补上(integration-test + verify 两个 goal),让这批 ITCase 真正进入构建,再谈这个 PR 的合并。否则下一次同类回归依然会带着全绿的 CI 进来。
上述修复我已在本地完成并验证(三模块 surefire 308 + failsafe 83 全绿),可以在本 PR 更新后提交,或者由我另开 PR 接在后面。
|
Closing in favour of #82, which contains this PR's commit plus fixes for the two regressions it carried. To recap the reason, since the direction here was right and worth keeping: moving dataset creation and primary-key persistence out of Neither was visible in CI because Full detail in the review above. |
Summary
This PR applies the "must-fix" architecture-review outcomes from
#76 (feat: add primary-key upsert/delete and ALTER TABLE schema evolution)
as a self-contained, independently reviewable change.
It is branched from #76 (
fightBoxing:feat/upsert-delete-alter-table @ 88b8c42)so it can be merged either as a follow-up to #76 or squashed into #76 by
its author before that PR lands.
7 files changed, +334 / -128.
mvn -o clean test-compileon Flink 1.18 / 1.19 / 1.20:BUILD SUCCESS, 0 errors.What changed and why
A1 + A3 + A5 — first-write path unified
The upsert sink previously took
Fragment.write+Overwrite-commit on thefirst write, gated by
Files.exists(datasetPath). That had two defects:Files.existsis not atomic withthe subsequent
Overwrite, so two subtasks that both observe "notexists" would both commit
Overwrite, and one would silently lose itsrows.
Files.exists(Paths.get("s3://…"))/Files.exists(Paths.get("tbdsfs://…"))returnsfalsefor every URIwith a non-
filescheme, so on remote storage every open would enterthe first-write branch and re-
Overwritethe entire dataset.Now the sink uses an open-or-create strategy in
open():This is atomic in the Lance native layer, works identically on local FS and
remote object stores, and makes the first-write path go through the
existing
mergeInsertcode (no separateOverwritebranch).OVERWRITEmode is now explicitly rejected for remote paths in the sink —truncation must be performed at DDL time via the catalog (which already
knows how to configure the storage environment).
A2 — checkpoint / consistency model tightened
leave behind a stale row that should have been superseded. With per-key
buffer collapsing, this also keeps the operation idempotent under
upstream replay.
close()no longer implicitly flushes. Flink invokesclose()oncancel / restart as well; writing on those paths violates the
"checkpoint is the persistence boundary" contract. Rows still in the
buffer are dropped and re-delivered by the (replayable) source on
restart. This makes the at-least-once semantics honest.
NULLand non-finite float primary-key values are rejectedexplicitly in the DELETE predicate builder. Previously
col = NULLproduced a silently no-op predicate (SQL UNKNOWN), so a
-Dwith aNULL PK would silently fail to delete anything.
IOException(was
LOG.warn, would previously leave half-old / half-new data).B2 — buffer is now bounded
invoke()now triggers an earlyflush()once the collapsed key countreaches
write.batch-size. The check happens between events (nevermid-flush), so the delete-before-upsert invariant is preserved. Prevents
unbounded heap growth in the gap between checkpoints on wide-key workloads.
B5 — foreign-namespaced dataset config is now preserved
applyTablePropertiesUNSET path no longer deletes config keys that looknamespaced (
engine.category.name). A FlinkALTER TABLE t RESET (...)will no longer silently remove metadata written by sister engines
(Spark / Trino / Ray) on the same Lance dataset.
Engineering quality
LanceDynamicTableSinkassigns a stable operator UID(
lance-upsert-sink) + name so state mapping survives job upgrades andsavepoints.
PrimaryKeySelector.project()extracted as a public static method;LanceUpsertSink.extractKey()delegates to it — thekeyByrouting keyand the buffer key can never drift.
PrimaryKeyPersistence.persist()rejects column names containing acomma (would corrupt the comma-delimited encoding).
ArrowArrayStreamsnarrowed frompublicto package-private (onlycalled by
LanceUpsertSinkin the same package).LanceUpsertSinkITCase.twoSubtasksConcurrentFirstWrite()is now trulyconcurrent (
CountDownLatchdouble-barrier +ExecutorService) —previously executed serially and would not have caught the A1 regression.
LanceUpsertSinkITCase.closeDoesNotImplicitlyFlush()regressiontest for the A2
close()semantics.Deliberately deferred to follow-up issues
The following review items are not addressed here. Each has a
dedicated tracking issue that describes the constraint, options, and
acceptance criteria:
TableChangeinLanceCatalog.alterTableinstead of the
SchemaDiffheuristiccross-engine PK metadata key
RootAllocatorcap (the latter needscross-component coordination across
LanceSink/LanceUpsertSink/LanceCatalog/LanceNamespaceCatalogand should not land piecemeal)Verification
I did not run
mvn testbecause it requires the Lance native libraryand platform-specific artifacts I don't have set up locally. CI on this
repo should exercise all of
LanceUpsertSinkITCase,LanceCatalogTableITCase,SchemaDiffTest, andLanceSchemaEvolutionITCaseend-to-end.Relationship to #76
This PR should not be merged before #76 — it depends on
88b8c42as its base commit. Options for the reviewers:feat/upsert-delete-alter-tablebefore mergingfeat: add primary-key upsert/delete and ALTER TABLE schema evolution #76 into
main(recommended: keeps history minimal).mainas a follow-up.Either way, the resulting
mainstate is identical.