Incremental Entity Resolution
Flag-gated incremental identity resolution via graph contraction — how the algorithm works, what changes semantically (entity-id stability, the resolution log), how to enable it in a client project, and how it is tested.
Status: experimental, flag-gated (nexus.incremental.enabled, default
false). With the flag off, every model behaves exactly as before.
By default every dbt-nexus run rebuilds the whole pipeline: every identifier, every edge, and a full connected-components traversal over the entire identity graph. Cost scales with total history. Incremental mode makes the identity-resolution subgraph — and the final source models that feed it — process only what arrived since the last run.
The full design rationale and spec live in the package repo:
docs/incremental-identity-resolution.md.
This page is the operator's view.
The idea: prior state is a contracted graph
Identifier edges only ever arrive (nothing retracts them), so resolved components can never split — they can only accrete new identifiers or merge with each other. That monotonicity means the previous run's identifier → entity_id mapping is a lossless summary of all prior connectivity: each existing entity enters the next run's graph as a single contracted "super-node", and connected components run over just (touched super-nodes + new identifiers + new edges). Cost scales with batch size, not history size. Old edges are never re-read.
Each resulting component classifies three ways:
| prior entities in component | outcome |
|---|---|
| 0 | born — a new entity id is minted |
| 1 | accreted — the entity keeps its id; new identifiers join it |
| 2+ | merged — the entity with the most mapping rows survives; every row of each loser is re-pointed to the survivor |
Watermarks are always on ingestion time (_ingested_at), never
occurred_at — a backfilled 2019 event arriving today still enters the graph,
and produces exactly the partition it would have produced arriving in 2019.
Re-processing a batch is a no-op (safe under at-least-once delivery).
What changes semantically
Entity ids become stable instead of reproducible. The default (full refresh)
scheme derives an entity id from the component's lexicographically-first
identifier — reproducible from scratch, but the id churns whenever a new
identifier happens to sort first. Incremental mode flips the contract: an entity
keeps its id for life, and the id changes only across merges, which are
enumerated in the resolution log. The practical rule stays what it always was:
look entities up by identifier; treat entity ids as internal join keys. A
--full-refresh renumbers entities and starts a new epoch — treat it as a rare,
announced event.
Merges become data. nexus_resolution_log (built only when the flag is on)
is an append-only record of every resolution decision: born, accreted,
repointed (a merge — carries previous_entity_id), and full_resolution
epochs. It is the downstream invalidation stream ("which entity ids changed this
run") and doubles as an outbound merge/alias protocol for external tools. It is
also the only table in the package that cannot be rebuilt from source data —
it records what the pipeline concluded and when. It's protected with
full_refresh: false; treat it as system-of-record.
Splits stay out of the standard flow. Removing edges (retroactive noise
filtering, deletions) can split components, and splits are not incrementally
computable. Incremental mode therefore refuses to compile with edge_quality
autofiltering enabled, and split repair is a full-refresh (or future
per-component surgery) concern.
Enabling it in a client project
# dbt_project.yml
vars:
nexus:
incremental:
enabled: true
What turns incremental under the flag:
- the ER chain:
nexus_entity_identifiers,nexus_entity_identifiers_edges,nexus_resolved_person_identifiers,nexus_resolved_group_identifiers, plus the newnexus_resolution_log - the package's final source models for gmail and google_calendar
(
<source>_events,_entity_identifiers,_entity_traits,_relationship_declarations)
The hard requirement: _ingested_at on every ER source
The core identifiers union keeps one watermark shared across every
entities: true source, so ingestion-timestamp discipline is all-or-nothing:
a source emitting rows behind the shared watermark silently loses them, and a
source stamping ahead of real time (now(), clock skew) drags the watermark
past every other source's upcoming rows. Enabling the flag therefore
hard-requires every enabled ER source's <source>_entity_identifiers model to
expose a stable, non-null _ingested_at (real ingestion/load time — never
occurred_at, never now()):
- the build fails at compile time naming offenders if the column is missing from a built source relation;
- the packaged
test_incremental_sources_ingested_at_not_nulltest catches null values at the sources (a null would fail the watermark predicate and vanish silently — the core table can never witness its own missing rows).
Note this is about timestamps, not materialization — a source model may stay a
full-rebuild table forever; only its rows' stamps must be truthful. For
static/seed sources with no loader timestamp, stamp a data-vintage literal
held in a var (SRT's upwork_data_vintage is the reference) and bump it when
the data is re-exported: every row re-offers, which is an idempotent no-op for
already-absorbed rows and exactly how backfills enter correctly. The package's
segment source is known non-compliant today (it stamps current_timestamp());
enabling segment with incremental mode fails loudly until its real loaded_at
is threaded through.
Client-local source finals can adopt the same incremental recipe — see the
slide-rule-tech project for the reference pattern:
nexus.nexus_incremental_materialization() +
nexus.nexus_incremental_source_filter() + a unique-key merge + an
incremental-only batch dedup. Verify a candidate's _ingested_at empirically
against a built table, not by grepping the SQL — columns get used upstream and
dropped from final selects, and a moving now() stamp looks fine in any single
snapshot.
First build over an existing deployment
Tables built before the flag lack the columns the incremental predicates read. Every flagged model carries an upgrade guard that fails loudly with the exact remediation:
dbt run --full-refresh --select +<model>+
The clean path when enabling on an existing warehouse is a one-time full refresh of the ER subgraph (and the flagged source finals), which becomes the epoch the incremental runs continue from. First builds on an empty schema need nothing special — the first run is the epoch.
How it's tested
The package repo carries integration_tests/ — a consumer-shaped dbt project
run against scratch DuckDB databases with a simulated ingestion clock: whole
scenarios live in one seed, a runner advances it_now across dbt invocations
(one batch per run), and after every step the incremental partition is compared
against a from-scratch shadow resolution (co-membership equality; ids
deliberately ignored). Sequence properties — id stability, log append-only,
idempotent re-runs, empty-batch no-ops, watermark advancement — are asserted by
the runner between runs.
cd dbt-nexus/integration_tests
python3 run.py
Eight scenarios cover birth, accretion, merges, within-batch merge chains, cross-batch merge chains, re-observation watermarks, out-of-order ingestion, and group-type merges with cross-entity-type edges.
Operational notes
- The delta contract for downstream consumers: the set of entity ids
affected by a run = new entities +
repointedlog entries. Nothing else changed. (Downstream models — participants, traits, states — are still full rebuilds today; this contract is what their future incrementalization keys off.) reobservedrows in the resolved tables are watermark bookkeeping (a known identifier seen again), not resolution changes; they never enter the log.- Watermark boundary ties: the delta predicate is strictly
>on_ingested_at. Run after loads complete, or accept that boundary-sharing stragglers wait for the next run's rows to pull the watermark past them. max_recursionchanges meaning: it bounds within-batch chain length over the contracted graph (small), not historical component diameter.