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.