GH-4437: recurring schedule operability — occurrence attribution and a manual trigger - #4451
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-scheduleheader, lifted onto thehandler span as
wolverine.schedule.name. Two gaps remained.The occurrence instant was not on the envelope as a first-class value.
Envelope.ScheduledTimeis 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-occurrenceheader (UTC,round-trippable
"O"), lifted onto the span aswolverine.schedule.occurrence.Metrics carried no schedule attribution, so the success / failure / effective-time counters could
not be sliced per cron job.
ToMetricsHeaders()now emits aschedule.nametag.It reads the header rather than the
SetMetricsTagseam the issue proposed, and that detail isthe 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
TriggerAsyncrecords the request on the schedule's durable tracking row (new nullabletrigger_requested_atcolumn) 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:
{schedule}:manual:{requested:O}. Dedup existsto 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.
RecurringSchedulePausedException. Pause says theschedule 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 = falsepredicate 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
IRecurringMessageStoremembers are defaulted rather than abstract, matching thatinterface's stated policy, so an out-of-tree store that predates the verb keeps compiling.
Verification
schedule.namemissing from the metrics dictionary, trace tag null), then green.RecurringMessageCompliancegains a durable-trigger fact, so it is proven on PostgreSQL, SQLServer, MySQL and SQLite at once.
wolverine.slnx -c Release -f net9.0: clean.Per repo convention
CHANGELOG.mdis untouched; the release notes carry this.🤖 Generated with Claude Code
https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj