From e650e5f1611d47d58c19da858da8a76e137338b6 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 2 Jul 2026 15:48:35 -0500 Subject: [PATCH] #13 Cloudflare Cron Trigger: Friday 17:00 America/Chicago, DST-correct Add a weekly Cron Trigger that fires at 17:00 America/Chicago (Central) every Friday year-round, correct across the CST/CDT DST transition, and on fire lists the pending reports (#10 Manager.ListPending) and hands their ids to the publish step. Cloudflare crons are UTC-only, and 17:00 Central is 22:00 UTC under CDT (summer) and 23:00 UTC under CST (winter), so no single UTC cron expresses it. Register BOTH Friday UTC hours in wrangler.jsonc (`0 22 * * 5` and `0 23 * * 5`) and gate each fire: only the fire that is actually 17:00 Central does the work, so publishing runs exactly once per Friday. TinyGo/Wasm may lack the IANA tz database, so the gate does not call time.LoadLocation. Instead internal/schedule computes the US Central DST rule from first principles (CDT from the 2nd Sunday of March 02:00 to the 1st Sunday of November 02:00, else CST) behind a pure func IsFriday1700Central(time.Time), host-testable without TinyGo and cross-checked against the real America/Chicago zone (via a test-only time/tzdata import) over a 20-year sweep. - internal/schedule: pure DST gate + Run orchestrator; Publisher/PendingLister seams; LogPublisher no-op default (the seam #14 replaces). - worker/scheduled_wasm.go: js/wasm-only adapter registering the scheduled task via init()+cron.ScheduleTaskNonBlock, wiring the R2-backed lifecycle Manager to schedule.Run. worker/main.go is untouched. - wrangler.jsonc: add triggers.crons (only the triggers section changed). Co-Authored-By: Claude Opus 4.8 --- internal/schedule/schedule.go | 169 ++++++++++++++++ internal/schedule/schedule_test.go | 314 +++++++++++++++++++++++++++++ worker/scheduled_wasm.go | 71 +++++++ wrangler.jsonc | 19 +- 4 files changed, 572 insertions(+), 1 deletion(-) create mode 100644 internal/schedule/schedule.go create mode 100644 internal/schedule/schedule_test.go create mode 100644 worker/scheduled_wasm.go diff --git a/internal/schedule/schedule.go b/internal/schedule/schedule.go new file mode 100644 index 0000000..d9a2786 --- /dev/null +++ b/internal/schedule/schedule.go @@ -0,0 +1,169 @@ +// Package schedule holds the timezone gate and orchestration for the weekly +// publish trigger (#13): "every Friday at 17:00 America/Chicago (Central), list +// the pending reports and hand them to the publish step". +// +// # Why a gate is needed at all +// +// Cloudflare Cron Triggers are evaluated in UTC and have no timezone support, so +// a single UTC cron cannot express "17:00 America/Chicago" year-round: Central is +// UTC-5 during Daylight time (CDT, summer) and UTC-6 during Standard time (CST, +// winter), so 17:00 Central is 22:00 UTC for part of the year and 23:00 UTC for +// the rest. The Worker therefore registers BOTH candidate Friday UTC hours in +// wrangler.jsonc (`0 22 * * 5` and `0 23 * * 5`) and gates each fire with +// [IsFriday1700Central]: exactly one of the two fires is 17:00 Central on any +// given Friday, so [Run] does the publish work exactly once per week across the +// CST/CDT boundary and does nothing on the other fire. +// +// # No timezone database needed +// +// The gate deliberately does not call time.LoadLocation("America/Chicago"): the +// Worker is compiled to Wasm by TinyGo, whose runtime may not embed the IANA +// tz database, so a LoadLocation could fail at deploy time. Instead the US +// Central DST rule is computed from first principles (see centralIsDaylight): +// Daylight time runs from the 2nd Sunday of March at 02:00 to the 1st Sunday of +// November at 02:00. This keeps the decision a pure function of a time.Time with +// no external data, so it is host-testable exhaustively without TinyGo (and the +// tests cross-check it against the real IANA zone via time/tzdata). +package schedule + +import ( + "context" + "fmt" + "log" + "time" +) + +// PendingLister is the seam for "list the reports awaiting publication". It is +// satisfied by *lifecycle.Manager (#10) via its ListPending method; tests inject +// a fake. Kept as a one-method interface so this package does not import the +// storage-backed lifecycle package (and its Wasm-only R2 dependencies). +type PendingLister interface { + // ListPending returns the ids of every report currently pending, oldest first. + ListPending(ctx context.Context) ([]string, error) +} + +// Publisher is the seam for the publish step (#14), which is not built yet. Run +// hands it the pending ids once the gate says it is time. Implementations turn +// each id into a GitHub issue and mark it published; until #14 lands the default +// [LogPublisher] is wired, so the trigger is exercisable end to end today. +type Publisher interface { + // Publish takes ownership of the pending report ids for this run. The slice may + // be empty (a Friday with no pending reports), which implementations should + // treat as a no-op. + Publish(ctx context.Context, ids []string) error +} + +// Run is the body of the scheduled handler. Given the UTC instant a Cron Trigger +// fired for (Cloudflare supplies it as the event's ScheduledTime), it gates on +// [IsFriday1700Central]: only when the fire is 17:00 Central on a Friday does it +// list the pending reports and hand them to publisher. +// +// The returned bool reports whether the gate fired (whether publish work ran), +// which lets the Worker log the "other" UTC fire as an intentional skip and lets +// tests assert the "runs exactly once per Friday" property. A non-nil error is +// the lister's or publisher's error, wrapped. +func Run(ctx context.Context, scheduledFor time.Time, lister PendingLister, publisher Publisher) (ran bool, err error) { + if !IsFriday1700Central(scheduledFor) { + // This is the other candidate UTC fire (or an off-schedule invocation); the + // sibling cron will fire at the correct Central hour. Do nothing. + return false, nil + } + ids, err := lister.ListPending(ctx) + if err != nil { + return true, fmt.Errorf("schedule: list pending: %w", err) + } + if err := publisher.Publish(ctx, ids); err != nil { + return true, fmt.Errorf("schedule: publish: %w", err) + } + return true, nil +} + +// IsFriday1700Central reports whether instant t falls on Friday at exactly 17:00 +// in America/Chicago (Central), correctly handling the CST/CDT DST transition. It +// is the gate that makes the two-UTC-cron scheme fire the publish job exactly +// once each Friday year-round (see the package doc). +// +// It is a pure function of t with no timezone-database or wall-clock dependency, +// so it is exhaustively unit-testable on the host. t may be in any location; it +// is normalized to UTC first. +func IsFriday1700Central(t time.Time) bool { + c := centralClock(t) + return c.Weekday() == time.Friday && c.Hour() == 17 && c.Minute() == 0 +} + +// centralClock returns t rendered as America/Chicago wall-clock time. The result +// is a time.Time whose calendar fields (Weekday, Hour, Minute, ...) read as the +// Central wall clock; its Location remains UTC because the shift is applied by +// arithmetic rather than a *time.Location, so callers must read only its fields, +// never treat it as an absolute instant. This trades a tz-database lookup for a +// hand-computed offset so it works under TinyGo/Wasm. +func centralClock(t time.Time) time.Time { + u := t.UTC() + return u.Add(centralOffset(u)) +} + +// centralOffset is the signed offset from UTC to Central wall-clock time at UTC +// instant u: -5h during Daylight time (CDT), -6h during Standard time (CST). +func centralOffset(u time.Time) time.Duration { + if centralIsDaylight(u) { + return -5 * time.Hour + } + return -6 * time.Hour +} + +// centralIsDaylight reports whether UTC instant u falls in US Central Daylight +// Time. Since 2007 US DST runs from the 2nd Sunday of March at 02:00 local +// (Standard) to the 1st Sunday of November at 02:00 local (Daylight). Expressed +// as absolute UTC instants the boundaries are: +// +// - spring forward: 2nd Sunday of March, 08:00 UTC (02:00 CST = UTC-6) +// - fall back: 1st Sunday of November, 07:00 UTC (02:00 CDT = UTC-5) +// +// u is in Daylight time iff it is at or after the spring-forward instant and +// strictly before the fall-back instant. Both boundaries lie in the same UTC +// calendar year as any instant they classify (DST spans March–November), so +// deriving them from u.Year() is correct for every month, including a January +// instant (before spring forward -> Standard) and a December one (after fall +// back -> Standard). +func centralIsDaylight(u time.Time) bool { + year := u.Year() + springForward := time.Date(year, time.March, nthSundayOfMonth(year, time.March, 2), 8, 0, 0, 0, time.UTC) + fallBack := time.Date(year, time.November, nthSundayOfMonth(year, time.November, 1), 7, 0, 0, 0, time.UTC) + return !u.Before(springForward) && u.Before(fallBack) +} + +// nthSundayOfMonth returns the day-of-month (1-based) of the nth Sunday in the +// given month and year, e.g. nthSundayOfMonth(2026, time.March, 2) is the 2nd +// Sunday of March 2026. n is assumed >= 1. +func nthSundayOfMonth(year int, month time.Month, n int) int { + first := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) + // Days from the 1st to the first Sunday (0 if the 1st is itself a Sunday). + toFirstSunday := (int(time.Sunday) - int(first.Weekday()) + 7) % 7 + return 1 + toFirstSunday + (n-1)*7 +} + +// LogPublisher is the default [Publisher] wired into the Worker until the real +// publish step (#14) exists. It publishes nothing; it only logs that the weekly +// trigger fired and how many reports it would have published, so the scheduled +// path is observable end to end before #14 lands. Report ids are opaque handles +// (a timestamp plus random bytes) and carry nothing sensitive, so logging them is +// safe. +type LogPublisher struct { + // Logf receives the message; it defaults to log.Printf when nil. Tests set it + // to capture output. + Logf func(format string, args ...any) +} + +// Publish logs the batch it was handed and returns nil. +func (p LogPublisher) Publish(_ context.Context, ids []string) error { + logf := p.Logf + if logf == nil { + logf = log.Printf + } + logf("schedule: weekly trigger fired; %d pending report(s) to publish "+ + "(publish step #14 not implemented yet): %v", len(ids), ids) + return nil +} + +// Compile-time check that the default publisher satisfies the seam. +var _ Publisher = LogPublisher{} diff --git a/internal/schedule/schedule_test.go b/internal/schedule/schedule_test.go new file mode 100644 index 0000000..9dedcd3 --- /dev/null +++ b/internal/schedule/schedule_test.go @@ -0,0 +1,314 @@ +package schedule_test + +import ( + "context" + "errors" + "slices" + "testing" + "time" + + // Embed the IANA tz database into the test binary so + // time.LoadLocation("America/Chicago") works on every host (notably Windows, + // which has no system zoneinfo). This is a TEST-only import: it never reaches + // the Wasm Worker build, whose gate is the hand-rolled rule under test. It lets + // these tests cross-check that hand-rolled rule against the authoritative zone. + _ "time/tzdata" + + "github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/schedule" +) + +// utc is a terse constructor for a UTC instant. +func utc(y int, mo time.Month, d, h, mi int) time.Time { + return time.Date(y, mo, d, h, mi, 0, 0, time.UTC) +} + +// TestIsFriday1700Central is the ticket's core acceptance: the gate returns true +// only at 17:00 America/Chicago on Fridays, across both DST seasons, the days +// bracketing each transition, wrong hours, wrong minutes, and non-Fridays. Every +// UTC instant below was verified against the real America/Chicago zone. +func TestIsFriday1700Central(t *testing.T) { + cases := []struct { + name string + in time.Time + want bool + }{ + // --- Summer, CDT (UTC-5): 17:00 Central == 22:00 UTC. Fri 2026-07-10. --- + {"CDT friday 22:00 UTC is 17:00 Central", utc(2026, time.July, 10, 22, 0), true}, + // The OTHER candidate cron hour must be false in summer, so work runs once. + {"CDT friday 23:00 UTC is 18:00 Central", utc(2026, time.July, 10, 23, 0), false}, + {"CDT friday 21:00 UTC is 16:00 Central", utc(2026, time.July, 10, 21, 0), false}, + + // --- Winter, CST (UTC-6): 17:00 Central == 23:00 UTC. Fri 2026-01-09. --- + {"CST friday 23:00 UTC is 17:00 Central", utc(2026, time.January, 9, 23, 0), true}, + // The OTHER candidate cron hour must be false in winter, so work runs once. + {"CST friday 22:00 UTC is 16:00 Central", utc(2026, time.January, 9, 22, 0), false}, + {"CST friday 00:00 UTC sat is 18:00 Central fri", utc(2026, time.January, 10, 0, 0), false}, + + // --- Around spring forward (2026-03-08 02:00). Fri before is CST, after CDT. --- + {"friday before spring-forward is CST -> 23:00 UTC", utc(2026, time.March, 6, 23, 0), true}, + {"friday before spring-forward: 22:00 UTC is 16:00", utc(2026, time.March, 6, 22, 0), false}, + {"friday after spring-forward is CDT -> 22:00 UTC", utc(2026, time.March, 13, 22, 0), true}, + {"friday after spring-forward: 23:00 UTC is 18:00", utc(2026, time.March, 13, 23, 0), false}, + + // --- Around fall back (2026-11-01 02:00). Fri before is CDT, after CST. --- + {"friday before fall-back is CDT -> 22:00 UTC", utc(2026, time.October, 30, 22, 0), true}, + {"friday before fall-back: 23:00 UTC is 18:00", utc(2026, time.October, 30, 23, 0), false}, + {"friday after fall-back is CST -> 23:00 UTC", utc(2026, time.November, 6, 23, 0), true}, + {"friday after fall-back: 22:00 UTC is 16:00", utc(2026, time.November, 6, 22, 0), false}, + + // --- Non-Fridays at the exact Central 17:00 hour must be false. --- + {"thursday 17:00 Central (CDT)", utc(2026, time.July, 9, 22, 0), false}, + {"saturday 17:00 Central (CDT)", utc(2026, time.July, 11, 22, 0), false}, + {"thursday 17:00 Central (CST)", utc(2026, time.January, 8, 23, 0), false}, + + // --- Right hour+weekday but wrong minute must be false (17:00 exactly). --- + {"CDT friday 22:30 UTC is 17:30 Central", utc(2026, time.July, 10, 22, 30), false}, + {"CST friday 23:01 UTC is 17:01 Central", utc(2026, time.January, 9, 23, 1), false}, + + // --- A different year's transition, to prove nthSunday isn't hard-coded. --- + // 2027 spring forward is 2027-03-14; Fri 2027-03-12 is still CST. + {"2027 friday before spring-forward is CST", utc(2027, time.March, 12, 23, 0), true}, + {"2027 friday before spring-forward: 22:00 is 16:00", utc(2027, time.March, 12, 22, 0), false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := schedule.IsFriday1700Central(tc.in); got != tc.want { + t.Errorf("IsFriday1700Central(%s) = %v, want %v", + tc.in.Format(time.RFC3339), got, tc.want) + } + }) + } +} + +// TestGateInputLocationIndependent proves the gate depends only on the instant, +// not on the time.Time's Location: the same absolute moment expressed in a +// non-UTC zone yields the same answer. +func TestGateInputLocationIndependent(t *testing.T) { + chi := mustLoadChicago(t) + // 17:00 America/Chicago on Fri 2026-07-10, expressed in Central rather than UTC. + central := time.Date(2026, time.July, 10, 17, 0, 0, 0, chi) + if !schedule.IsFriday1700Central(central) { + t.Errorf("gate should be true for 17:00 Central expressed in the Central zone") + } + // And the equivalent UTC instant agrees. + if !schedule.IsFriday1700Central(central.UTC()) { + t.Errorf("gate should be true for the same instant in UTC") + } +} + +// TestGateFiresExactlyOncePerFriday proves the two-UTC-cron scheme does the work +// exactly once each Friday year-round: for every Friday across several years, +// exactly one of the two candidate cron hours (22:00 and 23:00 UTC) passes the +// gate — never zero, never both — regardless of which side of a DST boundary the +// Friday falls on. +func TestGateFiresExactlyOncePerFriday(t *testing.T) { + fridays := 0 + for d := utc(2024, time.January, 1, 0, 0); d.Year() < 2031; d = d.AddDate(0, 0, 1) { + if d.Weekday() != time.Friday { + continue + } + fridays++ + hit22 := schedule.IsFriday1700Central(utc(d.Year(), d.Month(), d.Day(), 22, 0)) + hit23 := schedule.IsFriday1700Central(utc(d.Year(), d.Month(), d.Day(), 23, 0)) + count := 0 + if hit22 { + count++ + } + if hit23 { + count++ + } + if count != 1 { + t.Errorf("Friday %s: gate passed for %d of the two cron hours (22:00=%v, 23:00=%v), want exactly 1", + d.Format("2006-01-02"), count, hit22, hit23) + } + } + if fridays < 300 { + t.Fatalf("sanity: only iterated %d Fridays, expected ~365", fridays) + } +} + +// TestGateMatchesIANAAcrossDSTBoundary is the exhaustive DST verification the +// ticket asks for "without waiting for a real DST change": it sweeps every 30 +// minutes across 20 years and asserts the hand-rolled gate agrees with the real +// America/Chicago zone at every instant — including both transition weekends each +// year. If the hand-rolled CST/CDT rule ever drifts from IANA, this fails. +func TestGateMatchesIANAAcrossDSTBoundary(t *testing.T) { + chi := mustLoadChicago(t) + mismatches := 0 + var firstMismatch string + for tt := utc(2015, time.January, 1, 0, 0); tt.Year() < 2035; tt = tt.Add(30 * time.Minute) { + inChi := tt.In(chi) + want := inChi.Weekday() == time.Friday && inChi.Hour() == 17 && inChi.Minute() == 0 + got := schedule.IsFriday1700Central(tt) + if want != got { + mismatches++ + if firstMismatch == "" { + firstMismatch = tt.Format(time.RFC3339) + " (Central " + inChi.Format(time.RFC3339) + ")" + } + } + } + if mismatches != 0 { + t.Errorf("hand-rolled gate disagreed with America/Chicago at %d instants; first: %s", + mismatches, firstMismatch) + } +} + +// --- Run orchestration tests (mock lifecycle + mock publisher) --- + +// fakeLister is a mock PendingLister recording call count and returning canned ids/err. +type fakeLister struct { + ids []string + err error + calls int +} + +func (f *fakeLister) ListPending(context.Context) ([]string, error) { + f.calls++ + return f.ids, f.err +} + +// recordingPublisher captures the id batches handed to it. +type recordingPublisher struct { + batches [][]string + err error +} + +func (p *recordingPublisher) Publish(_ context.Context, ids []string) error { + p.batches = append(p.batches, ids) + return p.err +} + +// A Friday 17:00 Central instant (CDT), reused by the Run tests. +var fireInstant = utc(2026, time.July, 10, 22, 0) + +// notFireInstant is the sibling UTC cron hour on the same Friday: 18:00 Central. +var notFireInstant = utc(2026, time.July, 10, 23, 0) + +// TestRunPublishesPendingWhenGateFires is the ticket's second acceptance for the +// handler: when it is Friday 17:00 Central, Run lists pending and hands the +// publisher exactly those ids, once. +func TestRunPublishesPendingWhenGateFires(t *testing.T) { + want := []string{"20260703T120000-aaaa", "20260704T090000-bbbb", "20260705T221530-cccc"} + lister := &fakeLister{ids: want} + pub := &recordingPublisher{} + + ran, err := schedule.Run(context.Background(), fireInstant, lister, pub) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !ran { + t.Fatalf("Run ran=false at Friday 17:00 Central, want true") + } + if lister.calls != 1 { + t.Errorf("ListPending called %d times, want 1", lister.calls) + } + if len(pub.batches) != 1 { + t.Fatalf("Publish called %d times, want 1", len(pub.batches)) + } + if !slices.Equal(pub.batches[0], want) { + t.Errorf("Publish got ids %v, want exactly the pending ids %v", pub.batches[0], want) + } +} + +// TestRunSkipsWhenGateClosed proves the sibling cron fire (18:00 Central) does no +// work: neither the lister nor the publisher is touched. Combined with the +// gate-fires test above, this is what makes publishing happen exactly once per +// Friday from two UTC crons. +func TestRunSkipsWhenGateClosed(t *testing.T) { + lister := &fakeLister{ids: []string{"should-not-be-listed"}} + pub := &recordingPublisher{} + + ran, err := schedule.Run(context.Background(), notFireInstant, lister, pub) + if err != nil { + t.Fatalf("Run: %v", err) + } + if ran { + t.Errorf("Run ran=true at the sibling cron hour (18:00 Central), want false") + } + if lister.calls != 0 { + t.Errorf("ListPending called %d times on a skipped fire, want 0", lister.calls) + } + if len(pub.batches) != 0 { + t.Errorf("Publish called on a skipped fire, want not called") + } +} + +// TestRunPublishesEmptyBatch checks that a Friday with no pending reports still +// invokes the publisher (with an empty slice), so #14 sees every scheduled run. +func TestRunPublishesEmptyBatch(t *testing.T) { + lister := &fakeLister{ids: nil} + pub := &recordingPublisher{} + + ran, err := schedule.Run(context.Background(), fireInstant, lister, pub) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !ran { + t.Errorf("ran=false, want true") + } + if len(pub.batches) != 1 { + t.Fatalf("Publish called %d times, want 1 (even for an empty batch)", len(pub.batches)) + } + if len(pub.batches[0]) != 0 { + t.Errorf("Publish got %v, want empty batch", pub.batches[0]) + } +} + +// TestRunPropagatesListerError surfaces a lifecycle failure and does not publish. +func TestRunPropagatesListerError(t *testing.T) { + sentinel := errors.New("r2 list failed") + lister := &fakeLister{err: sentinel} + pub := &recordingPublisher{} + + ran, err := schedule.Run(context.Background(), fireInstant, lister, pub) + if !ran { + t.Errorf("ran=false, want true (the gate fired before the error)") + } + if !errors.Is(err, sentinel) { + t.Errorf("Run err = %v, want it to wrap %v", err, sentinel) + } + if len(pub.batches) != 0 { + t.Errorf("Publish called despite a list error, want not called") + } +} + +// TestRunPropagatesPublisherError surfaces a publish failure to the caller (the +// Worker), which lets the runtime record the scheduled invocation as failed. +func TestRunPropagatesPublisherError(t *testing.T) { + sentinel := errors.New("publish failed") + lister := &fakeLister{ids: []string{"id-1"}} + pub := &recordingPublisher{err: sentinel} + + ran, err := schedule.Run(context.Background(), fireInstant, lister, pub) + if !ran { + t.Errorf("ran=false, want true") + } + if !errors.Is(err, sentinel) { + t.Errorf("Run err = %v, want it to wrap %v", err, sentinel) + } +} + +// TestLogPublisherDefault covers the no-op default seam wired until #14: it +// reports the batch it was handed and never errors. +func TestLogPublisherDefault(t *testing.T) { + var logged string + pub := schedule.LogPublisher{Logf: func(format string, args ...any) { + logged = format + }} + if err := pub.Publish(context.Background(), []string{"a", "b"}); err != nil { + t.Fatalf("LogPublisher.Publish: %v", err) + } + if logged == "" { + t.Error("LogPublisher did not log anything") + } +} + +func mustLoadChicago(t *testing.T) *time.Location { + t.Helper() + loc, err := time.LoadLocation("America/Chicago") + if err != nil { + t.Fatalf("LoadLocation(America/Chicago): %v (time/tzdata should make this always succeed)", err) + } + return loc +} diff --git a/worker/scheduled_wasm.go b/worker/scheduled_wasm.go new file mode 100644 index 0000000..026f31c --- /dev/null +++ b/worker/scheduled_wasm.go @@ -0,0 +1,71 @@ +//go:build js && wasm + +// This file wires the weekly publish Cron Trigger (#13) into the Cloudflare +// Worker. It is deliberately separate from worker/main.go: the scheduled task is +// registered here from init() via cron.ScheduleTaskNonBlock, which only records a +// package-level callback and returns. init() runs before main(), so by the time +// main()'s workers.Serve signals the runtime ready both the fetch handler and +// this scheduled handler are registered — and worker/main.go needs no change +// (keeping this ticket's diff off the shared fetch wiring). +// +// Compiled only into the js/wasm Worker; excluded from host builds and tests. All +// non-trivial logic — the DST timezone gate and the list→publish orchestration — +// lives in internal/schedule, which is build-tag-free and host-tested; this file +// is the thin runtime adapter that binds the R2-backed lifecycle Manager to it. +package main + +import ( + "context" + "log" + "time" + + "github.com/syumai/workers/cloudflare/cron" + + "github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/lifecycle" + "github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/schedule" + "github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/storage" +) + +// init registers the Cron Trigger handler with the syumai/workers runtime. The +// cron package's own init installs the JS "runScheduler" binding; this call just +// selects which Task that binding runs. +func init() { + cron.ScheduleTaskNonBlock(runWeeklyTrigger) +} + +// runWeeklyTrigger is invoked by the Workers runtime on every Cron Trigger fire. +// +// Cloudflare Cron Triggers are UTC-only, so wrangler.jsonc schedules BOTH Friday +// UTC hours that can be 17:00 America/Chicago — 22:00 UTC during CDT (summer) and +// 23:00 UTC during CST (winter) — and this handler defers the timezone decision +// to schedule.Run. Run gates on the event's scheduled time so only the fire that +// is actually 17:00 Central lists the pending reports (via the R2-backed +// lifecycle Manager, #10) and hands their ids to the publish step; the sibling +// fire is a no-op. Net effect: publishing runs exactly once each Friday, correct +// across the DST boundary, from two static UTC crons. +func runWeeklyTrigger(ctx context.Context) error { + event, err := cron.NewEvent(ctx) + if err != nil { + return err + } + + // Resolve the R2 bucket binding (synchronous, no I/O). It goes unused on the + // sibling fire, where Run's gate is closed; that happens at most once a week. + store, err := storage.NewR2Store(storage.BucketBinding) + if err != nil { + return err + } + manager := lifecycle.New(store) + + // schedule.LogPublisher is the no-op default seam; the publish job (#14) will + // replace it with the real GitHub-issue publisher without touching this file. + ran, err := schedule.Run(ctx, event.ScheduledTime, manager, schedule.LogPublisher{}) + if err != nil { + return err + } + if !ran { + log.Printf("schedule: cron %q fired for %s, not Friday 17:00 America/Chicago; the sibling cron covers this week", + event.Cron, event.ScheduledTime.Format(time.RFC3339)) + } + return nil +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 21bda02..b228540 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -41,5 +41,22 @@ "store_id": "", "secret_name": "bugreport-enc-keyring" } - ] + ], + + // Cron Triggers for the weekly publish job (#13): "Friday 17:00 America/Chicago + // (Central), DST-correct". Cloudflare evaluates crons in UTC only and has no + // timezone support, and 17:00 Central is a different UTC hour depending on DST: + // 22:00 UTC during Central Daylight Time (CDT, UTC-5, summer) and 23:00 UTC + // during Central Standard Time (CST, UTC-6, winter). A single UTC cron therefore + // cannot express it, so we register BOTH candidate Friday UTC hours below. The + // Worker's scheduled handler (worker/scheduled_wasm.go -> internal/schedule) + // gates each fire on the real America/Chicago rule, so exactly one of the two + // does the publish work on any given Friday and the other is a no-op. This makes + // the trigger fire at 17:00 Central year-round with no manual DST maintenance. + "triggers": { + "crons": [ + "0 22 * * 5", // Fridays 22:00 UTC == 17:00 Central during CDT (summer) + "0 23 * * 5" // Fridays 23:00 UTC == 17:00 Central during CST (winter) + ] + } } -- 2.47.3