Skip to content

Make repeated DST times configurable in Cron and Schedule.cron - #9905

Open
effect-bot wants to merge 5 commits into
v4/next-minorfrom
agent/nelson-effect-bot/abbce32749e4
Open

effect-bot wants to merge 5 commits into
v4/next-minorfrom
agent/nelson-effect-bot/abbce32749e4

Conversation

@effect-bot

Copy link
Copy Markdown
Contributor

When clocks fall back, Cron.next and Schedule.cron only fire on the first occurrence of the repeated local hour. A */5 * * * * Europe/Berlin schedule therefore has a 65-minute gap on the night of 2026-10-25. This PR adds an opt-in policy for which occurrences to include. Existing schedules behave the same as before unless they set the option.

const cron = Cron.parseUnsafe("*/5 * * * *", "Europe/Berlin", { repeatedTimes: "both" })
Cron.next(cron, new Date("2026-10-25T00:55:00Z")) // 2026-10-25T01:00:00.000Z (default: 02:00Z)

Schedule.cron("*/5 * * * *", "Europe/Berlin", { repeatedTimes: "both" })

API

  • New type Cron.RepeatedTimesPolicy = "earlier" | "later" | "both".
  • New repeatedTimes field on Cron, defaulting to "earlier" (the current behavior). make, parse and parseUnsafe accept it, and so does the string form of Schedule.cron. A pre-parsed Cron passed to Schedule.cron keeps its own policy.
  • The policy is stored on the Cron value, like tz, so next, prev, sequence, both Schedule.cron overloads and ClusterCron all use it without any signature changes. Equivalence, Hash and toJSON/toString include it. format and match don't change.

Semantics

For every policy, next returns the earliest matching instant strictly after its input and prev the latest strictly before it. Under "both", the two occurrences come out in order with no duplicates. UTC and fixed-offset zones behave the same under every policy. The search no longer assumes a fold lasts one hour; Casey's three-hour fold and Lord Howe's 30-minute fold are covered.

This also fixes prev when it starts inside the second occurrence. On main, prev from 2026-10-25T01:07Z (Berlin */5) returns 00:05Z instead of 00:55Z, and a daily 0 30 2 * * * comes back a day early.

Gap limitations

The policy only applies to repeated times. Local times skipped when clocks move forward are handled the same under every policy: a matching time inside the gap resolves to the instant after the gap.

Near a gap, prev on main could throw "Unable to find cron date" (0 30 2 * * * Berlin on 2026-03-29) or return an instant after its input (30 * * * * from 01:20Z returned 01:30Z). It now returns an instant strictly before its input in both cases.

Near a gap, next and prev still aren't guaranteed to return the nearest matching instant. For example, in Australia/Lord_Howe 5,20,40 2 * * *, prev from 2026-10-03T15:55Z returns 15:40Z, not 15:50Z, because candidates are searched in wall-clock order. That behavior is inherited from main and deferred here. The RepeatedTimesPolicy docs and the changeset say so.

Performance

Inside a fold, a candidate that falls on the wrong side of the input is no longer skipped one second at a time. Within a single fold, later wall times always resolve to later instants. So the search checks that the candidate one fold length further on is in range, then binary-searches for the first in-range second. Gaps, where that ordering doesn't hold, keep the one-second step.

For * * * * * *, calls that start inside a fold went from 17–260 ms to under 1 ms (Berlin, Casey and Lord Howe, both directions, all policies). This also covers the default policy: next from inside the second occurrence cost about 72 ms in Berlin and about 200 ms in Casey on main.

Validation

  • Cron, Schedule and ClusterCron tests: 90/90 pass. Doc examples for Cron.ts and Schedule.ts: 42/42 pass.
  • tsc -b tsconfig.json: no errors. oxlint and dprint pass.
  • A brute-force oracle (throwaway, not committed) listed every matching second around the Berlin, Casey and Lord Howe folds and checked next/prev under every policy: 8,870 checks, 0 mismatches. An independent review oracle that also covered Havana and fractional inputs ran 9,360 checks with 0 mismatches.
  • Gap results are identical to the version before the optimization across 4,200 Berlin and Lord Howe cases.

New tests: a policy table in Cron.test.ts for next/prev/sequence (Berlin, daily, Casey, UTC, spring-forward gap), a prev gap regression table, and a TestClock test showing that Schedule.cron applies the policy for both the string and pre-parsed forms.

Closes #9902
Closes EFF-2905

@changeset-bot

changeset-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 27fdd2d

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

