Skip to content

baselib/actor+db: leaseless single-worker consume, transactional outbox - #727

Merged
Roasbeef merged 8 commits into
mainfrom
durable-actor-extensions
Jun 17, 2026
Merged

baselib/actor+db: leaseless single-worker consume, transactional outbox#727
Roasbeef merged 8 commits into
mainfrom
durable-actor-extensions

Conversation

@Roasbeef

@Roasbeef Roasbeef commented Jun 10, 2026

Copy link
Copy Markdown
Member

In this PR, we extend the durable actor runtime with two framework-layer
optimizations that came straight out of profiling the OOR payment path: a
leaseless consume path for single-worker actors, and a transactional fold for
outbox delivery.

The leaseless path attacks transaction #1 on every consume. The classic path
opens a write transaction just to lease the next mailbox message
(LeaseNextMessage is an UPDATE under BEGIN IMMEDIATE), then a second one to
commit the turn's effects. For an actor with numWorkers == 1 there is no
competing consumer, so the lease buys nothing: we add a read-only
PeekNextMailboxMessage plus by-id ack/nack variants and gate them strictly on
the single-worker case via a mailbox flag. Routing is keyed on the empty lease
token, so the multi-worker path is byte-for-byte unchanged. At its point in the
optimization chain this was worth ~1.6x: the peek is a pure read, so idle polls
go from one write transaction to zero and stop grabbing the single sqlite writer
under BEGIN IMMEDIATE, which is exactly what was serializing all the concurrent
session pollers. The head-to-head lives in lightninglabs/darepo#538's Experiment
6.

Because the peek takes no lease, it also doesn't pre-bump attempts; that bump
moves to nack-by-id, so a message that fails by returning an error still climbs
toward dead-lettering just like the leased path. The one case it doesn't cover
is a message that crashes the worker process on every attempt: it skips the
nack each time and so re-peeks forever instead of dead-lettering. That's
deliberate, not a gap to "fix" by bumping attempts at claim, which would put
every poll (including the empty ones that dominate under contention) back under
BEGIN IMMEDIATE and hand the 1.6x right back. The single-worker actors on this
path carry their own higher-level retry/backoff and treat the dead-letter table
as a manual sink, so retrying a process-poisoning message beats silently
dropping it. A wedged-DB nack now also backs off on the poll ticker instead of
tight-spinning re-peeks of the same row.

The outbox fold introduces TxAwareDeliveryStore and folds outbox delivery
(enqueue into the destination mailbox + mark-complete) into one write
transaction. Before the fold those were two commits ordered for safety, which
left a crash window where an enqueued message could be redelivered because its
completion never landed; the fold closes that window structurally. Folding the
enqueue inside the publisher's tx means the row isn't visible to the target
until commit, so the in-process wake from Send races ahead of visibility and
finds nothing. We restore the snappy same-process handoff with a post-commit
wake: once a folded enqueue commits, we rouse the local mailbox receive loops to
re-poll. It's a coarse broadcast (every local mailbox re-polls, a non-target
does one empty poll) with the poll ticker as the correctness fallback, so a
missed wake only ever costs a poll interval, never a message. The same TxAware
mechanism is what the serverconn ingress fold builds on.

Rounding things out: drip-box and concurrent-mailbox throughput benchmarks for
the delivery store, and a systest driver fix so the incoming outbox driver
handles ScheduleRetryRequest.

