Skip to content

GH-4437: recurring schedule operability — occurrence attribution and a manual trigger - #4451

Merged
jeremydmiller merged 2 commits into
mainfrom
gh-4437-recurring-operability
Sep 15, 2026
Merged

jeremydmiller merged 2 commits into
mainfrom
gh-4437-recurring-operability

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #4437
Closes #4445
Closes #4446

The two remaining halves of the recurring/cron operability issue, in one PR. (#4447, durable
last-run state, was closed as not-planned earlier: run state stays in OpenTelemetry and the
operability view belongs in CritterWatch.)

GH-4445 — attribution

Occurrences already carried their schedule's name in a recurring-schedule header, lifted onto the
handler span as wolverine.schedule.name. Two gaps remained.

The occurrence instant was not on the envelope as a first-class value. Envelope.ScheduledTime
is cleared by the scheduled machinery at fire time, so by the time a handler runs, the only record of
which firing it is serving was the deduplication id — which exists for deduplication and had to be
string-parsed to serve as attribution. Occurrences now carry a recurring-occurrence header (UTC,
round-trippable "O"), lifted onto the span as wolverine.schedule.occurrence.

Metrics carried no schedule attribution, so the success / failure / effective-time counters could
not be sliced per cron job. ToMetricsHeaders() now emits a schedule.name tag.

It reads the header rather than the SetMetricsTag seam the issue proposed, and that detail is
the point: the metric tag list is a private field that is never serialized, so an occurrence
published on one node and handled on another would reach the counters on the handling node with no
attribution at all. The header round-trips every transport — pinned by a serializer round-trip test
that asserts both tags and the metric tag still resolve on the deserialized envelope.

The occurrence instant is deliberately trace-only. One distinct value per firing would make the
metric series unbounded in cardinality; a test pins that it stays out of the tag set.

GH-4446 — a manual trigger

TriggerAsync records the request on the schedule's durable tracking row (new nullable
trigger_requested_at column) and the agent publishes one occurrence for it on its next pass —
following the dead-letter replay precedent, because the caller is usually not on the node running the
agent.

Two semantics, both deliberate:

  • A manual run carries its own deduplication id, {schedule}:manual:{requested:O}. Dedup exists
    to collapse a failover double-publish of the same occurrence; a "run now" is a separate intent,
    and inheriting the occurrence id would make a trigger issued in the same instant as a scheduled
    firing silently do nothing. Still derived from the request instant rather than a random value, so
    failover idempotence is preserved.
  • Triggering a paused schedule is refused with RecurringSchedulePausedException. Pause says the
    schedule must not fire. Refusing is also the future-compatible choice: relaxing it later is
    additive, restricting it later would break callers. The refusal is the and paused = false
    predicate on the update itself, so the check and the write are one atomic statement rather than a
    read-then-write race.

The trigger is honoured before the cron computation, so a fixed-date schedule whose occurrences
have run out still runs on demand, and it never touches the pending-occurrence bookkeeping — a manual
run is extra, never a replacement for the cadence. The request clears only after a successful
publish. One slot per schedule, so two triggers landing between passes coalesce into a single run.

The two new IRecurringMessageStore members are defaulted rather than abstract, matching that
interface's stated policy, so an out-of-tree store that predates the verb keeps compiling.

Verification

Per repo convention CHANGELOG.md is untouched; the release notes carry this.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj

jeremydmiller and others added 2 commits September 14, 2026 21:37
Occurrences already carried their schedule's name in a `recurring-schedule` header, lifted onto the
handler span as `wolverine.schedule.name`. Two gaps remained.

The occurrence instant was not on the envelope as a first-class value. `Envelope.ScheduledTime` is
cleared by the scheduled machinery at fire time, so by the time a handler runs, the only surviving
record of which firing it is serving is the deduplication id -- which exists for deduplication and
has to be string-parsed to serve as attribution. Occurrences now carry a `recurring-occurrence`
header (UTC, round-trippable "O"), lifted onto the span as `wolverine.schedule.occurrence`.

Metrics carried no schedule attribution at all, so the success / failure / effective-time counters
could not be sliced per cron job. `ToMetricsHeaders` now emits a `schedule.name` tag. It reads the
header rather than going through the `SetMetricsTag` seam the issue proposed: the metric tag list is
a private field that is never serialized, so an occurrence published on one node and handled on
another would reach the counters on the handling node with no attribution at all. The header
round-trips every transport, so the attribution survives the hop -- pinned by a serializer
round-trip test that asserts the tags and the metric tag still resolve on the deserialized envelope.

The occurrence instant is deliberately trace-only. One distinct value per firing would make the
metric series unbounded in cardinality, and a test pins that it stays out of the metrics tag set.

Item 3 of the issue -- server-side dead-letter filtering by schedule -- is not included here; it
implies a promoted column or an index over the body blob and wants its own issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj
`IRecurringScheduleControl` had pause, resume and query but no way to fire an occurrence now, so an
operator's only option was hand-publishing the message type out of band -- which bypasses the
occurrence and deduplication machinery entirely.

`TriggerAsync` records the request on the schedule's durable tracking row (a new nullable
`trigger_requested_at` column) and the agent publishes one occurrence for it on its next pass. That
follows the dead-letter replay precedent for the same reason pause already does: the caller is
usually not on the node running the agent, so a local mark would be invisible to it.

Two semantics, both deliberate:

A manual run carries its OWN deduplication id, `{schedule}:manual:{requested:O}`. Dedup exists to
collapse a failover double-publish of the same occurrence; a "run now" is a separate intent, and
inheriting the occurrence id would make a trigger issued in the same instant as a scheduled firing
silently do nothing. It is still derived from the request instant rather than a random value, so the
failover idempotence the scheduled id has is preserved -- a re-publish of the same outstanding
request collapses at consumption.

Triggering a PAUSED schedule is refused with `RecurringSchedulePausedException`. Pausing says the
schedule must not fire, so a trigger may not override it; refusing is also the future-compatible
choice, since relaxing it later is additive where restricting it later would break callers. The
refusal is the `and paused = false` predicate on the update itself, so the check and the write are
one atomic statement rather than a read-then-write race.

The trigger is honoured BEFORE the cron computation, so a fixed-date schedule whose occurrences have
run out still runs on demand, and it never touches the pending-occurrence bookkeeping -- a manual run
is extra, never a replacement for the cadence. The request is cleared only after a successful
publish, so a failure retries on the next pass. One slot per schedule: two triggers landing between
passes coalesce into a single run.

The two new store members are defaulted rather than abstract, matching this interface's stated
policy, so an out-of-tree store that predates the verb keeps compiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj
@jeremydmiller
jeremydmiller merged commit 2064a66 into main Sep 15, 2026
43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant