PekkoProjectionSupport: exactlyOnce does not cover the read-model write — projections are at-least-once against their own read model #41
Labels
No labels
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
iterative-works/support#41
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
PekkoProjectionSupportbuilds every projection withSlickProjection.exactlyOnce, but theViewProcessorwrite is escaped out of the Slick transaction. The exactly-once guarantee thereforecovers only the offset row — not the read model the projection exists to maintain. Any projection
whose
ViewProcessorwrites to a store other than the projection's own SlickDatabaseConfig(MongoDB, HTTP, a second datasource) is at-least-once with respect to that store.
This is not a theoretical concern. It caused a production incident in a downstream project
(medeca-modul-poptavky, MEDECA-411): a re-delivered envelope re-applied a non-idempotent event to an
already-updated read model, the aggregate's event-application threw, and
orDieWithturned it into aZIO defect that pinned a
ShardedDaemonProcessprojection at one offset while sibling projections ranthousands of events ahead. The read model froze and could never catch up.
Where
pekko-persistence/src/main/scala/works/iterative/pekko/PekkoProjectionSupport.scalaand, in
ProjectionHandler:DBIO.from(future)wraps an already-running Future. The effect starts whenruntime.unsafe.runToFutureis called and completes on its own; the surrounding Slick transactionneither sequences it nor rolls it back.
exactlyOncecommits the read-model write and the offset rowatomically only when both are DBIO actions against the same
dbConfig. Here the processor's writeis arbitrary ZIO against an arbitrary store.
Consequence: a crash, redeploy, node restart or shard rebalance between the processor's write and the
offset commit re-delivers the envelope, and the processor sees it twice.
Why it is easy to get wrong
The API reads as a guarantee. A caller passing a
ViewProcessorthat writes to Mongo gets aprojection named
exactlyOncethat is not exactly-once for their data, with nothing in the types orthe docs to say so. Every downstream
ViewProcessormust be idempotent, and today nothing states thatrequirement or checks it.
Compounding factors observed downstream:
numberOfInstances = 1underShardedDaemonProcessmeans one projection instance serves everyentity, so one poisoned envelope stops the read model for all of them, not just the offending
aggregate.
ProjectionBehaviorsupervision with the default recovery strategy, the failing envelope isretried forever. The projection never advances and never gives up.
Options
ViewProcessor.processmust be idempotent whenever it writes outsidedbConfig, and thatexactlyOnceguarantees only offset/Slick atomicity. Name the failure mode.atLeastOnceforforeign-store processors and reserve
exactlyOncefor processors that return aDBIOagainst thesame
dbConfig.ViewProcessorso a processorwriting to the projection's own database can return a
DBIOthat composes into the Slicktransaction instead of being escaped through
DBIO.from.so a caller chooses "stop the world on a poisoned envelope" deliberately rather than inheriting it.
Option 1 is the immediate one. Options 2–4 are a design discussion.
Notes
AkkaProjectionSupport) — the sameDBIO.fromescape ispresent there. This is not a Pekko-migration regression.
and idempotent. That is the correct fix on the consumer side, but it is a fix each consumer has to
discover independently — currently by having a production projection wedge.