Skip to content

Preserve sub-second precision in schedule interval phase calculation - #12479

Open
Mihir-Rabari wants to merge 2 commits into
temporalio:mainfrom
Mihir-Rabari:fix/schedule-interval-subsecond-phase
Open

Mihir-Rabari wants to merge 2 commits into
temporalio:mainfrom
Mihir-Rabari:fix/schedule-interval-subsecond-phase

Conversation

@Mihir-Rabari

Copy link
Copy Markdown

What changed?

I changed CompiledSpec.rawNextTime and nextIntervalTime in service/worker/scheduler/spec.go to compute interval-based schedule times at nanosecond precision instead of truncating everything to whole seconds:

  • after.Unix() → after.UnixNano(), next.Unix() → next.UnixNano(), and the result is rebuilt with time.Unix(0, minTimestamp) instead of time.Unix(minTimestamp, 0)
  • interval and phase now use the raw DurationValue (nanoseconds) instead of DurationValue(...)/time.Second, with the same clamps (interval >= 1s, phase >= 0)

I also added a regression test, TestSpecIntervalPhaseSubsecond, which pins the full-precision lattice for every=120s, phase=63.123456s (i.e. 13:51:03.123456, 13:53:03.123456, 13:55:03.123456) and asserts that a start time exactly on a lattice point yields the strictly following point.

Why?

nextIntervalTime divided both the interval and the phase by time.Second and the timestamp math ran on Unix() seconds, so any sub-second component of the offset was silently dropped. A schedule created with every=120s, offset=63.123456s produced upcoming runs at 13:51:03.000000 etc. — the offset's microseconds were ignored — instead of 13:51:03.123456. This is issue #10312, where a schedule with microseconds in its offset misfires (with the reporter's v1.18.2 server it stalled after the first run). Both schedule implementations share this calculator — the V1 schedule workflow and the CHASM state-machine scheduler (chasm/lib/scheduler) — so the fix covers both.

The formula itself is unchanged, so the strictly-after property of the lattice is preserved: whole-second specs produce byte-identical results to before.

Note: #10382 proposed a similar direction back in May and hasn't seen review activity since — happy to close this in favor of that one if maintainers prefer picking it back up. This alternative keeps the existing int64 lattice arithmetic (only the unit changes) and pins the exact reported lattice plus a strictly-after boundary case in the regression test.

Fixes #10312

How did you test it?

  • built
  • run locally and tested manually
  • covered by existing tests
  • added new unit test(s)
  • added new functional test(s)

go test ./service/worker/scheduler/... passes with the new test (which failed on main exactly the way the issue describes, asserting the µs-precision lattice and a strictly-after boundary case) and with all pre-existing spec/workflow tests, including the whole-second interval, calendar, jitter, exclusion, and timezone cases — confirming backwards compatibility. I also ran the suites of the other packages that consume this calculator: ./chasm/lib/scheduler/... and ./tools/tdbg/... (schedule audit tool), plus go vet, gofmt -l, and go build on the changed package.

Potential risks

Any change to schedule time computation can shift fire times, but specs whose interval and phase are whole seconds compute exactly the same times as before (nanoseconds are a strict refinement of the same lattice), so only schedules that actually declare sub-second offsets are affected — and those were computing wrong times before. Two known corners: (1) jitter remains millisecond-granular by design, so a sub-second lattice can still be perturbed by jitter exactly as whole-second lattices are; (2) times are now carried as int64 nanoseconds, which are only well-defined up to year 2262 — far past the maxCalendarYear = 2100 horizon the scheduler enforces, and only reachable by explicitly creating a schedule with a StartTime beyond that; happy to add an explicit guard if maintainers want one.

rawNextTime and nextIntervalTime computed schedule times at whole-second
resolution: after/next timestamps were converted with Unix() and interval
and phase durations were divided by time.Second, silently dropping any
sub-second components of the phase. A spec like every=120s,
offset=63.123456s therefore fired on the 13:51:03.000000 lattice instead
of 13:51:03.123456, so upcoming runs carried the wrong (and visibly
truncated) times.

Compute the lattice in nanoseconds instead: UnixNano()/time.Unix(0, ts)
for timestamps and raw DurationValue for interval and phase. Whole-second
specs are unaffected; the strictly-after property of the lattice is kept.

Fixes temporalio#10312
@Mihir-Rabari
Mihir-Rabari requested a review from a team as a code owner October 9, 2026 03:42
@CLAassistant

CLAassistant commented Oct 9, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@0xamlab

0xamlab commented Oct 9, 2026

Copy link
Copy Markdown

Verified the fix locally. Checking out just the new test onto main reproduces the reported truncation: TestSpecIntervalPhaseSubsecond expects 13:51:03.123456 but main returns 13:51:03.000000. On the PR branch the full service/worker/scheduler package passes all 227 tests, and go test ./chasm/lib/scheduler/... is green. The change at service/worker/scheduler/spec.go:369 keeps the lattice arithmetic intact while using nanoseconds for interval/phase, so whole-second specs are unchanged and sub-second offsets now land on the correct lattice point. Looks solid.

@Mihir-Rabari

Copy link
Copy Markdown
Author

Thanks for the thorough verification! That exact truncation case (13:51:03.000000 on main) is what got me digging — the lattice walk itself was right, it just lost the sub-second component before the minTimestamp compare. Appreciate the deep check on the full scheduler package too.

@0xamlab

0xamlab commented Oct 10, 2026

Copy link
Copy Markdown

You're welcome — the nanosecond lattice change is the right shape, and the full scheduler package passing on top of it is what made it easy to say so. Nice fix.

This branch has not been deployed

No deployments
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.

Schedule with microseconds in offset launches only once

3 participants