#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 <noreply@anthropic.com>
This commit is contained in:
committed by
Jason Ross
co-authored by
Claude Opus 4.8
parent
c9f8489350
commit
e650e5f161
@@ -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{}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+18
-1
@@ -41,5 +41,22 @@
|
||||
"store_id": "<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)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user