Skip to content

Make scheduleEvery() idempotent#1052

Merged
threepointone merged 2 commits intomainfrom
opencode/issue1049-20260302221732
Mar 2, 2026
Merged

Make scheduleEvery() idempotent#1052
threepointone merged 2 commits intomainfrom
opencode/issue1049-20260302221732

Conversation

@ask-bonk
Copy link
Contributor

@ask-bonk ask-bonk bot commented Mar 2, 2026

All done. Here's a summary of the changes:

Summary

Problem

scheduleEvery() generated a new nanoid(9) on every call, so INSERT OR REPLACE never actually replaced — it always inserted a new row. Calling scheduleEvery(30, "tick") in onStart() (which runs on every DO wake) created N duplicate interval schedules after N wakes.

Solution

packages/agents/src/index.ts — Modified scheduleEvery() to be idempotent by callback name:

  • Before inserting, queries for an existing interval schedule with the same callback
  • If found with identical intervalSeconds and payload: returns the existing schedule (no-op)
  • If found with different interval or payload: updates the existing row in place, reuses the same ID
  • If not found: creates a new schedule (original behavior)
  • Added internal _idempotent: false option so keepAlive() can opt out (it needs independent schedules for each concurrent caller)

packages/agents/src/tests/agents/schedule.ts — Added test agent methods:

  • createIntervalScheduleWithPayload(), createSecondIntervalSchedule(), countIntervalSchedules(), countIntervalSchedulesForCallback()

packages/agents/src/tests/schedule.test.ts — Added 6 idempotency tests:

  1. Same callback + interval returns same schedule ID
  2. Same callback + interval + payload returns same schedule ID
  3. Changed interval updates in place (same ID, new interval)
  4. Changed payload updates in place (same ID, new payload)
  5. Different callbacks get independent schedules
  6. 5 repeated calls (simulating onStart) produce exactly 1 schedule

docs/scheduling.md — Documented idempotency behavior in both the Interval section and API reference.

.changeset/idempotent-schedule-every.md — Patch changeset for the agents package.

Closes #1049

github run

scheduleEvery() now deduplicates by callback name: calling it multiple
times with the same callback returns the existing schedule instead of
creating a duplicate. If the interval or payload changed, the existing
schedule is updated in place.

This fixes the common pattern of calling scheduleEvery() inside
onStart(), which runs on every Durable Object wake. Previously each
wake created a new interval schedule, leading to duplicate executions.

keepAlive() opts out of idempotency via an internal _idempotent: false
flag so multiple concurrent keepAlive calls still get independent
schedules with independent disposers.

Closes #1049
@changeset-bot
Copy link

changeset-bot bot commented Mar 2, 2026

🦋 Changeset detected

Latest commit: 7b1b0c8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agents Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 2, 2026

Open in StackBlitz

npm i https://pkg.pr.new/agents@1052
npm i https://pkg.pr.new/@cloudflare/ai-chat@1052
npm i https://pkg.pr.new/@cloudflare/codemode@1052
npm i https://pkg.pr.new/hono-agents@1052

commit: 8721188

@threepointone
Copy link
Contributor

/bonk a different interval and/or payload should probably create a new row

Instead of updating the existing row when interval/payload differs,
treat a different interval or payload as a distinct schedule that
gets its own row. The idempotency key is now the full tuple of
(callback, intervalSeconds, payload) rather than just callback.

Uses SQL IS instead of = for payload comparison to correctly handle
NULL payload values (NULL IS NULL is true in SQLite).
@ask-bonk
Copy link
Contributor Author

ask-bonk bot commented Mar 2, 2026

Here's a summary of the changes made:

Changes

The idempotency key for scheduleEvery() has been changed from just callback to the full tuple of (callback, intervalSeconds, payload). This means:

Scenario Before (old) After (new)
Same callback + same interval + same payload No-op (return existing) No-op (return existing)
Same callback + different interval Update existing row in place Create new row
Same callback + different payload Update existing row in place Create new row
Different callback Create new row Create new row

Files changed

  • packages/agents/src/index.ts — Changed the SQL WHERE clause to match on callback, intervalSeconds, and payload (using IS instead of = to handle NULL payloads correctly). Removed the UPDATE branch entirely since it's no longer needed.

  • packages/agents/src/tests/schedule.test.ts — Updated the two "update in place" tests to assert the new behavior: different interval/payload now creates a new row with a different ID, and both schedules coexist independently.

  • docs/scheduling.md — Updated idempotency documentation in both the Interval section and the API reference to describe the new (callback, interval, payload) key.

  • .changeset/idempotent-schedule-every.md — Updated changeset description.

All 675 tests pass.

github run

@threepointone threepointone merged commit f1e2bfa into main Mar 2, 2026
@threepointone threepointone deleted the opencode/issue1049-20260302221732 branch March 2, 2026 22:47
@github-actions github-actions bot mentioned this pull request Mar 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

scheduleEvery() creates duplicate intervals on every call

1 participant