Skip to content

c.destroy() does not destroy an actor with a db when state changed in the same action (transaction_closed on shutdown state save) #5850

Open

Description

@trillhause

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.25 in Docker on localhost:6420, with RIVET__AUTH__ADMIN_TOKEN and RIVET__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

  1. linear-code commented on Oct 7, 2026

    @linear-code
  2. Osraka commented on Oct 12, 2026

    @Osraka

    I reproduced this independently with Node 22.21 on macOS arm64 using the published rivetkit@2.3.25 package and its packaged local Engine (no Docker). In an isolated setupTest run, plain c.destroy() removes the actor and a same-key getOrCreate gets 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 logged transaction_closed.

    I also tried a local-only experiment that awaits actorCtx.saveState({ immediate: true }) in the native onDestroy callback before closing the database, both before and after resolveNativeDestroy. 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?

  3. octopols commented on Oct 19, 2026

    @octopols

    put up a fix in #5893

    root cause was ordering, onDestroy was closing the actors sqlite in its finally block and then core tried to save the pending state after that, so it hit the closed coordinator and stop ended as Stopped{code: Error}. clean destroy only worked cause there was nothing to write

    fix just removes that close from onDestroy, core already closes sqlite itself after the final save + alarm sync. same idea as #5860 does for sleep

    @Osraka re ordering, with this the save runs before db closes so onDestroy dont need to save state itself. i think thats also why awaiting saveState there didnt help you since db still closed right after

    tested locally on native:

    • your repro fails on main, passes with the fix
    • added tests for clean + dirty state destroy, local and remote sqlite, all encodings. dirty one fails without fix, all pass with it
    • actor-destroy, lifecycle-hooks, actor-db suites pass, no more transaction_closed in logs

    havent tried wasm yet

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions