You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Child of CritterWatch/CritterWatch#208 — the master tracking issue for rebuild optimization work. Composes with Phase 4 (deferred flush via identity-map accumulation) and Phase E (measurement #4684).
Background
Rebuild has a property continuous catch-up doesn't: post-TRUNCATE, the entire rebuild write path is INSERT-only. There's no need for UPSERT machinery, no ON CONFLICT path, no concurrency checking — rebuild is authoritative by definition.
Binary COPY (via Npgsql's BeginBinaryImport) is typically 3-10× faster than per-row INSERT for bulk loads in PostgreSQL. On a 20M-event rebuild producing 5M aggregate rows, this could be 4-8 minutes of wall-clock saving on the write side alone.
Marten already has the COPY machinery: IDocumentStore.BulkInsertAsync uses BeginBinaryImport with the document binder pipeline. The rebuild path should reuse those binders.
Scope
Add a BulkWriter-based flush path to ProjectionUpdateBatch that activates when all operations in the batch are inserts (which rebuild always satisfies post-TRUNCATE). Fall back to per-row UPSERT otherwise (continuous mode).
Concrete shape
Per (tenant, projection) rebuild:
1. TRUNCATE the projection's rows for this tenant
- Partition-aware: TRUNCATE the per-tenant partition when UseTenantPartitionedEvents is on (cheap DDL)
- Conjoined-only: DELETE FROM mt_doc_xxx WHERE tenant_id = $1 (slower but still cheaper than per-row INSERT)
2. Replay events through normal batch machinery (same EvolveAsync, same aggregate cache)
3. At batch flush:
- If batch is INSERT-only (true for rebuild post-truncate):
open BulkWriter via BeginBinaryImport within the open transaction,
stream all rows, end COPY
- Otherwise: fall back to per-row UPSERT (continuous mode)
4. Progression row UPDATE at batch end (single statement; optional COPY-into-temp optimization deferred to a separate phase if measurement shows it dominates)
The insert-only detection is critical for graceful degradation. If a rebuild somehow ends up with mixed inserts/updates (e.g., partial-rebuild resume where TRUNCATE didn't happen), we don't silently corrupt — we just don't get the COPY win for that batch.
Constraints
Transaction scope.BeginBinaryImport runs within an open transaction. ProjectionUpdateBatch.ExecuteAsync already wraps each batch in one transaction. Composes cleanly.
Hierarchical / typed storage. If the projection's table has mt_doc_type (subclass projection), COPY must set it correctly per row. Reuse the binder machinery from BulkInsertAsync — already handles this.
Metadata columns. created / updated / version / tenant_id all need to be in the COPY stream. Same as BulkInsertAsync.
Partition awareness under UseTenantPartitionedEvents. COPY into the partitioned table works (PG routes to the right partition). Optionally COPY directly into the per-tenant child partition for marginal throughput — defer that complexity to v2.
Composite stage propagation. Stage 2's EvolveAsync may read stage 1's aggregates via Load. If we batch-buffer stage 1's COPY until batch end, stage 2 (running after stage 1 completes per batch) can't see stage 1's new rows in the DB yet. Two options:
Flush stage 1's COPY before stage 2 starts (smaller batches per stage, less COPY benefit)
Propagate stage 1's updates through the existing in-memory Upstream cache mechanism (ExecutionStage.ExecuteDownstreamAsync already does this for the synthetic-event propagation) — stage 2 reads from cache, not DB, so the un-flushed stage 1 COPY isn't visible-but-also-isn't-needed
Preferred: the second option. Stage 2 reads via the propagated Upstream cache (already there); the DB-side COPY happens at batch end. This composes with Phase F (cache-integration work). Confirm during implementation that the existing propagation hits all the read paths stage 2 might use.
Recovery. COPY rollback is atomic with the transaction; partial COPY doesn't leak rows. Failed rebuild leaves the truncated projection table empty + the progression row in "tried, didn't finish" state. Operator retries the rebuild. Same correctness story as today — just a larger replay window per failed attempt.
UseTenantPartitionedEvents + per-tenant rebuild. Each per-tenant rebuild truncates only its own partition, runs its own COPY. Per-tenant isolation preserved.
Reuse vs. new code
Reuse:BulkInsertAsync column binders, BeginBinaryImport orchestration, partition resolution. Most of the COPY plumbing exists.
New:ProjectionUpdateBatch flush-mode detection (insert-only vs. mixed), TRUNCATE step at rebuild start, fallback path.
Acceptance
New ProjectionUpdateBatch flush path that uses BulkWriter when all operations are inserts.
Rebuild path TRUNCATEs the projection's rows for the affected (tenant, projection) at start.
Fallback to per-row UPSERT works correctly for continuous catch-up (no behavior change).
Documentation: new section in docs/events/projections/rebuilding.md covering the BulkWriter optimization, when it activates, and the per-row fallback.
Non-goals
Not changing the continuous catch-up write path. Per-row UPSERT stays the default.
Not COPY for progression-row updates. Separate phase if Phase E shows progression writes dominate; defer until measured.
Not COPY into per-tenant child partitions directly. Use the partitioned parent and let PG route. Optimization for v2.
Not Polecat side. Marten-only in v1; Polecat gets symmetric treatment when it catches up.
Effort
~1-2 weeks. The COPY plumbing reuses existing machinery; the bulk of work is ProjectionUpdateBatch mode detection + the TRUNCATE step + harness validation.
CritterWatch#208 Phase 4 (deferred flush via identity-map accumulation) — composes: defer + then COPY the deferred batch
Phase F (TBD jasperfx issue, cache integration) — composes via the Upstream cache propagation, which is how stage 2 reads stage 1's un-flushed COPY data
Parent
Child of CritterWatch/CritterWatch#208 — the master tracking issue for rebuild optimization work. Composes with Phase 4 (deferred flush via identity-map accumulation) and Phase E (measurement #4684).
Background
Rebuild has a property continuous catch-up doesn't: post-TRUNCATE, the entire rebuild write path is INSERT-only. There's no need for UPSERT machinery, no
ON CONFLICTpath, no concurrency checking — rebuild is authoritative by definition.Binary COPY (via Npgsql's
BeginBinaryImport) is typically 3-10× faster than per-row INSERT for bulk loads in PostgreSQL. On a 20M-event rebuild producing 5M aggregate rows, this could be 4-8 minutes of wall-clock saving on the write side alone.Marten already has the COPY machinery:
IDocumentStore.BulkInsertAsyncusesBeginBinaryImportwith the document binder pipeline. The rebuild path should reuse those binders.Scope
Add a BulkWriter-based flush path to
ProjectionUpdateBatchthat activates when all operations in the batch are inserts (which rebuild always satisfies post-TRUNCATE). Fall back to per-row UPSERT otherwise (continuous mode).Concrete shape
The insert-only detection is critical for graceful degradation. If a rebuild somehow ends up with mixed inserts/updates (e.g., partial-rebuild resume where TRUNCATE didn't happen), we don't silently corrupt — we just don't get the COPY win for that batch.
Constraints
Transaction scope.
BeginBinaryImportruns within an open transaction.ProjectionUpdateBatch.ExecuteAsyncalready wraps each batch in one transaction. Composes cleanly.Hierarchical / typed storage. If the projection's table has
mt_doc_type(subclass projection), COPY must set it correctly per row. Reuse the binder machinery fromBulkInsertAsync— already handles this.Metadata columns. created / updated / version / tenant_id all need to be in the COPY stream. Same as
BulkInsertAsync.Partition awareness under
UseTenantPartitionedEvents. COPY into the partitioned table works (PG routes to the right partition). Optionally COPY directly into the per-tenant child partition for marginal throughput — defer that complexity to v2.Composite stage propagation. Stage 2's
EvolveAsyncmay read stage 1's aggregates viaLoad. If we batch-buffer stage 1's COPY until batch end, stage 2 (running after stage 1 completes per batch) can't see stage 1's new rows in the DB yet. Two options:Upstreamcache mechanism (ExecutionStage.ExecuteDownstreamAsyncalready does this for the synthetic-event propagation) — stage 2 reads from cache, not DB, so the un-flushed stage 1 COPY isn't visible-but-also-isn't-neededPreferred: the second option. Stage 2 reads via the propagated
Upstreamcache (already there); the DB-side COPY happens at batch end. This composes with Phase F (cache-integration work). Confirm during implementation that the existing propagation hits all the read paths stage 2 might use.Recovery. COPY rollback is atomic with the transaction; partial COPY doesn't leak rows. Failed rebuild leaves the truncated projection table empty + the progression row in "tried, didn't finish" state. Operator retries the rebuild. Same correctness story as today — just a larger replay window per failed attempt.
UseTenantPartitionedEvents+ per-tenant rebuild. Each per-tenant rebuild truncates only its own partition, runs its own COPY. Per-tenant isolation preserved.Reuse vs. new code
BulkInsertAsynccolumn binders,BeginBinaryImportorchestration, partition resolution. Most of the COPY plumbing exists.ProjectionUpdateBatchflush-mode detection (insert-only vs. mixed), TRUNCATE step at rebuild start, fallback path.Acceptance
ProjectionUpdateBatchflush path that uses BulkWriter when all operations are inserts.docs/events/projections/rebuilding.mdcovering the BulkWriter optimization, when it activates, and the per-row fallback.Non-goals
Effort
~1-2 weeks. The COPY plumbing reuses existing machinery; the bulk of work is
ProjectionUpdateBatchmode detection + the TRUNCATE step + harness validation.Dependencies
Related
Upstreamcache propagation, which is how stage 2 reads stage 1's un-flushed COPY data