Repository navigation
c.destroy() does not destroy an actor with a db when state changed in the same action (transaction_closed on shutdown state save) #5850
Description
Description
c.destroy() does not destroy an actor that has a db when c.state changed in the same action and has not been saved yet.
During the destroy, the actor's SQLite VFS closes in the grace phase. After that, the Destroying phase saves the pending state deltas (serialize_state_save) to the closed database. The write fails with transaction_closed, the stop ends as Stopped{code: Error}, and the actor stays alive with the same actor id. With the crash-as-sleep change, the next request wakes the same actor as generation 2.
The same actor with no state change before c.destroy() is destroyed correctly.
Environment
- rivetkit: 2.3.25 (also 2.3.15, 2.3.16, 2.3.17, 2.3.18, 2.3.21). Passes on 2.3.10 and 2.3.11.
- Engine:
rivetdev/engine:2.3.25in Docker onlocalhost:6420, withRIVET__AUTH__ADMIN_TOKENandRIVET__AUTH__INSECURE_ALLOW_UNAUTHENTICATED=true RIVET_RUN_SERVICES=0- Node 22.23.1, macOS arm64, Vitest 3
Reproduction
import { randomUUID } from "node:crypto";
import { actor, setup } from "rivetkit";
import { db } from "rivetkit/db";
import { setupTest } from "rivetkit/test";
import { expect, it } from "vitest";
const counter = actor({
createState: () => ({ count: 0 }),
db: db({ onMigrate: (d) => d.execute("CREATE TABLE IF NOT EXISTS t (v TEXT)") }),
actions: {
ping: () => true,
destroy: (c) => {
c.destroy();
},
bumpThenDestroy: (c) => {
c.state.count += 1;
c.destroy();
},
},
});
const registry = setup({ use: { counter }, envoy: { poolName: `repro-${randomUUID()}` } });
async function destroyedAfter(t: any, action: "destroy" | "bumpThenDestroy") {
const { client } = await setupTest(t, registry);
const key = [randomUUID()];
const handle = client.counter.getOrCreate(key);
await handle.ping();
const before = await handle.resolve();
await handle[action]();
await new Promise((r) => setTimeout(r, 1500));
const getFails = await client.counter.get(key).resolve().then(() => false, () => true);
const after = await client.counter.getOrCreate(key).resolve();
return { getFails, newActorId: after !== before };
}
it("destroy with clean state destroys the actor", async (t) => {
expect(await destroyedAfter(t, "destroy")).toEqual({ getFails: true, newActorId: true });
});
it("destroy right after a state change destroys the actor", async (t) => {
expect(await destroyedAfter(t, "bumpThenDestroy")).toEqual({ getFails: true, newActorId: true });
});Result on 2.3.25: the first test passes. The second test fails every run:
AssertionError: expected { getFails: false, newActorId: false } to deeply equal { getFails: true, newActorId: true }
Logs (debug, shortened)
actor lifecycle transition old=Started new=DestroyGrace
invoking napi TSF callback kind=onDestroy
sqlite vfs close summary generation=Some(1) ... commit_count=27
napi run handler exited cleanly
actor lifecycle transition old=DestroyGrace new=Destroying
actor event enqueued operation=shutdown_serialize_state event=serialize_state_save
applying actor state deltas delta_count=1 delta_bytes=0 state_revision=2 save_request_revision=3
sqlite operation failed backend=LocalNative operation=execute_batch sql=<batch> binding_count=2 statement_count=2 code=transaction_closed error_message="SQLite transaction coordinator is closed."
actor stop had both shutdown and join failures; only the shutdown error is returned shutdown_error="transaction_closed: SQLite transaction coordinator is closed." discarded_join_error="actor task failed"
actor stop failed after asynchronous completion handoff
EventActorStateUpdate{state: Stopped{code: Error, message: "transaction_closed: SQLite transaction coordinator is closed."}}
Bisect (same engine 2.3.25, only the rivetkit version changes)
| rivetkit | Result |
|---|---|
| 2.3.10, 2.3.11 | both pass |
| 2.3.12, 2.3.13 | both tests time out (perhaps not compatible with engine 2.3.25) |
| 2.3.14 | did not install from npm |
| 2.3.15 to 2.3.25 | state-change case fails |
Expected
The pending state save happens before the database closes (or it is skipped on destroy), and the actor is destroyed.
Impact
Any actor that sets state and then self-destroys in the same action is never destroyed. We found it in two actors: a tool approval actor that self-destroys from a scheduled action, and a loop actor that resets through c.destroy().
Possibly related: #5554, #5555 (transaction_closed after the SQLite coordinator closes).
Activity
linear-code commentedon Oct 7, 2026
ACT-6506
Osraka commentedon Oct 12, 2026
I reproduced this independently with Node 22.21 on macOS arm64 using the published
rivetkit@2.3.25package and its packaged local Engine (no Docker). In an isolatedsetupTestrun, plainc.destroy()removes the actor and a same-keygetOrCreategets a new ID;c.state.count += 1; c.destroy()leaves the old actor resolvable with the same ID. I ran the unmodified package twice and got the same split; the dirty-state path loggedtransaction_closed.I also tried a local-only experiment that awaits
actorCtx.saveState({ immediate: true })in the nativeonDestroycallback before closing the database, both before and afterresolveNativeDestroy. Neither placement is a safe fix: even the clean-state control then fails to destroy. I have not validated a patch against current main, so I am not opening a PR based on that experiment.The regression test should keep both cases and assert actor absence plus a new ID after same-key recreation. Is there a preferred ordering for final state serialization and database teardown here, especially with the separate sleep-shutdown work in #5860?