Problem
PersistentActor.persistAll is documented as "Persist several events atomically". On D1, MongoDB and Cassandra it is not, and the API gives the caller no way to find out which one it is talking to.
- D1 has no transaction at all.
adaptD1Client.withTransaction runs the callback against the plain executor, because D1's HTTP API is one statement per request. So RelationalJournal.append's per-event INSERT loop is N independent round-trips, and a connection failure between them leaves a committed prefix.
- MongoDB skips the transaction deliberately (a standalone
mongod cannot offer one) and carries the same caveat.
- Cassandra splits its unlogged batch per partition and is non-atomic across the split.
Both backends document the caveat in their own docblocks — accurately. What is missing is any of that reaching the caller: persistAll's own JSDoc says "atomically" flatly, the Journal interface's append contract says nothing about atomicity either way, and there is no capability flag (journal.supportsAtomicBatch, a JournalCapabilities getter, anything) that a PersistentActor could branch on or that a startup check could refuse to run against.
What follows from the prefix write is worse than the write itself:
- The batch throws. Because the fold happens after
append returns, _state and _seq are left behind the journal — the actor's in-memory state does not include the events that are now durably committed. (The prior review note claimed _state/_seq had already advanced; they had not, and the real shape is the mirror image.)
cb never runs, so an ask-style caller times out and, if it retries, issues the batch again against a head that has already moved.
- The default
Restart supervision replays the journal, and the committed prefix comes back as if it had been intended — a half-executed business operation with no marker that it was half.
A three-of-five-events write that the caller was told failed, that the actor's memory disagrees with, and that a restart adopts as history.
Evidence
The contract, stated without qualification:
src/persistence/PersistentActor.ts:261-282
/** Persist several events atomically. Must also be awaited in onCommand. */
protected async persistAll(
events: ReadonlyArray<Event>,
cb?: (state: State) => void | Promise<void>,
): Promise<void> {
if (events.length === 0) { await cb?.(this._state); return; }
this._persisting = true;
try {
// Collect tags from the first event — tags are per-event but a single
// persistAll keeps them grouped so they share the same tag set.
const tags = this.tagsFor(events[0]!);
// If an event adapter is active, wrap each event into a `{_v,_t,_e}`
// envelope before handing it to the journal. Domain events stay in-
// memory unchanged so `onEvent` and `snapshotPolicy` see the original
// (current-version) shape.
const evAdapter = this.eventAdapter();
const wireEvents: ReadonlyArray<unknown> = evAdapter
? events.map((e) => encodeEvent(e, evAdapter))
: events;
const written = await this._journal.append<unknown>(
this.persistenceId, wireEvents, this._seq, tags,
);
The fold that never runs on the failure path, leaving _state/_seq behind the journal:
src/persistence/PersistentActor.ts:286-296
const policy = this.snapshotPolicy();
let shouldSnapshot = false;
for (let i = 0; i < written.length; i++) {
const pe = written[i]!;
const domainEvent = events[i]!; // pre-envelope domain shape
this._state = this.onEvent(this._state, domainEvent);
this._seq = pe.sequenceNr;
if (policy(pe.sequenceNr, this._state, domainEvent)) shouldSnapshot = true;
}
if (shouldSnapshot) await this.saveSnapshotNow();
await cb?.(this._state);
The D1 transport that makes the "transaction" a no-op:
src/persistence/journals/D1Client.ts:157-171
export function adaptD1Client(client: D1ClientLike): SqlPool {
const executor = {
async query(sql: string, params?: ReadonlyArray<unknown>): Promise<SqlResult> {
const result = await client.query(sql, params ?? []);
return { rows: result.rows, affectedRows: result.changes };
},
};
return {
query: executor.query,
async withTransaction(body) {
return body(executor);
},
async end() { await client.close(); },
};
}
The per-event insert loop it runs under:
src/persistence/relational/RelationalJournal.ts:117-139
const written: PersistentEvent<E>[] = [];
const tagString = tags && tags.length ? tags.join(',') : null;
let seq = actualSeq;
for (const event of events) {
seq++;
await transaction.query(this.statements.insertEvent, [
persistenceId, seq, encodePayload(event, this.serializer), tagString, now,
]);
if (tags) {
for (const tag of tags) {
if (tag.length === 0) continue;
await transaction.query(this.statements.insertTag, [persistenceId, seq, tag, now]);
}
}
written.push({
persistenceId,
sequenceNr: seq,
event,
timestamp: now,
tags: tags ? [...tags] : undefined,
});
}
return written;
The backends are honest where nothing reads them — MongoDB:
src/persistence/journals/MongoJournal.ts:50-61
* **No transaction, deliberately.** Appends are contiguous from the head, so
* two writers that agree on the head both try the *same first* sequence number:
* the loser's `insertMany` fails on its first document and writes nothing, with
* `ordered: true` stopping the batch there. A partial append is therefore not
* reachable through contention, which is what a transaction would have been for
* — and skipping it keeps the backend usable on a standalone `mongod`, since
* MongoDB transactions require a replica set. A mid-batch *infrastructure*
* failure (a dropped connection after the second of five events) can still
* persist a prefix; the stream stays gap-free and the next append continues from
* the new head, so recovery is consistent, but the caller's error does not mean
* "nothing was written". Single-event appends — the common case — are
* atomic outright.
and the interface that never surfaces the difference:
src/persistence/Journal.ts:10-22
export interface Journal {
/**
* Append `events` to the stream of `persistenceId`, enforcing optimistic
* concurrency: the current highest sequence number MUST equal `expectedSeq`
* or the call throws `JournalConcurrencyError`. Returns the written events
* with their assigned sequence numbers.
*/
append<E = unknown>(
persistenceId: string,
events: ReadonlyArray<E>,
expectedSeq: number,
tags?: ReadonlyArray<string>,
): Promise<PersistentEvent<E>[]>;
Proposal
- Add an explicit capability to
Journal — readonly atomicBatchAppend?: boolean (or a small capabilities getter, which the same seam would serve for "has a tag index" and "has an in-process event bus"). Every backend that can genuinely commit N events as one sets it; D1, Mongo and Cassandra do not.
PersistentActor.persistAll consults it: when a multi-event batch is issued against a journal without the capability, either refuse at the call site or emit a one-time warning naming the backend. A single-event persist is unaffected on every backend and stays the silent fast path.
- Correct the JSDoc on
persistAll and give Journal.append an atomicity clause, so the contract lives on the interface rather than in three backend docblocks.
- Document the recovery consequence where an operator will look for it: a batch that throws may still have committed a prefix, in-memory state will be behind the journal, and a restart adopts the prefix.
Acceptance sketch
Reference issues: #631 is the other persistAll defect (a mixed batch stamps every event with the first event's tags) — same method, unrelated mechanism. #874 proposes a journal circuit breaker for outages; a prefix write is a success followed by a failure and the breaker never sees it. #536 (public persistence testkit with failure injection) is where the D1 mid-batch failure test belongs once it exists.
Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: reproduced by execution. A D1Journal over FakeD1Client wrapped to drop the connection on the third event INSERT, driven by a real PersistentActor.persistAll of five events on a live ActorSystem:
persistAll threw: JournalError - D1Journal.append failed: connection reset by peer
callback ran : 0 time(s)
actor _seq=0 state={"items":[]}
events COMMITTED in the journal: 2 [1,2]
journal head: 2
after restart: actor _seq=2 state={"items":[1,2]}
The prefix is durable, the callback never fires, the live actor's state disagrees with its own journal, and the next start adopts the half-batch as history. Note the correction this run forced: _state/_seq do not advance before the throw — they are left behind, which is the opposite of what the review note asserted and is what makes the restart the moment the divergence becomes real.
Part of the production-readiness review batch — tracked in #913.
Problem
PersistentActor.persistAllis documented as "Persist several events atomically". On D1, MongoDB and Cassandra it is not, and the API gives the caller no way to find out which one it is talking to.adaptD1Client.withTransactionruns the callback against the plain executor, because D1's HTTP API is one statement per request. SoRelationalJournal.append's per-eventINSERTloop is N independent round-trips, and a connection failure between them leaves a committed prefix.mongodcannot offer one) and carries the same caveat.Both backends document the caveat in their own docblocks — accurately. What is missing is any of that reaching the caller:
persistAll's own JSDoc says "atomically" flatly, theJournalinterface'sappendcontract says nothing about atomicity either way, and there is no capability flag (journal.supportsAtomicBatch, aJournalCapabilitiesgetter, anything) that aPersistentActorcould branch on or that a startup check could refuse to run against.What follows from the prefix write is worse than the write itself:
appendreturns,_stateand_seqare left behind the journal — the actor's in-memory state does not include the events that are now durably committed. (The prior review note claimed_state/_seqhad already advanced; they had not, and the real shape is the mirror image.)cbnever runs, so anask-style caller times out and, if it retries, issues the batch again against a head that has already moved.Restartsupervision replays the journal, and the committed prefix comes back as if it had been intended — a half-executed business operation with no marker that it was half.A three-of-five-events write that the caller was told failed, that the actor's memory disagrees with, and that a restart adopts as history.
Evidence
The contract, stated without qualification:
The fold that never runs on the failure path, leaving
_state/_seqbehind the journal:The D1 transport that makes the "transaction" a no-op:
The per-event insert loop it runs under:
The backends are honest where nothing reads them — MongoDB:
and the interface that never surfaces the difference:
Proposal
Journal—readonly atomicBatchAppend?: boolean(or a smallcapabilitiesgetter, which the same seam would serve for "has a tag index" and "has an in-process event bus"). Every backend that can genuinely commit N events as one sets it; D1, Mongo and Cassandra do not.PersistentActor.persistAllconsults it: when a multi-event batch is issued against a journal without the capability, either refuse at the call site or emit a one-time warning naming the backend. A single-eventpersistis unaffected on every backend and stays the silent fast path.persistAlland giveJournal.appendan atomicity clause, so the contract lives on the interface rather than in three backend docblocks.Acceptance sketch
Journalexposes an atomic-batch capability; every shipped backend declares it truthfully.persistAllagainst a non-atomic journal is refused or warns once, naming the backend.persistAll's JSDoc and theJournal.appendcontract state the actual guarantee.cbnot run, actor state behind the journal).docs/.../persistence/states which backends give atomic multi-event appends and which do not.Reference issues: #631 is the other
persistAlldefect (a mixed batch stamps every event with the first event's tags) — same method, unrelated mechanism. #874 proposes a journal circuit breaker for outages; a prefix write is a success followed by a failure and the breaker never sees it. #536 (public persistence testkit with failure injection) is where the D1 mid-batch failure test belongs once it exists.Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (
v0.13.0) and re-verified before filing: reproduced by execution. AD1JournaloverFakeD1Clientwrapped to drop the connection on the third eventINSERT, driven by a realPersistentActor.persistAllof five events on a liveActorSystem:The prefix is durable, the callback never fires, the live actor's state disagrees with its own journal, and the next start adopts the half-batch as history. Note the correction this run forced:
_state/_seqdo not advance before the throw — they are left behind, which is the opposite of what the review note asserted and is what makes the restart the moment the divergence becomes real.Part of the production-readiness review batch — tracked in #913.