cloudflare / cloudflare/actors
[Bug] Alarms execute before @Persist properties are hydrated on cold start
- Dominant language
- TypeScript
- Stars
- 422
- Forks
- 28
- PR merge metrics
- No merged PRs in 30d
Description
**Describe the bug**
When an Actor wakes up from hibernation/eviction (triggered by an alarm scheduled far enough in the future), the alarm callback executes before the `@Persist` properties have been hydrated from storage.
If the alarm callback relies on any `@Persist` property, it reads `null/undefined`, leading to silent failures on tasks like longer interval cron jobs.
While debugging this, I noticed it might be related to the initialization order of `blockConcurrencyWhile` locks on cold start. It seems like `this.alarms = new Alarms(...)` runs and schedules the alarms before the Actor constructor queues the lock to `_initializePersistedProperties()`.
**To Reproduce**
Steps to reproduce the behavior:
1. Create a basic Hono worker and Actor that schedules a task far enough in the future to force a Durable Object eviction (for example, 2 min):
- Commands used:
```sh
pnpm create hono@latest my-actor --template cloudflare-workers
cd my-actor
pnpm add @cloudflare/actors
```
- Add this to `wrangler.jsonc`:
```jsonc
{
"migrations": [
{
"new_sqlite_classes": ["MyActor"],
"tag": "v1"
}
],
"durable_objects": {
"bindings": [
{
"class_name": "MyActor",
"name": "MyActor"
}
]
}
}
```
```typescript
import { Actor, Persist } from "@cloudflare/actors";
import { Hono } from "hono";
import { logger } from "hono/logger";
export class MyActor extends Actor {
@Persist
myConfig: string | null = null;
async scheduleTask() {
this.myConfig = "hydrated_value";
// Schedule an alarm 2 minutes in the future (long enough to force DO eviction)
await this.alarms.schedule(120, "myAlarmCallback");
console.log("Scheduled!");
}
async myAlarmCallback() {
// BUG: When the DO wakes up, this logs "null" instead of "hydrated_value"
console.log("Alarm fired. Config is:", this.myConfig);
}
}
const app = new Hono<{ Bindings: CloudflareBindings }>();
app.use(logger());
app.get("/", async (c) => {
const exampleActor = MyActor.get("example");
const date = new Date();
console.log(`Current time: ${date.toLocaleTimeString()}`);
await exampleActor.scheduleTask();
date.setMinutes(date.getMinutes() + 2);
console.log(`Check back at: ${date.toLocaleTimeString()}`);
return c.text(`Schedule started`);
});
export default app;
```
2. Trigger the / route using wrangler dev.
3. Wait 2 minutes for the alarm to trigger (ensure the DO goes idle and gets evicted).
4. Check logs: The logs will print Alarm fired. Config is: `null` instead of `hydrated_value`.
**Expected behavior**
When the alarm triggers a callback on a sleeping Actor, the `@Persist` properties should be fully hydrated from the SQLite table before the user's callback function is invoked. In the example `hydrated_value` should have been printed.
**Screenshots**: N/A
**Information:**
- Actor Version: 0.0.1-beta.6
- Wrangler: 4.4.0 (I've tried deleting the `.wrangler` folder and updating wrangler to latest version(4.86.0) but faced the same issue)
- Node.js: v24.15.0
- Typescript: 6.0.3
**Additional context**
Currently, my only workaround is to manually query `this.sql` for the `_actor_persist` table inside the alarm handler to get the data I need.
Contributor guide
Research direction
Start with the Actor constructor, the `this.alarms = new Alarms(...)` initialization, and `_initializePersistedProperties()`, then reproduce the cold-start behavior with the provided Hono worker and two-minute alarm. Trace the initialization order when the Actor is evicted and awakened. Done means the alarm callback sees the hydrated `@Persist` value instead of `null` or `undefined`.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- sqlite, typescript
- Domain
- backend, databases
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100