Skip to content
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
# Changelog

## Unreleased

- Add reminder reading. `reminder()` returns one armed alarm as a
`ScheduledReminder`, and `reminders()` lists every key of one operation. Both
apply the intents staged so far in the turn, so a read agrees with what the
commit will write. Reading works on the SQL backends and on Durable Objects.
- Leave a one-shot reminder that already fired out of `reminder()` and
`reminders()`. Its row stays as `completed`, so a next-run lookup reported an
old time rather than nothing, and an existence check refused to re-arm an
alarm that could never fire again.
- Refuse an unknown operation in `unschedule()` and `unscheduleAll()`.
`schedule()` already threw `UnknownOperation` for one, so a typo cancelled
nothing quietly and left a recurring reminder running.
- Add reminder cancellation. `unschedule()` removes one alarm by operation, by
operation and key, or by the handle `schedule()` now returns.
`unscheduleAll()` removes every key of one operation. Both stage an intent
beside the schedules, so they apply in the order the turn called them, commit
with the state change that decided them, and cancel nothing when a turn
throws. Cancellation works on the SQL backends and on Durable Objects. A
cancellation that lands on an occurrence the scheduler claimed but has not yet
enqueued pre-empts it, and the scheduler continues rather than failing.
- `schedule()` now returns a `ReminderHandle` (`{ name: string }`) instead of
`void`. A handle is a plain object, so it survives in actor state and still
cancels after a deactivation. Code that assigned the result to `void` needs
updating, as `emit` required in 0.15.0.

## 0.15.2 - 2026-09-21

- Stop the deadlock between concurrent callers that create the same actor from
Expand Down
143 changes: 139 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ authorization, capability boundaries, and release validation.
- `Actor`: base class providing `ref()`, `actorId`, `currentMessage`,
`observables()`, `reject()`, `emit()`, `transmit()`, `commitAction()`,
`schedule()`, `sendTo()`, and protected lifecycle hooks.
`ScheduledOperationsFor` types the map `schedule()` returns, whose calls return a
`ReminderHandle`. `TransmittedOperationsFor` types the map `transmit()`
returns, whose calls return nothing.
- `broadcastValue(value)`: mark an observable so its changed value enters the
durable invalidation envelope.
- `broadcastInvalidation(value)`: compare the real observable value but put
Expand All @@ -67,10 +70,16 @@ createdAtMs }` shape returned by `SolidObjectsRuntime.snapshotWithIncarnation`.
`DestroyOptions`: the options for authorization, idempotency, time, and
schedule that the reference methods use.

`ScheduledReminder` is one armed reminder as an actor reads it, and
`ReminderReader` is how a runtime supplies them.

`ActorIntents`, `EffectIntent`, `CommitActionIntent`, `ReminderIntent`,
`UnscheduleIntent`, `UnscheduleAllIntent`, `ReminderMutation`,
`OutboundMessageIntent`, `ReminderOptions`, `OutboundMessageOptions`,
`PayloadBroadcasts`, and `PayloadBroadcastValue` describe actor-declared
transactional work and typed personalized projections.
transactional work and typed personalized projections. `ReminderMutation` is the
union of one scheduled reminder and the two cancellations, held in one list so
they apply in the order the turn called them.

`EffectFailurePayload<Arguments>`, `EffectSuccessPayload<Arguments, Result>`,
and `SerializedError` describe effect callback messages. They are also exported
Expand Down Expand Up @@ -162,6 +171,130 @@ row per item. It also cannot strand an entry when the runtime coalesces an
occurrence. Prefer it for a large queue of interchangeable items. Prefer `key`
when one item needs an alarm that you can move on its own.

#### Cancelling a reminder

`schedule()` returns a `ReminderHandle` (`{ name: string }`) naming the alarm it
armed. `unschedule()` cancels one alarm, by operation, by operation and key, or
by that handle. `unscheduleAll()` cancels every key of one operation.
Comment thread
greptile-apps[bot] marked this conversation as resolved.

```typescript
const MONTH = 30 * 24 * 60 * 60 * 1000

