From 01f6a4f7d6bbd6a2c0765f6fd63bd45cd83b6eb1 Mon Sep 17 00:00:00 2001 From: Brandur Date: Fri, 30 Oct 2026 22:39:14 -0500 Subject: [PATCH 1/2] [rust] Move from chrono (deprecated) to jiff It was called to my attention last week that it turns out that what's been traditionally the main date/time library in Rust, chrono, is actually soft deprecated [1]. The chrono maintainer recommends jiff [2] as a replacement. I figure that since River Rust is still quite new and River Pro for Rust is still unreleased, it's the best time ever to make this jump. Although annoying, jiff does seem to be very thoughtfully designed, with heavy inspiration from Temporal in JavaScript. [1] https://github.com/chronotope/chrono/issues/1768 [2] https://crates.io/crates/jiff --- Makefile | 2 +- rust/CHANGELOG.md | 4 + rust/Cargo.lock | 335 ++++++----------- rust/Cargo.toml | 2 +- rust/README.md | 8 +- rust/riverqueue-test/Cargo.toml | 2 +- rust/riverqueue-test/src/lib.rs | 4 +- rust/riverqueue-test/src/require.rs | 23 +- rust/riverqueue/Cargo.toml | 12 +- rust/riverqueue/README.md | 27 +- rust/riverqueue/docs/mixed-deployments.md | 9 +- rust/riverqueue/src/__private.rs | 32 +- rust/riverqueue/src/client/completer.rs | 12 +- rust/riverqueue/src/client/executor.rs | 28 +- rust/riverqueue/src/client/extension.rs | 10 +- rust/riverqueue/src/client/insert.rs | 24 +- rust/riverqueue/src/client/mod.rs | 32 +- rust/riverqueue/src/client/peers.rs | 11 +- rust/riverqueue/src/client/producer.rs | 10 +- rust/riverqueue/src/client/record.rs | 22 +- rust/riverqueue/src/client/tests.rs | 32 +- rust/riverqueue/src/database.rs | 6 +- rust/riverqueue/src/database/sqlite.rs | 322 +++++++++++----- rust/riverqueue/src/encoding.rs | 37 +- rust/riverqueue/src/event.rs | 2 +- rust/riverqueue/src/extension.rs | 8 +- rust/riverqueue/src/job.rs | 28 +- rust/riverqueue/src/job/attempt_error.rs | 66 ++-- rust/riverqueue/src/lib.rs | 6 +- rust/riverqueue/src/maintenance/cleaner.rs | 41 ++- rust/riverqueue/src/maintenance/elector.rs | 82 ++--- rust/riverqueue/src/maintenance/maintainer.rs | 4 +- rust/riverqueue/src/maintenance/mod.rs | 2 +- .../src/maintenance/periodic_enqueuer.rs | 8 +- rust/riverqueue/src/maintenance/reindexer.rs | 46 +-- rust/riverqueue/src/maintenance/rescuer.rs | 53 +-- rust/riverqueue/src/maintenance/scheduler.rs | 23 +- rust/riverqueue/src/maintenance/tests.rs | 50 +-- rust/riverqueue/src/periodic/cron.rs | 348 +++++++++--------- rust/riverqueue/src/periodic/mod.rs | 40 +- rust/riverqueue/src/pilot.rs | 4 +- rust/riverqueue/src/protocol.rs | 6 +- rust/riverqueue/src/query.rs | 30 +- rust/riverqueue/src/queue.rs | 8 +- rust/riverqueue/src/storage/postgres.rs | 18 +- rust/riverqueue/src/storage/sqlite.rs | 16 +- rust/riverqueue/src/unique.rs | 51 ++- rust/riverqueue/src/worker.rs | 19 +- rust/riverqueue/tests/caller_transactions.rs | 25 +- rust/riverqueue/tests/client_handles.rs | 6 +- rust/riverqueue/tests/extension_seams.rs | 4 +- rust/riverqueue/tests/extension_services.rs | 4 +- rust/riverqueue/tests/insert_notifications.rs | 12 +- rust/riverqueue/tests/job_list.rs | 78 ++-- rust/riverqueue/tests/metadata_merge.rs | 2 +- rust/riverqueue/tests/peer_attempts.rs | 4 +- .../tests/poll_only_cancellation.rs | 2 +- rust/riverqueue/tests/postgres.rs | 12 +- rust/riverqueue/tests/postgres_parity.rs | 8 +- rust/riverqueue/tests/prepared_insertion.rs | 6 +- rust/riverqueue/tests/producer_lifetime.rs | 2 +- rust/riverqueue/tests/protocol_fixtures.rs | 6 +- rust/riverqueue/tests/resilience_postgres.rs | 17 +- rust/riverqueue/tests/resilience_sqlite.rs | 22 +- rust/riverqueue/tests/serde_semantics.rs | 2 +- rust/riverqueue/tests/sqlite_runtime.rs | 10 +- rust/riverqueue/tests/sqlite_storage.rs | 43 ++- 67 files changed, 1160 insertions(+), 1070 deletions(-) diff --git a/Makefile b/Makefile index 7955d7781..f31a5091e 100644 --- a/Makefile +++ b/Makefile @@ -242,7 +242,7 @@ test/rust: generate/fixtures .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,sqlite --lib --test protocol_fixtures --test resumable_conformance --locked + cd rust && cargo test -p riverqueue --features tzdb,sqlite --lib --test protocol_fixtures --test resumable_conformance --locked .PHONY: test/rust/postgres test/rust/postgres: ## Run all Rust tests, including Postgres integration tests (requires RIVER_RUST_DATABASE_URL) diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index 5aa55487f..4f3e4b777 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -10,6 +10,10 @@ Changes to River for Go are recorded in the [repository changelog](../CHANGELOG. ## [Unreleased] +### Changed + +- **Breaking:** Replace Chrono with Jiff 0.2 throughout the Rust API. Job timestamps, insertion options, retry hooks, periodic schedules, and test helpers now use `jiff::Timestamp`; River re-exports Jiff as `riverqueue::jiff`. `CronSchedule::next_after` takes and returns `jiff::Zoned`, `CronTimeZone::Fixed` uses `jiff::tz::Offset`, and `CronTimeZone::Named` uses `jiff::tz::TimeZone`. `CronTimeZone` is now `Clone` rather than `Copy`. `PostgresReindexSchedule::DailyUtc` takes `jiff::civil::Time`. Rename the optional `chrono-tz` feature to `tzdb`. Database formats, canonical timestamp JSON, and Go-compatible cron behavior remain unchanged. Timestamp ranges and checked arithmetic follow Jiff's supported range. [PR #1567](https://github.com/riverqueue/river/pull/1567). + ### Fixed - Fixed a running job not being cancelled when its cancellation notification was lost, as happens if it's sent while the client's listener is reconnecting. Clients with notifications now also check their running jobs for cancellation requests once a minute, like River for Go. Retrying a job now clears its earlier attempt's cancellation request so that the retried job isn't cancelled again. [PR #1564](https://github.com/riverqueue/river/pull/1564). diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7809538dc..16f0758bd 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -8,15 +8,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - [[package]] name = "anyhow" version = "1.0.104" @@ -61,6 +52,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -88,12 +85,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - [[package]] name = "byteorder" version = "1.5.0" @@ -133,30 +124,6 @@ dependencies = [ "rand_core", ] -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "chrono-tz" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" -dependencies = [ - "chrono", - "phf", -] - [[package]] name = "cmov" version = "0.5.4" @@ -169,12 +136,6 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -251,6 +212,37 @@ dependencies = [ "cmov", ] +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror", +] + [[package]] name = "digest" version = "0.10.7" @@ -312,7 +304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -554,30 +546,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -698,14 +666,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "js-sys" -version = "0.3.104" +name = "jiff" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-sqlx" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f07e4e3efb45ce1507e32af3b1e8f31d33d5edbf041ffeda3180042c3abc58" +dependencies = [ + "jiff", + "sqlx", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", ] [[package]] @@ -829,24 +851,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" -dependencies = [ - "phf_shared", -] - -[[package]] -name = "phf_shared" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -859,6 +863,21 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -915,7 +934,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -939,9 +958,9 @@ dependencies = [ "anyhow", "async-trait", "base64 0.23.1", - "chrono", - "chrono-tz", "futures-util", + "jiff", + "jiff-sqlx", "rand", "riverqueue-macros", "riverqueue-migrate", @@ -994,7 +1013,7 @@ dependencies = [ name = "riverqueue-test" version = "0.4.0" dependencies = [ - "chrono", + "jiff", "riverqueue", "riverqueue-migrate", "serde", @@ -1038,12 +1057,6 @@ dependencies = [ "untrusted", ] -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - [[package]] name = "scopeguard" version = "1.2.0" @@ -1160,12 +1173,6 @@ dependencies = [ "libc", ] -[[package]] -name = "siphasher" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" - [[package]] name = "slab" version = "0.4.12" @@ -1222,7 +1229,6 @@ dependencies = [ "base64 0.22.1", "bytes", "cfg-if", - "chrono", "crc", "crossbeam-queue", "either", @@ -1294,10 +1300,9 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "bitflags", + "bitflags 2.13.1", "byteorder", "bytes", - "chrono", "crc", "digest 0.11.3", "dotenvy", @@ -1323,9 +1328,8 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.13.1", "byteorder", - "chrono", "crc", "dotenvy", "etcetera", @@ -1358,7 +1362,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", - "chrono", "flume", "form_urlencoded", "futures-channel", @@ -1725,51 +1728,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasm-bindgen" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" -dependencies = [ - "unicode-ident", -] - [[package]] name = "webpki-roots" version = "1.0.10" @@ -1791,42 +1749,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "windows-sys 0.61.2", ] [[package]] @@ -1835,24 +1758,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.52.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 9dfef0e7e..38ce080f3 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -20,8 +20,8 @@ version = "0.4.0" [workspace.dependencies] async-trait = "0.1.92" base64 = "0.23.1" -chrono = { version = "0.4.45", features = ["serde"] } futures-util = { version = "0.3.34", default-features = false, features = ["std"] } +jiff = { version = "0.2.38", features = ["serde"] } proc-macro2 = "1.0.107" quote = "1.0.47" rand = "0.10.2" diff --git a/rust/README.md b/rust/README.md index 947427f64..8aa30e428 100644 --- a/rust/README.md +++ b/rust/README.md @@ -20,17 +20,17 @@ Rust version is 1.95. |---|---|---| | `postgres` | yes | Postgres through SQLx | | `sqlite` | no | SQLite 3.45 or newer through SQLx | -| `chrono-tz` | no | IANA zone names such as `America/New_York` in cron `CRON_TZ=` and `TZ=` prefixes | +| `tzdb` | no | IANA zone names such as `America/New_York` in cron `CRON_TZ=` and `TZ=` prefixes | For SQLite alone, use `riverqueue = { version = "0.4.0", default-features = false, features = ["sqlite"] }`. -River's API uses types from SQLx (pools and transactions), Chrono +River's API uses types from SQLx (pools and transactions), Jiff (timestamps), `serde_json` (metadata, outputs, and other JSON values), and `tokio-util` (the worker's `CancellationToken`). The crate re-exports each one -as `riverqueue::sqlx`, `riverqueue::chrono`, `riverqueue::serde_json`, and +as `riverqueue::sqlx`, `riverqueue::jiff`, `riverqueue::serde_json`, and `riverqueue::tokio_util`. Use the re-exports, or depend on versions -compatible with River's (SQLx 0.9, Chrono 0.4, `serde_json` 1, and +compatible with River's (SQLx 0.9, Jiff 0.2, `serde_json` 1, and `tokio-util` 0.7), so the types match. River doesn't choose a TLS implementation for SQLx; enable one of SQLx's TLS features in your own SQLx dependency if your database connections use TLS. diff --git a/rust/riverqueue-test/Cargo.toml b/rust/riverqueue-test/Cargo.toml index 052885b3b..3d5bf04bb 100644 --- a/rust/riverqueue-test/Cargo.toml +++ b/rust/riverqueue-test/Cargo.toml @@ -23,7 +23,7 @@ postgres = ["riverqueue/postgres"] sqlite = ["riverqueue/sqlite"] [dependencies] -chrono.workspace = true +jiff.workspace = true riverqueue = { path = "../riverqueue", version = "=0.4.0", default-features = false } serde_json.workspace = true tokio-util.workspace = true diff --git a/rust/riverqueue-test/src/lib.rs b/rust/riverqueue-test/src/lib.rs index febc55bb4..8d0dbddc5 100644 --- a/rust/riverqueue-test/src/lib.rs +++ b/rust/riverqueue-test/src/lib.rs @@ -3,7 +3,7 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] -use chrono::Utc; +use jiff::Timestamp; use riverqueue::{ __private, Client, Error, Job, JobArgs, JobRow, JobState, MAX_ATTEMPTS_DEFAULT, PRIORITY_DEFAULT, QUEUE_DEFAULT, WorkContext, WorkError, WorkOutcome, Worker, @@ -55,7 +55,7 @@ impl TestJobBuilder { /// /// Returns an error when the arguments can't be encoded as JSON. pub fn build(self) -> Result, Error> { - let now = Utc::now(); + let now = Timestamp::now(); let encoded_args = riverqueue::encoding::encode_args(&self.args)?; let defaults = A::default_insert_opts(); let max_attempts = defaults.max_attempts().unwrap_or(MAX_ATTEMPTS_DEFAULT); diff --git a/rust/riverqueue-test/src/require.rs b/rust/riverqueue-test/src/require.rs index a67fd5ecc..dda6ec0ea 100644 --- a/rust/riverqueue-test/src/require.rs +++ b/rust/riverqueue-test/src/require.rs @@ -8,7 +8,7 @@ use std::fmt::Write as _; -use chrono::{DateTime, DurationRound as _, TimeDelta, Utc}; +use jiff::Timestamp; use riverqueue::{ Client, Job, JobArgs, JobListOrderBy, JobListParams, JobRow, JobState, database::DatabaseTransactionExecutor, @@ -27,7 +27,7 @@ pub struct RequireInsertedOpts { max_attempts: Option, priority: Option, queue: Option, - scheduled_at: Option>, + scheduled_at: Option, state: Option, tags: Option>, } @@ -59,7 +59,7 @@ impl RequireInsertedOpts { /// Returns the expected scheduled time. #[must_use] - pub const fn scheduled_at(&self) -> Option> { + pub const fn scheduled_at(&self) -> Option { self.scheduled_at } @@ -99,7 +99,7 @@ impl RequireInsertedOpts { /// Expects this scheduled time, compared at microsecond precision like /// the database stores it. #[must_use] - pub const fn with_scheduled_at(mut self, scheduled_at: DateTime) -> Self { + pub const fn with_scheduled_at(mut self, scheduled_at: Timestamp) -> Self { self.scheduled_at = Some(scheduled_at); self } @@ -164,12 +164,11 @@ impl RequireInsertedOpts { return None; } if let Some(expected) = self.scheduled_at { - let micros = TimeDelta::microseconds(1); - let expected = expected.duration_trunc(micros).unwrap_or(expected); - let actual = job - .scheduled_at - .duration_trunc(micros) - .unwrap_or(job.scheduled_at); + let micros = jiff::TimestampRound::new() + .smallest(jiff::Unit::Microsecond) + .mode(jiff::RoundMode::Floor); + let expected = expected.round(micros).unwrap_or(expected); + let actual = job.scheduled_at.round(micros).unwrap_or(job.scheduled_at); if !check( actual == expected, format!("scheduled at equal to excluded {}", micro_time(expected)), @@ -208,8 +207,8 @@ impl RequireInsertedOpts { } } -fn micro_time(time: DateTime) -> String { - time.to_rfc3339_opts(chrono::SecondsFormat::Micros, true) +fn micro_time(time: Timestamp) -> String { + format!("{time:.6}") } /// One job expected by [`require_many_inserted`]. diff --git a/rust/riverqueue/Cargo.toml b/rust/riverqueue/Cargo.toml index 75a709f68..015fac1e9 100644 --- a/rust/riverqueue/Cargo.toml +++ b/rust/riverqueue/Cargo.toml @@ -19,25 +19,25 @@ all-features = true [features] default = ["postgres"] +postgres = ["riverqueue-migrate/postgres", "sqlx/postgres", "jiff-sqlx/postgres"] +sqlite = ["riverqueue-migrate/sqlite", "sqlx/sqlite", "jiff-sqlx/sqlite"] # Resolves IANA zone names such as `America/New_York` in cron `CRON_TZ=` # prefixes from a bundled time zone database. -chrono-tz = ["dep:chrono-tz"] -postgres = ["riverqueue-migrate/postgres", "sqlx/postgres"] -sqlite = ["riverqueue-migrate/sqlite", "sqlx/sqlite"] +tzdb = ["jiff/tzdb-bundle-always"] [dependencies] async-trait.workspace = true base64.workspace = true -chrono.workspace = true -chrono-tz = { version = "0.10.4", optional = true } futures-util.workspace = true +jiff.workspace = true +jiff-sqlx = { version = "0.2.0", default-features = false } riverqueue-migrate = { path = "../riverqueue-migrate", version = "=0.4.0", default-features = false } riverqueue-macros = { path = "../riverqueue-macros", version = "=0.4.0" } rand = { workspace = true } serde.workspace = true serde_json.workspace = true sha2.workspace = true -sqlx = { workspace = true, features = ["chrono", "derive", "json"] } +sqlx = { workspace = true, features = ["derive", "json"] } thiserror.workspace = true tokio.workspace = true tokio-util.workspace = true diff --git a/rust/riverqueue/README.md b/rust/riverqueue/README.md index 5e5a42d7e..bccb89bba 100644 --- a/rust/riverqueue/README.md +++ b/rust/riverqueue/README.md @@ -26,21 +26,38 @@ Rust version is 1.95. |---|---|---| | `postgres` | yes | Postgres through SQLx | | `sqlite` | no | SQLite 3.45 or newer through SQLx | -| `chrono-tz` | no | IANA zone names such as `America/New_York` in cron `CRON_TZ=` and `TZ=` prefixes | +| `tzdb` | no | IANA zone names such as `America/New_York` in cron `CRON_TZ=` and `TZ=` prefixes | For SQLite alone, use `riverqueue = { version = "0.4.0", default-features = false, features = ["sqlite"] }`. -River's API uses types from SQLx (pools and transactions), Chrono +River's API uses types from SQLx (pools and transactions), Jiff (timestamps), `serde_json` (metadata, outputs, and other JSON values), and `tokio-util` (the worker's `CancellationToken`). The crate re-exports each one -as `riverqueue::sqlx`, `riverqueue::chrono`, `riverqueue::serde_json`, and +as `riverqueue::sqlx`, `riverqueue::jiff`, `riverqueue::serde_json`, and `riverqueue::tokio_util`. Use the re-exports, or depend on versions -compatible with River's (SQLx 0.9, Chrono 0.4, `serde_json` 1, and +compatible with River's (SQLx 0.9, Jiff 0.2, `serde_json` 1, and `tokio-util` 0.7), so the types match. River doesn't choose a TLS implementation for SQLx; enable one of SQLx's TLS features in your own SQLx dependency if your database connections use TLS. +## Migrating from Chrono + +Use `riverqueue::jiff::Timestamp` wherever River previously accepted or returned +`chrono::DateTime`. For example, `Timestamp::now()` replaces `Utc::now()`, +and `"2026-01-02T03:04:05Z".parse::()?` parses an absolute timestamp. +Use `std::time::Duration` for delays and intervals, and `jiff::SignedDuration` +when a duration can be negative. To measure elapsed time between timestamps, +use `later.duration_since(earlier)`; subtracting Jiff timestamps produces a +calendar `Span`. + +Cron's `next_after` accepts a `jiff::Zoned` and preserves the reference time +zone. Use `timestamp.to_zoned(jiff::tz::TimeZone::UTC)` for a UTC reference. +Fixed cron offsets use `jiff::tz::Offset`, named zones use `jiff::tz::TimeZone`, +and the `tzdb` feature replaces `chrono-tz`. Daily UTC reindex schedules use +`jiff::civil::Time`. River's persisted timestamps and unique-key encoding are +unchanged; existing databases require no migration. + ## Quick start Define serializable arguments, register an async function or a [`Worker`], @@ -255,7 +272,7 @@ results are persisted, and independent jobs have no global completion order. `CronSchedule` accepts standard five-field cron syntax and descriptors such as `@hourly` and `@every 90s`, evaluated in the process's local time zone unless another `CronTimeZone` is chosen. `CRON_TZ=` and `TZ=` prefixes - naming IANA zones such as `America/New_York` need the `chrono-tz` feature, + naming IANA zones such as `America/New_York` need the `tzdb` feature, which bundles the time zone database; without it only `UTC`, `Local`, and `Etc/GMT±N` names parse. - Resumable steps persist the last completed step and an optional cursor. Use diff --git a/rust/riverqueue/docs/mixed-deployments.md b/rust/riverqueue/docs/mixed-deployments.md index acad7153c..487a167c1 100644 --- a/rust/riverqueue/docs/mixed-deployments.md +++ b/rust/riverqueue/docs/mixed-deployments.md @@ -87,9 +87,16 @@ in their queues. Cron schedules use the same standard five-field syntax in both languages. A schedule with a `CRON_TZ=` prefix naming an IANA zone parses in Rust only -with the `chrono-tz` feature enabled; without it, Rust rejects the schedule +with the `tzdb` feature enabled; without it, Rust rejects the schedule rather than evaluating it in another zone. +Rust timestamps use Jiff's supported range and nanosecond precision. In +particular, Go's year-one zero time is representable, but dates outside +Jiff's `Timestamp::MIN` and `Timestamp::MAX` cannot be read by the Rust client. +The JSON representation remains RFC 3339 in UTC with trailing fractional +zeros removed. Persisted values retain each database's precision: microseconds +in Postgres and milliseconds in SQLite. + ## SQLite SQLite databases shared between processes must use WAL mode and a busy timeout diff --git a/rust/riverqueue/src/__private.rs b/rust/riverqueue/src/__private.rs index 9008d3c1e..4828369c6 100644 --- a/rust/riverqueue/src/__private.rs +++ b/rust/riverqueue/src/__private.rs @@ -9,7 +9,7 @@ use std::{fmt, time::Duration}; use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use serde_json::{Map, Value, value::RawValue}; #[cfg(feature = "postgres")] @@ -180,7 +180,7 @@ pub fn attempt_errors_from_json(json: &str) -> Result, serde_j /// millisecond-rounded, timezone-free SQLite representation. #[cfg(feature = "sqlite")] #[must_use] -pub fn sqlite_timestamp(time: DateTime) -> String { +pub fn sqlite_timestamp(time: Timestamp) -> String { crate::database::sqlite::sqlite_time(time) } @@ -384,11 +384,11 @@ pub async fn notify_many( #[non_exhaustive] pub struct FinalizedJobDeleteParams { /// Delete cancelled jobs finalized before this time. - pub cancelled_before: Option>, + pub cancelled_before: Option, /// Delete completed jobs finalized before this time. - pub completed_before: Option>, + pub completed_before: Option, /// Delete discarded jobs finalized before this time. - pub discarded_before: Option>, + pub discarded_before: Option, /// Maximum jobs to delete, lowest IDs first. pub limit: i64, /// Queues whose jobs are kept. @@ -628,7 +628,7 @@ pub struct RescueParams { pub rescue_after: Duration, /// Jobs attempted at or after this time are not stuck. Computed once per /// rescuer pass. - pub stuck_horizon: DateTime, + pub stuck_horizon: Timestamp, /// The limit on the rescuer transaction the selection runs in. An /// extension that reads through a connection of its own should bound /// that read the same way. @@ -641,11 +641,11 @@ pub struct RescueJob { /// Attempt error JSON appended to the job's `errors`. pub attempt_error: Value, /// Finalization time, set for `cancelled` and `discarded`. - pub finalized_at: Option>, + pub finalized_at: Option, /// Job ID. pub id: i64, /// Next scheduled time. - pub scheduled_at: DateTime, + pub scheduled_at: Timestamp, /// Target River state string. pub state: JobState, } @@ -663,7 +663,7 @@ pub struct RescueManyParams { /// Transitions OSS would write, in ID order. pub jobs: Vec, /// Horizon the batch was selected with. - pub stuck_horizon: DateTime, + pub stuck_horizon: Timestamp, } /// Whether the OSS rescuer should perform its normal guarded update. @@ -765,7 +765,7 @@ impl fmt::Debug for JobInsertParams<'_> { pub struct LeaderTerm { /// When the database recorded this client's election, which identifies /// the term. - pub elected_at: DateTime, + pub elected_at: Timestamp, /// Cancelled when the term ends. pub token: CancellationToken, } @@ -1314,7 +1314,7 @@ pub struct RawInsertResult { #[derive(Clone, Debug)] pub struct PreparedInsertParams { /// Original creation time. - pub created_at: DateTime, + pub created_at: Timestamp, /// Serialized job arguments. pub encoded_args: Box, /// Stable job kind. @@ -1328,7 +1328,7 @@ pub struct PreparedInsertParams { /// Queue in which the job runs. pub queue: String, /// Earliest time at which the reinserted job may run. - pub scheduled_at: DateTime, + pub scheduled_at: Timestamp, /// Searchable tags. pub tags: Vec, /// Existing unique hash, if any. @@ -1561,18 +1561,18 @@ impl RawInsertResult { pub struct JobRowParts { pub id: i64, pub attempt: i32, - pub attempted_at: Option>, + pub attempted_at: Option, pub attempted_by: Vec, - pub created_at: DateTime, + pub created_at: Timestamp, pub encoded_args: Box, pub errors: Vec, - pub finalized_at: Option>, + pub finalized_at: Option, pub kind: String, pub max_attempts: i32, pub metadata: crate::JobMetadata, pub priority: i16, pub queue: String, - pub scheduled_at: DateTime, + pub scheduled_at: Timestamp, pub state: JobState, pub tags: Vec, pub unique_key: Option>, diff --git a/rust/riverqueue/src/client/completer.rs b/rust/riverqueue/src/client/completer.rs index 5bb3ec2e2..93e7700b3 100644 --- a/rust/riverqueue/src/client/completer.rs +++ b/rust/riverqueue/src/client/completer.rs @@ -11,8 +11,8 @@ use std::collections::{HashSet, VecDeque}; use std::sync::Arc; use std::time::Duration; -use chrono::{DateTime, Utc}; use futures_util::FutureExt as _; +use jiff::Timestamp; use serde_json::{Map, Value}; #[cfg(feature = "postgres")] use sqlx::AssertSqlSafe; @@ -64,10 +64,10 @@ pub(super) struct CompletionUpdate { pub(super) cancellation: CancellationToken, pub(super) error: Option, pub(super) event_kind: JobEventKind, - pub(super) finalized_at: Option>, + pub(super) finalized_at: Option, pub(super) job_id: i64, pub(super) metadata: Map, - pub(super) scheduled_at: Option>, + pub(super) scheduled_at: Option, pub(super) state: JobState, pub(super) timing: CompletionTiming, /// Set for a peer attempt's outcome, whose ownership ends as it persists. @@ -562,7 +562,7 @@ pub(super) async fn persist_completion_batch( DatabasePool::Sqlite(pool) => { let mut transaction = crate::database::begin_sqlite_write(pool).await?; let mut rows = Vec::with_capacity(batch.len()); - let now = Utc::now(); + let now = Timestamp::now(); for update in batch { let row = crate::database::sqlite::complete_decoded( &mut transaction, @@ -641,7 +641,7 @@ pub(super) async fn persist_completion_batch( .collect::>(); let finalized_at = batch .iter() - .map(|update| update.finalized_at) + .map(|update| update.finalized_at.map(jiff_sqlx::Timestamp::from)) .collect::>(); let ids = batch.iter().map(|update| update.job_id).collect::>(); let metadata_do_merge = batch @@ -654,7 +654,7 @@ pub(super) async fn persist_completion_batch( .collect::>(); let scheduled_at = batch .iter() - .map(|update| update.scheduled_at) + .map(|update| update.scheduled_at.map(jiff_sqlx::Timestamp::from)) .collect::>(); let states = batch .iter() diff --git a/rust/riverqueue/src/client/executor.rs b/rust/riverqueue/src/client/executor.rs index 0cebc104f..a05cef54c 100644 --- a/rust/riverqueue/src/client/executor.rs +++ b/rust/riverqueue/src/client/executor.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::time::Duration; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use tokio::sync::{mpsc, oneshot}; @@ -47,14 +47,14 @@ pub(super) async fn execute_job( let queue_wait_duration = row .attempted_at .and_then(|attempted_at| { - (attempted_at - row.scheduled_at.max(row.created_at)) - .to_std() + attempted_at.duration_since(row.scheduled_at.max(row.created_at)) + .try_into() .ok() }) .unwrap_or_default(); // Like River Go's executor start time, which it records as the // attempt error's `at`. - let attempt_started_at = Utc::now(); + let attempt_started_at = Timestamp::now(); let peers = Arc::new(PeerLedger::new(row.id, attempt_started_at)); // The worker gets a child of the attempt's token. It observes // timeouts, remote cancellation, and stops, but cancelling it itself @@ -496,7 +496,7 @@ pub(super) fn public_work_result(result: &WorkerResult) -> WorkResult { pub(super) async fn persist_result( inner: &ClientInner, row: &JobRow, - attempt_started_at: DateTime, + attempt_started_at: Timestamp, completion: &CompletionAttempt, result: WorkerResult, metadata_updates: Map, @@ -505,7 +505,7 @@ pub(super) async fn persist_result( completion_sender: &mpsc::Sender, peer: Option>, ) -> Result<(), Error> { - let now = Utc::now(); + let now = Timestamp::now(); let (state, finalized_at, scheduled_at, attempt, attempt_error, metadata, event_kind) = match result { Ok(WorkOutcome::Complete) => ( @@ -668,14 +668,12 @@ const MAX_SCHEDULE_DELAY: Duration = Duration::from_nanos(i64::MAX.cast_unsigned /// Adds a snooze or retry delay to `now`, clamping out-of-range delays the way /// River Go's `time.Duration` arithmetic bounds them instead of failing. -pub(super) fn scheduled_after(now: DateTime, delay: Duration) -> DateTime { - chrono::Duration::from_std(delay.min(MAX_SCHEDULE_DELAY)) - .ok() - .and_then(|delay| now.checked_add_signed(delay)) - .unwrap_or(DateTime::::MAX_UTC) +pub(super) fn scheduled_after(now: Timestamp, delay: Duration) -> Timestamp { + now.checked_add(delay.min(MAX_SCHEDULE_DELAY)) + .unwrap_or(Timestamp::MAX) } -pub(crate) fn default_retry_delay(row: &JobRow, now: DateTime, seed: u64) -> Duration { +pub(crate) fn default_retry_delay(row: &JobRow, now: Timestamp, seed: u64) -> Duration { const MAX_RETRY_NANOS: u64 = i64::MAX as u64; let error_count = u32::try_from(row.errors.len().saturating_add(1)).unwrap_or(u32::MAX); @@ -690,7 +688,11 @@ pub(crate) fn default_retry_delay(row: &JobRow, now: DateTime, seed: u64) - hasher.update(seed.to_be_bytes()); hasher.update(row.id.to_be_bytes()); hasher.update(error_count.to_be_bytes()); - hasher.update(now.timestamp_nanos_opt().unwrap_or_default().to_be_bytes()); + hasher.update( + i64::try_from(now.as_nanosecond()) + .unwrap_or_default() + .to_be_bytes(), + ); let hash = hasher.finalize(); let sample = u32::from_be_bytes(hash[..4].try_into().unwrap()); let ratio = f64::from(sample) / f64::from(u32::MAX); diff --git a/rust/riverqueue/src/client/extension.rs b/rust/riverqueue/src/client/extension.rs index e1db13b80..3a8d4abd6 100644 --- a/rust/riverqueue/src/client/extension.rs +++ b/rust/riverqueue/src/client/extension.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::time::Duration; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use serde_json::Map; use serde_json::value::RawValue; @@ -64,7 +64,7 @@ impl<'client> ExtensionClient<'client> { impl<'client> ExtensionClient<'client> { /// Computes the configured retry delay for an exact-version extension. #[must_use] - pub fn retry_delay(&self, row: &JobRow, error: &WorkError, now: DateTime) -> Duration { + pub fn retry_delay(&self, row: &JobRow, error: &WorkError, now: Timestamp) -> Duration { self.client.inner.retry_policy.next_retry(row, error, now) } @@ -135,7 +135,7 @@ impl<'client> ExtensionClient<'client> { unique_fields: &'a [&'a [&'a str]], encoded_args: Box, params: InsertParams, - target: DateTime, + target: Timestamp, ) -> RawInsertRequest<'a> where 'client: 'a, @@ -289,7 +289,7 @@ enum RawInsertOptions { /// A periodic occurrence due at `target`. Periodic { params: InsertParams, - target: DateTime, + target: Timestamp, }, } @@ -311,7 +311,7 @@ impl RawInsertRequest<'_> { let executor = self.target.into_executor()?; let client = self.client; client.validate_known_kind(self.kind)?; - let now = Utc::now(); + let now = Timestamp::now(); let job = match self.options { RawInsertOptions::Opts(opts) => { let params = InsertOpts::resolve( diff --git a/rust/riverqueue/src/client/insert.rs b/rust/riverqueue/src/client/insert.rs index 5cf507b43..1e87f0e46 100644 --- a/rust/riverqueue/src/client/insert.rs +++ b/rust/riverqueue/src/client/insert.rs @@ -20,7 +20,7 @@ use std::{ pin::Pin, }; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; #[cfg(feature = "postgres")] use serde_json::Value; use serde_json::value::RawValue; @@ -147,7 +147,7 @@ impl<'a, A: JobArgs> IntoFuture for InsertRequest<'a, A> { opts, target, } = self; - let job = client.prepare_typed(&args, opts, Utc::now())?; + let job = client.prepare_typed(&args, opts, Timestamp::now())?; client .run_insert(target.into_executor()?, vec![job], |rows| { let row = rows.into_iter().next().ok_or_else(|| { @@ -195,7 +195,7 @@ impl<'a, A: JobArgs> InsertManyRequest<'a, A> { } fn prepare(client: &Client, jobs: Vec>) -> Result, Error> { - let now = Utc::now(); + let now = Timestamp::now(); jobs.into_iter() .map(|item| client.prepare_typed(&item.args, item.opts, now)) .collect() @@ -285,7 +285,7 @@ impl<'a> IntoFuture for InsertBatchRequest<'a> { client, target, } = self; - let now = Utc::now(); + let now = Timestamp::now(); let mut jobs = Vec::with_capacity(batch.len()); for item in batch.items { client.validate_known_kind(item.kind)?; @@ -459,7 +459,7 @@ impl Client { &self, insert: PeriodicInsert, opts: InsertParams, - target: DateTime, + target: Timestamp, ) -> Result { let job = self.prepare_periodic( insert.kind, @@ -467,7 +467,7 @@ impl Client { insert.encoded_args, opts, target, - Utc::now(), + Timestamp::now(), )?; self.run_insert(None, vec![job], |rows| { rows.into_iter().next().map(|row| row.job).ok_or_else(|| { @@ -482,7 +482,7 @@ impl Client { &self, args: &A, opts: InsertOpts, - now: DateTime, + now: Timestamp, ) -> Result { self.validate_known_kind(A::KIND)?; let encoded_args = crate::encoding::encode_args(args)?; @@ -508,8 +508,8 @@ impl Client { unique_fields: &[&[&str]], encoded_args: Box, mut opts: InsertParams, - target: DateTime, - now: DateTime, + target: Timestamp, + now: Timestamp, ) -> Result { let due_at_target = opts.scheduled_at.is_none(); opts.scheduled_at.get_or_insert(target); @@ -528,7 +528,7 @@ impl Client { unique_fields: &[&[&str]], encoded_args: Box, opts: InsertParams, - now: DateTime, + now: Timestamp, ) -> Result { validate_insert_parts(kind, &opts, self.inner.allow_legacy_job_kinds)?; let unique_key = build_unique_key_parts( @@ -879,7 +879,7 @@ impl Client { ); let record = sqlx::query_as::<_, JobRecord>(AssertSqlSafe(sql)) .bind(Json(&encoded_args)) - .bind(created_at) + .bind(created_at.map(jiff_sqlx::Timestamp::from)) .bind(&kind) // Like River Go's Postgres drivers, a value wider than // the `smallint` column is clamped rather than failing. @@ -887,7 +887,7 @@ impl Client { .bind(Json(&*metadata)) .bind(opts.priority) .bind(&opts.queue) - .bind(opts.scheduled_at) + .bind(opts.scheduled_at.map(jiff_sqlx::Timestamp::from)) .bind(state.as_str()) .bind(&opts.tags) .bind(unique_key) diff --git a/rust/riverqueue/src/client/mod.rs b/rust/riverqueue/src/client/mod.rs index 81da170df..ccd2fbc2d 100644 --- a/rust/riverqueue/src/client/mod.rs +++ b/rust/riverqueue/src/client/mod.rs @@ -56,7 +56,7 @@ use std::{ time::Duration, }; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use tokio::sync::{broadcast, mpsc, watch}; use tokio_util::sync::CancellationToken; @@ -466,17 +466,8 @@ impl Client { /// Formats a time as River Go's `time.Time` JSON (RFC 3339 with nanoseconds /// and trailing zeros trimmed, in UTC), which River stores for /// `cancel_attempted_at`. -pub(crate) fn go_time_json(time: DateTime) -> String { - let formatted = time.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true); - let Some((seconds, fraction)) = formatted.trim_end_matches('Z').split_once('.') else { - return formatted; - }; - let fraction = fraction.trim_end_matches('0'); - if fraction.is_empty() { - format!("{seconds}Z") - } else { - format!("{seconds}.{fraction}Z") - } +pub(crate) fn go_time_json(time: Timestamp) -> String { + crate::encoding::format_rfc3339_nano(time) } /// Generates a client ID unique to this `Client` instance. @@ -487,10 +478,14 @@ pub(crate) fn go_time_json(time: DateTime) -> String { /// microsecond or containers report identical host names, because a shared ID /// would let two clients renew one leadership lease. fn default_client_id() -> String { - default_client_id_with_host(&host_name(), Utc::now(), crate::maintenance::random_u64()) + default_client_id_with_host( + &host_name(), + Timestamp::now(), + crate::maintenance::random_u64(), + ) } -fn default_client_id_with_host(host: &str, created_at: DateTime, random: u64) -> String { +fn default_client_id_with_host(host: &str, created_at: Timestamp, random: u64) -> String { const MAX_HOST_LENGTH: usize = 60; let mut host = host.replace('.', "_"); @@ -503,7 +498,7 @@ fn default_client_id_with_host(host: &str, created_at: DateTime, random: u6 } format!( "{host}_{}_{:08x}", - created_at.format("%Y_%m_%dT%H_%M_%S_%6f"), + created_at.strftime("%Y_%m_%dT%H_%M_%S_%6f"), random & 0xffff_ffff ) } @@ -520,16 +515,13 @@ fn host_name() -> String { #[cfg(test)] mod default_client_id_tests { - use chrono::{TimeZone, Timelike}; use super::*; #[test] fn default_client_id_matches_go_shape_and_is_unique() { - let created_at = Utc - .with_ymd_and_hms(2026, 1, 2, 3, 4, 5) - .unwrap() - .with_nanosecond(678_901_000) + let created_at = "2026-01-02T03:04:05.678901000Z" + .parse::() .unwrap(); assert_eq!( default_client_id_with_host("worker.example.com", created_at, 0xdead_beef), diff --git a/rust/riverqueue/src/client/peers.rs b/rust/riverqueue/src/client/peers.rs index 2e508071d..359962327 100644 --- a/rust/riverqueue/src/client/peers.rs +++ b/rust/riverqueue/src/client/peers.rs @@ -27,8 +27,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use chrono::{DateTime, Utc}; use futures_util::future::BoxFuture; +use jiff::Timestamp; use serde_json::{Map, Value}; use tokio::sync::{Notify, mpsc, oneshot}; use tokio_util::sync::CancellationToken; @@ -78,7 +78,7 @@ pub(crate) struct PeerLedger { coordinator: i64, /// When the coordinator's attempt started, recorded as its peers' /// attempt errors' time. - started_at: DateTime, + started_at: Timestamp, id: u64, idle: Notify, state: Mutex, @@ -186,7 +186,7 @@ fn not_running() -> Error { } impl PeerLedger { - pub(super) fn new(coordinator: i64, started_at: DateTime) -> Self { + pub(super) fn new(coordinator: i64, started_at: Timestamp) -> Self { Self { coordinator, started_at, @@ -562,8 +562,9 @@ impl PeerLedger { let queue_wait_duration = row .attempted_at .and_then(|attempted_at| { - (attempted_at - row.scheduled_at.max(row.created_at)) - .to_std() + attempted_at + .duration_since(row.scheduled_at.max(row.created_at)) + .try_into() .ok() }) .unwrap_or_default(); diff --git a/rust/riverqueue/src/client/producer.rs b/rust/riverqueue/src/client/producer.rs index 4125785f1..aebf8d65a 100644 --- a/rust/riverqueue/src/client/producer.rs +++ b/rust/riverqueue/src/client/producer.rs @@ -4,8 +4,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; -use chrono::Utc; use futures_util::FutureExt as _; +use jiff::Timestamp; #[cfg(feature = "postgres")] use sqlx::postgres::PgRow; #[cfg(feature = "postgres")] @@ -589,9 +589,9 @@ async fn run_reports( ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { ticks.tick().await; - let stale_before = Utc::now() - - chrono::Duration::from_std(PRODUCER_STALE_RETENTION) - .unwrap_or(chrono::Duration::MAX); + let stale_before = Timestamp::now() + - jiff::SignedDuration::try_from(PRODUCER_STALE_RETENTION) + .unwrap_or(jiff::SignedDuration::MAX); let report = std::panic::AssertUnwindSafe( session.keep_alive(crate::__private::ProducerKeepAliveContext { stale_before }), ) @@ -1141,7 +1141,7 @@ pub(crate) async fn standard_claim( kinds: inner.fetch_kinds.as_deref(), limit, max_attempted_by: ATTEMPTED_BY_MAX, - now: Utc::now(), + now: Timestamp::now(), queue, }; crate::database::sqlite::claim(connection, ¶ms) diff --git a/rust/riverqueue/src/client/record.rs b/rust/riverqueue/src/client/record.rs index 5835b6384..950e3e8a3 100644 --- a/rust/riverqueue/src/client/record.rs +++ b/rust/riverqueue/src/client/record.rs @@ -14,7 +14,7 @@ use std::fmt::Display; #[cfg(feature = "postgres")] -use chrono::{DateTime, Utc}; +use jiff::Timestamp; #[cfg(feature = "postgres")] use serde_json::value::RawValue; #[cfg(feature = "postgres")] @@ -115,19 +115,19 @@ impl FieldErrors { #[cfg(feature = "postgres")] pub(crate) struct JobRecord { attempt: i16, - attempted_at: Option>, + attempted_at: Option, attempted_by: Result>, sqlx::Error>, - created_at: DateTime, + created_at: Timestamp, encoded_args: Json>, errors: Result>>>, sqlx::Error>, - finalized_at: Option>, + finalized_at: Option, id: i64, kind: String, max_attempts: i16, metadata: Json>, priority: i16, queue: String, - scheduled_at: DateTime, + scheduled_at: Timestamp, state: String, tags: Result, sqlx::Error>, unique_key: Option>, @@ -144,19 +144,23 @@ impl<'row> FromRow<'row, PgRow> for JobRecord { // and completion paths. Ok(Self { attempt: row.try_get(1)?, - attempted_at: row.try_get(2)?, + attempted_at: row + .try_get::, _>(2)? + .map(jiff_sqlx::Timestamp::to_jiff), attempted_by: row.try_get(3), - created_at: row.try_get(4)?, + created_at: row.try_get::(4)?.to_jiff(), encoded_args: row.try_get(5)?, errors: row.try_get(6), - finalized_at: row.try_get(7)?, + finalized_at: row + .try_get::, _>(7)? + .map(jiff_sqlx::Timestamp::to_jiff), id: row.try_get(0)?, kind: row.try_get(8)?, max_attempts: row.try_get(9)?, metadata: row.try_get(10)?, priority: row.try_get(11)?, queue: row.try_get(12)?, - scheduled_at: row.try_get(13)?, + scheduled_at: row.try_get::(13)?.to_jiff(), state: row.try_get(14)?, tags: row.try_get(15), unique_key: row.try_get(16)?, diff --git a/rust/riverqueue/src/client/tests.rs b/rust/riverqueue/src/client/tests.rs index d52f83f76..b87f6465c 100644 --- a/rust/riverqueue/src/client/tests.rs +++ b/rust/riverqueue/src/client/tests.rs @@ -280,9 +280,7 @@ fn unmatched_cancellation_is_not_retained_without_fetch() { } fn retry_row(error_count: usize) -> JobRow { - let now = DateTime::parse_from_rfc3339("2026-01-02T03:04:05Z") - .unwrap() - .with_timezone(&Utc); + let now = "2026-01-02T03:04:05Z".parse::().unwrap(); JobRow { attempt: i32::try_from(error_count).unwrap_or(i32::MAX), attempted_at: Some(now), @@ -315,7 +313,7 @@ fn retry_row(error_count: usize) -> JobRow { #[test] fn retry_delay_is_seeded_bounded_and_capped() { - let now = Utc::now(); + let now = Timestamp::now(); let row = retry_row(0); let first = default_retry_delay(&row, now, 123); assert_eq!(first, default_retry_delay(&row, now, 123)); @@ -476,33 +474,29 @@ async fn completion_retries_stop_immediately_for_a_closed_pool() { #[test] fn go_time_json_matches_go_rfc3339_nano() { - use chrono::TimeZone as _; - - let base = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(); + let base = "2026-01-02T03:04:05Z".parse::().unwrap(); assert_eq!(go_time_json(base), "2026-01-02T03:04:05Z"); assert_eq!( - go_time_json(base + chrono::Duration::nanoseconds(120_000_000)), + go_time_json(base + jiff::SignedDuration::from_nanos(120_000_000)), "2026-01-02T03:04:05.12Z" ); assert_eq!( - go_time_json(base + chrono::Duration::nanoseconds(123_456_789)), + go_time_json(base + jiff::SignedDuration::from_nanos(123_456_789)), "2026-01-02T03:04:05.123456789Z" ); } #[test] fn schedule_delays_clamp_like_go_durations() { - use chrono::TimeZone as _; - - let now = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(); + let now = "2026-01-02T03:04:05Z".parse::().unwrap(); assert_eq!( scheduled_after(now, Duration::from_secs(90)), - now + chrono::Duration::seconds(90) + now + jiff::SignedDuration::from_secs(90) ); let clamped = scheduled_after(now, Duration::MAX); assert_eq!( clamped, - now + chrono::Duration::nanoseconds(i64::MAX), + now + jiff::SignedDuration::from_nanos(i64::MAX), "delays saturate at Go's maximum time.Duration" ); } @@ -580,8 +574,8 @@ async fn intercepting_extensions_can_only_lower_completion_concurrency() { async fn periodic_jobs_run_at_their_target_unless_scheduled_explicitly() { let pool = sqlx::SqlitePool::connect_lazy("sqlite::memory:").unwrap(); let client = Client::builder(pool).build().unwrap(); - let now = Utc::now(); - let target = now - chrono::Duration::milliseconds(5); + let now = Timestamp::now(); + let target = now - jiff::SignedDuration::from_millis(5); let prepare = |opts: InsertParams| { client .prepare_periodic( @@ -608,7 +602,7 @@ async fn periodic_jobs_run_at_their_target_unless_scheduled_explicitly() { assert_eq!(due.opts.scheduled_at, Some(target)); // An explicit schedule from the constructor is kept and waits. - let later = now + chrono::Duration::minutes(5); + let later = now + jiff::SignedDuration::from_mins(5); let mut explicit = defaults(); explicit.scheduled_at = Some(later); let explicit = prepare(explicit); @@ -649,7 +643,7 @@ async fn completer_abandons_its_backlog_when_a_batch_fails_during_shutdown() { cancellation, error: None, event_kind: JobEventKind::Completed, - finalized_at: Some(Utc::now()), + finalized_at: Some(Timestamp::now()), job_id, metadata: Map::new(), peer: None, @@ -693,7 +687,7 @@ async fn readiness_survives_a_notification_listener_panic() { let path = std::env::temp_dir().join(format!( "river-readiness-{}-{}.sqlite", std::process::id(), - Utc::now().timestamp_nanos_opt().unwrap_or_default() + Timestamp::now().as_nanosecond() )); let pool = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(4) diff --git a/rust/riverqueue/src/database.rs b/rust/riverqueue/src/database.rs index 8dc343350..e02832147 100644 --- a/rust/riverqueue/src/database.rs +++ b/rust/riverqueue/src/database.rs @@ -9,7 +9,7 @@ use std::fmt; use std::time::Duration; #[cfg(feature = "postgres")] -use chrono::NaiveTime; +use jiff::civil::Time; pub use riverqueue_migrate::{SchemaName, SchemaNameError}; #[cfg(feature = "postgres")] use sqlx::{PgConnection, PgPool, Postgres}; @@ -401,7 +401,7 @@ impl Default for PostgresReindexConfig { #[non_exhaustive] pub enum PostgresReindexSchedule { /// Run each day at the supplied UTC wall-clock time. - DailyUtc(NaiveTime), + DailyUtc(Time), /// Run after each elapsed interval from client startup. Interval(Duration), } @@ -409,7 +409,7 @@ pub enum PostgresReindexSchedule { #[cfg(feature = "postgres")] impl Default for PostgresReindexSchedule { fn default() -> Self { - Self::DailyUtc(NaiveTime::MIN) + Self::DailyUtc(Time::MIN) } } diff --git a/rust/riverqueue/src/database/sqlite.rs b/rust/riverqueue/src/database/sqlite.rs index b9b9d5c51..99cb9ae33 100644 --- a/rust/riverqueue/src/database/sqlite.rs +++ b/rust/riverqueue/src/database/sqlite.rs @@ -19,9 +19,9 @@ use std::{ time::Duration, }; -use chrono::{DateTime, SubsecRound, Utc}; +use jiff::Timestamp; use serde_json::{Map, Value, value::RawValue}; -use sqlx::{AssertSqlSafe, FromRow, QueryBuilder, Sqlite, SqliteConnection}; +use sqlx::{AssertSqlSafe, FromRow, QueryBuilder, Row, Sqlite, SqliteConnection}; use sqlx::sqlite::SqliteRow; @@ -151,14 +151,14 @@ impl From for sqlx::Error { #[derive(Clone, Debug)] pub(crate) struct InsertJob<'a> { - pub attempted_at: Option>, + pub attempted_at: Option, pub attempted_by: &'a [String], pub attempt: i32, /// When unset, SQLite's `datetime('now', 'subsec')`, like Go's driver. - pub created_at: Option>, + pub created_at: Option, pub encoded_args: &'a serde_json::value::RawValue, pub errors: &'a [AttemptError], - pub finalized_at: Option>, + pub finalized_at: Option, pub id: Option, pub kind: &'a str, pub max_attempts: i32, @@ -166,7 +166,7 @@ pub(crate) struct InsertJob<'a> { pub priority: i16, pub queue: &'a str, /// When unset, SQLite's `datetime('now', 'subsec')`, like Go's driver. - pub scheduled_at: Option>, + pub scheduled_at: Option, pub state: JobState, pub tags: &'a [String], pub unique_key: Option<&'a [u8]>, @@ -188,7 +188,7 @@ pub(crate) struct ClaimJobs<'a> { pub kinds: Option<&'a [String]>, pub limit: i32, pub max_attempted_by: i32, - pub now: DateTime, + pub now: Timestamp, pub queue: &'a str, } @@ -212,31 +212,31 @@ pub(crate) struct ListJobs<'a> { pub(crate) struct CompleteJob<'a> { pub attempt: Option, pub error: Option<&'a AttemptError>, - pub finalized_at: Option>, + pub finalized_at: Option, pub id: i64, pub metadata_updates: Option<&'a Map>, - pub now: DateTime, - pub scheduled_at: Option>, + pub now: Timestamp, + pub scheduled_at: Option, pub state: JobState, } #[derive(Clone, Debug)] pub(crate) struct RescueJob<'a> { pub error: &'a AttemptError, - pub finalized_at: Option>, + pub finalized_at: Option, pub id: i64, - pub scheduled_at: DateTime, + pub scheduled_at: Timestamp, pub state: JobState, /// The rescue applies only to a job still running from before this time. - pub stuck_horizon: DateTime, + pub stuck_horizon: Timestamp, } /// Job cleaner deletion horizons; `None` keeps that state indefinitely. #[derive(Clone, Debug)] pub(crate) struct CleanupJobs<'a> { - pub cancelled_before: Option>, - pub completed_before: Option>, - pub discarded_before: Option>, + pub cancelled_before: Option, + pub completed_before: Option, + pub discarded_before: Option, pub limit: i32, pub queues_excluded: &'a [&'a str], pub queues_included: Option<&'a [&'a str]>, @@ -244,8 +244,8 @@ pub(crate) struct CleanupJobs<'a> { #[derive(Clone, Debug, PartialEq)] pub(crate) struct Leader { - pub elected_at: DateTime, - pub expires_at: DateTime, + pub elected_at: Timestamp, + pub expires_at: Timestamp, pub leader_id: String, } @@ -267,28 +267,57 @@ pub(crate) struct NotificationInput<'a> { // makes the row undecodable, like other columns River can't represent. // JSON columns are read as bytes, since a value changed out of band might not // even be UTF-8. -#[derive(Clone, Debug, FromRow)] +#[derive(Clone, Debug)] struct JobRecord { attempt: i64, - attempted_at: Option>, + attempted_at: Option, attempted_by: Option>, - created_at: DateTime, + created_at: Timestamp, encoded_args: Vec, errors: Option>, - finalized_at: Option>, + finalized_at: Option, id: i64, kind: String, max_attempts: i64, metadata: Vec, priority: i64, queue: String, - scheduled_at: DateTime, + scheduled_at: Timestamp, state: String, tags: Vec, unique_key: Option>, unique_states: Option, } +impl<'row> FromRow<'row, SqliteRow> for JobRecord { + fn from_row(row: &'row SqliteRow) -> Result { + Ok(Self { + attempt: row.try_get("attempt")?, + attempted_at: sqlite_timestamp(row, "attempted_at")?, + attempted_by: row.try_get("attempted_by")?, + created_at: sqlite_timestamp(row, "created_at")?.ok_or(sqlx::Error::Decode( + Box::new(sqlx::error::UnexpectedNullError), + ))?, + encoded_args: row.try_get("encoded_args")?, + errors: row.try_get("errors")?, + finalized_at: sqlite_timestamp(row, "finalized_at")?, + id: row.try_get("id")?, + kind: row.try_get("kind")?, + max_attempts: row.try_get("max_attempts")?, + metadata: row.try_get("metadata")?, + priority: row.try_get("priority")?, + queue: row.try_get("queue")?, + scheduled_at: sqlite_timestamp(row, "scheduled_at")?.ok_or(sqlx::Error::Decode( + Box::new(sqlx::error::UnexpectedNullError), + ))?, + state: row.try_get("state")?, + tags: row.try_get("tags")?, + unique_key: row.try_get("unique_key")?, + unique_states: row.try_get("unique_states")?, + }) + } +} + impl JobRecord { /// Decodes the row, failing if any field can't be decoded. fn into_job(self) -> Result { @@ -390,13 +419,29 @@ pub(crate) fn decode_job_row(row: &SqliteRow) -> DecodedJob { .decode() } -#[derive(Clone, Debug, FromRow)] +#[derive(Clone, Debug)] struct QueueRecord { - created_at: DateTime, + created_at: Timestamp, metadata: String, name: String, - paused_at: Option>, - updated_at: DateTime, + paused_at: Option, + updated_at: Timestamp, +} + +impl<'row> FromRow<'row, SqliteRow> for QueueRecord { + fn from_row(row: &'row SqliteRow) -> Result { + Ok(Self { + created_at: sqlite_timestamp(row, "created_at")?.ok_or(sqlx::Error::Decode( + Box::new(sqlx::error::UnexpectedNullError), + ))?, + metadata: row.try_get("metadata")?, + name: row.try_get("name")?, + paused_at: sqlite_timestamp(row, "paused_at")?, + updated_at: sqlite_timestamp(row, "updated_at")?.ok_or(sqlx::Error::Decode( + Box::new(sqlx::error::UnexpectedNullError), + ))?, + }) + } } impl QueueRecord { @@ -416,13 +461,27 @@ impl QueueRecord { } } -#[derive(Clone, Debug, FromRow)] +#[derive(Clone, Debug)] struct LeaderRecord { - elected_at: DateTime, - expires_at: DateTime, + elected_at: Timestamp, + expires_at: Timestamp, leader_id: String, } +impl<'row> FromRow<'row, SqliteRow> for LeaderRecord { + fn from_row(row: &'row SqliteRow) -> Result { + Ok(Self { + elected_at: sqlite_timestamp(row, "elected_at")?.ok_or(sqlx::Error::Decode( + Box::new(sqlx::error::UnexpectedNullError), + ))?, + expires_at: sqlite_timestamp(row, "expires_at")?.ok_or(sqlx::Error::Decode( + Box::new(sqlx::error::UnexpectedNullError), + ))?, + leader_id: row.try_get("leader_id")?, + }) + } +} + impl From for Leader { fn from(record: LeaderRecord) -> Self { Self { @@ -466,13 +525,50 @@ fn decode_unique_states(bits: i64) -> Result, String> { .collect()) } -pub(crate) fn sqlite_time(time: DateTime) -> String { - time.round_subsecs(3) - .format("%Y-%m-%d %H:%M:%S%.3f") - .to_string() +// Database timestamps without an offset are UTC. Keep decoding here separate +// from Jiff's SQLx timestamp adapter, which requires an explicit offset. +fn sqlite_timestamp(row: &SqliteRow, column: &str) -> Result, sqlx::Error> { + use sqlx::{Decode, TypeInfo, ValueRef}; + let value = row.try_get_raw(column)?; + if value.is_null() { + return Ok(None); + } + let result = match value.type_info().name() { + "INTEGER" => Timestamp::from_second( + >::decode(value).map_err(sqlx::Error::Decode)?, + ), + "REAL" => { + let days = >::decode(value).map_err(sqlx::Error::Decode)?; + jiff::SignedDuration::try_from_secs_f64((days - 2_440_587.5) * 86_400.0) + .and_then(|duration| Timestamp::UNIX_EPOCH.checked_add(duration)) + } + _ => { + let text = <&str as Decode>::decode(value).map_err(sqlx::Error::Decode)?; + text.parse::().or_else(|_| { + text.parse::() + .and_then(|datetime| datetime.to_zoned(jiff::tz::TimeZone::UTC)) + .map(|zoned| zoned.timestamp()) + }) + } + }; + result.map(Some).map_err(|error| sqlx::Error::ColumnDecode { + index: column.to_owned(), + source: Box::new(error), + }) } -fn sqlite_time_optional(time: Option>) -> Option { +pub(crate) fn sqlite_time(time: Timestamp) -> String { + time.round( + jiff::TimestampRound::new() + .smallest(jiff::Unit::Millisecond) + .mode(jiff::RoundMode::HalfCeil), + ) + .expect("database timestamp rounds to milliseconds") + .strftime("%Y-%m-%d %H:%M:%S%.3f") + .to_string() +} + +fn sqlite_time_optional(time: Option) -> Option { time.map(sqlite_time) } @@ -896,7 +992,7 @@ fn push_contains_value( pub(crate) async fn cancel( connection: &mut SqliteConnection, id: i64, - now: DateTime, + now: Timestamp, ) -> Result, BackendError> { let cancel_attempted_at = go_time_json(now); let sql = format!( @@ -945,7 +1041,7 @@ pub(crate) async fn delete( pub(crate) async fn retry( connection: &mut SqliteConnection, id: i64, - now: DateTime, + now: Timestamp, ) -> Result, BackendError> { let sql = format!( r#" @@ -1115,8 +1211,8 @@ pub(crate) async fn queue_upsert( connection: &mut SqliteConnection, name: &str, metadata: &Map, - paused_at: Option>, - now: DateTime, + paused_at: Option, + now: Timestamp, ) -> Result { let metadata = serde_json::to_string(metadata)?; let sql = format!( @@ -1169,7 +1265,7 @@ pub(crate) async fn queue_list( pub(crate) async fn queue_pause( connection: &mut SqliteConnection, name: &str, - now: DateTime, + now: Timestamp, ) -> Result, BackendError> { queue_set_paused(connection, name, Some(now), now).await } @@ -1177,7 +1273,7 @@ pub(crate) async fn queue_pause( pub(crate) async fn queue_resume( connection: &mut SqliteConnection, name: &str, - now: DateTime, + now: Timestamp, ) -> Result, BackendError> { queue_set_paused(connection, name, None, now).await } @@ -1185,8 +1281,8 @@ pub(crate) async fn queue_resume( async fn queue_set_paused( connection: &mut SqliteConnection, name: &str, - paused_at: Option>, - now: DateTime, + paused_at: Option, + now: Timestamp, ) -> Result, BackendError> { let (paused_at_sql, changed_sql) = if paused_at.is_some() { ("coalesce(paused_at, ?)", "paused_at IS NULL") @@ -1220,7 +1316,7 @@ pub(crate) async fn queue_update( connection: &mut SqliteConnection, name: &str, metadata: Option<&Map>, - now: DateTime, + now: Timestamp, ) -> Result, BackendError> { let metadata = metadata.map(serde_json::to_string).transpose()?; // Like Go, a queue update without metadata leaves it alone. @@ -1245,7 +1341,7 @@ pub(crate) async fn queue_update( pub(crate) async fn queue_delete_expired( connection: &mut SqliteConnection, - updated_before: DateTime, + updated_before: Timestamp, limit: i32, ) -> Result, BackendError> { if limit <= 0 { @@ -1274,7 +1370,7 @@ pub(crate) async fn queue_delete_expired( pub(crate) async fn leader_elect( connection: &mut SqliteConnection, leader_id: &str, - now: DateTime, + now: Timestamp, ttl: Duration, ) -> Result, BackendError> { sqlx::query_as::<_, LeaderRecord>( @@ -1300,8 +1396,8 @@ pub(crate) async fn leader_elect( pub(crate) async fn leader_reelect( connection: &mut SqliteConnection, leader_id: &str, - elected_at: DateTime, - now: DateTime, + elected_at: Timestamp, + now: Timestamp, ttl: Duration, ) -> Result, BackendError> { sqlx::query_as::<_, LeaderRecord>( @@ -1330,7 +1426,7 @@ pub(crate) async fn leader_reelect( pub(crate) async fn leader_resign( connection: &mut SqliteConnection, leader_id: &str, - elected_at: DateTime, + elected_at: Timestamp, ) -> Result { let result = sqlx::query( r#" @@ -1348,7 +1444,7 @@ pub(crate) async fn leader_resign( pub(crate) async fn leader_delete_expired( connection: &mut SqliteConnection, - now: DateTime, + now: Timestamp, ) -> Result { let result = sqlx::query("DELETE FROM river_leader WHERE expires_at < ?") .bind(sqlite_time(now)) @@ -1526,7 +1622,7 @@ impl NotificationListener { pub(crate) async fn notification_cleanup( connection: &mut SqliteConnection, - created_before: DateTime, + created_before: Timestamp, limit: i64, ) -> Result { if limit <= 0 { @@ -1547,7 +1643,7 @@ pub(crate) async fn notification_cleanup( pub(crate) async fn stuck_jobs( connection: &mut SqliteConnection, after_id: i64, - attempted_before: DateTime, + attempted_before: Timestamp, limit: i32, ) -> Result, BackendError> { if limit <= 0 { @@ -1734,7 +1830,7 @@ pub(crate) struct ScheduleCandidate { /// empty. pub(crate) async fn schedule_candidates( connection: &mut SqliteConnection, - now: DateTime, + now: Timestamp, limit: i32, ) -> Result, BackendError> { if limit <= 0 { @@ -1813,7 +1909,7 @@ pub(crate) async fn schedule_set_available( pub(crate) async fn schedule_discard_conflicts( connection: &mut SqliteConnection, ids: &[i64], - now: DateTime, + now: Timestamp, ) -> Result, BackendError> { if ids.is_empty() { return Ok(Vec::new()); @@ -1850,7 +1946,7 @@ pub(crate) async fn schedule_discard_conflicts( #[cfg(test)] mod tests { - use chrono::{TimeDelta, TimeZone, Timelike}; + use jiff::SignedDuration; use serde_json::json; use sqlx::{SqlitePool, sqlite::SqlitePoolOptions}; @@ -1930,10 +2026,8 @@ mod tests { async fn job_lifecycle_and_unique_insert() { let pool = setup().await; let mut connection = pool.acquire().await.unwrap(); - let now = Utc - .with_ymd_and_hms(2026, 1, 2, 3, 4, 5) - .unwrap() - .with_nanosecond(123_800_000) + let now = "2026-01-02T03:04:05.123800000Z" + .parse::() .unwrap(); let args = serde_json::value::to_raw_value(&json!({"message": "hello"})).unwrap(); let metadata = JobMetadata::default(); @@ -2070,7 +2164,7 @@ mod tests { async fn maintenance_leadership_and_wakeup_outbox() { let pool = setup().await; let mut connection = pool.acquire().await.unwrap(); - let now = Utc::now(); + let now = Timestamp::now(); let args = serde_json::value::to_raw_value(&json!({})).unwrap(); let metadata = JobMetadata::default(); let scheduled = insert( @@ -2119,13 +2213,13 @@ mod tests { .is_none() ); // A same-ID lease from another term is neither renewed nor resigned. - let other_term = leader.elected_at - TimeDelta::seconds(1); + let other_term = leader.elected_at - SignedDuration::from_secs(1); assert!( leader_reelect( &mut connection, "leader-1", other_term, - now + TimeDelta::seconds(1), + now + SignedDuration::from_secs(1), Duration::from_secs(30), ) .await @@ -2141,7 +2235,7 @@ mod tests { &mut connection, "leader-1", leader.elected_at, - now + TimeDelta::seconds(1), + now + SignedDuration::from_secs(1), Duration::from_secs(30), ) .await @@ -2183,7 +2277,7 @@ mod tests { async fn notification_cleanup_is_bounded_and_preserves_recent_rows() { let pool = setup().await; let mut connection = pool.acquire().await.unwrap(); - let now = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(); + let now = "2026-01-02T03:04:05Z".parse::().unwrap(); // The oldest expired row has the highest ID, so cleanup by age differs // from cleanup by ID. sqlx::query( @@ -2191,10 +2285,10 @@ mod tests { (?, 'old', 'test'), (?, 'recent', 'test'), (?, 'oldest', 'test')", ) .bind(sqlite_time( - now - TimeDelta::minutes(5) - TimeDelta::seconds(1), + now - SignedDuration::from_mins(5) - SignedDuration::from_secs(1), )) - .bind(sqlite_time(now - TimeDelta::minutes(4))) - .bind(sqlite_time(now - TimeDelta::minutes(6))) + .bind(sqlite_time(now - SignedDuration::from_mins(4))) + .bind(sqlite_time(now - SignedDuration::from_mins(6))) .execute(&mut *connection) .await .unwrap(); @@ -2206,14 +2300,14 @@ mod tests { }; assert_eq!( - notification_cleanup(&mut connection, now - TimeDelta::minutes(5), 1) + notification_cleanup(&mut connection, now - SignedDuration::from_mins(5), 1) .await .unwrap(), 1 ); assert_eq!(remaining(&mut connection).await, ["old", "recent"]); assert_eq!( - notification_cleanup(&mut connection, now - TimeDelta::minutes(5), 10) + notification_cleanup(&mut connection, now - SignedDuration::from_mins(5), 10) .await .unwrap(), 1 @@ -2485,7 +2579,7 @@ mod tests { async fn claim_caps_attempted_by_without_reversing_history() { let pool = setup().await; let mut connection = pool.acquire().await.unwrap(); - let now = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(); + let now = "2026-01-02T03:04:05Z".parse::().unwrap(); let inserted = insert( &mut connection, &InsertJob { @@ -2542,14 +2636,14 @@ mod tests { async fn insert_test_job( connection: &mut SqliteConnection, state: JobState, - now: DateTime, + now: Timestamp, ) -> JobRow { let running = state == JobState::Running; insert( connection, &InsertJob { attempt: i32::from(running), - attempted_at: running.then_some(now - TimeDelta::hours(2)), + attempted_at: running.then_some(now - SignedDuration::from_hours(2)), attempted_by: &[], created_at: Some(now), encoded_args: &serde_json::value::to_raw_value(&json!({})).unwrap(), @@ -2587,7 +2681,7 @@ mod tests { .unwrap(); } - fn retryable_completion(id: i64, error: &AttemptError, now: DateTime) -> CompleteJob<'_> { + fn retryable_completion(id: i64, error: &AttemptError, now: Timestamp) -> CompleteJob<'_> { CompleteJob { attempt: None, error: Some(error), @@ -2595,7 +2689,7 @@ mod tests { id, metadata_updates: None, now, - scheduled_at: Some(now + TimeDelta::hours(1)), + scheduled_at: Some(now + SignedDuration::from_hours(1)), state: JobState::Retryable, } } @@ -2606,7 +2700,7 @@ mod tests { async fn claim_returns_undecodable_rows_separately() { let pool = setup().await; let mut connection = pool.acquire().await.unwrap(); - let now = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(); + let now = "2026-01-02T03:04:05Z".parse::().unwrap(); let job1 = insert_test_job(&mut connection, JobState::Available, now).await; let job2 = insert_test_job(&mut connection, JobState::Available, now).await; let job3 = insert_test_job(&mut connection, JobState::Available, now).await; @@ -2665,7 +2759,7 @@ mod tests { async fn complete_returns_undecodable_rows_partially() { let pool = setup().await; let mut connection = pool.acquire().await.unwrap(); - let now = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(); + let now = "2026-01-02T03:04:05Z".parse::().unwrap(); let job1 = insert_test_job(&mut connection, JobState::Running, now).await; let job2 = insert_test_job(&mut connection, JobState::Running, now).await; set_json_column(&mut connection, job2.id, "tags", r#"{"not":"an array"}"#).await; @@ -2708,7 +2802,7 @@ mod tests { async fn complete_wraps_a_non_array_errors_value() { let pool = setup().await; let mut connection = pool.acquire().await.unwrap(); - let now = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(); + let now = "2026-01-02T03:04:05Z".parse::().unwrap(); let job = insert_test_job(&mut connection, JobState::Running, now).await; set_json_column( &mut connection, @@ -2740,7 +2834,7 @@ mod tests { async fn stuck_jobs_include_undecodable_rows() { let pool = setup().await; let mut connection = pool.acquire().await.unwrap(); - let now = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(); + let now = "2026-01-02T03:04:05Z".parse::().unwrap(); let job1 = insert_test_job(&mut connection, JobState::Running, now).await; let job2 = insert_test_job(&mut connection, JobState::Running, now).await; set_json_column(&mut connection, job1.id, "tags", r#"{"not":"an array"}"#).await; @@ -2758,7 +2852,7 @@ mod tests { async fn late_completion_merges_metadata_without_changing_terminal_state() { let pool = setup().await; let mut connection = pool.acquire().await.unwrap(); - let now = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(); + let now = "2026-01-02T03:04:05Z".parse::().unwrap(); let inserted = insert( &mut connection, &InsertJob { @@ -2826,21 +2920,65 @@ mod tests { } } + #[tokio::test] + async fn timestamp_columns_accept_existing_sqlite_representations() { + let pool = setup().await; + for (stored, expected) in [ + ("0001-01-01 00:00:00.000", "0001-01-01T00:00:00Z"), + ("1969-12-31 23:59:59.999", "1969-12-31T23:59:59.999Z"), + ("2026-01-02 03:04:05.123", "2026-01-02T03:04:05.123Z"), + ( + "2026-01-02T03:04:05.123456789Z", + "2026-01-02T03:04:05.123456789Z", + ), + ("2026-01-02 03:04:05.123+02:00", "2026-01-02T01:04:05.123Z"), + ] { + let row = sqlx::query("SELECT ? AS time") + .bind(stored) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + sqlite_timestamp(&row, "time").unwrap(), + Some(expected.parse::().unwrap()), + "{stored}" + ); + } + for sql in ["SELECT 0 AS time", "SELECT 2440587.5 AS time"] { + let row = sqlx::query(sqlx::AssertSqlSafe(sql)) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + sqlite_timestamp(&row, "time").unwrap(), + Some(Timestamp::UNIX_EPOCH) + ); + } + let row = sqlx::query("SELECT NULL AS time") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(sqlite_timestamp(&row, "time").unwrap(), None); + let row = sqlx::query("SELECT 'invalid' AS time") + .fetch_one(&pool) + .await + .unwrap(); + assert!(sqlite_timestamp(&row, "time").is_err()); + } + #[test] fn wire_encoders_match_go_sqlite() { - let base = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(); - assert_eq!( - sqlite_time(base.with_nanosecond(123_400_000).unwrap()), - "2026-01-02 03:04:05.123" - ); - assert_eq!( - sqlite_time(base.with_nanosecond(123_800_000).unwrap()), - "2026-01-02 03:04:05.124" - ); - assert_eq!( - sqlite_time(base.with_nanosecond(999_800_000).unwrap()), - "2026-01-02 03:04:06.000" - ); + for (input, expected) in [ + ("0001-01-01T00:00:00Z", "0001-01-01 00:00:00.000"), + ("1969-12-31T23:59:59.9994Z", "1969-12-31 23:59:59.999"), + ("1969-12-31T23:59:59.9995Z", "1970-01-01 00:00:00.000"), + ("2026-01-02T03:04:05.1234Z", "2026-01-02 03:04:05.123"), + ("2026-01-02T03:04:05.1235Z", "2026-01-02 03:04:05.124"), + ("2026-01-02T03:04:05.1238Z", "2026-01-02 03:04:05.124"), + ("2026-01-02T03:04:05.9998Z", "2026-01-02 03:04:06.000"), + ] { + assert_eq!(sqlite_time(input.parse().unwrap()), expected, "{input}"); + } assert_eq!(sqlite_ttl(Duration::from_millis(3_255)), "3.255 seconds"); } } diff --git a/rust/riverqueue/src/encoding.rs b/rust/riverqueue/src/encoding.rs index 34a80e545..829e1f284 100644 --- a/rust/riverqueue/src/encoding.rs +++ b/rust/riverqueue/src/encoding.rs @@ -25,15 +25,15 @@ //! * River clients in other languages may encode byte strings as base64 //! strings; a Rust `Vec` encodes as an array of numbers. //! * Canonical timestamps use RFC 3339 with the shortest fractional seconds, -//! while `chrono` pads fractional seconds to 3, 6, or 9 digits. Use -//! [`go_time`] for `DateTime` fields that participate in unique keys. +//! as Jiff timestamps do by default. [`go_time`] explicitly applies this +//! representation to `Timestamp` fields that participate in unique keys. //! //! Encoding fails for non-finite floats (`NaN` and infinities) instead of //! writing them as `null` the way [`serde_json`] does. use std::{fmt, fmt::Write as _, io}; -use chrono::{DateTime, SecondsFormat, Timelike, Utc}; +use jiff::Timestamp; use serde::Serialize; use serde_json::{ ser::{CharEscape, Formatter}, @@ -60,7 +60,7 @@ pub fn encode_args(args: &T) -> Result, ser RawValue::from_string(to_go_string(args)?) } -/// Serde helpers that encode a `DateTime` as River's canonical +/// Serde helpers that encode a `Timestamp` as River's canonical /// timestamp: RFC 3339 in UTC with `Z` and the shortest fractional seconds, /// with no fractional part when it's zero (`2026-01-02T03:04:05.5Z`). /// @@ -68,7 +68,7 @@ pub fn encode_args(args: &T) -> Result, ser /// fields that participate in unique keys shared with other River clients. /// Deserialization accepts any RFC 3339 timestamp. pub mod go_time { - use chrono::{DateTime, Utc}; + use jiff::Timestamp; use serde::{Deserialize, Deserializer, Serializer}; /// Serializes a timestamp in RFC 3339 form with the shortest fractional @@ -78,7 +78,7 @@ pub mod go_time { /// /// Returns the serializer's error. pub fn serialize( - timestamp: &DateTime, + timestamp: &Timestamp, serializer: S, ) -> Result { serializer.serialize_str(&super::format_rfc3339_nano(*timestamp)) @@ -89,23 +89,14 @@ pub mod go_time { /// # Errors /// /// Returns an error when the input is not an RFC 3339 timestamp. - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result, D::Error> { - DateTime::::deserialize(deserializer) + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + Timestamp::deserialize(deserializer) } } /// Formats a UTC timestamp like Go's `time.RFC3339Nano`. -pub(crate) fn format_rfc3339_nano(timestamp: DateTime) -> String { - let mut formatted = timestamp.to_rfc3339_opts(SecondsFormat::Secs, true); - let nanos = timestamp.nanosecond() % 1_000_000_000; - if nanos > 0 { - let fraction = format!("{nanos:09}"); - formatted.insert(formatted.len() - 1, '.'); - formatted.insert_str(formatted.len() - 1, fraction.trim_end_matches('0')); - } - formatted +pub(crate) fn format_rfc3339_nano(timestamp: Timestamp) -> String { + timestamp.to_string() } /// A [`serde_json`] formatter that writes compact JSON with Go's @@ -428,8 +419,6 @@ pub(crate) fn write_go_string(value: &str, output: &mut String) { mod tests { use std::collections::BTreeMap; - use chrono::TimeZone; - use super::*; fn encoded(value: &T) -> String { @@ -567,10 +556,10 @@ mod tests { #[derive(Serialize)] struct Times { #[serde(with = "go_time")] - at: DateTime, + at: Timestamp, } - let whole = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(); + let whole = "2026-01-02T03:04:05Z".parse::().unwrap(); for (nanos, expected) in [ (0, r#"{"at":"2026-01-02T03:04:05Z"}"#), (500_000_000, r#"{"at":"2026-01-02T03:04:05.5Z"}"#), @@ -578,7 +567,7 @@ mod tests { (123_456_000, r#"{"at":"2026-01-02T03:04:05.123456Z"}"#), (1, r#"{"at":"2026-01-02T03:04:05.000000001Z"}"#), ] { - let at = whole + chrono::Duration::nanoseconds(nanos); + let at = whole + jiff::SignedDuration::from_nanos(nanos); assert_eq!(encoded(&Times { at }), expected); } } diff --git a/rust/riverqueue/src/event.rs b/rust/riverqueue/src/event.rs index 9ecc24d52..d01c2d22f 100644 --- a/rust/riverqueue/src/event.rs +++ b/rust/riverqueue/src/event.rs @@ -362,7 +362,7 @@ mod tests { let dropped = Arc::new(AtomicU64::new(2)); let (sender, receiver) = mpsc::channel(1); let mut events = EventReceiver::new(Arc::clone(&dropped), receiver); - let now = chrono::Utc::now(); + let now = jiff::Timestamp::now(); sender .send(Event::queue( QueueEventKind::Paused, diff --git a/rust/riverqueue/src/extension.rs b/rust/riverqueue/src/extension.rs index 26691ef96..9c3f2c9b4 100644 --- a/rust/riverqueue/src/extension.rs +++ b/rust/riverqueue/src/extension.rs @@ -2,7 +2,7 @@ use std::{fmt, future::Future, pin::Pin, sync::Arc, time::Duration}; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use crate::{BoxError, Error, InsertParams, JobRow, PeriodicJobs, WorkContext, WorkOutcome}; @@ -234,7 +234,7 @@ pub struct InsertContext { /// State the job is inserted in: available, pending, or scheduled. pub state: crate::JobState, /// Creation time to persist instead of the database's current time. - pub(crate) created_at: Option>, + pub(crate) created_at: Option, /// Unique key hash computed from the original insertion. pub(crate) unique_key: Option>, /// Bitmask of states in which the unique key is enforced. @@ -706,7 +706,7 @@ impl fmt::Debug for WorkNext<'_> { pub trait RetryPolicy: Send + Sync + 'static { /// Returns the delay before another attempt of `job`, whose attempt /// failed with `error`. - fn next_retry(&self, job: &JobRow, error: &WorkError, now: DateTime) -> Duration; + fn next_retry(&self, job: &JobRow, error: &WorkError, now: Timestamp) -> Duration; } /// River's quartic retry policy with compatibility jitter. @@ -724,7 +724,7 @@ impl DefaultRetryPolicy { } impl RetryPolicy for DefaultRetryPolicy { - fn next_retry(&self, job: &JobRow, _error: &WorkError, now: DateTime) -> Duration { + fn next_retry(&self, job: &JobRow, _error: &WorkError, now: Timestamp) -> Duration { crate::client::default_retry_delay(job, now, self.seed) } } diff --git a/rust/riverqueue/src/job.rs b/rust/riverqueue/src/job.rs index df1533dde..83f81a436 100644 --- a/rust/riverqueue/src/job.rs +++ b/rust/riverqueue/src/job.rs @@ -2,7 +2,7 @@ use std::time::Duration; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::{Map, Value, value::RawValue}; @@ -116,7 +116,7 @@ pub struct AttemptError { /// Time at which the error occurred. It serializes as RFC 3339 with the /// shortest fractional seconds. #[serde(serialize_with = "crate::encoding::go_time::serialize")] - pub at: DateTime, + pub at: Timestamp, /// Attempt number on which the error occurred. pub attempt: i32, /// Stringified worker error or panic value. @@ -128,7 +128,7 @@ pub struct AttemptError { impl AttemptError { /// Creates a persisted attempt error without a panic trace. #[must_use] - pub fn new(at: DateTime, attempt: i32, error: impl Into) -> Self { + pub fn new(at: Timestamp, attempt: i32, error: impl Into) -> Self { Self { at, attempt, @@ -170,7 +170,7 @@ pub struct InsertOpts { #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum ScheduleOverride { /// Schedule the job no earlier than this time. - At(DateTime), + At(Timestamp), /// Make the job immediately eligible, replacing any schedule from a lower /// layer. Immediate, @@ -274,7 +274,7 @@ impl InsertOpts { /// Schedules the job no earlier than `scheduled_at`. #[must_use] - pub const fn with_scheduled_at(mut self, scheduled_at: DateTime) -> Self { + pub const fn with_scheduled_at(mut self, scheduled_at: Timestamp) -> Self { self.scheduled_at = ScheduleOverride::At(scheduled_at); self } @@ -379,7 +379,7 @@ pub struct InsertParams { /// Queue in which the job runs. pub queue: String, /// Earliest time the job may run. - pub scheduled_at: Option>, + pub scheduled_at: Option, /// Searchable tags. pub tags: Vec, /// Unique job options. @@ -499,17 +499,17 @@ pub struct JobRow { /// Current attempt number. pub attempt: i32, /// Last attempt time. - pub attempted_at: Option>, + pub attempted_at: Option, /// IDs of clients that attempted the job. pub attempted_by: Vec, /// Creation time. - pub created_at: DateTime, + pub created_at: Timestamp, /// Encoded job arguments as the exact JSON text stored with the job. pub encoded_args: Box, /// Failed attempts in chronological order. pub errors: Vec, /// Terminal-state time. - pub finalized_at: Option>, + pub finalized_at: Option, /// Stable job kind. pub kind: String, /// Maximum attempts. @@ -521,7 +521,7 @@ pub struct JobRow { /// Queue name. pub queue: String, /// Earliest run time. - pub scheduled_at: DateTime, + pub scheduled_at: Timestamp, /// Current state. pub state: JobState, /// Searchable tags. @@ -542,7 +542,7 @@ impl JobRow { id: i64, kind: impl Into, encoded_args: Box, - now: DateTime, + now: Timestamp, ) -> Self { Self { attempt: 0, @@ -947,7 +947,7 @@ mod tests { .with_max_attempts(9) .with_priority(3) .with_queue("job_queue") - .with_scheduled_at(Utc::now()); + .with_scheduled_at(Timestamp::now()); let resolved = InsertOpts::resolve( 7, job_defaults, @@ -990,7 +990,7 @@ mod tests { #[test] fn overlay_replaces_only_options_set_in_overrides() { - let scheduled_at = Utc::now(); + let scheduled_at = Timestamp::now(); let base = InsertOpts::default() .with_max_attempts(9) .with_priority(3) @@ -1025,7 +1025,7 @@ mod tests { 42, "id_test", crate::encoding::encode_args(&serde_json::json!({})).unwrap(), - Utc::now(), + Timestamp::now(), ); let job = Job::new((), row.clone()); assert_eq!(job.id(), 42); diff --git a/rust/riverqueue/src/job/attempt_error.rs b/rust/riverqueue/src/job/attempt_error.rs index 7f42a64a1..a8d647b08 100644 --- a/rust/riverqueue/src/job/attempt_error.rs +++ b/rust/riverqueue/src/job/attempt_error.rs @@ -9,7 +9,7 @@ use std::{borrow::Cow, fmt}; -use chrono::{DateTime, NaiveDate, Utc}; +use jiff::Timestamp; use serde::{ Deserialize, Deserializer, de::{self, MapAccess, Visitor}, @@ -121,11 +121,10 @@ impl AttemptError { /// Go's zero `time.Time`, which Go leaves in an attempt error without a /// usable `at`. -fn go_zero_time() -> DateTime { - NaiveDate::from_ymd_opt(1, 1, 1) - .and_then(|date| date.and_hms_opt(0, 0, 0)) - .expect("Go's zero time is a valid date") - .and_utc() +fn go_zero_time() -> Timestamp { + "0001-01-01T00:00:00Z" + .parse() + .expect("Go zero time is valid") } /// An attempt error object's fields as raw JSON, matched the way Go's @@ -200,7 +199,7 @@ fn is_json_whitespace(character: char) -> bool { /// Decodes `at` like Go's `time.Time.UnmarshalJSON`: `null` is Go's zero /// time, and a string must hold an RFC 3339 timestamp. Like Go 1.26, the /// string isn't unescaped first. -fn strict_time(raw: &str) -> Result, String> { +fn strict_time(raw: &str) -> Result { if raw == "null" { return Ok(go_zero_time()); } @@ -322,7 +321,7 @@ fn compact_json(raw: &str) -> String { /// two digit hour, `:MM:SS` with valid ranges and no leap second, an optional /// fraction introduced by `.` or `,` (digits past nanoseconds are ignored), /// and `Z` or a `±hh:mm` offset of up to 24 hours and 60 minutes. -fn parse_rfc3339(text: &str) -> Option> { +fn parse_rfc3339(text: &str) -> Option { let mut parser = TimeParser(text.as_bytes()); let year = parser.digits(4)?; parser.expect(b"-")?; @@ -342,10 +341,22 @@ fn parse_rfc3339(text: &str) -> Option> { return None; } - let local = NaiveDate::from_ymd_opt(i32::try_from(year).ok()?, month, day)? - .and_hms_nano_opt(hour, minute, second, nanosecond)? - .and_utc(); - local.checked_sub_signed(chrono::Duration::seconds(offset_seconds)) + let local = jiff::civil::DateTime::new( + i16::try_from(year).ok()?, + i8::try_from(month).ok()?, + i8::try_from(day).ok()?, + i8::try_from(hour).ok()?, + i8::try_from(minute).ok()?, + i8::try_from(second).ok()?, + i32::try_from(nanosecond).ok()?, + ) + .ok()? + .to_zoned(jiff::tz::TimeZone::UTC) + .ok()? + .timestamp(); + local + .checked_sub(jiff::SignedDuration::from_secs(offset_seconds)) + .ok() } /// The unparsed remainder of a timestamp. @@ -419,18 +430,15 @@ impl TimeParser<'_> { #[cfg(test)] mod tests { - use chrono::{DateTime, NaiveDate, Utc}; + use jiff::Timestamp; use super::{AttemptError, go_zero_time}; - fn attempt_at() -> DateTime { - NaiveDate::from_ymd_opt(2024, 1, 2) - .and_then(|date| date.and_hms_micro_opt(3, 4, 5, 123_456)) - .unwrap() - .and_utc() + fn attempt_at() -> Timestamp { + "2024-01-02T03:04:05.123456Z".parse().unwrap() } - fn attempt_error(at: DateTime, attempt: i32, error: &str, trace: &str) -> AttemptError { + fn attempt_error(at: Timestamp, attempt: i32, error: &str, trace: &str) -> AttemptError { AttemptError::new(at, attempt, error).with_trace(trace) } @@ -444,11 +452,8 @@ mod tests { } } - fn whole_second() -> DateTime { - NaiveDate::from_ymd_opt(2024, 1, 2) - .and_then(|date| date.and_hms_opt(3, 4, 5)) - .unwrap() - .and_utc() + fn whole_second() -> Timestamp { + "2024-01-02T03:04:05Z".parse::().unwrap() } #[test] @@ -641,10 +646,7 @@ mod tests { "AtYearZero", r#"{"at":"0000-01-01T00:00:00Z"}"#, attempt_error( - NaiveDate::from_ymd_opt(0, 1, 1) - .and_then(|date| date.and_hms_opt(0, 0, 0)) - .unwrap() - .and_utc(), + "0000-01-01T00:00:00Z".parse::().unwrap(), 0, "", "", @@ -803,12 +805,8 @@ mod tests { #[test] fn serializes_at_like_go() { - let at = NaiveDate::from_ymd_opt(2024, 1, 2) - .unwrap() - .and_hms_opt(3, 4, 5) - .unwrap() - .and_utc() - + chrono::Duration::nanoseconds(678_900_000); + let at = "2024-01-02T03:04:05Z".parse::().unwrap() + + jiff::SignedDuration::from_nanos(678_900_000); let encoded = serde_json::to_string(&attempt_error(at, 1, "", "")).unwrap(); assert_eq!( encoded, diff --git a/rust/riverqueue/src/lib.rs b/rust/riverqueue/src/lib.rs index 058cecae7..c627071f9 100644 --- a/rust/riverqueue/src/lib.rs +++ b/rust/riverqueue/src/lib.rs @@ -41,9 +41,6 @@ mod storage; mod unique; pub mod worker; -/// The Chrono version of the timestamps in River's API, such as -/// [`JobRow::scheduled_at`] and [`InsertOpts::with_scheduled_at`]. -pub use chrono; pub use client::{ Client, ClientBuilder, InsertBatchRequest, InsertManyItem, InsertManyRequest, InsertRequest, JobCancelRequest, JobCompleteRequest, JobCompleteTxRequest, JobDeleteManyRequest, @@ -66,6 +63,9 @@ pub use extension::{ InsertMiddleware, InsertNext, InsertedJob, JobCancelError, Metric, MetricName, PanicError, Plugin, RetryPolicy, WorkCancelled, WorkError, WorkMiddleware, WorkNext, WorkResult, }; +/// The Jiff version of the timestamps in River's API, such as +/// [`JobRow::scheduled_at`] and [`InsertOpts::with_scheduled_at`]. +pub use jiff; pub use job::{ AttemptError, InsertBatch, InsertBatchResult, InsertOpts, InsertParams, InsertResult, Job, JobArgs, JobMetadata, JobRow, JobState, JobStateParseError, ScheduleOverride, UniqueOpts, diff --git a/rust/riverqueue/src/maintenance/cleaner.rs b/rust/riverqueue/src/maintenance/cleaner.rs index 63fd25679..d2a6a2853 100644 --- a/rust/riverqueue/src/maintenance/cleaner.rs +++ b/rust/riverqueue/src/maintenance/cleaner.rs @@ -4,7 +4,7 @@ use std::time::Duration; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; #[cfg(feature = "postgres")] use sqlx::AssertSqlSafe; @@ -32,13 +32,10 @@ pub(super) const NOTIFICATION_CLEANER_INTERVAL: Duration = Duration::from_mins(1 #[cfg(feature = "sqlite")] const NOTIFICATION_RETENTION: Duration = Duration::from_mins(5); -fn horizon( - now: DateTime, - retention: Option, -) -> Result>, Error> { +fn horizon(now: Timestamp, retention: Option) -> Result, Error> { retention .map(|retention| { - chrono::Duration::from_std(retention) + jiff::SignedDuration::try_from(retention) .map(|retention| now - retention) .map_err(|error| Error::configuration_context("maintenance", error.to_string())) }) @@ -59,7 +56,7 @@ pub(super) async fn clean_jobs(context: &ServiceContext) -> Result<(), Maintenan } let queues_excluded = context.inner.pilot.job_cleaner_queue_exclusions(); loop { - let now = Utc::now(); + let now = Timestamp::now(); let limit = batch_size(&context.breakers.job_cleaner); let mut params = FinalizedJobDeleteParams::new(limit); params.cancelled_before = horizon(now, maintenance.cancelled_job_retention)?; @@ -190,7 +187,7 @@ pub(crate) async fn postgres_delete_finalized_jobs( return Ok(0); } let table = schema.qualify("river_job"); - let now = Utc::now(); + let now = Timestamp::now(); let result = sqlx::query(AssertSqlSafe(format!( "DELETE FROM {table} WHERE id IN (\ SELECT id FROM {table} WHERE (\ @@ -203,11 +200,17 @@ pub(crate) async fn postgres_delete_finalized_jobs( )" ))) .bind(params.cancelled_before.is_some()) - .bind(params.cancelled_before.unwrap_or(now)) + .bind(jiff_sqlx::Timestamp::from( + params.cancelled_before.unwrap_or(now), + )) .bind(params.completed_before.is_some()) - .bind(params.completed_before.unwrap_or(now)) + .bind(jiff_sqlx::Timestamp::from( + params.completed_before.unwrap_or(now), + )) .bind(params.discarded_before.is_some()) - .bind(params.discarded_before.unwrap_or(now)) + .bind(jiff_sqlx::Timestamp::from( + params.discarded_before.unwrap_or(now), + )) .bind(¶ms.queues_excluded) .bind(params.queues_included.as_deref()) .bind(params.limit) @@ -249,8 +252,8 @@ pub(crate) async fn sqlite_delete_finalized_jobs( /// Active producers refresh `updated_at`, so their queues survive. pub(super) async fn clean_queues(context: &ServiceContext) -> Result<(), MaintenanceError> { loop { - let updated_before = Utc::now() - - chrono::Duration::from_std(context.inner.maintenance.queue_retention) + let updated_before = Timestamp::now() + - jiff::SignedDuration::try_from(context.inner.maintenance.queue_retention) .map_err(|error| Error::configuration_context("maintenance", error.to_string()))?; let limit = batch_size(&context.breakers.queue_cleaner); let result = clean_queues_batch(context, updated_before, limit).await; @@ -264,7 +267,7 @@ pub(super) async fn clean_queues(context: &ServiceContext) -> Result<(), Mainten async fn clean_queues_batch( context: &ServiceContext, - updated_before: DateTime, + updated_before: Timestamp, limit: i64, ) -> Result { match context.inner.database.pool() { @@ -301,7 +304,7 @@ async fn clean_queues_batch( SELECT name FROM {table} WHERE updated_at < $1 ORDER BY name LIMIT $2\ ) RETURNING name" ))) - .bind(updated_before) + .bind(jiff_sqlx::Timestamp::from(updated_before)) .bind(limit) .fetch_all(&mut *transaction.transaction), ) @@ -325,9 +328,9 @@ pub(super) async fn clean_notifications( context: &ServiceContext, pool: &sqlx::SqlitePool, ) -> Result { - let retention = chrono::Duration::from_std(NOTIFICATION_RETENTION) + let retention = jiff::SignedDuration::try_from(NOTIFICATION_RETENTION) .map_err(|error| Error::configuration_context("maintenance", error.to_string()))?; - let created_before = Utc::now() - retention; + let created_before = Timestamp::now() - retention; let mut deleted = 0; loop { let limit = batch_size(&context.breakers.notification_cleaner); @@ -352,7 +355,7 @@ pub(super) async fn clean_notifications( mod sqlite_tests { use std::sync::Arc; - use chrono::Utc; + use jiff::Timestamp; use riverqueue_migrate::SqliteMigrator; use sqlx::sqlite::SqlitePoolOptions; use tokio_util::sync::CancellationToken; @@ -371,7 +374,7 @@ mod sqlite_tests { .migrate_up() .await .unwrap(); - let expired = sqlite_time(Utc::now() - chrono::Duration::hours(2)); + let expired = sqlite_time(Timestamp::now() - jiff::SignedDuration::from_hours(2)); for _ in 0..5 { sqlx::query( "INSERT INTO river_notification (created_at, payload, topic) \ diff --git a/rust/riverqueue/src/maintenance/elector.rs b/rust/riverqueue/src/maintenance/elector.rs index c3905c8b6..397e179ca 100644 --- a/rust/riverqueue/src/maintenance/elector.rs +++ b/rust/riverqueue/src/maintenance/elector.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration}; use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; #[cfg(feature = "postgres")] use sqlx::AssertSqlSafe; use tokio::{sync::mpsc, time::Instant}; @@ -45,7 +45,7 @@ const BACKOFF_RESET: u32 = 7; #[derive(Clone, Debug)] pub(crate) struct Term { /// Database `elected_at` identifying this term. - pub(crate) elected_at: DateTime, + pub(crate) elected_at: Timestamp, /// Cancelled the moment this client stops trusting the term. pub(crate) token: CancellationToken, } @@ -53,14 +53,14 @@ pub(crate) struct Term { /// Local view of a held lease (Go `leadershipTerm`). #[derive(Clone, Copy, Debug)] struct Lease { - elected_at: DateTime, + elected_at: Timestamp, trusted_until: Instant, } impl Lease { /// Trusts the lease until `ttl - 1s` after the attempt *started*, so time /// spent waiting on a slow database never extends local trust. - fn new(elected_at: DateTime, attempt_started: Instant, ttl: Duration) -> Self { + fn new(elected_at: Timestamp, attempt_started: Instant, ttl: Duration) -> Self { Self { elected_at, trusted_until: attempt_started + ttl.saturating_sub(LOCAL_DEADLINE_SAFETY_MARGIN), @@ -82,18 +82,18 @@ impl Lease { pub(crate) trait LeaderStore: Send + Sync + 'static { /// Deletes an expired lease and inserts this client's lease if none /// exists (`ON CONFLICT DO NOTHING`). Returns the new term's `elected_at`. - async fn elect(&self, ttl: Duration) -> Result>, Error>; + async fn elect(&self, ttl: Duration) -> Result, Error>; /// Extends the lease only when it still belongs to this client *and* term. /// Returns the renewed term's `elected_at`, or `None` if it was lost. async fn reelect( &self, - elected_at: DateTime, + elected_at: Timestamp, ttl: Duration, - ) -> Result>, Error>; + ) -> Result, Error>; /// Deletes this client's lease for exactly this term and announces it. - async fn resign(&self, elected_at: DateTime) -> Result; + async fn resign(&self, elected_at: Timestamp) -> Result; } /// Events observed by tests. @@ -375,7 +375,7 @@ impl Elector { /// Makes a bounded, good-faith attempt to give up the lease even during /// shutdown. The TTL is the backstop if every attempt fails. - async fn resign(&self, elected_at: DateTime) { + async fn resign(&self, elected_at: Timestamp) { for attempt in 1..=RESIGN_ATTEMPTS { let timeout = Duration::from_secs(u64::from(attempt)); match tokio::time::timeout(timeout, self.store.resign(elected_at)).await { @@ -443,12 +443,12 @@ impl DatabaseLeaderStore { #[async_trait] impl LeaderStore for DatabaseLeaderStore { - async fn elect(&self, ttl: Duration) -> Result>, Error> { + async fn elect(&self, ttl: Duration) -> Result, Error> { match self.inner.database.pool() { #[cfg(feature = "sqlite")] DatabasePool::Sqlite(pool) => { let mut transaction = crate::database::begin_sqlite_write(pool).await?; - let now = Utc::now(); + let now = Timestamp::now(); sqlite::leader_delete_expired(&mut transaction, now) .await .map_err(sqlite_error)?; @@ -467,26 +467,27 @@ impl LeaderStore for DatabaseLeaderStore { ))) .execute(&mut *transaction) .await?; - let elected_at = sqlx::query_scalar::<_, DateTime>(AssertSqlSafe(format!( - "INSERT INTO {table} (leader_id, elected_at, expires_at) \ + let elected_at = + sqlx::query_scalar::<_, jiff_sqlx::Timestamp>(AssertSqlSafe(format!( + "INSERT INTO {table} (leader_id, elected_at, expires_at) \ VALUES ($1, now(), now() + make_interval(secs => $2)) \ ON CONFLICT (name) DO NOTHING RETURNING elected_at" - ))) - .bind(&self.inner.id) - .bind(ttl.as_secs_f64()) - .fetch_optional(&mut *transaction) - .await?; + ))) + .bind(&self.inner.id) + .bind(ttl.as_secs_f64()) + .fetch_optional(&mut *transaction) + .await?; transaction.commit().await?; - Ok(elected_at) + Ok(elected_at.map(jiff_sqlx::Timestamp::to_jiff)) } } } async fn reelect( &self, - elected_at: DateTime, + elected_at: Timestamp, ttl: Duration, - ) -> Result>, Error> { + ) -> Result, Error> { match self.inner.database.pool() { #[cfg(feature = "sqlite")] DatabasePool::Sqlite(pool) => { @@ -495,7 +496,7 @@ impl LeaderStore for DatabaseLeaderStore { &mut transaction, &self.inner.id, elected_at, - Utc::now(), + Timestamp::now(), ttl, ) .await @@ -507,22 +508,23 @@ impl LeaderStore for DatabaseLeaderStore { DatabasePool::Postgres(pool) => { let table = self.inner.schema.qualify("river_leader"); Ok( - sqlx::query_scalar::<_, DateTime>(AssertSqlSafe(format!( + sqlx::query_scalar::<_, jiff_sqlx::Timestamp>(AssertSqlSafe(format!( "UPDATE {table} SET expires_at = now() + make_interval(secs => $1) \ WHERE elected_at = $2 AND expires_at >= now() AND leader_id = $3 \ RETURNING elected_at" ))) .bind(ttl.as_secs_f64()) - .bind(elected_at) + .bind(jiff_sqlx::Timestamp::from(elected_at)) .bind(&self.inner.id) .fetch_optional(pool) - .await?, + .await? + .map(jiff_sqlx::Timestamp::to_jiff), ) } } } - async fn resign(&self, elected_at: DateTime) -> Result { + async fn resign(&self, elected_at: Timestamp) -> Result { match self.inner.database.pool() { #[cfg(feature = "sqlite")] DatabasePool::Sqlite(pool) => { @@ -554,7 +556,7 @@ impl LeaderStore for DatabaseLeaderStore { ) \ DELETE FROM {table} USING notified_resignations" ))) - .bind(elected_at) + .bind(jiff_sqlx::Timestamp::from(elected_at)) .bind(&self.inner.id) .bind(self.inner.schema.as_deref()) .bind(crate::NOTIFICATION_TOPIC_LEADERSHIP) @@ -580,9 +582,9 @@ mod unit_tests { /// A lease store whose renewals can be slowed or failed. struct ScriptedStore { - elected_at: DateTime, + elected_at: Timestamp, reelect: Mutex>, - resigned: Mutex>>, + resigned: Mutex>, } enum Reelect { @@ -594,15 +596,15 @@ mod unit_tests { #[async_trait] impl LeaderStore for ScriptedStore { - async fn elect(&self, _ttl: Duration) -> Result>, Error> { + async fn elect(&self, _ttl: Duration) -> Result, Error> { Ok(Some(self.elected_at)) } async fn reelect( &self, - elected_at: DateTime, + elected_at: Timestamp, _ttl: Duration, - ) -> Result>, Error> { + ) -> Result, Error> { let step = self.reelect.lock().unwrap().pop(); match step { Some(Reelect::SlowSuccess(delay)) => { @@ -618,17 +620,15 @@ mod unit_tests { } } - async fn resign(&self, elected_at: DateTime) -> Result { + async fn resign(&self, elected_at: Timestamp) -> Result { self.resigned.lock().unwrap().push(elected_at); Ok(true) } } - async fn run_scripted( - reelect: Reelect, - ) -> (Vec, Vec>, DateTime) { + async fn run_scripted(reelect: Reelect) -> (Vec, Vec, Timestamp) { let elect_interval = Duration::from_millis(100); - let elected_at = Utc::now(); + let elected_at = Timestamp::now(); let store = Arc::new(ScriptedStore { elected_at, reelect: Mutex::new(vec![reelect]), @@ -664,7 +664,7 @@ mod unit_tests { #[tokio::test(start_paused = true)] async fn ignores_resign_requests_queued_before_gaining_leadership() { - let elected_at = Utc::now(); + let elected_at = Timestamp::now(); let store = Arc::new(ScriptedStore { elected_at, reelect: Mutex::new(Vec::new()), @@ -698,7 +698,7 @@ mod unit_tests { #[tokio::test(start_paused = true)] async fn resigns_only_the_named_term() { - let elected_at = Utc::now(); + let elected_at = Timestamp::now(); let store = Arc::new(ScriptedStore { elected_at, reelect: Mutex::new(Vec::new()), @@ -719,7 +719,7 @@ mod unit_tests { // after that term ended, is ignored. wakeup_sender .send(LeadershipWakeup::ResignTerm( - elected_at - chrono::Duration::seconds(1), + elected_at - jiff::SignedDuration::from_secs(1), )) .unwrap(); tokio::time::sleep(Duration::from_millis(50)).await; @@ -771,7 +771,7 @@ mod unit_tests { #[test] fn lease_trust_is_measured_from_attempt_start() { let started = Instant::now(); - let lease = Lease::new(Utc::now(), started, Duration::from_secs(15)); + let lease = Lease::new(Timestamp::now(), started, Duration::from_secs(15)); assert_eq!(lease.remaining(started), Duration::from_secs(14)); assert_eq!(lease.reelect_attempt_timeout(started), ATTEMPT_TIMEOUT); assert_eq!( diff --git a/rust/riverqueue/src/maintenance/maintainer.rs b/rust/riverqueue/src/maintenance/maintainer.rs index c707c101d..39cbc4a76 100644 --- a/rust/riverqueue/src/maintenance/maintainer.rs +++ b/rust/riverqueue/src/maintenance/maintainer.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration}; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use tokio::{sync::mpsc, task::JoinSet}; use tokio_util::sync::CancellationToken; use tracing::{debug, error}; @@ -194,7 +194,7 @@ async fn start_or_resign( inner: &Arc, cancel: &CancellationToken, resign: &mpsc::UnboundedSender, - elected_at: DateTime, + elected_at: Timestamp, ) -> bool { for attempt in 1..=START_ATTEMPTS { match start(inner, cancel).await { diff --git a/rust/riverqueue/src/maintenance/mod.rs b/rust/riverqueue/src/maintenance/mod.rs index 1f674b4bf..7c21e501e 100644 --- a/rust/riverqueue/src/maintenance/mod.rs +++ b/rust/riverqueue/src/maintenance/mod.rs @@ -101,7 +101,7 @@ pub(crate) enum LeadershipWakeup { /// This client's maintenance asks it to resign the term elected at this /// time, and no later one. It never passes through the database, so it /// works without notifications. - ResignTerm(chrono::DateTime), + ResignTerm(jiff::Timestamp), } /// Moves leadership notifications from the client's broadcast channel into diff --git a/rust/riverqueue/src/maintenance/periodic_enqueuer.rs b/rust/riverqueue/src/maintenance/periodic_enqueuer.rs index 4a3dd456e..f66c256cf 100644 --- a/rust/riverqueue/src/maintenance/periodic_enqueuer.rs +++ b/rust/riverqueue/src/maintenance/periodic_enqueuer.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration}; -use chrono::Utc; +use jiff::Timestamp; use super::maintainer::ServiceContext; @@ -27,10 +27,12 @@ pub(super) async fn run(context: Arc) { changed.as_mut().enable(); periodic_jobs - .run_due(&client, Utc::now(), &context.cancel) + .run_due(&client, Timestamp::now(), &context.cancel) .await; let wait = periodic_jobs.next_run_at().map_or(IDLE_WAIT, |next| { - (next - Utc::now()).to_std().unwrap_or_default() + next.duration_since(Timestamp::now()) + .try_into() + .unwrap_or_default() }); tokio::select! { biased; diff --git a/rust/riverqueue/src/maintenance/reindexer.rs b/rust/riverqueue/src/maintenance/reindexer.rs index e13a2a087..d2ea9e5fd 100644 --- a/rust/riverqueue/src/maintenance/reindexer.rs +++ b/rust/riverqueue/src/maintenance/reindexer.rs @@ -3,7 +3,7 @@ use std::time::Duration; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use sqlx::{AssertSqlSafe, PgConnection, PgPool}; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; @@ -25,26 +25,27 @@ const ARTIFACT_CLEANUP_TIMEOUT: Duration = Duration::from_secs(15); const CATALOG_TIMEOUT: Duration = Duration::from_secs(30); /// Returns the first scheduled run strictly after `after`. -pub(crate) fn next_run(schedule: PostgresReindexSchedule, after: DateTime) -> DateTime { +pub(crate) fn next_run(schedule: PostgresReindexSchedule, after: Timestamp) -> Timestamp { match schedule { PostgresReindexSchedule::DailyUtc(time) => { - let today = after.date_naive().and_time(time).and_utc(); + let date = after.to_zoned(jiff::tz::TimeZone::UTC).date(); + let today = date + .to_datetime(time) + .to_zoned(jiff::tz::TimeZone::UTC) + .expect("valid UTC date") + .timestamp(); if today > after { today } else { - after - .date_naive() - .succ_opt() + date.tomorrow() .expect("UTC date has a following day") - .and_time(time) - .and_utc() + .to_datetime(time) + .to_zoned(jiff::tz::TimeZone::UTC) + .expect("valid UTC date") + .timestamp() } } - PostgresReindexSchedule::Interval(interval) => { - after - + chrono::Duration::from_std(interval) - .expect("validated reindexer interval fits chrono duration") - } + PostgresReindexSchedule::Interval(interval) => after + interval, } } @@ -61,10 +62,13 @@ pub(super) async fn run(context: std::sync::Arc, pool: sqlx::PgP if config.index_names().is_empty() { return; } - let mut scheduled = next_run(config.schedule(), Utc::now()); + let mut scheduled = next_run(config.schedule(), Timestamp::now()); debug!(next_run_at = %scheduled, "River reindexer scheduled its first run"); loop { - let wait = (scheduled - Utc::now()).to_std().unwrap_or_default(); + let wait = scheduled + .duration_since(Timestamp::now()) + .try_into() + .unwrap_or_default(); if !sleep_cancellable(&context.cancel, wait).await { return; } @@ -306,26 +310,26 @@ async fn cancellable_catalog( #[cfg(test)] mod unit_tests { - use chrono::{NaiveTime, TimeZone, Utc}; + use jiff::civil::Time; use super::{PostgresReindexSchedule, next_run}; #[test] fn schedule_advances_from_the_previous_run() { - let before_midnight = Utc.with_ymd_and_hms(2026, 8, 11, 23, 59, 0).unwrap(); - let midnight = Utc.with_ymd_and_hms(2026, 8, 12, 0, 0, 0).unwrap(); - let daily = PostgresReindexSchedule::DailyUtc(NaiveTime::MIN); + let before_midnight = "2026-08-11T23:59:00Z".parse::().unwrap(); + let midnight = "2026-08-12T00:00:00Z".parse::().unwrap(); + let daily = PostgresReindexSchedule::DailyUtc(Time::MIN); assert_eq!(next_run(daily, before_midnight), midnight); // A run exactly at midnight schedules the following midnight, like // Go's `t.Add(24h).Truncate(24h)`. assert_eq!( next_run(daily, midnight), - Utc.with_ymd_and_hms(2026, 8, 13, 0, 0, 0).unwrap() + "2026-08-13T00:00:00Z".parse::().unwrap() ); let interval = PostgresReindexSchedule::Interval(std::time::Duration::from_secs(30)); assert_eq!( next_run(interval, before_midnight), - before_midnight + chrono::Duration::seconds(30) + before_midnight + jiff::SignedDuration::from_secs(30) ); } } diff --git a/rust/riverqueue/src/maintenance/rescuer.rs b/rust/riverqueue/src/maintenance/rescuer.rs index 9030f4c78..355397b25 100644 --- a/rust/riverqueue/src/maintenance/rescuer.rs +++ b/rust/riverqueue/src/maintenance/rescuer.rs @@ -1,6 +1,6 @@ //! Rescues jobs stuck in `running`, a port of Go's `JobRescuer`. -use chrono::{DateTime, Utc}; +use jiff::Timestamp; #[cfg(feature = "postgres")] use sqlx::AssertSqlSafe; use tracing::{debug, error}; @@ -33,8 +33,8 @@ pub(crate) const RESCUE_ERROR: &str = "Stuck job rescued by JobRescuer"; /// rows that are still `running` with `attempted_at` before the horizon, which /// leaves jobs that completed or were claimed again after selection untouched. pub(super) async fn run_once(context: &ServiceContext) -> Result<(), MaintenanceError> { - let stuck_horizon = Utc::now() - - chrono::Duration::from_std(context.inner.maintenance.effective_rescue_after()) + let stuck_horizon = Timestamp::now() + - jiff::SignedDuration::try_from(context.inner.maintenance.effective_rescue_after()) .map_err(|error| Error::configuration_context("maintenance", error.to_string()))?; let mut after_id = 0_i64; loop { @@ -61,7 +61,7 @@ async fn rescue_batch( context: &ServiceContext, after_id: i64, limit: i64, - stuck_horizon: DateTime, + stuck_horizon: Timestamp, ) -> Result { match context.inner.database.pool() { #[cfg(feature = "sqlite")] @@ -84,7 +84,7 @@ fn rescue_params( inner: &ClientInner, after_id: i64, limit: i64, - stuck_horizon: DateTime, + stuck_horizon: Timestamp, ) -> RescueParams { RescueParams { after_id, @@ -101,7 +101,7 @@ fn rescue_params( fn rescue_jobs( inner: &ClientInner, rows: &[JobRow], - now: DateTime, + now: Timestamp, ) -> Result, Error> { let mut jobs = Vec::with_capacity(rows.len()); for row in rows { @@ -125,10 +125,10 @@ fn rescue_jobs( Ok(jobs) } -type Decision = Option<(JobState, Option>, DateTime)>; +type Decision = Option<(JobState, Option, Timestamp)>; /// Go's `makeRetryDecision`, preceded by its cancellation check. -fn decide(inner: &ClientInner, row: &JobRow, now: DateTime) -> Result { +fn decide(inner: &ClientInner, row: &JobRow, now: Timestamp) -> Result { if cancel_attempted(row.metadata.get_raw("cancel_attempted_at")) { return Ok(Some((JobState::Cancelled, Some(now), row.scheduled_at))); } @@ -140,7 +140,7 @@ fn decide(inner: &ClientInner, row: &JobRow, now: DateTime) -> Result| { + let retry_or_discard = |retry_at: Timestamp| { if row.attempt < row.max_attempts.max(0) { (JobState::Retryable, None, retry_at) } else { @@ -151,10 +151,10 @@ fn decide(inner: &ClientInner, row: &JobRow, now: DateTime) -> Result Result, Error> { + let client_retry = |row: &JobRow| -> Result { let delay = inner.retry_policy.next_retry(row, &rescued_error, now); Ok(now - + chrono::Duration::from_std(delay) + + jiff::SignedDuration::try_from(delay) .map_err(|error| Error::invalid_job_context("maintenance", error.to_string()))?) }; @@ -174,9 +174,9 @@ fn decide(inner: &ClientInner, row: &JobRow, now: DateTime) -> Result) -> Result { - now + chrono::Duration::from_std(delay) + now + jiff::SignedDuration::try_from(delay) .map_err(|error| Error::invalid_job_context("maintenance", error.to_string()))? } Ok(None) => client_retry(row)?, @@ -199,12 +199,13 @@ fn decide(inner: &ClientInner, row: &JobRow, now: DateTime) -> Result) -> bool { - let go_zero_time = - chrono::NaiveDate::from_ymd_opt(1, 1, 1).and_then(|date| date.and_hms_opt(0, 0, 0)); + let go_zero_time = "0001-01-01T00:00:00Z" + .parse::() + .expect("Go zero time is valid"); value .and_then(|raw| serde_json::from_str::(raw.get()).ok()) - .and_then(|value| DateTime::parse_from_rfc3339(&value).ok()) - .is_some_and(|time| Some(time.naive_utc()) != go_zero_time) + .and_then(|value| value.parse::().ok()) + .is_some_and(|time| time != go_zero_time) } #[cfg(feature = "postgres")] @@ -217,7 +218,7 @@ async fn rescue_batch_postgres( pool: &sqlx::PgPool, after_id: i64, limit: i64, - stuck_horizon: DateTime, + stuck_horizon: Timestamp, ) -> Result { use super::postgres::{MaintenanceTransaction, cancellable}; @@ -276,7 +277,7 @@ async fn rescue_batch_postgres( job_projection("job") ))) .bind(after_id) - .bind(stuck_horizon) + .bind(jiff_sqlx::Timestamp::from(stuck_horizon)) .bind(limit) .fetch_all(&mut *transaction.transaction), ) @@ -294,7 +295,7 @@ async fn rescue_batch_postgres( last_id: rows.last().map(|row| row.id), selected: rows.len(), }; - let jobs = rescue_jobs(inner, &rows, Utc::now())?; + let jobs = rescue_jobs(inner, &rows, Timestamp::now())?; if jobs.is_empty() { transaction.commit(pool, &context.cancel).await?; return Ok(batch); @@ -360,14 +361,14 @@ async fn rescue_batch_postgres( params .jobs .iter() - .map(|job| job.finalized_at) + .map(|job| job.finalized_at.map(jiff_sqlx::Timestamp::from)) .collect::>(), ) .bind( params .jobs .iter() - .map(|job| job.scheduled_at) + .map(|job| jiff_sqlx::Timestamp::from(job.scheduled_at)) .collect::>(), ) .bind( @@ -377,7 +378,7 @@ async fn rescue_batch_postgres( .map(|job| job.state.as_str()) .collect::>(), ) - .bind(stuck_horizon) + .bind(jiff_sqlx::Timestamp::from(stuck_horizon)) .execute(&mut *transaction.transaction), ) .await?; @@ -392,7 +393,7 @@ async fn rescue_batch_sqlite( pool: &sqlx::SqlitePool, after_id: i64, limit: i64, - stuck_horizon: DateTime, + stuck_horizon: Timestamp, ) -> Result { let mut transaction = crate::database::begin_sqlite_write(pool).await?; let selected_ids = if inner.pilot.intercepts_rescue() { @@ -428,7 +429,7 @@ async fn rescue_batch_sqlite( last_id: rows.last().map(|row| row.id), selected: rows.len(), }; - let jobs = rescue_jobs(inner, &rows, Utc::now())?; + let jobs = rescue_jobs(inner, &rows, Timestamp::now())?; if !jobs.is_empty() { let params = RescueManyParams { database: inner.pilot_database_config(), diff --git a/rust/riverqueue/src/maintenance/scheduler.rs b/rust/riverqueue/src/maintenance/scheduler.rs index 03dad9571..9d3424a0a 100644 --- a/rust/riverqueue/src/maintenance/scheduler.rs +++ b/rust/riverqueue/src/maintenance/scheduler.rs @@ -3,7 +3,7 @@ use std::{collections::BTreeSet, time::Duration}; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; #[cfg(feature = "postgres")] use sqlx::{AssertSqlSafe, Row}; @@ -27,7 +27,7 @@ const NOTIFICATION_HORIZON: Duration = Duration::from_millis(5); /// A job the scheduler transitioned. struct Scheduled { queue: String, - scheduled_at: DateTime, + scheduled_at: Timestamp, } /// Runs scheduling batches until a batch is smaller than the batch size. @@ -48,9 +48,9 @@ pub(super) async fn run_once(context: &ServiceContext) -> Result<(), Maintenance } async fn schedule_batch(context: &ServiceContext, limit: i64) -> Result { - let now = Utc::now(); + let now = Timestamp::now(); let look_ahead = now - + chrono::Duration::from_std(context.inner.maintenance.scheduler_interval) + + jiff::SignedDuration::try_from(context.inner.maintenance.scheduler_interval) .unwrap_or_default(); match context.inner.database.pool() { #[cfg(feature = "sqlite")] @@ -75,7 +75,8 @@ async fn schedule_batch(context: &ServiceContext, limit: i64) -> Result BTreeSet { - let horizon = Utc::now() + chrono::Duration::from_std(NOTIFICATION_HORIZON).unwrap_or_default(); + let horizon = + Timestamp::now() + jiff::SignedDuration::try_from(NOTIFICATION_HORIZON).unwrap_or_default(); scheduled .iter() .filter(|job| job.scheduled_at <= horizon) @@ -87,7 +88,7 @@ fn notified_queues(scheduled: &[Scheduled]) -> BTreeSet { async fn schedule_batch_postgres( context: &ServiceContext, pool: &sqlx::PgPool, - look_ahead: DateTime, + look_ahead: Timestamp, limit: i64, ) -> Result { use super::postgres::{MaintenanceTransaction, cancellable}; @@ -141,7 +142,7 @@ async fn schedule_batch_postgres( TIMEOUT_DEFAULT, sqlx::query(AssertSqlSafe(sql)) .bind(limit) - .bind(look_ahead) + .bind(jiff_sqlx::Timestamp::from(look_ahead)) .fetch_all(&mut *transaction.transaction), ) .await?; @@ -151,7 +152,7 @@ async fn schedule_batch_postgres( .filter(|row| row.get::("state") == "available") .map(|row| Scheduled { queue: row.get("queue"), - scheduled_at: row.get("scheduled_at"), + scheduled_at: row.get::("scheduled_at").to_jiff(), }) .collect::>(); let notified = notified_queues(&scheduled); @@ -211,7 +212,7 @@ async fn delivers_notifications( async fn schedule_batch_sqlite( pool: &sqlx::SqlitePool, notify_limiter: &InsertNotifyLimiter, - look_ahead: DateTime, + look_ahead: Timestamp, limit: i64, ) -> Result { let mut transaction = crate::database::begin_sqlite_write(pool).await?; @@ -274,7 +275,7 @@ async fn schedule_batch_sqlite( mod sqlite_tests { use std::{sync::Arc, time::Duration}; - use chrono::Utc; + use jiff::Timestamp; use riverqueue_migrate::SqliteMigrator; use serde::{Deserialize, Serialize}; use sqlx::{SqlitePool, sqlite::SqlitePoolOptions}; @@ -293,7 +294,7 @@ mod sqlite_tests { VALUES (jsonb('{}'), 'scheduler_notification', 25, jsonb('{}'), ?, ?, 'scheduled')", ) .bind(queue) - .bind(sqlite_time(Utc::now() - chrono::Duration::hours(1))) + .bind(sqlite_time(Timestamp::now() - jiff::SignedDuration::from_hours(1))) .execute(pool) .await .unwrap(); diff --git a/rust/riverqueue/src/maintenance/tests.rs b/rust/riverqueue/src/maintenance/tests.rs index 1422055f0..70f6d4a4f 100644 --- a/rust/riverqueue/src/maintenance/tests.rs +++ b/rust/riverqueue/src/maintenance/tests.rs @@ -13,7 +13,7 @@ use std::{ }; use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use serde::{Deserialize, Serialize}; use sqlx::{AssertSqlSafe, PgPool, postgres::PgPoolOptions}; use tokio::sync::mpsc; @@ -111,7 +111,7 @@ impl TestDatabase { "{prefix}_{:x}_{:x}_{:x}", std::process::id(), NONCE.fetch_add(1, Ordering::Relaxed), - Utc::now().timestamp_subsec_nanos() + Timestamp::now().subsec_nanosecond() ); name.truncate(riverqueue_migrate::SCHEMA_MAX_LEN); sqlx::raw_sql(AssertSqlSafe(format!("CREATE SCHEMA \"{name}\""))) @@ -151,13 +151,13 @@ impl TestDatabase { self.schema.qualify("river_job_state"), ))) .bind(job.attempt) - .bind(job.attempted_at) - .bind(job.finalized_at) + .bind(job.attempted_at.map(jiff_sqlx::Timestamp::from)) + .bind(job.finalized_at.map(jiff_sqlx::Timestamp::from)) .bind(job.kind) .bind(job.max_attempts) .bind(sqlx::types::Json(job.metadata)) .bind(job.queue) - .bind(job.scheduled_at) + .bind(job.scheduled_at.map(jiff_sqlx::Timestamp::from)) .bind(job.state) .bind(job.unique_key) .bind(job.unique_states) @@ -195,13 +195,13 @@ impl TestDatabase { struct RawJob<'a> { attempt: i16, - attempted_at: Option>, - finalized_at: Option>, + attempted_at: Option, + finalized_at: Option, kind: &'a str, max_attempts: i16, metadata: serde_json::Value, queue: &'a str, - scheduled_at: Option>, + scheduled_at: Option, state: &'a str, unique_key: Option>, /// Unique-state bitmask; defaults to every state when a key is set. @@ -226,8 +226,8 @@ impl Default for RawJob<'_> { } } -fn hours_ago(hours: i64) -> DateTime { - Utc::now() - chrono::Duration::hours(hours) +fn hours_ago(hours: i64) -> Timestamp { + Timestamp::now() - jiff::SignedDuration::from_hours(hours) } fn stuck(kind: &str) -> RawJob<'_> { @@ -405,14 +405,14 @@ async fn scheduler_look_ahead_and_unique_conflicts() { .await; let look_ahead = database .insert_job(RawJob { - scheduled_at: Some(Utc::now() + chrono::Duration::seconds(2)), + scheduled_at: Some(Timestamp::now() + jiff::SignedDuration::from_secs(2)), state: "retryable", ..RawJob::default() }) .await; let later = database .insert_job(RawJob { - scheduled_at: Some(Utc::now() + chrono::Duration::minutes(5)), + scheduled_at: Some(Timestamp::now() + jiff::SignedDuration::from_mins(5)), state: "scheduled", ..RawJob::default() }) @@ -459,10 +459,10 @@ async fn scheduler_look_ahead_and_unique_conflicts() { database .insert_job(RawJob { attempt: i16::from(holder_state == "running"), - attempted_at: (holder_state == "running").then(Utc::now), - finalized_at: (holder_state == "completed").then(Utc::now), + attempted_at: (holder_state == "running").then(Timestamp::now), + finalized_at: (holder_state == "completed").then(Timestamp::now), scheduled_at: (holder_state == "scheduled") - .then(|| Utc::now() + chrono::Duration::hours(1)), + .then(|| Timestamp::now() + jiff::SignedDuration::from_hours(1)), state: holder_state, unique_key: Some(vec![key; 32]), unique_states: without_retryable, @@ -541,7 +541,7 @@ async fn job_cleaner_retention_exclusions_and_batches() { } let recent = database .insert_job(RawJob { - finalized_at: Some(Utc::now()), + finalized_at: Some(Timestamp::now()), state: "completed", ..RawJob::default() }) @@ -728,7 +728,7 @@ async fn elector_loses_leadership_when_same_id_term_is_replaced() { // Another instance with the same ID replaces the term: same leader ID, // newer `elected_at`. let table = database.table("river_leader"); - let replaced_elected_at: DateTime = sqlx::query_scalar(AssertSqlSafe(format!( + let replaced_elected_at: jiff_sqlx::Timestamp = sqlx::query_scalar(AssertSqlSafe(format!( "WITH removed AS (DELETE FROM {table} RETURNING leader_id, elected_at) \ INSERT INTO {table} (leader_id, elected_at, expires_at) \ SELECT leader_id, elected_at + interval '1 second', now() + interval '1 hour' FROM removed \ @@ -737,7 +737,7 @@ async fn elector_loses_leadership_when_same_id_term_is_replaced() { .fetch_one(&database.pool) .await .unwrap(); - assert!(replaced_elected_at > term.elected_at); + assert!(replaced_elected_at.to_jiff() > term.elected_at); tokio::time::timeout(Duration::from_secs(10), term.token.cancelled()) .await @@ -757,7 +757,7 @@ async fn elector_loses_leadership_when_same_id_term_is_replaced() { assert!(!observed.contains(&ElectorEvent::Resigned)); // The replacement term was neither renewed nor deleted by this client. - let (elected_at, expires_in_minutes): (DateTime, f64) = + let (elected_at, expires_in_minutes): (jiff_sqlx::Timestamp, f64) = sqlx::query_as(AssertSqlSafe(format!( "SELECT elected_at, (extract(epoch FROM expires_at - now()) / 60)::float8 FROM {table}" ))) @@ -873,7 +873,7 @@ async fn periodic_jobs_use_expected_run_time_and_uniqueness() { )) .unwrap(); - let start = Utc::now(); + let start = Timestamp::now(); periodic.reset_for_leadership(); periodic .run_due(&client, start, &CancellationToken::new()) @@ -885,11 +885,11 @@ async fn periodic_jobs_use_expected_run_time_and_uniqueness() { periodic .run_due( &client, - target - chrono::Duration::milliseconds(50), + target - jiff::SignedDuration::from_millis(50), &CancellationToken::new(), ) .await; - let rows: Vec> = sqlx::query_scalar(AssertSqlSafe(format!( + let rows: Vec = sqlx::query_scalar(AssertSqlSafe(format!( "SELECT scheduled_at FROM {}", database.table("river_job") ))) @@ -898,8 +898,8 @@ async fn periodic_jobs_use_expected_run_time_and_uniqueness() { .unwrap(); assert_eq!(rows.len(), 1); assert_eq!( - rows[0].timestamp_micros(), - target.timestamp_micros(), + rows[0].to_jiff().as_microsecond(), + target.as_microsecond(), "periodic job scheduled at its expected run time" ); @@ -1140,7 +1140,7 @@ async fn maintenance_start_retries_then_resigns(poll_only: bool) { let pool = database.pool.clone(); let table = database.table("river_leader"); async move { - sqlx::query_scalar::<_, DateTime>(AssertSqlSafe(format!( + sqlx::query_scalar::<_, jiff_sqlx::Timestamp>(AssertSqlSafe(format!( "SELECT elected_at FROM {table}" ))) .fetch_optional(&pool) diff --git a/rust/riverqueue/src/periodic/cron.rs b/rust/riverqueue/src/periodic/cron.rs index 19dc8dd20..1c946cdba 100644 --- a/rust/riverqueue/src/periodic/cron.rs +++ b/rust/riverqueue/src/periodic/cron.rs @@ -12,9 +12,10 @@ use std::{fmt, str::FromStr, time::Duration}; -use chrono::{ - DateTime, Datelike, FixedOffset, Local, NaiveDate, NaiveDateTime, Offset as _, TimeZone, - Timelike, Utc, +use jiff::{ + SignedDuration, Timestamp, Zoned, + civil::{Date, DateTime}, + tz::{Offset, TimeZone}, }; use thiserror::Error as ThisError; @@ -77,7 +78,7 @@ const DAYS_OF_WEEK: Bounds = Bounds { }; /// Time zone in which a [`CronSchedule`] is evaluated. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Eq, PartialEq)] #[non_exhaustive] pub enum CronTimeZone { /// The process's local time zone, the default. Containers usually run in @@ -88,12 +89,24 @@ pub enum CronTimeZone { /// Coordinated Universal Time. Utc, /// A fixed offset from UTC, without daylight saving time. - Fixed(FixedOffset), + Fixed(Offset), /// A zone from the IANA time zone database, with its daylight saving /// rules. `CRON_TZ=` and `TZ=` prefixes naming a zone such as /// `America/New_York` parse to this variant. - #[cfg(feature = "chrono-tz")] - Named(chrono_tz::Tz), + #[cfg(feature = "tzdb")] + Named(TimeZone), +} + +impl CronTimeZone { + fn resolve(&self) -> TimeZone { + match self { + Self::Local => TimeZone::system(), + Self::Utc => TimeZone::UTC, + Self::Fixed(offset) => TimeZone::fixed(*offset), + #[cfg(feature = "tzdb")] + Self::Named(zone) => zone.clone(), + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -113,7 +126,7 @@ enum Spec { /// Parsing accepts exactly the expressions every River client accepts, and /// [`CronSchedule::next_after`] returns the same occurrences, so a schedule /// fires at the same times whichever client leads. -/// Without the `chrono-tz` feature, which bundles the IANA time zone +/// Without the `tzdb` feature, which bundles the IANA time zone /// database, named `CRON_TZ=`/`TZ=` zones are limited to `UTC`, `Local`, and /// `Etc/GMT±N`, and other names are rejected; use /// [`CronSchedule::with_time_zone`] for other fixed offsets. @@ -150,7 +163,7 @@ impl CronSchedule { /// Returns the zone in which periodic occurrences are computed. #[must_use] pub fn time_zone(&self) -> CronTimeZone { - self.zone.unwrap_or_default() + self.zone.clone().unwrap_or_default() } /// Returns the first occurrence strictly after `after`, or `None` when @@ -159,21 +172,14 @@ impl CronSchedule { /// Without an explicit zone or `CRON_TZ=` prefix, the occurrence is /// computed in `after`'s own time zone. #[must_use] - pub fn next_after(&self, after: &DateTime) -> Option> { - let zone = after.timezone(); - match self.zone { - None => next_in(self.spec, after.clone()), - Some(CronTimeZone::Local) => next_in(self.spec, after.with_timezone(&Local)) - .map(|next| next.with_timezone(&zone)), - Some(CronTimeZone::Utc) => { - next_in(self.spec, after.with_timezone(&Utc)).map(|next| next.with_timezone(&zone)) - } - Some(CronTimeZone::Fixed(offset)) => next_in(self.spec, after.with_timezone(&offset)) - .map(|next| next.with_timezone(&zone)), - #[cfg(feature = "chrono-tz")] - Some(CronTimeZone::Named(named)) => next_in(self.spec, after.with_timezone(&named)) - .map(|next| next.with_timezone(&zone)), - } + pub fn next_after(&self, after: &Zoned) -> Option { + let zone = after.time_zone().clone(); + let evaluation_zone = self + .zone + .as_ref() + .map_or_else(|| zone.clone(), CronTimeZone::resolve); + next_in(self.spec, &after.with_time_zone(evaluation_zone)) + .map(|next| next.with_time_zone(zone)) } } @@ -200,17 +206,9 @@ impl fmt::Display for CronSchedule { } impl PeriodicSchedule for CronSchedule { - fn next(&self, current: DateTime) -> Option> { - match self.time_zone() { - CronTimeZone::Local => next_in(self.spec, current.with_timezone(&Local)) - .map(|next| next.with_timezone(&Utc)), - CronTimeZone::Utc => next_in(self.spec, current), - CronTimeZone::Fixed(offset) => next_in(self.spec, current.with_timezone(&offset)) - .map(|next| next.with_timezone(&Utc)), - #[cfg(feature = "chrono-tz")] - CronTimeZone::Named(named) => next_in(self.spec, current.with_timezone(&named)) - .map(|next| next.with_timezone(&Utc)), - } + fn next(&self, current: Timestamp) -> Option { + next_in(self.spec, ¤t.to_zoned(self.time_zone().resolve())) + .map(|next| next.timestamp()) } } @@ -273,20 +271,20 @@ fn parse_zone(name: &str) -> Result { .or_else(|| offset.strip_prefix('-').map(|hours| (1, hours))) && let Ok(hours) = hours.parse::() && (0..=14).contains(&hours) - && let Some(offset) = FixedOffset::east_opt(sign * hours * 3_600) + && let Ok(offset) = Offset::from_seconds(sign * hours * 3_600) { return Ok(CronTimeZone::Fixed(offset)); } - #[cfg(feature = "chrono-tz")] - if let Ok(named) = name.parse::() { + #[cfg(feature = "tzdb")] + if let Ok(named) = TimeZone::get(name) { return Ok(CronTimeZone::Named(named)); } - #[cfg(feature = "chrono-tz")] + #[cfg(feature = "tzdb")] return Err(format!("provided bad location {name}: unknown time zone")); - #[cfg(not(feature = "chrono-tz"))] + #[cfg(not(feature = "tzdb"))] Err(format!( "provided bad location {name}: only UTC, Local, and Etc/GMT offsets are supported \ - without River's chrono-tz feature; use CronSchedule::with_time_zone for other zones" + without River's tzdb feature; use CronSchedule::with_time_zone for other zones" )) } @@ -521,11 +519,11 @@ fn parse_go_duration(text: &str) -> Result { clippy::too_many_lines, reason = "a line-for-line port keeps robfig's field loops auditable" )] -fn next_in(spec: Spec, after: DateTime) -> Option> { - let subsecond = chrono::Duration::nanoseconds(i64::from(after.nanosecond())); +fn next_in(spec: Spec, after: &Zoned) -> Option { + let subsecond = SignedDuration::from_nanos(i64::from(after.subsec_nanosecond())); let (minute, hour, dom, month, dow) = match spec { Spec::Every(delay) => { - return Some(after + chrono::Duration::from_std(delay).ok()? - subsecond); + return after.checked_add(delay).ok()?.checked_sub(subsecond).ok(); } Spec::Fields { dom, @@ -535,12 +533,16 @@ fn next_in(spec: Spec, after: DateTime) -> Option> { month, } => (minute, hour, dom, month, dow), }; - let zone = after.timezone(); - let one_hour = chrono::Duration::hours(1); - let one_minute = chrono::Duration::minutes(1); + let zone = after.time_zone().clone(); + let one_hour = SignedDuration::from_hours(1); + let one_minute = SignedDuration::from_mins(1); // Start at the earliest possible time (the upcoming second). - let mut time = after + chrono::Duration::seconds(1) - subsecond; + let mut time = after + .checked_add(SignedDuration::from_secs(1)) + .ok()? + .checked_sub(subsecond) + .ok()?; let mut added = false; let year_limit = time.year() + 5; @@ -587,9 +589,10 @@ fn next_in(spec: Spec, after: DateTime) -> Option> { if time.hour() != 0 { let hour = i64::from(time.hour()); time = if hour > 12 { - time + chrono::Duration::hours(24 - hour) + time.checked_add(SignedDuration::from_hours(24 - hour)) + .ok()? } else { - time - chrono::Duration::hours(hour) + time.checked_sub(SignedDuration::from_hours(hour)).ok()? }; } if time.day() == 1 { @@ -610,7 +613,7 @@ fn next_in(spec: Spec, after: DateTime) -> Option> { 0, )?; } - time += one_hour; + time = time.checked_add(one_hour).ok()?; if time.hour() == 0 { continue 'wrap; } @@ -619,9 +622,9 @@ fn next_in(spec: Spec, after: DateTime) -> Option> { while (1 << time.minute()) & minute == 0 { if !added { added = true; - time = truncate_to_minute(time); + time = truncate_to_minute(&time)?; } - time += one_minute; + time = time.checked_add(one_minute).ok()?; if time.minute() == 0 { continue 'wrap; } @@ -633,16 +636,18 @@ fn next_in(spec: Spec, after: DateTime) -> Option> { let second = i64::from(time.second()); if second != 0 { added = true; - time += chrono::Duration::seconds(60 - second); + time = time + .checked_add(SignedDuration::from_secs(60 - second)) + .ok()?; continue 'wrap; } return Some(time); } } -fn day_matches(dom: u64, dow: u64, time: &DateTime) -> bool { +fn day_matches(dom: u64, dow: u64, time: &Zoned) -> bool { let day_of_month = (1 << time.day()) & dom != 0; - let weekday = (1 << time.weekday().num_days_from_sunday()) & dow != 0; + let weekday = (1 << time.weekday().to_sunday_zero_offset()) & dow != 0; if dom & STAR_BIT != 0 || dow & STAR_BIT != 0 { day_of_month && weekday } else { @@ -651,31 +656,35 @@ fn day_matches(dom: u64, dow: u64, time: &DateTime) -> bool { } /// Go's `time.Truncate(time.Minute)`, which rounds absolute time. -fn truncate_to_minute(time: DateTime) -> DateTime { - let seconds = time.timestamp().rem_euclid(60); - let nanos = i64::from(time.nanosecond()); - time - chrono::Duration::seconds(seconds) - chrono::Duration::nanoseconds(nanos) +fn truncate_to_minute(time: &Zoned) -> Option { + let truncated = time.timestamp().as_nanosecond().div_euclid(60_000_000_000) * 60_000_000_000; + Some( + Timestamp::from_nanosecond(truncated) + .ok()? + .to_zoned(time.time_zone().clone()), + ) } /// Go's `time.Date` in `zone`: overflowing months and days roll forward, and /// a wall-clock time that a transition skips or repeats resolves as Go /// resolves it (see [`resolve_local`]). -fn go_date( - zone: &Z, - year: i32, - month: u32, - day: u32, - hour: u32, - minute: u32, - second: u32, -) -> Option> { - let months = i64::from(year) * 12 + i64::from(month) - 1; - let year = i32::try_from(months.div_euclid(12)).ok()?; - let month = u32::try_from(months.rem_euclid(12)).ok()? + 1; - let date = NaiveDate::from_ymd_opt(year, month, 1)? - .checked_add_days(chrono::Days::new(u64::from(day.checked_sub(1)?)))?; - let naive = date.and_hms_opt(hour, minute, second)?; - resolve_local(zone, naive) +fn go_date( + zone: &TimeZone, + year: i16, + month: i8, + day: i8, + hour: i8, + minute: i8, + second: i8, +) -> Option { + let months = i32::from(year) * 12 + i32::from(month) - 1; + let year = i16::try_from(months.div_euclid(12)).ok()?; + let month = i8::try_from(months.rem_euclid(12)).ok()? + 1; + let date = Date::new(year, month, 1) + .ok()? + .checked_add(jiff::Span::new().days(i64::from(day.checked_sub(1)?))) + .ok()?; + resolve_local(zone, date.at(hour, minute, second, 0)) } /// Converts a wall-clock time in `zone` to an instant exactly like Go's @@ -689,29 +698,30 @@ fn go_date( /// reads as the hour before it: `02:30` on a New York spring-forward day is /// `01:30` EST, so a daily `30 2 * * *` schedule skips that day. Like Go, a /// zero first offset is used as is. -fn resolve_local(zone: &Z, naive: NaiveDateTime) -> Option> { - let offset_at = |instant: NaiveDateTime| { - i64::from( - zone.offset_from_utc_datetime(&instant) - .fix() - .local_minus_utc(), - ) - }; - let mut offset = offset_at(naive); - if offset != 0 { - let utc = naive.checked_sub_signed(chrono::Duration::seconds(offset))?; - let corrected = offset_at(utc); +fn resolve_local(zone: &TimeZone, naive: DateTime) -> Option { + let local = naive.to_zoned(TimeZone::UTC).ok()?.timestamp(); + let mut offset = zone.to_offset(local); + if offset != Offset::UTC { + let utc = local + .checked_sub(SignedDuration::from_secs(i64::from(offset.seconds()))) + .ok()?; + let corrected = zone.to_offset(utc); if corrected != offset { offset = corrected; } } - let utc = naive.checked_sub_signed(chrono::Duration::seconds(offset))?; - Some(zone.from_utc_datetime(&utc)) + let utc = local + .checked_sub(SignedDuration::from_secs(i64::from(offset.seconds()))) + .ok()?; + Some(utc.to_zoned(zone.clone())) } #[cfg(test)] mod tests { - use chrono::{DateTime, FixedOffset}; + use jiff::{ + Zoned, + tz::{Offset, TimeZone}, + }; use serde::Deserialize; use super::{CronSchedule, CronTimeZone, PeriodicSchedule}; @@ -731,9 +741,35 @@ mod tests { #[derive(Deserialize)] struct CronCase { expression: String, - from: DateTime, + #[serde(deserialize_with = "deserialize_zoned")] + from: Zoned, name: String, - next: Vec>, + #[serde(deserialize_with = "deserialize_zoned_vec")] + next: Vec, + } + + fn parse_zoned(text: &str) -> Result { + let offset = jiff::fmt::temporal::Pieces::parse(text)? + .to_numeric_offset() + .unwrap_or(Offset::UTC); + Ok(text + .parse::()? + .to_zoned(TimeZone::fixed(offset))) + } + + fn deserialize_zoned<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result { + parse_zoned(&String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } + + fn deserialize_zoned_vec<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + Vec::::deserialize(deserializer)? + .iter() + .map(|text| parse_zoned(text).map_err(serde::de::Error::custom)) + .collect() } fn fixture() -> Fixture { @@ -744,13 +780,13 @@ mod tests { let schedule = CronSchedule::parse(&case.expression) .unwrap_or_else(|error| panic!("{}: {error}", case.name)); // The fixture records five occurrences, stopping early at Go's zero time. - let mut current = case.from; + let mut current = case.from.clone(); let mut observed = Vec::new(); while observed.len() < 5 { let Some(next) = schedule.next_after(¤t) else { break; }; - observed.push(next); + observed.push(next.clone()); current = next; } assert_eq!(observed, case.next, "{}", case.name); @@ -775,60 +811,14 @@ mod tests { // cases' own offsets. #[test] fn transitions_resolve_like_go_time_date() { - use chrono::{NaiveDate, TimeZone as _}; - - // A zone at UTC-5 that springs forward to UTC-4 at 07:00 UTC on - // 2026-03-08 and falls back at 06:00 UTC on 2026-11-01, like New - // York. - #[derive(Clone, Copy, Debug)] - struct NewYork2026; - - impl chrono::TimeZone for NewYork2026 { - type Offset = FixedOffset; - - fn from_offset(_: &FixedOffset) -> Self { - Self - } - - fn offset_from_local_date(&self, _: &NaiveDate) -> chrono::LocalResult { - unimplemented!("River resolves wall-clock times itself") - } - - fn offset_from_local_datetime( - &self, - _: &chrono::NaiveDateTime, - ) -> chrono::LocalResult { - unimplemented!("River resolves wall-clock times itself") - } - - fn offset_from_utc_date(&self, _: &NaiveDate) -> FixedOffset { - unimplemented!("River only looks up instants") - } - - fn offset_from_utc_datetime(&self, utc: &chrono::NaiveDateTime) -> FixedOffset { - let spring = NaiveDate::from_ymd_opt(2026, 3, 8) - .unwrap() - .and_hms_opt(7, 0, 0) - .unwrap(); - let fall = NaiveDate::from_ymd_opt(2026, 11, 1) - .unwrap() - .and_hms_opt(6, 0, 0) - .unwrap(); - let hours = if (spring..fall).contains(utc) { 4 } else { 5 }; - FixedOffset::west_opt(hours * 3_600).unwrap() - } - } - - let wall = |month, day, hour, minute| { - NaiveDate::from_ymd_opt(2026, month, day) - .unwrap() - .and_hms_opt(hour, minute, 0) - .unwrap() - }; + let zone = TimeZone::posix("EST5EDT,M3.2.0/2,M11.1.0/2").unwrap(); + let wall = + |month, day, hour, minute| jiff::civil::date(2026, month, day).at(hour, minute, 0, 0); let resolved = |naive| { - super::resolve_local(&NewYork2026, naive) + super::resolve_local(&zone, naive) .unwrap() - .naive_utc() + .with_time_zone(TimeZone::UTC) + .datetime() }; // A skipped time resolves to the hour before the transition. assert_eq!(resolved(wall(3, 8, 2, 30)), wall(3, 8, 6, 30)); @@ -839,21 +829,33 @@ mod tests { // So, like Go, a daily schedule at a skipped time misses that day, // and one at a repeated time fires twice. - let from = NewYork2026.from_utc_datetime(&wall(3, 7, 12, 0)); + let from = wall(3, 7, 12, 0) + .to_zoned(TimeZone::UTC) + .unwrap() + .with_time_zone(zone.clone()); let skipped = CronSchedule::parse("30 2 * * *").unwrap(); let first = skipped.next_after(&from).unwrap(); - assert_eq!(first.naive_utc(), wall(3, 9, 6, 30)); - let from = NewYork2026.from_utc_datetime(&wall(10, 31, 12, 0)); + assert_eq!( + first.with_time_zone(TimeZone::UTC).datetime(), + wall(3, 9, 6, 30) + ); + let from = wall(10, 31, 12, 0) + .to_zoned(TimeZone::UTC) + .unwrap() + .with_time_zone(zone); let repeated = CronSchedule::parse("30 1 * * *").unwrap(); let first = repeated.next_after(&from).unwrap(); let second = repeated.next_after(&first).unwrap(); assert_eq!( - (first.naive_utc(), second.naive_utc()), + ( + first.with_time_zone(TimeZone::UTC).datetime(), + second.with_time_zone(TimeZone::UTC).datetime() + ), (wall(11, 1, 5, 30), wall(11, 1, 6, 30)) ); } - #[cfg(feature = "chrono-tz")] + #[cfg(feature = "tzdb")] #[test] fn named_zone_schedules_match_go_fixture() { let fixture = fixture(); @@ -863,12 +865,25 @@ mod tests { } } - #[cfg(not(feature = "chrono-tz"))] + #[cfg(not(feature = "tzdb"))] #[test] fn named_zones_need_the_time_zone_database() { for case in fixture().cron_named_zone_cases { let error = CronSchedule::parse(&case.expression).unwrap_err(); - assert!(error.to_string().contains("chrono-tz"), "{error}"); + assert!(error.to_string().contains("tzdb"), "{error}"); + } + } + + #[test] + fn schedules_stop_at_the_timestamp_limit() { + let after = jiff::Timestamp::MAX.to_zoned(TimeZone::UTC); + for expression in ["* * * * *", "@every 1h"] { + assert!( + CronSchedule::parse(expression) + .unwrap() + .next_after(&after) + .is_none() + ); } } @@ -886,24 +901,23 @@ mod tests { fn explicit_time_zones_override_the_reference_zone() { let schedule = CronSchedule::parse("0 9 * * *").unwrap(); assert_eq!(schedule.time_zone(), CronTimeZone::Local); - let eastern = FixedOffset::west_opt(5 * 3_600).unwrap(); + let eastern = Offset::from_hours(-5).unwrap(); let utc = schedule.clone().with_time_zone(CronTimeZone::Utc); - let from = DateTime::parse_from_rfc3339("2026-03-07T08:00:00-05:00").unwrap(); + let from = parse_zoned("2026-03-07T08:00:00-05:00").unwrap(); assert_eq!( - utc.next_after(&from).unwrap().to_rfc3339(), + utc.next_after(&from) + .unwrap() + .strftime("%Y-%m-%dT%H:%M:%S%:z") + .to_string(), "2026-03-08T04:00:00-05:00" ); let fixed = schedule.with_time_zone(CronTimeZone::Fixed(eastern)); assert_eq!( fixed - .next( - DateTime::parse_from_rfc3339("2026-03-07T13:00:00Z") - .unwrap() - .to_utc() - ) + .next(parse_zoned("2026-03-07T13:00:00Z").unwrap().timestamp()) .unwrap() - .to_rfc3339(), - "2026-03-07T14:00:00+00:00" + .to_string(), + "2026-03-07T14:00:00Z" ); assert_eq!( CronSchedule::parse("CRON_TZ=Etc/GMT+5 0 9 * * *") @@ -911,14 +925,14 @@ mod tests { .time_zone(), CronTimeZone::Fixed(eastern) ); - #[cfg(feature = "chrono-tz")] + #[cfg(feature = "tzdb")] assert_eq!( CronSchedule::parse("CRON_TZ=America/New_York 0 9 * * *") .unwrap() .time_zone(), - CronTimeZone::Named(chrono_tz::America::New_York) + CronTimeZone::Named(TimeZone::get("America/New_York").unwrap()) ); - #[cfg(not(feature = "chrono-tz"))] + #[cfg(not(feature = "tzdb"))] assert!(CronSchedule::parse("CRON_TZ=America/New_York 0 9 * * *").is_err()); } } diff --git a/rust/riverqueue/src/periodic/mod.rs b/rust/riverqueue/src/periodic/mod.rs index fc2481626..d498cc42f 100644 --- a/rust/riverqueue/src/periodic/mod.rs +++ b/rust/riverqueue/src/periodic/mod.rs @@ -11,7 +11,7 @@ use std::{ time::Duration, }; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use tokio::sync::Notify; use tokio_util::sync::CancellationToken; @@ -20,7 +20,7 @@ use crate::{Client, Error, InsertOpts, JobArgs}; /// A schedule that calculates the next periodic run after a UTC instant. pub trait PeriodicSchedule: Send + Sync + 'static { /// Returns the next run time, or `None` to disable future runs. - fn next(&self, current: DateTime) -> Option>; + fn next(&self, current: Timestamp) -> Option; } /// A fixed-duration periodic schedule. @@ -52,10 +52,8 @@ impl IntervalSchedule { } impl PeriodicSchedule for IntervalSchedule { - fn next(&self, current: DateTime) -> Option> { - chrono::Duration::from_std(self.0) - .ok() - .and_then(|interval| current.checked_add_signed(interval)) + fn next(&self, current: Timestamp) -> Option { + current.checked_add(self.0).ok() } } @@ -64,7 +62,7 @@ impl PeriodicSchedule for IntervalSchedule { pub struct NeverSchedule; impl PeriodicSchedule for NeverSchedule { - fn next(&self, _current: DateTime) -> Option> { + fn next(&self, _current: Timestamp) -> Option { None } } @@ -215,7 +213,7 @@ pub struct PeriodicJobHandle(u64); pub(crate) struct PeriodicEntry { pub(crate) job: PeriodicJob, - pub(crate) next_run: Option>, + pub(crate) next_run: Option, pub(crate) needs_initialization: bool, } @@ -235,7 +233,7 @@ pub struct PeriodicJobs { /// Jobs due within this margin are inserted in the current pass, like Go's /// enqueuer, which also keeps each occurrence's original scheduled time. -const DUE_MARGIN: chrono::Duration = chrono::Duration::milliseconds(100); +const DUE_MARGIN: jiff::SignedDuration = jiff::SignedDuration::from_millis(100); /// Bound on one periodic insert (Go `riversharedmaintenance.TimeoutDefault`). const INSERT_TIMEOUT: Duration = Duration::from_secs(30); @@ -294,12 +292,12 @@ impl PeriodicJobs { /// Returns the earliest scheduled occurrence, or now when a job still /// needs its first schedule computed. - pub(crate) fn next_run_at(&self) -> Option> { + pub(crate) fn next_run_at(&self) -> Option { let registry = self.lock(); - let mut next: Option> = None; + let mut next: Option = None; for entry in registry.entries.values() { let candidate = if entry.needs_initialization { - Some(Utc::now()) + Some(Timestamp::now()) } else { entry.next_run }; @@ -405,14 +403,14 @@ impl PeriodicJobs { pub(crate) async fn run_due( &self, client: &Client, - now: DateTime, + now: Timestamp, cancel: &CancellationToken, ) { struct DueJob { advance_handle: Option, handle: PeriodicJobHandle, job: PeriodicJob, - target: DateTime, + target: Timestamp, } let due = { @@ -557,10 +555,10 @@ mod tests { #[test] fn interval_schedules_accept_any_positive_interval() { let schedule = IntervalSchedule::new(Duration::from_millis(500)).unwrap(); - let now = Utc::now(); + let now = Timestamp::now(); assert_eq!( schedule.next(now), - Some(now + chrono::Duration::milliseconds(500)) + Some(now + jiff::SignedDuration::from_millis(500)) ); let error = IntervalSchedule::new(Duration::ZERO).unwrap_err(); assert!(matches!(error, Error::Configuration(_)), "{error}"); @@ -620,8 +618,8 @@ mod tests { .connect_lazy("postgres://127.0.0.1:1/river_periodic_test") .unwrap(); let client = Client::builder(pool).build().unwrap(); - let now = Utc::now(); - let target = now + chrono::Duration::seconds(1); + let now = Timestamp::now(); + let target = now + jiff::SignedDuration::from_secs(1); let cancel = CancellationToken::new(); jobs.run_due(&client, now, &cancel).await; @@ -633,7 +631,7 @@ mod tests { let registry = jobs.lock(); assert_eq!( registry.entries.values().next().unwrap().next_run, - Some(target + chrono::Duration::seconds(1)) + Some(target + jiff::SignedDuration::from_secs(1)) ); } @@ -663,8 +661,8 @@ mod tests { .connect_lazy(&format!("postgres://{address}/river_periodic_test")) .unwrap(); let client = Client::builder(pool).build().unwrap(); - let now = Utc::now(); - let target = now + chrono::Duration::seconds(1); + let now = Timestamp::now(); + let target = now + jiff::SignedDuration::from_secs(1); let cancel = CancellationToken::new(); jobs.run_due(&client, now, &cancel).await; diff --git a/rust/riverqueue/src/pilot.rs b/rust/riverqueue/src/pilot.rs index 4f5487477..83d2ca0ef 100644 --- a/rust/riverqueue/src/pilot.rs +++ b/rust/riverqueue/src/pilot.rs @@ -6,7 +6,7 @@ use std::{fmt, sync::Arc, time::Duration}; use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use serde_json::{Map, Value}; #[cfg(feature = "postgres")] use sqlx::Postgres; @@ -328,7 +328,7 @@ pub struct ProducerStartContext { pub struct ProducerKeepAliveContext { /// Peers that haven't reported since this time are stale, like River /// Go's `StaleUpdatedAtHorizon`. - pub stale_before: DateTime, + pub stale_before: Timestamp, } /// Inputs to one [`PilotProducer::shutdown`] attempt. diff --git a/rust/riverqueue/src/protocol.rs b/rust/riverqueue/src/protocol.rs index 82f89e6ec..7ed6a1e72 100644 --- a/rust/riverqueue/src/protocol.rs +++ b/rust/riverqueue/src/protocol.rs @@ -5,7 +5,7 @@ //! the unique key River would assign to a job or listening for River's //! notifications. -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use serde_json::value::RawValue; use crate::{Error, JobState, UniqueOpts}; @@ -29,13 +29,13 @@ pub struct UniqueKeyInput<'a> { pub kind: &'a str, /// Current time, used for period-scoped uniqueness when `scheduled_at` is /// absent. - pub now: DateTime, + pub now: Timestamp, /// Uniqueness options. pub opts: &'a UniqueOpts, /// Queue name. pub queue: &'a str, /// Scheduled time, if the job is scheduled. - pub scheduled_at: Option>, + pub scheduled_at: Option, /// Literal argument path components selected for uniqueness, such as /// [`JobArgs::unique_fields`](crate::JobArgs::unique_fields). For example, /// `&[&["user.id"], &["user", "id"]]` selects two distinct fields. diff --git a/rust/riverqueue/src/query.rs b/rust/riverqueue/src/query.rs index bd610fb90..f10160618 100644 --- a/rust/riverqueue/src/query.rs +++ b/rust/riverqueue/src/query.rs @@ -6,7 +6,7 @@ use base64::{ Engine as _, alphabet, engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig, general_purpose}, }; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use thiserror::Error; @@ -22,7 +22,7 @@ pub struct JobListCursor { queue: String, /// Value of the time field the list is ordered by, or `None` when the /// list is ordered by ID alone or the job's value is null. - pub(crate) sort_time: Option>, + pub(crate) sort_time: Option, } impl JobListCursor { @@ -152,7 +152,7 @@ struct JobListCursorValue { queue: String, sort_field: String, #[serde(with = "crate::encoding::go_time")] - time: DateTime, + time: Timestamp, } /// Field used for stable job-list ordering. @@ -552,7 +552,7 @@ pub(crate) enum JobListAfter { /// last ascending and first descending. NullTime { id: i64 }, /// Jobs following a cursor job with this time. - Time { id: i64, time: DateTime }, + Time { id: i64, time: Timestamp }, } /// How a validated job list is ordered and where it resumes. Each storage @@ -584,7 +584,7 @@ impl JobListKeyset { /// Returns the time of the job the list resumes after, if that job has /// one. - pub(crate) const fn after_time(&self) -> Option> { + pub(crate) const fn after_time(&self) -> Option { match self.after { Some(JobListAfter::Time { time, .. }) => Some(time), Some(JobListAfter::Id(_) | JobListAfter::NullTime { .. }) | None => None, @@ -703,7 +703,7 @@ impl JobListTimeField { } /// Returns the field's value on `job`, or `None` if it's null. - const fn value(self, job: &JobRow) -> Option> { + const fn value(self, job: &JobRow) -> Option { match self { Self::Attempted => job.attempted_at, Self::Finalized => job.finalized_at, @@ -712,10 +712,10 @@ impl JobListTimeField { } } -fn go_zero_time() -> DateTime { - DateTime::parse_from_rfc3339("0001-01-01T00:00:00Z") +fn go_zero_time() -> Timestamp { + "0001-01-01T00:00:00Z" + .parse::() .expect("Go zero time is valid RFC 3339") - .with_timezone(&Utc) } #[cfg(test)] @@ -729,10 +729,8 @@ mod tests { "eyJpZCI6OTAwNzE5OTI1NDc0MDk5Mywia2luZCI6ImNvbmZvcm1hbmNlX2N1cnNvcn5-fiIsInF1ZXVlIjoiZGVmYXVsdCIsInNvcnRfZmllbGQiOiJmaW5hbGl6ZWRfYXQiLCJ0aW1lIjoiMjAyNi0wMS0wMlQwMzowNDowNS4xMloifQ==", ]; - fn utc(text: &str) -> DateTime { - DateTime::parse_from_rfc3339(text) - .unwrap() - .with_timezone(&Utc) + fn utc(text: &str) -> Timestamp { + text.parse::().unwrap() } #[test] @@ -797,9 +795,9 @@ mod tests { order_by: JobListOrderBy::ScheduledAt, queue: "priority".to_owned(), sort_time: Some( - DateTime::parse_from_rfc3339("2026-01-02T03:04:05.6789Z") - .unwrap() - .with_timezone(&Utc), + "2026-01-02T03:04:05.6789Z" + .parse::() + .unwrap(), ), }; diff --git a/rust/riverqueue/src/queue.rs b/rust/riverqueue/src/queue.rs index 987648b46..5c286eb7b 100644 --- a/rust/riverqueue/src/queue.rs +++ b/rust/riverqueue/src/queue.rs @@ -1,6 +1,6 @@ //! Persisted queue configuration. -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -9,7 +9,7 @@ use serde_json::{Map, Value}; #[non_exhaustive] pub struct Queue { /// Time at which this active queue record was created. - pub created_at: DateTime, + pub created_at: Timestamp, /// Reserved queue metadata. pub metadata: Map, /// The metadata's text as the database renders it, keeping the key @@ -19,9 +19,9 @@ pub struct Queue { /// Stable queue name. pub name: String, /// Time at which the queue was paused. - pub paused_at: Option>, + pub paused_at: Option, /// Last client heartbeat or configuration update. - pub updated_at: DateTime, + pub updated_at: Timestamp, } /// Parameters for listing queues. diff --git a/rust/riverqueue/src/storage/postgres.rs b/rust/riverqueue/src/storage/postgres.rs index e5b3bf151..7ce33b6d7 100644 --- a/rust/riverqueue/src/storage/postgres.rs +++ b/rust/riverqueue/src/storage/postgres.rs @@ -1,6 +1,6 @@ //! Postgres implementation of River's storage operations. -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use serde_json::{Map, Value}; use sqlx::{AssertSqlSafe, FromRow, PgConnection, Postgres, types::Json}; @@ -68,7 +68,7 @@ impl Backend for PostgresBackend<'_> { .bind(id) .bind(self.schema.as_deref()) .bind(crate::NOTIFICATION_TOPIC_CONTROL) - .bind(go_time_json(Utc::now())) + .bind(go_time_json(Timestamp::now())) .bind(notify) .fetch_optional(&mut *self.connection) .await? @@ -381,18 +381,18 @@ const QUEUE_COLUMNS: &str = #[derive(FromRow)] struct QueueRecord { - created_at: DateTime, + created_at: jiff_sqlx::Timestamp, metadata: Json, metadata_text: String, name: String, - paused_at: Option>, - updated_at: DateTime, + paused_at: Option, + updated_at: jiff_sqlx::Timestamp, } impl QueueRecord { fn into_queue(self) -> Result { Ok(Queue { - created_at: self.created_at, + created_at: self.created_at.to_jiff(), metadata: self.metadata.0.as_object().cloned().ok_or_else(|| { Error::invalid_job_context( "storage parameters", @@ -401,8 +401,8 @@ impl QueueRecord { })?, metadata_text: self.metadata_text, name: self.name, - paused_at: self.paused_at, - updated_at: self.updated_at, + paused_at: self.paused_at.map(jiff_sqlx::Timestamp::to_jiff), + updated_at: self.updated_at.to_jiff(), }) } } @@ -497,7 +497,7 @@ fn bind_job_list<'query>( .bind(¶ms.tags_all) .bind(¶ms.tags_any) .bind(metadata) - .bind(keyset.after_time()) + .bind(keyset.after_time().map(jiff_sqlx::Timestamp::from)) .bind(keyset.after_id()) .bind(i64::from(params.limit)) } diff --git a/rust/riverqueue/src/storage/sqlite.rs b/rust/riverqueue/src/storage/sqlite.rs index 57694909e..0e88c7882 100644 --- a/rust/riverqueue/src/storage/sqlite.rs +++ b/rust/riverqueue/src/storage/sqlite.rs @@ -1,6 +1,6 @@ //! SQLite implementation of River's storage operations. -use chrono::Utc; +use jiff::Timestamp; use serde_json::{Map, Value}; use sqlx::SqliteConnection; @@ -61,7 +61,7 @@ impl Backend for SqliteBackend<'_> { } async fn job_cancel(&mut self, id: i64) -> Result, Error> { - let updated = sqlite::cancel(self.connection, id, Utc::now()) + let updated = sqlite::cancel(self.connection, id, Timestamp::now()) .await .map_err(database_error)?; let Some(row) = updated else { @@ -113,7 +113,7 @@ impl Backend for SqliteBackend<'_> { if job.state != JobState::Running { return Err(super::job_not_running(job.state)); } - let now = Utc::now(); + let now = Timestamp::now(); sqlite::complete( self.connection, &sqlite::CompleteJob { @@ -178,7 +178,7 @@ impl Backend for SqliteBackend<'_> { kinds: None, limit: 1, max_attempted_by, - now: Utc::now(), + now: Timestamp::now(), queue: "", }, &[id], @@ -202,7 +202,7 @@ impl Backend for SqliteBackend<'_> { } async fn job_retry(&mut self, id: i64) -> Result, Error> { - let updated = sqlite::retry(self.connection, id, Utc::now()) + let updated = sqlite::retry(self.connection, id, Timestamp::now()) .await .map_err(database_error)?; let Some(row) = updated else { @@ -247,7 +247,7 @@ impl Backend for SqliteBackend<'_> { } async fn queue_set_paused(&mut self, name: &str, paused: bool) -> Result { - let now = Utc::now(); + let now = Timestamp::now(); let updated = if paused { sqlite::queue_pause(self.connection, name, now).await } else { @@ -258,7 +258,7 @@ impl Backend for SqliteBackend<'_> { } async fn queue_touch(&mut self, name: &str) -> Result { - sqlite::queue_upsert(self.connection, name, &Map::new(), None, Utc::now()) + sqlite::queue_upsert(self.connection, name, &Map::new(), None, Timestamp::now()) .await .map_err(database_error) } @@ -268,7 +268,7 @@ impl Backend for SqliteBackend<'_> { name: &str, metadata: Option<&Map>, ) -> Result, Error> { - sqlite::queue_update(self.connection, name, metadata, Utc::now()) + sqlite::queue_update(self.connection, name, metadata, Timestamp::now()) .await .map_err(database_error) } diff --git a/rust/riverqueue/src/unique.rs b/rust/riverqueue/src/unique.rs index 0d0bb92d7..33c806c4b 100644 --- a/rust/riverqueue/src/unique.rs +++ b/rust/riverqueue/src/unique.rs @@ -23,7 +23,7 @@ use std::{borrow::Cow, time::Duration}; -use chrono::{DateTime, SecondsFormat, Utc}; +use jiff::Timestamp; use serde_json::value::RawValue; use sha2::{Digest, Sha256}; @@ -37,10 +37,10 @@ pub(crate) fn build_unique_key_parts( kind: &str, unique_fields: &[&[&str]], encoded_args: &RawValue, - now: DateTime, + now: Timestamp, opts: &UniqueOpts, queue: &str, - scheduled_at: Option>, + scheduled_at: Option, ) -> Result, Error> { if opts.is_empty() { return Ok(None); @@ -64,7 +64,9 @@ pub(crate) fn build_unique_key_parts( key.push_str("&period="); let scheduled_at = scheduled_at.unwrap_or(now); key.push_str( - &truncate_period(scheduled_at, period)?.to_rfc3339_opts(SecondsFormat::Secs, true), + &truncate_period(scheduled_at, period)? + .strftime("%Y-%m-%dT%H:%M:%SZ") + .to_string(), ); } if opts.by_queue { @@ -437,12 +439,11 @@ impl<'a> Scanner<'a> { } } -fn truncate_period(timestamp: DateTime, period: Duration) -> Result, Error> { +fn truncate_period(timestamp: Timestamp, period: Duration) -> Result { let period_nanos = i128::try_from(period.as_nanos()).map_err(|_| { Error::invalid_job_context("job uniqueness", "unique period is too large".to_owned()) })?; - let unix_nanos = i128::from(timestamp.timestamp()) * 1_000_000_000 - + i128::from(timestamp.timestamp_subsec_nanos()); + let unix_nanos = timestamp.as_nanosecond(); let absolute_nanos = unix_nanos + SECONDS_FROM_YEAR_ONE_TO_UNIX_EPOCH * 1_000_000_000; let truncated_absolute = absolute_nanos - absolute_nanos.rem_euclid(period_nanos); let truncated_unix = truncated_absolute - SECONDS_FROM_YEAR_ONE_TO_UNIX_EPOCH * 1_000_000_000; @@ -452,13 +453,13 @@ fn truncate_period(timestamp: DateTime, period: Duration) -> Result, period: Duration) -> Result, + now: Timestamp, options: FixtureOptions, queue: String, - scheduled_at: Option>, + scheduled_at: Option, selected_unique_components: Option>>, } @@ -588,7 +587,7 @@ mod tests { kind, unique_fields, args, - Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(), + "2026-01-02T03:04:05Z".parse::().unwrap(), opts, "default", None, @@ -939,24 +938,24 @@ mod tests { #[river(kind = "conformance_all_args")] struct TimeArgs { #[serde(with = "crate::encoding::go_time")] - fraction: DateTime, + fraction: Timestamp, #[serde(with = "crate::encoding::go_time")] - micros: DateTime, + micros: Timestamp, #[serde(with = "crate::encoding::go_time")] - millis: DateTime, - whole: DateTime, + millis: Timestamp, + whole: Timestamp, } #[test] fn typed_args_match_go_time_golden() { - let whole = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(); + let whole = "2026-01-02T03:04:05Z".parse::().unwrap(); assert_typed_golden( "typed_time_values", &TimeArgs { - fraction: whole + chrono::Duration::milliseconds(500), - micros: whole + chrono::Duration::microseconds(123_456), - millis: whole + chrono::Duration::milliseconds(120), - // Whole seconds need no helper: chrono and Go agree. + fraction: whole + jiff::SignedDuration::from_millis(500), + micros: whole + jiff::SignedDuration::from_micros(123_456), + millis: whole + jiff::SignedDuration::from_millis(120), + // Whole seconds need no helper: Jiff and Go agree. whole, }, ); @@ -1143,11 +1142,11 @@ mod tests { #[test] fn truncates_from_go_time_zero() { - let timestamp = Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap(); + let timestamp = "2026-01-02T03:04:05Z".parse::().unwrap(); let truncated = truncate_period(timestamp, Duration::from_mins(1)).unwrap(); assert_eq!( truncated, - Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 0).unwrap() + "2026-01-02T03:04:00Z".parse::().unwrap() ); } } diff --git a/rust/riverqueue/src/worker.rs b/rust/riverqueue/src/worker.rs index a83de48aa..8f1052cd3 100644 --- a/rust/riverqueue/src/worker.rs +++ b/rust/riverqueue/src/worker.rs @@ -9,7 +9,7 @@ use std::{ }; use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::{Map, Value}; @@ -769,12 +769,7 @@ where /// Overrides the client retry delay for this job. Returning `None` uses the /// client policy. - fn next_retry( - &self, - _job: &Job, - _error: &WorkError, - _now: DateTime, - ) -> Option { + fn next_retry(&self, _job: &Job, _error: &WorkError, _now: Timestamp) -> Option { None } @@ -802,7 +797,7 @@ trait ErasedWorker: Send + Sync { &self, row: &JobRow, error: &WorkError, - now: DateTime, + now: Timestamp, ) -> Result, Box>; fn timeout(&self, row: &JobRow) -> Result>; @@ -856,7 +851,7 @@ where &self, row: &JobRow, error: &WorkError, - now: DateTime, + now: Timestamp, ) -> Result, Box> { // `work` consumes its job, so a failed attempt decodes once more to // consult the worker's retry override. @@ -943,7 +938,7 @@ impl Workers { &self, row: &JobRow, error: &WorkError, - now: DateTime, + now: Timestamp, ) -> Result, Box> { self.worker_for(row)?.next_retry(row, error, now) } @@ -1096,7 +1091,7 @@ mod tests { }, }; - use chrono::Utc; + use jiff::Timestamp; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -1139,7 +1134,7 @@ mod tests { } fn job_row(kind: &str, fail: bool) -> JobRow { - let now = Utc::now(); + let now = Timestamp::now(); JobRow { attempt: 1, attempted_at: Some(now), diff --git a/rust/riverqueue/tests/caller_transactions.rs b/rust/riverqueue/tests/caller_transactions.rs index a97345e91..b34d9dff2 100644 --- a/rust/riverqueue/tests/caller_transactions.rs +++ b/rust/riverqueue/tests/caller_transactions.rs @@ -283,10 +283,9 @@ macro_rules! scenarios { let available = client.insert(args("available")).await.unwrap().id(); let scheduled = client .insert(args("scheduled")) - .opts( - InsertOpts::default() - .with_scheduled_at(chrono::Utc::now() + chrono::Duration::hours(1)), - ) + .opts(InsertOpts::default().with_scheduled_at( + jiff::Timestamp::now() + jiff::SignedDuration::from_hours(1), + )) .await .unwrap() .id(); @@ -529,15 +528,15 @@ mod postgres { let client = fixture.client(&fail); let plain = fixture.builder().build().unwrap(); let cancellable = plain.insert(args("cancellable")).await.unwrap().id(); - let retryable = plain - .insert(args("retryable")) - .opts( - InsertOpts::default() - .with_scheduled_at(chrono::Utc::now() + chrono::Duration::hours(1)), - ) - .await - .unwrap() - .id(); + let retryable = + plain + .insert(args("retryable")) + .opts(InsertOpts::default().with_scheduled_at( + jiff::Timestamp::now() + jiff::SignedDuration::from_hours(1), + )) + .await + .unwrap() + .id(); let mut tx = fixture.begin().await; // A write directly in the caller's transaction, so that even one diff --git a/rust/riverqueue/tests/client_handles.rs b/rust/riverqueue/tests/client_handles.rs index c58e35cc5..7c06c1d5d 100644 --- a/rust/riverqueue/tests/client_handles.rs +++ b/rust/riverqueue/tests/client_handles.rs @@ -366,7 +366,7 @@ macro_rules! scenarios { #[tokio::test(flavor = "multi_thread")] async fn fetches_by_priority_then_schedule_then_id() { let fixture = Fixture::new().await; - let base = chrono::Utc::now() - chrono::Duration::minutes(10); + let base = jiff::Timestamp::now() - jiff::SignedDuration::from_mins(10); let mut ids = Vec::new(); for (name, priority, minutes) in [ ("p2_early", 2, 0), @@ -381,7 +381,7 @@ macro_rules! scenarios { .opts( InsertOpts::default() .with_priority(priority) - .with_scheduled_at(base + chrono::Duration::minutes(minutes)), + .with_scheduled_at(base + jiff::SignedDuration::from_mins(minutes)), ) .await .unwrap() @@ -463,7 +463,7 @@ macro_rules! scenarios { assert_eq!(job.id, id); assert_eq!(job.state, JobState::Cancelled); let finalized_at = job.finalized_at.unwrap(); - assert!((chrono::Utc::now() - finalized_at).num_seconds().abs() < 2); + assert!(jiff::Timestamp::now().duration_since(finalized_at).as_secs().abs() < 2); run.stop().await.unwrap(); fixture.cleanup().await; diff --git a/rust/riverqueue/tests/extension_seams.rs b/rust/riverqueue/tests/extension_seams.rs index 8c9fe4fd8..a98ca4ed0 100644 --- a/rust/riverqueue/tests/extension_seams.rs +++ b/rust/riverqueue/tests/extension_seams.rs @@ -40,7 +40,7 @@ const FINALIZED_SEEDS: [(&str, &str); 6] = [ fn finalized_deletions() -> Vec<(riverqueue::__private::FinalizedJobDeleteParams, Vec)> { use riverqueue::__private::FinalizedJobDeleteParams; - let before = chrono::Utc::now(); + let before = jiff::Timestamp::now(); let mut none = FinalizedJobDeleteParams::new(100); none.queues_included = Some(vec!["alpha".to_owned()]); @@ -129,7 +129,7 @@ fn queue_filter_cases() -> Vec { queues_included: Option<&[&str]>, batches: Vec, deleted_queues: Vec<&'static str>| { - let horizon = chrono::Utc::now(); + let horizon = jiff::Timestamp::now(); let mut params = riverqueue::__private::FinalizedJobDeleteParams::new(2); params.cancelled_before = Some(horizon); params.completed_before = Some(horizon); diff --git a/rust/riverqueue/tests/extension_services.rs b/rust/riverqueue/tests/extension_services.rs index 87477c706..4470f53b0 100644 --- a/rust/riverqueue/tests/extension_services.rs +++ b/rust/riverqueue/tests/extension_services.rs @@ -23,7 +23,7 @@ use std::{ }; use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use riverqueue::__private::{ ClientBuilderExt, ExtensionClient, MaintenanceService, MaintenanceServiceContext, Pilot, PilotError, PilotProducer, ProducerKeepAliveContext, ProducerShutdownContext, @@ -52,7 +52,7 @@ struct GatedArgs {} enum Call { Finished(i64), KeepAlive, - MaintenanceStarted(DateTime), + MaintenanceStarted(Timestamp), MaintenanceStopped, RuntimeStarted, RuntimeStopped, diff --git a/rust/riverqueue/tests/insert_notifications.rs b/rust/riverqueue/tests/insert_notifications.rs index 56755e7c1..3dfb11e50 100644 --- a/rust/riverqueue/tests/insert_notifications.rs +++ b/rust/riverqueue/tests/insert_notifications.rs @@ -226,7 +226,11 @@ mod postgres { // Scheduled jobs send nothing and leave the queue's window alone. client .insert(NotificationArgs {}) - .opts(queue("d").with_scheduled_at(chrono::Utc::now() + chrono::Duration::hours(1))) + .opts( + queue("d").with_scheduled_at( + jiff::Timestamp::now() + jiff::SignedDuration::from_hours(1), + ), + ) .await .unwrap(); assert_eq!(notifications.next(&schema).await, Vec::::new()); @@ -385,7 +389,11 @@ mod sqlite { // Scheduled jobs send nothing and leave the queue's window alone. client .insert(NotificationArgs {}) - .opts(queue("d").with_scheduled_at(chrono::Utc::now() + chrono::Duration::hours(1))) + .opts( + queue("d").with_scheduled_at( + jiff::Timestamp::now() + jiff::SignedDuration::from_hours(1), + ), + ) .await .unwrap(); assert_eq!(notifications.next(&pool).await, Vec::::new()); diff --git a/rust/riverqueue/tests/job_list.rs b/rust/riverqueue/tests/job_list.rs index 15a1eac5e..cefb0ee15 100644 --- a/rust/riverqueue/tests/job_list.rs +++ b/rust/riverqueue/tests/job_list.rs @@ -8,15 +8,15 @@ mod support; -use chrono::{DateTime, Duration, TimeZone, Utc}; +use jiff::{SignedDuration as Duration, Timestamp}; use riverqueue::{Client, JobListCursor, JobListOrderBy, JobListParams, JobState, SortDirection}; /// A job to seed, with the time fields job lists order by. #[derive(Clone, Copy)] struct Seed { - attempted_at: Option>, - finalized_at: Option>, - scheduled_at: DateTime, + attempted_at: Option, + finalized_at: Option, + scheduled_at: Timestamp, state: JobState, } @@ -30,17 +30,17 @@ impl Seed { } } - const fn attempted(mut self, at: DateTime) -> Self { + const fn attempted(mut self, at: Timestamp) -> Self { self.attempted_at = Some(at); self } - const fn finalized(mut self, at: DateTime) -> Self { + const fn finalized(mut self, at: Timestamp) -> Self { self.finalized_at = Some(at); self } - const fn scheduled(mut self, at: DateTime) -> Self { + const fn scheduled(mut self, at: Timestamp) -> Self { self.scheduled_at = at; self } @@ -48,11 +48,11 @@ impl Seed { /// A fixed time with millisecond precision, which both backends store /// exactly. -fn now() -> DateTime { - Utc.with_ymd_and_hms(2026, 9, 9, 12, 0, 0).unwrap() + Duration::milliseconds(123) +fn now() -> Timestamp { + "2026-09-09T12:00:00Z".parse::().unwrap() + Duration::from_millis(123) } -fn at(offset: Duration) -> DateTime { +fn at(offset: Duration) -> Timestamp { now() + offset } @@ -121,13 +121,13 @@ macro_rules! scenarios { let fixture = Fixture::new().await; let ids = fixture .insert(&[ - Seed::new(JobState::Running).attempted(at(Duration::seconds(2))), + Seed::new(JobState::Running).attempted(at(Duration::from_secs(2))), Seed::new(JobState::Available), - Seed::new(JobState::Running).attempted(at(Duration::seconds(1))), + Seed::new(JobState::Running).attempted(at(Duration::from_secs(1))), Seed::new(JobState::Available) - .attempted(at(-Duration::hours(1))) - .scheduled(at(Duration::hours(1))), - Seed::new(JobState::Running).attempted(at(Duration::seconds(1))), + .attempted(at(-Duration::from_hours(1))) + .scheduled(at(Duration::from_hours(1))), + Seed::new(JobState::Running).attempted(at(Duration::from_secs(1))), Seed::new(JobState::Available), ]) .await; @@ -151,17 +151,17 @@ macro_rules! scenarios { let fixture = Fixture::new().await; let ids = fixture .insert(&[ - Seed::new(JobState::Available).scheduled(at(-Duration::hours(2))), + Seed::new(JobState::Available).scheduled(at(-Duration::from_hours(2))), Seed::new(JobState::Completed) - .finalized(at(Duration::seconds(1))) - .scheduled(at(-Duration::hours(3))), + .finalized(at(Duration::from_secs(1))) + .scheduled(at(-Duration::from_hours(3))), Seed::new(JobState::Completed) .finalized(now()) - .scheduled(at(-Duration::hours(1))), - Seed::new(JobState::Available).scheduled(at(-Duration::hours(4))), + .scheduled(at(-Duration::from_hours(1))), + Seed::new(JobState::Available).scheduled(at(-Duration::from_hours(4))), Seed::new(JobState::Completed) - .finalized(at(Duration::seconds(1))) - .scheduled(at(-Duration::hours(5))), + .finalized(at(Duration::from_secs(1))) + .scheduled(at(-Duration::from_hours(5))), ]) .await; @@ -185,15 +185,15 @@ macro_rules! scenarios { let fixture = Fixture::new().await; let ids = fixture .insert(&[ - Seed::new(JobState::Available).scheduled(at(Duration::seconds(1))), + Seed::new(JobState::Available).scheduled(at(Duration::from_secs(1))), Seed::new(JobState::Cancelled) - .finalized(at(-Duration::hours(1))) - .scheduled(at(Duration::seconds(2))), - Seed::new(JobState::Available).scheduled(at(Duration::seconds(3))), + .finalized(at(-Duration::from_hours(1))) + .scheduled(at(Duration::from_secs(2))), + Seed::new(JobState::Available).scheduled(at(Duration::from_secs(3))), Seed::new(JobState::Cancelled) - .finalized(at(Duration::hours(1))) - .scheduled(at(Duration::seconds(2))), - Seed::new(JobState::Available).scheduled(at(Duration::seconds(4))), + .finalized(at(Duration::from_hours(1))) + .scheduled(at(Duration::from_secs(2))), + Seed::new(JobState::Available).scheduled(at(Duration::from_secs(4))), ]) .await; @@ -216,14 +216,14 @@ macro_rules! scenarios { let fixture = Fixture::new().await; let ids = fixture .insert(&[ - Seed::new(JobState::Available).scheduled(at(Duration::seconds(3))), + Seed::new(JobState::Available).scheduled(at(Duration::from_secs(3))), Seed::new(JobState::Running) - .attempted(at(-Duration::hours(1))) - .scheduled(at(Duration::seconds(1))), + .attempted(at(-Duration::from_hours(1))) + .scheduled(at(Duration::from_secs(1))), Seed::new(JobState::Completed) - .finalized(at(-Duration::hours(1))) - .scheduled(at(Duration::seconds(2))), - Seed::new(JobState::Scheduled).scheduled(at(Duration::seconds(1))), + .finalized(at(-Duration::from_hours(1))) + .scheduled(at(Duration::from_secs(2))), + Seed::new(JobState::Scheduled).scheduled(at(Duration::from_secs(1))), ]) .await; @@ -274,9 +274,9 @@ mod postgres { let mut ids = Vec::with_capacity(seeds.len()); for seed in seeds { let id: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(sql.clone())) - .bind(seed.attempted_at) - .bind(seed.finalized_at) - .bind(seed.scheduled_at) + .bind(seed.attempted_at.map(jiff_sqlx::Timestamp::from)) + .bind(seed.finalized_at.map(jiff_sqlx::Timestamp::from)) + .bind(jiff_sqlx::Timestamp::from(seed.scheduled_at)) .bind(seed.state.as_str()) .fetch_one(&self.schema.pool) .await @@ -315,7 +315,7 @@ mod sqlite { } async fn insert(&self, seeds: &[Seed]) -> Vec { - let time = |time: DateTime| time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); + let time = |time: Timestamp| time.strftime("%Y-%m-%d %H:%M:%S%.3f").to_string(); let mut ids = Vec::with_capacity(seeds.len()); for seed in seeds { let id: i64 = sqlx::query_scalar( diff --git a/rust/riverqueue/tests/metadata_merge.rs b/rust/riverqueue/tests/metadata_merge.rs index cc6c81e28..041c88f9a 100644 --- a/rust/riverqueue/tests/metadata_merge.rs +++ b/rust/riverqueue/tests/metadata_merge.rs @@ -150,7 +150,7 @@ mod sqlite_resumable { &self, _job: &Job, _error: &WorkError, - _now: chrono::DateTime, + _now: jiff::Timestamp, ) -> Option { Some(Duration::from_millis(10)) } diff --git a/rust/riverqueue/tests/peer_attempts.rs b/rust/riverqueue/tests/peer_attempts.rs index 1ece92bf6..3a683be7a 100644 --- a/rust/riverqueue/tests/peer_attempts.rs +++ b/rust/riverqueue/tests/peer_attempts.rs @@ -268,7 +268,9 @@ async fn scripted_claim( attempted_by = jsonb_insert(coalesce(attempted_by, jsonb('[]')), '$[#]', ?) \ WHERE id IN (SELECT value FROM json_each(?)) RETURNING {columns}" ))) - .bind(riverqueue::__private::sqlite_timestamp(chrono::Utc::now())) + .bind(riverqueue::__private::sqlite_timestamp( + jiff::Timestamp::now(), + )) .bind(&client_id) .bind(&ids_json) .fetch_all(connection) diff --git a/rust/riverqueue/tests/poll_only_cancellation.rs b/rust/riverqueue/tests/poll_only_cancellation.rs index 4d999a0c7..5fb002de9 100644 --- a/rust/riverqueue/tests/poll_only_cancellation.rs +++ b/rust/riverqueue/tests/poll_only_cancellation.rs @@ -59,7 +59,7 @@ impl Backend { /// Requests a job's cancellation the way `JobCancel` does, but without a /// notification, as if it was lost. async fn request_cancellation_without_notification(&self, id: i64) { - let cancel_attempted_at = chrono::Utc::now().to_rfc3339(); + let cancel_attempted_at = jiff::Timestamp::now().to_string(); match self { #[cfg(all(feature = "postgres", river_postgres_tests))] Self::Postgres(schema) => { diff --git a/rust/riverqueue/tests/postgres.rs b/rust/riverqueue/tests/postgres.rs index 5f5a6c7aa..c63e3e27a 100644 --- a/rust/riverqueue/tests/postgres.rs +++ b/rust/riverqueue/tests/postgres.rs @@ -185,7 +185,7 @@ impl Worker for RescueRetryOverrideWorker { &self, _job: &Job, _error: &WorkError, - _now: chrono::DateTime, + _now: jiff::Timestamp, ) -> Option { Some(Duration::from_hours(2)) } @@ -223,7 +223,7 @@ impl Worker for ResumableCheckpointWorker { &self, job: &Job, _error: &WorkError, - _now: chrono::DateTime, + _now: jiff::Timestamp, ) -> Option { (job.args.mode == "cursor_retry").then_some(Duration::from_millis(500)) } @@ -762,7 +762,7 @@ async fn concurrent_unique_inserts_return_the_conflicting_job() { JobState::Running, JobState::Scheduled, ]; - let fixed_scheduled_at = chrono::Utc::now() - chrono::Duration::minutes(1); + let fixed_scheduled_at = jiff::Timestamp::now() - jiff::SignedDuration::from_mins(1); let cases = [ ( "by_args", @@ -868,7 +868,7 @@ async fn insert_many_variants_preserve_order_and_transactionality() { assert_eq!(empty_batch_tx.to_string(), "invalid job: no jobs to insert"); empty_transaction.commit().await.unwrap(); - let past_scheduled_at = chrono::Utc::now() - chrono::Duration::minutes(1); + let past_scheduled_at = jiff::Timestamp::now() - jiff::SignedDuration::from_mins(1); let ordered = client .insert_many([ ( @@ -1370,7 +1370,7 @@ async fn maintenance_client_runs_pilot_periodic_scheduled_and_transactional_jobs }) .opts( InsertOpts::default() - .with_scheduled_at(chrono::Utc::now() + chrono::Duration::milliseconds(100)), + .with_scheduled_at(jiff::Timestamp::now() + jiff::SignedDuration::from_millis(100)), ) .await .unwrap(); @@ -1691,7 +1691,7 @@ async fn rescuer_honors_worker_timeout_and_retry_overrides() { ); assert_eq!(retry_override.errors.len(), 1); assert!( - retry_override.scheduled_at > chrono::Utc::now() + chrono::Duration::minutes(90), + retry_override.scheduled_at > jiff::Timestamp::now() + jiff::SignedDuration::from_mins(90), "worker retry override was not applied: {:?}", retry_override.scheduled_at ); diff --git a/rust/riverqueue/tests/postgres_parity.rs b/rust/riverqueue/tests/postgres_parity.rs index ec3f43187..eba57575d 100644 --- a/rust/riverqueue/tests/postgres_parity.rs +++ b/rust/riverqueue/tests/postgres_parity.rs @@ -484,9 +484,9 @@ async fn leader_renews_while_maintenance_is_blocked() { let pool = database.pool.clone(); let table = database.table("river_leader"); async move { - sqlx::query_as::<_, (chrono::DateTime, chrono::DateTime)>( - sqlx::AssertSqlSafe(format!("SELECT elected_at, expires_at FROM {table}")), - ) + sqlx::query_as::<_, (jiff_sqlx::Timestamp, jiff_sqlx::Timestamp)>(sqlx::AssertSqlSafe( + format!("SELECT elected_at, expires_at FROM {table}"), + )) .fetch_optional(&pool) .await .unwrap() @@ -508,7 +508,7 @@ async fn leader_renews_while_maintenance_is_blocked() { loop { let (current_elected_at, current_expires_at) = lease().await.unwrap(); assert_eq!(current_elected_at, elected_at); - if current_expires_at > expires_at { + if current_expires_at.to_jiff() > expires_at.to_jiff() { expires_at = current_expires_at; return; } diff --git a/rust/riverqueue/tests/prepared_insertion.rs b/rust/riverqueue/tests/prepared_insertion.rs index 9a61e9485..97f5ba36e 100644 --- a/rust/riverqueue/tests/prepared_insertion.rs +++ b/rust/riverqueue/tests/prepared_insertion.rs @@ -106,7 +106,7 @@ async fn assert_stored_jobs_insert_like_ordinary_jobs( .insert_middleware(middleware.clone()) .build() .unwrap(); - let scheduled_at = chrono::Utc::now() + chrono::Duration::hours(1); + let scheduled_at = jiff::Timestamp::now() + jiff::SignedDuration::from_hours(1); let original = client .insert(PreparedArgs { value: 7 }) .opts( @@ -189,7 +189,7 @@ async fn assert_stored_jobs_insert_like_ordinary_jobs( /// Two available stored jobs to insert again, without uniqueness. fn available_params() -> Vec { - let now = chrono::Utc::now(); + let now = jiff::Timestamp::now(); (1..=2) .map(|value| PreparedInsertParams { created_at: now, @@ -199,7 +199,7 @@ fn available_params() -> Vec { metadata: riverqueue::JobMetadata::default(), priority: 1, queue: "prepared".to_owned(), - scheduled_at: now - chrono::Duration::seconds(1), + scheduled_at: now - jiff::SignedDuration::from_secs(1), tags: Vec::new(), unique_key: None, unique_states: None, diff --git a/rust/riverqueue/tests/producer_lifetime.rs b/rust/riverqueue/tests/producer_lifetime.rs index d81b28167..75757e61a 100644 --- a/rust/riverqueue/tests/producer_lifetime.rs +++ b/rust/riverqueue/tests/producer_lifetime.rs @@ -225,7 +225,7 @@ impl PilotProducer for LifetimePilot { } async fn keep_alive(&self, context: ProducerKeepAliveContext) -> Result<(), PilotError> { - assert!(context.stale_before < chrono::Utc::now()); + assert!(context.stale_before < jiff::Timestamp::now()); self.calls.push(Call::KeepAlive); if self.hang_first_keep_alive && self.keep_alives() == 1 { std::future::pending::<()>().await; diff --git a/rust/riverqueue/tests/protocol_fixtures.rs b/rust/riverqueue/tests/protocol_fixtures.rs index 6b87106f0..f65943f96 100644 --- a/rust/riverqueue/tests/protocol_fixtures.rs +++ b/rust/riverqueue/tests/protocol_fixtures.rs @@ -1,6 +1,6 @@ use std::{io::ErrorKind, path::Path, time::Duration}; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use riverqueue::{ AttemptError, DefaultRetryPolicy, JobRow, JobState, METADATA_KEY_OUTPUT, METADATA_KEY_PERIODIC_JOB_ID, METADATA_KEY_RESCUE_COUNT, METADATA_KEY_RESUMABLE_CURSOR, @@ -28,7 +28,7 @@ struct RetryFixture { job_id: i64, max_delay_ns: u64, min_delay_ns: u64, - now: DateTime, + now: Timestamp, seed: u64, } @@ -97,7 +97,7 @@ fn go_protocol_values_match_rust() { } } -fn retry_row(id: i64, now: DateTime, previous_errors: usize) -> JobRow { +fn retry_row(id: i64, now: Timestamp, previous_errors: usize) -> JobRow { let mut row = JobRow::new( id, "fixture_retry", diff --git a/rust/riverqueue/tests/resilience_postgres.rs b/rust/riverqueue/tests/resilience_postgres.rs index b5fc5642d..8a5aa0522 100644 --- a/rust/riverqueue/tests/resilience_postgres.rs +++ b/rust/riverqueue/tests/resilience_postgres.rs @@ -19,7 +19,7 @@ use std::{ }; use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use riverqueue::__private::ClientBuilderExt; use riverqueue::{ __private::{DatabaseConnection, JobSetStateParams, Pilot, PilotError}, @@ -454,7 +454,7 @@ impl RetryPolicy for RetryAnHourLater { &self, _job: &JobRow, _error: &riverqueue::WorkError, - _now: DateTime, + _now: Timestamp, ) -> Duration { Duration::from_hours(1) } @@ -595,7 +595,7 @@ async fn claimed_rows_decode_individually_and_leniently() { i16, Vec, serde_json::Value, - DateTime, + jiff_sqlx::Timestamp, ) = sqlx::query_as(AssertSqlSafe(format!( "SELECT state::text, attempt, errors, metadata, scheduled_at FROM {} WHERE id = $1", schema.table() @@ -612,8 +612,8 @@ async fn claimed_rows_decode_individually_and_leniently() { assert_eq!(metadata, serde_json::json!([1])); if state == "retryable" { assert!( - scheduled_at > Utc::now() + chrono::Duration::minutes(50), - "the client retry policy wasn't used: {scheduled_at}" + scheduled_at.to_jiff() > Timestamp::now() + jiff::SignedDuration::from_mins(50), + "the client retry policy wasn't used: {scheduled_at:?}" ); } } @@ -973,7 +973,10 @@ async fn out_of_range_snooze_is_clamped_and_cancel_time_matches_go() { run.stop().await.unwrap(); let snoozed = client.jobs().get(snoozed.job.row.id).await.unwrap(); assert_eq!(snoozed.attempt, 0); - assert!(snoozed.scheduled_at > chrono::Utc::now() + chrono::Duration::days(365 * 200)); + assert!( + snoozed.scheduled_at + > jiff::Timestamp::now() + jiff::SignedDuration::from_hours(24 * 365 * 200) + ); // River Go writes `cancel_attempted_at` as `time.Time` JSON. let cancelled = client.jobs().cancel(snoozed.id).await.unwrap(); @@ -989,7 +992,7 @@ async fn out_of_range_snooze_is_clamped_and_cancel_time_matches_go() { "trailing zeros are trimmed: {cancel_attempted_at}" ); } - chrono::DateTime::parse_from_rfc3339(&cancel_attempted_at).unwrap(); + cancel_attempted_at.parse::().unwrap(); schema.drop().await; } diff --git a/rust/riverqueue/tests/resilience_sqlite.rs b/rust/riverqueue/tests/resilience_sqlite.rs index af72530f9..cc5c7a284 100644 --- a/rust/riverqueue/tests/resilience_sqlite.rs +++ b/rust/riverqueue/tests/resilience_sqlite.rs @@ -13,7 +13,7 @@ use std::{ time::Duration, }; -use chrono::{DateTime, Utc}; +use jiff::Timestamp; use riverqueue::{ AttemptError, BoxError, Client, ErrorHandler, ErrorHandlerDecision, EventKind, InsertOpts, Job, JobArgs, JobEventKind, JobRow, JobState, MaintenanceConfig, QueueConfig, RetryPolicy, @@ -240,7 +240,7 @@ impl RetryPolicy for RetryAnHourLater { &self, _job: &JobRow, _error: &riverqueue::WorkError, - _now: DateTime, + _now: Timestamp, ) -> Duration { Duration::from_hours(1) } @@ -391,7 +391,7 @@ async fn claimed_rows_decode_individually_and_accept_go_integer_ranges() { i64, String, String, - DateTime, + jiff_sqlx::DateTime, ) = sqlx::query_as( "SELECT state, attempt, json(errors), json(tags), scheduled_at FROM river_job WHERE id = ?", ) @@ -409,8 +409,13 @@ async fn claimed_rows_decode_individually_and_accept_go_integer_ranges() { if state == JobState::Retryable { assert_eq!(errors[0], serde_json::json!({"not": "an array"})); assert!( - scheduled_at > Utc::now() + chrono::Duration::minutes(50), - "the client retry policy wasn't used: {scheduled_at}" + scheduled_at + .to_jiff() + .to_zoned(jiff::tz::TimeZone::UTC) + .unwrap() + .timestamp() + > Timestamp::now() + jiff::SignedDuration::from_mins(50), + "the client retry policy wasn't used: {scheduled_at:?}" ); } else { assert_eq!(errors.len(), 1); @@ -603,7 +608,10 @@ async fn invalid_json_columns_fail_attempts_without_stalling_the_queue() { // their invalid values, along with an ordinary scheduled job. let scheduled = client .insert(ResilienceArgs {}) - .opts(InsertOpts::default().with_scheduled_at(Utc::now() + chrono::Duration::hours(1))) + .opts( + InsertOpts::default() + .with_scheduled_at(Timestamp::now() + jiff::SignedDuration::from_hours(1)), + ) .await .unwrap(); sqlx::query("UPDATE river_job SET scheduled_at = datetime('now', '-1 second') WHERE state IN ('retryable', 'scheduled')") @@ -800,7 +808,7 @@ async fn cancel_attempted_at_matches_go_time_json() { "trailing zeros are trimmed: {cancel_attempted_at}" ); } - chrono::DateTime::parse_from_rfc3339(&cancel_attempted_at).unwrap(); + cancel_attempted_at.parse::().unwrap(); } #[tokio::test(flavor = "multi_thread")] diff --git a/rust/riverqueue/tests/serde_semantics.rs b/rust/riverqueue/tests/serde_semantics.rs index a3bfa39ae..46b5636d7 100644 --- a/rust/riverqueue/tests/serde_semantics.rs +++ b/rust/riverqueue/tests/serde_semantics.rs @@ -39,7 +39,7 @@ fn flattened_job_args_round_trip_through_a_job_row() { 1, FlattenedArgs::KIND, encode_args(&args).unwrap(), - chrono::Utc::now(), + jiff::Timestamp::now(), ); assert_eq!( diff --git a/rust/riverqueue/tests/sqlite_runtime.rs b/rust/riverqueue/tests/sqlite_runtime.rs index a8f7fcd2f..d8d7973e5 100644 --- a/rust/riverqueue/tests/sqlite_runtime.rs +++ b/rust/riverqueue/tests/sqlite_runtime.rs @@ -853,7 +853,7 @@ async fn sqlite_reinsert_preserves_wire_fields_and_runs_the_canonical_pipeline() .workers(workers) .build() .unwrap(); - let scheduled_at = chrono::Utc::now() + chrono::Duration::hours(2); + let scheduled_at = jiff::Timestamp::now() + jiff::SignedDuration::from_hours(2); let raw = riverqueue::__private::ExtensionClient::new(&client) .insert_raw( CancelArgs::KIND, @@ -1302,7 +1302,7 @@ async fn sqlite_attempt_errors_record_when_the_attempt_started() { .add_fn(move |_context: WorkContext, _job: Job| { let worker_started = Arc::clone(&worker_started_for_worker); async move { - *worker_started.lock().unwrap() = Some(chrono::Utc::now()); + *worker_started.lock().unwrap() = Some(jiff::Timestamp::now()); tokio::time::sleep(Duration::from_millis(200)).await; Err::(FailedLate) } @@ -1333,7 +1333,7 @@ async fn sqlite_attempt_errors_record_when_the_attempt_started() { // when the worker returned. SQLite stores milliseconds. let at = row.errors[0].at; assert!( - at <= worker_started && at > worker_started - chrono::Duration::milliseconds(100), + at <= worker_started && at > worker_started - jiff::SignedDuration::from_millis(100), "error at {at}, worker started at {worker_started}" ); @@ -1723,7 +1723,9 @@ impl PilotProducer for ClaimingPilot { attempted_by = jsonb_insert(coalesce(attempted_by, jsonb('[]')), '$[#]', ?) \ WHERE id = ?", ) - .bind(riverqueue::__private::sqlite_timestamp(chrono::Utc::now())) + .bind(riverqueue::__private::sqlite_timestamp( + jiff::Timestamp::now(), + )) .bind(context.client_id) .bind(id) .execute(&mut *connection) diff --git a/rust/riverqueue/tests/sqlite_storage.rs b/rust/riverqueue/tests/sqlite_storage.rs index d10ddf58b..7b537a4f0 100644 --- a/rust/riverqueue/tests/sqlite_storage.rs +++ b/rust/riverqueue/tests/sqlite_storage.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, Duration, SubsecRound, Utc}; +use jiff::{SignedDuration as Duration, Timestamp}; use riverqueue::{ Client, Error, InsertBatch, InsertOpts, JobArgs, JobDeleteManyParams, JobListCursor, JobListOrderBy, JobListParams, JobState, JobUpdateParams, QueueListParams, QueueSelector, @@ -216,10 +216,8 @@ async fn inserts_leave_unset_times_to_sqlite() { .unwrap() } }; - let is_sqlite_time = |text: &str| { - text.len() == 23 - && chrono::NaiveDateTime::parse_from_str(text, "%Y-%m-%d %H:%M:%S%.3f").is_ok() - }; + let is_sqlite_time = + |text: &str| text.len() == 23 && text.parse::().is_ok(); let before = sqlite_now().await; let unscheduled = client @@ -238,7 +236,7 @@ async fn inserts_leave_unset_times_to_sqlite() { .iter() .map(riverqueue::InsertBatchResult::id) .collect::>(); - let scheduled_for = Utc::now() + Duration::hours(1); + let scheduled_for = Timestamp::now() + Duration::from_hours(1); let scheduled = client .insert(EmptyBatchArgs { value: 4 }) .opts(riverqueue::InsertOpts::default().with_scheduled_at(scheduled_for)) @@ -268,11 +266,11 @@ async fn inserts_leave_unset_times_to_sqlite() { #[tokio::test] async fn job_list_time_without_states_uses_schedule_and_finalized_requires_states() { let (client, pool) = setup().await; - let now = Utc::now(); + let now = Timestamp::now(); let first = insert_job( &pool, JobSeed { - scheduled_at: now + Duration::hours(1), + scheduled_at: now + Duration::from_hours(1), ..JobSeed::default() }, ) @@ -280,7 +278,7 @@ async fn job_list_time_without_states_uses_schedule_and_finalized_requires_state let second = insert_job( &pool, JobSeed { - scheduled_at: now - Duration::hours(1), + scheduled_at: now - Duration::from_hours(1), ..JobSeed::default() }, ) @@ -318,12 +316,12 @@ async fn job_list_time_without_states_uses_schedule_and_finalized_requires_state )] async fn job_crud_preserves_sqlite_semantics() { let (client, pool) = setup().await; - let now = Utc::now(); + let now = Timestamp::now(); let delete_id = insert_job( &pool, JobSeed { metadata: json!({"original": true}), - scheduled_at: now - Duration::minutes(3), + scheduled_at: now - Duration::from_mins(3), tags: json!(["delete-me", "shared"]), ..JobSeed::default() }, @@ -334,7 +332,7 @@ async fn job_crud_preserves_sqlite_semantics() { JobSeed { attempt: 3, max_attempts: 3, - scheduled_at: now - Duration::minutes(2), + scheduled_at: now - Duration::from_mins(2), state: JobState::Retryable, tags: json!(["retry-me", "shared"]), ..JobSeed::default() @@ -344,7 +342,7 @@ async fn job_crud_preserves_sqlite_semantics() { let running_id = insert_job( &pool, JobSeed { - scheduled_at: now - Duration::minutes(1), + scheduled_at: now - Duration::from_mins(1), state: JobState::Running, tags: json!(["running", "shared"]), ..JobSeed::default() @@ -677,7 +675,7 @@ struct JobSeed { max_attempts: i16, metadata: Value, queue: &'static str, - scheduled_at: DateTime, + scheduled_at: Timestamp, state: JobState, tags: Value, } @@ -690,7 +688,7 @@ impl Default for JobSeed { max_attempts: 25, metadata: json!({}), queue: "default", - scheduled_at: Utc::now(), + scheduled_at: Timestamp::now(), state: JobState::Available, tags: json!([]), } @@ -698,7 +696,7 @@ impl Default for JobSeed { } async fn insert_job(pool: &SqlitePool, seed: JobSeed) -> i64 { - let now = Utc::now(); + let now = Timestamp::now(); let finalized_at = matches!( seed.state, JobState::Cancelled | JobState::Completed | JobState::Discarded @@ -733,10 +731,15 @@ async fn insert_job(pool: &SqlitePool, seed: JobSeed) -> i64 { .unwrap() } -fn sqlite_time(time: DateTime) -> String { - time.round_subsecs(3) - .format("%Y-%m-%d %H:%M:%S%.3f") - .to_string() +fn sqlite_time(time: Timestamp) -> String { + time.round( + jiff::TimestampRound::new() + .smallest(jiff::Unit::Millisecond) + .mode(jiff::RoundMode::HalfCeil), + ) + .unwrap() + .strftime("%Y-%m-%d %H:%M:%S%.3f") + .to_string() } async fn insert_queue(pool: &SqlitePool, name: &str) { From e66b0279640a66b5377965acc0f17738a6d4693d Mon Sep 17 00:00:00 2001 From: Brandur Date: Sun, 1 Nov 2026 18:17:17 -0600 Subject: [PATCH 2/2] [rust] Fix Jiff migration regressions and clarify upgrade docs Handle SQLite timestamp boundaries without panicking, preserve stored negative-year timestamps, and retain column names in unexpected-NULL errors. Saturate retry, retention, and reindex arithmetic at Jiff's timestamp bounds. Reject newly accepted Temporal syntax in cancellation timestamps while preserving Chrono's existing parsing leniency. Make jiff-sqlx optional and Postgres-only. Add boundary and decode-error coverage, clarify go_time's behavior and the breaking changelog entries, and move the Chrono migration guide out of the README into a separate published documentation page. Validation: - make test/rust with Postgres and SQLite: 577 passed, 1 ignored - make lint/rust - make doc/rust --- rust/CHANGELOG.md | 5 +- rust/riverqueue/Cargo.toml | 6 +- rust/riverqueue/README.md | 17 +---- rust/riverqueue/docs/migrating-from-chrono.md | 16 +++++ rust/riverqueue/src/client/mod.rs | 1 - rust/riverqueue/src/database/sqlite.rs | 62 +++++++++++++------ rust/riverqueue/src/encoding.rs | 17 ++--- rust/riverqueue/src/guide.rs | 9 ++- rust/riverqueue/src/maintenance/cleaner.rs | 25 ++++++-- rust/riverqueue/src/maintenance/reindexer.rs | 17 ++++- rust/riverqueue/src/maintenance/rescuer.rs | 53 +++++++++++++--- rust/riverqueue/src/unique.rs | 5 +- rust/riverqueue/tests/postgres_parity.rs | 27 ++++++++ rust/riverqueue/tests/resilience_sqlite.rs | 5 +- 14 files changed, 197 insertions(+), 68 deletions(-) create mode 100644 rust/riverqueue/docs/migrating-from-chrono.md diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index 4f3e4b777..852c84a68 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -12,7 +12,10 @@ Changes to River for Go are recorded in the [repository changelog](../CHANGELOG. ### Changed -- **Breaking:** Replace Chrono with Jiff 0.2 throughout the Rust API. Job timestamps, insertion options, retry hooks, periodic schedules, and test helpers now use `jiff::Timestamp`; River re-exports Jiff as `riverqueue::jiff`. `CronSchedule::next_after` takes and returns `jiff::Zoned`, `CronTimeZone::Fixed` uses `jiff::tz::Offset`, and `CronTimeZone::Named` uses `jiff::tz::TimeZone`. `CronTimeZone` is now `Clone` rather than `Copy`. `PostgresReindexSchedule::DailyUtc` takes `jiff::civil::Time`. Rename the optional `chrono-tz` feature to `tzdb`. Database formats, canonical timestamp JSON, and Go-compatible cron behavior remain unchanged. Timestamp ranges and checked arithmetic follow Jiff's supported range. [PR #1567](https://github.com/riverqueue/river/pull/1567). +- **Breaking:** Replace Chrono with Jiff 0.2 throughout the Rust API. Job timestamps, insertion options, retry hooks, periodic schedules, and test helpers now use `jiff::Timestamp`; River re-exports Jiff as `riverqueue::jiff`. Database formats and canonical timestamp JSON remain unchanged. [PR #1567](https://github.com/riverqueue/river/pull/1567). +- **Breaking:** Timestamps outside years -9999 to 9999 are rejected. The exact bounds are Jiff's `Timestamp::MIN` and `Timestamp::MAX`, narrower than Chrono's approximately ±262,000-year range. [PR #1567](https://github.com/riverqueue/river/pull/1567). +- **Breaking:** `CronSchedule::next_after` takes `&jiff::Zoned` and returns `Option`. `CronTimeZone::Fixed` uses `jiff::tz::Offset`, `CronTimeZone::Named` uses `jiff::tz::TimeZone`, and `CronTimeZone` is now `Clone` rather than `Copy`. Go-compatible cron scheduling behavior is unchanged. [PR #1567](https://github.com/riverqueue/river/pull/1567). +- **Breaking:** Rename the optional `chrono-tz` feature to `tzdb`. `PostgresReindexSchedule::DailyUtc` takes `jiff::civil::Time`. [PR #1567](https://github.com/riverqueue/river/pull/1567). ### Fixed diff --git a/rust/riverqueue/Cargo.toml b/rust/riverqueue/Cargo.toml index 015fac1e9..5c0ea17e3 100644 --- a/rust/riverqueue/Cargo.toml +++ b/rust/riverqueue/Cargo.toml @@ -19,8 +19,8 @@ all-features = true [features] default = ["postgres"] -postgres = ["riverqueue-migrate/postgres", "sqlx/postgres", "jiff-sqlx/postgres"] -sqlite = ["riverqueue-migrate/sqlite", "sqlx/sqlite", "jiff-sqlx/sqlite"] +postgres = ["riverqueue-migrate/postgres", "sqlx/postgres", "dep:jiff-sqlx", "jiff-sqlx/postgres"] +sqlite = ["riverqueue-migrate/sqlite", "sqlx/sqlite"] # Resolves IANA zone names such as `America/New_York` in cron `CRON_TZ=` # prefixes from a bundled time zone database. tzdb = ["jiff/tzdb-bundle-always"] @@ -30,7 +30,7 @@ async-trait.workspace = true base64.workspace = true futures-util.workspace = true jiff.workspace = true -jiff-sqlx = { version = "0.2.0", default-features = false } +jiff-sqlx = { version = "0.2.0", default-features = false, optional = true } riverqueue-migrate = { path = "../riverqueue-migrate", version = "=0.4.0", default-features = false } riverqueue-macros = { path = "../riverqueue-macros", version = "=0.4.0" } rand = { workspace = true } diff --git a/rust/riverqueue/README.md b/rust/riverqueue/README.md index bccb89bba..a618f8a51 100644 --- a/rust/riverqueue/README.md +++ b/rust/riverqueue/README.md @@ -41,22 +41,7 @@ compatible with River's (SQLx 0.9, Jiff 0.2, `serde_json` 1, and implementation for SQLx; enable one of SQLx's TLS features in your own SQLx dependency if your database connections use TLS. -## Migrating from Chrono - -Use `riverqueue::jiff::Timestamp` wherever River previously accepted or returned -`chrono::DateTime`. For example, `Timestamp::now()` replaces `Utc::now()`, -and `"2026-01-02T03:04:05Z".parse::()?` parses an absolute timestamp. -Use `std::time::Duration` for delays and intervals, and `jiff::SignedDuration` -when a duration can be negative. To measure elapsed time between timestamps, -use `later.duration_since(earlier)`; subtracting Jiff timestamps produces a -calendar `Span`. - -Cron's `next_after` accepts a `jiff::Zoned` and preserves the reference time -zone. Use `timestamp.to_zoned(jiff::tz::TimeZone::UTC)` for a UTC reference. -Fixed cron offsets use `jiff::tz::Offset`, named zones use `jiff::tz::TimeZone`, -and the `tzdb` feature replaces `chrono-tz`. Daily UTC reindex schedules use -`jiff::civil::Time`. River's persisted timestamps and unique-key encoding are -unchanged; existing databases require no migration. +For upgrades from Chrono, see the [migration guide](https://docs.rs/riverqueue/latest/riverqueue/guide/migrating_from_chrono/index.html). ## Quick start diff --git a/rust/riverqueue/docs/migrating-from-chrono.md b/rust/riverqueue/docs/migrating-from-chrono.md new file mode 100644 index 000000000..82f53d1de --- /dev/null +++ b/rust/riverqueue/docs/migrating-from-chrono.md @@ -0,0 +1,16 @@ +# Migrating from Chrono + +Use `riverqueue::jiff::Timestamp` wherever River previously accepted or returned +`chrono::DateTime`. For example, `Timestamp::now()` replaces `Utc::now()`, +and `"2026-01-02T03:04:05Z".parse::()?` parses an absolute timestamp. +Use `std::time::Duration` for delays and intervals, and `jiff::SignedDuration` +when a duration can be negative. To measure elapsed time between timestamps, +use `later.duration_since(earlier)`; subtracting Jiff timestamps produces a +calendar `Span`. + +Cron's `next_after` accepts a `jiff::Zoned` and preserves the reference time +zone. Use `timestamp.to_zoned(jiff::tz::TimeZone::UTC)` for a UTC reference. +Fixed cron offsets use `jiff::tz::Offset`, named zones use `jiff::tz::TimeZone`, +and the `tzdb` feature replaces `chrono-tz`. Daily UTC reindex schedules use +`jiff::civil::Time`. River's persisted timestamps and unique-key encoding are +unchanged; existing databases require no migration. diff --git a/rust/riverqueue/src/client/mod.rs b/rust/riverqueue/src/client/mod.rs index ccd2fbc2d..2a5f2fbd6 100644 --- a/rust/riverqueue/src/client/mod.rs +++ b/rust/riverqueue/src/client/mod.rs @@ -515,7 +515,6 @@ fn host_name() -> String { #[cfg(test)] mod default_client_id_tests { - use super::*; #[test] diff --git a/rust/riverqueue/src/database/sqlite.rs b/rust/riverqueue/src/database/sqlite.rs index 99cb9ae33..b0f65b9d2 100644 --- a/rust/riverqueue/src/database/sqlite.rs +++ b/rust/riverqueue/src/database/sqlite.rs @@ -295,9 +295,7 @@ impl<'row> FromRow<'row, SqliteRow> for JobRecord { attempt: row.try_get("attempt")?, attempted_at: sqlite_timestamp(row, "attempted_at")?, attempted_by: row.try_get("attempted_by")?, - created_at: sqlite_timestamp(row, "created_at")?.ok_or(sqlx::Error::Decode( - Box::new(sqlx::error::UnexpectedNullError), - ))?, + created_at: sqlite_timestamp_required(row, "created_at")?, encoded_args: row.try_get("encoded_args")?, errors: row.try_get("errors")?, finalized_at: sqlite_timestamp(row, "finalized_at")?, @@ -307,9 +305,7 @@ impl<'row> FromRow<'row, SqliteRow> for JobRecord { metadata: row.try_get("metadata")?, priority: row.try_get("priority")?, queue: row.try_get("queue")?, - scheduled_at: sqlite_timestamp(row, "scheduled_at")?.ok_or(sqlx::Error::Decode( - Box::new(sqlx::error::UnexpectedNullError), - ))?, + scheduled_at: sqlite_timestamp_required(row, "scheduled_at")?, state: row.try_get("state")?, tags: row.try_get("tags")?, unique_key: row.try_get("unique_key")?, @@ -431,15 +427,11 @@ struct QueueRecord { impl<'row> FromRow<'row, SqliteRow> for QueueRecord { fn from_row(row: &'row SqliteRow) -> Result { Ok(Self { - created_at: sqlite_timestamp(row, "created_at")?.ok_or(sqlx::Error::Decode( - Box::new(sqlx::error::UnexpectedNullError), - ))?, + created_at: sqlite_timestamp_required(row, "created_at")?, metadata: row.try_get("metadata")?, name: row.try_get("name")?, paused_at: sqlite_timestamp(row, "paused_at")?, - updated_at: sqlite_timestamp(row, "updated_at")?.ok_or(sqlx::Error::Decode( - Box::new(sqlx::error::UnexpectedNullError), - ))?, + updated_at: sqlite_timestamp_required(row, "updated_at")?, }) } } @@ -471,12 +463,8 @@ struct LeaderRecord { impl<'row> FromRow<'row, SqliteRow> for LeaderRecord { fn from_row(row: &'row SqliteRow) -> Result { Ok(Self { - elected_at: sqlite_timestamp(row, "elected_at")?.ok_or(sqlx::Error::Decode( - Box::new(sqlx::error::UnexpectedNullError), - ))?, - expires_at: sqlite_timestamp(row, "expires_at")?.ok_or(sqlx::Error::Decode( - Box::new(sqlx::error::UnexpectedNullError), - ))?, + elected_at: sqlite_timestamp_required(row, "elected_at")?, + expires_at: sqlite_timestamp_required(row, "expires_at")?, leader_id: row.try_get("leader_id")?, }) } @@ -546,6 +534,9 @@ fn sqlite_timestamp(row: &SqliteRow, column: &str) -> Result, let text = <&str as Decode>::decode(value).map_err(sqlx::Error::Decode)?; text.parse::().or_else(|_| { text.parse::() + // Chrono's SQLite format uses four digits for negative + // years; Jiff's default parser requires six. + .or_else(|_| jiff::civil::DateTime::strptime("%Y-%m-%d %H:%M:%S%.f", text)) .and_then(|datetime| datetime.to_zoned(jiff::tz::TimeZone::UTC)) .map(|zoned| zoned.timestamp()) }) @@ -557,13 +548,22 @@ fn sqlite_timestamp(row: &SqliteRow, column: &str) -> Result, }) } +fn sqlite_timestamp_required(row: &SqliteRow, column: &str) -> Result { + sqlite_timestamp(row, column)?.ok_or_else(|| sqlx::Error::ColumnDecode { + index: column.to_owned(), + source: Box::new(sqlx::error::UnexpectedNullError), + }) +} + pub(crate) fn sqlite_time(time: Timestamp) -> String { time.round( jiff::TimestampRound::new() .smallest(jiff::Unit::Millisecond) .mode(jiff::RoundMode::HalfCeil), ) - .expect("database timestamp rounds to milliseconds") + // Rounding the last half millisecond would exceed Timestamp::MAX. + // Formatting truncates it to the last representable millisecond instead. + .unwrap_or(time) .strftime("%Y-%m-%d %H:%M:%S%.3f") .to_string() } @@ -2920,6 +2920,24 @@ mod tests { } } + #[tokio::test] + async fn timestamp_bounds_round_trip() { + let pool = setup().await; + for (time, encoded) in [ + (Timestamp::MIN, "-9999-01-02 01:59:59.000"), + (Timestamp::MAX, "9999-12-30 22:00:00.999"), + ] { + assert_eq!(sqlite_time(time), encoded); + let row = sqlx::query("SELECT ? AS time") + .bind(encoded) + .fetch_one(&pool) + .await + .unwrap(); + let decoded = sqlite_timestamp_required(&row, "time").unwrap(); + assert_eq!(decoded.as_millisecond(), time.as_millisecond()); + } + } + #[tokio::test] async fn timestamp_columns_accept_existing_sqlite_representations() { let pool = setup().await; @@ -2959,6 +2977,12 @@ mod tests { .await .unwrap(); assert_eq!(sqlite_timestamp(&row, "time").unwrap(), None); + let error = sqlite_timestamp_required(&row, "time").unwrap_err(); + assert!(matches!( + error, + sqlx::Error::ColumnDecode { index, source } + if index == "time" && source.is::() + )); let row = sqlx::query("SELECT 'invalid' AS time") .fetch_one(&pool) .await diff --git a/rust/riverqueue/src/encoding.rs b/rust/riverqueue/src/encoding.rs index 829e1f284..abce5ff4c 100644 --- a/rust/riverqueue/src/encoding.rs +++ b/rust/riverqueue/src/encoding.rs @@ -25,8 +25,8 @@ //! * River clients in other languages may encode byte strings as base64 //! strings; a Rust `Vec` encodes as an array of numbers. //! * Canonical timestamps use RFC 3339 with the shortest fractional seconds, -//! as Jiff timestamps do by default. [`go_time`] explicitly applies this -//! representation to `Timestamp` fields that participate in unique keys. +//! as Jiff timestamps do by default. [`go_time`] pins this representation +//! for `Timestamp` fields that participate in unique keys. //! //! Encoding fails for non-finite floats (`NaN` and infinities) instead of //! writing them as `null` the way [`serde_json`] does. @@ -64,9 +64,12 @@ pub fn encode_args(args: &T) -> Result, ser /// timestamp: RFC 3339 in UTC with `Z` and the shortest fractional seconds, /// with no fractional part when it's zero (`2026-01-02T03:04:05.5Z`). /// -/// Use it with `#[serde(with = "riverqueue::encoding::go_time")]` on argument -/// fields that participate in unique keys shared with other River clients. -/// Deserialization accepts any RFC 3339 timestamp. +/// Jiff currently produces the same output by default. Use +/// `#[serde(with = "riverqueue::encoding::go_time")]` to pin River's format +/// on argument fields that participate in unique keys shared with other +/// River clients. Deserialization uses Jiff's timestamp parser, which also +/// accepts Temporal zone annotations, lowercase `t`/`z`, a space separator, +/// and offsets with seconds. pub mod go_time { use jiff::Timestamp; use serde::{Deserialize, Deserializer, Serializer}; @@ -84,11 +87,11 @@ pub mod go_time { serializer.serialize_str(&super::format_rfc3339_nano(*timestamp)) } - /// Deserializes an RFC 3339 timestamp. + /// Deserializes a timestamp using Jiff's parser. /// /// # Errors /// - /// Returns an error when the input is not an RFC 3339 timestamp. + /// Returns an error when Jiff cannot parse the input as a timestamp. pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { Timestamp::deserialize(deserializer) } diff --git a/rust/riverqueue/src/guide.rs b/rust/riverqueue/src/guide.rs index 553519bd3..85efe8567 100644 --- a/rust/riverqueue/src/guide.rs +++ b/rust/riverqueue/src/guide.rs @@ -1,7 +1,10 @@ -//! Guides for running River in production. +//! Guides for upgrading and running River in production. //! -//! These pages contain no API items; they document how River behaves when -//! deployed alongside other River implementations. +//! These pages contain no API items; they cover API upgrades and how River +//! behaves when deployed alongside other River implementations. + +#[doc = include_str!("../docs/migrating-from-chrono.md")] +pub mod migrating_from_chrono {} #[doc = include_str!("../docs/mixed-deployments.md")] pub mod mixed_deployments {} diff --git a/rust/riverqueue/src/maintenance/cleaner.rs b/rust/riverqueue/src/maintenance/cleaner.rs index d2a6a2853..760c0164f 100644 --- a/rust/riverqueue/src/maintenance/cleaner.rs +++ b/rust/riverqueue/src/maintenance/cleaner.rs @@ -36,7 +36,7 @@ fn horizon(now: Timestamp, retention: Option) -> Result Result<(), MaintenanceError> { loop { - let updated_before = Timestamp::now() - - jiff::SignedDuration::try_from(context.inner.maintenance.queue_retention) - .map_err(|error| Error::configuration_context("maintenance", error.to_string()))?; + let updated_before = horizon( + Timestamp::now(), + Some(context.inner.maintenance.queue_retention), + )? + .expect("queue retention is set"); let limit = batch_size(&context.breakers.queue_cleaner); let result = clean_queues_batch(context, updated_before, limit).await; record_batch(&context.breakers.queue_cleaner, &result); @@ -351,6 +353,21 @@ pub(super) async fn clean_notifications( } } +#[cfg(test)] +mod unit_tests { + use super::{Duration, Timestamp, horizon}; + + #[test] + fn retention_horizon_saturates_at_timestamp_min() { + let now = "2026-01-02T03:04:05Z".parse().unwrap(); + assert_eq!(horizon(now, None).unwrap(), None); + assert_eq!(horizon(now, Some(Duration::ZERO)).unwrap(), Some(now)); + // This retention fits Chrono's range but exceeds Jiff's. + let retention = Duration::from_hours(20_000 * 365 * 24); + assert_eq!(horizon(now, Some(retention)).unwrap(), Some(Timestamp::MIN)); + } +} + #[cfg(all(test, feature = "sqlite"))] mod sqlite_tests { use std::sync::Arc; diff --git a/rust/riverqueue/src/maintenance/reindexer.rs b/rust/riverqueue/src/maintenance/reindexer.rs index d2ea9e5fd..911325661 100644 --- a/rust/riverqueue/src/maintenance/reindexer.rs +++ b/rust/riverqueue/src/maintenance/reindexer.rs @@ -45,7 +45,9 @@ pub(crate) fn next_run(schedule: PostgresReindexSchedule, after: Timestamp) -> T .timestamp() } } - PostgresReindexSchedule::Interval(interval) => after + interval, + PostgresReindexSchedule::Interval(interval) => { + after.checked_add(interval).unwrap_or(Timestamp::MAX) + } } } @@ -314,6 +316,19 @@ mod unit_tests { use super::{PostgresReindexSchedule, next_run}; + #[test] + fn interval_saturates_at_timestamp_max() { + // This interval fits Chrono's range but exceeds Jiff's. + let interval = std::time::Duration::from_hours(20_000 * 365 * 24); + assert_eq!( + next_run( + PostgresReindexSchedule::Interval(interval), + "2026-01-02T03:04:05Z".parse().unwrap(), + ), + jiff::Timestamp::MAX, + ); + } + #[test] fn schedule_advances_from_the_previous_run() { let before_midnight = "2026-08-11T23:59:00Z".parse::().unwrap(); diff --git a/rust/riverqueue/src/maintenance/rescuer.rs b/rust/riverqueue/src/maintenance/rescuer.rs index 355397b25..0d550e78f 100644 --- a/rust/riverqueue/src/maintenance/rescuer.rs +++ b/rust/riverqueue/src/maintenance/rescuer.rs @@ -153,9 +153,7 @@ fn decide(inner: &ClientInner, row: &JobRow, now: Timestamp) -> Result Result { let delay = inner.retry_policy.next_retry(row, &rescued_error, now); - Ok(now - + jiff::SignedDuration::try_from(delay) - .map_err(|error| Error::invalid_job_context("maintenance", error.to_string()))?) + retry_at(now, delay) }; // A worker that cannot evaluate the job, for example because its args no @@ -183,10 +181,7 @@ fn decide(inner: &ClientInner, row: &JobRow, now: Timestamp) -> Result { - now + jiff::SignedDuration::try_from(delay) - .map_err(|error| Error::invalid_job_context("maintenance", error.to_string()))? - } + Ok(Some(delay)) => retry_at(now, delay)?, Ok(None) => client_retry(row)?, Err(retry_error) => { debug!(error = %retry_error, job_id = row.id, "River rescuer used the client retry policy"); @@ -196,6 +191,12 @@ fn decide(inner: &ClientInner, row: &JobRow, now: Timestamp) -> Result Result { + let delay = jiff::SignedDuration::try_from(delay) + .map_err(|error| Error::invalid_job_context("maintenance", error.to_string()))?; + Ok(now.checked_add(delay).unwrap_or(Timestamp::MAX)) +} + /// Go decodes `cancel_attempted_at` as a `time.Time` and cancels only when it /// is a non-zero timestamp; absent, null, or unparsable values do not cancel. fn cancel_attempted(value: Option<&serde_json::value::RawValue>) -> bool { @@ -204,7 +205,18 @@ fn cancel_attempted(value: Option<&serde_json::value::RawValue>) -> bool { .expect("Go zero time is valid"); value .and_then(|raw| serde_json::from_str::(raw.get()).ok()) - .and_then(|value| value.parse::().ok()) + .and_then(|value| { + // Preserve Chrono's acceptance of lowercase designators and a + // space separator, but exclude newly accepted Temporal zone + // annotations and offsets other than Z or ±hh:mm. + let offset = value.get(value.len().saturating_sub(6)..)?; + let valid_offset = value.ends_with(['Z', 'z']) + || (offset.starts_with(['+', '-']) && offset.as_bytes().get(3) == Some(&b':')); + if !valid_offset { + return None; + } + value.parse::().ok() + }) .is_some_and(|time| time != go_zero_time) } @@ -476,9 +488,12 @@ async fn rescue_batch_sqlite( #[cfg(test)] mod unit_tests { + use std::time::Duration; + + use jiff::Timestamp; use serde_json::json; - use super::cancel_attempted; + use super::{cancel_attempted, retry_at}; #[test] fn cancel_attempted_requires_a_non_zero_timestamp_like_go() { @@ -488,10 +503,30 @@ mod unit_tests { }; assert!(cancel(Some(json!("2026-01-02T03:04:05Z")))); assert!(cancel(Some(json!("2026-01-02T03:04:05.123456+00:00")))); + // These forms were already accepted before the Jiff migration. + assert!(cancel(Some(json!("2026-01-02t03:04:05z")))); + assert!(cancel(Some(json!("2026-01-02 03:04:05Z")))); assert!(!cancel(None)); assert!(!cancel(Some(json!(null)))); assert!(!cancel(Some(json!("0001-01-01T00:00:00Z")))); assert!(!cancel(Some(json!("not a time")))); assert!(!cancel(Some(json!(true)))); + for text in [ + "2026-01-02T03:04:05+00:00[America/New_York]", + "2026-01-02T03:04:05+00:00:30", + "2026-01-02T03:04:05+00", + "2026-01-02T03:04:05+0000", + ] { + assert!(!cancel(Some(json!(text))), "{text}"); + } + } + + #[test] + fn retry_delay_saturates_at_timestamp_max() { + let now = "2026-01-02T03:04:05Z".parse().unwrap(); + assert_eq!(retry_at(now, Duration::ZERO).unwrap(), now); + // This delay fits Chrono's range but exceeds Jiff's. + let delay = Duration::from_hours(20_000 * 365 * 24); + assert_eq!(retry_at(now, delay).unwrap(), Timestamp::MAX); } } diff --git a/rust/riverqueue/src/unique.rs b/rust/riverqueue/src/unique.rs index 33c806c4b..0370df5b8 100644 --- a/rust/riverqueue/src/unique.rs +++ b/rust/riverqueue/src/unique.rs @@ -469,9 +469,10 @@ fn truncate_period(timestamp: Timestamp, period: Duration) -> Result Client { .unwrap() } +#[tokio::test] +async fn out_of_range_timestamps_return_decode_errors() { + let database = PostgresSchema::new("rpp_timestamp_range").await; + let client = insert_only_client(&database); + let job_id = insert_raw_job(&database, "available", None).await; + + for timestamp in ["10000-01-01 00:00:00+00", "infinity", "-infinity"] { + sqlx::query(sqlx::AssertSqlSafe(format!( + "UPDATE {} SET scheduled_at = $1::text::timestamptz WHERE id = $2", + database.table("river_job") + ))) + .bind(timestamp) + .bind(job_id) + .execute(&database.pool) + .await + .unwrap(); + + let error = client.jobs().get(job_id).await.unwrap_err(); + assert!( + matches!(error, Error::Database(sqlx::Error::ColumnDecode { .. })), + "{timestamp}: {error}", + ); + } + + database.cleanup().await; +} + #[tokio::test(flavor = "multi_thread")] async fn queue_pause_and_resume() { let database = PostgresSchema::new("rpp_queue_pause").await; diff --git a/rust/riverqueue/tests/resilience_sqlite.rs b/rust/riverqueue/tests/resilience_sqlite.rs index cc5c7a284..0f7c48cfd 100644 --- a/rust/riverqueue/tests/resilience_sqlite.rs +++ b/rust/riverqueue/tests/resilience_sqlite.rs @@ -391,7 +391,7 @@ async fn claimed_rows_decode_individually_and_accept_go_integer_ranges() { i64, String, String, - jiff_sqlx::DateTime, + String, ) = sqlx::query_as( "SELECT state, attempt, json(errors), json(tags), scheduled_at FROM river_job WHERE id = ?", ) @@ -410,7 +410,8 @@ async fn claimed_rows_decode_individually_and_accept_go_integer_ranges() { assert_eq!(errors[0], serde_json::json!({"not": "an array"})); assert!( scheduled_at - .to_jiff() + .parse::() + .unwrap() .to_zoned(jiff::tz::TimeZone::UTC) .unwrap() .timestamp()