Rebuild & Reconcile
Blue-green projection rebuilds and drift reconciliation — both source-of-truth from Postgres, never written to an engine directly.
The graph and search projections are derived, never authoritative. Two operations restore them from Postgres: rebuild (replace a projection wholesale, blue-green) and reconcile (heal per-aggregate drift in place). Both source from Postgres, and reconcile never touches an engine directly.
Blue-green rebuild
Use rebuild when a projection must be rebuilt from scratch — a model-version
bump, a corrupted target, or a projection that fell off the Redis stream's
MAXLEN horizon (replay is no longer possible from the stream, but Postgres
always can).
fabriq rebuild --tenant T --projection graph --falkordb falkordb:6379
fabriq rebuild --tenant T --projection search --elasticsearch https://es:9200What happens:
The rebuilder marks fabriq_projection_state status=building. The live
engine starts dual-applying immediately — new events land in both the old
and new target while the rebuild runs, so the new target is never stale on
flip.
It replays the Postgres snapshot into the new target: tenant_T_v{N+1} for
graph, or the _v{N+1} indexes for search. The reindex source is always
Postgres, never the old projection.
It flips: model_version++, the target pointer moves, and for search the ES
aliases swap atomically in one _aliases call. Readers follow the pointer or
alias; graph reads re-resolve within the resolver TTL.
The new target enters soaking. Verify it, then end the soak (below).
The e2e suite proves the rebuilt graph is identical to the event-built one. A rebuild from Postgres is always safe and always possible.
Finalize
Without --drop-old, the command leaves the old target in place and prints the
finalize line. After the soak window, drop the old target and mark the
projection live:
fabriq rebuild finalize --tenant T --old <oldTarget> --falkordb falkordb:6379--old is the old target name the rebuild printed (for example
tenant_acme_v2). To skip the soak entirely, pass --drop-old on the original
rebuild — it finalizes immediately after the flip.
Rebuild every tenant at once with --all-tenants (it iterates every tenant in
projection state) instead of --tenant.
Reconciler
Reconciliation compares per-aggregate versions between Postgres and each projection and heals the difference by re-emitting through the outbox.
fabriq reconcile --tenant T --falkordb falkordb:6379 # dry run
fabriq reconcile --tenant T --falkordb falkordb:6379 --repair # heal
fabriq reconcile --tenant T --elasticsearch https://es:9200 --repairDrift classes:
- missing / stale (the projection is behind the Postgres row): repaired by
upserting the aggregate's latest event into the outbox with
published_at = NULL. The relay republishes it; version-gated consumers apply it and converge. Replaying an already-seen event is harmless — consumers are idempotent. - zombie (projected but the row is gone): emits a synthetic
<entity>.deletedevent one version past what the projection holds, so the consumer removes it. - AHEAD is not drift. A projection scanning ahead of the truth read just saw events that landed between the two reads.
The reconciler never writes an engine directly — every repair flows through the outbox and the normal relay/consumer path, so a single code path owns all projection writes.
Without --repair the command is a dry run that lists drift. With --repair
it re-emits each drifted aggregate. You must pass at least one of --falkordb
/ --elasticsearch.
Scheduled reconciliation in the worker
The worker also runs the reconciler on a schedule, leader-elected (advisory
lock 1002) so exactly one replica scans. It runs every
FABRIQ_RECONCILE_INTERVAL (Go duration, default 5m; 0 disables it), across
every tenant seen in projection state, with repair enabled. It starts only
when a graph or search store is configured.
The Elasticsearch projection scan is capped at 10k docs per entity per tenant. Beyond that, zombie detection on search is not trustworthy until scroll support lands — rebuild instead.