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?

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