class Subscription extends Actor {
static override readonly actorType = "subscriptions"

status = "trialing"
renewal: ReminderHandle | null = null

startTrial(): void {
this.schedule({ at: new Date(Date.now() + (14 * MONTH) / 30) }).trialExpired()
}

convertToPaid(): void {
this.status = "active"
this.unschedule("trialExpired")
this.renewal = this.schedule({
at: new Date(Date.now() + MONTH),
everyMilliseconds: MONTH,
}).chargeRenewal()
}

cancelled(): void {
this.status = "cancelled"
if (this.renewal) this.unschedule(this.renewal)
}

trialExpired(): void {
this.status = "expired"
}

chargeRenewal(): void {}
}
```

`this.unschedule(this.renewal)` and `this.unschedule("chargeRenewal")` cancel the
same alarm. Prefer the handle when the actor already stored one, because it
cannot drift from the name that armed the reminder.

A keyed alarm cancels by the key that armed it, and `unscheduleAll()` cancels
every key of one operation:

```typescript
class Shipment extends Actor {
static override readonly actorType = "shipments"

dispatch({ carrierIds }: { carrierIds: string[] }): void {
for (const carrierId of carrierIds) {
this.schedule({ at: new Date(Date.now() + MONTH / 30), key: carrierId }).chaseCarrier({
carrierId,
})
}
}

shipped({ carrierId }: { carrierId: string }): void {
this.unschedule("chaseCarrier", { key: carrierId })
}

stopChasing(): void {
this.unscheduleAll("chaseCarrier")
}

chaseCarrier(_options: { carrierId: string }): void {}
}
```

`unschedule()` and `unscheduleAll()` refuse an operation the actor does not
declare, with the `UnknownOperation` that `schedule()` already throws, so a typo
fails the turn rather than cancelling nothing. A handle skips that check, because
the `schedule()` call that produced it was already checked.

#### Reading the schedule

`reminder()` returns the armed alarm as a `ScheduledReminder`, or `undefined`.
`reminders()` returns every key of one operation. Both are async, because an
actor reads its own rows rather than holding them in memory:

```typescript
async nextChargeAt(): Promise<number | null> {
return (await this.reminder("chargeRenewal"))?.runAtMilliseconds ?? null
}

async pendingCarriers(): Promise<(string | null)[]> {
return (await this.reminders("chaseCarrier")).map((reminder) => reminder.key)
}
```

A read applies the intents staged so far in the turn, so an actor that schedules
and then reads sees what the commit will write, and one that cancels and then
reads sees the alarm gone.

`key` and `intervalMilliseconds` are `null` rather than `undefined` when absent,
so a `ScheduledReminder` returns from an operation without a serialization error.

Reading is available during a turn and from a snapshot projection, so an
observable can report what is armed. `reminder()` and `reminders()` refuse an
operation the actor does not declare, as `schedule()` and `unschedule()` do.

A one-shot that already fired is not reported. Its row stays as `completed`, and
an alarm that cannot fire again is not armed.

`ScheduledReminder` carries no occurrence count. The SQL backends track one and
Durable Objects does not, so it is left out rather than reported for one backend
only.

A cancellation is staged like a schedule, so it commits with the state change
that decided it and a turn that throws cancels nothing. Both apply in the order
the turn called them, so cancelling and then scheduling the same name leaves it
armed at the new time.

Cancelling an alarm that does not exist is not an error. `unschedule()` returns
nothing, because it stages an intent rather than applying one, and an answer
given at call time could be stale by the time the turn commits.

A handle is a plain object, so it survives in actor state and still cancels
after a deactivation. Passing a handle together with `key` is a `TypeError`,
because the handle already names the key.

### Recovering abandoned effects

`emit` returns an `EffectHandle` (`{ id: string }`) on every successful call. Save
Expand Down Expand Up @@ -358,9 +491,11 @@ effect results and the handler's declared argument/result types in agreement.
### Typed operation references

`schedule` and `transmit` infer this actor's operation names and arguments, including
inside actor methods and for inherited application operations. The returned
`ScheduledOperationsFor<ActorType>` values return `void` and preserve required,
optional, and zero-argument operation signatures. No non-null assertion is needed:
inside actor methods and for inherited application operations. A scheduled operation
call returns a `ReminderHandle`, through `ScheduledOperationsFor<ActorType>`, and a
transmitted one returns `void`, through `TransmittedOperationsFor<ActorType>`. Both
preserve required, optional, and zero-argument operation signatures. No non-null
assertion is needed:

```typescript
class ChatRun extends Actor {
Expand Down
8 changes: 8 additions & 0 deletions docs/correctness.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
- Delivery is ordered per actor identity and at least once.
- Different identities may execute concurrently.
- Sequence allocation and durable enqueue are one transaction.
- An actor reads its own schedule. A read applies the intents staged so far in
the turn, so it agrees with what the commit will write rather than with what
the turn began with.
- A reminder can be cancelled. A cancellation commits with the state change that
decided it, and applies in the order the turn called it. A cancellation cannot
recall an occurrence the scheduler already turned into a message. It does
pre-empt one the scheduler claimed but has not yet enqueued, and the scheduler
treats that as ordinary work rather than a failure.
- Concurrent callers that create the same actor produce one instance row and
distinct sequences. The mailbox locks that row by its primary key, so MySQL
does not upgrade a shared lock and the enqueue does not deadlock.
Expand Down
Loading
Loading