This is part of the OOR optimization train; see each commit message for a
detailed description w.r.t the incremental changes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a leaseless single-worker fast path for durable actors, optimizing performance by using read-only peeks instead of write-transaction leases when there is no competing consumer. It also folds target enqueues and outbox completions into a single transaction when supported. Feedback on these changes highlights two critical issues: first, transaction execution failures in the outbox publisher are silently ignored and should be logged; second, passing raw delivery attempts to the retry policy on the leaseless path causes an off-by-one discrepancy, which should be resolved by calculating effective attempts.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +307 to +311
if err := txStore.ExecTx(
deliverCtx, false, deliver,
); err != nil {
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the transaction execution or commit fails (for example, due to a database conflict, lock, or connection loss), the error returned by txStore.ExecTx is silently ignored and not logged. While errors inside the deliver function are logged, commit-level failures occur after deliver returns successfully and will not be captured there.

We should log this error to ensure that transaction failures are visible and diagnosable.

        if err := txStore.ExecTx(
            deliverCtx, false, deliver,
        ); err != nil {
            logger(p.ctx).WarnS(p.ctx,
                "Failed to execute outbox delivery transaction", err,
                "message_id", msg.ID,
                "target", msg.TargetActorID,
            )
            return
        }

Comment thread baselib/actor/durable_actor.go Outdated
@@ -1112,8 +1135,11 @@ func (a *DurableActor[M, R]) handleResultInTx(
retry, delay := a.tellRetryPolicy(err, delivery.Attempts)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

On the leaseless (peeked) path, delivery.Attempts is not pre-incremented at claim time (unlike the leased path where it is incremented during LeaseNextMailboxMessage). As a result, delivery.Attempts is 0 on the first failure of a leaseless message, whereas it is 1 on the leased path.

Passing delivery.Attempts directly to tellRetryPolicy causes an off-by-one discrepancy in retry policy evaluation (e.g., incorrect backoff delays or allowing an extra retry attempt). We should calculate and pass the effectiveAttempts (adding 1 if the delivery is leaseless) to match the leased path's behavior.

        effectiveAttempts := delivery.Attempts
        if delivery.leaseless {
            effectiveAttempts++
        }
        retry, delay := a.tellRetryPolicy(err, effectiveAttempts)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 878de84382

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1140 to +1142
_, nackErr := nackMessage(
ctx, store, delivery.ID, delivery.LeaseToken,
delay,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Count the in-flight leaseless attempt before retrying

In the single-worker TxBehavior path, delivery.Attempts is the pre-peek value, but this retry branch still passes it unchanged to TellRetryPolicy and then the new by-ID nack increments the stored attempts. With a common config such as MaxAttempts=5 and DefaultTellRetryPolicy, the fifth failed delivery is seen as attempt 4, so this nack raises the row to attempts == max_attempts; PeekNextMailboxMessage filters on m.attempts < m.max_attempts, so the message is never delivered again for the policy to return false or for dead-lettering to run. Use the effective attempt count for leaseless deliveries before deciding to retry, or dead-letter before nacking to the max.

Useful? React with 👍 / 👎.

@Roasbeef
Roasbeef force-pushed the durable-actor-extensions branch from 878de84 to 87c3e04 Compare June 10, 2026 17:34
@Roasbeef
Roasbeef force-pushed the db-storage-knobs branch 2 times, most recently from ce06573 to 27a1bf4 Compare June 15, 2026 04:16
@Roasbeef
Roasbeef force-pushed the durable-actor-extensions branch 2 times, most recently from 5a88bd8 to dcf9293 Compare June 15, 2026 04:20
@Roasbeef
Roasbeef force-pushed the durable-actor-extensions branch 2 times, most recently from 9e6f144 to 6be5a1d Compare June 15, 2026 05:30

Copy link
Copy Markdown
Member Author

Finished the adversarial follow-up pass and pushed an autosquashed update (6be5a1df). Main fixes:

  • Leaseless peek now normalizes every peeked row to an empty lease token/zero lease deadline at the store adapter boundary, even if the DB row still has stale expired lease metadata from an older leased claim.
  • NackMailboxMessageByID now clears stale lease_token/lease_until while incrementing attempts, keeping the persisted row aligned with the leaseless state machine.
  • Added Delivery.EffectiveAttempts() and routed Tell retry/dead-letter policy decisions through it on the normal, tx-aware, and fallback result paths. This counts the in-flight leaseless attempt before a nack can bump the row to max_attempts.
  • Added commit-level logging around tx-aware outbox delivery, so begin/commit/rollback-level ExecTx failures are visible even when the inner enqueue + complete operations returned nil.
  • Updated the p-model docs for the two key invariants: the leaseless consume ownership model and the outbox fold model.

Validation run after autosquash:

  • make unit pkg=./baselib/actor
  • make unit pkg=./db/actordelivery
  • make unit pkg=./internal/actortest
  • make lint-changed-local
  • make commitmsg-lint range="1047332e521d4798a8f168e9609ad3b1e43b9653..HEAD"
  • git diff --check 1047332e521d4798a8f168e9609ad3b1e43b9653..HEAD

Second adversarial pass focused on stale lease metadata, in-flight attempt accounting, tx boundary observability, and the distributed ownership assumption behind SingleWorkerLeaseless. I don't see remaining blockers under the documented invariant that leaseless mailboxes have exactly one live runtime owner, or an external singleton/ownership fence if more than one daemon/process could otherwise drain the same mailbox.

@Roasbeef
Roasbeef force-pushed the durable-actor-extensions branch from 6be5a1d to 8e93cfb Compare June 15, 2026 05:38

Roasbeef commented Jun 15, 2026

Copy link
Copy Markdown
Member Author

Follow-up correction: I had updated the package invariant docs, but missed the dedicated p-models/ tree on the first pass. That is now fixed and pushed in the autosquashed branch (8e93cfba).

P-model additions:

  • Added explicit leaseless mailbox operations to p-models/durableactor/src/mailbox_fifo.p: PeekNext, AckByID, and NackByID.
  • Added a green P scenario, TestDurableMailboxSpec_LeaselessPeekMasksStaleLease, covering the review bug shape: a row is leased once, the lease expires without maintenance clearing metadata, then the leaseless path peeks it as an empty-token delivery and nacks it by ID.
  • Added a bridge trace, mailbox_leaseless_peek_stale_lease.json, replayed against the real db/actordelivery store.
  • Extended the Go bridge to replay peek, ack_by_id, and nack_by_id, and to assert attempts/token values on leased and peeked deliveries.
  • Updated the p-model READMEs to document the leaseless empty-token/by-ID retry contract.

Validation run after autosquash:

  • ./p-models/scripts/check.sh
  • go test ./p-models/durableactor/bridge
  • make unit pkg=./baselib/actor
  • make unit pkg=./db/actordelivery
  • make unit pkg=./internal/actortest
  • make lint-changed-local
  • make commitmsg-lint range="1047332e521d4798a8f168e9609ad3b1e43b9653..HEAD"
  • git diff --check 1047332e521d4798a8f168e9609ad3b1e43b9653..HEAD

The full p-model script now compiles the P project, checks all green cases with 0 bugs, confirms the intentional negative counterexamples still find bugs, and runs the Go bridge conformance replay successfully.

@levmi levmi added the P1 Priority 1 — high label Jun 15, 2026
@Roasbeef
Roasbeef force-pushed the db-storage-knobs branch 2 times, most recently from d48f908 to bcad772 Compare June 16, 2026 22:54
@Roasbeef
Roasbeef changed the base branch from db-storage-knobs to main June 16, 2026 22:58
@Roasbeef
Roasbeef force-pushed the durable-actor-extensions branch 2 times, most recently from 1e1ebf7 to e0b941e Compare June 16, 2026 23:20
Roasbeef added 7 commits June 16, 2026 19:07
In this commit, we add Go benchmarks for the durable mailbox and FSM
checkpoint write paths -- the "drip box" the OOR per-session refactor
reshapes. The repo had no benchmarks before, so these establish the
before-and-after baseline for the write path the refactor targets.

BenchmarkDeliveryCheckpointWrite measures one checkpoint write as the
state blob grows. This isolates the cost the old global OOR actor paid
on every mutation: it serialized all in-flight sessions into one blob,
so the per-mutation write grew with the session count and every mutation
funneled through one actor. On SQLite with synchronous=full the write is
fsync-bound at a few milliseconds, so the real lever is not the blob
size but how many of these run in series -- which is exactly what
per-session sharding parallelizes.

BenchmarkDeliveryMailboxRoundTrip measures one message's full durable
trip through a single mailbox (enqueue, lease, ack). A single global
actor funnels every session's messages through one such serial trip;
the per-session refactor spreads them across independent mailboxes, so
this is the per-message floor each shard pays in parallel rather than in
series.

Run with: go test ./db/actordelivery/ -run=^$ -bench=BenchmarkDelivery
-benchmem.
In this commit, we add BenchmarkDeliveryConcurrentActors, which runs the
full enqueue/lease/ack mailbox trip across a growing number of
independent mailboxes in parallel. It completes the drip-box suite: the
checkpoint-write and round-trip benchmarks measure one mailbox's cost,
and this one measures what happens when many run at once.

This is the per-session sharding shape. The old global OOR actor
funneled every session through one mailbox, so unrelated sessions queued
behind each other; the per-session refactor gives each session its own
mailbox. The SQLite writer is still a shared floor, so the benchmark
shows write contention rather than linear scaling, which is the honest
picture: the win is that no session blocks behind another's queue, and
each per-session write is a small fixed-size row, not a whole-map blob.
Add PeekNextMailboxMessage (a read-only claim mirroring
LeaseNextMailboxMessage's eligibility and ordering but taking no lease
and not bumping attempts), AckMailboxMessageByID (unfenced delete), and
NackMailboxMessageByID (unfenced release that increments attempts).
These back the single-worker leaseless consume path. Regenerated via
make sqlc.
On the Read/Commit path a NumWorkers==1 durable actor did two write
transactions per consumed message: a LeaseNextMessage write to fence
the ack against a competing worker, then the behavior Commit. A single
worker has no competitor, so the lease write is pure overhead -- and
because the DSN uses _txlock=immediate, every lease (even an empty
poll) does BEGIN IMMEDIATE and grabs the global SQLite writer lock,
serializing all concurrent pollers.

Replace the lease with a read-only PeekNextMailboxMessage for
single-worker Read/Commit actors: empty polls take a WAL read lock
(concurrent), and the message is acked by id inside the existing
Commit transaction. A crash before commit leaves the row untouched, so
it is re-peeked on restart -- at-least-once preserved, identical to
lease expiry. Attempts move to the by-id nack so dead-lettering is
unchanged.

The multi-worker egress pool (NumWorkers>1) and the classic path are
byte-for-byte unchanged: they keep LeaseNextMessage and the fenced ack.
The incoming metadata path arms a give-up/backoff retry timer
(ScheduleRetryRequest) alongside its query, added in fab1754. The
synchronous driveIncomingOutbox test driver was never taught to handle
that event type, so it tripped its default case and failed three OOR
materialization systests. The driver resolves metadata immediately, so
the timer never needs to fire; ignore it. Pre-existing on the branch,
independent of the leaseless-consume change.
In this commit, we collapse the outbox publisher's per-message handoff
from two write transactions down to one. Previously each delivery paid
three commits: the batch claim, the target mailbox enqueue inside Tell,
and the CompleteOutbox status flip. The enqueue and the completion
always touch the same actor-delivery database, so there is no reason
for them to commit separately.

When the configured store supports transactions (the production wiring
always does), deliverMessage now wraps the Tell and the CompleteOutbox
in one ExecTx. The Tell joins the ambient transaction via the context,
using the same mechanism session actors already rely on for atomic
enqueue, and the completion runs on the transaction-scoped store. On
any failure the whole transaction rolls back, leaving the claimed row
to be redelivered after claim expiry. This also closes the window
where a crash between enqueue and complete forced a redelivery that
only receiver-side dedup could absorb.

Stores without transaction support keep the existing two-step path.

The actortest harness now builds the transaction-aware store to match
production wiring, so every publisher end-to-end test exercises the
folded path. A new test forces a CompleteOutbox failure after a
successful Tell and asserts the rollback erases the target enqueue,
then verifies exactly-once redelivery after the claim expires.
Add a P scenario and a Go bridge test that pin down the CDC outbox
publisher's transactional fold (deliverMessage's ExecTx): the target
mailbox enqueue and the outbox completion commit as one transaction.

The P model adds an OutboxFoldSpec machine with AtomicFold (production)
and SplitWrite (counterexample) profiles, plus two monitors:
OutboxCompletionImpliesDelivery (no lost messages -- never complete
without a durable enqueue) and OutboxTargetDeliveredAtMostOnce
(exactly-once delivery via the idempotent enqueue). tcOutboxFold is
green; tcOutboxSplitWriteCounterexample finds the lost-message bug.

The Go bridge drives the real SQLite actordelivery store: a fold that
fails after the enqueue rolls back with no orphan, the outbox row stays
pending until the claim expires, redelivery lands exactly once, and a
concurrent stale-token reclaim is fenced while the idempotent enqueue
collapses the duplicate. check.sh runs both new cases.
Add bridge tests that model a process crash by reopening a fresh
*sql.DB and store against the same on-disk SQLite file. A peeked but
unacked message survives the restart with its attempt count unchanged
(peek is read-only), and a leased but unacked message survives with its
attempt bump durable and becomes re-leasable after ExpireLeases.

Document the direct (non-trace) bridge tests in the bridge guide.
@Roasbeef
Roasbeef force-pushed the durable-actor-extensions branch from e0b941e to 691f484 Compare June 17, 2026 02:09
@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jun 17, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 2m 46s —— View job


Review: leaseless single-worker consume + transactional outbox fold

  • Read core changes (durable_actor, durable_mailbox, delivery, outbox_publisher)
  • Read store layer (db/actordelivery)
  • Verify previously-flagged issues are resolved (attempt accounting, tx logging)
  • Assess correctness of leaseless ownership model
  • Post review

Overall this is a careful, well-documented change. The two earlier bot findings (gemini/codex) are both resolved correctly, and the invariants are now mirrored across the package docs and the p-models. Notes below.

Confirmed fixes from prior review

  • Off-by-one in leaseless retry accounting — resolved via Delivery.EffectiveAttempts() (delivery.go:167). The +1 for the in-flight peeked attempt is now threaded through ShouldDeadLetter, finishNonTx (durable_actor.go:831), handleResultInTx (:1122/1134), handleResult (:1240/1252), and handlePoisonMessage (durable_mailbox.go:601-607). I traced MaxAttempts=5 + DefaultTellRetryPolicy on the leaseless path: the message dead-letters at the 5th failure (peeked at attempts==4, dead-lettered before the by-ID nack would bump the row to 5 and make it peek-ineligible). Boundary now matches the leased path exactly. ✅
  • Silent tx failure in the outbox fold — resolved at outbox_publisher.go:309-318; begin/commit/rollback failures now WarnS with message_id/target/attempts/claim_token, which the inner deliver logs could not capture. ✅

Design soundness

  • The leaseless gate is correctly conservative: SingleWorkerLeaseless = numWorkers == 1 && cfg.Behavior.IsRight() (durable_actor.go:455), and all ack/nack routing keys off the empty lease token (ackMessage/nackMessage in delivery.go:157-179), so the multi-worker and classic paths are byte-for-byte unchanged.
  • Stale-lease masking is handled at the adapter boundary (leaselessMessageFromRow, store_impl.go:311-317) and the by-ID nack clears stale lease_token/lease_until while bumping attempts (mailbox.sql:158-164). Good — this keeps the persisted row aligned with the empty-token state machine.
  • The outbox fold (ExecTx wrapping Tell-join + CompleteOutbox) structurally closes the enqueue-without-complete redelivery window, and the coarse post-commit notifyMailboxWake (store_impl.go:1683-1693) restores same-process delivery latency that the fold would otherwise regress. The opaque-handle wake registry (:679-698) correctly avoids the restart-clobber race a mailbox-ID-keyed map would have.

Observations (non-blocking)

  1. Re-peek backoff is asymmetric: it covers failed nacks but not failed acks. Delivery.MutationFailed() is set only on the Nack write-failure paths (delivery.go:284/290/310), and Receive backs off a poll interval when it is set (durable_mailbox.go:568-582). A failed ack on the leaseless path leaves the row present and immediately re-peekable with no backoff. The realistic trigger is the dedup-skip branch (durable_actor.go:640-650): if a message is MarkProcessed separately (the finishNonTx success path) but its delete fails, the row stays present-and-processed, and a re-peek hits the dedup ack again — which can return without setting mutationFailed, tight-spinning against a wedged writer. On the leased path the held lease provides implicit backoff here; the leaseless path lost that for the ack case. Narrow (requires a write-failing-but-read-OK DB and a non-atomic mark/ack), so low severity, but the symmetry is worth closing — e.g. set mutationFailed on a zero/errored leaseless ack too. Fix this →

  2. Indefinite redelivery of a process-poisoning message (worker crashes before any nack, so attempts never climbs) is a deliberate, well-documented tradeoff (durable_mailbox.go:110-123). Flagging only for visibility — the reasoning (don't turn empty polls back into writer-lock-grabbing write txs) is sound, and the doc explicitly steers future readers away from "fixing" it by bumping attempts at claim.

  3. Ownership fence is a documented operational invariant, not a structural one. SingleWorkerLeaseless drops the lease fence and relies on "exactly one live runtime owner for this mailbox." This is correctly called out in baselib/actor/CLAUDE.md and db/actordelivery/CLAUDE.md, and the gate makes accidental enablement hard. The only residual risk is two daemons/processes draining the same mailbox without an external singleton — which the docs name explicitly. No action needed; noting it as the key assumption a future adopter must honor.

Tests / modeling

Coverage looks strong: the p-model adds PeekNext/AckByID/NackByID ops plus the LeaselessPeekMasksStaleLease green scenario and a bridge trace replayed against the real store, and the Go side adds drip-box/concurrent benches, crash-restart and outbox-fold bridge tests. The stale-lease and in-flight-attempt edges that produced the prior bugs are now both modeled and bridge-tested, which is the right place to pin them.

I couldn't run make build/tests in this environment (sandbox approval unavailable), so the above is from static review only; the author's posted validation runs (make unit for the three packages, p-models/scripts/check.sh, bridge tests, lint, commitmsg-lint) cover the dynamic side.
· durable-actor-extensions

@Roasbeef
Roasbeef merged commit 8d2870f into main Jun 17, 2026
33 of 34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P1 Priority 1 — high

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants