RPL-6780: fan out non-natural-key deletes to real natural keys (leo-connector-common) - #250
Conversation
…86 integer validation RPL-6780 — a delete keyed by something other than the row's natural key (a parent FK like shipment_id, or a source mongo_id) was synthesized as a `_del_<value>` marker. combine() groups by natural key, so that marker landed in a group of its own and was never compared against the same-batch writes for the rows it affects. The outcome was then decided by the connector's fixed flushDeletes-before-MERGE order rather than by event order, so any write in the batch reverted the delete. The ordering logic was never missing — combineRecords already implements last-event-wins correctly. The markers just never reached it. So resolve the delete to the natural keys it currently matches and emit one marker per row; those flow through the existing logic unchanged. - common/datawarehouse/delete-fanout.js: new, extracted for testability (same reason combine-records.js was split out of combine.js). - common/datawarehouse/load.js: checkforDelete delegates to it. - postgres/lib/dwconnect.js: implements the resolveDeleteKeys capability. Resolution is an optional client capability. load.js is shared by every connector, so a connector without it — or a table with a composite natural key, or one absent from tableConfig — falls back to today's marker rather than failing. Also fixes a latent case: when a table's natural key is not named `id`, the old marker left the natural key unset, so every such delete hashed to the same empty combine key and all but the last were silently dropped. Known residual gap: a row created in the same batch as the delete does not exist when the target is queried, so it is not resolved. Not a regression — the previous FK-keyed UPDATE also ran before the merge and matched nothing. Closing it needs a reconciliation sweep, tracked on RPL-6780. DPT-2586 — isValidInteger accepted decimals inside the integer range (and NaN and Infinity), so a bad value passed validation and then failed at load time, taking the batch down instead of routing the one record to the error queue. Note: isValidBigint has the same decimal hole but a different shape (it also accepts numeric strings). Left alone deliberately — out of DPT-2586's stated scope. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Folds the bigint half of DPT-2586 into the integer fix. isValidBigint had four
ways to pass an invalid value:
- Any `number` skipped every check, because `.length` is undefined on a number.
Decimals, NaN and Infinity all validated, then failed at load time.
- The `{0,19}` string pattern matched the empty string and a lone '-'.
- null/undefined threw a TypeError on .match() instead of failing validation.
- The magnitude walk only ran when the string was exactly 19 chars, so a negative
at the boundary was 20 chars with its sign and skipped the check entirely.
Magnitude is now compared with the sign stripped. That also rejects the true
minimum, -9223372036854775808; being one short is safer than overflowing.
Number handling is deliberately limited to an integrality check. A number above
Number.MAX_SAFE_INTEGER is already imprecise before it reaches validation, but
rejecting those is a wider behavior change than DPT-2586 calls for and would
newly divert records that load today. Left as a known gap.
Also drops a useless-escape lint error that the old pattern carried.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Backs the DPT-2586 changes out of this branch so it carries only the RPL-6780 delete-resolution work. The numeric validation fixes will be issued separately. Reverts the validation changes from f3e8dc8 (isValidInteger) and all of c11c80b (isValidBigint), and drops their tests. common/utils/validation.js is now byte-identical to the branch point again. No RPL-6780 code is affected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The schema was inlined into the resolution SQL. Every sibling in this file — importFact (190), importDimension (425), linkDimensions (806) — builds a `qualifiedTable` local first and interpolates that, so do the same. No behavior change: `public` is hardcoded for the main table in all three of those places too, and columnConfig.stageSchema governs staging tables only, not the table this query reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9fc336f. Configure here.
Addresses Cursor Bugbot on #250. importDimension applies its deletes with a `_current = true` predicate, while importFact applies none. Resolution ignored that distinction and matched every version of a row. On an SCD-2 dimension that lets a historical row hand back a natural key whose CURRENT version holds a different value in the delete column. The follow-up UPDATE then closes that current row — something the previous column-keyed UPDATE never did, because it filtered on _current itself. Resolution now adds the same predicate when the table has a _current column, which is exactly the dimension case; facts stay unfiltered. Not reachable in the Redshift offload config, which runs hashedSurrogateKeys with bypassSlowlyChangingDimensions and so keeps one row per natural key. It is reachable for any consumer using the SCD-2 path this library still supports. Not unit-tested here: postgres/ has no unit-test harness and lib/dwconnect.js cannot even be required without aws-sdk, which the Lambda layer supplies. The equivalent fix in rstreams-connector-datalake carries regression tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fan-out Backs the postgres connector changes out of this branch so it carries only the leo-connector-common fan-out that rstreams-connector-datalake needs. Removes the resolveDeleteKeys implementation added in f3e8dc8, the qualifiedTable convention change in 9fc336f, and the _current narrowing in 2f074dd. postgres/lib/dwconnect.js is byte-identical to the branch point again. Redshift behavior is therefore unchanged by this branch. Resolution is an optional client capability: with no implementation on the postgres client, checkforDelete falls back to the historical `_del_<value>` marker exactly as today. The Databricks connector implements the capability in its own repo, so only that path changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Enables an rc to be cut for this branch. workflow_dispatch is not usable here: release.yaml exists only on fix/rpl-5795-combine-soft-delete-ordering, not on the default branch, and GitHub only offers dispatch for workflows present on the default branch. Every rc so far came from this push trigger instead. For push events GitHub evaluates the workflow file from the pushed ref, so listing the branch here is sufficient — no change needed on master. Remove this entry once the branch is done; it should not outlive the work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pmogren
left a comment
There was a problem hiding this comment.
Review summary
The delete fan-out design is correct: keying resolved deletes by the row's real natural key so combine() groups them with that row's writes is the right fix for RPL-6780's collision. Traced the mechanism end to end (delete-fanout.js → combine.js → combine-records.js) to confirm exactly one winning record per natural key survives per batch — that holds regardless of which connector eventually implements resolveDeleteKeys.
Two things worth addressing before merge, plus two minor cleanups.
1. Resolver errors now abort the whole batch (load.js, checkforDelete)
deleteFanout(obj, tableNks, client, (err, records) => {
if (err) {
return done(err);
}
...done(err) propagates through ls.pipe(validateData, checkforDelete, combine(tableNks), ls.write(...)) and fails the whole batch's callback. Every other malformed record in this same file — see validateData's handleFailedValidation — is quarantined to a separate _error queue instead, and the stream continues. A transient resolveDeleteKeys failure (timeout, connectivity blip) now takes down every other valid write/delete in the batch with it. Before this PR, checkforDelete was synchronous and had no failure mode at all.
Worth at least a decision: is fail-loud intentional here, or should resolver errors get the same quarantine treatment as other invalid records?
2. Composite natural-key tables are permanently excluded, not just unimplemented
const nk = nks.length === 1 ? nks[0] : null;For any table with a multi-column NK, nk is always null, so it always takes the unresolved() fallback — unresolved() only ever sets data.id, never the real NK columns, so combine.js's tableIds[table].map(f => values[f]) hashes every such delete on that table to the same key regardless of connector or resolver support. This is the exact RPL-6780 collision, and it's structural: there's no path to close it later the way there is for the "no connector implements the resolver yet" gap. It's documented in an inline comment but not called out in the PR description's own "Not in this PR" list — worth adding there so it isn't lost once the resolver-adoption gap closes.
3. Minor: unneeded serialization (delete-fanout.js)
async.eachSeries(entities, (entity, entityDone) => { ... client.resolveDeleteKeys(...) ... })Entities resolve against independent tables with no shared state (confirmed by the test asserting each entity hits its own table). Once a connector implements the resolver, an event naming N entities pays N sequential round-trips instead of running concurrently, and checkforDelete's done() — and the whole stream's forward progress — waits on it. async.eachLimit(entities, limit, ...) removes the serialization while still bounding concurrent DB load (plain async.each is riskier since no connector's client has been confirmed safe under unbounded concurrent calls). Would need the one order-asserting test updated to be order-independent.
4. Minor: dead self = this alias (load.js)
let self = this;
deleteFanout(obj, tableNks, client, (err, records) => {
...
records.forEach(record => self.push(record));The callback is an arrow function, so it already inherits this lexically from the enclosing function(obj, done) — same this self points to. Harmless, but this repo's self = this convention (e.g. dol.js) exists specifically for non-arrow callbacks that get their own rebound this; copying it here where it isn't needed makes it look load-bearing.
Also traced (and ruled out as a non-issue): postgres/mysql's deleteHandler in dwconnect.js diverts any record carrying __leo_delete__ away from the staging-table merge before it's ever seen. That looked at first like it could bypass this PR's combine-group fix entirely, but combine.js/combine-records.js already resolve to exactly one winning record per natural key before that diversion runs — so it doesn't re-decide anything, and the fix's premise holds for those connectors too. It's just dormant for them today because neither implements resolveDeleteKeys, which the PR description already discloses.

Stacked on #249 — please merge that first. This depends on
combine-records.js, which only exists on that branch.Scope:
leo-connector-commononly. This PR adds the delete fan-out thatrstreams-connector-datalakeneeds. It changes no connector implementation, so Redshift behavior is unchanged.Problem
A delete keyed by something other than the row's natural key — a parent FK like
shipment_id, or a sourcemongo_id— is synthesized as a_del_<value>marker.combine()groups by natural key, so that marker lands in a group of its own and is never compared against the same-batch writes for the rows it affects.The outcome is then decided by the connector's fixed flush-before-merge order rather than by event order. Net effect: if any write for the row exists in the batch, the delete is reverted, regardless of which event was actually later on the bus.
Confirmed live in production on
f_shipment_item,f_order_item_activityandf_warehouse; structurally present on 18 tables.Approach
The ordering logic was never missing.
combineRecordsalready implements last-event-wins correctly — the markers just never reached it. So resolve the delete to the natural keys it currently matches and emit one marker per row. Those flow through the existing logic unchanged.common/datawarehouse/delete-fanout.js(new) — the fan-out, extracted for testability, same reasoncombine-records.jswas split out ofcombine.js.common/datawarehouse/load.js—checkforDeletedelegates to it.Resolution is an optional client capability
load.jsis shared by every connector — postgres, mysql, oracle, sqlserver, mongo, elasticsearch. Raw SQL there would break or misbehave for all of them, so the fan-out asks the client for an optionalresolveDeleteKeys(table, field, nk, ids, cb).A connector that does not implement it — plus any table with a composite natural key, or one absent from
tableConfig— falls back to today's_del_marker. That is every connector in this repo, including postgres, so nothing here changes behavior for existing consumers.The Databricks connector implements the capability in its own repo (rstreams-connector-datalake
fix/rpl-6780-resolve-delete-keys), so only that path changes.Also fixes a latent case
When a table's natural key is not named
id(f_shipping_label_package, NKpackage_id), the old marker left the natural key unset. Every such delete hashed to the same empty combine key and all but the last were silently dropped — data loss, not just misordering. No producer targets that table today, so this was latent.Tests
16 new, all passing. Includes RPL-6780's five ordering scenarios replayed through a resolved marker; rows 1 and 4 — the two the ticket calls provably wrong today — now come out correct. Also covers every fallback path, resolver errors, and the non-
idnatural key case.Suite is 23 passing / 1 failing. That failure (
test/checksum/index.test.js) is pre-existing on this branch's base — not introduced here. Lint error counts are identical to base on both files touched.Not in this PR
resolveDeleteKeyslives in its own repo and PR. Until it lands and the datalake pin moves off4.1.0-rc.32056762820, nothing in production changes.Note on the commit list
The branch history includes DPT-2586 numeric-validation commits and a postgres
resolveDeleteKeysimplementation, both since reverted in full — DPT-2586 is being issued separately, and the postgres work was dropped to keep this branch focused on the fan-out.common/utils/validation.jsandpostgres/lib/dwconnect.jsare byte-identical to the base branch. The net diff is three files. History was preserved rather than rewritten because the branch was already pushed.🤖 Generated with Claude Code
Note
Medium Risk
Changes the shared DW load delete path used by every connector; behavior stays on the old path until a client implements
resolveDeleteKeys, but async fan-out and new failure modes affect all delete events once connectors opt in.Overview
Fixes RPL-6780, where deletes keyed by a parent FK or other non–natural-key column were emitted as
_del_<value>markers that never joined the samecombine()group as that row’s writes, so batch flush order could silently undo a delete even when the delete was later on the bus.load.jsnow routestype: 'delete'events through a newdelete-fanout.jsmodule instead of inlining marker synthesis. The fan-out emits one delete marker per affected row. When the delete field is already the table’s single-column natural key, behavior is unchanged. Otherwise it optionally callsclient.resolveDeleteKeys(table, field, nk, ids, cb)to look up current natural keys and build markers keyed by the real NK (socombineRecordslast-event-wins applies). Connectors without that hook, composite NKs, or unknown tables still get the historical_del_marker. Resolver errors propagate instead of dropping the delete. The same change also fixes a latent bug for NKs not namedid(e.g.package_id), where markers previously collapsed to one combine group.Adds unit tests for resolution, fallbacks, errors, and the five ordering scenarios from the ticket. The release workflow also pushes from the feature branch for RC publishes.
Reviewed by Cursor Bugbot for commit 3df52d3. Bugbot is set up for automated code reviews on this repo. Configure here.