This PR includes changesets to release 33 packages
Name Type
effect Minor
@effect/opentelemetry Minor
@effect/vitest Minor
@effect/ai-anthropic Minor
@effect/ai-cloudflare Minor
@effect/ai-openai-compat Minor
@effect/ai-openai Minor
@effect/ai-openrouter Minor
@effect/ai-typesafe Minor
@effect/atom-react Minor
@effect/atom-solid Minor
@effect/atom-vue Minor
@effect/platform-browser Minor
@effect/platform-bun Minor
@effect/platform-deno Minor
@effect/platform-node-shared Minor
@effect/platform-node Minor
@effect/sql-clickhouse Minor
@effect/sql-d1 Minor
@effect/sql-libsql Minor
@effect/sql-mssql Minor
@effect/sql-mysql2 Minor
@effect/sql-pg Minor
@effect/sql-pglite Minor
@effect/sql-sqlite-bun Minor
@effect/sql-sqlite-do Minor
@effect/sql-sqlite-node Minor
@effect/sql-sqlite-react-native Minor
@effect/sql-sqlite-wasm Minor
@effect/docgen Minor
@effect/doctest Minor
@effect/graphql-generator Minor
@effect/openapi-generator Minor

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

@effect-bot
effect-bot changed the base branch from main to v4/next-minor October 25, 2026 12:40
@effect-bot
effect-bot force-pushed the agent/nelson-effect-bot/abbce32749e4 branch from 1ee3f47 to 0baed5e Compare October 25, 2026 12:40
@github-actions

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 39.31 KB 39.31 KB 0.00 KB (0.00%)
basic.ts 7.17 KB 7.17 KB 0.00 KB (0.00%)
batching.ts 11.04 KB 11.04 KB 0.00 KB (0.00%)
brand.ts 6.83 KB 6.83 KB 0.00 KB (0.00%)
cache.ts 11.47 KB 11.47 KB 0.00 KB (0.00%)
config.ts 22.73 KB 22.73 KB 0.00 KB (0.00%)
differ.ts 21.86 KB 21.86 KB 0.00 KB (0.00%)
http-client.ts 26.70 KB 26.70 KB 0.00 KB (0.00%)
http-router.ts 36.64 KB 36.64 KB 0.00 KB (0.00%)
logger.ts 11.86 KB 11.86 KB 0.00 KB (0.00%)
metric.ts 9.40 KB 9.40 KB 0.00 KB (0.00%)
optic.ts 7.04 KB 7.04 KB 0.00 KB (0.00%)
pubsub.ts 15.95 KB 15.95 KB 0.00 KB (0.00%)
queue.ts 12.75 KB 12.75 KB 0.00 KB (0.00%)
schedule.ts 11.72 KB 11.72 KB 0.00 KB (0.00%)
schema-bigdecimal.ts 13.98 KB 13.98 KB 0.00 KB (0.00%)
schema-binary.ts 41.02 KB 41.02 KB 0.00 KB (0.00%)
schema-class.ts 21.52 KB 21.52 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 29.49 KB 29.49 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 27.87 KB 27.87 KB 0.00 KB (0.00%)
schema-string-transformation.ts 15.24 KB 15.24 KB 0.00 KB (0.00%)
schema-string.ts 12.22 KB 12.22 KB 0.00 KB (0.00%)
schema-template-literal.ts 16.16 KB 16.16 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 38.85 KB 38.85 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 26.05 KB 26.05 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 20.79 KB 20.79 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 20.94 KB 20.94 KB 0.00 KB (0.00%)
schema-toFormatter.ts 21.04 KB 21.04 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 26.07 KB 26.07 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 21.12 KB 21.12 KB 0.00 KB (0.00%)
schema.ts 20.72 KB 20.72 KB 0.00 KB (0.00%)
stm.ts 14.06 KB 14.06 KB 0.00 KB (0.00%)
stream.ts 10.31 KB 10.31 KB 0.00 KB (0.00%)

Cron values gain a repeatedTimes policy (earlier, later or both) that
chooses which occurrences of repeated local times next, prev, sequence
and Schedule.cron include. The default stays earlier.

prev now always returns the latest eligible instant strictly before its
input, including when starting inside the second occurrence of a fold or
just after a spring-forward gap.
Narrow the gap documentation and add the changeset.
Move the "both" handling for repeatedTimes into stepCron so next and prev
are one-line wrappers again, and express the rejected-interval skip as a
binary search over whole seconds relative to the rejected candidate, so the
direction and fold length are no longer mixed into the millisecond
arithmetic. Range checks take wall-clock millis instead of allocating Dates.
@effect-bot
effect-bot force-pushed the agent/nelson-effect-bot/abbce32749e4 branch from 0baed5e to 27fdd2d Compare October 25, 2026 17:43

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cron: make handling of repeated DST hours configurable

1 participant