From beb0d6c8a4045509a7ccc43ad57f03aa0ac9eb6b Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Mon, 5 Oct 2026 17:54:39 -0500 Subject: [PATCH 1/6] add a conformance module with a Go fixture generator The Rust and JavaScript ports check unique key hashes, cron next run times, snooze counts, job state bits, retry delay bounds, and notification payloads against goldens recorded from River's Go implementation. Those goldens live as hand-copied JSON inside each port, with no generator in the tree, so when Go's behavior changes the copies keep passing against stale values and nothing flags it. Add a nested `github.com/riverqueue/river/conformance` module with a `generatefixtures` command that writes four fixtures to `conformance/testdata` by calling River's own code: `dbunique.UniqueKey` and `uniquestates.UniqueStatesToBitmask` for `unique_keys.json`, robfig/cron's `ParseStandard` for `cron_schedules.json`, the job executor's snooze rule for `snooze_counters.json`, and River's state, metadata key, notification, attempt error, and retry definitions for `protocol_values.json`. The files use the shapes the ports already read, with cron and snooze split apart so each port test reads only what it needs. The fixtures are generated on demand with `make generate/fixtures` rather than committed, and the directory is ignored by Git. Nothing opaque lands in the tree or in diffs, and generation takes about a second for anyone who already has Go to work on River. Port test targets depend on the generator, so they always read what the current Go code produces. Expose the two values the generator needs from internal packages: `jobexecutor.NextSnoozeCount`, which the executor now calls itself, and `retrypolicy.DelayBounds`, which reports the jitter range of the default retry policy and is checked against `NextRetryAt`. The module is nested, like `riverdriver/riverdrivertest`, so none of it ends up in River's module zip, while it can still import River's internal packages through the workspace. It's never tagged. --- .gitignore | 1 + Makefile | 7 + .../cmd/generatefixtures/cron_schedules.go | 167 +++++ conformance/cmd/generatefixtures/main.go | 103 +++ .../cmd/generatefixtures/protocol_values.go | 268 ++++++++ .../cmd/generatefixtures/snooze_counters.go | 57 ++ conformance/cmd/generatefixtures/source.go | 144 ++++ .../cmd/generatefixtures/unique_keys.go | 648 ++++++++++++++++++ conformance/go.mod | 24 + conformance/go.sum | 41 ++ go.work | 1 + internal/jobexecutor/job_executor.go | 11 +- internal/retrypolicy/default.go | 19 +- internal/retrypolicy/default_test.go | 21 + 14 files changed, 1509 insertions(+), 3 deletions(-) create mode 100644 conformance/cmd/generatefixtures/cron_schedules.go create mode 100644 conformance/cmd/generatefixtures/main.go create mode 100644 conformance/cmd/generatefixtures/protocol_values.go create mode 100644 conformance/cmd/generatefixtures/snooze_counters.go create mode 100644 conformance/cmd/generatefixtures/source.go create mode 100644 conformance/cmd/generatefixtures/unique_keys.go create mode 100644 conformance/go.mod create mode 100644 conformance/go.sum diff --git a/.gitignore b/.gitignore index d09a4cac6..1c192c195 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.envrc +/conformance/testdata/ /go.work.sum /internal/cmd/riverbench/riverbench /river diff --git a/Makefile b/Makefile index 880c1ef60..bdfe74c5d 100644 --- a/Makefile +++ b/Makefile @@ -19,11 +19,18 @@ db/reset/test: ## Drop, create, and migrate test databases .PHONY: generate generate: ## Generate generated artifacts +generate: generate/fixtures generate: generate/js-migrations generate: generate/migrations generate: generate/rust-migrations generate: generate/sqlc +# Fixtures are written to conformance/testdata, which is ignored by Git. Port +# test targets that read them depend on this target. +.PHONY: generate/fixtures +generate/fixtures: ## Generate cross-language conformance fixtures from River's Go implementation + go run ./conformance/cmd/generatefixtures + .PHONY: generate/js-migrations generate/js-migrations: ## Sync database migrations to JavaScript pnpm -C js run generate:migrations diff --git a/conformance/cmd/generatefixtures/cron_schedules.go b/conformance/cmd/generatefixtures/cron_schedules.go new file mode 100644 index 000000000..760be0948 --- /dev/null +++ b/conformance/cmd/generatefixtures/cron_schedules.go @@ -0,0 +1,167 @@ +package main + +import ( + "fmt" + "time" + _ "time/tzdata" // named cron zones resolve the same on every host + + "github.com/robfig/cron/v3" +) + +// cronNextCount is the number of successive occurrences recorded per cron +// case. Each is computed from the previous one, the way the periodic job +// enqueuer advances a schedule. +const cronNextCount = 5 + +type cronSchedules struct { + Comment string `json:"$comment"` + CronCases []cronCase `json:"cron_cases"` + CronInvalid []string `json:"cron_invalid"` + CronNamedZoneCases []cronCase `json:"cron_named_zone_cases"` +} + +type cronCase struct { + Expression string `json:"expression"` + From time.Time `json:"from"` + Name string `json:"name"` + Next []time.Time `json:"next"` +} + +type cronCaseInput struct { + expression string + from time.Time + name string +} + +// makeCronSchedules records next run times from robfig/cron's +// `ParseStandard`, the parser River documents for periodic job schedules. +func makeCronSchedules() (cronSchedules, error) { + fixture := cronSchedules{ + Comment: generatedComment("robfig/cron/v3 ParseStandard, as used for River periodic jobs"), + } + + eastern := time.FixedZone("", -5*60*60) + kolkata := time.FixedZone("", 5*60*60+30*60) + for _, testCase := range []cronCaseInput{ + {expression: "* * * * *", from: referenceNow, name: "every_minute"}, + {expression: "30 * * * *", from: referenceNow, name: "half_past_every_hour"}, + {expression: "0 9 * * 1", from: referenceNow, name: "monday_numeric_weekday"}, + {expression: "0 9 * * mon", from: referenceNow, name: "monday_named_weekday"}, + {expression: "0 0 * * 0", from: referenceNow, name: "sunday_is_zero"}, + {expression: "0 0 * * SUN", from: referenceNow, name: "weekday_names_ignore_case"}, + {expression: "*/15 9-17 * * mon-fri", from: referenceNow, name: "business_hours_steps"}, + {expression: "0 0 1 * *", from: referenceNow, name: "first_of_month"}, + {expression: "0 0 1 jan,JUL *", from: referenceNow, name: "named_months"}, + {expression: "0 0 29 2 *", from: referenceNow, name: "leap_day"}, + {expression: "0 0 30 2 *", from: referenceNow, name: "impossible_date_never_runs"}, + {expression: "0 12 1,15 * 5", from: referenceNow, name: "day_of_month_or_weekday"}, + {expression: "0 12 * * 5", from: referenceNow, name: "wildcard_day_of_month_and_weekday"}, + {expression: "0 12 ? * 5", from: referenceNow, name: "question_mark_wildcard"}, + {expression: "0 12 */2 * 5", from: referenceNow, name: "stepped_day_of_month_or_weekday"}, + {expression: "0 12 */1 * 5", from: referenceNow, name: "unit_step_keeps_wildcard"}, + {expression: "5/15 * * * *", from: referenceNow, name: "start_with_step"}, + {expression: "0-10/5 * * * *", from: referenceNow, name: "range_with_step"}, + {expression: "59 23 31 12 *", from: referenceNow, name: "year_end"}, + {expression: "@hourly", from: referenceNow, name: "descriptor_hourly"}, + {expression: "@daily", from: referenceNow, name: "descriptor_daily"}, + {expression: "@midnight", from: referenceNow, name: "descriptor_midnight"}, + {expression: "@weekly", from: referenceNow, name: "descriptor_weekly"}, + {expression: "@monthly", from: referenceNow, name: "descriptor_monthly"}, + {expression: "@yearly", from: referenceNow, name: "descriptor_yearly"}, + {expression: "@annually", from: referenceNow, name: "descriptor_annually"}, + {expression: "@every 1h30m", from: referenceNow, name: "every_compound_duration"}, + {expression: "@every 1.5h", from: referenceNow, name: "every_fractional_duration"}, + {expression: "@every 90s", from: referenceNow, name: "every_seconds"}, + {expression: "@every 500ms", from: referenceNow, name: "every_rounds_up_to_one_second"}, + {expression: "@every 1500ms", from: referenceNow, name: "every_truncates_subseconds"}, + {expression: "0 9 * * *", from: time.Date(2026, time.March, 7, 8, 0, 0, 0, eastern), name: "reference_time_offset"}, + {expression: "30 0 * * *", from: time.Date(2026, time.March, 7, 23, 45, 0, 0, kolkata), name: "reference_time_half_hour_offset"}, + {expression: "CRON_TZ=UTC 0 9 * * *", from: time.Date(2026, time.March, 7, 8, 0, 0, 0, eastern), name: "cron_tz_utc_prefix"}, + {expression: "TZ=UTC 0 9 * * *", from: time.Date(2026, time.March, 7, 8, 0, 0, 0, eastern), name: "tz_utc_prefix"}, + {expression: " 0 9 * * 1 ", from: referenceNow, name: "extra_whitespace"}, + } { + cronCase, err := makeCronCase(testCase) + if err != nil { + return cronSchedules{}, err + } + fixture.CronCases = append(fixture.CronCases, cronCase) + } + + // IANA zones named in `CRON_TZ=`/`TZ=` prefixes, including daylight saving + // transitions. Kept apart from `cron_cases` because an implementation may + // need an optional time zone database for them. + for _, testCase := range []cronCaseInput{ + {expression: "CRON_TZ=America/New_York 0 9 * * *", from: time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC), name: "new_york_across_dst_start"}, + {expression: "CRON_TZ=America/New_York 30 2 * * *", from: time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC), name: "new_york_skipped_wall_time"}, + {expression: "CRON_TZ=America/New_York 30 1 * * *", from: time.Date(2026, time.October, 30, 12, 0, 0, 0, time.UTC), name: "new_york_repeated_wall_time"}, + {expression: "CRON_TZ=America/New_York 0 * * * *", from: time.Date(2026, time.November, 1, 4, 30, 0, 0, time.UTC), name: "new_york_hourly_across_dst_end"}, + {expression: "CRON_TZ=Europe/London 0 0 * * *", from: time.Date(2026, time.October, 23, 12, 0, 0, 0, time.UTC), name: "london_across_dst_end"}, + {expression: "CRON_TZ=America/Santiago 0 0 * * *", from: time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC), name: "santiago_skipped_midnight"}, + {expression: "CRON_TZ=America/Santiago 0 12 * * *", from: time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC), name: "santiago_day_after_skipped_midnight"}, + {expression: "CRON_TZ=America/Santiago 30 23 * * *", from: time.Date(2026, time.April, 2, 12, 0, 0, 0, time.UTC), name: "santiago_repeated_hour_before_midnight"}, + {expression: "TZ=Asia/Kolkata 0 9 * * mon", from: time.Date(2026, time.January, 2, 3, 4, 5, 0, eastern), name: "kolkata_tz_prefix"}, + } { + cronCase, err := makeCronCase(testCase) + if err != nil { + return cronSchedules{}, err + } + fixture.CronNamedZoneCases = append(fixture.CronNamedZoneCases, cronCase) + } + + for _, expression := range []string{ + "", + "* * * *", + "* * * * * *", + "0 9 * * 7", + "60 * * * *", + "* 24 * * *", + "* * 0 * *", + "* * 32 * *", + "* * * 0 *", + "* * * 13 *", + "-1 * * * *", + "5-1 * * * *", + "1-2-3 * * * *", + "1/2/3 * * * *", + "*/0 * * * *", + "*/x * * * *", + "0 9 * * funday", + "@every", + "@every 5x", + "@reboot", + "CRON_TZ=Nowhere/Invalid 0 9 * * *", + } { + if _, err := cron.ParseStandard(expression); err == nil { + return cronSchedules{}, fmt.Errorf("invalid cron expression unexpectedly parsed: %q", expression) + } + fixture.CronInvalid = append(fixture.CronInvalid, expression) + } + + return fixture, nil +} + +// makeCronCase records the occurrences Go computes for one cron case, +// stopping early if the schedule never runs again. +func makeCronCase(input cronCaseInput) (cronCase, error) { + schedule, err := cron.ParseStandard(input.expression) + if err != nil { + return cronCase{}, fmt.Errorf("error parsing cron case %s: %w", input.name, err) + } + + next := make([]time.Time, 0, cronNextCount) + current := input.from + for range cronNextCount { + current = schedule.Next(current) + if current.IsZero() { + break + } + next = append(next, current) + } + + return cronCase{ + Expression: input.expression, + From: input.from, + Name: input.name, + Next: next, + }, nil +} diff --git a/conformance/cmd/generatefixtures/main.go b/conformance/cmd/generatefixtures/main.go new file mode 100644 index 000000000..a4125a432 --- /dev/null +++ b/conformance/cmd/generatefixtures/main.go @@ -0,0 +1,103 @@ +// Command generatefixtures writes the language-neutral fixtures in +// conformance/testdata by calling River's Go implementation, so Go stays the +// source of truth for values the Rust and JavaScript ports check in their own +// tests. The fixtures are never committed; the directory is ignored by Git +// and the port test targets regenerate it before running. +// +// Run it from anywhere in the repository with `make generate/fixtures`. +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// fixtureDir is the directory of generated fixtures relative to the +// repository root. +const fixtureDir = "conformance/testdata" + +// referenceNow is the fixed clock most fixtures are generated at. +var referenceNow = time.Date(2026, time.January, 2, 3, 4, 5, 678_900_000, time.UTC) //nolint:gochecknoglobals + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + root, err := repoRoot() + if err != nil { + return err + } + + protocolValues, err := makeProtocolValues(root) + if err != nil { + return err + } + uniqueKeys, err := makeUniqueKeys() + if err != nil { + return err + } + cronSchedules, err := makeCronSchedules() + if err != nil { + return err + } + + for _, fixture := range []struct { + name string + value any + }{ + {"cron_schedules.json", cronSchedules}, + {"protocol_values.json", protocolValues}, + {"snooze_counters.json", makeSnoozeCounters()}, + {"unique_keys.json", uniqueKeys}, + } { + contents, err := json.MarshalIndent(fixture.value, "", " ") + if err != nil { + return fmt.Errorf("error encoding %s: %w", fixture.name, err) + } + contents = append(contents, '\n') + + path := filepath.Join(root, fixtureDir, fixture.name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("error creating fixture directory: %w", err) + } + if err := os.WriteFile(path, contents, 0o644); err != nil { //nolint:gosec // generated fixtures are world-readable + return fmt.Errorf("error writing %s: %w", path, err) + } + } + + return nil +} + +// generatedComment is the `$comment` at the top of every fixture. +func generatedComment(source string) string { + return "Generated by `make generate/fixtures` from " + source + ". Do not edit." +} + +// repoRoot returns the nearest directory at or above the working directory +// that contains go.work, so the command runs the same from any subdirectory. +func repoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("error getting working directory: %w", err) + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.work")); err == nil { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", errors.New("go.work not found in the working directory or any parent; run from the River repository") + } + dir = parent + } +} diff --git a/conformance/cmd/generatefixtures/protocol_values.go b/conformance/cmd/generatefixtures/protocol_values.go new file mode 100644 index 000000000..1fa2f71dd --- /dev/null +++ b/conformance/cmd/generatefixtures/protocol_values.go @@ -0,0 +1,268 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "math" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/riverqueue/river/internal/leadership" + "github.com/riverqueue/river/internal/notifier" + "github.com/riverqueue/river/internal/retrypolicy" + "github.com/riverqueue/river/internal/rivercommon" + "github.com/riverqueue/river/riverdriver" + "github.com/riverqueue/river/rivershared/uniquestates" + "github.com/riverqueue/river/rivertype" +) + +type protocolJobState struct { + State rivertype.JobState `json:"state"` + UniqueBit byte `json:"unique_bit"` +} + +type protocolNotification struct { + Fields []jsonField `json:"fields"` + Name string `json:"name"` + Payload json.RawMessage `json:"payload"` + Source string `json:"source"` + Topic string `json:"topic"` +} + +// protocolRetryCase bounds the delay River's default retry policy schedules +// after error_count failures. Ports with seedable jitter may use seed; the +// bounds hold for any seed. +type protocolRetryCase struct { + ErrorCount uint32 `json:"error_count"` + JobID int64 `json:"job_id"` + MaxDelayNS int64 `json:"max_delay_ns"` + MinDelayNS int64 `json:"min_delay_ns"` + Now time.Time `json:"now"` + Seed uint64 `json:"seed"` +} + +type protocolValues struct { + Comment string `json:"$comment"` + AttemptError rivertype.AttemptError `json:"attempt_error"` + JobStates []protocolJobState `json:"job_states"` + MetadataKeys map[string]string `json:"metadata_keys"` + Notifications []protocolNotification `json:"notifications"` + RetryCases []protocolRetryCase `json:"retry_cases"` + Topics map[string]notifier.NotificationTopic `json:"topics"` +} + +// makeProtocolValues records values that River processes must agree on to +// share a database: job states and their unique bits, the attempt error +// encoding, reserved metadata keys, notification topics and payloads, and +// retry delay bounds. root is the repository root, used to read the Go and SQL +// sources that notification payloads are derived from. +func makeProtocolValues(root string) (protocolValues, error) { + fixture := protocolValues{ + Comment: generatedComment("River's job state, metadata key, notification, attempt error, and retry policy definitions"), + AttemptError: rivertype.AttemptError{ + At: referenceNow, + Attempt: 3, + Error: `worker failed: escaped "detail"`, + Trace: "frame one\nframe two", + }, + MetadataKeys: map[string]string{ + "output": rivertype.MetadataKeyOutput, + "periodic_job_id": rivercommon.MetadataKeyPeriodicJobID, + "rescue_count": rivercommon.MetadataKeyRescueCount, + "resumable_cursor": rivercommon.MetadataKeyResumableCursor, + "resumable_step": rivercommon.MetadataKeyResumableStep, + "unique_nonce": riverdriver.UniqueInsertMetadataKey, + }, + Topics: map[string]notifier.NotificationTopic{ + "control": notifier.NotificationTopicControl, + "insert": notifier.NotificationTopicInsert, + "leadership": notifier.NotificationTopicLeadership, + }, + } + + for _, state := range rivertype.JobStates() { + fixture.JobStates = append(fixture.JobStates, protocolJobState{ + State: state, + UniqueBit: uniquestates.UniqueStatesToBitmask([]rivertype.JobState{state}), + }) + } + + notifications, err := makeProtocolNotifications(root) + if err != nil { + return protocolValues{}, err + } + fixture.Notifications = notifications + + for _, testCase := range []struct { + errorCount uint32 + jobID int64 + seed uint64 + }{ + {errorCount: 1, jobID: 42, seed: 0}, + {errorCount: 2, jobID: 42, seed: 123}, + {errorCount: 3, jobID: 9_007_199_254_740_991, seed: math.MaxUint64}, + {errorCount: 11, jobID: 1, seed: 456}, + {errorCount: 309, jobID: 42, seed: 789}, + {errorCount: 310, jobID: 42, seed: 123}, + } { + minDelay, maxDelay := retrypolicy.DelayBounds(int(testCase.errorCount)) + fixture.RetryCases = append(fixture.RetryCases, protocolRetryCase{ + ErrorCount: testCase.errorCount, + JobID: testCase.jobID, + MaxDelayNS: maxDelay.Nanoseconds(), + MinDelayNS: minDelay.Nanoseconds(), + Now: referenceNow, + Seed: testCase.seed, + }) + } + + return fixture, nil +} + +// makeProtocolNotifications derives notification payload goldens from the Go +// payload structs and action constants, and checks that payloads built in SQL +// use the same keys. +func makeProtocolNotifications(root string) ([]protocolNotification, error) { + const ( + electorSource = "internal/leadership/elector.go" + producerSource = "producer.go" + ) + + controlFields, err := sourceStructJSONFields(filepath.Join(root, producerSource), "controlEventPayload") + if err != nil { + return nil, err + } + insertFields, err := sourceStructJSONFields(filepath.Join(root, producerSource), "insertPayload") + if err != nil { + return nil, err + } + leadershipFields, err := sourceStructJSONFields(filepath.Join(root, electorSource), "DBNotification") + if err != nil { + return nil, err + } + controlActions, err := sourceStringConstants(filepath.Join(root, producerSource), "controlAction") + if err != nil { + return nil, err + } + leadershipActions, err := sourceStringConstants(filepath.Join(root, electorSource), "DBNotificationKind") + if err != nil { + return nil, err + } + + const ( + exampleJobID = 42 + exampleLeaderID = "client-1" + exampleQueue = "priority" + ) + + var notifications []protocolNotification + for _, constant := range slices.Sorted(maps.Keys(controlActions)) { + action := controlActions[constant] + values := map[string]any{"action": action, "queue": exampleQueue} + switch action { + case "cancel": + values["job_id"] = exampleJobID + case "metadata_changed": + values["metadata"] = map[string]any{"owner": "candidate"} + } + notification, err := newProtocolNotification(action, string(notifier.NotificationTopicControl), producerSource+":controlEventPayload", controlFields, values) + if err != nil { + return nil, err + } + notifications = append(notifications, notification) + } + + insert, err := newProtocolNotification("insert", string(notifier.NotificationTopicInsert), producerSource+":insertPayload", insertFields, map[string]any{"queue": exampleQueue}) + if err != nil { + return nil, err + } + notifications = append(notifications, insert) + + for _, constant := range slices.Sorted(maps.Keys(leadershipActions)) { + action := leadershipActions[constant] + leaderID := "" + if action == string(leadership.DBNotificationKindResigned) { + leaderID = exampleLeaderID + } + notification, err := newProtocolNotification(action, string(notifier.NotificationTopicLeadership), electorSource+":DBNotification", leadershipFields, map[string]any{"action": action, "leader_id": leaderID}) + if err != nil { + return nil, err + } + notifications = append(notifications, notification) + } + + // Some notifications are built in SQL rather than Go. Their keys must + // match the payload structs consumers decode them into. + for _, sqlSource := range []struct { + name string + path string + query string + }{ + {name: "cancel", path: "riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql", query: "JobCancel"}, + {name: "resigned", path: "riverdriver/riverpgxv5/internal/dbsqlc/river_leader.sql", query: "LeaderResign"}, + } { + keys, err := sqlNotificationKeys(filepath.Join(root, sqlSource.path), sqlSource.query) + if err != nil { + return nil, err + } + index := slices.IndexFunc(notifications, func(notification protocolNotification) bool { return notification.Name == sqlSource.name }) + if index < 0 { + return nil, fmt.Errorf("no %s notification to compare with %s", sqlSource.name, sqlSource.query) + } + var payload map[string]any + if err := json.Unmarshal(notifications[index].Payload, &payload); err != nil { + return nil, fmt.Errorf("error decoding %s notification payload: %w", sqlSource.name, err) + } + if expected := slices.Sorted(maps.Keys(payload)); !slices.Equal(expected, keys) { + return nil, fmt.Errorf("%s notification keys %v from %s differ from the Go payload keys %v", sqlSource.name, keys, sqlSource.query, expected) + } + notifications[index].Source += "; " + sqlSource.path + ":" + sqlSource.query + } + + slices.SortFunc(notifications, func(a, b protocolNotification) int { return strings.Compare(a.Name, b.Name) }) + return notifications, nil +} + +// newProtocolNotification encodes values in the payload struct's field order, +// omitting absent omitempty fields as encoding/json does. +func newProtocolNotification(name, topic, source string, fields []jsonField, values map[string]any) (protocolNotification, error) { + for key := range values { + if !slices.ContainsFunc(fields, func(field jsonField) bool { return field.Name == key }) { + return protocolNotification{}, fmt.Errorf("%s notification value %s is not a payload field", name, key) + } + } + + var payload bytes.Buffer + payload.WriteByte('{') + for _, field := range fields { + value, ok := values[field.Name] + if !ok { + if !field.OmitEmpty { + return protocolNotification{}, fmt.Errorf("%s notification has no value for required field %s", name, field.Name) + } + continue + } + + key, err := json.Marshal(field.Name) + if err != nil { + return protocolNotification{}, fmt.Errorf("error encoding %s notification key %s: %w", name, field.Name, err) + } + encoded, err := json.Marshal(value) + if err != nil { + return protocolNotification{}, fmt.Errorf("error encoding %s notification value %s: %w", name, field.Name, err) + } + if payload.Len() > 1 { + payload.WriteByte(',') + } + payload.Write(key) + payload.WriteByte(':') + payload.Write(encoded) + } + payload.WriteByte('}') + + return protocolNotification{Fields: fields, Name: name, Payload: payload.Bytes(), Source: source, Topic: topic}, nil +} diff --git a/conformance/cmd/generatefixtures/snooze_counters.go b/conformance/cmd/generatefixtures/snooze_counters.go new file mode 100644 index 000000000..2fd8a9ec2 --- /dev/null +++ b/conformance/cmd/generatefixtures/snooze_counters.go @@ -0,0 +1,57 @@ +package main + +import ( + "encoding/json" + + "github.com/riverqueue/river/internal/jobexecutor" +) + +type snoozeCounters struct { + Comment string `json:"$comment"` + SnoozeCounters []snoozeCounterCase `json:"snooze_counters"` +} + +type snoozeCounterCase struct { + ExpectedSnoozes int64 `json:"expected_snoozes"` + Metadata json.RawMessage `json:"metadata"` + Name string `json:"name"` +} + +// makeSnoozeCounters records the `snoozes` count the job executor writes when +// a job with the given metadata snoozes, including how it coerces values that +// aren't integers. +func makeSnoozeCounters() snoozeCounters { + fixture := snoozeCounters{ + Comment: generatedComment("jobexecutor.NextSnoozeCount"), + } + + for _, testCase := range []struct { + metadata string + name string + }{ + {metadata: `{}`, name: "absent"}, + {metadata: `{"snoozes":2}`, name: "integer"}, + {metadata: `{"snoozes":2.9}`, name: "fraction_truncates"}, + {metadata: `{"snoozes":-2.5}`, name: "negative_fraction_truncates_toward_zero"}, + {metadata: `{"snoozes":1e3}`, name: "exponent"}, + {metadata: `{"snoozes":9007199254740993}`, name: "beyond_float_precision"}, + {metadata: `{"snoozes":"4"}`, name: "numeric_string"}, + {metadata: `{"snoozes":"-7"}`, name: "negative_numeric_string"}, + {metadata: `{"snoozes":"4.5"}`, name: "fractional_string_is_zero"}, + {metadata: `{"snoozes":" 5"}`, name: "padded_string_is_zero"}, + {metadata: `{"snoozes":"abc"}`, name: "non_numeric_string_is_zero"}, + {metadata: `{"snoozes":true}`, name: "true_is_one"}, + {metadata: `{"snoozes":false}`, name: "false_is_zero"}, + {metadata: `{"snoozes":null}`, name: "null_is_zero"}, + {metadata: `{"snoozes":[3]}`, name: "array_is_zero"}, + {metadata: `{"snoozes":{"count":3}}`, name: "object_is_zero"}, + } { + fixture.SnoozeCounters = append(fixture.SnoozeCounters, snoozeCounterCase{ + ExpectedSnoozes: jobexecutor.NextSnoozeCount([]byte(testCase.metadata)), + Metadata: json.RawMessage(testCase.metadata), + Name: testCase.name, + }) + } + + return fixture +} diff --git a/conformance/cmd/generatefixtures/source.go b/conformance/cmd/generatefixtures/source.go new file mode 100644 index 000000000..b7543384d --- /dev/null +++ b/conformance/cmd/generatefixtures/source.go @@ -0,0 +1,144 @@ +package main + +import ( + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "reflect" + "regexp" + "slices" + "strconv" + "strings" +) + +// jsonField is one field of a Go struct's JSON encoding. +type jsonField struct { + Name string `json:"name"` + OmitEmpty bool `json:"omitempty"` +} + +// sourceStructJSONFields returns a struct's JSON fields in declaration order +// by parsing its source file, so payload shapes of unexported notification +// structs are derived from Go rather than restated by hand. +func sourceStructJSONFields(path, typeName string) ([]jsonField, error) { + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution) + if err != nil { + return nil, err + } + var fields []jsonField + found := false + ast.Inspect(file, func(node ast.Node) bool { + spec, ok := node.(*ast.TypeSpec) + if !ok || spec.Name.Name != typeName { + return true + } + structType, ok := spec.Type.(*ast.StructType) + if !ok { + return false + } + found = true + for _, field := range structType.Fields.List { + if field.Tag == nil { + continue + } + tag, err := strconv.Unquote(field.Tag.Value) + if err != nil { + continue + } + name, options, _ := strings.Cut(reflect.StructTag(tag).Get("json"), ",") + if name == "" || name == "-" { + continue + } + fields = append(fields, jsonField{Name: name, OmitEmpty: slices.Contains(strings.Split(options, ","), "omitempty")}) + } + return false + }) + if !found { + return nil, fmt.Errorf("struct %s not found in %s", typeName, path) + } + if len(fields) == 0 { + return nil, fmt.Errorf("struct %s in %s has no JSON fields", typeName, path) + } + return fields, nil +} + +// sourceStringConstants returns the values of string constants declared with +// the named type in a source file, keyed by constant name. +func sourceStringConstants(path, typeName string) (map[string]string, error) { + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution) + if err != nil { + return nil, err + } + constants := make(map[string]string) + for _, declaration := range file.Decls { + general, ok := declaration.(*ast.GenDecl) + if !ok || general.Tok != token.CONST { + continue + } + for _, spec := range general.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok || len(value.Values) != len(value.Names) { + continue + } + identifier, ok := value.Type.(*ast.Ident) + if !ok || identifier.Name != typeName { + continue + } + for index, name := range value.Names { + literal, ok := value.Values[index].(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + continue + } + unquoted, err := strconv.Unquote(literal.Value) + if err != nil { + return nil, err + } + constants[name.Name] = unquoted + } + } + } + if len(constants) == 0 { + return nil, fmt.Errorf("no %s string constants in %s", typeName, path) + } + return constants, nil +} + +var jsonBuildObjectPattern = regexp.MustCompile(`json_build_object\(([^)]*)\)`) + +// sqlNotificationKeys returns the keys of the json_build_object payload that a +// named sqlc query passes to pg_notify. +func sqlNotificationKeys(path, queryName string) ([]string, error) { + contents, err := os.ReadFile(path) + if err != nil { + return nil, err + } + _, query, found := strings.Cut(string(contents), "-- name: "+queryName+" ") + if !found { + return nil, fmt.Errorf("query %s not found in %s", queryName, path) + } + query, _, _ = strings.Cut(query, "-- name: ") + if !strings.Contains(query, "pg_notify(") { + return nil, fmt.Errorf("query %s in %s sends no notification", queryName, path) + } + match := jsonBuildObjectPattern.FindStringSubmatch(query) + if match == nil { + return nil, fmt.Errorf("query %s in %s builds no JSON payload", queryName, path) + } + arguments := strings.Split(match[1], ",") + if len(arguments)%2 != 0 { + return nil, fmt.Errorf("query %s in %s has an odd json_build_object argument list", queryName, path) + } + keys := make([]string, 0, len(arguments)/2) + for index := 0; index < len(arguments); index += 2 { + key := strings.TrimSpace(arguments[index]) + if !strings.HasPrefix(key, "'") || !strings.HasSuffix(key, "'") { + return nil, errors.New("json_build_object keys must be literals") + } + keys = append(keys, strings.Trim(key, "'")) + } + slices.Sort(keys) + return keys, nil +} diff --git a/conformance/cmd/generatefixtures/unique_keys.go b/conformance/cmd/generatefixtures/unique_keys.go new file mode 100644 index 000000000..193c323eb --- /dev/null +++ b/conformance/cmd/generatefixtures/unique_keys.go @@ -0,0 +1,648 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "math" + "strings" + "time" + + "github.com/riverqueue/river/internal/dbunique" + "github.com/riverqueue/river/rivershared/uniquestates" + "github.com/riverqueue/river/rivertype" +) + +// errorNameRejected is the error name a port must report instead of a key +// for a request River rejects, such as all-args uniqueness over arguments +// that don't encode a JSON object. +const errorNameRejected = "rejected" + +type allArgs struct { + Zeta string `json:"zeta"` + Alpha string `json:"alpha"` + Maximum int64 `json:"maximum"` +} + +func (allArgs) Kind() string { return "conformance_all_args" } + +// collectionsArgs exercises nested values, arrays, and nulls whose wire +// order and representation are preserved in hashed arguments. +type collectionsArgs struct { + Empty []string `json:"empty"` + Labels map[string]string `json:"labels"` + Matrix [][]int `json:"matrix"` + Missing []string `json:"missing"` + Objects []collectionsItem `json:"objects"` + Pointer *string `json:"pointer"` +} + +func (collectionsArgs) Kind() string { return "conformance_all_args" } + +type collectionsItem struct { + // Deliberately non-alphabetical: nested struct wire order is significant. + Zulu string `json:"zulu"` + Alpha *int `json:"alpha"` +} + +type dottedSelectedArgs struct { + At string `json:"@user,omitempty" river:"unique"` + Bang string `json:"!x,omitempty" river:"unique"` + Brace string `json:"{x},omitempty" river:"unique"` + Bracket string `json:"[x],omitempty" river:"unique"` + Colon string `json:":id,omitempty" river:"unique"` + //nolint:tagliatelle // literal dotted names distinguish them from nested paths + Literal string `json:"user.id,omitempty" river:"unique"` + Symbols string `json:"a*b?c#d|e,omitempty" river:"unique"` + User dottedSelectedUser `json:"user"` + Unicode string `json:"é,omitempty" river:"unique"` +} + +func (dottedSelectedArgs) Kind() string { return "conformance_dotted_selected_args" } + +type dottedSelectedUser struct { + ID string `json:"id,omitempty" river:"unique"` +} + +type emptyArgs struct{} + +func (emptyArgs) Kind() string { return "conformance_all_args" } + +// escapingArgs exercises encoding/json string and key escaping, including +// keys that gjson reports unescaped and sjson rewrites while hashing. +type escapingArgs struct { + Angle string `json:"a"` + Controls string `json:"controls"` + HTML string `json:"html"` + Keys map[string]int `json:"keys"` + Separators string `json:"separators"` + Unicode string `json:"unicode"` + UnicodeAmp string `json:"é&"` +} + +func (escapingArgs) Kind() string { return "conformance_all_args" } + +type mapOrderArgs struct{} + +func (mapOrderArgs) Kind() string { return "conformance_all_args" } + +func (mapOrderArgs) MarshalJSON() ([]byte, error) { //nolint:unparam // json.Marshaler requires an error result. + return []byte(`{"2":2,"10":10,"zero":-0,"😀":1,"":2}`), nil +} + +type nestedOrderArgs struct { + Nested struct { + // Deliberately non-alphabetical: nested struct wire order is significant. + Z int `json:"z"` + A int `json:"a"` + } `json:"nested"` +} + +func (nestedOrderArgs) Kind() string { return "conformance_all_args" } + +type numericBoundaryArgs struct { + Exponent float64 `json:"exponent"` + Fraction float64 `json:"fraction"` + Maximum int64 `json:"maximum"` + Minimum int64 `json:"minimum"` + UnsignedMaximum uint64 `json:"unsigned_maximum"` +} + +func (numericBoundaryArgs) Kind() string { return "conformance_numeric_boundaries" } + +// rawAllArgs keeps duplicate members and unusual top-level names intact for +// Go's all-arguments unique-key oracle. +type rawAllArgs struct{ text string } + +func (rawAllArgs) Kind() string { return "conformance_all_args" } + +func (args rawAllArgs) MarshalJSON() ([]byte, error) { //nolint:unparam // json.Marshaler requires an error result. + return []byte(args.text), nil +} + +type selectedAccount struct { + ID string `json:"id,omitempty" river:"unique"` + Ignored string `json:"ignored,omitempty"` + Region string `json:"region,omitempty" river:"unique"` +} + +type selectedArgs struct { + Account selectedAccount `json:"account,omitzero"` + Ignored bool `json:"ignored,omitempty"` + Label string `json:"label,omitempty" river:"unique"` + PathKey string `json:"path/key,omitempty" river:"unique"` +} + +func (selectedArgs) Kind() string { return "conformance_selected_args" } + +// selectedNullArgs selects an explicitly null field, which is retained in the +// hashed arguments, while omitted selected fields are skipped. +type selectedNullArgs struct { + Account selectedAccount `json:"account,omitzero"` + Label *string `json:"label" river:"unique"` + PathKey string `json:"path/key,omitempty" river:"unique"` +} + +func (selectedNullArgs) Kind() string { return "conformance_selected_args" } + +type simpleArgs struct { + ID int64 `json:"id"` +} + +func (simpleArgs) Kind() string { return "conformance_simple" } + +type staticClock struct{ now time.Time } + +func (clock staticClock) Now() time.Time { return clock.now } +func (staticClock) NowOrNil() *time.Time { return nil } + +// timeArgs exercises encoding/json time formatting, which trims fractional +// seconds to their shortest form. +type timeArgs struct { + Fraction time.Time `json:"fraction"` + Micros time.Time `json:"micros"` + Millis time.Time `json:"millis"` + Whole time.Time `json:"whole"` +} + +func (timeArgs) Kind() string { return "conformance_all_args" } + +// typedFloatArgs exercises encoding/json float formatting: 'f' notation +// between 1e-6 and 1e21, exponent notation outside it, and shortest +// round-trip digits for both 64- and 32-bit floats. +type typedFloatArgs struct { + BelowLarge float64 `json:"below_large"` + Large float64 `json:"large"` + LargeBoundary float64 `json:"large_boundary"` + Largest float64 `json:"largest"` + Negative float64 `json:"negative"` + NegativeZero float64 `json:"negative_zero"` + One float64 `json:"one"` + Single float32 `json:"single"` + SingleLarge float32 `json:"single_large"` + SingleSmall float32 `json:"single_small"` + Small float64 `json:"small"` + SmallBoundary float64 `json:"small_boundary"` + Smallest float64 `json:"smallest"` + Tenth float64 `json:"tenth"` +} + +func (typedFloatArgs) Kind() string { return "conformance_all_args" } + +type uniqueKeyCase struct { + Args json.RawMessage `json:"args"` + // ExpectedError is the error name a port must report instead of a key, as + // Go does for all-args uniqueness over arguments that don't encode a JSON + // object. ExpectedSHA256 is empty when it's set. + ExpectedError string `json:"expected_error,omitempty"` + ExpectedSHA256 string `json:"expected_sha256,omitempty"` + ExpectedStateMask byte `json:"expected_state_mask"` + Kind string `json:"kind"` + Name string `json:"name"` + Now time.Time `json:"now"` + Options uniqueKeyOptions `json:"options"` + Queue string `json:"queue"` + ScheduledAt *time.Time `json:"scheduled_at"` + SelectedUniqueComponents [][]string `json:"selected_unique_components,omitempty"` + SelectedUniquePaths []string `json:"selected_unique_paths"` +} + +type uniqueKeyOptions struct { + ByArgs bool `json:"by_args"` + ByPeriodNanos int64 `json:"by_period_nanos"` + ByQueue bool `json:"by_queue"` + ByState []rivertype.JobState `json:"by_state,omitempty"` + ExcludeKind bool `json:"exclude_kind"` +} + +type uniqueKeyReference struct { + args rivertype.JobArgs + expectedError string + name string + now time.Time + opts dbunique.UniqueOpts + queue string + scheduledAt *time.Time + selectedUniquePaths []string + typedOnly bool +} + +type uniqueKeys struct { + Comment string `json:"$comment"` + Cases []uniqueKeyCase `json:"cases"` + + // TypedOnlyCases are goldens for typed arguments whose encoded byte order + // a producer built on dynamic objects can't reproduce, such as a map with + // integer-like keys, which JavaScript objects enumerate first in ascending + // numeric order. Ports with typed serializers assert them in their own + // tests. + TypedOnlyCases []uniqueKeyCase `json:"typed_only_cases"` +} + +// makeUniqueKeys records the unique key hash and unique states bitmask River +// computes for combinations of unique options and job arguments, including +// numeric, escaping, and ordering edge cases in the hashed arguments. +func makeUniqueKeys() (uniqueKeys, error) { + now := referenceNow + scheduledAt := now.Add(2*time.Hour + 17*time.Minute) + validCustomStates := []rivertype.JobState{ + rivertype.JobStateAvailable, + rivertype.JobStateCompleted, + rivertype.JobStatePending, + rivertype.JobStateRunning, + rivertype.JobStateScheduled, + } + dottedSelectedPaths := []string{`\@user`, `\!x`, `\{x\}`, `\[x\]`, `\:id`, "user.id", `user\.id`, `a\*b\?c\#d\|e`, "é"} + + references := []uniqueKeyReference{ + { + args: selectedArgs{}, + name: "all_selected_fields_omitted", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + selectedUniquePaths: []string{"account.id", "account.region", "label", "path/key"}, + }, + { + args: selectedArgs{Account: selectedAccount{ID: "acct", Ignored: "irrelevant", Region: "west"}, PathKey: "slash"}, + name: "selected_siblings_and_slash_key", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + selectedUniquePaths: []string{"account.id", "account.region", "label", "path/key"}, + }, + { + args: nestedOrderArgs{Nested: struct { + Z int `json:"z"` + A int `json:"a"` + }{Z: 1, A: 2}}, + name: "nested_struct_wire_order", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: allArgs{ + Alpha: "&\u2028line", + Maximum: 9_007_199_254_740_991, + Zeta: "quoted \\\"value\\\" and \\\\ slash", + }, + name: "all_args_sorted_and_escaped", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: mapOrderArgs{}, + name: "map_order_and_negative_zero", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: rawAllArgs{`{"":0,"a.b":1,"@x":2,":lead":3,"!bang":4,"[open":5,"{brace":6,"a\\b":7}`}, + name: "all_args_literal_path_syntax", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: rawAllArgs{`{"a\"b":1,"line\n":2,"é":3,"a", + Controls: "\b\f\n\r\t\x00\x01\x1f\x7f", + HTML: `&`, + Keys: map[string]int{"": 1, "a&b": 2, "é": 3, "é<": 4}, + Separators: "line\u2028paragraph\u2029end", + Unicode: "é😀/\\", + UnicodeAmp: "unicode key", + }, + name: "typed_escaping", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: selectedNullArgs{}, + name: "selected_explicit_null", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + selectedUniquePaths: []string{"account.id", "account.region", "label", "path/key"}, + }, + { + args: timeArgs{ + Fraction: time.Date(2026, time.January, 2, 3, 4, 5, 500_000_000, time.UTC), + Micros: time.Date(2026, time.January, 2, 3, 4, 5, 123_456_000, time.UTC), + Millis: time.Date(2026, time.January, 2, 3, 4, 5, 120_000_000, time.UTC), + Whole: time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC), + }, + name: "typed_time_values", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: typedFloatArgs{ + BelowLarge: math.Nextafter(1e21, 0), + Large: 1e20, + LargeBoundary: 1e21, + Largest: math.MaxFloat64, + Negative: -1.5e-9, + NegativeZero: math.Copysign(0, -1), + One: 1, + Single: 1.1, + SingleLarge: 1e21, + SingleSmall: 1e-7, + Small: 1e-7, + SmallBoundary: 1e-6, + Smallest: math.SmallestNonzeroFloat64, + Tenth: 0.1, + }, + name: "typed_float_formatting", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true}, + queue: "default", + }, + { + args: simpleArgs{ID: 42}, + name: "period_from_now", + now: now, + opts: dbunique.UniqueOpts{ByPeriod: 90 * time.Minute}, + queue: "default", + }, + { + args: simpleArgs{ID: 42}, + name: "period_from_schedule", + now: now, + opts: dbunique.UniqueOpts{ByPeriod: time.Hour}, + queue: "default", + scheduledAt: &scheduledAt, + }, + { + // A process clock outside UTC must produce the same period as UTC. + args: simpleArgs{ID: 42}, + name: "period_from_non_utc_now", + now: now.In(time.FixedZone("UTC-5", -5*60*60)), + opts: dbunique.UniqueOpts{ByPeriod: time.Hour}, + queue: "default", + }, + { + // A half-hour offset puts the local wall-clock hour in a different + // UTC hour, so a local truncation would pick the wrong period. + args: simpleArgs{ID: 42}, + name: "period_from_non_utc_schedule", + now: now, + opts: dbunique.UniqueOpts{ByPeriod: time.Hour}, + queue: "default", + scheduledAt: new(scheduledAt.In(time.FixedZone("UTC+5:30", 5*60*60+30*60))), + }, + { + args: simpleArgs{ID: 42}, + name: "queue_without_kind", + now: now, + opts: dbunique.UniqueOpts{ByQueue: true, ExcludeKind: true}, + queue: "priority_emails", + }, + { + args: simpleArgs{ID: 42}, + name: "all_dimensions_custom_states", + now: now, + opts: dbunique.UniqueOpts{ByArgs: true, ByPeriod: time.Minute, ByQueue: true, ByState: validCustomStates}, + queue: "priority_emails", + scheduledAt: &scheduledAt, + }, + } + + fixture := uniqueKeys{ + Comment: generatedComment("dbunique.UniqueKey and uniquestates.UniqueStatesToBitmask"), + } + for _, reference := range references { + encodedArgs, err := json.Marshal(reference.args) + if err != nil { + return uniqueKeys{}, fmt.Errorf("error encoding args for unique key case %s: %w", reference.name, err) + } + + states := rivertype.UniqueOptsByStateDefault() + if len(reference.opts.ByState) > 0 { + states = reference.opts.ByState + } + + key, err := dbunique.UniqueKey(staticClock{now: reference.now}, &reference.opts, &rivertype.JobInsertParams{ + Args: reference.args, + EncodedArgs: encodedArgs, + Kind: reference.args.Kind(), + Queue: reference.queue, + ScheduledAt: reference.scheduledAt, + UniqueStates: uniquestates.UniqueStatesToBitmask(states), + }) + switch { + case reference.expectedError != "" && err == nil: + return uniqueKeys{}, fmt.Errorf("unique key case %s: expected an error", reference.name) + case reference.expectedError == "" && err != nil: + return uniqueKeys{}, fmt.Errorf("unique key case %s: %w", reference.name, err) + } + + uniqueKeyCase := uniqueKeyCase{ + Args: encodedArgs, + ExpectedError: reference.expectedError, + ExpectedSHA256: hex.EncodeToString(key), + ExpectedStateMask: uniquestates.UniqueStatesToBitmask(states), + Kind: reference.args.Kind(), + Name: reference.name, + Now: reference.now, + Options: uniqueKeyOptions{ + ByArgs: reference.opts.ByArgs, + ByPeriodNanos: reference.opts.ByPeriod.Nanoseconds(), + ByQueue: reference.opts.ByQueue, + ByState: reference.opts.ByState, + ExcludeKind: reference.opts.ExcludeKind, + }, + Queue: reference.queue, + ScheduledAt: reference.scheduledAt, + SelectedUniqueComponents: selectedUniqueComponents(reference.selectedUniquePaths), + SelectedUniquePaths: reference.selectedUniquePaths, + } + if reference.typedOnly { + fixture.TypedOnlyCases = append(fixture.TypedOnlyCases, uniqueKeyCase) + } else { + fixture.Cases = append(fixture.Cases, uniqueKeyCase) + } + } + + return fixture, nil +} + +// selectedUniqueComponents splits gjson-escaped selected unique paths into +// decoded JSON field names, so ports needn't parse gjson's path syntax. Each +// inner slice is one path. +func selectedUniqueComponents(paths []string) [][]string { + if len(paths) == 0 { + return nil + } + + components := make([][]string, 0, len(paths)) + for _, path := range paths { + var ( + part strings.Builder + parts []string + ) + for index := 0; index < len(path); index++ { + switch path[index] { + case '\\': + index++ + if index < len(path) { + part.WriteByte(path[index]) + } + case '.': + parts = append(parts, part.String()) + part.Reset() + default: + part.WriteByte(path[index]) + } + } + parts = append(parts, part.String()) + components = append(components, parts) + } + + return components +} diff --git a/conformance/go.mod b/conformance/go.mod new file mode 100644 index 000000000..c6ea22682 --- /dev/null +++ b/conformance/go.mod @@ -0,0 +1,24 @@ +// Cross-language conformance tooling and the Go-generated fixtures that ports +// test against. It's a separate module so none of it ships in River's module +// zip, and it's never tagged or released. +module github.com/riverqueue/river/conformance + +go 1.26.0 + +toolchain go1.26.6 + +require ( + github.com/riverqueue/river v0.49.0 + github.com/riverqueue/river/riverdriver v0.49.0 + github.com/riverqueue/river/rivershared v0.49.0 + github.com/riverqueue/river/rivertype v0.49.0 + github.com/robfig/cron/v3 v3.0.1 +) + +require ( + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + golang.org/x/sync v0.23.0 // indirect +) diff --git a/conformance/go.sum b/conformance/go.sum new file mode 100644 index 000000000..00e32ed6f --- /dev/null +++ b/conformance/go.sum @@ -0,0 +1,41 @@ +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/riverqueue/river v0.49.0 h1:JUCLFgregbX1Wu+bSTHCjF/WHGdrwGbtz/ddInHfeb0= +github.com/riverqueue/river v0.49.0/go.mod h1:USYb57gpMBXLQm2l7poskAalOPaf5uHjSZgp2AOoYDw= +github.com/riverqueue/river/riverdriver v0.49.0 h1:kSykNQJNB7AeG6kudjB0mThV29PvykoOBIFT8ipCHEc= +github.com/riverqueue/river/riverdriver v0.49.0/go.mod h1:rVUuX/fTF2kAiJjpPT+10Lft8n90kvLqjm6b+rFXaVE= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.49.0 h1:c7YA1plP/nrNS3SEHuKizf4wHx9OQ87Yxuhj3V0eQ50= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.49.0/go.mod h1:o7zkFstM+Fk+mnlOW2sx92mQW4JswSc6zvN191l8Isg= +github.com/riverqueue/river/rivershared v0.49.0 h1:wnCYVwftMiu85kT1JrUPKEgujKkBIWoRtSNZJTUtotY= +github.com/riverqueue/river/rivershared v0.49.0/go.mod h1:E8UzQAdDutFT8rVL1wZeNbuphmIS81TgngU7/1F4U5E= +github.com/riverqueue/river/rivertype v0.49.0 h1:3up3P2DtOqnM2yEYSC1xeK9AjrljOtIfl10yZlaF4aM= +github.com/riverqueue/river/rivertype v0.49.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= diff --git a/go.work b/go.work index 8f7b945da..7f9949533 100644 --- a/go.work +++ b/go.work @@ -5,6 +5,7 @@ toolchain go1.26.6 use ( . ./cmd/river + ./conformance ./riverdriver ./riverdriver/riverdatabasesql ./riverdriver/riverdrivertest diff --git a/internal/jobexecutor/job_executor.go b/internal/jobexecutor/job_executor.go index 3d5b455e3..07a2a0a94 100644 --- a/internal/jobexecutor/job_executor.go +++ b/internal/jobexecutor/job_executor.go @@ -80,6 +80,14 @@ func MetadataUpdatesFromWorkContext(ctx context.Context) (map[string]any, bool) return typedMetadataUpdates, true } +// NextSnoozeCount returns the snooze count recorded on a job that's snoozed +// again: its metadata's current `snoozes` value plus one. The current value is +// read leniently, so a missing, non-numeric, or malformed count reads as zero +// and a fractional one truncates toward zero. +func NextSnoozeCount(metadata []byte) int64 { + return gjson.GetBytes(metadata, "snoozes").Int() + 1 +} + type jobExecutorResult struct { Err error JobArgsUnmarshaled bool @@ -422,13 +430,12 @@ func (e *JobExecutor) reportResult(ctx context.Context, jobRow *rivertype.JobRow ) nextAttemptScheduledAt := e.Time.Now().Add(snoozeErr.Duration) - snoozesValue := gjson.GetBytes(jobRow.Metadata, "snoozes").Int() if res.MetadataUpdates == nil { res.MetadataUpdates = make(map[string]any) } // Set snooze count in the metadata map before marshaling so we avoid // rewriting a potentially large encoded metadata payload. - res.MetadataUpdates["snoozes"] = snoozesValue + 1 + res.MetadataUpdates["snoozes"] = NextSnoozeCount(jobRow.Metadata) metadataUpdatesBytes, err := marshalMetadataUpdates(res.MetadataUpdates) if err != nil { diff --git a/internal/retrypolicy/default.go b/internal/retrypolicy/default.go index cbf0d1bae..3d1d1b5fb 100644 --- a/internal/retrypolicy/default.go +++ b/internal/retrypolicy/default.go @@ -39,6 +39,19 @@ func NextRetryAt(now time.Time, job *rivertype.JobRow) time.Time { return now.Add(secondsAsCappedDuration(retrySeconds(errorCount))) } +// DelayBounds returns the smallest and largest delay NextRetryAt schedules +// for a job with errorCount-1 recorded errors. Delays are errorCount^4 +// seconds with up to 10% jitter either way, capped at the maximum +// time.Duration. +func DelayBounds(errorCount int) (time.Duration, time.Duration) { + base := retrySecondsWithoutJitter(errorCount) + if base == maxDurationSeconds { + return maxDuration, maxDuration + } + return secondsAsCappedDuration(base - base*jitterFraction), + secondsAsCappedDuration(min(base+base*jitterFraction, maxDurationSeconds)) +} + // secondsAsCappedDuration converts seconds to a duration, returning the // maximum duration for values at or above it. Converting an out-of-range // float to an integer is implementation-specific in Go and yields the minimum @@ -54,6 +67,10 @@ func secondsAsCappedDuration(seconds float64) time.Duration { // The maximum value of a duration before it overflows. About 292 years. const maxDuration time.Duration = 1<<63 - 1 +// jitterFraction is the largest fraction of a retry delay that jitter adds +// or removes. +const jitterFraction = 0.1 + // Same as the above, but changed to a float represented in seconds. var maxDurationSeconds = maxDuration.Seconds() //nolint:gochecknoglobals @@ -71,7 +88,7 @@ func retrySeconds(attempt int) float64 { } // Jitter number of seconds +/- 10%. - retrySeconds += retrySeconds * (rand.Float64()*0.2 - 0.1) + retrySeconds += retrySeconds * (rand.Float64()*2*jitterFraction - jitterFraction) // Cap retrySeconds once more in case adding random jitter pushed it over // maxDurationSeconds. (This should never realistically happen, but protect diff --git a/internal/retrypolicy/default_test.go b/internal/retrypolicy/default_test.go index 0ad54e38e..f9ec5c791 100644 --- a/internal/retrypolicy/default_test.go +++ b/internal/retrypolicy/default_test.go @@ -85,6 +85,27 @@ func TestDefault_NextRetry(t *testing.T) { }) } +func TestDelayBounds(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + for _, errorCount := range []int{1, 2, 11, 309, 310, 1_000} { + minDelay, maxDelay := DelayBounds(errorCount) + require.LessOrEqual(t, minDelay, maxDelay) + for range 20 { + delay := NextRetryAt(now, &rivertype.JobRow{Errors: make([]rivertype.AttemptError, errorCount-1)}).Sub(now) + require.GreaterOrEqual(t, delay, minDelay, "error count %d", errorCount) + require.LessOrEqual(t, delay, maxDelay, "error count %d", errorCount) + } + } + minDelay, maxDelay := DelayBounds(1) + require.Equal(t, 900*time.Millisecond, minDelay) + require.Equal(t, 1100*time.Millisecond, maxDelay) + minDelay, maxDelay = DelayBounds(310) + require.Equal(t, time.Duration(math.MaxInt64), minDelay) + require.Equal(t, time.Duration(math.MaxInt64), maxDelay) +} + func TestRetrySeconds(t *testing.T) { t.Parallel() From 3ba1439fd86f42a5cf026f28e2a8f255a29129b2 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Mon, 5 Oct 2026 17:54:54 -0500 Subject: [PATCH 2/6] check that Go module zips exclude fixtures The conformance module, its generated fixtures, and the Rust and JavaScript ports stay out of River's Go module zip only because their directories carry their own `go.mod`. Dropping one of those files, or adding fixtures somewhere new, would quietly publish test data in the zip every River user downloads. Add a `checkmodzip` command to the conformance module and a `make check/modzip` target that runs it over every module in `go.work`. It selects each module's files with `golang.org/x/mod/zip`, which applies the same rules as the module proxy, and fails if any `testdata` or `fixtures` directory, any JSON file, or anything under `conformance/`, `js/`, or `rust/` would be included. The conformance module itself is skipped because it holds the fixtures and is never published. CI runs the check in the existing `submodule_check` job. --- .github/workflows/ci.yaml | 3 + Makefile | 4 ++ conformance/cmd/checkmodzip/main.go | 91 +++++++++++++++++++++++++++++ conformance/go.mod | 1 + conformance/go.sum | 4 ++ 5 files changed, 103 insertions(+) create mode 100644 conformance/cmd/checkmodzip/main.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 415fb799b..5361bfbcb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -454,3 +454,6 @@ jobs: - name: Check all go/toolchain directives match run: CHECK=true make update-mod-go + + - name: Check module zips exclude fixtures and ports + run: make check/modzip diff --git a/Makefile b/Makefile index bdfe74c5d..259406784 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,10 @@ SQLC ?= sqlc +.PHONY: check/modzip +check/modzip: ## Check that no Go module zip includes fixtures, testdata, or the Rust or JS ports + go run ./conformance/cmd/checkmodzip ./go.work + .PHONY: db/reset db/reset: ## Drop, create, and migrate dev and test databases db/reset: db/reset/dev diff --git a/conformance/cmd/checkmodzip/main.go b/conformance/cmd/checkmodzip/main.go new file mode 100644 index 000000000..d051602ae --- /dev/null +++ b/conformance/cmd/checkmodzip/main.go @@ -0,0 +1,91 @@ +// Command checkmodzip fails if the module zip of any module in a Go workspace +// would include test fixtures or another language's port. It selects files +// with the same rules the Go module proxy uses, so it catches fixtures that a +// missing nested go.mod would otherwise publish with River. It reads the +// working tree, ignored files included, so it's exact on a clean checkout +// like CI's. +// +// Run it with a make target: +// +// make check/modzip +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/zip" +) + +// conformanceModulePath is this command's own module. It holds the shared +// fixtures by design and is never published, so it isn't checked. +const conformanceModulePath = "github.com/riverqueue/river/conformance" + +// disallowedPathPattern matches paths, relative to a module's root, that must +// never be published: fixture and testdata directories, JSON files, and the +// conformance, JavaScript, and Rust trees. +var disallowedPathPattern = regexp.MustCompile(`(^|/)(fixtures?|testdata)/|\.json$|^(conformance|js|rust)/`) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: checkmodzip ") + os.Exit(2) + } + + if err := run(os.Args[1]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(workFilename string) error { + workFileData, err := os.ReadFile(workFilename) //nolint:gosec // a developer-supplied go.work path + if err != nil { + return fmt.Errorf("error reading %s: %w", workFilename, err) + } + + workFile, err := modfile.ParseWork(workFilename, workFileData, nil) + if err != nil { + return fmt.Errorf("error parsing %s: %w", workFilename, err) + } + + var violations []error + for _, use := range workFile.Use { + dir := filepath.Join(filepath.Dir(workFilename), use.Path) + + modFilename := filepath.Join(dir, "go.mod") + modFileData, err := os.ReadFile(modFilename) //nolint:gosec // a module directory listed in go.work + if err != nil { + return fmt.Errorf("error reading %s: %w", modFilename, err) + } + modulePath := modfile.ModulePath(modFileData) + if modulePath == conformanceModulePath { + continue + } + + files, err := zip.CheckDir(dir) + if err != nil { + return fmt.Errorf("error checking module zip for %s: %w", modulePath, err) + } + + for _, path := range files.Valid { + relPath, err := filepath.Rel(dir, path) + if err != nil { + return fmt.Errorf("error making %s relative to %s: %w", path, dir, err) + } + relPath = filepath.ToSlash(relPath) + + if disallowedPathPattern.MatchString(relPath) { + violations = append(violations, fmt.Errorf("%s: module zip would include %s", modulePath, relPath)) + } + } + + fmt.Printf("%s: %d files\n", modulePath, len(files.Valid)) + } + + return errors.Join(violations...) +} diff --git a/conformance/go.mod b/conformance/go.mod index c6ea22682..96f3084ea 100644 --- a/conformance/go.mod +++ b/conformance/go.mod @@ -13,6 +13,7 @@ require ( github.com/riverqueue/river/rivershared v0.49.0 github.com/riverqueue/river/rivertype v0.49.0 github.com/robfig/cron/v3 v3.0.1 + golang.org/x/mod v0.41.0 ) require ( diff --git a/conformance/go.sum b/conformance/go.sum index 00e32ed6f..6059fab7d 100644 --- a/conformance/go.sum +++ b/conformance/go.sum @@ -35,7 +35,11 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= From 33312a98dbecf8603fdb3e87e5d0a4344ce4ea43 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Mon, 5 Oct 2026 17:57:41 -0500 Subject: [PATCH 3/6] read generated Go fixtures in the Rust tests Point the Rust unique key, cron, snooze, and protocol tests at the fixtures River's Go implementation generates into `conformance/testdata` and delete the hand-copied versions under `tests/fixtures`. The tests read the files at runtime and panic with a pointer to `make generate/fixtures` when one is missing, so an ungenerated fixture fails the test instead of skipping it, and builds and lints that don't run tests don't need Go. `test/rust`, `test/rust/postgres`, and `test/rust/sqlite` now depend on `generate/fixtures`, so they always compare Rust against what the current Go code produces. A new `test/rust/conformance` target runs only the library unit tests and `protocol_fixtures`, which hold every fixture check. The Rust test jobs set up Go for the generator, and the Rust workflow also runs when anything under `conformance/` changes. Give every crate an `include` allowlist so only sources, examples, docs, migrations, the README, and the license are published; tests and their fixtures stay in the repository. `riverqueue` currently publishes all of `tests/`. `make check/rust/package` now also fails if any crate archive would contain a `tests`, `fixtures`, or `testdata` path or a JSON file. --- .github/workflows/rust.yaml | 10 + Makefile | 13 + rust/README.md | 9 +- rust/riverqueue-cli/Cargo.toml | 3 + rust/riverqueue-macros/Cargo.toml | 3 + rust/riverqueue-migrate/Cargo.toml | 3 + rust/riverqueue-test/Cargo.toml | 3 + rust/riverqueue/Cargo.toml | 3 + rust/riverqueue/src/client/executor.rs | 6 +- rust/riverqueue/src/conformance.rs | 27 + rust/riverqueue/src/lib.rs | 2 + rust/riverqueue/src/periodic/cron.rs | 3 +- rust/riverqueue/src/unique.rs | 7 +- .../tests/fixtures/maintenance_values.json | 679 ------------- .../tests/fixtures/protocol_values.json | 317 ------ .../tests/fixtures/unique_keys.json | 948 ------------------ rust/riverqueue/tests/protocol_fixtures.rs | 24 +- 17 files changed, 103 insertions(+), 1957 deletions(-) create mode 100644 rust/riverqueue/src/conformance.rs delete mode 100644 rust/riverqueue/tests/fixtures/maintenance_values.json delete mode 100644 rust/riverqueue/tests/fixtures/protocol_values.json delete mode 100644 rust/riverqueue/tests/fixtures/unique_keys.json diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml index 5d8325749..d524012a3 100644 --- a/.github/workflows/rust.yaml +++ b/.github/workflows/rust.yaml @@ -9,6 +9,7 @@ on: paths: - ".github/workflows/rust.yaml" - "Makefile" + - "conformance/**" - "internal/cmd/syncrustmigrations/**" - "riverdriver/riverpgxv5/migration/**" - "riverdriver/riversqlite/migration/**" @@ -17,6 +18,7 @@ on: paths: - ".github/workflows/rust.yaml" - "Makefile" + - "conformance/**" - "internal/cmd/syncrustmigrations/**" - "riverdriver/riverpgxv5/migration/**" - "riverdriver/riversqlite/migration/**" @@ -108,6 +110,10 @@ jobs: rust/target key: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-${{ hashFiles('rust/**/Cargo.toml', 'rust/Cargo.lock') }} restore-keys: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}- + # Tests read fixtures generated from River's Go implementation. + - uses: actions/setup-go@v6 + with: + go-version-file: go.work - name: Check every target and feature run: cargo check --manifest-path rust/Cargo.toml --workspace --all-targets --all-features --locked - name: Unit, doc, and SQLite tests @@ -150,6 +156,10 @@ jobs: rust/target key: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-${{ hashFiles('rust/**/Cargo.toml', 'rust/Cargo.lock') }} restore-keys: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}- + # Tests read fixtures generated from River's Go implementation. + - uses: actions/setup-go@v6 + with: + go-version-file: go.work - name: Create test database run: PGPASSWORD=postgres createdb -h localhost -U postgres river_rust_test - name: Rust unit, SQLite, and PostgreSQL tests diff --git a/Makefile b/Makefile index 259406784..d05dd1d91 100644 --- a/Makefile +++ b/Makefile @@ -162,6 +162,7 @@ test/js/integration: build/js .PHONY: test/rust test/rust: ## Run Rust unit and SQLite tests, plus PostgreSQL tests when RIVER_RUST_DATABASE_URL is set +test/rust: generate/fixtures @if [ -n "$$RIVER_RUST_DATABASE_URL" ]; then \ cd rust && $(RUST_POSTGRES_TESTS_ENV) cargo test --workspace --all-features --locked; \ elif [ -n "$$CI" ]; then \ @@ -171,13 +172,22 @@ test/rust: ## Run Rust unit and SQLite tests, plus PostgreSQL tests when RIVER_R cd rust && cargo test --workspace --features riverqueue/sqlite,riverqueue-migrate/sqlite --locked; \ fi +# Only the tests that compare Rust with fixtures generated from River's Go +# implementation, for checking a Go change against the port. +.PHONY: test/rust/conformance +test/rust/conformance: ## Run Rust tests that check Go-generated conformance fixtures +test/rust/conformance: generate/fixtures + cd rust && cargo test -p riverqueue --features chrono-tz --lib --test protocol_fixtures --locked + .PHONY: test/rust/postgres test/rust/postgres: ## Run all Rust tests, including PostgreSQL integration tests (requires RIVER_RUST_DATABASE_URL) +test/rust/postgres: generate/fixtures @test -n "$$RIVER_RUST_DATABASE_URL" || { echo "RIVER_RUST_DATABASE_URL is required" >&2; exit 1; } cd rust && $(RUST_POSTGRES_TESTS_ENV) cargo test --workspace --all-features --locked .PHONY: test/rust/sqlite test/rust/sqlite: ## Run Rust unit, doc, and SQLite integration tests without a PostgreSQL database +test/rust/sqlite: generate/fixtures cd rust && cargo test --workspace --features riverqueue/sqlite,riverqueue-migrate/sqlite --locked .PHONY: doc/js @@ -225,6 +235,9 @@ check/rust/package: ## Build and verify publishable crate archives without publi cd rust && package_build_dir=$$(mktemp -d) && \ trap 'rm -rf "$$package_build_dir"' EXIT && \ CARGO_BUILD_BUILD_DIR="$$package_build_dir" cargo package --workspace --allow-dirty --locked + cd rust && for crate in riverqueue riverqueue-cli riverqueue-macros riverqueue-migrate riverqueue-test; do \ + ! cargo package --list --allow-dirty --locked -p $$crate | grep -E '(^|/)(tests|fixtures|testdata)/|\.json$$' | grep -vxF .cargo_vcs_info.json || exit 1; \ + done # The baseline is the latest published riverqueue-v* tag, and # cargo-semver-checks infers the allowed change from the version bump. It diff --git a/rust/README.md b/rust/README.md index 4bf34c0db..a4a25edb4 100644 --- a/rust/README.md +++ b/rust/README.md @@ -68,9 +68,12 @@ RIVER_RUST_DATABASE_URL=postgres://localhost/river_rust_test \ ``` CI runs unit, doc, and SQLite tests on each supported Rust version, and -PostgreSQL tests against versions 14 through 18. Rust tests include local -reference fixtures for unique keys, retry bounds, and cron schedules; they -run with Cargo without requiring Go or a separate test harness. +PostgreSQL tests against versions 14 through 18. Rust tests check unique +keys, retry bounds, cron schedules, and snooze counts against fixtures that +River's Go implementation generates into `conformance/testdata`, which isn't +committed. The `make test/rust` targets generate them first, so Go is needed +to run the tests; when running `cargo test` directly, run `make +generate/fixtures` beforehand. A missing fixture fails its test. `make check/rust/package` builds the five publishable crate archives and verifies that each one builds from its packaged sources, resolving the diff --git a/rust/riverqueue-cli/Cargo.toml b/rust/riverqueue-cli/Cargo.toml index 34f27a7e3..c7a38623f 100644 --- a/rust/riverqueue-cli/Cargo.toml +++ b/rust/riverqueue-cli/Cargo.toml @@ -10,6 +10,9 @@ license.workspace = true readme = "README.md" repository.workspace = true homepage.workspace = true +# Only what builds and documents the crate ships; tests and their fixtures stay +# in the repository. +include = ["src/**", "LICENSE", "README.md"] [features] default = ["postgres", "sqlite"] diff --git a/rust/riverqueue-macros/Cargo.toml b/rust/riverqueue-macros/Cargo.toml index 28e922d16..4498ce999 100644 --- a/rust/riverqueue-macros/Cargo.toml +++ b/rust/riverqueue-macros/Cargo.toml @@ -10,6 +10,9 @@ license.workspace = true readme = "README.md" repository.workspace = true homepage.workspace = true +# Only what builds and documents the crate ships; tests and their fixtures stay +# in the repository. +include = ["src/**", "LICENSE", "README.md"] [lib] proc-macro = true diff --git a/rust/riverqueue-migrate/Cargo.toml b/rust/riverqueue-migrate/Cargo.toml index 536372b6b..361e5071a 100644 --- a/rust/riverqueue-migrate/Cargo.toml +++ b/rust/riverqueue-migrate/Cargo.toml @@ -10,6 +10,9 @@ license.workspace = true readme = "README.md" repository.workspace = true homepage.workspace = true +# Only what builds and documents the crate ships; tests and their fixtures stay +# in the repository. +include = ["src/**", "examples/**", "migrations/**", "LICENSE", "README.md"] [package.metadata.docs.rs] all-features = true diff --git a/rust/riverqueue-test/Cargo.toml b/rust/riverqueue-test/Cargo.toml index ce72e2415..fc89ed704 100644 --- a/rust/riverqueue-test/Cargo.toml +++ b/rust/riverqueue-test/Cargo.toml @@ -10,6 +10,9 @@ license.workspace = true readme = "README.md" repository.workspace = true homepage.workspace = true +# Only what builds and documents the crate ships; tests and their fixtures stay +# in the repository. +include = ["src/**", "LICENSE", "README.md"] [package.metadata.docs.rs] all-features = true diff --git a/rust/riverqueue/Cargo.toml b/rust/riverqueue/Cargo.toml index 9febe6795..b4457be83 100644 --- a/rust/riverqueue/Cargo.toml +++ b/rust/riverqueue/Cargo.toml @@ -10,6 +10,9 @@ license.workspace = true readme = "README.md" repository.workspace = true homepage.workspace = true +# Only what builds and documents the crate ships; tests and their fixtures stay +# in the repository. +include = ["src/**", "docs/**", "examples/**", "LICENSE", "README.md"] [package.metadata.docs.rs] all-features = true diff --git a/rust/riverqueue/src/client/executor.rs b/rust/riverqueue/src/client/executor.rs index 2aa276516..4df75d75f 100644 --- a/rust/riverqueue/src/client/executor.rs +++ b/rust/riverqueue/src/client/executor.rs @@ -748,7 +748,7 @@ fn go_json_int(value: Option<&serde_json::value::RawValue>) -> i64 { #[cfg(test)] mod go_json_int_tests { - use crate::JobMetadata; + use crate::{JobMetadata, conformance::read_fixture}; use serde::Deserialize; use super::go_json_int; @@ -767,9 +767,7 @@ mod go_json_int_tests { #[test] fn snooze_counter_matches_go_fixture() { - let fixture: Fixture = - serde_json::from_str(include_str!("../../tests/fixtures/maintenance_values.json")) - .unwrap(); + let fixture: Fixture = serde_json::from_str(&read_fixture("snooze_counters.json")).unwrap(); assert!(!fixture.snooze_counters.is_empty()); for case in fixture.snooze_counters { assert_eq!( diff --git a/rust/riverqueue/src/conformance.rs b/rust/riverqueue/src/conformance.rs new file mode 100644 index 000000000..a041d7faf --- /dev/null +++ b/rust/riverqueue/src/conformance.rs @@ -0,0 +1,27 @@ +//! Access to the fixtures River's Go implementation generates for the ports' +//! conformance tests. + +use std::{io::ErrorKind, path::Path}; + +/// Reads `name` from `conformance/testdata`, where `make generate/fixtures` +/// writes fixtures produced by River's Go implementation. +/// +/// # Panics +/// +/// Panics when the fixture can't be read, so a missing fixture fails the test +/// rather than skipping it. +pub(crate) fn read_fixture(name: &str) -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../conformance/testdata") + .join(name); + std::fs::read_to_string(&path).unwrap_or_else(|error| match error.kind() { + ErrorKind::NotFound => panic!( + "missing conformance fixture {}; run `make generate/fixtures` from the repository root", + path.display() + ), + _ => panic!( + "error reading conformance fixture {}: {error}", + path.display() + ), + }) +} diff --git a/rust/riverqueue/src/lib.rs b/rust/riverqueue/src/lib.rs index e84ec151c..24455ee7e 100644 --- a/rust/riverqueue/src/lib.rs +++ b/rust/riverqueue/src/lib.rs @@ -22,6 +22,8 @@ extern crate self as riverqueue; #[doc(hidden)] pub mod __private; mod client; +#[cfg(test)] +mod conformance; pub mod database; pub mod encoding; pub mod error; diff --git a/rust/riverqueue/src/periodic/cron.rs b/rust/riverqueue/src/periodic/cron.rs index eb027b280..89941d355 100644 --- a/rust/riverqueue/src/periodic/cron.rs +++ b/rust/riverqueue/src/periodic/cron.rs @@ -715,6 +715,7 @@ mod tests { use serde::Deserialize; use super::{CronSchedule, CronTimeZone, PeriodicSchedule}; + use crate::conformance::read_fixture; #[derive(Deserialize)] #[expect( @@ -736,7 +737,7 @@ mod tests { } fn fixture() -> Fixture { - serde_json::from_str(include_str!("../../tests/fixtures/maintenance_values.json")).unwrap() + serde_json::from_str(&read_fixture("cron_schedules.json")).unwrap() } fn assert_matches_go(case: &CronCase) { diff --git a/rust/riverqueue/src/unique.rs b/rust/riverqueue/src/unique.rs index 33e5602cc..623b12976 100644 --- a/rust/riverqueue/src/unique.rs +++ b/rust/riverqueue/src/unique.rs @@ -474,7 +474,10 @@ mod tests { use serde::{Deserialize, Serialize}; use super::*; - use crate::{JobArgs, JobArgs as JobArgsDerive, JobState, encoding::encode_args}; + use crate::{ + JobArgs, JobArgs as JobArgsDerive, JobState, conformance::read_fixture, + encoding::encode_args, + }; #[derive(Deserialize)] struct Fixture { @@ -537,7 +540,7 @@ mod tests { } fn fixture() -> Fixture { - serde_json::from_str(include_str!("../tests/fixtures/unique_keys.json")).unwrap() + serde_json::from_str(&read_fixture("unique_keys.json")).unwrap() } fn golden(name: &str) -> FixtureCase { diff --git a/rust/riverqueue/tests/fixtures/maintenance_values.json b/rust/riverqueue/tests/fixtures/maintenance_values.json deleted file mode 100644 index 1b69ceb1c..000000000 --- a/rust/riverqueue/tests/fixtures/maintenance_values.json +++ /dev/null @@ -1,679 +0,0 @@ -{ - "cron_cases": [ - { - "expression": "* * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_minute", - "next": [ - "2026-01-02T03:05:00Z", - "2026-01-02T03:06:00Z", - "2026-01-02T03:07:00Z", - "2026-01-02T03:08:00Z", - "2026-01-02T03:09:00Z" - ] - }, - { - "expression": "30 * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "half_past_every_hour", - "next": [ - "2026-01-02T03:30:00Z", - "2026-01-02T04:30:00Z", - "2026-01-02T05:30:00Z", - "2026-01-02T06:30:00Z", - "2026-01-02T07:30:00Z" - ] - }, - { - "expression": "0 9 * * 1", - "from": "2026-01-02T03:04:05.6789Z", - "name": "monday_numeric_weekday", - "next": [ - "2026-01-05T09:00:00Z", - "2026-01-12T09:00:00Z", - "2026-01-19T09:00:00Z", - "2026-01-26T09:00:00Z", - "2026-02-02T09:00:00Z" - ] - }, - { - "expression": "0 9 * * mon", - "from": "2026-01-02T03:04:05.6789Z", - "name": "monday_named_weekday", - "next": [ - "2026-01-05T09:00:00Z", - "2026-01-12T09:00:00Z", - "2026-01-19T09:00:00Z", - "2026-01-26T09:00:00Z", - "2026-02-02T09:00:00Z" - ] - }, - { - "expression": "0 0 * * 0", - "from": "2026-01-02T03:04:05.6789Z", - "name": "sunday_is_zero", - "next": [ - "2026-01-04T00:00:00Z", - "2026-01-11T00:00:00Z", - "2026-01-18T00:00:00Z", - "2026-01-25T00:00:00Z", - "2026-02-01T00:00:00Z" - ] - }, - { - "expression": "0 0 * * SUN", - "from": "2026-01-02T03:04:05.6789Z", - "name": "weekday_names_ignore_case", - "next": [ - "2026-01-04T00:00:00Z", - "2026-01-11T00:00:00Z", - "2026-01-18T00:00:00Z", - "2026-01-25T00:00:00Z", - "2026-02-01T00:00:00Z" - ] - }, - { - "expression": "*/15 9-17 * * mon-fri", - "from": "2026-01-02T03:04:05.6789Z", - "name": "business_hours_steps", - "next": [ - "2026-01-02T09:00:00Z", - "2026-01-02T09:15:00Z", - "2026-01-02T09:30:00Z", - "2026-01-02T09:45:00Z", - "2026-01-02T10:00:00Z" - ] - }, - { - "expression": "0 0 1 * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "first_of_month", - "next": [ - "2026-02-01T00:00:00Z", - "2026-03-01T00:00:00Z", - "2026-04-01T00:00:00Z", - "2026-05-01T00:00:00Z", - "2026-06-01T00:00:00Z" - ] - }, - { - "expression": "0 0 1 jan,JUL *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "named_months", - "next": [ - "2026-07-01T00:00:00Z", - "2027-01-01T00:00:00Z", - "2027-07-01T00:00:00Z", - "2028-01-01T00:00:00Z", - "2028-07-01T00:00:00Z" - ] - }, - { - "expression": "0 0 29 2 *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "leap_day", - "next": [ - "2028-02-29T00:00:00Z", - "2032-02-29T00:00:00Z", - "2036-02-29T00:00:00Z", - "2040-02-29T00:00:00Z", - "2044-02-29T00:00:00Z" - ] - }, - { - "expression": "0 0 30 2 *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "impossible_date_never_runs", - "next": [] - }, - { - "expression": "0 12 1,15 * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "day_of_month_or_weekday", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-15T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z" - ] - }, - { - "expression": "0 12 * * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "wildcard_day_of_month_and_weekday", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z", - "2026-01-30T12:00:00Z" - ] - }, - { - "expression": "0 12 ? * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "question_mark_wildcard", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z", - "2026-01-30T12:00:00Z" - ] - }, - { - "expression": "0 12 */2 * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "stepped_day_of_month_or_weekday", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-03T12:00:00Z", - "2026-01-05T12:00:00Z", - "2026-01-07T12:00:00Z", - "2026-01-09T12:00:00Z" - ] - }, - { - "expression": "0 12 */1 * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "unit_step_keeps_wildcard", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z", - "2026-01-30T12:00:00Z" - ] - }, - { - "expression": "5/15 * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "start_with_step", - "next": [ - "2026-01-02T03:05:00Z", - "2026-01-02T03:20:00Z", - "2026-01-02T03:35:00Z", - "2026-01-02T03:50:00Z", - "2026-01-02T04:05:00Z" - ] - }, - { - "expression": "0-10/5 * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "range_with_step", - "next": [ - "2026-01-02T03:05:00Z", - "2026-01-02T03:10:00Z", - "2026-01-02T04:00:00Z", - "2026-01-02T04:05:00Z", - "2026-01-02T04:10:00Z" - ] - }, - { - "expression": "59 23 31 12 *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "year_end", - "next": [ - "2026-12-31T23:59:00Z", - "2027-12-31T23:59:00Z", - "2028-12-31T23:59:00Z", - "2029-12-31T23:59:00Z", - "2030-12-31T23:59:00Z" - ] - }, - { - "expression": "@hourly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_hourly", - "next": [ - "2026-01-02T04:00:00Z", - "2026-01-02T05:00:00Z", - "2026-01-02T06:00:00Z", - "2026-01-02T07:00:00Z", - "2026-01-02T08:00:00Z" - ] - }, - { - "expression": "@daily", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_daily", - "next": [ - "2026-01-03T00:00:00Z", - "2026-01-04T00:00:00Z", - "2026-01-05T00:00:00Z", - "2026-01-06T00:00:00Z", - "2026-01-07T00:00:00Z" - ] - }, - { - "expression": "@midnight", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_midnight", - "next": [ - "2026-01-03T00:00:00Z", - "2026-01-04T00:00:00Z", - "2026-01-05T00:00:00Z", - "2026-01-06T00:00:00Z", - "2026-01-07T00:00:00Z" - ] - }, - { - "expression": "@weekly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_weekly", - "next": [ - "2026-01-04T00:00:00Z", - "2026-01-11T00:00:00Z", - "2026-01-18T00:00:00Z", - "2026-01-25T00:00:00Z", - "2026-02-01T00:00:00Z" - ] - }, - { - "expression": "@monthly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_monthly", - "next": [ - "2026-02-01T00:00:00Z", - "2026-03-01T00:00:00Z", - "2026-04-01T00:00:00Z", - "2026-05-01T00:00:00Z", - "2026-06-01T00:00:00Z" - ] - }, - { - "expression": "@yearly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_yearly", - "next": [ - "2027-01-01T00:00:00Z", - "2028-01-01T00:00:00Z", - "2029-01-01T00:00:00Z", - "2030-01-01T00:00:00Z", - "2031-01-01T00:00:00Z" - ] - }, - { - "expression": "@annually", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_annually", - "next": [ - "2027-01-01T00:00:00Z", - "2028-01-01T00:00:00Z", - "2029-01-01T00:00:00Z", - "2030-01-01T00:00:00Z", - "2031-01-01T00:00:00Z" - ] - }, - { - "expression": "@every 1h30m", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_compound_duration", - "next": [ - "2026-01-02T04:34:05Z", - "2026-01-02T06:04:05Z", - "2026-01-02T07:34:05Z", - "2026-01-02T09:04:05Z", - "2026-01-02T10:34:05Z" - ] - }, - { - "expression": "@every 1.5h", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_fractional_duration", - "next": [ - "2026-01-02T04:34:05Z", - "2026-01-02T06:04:05Z", - "2026-01-02T07:34:05Z", - "2026-01-02T09:04:05Z", - "2026-01-02T10:34:05Z" - ] - }, - { - "expression": "@every 90s", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_seconds", - "next": [ - "2026-01-02T03:05:35Z", - "2026-01-02T03:07:05Z", - "2026-01-02T03:08:35Z", - "2026-01-02T03:10:05Z", - "2026-01-02T03:11:35Z" - ] - }, - { - "expression": "@every 500ms", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_rounds_up_to_one_second", - "next": [ - "2026-01-02T03:04:06Z", - "2026-01-02T03:04:07Z", - "2026-01-02T03:04:08Z", - "2026-01-02T03:04:09Z", - "2026-01-02T03:04:10Z" - ] - }, - { - "expression": "@every 1500ms", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_truncates_subseconds", - "next": [ - "2026-01-02T03:04:06Z", - "2026-01-02T03:04:07Z", - "2026-01-02T03:04:08Z", - "2026-01-02T03:04:09Z", - "2026-01-02T03:04:10Z" - ] - }, - { - "expression": "0 9 * * *", - "from": "2026-03-07T08:00:00-05:00", - "name": "reference_time_offset", - "next": [ - "2026-03-07T09:00:00-05:00", - "2026-03-08T09:00:00-05:00", - "2026-03-09T09:00:00-05:00", - "2026-03-10T09:00:00-05:00", - "2026-03-11T09:00:00-05:00" - ] - }, - { - "expression": "30 0 * * *", - "from": "2026-03-07T23:45:00+05:30", - "name": "reference_time_half_hour_offset", - "next": [ - "2026-03-08T00:30:00+05:30", - "2026-03-09T00:30:00+05:30", - "2026-03-10T00:30:00+05:30", - "2026-03-11T00:30:00+05:30", - "2026-03-12T00:30:00+05:30" - ] - }, - { - "expression": "CRON_TZ=UTC 0 9 * * *", - "from": "2026-03-07T08:00:00-05:00", - "name": "cron_tz_utc_prefix", - "next": [ - "2026-03-08T04:00:00-05:00", - "2026-03-09T04:00:00-05:00", - "2026-03-10T04:00:00-05:00", - "2026-03-11T04:00:00-05:00", - "2026-03-12T04:00:00-05:00" - ] - }, - { - "expression": "TZ=UTC 0 9 * * *", - "from": "2026-03-07T08:00:00-05:00", - "name": "tz_utc_prefix", - "next": [ - "2026-03-08T04:00:00-05:00", - "2026-03-09T04:00:00-05:00", - "2026-03-10T04:00:00-05:00", - "2026-03-11T04:00:00-05:00", - "2026-03-12T04:00:00-05:00" - ] - }, - { - "expression": " 0 9 * * 1 ", - "from": "2026-01-02T03:04:05.6789Z", - "name": "extra_whitespace", - "next": [ - "2026-01-05T09:00:00Z", - "2026-01-12T09:00:00Z", - "2026-01-19T09:00:00Z", - "2026-01-26T09:00:00Z", - "2026-02-02T09:00:00Z" - ] - } - ], - "cron_invalid": [ - "", - "* * * *", - "* * * * * *", - "0 9 * * 7", - "60 * * * *", - "* 24 * * *", - "* * 0 * *", - "* * 32 * *", - "* * * 0 *", - "* * * 13 *", - "-1 * * * *", - "5-1 * * * *", - "1-2-3 * * * *", - "1/2/3 * * * *", - "*/0 * * * *", - "*/x * * * *", - "0 9 * * funday", - "@every", - "@every 5x", - "@reboot", - "CRON_TZ=Nowhere/Invalid 0 9 * * *" - ], - "cron_named_zone_cases": [ - { - "expression": "CRON_TZ=America/New_York 0 9 * * *", - "from": "2026-03-06T12:00:00Z", - "name": "new_york_across_dst_start", - "next": [ - "2026-03-06T14:00:00Z", - "2026-03-07T14:00:00Z", - "2026-03-08T13:00:00Z", - "2026-03-09T13:00:00Z", - "2026-03-10T13:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/New_York 30 2 * * *", - "from": "2026-03-06T12:00:00Z", - "name": "new_york_skipped_wall_time", - "next": [ - "2026-03-07T07:30:00Z", - "2026-03-09T06:30:00Z", - "2026-03-10T06:30:00Z", - "2026-03-11T06:30:00Z", - "2026-03-12T06:30:00Z" - ] - }, - { - "expression": "CRON_TZ=America/New_York 30 1 * * *", - "from": "2026-10-30T12:00:00Z", - "name": "new_york_repeated_wall_time", - "next": [ - "2026-10-31T05:30:00Z", - "2026-11-01T05:30:00Z", - "2026-11-01T06:30:00Z", - "2026-11-02T06:30:00Z", - "2026-11-03T06:30:00Z" - ] - }, - { - "expression": "CRON_TZ=America/New_York 0 * * * *", - "from": "2026-11-01T04:30:00Z", - "name": "new_york_hourly_across_dst_end", - "next": [ - "2026-11-01T05:00:00Z", - "2026-11-01T06:00:00Z", - "2026-11-01T07:00:00Z", - "2026-11-01T08:00:00Z", - "2026-11-01T09:00:00Z" - ] - }, - { - "expression": "CRON_TZ=Europe/London 0 0 * * *", - "from": "2026-10-23T12:00:00Z", - "name": "london_across_dst_end", - "next": [ - "2026-10-23T23:00:00Z", - "2026-10-24T23:00:00Z", - "2026-10-26T00:00:00Z", - "2026-10-27T00:00:00Z", - "2026-10-28T00:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/Santiago 0 0 * * *", - "from": "2026-09-03T12:00:00Z", - "name": "santiago_skipped_midnight", - "next": [ - "2026-09-04T04:00:00Z", - "2026-09-05T04:00:00Z", - "2026-09-07T03:00:00Z", - "2026-09-08T03:00:00Z", - "2026-09-09T03:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/Santiago 0 12 * * *", - "from": "2026-09-03T12:00:00Z", - "name": "santiago_day_after_skipped_midnight", - "next": [ - "2026-09-03T16:00:00Z", - "2026-09-04T16:00:00Z", - "2026-09-05T16:00:00Z", - "2026-09-06T15:00:00Z", - "2026-09-07T15:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/Santiago 30 23 * * *", - "from": "2026-04-02T12:00:00Z", - "name": "santiago_repeated_hour_before_midnight", - "next": [ - "2026-04-03T02:30:00Z", - "2026-04-04T02:30:00Z", - "2026-04-05T02:30:00Z", - "2026-04-05T03:30:00Z", - "2026-04-06T03:30:00Z" - ] - }, - { - "expression": "TZ=Asia/Kolkata 0 9 * * mon", - "from": "2026-01-02T03:04:05-05:00", - "name": "kolkata_tz_prefix", - "next": [ - "2026-01-04T22:30:00-05:00", - "2026-01-11T22:30:00-05:00", - "2026-01-18T22:30:00-05:00", - "2026-01-25T22:30:00-05:00", - "2026-02-01T22:30:00-05:00" - ] - } - ], - "snooze_counters": [ - { - "expected_snoozes": 1, - "metadata": {}, - "name": "absent" - }, - { - "expected_snoozes": 3, - "metadata": { - "snoozes": 2 - }, - "name": "integer" - }, - { - "expected_snoozes": 3, - "metadata": { - "snoozes": 2.9 - }, - "name": "fraction_truncates" - }, - { - "expected_snoozes": -1, - "metadata": { - "snoozes": -2.5 - }, - "name": "negative_fraction_truncates_toward_zero" - }, - { - "expected_snoozes": 1001, - "metadata": { - "snoozes": 1e3 - }, - "name": "exponent" - }, - { - "expected_snoozes": 9007199254740994, - "metadata": { - "snoozes": 9007199254740993 - }, - "name": "beyond_float_precision" - }, - { - "expected_snoozes": 5, - "metadata": { - "snoozes": "4" - }, - "name": "numeric_string" - }, - { - "expected_snoozes": -6, - "metadata": { - "snoozes": "-7" - }, - "name": "negative_numeric_string" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": "4.5" - }, - "name": "fractional_string_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": " 5" - }, - "name": "padded_string_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": "abc" - }, - "name": "non_numeric_string_is_zero" - }, - { - "expected_snoozes": 2, - "metadata": { - "snoozes": true - }, - "name": "true_is_one" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": false - }, - "name": "false_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": null - }, - "name": "null_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": [ - 3 - ] - }, - "name": "array_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": { - "count": 3 - } - }, - "name": "object_is_zero" - } - ] -} diff --git a/rust/riverqueue/tests/fixtures/protocol_values.json b/rust/riverqueue/tests/fixtures/protocol_values.json deleted file mode 100644 index 15fb9a779..000000000 --- a/rust/riverqueue/tests/fixtures/protocol_values.json +++ /dev/null @@ -1,317 +0,0 @@ -{ - "attempt_error": { - "at": "2026-01-02T03:04:05.6789Z", - "attempt": 3, - "error": "worker failed: escaped \"detail\"", - "trace": "frame one\nframe two" - }, - "job_states": [ - { - "state": "available", - "unique_bit": 1 - }, - { - "state": "cancelled", - "unique_bit": 2 - }, - { - "state": "completed", - "unique_bit": 4 - }, - { - "state": "discarded", - "unique_bit": 8 - }, - { - "state": "pending", - "unique_bit": 16 - }, - { - "state": "retryable", - "unique_bit": 32 - }, - { - "state": "running", - "unique_bit": 64 - }, - { - "state": "scheduled", - "unique_bit": 128 - } - ], - "metadata_keys": { - "output": "output", - "periodic_job_id": "river:periodic_job_id", - "rescue_count": "river:rescue_count", - "resumable_cursor": "river:resumable_cursor", - "resumable_step": "river:resumable_step", - "unique_nonce": "river:unique_nonce" - }, - "notifications": [ - { - "fields": [ - { - "name": "action", - "omitempty": false - }, - { - "name": "job_id", - "omitempty": true - }, - { - "name": "metadata", - "omitempty": true - }, - { - "name": "queue", - "omitempty": false - } - ], - "name": "cancel", - "payload": { - "action": "cancel", - "job_id": 42, - "queue": "priority" - }, - "source": "producer.go:controlEventPayload; riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql:JobCancel", - "topic": "river_control" - }, - { - "fields": [ - { - "name": "queue", - "omitempty": false - } - ], - "name": "insert", - "payload": { - "queue": "priority" - }, - "source": "producer.go:insertPayload", - "topic": "river_insert" - }, - { - "fields": [ - { - "name": "action", - "omitempty": false - }, - { - "name": "job_id", - "omitempty": true - }, - { - "name": "metadata", - "omitempty": true - }, - { - "name": "queue", - "omitempty": false - } - ], - "name": "metadata_changed", - "payload": { - "action": "metadata_changed", - "metadata": { - "owner": "candidate" - }, - "queue": "priority" - }, - "source": "producer.go:controlEventPayload", - "topic": "river_control" - }, - { - "fields": [ - { - "name": "action", - "omitempty": false - }, - { - "name": "job_id", - "omitempty": true - }, - { - "name": "metadata", - "omitempty": true - }, - { - "name": "queue", - "omitempty": false - } - ], - "name": "pause", - "payload": { - "action": "pause", - "queue": "priority" - }, - "source": "producer.go:controlEventPayload", - "topic": "river_control" - }, - { - "fields": [ - { - "name": "action", - "omitempty": false - }, - { - "name": "leader_id", - "omitempty": false - } - ], - "name": "request_resign", - "payload": { - "action": "request_resign", - "leader_id": "" - }, - "source": "internal/leadership/elector.go:DBNotification", - "topic": "river_leadership" - }, - { - "fields": [ - { - "name": "action", - "omitempty": false - }, - { - "name": "leader_id", - "omitempty": false - } - ], - "name": "resigned", - "payload": { - "action": "resigned", - "leader_id": "client-1" - }, - "source": "internal/leadership/elector.go:DBNotification; riverdriver/riverpgxv5/internal/dbsqlc/river_leader.sql:LeaderResign", - "topic": "river_leadership" - }, - { - "fields": [ - { - "name": "action", - "omitempty": false - }, - { - "name": "job_id", - "omitempty": true - }, - { - "name": "metadata", - "omitempty": true - }, - { - "name": "queue", - "omitempty": false - } - ], - "name": "resume", - "payload": { - "action": "resume", - "queue": "priority" - }, - "source": "producer.go:controlEventPayload", - "topic": "river_control" - } - ], - "reserved_metadata_keys": [ - { - "applicability": "protocol_visible", - "key": "cancel_attempted_at" - }, - { - "applicability": "protocol_visible", - "key": "output" - }, - { - "applicability": "protocol_visible", - "key": "periodic" - }, - { - "applicability": "protocol_visible", - "key": "river:log" - }, - { - "applicability": "protocol_visible", - "key": "river:periodic_job_id" - }, - { - "applicability": "protocol_visible", - "key": "river:rescue_count" - }, - { - "applicability": "protocol_visible", - "key": "river:resumable_cursor" - }, - { - "applicability": "protocol_visible", - "key": "river:resumable_step" - }, - { - "applicability": "protocol_visible", - "key": "river:unique_nonce" - }, - { - "applicability": "protocol_visible", - "key": "snoozes" - }, - { - "applicability": "protocol_visible", - "key": "unique_key_conflict" - } - ], - "retry_cases": [ - { - "error_count": 1, - "job_id": 42, - "max_delay_ns": 1100000000, - "min_delay_ns": 900000000, - "now": "2026-01-02T03:04:05.6789Z", - "seed": 0 - }, - { - "error_count": 2, - "job_id": 42, - "max_delay_ns": 17600000000, - "min_delay_ns": 14400000000, - "now": "2026-01-02T03:04:05.6789Z", - "seed": 123 - }, - { - "error_count": 3, - "job_id": 9007199254740991, - "max_delay_ns": 89100000000, - "min_delay_ns": 72900000000, - "now": "2026-01-02T03:04:05.6789Z", - "seed": 18446744073709551615 - }, - { - "error_count": 11, - "job_id": 1, - "max_delay_ns": 16105100000000, - "min_delay_ns": 13176900000000, - "now": "2026-01-02T03:04:05.6789Z", - "seed": 456 - }, - { - "error_count": 309, - "job_id": 42, - "max_delay_ns": 9223372036854775807, - "min_delay_ns": 8204959224899999744, - "now": "2026-01-02T03:04:05.6789Z", - "seed": 789 - }, - { - "error_count": 310, - "job_id": 42, - "max_delay_ns": 9223372036854775807, - "min_delay_ns": 9223372036854775807, - "now": "2026-01-02T03:04:05.6789Z", - "seed": 123 - } - ], - "topics": { - "control": "river_control", - "insert": "river_insert", - "leadership": "river_leadership" - } -} diff --git a/rust/riverqueue/tests/fixtures/unique_keys.json b/rust/riverqueue/tests/fixtures/unique_keys.json deleted file mode 100644 index 066f4d86e..000000000 --- a/rust/riverqueue/tests/fixtures/unique_keys.json +++ /dev/null @@ -1,948 +0,0 @@ -{ - "cases": [ - { - "args": {}, - "expected_sha256": "23aa86692d9807ab10e433e378f1c0804573f5e345818461b919322dd381b4c3", - "expected_state_mask": 245, - "kind": "conformance_selected_args", - "name": "all_selected_fields_omitted", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "account", - "id" - ], - [ - "account", - "region" - ], - [ - "label" - ], - [ - "path/key" - ] - ], - "selected_unique_paths": [ - "account.id", - "account.region", - "label", - "path/key" - ] - }, - { - "args": { - "account": { - "id": "acct", - "ignored": "irrelevant", - "region": "west" - }, - "path/key": "slash" - }, - "expected_sha256": "7d62e81ac25cfa2dec69ad5a41e0b78188ee1b299bed329b453da6b3abca70bd", - "expected_state_mask": 245, - "kind": "conformance_selected_args", - "name": "selected_siblings_and_slash_key", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "account", - "id" - ], - [ - "account", - "region" - ], - [ - "label" - ], - [ - "path/key" - ] - ], - "selected_unique_paths": [ - "account.id", - "account.region", - "label", - "path/key" - ] - }, - { - "args": { - "nested": { - "z": 1, - "a": 2 - } - }, - "expected_sha256": "46ff499cb031d0458bb00ef87e7b83321eefb6e7534497c279c4ae0f474cdde0", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "nested_struct_wire_order", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "zeta": "quoted \\\"value\\\" and \\\\ slash", - "alpha": "\u003calpha\u003e\u0026\u2028line", - "maximum": 9007199254740991 - }, - "expected_sha256": "7a84c62c8d470ca388a0a1e41c311b9eb1ea21f7b88157ceb876fc82e698b6af", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_sorted_and_escaped", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "2": 2, - "10": 10, - "zero": -0, - "😀": 1, - "": 2 - }, - "expected_sha256": "fcdf33e0c39c1fc7e956876345a985f2418bd69c6e4d6a5c794abf1e78cdfdb6", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "map_order_and_negative_zero", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "": 0, - "a.b": 1, - "@x": 2, - ":lead": 3, - "!bang": 4, - "[open": 5, - "{brace": 6, - "a\\b": 7 - }, - "expected_sha256": "1d254dda1efe1009ffb205ede791d481545d66abb8542e85f0d895415b05cdf9", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_literal_path_syntax", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "a\"b": 1, - "line\n": 2, - "é": 3, - "a\u003cb": 4, - "a\u0026b": 5, - "a\u2028b": 6 - }, - "expected_sha256": "bab84635792449d758b18950f6f014bfb16d26430e798d84afee9fc9cb793163", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_escaped_key_encoding", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": [], - "expected_sha256": "fe05a58ddb79a8d4544da962582d9a290d59788c920afd3597da3a62e3c1b0ac", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_empty_array", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": [ - 1 - ], - "expected_error": "rejected", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_array_rejected", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": null, - "expected_error": "rejected", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_null_rejected", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": "args", - "expected_error": "rejected", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "all_args_scalar_rejected", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "exponent": 1e+100, - "fraction": 1.25, - "maximum": 9223372036854775807, - "minimum": -9223372036854775808, - "unsigned_maximum": 18446744073709551615 - }, - "expected_sha256": "2c1533b3ab43068407d14e82ddb34a295a51375ae3a27fef6931123f07677f38", - "expected_state_mask": 245, - "kind": "conformance_numeric_boundaries", - "name": "numeric_boundaries", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "account": { - "id": "acct-123", - "ignored": "not selected" - }, - "ignored": true, - "label": "selected" - }, - "expected_sha256": "6130dc4f753402d1faeb6bbc3e6c21415245bb282ad1fd16bcbfeebde525e726", - "expected_state_mask": 245, - "kind": "conformance_selected_args", - "name": "selected_nested_args", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "account", - "id" - ], - [ - "account", - "region" - ], - [ - "label" - ], - [ - "path/key" - ] - ], - "selected_unique_paths": [ - "account.id", - "account.region", - "label", - "path/key" - ] - }, - { - "args": { - "user.id": "literal", - "user": {} - }, - "expected_sha256": "7d478fa6978b3fbb5c326d90fd10c2eab663cf2c9c2e1e3ddcce032989a05cdb", - "expected_state_mask": 245, - "kind": "conformance_dotted_selected_args", - "name": "selected_literal_dotted_name", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "@user" - ], - [ - "!x" - ], - [ - "{x}" - ], - [ - "[x]" - ], - [ - ":id" - ], - [ - "user", - "id" - ], - [ - "user.id" - ], - [ - "a*b?c#d|e" - ], - [ - "é" - ] - ], - "selected_unique_paths": [ - "\\@user", - "\\!x", - "\\{x\\}", - "\\[x\\]", - "\\:id", - "user.id", - "user\\.id", - "a\\*b\\?c\\#d\\|e", - "é" - ] - }, - { - "args": { - "user": { - "id": "nested" - } - }, - "expected_sha256": "6fd34aa5a46274e4f7d159063be43c2c02e90438b84dab208c8c5209f94dda25", - "expected_state_mask": 245, - "kind": "conformance_dotted_selected_args", - "name": "selected_nested_dotted_path", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "@user" - ], - [ - "!x" - ], - [ - "{x}" - ], - [ - "[x]" - ], - [ - ":id" - ], - [ - "user", - "id" - ], - [ - "user.id" - ], - [ - "a*b?c#d|e" - ], - [ - "é" - ] - ], - "selected_unique_paths": [ - "\\@user", - "\\!x", - "\\{x\\}", - "\\[x\\]", - "\\:id", - "user.id", - "user\\.id", - "a\\*b\\?c\\#d\\|e", - "é" - ] - }, - { - "args": { - "user": {}, - "é": "café" - }, - "expected_sha256": "28513f484784e6b0fe8aed6cc1fadb04498f43305b74619aa56e701a2feff578", - "expected_state_mask": 245, - "kind": "conformance_dotted_selected_args", - "name": "selected_unicode_field_name", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "@user" - ], - [ - "!x" - ], - [ - "{x}" - ], - [ - "[x]" - ], - [ - ":id" - ], - [ - "user", - "id" - ], - [ - "user.id" - ], - [ - "a*b?c#d|e" - ], - [ - "é" - ] - ], - "selected_unique_paths": [ - "\\@user", - "\\!x", - "\\{x\\}", - "\\[x\\]", - "\\:id", - "user.id", - "user\\.id", - "a\\*b\\?c\\#d\\|e", - "é" - ] - }, - { - "args": { - "@user": "at", - "!x": "bang", - "{x}": "brace", - "[x]": "bracket", - ":id": "colon", - "a*b?c#d|e": "symbols", - "user": {} - }, - "expected_sha256": "d00ff085218024d7059a4556b24af92cbe57744935e2d37ff644b149fae4c2f3", - "expected_state_mask": 245, - "kind": "conformance_dotted_selected_args", - "name": "selected_punctuation_field_names", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "@user" - ], - [ - "!x" - ], - [ - "{x}" - ], - [ - "[x]" - ], - [ - ":id" - ], - [ - "user", - "id" - ], - [ - "user.id" - ], - [ - "a*b?c#d|e" - ], - [ - "é" - ] - ], - "selected_unique_paths": [ - "\\@user", - "\\!x", - "\\{x\\}", - "\\[x\\]", - "\\:id", - "user.id", - "user\\.id", - "a\\*b\\?c\\#d\\|e", - "é" - ] - }, - { - "args": { - "empty": [], - "labels": { - "alpha": "first", - "k10": "ten", - "k2": "two", - "zulu": "last" - }, - "matrix": [ - [ - 3, - 1 - ], - [], - [ - 2 - ] - ], - "missing": null, - "objects": [ - { - "zulu": "z", - "alpha": 1 - }, - { - "zulu": "y", - "alpha": null - } - ], - "pointer": null - }, - "expected_sha256": "66d457888b4b71f0a0041251f75d494e1ac543717283d84633a274648559306d", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_collections_and_nulls", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": {}, - "expected_sha256": "fe05a58ddb79a8d4544da962582d9a290d59788c920afd3597da3a62e3c1b0ac", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_empty_args", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "a\u003cb\u003e": "\u003cangle\u003e", - "controls": "\b\f\n\r\t\u0000\u0001\u001f", - "html": "\u003ca href=\"x\"\u003e\u0026amp;\u003c/a\u003e", - "keys": { - "\u003ck\u003e": 1, - "a\u0026b": 2, - "é": 3, - "é\u003c": 4 - }, - "separators": "line\u2028paragraph\u2029end", - "unicode": "é😀/\\", - "é\u0026": "unicode key" - }, - "expected_sha256": "a4c2a164225cba2e3ae56edbc11258e49a7325d73a9b70e5c9b47a51963d00c5", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_escaping", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "label": null - }, - "expected_sha256": "d137d7c4f1e3f8369037b1890357655b4fa1978a329bcf51435f9f0b54abcab7", - "expected_state_mask": 245, - "kind": "conformance_selected_args", - "name": "selected_explicit_null", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_components": [ - [ - "account", - "id" - ], - [ - "account", - "region" - ], - [ - "label" - ], - [ - "path/key" - ] - ], - "selected_unique_paths": [ - "account.id", - "account.region", - "label", - "path/key" - ] - }, - { - "args": { - "fraction": "2026-01-02T03:04:05.5Z", - "micros": "2026-01-02T03:04:05.123456Z", - "millis": "2026-01-02T03:04:05.12Z", - "whole": "2026-01-02T03:04:05Z" - }, - "expected_sha256": "18b38780de3019cc75d49ff24a74eecdcff99fda0bb36c0244a35f262b15322b", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_time_values", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "below_large": 999999999999999900000, - "large": 100000000000000000000, - "large_boundary": 1e+21, - "largest": 1.7976931348623157e+308, - "negative": -1.5e-9, - "negative_zero": -0, - "one": 1, - "single": 1.1, - "single_large": 1e+21, - "single_small": 1e-7, - "small": 1e-7, - "small_boundary": 0.000001, - "smallest": 5e-324, - "tenth": 0.1 - }, - "expected_sha256": "dc330477ebe8bf2bc3402476365fcfe22c82253f744481c49a23f760f059cad4", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_float_formatting", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "id": 42 - }, - "expected_sha256": "5396f06a082abd7a929915135ebd363a9a47d800176b03ce7736f93a5ba9e22e", - "expected_state_mask": 245, - "kind": "conformance_simple", - "name": "period_from_now", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": false, - "by_period_nanos": 5400000000000, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "id": 42 - }, - "expected_sha256": "b7f3c49952996b760b8b3ff6cf48f426e03a6ef0f004fb6faa51725365cf309a", - "expected_state_mask": 245, - "kind": "conformance_simple", - "name": "period_from_schedule", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": false, - "by_period_nanos": 3600000000000, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": "2026-01-02T05:21:05.6789Z", - "selected_unique_paths": null - }, - { - "args": { - "id": 42 - }, - "expected_sha256": "5396f06a082abd7a929915135ebd363a9a47d800176b03ce7736f93a5ba9e22e", - "expected_state_mask": 245, - "kind": "conformance_simple", - "name": "period_from_non_utc_now", - "now": "2026-01-01T22:04:05.6789-05:00", - "options": { - "by_args": false, - "by_period_nanos": 3600000000000, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "id": 42 - }, - "expected_sha256": "b7f3c49952996b760b8b3ff6cf48f426e03a6ef0f004fb6faa51725365cf309a", - "expected_state_mask": 245, - "kind": "conformance_simple", - "name": "period_from_non_utc_schedule", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": false, - "by_period_nanos": 3600000000000, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": "2026-01-02T10:51:05.6789+05:30", - "selected_unique_paths": null - }, - { - "args": { - "id": 42 - }, - "expected_sha256": "d20ce47da8e8015e68b020bbca2b17494139519ae05f8985e5992db4d8dd8a09", - "expected_state_mask": 245, - "kind": "conformance_simple", - "name": "queue_without_kind", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": false, - "by_period_nanos": 0, - "by_queue": true, - "exclude_kind": true - }, - "queue": "priority_emails", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "id": 42 - }, - "expected_sha256": "6f20262e7b1fa9beaf98255f23030800376636484a98b20c2c04a303bab5a8d5", - "expected_state_mask": 213, - "kind": "conformance_simple", - "name": "all_dimensions_custom_states", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 60000000000, - "by_queue": true, - "by_state": [ - "available", - "completed", - "pending", - "running", - "scheduled" - ], - "exclude_kind": false - }, - "queue": "priority_emails", - "scheduled_at": "2026-01-02T05:21:05.6789Z", - "selected_unique_paths": null - } - ], - "typed_only_cases": [ - { - "args": { - "a": 1, - "a": 2, - "b": 3 - }, - "expected_sha256": "f3568e94e18a68ce633bd444fe9ec448382740bcf02b83b210cb68e74220fb5f", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_duplicate_top_level_keys", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - }, - { - "args": { - "empty": [], - "labels": { - "10": "ten", - "2": "two", - "alpha": "first", - "zulu": "last" - }, - "matrix": [], - "missing": null, - "objects": [], - "pointer": null - }, - "expected_sha256": "38406019aea1ea67f81186d845e983b197b2d919ded5eca871e4fef0c8b0ad30", - "expected_state_mask": 245, - "kind": "conformance_all_args", - "name": "typed_integer_like_map_keys", - "now": "2026-01-02T03:04:05.6789Z", - "options": { - "by_args": true, - "by_period_nanos": 0, - "by_queue": false, - "exclude_kind": false - }, - "queue": "default", - "scheduled_at": null, - "selected_unique_paths": null - } - ] -} diff --git a/rust/riverqueue/tests/protocol_fixtures.rs b/rust/riverqueue/tests/protocol_fixtures.rs index 82f645aa5..66c64eb3d 100644 --- a/rust/riverqueue/tests/protocol_fixtures.rs +++ b/rust/riverqueue/tests/protocol_fixtures.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{io::ErrorKind, path::Path, time::Duration}; use chrono::{DateTime, Utc}; use riverqueue::{ @@ -46,10 +46,28 @@ struct StateFixture { unique_bit: u8, } +/// Reads `name` from `conformance/testdata`, where `make generate/fixtures` +/// writes fixtures produced by River's Go implementation. A missing fixture +/// fails the test rather than skipping it. +fn read_fixture(name: &str) -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../conformance/testdata") + .join(name); + std::fs::read_to_string(&path).unwrap_or_else(|error| match error.kind() { + ErrorKind::NotFound => panic!( + "missing conformance fixture {}; run `make generate/fixtures` from the repository root", + path.display() + ), + _ => panic!( + "error reading conformance fixture {}: {error}", + path.display() + ), + }) +} + #[test] fn go_protocol_values_match_rust() { - let fixture: Fixture = - serde_json::from_str(include_str!("fixtures/protocol_values.json")).unwrap(); + let fixture: Fixture = serde_json::from_str(&read_fixture("protocol_values.json")).unwrap(); assert_eq!(fixture.attempt_error.attempt, 3); assert!(fixture.attempt_error.error.contains("escaped")); From 13157e2e7dae46d01ce4981b67b75c9e0b1fcb00 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Mon, 5 Oct 2026 17:59:34 -0500 Subject: [PATCH 4/6] read generated Go fixtures in the JavaScript tests Point the cron and snooze counter tests at the fixtures River's Go implementation generates into `conformance/testdata` and delete the hand-copied goldens. Like the Rust tests, they fail with a pointer to `make generate/fixtures` when a fixture is missing instead of skipping. `make test/js` now depends on `generate/fixtures`, and a new `test/js/conformance` target runs only the two fixture test files, which import sources directly and need no build. The JavaScript unit test job sets up Go and generates the fixtures before testing, and the workflow also runs when anything under `conformance/` changes. The package check now also rejects any packed `testdata`, `fixture`, or `golden` path, any `.tsbuildinfo`, and any JSON file other than `package.json` and `migrations/manifest.json`, so test data can't slip into a published tarball. --- .github/workflows/js.yaml | 11 + Makefile | 9 + js/docs/development.md | 9 +- js/package.json | 1 - js/scripts/check-packages.mjs | 14 + js/src/cron.test.ts | 28 +- js/src/runtime/completion-command.test.ts | 28 +- js/src/testdata/cron-goldens.json | 564 ---------------------- js/src/testdata/snooze-counters.json | 117 ----- 9 files changed, 90 insertions(+), 691 deletions(-) delete mode 100644 js/src/testdata/cron-goldens.json delete mode 100644 js/src/testdata/snooze-counters.json diff --git a/.github/workflows/js.yaml b/.github/workflows/js.yaml index f8fe9bb9d..40d1bca74 100644 --- a/.github/workflows/js.yaml +++ b/.github/workflows/js.yaml @@ -10,6 +10,7 @@ on: - ".github/actions/setup-js/**" - ".github/workflows/js.yaml" - "Makefile" + - "conformance/**" - "js/**" - "riverdriver/**/*.sql" tags: ["v*"] @@ -18,6 +19,7 @@ on: - ".github/actions/setup-js/**" - ".github/workflows/js.yaml" - "Makefile" + - "conformance/**" - "js/**" - "riverdriver/**/*.sql" @@ -182,6 +184,15 @@ jobs: with: node-version: ${{ matrix.node-version }} + # Some unit tests read fixtures generated from River's Go + # implementation. + - uses: actions/setup-go@v6 + with: + go-version-file: go.work + + - run: make generate/fixtures + working-directory: . + # The unit tests import the other workspace packages through their # build output, and worker threads run compiled handler modules. - run: pnpm run build:all diff --git a/Makefile b/Makefile index d05dd1d91..a54a2b176 100644 --- a/Makefile +++ b/Makefile @@ -150,8 +150,17 @@ RUST_POSTGRES_TESTS_ENV = RUSTFLAGS="$$RUSTFLAGS --cfg river_postgres_tests" \ .PHONY: test/js test/js: ## Run JavaScript unit tests test/js: build/js +test/js: generate/fixtures pnpm -C js run test +# Only the tests that compare JavaScript with fixtures generated from River's +# Go implementation, for checking a Go change against the port. They import +# sources directly and need no build. +.PHONY: test/js/conformance +test/js/conformance: ## Run JavaScript tests that check Go-generated conformance fixtures +test/js/conformance: generate/fixtures + pnpm -C js exec vitest run src/cron.test.ts src/runtime/completion-command.test.ts + # Integration tests use TEST_DATABASE_URL (default # postgres://localhost:5432/river_test), migrated with # `node js/cli/dist/bin.js migrate-up`. diff --git a/js/docs/development.md b/js/docs/development.md index cb4bfcbb5..894cb94f5 100644 --- a/js/docs/development.md +++ b/js/docs/development.md @@ -197,9 +197,12 @@ formatting, licenses, packed archives and examples, unit tests on Node 26.0.0 and the current Node 26 release, and integration tests on PostgreSQL 14 through 18. -Unit tests compare cron schedules and snooze counting with goldens recorded -from River Go in `src/testdata`, the same values the Rust port checks in its -own fixtures. +Unit tests compare cron schedules and snooze counting with fixtures that +River's Go implementation generates into `conformance/testdata`, the same +files the Rust port reads. They aren't committed: `make test/js` generates +them first, so Go is needed to run the unit tests, and `pnpm run test` needs +a prior `make generate/fixtures` from the repository root. A missing fixture +fails its test. ## Preparing a release diff --git a/js/package.json b/js/package.json index b6daf50cf..7693907be 100644 --- a/js/package.json +++ b/js/package.json @@ -25,7 +25,6 @@ "dist", "src", "!src/**/*.test.ts", - "!src/testdata", "README.md", "LICENSE" ], diff --git a/js/scripts/check-packages.mjs b/js/scripts/check-packages.mjs index f4bc47c0d..0e90d8078 100644 --- a/js/scripts/check-packages.mjs +++ b/js/scripts/check-packages.mjs @@ -26,6 +26,9 @@ import { const execFileAsync = promisify(execFile); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const examplesOnly = process.argv.includes("--examples-only"); +// The only JSON a package may ship: its manifest and runtime migration data. +// Anything else, like Go-generated test goldens, is test data. +const packagedJsonFiles = new Set(["migrations/manifest.json", "package.json"]); const rootPackageJson = JSON.parse( await readFile(join(repositoryRoot, "package.json"), "utf8") ); @@ -184,6 +187,17 @@ async function inspectArchive(packageSpec, archivePath) { [], `${packageSpec.name} does not package test builds` ); + assert.deepEqual( + files.filter( + (file) => + /(?:^|\/)(?:testdata|fixtures?|goldens?)\/|\.tsbuildinfo$/u.test( + file + ) || + (file.endsWith(".json") && !packagedJsonFiles.has(file)) + ), + [], + `${packageSpec.name} does not package test data` + ); const denied = deniedSubstrings(repositoryRoot); for (const file of files) { assertPortableArchivePath(`${packageSpec.name}:${file}`, file); diff --git a/js/src/cron.test.ts b/js/src/cron.test.ts index d5b2f144d..3aaf04375 100644 --- a/js/src/cron.test.ts +++ b/js/src/cron.test.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { @@ -37,8 +38,29 @@ interface CronGoldens { }[]; } -/** River Go's cron goldens, recorded with robfig/cron; see the file. */ -const GOLDENS = new URL("./testdata/cron-goldens.json", import.meta.url); +/** River Go's cron goldens, generated with robfig/cron; see the file. */ +const GOLDENS = new URL( + "../../conformance/testdata/cron_schedules.json", + import.meta.url +); + +/** + * Reads a fixture that `make generate/fixtures` writes from River's Go + * implementation. A missing fixture fails the test rather than skipping it. + */ +async function readFixture(url: URL): Promise { + try { + return await readFile(url, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error( + `missing conformance fixture ${fileURLToPath(url)}; run \`make generate/fixtures\` from the repository root`, + { cause: error } + ); + } + throw error; + } +} /** Up to `count` successive occurrences after `from`, like robfig's tests. */ function occurrences( @@ -110,7 +132,7 @@ const midnight = fieldsSpec({ hour: 1n, minute: 1n }); describe("cron", () => { describe("River Go goldens", () => { const load = async (): Promise => - JSON.parse(await readFile(GOLDENS, "utf8")) as CronGoldens; + JSON.parse(await readFixture(GOLDENS)) as CronGoldens; it("returns robfig's successive occurrences in the reference offset", async () => { const goldens = await load(); diff --git a/js/src/runtime/completion-command.test.ts b/js/src/runtime/completion-command.test.ts index fb95ded76..898df5fa4 100644 --- a/js/src/runtime/completion-command.test.ts +++ b/js/src/runtime/completion-command.test.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; @@ -18,13 +19,34 @@ interface SnoozeCounterCase { readonly name: string; } -/** River Go's snooze counter goldens, recorded from its executor. */ -const GOLDENS = new URL("../testdata/snooze-counters.json", import.meta.url); +/** River Go's snooze counter goldens, generated from its executor. */ +const GOLDENS = new URL( + "../../../conformance/testdata/snooze_counters.json", + import.meta.url +); + +/** + * Reads a fixture that `make generate/fixtures` writes from River's Go + * implementation. A missing fixture fails the test rather than skipping it. + */ +async function readFixture(url: URL): Promise { + try { + return await readFile(url, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error( + `missing conformance fixture ${fileURLToPath(url)}; run \`make generate/fixtures\` from the repository root`, + { cause: error } + ); + } + throw error; + } +} describe("completionCommand", () => { it("counts snoozes like River for Go's executor", async () => { // Parse with River's exact JSON so integers beyond 2^53 stay exact. - const golden = parseJson(await readFile(GOLDENS, "utf8")) as unknown as { + const golden = parseJson(await readFixture(GOLDENS)) as unknown as { readonly snooze_counters: readonly SnoozeCounterCase[]; }; const now = Temporal.Instant.from("2026-09-01T00:00:00Z"); diff --git a/js/src/testdata/cron-goldens.json b/js/src/testdata/cron-goldens.json deleted file mode 100644 index 00038a70c..000000000 --- a/js/src/testdata/cron-goldens.json +++ /dev/null @@ -1,564 +0,0 @@ -{ - "$comment": "River Go's cron schedule goldens, recorded with robfig/cron v3.0.1, the parser River Go uses. They match the Rust port's rust/riverqueue/tests/fixtures/maintenance_values.json.", - "cron_cases": [ - { - "expression": "* * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_minute", - "next": [ - "2026-01-02T03:05:00Z", - "2026-01-02T03:06:00Z", - "2026-01-02T03:07:00Z", - "2026-01-02T03:08:00Z", - "2026-01-02T03:09:00Z" - ] - }, - { - "expression": "30 * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "half_past_every_hour", - "next": [ - "2026-01-02T03:30:00Z", - "2026-01-02T04:30:00Z", - "2026-01-02T05:30:00Z", - "2026-01-02T06:30:00Z", - "2026-01-02T07:30:00Z" - ] - }, - { - "expression": "0 9 * * 1", - "from": "2026-01-02T03:04:05.6789Z", - "name": "monday_numeric_weekday", - "next": [ - "2026-01-05T09:00:00Z", - "2026-01-12T09:00:00Z", - "2026-01-19T09:00:00Z", - "2026-01-26T09:00:00Z", - "2026-02-02T09:00:00Z" - ] - }, - { - "expression": "0 9 * * mon", - "from": "2026-01-02T03:04:05.6789Z", - "name": "monday_named_weekday", - "next": [ - "2026-01-05T09:00:00Z", - "2026-01-12T09:00:00Z", - "2026-01-19T09:00:00Z", - "2026-01-26T09:00:00Z", - "2026-02-02T09:00:00Z" - ] - }, - { - "expression": "0 0 * * 0", - "from": "2026-01-02T03:04:05.6789Z", - "name": "sunday_is_zero", - "next": [ - "2026-01-04T00:00:00Z", - "2026-01-11T00:00:00Z", - "2026-01-18T00:00:00Z", - "2026-01-25T00:00:00Z", - "2026-02-01T00:00:00Z" - ] - }, - { - "expression": "0 0 * * SUN", - "from": "2026-01-02T03:04:05.6789Z", - "name": "weekday_names_ignore_case", - "next": [ - "2026-01-04T00:00:00Z", - "2026-01-11T00:00:00Z", - "2026-01-18T00:00:00Z", - "2026-01-25T00:00:00Z", - "2026-02-01T00:00:00Z" - ] - }, - { - "expression": "*/15 9-17 * * mon-fri", - "from": "2026-01-02T03:04:05.6789Z", - "name": "business_hours_steps", - "next": [ - "2026-01-02T09:00:00Z", - "2026-01-02T09:15:00Z", - "2026-01-02T09:30:00Z", - "2026-01-02T09:45:00Z", - "2026-01-02T10:00:00Z" - ] - }, - { - "expression": "0 0 1 * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "first_of_month", - "next": [ - "2026-02-01T00:00:00Z", - "2026-03-01T00:00:00Z", - "2026-04-01T00:00:00Z", - "2026-05-01T00:00:00Z", - "2026-06-01T00:00:00Z" - ] - }, - { - "expression": "0 0 1 jan,JUL *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "named_months", - "next": [ - "2026-07-01T00:00:00Z", - "2027-01-01T00:00:00Z", - "2027-07-01T00:00:00Z", - "2028-01-01T00:00:00Z", - "2028-07-01T00:00:00Z" - ] - }, - { - "expression": "0 0 29 2 *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "leap_day", - "next": [ - "2028-02-29T00:00:00Z", - "2032-02-29T00:00:00Z", - "2036-02-29T00:00:00Z", - "2040-02-29T00:00:00Z", - "2044-02-29T00:00:00Z" - ] - }, - { - "expression": "0 0 30 2 *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "impossible_date_never_runs", - "next": [] - }, - { - "expression": "0 12 1,15 * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "day_of_month_or_weekday", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-15T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z" - ] - }, - { - "expression": "0 12 * * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "wildcard_day_of_month_and_weekday", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z", - "2026-01-30T12:00:00Z" - ] - }, - { - "expression": "0 12 ? * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "question_mark_wildcard", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z", - "2026-01-30T12:00:00Z" - ] - }, - { - "expression": "0 12 */2 * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "stepped_day_of_month_or_weekday", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-03T12:00:00Z", - "2026-01-05T12:00:00Z", - "2026-01-07T12:00:00Z", - "2026-01-09T12:00:00Z" - ] - }, - { - "expression": "0 12 */1 * 5", - "from": "2026-01-02T03:04:05.6789Z", - "name": "unit_step_keeps_wildcard", - "next": [ - "2026-01-02T12:00:00Z", - "2026-01-09T12:00:00Z", - "2026-01-16T12:00:00Z", - "2026-01-23T12:00:00Z", - "2026-01-30T12:00:00Z" - ] - }, - { - "expression": "5/15 * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "start_with_step", - "next": [ - "2026-01-02T03:05:00Z", - "2026-01-02T03:20:00Z", - "2026-01-02T03:35:00Z", - "2026-01-02T03:50:00Z", - "2026-01-02T04:05:00Z" - ] - }, - { - "expression": "0-10/5 * * * *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "range_with_step", - "next": [ - "2026-01-02T03:05:00Z", - "2026-01-02T03:10:00Z", - "2026-01-02T04:00:00Z", - "2026-01-02T04:05:00Z", - "2026-01-02T04:10:00Z" - ] - }, - { - "expression": "59 23 31 12 *", - "from": "2026-01-02T03:04:05.6789Z", - "name": "year_end", - "next": [ - "2026-12-31T23:59:00Z", - "2027-12-31T23:59:00Z", - "2028-12-31T23:59:00Z", - "2029-12-31T23:59:00Z", - "2030-12-31T23:59:00Z" - ] - }, - { - "expression": "@hourly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_hourly", - "next": [ - "2026-01-02T04:00:00Z", - "2026-01-02T05:00:00Z", - "2026-01-02T06:00:00Z", - "2026-01-02T07:00:00Z", - "2026-01-02T08:00:00Z" - ] - }, - { - "expression": "@daily", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_daily", - "next": [ - "2026-01-03T00:00:00Z", - "2026-01-04T00:00:00Z", - "2026-01-05T00:00:00Z", - "2026-01-06T00:00:00Z", - "2026-01-07T00:00:00Z" - ] - }, - { - "expression": "@midnight", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_midnight", - "next": [ - "2026-01-03T00:00:00Z", - "2026-01-04T00:00:00Z", - "2026-01-05T00:00:00Z", - "2026-01-06T00:00:00Z", - "2026-01-07T00:00:00Z" - ] - }, - { - "expression": "@weekly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_weekly", - "next": [ - "2026-01-04T00:00:00Z", - "2026-01-11T00:00:00Z", - "2026-01-18T00:00:00Z", - "2026-01-25T00:00:00Z", - "2026-02-01T00:00:00Z" - ] - }, - { - "expression": "@monthly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_monthly", - "next": [ - "2026-02-01T00:00:00Z", - "2026-03-01T00:00:00Z", - "2026-04-01T00:00:00Z", - "2026-05-01T00:00:00Z", - "2026-06-01T00:00:00Z" - ] - }, - { - "expression": "@yearly", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_yearly", - "next": [ - "2027-01-01T00:00:00Z", - "2028-01-01T00:00:00Z", - "2029-01-01T00:00:00Z", - "2030-01-01T00:00:00Z", - "2031-01-01T00:00:00Z" - ] - }, - { - "expression": "@annually", - "from": "2026-01-02T03:04:05.6789Z", - "name": "descriptor_annually", - "next": [ - "2027-01-01T00:00:00Z", - "2028-01-01T00:00:00Z", - "2029-01-01T00:00:00Z", - "2030-01-01T00:00:00Z", - "2031-01-01T00:00:00Z" - ] - }, - { - "expression": "@every 1h30m", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_compound_duration", - "next": [ - "2026-01-02T04:34:05Z", - "2026-01-02T06:04:05Z", - "2026-01-02T07:34:05Z", - "2026-01-02T09:04:05Z", - "2026-01-02T10:34:05Z" - ] - }, - { - "expression": "@every 1.5h", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_fractional_duration", - "next": [ - "2026-01-02T04:34:05Z", - "2026-01-02T06:04:05Z", - "2026-01-02T07:34:05Z", - "2026-01-02T09:04:05Z", - "2026-01-02T10:34:05Z" - ] - }, - { - "expression": "@every 90s", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_seconds", - "next": [ - "2026-01-02T03:05:35Z", - "2026-01-02T03:07:05Z", - "2026-01-02T03:08:35Z", - "2026-01-02T03:10:05Z", - "2026-01-02T03:11:35Z" - ] - }, - { - "expression": "@every 500ms", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_rounds_up_to_one_second", - "next": [ - "2026-01-02T03:04:06Z", - "2026-01-02T03:04:07Z", - "2026-01-02T03:04:08Z", - "2026-01-02T03:04:09Z", - "2026-01-02T03:04:10Z" - ] - }, - { - "expression": "@every 1500ms", - "from": "2026-01-02T03:04:05.6789Z", - "name": "every_truncates_subseconds", - "next": [ - "2026-01-02T03:04:06Z", - "2026-01-02T03:04:07Z", - "2026-01-02T03:04:08Z", - "2026-01-02T03:04:09Z", - "2026-01-02T03:04:10Z" - ] - }, - { - "expression": "0 9 * * *", - "from": "2026-03-07T08:00:00-05:00", - "name": "reference_time_offset", - "next": [ - "2026-03-07T09:00:00-05:00", - "2026-03-08T09:00:00-05:00", - "2026-03-09T09:00:00-05:00", - "2026-03-10T09:00:00-05:00", - "2026-03-11T09:00:00-05:00" - ] - }, - { - "expression": "30 0 * * *", - "from": "2026-03-07T23:45:00+05:30", - "name": "reference_time_half_hour_offset", - "next": [ - "2026-03-08T00:30:00+05:30", - "2026-03-09T00:30:00+05:30", - "2026-03-10T00:30:00+05:30", - "2026-03-11T00:30:00+05:30", - "2026-03-12T00:30:00+05:30" - ] - }, - { - "expression": "CRON_TZ=UTC 0 9 * * *", - "from": "2026-03-07T08:00:00-05:00", - "name": "cron_tz_utc_prefix", - "next": [ - "2026-03-08T04:00:00-05:00", - "2026-03-09T04:00:00-05:00", - "2026-03-10T04:00:00-05:00", - "2026-03-11T04:00:00-05:00", - "2026-03-12T04:00:00-05:00" - ] - }, - { - "expression": "TZ=UTC 0 9 * * *", - "from": "2026-03-07T08:00:00-05:00", - "name": "tz_utc_prefix", - "next": [ - "2026-03-08T04:00:00-05:00", - "2026-03-09T04:00:00-05:00", - "2026-03-10T04:00:00-05:00", - "2026-03-11T04:00:00-05:00", - "2026-03-12T04:00:00-05:00" - ] - }, - { - "expression": " 0 9 * * 1 ", - "from": "2026-01-02T03:04:05.6789Z", - "name": "extra_whitespace", - "next": [ - "2026-01-05T09:00:00Z", - "2026-01-12T09:00:00Z", - "2026-01-19T09:00:00Z", - "2026-01-26T09:00:00Z", - "2026-02-02T09:00:00Z" - ] - } - ], - "cron_invalid": [ - "", - "* * * *", - "* * * * * *", - "0 9 * * 7", - "60 * * * *", - "* 24 * * *", - "* * 0 * *", - "* * 32 * *", - "* * * 0 *", - "* * * 13 *", - "-1 * * * *", - "5-1 * * * *", - "1-2-3 * * * *", - "1/2/3 * * * *", - "*/0 * * * *", - "*/x * * * *", - "0 9 * * funday", - "@every", - "@every 5x", - "@reboot", - "CRON_TZ=Nowhere/Invalid 0 9 * * *" - ], - "cron_named_zone_cases": [ - { - "expression": "CRON_TZ=America/New_York 0 9 * * *", - "from": "2026-03-06T12:00:00Z", - "name": "new_york_across_dst_start", - "next": [ - "2026-03-06T14:00:00Z", - "2026-03-07T14:00:00Z", - "2026-03-08T13:00:00Z", - "2026-03-09T13:00:00Z", - "2026-03-10T13:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/New_York 30 2 * * *", - "from": "2026-03-06T12:00:00Z", - "name": "new_york_skipped_wall_time", - "next": [ - "2026-03-07T07:30:00Z", - "2026-03-09T06:30:00Z", - "2026-03-10T06:30:00Z", - "2026-03-11T06:30:00Z", - "2026-03-12T06:30:00Z" - ] - }, - { - "expression": "CRON_TZ=America/New_York 30 1 * * *", - "from": "2026-10-30T12:00:00Z", - "name": "new_york_repeated_wall_time", - "next": [ - "2026-10-31T05:30:00Z", - "2026-11-01T05:30:00Z", - "2026-11-01T06:30:00Z", - "2026-11-02T06:30:00Z", - "2026-11-03T06:30:00Z" - ] - }, - { - "expression": "CRON_TZ=America/New_York 0 * * * *", - "from": "2026-11-01T04:30:00Z", - "name": "new_york_hourly_across_dst_end", - "next": [ - "2026-11-01T05:00:00Z", - "2026-11-01T06:00:00Z", - "2026-11-01T07:00:00Z", - "2026-11-01T08:00:00Z", - "2026-11-01T09:00:00Z" - ] - }, - { - "expression": "CRON_TZ=Europe/London 0 0 * * *", - "from": "2026-10-23T12:00:00Z", - "name": "london_across_dst_end", - "next": [ - "2026-10-23T23:00:00Z", - "2026-10-24T23:00:00Z", - "2026-10-26T00:00:00Z", - "2026-10-27T00:00:00Z", - "2026-10-28T00:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/Santiago 0 0 * * *", - "from": "2026-09-03T12:00:00Z", - "name": "santiago_skipped_midnight", - "next": [ - "2026-09-04T04:00:00Z", - "2026-09-05T04:00:00Z", - "2026-09-07T03:00:00Z", - "2026-09-08T03:00:00Z", - "2026-09-09T03:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/Santiago 0 12 * * *", - "from": "2026-09-03T12:00:00Z", - "name": "santiago_day_after_skipped_midnight", - "next": [ - "2026-09-03T16:00:00Z", - "2026-09-04T16:00:00Z", - "2026-09-05T16:00:00Z", - "2026-09-06T15:00:00Z", - "2026-09-07T15:00:00Z" - ] - }, - { - "expression": "CRON_TZ=America/Santiago 30 23 * * *", - "from": "2026-04-02T12:00:00Z", - "name": "santiago_repeated_hour_before_midnight", - "next": [ - "2026-04-03T02:30:00Z", - "2026-04-04T02:30:00Z", - "2026-04-05T02:30:00Z", - "2026-04-05T03:30:00Z", - "2026-04-06T03:30:00Z" - ] - }, - { - "expression": "TZ=Asia/Kolkata 0 9 * * mon", - "from": "2026-01-02T03:04:05-05:00", - "name": "kolkata_tz_prefix", - "next": [ - "2026-01-04T22:30:00-05:00", - "2026-01-11T22:30:00-05:00", - "2026-01-18T22:30:00-05:00", - "2026-01-25T22:30:00-05:00", - "2026-02-01T22:30:00-05:00" - ] - } - ] -} diff --git a/js/src/testdata/snooze-counters.json b/js/src/testdata/snooze-counters.json deleted file mode 100644 index 3c9b5439a..000000000 --- a/js/src/testdata/snooze-counters.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "$comment": "River Go's snooze counter goldens, recorded from its executor's rule in internal/jobexecutor/job_executor.go, which reads each count with gjson. They match the Rust port's rust/riverqueue/tests/fixtures/maintenance_values.json.", - "snooze_counters": [ - { - "expected_snoozes": 1, - "metadata": {}, - "name": "absent" - }, - { - "expected_snoozes": 3, - "metadata": { - "snoozes": 2 - }, - "name": "integer" - }, - { - "expected_snoozes": 3, - "metadata": { - "snoozes": 2.9 - }, - "name": "fraction_truncates" - }, - { - "expected_snoozes": -1, - "metadata": { - "snoozes": -2.5 - }, - "name": "negative_fraction_truncates_toward_zero" - }, - { - "expected_snoozes": 1001, - "metadata": { - "snoozes": 1000.0 - }, - "name": "exponent" - }, - { - "expected_snoozes": 9007199254740994, - "metadata": { - "snoozes": 9007199254740993 - }, - "name": "beyond_float_precision" - }, - { - "expected_snoozes": 5, - "metadata": { - "snoozes": "4" - }, - "name": "numeric_string" - }, - { - "expected_snoozes": -6, - "metadata": { - "snoozes": "-7" - }, - "name": "negative_numeric_string" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": "4.5" - }, - "name": "fractional_string_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": " 5" - }, - "name": "padded_string_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": "abc" - }, - "name": "non_numeric_string_is_zero" - }, - { - "expected_snoozes": 2, - "metadata": { - "snoozes": true - }, - "name": "true_is_one" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": false - }, - "name": "false_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": null - }, - "name": "null_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": [3] - }, - "name": "array_is_zero" - }, - { - "expected_snoozes": 1, - "metadata": { - "snoozes": { - "count": 3 - } - }, - "name": "object_is_zero" - } - ] -} From 6729d8a07561231ef072760cc5491c77847454b6 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Mon, 5 Oct 2026 18:00:09 -0500 Subject: [PATCH 5/6] run port fixture tests when Go code changes The Rust and JavaScript workflows are path filtered to their own files, so a Go change that alters a generated fixture, like a new unique key encoding or a different snooze rule, merges without either port's tests seeing the new values. The mismatch only surfaces later, on an unrelated port change. Add a `Conformance` workflow with one job that runs when Go files, `go.mod`, `go.sum`, `go.work`, the SQL queries the generator reads, or the conformance module change. It regenerates the fixtures and runs only `make test/rust/conformance` and `make test/js/conformance`, with Cargo and pnpm caches, instead of either port's full suite or matrix. --- .github/workflows/conformance.yaml | 77 ++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/conformance.yaml diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml new file mode 100644 index 000000000..8a4dde8c4 --- /dev/null +++ b/.github/workflows/conformance.yaml @@ -0,0 +1,77 @@ +name: Conformance + +# The Rust and JavaScript workflows run only when their own files change, but +# their fixture tests compare against values generated from River's Go code. +# This job closes that gap: when Go code, SQL queries the generator reads, or +# the generator itself changes, it regenerates the fixtures and runs only the +# port tests that read them. Keep both events' paths in sync. +on: + push: + branches: + - master + paths: + - ".github/workflows/conformance.yaml" + - "**.go" + - "**/go.mod" + - "**/go.sum" + - "conformance/**" + - "go.work" + - "riverdriver/**/*.sql" + pull_request: + paths: + - ".github/workflows/conformance.yaml" + - "**.go" + - "**/go.mod" + - "**/go.sum" + - "conformance/**" + - "go.work" + - "riverdriver/**/*.sql" + +concurrency: + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: read + +env: + # Keep the cross-run cache small; Cargo still reuses compiled dependencies. + CARGO_INCREMENTAL: "0" + +jobs: + port_fixtures: + name: Port fixture tests + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-go@v6 + with: + go-version-file: go.work + + - uses: dtolnay/rust-toolchain@stable + id: rust + + - name: Cache Rust dependencies and build artifacts + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + rust/target + key: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}-${{ hashFiles('rust/**/Cargo.toml', 'rust/Cargo.lock') }} + restore-keys: rust-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ steps.rust.outputs.cachekey }}- + + - uses: ./.github/actions/setup-js + + # Each target generates the fixtures before running its tests. + - name: Rust fixture tests + run: make test/rust/conformance + + - name: JavaScript fixture tests + run: make test/js/conformance From 35949ea435ff2ed85c27d9e2715e65085c6cb700 Mon Sep 17 00:00:00 2001 From: Brandur Date: Tue, 6 Oct 2026 11:49:21 -0500 Subject: [PATCH 6/6] Slightly more elaborate notification checks At Codex's suggestion, replace superficial notification checks with a test that sends all seven generated payloads through Rust's dispatcher and verifies cancellation, queue wakeups, and leadership signals. --- rust/riverqueue/src/client/tests.rs | 115 ++++++++++++++++++++- rust/riverqueue/tests/protocol_fixtures.rs | 21 ---- 2 files changed, 113 insertions(+), 23 deletions(-) diff --git a/rust/riverqueue/src/client/tests.rs b/rust/riverqueue/src/client/tests.rs index b34aaad03..35ed8bbae 100644 --- a/rust/riverqueue/src/client/tests.rs +++ b/rust/riverqueue/src/client/tests.rs @@ -1,4 +1,3 @@ -#[cfg(feature = "sqlite")] use serde::Deserialize; use serde_json::Map; @@ -11,7 +10,6 @@ use super::completer::{ }; use super::completer::{persisted_completion_event_kind, with_completion_retries}; use super::executor::scheduled_after; -#[cfg(feature = "sqlite")] use super::notifier::dispatch_notification; use super::*; use crate::{AttemptError, JobEventKind, JobRow, JobState, WorkError, WorkResult}; @@ -122,6 +120,119 @@ fn completion_cleanup_preserves_newer_attempt() { ); } +#[tokio::test] +async fn go_notification_fixtures_dispatch_correctly() { + #[derive(Deserialize)] + struct Fixture { + notifications: Vec, + } + + #[derive(Deserialize)] + struct NotificationFixture { + name: String, + payload: Box, + topic: String, + } + + let fixture: Fixture = + serde_json::from_str(&crate::conformance::read_fixture("protocol_values.json")).unwrap(); + let names = [ + "cancel", + "insert", + "metadata_changed", + "pause", + "request_resign", + "resigned", + "resume", + ]; + assert_eq!(fixture.notifications.len(), names.len()); + + // Dispatch is synchronous and needs no database connection. Exercise it + // in both PostgreSQL-only and SQLite-only builds. + #[cfg(feature = "postgres")] + let pool = sqlx::PgPool::connect_lazy("postgres://localhost/unused").unwrap(); + #[cfg(not(feature = "postgres"))] + let pool = sqlx::SqlitePool::connect_lazy("sqlite::memory:").unwrap(); + + for name in names { + let notification = fixture + .notifications + .iter() + .find(|notification| notification.name == name) + .unwrap_or_else(|| panic!("missing Go notification fixture {name}")); + + // The Go examples target job 42 in queue "priority", with resignations + // from "client-1". A leader ignores its own resignation notification. + for client_id in ["client-1", "observer"] { + let client = Client::builder(pool.clone()).id(client_id).build().unwrap(); + let inner = &client.inner; + let mut leadership = inner.leadership_wakeups.subscribe(); + let mut producer = inner.queue_notifications.subscribe(); + let cancellation = CancellationToken::new(); + let unrelated = CancellationToken::new(); + for (job_id, token) in [(42, &cancellation), (43, &unrelated)] { + register_running_attempt( + &inner.running, + &inner.pending_cancellations, + job_id, + token, + ); + } + + dispatch_notification( + inner, + &inner.queue_notifications, + ¬ification.topic, + notification.payload.get(), + ); + + match name { + "cancel" => {} + "insert" => assert!( + matches!(producer.try_recv(), Ok(RuntimeNotification::Insert(queue)) if queue == "priority"), + "{name}: expected an insert wakeup" + ), + "metadata_changed" | "pause" | "resume" => assert!( + matches!(producer.try_recv(), Ok(RuntimeNotification::QueueControl(queue)) if queue == "priority"), + "{name}: expected a queue control wakeup" + ), + "request_resign" => assert_eq!( + leadership.try_recv().unwrap(), + LeadershipWakeup::RequestResign, + "{name}" + ), + "resigned" if client_id == "client-1" => {} + "resigned" => assert_eq!( + leadership.try_recv().unwrap(), + LeadershipWakeup::Changed, + "{name}" + ), + _ => unreachable!(), + } + assert_eq!(cancellation.is_cancelled(), name == "cancel", "{name}"); + assert!( + !unrelated.is_cancelled(), + "{name}: cancelled an unrelated job" + ); + assert!( + matches!( + producer.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + ), + "{name}: unexpected producer notification" + ); + assert!( + matches!( + leadership.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + ), + "{name}: unexpected leadership notification" + ); + } + } + pool.close().await; +} + #[test] fn pending_cancellation_reaches_fetched_attempt() { let job_id = 42; diff --git a/rust/riverqueue/tests/protocol_fixtures.rs b/rust/riverqueue/tests/protocol_fixtures.rs index 66c64eb3d..d58679a47 100644 --- a/rust/riverqueue/tests/protocol_fixtures.rs +++ b/rust/riverqueue/tests/protocol_fixtures.rs @@ -18,18 +18,10 @@ struct Fixture { attempt_error: AttemptError, job_states: Vec, metadata_keys: Map, - notifications: Vec, retry_cases: Vec, topics: Map, } -#[derive(Deserialize)] -struct NotificationFixture { - name: String, - payload: Map, - topic: String, -} - #[derive(Deserialize)] struct RetryFixture { error_count: usize, @@ -88,19 +80,6 @@ fn go_protocol_values_match_rust() { assert_eq!(fixture.topics["control"], NOTIFICATION_TOPIC_CONTROL); assert_eq!(fixture.topics["insert"], NOTIFICATION_TOPIC_INSERT); assert_eq!(fixture.topics["leadership"], NOTIFICATION_TOPIC_LEADERSHIP); - for notification in fixture.notifications { - assert_ne!(notification.name, ""); - assert!(notification.payload.contains_key("action") || notification.name == "insert"); - assert!( - [ - NOTIFICATION_TOPIC_CONTROL, - NOTIFICATION_TOPIC_INSERT, - NOTIFICATION_TOPIC_LEADERSHIP, - ] - .contains(¬ification.topic.as_str()) - ); - } - for test_case in fixture.retry_cases { let row = retry_row(test_case.job_id, test_case.now, test_case.error_count - 1); let delay = DefaultRetryPolicy::with_seed(test_case.seed).next_retry(