From 8a33e9bc486cc4ac8177a52a2b9dce7df38f0e9e Mon Sep 17 00:00:00 2001 From: shikhar Date: Fri, 7 Nov 2025 12:06:22 -0800 Subject: [PATCH 001/700] init Co-authored-by: Stephen Balogh Co-authored-by: Dwarak Govind Parthiban Co-authored-by: Mehul Arora Co-authored-by: Vaibhav Rabber --- .gitignore | 1 + .gitmodules | 6 + .rustfmt.toml | 8 + Cargo.lock | 4152 ++++++++++++++++++++++++++++++ Cargo.toml | 40 + LICENSE | 21 + README.md | 7 + api/Cargo.toml | 35 + api/build.rs | 10 + api/protos | 1 + api/src/lib.rs | 7 + api/src/v1/access.rs | 441 ++++ api/src/v1/basin.rs | 164 ++ api/src/v1/config.rs | 504 ++++ api/src/v1/metrics.rs | 442 ++++ api/src/v1/mod.rs | 90 + api/src/v1/stream/mod.rs | 553 ++++ api/src/v1/stream/proto/mod.rs | 194 ++ api/src/v1/stream/proto/s2.v1.rs | 95 + api/src/v1/stream/s2s.rs | 717 ++++++ api/src/v1/stream/sse.rs | 194 ++ common/Cargo.toml | 29 + common/src/caps.rs | 14 + common/src/deep_size.rs | 70 + common/src/lib.rs | 6 + common/src/maybe.rs | 129 + common/src/read_extent.rs | 220 ++ common/src/record/batcher.rs | 347 +++ common/src/record/command.rs | 308 +++ common/src/record/envelope.rs | 372 +++ common/src/record/fencing.rs | 97 + common/src/record/metering.rs | 132 + common/src/record/mod.rs | 458 ++++ common/src/types/access.rs | 324 +++ common/src/types/basin.rs | 204 ++ common/src/types/config.rs | 423 +++ common/src/types/metrics.rs | 97 + common/src/types/mod.rs | 35 + common/src/types/resources.rs | 154 ++ common/src/types/stream.rs | 358 +++ common/src/types/strings.rs | 36 + lite/Cargo.toml | 20 + lite/docs/notes.md | 73 + lite/src/backend/mod.rs | 4 + lite/src/backend/ops.rs | 150 ++ lite/src/backend/slatedb.rs | 79 + lite/src/backend/stream_id.rs | 67 + lite/src/handlers/mod.rs | 1 + lite/src/handlers/v1.rs | 1 + lite/src/lib.rs | 4 + lite/src/service.rs | 0 51 files changed, 11894 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .rustfmt.toml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 api/Cargo.toml create mode 100644 api/build.rs create mode 160000 api/protos create mode 100644 api/src/lib.rs create mode 100644 api/src/v1/access.rs create mode 100644 api/src/v1/basin.rs create mode 100644 api/src/v1/config.rs create mode 100644 api/src/v1/metrics.rs create mode 100644 api/src/v1/mod.rs create mode 100644 api/src/v1/stream/mod.rs create mode 100644 api/src/v1/stream/proto/mod.rs create mode 100644 api/src/v1/stream/proto/s2.v1.rs create mode 100644 api/src/v1/stream/s2s.rs create mode 100644 api/src/v1/stream/sse.rs create mode 100644 common/Cargo.toml create mode 100644 common/src/caps.rs create mode 100644 common/src/deep_size.rs create mode 100644 common/src/lib.rs create mode 100644 common/src/maybe.rs create mode 100644 common/src/read_extent.rs create mode 100644 common/src/record/batcher.rs create mode 100644 common/src/record/command.rs create mode 100644 common/src/record/envelope.rs create mode 100644 common/src/record/fencing.rs create mode 100644 common/src/record/metering.rs create mode 100644 common/src/record/mod.rs create mode 100644 common/src/types/access.rs create mode 100644 common/src/types/basin.rs create mode 100644 common/src/types/config.rs create mode 100644 common/src/types/metrics.rs create mode 100644 common/src/types/mod.rs create mode 100644 common/src/types/resources.rs create mode 100644 common/src/types/stream.rs create mode 100644 common/src/types/strings.rs create mode 100644 lite/Cargo.toml create mode 100644 lite/docs/notes.md create mode 100644 lite/src/backend/mod.rs create mode 100644 lite/src/backend/ops.rs create mode 100644 lite/src/backend/slatedb.rs create mode 100644 lite/src/backend/stream_id.rs create mode 100644 lite/src/handlers/mod.rs create mode 100644 lite/src/handlers/v1.rs create mode 100644 lite/src/lib.rs create mode 100644 lite/src/service.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..195b9df6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "protos"] + path = api/protos + url = https://github.com/s2-streamstore/s2-protos +[submodule "api/protos"] + path = api/protos + url = git@github.com:s2-streamstore/s2-protos.git diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 00000000..76f7efe3 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,8 @@ +max_width = 100 + +group_imports = "StdExternalCrate" +imports_granularity = "Crate" +imports_layout = "Mixed" + +comment_width = 100 +wrap_comments = true diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..89efcb80 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4152 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto_enums" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c170965892137a3a9aeb000b4524aa3cc022a310e709d848b6e1cdce4ab4781" +dependencies = [ + "derive_utils", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "blake3" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytecheck" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +dependencies = [ + "serde", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "cmsketch" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e708912643ef44f956dca7b2de7b97d90ff79ce39191607b2fc1eb3f1be33c" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rkyv", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_utils" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccfae181bab5ab6c5478b2ccb69e4c68a02f8c3ec72f6616bfec9dbc599d2ee0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "duration-str" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f88959de2d447fd3eddcf1909d1f19fe084e27a056a6904203dc5d8b9e771c1e" +dependencies = [ + "rust_decimal", + "serde", + "thiserror 2.0.17", + "time", + "winnow 0.6.26", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fail-parallel" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5666e8ca4ec174d896fb742789c29b1bea9319dcfd623c41bececc0a60c4939d" +dependencies = [ + "log", + "once_cell", + "rand 0.8.5", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "serde_json", + "serde_yaml", + "toml 0.8.23", + "uncased", + "version_check", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flatbuffers" +version = "25.9.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b6620799e7340ebd9968d2e0708eb82cf1971e9a16821e2091b6d6e475eed5" +dependencies = [ + "bitflags", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "foyer" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "642093b1a72c4a0ef89862484d669a353e732974781bb9c49a979526d1e30edc" +dependencies = [ + "equivalent", + "foyer-common", + "foyer-memory", + "foyer-storage", + "madsim-tokio", + "mixtrics", + "pin-project", + "serde", + "thiserror 2.0.17", + "tokio", + "tracing", +] + +[[package]] +name = "foyer-common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9db9c0e4648b13e9216d785b308d43751ca975301aeb83e607ec630b6f956944" +dependencies = [ + "bincode", + "bytes", + "cfg-if", + "itertools", + "madsim-tokio", + "mixtrics", + "parking_lot", + "pin-project", + "serde", + "thiserror 2.0.17", + "tokio", + "twox-hash", +] + +[[package]] +name = "foyer-intrusive-collections" +version = "0.10.0-dev" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4fee46bea69e0596130e3210e65d3424e0ac1e6df3bde6636304bdf1ca4a3b" +dependencies = [ + "memoffset", +] + +[[package]] +name = "foyer-memory" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "040dc38acbfca8f1def26bbbd9e9199090884aabb15de99f7bf4060be66ff608" +dependencies = [ + "arc-swap", + "bitflags", + "cmsketch", + "equivalent", + "foyer-common", + "foyer-intrusive-collections", + "hashbrown 0.15.5", + "itertools", + "madsim-tokio", + "mixtrics", + "parking_lot", + "pin-project", + "serde", + "thiserror 2.0.17", + "tokio", + "tracing", +] + +[[package]] +name = "foyer-storage" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a77ed888da490e997da6d6d62fcbce3f202ccf28be098c4ea595ca046fc4a9" +dependencies = [ + "allocator-api2", + "anyhow", + "auto_enums", + "bytes", + "equivalent", + "flume", + "foyer-common", + "foyer-memory", + "fs4", + "futures-core", + "futures-util", + "itertools", + "libc", + "lz4", + "madsim-tokio", + "ordered_hash_map", + "parking_lot", + "paste", + "pin-project", + "rand 0.9.2", + "serde", + "thiserror 2.0.17", + "tokio", + "tracing", + "twox-hash", + "zstd", +] + +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "madsim" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18351aac4194337d6ea9ffbd25b3d1540ecc0754142af1bff5ba7392d1f6f771" +dependencies = [ + "ahash", + "async-channel", + "async-stream", + "async-task", + "bincode", + "bytes", + "downcast-rs", + "errno", + "futures-util", + "lazy_static", + "libc", + "madsim-macros", + "naive-timer", + "panic-message", + "rand 0.8.5", + "rand_xoshiro 0.6.0", + "rustversion", + "serde", + "spin", + "tokio", + "tokio-util", + "toml 0.9.8", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "madsim-macros" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d248e97b1a48826a12c3828d921e8548e714394bf17274dd0a93910dc946e1" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "madsim-tokio" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d3eb2acc57c82d21d699119b859e2df70a91dbdb84734885a1e72be83bdecb5" +dependencies = [ + "madsim", + "spin", + "tokio", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mixtrics" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c1f8a5250642cbedbb30bd21a84bb960a9cbfe8c8c30a910103513647326d" +dependencies = [ + "itertools", + "parking_lot", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "naive-timer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "034a0ad7deebf0c2abcf2435950a6666c3c15ea9d8fad0c0f48efa8a7f843fed" + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object_store" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c1be0c6c22ec0817cdc77d3842f721a17fd30ab6965001415b5402a74e6b740" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "form_urlencoded", + "futures", + "http", + "http-body-util", + "humantime", + "hyper", + "itertools", + "md-5", + "parking_lot", + "percent-encoding", + "quick-xml", + "rand 0.9.2", + "reqwest", + "ring", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.17", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "ordered_hash_map" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab0e5f22bf6dd04abd854a8874247813a8fa2c8c1260eba6fbb150270ce7c176" +dependencies = [ + "hashbrown 0.13.2", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "panic-message" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384e52fd8fbd4cbe3c317e8216260c21a0f9134de108cea8a4dd4e7e152c472d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.108", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.7", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", + "version_check", + "yansi", +] + +[[package]] +name = "proptest" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "prost" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" +dependencies = [ + "heck 0.5.0", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.108", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "prost-types" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" +dependencies = [ + "prost", +] + +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.3", +] + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.3", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rend" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a640b26f007713818e9a9b65d34da1cf58538207b052916a83d80e43f3ffa4" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.15.5", + "indexmap", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd83f5f173ff41e00337d97f6572e416d022ef8a19f371817259ae960324c482" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.108", + "unicode-ident", +] + +[[package]] +name = "rust_decimal" +version = "1.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" +dependencies = [ + "arrayvec", + "num-traits", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "s2-api" +version = "0.1.0" +dependencies = [ + "axum", + "base64ct", + "bytes", + "compact_str", + "enum-ordinalize", + "flate2", + "futures", + "http", + "itertools", + "proptest", + "prost", + "prost-build", + "s2-common", + "serde", + "thiserror 2.0.17", + "time", + "tokio-util", + "utoipa", + "zstd", +] + +[[package]] +name = "s2-common" +version = "0.1.0" +dependencies = [ + "bytes", + "clap", + "compact_str", + "const_format", + "enum-ordinalize", + "enumset", + "futures", + "proptest", + "rkyv", + "rstest", + "serde", + "strum", + "thiserror 2.0.17", + "time", + "utoipa", +] + +[[package]] +name = "s2-lite" +version = "0.1.0" +dependencies = [ + "async-trait", + "blake3", + "bytes", + "clap", + "compact_str", + "dashmap", + "eyre", + "futures", + "s2-api", + "s2-common", + "slatedb", + "thiserror 2.0.17", + "tokio", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "slatedb" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1929df128f27789722644d8e9a68eaddec2e656ed8bac2a088159c8cce423590" +dependencies = [ + "anyhow", + "async-trait", + "atomic", + "backon", + "bitflags", + "bytemuck", + "bytes", + "chrono", + "crc32fast", + "crossbeam-skiplist", + "dotenvy", + "duration-str", + "fail-parallel", + "figment", + "flatbuffers", + "foyer", + "futures", + "log", + "object_store", + "once_cell", + "ouroboros", + "parking_lot", + "radix_trie", + "rand 0.9.2", + "rand_xoshiro 0.7.0", + "serde", + "serde_json", + "siphasher", + "thiserror 1.0.69", + "thread_local", + "tokio", + "tokio-util", + "tracing", + "ulid", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.0.3", + "toml_datetime 0.7.3", + "toml_parser", + "toml_writer", + "winnow 0.7.13", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.13", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap", + "toml_datetime 0.7.3", + "toml_parser", + "winnow 0.7.13", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow 0.7.13", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +dependencies = [ + "rand 0.9.2", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.2", + "serde", + "web-time", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "git+https://github.com/infiniteregrets/utoipa?rev=9cd181d40ac0a50551ebe1995a4d73e8685890d6#9cd181d40ac0a50551ebe1995a4d73e8685890d6" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "git+https://github.com/infiniteregrets/utoipa?rev=9cd181d40ac0a50551ebe1995a4d73e8685890d6#9cd181d40ac0a50551ebe1995a4d73e8685890d6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.108", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.108", +] + +[[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.108", +] + +[[package]] +name = "windows-link" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.6.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..71fbaa9a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,40 @@ +[workspace] +resolver = "2" +members = ["common", "api", "lite"] + +[workspace.package] +edition = "2024" +license = "MIT" +version = "0.1.0" + +[workspace.dependencies] +async-trait = "0.1.89" +axum = "0.8.6" +base64ct = { version = "1.8.0", features = ["alloc"] } +blake3 = "1.8.2" +bytes = "1.10.1" +compact_str = "0.9.0" +clap = { version = "4.5.51", features = ["derive"] } +const_format = "0.2.35" +dashmap = "6.1.0" +enum-ordinalize = "4.3.2" +enumset = "1.1.10" +eyre = "0.6.12" +flate2 = "1.1.5" +futures = "0.3.31" +http = "1.3.1" +itertools = "0.14.0" +proptest = "1.9.0" +prost = "0.14.1" +prost-build = "0.14.1" +rkyv = "0.8.12" +rstest = "0.26.1" +serde = { version = "1.0.228", features = ["derive"] } +slatedb = "0.8.2" +strum = { version = "0.27", features = ["derive"] } +thiserror = "2.0.17" +time = { version = "0.3.44", features = ["serde", "parsing", "formatting"] } +tokio = "1.48.0" +tokio-util = "0.7.17" +utoipa = { git = "https://github.com/infiniteregrets/utoipa", rev = "9cd181d40ac0a50551ebe1995a4d73e8685890d6" } +zstd = "0.13.3" diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..c535bbb5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Bandar Systems Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..a18cc188 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# s2-lite + +This is the home of `s2-lite` — an open source, self-hostable implementation of the [S2 API](https://s2.dev/docs/rest) for durable streams. + +_It will be backed by [SlateDB](https://slatedb.io), and it is currently in development._ + +Check out [s2.dev](https://s2.dev/) for the serverless cloud offering. diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 00000000..e7ebfb10 --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "s2-api" +edition.workspace = true +license.workspace = true +version.workspace = true + +[features] +axum = ["dep:axum"] +utoipa = ["dep:utoipa"] +codegen = ["dep:prost-build"] + +[dependencies] +axum = { workspace = true, optional = true } +base64ct = { workspace = true } +bytes = { workspace = true } +compact_str = { workspace = true, features = ["serde"] } +enum-ordinalize = { workspace = true } +flate2 = { workspace = true } +futures = { workspace = true } +http = { workspace = true } +itertools = { workspace = true } +prost = { workspace = true } +s2-common = { path = "../common" } +serde = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true } +tokio-util = { workspace = true, features = ["rt", "codec"] } +utoipa = { workspace = true, optional = true } +zstd = { workspace = true } + +[build-dependencies] +prost-build = { workspace = true, optional = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/api/build.rs b/api/build.rs new file mode 100644 index 00000000..f377df69 --- /dev/null +++ b/api/build.rs @@ -0,0 +1,10 @@ +fn main() -> Result<(), Box> { + #[cfg(feature = "codegen")] + { + prost_build::Config::new() + .bytes(["."]) + .out_dir("src/v1/stream/proto") + .compile_protos(&["protos/s2/v1/s2.proto"], &["protos/s2/v1"])?; + } + Ok(()) +} diff --git a/api/protos b/api/protos new file mode 160000 index 00000000..1c719609 --- /dev/null +++ b/api/protos @@ -0,0 +1 @@ +Subproject commit 1c719609c03abbd0eb1214c899b03f0c6571d907 diff --git a/api/src/lib.rs b/api/src/lib.rs new file mode 100644 index 00000000..aefbbadf --- /dev/null +++ b/api/src/lib.rs @@ -0,0 +1,7 @@ +pub mod v1; + +pub mod spec { + pub fn generate() -> String { + "OpenAPI 3.0 spec".to_string() + } +} diff --git a/api/src/v1/access.rs b/api/src/v1/access.rs new file mode 100644 index 00000000..82ba6929 --- /dev/null +++ b/api/src/v1/access.rs @@ -0,0 +1,441 @@ +use s2_common::types::{ + self, + access::{AccessTokenId, AccessTokenIdPrefix}, + basin::{BasinName, BasinNamePrefix}, + stream::{StreamName, StreamNamePrefix}, +}; +use serde::{Deserialize, Serialize}; +use time::{OffsetDateTime, format_description::well_known::Iso8601}; +#[cfg(feature = "utoipa")] +use utoipa::{IntoParams, ToSchema}; + +#[rustfmt::skip] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum Operation { + /// List basins. + ListBasins, + /// Create a basin. + CreateBasin, + /// Delete a basin. + DeleteBasin, + /// Reconfigure a basin. + ReconfigureBasin, + /// Get basin configuration. + GetBasinConfig, + /// Issue an access token. + IssueAccessToken, + /// Revoke an access token. + RevokeAccessToken, + /// List access tokens. + ListAccessTokens, + /// List streams. + ListStreams, + /// Create a stream. + CreateStream, + /// Delete a stream. + DeleteStream, + /// Get stream configuration. + GetStreamConfig, + /// Reconfigure a stream. + ReconfigureStream, + /// Check the tail of a stream. + CheckTail, + /// Append records to a stream. + Append, + /// Read records from a stream. + Read, + /// Trim records on a stream. + Trim, + /// Set the fencing token on a stream. + Fence, + /// Retrieve account-level metrics. + AccountMetrics, + /// Retrieve basin-level metrics. + BasinMetrics, + /// Retrieve stream-level metrics. + StreamMetrics, +} + +impl From for types::access::Operation { + fn from(value: Operation) -> Self { + match value { + Operation::ListBasins => Self::ListBasins, + Operation::CreateBasin => Self::CreateBasin, + Operation::DeleteBasin => Self::DeleteBasin, + Operation::ReconfigureBasin => Self::ReconfigureBasin, + Operation::GetBasinConfig => Self::GetBasinConfig, + Operation::IssueAccessToken => Self::IssueAccessToken, + Operation::RevokeAccessToken => Self::RevokeAccessToken, + Operation::ListAccessTokens => Self::ListAccessTokens, + Operation::ListStreams => Self::ListStreams, + Operation::CreateStream => Self::CreateStream, + Operation::DeleteStream => Self::DeleteStream, + Operation::GetStreamConfig => Self::GetStreamConfig, + Operation::ReconfigureStream => Self::ReconfigureStream, + Operation::CheckTail => Self::CheckTail, + Operation::Append => Self::Append, + Operation::Read => Self::Read, + Operation::Trim => Self::Trim, + Operation::Fence => Self::Fence, + Operation::AccountMetrics => Self::AccountMetrics, + Operation::BasinMetrics => Self::BasinMetrics, + Operation::StreamMetrics => Self::StreamMetrics, + } + } +} + +impl From for Operation { + fn from(value: types::access::Operation) -> Self { + use types::access::Operation::*; + match value { + ListBasins => Self::ListBasins, + CreateBasin => Self::CreateBasin, + DeleteBasin => Self::DeleteBasin, + ReconfigureBasin => Self::ReconfigureBasin, + GetBasinConfig => Self::GetBasinConfig, + IssueAccessToken => Self::IssueAccessToken, + RevokeAccessToken => Self::RevokeAccessToken, + ListAccessTokens => Self::ListAccessTokens, + ListStreams => Self::ListStreams, + CreateStream => Self::CreateStream, + DeleteStream => Self::DeleteStream, + GetStreamConfig => Self::GetStreamConfig, + ReconfigureStream => Self::ReconfigureStream, + CheckTail => Self::CheckTail, + Append => Self::Append, + Read => Self::Read, + Trim => Self::Trim, + Fence => Self::Fence, + AccountMetrics => Self::AccountMetrics, + BasinMetrics => Self::BasinMetrics, + StreamMetrics => Self::StreamMetrics, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct AccessTokenInfo { + /// Access token ID. + /// It must be unique to the account and between 1 and 96 bytes in length. + pub id: types::access::AccessTokenId, + /// Expiration time in ISO 8601 format. + /// If not set, the expiration will be set to that of the requestor's token. + #[cfg_attr(feature = "utoipa", schema(format = Time))] + pub expires_at: Option, + /// Namespace streams based on the configured stream-level scope, which must be a prefix. + /// Stream name arguments will be automatically prefixed, and the prefix will be stripped when listing streams. + #[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))] + pub auto_prefix_streams: Option, + /// Access token scope. + pub scope: AccessTokenScope, +} + +impl TryFrom for types::access::IssueAccessTokenRequest { + type Error = types::ValidationError; + + fn try_from(value: AccessTokenInfo) -> Result { + let AccessTokenInfo { + id, + expires_at, + auto_prefix_streams, + scope, + } = value; + + Ok(Self { + id, + expires_at: expires_at + .map(|e| OffsetDateTime::parse(&e, &Iso8601::DEFAULT)) + .transpose() + .map_err(|_| "Invalid ISO-8601 formatted `expires_at` time")?, + auto_prefix_streams: auto_prefix_streams.unwrap_or_default(), + scope: scope.try_into()?, + }) + } +} + +impl From for AccessTokenInfo { + fn from(value: types::access::AccessTokenInfo) -> Self { + let types::access::AccessTokenInfo { + id, + expires_at, + auto_prefix_streams, + scope, + } = value; + + Self { + id, + expires_at: Some( + expires_at + .format(&Iso8601::DEFAULT) + .expect("valid iso8601 time"), + ), + auto_prefix_streams: Some(auto_prefix_streams), + scope: scope.into(), + } + } +} + +impl From for AccessTokenInfo { + fn from(value: types::access::IssueAccessTokenRequest) -> Self { + let types::access::IssueAccessTokenRequest { + id, + expires_at, + auto_prefix_streams, + scope, + } = value; + + Self { + id, + expires_at: expires_at + .map(|e| e.format(&Iso8601::DEFAULT).expect("valid iso8601 time")), + auto_prefix_streams: Some(auto_prefix_streams), + scope: scope.into(), + } + } +} + +impl TryFrom for types::access::AccessTokenInfo { + type Error = types::ValidationError; + + fn try_from(value: AccessTokenInfo) -> Result { + let AccessTokenInfo { + id, + expires_at, + auto_prefix_streams, + scope, + } = value; + + Ok(Self { + id, + expires_at: expires_at.ok_or("Missing `expires_at`").and_then(|e| { + OffsetDateTime::parse(&e, &Iso8601::DEFAULT) + .map_err(|_| "Invalid ISO-8601 formatted `expires_at` time") + })?, + auto_prefix_streams: auto_prefix_streams.unwrap_or_default(), + scope: scope.try_into()?, + }) + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct AccessTokenScope { + /// Basin names allowed. + pub basins: Option>, + /// Stream names allowed. + pub streams: Option>, + /// Token IDs allowed. + pub access_tokens: Option>, + /// Access permissions at operation group level. + pub op_groups: Option, + /// Operations allowed for the token. + /// A union of allowed operations and groups is used as an effective set of allowed operations. + #[cfg_attr(feature = "utoipa", schema(required = false))] + pub ops: Option>, +} + +impl TryFrom for types::access::AccessTokenScope { + type Error = types::ValidationError; + + fn try_from(value: AccessTokenScope) -> Result { + let AccessTokenScope { + basins, + streams, + access_tokens, + op_groups, + ops, + } = value; + + Ok(Self { + basins: basins.map(Into::into).unwrap_or_default(), + streams: streams.map(Into::into).unwrap_or_default(), + access_tokens: access_tokens.map(Into::into).unwrap_or_default(), + op_groups: op_groups.map(Into::into).unwrap_or_default(), + ops: ops + .map(|o| o.into_iter().map(types::access::Operation::from).collect()) + .unwrap_or_default(), + }) + } +} + +impl From for AccessTokenScope { + fn from(value: types::access::AccessTokenScope) -> Self { + let types::access::AccessTokenScope { + basins, + streams, + access_tokens, + op_groups, + ops, + } = value; + + Self { + basins: ResourceSet::to_opt(basins), + streams: ResourceSet::to_opt(streams), + access_tokens: ResourceSet::to_opt(access_tokens), + op_groups: Some(op_groups.into()), + ops: Some(ops.into_iter().map(Operation::from).collect()), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum ResourceSet { + /// Match only the resource with this exact name. + /// Use an empty string to match no resources. + #[cfg_attr(feature = "utoipa", schema(title = "exact", value_type = String))] + Exact(E), + /// Match all resources that start with this prefix. + /// Use an empty string to match all resource. + #[cfg_attr(feature = "utoipa", schema(title = "prefix", value_type = String))] + Prefix(P), +} + +impl ResourceSet { + pub fn to_opt(rs: types::access::ResourceSet) -> Option { + match rs { + types::access::ResourceSet::None => None, + types::access::ResourceSet::Exact(e) => Some(ResourceSet::Exact(e)), + types::access::ResourceSet::Prefix(p) => Some(ResourceSet::Prefix(p)), + } + } +} + +impl From> for types::access::ResourceSet +where + E: AsRef, +{ + fn from(value: ResourceSet) -> Self { + match value { + ResourceSet::Exact(e) if e.as_ref().is_empty() => Self::None, + ResourceSet::Exact(e) => Self::Exact(e), + ResourceSet::Prefix(p) => Self::Prefix(p), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct PermittedOperationGroups { + /// Account-level access permissions. + pub account: Option, + /// Basin-level access permissions. + pub basin: Option, + /// Stream-level access permissions. + pub stream: Option, +} + +impl From for types::access::PermittedOperationGroups { + fn from(value: PermittedOperationGroups) -> Self { + let PermittedOperationGroups { + account, + basin, + stream, + } = value; + + Self { + account: account.map(Into::into).unwrap_or_default(), + basin: basin.map(Into::into).unwrap_or_default(), + stream: stream.map(Into::into).unwrap_or_default(), + } + } +} + +impl From for PermittedOperationGroups { + fn from(value: types::access::PermittedOperationGroups) -> Self { + let types::access::PermittedOperationGroups { + account, + basin, + stream, + } = value; + + Self { + account: Some(account.into()), + basin: Some(basin.into()), + stream: Some(stream.into()), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct ReadWritePermissions { + /// Read permission. + #[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))] + pub read: Option, + /// Write permission. + #[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))] + pub write: Option, +} + +impl From for types::access::ReadWritePermissions { + fn from(value: ReadWritePermissions) -> Self { + let ReadWritePermissions { read, write } = value; + + Self { + read: read.unwrap_or_default(), + write: write.unwrap_or_default(), + } + } +} + +impl From for ReadWritePermissions { + fn from(value: types::access::ReadWritePermissions) -> Self { + let types::access::ReadWritePermissions { read, write } = value; + + Self { + read: Some(read), + write: Some(write), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct ListAccessTokensRequest { + /// Filter to access tokens whose ID begins with this prefix. + #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))] + pub prefix: Option, + /// Filter to access tokens whose ID lexicographically starts after this string. + #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))] + pub start_after: Option, + /// Number of results, up to a maximum of 1000. + #[cfg_attr(feature = "utoipa", param(value_type = usize, maximum = 1000, default = 1000, required = false))] + pub limit: Option, +} + +super::impl_list_request_conversions!( + ListAccessTokensRequest, + types::access::AccessTokenIdPrefix, + types::access::AccessTokenIdStartAfter +); + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct ListAccessTokensResponse { + /// Matching access tokens. + #[cfg_attr(feature = "utoipa", schema(max_items = 1000))] + pub access_tokens: Vec, + /// Indicates that there are more access tokens that match the criteria. + pub has_more: bool, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct IssueAccessTokenResponse { + /// Created access token. + pub access_token: String, +} diff --git a/api/src/v1/basin.rs b/api/src/v1/basin.rs new file mode 100644 index 00000000..485b4551 --- /dev/null +++ b/api/src/v1/basin.rs @@ -0,0 +1,164 @@ +use s2_common::types::{ + self, + basin::{BasinName, BasinNamePrefix, BasinNameStartAfter}, +}; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "utoipa")] +use utoipa::{IntoParams, ToSchema}; + +use super::config::BasinConfig; + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct ListBasinsRequest { + /// Filter to basins whose names begin with this prefix. + #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))] + pub prefix: Option, + /// Filter to basins whose names lexicographically start after this string. + /// It must be greater than or equal to the `prefix` if specified. + #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))] + pub start_after: Option, + /// Number of results, up to a maximum of 1000. + #[cfg_attr(feature = "utoipa", param(value_type = usize, maximum = 1000, default = 1000, required = false))] + pub limit: Option, +} + +super::impl_list_request_conversions!( + ListBasinsRequest, + types::basin::BasinNamePrefix, + types::basin::BasinNameStartAfter +); + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct ListBasinsResponse { + /// Matching basins. + #[cfg_attr(feature = "utoipa", schema(max_items = 1000))] + pub basins: Vec, + /// Indicates that there are more basins that match the criteria. + pub has_more: bool, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct BasinInfo { + /// Basin name. + pub name: BasinName, + /// Basin scope. + pub scope: BasinScope, + /// Basin state. + pub state: BasinState, +} + +impl From for BasinInfo { + fn from(value: types::basin::BasinInfo) -> Self { + let types::basin::BasinInfo { name, scope, state } = value; + + Self { + name, + scope: scope.into(), + state: state.into(), + } + } +} + +impl From for types::basin::BasinInfo { + fn from(value: BasinInfo) -> Self { + let BasinInfo { name, scope, state } = value; + + Self { + name, + scope: scope.into(), + state: state.into(), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub enum BasinScope { + /// AWS `us-east-1` region. + #[serde(rename = "aws:us-east-1")] + AwsUsEast1, +} + +impl From for types::basin::BasinScope { + fn from(value: BasinScope) -> Self { + match value { + BasinScope::AwsUsEast1 => Self::AwsUsEast1, + } + } +} + +impl From for BasinScope { + fn from(value: types::basin::BasinScope) -> Self { + match value { + types::basin::BasinScope::AwsUsEast1 => Self::AwsUsEast1, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum BasinState { + /// Basin is active. + Active, + /// Basin is being created. + Creating, + /// Basin is being deleted. + Deleting, +} + +impl From for BasinState { + fn from(value: types::basin::BasinState) -> Self { + match value { + types::basin::BasinState::Active => Self::Active, + types::basin::BasinState::Creating => Self::Creating, + types::basin::BasinState::Deleting => Self::Deleting, + } + } +} + +impl From for types::basin::BasinState { + fn from(value: BasinState) -> Self { + match value { + BasinState::Active => Self::Active, + BasinState::Creating => Self::Creating, + BasinState::Deleting => Self::Deleting, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct CreateOrReconfigureBasinRequest { + /// Basin configuration. + pub config: Option, + /// Basin scope. + /// This cannot be reconfigured. + #[cfg_attr(feature = "utoipa", schema(value_type = BasinScope, default = "aws:us-east-1", required = false))] + pub scope: Option, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct CreateBasinRequest { + /// Basin name which must be globally unique. + /// It can be between 8 and 48 characters in length, and comprise lowercase letters, numbers and hyphens. + /// It cannot begin or end with a hyphen. + pub basin: BasinName, + /// Basin configuration. + pub config: Option, + /// Basin scope. + #[cfg_attr(feature = "utoipa", schema(value_type = BasinScope, default = "aws:us-east-1", required = false))] + pub scope: Option, +} diff --git a/api/src/v1/config.rs b/api/src/v1/config.rs new file mode 100644 index 00000000..e698f434 --- /dev/null +++ b/api/src/v1/config.rs @@ -0,0 +1,504 @@ +use std::time::Duration; + +use s2_common::{maybe::Maybe, types}; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "utoipa")] +use utoipa::ToSchema; + +#[rustfmt::skip] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum StorageClass { + /// Append tail latency under 500 ms. + Standard, + /// Append tail latency under 50 ms. + Express, +} + +impl From for types::config::StorageClass { + fn from(value: StorageClass) -> Self { + match value { + StorageClass::Express => Self::Express, + StorageClass::Standard => Self::Standard, + } + } +} + +impl From for StorageClass { + fn from(value: types::config::StorageClass) -> Self { + match value { + types::config::StorageClass::Express => Self::Express, + types::config::StorageClass::Standard => Self::Standard, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum RetentionPolicy { + /// Age in seconds for automatic trimming of records older than this threshold. + /// This must be set to a value greater than 0 seconds. + Age(u64), + /// Retain records unless explicitly trimmed. + Infinite(InfiniteRetention) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "kebab-case")] +pub struct InfiniteRetention {} + +impl TryFrom for types::config::RetentionPolicy { + type Error = types::ValidationError; + + fn try_from(value: RetentionPolicy) -> Result { + match value { + RetentionPolicy::Age(0) => Err(types::ValidationError( + "Age must be greater than 0 seconds".to_string(), + )), + RetentionPolicy::Age(age) => Ok(Self::Age(Duration::from_secs(age))), + RetentionPolicy::Infinite(_) => Ok(Self::Infinite()), + } + } +} + +impl From for RetentionPolicy { + fn from(value: types::config::RetentionPolicy) -> Self { + match value { + types::config::RetentionPolicy::Age(age) => Self::Age(age.as_secs()), + types::config::RetentionPolicy::Infinite() => Self::Infinite(InfiniteRetention {}), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum TimestampingMode { + /// Prefer client-specified timestamp if present otherwise use arrival time. + #[default] + ClientPrefer, + /// Require a client-specified timestamp and reject the append if it is missing. + ClientRequire, + /// Use the arrival time and ignore any client-specified timestamp. + Arrival, +} + +impl From for types::config::TimestampingMode { + fn from(value: TimestampingMode) -> Self { + match value { + TimestampingMode::ClientPrefer => Self::ClientPrefer, + TimestampingMode::ClientRequire => Self::ClientRequire, + TimestampingMode::Arrival => Self::Arrival, + } + } +} + +impl From for TimestampingMode { + fn from(value: types::config::TimestampingMode) -> Self { + match value { + types::config::TimestampingMode::ClientPrefer => Self::ClientPrefer, + types::config::TimestampingMode::ClientRequire => Self::ClientRequire, + types::config::TimestampingMode::Arrival => Self::Arrival, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct TimestampingConfig { + /// Timestamping mode for appends that influences how timestamps are handled. + pub mode: Option, + /// Allow client-specified timestamps to exceed the arrival time. + /// If this is `false` or not set, client timestamps will be capped at the arrival time. + pub uncapped: Option, +} + +impl TimestampingConfig { + pub fn to_opt(config: types::config::OptionalTimestampingConfig) -> Option { + let config = TimestampingConfig { + mode: config.mode.map(Into::into), + uncapped: config.uncapped, + }; + if config == Self::default() { + None + } else { + Some(config) + } + } +} + +impl From for TimestampingConfig { + fn from(value: types::config::TimestampingConfig) -> Self { + Self { + mode: Some(value.mode.into()), + uncapped: Some(value.uncapped), + } + } +} + +impl From for types::config::OptionalTimestampingConfig { + fn from(value: TimestampingConfig) -> Self { + Self { + mode: value.mode.map(Into::into), + uncapped: value.uncapped, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct TimestampingReconfiguration { + /// Timestamping mode for appends that influences how timestamps are handled. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub mode: Maybe>, + /// Allow client-specified timestamps to exceed the arrival time. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub uncapped: Maybe>, +} + +impl From for types::config::TimestampingReconfiguration { + fn from(value: TimestampingReconfiguration) -> Self { + Self { + mode: value.mode.map_opt(Into::into), + uncapped: value.uncapped, + } + } +} + +impl From for TimestampingReconfiguration { + fn from(value: types::config::TimestampingReconfiguration) -> Self { + Self { + mode: value.mode.map_opt(Into::into), + uncapped: value.uncapped, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct DeleteOnEmptyConfig { + /// Minimum age in seconds before an empty stream can be deleted. + /// Set to 0 (default) to disable delete-on-empty (don't delete automatically). + #[serde(default)] + pub min_age_secs: u64, +} + +impl DeleteOnEmptyConfig { + pub fn to_opt(config: types::config::OptionalDeleteOnEmptyConfig) -> Option { + let min_age = config.min_age.unwrap_or_default(); + if min_age > Duration::ZERO { + Some(DeleteOnEmptyConfig { + min_age_secs: min_age.as_secs(), + }) + } else { + None + } + } +} + +impl From for DeleteOnEmptyConfig { + fn from(value: types::config::DeleteOnEmptyConfig) -> Self { + Self { + min_age_secs: value.min_age.as_secs(), + } + } +} + +impl From for types::config::DeleteOnEmptyConfig { + fn from(value: DeleteOnEmptyConfig) -> Self { + Self { + min_age: Duration::from_secs(value.min_age_secs), + } + } +} + +impl From for types::config::OptionalDeleteOnEmptyConfig { + fn from(value: DeleteOnEmptyConfig) -> Self { + Self { + min_age: Some(Duration::from_secs(value.min_age_secs)), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct DeleteOnEmptyReconfiguration { + /// Minimum age in seconds before an empty stream can be deleted. + /// Set to 0 to disable delete-on-empty (don't delete automatically). + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub min_age_secs: Maybe>, +} + +impl From for types::config::DeleteOnEmptyReconfiguration { + fn from(value: DeleteOnEmptyReconfiguration) -> Self { + Self { + min_age: value.min_age_secs.map_opt(Duration::from_secs), + } + } +} + +impl From for DeleteOnEmptyReconfiguration { + fn from(value: types::config::DeleteOnEmptyReconfiguration) -> Self { + Self { + min_age_secs: value.min_age.map_opt(|d| d.as_secs()), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct StreamConfig { + /// Storage class for recent writes. + pub storage_class: Option, + /// Retention policy for the stream. + /// If unspecified, the default is to retain records for 7 days. + pub retention_policy: Option, + /// Timestamping behavior. + pub timestamping: Option, + /// Delete-on-empty configuration. + #[serde(default)] + pub delete_on_empty: Option, +} + +impl StreamConfig { + pub fn to_opt(config: types::config::OptionalStreamConfig) -> Option { + let types::config::OptionalStreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = config; + + let config = StreamConfig { + storage_class: storage_class.map(Into::into), + retention_policy: retention_policy.map(Into::into), + timestamping: TimestampingConfig::to_opt(timestamping), + delete_on_empty: DeleteOnEmptyConfig::to_opt(delete_on_empty), + }; + if config == Self::default() { + None + } else { + Some(config) + } + } +} + +impl From for StreamConfig { + fn from(value: types::config::StreamConfig) -> Self { + let types::config::StreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = value; + + Self { + storage_class: Some(storage_class.into()), + retention_policy: Some(retention_policy.into()), + timestamping: Some(timestamping.into()), + delete_on_empty: Some(delete_on_empty.into()), + } + } +} + +impl TryFrom for types::config::OptionalStreamConfig { + type Error = types::ValidationError; + + fn try_from(value: StreamConfig) -> Result { + let StreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = value; + + let retention_policy = match retention_policy { + None => None, + Some(policy) => Some(policy.try_into()?), + }; + + Ok(Self { + storage_class: storage_class.map(Into::into), + retention_policy, + timestamping: timestamping.unwrap_or_default().into(), + delete_on_empty: delete_on_empty.unwrap_or_default().into(), + }) + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct StreamReconfiguration { + /// Storage class for recent writes. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub storage_class: Maybe>, + /// Retention policy for the stream. + /// If unspecified, the default is to retain records for 7 days. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub retention_policy: Maybe>, + /// Timestamping behavior. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub timestamping: Maybe>, + /// Delete-on-empty configuration. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub delete_on_empty: Maybe>, +} + +impl TryFrom for types::config::StreamReconfiguration { + type Error = types::ValidationError; + + fn try_from(value: StreamReconfiguration) -> Result { + let StreamReconfiguration { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = value; + + Ok(Self { + storage_class: storage_class.map_opt(Into::into), + retention_policy: retention_policy.try_map_opt(TryInto::try_into)?, + timestamping: timestamping.map_opt(Into::into), + delete_on_empty: delete_on_empty.map_opt(Into::into), + }) + } +} + +impl From for StreamReconfiguration { + fn from(value: types::config::StreamReconfiguration) -> Self { + let types::config::StreamReconfiguration { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = value; + + Self { + storage_class: storage_class.map_opt(Into::into), + retention_policy: retention_policy.map_opt(Into::into), + timestamping: timestamping.map_opt(Into::into), + delete_on_empty: delete_on_empty.map_opt(Into::into), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct BasinConfig { + /// Default stream configuration. + pub default_stream_config: Option, + /// Create stream on append if it doesn't exist, using the default stream configuration. + #[serde(default)] + pub create_stream_on_append: bool, + /// Create stream on read if it doesn't exist, using the default stream configuration. + #[serde(default)] + pub create_stream_on_read: bool, +} + +impl TryFrom for types::config::BasinConfig { + type Error = types::ValidationError; + + fn try_from(value: BasinConfig) -> Result { + let BasinConfig { + default_stream_config, + create_stream_on_append, + create_stream_on_read, + } = value; + + Ok(Self { + default_stream_config: match default_stream_config { + Some(config) => config.try_into()?, + None => Default::default(), + }, + create_stream_on_append, + create_stream_on_read, + }) + } +} + +impl From for BasinConfig { + fn from(value: types::config::BasinConfig) -> Self { + let types::config::BasinConfig { + default_stream_config, + create_stream_on_append, + create_stream_on_read, + } = value; + + Self { + default_stream_config: StreamConfig::to_opt(default_stream_config), + create_stream_on_append, + create_stream_on_read, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct BasinReconfiguration { + /// Basin configuration. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub default_stream_config: Maybe>, + /// Create a stream on append. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub create_stream_on_append: Maybe, + /// Create a stream on read. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub create_stream_on_read: Maybe, +} + +impl TryFrom for types::config::BasinReconfiguration { + type Error = types::ValidationError; + + fn try_from(value: BasinReconfiguration) -> Result { + let BasinReconfiguration { + default_stream_config, + create_stream_on_append, + create_stream_on_read, + } = value; + + Ok(Self { + default_stream_config: default_stream_config.try_map_opt(TryInto::try_into)?, + create_stream_on_append: create_stream_on_append.map(Into::into), + create_stream_on_read: create_stream_on_read.map(Into::into), + }) + } +} + +impl From for BasinReconfiguration { + fn from(value: types::config::BasinReconfiguration) -> Self { + let types::config::BasinReconfiguration { + default_stream_config, + create_stream_on_append, + create_stream_on_read, + } = value; + + Self { + default_stream_config: default_stream_config.map_opt(Into::into), + create_stream_on_append: create_stream_on_append.map(Into::into), + create_stream_on_read: create_stream_on_read.map(Into::into), + } + } +} diff --git a/api/src/v1/metrics.rs b/api/src/v1/metrics.rs new file mode 100644 index 00000000..f0305666 --- /dev/null +++ b/api/src/v1/metrics.rs @@ -0,0 +1,442 @@ +use compact_str::CompactString; +use s2_common::types; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "utoipa")] +use utoipa::{IntoParams, ToSchema}; + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum TimeseriesInterval { + Minute, + Hour, + Day, +} + +impl From for types::metrics::TimeseriesInterval { + fn from(value: TimeseriesInterval) -> Self { + match value { + TimeseriesInterval::Minute => types::metrics::TimeseriesInterval::Minute, + TimeseriesInterval::Hour => types::metrics::TimeseriesInterval::Hour, + TimeseriesInterval::Day => types::metrics::TimeseriesInterval::Day, + } + } +} + +impl From for TimeseriesInterval { + fn from(value: types::metrics::TimeseriesInterval) -> Self { + match value { + types::metrics::TimeseriesInterval::Minute => Self::Minute, + types::metrics::TimeseriesInterval::Hour => Self::Hour, + types::metrics::TimeseriesInterval::Day => Self::Day, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema, IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct AccountMetricSetRequest { + /// Metric set to return. + pub set: AccountMetricSet, + /// Start timestamp as Unix epoch seconds, if applicable for the metric set. + pub start: Option, + /// End timestamp as Unix epoch seconds, if applicable for the metric set. + pub end: Option, + /// Interval to aggregate over for timeseries metric sets. + pub interval: Option, +} + +impl From for types::metrics::AccountMetricsRequest { + fn from(value: AccountMetricSetRequest) -> Self { + Self { + set: match value.set { + AccountMetricSet::ActiveBasins => types::metrics::AccountMetricSet::ActiveBasins, + AccountMetricSet::AccountOps => types::metrics::AccountMetricSet::AccountOps, + }, + start: value.start, + end: value.end, + interval: value.interval.map(Into::into), + } + } +} + +impl From for AccountMetricSetRequest { + fn from(value: types::metrics::AccountMetricsRequest) -> Self { + Self { + set: match value.set { + types::metrics::AccountMetricSet::ActiveBasins => AccountMetricSet::ActiveBasins, + types::metrics::AccountMetricSet::AccountOps => AccountMetricSet::AccountOps, + }, + start: value.start, + end: value.end, + interval: value.interval.map(Into::into), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum AccountMetricSet { + /// Set of all basins that had at least one stream during the specified period. + ActiveBasins, + /// Count of append RPC operations, per interval. + AccountOps, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema, IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct BasinMetricSetRequest { + /// Metric set to return. + pub set: BasinMetricSet, + /// Start timestamp as Unix epoch seconds, if applicable for the metric set. + pub start: Option, + /// End timestamp as Unix epoch seconds, if applicable for the metric set. + pub end: Option, + /// Interval to aggregate over for timeseries metric sets. + pub interval: Option, +} + +impl From for types::metrics::BasinMetricsRequest { + fn from(value: BasinMetricSetRequest) -> Self { + Self { + set: match value.set { + BasinMetricSet::AppendOps => types::metrics::BasinMetricSet::AppendOps, + BasinMetricSet::AppendThroughput => { + types::metrics::BasinMetricSet::AppendThroughput + } + BasinMetricSet::BasinOps => types::metrics::BasinMetricSet::BasinOps, + BasinMetricSet::ReadOps => types::metrics::BasinMetricSet::ReadOps, + BasinMetricSet::ReadThroughput => types::metrics::BasinMetricSet::ReadThroughput, + BasinMetricSet::Storage => types::metrics::BasinMetricSet::Storage, + }, + start: value.start, + end: value.end, + interval: value.interval.map(Into::into), + } + } +} + +impl From for BasinMetricSetRequest { + fn from(value: types::metrics::BasinMetricsRequest) -> Self { + Self { + set: match value.set { + types::metrics::BasinMetricSet::AppendOps => BasinMetricSet::AppendOps, + types::metrics::BasinMetricSet::AppendThroughput => { + BasinMetricSet::AppendThroughput + } + types::metrics::BasinMetricSet::BasinOps => BasinMetricSet::BasinOps, + types::metrics::BasinMetricSet::ReadOps => BasinMetricSet::ReadOps, + types::metrics::BasinMetricSet::ReadThroughput => BasinMetricSet::ReadThroughput, + types::metrics::BasinMetricSet::Storage => BasinMetricSet::Storage, + }, + start: value.start, + end: value.end, + interval: value.interval.map(Into::into), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum BasinMetricSet { + /// Amount of stored data, per hour, aggregated over all streams in a basin. + Storage, + /// Append operations, per interval. + AppendOps, + /// Read operations, per interval. + ReadOps, + /// Read bytes, per interval. + ReadThroughput, + /// Appended bytes, per interval. + AppendThroughput, + /// Count of basin RPC operations, per interval. + BasinOps, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema, IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct StreamMetricSetRequest { + /// Metric set to return. + pub set: StreamMetricSet, + /// Start timestamp as Unix epoch seconds, if applicable for the metric set. + pub start: Option, + /// End timestamp as Unix epoch seconds, if applicable for metric set. + pub end: Option, + /// Interval to aggregate over for timeseries metric sets. + pub interval: Option, +} + +impl From for types::metrics::StreamMetricsRequest { + fn from(value: StreamMetricSetRequest) -> Self { + Self { + set: match value.set { + StreamMetricSet::Storage => types::metrics::StreamMetricSet::Storage, + }, + start: value.start, + end: value.end, + interval: value.interval.map(Into::into), + } + } +} + +impl From for StreamMetricSetRequest { + fn from(value: types::metrics::StreamMetricsRequest) -> Self { + Self { + set: match value.set { + types::metrics::StreamMetricSet::Storage => StreamMetricSet::Storage, + }, + start: value.start, + end: value.end, + interval: value.interval.map(Into::into), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum StreamMetricSet { + /// Amount of stored data, per minute, for a specific stream. + Storage, +} + +#[rustfmt::skip] +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum MetricUnit { + Bytes, + Operations, +} + +impl From for MetricUnit { + fn from(value: types::metrics::MetricUnit) -> Self { + match value { + types::metrics::MetricUnit::Bytes => MetricUnit::Bytes, + types::metrics::MetricUnit::Operations => MetricUnit::Operations, + } + } +} + +impl From for types::metrics::MetricUnit { + fn from(value: MetricUnit) -> Self { + match value { + MetricUnit::Bytes => types::metrics::MetricUnit::Bytes, + MetricUnit::Operations => types::metrics::MetricUnit::Operations, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct ScalarMetric { + /// Metric name. + #[cfg_attr(feature = "utoipa", schema(value_type = String))] + pub name: CompactString, + /// Unit of the metric. + pub unit: MetricUnit, + /// Metric value. + pub value: f64, +} + +impl From for ScalarMetric { + fn from(value: types::metrics::ScalarMetric) -> Self { + Self { + name: value.name, + unit: value.unit.into(), + value: value.value, + } + } +} + +impl From for types::metrics::ScalarMetric { + fn from(value: ScalarMetric) -> Self { + Self { + name: value.name, + unit: value.unit.into(), + value: value.value, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct AccumulationMetric { + /// Timeseries name. + #[cfg_attr(feature = "utoipa", schema(value_type = String))] + pub name: CompactString, + /// Unit of the metric. + pub unit: MetricUnit, + /// The duration of bucket for the accumulation. + pub bucket_length: TimeseriesInterval, + /// Timeseries values. + /// Each element is a tuple of a timestamp in Unix epoch seconds and a data point. + /// The data point represents the accumulated value for a bucket of time starting at the provided timestamp, lasting for the duration of the `bucket_length` parameter. + pub values: Vec<(u32, f64)>, +} + +impl From for AccumulationMetric { + fn from(value: types::metrics::AccumulationMetric) -> Self { + Self { + name: value.name, + unit: value.unit.into(), + bucket_length: value.bucket_length.into(), + values: value.values, + } + } +} + +impl From for types::metrics::AccumulationMetric { + fn from(value: AccumulationMetric) -> Self { + Self { + name: value.name, + unit: value.unit.into(), + bucket_length: value.bucket_length.into(), + values: value.values, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct GaugeMetric { + /// Timeseries name. + #[cfg_attr(feature = "utoipa", schema(value_type = String))] + pub name: CompactString, + /// Unit of the metric. + pub unit: MetricUnit, + /// Timeseries values. + /// Each element is a tuple of a timestamp in Unix epoch seconds and a data point. + /// The data point represents the value at the instant of the timestamp. + pub values: Vec<(u32, f64)>, +} + +impl From for GaugeMetric { + fn from(value: types::metrics::GaugeMetric) -> Self { + Self { + name: value.name, + unit: value.unit.into(), + values: value.values, + } + } +} + +impl From for types::metrics::GaugeMetric { + fn from(value: GaugeMetric) -> Self { + Self { + name: value.name, + unit: value.unit.into(), + values: value.values, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct LabelMetric { + /// Label name. + #[cfg_attr(feature = "utoipa", schema(value_type = String))] + pub name: CompactString, + /// Label values. + pub values: Vec, +} + +impl From for LabelMetric { + fn from(value: types::metrics::LabelMetric) -> Self { + Self { + name: value.name, + values: value.values, + } + } +} + +impl From for types::metrics::LabelMetric { + fn from(value: LabelMetric) -> Self { + Self { + name: value.name, + values: value.values, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum Metric { + /// Single named value. + Scalar(ScalarMetric), + /// Named series of `(timestamp, value)` points representing an accumulation over a specified + /// bucket. + Accumulation(AccumulationMetric), + /// Named series of `(timestamp, value)` points each representing an instantaneous value. + Gauge(GaugeMetric), + /// Set of string labels. + Label(LabelMetric), +} + +impl From for Metric { + fn from(value: types::metrics::Metric) -> Self { + match value { + types::metrics::Metric::Scalar(scalar) => Metric::Scalar(scalar.into()), + types::metrics::Metric::Accumulation(timeseries) => { + Metric::Accumulation(timeseries.into()) + } + types::metrics::Metric::Gauge(timeseries) => Metric::Gauge(timeseries.into()), + types::metrics::Metric::Label(label) => Metric::Label(label.into()), + } + } +} + +impl From for types::metrics::Metric { + fn from(value: Metric) -> Self { + match value { + Metric::Scalar(scalar) => types::metrics::Metric::Scalar(scalar.into()), + Metric::Accumulation(timeseries) => { + types::metrics::Metric::Accumulation(timeseries.into()) + } + Metric::Gauge(timeseries) => types::metrics::Metric::Gauge(timeseries.into()), + Metric::Label(label) => types::metrics::Metric::Label(label.into()), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct MetricSetResponse { + /// Metrics comprising the set. + pub values: Vec, +} + +impl From for MetricSetResponse { + fn from(value: types::metrics::MetricsResponse) -> Self { + Self { + values: value.values.into_iter().map(Into::into).collect(), + } + } +} + +impl From for types::metrics::MetricsResponse { + fn from(value: MetricSetResponse) -> Self { + Self { + values: value.values.into_iter().map(Into::into).collect(), + } + } +} diff --git a/api/src/v1/mod.rs b/api/src/v1/mod.rs new file mode 100644 index 00000000..6b316186 --- /dev/null +++ b/api/src/v1/mod.rs @@ -0,0 +1,90 @@ +pub mod access; +pub mod basin; +pub mod config; +pub mod metrics; +pub mod stream; + +use s2_common::types; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "utoipa")] +use utoipa::{IntoParams, ToSchema}; + +#[rustfmt::skip] +#[derive(Debug)] +#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))] +pub struct S2RequestTokenHeader { + /// Client-specified request token for idempotent retries. + #[cfg_attr(feature = "utoipa", param(required = false, rename = "s2-request-token"))] + pub s2_request_token: String, +} + +#[rustfmt::skip] +#[derive(Debug)] +#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Path))] +pub struct AccessTokenIdPathSegment { + /// Access token ID. + pub id: types::access::AccessTokenId, +} + +#[rustfmt::skip] +#[derive(Debug)] +#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Path))] +pub struct BasinNamePathSegment { + /// Basin name. + pub basin: types::basin::BasinName, +} + +#[rustfmt::skip] +#[derive(Debug)] +#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Path))] +pub struct StreamNamePathSegment { + /// Stream name. + pub stream: types::stream::StreamName, +} + +macro_rules! impl_list_request_conversions { + ($name:ident, $prefix:ty, $start_after:ty) => { + impl TryFrom<$name> for types::resources::ListItemsRequest<$prefix, $start_after> { + type Error = types::ValidationError; + + fn try_from(value: $name) -> Result { + let $name { + prefix, + start_after, + limit, + } = value; + + Ok(Self::try_from(types::resources::ListItemsRequestParts { + prefix: prefix.unwrap_or_default(), + start_after: start_after.unwrap_or_default(), + limit: limit.map(Into::into).unwrap_or_default(), + })?) + } + } + + impl From> for $name { + fn from(value: types::resources::ListItemsRequest<$prefix, $start_after>) -> Self { + let parts: types::resources::ListItemsRequestParts<$prefix, $start_after> = + value.into(); + Self { + prefix: Some(parts.prefix), + start_after: Some(parts.start_after), + limit: Some(parts.limit.into()), + } + } + } + }; +} + +pub(crate) use impl_list_request_conversions; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct ErrorInfo { + pub code: &'static str, + pub message: String, +} diff --git a/api/src/v1/stream/mod.rs b/api/src/v1/stream/mod.rs new file mode 100644 index 00000000..f03a4194 --- /dev/null +++ b/api/src/v1/stream/mod.rs @@ -0,0 +1,553 @@ +pub mod proto; +pub mod s2s; +pub mod sse; + +use std::time::Duration; + +use base64ct::{Base64, Encoding as _}; +use bytes::Bytes; +use itertools::Itertools as _; +use s2_common::{ + record, + types::{ + self, + stream::{StreamName, StreamNamePrefix, StreamNameStartAfter}, + }, +}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +#[cfg(feature = "utoipa")] +use utoipa::{IntoParams, ToSchema}; + +use super::config::StreamConfig; + +pub const S2_FORMAT_HEADER: http::HeaderName = http::HeaderName::from_static("s2-format"); + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct StreamInfo { + /// Stream name. + pub name: StreamName, + /// Creation time in ISO 8601 format. + #[cfg_attr(feature = "utoipa", schema(format = Time))] + pub created_at: String, + /// Deletion time in ISO 8601 format, if the stream is being deleted. + #[cfg_attr(feature = "utoipa", schema(format = Time))] + pub deleted_at: Option, +} + +impl From for StreamInfo { + fn from(value: types::stream::StreamInfo) -> Self { + use time::format_description::well_known::Iso8601; + + let types::stream::StreamInfo { + name, + created_at, + deleted_at, + } = value; + + Self { + name, + created_at: created_at + .format(&Iso8601::DEFAULT) + .expect("valid iso8601 time"), + deleted_at: deleted_at + .map(|d| d.format(&Iso8601::DEFAULT).expect("valid iso8601 time")), + } + } +} + +impl From for types::stream::StreamInfo { + fn from(value: StreamInfo) -> Self { + use time::format_description::well_known::Iso8601; + + let StreamInfo { + name, + created_at, + deleted_at, + } = value; + + Self { + name, + created_at: OffsetDateTime::parse(&created_at, &Iso8601::DEFAULT) + .expect("valid iso8601 time"), + deleted_at: deleted_at + .map(|d| OffsetDateTime::parse(&d, &Iso8601::DEFAULT).expect("valid iso8601 time")), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct ListStreamsRequest { + /// Filter to streams whose name begins with this prefix. + #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))] + pub prefix: Option, + /// Filter to streams whose name begins with this prefix. + /// It must be greater than or equal to the `prefix` if specified. + #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))] + pub start_after: Option, + /// Number of results, up to a maximum of 1000. + #[cfg_attr(feature = "utoipa", param(value_type = usize, maximum = 1000, default = 1000, required = false))] + pub limit: Option, +} + +super::impl_list_request_conversions!( + ListStreamsRequest, + types::stream::StreamNamePrefix, + types::stream::StreamNameStartAfter +); + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct ListStreamsResponse { + /// Matching streams. + #[cfg_attr(feature = "utoipa", schema(max_items = 1000))] + pub streams: Vec, + /// Indicates that there are more results that match the criteria. + pub has_more: bool, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct CreateStreamRequest { + /// Stream name that is unique to the basin. + /// It can be between 1 and 512 bytes in length. + pub stream: StreamName, + /// Stream configuration. + pub config: Option, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +/// Position of a record in a stream. +pub struct StreamPosition { + /// Sequence number assigned by the service. + pub seq_num: record::SeqNum, + /// Timestamp, which may be client-specified or assigned by the service. + /// If it is assigned by the service, it will represent milliseconds since Unix epoch. + pub timestamp: record::Timestamp, +} + +impl From for StreamPosition { + fn from(pos: types::stream::StreamPosition) -> Self { + Self { + seq_num: pos.seq_num, + timestamp: pos.timestamp, + } + } +} + +impl From for types::stream::StreamPosition { + fn from(pos: StreamPosition) -> Self { + Self { + seq_num: pos.seq_num, + timestamp: pos.timestamp, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct TailResponse { + /// Sequence number that will be assigned to the next record on the stream, and timestamp of the last record. + pub tail: StreamPosition, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct ReadStart { + /// Start from a sequence number. + #[cfg_attr(feature = "utoipa", param(value_type = record::SeqNum, required = false))] + pub seq_num: Option, + /// Start from a timestamp. + #[cfg_attr(feature = "utoipa", param(value_type = record::Timestamp, required = false))] + pub timestamp: Option, + /// Start from number of records before the next sequence number. + #[cfg_attr(feature = "utoipa", param(value_type = u64, required = false))] + pub tail_offset: Option, + /// Start reading from the tail if the requested position is beyond it. + /// Otherwise, a `416 Range Not Satisfiable` response is returned. + #[cfg_attr(feature = "utoipa", param(value_type = bool, required = false))] + pub clamp: Option, +} + +impl TryFrom for types::stream::ReadStart { + type Error = types::ValidationError; + + fn try_from(value: ReadStart) -> Result { + let from = match (value.seq_num, value.timestamp, value.tail_offset) { + (Some(seq_num), None, None) => types::stream::ReadFrom::SeqNum(seq_num), + (None, Some(timestamp), None) => types::stream::ReadFrom::Timestamp(timestamp), + (None, None, Some(tail_offset)) => types::stream::ReadFrom::TailOffset(tail_offset), + (None, None, None) => types::stream::ReadFrom::TailOffset(0), + _ => { + return Err(types::ValidationError( + "only one of seq_num, timestamp, or tail_offset can be provided".to_owned(), + )); + } + }; + let clamp = value.clamp.unwrap_or(false); + Ok(Self { from, clamp }) + } +} + +impl From for ReadStart { + fn from(types::stream::ReadStart { from, clamp }: types::stream::ReadStart) -> Self { + let (seq_num, timestamp, tail_offset) = match from { + types::stream::ReadFrom::SeqNum(seq_num) => (Some(seq_num), None, None), + types::stream::ReadFrom::Timestamp(timestamp) => (None, Some(timestamp), None), + types::stream::ReadFrom::TailOffset(tail_offset) => (None, None, Some(tail_offset)), + }; + Self { + seq_num, + timestamp, + tail_offset, + clamp: Some(clamp), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct ReadEnd { + /// Record count limit. + /// Non-streaming reads are capped by the default limit of 1000 records. + #[cfg_attr(feature = "utoipa", param(value_type = u64, required = false))] + pub count: Option, + /// Metered bytes limit. + /// Non-streaming reads are capped by the default limit of 1 MiB. + #[cfg_attr(feature = "utoipa", param(value_type = usize, required = false))] + pub bytes: Option, + /// Exclusive timestamp to read until. + #[cfg_attr(feature = "utoipa", param(value_type = record::Timestamp, required = false))] + pub until: Option, + /// Duration in seconds to wait for new records. + /// The default duration is 0 if there is a bound on `count`, `bytes`, or `until`, and otherwise infinite. + /// Non-streaming reads are always bounded on `count` and `bytes`, so you can achieve long poll semantics by specifying a non-zero duration up to 60 seconds. + /// In the context of an SSE or S2S streaming read, the duration will bound how much time can elapse between records throughout the lifetime of the session. + #[cfg_attr(feature = "utoipa", param(value_type = u32, required = false))] + pub wait: Option, +} + +impl From for types::stream::ReadEnd { + fn from(value: ReadEnd) -> Self { + Self { + limit: s2_common::read_extent::ReadLimit::from_count_and_bytes( + value.count, + value.bytes, + ), + until: value.until.into(), + wait: value.wait.map(|w| Duration::from_secs(w as u64)), + } + } +} + +impl From for ReadEnd { + fn from(value: types::stream::ReadEnd) -> Self { + Self { + count: value.limit.count(), + bytes: value.limit.bytes(), + until: value.until.into(), + wait: value.wait.map(|w| w.as_secs() as u32), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum ReadResponseKind { + /// Unary response + Unary, + /// Server-sent events streaming response + EventStream, + /// S2S streaming response + S2sStream, +} + +impl ReadResponseKind { + /// Determines the response kind based on the `Content-Type` or `Accept` headers. + pub fn from_headers(headers: &http::HeaderMap) -> Self { + headers + .get(http::header::CONTENT_TYPE) + .and_then(|ct| ct.to_str().ok()) + .and_then(|ct| { + if ct == "s2s/proto" { + return Some(Self::S2sStream); + } + None + }) + .or_else(|| { + headers + .get(http::header::ACCEPT) + .and_then(|accept| accept.to_str().ok()) + .map(|accept| { + if accept == "text/event-stream" { + Self::EventStream + } else { + Self::Unary + } + }) + }) + .unwrap_or(Self::Unary) + } +} + +#[derive(Debug, Clone, Copy)] +pub enum AppendResponseKind { + /// Unary response + Unary, + /// S2S streaming response + S2sStream, +} + +impl AppendResponseKind { + pub fn from_content_type(headers: &http::HeaderMap) -> Self { + headers + .get(http::header::CONTENT_TYPE) + .and_then(|ct| ct.to_str().ok()) + .map(|ct| { + if ct == "s2s/proto" { + Self::S2sStream + } else { + Self::Unary + } + }) + .unwrap_or(Self::Unary) + } +} + +#[rustfmt::skip] +#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))] +pub struct FormatHeader { + /// Defines the interpretation of record data (header name, header value, and body) with the JSON content type. + /// Use `raw` (default) for efficient transmission and storage of Unicode data — storage will be in UTF-8. + /// Use `base64` for safe transmission with efficient storage of binary data. + #[cfg_attr(feature = "utoipa", param(required = false, rename = "s2-format"))] + pub s2_format: S2Format, +} + +#[rustfmt::skip] +#[derive(Default, Clone, Copy)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub enum S2Format { + #[default] + #[cfg_attr(feature = "utoipa", schema(rename = "raw"))] + Raw, + #[cfg_attr(feature = "utoipa", schema(rename = "base64"))] + Base64, +} + +impl S2Format { + /// Returns `None` in case of more than 1 header or an invalid header value. + pub fn from_headers(headers: &http::HeaderMap) -> Option { + let mut formats = headers.get_all(S2_FORMAT_HEADER).iter(); + let Some(format) = formats.next() else { + return Some(S2Format::default()); + }; + if formats.next().is_some() { + return None; + } + format.to_str().ok().and_then(|fmt| match fmt.trim() { + "raw" | "json" => Some(Self::Raw), + "base64" | "json-binsafe" => Some(Self::Base64), + _ => None, + }) + } + + fn encode(self, bytes: &[u8]) -> String { + match self { + S2Format::Raw => String::from_utf8_lossy(bytes).into_owned(), + S2Format::Base64 => Base64::encode_string(bytes), + } + } + + pub fn encode_read(self, record: record::SequencedRecord) -> SequencedRecord { + let (headers, body) = record.record.into_parts(); + SequencedRecord { + seq_num: record.seq_num, + timestamp: record.timestamp, + headers: headers + .into_iter() + .map(|h| Header(self.encode(&h.name), self.encode(&h.value))) + .collect(), + body: self.encode(&body), + } + } + + fn decode(self, s: String) -> Result { + Ok(match self { + S2Format::Raw => s.into_bytes().into(), + S2Format::Base64 => Base64::decode_vec(&s) + .map_err(|_| types::ValidationError("Invalid Base64 encoding".to_owned()))? + .into(), + }) + } + + pub fn decode_append( + self, + AppendRecord { + timestamp, + headers, + body, + }: AppendRecord, + ) -> Result { + let headers = headers + .into_iter() + .map(|Header(name, value)| { + Ok::(record::Header { + name: self.decode(name)?, + value: self.decode(value)?, + }) + }) + .try_collect()?; + + let body = self.decode(body)?; + + let record = record::Record::try_from_parts(headers, body) + .map_err(|e| e.to_string())? + .into(); + + let parts = types::stream::AppendRecordParts { timestamp, record }; + + types::stream::AppendRecord::try_from(parts) + .map_err(|e| types::ValidationError(e.to_string())) + } +} + +/// Headers add structured information to a record as name-value pairs. +/// +/// The name cannot be empty, with the exception of an S2 command record. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct Header(pub String, pub String); + +#[rustfmt::skip] +/// Record that is durably sequenced on a stream. +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct SequencedRecord { + /// Sequence number assigned by the service. + pub seq_num: record::SeqNum, + /// Timestamp for this record. + pub timestamp: record::Timestamp, + /// Series of name-value pairs for this record. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[cfg_attr(feature = "utoipa", schema(required = false))] + pub headers: Vec
, + /// Body of the record. + #[serde(default, skip_serializing_if = "String::is_empty")] + #[cfg_attr(feature = "utoipa", schema(required = false))] + pub body: String, +} + +#[rustfmt::skip] +/// Record to be appended to a stream. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct AppendRecord { + /// Timestamp for this record. + /// The service will always ensure monotonicity by adjusting it up if necessary to the maximum observed timestamp. + /// Refer to stream timestamping configuration for the finer semantics around whether a client-specified timestamp is required, and whether it will be capped at the arrival time. + pub timestamp: Option, + /// Series of name-value pairs for this record. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[cfg_attr(feature = "utoipa", schema(required = false))] + pub headers: Vec
, + /// Body of the record. + #[serde(default, skip_serializing_if = "String::is_empty")] + #[cfg_attr(feature = "utoipa", schema(required = false))] + pub body: String, +} + +#[rustfmt::skip] +/// Payload of an `append` request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct AppendInput { + /// Batch of records to append atomically, which must contain at least one record, and no more than 1000. + /// The total size of a batch of records may not exceed 1 MiB of metered bytes. + pub records: Vec, + /// Enforce that the sequence number assigned to the first record matches. + pub match_seq_num: Option, + /// Enforce a fencing token, which starts out as an empty string that can be overridden by a `fence` command record. + pub fencing_token: Option, +} + +#[rustfmt::skip] +/// Success response to an `append` request. +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct AppendAck { + /// Sequence number and timestamp of the first record that was appended. + pub start: StreamPosition, + /// Sequence number of the last record that was appended `+ 1`, and timestamp of the last record that was appended. + /// The difference between `end.seq_num` and `start.seq_num` will be the number of records appended. + pub end: StreamPosition, + /// Sequence number that will be assigned to the next record on the stream, and timestamp of the last record on the stream. + /// This can be greater than the `end` position in case of concurrent appends. + pub tail: StreamPosition, +} + +impl From for AppendAck { + fn from(ack: types::stream::AppendAck) -> Self { + Self { + start: ack.start.into(), + end: ack.end.into(), + tail: ack.tail.into(), + } + } +} + +#[rustfmt::skip] +/// Aborted due to a failed condition. +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum AppendConditionFailed { + /// Fencing token did not match. + /// The expected fencing token is returned. + #[cfg_attr(feature = "utoipa", schema(title = "fencing token"))] + FencingTokenMismatch(record::FencingToken), + /// Sequence number did not match the tail of the stream. + /// The expected next sequence number is returned. + #[cfg_attr(feature = "utoipa", schema(title = "seq num"))] + SeqNumMismatch(record::SeqNum), +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct ReadBatch { + /// Records that are durably sequenced on the stream, retrieved based on the requested criteria. + /// This can only be empty in response to a unary read (i.e. not SSE), if the request cannot be satisfied without violating an explicit bound (`count`, `bytes`, or `until`). + pub records: Vec, + /// Sequence number that will be assigned to the next record on the stream, and timestamp of the last record. + /// This will only be present when reading recent records. + #[serde(skip_serializing_if = "Option::is_none")] + pub tail: Option, +} + +impl From<(S2Format, types::stream::ReadBatch)> for ReadBatch { + fn from((s2_format, batch): (S2Format, types::stream::ReadBatch)) -> Self { + Self { + records: batch + .records + .into_iter() + .map(|record| s2_format.encode_read(record)) + .collect(), + tail: batch.tail.map(Into::into), + } + } +} diff --git a/api/src/v1/stream/proto/mod.rs b/api/src/v1/stream/proto/mod.rs new file mode 100644 index 00000000..63e348f4 --- /dev/null +++ b/api/src/v1/stream/proto/mod.rs @@ -0,0 +1,194 @@ +use compact_str::ToCompactString; +use s2_common::{record, types}; + +include!("s2.v1.rs"); + +impl From for types::stream::StreamPosition { + fn from(StreamPosition { seq_num, timestamp }: StreamPosition) -> Self { + Self { seq_num, timestamp } + } +} + +impl From for StreamPosition { + fn from( + types::stream::StreamPosition { seq_num, timestamp }: types::stream::StreamPosition, + ) -> Self { + Self { seq_num, timestamp } + } +} + +impl From
for record::Header { + fn from(Header { name, value }: Header) -> Self { + Self { name, value } + } +} + +impl From for Header { + fn from(record::Header { name, value }: record::Header) -> Self { + Self { name, value } + } +} + +impl TryFrom for types::stream::AppendRecord { + type Error = types::ValidationError; + + fn try_from( + AppendRecord { + timestamp, + headers, + body, + }: AppendRecord, + ) -> Result { + Ok(Self::try_from(types::stream::AppendRecordParts { + timestamp, + record: record::Record::try_from_parts( + headers.into_iter().map(Into::into).collect(), + body, + ) + .map_err(|e| e.to_string())? + .into(), + })?) + } +} + +impl TryFrom for types::stream::AppendInput { + type Error = types::ValidationError; + + fn try_from( + AppendInput { + records, + match_seq_num, + fencing_token, + }: AppendInput, + ) -> Result { + let records = records + .into_iter() + .map(types::stream::AppendRecord::try_from) + .collect::, _>>()?; + + Ok(Self { + records: types::stream::AppendRecordBatch::try_from(records)?, + match_seq_num, + fencing_token: fencing_token + .as_deref() + .map(|s| s.to_compact_string().try_into()) + .transpose()?, + }) + } +} + +impl From for AppendInput { + fn from( + types::stream::AppendInput { + records, + match_seq_num, + fencing_token, + }: types::stream::AppendInput, + ) -> Self { + let records = records + .into_iter() + .map(|record| { + let types::stream::AppendRecordParts { timestamp, record } = record.into(); + let (headers, body) = record.into_inner().into_parts(); + AppendRecord { + timestamp, + headers: headers.into_iter().map(Into::into).collect(), + body, + } + }) + .collect(); + + Self { + records, + match_seq_num, + fencing_token: fencing_token.as_ref().map(|t| t.to_string()), + } + } +} + +impl From for AppendAck { + fn from(types::stream::AppendAck { start, end, tail }: types::stream::AppendAck) -> Self { + Self { + start: Some(start.into()), + end: Some(end.into()), + tail: Some(tail.into()), + } + } +} + +impl From for types::stream::AppendAck { + fn from(AppendAck { start, end, tail }: AppendAck) -> Self { + Self { + start: start.unwrap_or_default().into(), + end: end.unwrap_or_default().into(), + tail: tail.unwrap_or_default().into(), + } + } +} + +impl From for SequencedRecord { + fn from( + record::SequencedRecord { + seq_num, + timestamp, + record, + }: record::SequencedRecord, + ) -> Self { + let (headers, body) = record.into_parts(); + Self { + seq_num, + timestamp, + headers: headers.into_iter().map(Into::into).collect(), + body, + } + } +} + +impl TryFrom for record::SequencedRecord { + type Error = types::ValidationError; + + fn try_from( + SequencedRecord { + seq_num, + timestamp, + headers, + body, + }: SequencedRecord, + ) -> Result { + let record = + record::Record::try_from_parts(headers.into_iter().map(Into::into).collect(), body) + .map_err(|e| e.to_string())?; + + Ok(Self { + seq_num, + timestamp, + record, + }) + } +} + +impl From for ReadBatch { + fn from(batch: types::stream::ReadBatch) -> Self { + Self { + records: batch.records.into_iter().map(Into::into).collect(), + tail: batch.tail.map(Into::into), + } + } +} + +impl TryFrom for types::stream::ReadBatch { + type Error = types::ValidationError; + + fn try_from(batch: ReadBatch) -> Result { + let records: Vec<_> = batch + .records + .into_iter() + .map(record::SequencedRecord::try_from) + .collect::>()?; + + Ok(Self { + records: records.into(), + tail: batch.tail.map(Into::into), + }) + } +} diff --git a/api/src/v1/stream/proto/s2.v1.rs b/api/src/v1/stream/proto/s2.v1.rs new file mode 100644 index 00000000..c45d6a11 --- /dev/null +++ b/api/src/v1/stream/proto/s2.v1.rs @@ -0,0 +1,95 @@ +// This file is @generated by prost-build. +/// Position of a record in a stream. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct StreamPosition { + /// Sequence number assigned by the service. + #[prost(uint64, tag = "1")] + pub seq_num: u64, + /// Timestamp, which may be user-specified or assigned by the service. + /// If it is assigned by the service, it will represent milliseconds since Unix epoch. + #[prost(uint64, tag = "2")] + pub timestamp: u64, +} +/// Headers add structured information to a record as name-value pairs. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Header { + /// Header name blob. + /// The name cannot be empty, with the exception of an S2 command record. + #[prost(bytes = "bytes", tag = "1")] + pub name: ::prost::bytes::Bytes, + /// Header value blob. + #[prost(bytes = "bytes", tag = "2")] + pub value: ::prost::bytes::Bytes, +} +/// Record to be appended to a stream. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AppendRecord { + /// Timestamp for this record. + /// Precise semantics depend on the stream's `timestamping` config. + #[prost(uint64, optional, tag = "1")] + pub timestamp: ::core::option::Option, + /// Series of name-value pairs for this record. + #[prost(message, repeated, tag = "2")] + pub headers: ::prost::alloc::vec::Vec
, + /// Body of this record. + #[prost(bytes = "bytes", tag = "3")] + pub body: ::prost::bytes::Bytes, +} +/// Payload of an Append request message. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AppendInput { + /// Batch of records to append atomically, which must contain at least one record, and no more than 1000. + /// The total size of a batch of records may not exceed 1MiB of metered bytes. + #[prost(message, repeated, tag = "1")] + pub records: ::prost::alloc::vec::Vec, + /// Enforce that the sequence number issued to the first record matches. + #[prost(uint64, optional, tag = "2")] + pub match_seq_num: ::core::option::Option, + /// Enforce a fencing token which must have been previously set by a `fence` command record. + #[prost(string, optional, tag = "3")] + pub fencing_token: ::core::option::Option<::prost::alloc::string::String>, +} +/// Success response message to an Append request. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AppendAck { + /// Sequence number and timestamp of the first record that was appended. + #[prost(message, optional, tag = "1")] + pub start: ::core::option::Option, + /// Sequence number of the last record that was appended + 1, and timestamp of the last record that was appended. + /// The difference between `end.seq_num` and `start.seq_num` will be the number of records appended. + #[prost(message, optional, tag = "2")] + pub end: ::core::option::Option, + /// Sequence number that will be assigned to the next record on the stream, and timestamp of the last record on the stream. + /// This can be greater than the `end` position in case of concurrent appends. + #[prost(message, optional, tag = "3")] + pub tail: ::core::option::Option, +} +/// Record that is durably sequenced on a stream. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SequencedRecord { + /// Sequence number assigned to this record. + #[prost(uint64, tag = "1")] + pub seq_num: u64, + /// Timestamp for this record. + #[prost(uint64, tag = "2")] + pub timestamp: u64, + /// Series of name-value pairs for this record. + #[prost(message, repeated, tag = "3")] + pub headers: ::prost::alloc::vec::Vec
, + /// Body of this record. + #[prost(bytes = "bytes", tag = "4")] + pub body: ::prost::bytes::Bytes, +} +/// Success response message to a Read request. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ReadBatch { + /// Records that are durably sequenced on the stream, retrieved based on the requested criteria. + /// This can only be empty in response to a unary read if the request cannot be satisfied without violating an explicit bound (`count`, `bytes`, or `until`). + /// In the context of a session, it can be empty as a heartbeat message. A heartbeat will be sent whenever a switch to following in real-time happens, and then at a randomized gap between 5 and 15 seconds if no records have become available. + #[prost(message, repeated, tag = "1")] + pub records: ::prost::alloc::vec::Vec, + /// Sequence number that will be assigned to the next record on the stream, and timestamp of the last record. + /// It will only be present when reading recent records. + #[prost(message, optional, tag = "2")] + pub tail: ::core::option::Option, +} diff --git a/api/src/v1/stream/s2s.rs b/api/src/v1/stream/s2s.rs new file mode 100644 index 00000000..975e8dd6 --- /dev/null +++ b/api/src/v1/stream/s2s.rs @@ -0,0 +1,717 @@ +use std::{ + io::{Read, Write}, + pin::Pin, + task::{Context, Poll}, +}; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use enum_ordinalize::Ordinalize; +use flate2::{Compression, read::GzDecoder, write::GzEncoder}; +use futures::Stream; +use zstd::zstd_safe::WriteBuf; + +/* + REGULAR MESSAGE: + ┌─────────────┬────────────┬─────────────────────────────┐ + │ LENGTH │ FLAGS │ PAYLOAD DATA │ + │ (3 bytes) │ (1 byte) │ (variable length) │ + ├─────────────┼────────────┼─────────────────────────────┤ + │ 0x00 00 XX │ 0 CA XXXXX │ Compressed proto message │ + └─────────────┴────────────┴─────────────────────────────┘ + + TERMINAL MESSAGE: + ┌─────────────┬────────────┬─────────────┬───────────────┐ + │ LENGTH │ FLAGS │ STATUS CODE │ JSON BODY │ + │ (3 bytes) │ (1 byte) │ (2 bytes) │ (variable) │ + ├─────────────┼────────────┼─────────────┼───────────────┤ + │ 0x00 00 XX │ 1 CA XXXXX │ HTTP Code │ JSON data │ + └─────────────┴────────────┴─────────────┴───────────────┘ + + LENGTH = size of (FLAGS + PAYLOAD), does NOT include length header itself + Implemented limit: 2 MiB (smaller than 24-bit protocol maximum) +*/ + +const LENGTH_PREFIX_SIZE: usize = 3; +const STATUS_CODE_SIZE: usize = 2; +const COMPRESSION_THRESHOLD_BYTES: usize = 1024; // 1 KiB +const MAX_FRAME_BYTES: usize = 2 * 1024 * 1024; // 2 MiB + +/* +Flag byte layout: + ┌───┬───┬───┬───┬───┬───┬───┬───┐ + │ 7 │ 6 │ 5 │ 4 │ 3 │ 2 │ 1 │ 0 │ Bit positions + ├───┼───┴───┼───┴───┴───┴───┴───┤ + │ T │ C C │ Reserved (0s) │ Purpose + └───┴───────┴───────────────────┘ + + T = Terminal flag (1 bit) + C = Compression (2 bits, encodes 0-3) +*/ + +const FLAG_TOTAL_SIZE: usize = 1; +const FLAG_TERMINAL: u8 = 0b1000_0000; +const FLAG_COMPRESSION_MASK: u8 = 0b0110_0000; +const FLAG_COMPRESSION_SHIFT: u8 = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ordinalize)] +#[repr(u8)] +pub enum CompressionAlgorithm { + None = 0, + Zstd = 1, + Gzip = 2, +} + +impl CompressionAlgorithm { + pub fn from_accept_encoding(headers: &http::HeaderMap) -> Self { + let mut gzip = false; + for header_value in headers.get_all(http::header::ACCEPT_ENCODING) { + if let Ok(value) = header_value.to_str() { + for encoding in value.split(',') { + let encoding = encoding.trim().split(';').next().unwrap_or("").trim(); + if encoding.eq_ignore_ascii_case("zstd") { + return Self::Zstd; + } else if encoding.eq_ignore_ascii_case("gzip") { + gzip = true; + } + } + } + } + if gzip { Self::Gzip } else { Self::None } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompressedData { + compression: CompressionAlgorithm, + payload: Bytes, +} + +impl CompressedData { + pub fn for_proto( + compression: CompressionAlgorithm, + proto: &impl prost::Message, + ) -> std::io::Result { + Self::compress(compression, proto.encode_to_vec()) + } + + fn compress(compression: CompressionAlgorithm, data: Vec) -> std::io::Result { + if compression == CompressionAlgorithm::None || data.len() < COMPRESSION_THRESHOLD_BYTES { + return Ok(Self { + compression: CompressionAlgorithm::None, + payload: data.into(), + }); + } + let mut buf = Vec::with_capacity(data.len()); + match compression { + CompressionAlgorithm::Gzip => { + let mut encoder = GzEncoder::new(buf, Compression::default()); + encoder.write_all(data.as_slice())?; + buf = encoder.finish()?; + } + CompressionAlgorithm::Zstd => { + zstd::stream::copy_encode(data.as_slice(), &mut buf, 0)?; + } + CompressionAlgorithm::None => unreachable!("handled above"), + }; + let payload = Bytes::from(buf.into_boxed_slice()); + Ok(Self { + compression, + payload, + }) + } + + fn decompressed(self) -> std::io::Result { + match self.compression { + CompressionAlgorithm::None => Ok(self.payload), + CompressionAlgorithm::Gzip => { + let mut decoder = GzDecoder::new(self.payload.as_slice()); + let mut buf = Vec::with_capacity(self.payload.len() * 2); + decoder.read_to_end(&mut buf)?; + Ok(Bytes::from(buf.into_boxed_slice())) + } + CompressionAlgorithm::Zstd => { + let mut buf = Vec::with_capacity(self.payload.len() * 2); + zstd::stream::copy_decode(self.payload.as_slice(), &mut buf)?; + Ok(Bytes::from(buf.into_boxed_slice())) + } + } + } + + pub fn try_into_proto(self) -> std::io::Result

{ + let payload = self.decompressed()?; + P::decode(payload.as_ref()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalMessage { + pub status: u16, + pub body: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionMessage { + Regular(CompressedData), + Terminal(TerminalMessage), +} + +impl From for SessionMessage { + fn from(data: CompressedData) -> Self { + Self::Regular(data) + } +} + +impl From for SessionMessage { + fn from(msg: TerminalMessage) -> Self { + Self::Terminal(msg) + } +} + +impl SessionMessage { + pub fn regular( + compression: CompressionAlgorithm, + proto: &impl prost::Message, + ) -> std::io::Result { + Ok(Self::Regular(CompressedData::for_proto( + compression, + proto, + )?)) + } + + pub fn encode(&self) -> Bytes { + let encoded_size = FLAG_TOTAL_SIZE + self.payload_size(); + assert!( + encoded_size <= MAX_FRAME_BYTES, + "payload exceeds encoder limit" + ); + let mut buf = BytesMut::with_capacity(LENGTH_PREFIX_SIZE + encoded_size); + buf.put_uint(encoded_size as u64, 3); + match self { + Self::Regular(msg) => { + let flag = + (msg.compression.ordinal() << FLAG_COMPRESSION_SHIFT) & FLAG_COMPRESSION_MASK; + buf.put_u8(flag); + buf.extend_from_slice(&msg.payload); + } + Self::Terminal(msg) => { + buf.put_u8(FLAG_TERMINAL); + buf.put_u16(msg.status); + buf.extend_from_slice(msg.body.as_bytes()); + } + } + buf.freeze() + } + + fn decode_message(mut buf: Bytes) -> std::io::Result { + let flag = buf.get_u8(); + + let is_terminal = (flag & FLAG_TERMINAL) != 0; + if is_terminal { + if buf.len() < STATUS_CODE_SIZE { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "terminal message missing status code", + )); + } + let status = buf.get_u16(); + let body = String::from_utf8(buf.into()).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid utf-8") + })?; + return Ok(TerminalMessage { status, body }.into()); + } + + let compression_bits = (flag & FLAG_COMPRESSION_MASK) >> FLAG_COMPRESSION_SHIFT; + let Some(compression) = CompressionAlgorithm::from_ordinal(compression_bits) else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "unknown compression algorithm", + )); + }; + + Ok(CompressedData { + compression, + payload: buf, + } + .into()) + } + + fn payload_size(&self) -> usize { + match self { + Self::Regular(msg) => msg.payload.len(), + Self::Terminal(msg) => STATUS_CODE_SIZE + msg.body.len(), + } + } +} + +pub struct FramedMessageStream { + inner: S, + compression: CompressionAlgorithm, + terminated: bool, +} + +impl FramedMessageStream { + pub fn new(compression: CompressionAlgorithm, inner: S) -> Self { + Self { + inner, + compression, + terminated: false, + } + } +} + +impl Stream for FramedMessageStream +where + S: Stream> + Unpin, + P: prost::Message, + E: Into, +{ + type Item = std::io::Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.terminated { + return Poll::Ready(None); + } + + match Pin::new(&mut self.inner).poll_next(cx) { + Poll::Ready(Some(Ok(item))) => { + let bytes = + SessionMessage::regular(self.compression, &item).map(|msg| msg.encode()); + Poll::Ready(Some(bytes)) + } + Poll::Ready(Some(Err(e))) => { + self.terminated = true; + let bytes = SessionMessage::Terminal(e.into()).encode(); + Poll::Ready(Some(Ok(bytes))) + } + Poll::Ready(None) => { + self.terminated = true; + Poll::Ready(None) + } + Poll::Pending => Poll::Pending, + } + } +} + +pub struct FrameDecoder; + +impl tokio_util::codec::Decoder for FrameDecoder { + type Item = SessionMessage; + type Error = std::io::Error; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + if src.len() < LENGTH_PREFIX_SIZE { + return Ok(None); + } + + let length = ((src[0] as usize) << 16) | ((src[1] as usize) << 8) | (src[2] as usize); + + if length > MAX_FRAME_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "frame exceeds decode limit", + )); + } + + let total_size = LENGTH_PREFIX_SIZE + length; + if src.len() < total_size { + return Ok(None); + } + + src.advance(LENGTH_PREFIX_SIZE); + let frame_bytes = src.split_to(length).freeze(); + Ok(Some(SessionMessage::decode_message(frame_bytes)?)) + } +} + +#[cfg(test)] +mod test { + use super::*; + use bytes::BytesMut; + use futures::{StreamExt, stream}; + use http::HeaderValue; + use proptest::{collection::vec, prelude::*}; + use prost::Message; + use std::{ + io, + pin::Pin, + task::{Context, Poll}, + }; + use tokio_util::codec::Decoder; + + #[derive(Clone, PartialEq, prost::Message)] + struct TestProto { + #[prost(bytes, tag = "1")] + payload: Vec, + } + + impl TestProto { + fn new(payload: Vec) -> Self { + Self { payload } + } + } + + #[derive(Debug, Clone)] + struct TestError { + status: u16, + body: &'static str, + } + + impl Into for TestError { + fn into(self) -> TerminalMessage { + TerminalMessage { + status: self.status, + body: self.body.to_string(), + } + } + } + + fn decode_once(bytes: &Bytes) -> io::Result { + let mut decoder = FrameDecoder; + let mut buf = BytesMut::from(bytes.as_ref()); + decoder + .decode(&mut buf)? + .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "frame incomplete")) + } + + fn compression_strategy() -> impl proptest::strategy::Strategy { + prop_oneof![ + Just(CompressionAlgorithm::None), + Just(CompressionAlgorithm::Gzip), + Just(CompressionAlgorithm::Zstd), + ] + } + + fn chunk_bytes(data: &Bytes, pattern: &[usize]) -> Vec { + let mut chunks = Vec::new(); + let mut offset = 0; + for &hint in pattern { + if offset >= data.len() { + break; + } + let remaining = data.len() - offset; + let take = (hint % remaining).saturating_add(1).min(remaining); + chunks.push(data.slice(offset..offset + take)); + offset += take; + } + if offset < data.len() { + chunks.push(data.slice(offset..)); + } + if chunks.is_empty() { + chunks.push(data.clone()); + } + chunks + } + + proptest! { + #[test] + fn regular_session_message_round_trips_proptest( + algo in compression_strategy(), + payload in vec(any::(), 0..=COMPRESSION_THRESHOLD_BYTES * 4) + ) { + let proto = TestProto::new(payload.clone()); + let msg = SessionMessage::regular(algo, &proto).unwrap(); + let encoded = msg.encode(); + let decoded = decode_once(&encoded).unwrap(); + + prop_assert!(matches!(decoded, SessionMessage::Regular(_))); + let SessionMessage::Regular(data) = decoded else { unreachable!() }; + + let expected_compression = if algo == CompressionAlgorithm::None || payload.len() < COMPRESSION_THRESHOLD_BYTES { + CompressionAlgorithm::None + } else { + algo + }; + let actual_compression = data.compression; + + let restored = data.try_into_proto::().unwrap(); + prop_assert_eq!(restored.payload, payload); + prop_assert_eq!(actual_compression, expected_compression); + } + + #[test] + fn frame_decoder_handles_chunked_frames( + algo in compression_strategy(), + payload in vec(any::(), 0..=COMPRESSION_THRESHOLD_BYTES * 4), + chunk_pattern in vec(0usize..=16, 0..=16) + ) { + let proto = TestProto::new(payload); + let msg = SessionMessage::regular(algo, &proto).unwrap(); + let encoded = msg.encode(); + let expected = decode_once(&encoded).unwrap(); + + let chunks = chunk_bytes(&encoded, &chunk_pattern); + prop_assert_eq!(chunks.iter().map(|c| c.len()).sum::(), encoded.len()); + + let mut decoder = FrameDecoder; + let mut buf = BytesMut::new(); + let mut decoded = None; + + for (idx, chunk) in chunks.iter().enumerate() { + buf.extend_from_slice(chunk.as_ref()); + let result = decoder.decode(&mut buf).expect("decode invocation failed"); + if idx < chunks.len() - 1 { + prop_assert!(result.is_none()); + } else { + let message = result.expect("final chunk should produce frame"); + prop_assert!(buf.is_empty()); + decoded = Some(message); + } + } + + let decoded = decoded.expect("decoder never emitted frame"); + prop_assert_eq!(decoded, expected); + } + } + + #[test] + fn from_accept_encoding_prefers_zstd() { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::ACCEPT_ENCODING, + HeaderValue::from_static("gzip, zstd, br"), + ); + + let algo = CompressionAlgorithm::from_accept_encoding(&headers); + assert_eq!(algo, CompressionAlgorithm::Zstd); + } + + #[test] + fn from_accept_encoding_falls_back_to_gzip() { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::ACCEPT_ENCODING, + HeaderValue::from_static("gzip;q=0.8, deflate"), + ); + + let algo = CompressionAlgorithm::from_accept_encoding(&headers); + assert_eq!(algo, CompressionAlgorithm::Gzip); + } + + #[test] + fn from_accept_encoding_defaults_to_none() { + let headers = http::HeaderMap::new(); + let algo = CompressionAlgorithm::from_accept_encoding(&headers); + assert_eq!(algo, CompressionAlgorithm::None); + } + + #[test] + fn regular_session_message_round_trips() { + let proto = TestProto::new(vec![1, 2, 3, 4]); + let msg = SessionMessage::regular(CompressionAlgorithm::None, &proto).unwrap(); + let encoded = msg.encode(); + let decoded = decode_once(&encoded).unwrap(); + + match decoded { + SessionMessage::Regular(data) => { + assert_eq!(data.compression, CompressionAlgorithm::None); + let restored = data.try_into_proto::().unwrap(); + assert_eq!(restored, proto); + } + SessionMessage::Terminal(_) => panic!("expected regular message"), + } + } + + #[test] + fn terminal_session_message_round_trips() { + let terminal = TerminalMessage { + status: 418, + body: "short-circuit".to_string(), + }; + let msg = SessionMessage::from(terminal.clone()); + let encoded = msg.encode(); + let decoded = decode_once(&encoded).unwrap(); + + match decoded { + SessionMessage::Regular(_) => panic!("expected terminal message"), + SessionMessage::Terminal(decoded_terminal) => { + assert_eq!(decoded_terminal, terminal); + } + } + } + + #[test] + fn frame_decoder_waits_for_complete_frame() { + let proto = TestProto::new(vec![9, 9, 9]); + let msg = SessionMessage::regular(CompressionAlgorithm::None, &proto).unwrap(); + let encoded = msg.encode(); + let mut decoder = FrameDecoder; + + let split_idx = encoded.len() - 1; + let mut buf = BytesMut::from(&encoded[..split_idx]); + assert!(decoder.decode(&mut buf).unwrap().is_none()); + buf.extend_from_slice(&encoded[split_idx..]); + let decoded = decoder.decode(&mut buf).unwrap().unwrap(); + + match decoded { + SessionMessage::Regular(data) => { + let restored = data.try_into_proto::().unwrap(); + assert_eq!(restored, proto); + } + SessionMessage::Terminal(_) => panic!("expected regular message"), + } + assert!(buf.is_empty()); + } + + #[test] + fn frame_decoder_rejects_frames_exceeding_decode_limit() { + let length = MAX_FRAME_BYTES + 1; + let prefix = [ + ((length >> 16) & 0xFF) as u8, + ((length >> 8) & 0xFF) as u8, + (length & 0xFF) as u8, + ]; + let mut buf = BytesMut::from(prefix.as_slice()); + let mut decoder = FrameDecoder; + let err = decoder.decode(&mut buf).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } + + #[test] + #[should_panic(expected = "encoder limit")] + fn session_message_encode_rejects_frames_over_limit() { + let data = CompressedData { + compression: CompressionAlgorithm::None, + payload: Bytes::from(vec![0u8; MAX_FRAME_BYTES]), + }; + let msg = SessionMessage::from(data); + let _ = msg.encode(); + } + + #[test] + fn frame_decoder_rejects_unknown_compression() { + let mut raw = vec![0, 0, 1]; + raw.push(0x60); + let mut decoder = FrameDecoder; + let mut buf = BytesMut::from(raw.as_slice()); + let err = decoder.decode(&mut buf).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn frame_decoder_rejects_terminal_without_status() { + let mut raw = vec![0, 0, 1]; + raw.push(FLAG_TERMINAL); + let mut decoder = FrameDecoder; + let mut buf = BytesMut::from(raw.as_slice()); + let err = decoder.decode(&mut buf).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn compressed_data_round_trip_gzip() { + let payload = vec![42; 1_200_000]; + let proto = TestProto::new(payload.clone()); + let msg = SessionMessage::regular(CompressionAlgorithm::Gzip, &proto).unwrap(); + let encoded = msg.encode(); + let decoded = decode_once(&encoded).unwrap(); + + match decoded { + SessionMessage::Regular(data) => { + assert_eq!(data.compression, CompressionAlgorithm::Gzip); + assert!(data.payload.len() < proto.encode_to_vec().len()); + let restored = data.try_into_proto::().unwrap(); + assert_eq!(restored.payload, payload); + } + SessionMessage::Terminal(_) => panic!("expected regular message"), + } + } + + #[test] + fn compressed_data_round_trip_zstd() { + let payload = vec![7; 1_100_000]; + let proto = TestProto::new(payload.clone()); + let msg = SessionMessage::regular(CompressionAlgorithm::Zstd, &proto).unwrap(); + let encoded = msg.encode(); + let decoded = decode_once(&encoded).unwrap(); + + match decoded { + SessionMessage::Regular(data) => { + assert_eq!(data.compression, CompressionAlgorithm::Zstd); + assert!(data.payload.len() < proto.encode_to_vec().len()); + let restored = data.try_into_proto::().unwrap(); + assert_eq!(restored.payload, payload); + } + SessionMessage::Terminal(_) => panic!("expected regular message"), + } + } + + #[test] + fn framed_message_stream_yields_terminal_on_error() { + let proto = TestProto::new(vec![1, 2, 3]); + let items = vec![ + Ok(proto.clone()), + Err(TestError { + status: 500, + body: "boom", + }), + Ok(proto.clone()), + ]; + + let stream = stream::iter(items.into_iter()); + let framed = FramedMessageStream::new(CompressionAlgorithm::None, stream); + let outputs = futures::executor::block_on(async { + framed.collect::>>().await + }); + + assert_eq!(outputs.len(), 2); + + let first = outputs[0].as_ref().expect("first frame ok"); + match decode_once(first).unwrap() { + SessionMessage::Regular(data) => { + let restored = data.try_into_proto::().unwrap(); + assert_eq!(restored, proto); + } + SessionMessage::Terminal(_) => panic!("expected regular message"), + } + + let second = outputs[1].as_ref().expect("second frame ok"); + match decode_once(second).unwrap() { + SessionMessage::Regular(_) => panic!("expected terminal message"), + SessionMessage::Terminal(term) => { + assert_eq!(term.status, 500); + assert_eq!(term.body, "boom"); + } + } + } + + #[test] + fn framed_message_stream_stops_after_termination() { + let mut stream = FramedMessageStream::new( + CompressionAlgorithm::None, + stream::iter(vec![ + Ok(TestProto::new(vec![0])), + Err(TestError { + status: 400, + body: "bad", + }), + ]), + ); + + let mut cx = Context::from_waker(futures::task::noop_waker_ref()); + + match Pin::new(&mut stream).poll_next(&mut cx) { + Poll::Ready(Some(Ok(bytes))) => match decode_once(&bytes).unwrap() { + SessionMessage::Regular(_) => {} + SessionMessage::Terminal(_) => panic!("expected regular message"), + }, + other => panic!("unexpected poll result: {other:?}"), + } + + match Pin::new(&mut stream).poll_next(&mut cx) { + Poll::Ready(Some(Ok(bytes))) => match decode_once(&bytes).unwrap() { + SessionMessage::Terminal(term) => { + assert_eq!(term.status, 400); + assert_eq!(term.body, "bad"); + } + SessionMessage::Regular(_) => panic!("expected terminal message"), + }, + other => panic!("unexpected poll result: {other:?}"), + } + + match Pin::new(&mut stream).poll_next(&mut cx) { + Poll::Ready(None) => {} + other => panic!("expected stream to terminate, got {other:?}"), + } + } +} diff --git a/api/src/v1/stream/sse.rs b/api/src/v1/stream/sse.rs new file mode 100644 index 00000000..e19294be --- /dev/null +++ b/api/src/v1/stream/sse.rs @@ -0,0 +1,194 @@ +use std::{str::FromStr, time::Duration}; + +use s2_common::types; +use serde::Serialize; +#[cfg(feature = "utoipa")] +use utoipa::ToSchema; + +use super::ReadBatch; + +#[derive(Debug, Clone, Copy)] +pub struct LastEventId { + pub seq_num: u64, + pub count: usize, + pub bytes: usize, +} + +impl Serialize for LastEventId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} + +impl std::fmt::Display for LastEventId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + seq_num, + count, + bytes, + } = self; + write!(f, "{seq_num},{count},{bytes}") + } +} + +impl FromStr for LastEventId { + type Err = types::ValidationError; + + fn from_str(s: &str) -> Result { + let mut iter = s.splitn(3, ","); + + fn get_next( + iter: &mut std::str::SplitN<&str>, + field: &str, + ) -> Result + where + T: FromStr, + ::Err: std::fmt::Display, + { + let item = iter + .next() + .ok_or_else(|| format!("Missing {field} in Last-Event-Id"))?; + item.parse() + .map_err(|e| format!("Invalid {field} in Last-Event-ID: {e}").into()) + } + + let seq_num = get_next(&mut iter, "seq_num")?; + let count = get_next(&mut iter, "count")?; + let bytes = get_next(&mut iter, "bytes")?; + + Ok(Self { + seq_num, + count, + bytes, + }) + } +} + +macro_rules! event { + ($name:ident, $val:expr) => { + #[derive(Serialize)] + #[cfg_attr(feature = "utoipa", derive(ToSchema))] + #[serde(rename_all = "snake_case")] + pub enum $name { + $name, + } + + impl AsRef for $name { + fn as_ref(&self) -> &str { + $val + } + } + }; +} + +event!(Batch, "batch"); +event!(Error, "error"); +event!(Ping, "ping"); + +#[derive(Serialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(untagged)] +pub enum ReadEvent { + #[cfg_attr(feature = "utoipa", schema(title = "batch"))] + Batch { + #[cfg_attr(feature = "utoipa", schema(inline))] + event: Batch, + data: ReadBatch, + #[cfg_attr(feature = "utoipa", schema(value_type = String, pattern = "^[0-9]+,[0-9]+,[0-9]+$"))] + id: LastEventId, + }, + #[cfg_attr(feature = "utoipa", schema(title = "error"))] + Error { + #[cfg_attr(feature = "utoipa", schema(inline))] + event: Error, + data: String, + }, + #[cfg_attr(feature = "utoipa", schema(title = "ping"))] + Ping { + #[cfg_attr(feature = "utoipa", schema(inline))] + event: Ping, + data: PingEventData, + }, + #[cfg_attr(feature = "utoipa", schema(title = "done"))] + #[serde(skip)] + Done { + #[cfg_attr(feature = "utoipa", schema(value_type = String, pattern = r"^\[DONE\]$"))] + data: DoneEventData, + }, +} + +fn elapsed_since_epoch() -> Duration { + std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() +} + +impl ReadEvent { + pub fn batch(data: ReadBatch, id: LastEventId) -> Self { + Self::Batch { + event: Batch::Batch, + data, + id, + } + } + + pub fn error(data: String) -> Self { + Self::Error { + event: Error::Error, + data, + } + } + + pub fn ping() -> Self { + Self::Ping { + event: Ping::Ping, + data: PingEventData { + timestamp: elapsed_since_epoch().as_millis() as u64, + }, + } + } + + pub fn done() -> Self { + Self::Done { + data: DoneEventData, + } + } +} + +#[cfg(feature = "axum")] +impl TryFrom for axum::response::sse::Event { + type Error = axum::Error; + + fn try_from(event: ReadEvent) -> Result { + match event { + ReadEvent::Batch { event, data, id } => Self::default() + .event(event) + .id(id.to_string()) + .json_data(data), + ReadEvent::Error { event, data } => Ok(Self::default().event(event).data(data)), + ReadEvent::Ping { event, data } => Self::default().event(event).json_data(data), + ReadEvent::Done { data } => Ok(Self::default().data(data)), + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[serde(rename = "[DONE]")] +pub struct DoneEventData; + +impl AsRef for DoneEventData { + fn as_ref(&self) -> &str { + "[DONE]" + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "utoipa", derive(ToSchema))] +pub struct PingEventData { + pub timestamp: u64, +} diff --git a/common/Cargo.toml b/common/Cargo.toml new file mode 100644 index 00000000..25f0e7af --- /dev/null +++ b/common/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "s2-common" +edition.workspace = true +license.workspace = true +version.workspace = true + +[features] +rkyv = ["dep:rkyv", "compact_str/rkyv"] +utoipa = ["dep:utoipa"] +clap = ["dep:clap"] + +[dependencies] +bytes = { workspace = true } +clap = { workspace = true, optional = true } +compact_str = { workspace = true, features = ["serde"] } +const_format = { workspace = true } +enum-ordinalize = { workspace = true } +enumset = { workspace = true } +futures = { workspace = true } +rkyv = { workspace = true, optional = true } +serde = { workspace = true } +strum = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true } +utoipa = { workspace = true, optional = true } + +[dev-dependencies] +proptest = { workspace = true } +rstest = { workspace = true } diff --git a/common/src/caps.rs b/common/src/caps.rs new file mode 100644 index 00000000..dd5b7421 --- /dev/null +++ b/common/src/caps.rs @@ -0,0 +1,14 @@ +pub const MIN_BASIN_NAME_LEN: usize = 8; +pub const MAX_BASIN_NAME_LEN: usize = 48; + +pub const MAX_STREAM_NAME_LEN: usize = 512; + +pub const MAX_TOKEN_ID_LEN: usize = 96; + +/// All record batches in the system are limited to 1000 records. +/// Batches are limited to a collective size of 1 MiB, which is also the maximum size of a single +/// record. +pub const RECORD_BATCH_MAX: crate::read_extent::CountOrBytes = crate::read_extent::CountOrBytes { + count: 1000, + bytes: 1024 * 1024, +}; diff --git a/common/src/deep_size.rs b/common/src/deep_size.rs new file mode 100644 index 00000000..283f8e7e --- /dev/null +++ b/common/src/deep_size.rs @@ -0,0 +1,70 @@ +pub trait DeepSize { + /// - size_of(primitive) + /// - length for chunks of data like strings and bytes (so not including the container overhead) + /// - deep size of all struct fields + /// - deep size of actual variant for enums + fn deep_size(&self) -> usize; +} + +impl DeepSize for (X, Y) { + fn deep_size(&self) -> usize { + self.0.deep_size() + self.1.deep_size() + } +} + +impl DeepSize for &[T] { + fn deep_size(&self) -> usize { + self.iter().map(DeepSize::deep_size).sum::() + } +} + +impl DeepSize for Vec { + fn deep_size(&self) -> usize { + self.iter().map(DeepSize::deep_size).sum::() + } +} + +impl DeepSize for Option { + fn deep_size(&self) -> usize { + match self { + Some(v) => v.deep_size(), + None => 1, + } + } +} + +impl DeepSize for String { + fn deep_size(&self) -> usize { + self.len() + } +} + +impl DeepSize for bytes::Bytes { + fn deep_size(&self) -> usize { + self.len() + } +} + +impl DeepSize for std::ops::Bound { + fn deep_size(&self) -> usize { + match self { + std::ops::Bound::Included(x) => x.deep_size(), + std::ops::Bound::Excluded(x) => x.deep_size(), + std::ops::Bound::Unbounded => 1, + } + } +} + +macro_rules! impl_deep_size_prim { + ($($t:ty),+) => { + $( + impl DeepSize for $t { + fn deep_size(&self) -> usize { + size_of_val(self) + } + } + )+ + }; +} + +impl_deep_size_prim!(bool, u64, usize, std::num::NonZeroU64); diff --git a/common/src/lib.rs b/common/src/lib.rs new file mode 100644 index 00000000..22155fa1 --- /dev/null +++ b/common/src/lib.rs @@ -0,0 +1,6 @@ +pub mod caps; +pub mod deep_size; +pub mod maybe; +pub mod read_extent; +pub mod record; +pub mod types; diff --git a/common/src/maybe.rs b/common/src/maybe.rs new file mode 100644 index 00000000..560c3c26 --- /dev/null +++ b/common/src/maybe.rs @@ -0,0 +1,129 @@ +use serde::{Deserialize, Serialize}; + +/// The [`Maybe`] type represents an optional that might or might not be specified. +/// +/// An [`Option`] is deserialized as [`None`] if either the value is not specified or the value is +/// `null`. [`Maybe`] allows us to distinguish between the two. +/// +/// # Examples +/// +/// ``` +/// #[derive(Debug, PartialEq, Eq, serde::Deserialize)] +/// pub struct MyStruct { +/// #[serde(default)] +/// pub field: Maybe>, +/// } +/// +/// assert_eq!( +/// MyStruct { field: Maybe::Unspecified }, +/// serde_json::from_str("{}").unwrap(), +/// ); +/// +/// assert_eq!( +/// MyStruct { field: Maybe::Specified(None) }, +/// serde_json::from_str(r#"{ "field": null }"#).unwrap(), +/// ); +/// +/// assert_eq!( +/// MyStruct { field: Maybe::Specified(Some(10)) }, +/// serde_json::from_str(r#"{ "field": 10 }"#).unwrap(), +/// ); +/// ``` +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum Maybe { + #[default] + Unspecified, + Specified(T), +} + +impl Maybe { + pub fn is_unspecified(&self) -> bool { + matches!(self, Self::Unspecified) + } + + pub fn map(self, f: F) -> Maybe + where + F: FnOnce(T) -> U, + { + match self { + Self::Unspecified => Maybe::Unspecified, + Self::Specified(x) => Maybe::Specified(f(x)), + } + } + + pub fn unwrap_or_default(self) -> T + where + T: Default, + { + match self { + Self::Unspecified => T::default(), + Self::Specified(x) => x, + } + } +} + +impl Maybe> { + pub fn map_opt(self, f: F) -> Maybe> + where + F: FnOnce(T) -> U, + { + self.map(|opt| opt.map(f)) + } + + pub fn try_map_opt(self, f: F) -> Result>, E> + where + F: FnOnce(T) -> Result, + { + match self { + Maybe::Unspecified => Ok(Maybe::Unspecified), + Maybe::Specified(opt) => match opt { + Some(value) => f(value).map(|converted| Maybe::Specified(Some(converted))), + None => Ok(Maybe::Specified(None)), + }, + } + } + + pub fn opt_or_default_mut(&mut self) -> &mut T + where + T: Default, + { + match self { + Maybe::Unspecified | Maybe::Specified(None) => { + *self = Self::Specified(Some(T::default())); + match self { + Self::Specified(Some(x)) => x, + _ => unreachable!(), + } + } + Maybe::Specified(Some(x)) => x, + } + } +} + +impl From for Maybe { + fn from(value: T) -> Self { + Self::Specified(value) + } +} + +impl Serialize for Maybe { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Unspecified => serializer.serialize_none(), + Self::Specified(v) => v.serialize(serializer), + } + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for Maybe { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let v = T::deserialize(deserializer)?; + Ok(v.into()) + } +} diff --git a/common/src/read_extent.rs b/common/src/read_extent.rs new file mode 100644 index 00000000..4fd9dadb --- /dev/null +++ b/common/src/read_extent.rs @@ -0,0 +1,220 @@ +use crate::record::Timestamp; + +#[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)] +pub struct CountOrBytes { + pub count: usize, + pub bytes: usize, +} + +impl CountOrBytes { + pub const ZERO: CountOrBytes = CountOrBytes { count: 0, bytes: 0 }; + + pub const MAX: CountOrBytes = CountOrBytes { + count: usize::MAX, + bytes: usize::MAX, + }; +} + +#[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)] +pub enum ReadLimit { + #[default] + Unbounded, + Count(usize), + Bytes(usize), + CountOrBytes(CountOrBytes), +} + +#[derive(PartialEq, Debug)] +pub enum EvaluatedReadLimit { + Remaining(ReadLimit), + Exhausted, +} + +impl ReadLimit { + pub fn is_unbounded(&self) -> bool { + matches!(self, ReadLimit::Unbounded) + } + + pub fn is_bounded(&self) -> bool { + !matches!(self, ReadLimit::Unbounded) + } + + pub fn from_count_and_bytes(count: Option, bytes: Option) -> Self { + match (count, bytes) { + (None, None) => Self::Unbounded, + (Some(0), _) | (_, Some(0)) => Self::Count(0), + (Some(count), None) => Self::Count(count), + (None, Some(bytes)) => Self::Bytes(bytes), + (Some(count), Some(bytes)) => Self::CountOrBytes(CountOrBytes { count, bytes }), + } + } + + pub fn count(&self) -> Option { + match self { + ReadLimit::Unbounded => None, + ReadLimit::Count(count) => Some(*count), + ReadLimit::Bytes(_) => None, + ReadLimit::CountOrBytes(CountOrBytes { count, .. }) => Some(*count), + } + } + + pub fn bytes(&self) -> Option { + match self { + ReadLimit::Unbounded => None, + ReadLimit::Count(_) => None, + ReadLimit::Bytes(bytes) => Some(*bytes), + ReadLimit::CountOrBytes(CountOrBytes { bytes, .. }) => Some(*bytes), + } + } + + pub fn into_allowance(self, max: CountOrBytes) -> CountOrBytes { + match self { + ReadLimit::Unbounded => max, + ReadLimit::Count(count) => CountOrBytes { + count: count.min(max.count), + bytes: max.bytes, + }, + ReadLimit::Bytes(bytes) => CountOrBytes { + count: max.count, + bytes: bytes.min(max.bytes), + }, + ReadLimit::CountOrBytes(CountOrBytes { count, bytes }) => CountOrBytes { + count: count.min(max.count), + bytes: bytes.min(max.bytes), + }, + } + } + + pub fn allow(&self, additional_count: usize, additional_bytes: usize) -> bool { + match self { + ReadLimit::Unbounded => true, + ReadLimit::Count(count) => additional_count <= *count, + ReadLimit::Bytes(bytes) => additional_bytes <= *bytes, + ReadLimit::CountOrBytes(CountOrBytes { count, bytes }) => { + additional_count <= *count && additional_bytes <= *bytes + } + } + } + + pub fn deny(&self, additional_count: usize, additional_bytes: usize) -> bool { + match self { + ReadLimit::Unbounded => false, + ReadLimit::Count(count) => additional_count > *count, + ReadLimit::Bytes(bytes) => additional_bytes > *bytes, + ReadLimit::CountOrBytes(CountOrBytes { count, bytes }) => { + additional_count > *count || additional_bytes > *bytes + } + } + } + + /// Given the amount of records already consumed, generate a new `ReadLimit` representing + /// the remaining limit, or none if the limit has been met. + pub fn remaining(&self, consumed_count: usize, consumed_bytes: usize) -> EvaluatedReadLimit { + let remaining = match self { + ReadLimit::Unbounded => Some(ReadLimit::Unbounded), + ReadLimit::Count(count) => { + (consumed_count < *count).then(|| ReadLimit::Count(count - consumed_count)) + } + ReadLimit::Bytes(bytes) => { + (consumed_bytes < *bytes).then(|| ReadLimit::Bytes(bytes - consumed_bytes)) + } + ReadLimit::CountOrBytes(CountOrBytes { count, bytes }) => { + (consumed_count < *count && consumed_bytes < *bytes).then(|| { + ReadLimit::CountOrBytes(CountOrBytes { + count: count - consumed_count, + bytes: bytes - consumed_bytes, + }) + }) + } + }; + + match remaining { + Some(limit) => EvaluatedReadLimit::Remaining(limit), + None => EvaluatedReadLimit::Exhausted, + } + } +} + +#[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)] +pub enum ReadUntil { + #[default] + Unbounded, + Timestamp(Timestamp), +} + +impl From> for ReadUntil { + fn from(timestamp: Option) -> Self { + match timestamp { + Some(ts) => ReadUntil::Timestamp(ts), + None => ReadUntil::Unbounded, + } + } +} + +impl From for Option { + fn from(until: ReadUntil) -> Self { + match until { + ReadUntil::Unbounded => None, + ReadUntil::Timestamp(ts) => Some(ts), + } + } +} + +impl ReadUntil { + pub fn is_unbounded(&self) -> bool { + matches!(self, ReadUntil::Unbounded) + } + + pub fn is_timestamp(&self) -> bool { + matches!(self, ReadUntil::Timestamp(_)) + } + + pub fn allow(&self, timestamp: Timestamp) -> bool { + match self { + ReadUntil::Unbounded => true, + ReadUntil::Timestamp(t) => timestamp < *t, + } + } + + pub fn deny(&self, timestamp: Timestamp) -> bool { + match self { + ReadUntil::Unbounded => false, + ReadUntil::Timestamp(t) => timestamp >= *t, + } + } +} + +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::{CountOrBytes, EvaluatedReadLimit, ReadLimit}; + + #[rstest] + #[case( + ReadLimit::Count(100), + 10, + 100000, + EvaluatedReadLimit::Remaining(ReadLimit::Count(90)) + )] + #[case(ReadLimit::Count(100), 100, 100000, EvaluatedReadLimit::Exhausted)] + #[case( + ReadLimit::Bytes(100), + 1000, + 99, + EvaluatedReadLimit::Remaining(ReadLimit::Bytes(1)) + )] + #[case(ReadLimit::CountOrBytes(CountOrBytes{count: 50, bytes: 50}), 40, 45, EvaluatedReadLimit::Remaining(ReadLimit::CountOrBytes(CountOrBytes{count: 10, bytes: 5})))] + #[case(ReadLimit::CountOrBytes(CountOrBytes{count: 50, bytes: 50}), 51, 45, EvaluatedReadLimit::Exhausted)] + fn remaining( + #[case] old_limit: ReadLimit, + #[case] consumed_count: usize, + #[case] consumed_bytes: usize, + #[case] remaining_limit: EvaluatedReadLimit, + ) { + assert_eq!( + old_limit.remaining(consumed_count, consumed_bytes), + remaining_limit + ) + } +} diff --git a/common/src/record/batcher.rs b/common/src/record/batcher.rs new file mode 100644 index 00000000..7d5a8eff --- /dev/null +++ b/common/src/record/batcher.rs @@ -0,0 +1,347 @@ +use std::iter::FusedIterator; + +use bytes::Bytes; + +use super::InternalRecordError; +use crate::{ + caps, + read_extent::{EvaluatedReadLimit, ReadLimit, ReadUntil}, + record::{MeteredRecord, MeteredSequencedRecords, MeteredSize, SeqNum, Timestamp}, +}; + +#[derive(Debug)] +pub struct RecordBatch { + pub records: MeteredSequencedRecords, + pub is_terminal: bool, +} + +pub struct RecordBatcher +where + I: Iterator>, + E: Into, +{ + record_iterator: I, + buffered_records: MeteredSequencedRecords, + buffered_error: Option, + read_limit: EvaluatedReadLimit, + until: ReadUntil, + is_terminated: bool, +} + +fn make_records(read_limit: &EvaluatedReadLimit) -> MeteredSequencedRecords { + match read_limit { + EvaluatedReadLimit::Remaining(limit) => MeteredSequencedRecords::with_capacity( + limit.count().map_or(caps::RECORD_BATCH_MAX.count, |n| { + n.min(caps::RECORD_BATCH_MAX.count) + }), + ), + EvaluatedReadLimit::Exhausted => MeteredSequencedRecords::default(), + } +} + +impl RecordBatcher +where + I: Iterator>, + E: std::fmt::Debug + Into, +{ + pub fn new(record_iterator: I, read_limit: ReadLimit, until: ReadUntil) -> Self { + let read_limit = read_limit.remaining(0, 0); + Self { + record_iterator, + buffered_records: make_records(&read_limit), + buffered_error: None, + read_limit, + until, + is_terminated: false, + } + } + + fn iter_next(&mut self) -> Option> { + let EvaluatedReadLimit::Remaining(remaining_limit) = self.read_limit else { + return None; + }; + + let mut stashed_record = None; + while self.buffered_error.is_none() { + match self.record_iterator.next() { + Some(Ok((seq_num, timestamp, data))) => { + let record = match MeteredRecord::try_from(data) { + Ok(record) => record.sequenced(seq_num, timestamp), + Err(err) => { + self.buffered_error = Some(err); + break; + } + }; + + if remaining_limit.deny( + self.buffered_records.len() + 1, + self.buffered_records.metered_size() + record.metered_size(), + ) || self.until.deny(timestamp) + { + self.read_limit = EvaluatedReadLimit::Exhausted; + break; + } + + if self.buffered_records.len() == caps::RECORD_BATCH_MAX.count + || self.buffered_records.metered_size() + record.metered_size() + > caps::RECORD_BATCH_MAX.bytes + { + // It would would violate the per-batch limits. + stashed_record = Some(record); + break; + } + + self.buffered_records.push(record); + } + Some(Err(err)) => { + self.buffered_error = Some(err.into()); + break; + } + None => { + break; + } + } + } + if !self.buffered_records.is_empty() { + self.read_limit = match self.read_limit { + EvaluatedReadLimit::Remaining(read_limit) => read_limit.remaining( + self.buffered_records.len(), + self.buffered_records.metered_size(), + ), + EvaluatedReadLimit::Exhausted => EvaluatedReadLimit::Exhausted, + }; + let is_terminal = self.read_limit == EvaluatedReadLimit::Exhausted; + let records = std::mem::replace( + &mut self.buffered_records, + if is_terminal || self.buffered_error.is_some() { + MeteredSequencedRecords::default() + } else { + let mut buf = make_records(&self.read_limit); + if let Some(record) = stashed_record.take() { + buf.push(record); + } + buf + }, + ); + return Some(Ok(RecordBatch { + records, + is_terminal, + })); + } + if let Some(err) = self.buffered_error.take() { + return Some(Err(err)); + } + None + } +} + +impl Iterator for RecordBatcher +where + I: Iterator>, + E: std::fmt::Debug + Into, +{ + type Item = Result; + + fn next(&mut self) -> Option { + if self.is_terminated { + return None; + } + let item = self.iter_next(); + self.is_terminated = matches!(&item, None | Some(Err(_))); + item + } +} + +impl FusedIterator for RecordBatcher +where + I: Iterator>, + E: std::fmt::Debug + Into, +{ +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + caps, + read_extent::{ReadLimit, ReadUntil}, + record::{CommandRecord, Encodable, MeteredRecord, MeteredSize, Record}, + }; + use bytes::Bytes; + + type BatchItem = (SeqNum, Timestamp, Bytes); + + #[derive(Clone)] + struct TestRecord { + seq_num: SeqNum, + timestamp: Timestamp, + record: Record, + bytes: Bytes, + metered_size: usize, + } + + impl TestRecord { + fn trim(seq_num: SeqNum, timestamp: Timestamp) -> Self { + let record = Record::Command(CommandRecord::Trim(seq_num)); + let metered: MeteredRecord = record.clone().into(); + Self { + seq_num, + timestamp, + record, + bytes: metered.to_bytes(), + metered_size: metered.metered_size(), + } + } + + fn into_item(self) -> (SeqNum, Timestamp, Bytes) { + (self.seq_num, self.timestamp, self.bytes) + } + } + + fn to_ok(record: TestRecord) -> Result { + Ok(record.into_item()) + } + + fn assert_batch(batch: &RecordBatch, expected: &[TestRecord], is_terminal: bool) { + assert_eq!(batch.is_terminal, is_terminal); + assert_eq!(batch.records.len(), expected.len()); + let expected_size: usize = expected.iter().map(|r| r.metered_size).sum(); + assert_eq!(batch.records.metered_size(), expected_size); + for (actual, expected) in batch.records.iter().zip(expected.iter()) { + assert_eq!(actual.seq_num, expected.seq_num); + assert_eq!(actual.timestamp, expected.timestamp); + assert_eq!(actual.record, expected.record); + } + } + + #[test] + fn collects_records_until_iterator_ends() { + let expected = vec![ + TestRecord::trim(1, 10), + TestRecord::trim(2, 11), + TestRecord::trim(3, 12), + ]; + let iterator = expected.clone().into_iter().map(to_ok); + let mut batcher = RecordBatcher::new(iterator, ReadLimit::Unbounded, ReadUntil::Unbounded); + + let batch = batcher.next().expect("batch expected").expect("ok batch"); + assert_batch(&batch, &expected, false); + assert!(batcher.next().is_none()); + } + + #[test] + fn stops_at_count_read_limit() { + let expected = vec![ + TestRecord::trim(1, 10), + TestRecord::trim(2, 11), + TestRecord::trim(3, 12), + ]; + let iterator = expected.clone().into_iter().map(to_ok); + let mut batcher = RecordBatcher::new(iterator, ReadLimit::Count(2), ReadUntil::Unbounded); + + let batch = batcher.next().expect("batch expected").expect("ok batch"); + assert_batch(&batch, &expected[..2], true); + assert!(batcher.next().is_none()); + } + + #[test] + fn stops_at_byte_read_limit() { + let expected = vec![TestRecord::trim(1, 10), TestRecord::trim(2, 11)]; + let first_size = expected[0].metered_size; + let iterator = expected.clone().into_iter().map(to_ok); + let mut batcher = + RecordBatcher::new(iterator, ReadLimit::Bytes(first_size), ReadUntil::Unbounded); + + let batch = batcher.next().expect("batch expected").expect("ok batch"); + assert_batch(&batch, &expected[..1], true); + assert!(batcher.next().is_none()); + } + + #[test] + fn stops_at_timestamp_limit() { + let expected = vec![ + TestRecord::trim(1, 10), + TestRecord::trim(2, 19), + TestRecord::trim(3, 20), + ]; + let iterator = expected.clone().into_iter().map(to_ok); + let mut batcher = + RecordBatcher::new(iterator, ReadLimit::Unbounded, ReadUntil::Timestamp(20)); + + let batch = batcher.next().expect("batch expected").expect("ok batch"); + assert_batch(&batch, &expected[..2], true); + assert!(batcher.next().is_none()); + } + + #[test] + fn splits_batches_when_caps_are_hit() { + let mut records = Vec::with_capacity(caps::RECORD_BATCH_MAX.count + 1); + for index in 0..=(caps::RECORD_BATCH_MAX.count as SeqNum) { + records.push(TestRecord::trim(index, index + 10)); + } + let iterator = records.clone().into_iter().map(to_ok); + let mut batcher = RecordBatcher::new(iterator, ReadLimit::Unbounded, ReadUntil::Unbounded); + + let first_batch = batcher + .next() + .expect("first batch expected") + .expect("first batch ok"); + assert_batch( + &first_batch, + &records[..caps::RECORD_BATCH_MAX.count], + false, + ); + + let second_batch = batcher + .next() + .expect("second batch expected") + .expect("second batch ok"); + assert_batch( + &second_batch, + &records[caps::RECORD_BATCH_MAX.count..], + false, + ); + assert!(batcher.next().is_none()); + } + + #[test] + fn surfaces_decode_errors_after_draining_buffer() { + let records = vec![TestRecord::trim(1, 10), TestRecord::trim(2, 11)]; + let invalid_data = (3, 12, Bytes::new()); + + let iterator = records + .clone() + .into_iter() + .map(to_ok) + .chain(std::iter::once(Ok(invalid_data))); + let mut batcher = RecordBatcher::new(iterator, ReadLimit::Unbounded, ReadUntil::Unbounded); + + let batch = batcher.next().expect("batch expected").expect("ok batch"); + assert_batch(&batch, &records, false); + + let error = batcher + .next() + .expect("error expected") + .expect_err("expected decode error"); + assert!(matches!(error, InternalRecordError::Truncated("MagicByte"))); + assert!(batcher.next().is_none()); + } + + #[test] + fn surfaces_iterator_errors_immediately() { + let iterator = std::iter::once::>( + Err(InternalRecordError::InvalidValue("test", "boom")), + ); + let mut batcher = RecordBatcher::new(iterator, ReadLimit::Unbounded, ReadUntil::Unbounded); + + let error = batcher + .next() + .expect("error expected") + .expect_err("expected iterator error"); + assert!(matches!( + error, + InternalRecordError::InvalidValue("test", "boom") + )); + assert!(batcher.next().is_none()); + } +} diff --git a/common/src/record/command.rs b/common/src/record/command.rs new file mode 100644 index 00000000..3b4d53f2 --- /dev/null +++ b/common/src/record/command.rs @@ -0,0 +1,308 @@ +use std::{fmt, str::Utf8Error}; + +use bytes::{BufMut, Bytes}; +use compact_str::CompactString; +use enum_ordinalize::Ordinalize; + +use super::{Encodable, FencingTokenTooLongError, InternalRecordError, fencing::FencingToken}; +use crate::{deep_size::DeepSize, record::SeqNum}; + +pub const COMMAND_ID_FENCE: &[u8] = b"fence"; +pub const COMMAND_ID_TRIM: &[u8] = b"trim"; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Ordinalize)] +#[repr(u8)] +pub enum CommandOp { + Fence, + Trim, +} + +impl CommandOp { + pub fn to_id(self) -> &'static [u8] { + match self { + Self::Fence => COMMAND_ID_FENCE, + Self::Trim => COMMAND_ID_TRIM, + } + } + + pub fn from_id(name: &[u8]) -> Option { + match name { + COMMAND_ID_FENCE => Some(Self::Fence), + COMMAND_ID_TRIM => Some(Self::Trim), + _ => None, + } + } +} + +impl fmt::Display for CommandOp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = std::str::from_utf8(self.to_id()).map_err(|_| fmt::Error)?; + f.write_str(name) + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum CommandRecord { + Fence(FencingToken), + Trim(SeqNum), +} + +impl DeepSize for CommandRecord { + fn deep_size(&self) -> usize { + match self { + Self::Fence(token) => token.deep_size(), + Self::Trim(seq_num) => seq_num.deep_size(), + } + } +} + +impl CommandRecord { + pub fn op(&self) -> CommandOp { + match self { + CommandRecord::Fence(_) => CommandOp::Fence, + CommandRecord::Trim(_) => CommandOp::Trim, + } + } + + pub fn payload(&self) -> Bytes { + match self { + Self::Fence(token) => Bytes::copy_from_slice(token.as_bytes()), + Self::Trim(trim_point) => Bytes::copy_from_slice(&trim_point.to_be_bytes()), + } + } + + pub fn try_from_parts(op: CommandOp, payload: &[u8]) -> Result { + match op { + CommandOp::Fence => { + let token = CompactString::from_utf8(payload) + .map_err(CommandPayloadError::InvalidUtf8)? + .try_into()?; + Ok(Self::Fence(token)) + } + CommandOp::Trim => { + let trim_point = SeqNum::from_be_bytes( + payload + .try_into() + .map_err(|_| CommandPayloadError::TrimPointSize(payload.len()))?, + ); + Ok(Self::Trim(trim_point)) + } + } + } +} + +impl TryFrom<&[u8]> for CommandRecord { + type Error = InternalRecordError; + + fn try_from(record: &[u8]) -> Result { + if record.is_empty() { + return Err(InternalRecordError::Truncated("CommandOrdinal")); + } + let op = CommandOp::from_ordinal(record[0]).ok_or(InternalRecordError::InvalidValue( + "CommandOrdinal", + "unknown", + ))?; + Self::try_from_parts(op, &record[1..]).map_err(Into::into) + } +} + +impl Encodable for CommandRecord { + fn encoded_size(&self) -> usize { + 1 + match self { + CommandRecord::Fence(token) => token.len(), + CommandRecord::Trim(trim_point) => size_of_val(trim_point), + } + } + + fn encode_into(&self, buf: &mut impl BufMut) { + buf.put_u8(self.op().ordinal()); + match self { + CommandRecord::Fence(token) => { + buf.put_slice(token.as_bytes()); + } + CommandRecord::Trim(trim_point) => { + buf.put_u64(*trim_point); + } + } + } +} + +#[derive(Debug, PartialEq, thiserror::Error)] +pub enum CommandPayloadError { + #[error("Invalid UTF-8")] + InvalidUtf8(Utf8Error), + #[error(transparent)] + FencingTokenTooLong(#[from] FencingTokenTooLongError), + #[error("Earliest sequence number to trim to was {0} bytes, must be 8")] + TrimPointSize(usize), +} + +impl From for InternalRecordError { + fn from(e: CommandPayloadError) -> Self { + match e { + CommandPayloadError::InvalidUtf8(_) => { + InternalRecordError::InvalidValue("CommandPayload", "fencing token not valid utf8") + } + CommandPayloadError::FencingTokenTooLong(_) => { + InternalRecordError::InvalidValue("CommandPayload", "fencing token too long") + } + CommandPayloadError::TrimPointSize(_) => { + InternalRecordError::InvalidValue("CommandPayload", "trim point size") + } + } + } +} + +#[cfg(test)] +mod tests { + use compact_str::ToCompactString; + use enum_ordinalize::Ordinalize; + use proptest::prelude::*; + use rstest::rstest; + + use super::*; + + fn roundtrip(cmd: CommandRecord, expected_len: usize) { + assert_eq!(cmd.encoded_size(), expected_len); + let encoded = cmd.to_bytes(); + assert_eq!(encoded.len(), expected_len); + assert_eq!(CommandRecord::try_from(encoded.as_ref()), Ok(cmd)); + } + + #[test] + fn command_op_names() { + for cmd in CommandOp::VARIANTS { + let name = cmd.to_id(); + assert_eq!(CommandOp::from_id(name), Some(*cmd)); + } + assert_eq!(CommandOp::from_id(b""), None); + assert_eq!(CommandOp::from_id(b"invalid"), None); + } + + #[test] + fn fencing_token_invalid_utf8() { + assert!(matches!( + CommandRecord::try_from_parts(CommandOp::Fence, &[0xff]), + Err(CommandPayloadError::InvalidUtf8(_)) + )); + } + + #[test] + fn fencing_token_too_long() { + assert_eq!( + CommandRecord::try_from_parts( + CommandOp::Fence, + b"0123456789012345678901234567890123456789" + ), + Err(CommandPayloadError::FencingTokenTooLong( + FencingTokenTooLongError(40) + )) + ); + } + + #[rstest] + #[case::empty("")] + #[case::arbit("arbitrary")] + #[case::full("0123456789012345")] + fn fence_roundtrip(#[case] token: &str) { + let cmd = CommandRecord::Fence(FencingToken::try_from(token.to_compact_string()).unwrap()); + assert_eq!( + CommandRecord::try_from_parts(CommandOp::Fence, token.as_bytes()), + Ok(cmd.clone()) + ); + roundtrip(cmd, 1 + token.len()); + } + + #[rstest] + #[case::empty(b"")] + #[case::too_small(b"0123")] + #[case::too_big(b"0123456789")] + fn trim_point_size(#[case] payload: &[u8]) { + assert_eq!( + CommandRecord::try_from_parts(CommandOp::Trim, payload), + Err(CommandPayloadError::TrimPointSize(payload.len())) + ); + } + + proptest! { + #[test] + fn trim_roundtrip(trim_point in any::()) { + let cmd = CommandRecord::Trim(trim_point); + assert_eq!(CommandRecord::try_from_parts(CommandOp::Trim, trim_point.to_be_bytes().as_slice()), Ok(cmd.clone())); + roundtrip(cmd, 9); + } + } + + #[test] + fn decode_invalid_command() { + let try_convert = |raw: &[u8]| CommandRecord::try_from(raw); + assert_eq!( + try_convert(&[]), + Err(InternalRecordError::Truncated("CommandOrdinal")) + ); + assert_eq!( + try_convert(&[0xff]), + Err(InternalRecordError::InvalidValue( + "CommandOrdinal", + "unknown" + )) + ); + assert_eq!( + try_convert(&[CommandOp::Fence.ordinal(), 0xff, 0xff]), + Err(InternalRecordError::InvalidValue( + "CommandPayload", + "fencing token not valid utf8" + )) + ); + assert_eq!( + try_convert(&[ + CommandOp::Fence.ordinal(), + b'0', + b'1', + b'2', + b'3', + b'4', + b'5', + b'6', + b'7', + b'8', + b'9', + b'0', + b'1', + b'2', + b'3', + b'4', + b'5', + b'6', + b'7', + b'8', + b'9', + b'0', + b'1', + b'2', + b'3', + b'4', + b'5', + b'6', + b'7', + b'8', + b'9', + b'0', + b'1', + b'2', + b'3', + b'4', + b'5', + b'6', + b'7', + b'8', + b'9', + ]), + Err(CommandPayloadError::FencingTokenTooLong(FencingTokenTooLongError(40)).into()) + ); + assert_eq!( + try_convert(&[CommandOp::Trim.ordinal(), 0xff]), + Err(CommandPayloadError::TrimPointSize(1).into()) + ); + } +} diff --git a/common/src/record/envelope.rs b/common/src/record/envelope.rs new file mode 100644 index 00000000..a1132b2d --- /dev/null +++ b/common/src/record/envelope.rs @@ -0,0 +1,372 @@ +use std::num::NonZeroU8; + +use bytes::{Buf, BufMut, Bytes}; + +use super::{Encodable, Header, InternalRecordError, PublicRecordError}; +use crate::deep_size::DeepSize; + +#[derive(Debug, PartialEq, thiserror::Error)] +pub enum HeaderValidationError { + #[error("Too many")] + TooMany, + #[error("Too long")] + TooLong, + #[error("Empty name")] + NameEmpty, +} + +#[derive(PartialEq, Clone)] +pub struct EnvelopeRecord { + headers: Vec

, + body: Bytes, + encoding_info: EncodingInfo, +} + +impl std::fmt::Debug for EnvelopeRecord { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EnvelopeRecord") + .field("headers.len", &self.headers.len()) + .field("body.len", &self.body.len()) + .finish() + } +} + +impl DeepSize for EnvelopeRecord { + fn deep_size(&self) -> usize { + self.headers.deep_size() + self.body.deep_size() + } +} + +impl EnvelopeRecord { + pub fn headers(&self) -> &[Header] { + &self.headers + } + + pub fn body(&self) -> &Bytes { + &self.body + } + + pub fn into_parts(self) -> (Vec
, Bytes) { + (self.headers, self.body) + } + + pub fn try_from_parts(headers: Vec
, body: Bytes) -> Result { + let encoding_info = headers.as_slice().try_into()?; + Ok(Self { + headers, + body, + encoding_info, + }) + } +} + +impl Encodable for EnvelopeRecord { + fn encoded_size(&self) -> usize { + 1 + self.encoding_info.flag.num_headers_length_bytes as usize + + self.headers.len() + * (self.encoding_info.flag.name_length_bytes.get() as usize + + self.encoding_info.flag.value_length_bytes.get() as usize) + + self.encoding_info.headers_total_bytes + + self.body.len() + } + + fn encode_into(&self, buf: &mut impl BufMut) { + // Write prefix: flag and number of headers. + buf.put_u8(self.encoding_info.flag.into()); + buf.put_uint( + self.headers.len() as u64, + self.encoding_info.flag.num_headers_length_bytes as usize, + ); + // Write headers. + for Header { name, value } in &self.headers { + buf.put_uint( + name.len() as u64, + self.encoding_info.flag.name_length_bytes.get() as usize, + ); + buf.put_slice(name); + buf.put_uint( + value.len() as u64, + self.encoding_info.flag.value_length_bytes.get() as usize, + ); + buf.put_slice(value); + } + buf.put_slice(&self.body); + } +} + +impl TryFrom for EnvelopeRecord { + type Error = InternalRecordError; + + fn try_from(mut buf: Bytes) -> Result { + if buf.is_empty() { + return Err(InternalRecordError::InvalidValue("HeaderFlag", "missing")); + } + + let flag: HeaderFlag = buf + .get_u8() + .try_into() + .map_err(|info| InternalRecordError::InvalidValue("HeaderFlag", info))?; + if flag.num_headers_length_bytes == 0 { + // No headers. + return Ok(Self { + encoding_info: EMPTY_HEADERS_ENCODING_INFO, + headers: vec![], + body: buf, + }); + } + + let num_headers = buf.get_uint(flag.num_headers_length_bytes as usize); + + let mut headers_total_bytes = 0; + let mut headers: Vec
= Vec::with_capacity(num_headers as usize); + for _ in 0..num_headers { + let name_len = buf.get_uint(flag.name_length_bytes.get() as usize); + let name = buf.copy_to_bytes(name_len as usize); + let value_len = buf.get_uint(flag.value_length_bytes.get() as usize); + let value = buf.copy_to_bytes(value_len as usize); + headers_total_bytes += name.len() + value.len(); + headers.push(Header { name, value }) + } + + Ok(Self { + encoding_info: EncodingInfo { + headers_total_bytes, + flag, + }, + headers, + body: buf, + }) + } +} + +const EMPTY_HEADER_FLAG: HeaderFlag = HeaderFlag { + num_headers_length_bytes: 0, + name_length_bytes: NonZeroU8::new(1).unwrap(), + value_length_bytes: NonZeroU8::new(1).unwrap(), +}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +struct HeaderFlag { + num_headers_length_bytes: u8, + name_length_bytes: NonZeroU8, + value_length_bytes: NonZeroU8, +} + +impl From for u8 { + fn from(value: HeaderFlag) -> Self { + (value.num_headers_length_bytes << 4) + | ((value.name_length_bytes.get() - 1) << 2) + | (value.value_length_bytes.get() - 1) + } +} + +impl TryFrom for HeaderFlag { + type Error = &'static str; + + fn try_from(value: u8) -> Result { + if (value & (0b11u8 << 6)) != 0u8 { + return Err("reserved bit set"); + } + Ok(Self { + num_headers_length_bytes: (0b110000 & value) >> 4, + name_length_bytes: NonZeroU8::new(((0b1100 & value) >> 2) + 1).unwrap(), + value_length_bytes: NonZeroU8::new((0b11 & value) + 1).unwrap(), + }) + } +} + +const EMPTY_HEADERS_ENCODING_INFO: EncodingInfo = EncodingInfo { + headers_total_bytes: 0, + flag: EMPTY_HEADER_FLAG, +}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +struct EncodingInfo { + headers_total_bytes: usize, + flag: HeaderFlag, +} + +impl TryFrom<&[Header]> for EncodingInfo { + type Error = HeaderValidationError; + + fn try_from(headers: &[Header]) -> Result { + // Given number of KV pairs, determine how many bytes are required for storing + // the length number. + fn size_bytes_headers_len(elems: u64) -> Result { + let size = 8 - elems.leading_zeros() / 8; + if size > 3 { + Err(HeaderValidationError::TooMany) + } else { + Ok(size as u8) + } + } + + // Given max length of a name (key) or value, determine how many bytes are required for + // storing this number. + fn size_bytes_name_value_len(elems: u64) -> Result { + if elems == 0 { + return Ok(NonZeroU8::new(1u8).unwrap()); + } + let size = 8 - (elems.leading_zeros() / 8); + if size > 4 { + Err(HeaderValidationError::TooLong) + } else { + Ok(NonZeroU8::new(size as u8).unwrap()) + } + } + + if headers.is_empty() { + return Ok(EMPTY_HEADERS_ENCODING_INFO); + } + + let (headers_total_bytes, name_max, value_max) = headers.iter().try_fold( + (0usize, 0usize, 0usize), + |(size_bytes_acc, name_max, value_max), Header { name, value }| { + if name.is_empty() { + return Err(HeaderValidationError::NameEmpty); + } + let name_len = name.len(); + let value_len = value.len(); + Ok(( + size_bytes_acc + name_len + value_len, + name_max.max(name_len), + value_max.max(value_len), + )) + }, + )?; + + let num_headers_length_bytes = size_bytes_headers_len(headers.len() as u64)?; + let name_length_bytes = size_bytes_name_value_len(name_max as u64)?; + let value_length_bytes = size_bytes_name_value_len(value_max as u64)?; + + Ok(Self { + headers_total_bytes, + flag: HeaderFlag { + num_headers_length_bytes, + name_length_bytes, + value_length_bytes, + }, + }) + } +} + +#[cfg(test)] +mod test { + use std::num::NonZeroU8; + + use bytes::Bytes; + + use super::{Encodable as _, EnvelopeRecord, Header, HeaderFlag}; + + fn roundtrip_parts(headers: Vec
, body: Bytes) { + let encoded: Bytes = EnvelopeRecord::try_from_parts(headers.clone(), body.clone()) + .unwrap() + .to_bytes(); + let decoded = EnvelopeRecord::try_from(encoded).unwrap(); + assert_eq!(decoded.headers(), headers); + assert_eq!(decoded.body(), &body); + } + + #[test] + fn framed_with_headers() { + roundtrip_parts( + vec![ + Header { + name: Bytes::from("key_1"), + value: Bytes::from("val_1"), + }, + Header { + name: Bytes::from("key_2"), + value: Bytes::from("val_2"), + }, + Header { + name: Bytes::from("key_3"), + value: Bytes::from("val_3"), + }, + Header { + name: Bytes::from("key_4"), + value: Bytes::from("val_4"), + }, + ], + Bytes::from("hello"), + ); + } + + #[test] + fn framed_no_headers() { + roundtrip_parts(vec![], Bytes::from("hello")); + } + + #[test] + fn framed_duplicate_keys() { + // Duplicate keys preserved in original order. + roundtrip_parts( + vec![ + Header { + name: Bytes::from("b"), + value: Bytes::from("val_1"), + }, + Header { + name: Bytes::from("b"), + value: Bytes::from("val_2"), + }, + Header { + name: Bytes::from("a"), + value: Bytes::from("val_3"), + }, + ], + Bytes::from("hello"), + ); + } + + #[test] + fn flag_ex1() { + assert_eq!( + Ok(HeaderFlag { + num_headers_length_bytes: 2, + name_length_bytes: NonZeroU8::new(1).unwrap(), + value_length_bytes: NonZeroU8::new(1).unwrap(), + }), + 0b00100000.try_into() + ); + + let u8_repr: u8 = HeaderFlag { + num_headers_length_bytes: 2, + name_length_bytes: NonZeroU8::new(1).unwrap(), + value_length_bytes: NonZeroU8::new(1).unwrap(), + } + .into(); + assert_eq!(u8_repr, 0b00100000); + } + + #[test] + fn flag_ex2() { + assert_eq!( + Ok(HeaderFlag { + num_headers_length_bytes: 1, + name_length_bytes: NonZeroU8::new(1).unwrap(), + value_length_bytes: NonZeroU8::new(1).unwrap(), + }), + 0b00010000.try_into() + ); + + let u8_repr: u8 = HeaderFlag { + num_headers_length_bytes: 1, + name_length_bytes: NonZeroU8::new(1).unwrap(), + value_length_bytes: NonZeroU8::new(1).unwrap(), + } + .into(); + assert_eq!(u8_repr, 0b00010000); + } + + #[test] + fn empty_envelope_size() { + assert_eq!( + 1, + EnvelopeRecord::try_from_parts(vec![], Bytes::new()) + .unwrap() + .to_bytes() + .len() + ); + } +} diff --git a/common/src/record/fencing.rs b/common/src/record/fencing.rs new file mode 100644 index 00000000..64273c85 --- /dev/null +++ b/common/src/record/fencing.rs @@ -0,0 +1,97 @@ +use std::{ops::Deref, str::FromStr}; + +use compact_str::{CompactString, ToCompactString}; + +use crate::deep_size::DeepSize; + +pub const MAX_FENCING_TOKEN_LENGTH: usize = 36; + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[error("Fencing token was longer than {MAX_FENCING_TOKEN_LENGTH} bytes in length: {0}")] +pub struct FencingTokenTooLongError(pub usize); + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct FencingToken(CompactString); + +#[cfg(feature = "utoipa")] +impl utoipa::PartialSchema for FencingToken { + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::Object::builder() + .schema_type(utoipa::openapi::Type::String) + .max_length(Some(MAX_FENCING_TOKEN_LENGTH)) + .into() + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::ToSchema for FencingToken {} + +impl serde::Serialize for FencingToken { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for FencingToken { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = CompactString::deserialize(deserializer)?; + FencingToken::try_from(s).map_err(serde::de::Error::custom) + } +} + +impl std::fmt::Display for FencingToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for FencingToken { + type Error = FencingTokenTooLongError; + + fn try_from(input: CompactString) -> Result { + if input.len() > MAX_FENCING_TOKEN_LENGTH { + return Err(FencingTokenTooLongError(input.len())); + } + Ok(FencingToken(input)) + } +} + +impl FromStr for FencingToken { + type Err = FencingTokenTooLongError; + + fn from_str(s: &str) -> Result { + s.to_compact_string().try_into() + } +} + +impl From for CompactString { + fn from(token: FencingToken) -> Self { + token.0 + } +} + +impl AsRef for FencingToken { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for FencingToken { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DeepSize for FencingToken { + fn deep_size(&self) -> usize { + self.0.len() + } +} diff --git a/common/src/record/metering.rs b/common/src/record/metering.rs new file mode 100644 index 00000000..6ebeec4d --- /dev/null +++ b/common/src/record/metering.rs @@ -0,0 +1,132 @@ +pub trait MeteredSize { + /// Return the metered size of a record or batch of records. + fn metered_size(&self) -> usize; +} + +impl MeteredSize for &[T] { + fn metered_size(&self) -> usize { + self.iter().fold(0, |acc, item| acc + item.metered_size()) + } +} + +impl MeteredSize for Vec { + fn metered_size(&self) -> usize { + self.as_slice().metered_size() + } +} + +pub struct Metered { + pub(super) size: usize, + pub(super) inner: T, +} + +impl Metered { + pub fn into_inner(self) -> T { + self.inner + } +} + +impl std::fmt::Debug for Metered +where + T: std::fmt::Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Metered") + .field("size", &self.size) + .field("inner", &self.inner) + .finish() + } +} + +impl PartialEq for Metered +where + T: PartialEq, +{ + fn eq(&self, other: &Metered) -> bool { + self.size == other.size && self.inner == other.inner + } +} + +impl Eq for Metered where T: Eq {} + +impl std::ops::Deref for Metered { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl From for Metered +where + T: MeteredSize, +{ + fn from(inner: T) -> Self { + Self { + size: inner.metered_size(), + inner, + } + } +} + +impl Default for Metered +where + T: Default + MeteredSize, +{ + fn default() -> Self { + T::default().into() + } +} + +impl Clone for Metered +where + T: Clone, +{ + fn clone(&self) -> Self { + Self { + size: self.size, + inner: self.inner.clone(), + } + } +} + +impl MeteredSize for Metered { + fn metered_size(&self) -> usize { + self.size + } +} + +impl Metered> +where + T: MeteredSize, +{ + pub fn with_capacity(capacity: usize) -> Self { + Self { + size: 0, + inner: Vec::with_capacity(capacity), + } + } + + pub fn reserve(&mut self, additional: usize) { + self.inner.reserve(additional); + } + + pub fn push(&mut self, item: Metered) { + self.inner.push(item.inner); + self.size += item.size; + } + + pub fn append(&mut self, other: Self) { + self.inner.extend(other.inner); + self.size += other.size; + } +} + +impl IntoIterator for Metered> { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.inner.into_iter() + } +} diff --git a/common/src/record/mod.rs b/common/src/record/mod.rs new file mode 100644 index 00000000..39900760 --- /dev/null +++ b/common/src/record/mod.rs @@ -0,0 +1,458 @@ +mod batcher; +mod command; +mod envelope; +mod fencing; +mod metering; + +pub use batcher::{RecordBatch, RecordBatcher}; +use bytes::{Buf, BufMut, Bytes, BytesMut}; +pub use command::CommandRecord; +use command::{CommandOp, CommandPayloadError}; +use enum_ordinalize::Ordinalize; +pub use envelope::EnvelopeRecord; +use envelope::HeaderValidationError; +pub use fencing::{FencingToken, FencingTokenTooLongError, MAX_FENCING_TOKEN_LENGTH}; +pub use metering::{Metered, MeteredSize}; + +use crate::deep_size::DeepSize; + +pub type SeqNum = u64; +pub type NonZeroSeqNum = std::num::NonZeroU64; +pub type Timestamp = u64; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum InternalRecordError { + #[error("Truncated: {0}")] + Truncated(&'static str), + #[error("Invalid value [{0}]: {1}")] + InvalidValue(&'static str, &'static str), +} + +/// `impl Display` can be safely returned to the client without leaking internal details. +#[derive(Debug, PartialEq, thiserror::Error)] +pub enum PublicRecordError { + #[error("Unknown command")] + UnknownCommand, + #[error("Invalid `{0}` command: {1}")] + CommandPayload(CommandOp, CommandPayloadError), + #[error("Invalid header: {0}")] + Header(#[from] HeaderValidationError), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Header { + pub name: Bytes, + pub value: Bytes, +} + +impl DeepSize for Header { + fn deep_size(&self) -> usize { + self.name.len() + self.value.len() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Ordinalize)] +#[repr(u8)] +pub enum RecordType { + Command = 1, + Envelope = 2, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct MagicByte { + pub record_type: RecordType, + pub metered_size_varlen: u8, +} + +/// Read bytes to u32 in big-endian order. +fn read_vint_u32_be(bytes: &[u8]) -> u32 { + if bytes.len() > size_of::() || bytes.is_empty() { + panic!("invalid variable int bytes = {} len", bytes.len()) + } + let mut acc: u32 = 0; + for &byte in bytes { + acc = (acc << 8) | byte as u32; + } + acc +} + +pub fn try_metered_size(record_bytes: &[u8]) -> Result { + let magic_byte_u8 = *record_bytes.first().ok_or("byte range is empty")?; + let magic_byte = MagicByte::try_from(magic_byte_u8)?; + Ok(read_vint_u32_be( + record_bytes + .get(1..1 + magic_byte.metered_size_varlen as usize) + .ok_or("byte range doesn't include bytes for metered size")?, + )) +} + +impl MeteredSize for Record { + fn metered_size(&self) -> usize { + 8 + (match self { + Record::Command(command) => 2 + command.op().to_id().len() + command.payload().len(), + Record::Envelope(envelope) => { + (2 * envelope.headers().len()) + + envelope.headers().deep_size() + + envelope.body().len() + } + }) + } +} + +impl TryFrom for MagicByte { + type Error = &'static str; + + fn try_from(value: u8) -> Result { + let record_type = + RecordType::from_ordinal(value & 0b111).ok_or("invalid record type ordinal")?; + Ok(Self { + record_type, + metered_size_varlen: match (value >> 3) & 0b11 { + 0 => 1u8, + 1 => 2u8, + 2 => 3u8, + _ => Err("invalid metered_size_varlen")?, + }, + }) + } +} + +impl From for u8 { + fn from(value: MagicByte) -> Self { + ((value.metered_size_varlen - 1) << 3) | value.record_type as u8 + } +} + +#[derive(Debug, PartialEq, Clone)] +pub enum Record { + Command(CommandRecord), + Envelope(EnvelopeRecord), +} + +impl DeepSize for Record { + fn deep_size(&self) -> usize { + match self { + Self::Command(c) => c.deep_size(), + Self::Envelope(e) => e.deep_size(), + } + } +} + +impl Record { + pub fn try_from_parts(headers: Vec
, body: Bytes) -> Result { + if headers.len() == 1 { + let header = &headers[0]; + if header.name.is_empty() { + let op = CommandOp::from_id(header.value.as_ref()) + .ok_or(PublicRecordError::UnknownCommand)?; + let command_record = CommandRecord::try_from_parts(op, body.as_ref()) + .map_err(|e| PublicRecordError::CommandPayload(op, e))?; + return Ok(Self::Command(command_record)); + } + } + let envelope = EnvelopeRecord::try_from_parts(headers, body)?; + Ok(Self::Envelope(envelope)) + } + + pub fn sequenced(self, seq_num: SeqNum, timestamp: Timestamp) -> SequencedRecord { + SequencedRecord { + seq_num, + timestamp, + record: self, + } + } + + pub fn into_parts(self) -> (Vec
, Bytes) { + match self { + Record::Envelope(e) => e.into_parts(), + Record::Command(c) => { + let op = c.op(); + let header = Header { + name: Bytes::new(), + value: Bytes::from_static(op.to_id()), + }; + (vec![header], c.payload()) + } + } + } +} + +pub fn decode_if_command_record( + record: &[u8], +) -> Result, InternalRecordError> { + if record.is_empty() { + return Err(InternalRecordError::Truncated("MagicByte")); + } + let magic_byte = MagicByte::try_from(record[0]) + .map_err(|msg| InternalRecordError::InvalidValue("MagicByte", msg))?; + match magic_byte.record_type { + RecordType::Command => { + let offset = 1 + magic_byte.metered_size_varlen as usize; + if record.len() < offset { + return Err(InternalRecordError::Truncated("MeteredSize")); + } + Ok(Some(CommandRecord::try_from(&record[offset..])?)) + } + RecordType::Envelope => Ok(None), + } +} + +pub trait Encodable { + fn to_bytes(&self) -> Bytes { + let expected_size = self.encoded_size(); + let mut buf = BytesMut::with_capacity(expected_size); + self.encode_into(&mut buf); + assert_eq!(buf.len(), expected_size, "no reallocation"); + buf.freeze() + } + + fn encoded_size(&self) -> usize; + + fn encode_into(&self, buf: &mut impl BufMut); +} + +impl Encodable for MeteredRecord { + fn encoded_size(&self) -> usize { + 1 + self.magic_byte().metered_size_varlen as usize + + match &**self { + Record::Command(r) => r.encoded_size(), + Record::Envelope(r) => r.encoded_size(), + } + } + + fn encode_into(&self, buf: &mut impl BufMut) { + let magic_byte = self.magic_byte(); + buf.put_u8(magic_byte.into()); + buf.put_uint( + self.metered_size() as u64, + magic_byte.metered_size_varlen as usize, + ); + match &**self { + Record::Command(r) => r.encode_into(buf), + Record::Envelope(r) => r.encode_into(buf), + } + } +} + +#[derive(Debug, Clone)] +pub struct SequencedRecord { + pub seq_num: SeqNum, + pub timestamp: Timestamp, + pub record: Record, +} + +impl MeteredSize for SequencedRecord { + fn metered_size(&self) -> usize { + self.record.metered_size() + } +} + +impl DeepSize for SequencedRecord { + fn deep_size(&self) -> usize { + self.seq_num.deep_size() + self.timestamp.deep_size() + self.record.deep_size() + } +} + +pub type MeteredRecord = Metered; + +impl MeteredRecord { + pub fn sequenced(self, seq_num: SeqNum, timestamp: Timestamp) -> MeteredSequencedRecord { + MeteredSequencedRecord { + size: self.metered_size(), + inner: self.inner.sequenced(seq_num, timestamp), + } + } + + fn magic_byte(&self) -> MagicByte { + let metered_size = self.metered_size(); + let metered_size_varlen = 8 - (metered_size.leading_zeros() / 8) as u8; + if metered_size_varlen > 3 { + panic!("illegal metered size varlen {metered_size} for record") + } + let record_type = match self.inner { + Record::Command(_) => RecordType::Command, + Record::Envelope(_) => RecordType::Envelope, + }; + MagicByte { + record_type, + metered_size_varlen, + } + } +} + +impl TryFrom for MeteredRecord { + type Error = InternalRecordError; + + fn try_from(mut buf: Bytes) -> Result { + if buf.is_empty() { + return Err(InternalRecordError::Truncated("MagicByte")); + } + let magic_byte = MagicByte::try_from(buf.get_u8()) + .map_err(|msg| InternalRecordError::InvalidValue("MagicByte", msg))?; + + let metered_size = buf.get_uint(magic_byte.metered_size_varlen as usize) as usize; + + Ok(Self { + size: metered_size, + inner: match magic_byte.record_type { + RecordType::Command => Record::Command(CommandRecord::try_from(buf.as_ref())?), + RecordType::Envelope => Record::Envelope(EnvelopeRecord::try_from(buf)?), + }, + }) + } +} + +pub type MeteredSequencedRecord = Metered; + +pub type MeteredSequencedRecords = Metered>; + +#[cfg(test)] +mod test { + use proptest::prelude::*; + use rstest::rstest; + + use super::*; + + fn bytes_strategy(allow_empty: bool) -> impl Strategy { + prop_oneof![ + prop::collection::vec(any::(), (if allow_empty { 0 } else { 1 })..10) + .prop_map(Bytes::from), + prop::collection::vec(any::(), 100..1000).prop_map(Bytes::from), + ] + } + + fn header_strategy() -> impl Strategy { + (bytes_strategy(false), bytes_strategy(true)) + .prop_map(|(name, value)| Header { name, value }) + } + + fn headers_strategy() -> impl Strategy> { + prop_oneof![ + prop::collection::vec(header_strategy(), 0..10), + prop::collection::vec(header_strategy(), 200..300), + ] + } + + proptest!( + #![proptest_config(ProptestConfig::with_cases(10))] + #[test] + fn roundtrip_envelope( + seq_num in any::(), + timestamp in any::(), + headers in headers_strategy(), + body in bytes_strategy(true), + ) { + let record = Record::try_from_parts(headers, body).unwrap(); + let metered_record: MeteredRecord = record.clone().into(); + let as_bytes = metered_record.clone().to_bytes(); + let decoded_record = MeteredRecord::try_from(as_bytes).unwrap(); + prop_assert_eq!(&decoded_record, &metered_record); + let sequenced = decoded_record.sequenced(seq_num, timestamp); + assert_eq!(sequenced.seq_num, seq_num); + assert_eq!(sequenced.timestamp, timestamp); + assert_eq!(sequenced.record, record); + } + ); + + proptest!( + #![proptest_config(ProptestConfig::with_cases(10))] + #[test] + fn roundtrip_metered( + headers in headers_strategy(), + body in bytes_strategy(true), + ) { + let record = Record::try_from_parts(headers.clone(), body.clone()).unwrap(); + let data_as_bytes = MeteredRecord::from(record.clone()).to_bytes(); + assert_eq!(record.metered_size(), try_metered_size(data_as_bytes.as_ref()).unwrap() as usize); + } + ); + + #[test] + fn empty_header_name_solo() { + let headers = vec![Header { + name: Bytes::new(), + value: Bytes::from("hi"), + }]; + let body = Bytes::from("hello"); + assert_eq!( + Record::try_from_parts(headers, body), + Err(PublicRecordError::UnknownCommand) + ); + } + + #[test] + fn empty_header_name_among_others() { + let headers = vec![ + Header { + name: Bytes::from("boku"), + value: Bytes::from("hi"), + }, + Header { + name: Bytes::new(), + value: Bytes::from("hi"), + }, + ]; + let body = Bytes::from("hello"); + assert_eq!( + Record::try_from_parts(headers, body), + Err(PublicRecordError::Header(HeaderValidationError::NameEmpty)) + ); + } + + #[rstest] + #[case::fence_empty(b"fence", b"")] + #[case::fence_uuid(b"fence", b"my-special-uuid")] + #[should_panic(expected = "FencingTokenTooLongError(49)")] + #[case::fence_too_long(b"fence", b"toolongtoolongtoolongtoolongtoolongtoolongtoolong")] + #[case::trim_0(b"trim", b"\x00\x00\x00\x00\x00\x00\x00\x00")] + #[should_panic(expected = "TrimPointSize(0)")] + #[case::trim_empty(b"trim", b"")] + #[should_panic(expected = "TrimPointSize(9)")] + #[case::trim_overflow(b"trim", b"\x00\x00\x00\x00\x00\x00\x00\x00\x00")] + fn command_records(#[case] op: &'static [u8], #[case] payload: &'static [u8]) { + let headers = vec![Header { + name: Bytes::new(), + value: Bytes::from_static(op), + }]; + let body = Bytes::from_static(payload); + let record = Record::try_from_parts(headers.clone(), body.clone()).unwrap(); + let record_metered = record.metered_size(); + match &record { + Record::Command(cmd) => { + assert_eq!(cmd.op().to_id(), op); + assert_eq!(cmd.payload().as_ref(), payload); + } + Record::Envelope(e) => panic!("Command expected, got Envelope: {e:?}"), + } + let sequenced_record = record.sequenced(42, 100_000); + let sequenced_metered = sequenced_record.metered_size(); + assert_eq!(record_metered, sequenced_metered); + assert_eq!(sequenced_record.seq_num, 42); + assert_eq!(sequenced_record.timestamp, 100_000); + assert_eq!( + sequenced_record.record, + Record::try_from_parts(headers, body).unwrap() + ); + } + + #[rstest] + #[case(0b0000_0010, MagicByte { record_type: RecordType::Envelope, metered_size_varlen: 1})] + #[case(0b0001_0010, MagicByte { record_type: RecordType::Envelope, metered_size_varlen: 3})] + #[case(0b0000_1001, MagicByte { record_type: RecordType::Command, metered_size_varlen: 2})] + #[should_panic(expected = "invalid record type ordinal")] + #[case(0b0000_1101, MagicByte { record_type: RecordType::Command, metered_size_varlen: 2})] + fn magic_byte_parsing(#[case] as_u8: u8, #[case] magic_byte: MagicByte) { + assert_eq!(MagicByte::try_from(as_u8).unwrap(), magic_byte); + assert_eq!(u8::from(magic_byte), as_u8); + } + + #[test] + fn test_read_varint() { + let data = [0u8, 0, 0, 1, 0, 0, 0]; + + assert_eq!(read_vint_u32_be(&data[..4]), 1u32); + assert_eq!(read_vint_u32_be(&data[2..5]), 2u32.pow(8)); + assert_eq!(read_vint_u32_be(&data[2..6]), 2u32.pow(16)); + assert_eq!(read_vint_u32_be(&data[3..]), 2u32.pow(24)); + } +} diff --git a/common/src/types/access.rs b/common/src/types/access.rs new file mode 100644 index 00000000..fab24285 --- /dev/null +++ b/common/src/types/access.rs @@ -0,0 +1,324 @@ +use std::{marker::PhantomData, ops::Deref, str::FromStr}; + +use compact_str::{CompactString, ToCompactString}; +use enumset::{EnumSet, EnumSetType}; + +use super::{ + ValidationError, + basin::{BasinName, BasinNamePrefix}, + stream::{StreamName, StreamNamePrefix}, + strings::{IdProps, PrefixProps, StartAfterProps, StrProps}, +}; +use crate::{caps, types::resources::ListItemsRequest}; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize) +)] +pub struct AccessTokenIdStr(CompactString, PhantomData); + +#[cfg(feature = "utoipa")] +impl utoipa::PartialSchema for AccessTokenIdStr +where + T: StrProps, +{ + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::Object::builder() + .schema_type(utoipa::openapi::Type::String) + .min_length((!T::IS_PREFIX).then_some(1)) + .max_length(Some(Self::MAX_LENGTH)) + .into() + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::ToSchema for AccessTokenIdStr where T: StrProps {} + +impl serde::Serialize for AccessTokenIdStr { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de, T: StrProps> serde::Deserialize<'de> for AccessTokenIdStr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = CompactString::deserialize(deserializer)?; + s.try_into().map_err(serde::de::Error::custom) + } +} + +impl AccessTokenIdStr { + const MAX_LENGTH: usize = caps::MAX_TOKEN_ID_LEN; +} + +impl AsRef for AccessTokenIdStr { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for AccessTokenIdStr { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom for AccessTokenIdStr { + type Error = ValidationError; + + fn try_from(name: CompactString) -> Result { + if !T::IS_PREFIX && name.is_empty() { + return Err(format!("Access token {} must not be empty", T::FIELD_NAME).into()); + } + + if name.len() > Self::MAX_LENGTH { + return Err(format!( + "Access token {} must not exceed {} characters in length", + T::FIELD_NAME, + Self::MAX_LENGTH + ) + .into()); + } + + Ok(Self(name, PhantomData)) + } +} + +impl FromStr for AccessTokenIdStr { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + s.to_compact_string().try_into() + } +} + +impl std::fmt::Debug for AccessTokenIdStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::fmt::Display for AccessTokenIdStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From> for CompactString { + fn from(value: AccessTokenIdStr) -> Self { + value.0 + } +} + +pub type AccessTokenId = AccessTokenIdStr; + +pub type AccessTokenIdPrefix = AccessTokenIdStr; + +impl Default for AccessTokenIdPrefix { + fn default() -> Self { + AccessTokenIdStr(CompactString::default(), PhantomData) + } +} + +impl From for AccessTokenIdPrefix { + fn from(value: AccessTokenId) -> Self { + Self(value.0, PhantomData) + } +} + +pub type AccessTokenIdStartAfter = AccessTokenIdStr; + +impl Default for AccessTokenIdStartAfter { + fn default() -> Self { + AccessTokenIdStr(CompactString::default(), PhantomData) + } +} + +#[derive(Debug, Hash, EnumSetType, strum::EnumCount)] +pub enum Operation { + ListBasins = 1, + CreateBasin = 2, + DeleteBasin = 3, + ReconfigureBasin = 4, + GetBasinConfig = 5, + IssueAccessToken = 6, + RevokeAccessToken = 7, + ListAccessTokens = 8, + ListStreams = 9, + CreateStream = 10, + DeleteStream = 11, + GetStreamConfig = 12, + ReconfigureStream = 13, + CheckTail = 14, + Append = 15, + Read = 16, + Trim = 17, + Fence = 18, + AccountMetrics = 19, + BasinMetrics = 20, + StreamMetrics = 21, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize) +)] +pub enum ResourceSet { + #[default] + None, + Exact(E), + Prefix(P), +} + +pub type BasinResourceSet = ResourceSet; +pub type StreamResourceSet = ResourceSet; +pub type AccessTokenResourceSet = ResourceSet; + +#[derive(Debug, Clone, Copy, Default)] +pub struct ReadWritePermissions { + pub read: bool, + pub write: bool, +} + +impl ReadWritePermissions { + pub fn new() -> Self { + Self::default() + } + + pub fn with_read(self, read: bool) -> Self { + Self { read, ..self } + } + + pub fn with_write(self, write: bool) -> Self { + Self { write, ..self } + } +} + +#[derive(Debug, Clone, Default)] +pub struct PermittedOperationGroups { + pub account: ReadWritePermissions, + pub basin: ReadWritePermissions, + pub stream: ReadWritePermissions, +} + +impl PermittedOperationGroups { + pub fn new() -> Self { + Self::default() + } + + pub fn with_account(self, account: ReadWritePermissions) -> Self { + Self { account, ..self } + } + + pub fn with_basin(self, basin: ReadWritePermissions) -> Self { + Self { basin, ..self } + } + + pub fn with_stream(self, stream: ReadWritePermissions) -> Self { + Self { stream, ..self } + } +} + +#[derive(Debug, Clone, Default)] +pub struct AccessTokenScope { + pub basins: BasinResourceSet, + pub streams: StreamResourceSet, + pub access_tokens: AccessTokenResourceSet, + pub op_groups: PermittedOperationGroups, + pub ops: EnumSet, +} + +impl AccessTokenScope { + pub fn new() -> Self { + Self::default() + } + + pub fn with_basins(self, basins: BasinResourceSet) -> Self { + Self { basins, ..self } + } + + pub fn with_streams(self, streams: StreamResourceSet) -> Self { + Self { streams, ..self } + } + + pub fn with_tokens(self, access_tokens: AccessTokenResourceSet) -> Self { + Self { + access_tokens, + ..self + } + } + + pub fn with_op_groups(self, op_groups: PermittedOperationGroups) -> Self { + Self { op_groups, ..self } + } + + pub fn with_ops(self, ops: impl IntoIterator) -> Self { + Self { + ops: ops.into_iter().collect(), + ..self + } + } + + pub fn with_op(self, op: Operation) -> Self { + let mut ops = self.ops; + ops.insert(op); + Self { ops, ..self } + } +} + +#[derive(Debug, Clone)] +pub struct AccessTokenInfo { + pub id: AccessTokenId, + pub expires_at: time::OffsetDateTime, + pub auto_prefix_streams: bool, + pub scope: AccessTokenScope, +} + +#[derive(Debug, Clone)] +pub struct IssueAccessTokenRequest { + pub id: AccessTokenId, + pub expires_at: Option, + pub auto_prefix_streams: bool, + pub scope: AccessTokenScope, +} + +impl IssueAccessTokenRequest { + pub fn new(id: impl AsRef) -> Result { + Ok(Self { + id: id.as_ref().parse()?, + expires_at: None, + auto_prefix_streams: false, + scope: AccessTokenScope::default(), + }) + } + + pub fn with_expires_at(self, expires_at: time::OffsetDateTime) -> Self { + Self { + expires_at: Some(expires_at), + ..self + } + } + + pub fn with_auto_prefix_streams(self, auto_prefix_streams: bool) -> Self { + Self { + auto_prefix_streams, + ..self + } + } + + pub fn with_scope(self, scope: AccessTokenScope) -> Self { + Self { scope, ..self } + } +} + +pub type ListAccessTokensRequest = ListItemsRequest; diff --git a/common/src/types/basin.rs b/common/src/types/basin.rs new file mode 100644 index 00000000..32e18048 --- /dev/null +++ b/common/src/types/basin.rs @@ -0,0 +1,204 @@ +use std::{marker::PhantomData, ops::Deref, str::FromStr}; + +use compact_str::{CompactString, ToCompactString}; + +use super::{ + ValidationError, + strings::{NameProps, PrefixProps, StartAfterProps, StrProps}, +}; +use crate::{caps, types::resources::ListItemsRequest}; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize) +)] +pub struct BasinNameStr(CompactString, PhantomData); + +#[cfg(feature = "utoipa")] +impl utoipa::PartialSchema for BasinNameStr +where + T: StrProps, +{ + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::Object::builder() + .schema_type(utoipa::openapi::Type::String) + .min_length((!T::IS_PREFIX).then_some(1)) + .max_length(Some(Self::MAX_LENGTH)) + .into() + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::ToSchema for BasinNameStr where T: StrProps {} + +impl serde::Serialize for BasinNameStr { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de, T: StrProps> serde::Deserialize<'de> for BasinNameStr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = CompactString::deserialize(deserializer)?; + s.try_into().map_err(serde::de::Error::custom) + } +} + +impl BasinNameStr { + const MIN_LENGTH: usize = 8; + const MAX_LENGTH: usize = caps::MAX_BASIN_NAME_LEN; +} + +impl AsRef for BasinNameStr { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for BasinNameStr { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom for BasinNameStr { + type Error = ValidationError; + + fn try_from(name: CompactString) -> Result { + if name.len() > Self::MAX_LENGTH { + return Err(format!( + "Basin {} must not exceed {} characters in length", + T::FIELD_NAME, + Self::MAX_LENGTH + ) + .into()); + } + + if !T::IS_PREFIX && name.len() < Self::MIN_LENGTH { + return Err(format!( + "Basin {} should be at least {} characters in length", + T::FIELD_NAME, + Self::MIN_LENGTH + ) + .into()); + } + + let mut chars = name.chars(); + + let Some(first_char) = chars.next() else { + return Ok(Self(name, PhantomData)); + }; + + if !first_char.is_ascii_lowercase() && !first_char.is_ascii_digit() { + return Err(format!( + "Basin {} must begin with a lowercase letter or number", + T::FIELD_NAME + ) + .into()); + } + + if !T::IS_PREFIX + && let Some(last_char) = chars.next_back() + && !last_char.is_ascii_lowercase() + && !last_char.is_ascii_digit() + { + return Err(format!( + "Basin {} must end with a lowercase letter or number", + T::FIELD_NAME + ) + .into()); + } + + if chars.any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-') { + return Err(format!( + "Basin {} must comprise lowercase letters, numbers, and hyphens", + T::FIELD_NAME + ) + .into()); + } + + Ok(Self(name, PhantomData)) + } +} + +impl FromStr for BasinNameStr { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + s.to_compact_string().try_into() + } +} + +impl std::fmt::Debug for BasinNameStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::fmt::Display for BasinNameStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From> for CompactString { + fn from(value: BasinNameStr) -> Self { + value.0 + } +} + +pub type BasinName = BasinNameStr; + +pub type BasinNamePrefix = BasinNameStr; + +impl Default for BasinNamePrefix { + fn default() -> Self { + BasinNameStr(CompactString::default(), PhantomData) + } +} + +impl From for BasinNamePrefix { + fn from(value: BasinName) -> Self { + Self(value.0, PhantomData) + } +} + +pub type BasinNameStartAfter = BasinNameStr; + +impl Default for BasinNameStartAfter { + fn default() -> Self { + BasinNameStr(CompactString::default(), PhantomData) + } +} + +pub type ListBasinsRequest = ListItemsRequest; + +#[derive(Debug, Clone, Copy)] +pub enum BasinState { + Active, + Creating, + Deleting, +} + +#[derive(Debug, strum::Display, Clone, Copy, PartialEq, Eq, Default)] +pub enum BasinScope { + #[strum(serialize = "aws:us-east-1")] + #[default] + AwsUsEast1, +} + +#[derive(Debug, Clone)] +pub struct BasinInfo { + pub name: BasinName, + pub scope: BasinScope, + pub state: BasinState, +} diff --git a/common/src/types/config.rs b/common/src/types/config.rs new file mode 100644 index 00000000..52bef84d --- /dev/null +++ b/common/src/types/config.rs @@ -0,0 +1,423 @@ +use std::time::Duration; + +use enum_ordinalize::Ordinalize; + +use crate::maybe::Maybe; + +#[derive( + Debug, + Default, + Clone, + Copy, + strum::Display, + strum::IntoStaticStr, + strum::EnumIter, + strum::FromRepr, + strum::EnumString, + PartialEq, + Eq, + Ordinalize, +)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[repr(u8)] +pub enum StorageClass { + #[strum(serialize = "standard")] + Standard = 1, + #[default] + #[strum(serialize = "express")] + Express = 2, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetentionPolicy { + Age(Duration), + Infinite(), +} + +impl RetentionPolicy { + pub fn age(&self) -> Option { + match self { + Self::Age(duration) => Some(*duration), + Self::Infinite() => None, + } + } +} + +impl Default for RetentionPolicy { + fn default() -> Self { + const ONE_WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60); + + Self::Age(ONE_WEEK) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum TimestampingMode { + #[default] + ClientPrefer, + ClientRequire, + Arrival, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TimestampingConfig { + pub mode: TimestampingMode, + pub uncapped: bool, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct DeleteOnEmptyConfig { + pub min_age: Duration, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StreamConfig { + pub storage_class: StorageClass, + pub retention_policy: RetentionPolicy, + pub timestamping: TimestampingConfig, + pub delete_on_empty: DeleteOnEmptyConfig, +} + +impl StreamConfig { + pub fn new() -> Self { + Self::default() + } + + pub fn with_storage_class(self, storage_class: StorageClass) -> Self { + Self { + storage_class, + ..self + } + } + + pub fn with_retention_policy(self, retention_policy: RetentionPolicy) -> Self { + Self { + retention_policy, + ..self + } + } + + pub fn with_timestamping(self, timestamping: TimestampingConfig) -> Self { + Self { + timestamping, + ..self + } + } + + pub fn with_delete_on_empty(self, delete_on_empty: DeleteOnEmptyConfig) -> Self { + Self { + delete_on_empty, + ..self + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct TimestampingReconfiguration { + pub mode: Maybe>, + pub uncapped: Maybe>, +} + +#[derive(Debug, Clone, Default)] +pub struct DeleteOnEmptyReconfiguration { + pub min_age: Maybe>, +} + +#[derive(Debug, Clone, Default)] +pub struct StreamReconfiguration { + pub storage_class: Maybe>, + pub retention_policy: Maybe>, + pub timestamping: Maybe>, + pub delete_on_empty: Maybe>, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct OptionalTimestampingConfig { + pub mode: Option, + pub uncapped: Option, +} + +impl OptionalTimestampingConfig { + pub fn reconfigure(mut self, reconfiguration: TimestampingReconfiguration) -> Self { + if let Maybe::Specified(mode) = reconfiguration.mode { + self.mode = mode; + } + if let Maybe::Specified(uncapped) = reconfiguration.uncapped { + self.uncapped = uncapped; + } + self + } + + pub fn merge(self, basin_defaults: Self) -> TimestampingConfig { + let mode = self.mode.or(basin_defaults.mode).unwrap_or_default(); + let uncapped = self + .uncapped + .or(basin_defaults.uncapped) + .unwrap_or_default(); + TimestampingConfig { mode, uncapped } + } +} + +impl From for TimestampingConfig { + fn from(value: OptionalTimestampingConfig) -> Self { + Self { + mode: value.mode.unwrap_or_default(), + uncapped: value.uncapped.unwrap_or_default(), + } + } +} + +impl From for OptionalTimestampingConfig { + fn from(value: TimestampingConfig) -> Self { + Self { + mode: Some(value.mode), + uncapped: Some(value.uncapped), + } + } +} + +impl From for TimestampingReconfiguration { + fn from(value: OptionalTimestampingConfig) -> Self { + Self { + mode: value.mode.into(), + uncapped: value.uncapped.into(), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct OptionalDeleteOnEmptyConfig { + pub min_age: Option, +} + +impl OptionalDeleteOnEmptyConfig { + pub fn reconfigure(mut self, reconfiguration: DeleteOnEmptyReconfiguration) -> Self { + if let Maybe::Specified(min_age) = reconfiguration.min_age { + self.min_age = min_age; + } + self + } + + pub fn merge(self, basin_defaults: Self) -> DeleteOnEmptyConfig { + let min_age = self.min_age.or(basin_defaults.min_age).unwrap_or_default(); + DeleteOnEmptyConfig { min_age } + } +} + +impl From for DeleteOnEmptyConfig { + fn from(value: OptionalDeleteOnEmptyConfig) -> Self { + Self { + min_age: value.min_age.unwrap_or_default(), + } + } +} + +impl From for OptionalDeleteOnEmptyConfig { + fn from(value: DeleteOnEmptyConfig) -> Self { + Self { + min_age: Some(value.min_age), + } + } +} + +impl From for DeleteOnEmptyReconfiguration { + fn from(value: OptionalDeleteOnEmptyConfig) -> Self { + Self { + min_age: value.min_age.into(), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct OptionalStreamConfig { + pub storage_class: Option, + pub retention_policy: Option, + pub timestamping: OptionalTimestampingConfig, + pub delete_on_empty: OptionalDeleteOnEmptyConfig, +} + +impl OptionalStreamConfig { + pub fn reconfigure(mut self, reconfiguration: StreamReconfiguration) -> Self { + let StreamReconfiguration { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = reconfiguration; + if let Maybe::Specified(storage_class) = storage_class { + self.storage_class = storage_class; + } + if let Maybe::Specified(retention_policy) = retention_policy { + self.retention_policy = retention_policy; + } + if let Maybe::Specified(timestamping) = timestamping { + self.timestamping = timestamping + .map(|ts| self.timestamping.reconfigure(ts)) + .unwrap_or_default(); + } + if let Maybe::Specified(delete_on_empty_reconfig) = delete_on_empty { + self.delete_on_empty = delete_on_empty_reconfig + .map(|reconfig| self.delete_on_empty.reconfigure(reconfig)) + .unwrap_or_default(); + } + self + } + + pub fn merge(self, basin_defaults: Self) -> StreamConfig { + let storage_class = self + .storage_class + .or(basin_defaults.storage_class) + .unwrap_or_default(); + + let retention_policy = self + .retention_policy + .or(basin_defaults.retention_policy) + .unwrap_or_default(); + + let timestamping = self.timestamping.merge(basin_defaults.timestamping); + + let delete_on_empty = self.delete_on_empty.merge(basin_defaults.delete_on_empty); + + StreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } + } +} + +impl From for StreamReconfiguration { + fn from(value: OptionalStreamConfig) -> Self { + let OptionalStreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = value; + + Self { + storage_class: storage_class.into(), + retention_policy: retention_policy.into(), + timestamping: Some(timestamping.into()).into(), + delete_on_empty: Some(delete_on_empty.into()).into(), + } + } +} + +impl From for StreamConfig { + fn from(value: OptionalStreamConfig) -> Self { + let OptionalStreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = value; + + Self { + storage_class: storage_class.unwrap_or_default(), + retention_policy: retention_policy.unwrap_or_default(), + timestamping: timestamping.into(), + delete_on_empty: delete_on_empty.into(), + } + } +} + +impl From for OptionalStreamConfig { + fn from(value: StreamConfig) -> Self { + let StreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = value; + + Self { + storage_class: Some(storage_class), + retention_policy: Some(retention_policy), + timestamping: timestamping.into(), + delete_on_empty: delete_on_empty.into(), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct BasinConfig { + pub default_stream_config: OptionalStreamConfig, + pub create_stream_on_append: bool, + pub create_stream_on_read: bool, +} + +impl BasinConfig { + pub fn new() -> Self { + Self::default() + } + + pub fn with_default_stream_config(self, default_stream_config: StreamConfig) -> Self { + Self { + default_stream_config: default_stream_config.into(), + ..self + } + } + + pub fn with_create_stream_on_append(self, create_stream_on_append: bool) -> Self { + Self { + create_stream_on_append, + ..self + } + } + + pub fn with_create_stream_on_read(self, create_stream_on_read: bool) -> Self { + Self { + create_stream_on_read, + ..self + } + } + + pub fn reconfigure(mut self, reconfiguration: BasinReconfiguration) -> Self { + let BasinReconfiguration { + default_stream_config, + create_stream_on_append, + create_stream_on_read, + } = reconfiguration; + + if let Maybe::Specified(default_stream_config) = default_stream_config { + self.default_stream_config = default_stream_config + .map(|reconfig| self.default_stream_config.reconfigure(reconfig)) + .unwrap_or_default(); + } + + if let Maybe::Specified(create_stream_on_append) = create_stream_on_append { + self.create_stream_on_append = create_stream_on_append; + } + + if let Maybe::Specified(create_stream_on_read) = create_stream_on_read { + self.create_stream_on_read = create_stream_on_read; + } + + self + } +} + +impl From for BasinReconfiguration { + fn from(value: BasinConfig) -> Self { + let BasinConfig { + default_stream_config, + create_stream_on_append, + create_stream_on_read, + } = value; + + Self { + default_stream_config: Some(default_stream_config.into()).into(), + create_stream_on_append: create_stream_on_append.into(), + create_stream_on_read: create_stream_on_read.into(), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct BasinReconfiguration { + pub default_stream_config: Maybe>, + pub create_stream_on_append: Maybe, + pub create_stream_on_read: Maybe, +} diff --git a/common/src/types/metrics.rs b/common/src/types/metrics.rs new file mode 100644 index 00000000..2e7efdb5 --- /dev/null +++ b/common/src/types/metrics.rs @@ -0,0 +1,97 @@ +use compact_str::CompactString; + +#[derive(Clone, Copy, Debug)] +pub enum MetricUnit { + Bytes, + Operations, +} + +#[derive(Clone, Copy, Debug, strum::IntoStaticStr)] +pub enum TimeseriesInterval { + #[strum(serialize = "minute")] + Minute, + #[strum(serialize = "hour")] + Hour, + #[strum(serialize = "day")] + Day, +} + +#[derive(Debug, Clone)] +pub struct ScalarMetric { + pub name: CompactString, + pub unit: MetricUnit, + pub value: f64, +} + +#[derive(Debug, Clone)] +pub struct AccumulationMetric { + pub name: CompactString, + pub unit: MetricUnit, + pub bucket_length: TimeseriesInterval, + pub values: Vec<(u32, f64)>, +} + +#[derive(Debug, Clone)] +pub struct GaugeMetric { + pub name: CompactString, + pub unit: MetricUnit, + pub values: Vec<(u32, f64)>, +} + +#[derive(Debug, Clone)] +pub struct LabelMetric { + pub name: CompactString, + pub values: Vec, +} + +#[derive(Debug, Clone)] +pub enum Metric { + Scalar(ScalarMetric), + Accumulation(AccumulationMetric), + Gauge(GaugeMetric), + Label(LabelMetric), +} + +pub enum AccountMetricSet { + ActiveBasins, + AccountOps, +} + +pub struct AccountMetricsRequest { + pub set: AccountMetricSet, + pub start: Option, + pub end: Option, + pub interval: Option, +} + +pub enum BasinMetricSet { + Storage, + AppendOps, + BasinOps, + ReadOps, + ReadThroughput, + AppendThroughput, +} + +pub struct BasinMetricsRequest { + pub set: BasinMetricSet, + pub start: Option, + pub end: Option, + pub interval: Option, +} + +pub enum StreamMetricSet { + Storage, +} + +pub struct StreamMetricsRequest { + pub set: StreamMetricSet, + pub start: Option, + pub end: Option, + pub interval: Option, +} + +// Common response type +pub struct MetricsResponse { + pub values: Vec, +} diff --git a/common/src/types/mod.rs b/common/src/types/mod.rs new file mode 100644 index 00000000..15061a70 --- /dev/null +++ b/common/src/types/mod.rs @@ -0,0 +1,35 @@ +pub mod access; +pub mod basin; +pub mod config; +pub mod metrics; +pub mod resources; +pub mod stream; +mod strings; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{0}")] +pub struct ValidationError(pub String); + +impl From for ValidationError { + fn from(value: String) -> Self { + ValidationError(value) + } +} + +impl From<&str> for ValidationError { + fn from(value: &str) -> Self { + ValidationError(value.to_owned()) + } +} + +impl From for ValidationError { + fn from(e: crate::record::FencingTokenTooLongError) -> Self { + ValidationError(e.to_string()) + } +} + +impl From for ValidationError { + fn from(e: resources::StartAfterLessThanPrefixError) -> Self { + ValidationError(e.to_string()) + } +} diff --git a/common/src/types/resources.rs b/common/src/types/resources.rs new file mode 100644 index 00000000..f034a092 --- /dev/null +++ b/common/src/types/resources.rs @@ -0,0 +1,154 @@ +use std::{fmt::Debug, num::NonZeroUsize, ops::Deref, str::FromStr}; + +use crate::types::ValidationError; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Page { + pub values: Vec, + pub has_more: bool, +} + +impl Page { + pub fn new_empty() -> Self { + Self { + values: Vec::new(), + has_more: false, + } + } + + pub fn new(values: impl Into>, has_more: bool) -> Self { + Self { + values: values.into(), + has_more, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ListLimit(NonZeroUsize); + +impl ListLimit { + const MAX: NonZeroUsize = NonZeroUsize::new(1000).unwrap(); + + pub fn get(&self) -> NonZeroUsize { + self.0 + } + + pub fn as_usize(&self) -> usize { + self.0.get() + } +} + +impl Default for ListLimit { + fn default() -> Self { + Self(Self::MAX) + } +} + +impl From for ListLimit { + fn from(value: usize) -> Self { + Self(NonZeroUsize::new(value).unwrap_or(Self::MAX).min(Self::MAX)) + } +} + +impl From for usize { + fn from(value: ListLimit) -> Self { + value.as_usize() + } +} + +#[derive(Debug, Clone, Default)] +pub struct ListItemsRequestParts { + pub prefix: P, + pub start_after: S, + pub limit: ListLimit, +} + +#[derive(Debug, Clone, Default)] +pub struct ListItemsRequest(ListItemsRequestParts) +where + P: Default, + S: Default; + +impl ListItemsRequest +where + P: Default + FromStr, + S: Default + FromStr, +

::Err: Debug, + ::Err: Debug, +{ + pub fn new() -> Self { + Self::default() + } + + pub fn with_prefix(mut self, prefix: impl AsRef) -> Result { + self.0.prefix = prefix.as_ref().parse().map_err(|e| format!("{:?}", e))?; + Ok(self) + } + + pub fn with_start_after( + mut self, + start_after: impl AsRef, + ) -> Result { + self.0.start_after = start_after + .as_ref() + .parse() + .map_err(|e| format!("{:?}", e))?; + Ok(self) + } + + pub fn with_limit(mut self, limit: impl Into) -> Self { + self.0.limit = limit.into(); + self + } +} + +impl ListItemsRequest +where + P: Default, + S: Default, +{ + pub fn parts(&self) -> &ListItemsRequestParts { + &self.0 + } +} + +impl From> for ListItemsRequestParts +where + P: Default, + S: Default, +{ + fn from(ListItemsRequest(parts): ListItemsRequest) -> Self { + parts + } +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("`start_after` must be greater than or equal to the `prefix`")] +pub struct StartAfterLessThanPrefixError; + +impl TryFrom> for ListItemsRequest +where + P: Deref + Default, + S: Deref + Default, +{ + type Error = StartAfterLessThanPrefixError; + + fn try_from(parts: ListItemsRequestParts) -> Result { + let start_after: &str = &parts.start_after; + let prefix: &str = &parts.prefix; + + if !start_after.is_empty() && !prefix.is_empty() && start_after < prefix { + return Err(StartAfterLessThanPrefixError); + } + + Ok(Self(parts)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CreateMode { + CreateOnly, + #[default] + CreateOrReconfigure, +} diff --git a/common/src/types/stream.rs b/common/src/types/stream.rs new file mode 100644 index 00000000..6fe9f991 --- /dev/null +++ b/common/src/types/stream.rs @@ -0,0 +1,358 @@ +use std::{marker::PhantomData, ops::Deref, str::FromStr, time::Duration}; + +use compact_str::{CompactString, ToCompactString}; +use time::OffsetDateTime; + +use super::{ + ValidationError, + strings::{NameProps, PrefixProps, StartAfterProps, StrProps}, +}; +use crate::{ + caps, + read_extent::{ReadLimit, ReadUntil}, + record::{ + FencingToken, Metered, MeteredRecord, MeteredSequencedRecords, MeteredSize, SeqNum, + Timestamp, + }, + types::resources::ListItemsRequest, +}; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize) +)] +pub struct StreamNameStr(CompactString, PhantomData); + +#[cfg(feature = "utoipa")] +impl utoipa::PartialSchema for StreamNameStr +where + T: StrProps, +{ + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::Object::builder() + .schema_type(utoipa::openapi::Type::String) + .min_length((!T::IS_PREFIX).then_some(1)) + .max_length(Some(Self::MAX_LENGTH)) + .into() + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::ToSchema for StreamNameStr where T: StrProps {} + +impl serde::Serialize for StreamNameStr { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de, T: StrProps> serde::Deserialize<'de> for StreamNameStr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = CompactString::deserialize(deserializer)?; + s.try_into().map_err(serde::de::Error::custom) + } +} + +impl StreamNameStr { + const MAX_LENGTH: usize = caps::MAX_STREAM_NAME_LEN; +} + +impl AsRef for StreamNameStr { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for StreamNameStr { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom for StreamNameStr { + type Error = ValidationError; + + fn try_from(name: CompactString) -> Result { + if !T::IS_PREFIX && name.is_empty() { + return Err(format!("Stream {} must not be empty", T::FIELD_NAME).into()); + } + + if name.len() > Self::MAX_LENGTH { + return Err(format!( + "Stream {} must not exceed {} characters in length", + T::FIELD_NAME, + Self::MAX_LENGTH + ) + .into()); + } + + Ok(Self(name, PhantomData)) + } +} + +impl FromStr for StreamNameStr { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + s.to_compact_string().try_into() + } +} + +impl std::fmt::Debug for StreamNameStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::fmt::Display for StreamNameStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From> for CompactString { + fn from(value: StreamNameStr) -> Self { + value.0 + } +} + +pub type StreamName = StreamNameStr; + +pub type StreamNamePrefix = StreamNameStr; + +impl From for StreamNamePrefix { + fn from(value: StreamName) -> Self { + Self(value.0, PhantomData) + } +} + +impl Default for StreamNamePrefix { + fn default() -> Self { + StreamNameStr(CompactString::default(), PhantomData) + } +} + +pub type StreamNameStartAfter = StreamNameStr; + +impl Default for StreamNameStartAfter { + fn default() -> Self { + StreamNameStr(CompactString::default(), PhantomData) + } +} + +#[derive(Debug, Clone)] +pub struct StreamInfo { + pub name: StreamName, + pub created_at: OffsetDateTime, + pub deleted_at: Option, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct StreamPosition { + pub seq_num: SeqNum, + pub timestamp: Timestamp, +} + +impl std::fmt::Display for StreamPosition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} @ {}", self.seq_num, self.timestamp) + } +} + +#[derive(Debug, Clone)] +pub struct AppendRecord(AppendRecordParts); + +impl Deref for AppendRecord { + type Target = AppendRecordParts; + + fn deref(&self) -> &Self::Target { + let Self(parts) = self; + parts + } +} + +impl MeteredSize for AppendRecord { + fn metered_size(&self) -> usize { + self.0.record.metered_size() + } +} + +#[derive(Debug, Clone)] +pub struct AppendRecordParts { + pub timestamp: Option, + pub record: MeteredRecord, +} + +impl MeteredSize for AppendRecordParts { + fn metered_size(&self) -> usize { + self.record.metered_size() + } +} + +impl From for AppendRecordParts { + fn from(AppendRecord(parts): AppendRecord) -> Self { + parts + } +} + +impl TryFrom for AppendRecord { + type Error = &'static str; + + fn try_from(parts: AppendRecordParts) -> Result { + if parts.metered_size() > caps::RECORD_BATCH_MAX.bytes { + Err("Record must have metered size less than 1 MiB") + } else { + Ok(Self(parts)) + } + } +} + +#[derive(Clone)] +pub struct AppendRecordBatch(Metered>); + +impl std::fmt::Debug for AppendRecordBatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AppendRecordBatch") + .field("num_records", &self.0.len()) + .field("metered_size", &self.0.metered_size()) + .finish() + } +} + +impl MeteredSize for AppendRecordBatch { + fn metered_size(&self) -> usize { + self.0.metered_size() + } +} + +impl std::ops::Deref for AppendRecordBatch { + type Target = [AppendRecord]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom>> for AppendRecordBatch { + type Error = &'static str; + + fn try_from(records: Metered>) -> Result { + if records.is_empty() { + return Err("Record batch must not be empty"); + } + + if records.len() > caps::RECORD_BATCH_MAX.count { + return Err("Record batch must not exceed 1000 records"); + } + + if records.metered_size() > caps::RECORD_BATCH_MAX.bytes { + return Err("Record batch must not exceed a metered size of 1 MiB"); + } + + Ok(Self(records)) + } +} + +impl TryFrom> for AppendRecordBatch { + type Error = &'static str; + + fn try_from(records: Vec) -> Result { + let records = Metered::from(records); + Self::try_from(records) + } +} + +impl IntoIterator for AppendRecordBatch { + type Item = AppendRecord; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +#[derive(Debug, Clone)] +pub struct AppendInput { + pub records: AppendRecordBatch, + pub match_seq_num: Option, + pub fencing_token: Option, +} + +#[derive(Debug, Clone)] +pub struct AppendAck { + pub start: StreamPosition, + pub end: StreamPosition, + pub tail: StreamPosition, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadPosition { + SeqNum(SeqNum), + Timestamp(Timestamp), +} + +#[derive(Debug, Clone, Copy)] +pub enum ReadFrom { + SeqNum(SeqNum), + Timestamp(Timestamp), + TailOffset(u64), +} + +impl Default for ReadFrom { + fn default() -> Self { + Self::SeqNum(0) + } +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct ReadStart { + pub from: ReadFrom, + pub clamp: bool, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct ReadEnd { + pub limit: ReadLimit, + pub until: ReadUntil, + pub wait: Option, +} + +impl ReadEnd { + pub fn may_follow(&self) -> bool { + (self.limit.is_unbounded() && self.until.is_unbounded()) + || self.wait.is_some_and(|d| d > Duration::ZERO) + } +} + +#[derive(Default, Clone)] +pub struct ReadBatch { + pub records: MeteredSequencedRecords, + pub tail: Option, +} + +impl std::fmt::Debug for ReadBatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReadBatch") + .field("num_records", &self.records.len()) + .field("metered_size", &self.records.metered_size()) + .field("tail", &self.tail) + .finish() + } +} + +#[derive(Debug, Clone)] +pub enum ReadSessionOutput { + Heartbeat(StreamPosition), + Batch(ReadBatch), +} + +pub type ListStreamsRequest = ListItemsRequest; diff --git a/common/src/types/strings.rs b/common/src/types/strings.rs new file mode 100644 index 00000000..3bed2c44 --- /dev/null +++ b/common/src/types/strings.rs @@ -0,0 +1,36 @@ +pub trait StrProps: std::fmt::Debug + Clone { + const IS_PREFIX: bool; + const FIELD_NAME: &'static str; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct NameProps; + +impl StrProps for NameProps { + const IS_PREFIX: bool = false; + const FIELD_NAME: &'static str = "name"; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct IdProps; + +impl StrProps for IdProps { + const IS_PREFIX: bool = false; + const FIELD_NAME: &'static str = "id"; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PrefixProps; + +impl StrProps for PrefixProps { + const IS_PREFIX: bool = true; + const FIELD_NAME: &'static str = "prefix"; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct StartAfterProps; + +impl StrProps for StartAfterProps { + const IS_PREFIX: bool = true; + const FIELD_NAME: &'static str = "start-after"; +} diff --git a/lite/Cargo.toml b/lite/Cargo.toml new file mode 100644 index 00000000..8500c1c6 --- /dev/null +++ b/lite/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "s2-lite" +edition.workspace = true +license.workspace = true +version.workspace = true + +[dependencies] +async-trait = { workspace = true } +blake3 = { workspace = true } +bytes = { workspace = true } +clap = { workspace = true } +compact_str = { workspace = true } +dashmap = { workspace = true } +eyre = { workspace = true } +futures = { workspace = true } +s2-api = { path = "../api" } +s2-common = { path = "../common", features = ["clap"] } +slatedb = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["sync"] } diff --git a/lite/docs/notes.md b/lite/docs/notes.md new file mode 100644 index 00000000..bda61577 --- /dev/null +++ b/lite/docs/notes.md @@ -0,0 +1,73 @@ +Following live updates is easy because it'd be a single node that is aware of what's being written on a stream, so always just publish to a tailer broadcast channel. + +TBD: Task per stream and communicate over a channel, or shared state? + +Basins supported via `S2-Basin` header. If missing, `default` basin used. + +Granular access tokens supported by storing them in the same DB. + +## Data model + +`StreamID = Blake3(BasinName, StreamName)` + +1 byte prefix for each key identifying the type of data: +``` +BC Basin config upd per-basin +SC Stream config upd per-stream +SD Stream data imm per-record +ST Stream timestamp imm per-record +SP Stream tail position upd per-stream +``` + +Mappings +``` +BC "{BasinName}" -> BasinConfig +SC "{BasinName}#{StreamName}" -> StreamConfig +SD StreamID.SeqNum -> Record +ST StreamID.Timestamp -> SeqNum +SP StreamID -> SeqNum.Timestamp +``` + +Appends: Every AppendInput maps to a WriteBatch, with per-record SR and ST key-value pairs, and an SP update + +Reads: +- By seq num – straightforward, scan >= SD key +- By timestamp – find first record >= ST key, and then look by seq num +- If no record found, figure out the tail if possible to follow + +Figuring out the tail: +- Init SP if it isn't already + +Retention: +- TTL set on each SD & ST row if configured + +## API surface + +DataOps /records + append # (basin, stream) + read # (basin, stream) + check_tail # (basin, stream) + +StreamOps /streams + list_streams # (basin) + create_stream # (basin, stream) + get_stream_config # (basin, stream) + reconfigure_stream # (basin, stream) + delete_stream # (basin, stream) + +BasinOps /basins + list_basins # () + create_basin # (basin) + get_basin_config # (basin) + reconfigure_basin # (basin) + delete_basin # (basin) + +AccessTokenOps /access-tokens # not initially supported + list_access_tokens + issue_access_token + revoke_access_token + +MetricOps /metrics # not initially supported + get_account_metrics + get_basin_metrics + get_stream_metrics diff --git a/lite/src/backend/mod.rs b/lite/src/backend/mod.rs new file mode 100644 index 00000000..96f40b91 --- /dev/null +++ b/lite/src/backend/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod ops; +pub mod slatedb; + +mod stream_id; diff --git a/lite/src/backend/ops.rs b/lite/src/backend/ops.rs new file mode 100644 index 00000000..ef2fba7e --- /dev/null +++ b/lite/src/backend/ops.rs @@ -0,0 +1,150 @@ +use bytes::Bytes; +use futures::stream::BoxStream; +use s2_common::{ + record::{FencingToken, SeqNum}, + types::{ + basin::{BasinInfo, BasinName, BasinScope, ListBasinsRequest}, + config::{BasinConfig, BasinReconfiguration, OptionalStreamConfig, StreamReconfiguration}, + resources::{CreateMode, Page}, + stream::{ + AppendAck, AppendInput, ListStreamsRequest, ReadBatch, ReadEnd, ReadStart, StreamInfo, + StreamName, StreamPosition, + }, + }, +}; + +// TODO Create/Reconfigure/Delete Ok() variants +// TODO spec out all the errors properly + +#[derive(Debug, Clone, thiserror::Error)] +pub enum CheckTailError { + #[error("Stream not found")] + StreamNotFound, +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum AppendError { + #[error("Stream not found")] + StreamNotFound, + #[error("Sequence number mismatch: expected {expected}, actual {actual}")] + SeqNumMismatch { expected: SeqNum, actual: SeqNum }, + #[error("Fencing token mismatch: expected {expected}, actual {actual}")] + FencingTokenMismatch { + expected: FencingToken, + actual: FencingToken, + }, +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ReadError { + #[error("Stream not found")] + StreamNotFound, +} + +pub trait DataOps { + async fn check_tail( + &self, + basin: BasinName, + stream: StreamName, + ) -> Result; + + async fn read( + &self, + basin: BasinName, + stream: StreamName, + start: ReadStart, + end: ReadEnd, + ) -> Result>, ReadError>; + + async fn append( + &self, + basin: BasinName, + stream: StreamName, + input: AppendInput, + ) -> Result; + + async fn append_session( + &self, + basin: BasinName, + stream: StreamName, + requests: BoxStream<'static, AppendInput>, + ) -> Result>, AppendError>; +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ListStreamsError {} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum CreateStreamError {} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ReconfigureStreamError {} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum DeleteStreamError {} + +pub trait StreamOps { + async fn list_streams( + &self, + basin: BasinName, + request: ListStreamsRequest, + ) -> Result, ListStreamsError>; + + async fn create_stream( + &self, + basin: BasinName, + stream: StreamName, + config: OptionalStreamConfig, + mode: CreateMode, + idempotence_key: Option, + ) -> Result<(), CreateStreamError>; + + async fn reconfigure_stream( + &self, + basin: BasinName, + stream: StreamName, + config: StreamReconfiguration, + ) -> Result<(), ReconfigureStreamError>; + + async fn delete_stream( + &self, + basin: BasinName, + stream: StreamName, + ) -> Result<(), DeleteStreamError>; +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ListBasinsError {} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum CreateBasinError {} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ReconfigureBasinError {} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum DeleteBasinError {} + +pub trait BasinOps { + async fn list_basins( + &self, + request: ListBasinsRequest, + ) -> Result, ListBasinsError>; + + async fn create_basin( + &self, + basin: BasinName, + scope: BasinScope, + config: BasinConfig, + mode: CreateMode, + idempotence_key: Option, + ) -> Result<(), CreateBasinError>; + + async fn reconfigure_basin( + &self, + basin: BasinName, + config: BasinReconfiguration, + ) -> Result<(), ReconfigureBasinError>; + + async fn delete_basin(&self, basin: BasinName) -> Result<(), DeleteBasinError>; +} diff --git a/lite/src/backend/slatedb.rs b/lite/src/backend/slatedb.rs new file mode 100644 index 00000000..95988f9d --- /dev/null +++ b/lite/src/backend/slatedb.rs @@ -0,0 +1,79 @@ +use std::sync::Arc; + +use dashmap::DashMap; +use futures::stream::BoxStream; +use s2_common::types::{ + basin::BasinName, + stream::{AppendAck, AppendInput, ReadBatch, ReadEnd, ReadStart, StreamName, StreamPosition}, +}; +use tokio::sync::{broadcast, mpsc}; + +use crate::backend::{ + ops::{AppendError, ReadError}, + stream_id::StreamId, +}; + +use super::ops::{CheckTailError, DataOps}; + +struct StreamTailState { + tail: StreamPosition, + follower_tx: broadcast::Sender, +} + +struct StreamState { + tail_state: Option, +} + +impl StreamState {} + +pub struct SlateDbBackend { + db: slatedb::Db, + streams: DashMap, +} + +impl SlateDbBackend { + pub fn new(db: slatedb::Db) -> Self { + Self { + db, + streams: DashMap::default(), + } + } +} + +impl DataOps for SlateDbBackend { + async fn check_tail( + &self, + basin: BasinName, + stream: StreamName, + ) -> Result { + todo!() + } + + async fn read( + &self, + basin: BasinName, + stream: StreamName, + start: ReadStart, + end: ReadEnd, + ) -> Result>, ReadError> { + todo!() + } + + async fn append( + &self, + basin: BasinName, + stream: StreamName, + input: AppendInput, + ) -> Result { + todo!() + } + + async fn append_session( + &self, + basin: BasinName, + stream: StreamName, + requests: BoxStream<'static, AppendInput>, + ) -> Result>, AppendError> { + todo!() + } +} diff --git a/lite/src/backend/stream_id.rs b/lite/src/backend/stream_id.rs new file mode 100644 index 00000000..6dd32e11 --- /dev/null +++ b/lite/src/backend/stream_id.rs @@ -0,0 +1,67 @@ +use s2_common::types::{basin::BasinName, stream::StreamName}; + +const FIELD_SEPARATOR: u8 = 0x00; + +/// Unique identifier for a stream scoped by its basin. +/// +/// The identifier is the Blake3 hash of the string representations of the +/// basin and stream names with a separator byte to avoid collisions when +/// concatenating variable length fields. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct StreamId(blake3::Hash); + +impl StreamId { + pub fn new(basin: &BasinName, stream: &StreamName) -> Self { + let mut hasher = blake3::Hasher::new(); + hasher.update(basin.as_ref().as_bytes()); + hasher.update(&[FIELD_SEPARATOR]); + hasher.update(stream.as_ref().as_bytes()); + + Self(hasher.finalize()) + } + + /// Access the raw 32-byte hash. + pub fn as_hash(&self) -> blake3::Hash { + self.0 + } + + /// Borrow the identifier as raw bytes. + pub fn as_bytes(&self) -> &[u8; 32] { + self.0.as_bytes() + } + + /// Consume the identifier and return the raw bytes. + pub fn into_bytes(self) -> [u8; 32] { + *self.0.as_bytes() + } +} + +#[cfg(test)] +mod tests { + use super::StreamId; + use s2_common::types::{basin::BasinName, stream::StreamName}; + use std::str::FromStr; + + #[test] + fn deterministic_for_same_inputs() { + let basin = BasinName::from_str("basin-alpha").unwrap(); + let stream = StreamName::from_str("stream-main").unwrap(); + + let first = StreamId::new(&basin, &stream); + let second = StreamId::new(&basin, &stream); + + assert_eq!(first, second); + } + + #[test] + fn distinct_for_different_inputs() { + let basin = BasinName::from_str("basin-alpha").unwrap(); + let stream_a = StreamName::from_str("stream-main").unwrap(); + let stream_b = StreamName::from_str("stream-aux").unwrap(); + + let id_a = StreamId::new(&basin, &stream_a); + let id_b = StreamId::new(&basin, &stream_b); + + assert_ne!(id_a, id_b); + } +} diff --git a/lite/src/handlers/mod.rs b/lite/src/handlers/mod.rs new file mode 100644 index 00000000..a3a6d96c --- /dev/null +++ b/lite/src/handlers/mod.rs @@ -0,0 +1 @@ +pub mod v1; diff --git a/lite/src/handlers/v1.rs b/lite/src/handlers/v1.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/lite/src/handlers/v1.rs @@ -0,0 +1 @@ + diff --git a/lite/src/lib.rs b/lite/src/lib.rs new file mode 100644 index 00000000..6255b247 --- /dev/null +++ b/lite/src/lib.rs @@ -0,0 +1,4 @@ +// TODO temporary +#[allow(unused)] +pub mod backend; +pub mod handlers; diff --git a/lite/src/service.rs b/lite/src/service.rs new file mode 100644 index 00000000..e69de29b From fe8bdcb90490face1e729a566fabb18c45713026 Mon Sep 17 00:00:00 2001 From: shikhar Date: Fri, 7 Nov 2025 12:32:27 -0800 Subject: [PATCH 002/700] fix: duplicate submodule --- .gitmodules | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitmodules b/.gitmodules index 195b9df6..35f5afb3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "protos"] - path = api/protos - url = https://github.com/s2-streamstore/s2-protos [submodule "api/protos"] path = api/protos url = git@github.com:s2-streamstore/s2-protos.git From 513b3beb8c5754959f09ba4228a2020b9f2cebef Mon Sep 17 00:00:00 2001 From: shikhar Date: Fri, 7 Nov 2025 12:37:39 -0800 Subject: [PATCH 003/700] Revert "fix: duplicate submodule" This reverts commit fe8bdcb90490face1e729a566fabb18c45713026. --- .gitmodules | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitmodules b/.gitmodules index 35f5afb3..195b9df6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ +[submodule "protos"] + path = api/protos + url = https://github.com/s2-streamstore/s2-protos [submodule "api/protos"] path = api/protos url = git@github.com:s2-streamstore/s2-protos.git From 6fb68d4cfa6094e350d756c91c3cf6162c83f7ea Mon Sep 17 00:00:00 2001 From: shikhar Date: Fri, 7 Nov 2025 12:38:09 -0800 Subject: [PATCH 004/700] fix: clone submodule over https --- .gitmodules | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitmodules b/.gitmodules index 195b9df6..f2d798f1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "protos"] path = api/protos url = https://github.com/s2-streamstore/s2-protos -[submodule "api/protos"] - path = api/protos - url = git@github.com:s2-streamstore/s2-protos.git From 9e63dab48d3c9effc104e8d69ab8f5ca99f5dc96 Mon Sep 17 00:00:00 2001 From: shikhar Date: Fri, 7 Nov 2025 13:04:27 -0800 Subject: [PATCH 005/700] doc: notes --- lite/docs/notes.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lite/docs/notes.md b/lite/docs/notes.md index bda61577..37088f68 100644 --- a/lite/docs/notes.md +++ b/lite/docs/notes.md @@ -4,7 +4,7 @@ TBD: Task per stream and communicate over a channel, or shared state? Basins supported via `S2-Basin` header. If missing, `default` basin used. -Granular access tokens supported by storing them in the same DB. +Granular access tokens can be supported by storing them in the same DB. ## Data model @@ -43,6 +43,7 @@ Retention: ## API surface +``` DataOps /records append # (basin, stream) read # (basin, stream) @@ -71,3 +72,4 @@ MetricOps /metrics # not initially supported get_account_metrics get_basin_metrics get_stream_metrics +``` From 559b4ee8f351ab918563c1ff8894df01c4502fbb Mon Sep 17 00:00:00 2001 From: shikhar Date: Fri, 7 Nov 2025 13:18:08 -0800 Subject: [PATCH 006/700] doc: notes --- lite/docs/notes.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lite/docs/notes.md b/lite/docs/notes.md index 37088f68..c2ce04af 100644 --- a/lite/docs/notes.md +++ b/lite/docs/notes.md @@ -64,12 +64,12 @@ BasinOps /basins delete_basin # (basin) AccessTokenOps /access-tokens # not initially supported - list_access_tokens - issue_access_token - revoke_access_token + list_access_tokens # (id) + issue_access_token # (id) + revoke_access_token # (id) MetricOps /metrics # not initially supported - get_account_metrics - get_basin_metrics - get_stream_metrics + get_account_metrics # () + get_basin_metrics # (basin) + get_stream_metrics # (basin, stream) ``` From 9ab28d2eb6fa76c3a7c3403e4f4f7cf1302b8326 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Wed, 12 Nov 2025 07:38:25 +0530 Subject: [PATCH 007/700] fix: malformed frames can panic (#1) --- api/src/v1/stream/s2s.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/api/src/v1/stream/s2s.rs b/api/src/v1/stream/s2s.rs index 975e8dd6..f86d696e 100644 --- a/api/src/v1/stream/s2s.rs +++ b/api/src/v1/stream/s2s.rs @@ -204,6 +204,12 @@ impl SessionMessage { } fn decode_message(mut buf: Bytes) -> std::io::Result { + if buf.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "empty frame payload", + )); + } let flag = buf.get_u8(); let is_terminal = (flag & FLAG_TERMINAL) != 0; @@ -598,6 +604,15 @@ mod test { assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); } + #[test] + fn frame_decoder_handles_empty_payload() { + let raw = vec![0, 0, 0]; + let mut decoder = FrameDecoder; + let mut buf = BytesMut::from(raw.as_slice()); + let err = decoder.decode(&mut buf).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof); + } + #[test] fn compressed_data_round_trip_gzip() { let payload = vec![42; 1_200_000]; From f20a37b66437bdd9e1e20dbaf9027e42c8d97f49 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Mon, 8 Dec 2025 11:04:52 -0500 Subject: [PATCH 008/700] fix: allow empty strings for an exact ResourceSet (#2) --- Cargo.lock | 1 + api/Cargo.toml | 1 + api/src/v1/access.rs | 166 +++++++++++++++++++++++++++++++--- api/src/v1/stream/s2s.rs | 16 ++-- common/src/record/batcher.rs | 3 +- lite/src/backend/slatedb.rs | 3 +- lite/src/backend/stream_id.rs | 6 +- 7 files changed, 172 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 89efcb80..ba4fbf23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2704,6 +2704,7 @@ dependencies = [ "prost-build", "s2-common", "serde", + "serde_json", "thiserror 2.0.17", "time", "tokio-util", diff --git a/api/Cargo.toml b/api/Cargo.toml index e7ebfb10..bbaed512 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -33,3 +33,4 @@ prost-build = { workspace = true, optional = true } [dev-dependencies] proptest = { workspace = true } +serde_json = "1" diff --git a/api/src/v1/access.rs b/api/src/v1/access.rs index 82ba6929..1431c48c 100644 --- a/api/src/v1/access.rs +++ b/api/src/v1/access.rs @@ -5,6 +5,43 @@ use s2_common::types::{ stream::{StreamName, StreamNamePrefix}, }; use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub enum MaybeEmpty { + Empty, + NonEmpty(T), +} + +impl Serialize for MaybeEmpty { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::NonEmpty(v) => v.serialize(serializer), + Self::Empty => serializer.serialize_str(""), + } + } +} + +impl<'de, T> Deserialize<'de> for MaybeEmpty +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + if s.is_empty() { + Ok(MaybeEmpty::Empty) + } else { + T::deserialize(serde::de::value::StringDeserializer::new(s)).map(MaybeEmpty::NonEmpty) + } + } +} + use time::{OffsetDateTime, format_description::well_known::Iso8601}; #[cfg(feature = "utoipa")] use utoipa::{IntoParams, ToSchema}; @@ -226,11 +263,11 @@ impl TryFrom for types::access::AccessTokenInfo { #[cfg_attr(feature = "utoipa", derive(ToSchema))] pub struct AccessTokenScope { /// Basin names allowed. - pub basins: Option>, + pub basins: Option, BasinNamePrefix>>, /// Stream names allowed. - pub streams: Option>, + pub streams: Option, StreamNamePrefix>>, /// Token IDs allowed. - pub access_tokens: Option>, + pub access_tokens: Option, AccessTokenIdPrefix>>, /// Access permissions at operation group level. pub op_groups: Option, /// Operations allowed for the token. @@ -298,24 +335,23 @@ pub enum ResourceSet { Prefix(P), } -impl ResourceSet { +impl ResourceSet, P> { pub fn to_opt(rs: types::access::ResourceSet) -> Option { match rs { types::access::ResourceSet::None => None, - types::access::ResourceSet::Exact(e) => Some(ResourceSet::Exact(e)), + types::access::ResourceSet::Exact(e) => { + Some(ResourceSet::Exact(MaybeEmpty::NonEmpty(e))) + } types::access::ResourceSet::Prefix(p) => Some(ResourceSet::Prefix(p)), } } } -impl From> for types::access::ResourceSet -where - E: AsRef, -{ - fn from(value: ResourceSet) -> Self { +impl From, P>> for types::access::ResourceSet { + fn from(value: ResourceSet, P>) -> Self { match value { - ResourceSet::Exact(e) if e.as_ref().is_empty() => Self::None, - ResourceSet::Exact(e) => Self::Exact(e), + ResourceSet::Exact(MaybeEmpty::Empty) => Self::None, + ResourceSet::Exact(MaybeEmpty::NonEmpty(e)) => Self::Exact(e), ResourceSet::Prefix(p) => Self::Prefix(p), } } @@ -439,3 +475,109 @@ pub struct IssueAccessTokenResponse { /// Created access token. pub access_token: String, } + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + fn random_basin_resource_set() -> impl Strategy { + prop_oneof![ + Just(serde_json::json!({"exact": ""})), + "[a-z][a-z0-9]{7,20}".prop_map(|s| serde_json::json!({"exact": s})), + Just(serde_json::json!({"prefix": ""})), + "[a-z][a-z0-9]{0,10}".prop_map(|s| serde_json::json!({"prefix": s})), + ] + } + + fn random_resource_set() -> impl Strategy { + prop_oneof![ + Just(serde_json::json!({"exact": ""})), + "[a-z][a-z0-9]{0,20}".prop_map(|s| serde_json::json!({"exact": s})), + Just(serde_json::json!({"prefix": ""})), + "[a-z][a-z0-9]{0,10}".prop_map(|s| serde_json::json!({"prefix": s})), + ] + } + + fn random_access_token_info() -> impl Strategy { + ( + "[a-z][a-z0-9]{0,20}", + proptest::option::of(random_basin_resource_set()), + proptest::option::of(random_resource_set()), + proptest::option::of(random_resource_set()), + ) + .prop_map(|(id, basins, streams, access_tokens)| { + serde_json::json!({ + "id": id, + "scope": { + "basins": basins, + "streams": streams, + "access_tokens": access_tokens + } + }) + }) + } + + proptest! { + #[test] + fn access_token_info_roundtrip(json in random_access_token_info()) { + let parsed: AccessTokenInfo = serde_json::from_value(json).unwrap(); + let internal: types::access::IssueAccessTokenRequest = parsed.clone().try_into().unwrap(); + let back: AccessTokenInfo = internal.into(); + prop_assert_eq!(parsed.id, back.id); + } + } + + #[test] + fn empty_exact_converts_to_resource_set_none() { + let json = serde_json::json!({ + "id": "test-token", + "scope": { + "streams": {"exact": ""}, + "basins": {"exact": ""}, + "access_tokens": {"exact": ""} + } + }); + + let parsed: AccessTokenInfo = serde_json::from_value(json).unwrap(); + let internal: types::access::IssueAccessTokenRequest = parsed.try_into().unwrap(); + + assert!(matches!( + internal.scope.streams, + types::access::ResourceSet::None + )); + assert!(matches!( + internal.scope.basins, + types::access::ResourceSet::None + )); + assert!(matches!( + internal.scope.access_tokens, + types::access::ResourceSet::None + )); + } + + #[test] + fn missing_scope_fields_default_to_resource_set_none() { + let json = serde_json::json!({ + "id": "test-token", + "scope": {} + }); + + let parsed: AccessTokenInfo = serde_json::from_value(json).unwrap(); + let internal: types::access::IssueAccessTokenRequest = parsed.try_into().unwrap(); + + assert!(matches!( + internal.scope.streams, + types::access::ResourceSet::None + )); + assert!(matches!( + internal.scope.basins, + types::access::ResourceSet::None + )); + assert!(matches!( + internal.scope.access_tokens, + types::access::ResourceSet::None + )); + } +} diff --git a/api/src/v1/stream/s2s.rs b/api/src/v1/stream/s2s.rs index f86d696e..b75016f6 100644 --- a/api/src/v1/stream/s2s.rs +++ b/api/src/v1/stream/s2s.rs @@ -332,19 +332,21 @@ impl tokio_util::codec::Decoder for FrameDecoder { #[cfg(test)] mod test { - use super::*; - use bytes::BytesMut; - use futures::{StreamExt, stream}; - use http::HeaderValue; - use proptest::{collection::vec, prelude::*}; - use prost::Message; use std::{ io, pin::Pin, task::{Context, Poll}, }; + + use bytes::BytesMut; + use futures::{StreamExt, stream}; + use http::HeaderValue; + use proptest::{collection::vec, prelude::*}; + use prost::Message; use tokio_util::codec::Decoder; + use super::*; + #[derive(Clone, PartialEq, prost::Message)] struct TestProto { #[prost(bytes, tag = "1")] @@ -605,7 +607,7 @@ mod test { } #[test] - fn frame_decoder_handles_empty_payload() { + fn frame_decoder_handles_empty_payload() { let raw = vec![0, 0, 0]; let mut decoder = FrameDecoder; let mut buf = BytesMut::from(raw.as_slice()); diff --git a/common/src/record/batcher.rs b/common/src/record/batcher.rs index 7d5a8eff..9a30c131 100644 --- a/common/src/record/batcher.rs +++ b/common/src/record/batcher.rs @@ -161,13 +161,14 @@ where #[cfg(test)] mod tests { + use bytes::Bytes; + use super::*; use crate::{ caps, read_extent::{ReadLimit, ReadUntil}, record::{CommandRecord, Encodable, MeteredRecord, MeteredSize, Record}, }; - use bytes::Bytes; type BatchItem = (SeqNum, Timestamp, Bytes); diff --git a/lite/src/backend/slatedb.rs b/lite/src/backend/slatedb.rs index 95988f9d..dcc11c92 100644 --- a/lite/src/backend/slatedb.rs +++ b/lite/src/backend/slatedb.rs @@ -8,13 +8,12 @@ use s2_common::types::{ }; use tokio::sync::{broadcast, mpsc}; +use super::ops::{CheckTailError, DataOps}; use crate::backend::{ ops::{AppendError, ReadError}, stream_id::StreamId, }; -use super::ops::{CheckTailError, DataOps}; - struct StreamTailState { tail: StreamPosition, follower_tx: broadcast::Sender, diff --git a/lite/src/backend/stream_id.rs b/lite/src/backend/stream_id.rs index 6dd32e11..43e7f5e2 100644 --- a/lite/src/backend/stream_id.rs +++ b/lite/src/backend/stream_id.rs @@ -38,10 +38,12 @@ impl StreamId { #[cfg(test)] mod tests { - use super::StreamId; - use s2_common::types::{basin::BasinName, stream::StreamName}; use std::str::FromStr; + use s2_common::types::{basin::BasinName, stream::StreamName}; + + use super::StreamId; + #[test] fn deterministic_for_same_inputs() { let basin = BasinName::from_str("basin-alpha").unwrap(); From bafd31c91e2645cb044b06295c4bfde0b15a64ee Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Thu, 11 Dec 2025 18:22:14 -0800 Subject: [PATCH 009/700] chore: dep upgrades (#3) --- Cargo.lock | 296 +++++++++++++++++++-------------------- Cargo.toml | 12 +- api/src/v1/stream/s2s.rs | 14 +- 3 files changed, 161 insertions(+), 161 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba4fbf23..16a7677d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,22 +83,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -156,7 +156,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -173,7 +173,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -200,7 +200,7 @@ dependencies = [ "derive_utils", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -211,9 +211,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" dependencies = [ "axum-core", "bytes", @@ -280,9 +280,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" [[package]] name = "bincode" @@ -362,7 +362,7 @@ checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -373,9 +373,9 @@ checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" dependencies = [ "serde", ] @@ -391,9 +391,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.44" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "jobserver", @@ -429,9 +429,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.51" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -439,9 +439,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.51" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -458,7 +458,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -469,9 +469,9 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cmsketch" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e708912643ef44f956dca7b2de7b97d90ff79ce39191607b2fc1eb3f1be33c" +checksum = "d7ee2cfacbd29706479902b06d75ad8f1362900836aa32799eabc7e004bfd854" [[package]] name = "colorchoice" @@ -582,9 +582,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -634,7 +634,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -656,7 +656,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -691,7 +691,7 @@ checksum = "ccfae181bab5ab6c5478b2ccb69e4c68a02f8c3ec72f6616bfec9dbc599d2ee0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -712,7 +712,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -748,9 +748,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "endian-type" -version = "0.1.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" [[package]] name = "enum-ordinalize" @@ -769,7 +769,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -790,7 +790,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -875,9 +875,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "fixedbitset" @@ -1110,7 +1110,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -1151,9 +1151,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -1251,9 +1251,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -1269,12 +1269,11 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1321,9 +1320,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -1361,9 +1360,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "base64", "bytes", @@ -1455,9 +1454,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1469,9 +1468,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1523,12 +1522,12 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -1588,9 +1587,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -1604,9 +1603,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "linux-raw-sys" @@ -1631,9 +1630,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru-slab" @@ -1764,9 +1763,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -1775,9 +1774,9 @@ dependencies = [ [[package]] name = "mixtrics" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c4c1f8a5250642cbedbb30bd21a84bb960a9cbfe8c8c30a910103513647326d" +checksum = "fb252c728b9d77c6ef9103f0c81524fa0a3d3b161d0a936295d7fbeff6e04c11" dependencies = [ "itertools", "parking_lot", @@ -1806,7 +1805,7 @@ checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -1941,7 +1940,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -2005,7 +2004,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -2041,7 +2040,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -2093,7 +2092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -2102,7 +2101,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.7", + "toml_edit 0.23.9", ] [[package]] @@ -2122,7 +2121,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", "version_check", "yansi", ] @@ -2172,7 +2171,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.108", + "syn 2.0.111", "tempfile", ] @@ -2186,7 +2185,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -2215,7 +2214,7 @@ checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -2226,9 +2225,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quick-xml" -version = "0.38.3" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", "serde", @@ -2291,9 +2290,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -2306,9 +2305,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "radix_trie" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" dependencies = [ "endian-type", "nibble_vec", @@ -2464,9 +2463,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" dependencies = [ "base64", "bytes", @@ -2545,7 +2544,7 @@ checksum = "bd83f5f173ff41e00337d97f6572e416d022ef8a19f371817259ae960324c482" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -2573,7 +2572,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.108", + "syn 2.0.111", "unicode-ident", ] @@ -2643,9 +2642,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" dependencies = [ "web-time", "zeroize", @@ -2832,7 +2831,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -2919,18 +2918,18 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" dependencies = [ "libc", ] [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simdutf8" @@ -2952,9 +2951,9 @@ checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "slatedb" -version = "0.8.2" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1929df128f27789722644d8e9a68eaddec2e656ed8bac2a088159c8cce423590" +checksum = "16c3a10e8975744fddd018b3f442e6309f2a24713c4f1d65341ca0c472bc9c07" dependencies = [ "anyhow", "async-trait", @@ -3062,7 +3061,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -3084,9 +3083,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.108" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -3110,7 +3109,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -3152,7 +3151,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -3163,7 +3162,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -3255,7 +3254,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -3278,6 +3277,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", + "hashbrown 0.15.5", "pin-project-lite", "tokio", ] @@ -3306,7 +3306,7 @@ dependencies = [ "toml_datetime 0.7.3", "toml_parser", "toml_writer", - "winnow 0.7.13", + "winnow 0.7.14", ] [[package]] @@ -3338,19 +3338,19 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow 0.7.13", + "winnow 0.7.14", ] [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.23.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" dependencies = [ "indexmap", "toml_datetime 0.7.3", "toml_parser", - "winnow 0.7.13", + "winnow 0.7.14", ] [[package]] @@ -3359,7 +3359,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "winnow 0.7.13", + "winnow 0.7.14", ] [[package]] @@ -3392,9 +3392,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", @@ -3422,9 +3422,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "log", "pin-project-lite", @@ -3434,20 +3434,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", @@ -3466,9 +3466,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -3591,18 +3591,18 @@ source = "git+https://github.com/infiniteregrets/utoipa?rev=9cd181d40ac0a50551eb dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -3663,9 +3663,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -3676,9 +3676,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", @@ -3689,9 +3689,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3699,22 +3699,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] @@ -3734,9 +3734,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -3782,7 +3782,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -3793,7 +3793,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -3996,9 +3996,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] @@ -4040,28 +4040,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] @@ -4081,7 +4081,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", "synstructure", ] @@ -4121,7 +4121,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.111", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 71fbaa9a..cdab63e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,12 +9,12 @@ version = "0.1.0" [workspace.dependencies] async-trait = "0.1.89" -axum = "0.8.6" -base64ct = { version = "1.8.0", features = ["alloc"] } +axum = "0.8.7" +base64ct = { version = "1.8.1", features = ["alloc"] } blake3 = "1.8.2" -bytes = "1.10.1" +bytes = "1.11.0" compact_str = "0.9.0" -clap = { version = "4.5.51", features = ["derive"] } +clap = { version = "4.5.53", features = ["derive"] } const_format = "0.2.35" dashmap = "6.1.0" enum-ordinalize = "4.3.2" @@ -22,7 +22,7 @@ enumset = "1.1.10" eyre = "0.6.12" flate2 = "1.1.5" futures = "0.3.31" -http = "1.3.1" +http = "1.4.0" itertools = "0.14.0" proptest = "1.9.0" prost = "0.14.1" @@ -30,7 +30,7 @@ prost-build = "0.14.1" rkyv = "0.8.12" rstest = "0.26.1" serde = { version = "1.0.228", features = ["derive"] } -slatedb = "0.8.2" +slatedb = "0.9.2" strum = { version = "0.27", features = ["derive"] } thiserror = "2.0.17" time = { version = "0.3.44", features = ["serde", "parsing", "formatting"] } diff --git a/api/src/v1/stream/s2s.rs b/api/src/v1/stream/s2s.rs index b75016f6..19017256 100644 --- a/api/src/v1/stream/s2s.rs +++ b/api/src/v1/stream/s2s.rs @@ -339,7 +339,7 @@ mod test { }; use bytes::BytesMut; - use futures::{StreamExt, stream}; + use futures::StreamExt; use http::HeaderValue; use proptest::{collection::vec, prelude::*}; use prost::Message; @@ -365,11 +365,11 @@ mod test { body: &'static str, } - impl Into for TestError { - fn into(self) -> TerminalMessage { + impl From for TerminalMessage { + fn from(val: TestError) -> Self { TerminalMessage { - status: self.status, - body: self.body.to_string(), + status: val.status, + body: val.body.to_string(), } } } @@ -665,7 +665,7 @@ mod test { Ok(proto.clone()), ]; - let stream = stream::iter(items.into_iter()); + let stream = futures::stream::iter(items); let framed = FramedMessageStream::new(CompressionAlgorithm::None, stream); let outputs = futures::executor::block_on(async { framed.collect::>>().await @@ -696,7 +696,7 @@ mod test { fn framed_message_stream_stops_after_termination() { let mut stream = FramedMessageStream::new( CompressionAlgorithm::None, - stream::iter(vec![ + futures::stream::iter(vec![ Ok(TestProto::new(vec![0])), Err(TestError { status: 400, From b7644ef692f62157e2f0edede6b6b7aa0e8b85c8 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Thu, 11 Dec 2025 18:55:24 -0800 Subject: [PATCH 010/700] chore: Github CI (#4) --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..5a81f4f5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + with: + components: rustfmt + - run: cargo +nightly fmt --all --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - uses: arduino/setup-protoc@v3 + - run: cargo clippy --workspace --all-features --all-targets -- -D warnings --allow deprecated + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: arduino/setup-protoc@v3 + - uses: taiki-e/install-action@nextest + - run: cargo nextest run --workspace --all-features From cd43b1e4e985c3dcc5b01546f4f905d9c10b51f5 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Fri, 12 Dec 2025 10:24:24 -0500 Subject: [PATCH 011/700] fix: s2s proptest failing on compression algo (#5) --- api/src/v1/stream/s2s.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/v1/stream/s2s.rs b/api/src/v1/stream/s2s.rs index 19017256..316a38ad 100644 --- a/api/src/v1/stream/s2s.rs +++ b/api/src/v1/stream/s2s.rs @@ -425,7 +425,7 @@ mod test { prop_assert!(matches!(decoded, SessionMessage::Regular(_))); let SessionMessage::Regular(data) = decoded else { unreachable!() }; - let expected_compression = if algo == CompressionAlgorithm::None || payload.len() < COMPRESSION_THRESHOLD_BYTES { + let expected_compression = if algo == CompressionAlgorithm::None || proto.encoded_len() < COMPRESSION_THRESHOLD_BYTES { CompressionAlgorithm::None } else { algo From 10a323ed969107df10e86b9442a4a3e6f6c162f4 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Thu, 18 Dec 2025 10:06:55 -0500 Subject: [PATCH 012/700] fix: basin default inheritance for `delete_on_empty` (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When converting `StreamConfig` to `OptionalStreamConfig`, an omitted `delete_on_empty` field is incorrectly converted to Some(Duration::ZERO) instead of None Example: - Basin configuration has delete-on-empty enabled with a 5-minute minimum age: ``` OptionalDeleteOnEmptyConfig { min_age: Some(Duration::from_secs(300)) } ``` - Create a stream without specifying delete_on_empty: - ``` StreamConfig { delete_on_empty: None } None.unwrap_or_default() → DeleteOnEmptyConfig { min_age_secs: 0 } into() → OptionalDeleteOnEmptyConfig { min_age: Some(Duration::ZERO) } ``` - Merging with basin defaults uses or(): ``` pub fn merge(self, basin_defaults: Self) -> DeleteOnEmptyConfig { let min_age = self.min_age.or(basin_defaults.min_age).unwrap_or_default(); DeleteOnEmptyConfig { min_age } } ``` Since Some(Duration::ZERO).or(Some(Duration::from_secs(300))) yields Some(Duration::ZERO), the stream ends up with min_age = Duration::ZERO instead of inheriting 300 seconds. --- api/src/v1/config.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/v1/config.rs b/api/src/v1/config.rs index e698f434..f09181f6 100644 --- a/api/src/v1/config.rs +++ b/api/src/v1/config.rs @@ -333,8 +333,8 @@ impl TryFrom for types::config::OptionalStreamConfig { Ok(Self { storage_class: storage_class.map(Into::into), retention_policy, - timestamping: timestamping.unwrap_or_default().into(), - delete_on_empty: delete_on_empty.unwrap_or_default().into(), + timestamping: timestamping.map(Into::into).unwrap_or_default(), + delete_on_empty: delete_on_empty.map(Into::into).unwrap_or_default(), }) } } From e8b455d69a8dc05010fde18083b6ed948c5c79da Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Thu, 18 Dec 2025 17:32:49 -0500 Subject: [PATCH 013/700] chore: add stream/basin configuration tests (#8) --- .github/workflows/ci.yml | 5 +- api/src/v1/config.rs | 470 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 469 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a81f4f5..83ca7ac9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,10 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 - - uses: arduino/setup-protoc@v3 + - name: Install Protoc + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} - run: cargo clippy --workspace --all-features --all-targets -- -D warnings --allow deprecated test: diff --git a/api/src/v1/config.rs b/api/src/v1/config.rs index f09181f6..2ea92832 100644 --- a/api/src/v1/config.rs +++ b/api/src/v1/config.rs @@ -152,7 +152,7 @@ impl From for types::config::OptionalTimestampingConfig { } #[rustfmt::skip] -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] pub struct TimestampingReconfiguration { /// Timestamping mode for appends that influences how timestamps are handled. @@ -231,7 +231,7 @@ impl From for types::config::OptionalDeleteOnEmptyConfig { } #[rustfmt::skip] -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] pub struct DeleteOnEmptyReconfiguration { /// Minimum age in seconds before an empty stream can be deleted. @@ -340,7 +340,7 @@ impl TryFrom for types::config::OptionalStreamConfig { } #[rustfmt::skip] -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] pub struct StreamReconfiguration { /// Storage class for recent writes. @@ -401,7 +401,7 @@ impl From for StreamReconfiguration { } #[rustfmt::skip] -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] pub struct BasinConfig { /// Default stream configuration. @@ -452,7 +452,7 @@ impl From for BasinConfig { } #[rustfmt::skip] -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] pub struct BasinReconfiguration { /// Basin configuration. @@ -502,3 +502,463 @@ impl From for BasinReconfiguration { } } } + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + fn gen_storage_class() -> impl Strategy { + prop_oneof![Just(StorageClass::Standard), Just(StorageClass::Express)] + } + + fn gen_timestamping_mode() -> impl Strategy { + prop_oneof![ + Just(TimestampingMode::ClientPrefer), + Just(TimestampingMode::ClientRequire), + Just(TimestampingMode::Arrival), + ] + } + + fn gen_retention_policy() -> impl Strategy { + prop_oneof![ + any::().prop_map(RetentionPolicy::Age), + Just(RetentionPolicy::Infinite(InfiniteRetention {})), + ] + } + + fn gen_timestamping_config() -> impl Strategy { + ( + proptest::option::of(gen_timestamping_mode()), + proptest::option::of(any::()), + ) + .prop_map(|(mode, uncapped)| TimestampingConfig { mode, uncapped }) + } + + fn gen_delete_on_empty_config() -> impl Strategy { + any::().prop_map(|min_age_secs| DeleteOnEmptyConfig { min_age_secs }) + } + + fn gen_stream_config() -> impl Strategy { + ( + proptest::option::of(gen_storage_class()), + proptest::option::of(gen_retention_policy()), + proptest::option::of(gen_timestamping_config()), + proptest::option::of(gen_delete_on_empty_config()), + ) + .prop_map( + |(storage_class, retention_policy, timestamping, delete_on_empty)| StreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + }, + ) + } + + fn gen_basin_config() -> impl Strategy { + ( + proptest::option::of(gen_stream_config()), + any::(), + any::(), + ) + .prop_map( + |(default_stream_config, create_stream_on_append, create_stream_on_read)| { + BasinConfig { + default_stream_config, + create_stream_on_append, + create_stream_on_read, + } + }, + ) + } + + fn gen_maybe( + inner: impl Strategy, + ) -> impl Strategy>> { + prop_oneof![ + Just(Maybe::Unspecified), + Just(Maybe::Specified(None)), + inner.prop_map(|v| Maybe::Specified(Some(v))), + ] + } + + fn gen_stream_reconfiguration() -> impl Strategy { + ( + gen_maybe(gen_storage_class()), + gen_maybe(gen_retention_policy()), + gen_maybe(gen_timestamping_reconfiguration()), + gen_maybe(gen_delete_on_empty_reconfiguration()), + ) + .prop_map( + |(storage_class, retention_policy, timestamping, delete_on_empty)| { + StreamReconfiguration { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } + }, + ) + } + + fn gen_timestamping_reconfiguration() -> impl Strategy { + (gen_maybe(gen_timestamping_mode()), gen_maybe(any::())) + .prop_map(|(mode, uncapped)| TimestampingReconfiguration { mode, uncapped }) + } + + fn gen_delete_on_empty_reconfiguration() -> impl Strategy + { + gen_maybe(any::()) + .prop_map(|min_age_secs| DeleteOnEmptyReconfiguration { min_age_secs }) + } + + fn gen_basin_reconfiguration() -> impl Strategy { + ( + gen_maybe(gen_stream_reconfiguration()), + prop_oneof![ + Just(Maybe::Unspecified), + any::().prop_map(Maybe::Specified), + ], + prop_oneof![ + Just(Maybe::Unspecified), + any::().prop_map(Maybe::Specified), + ], + ) + .prop_map( + |(default_stream_config, create_stream_on_append, create_stream_on_read)| { + BasinReconfiguration { + default_stream_config, + create_stream_on_append, + create_stream_on_read, + } + }, + ) + } + + fn gen_internal_optional_stream_config() + -> impl Strategy { + ( + proptest::option::of(gen_storage_class()), + proptest::option::of(gen_retention_policy()), + proptest::option::of(gen_timestamping_mode()), + proptest::option::of(any::()), + proptest::option::of(any::()), + ) + .prop_map(|(sc, rp, ts_mode, ts_uncapped, doe)| { + types::config::OptionalStreamConfig { + storage_class: sc.map(Into::into), + retention_policy: rp.map(|rp| match rp { + RetentionPolicy::Age(secs) => { + types::config::RetentionPolicy::Age(Duration::from_secs(secs)) + } + RetentionPolicy::Infinite(_) => types::config::RetentionPolicy::Infinite(), + }), + timestamping: types::config::OptionalTimestampingConfig { + mode: ts_mode.map(Into::into), + uncapped: ts_uncapped, + }, + delete_on_empty: types::config::OptionalDeleteOnEmptyConfig { + min_age: doe.map(Duration::from_secs), + }, + } + }) + } + + proptest! { + #[test] + fn stream_config_conversion_validates(config in gen_stream_config()) { + let has_zero_age = matches!(config.retention_policy, Some(RetentionPolicy::Age(0))); + let result: Result = config.try_into(); + + if has_zero_age { + prop_assert!(result.is_err()); + } else { + prop_assert!(result.is_ok()); + } + } + + #[test] + fn basin_config_conversion_validates(config in gen_basin_config()) { + let has_invalid_config = config.default_stream_config.as_ref().is_some_and(|sc| { + matches!(sc.retention_policy, Some(RetentionPolicy::Age(0))) + }); + + let result: Result = config.try_into(); + + if has_invalid_config { + prop_assert!(result.is_err()); + } else { + prop_assert!(result.is_ok()); + } + } + + #[test] + fn stream_reconfiguration_conversion_validates(reconfig in gen_stream_reconfiguration()) { + let has_zero_age = matches!( + reconfig.retention_policy, + Maybe::Specified(Some(RetentionPolicy::Age(0))) + ); + let result: Result = reconfig.try_into(); + + if has_zero_age { + prop_assert!(result.is_err()); + } else { + prop_assert!(result.is_ok()); + } + } + + #[test] + fn merge_stream_or_basin_or_default( + stream in gen_internal_optional_stream_config(), + basin in gen_internal_optional_stream_config(), + ) { + let merged = stream.clone().merge(basin.clone()); + + prop_assert_eq!( + merged.storage_class, + stream.storage_class.or(basin.storage_class).unwrap_or_default() + ); + prop_assert_eq!( + merged.retention_policy, + stream.retention_policy.or(basin.retention_policy).unwrap_or_default() + ); + prop_assert_eq!( + merged.timestamping.mode, + stream.timestamping.mode.or(basin.timestamping.mode).unwrap_or_default() + ); + prop_assert_eq!( + merged.timestamping.uncapped, + stream.timestamping.uncapped.or(basin.timestamping.uncapped).unwrap_or_default() + ); + prop_assert_eq!( + merged.delete_on_empty.min_age, + stream.delete_on_empty.min_age.or(basin.delete_on_empty.min_age).unwrap_or_default() + ); + } + + #[test] + fn reconfigure_unspecified_preserves_base(base in gen_internal_optional_stream_config()) { + let reconfig = types::config::StreamReconfiguration::default(); + let result = base.clone().reconfigure(reconfig); + + prop_assert_eq!(result.storage_class, base.storage_class); + prop_assert_eq!(result.retention_policy, base.retention_policy); + prop_assert_eq!(result.timestamping.mode, base.timestamping.mode); + prop_assert_eq!(result.timestamping.uncapped, base.timestamping.uncapped); + prop_assert_eq!(result.delete_on_empty.min_age, base.delete_on_empty.min_age); + } + + #[test] + fn reconfigure_specified_none_clears(base in gen_internal_optional_stream_config()) { + let reconfig = types::config::StreamReconfiguration { + storage_class: Maybe::Specified(None), + retention_policy: Maybe::Specified(None), + timestamping: Maybe::Specified(None), + delete_on_empty: Maybe::Specified(None), + }; + let result = base.reconfigure(reconfig); + + prop_assert!(result.storage_class.is_none()); + prop_assert!(result.retention_policy.is_none()); + prop_assert!(result.timestamping.mode.is_none()); + prop_assert!(result.timestamping.uncapped.is_none()); + prop_assert!(result.delete_on_empty.min_age.is_none()); + } + + #[test] + fn reconfigure_specified_some_sets_value( + base in gen_internal_optional_stream_config(), + new_sc in gen_storage_class(), + new_rp_secs in 1u64..u64::MAX, + ) { + let reconfig = types::config::StreamReconfiguration { + storage_class: Maybe::Specified(Some(new_sc.into())), + retention_policy: Maybe::Specified(Some( + types::config::RetentionPolicy::Age(Duration::from_secs(new_rp_secs)) + )), + ..Default::default() + }; + let result = base.reconfigure(reconfig); + + prop_assert_eq!(result.storage_class, Some(new_sc.into())); + prop_assert_eq!( + result.retention_policy, + Some(types::config::RetentionPolicy::Age(Duration::from_secs(new_rp_secs))) + ); + } + + #[test] + fn to_opt_returns_some_for_non_defaults( + sc in gen_storage_class(), + doe_secs in 1u64..u64::MAX, + ts_mode in gen_timestamping_mode(), + ) { + // non-default storage class -> Some + let internal = types::config::OptionalStreamConfig { + storage_class: Some(sc.into()), + ..Default::default() + }; + prop_assert!(StreamConfig::to_opt(internal).is_some()); + + // non-zero delete_on_empty -> Some + let internal = types::config::OptionalDeleteOnEmptyConfig { + min_age: Some(Duration::from_secs(doe_secs)), + }; + let api = DeleteOnEmptyConfig::to_opt(internal); + prop_assert!(api.is_some()); + prop_assert_eq!(api.unwrap().min_age_secs, doe_secs); + + // non-default timestamping -> Some + let internal = types::config::OptionalTimestampingConfig { + mode: Some(ts_mode.into()), + uncapped: None, + }; + prop_assert!(TimestampingConfig::to_opt(internal).is_some()); + } + + #[test] + fn basin_reconfiguration_conversion_validates(reconfig in gen_basin_reconfiguration()) { + let has_zero_age = matches!( + &reconfig.default_stream_config, + Maybe::Specified(Some(sr)) if matches!( + sr.retention_policy, + Maybe::Specified(Some(RetentionPolicy::Age(0))) + ) + ); + let result: Result = reconfig.try_into(); + + if has_zero_age { + prop_assert!(result.is_err()); + } else { + prop_assert!(result.is_ok()); + } + } + + #[test] + fn reconfigure_basin_unspecified_preserves( + base_sc in proptest::option::of(gen_storage_class()), + base_on_append in any::(), + base_on_read in any::(), + ) { + let base = types::config::BasinConfig { + default_stream_config: types::config::OptionalStreamConfig { + storage_class: base_sc.map(Into::into), + ..Default::default() + }, + create_stream_on_append: base_on_append, + create_stream_on_read: base_on_read, + }; + + let reconfig = types::config::BasinReconfiguration::default(); + let result = base.clone().reconfigure(reconfig); + + prop_assert_eq!(result.default_stream_config.storage_class, base.default_stream_config.storage_class); + prop_assert_eq!(result.create_stream_on_append, base.create_stream_on_append); + prop_assert_eq!(result.create_stream_on_read, base.create_stream_on_read); + } + + #[test] + fn reconfigure_basin_specified_updates( + base_on_append in any::(), + new_on_append in any::(), + new_sc in gen_storage_class(), + ) { + let base = types::config::BasinConfig { + create_stream_on_append: base_on_append, + ..Default::default() + }; + + let reconfig = types::config::BasinReconfiguration { + default_stream_config: Maybe::Specified(Some(types::config::StreamReconfiguration { + storage_class: Maybe::Specified(Some(new_sc.into())), + ..Default::default() + })), + create_stream_on_append: Maybe::Specified(new_on_append), + ..Default::default() + }; + let result = base.reconfigure(reconfig); + + prop_assert_eq!(result.default_stream_config.storage_class, Some(new_sc.into())); + prop_assert_eq!(result.create_stream_on_append, new_on_append); + } + + #[test] + fn reconfigure_nested_partial_update( + base_mode in gen_timestamping_mode(), + base_uncapped in any::(), + new_mode in gen_timestamping_mode(), + ) { + let base = types::config::OptionalStreamConfig { + timestamping: types::config::OptionalTimestampingConfig { + mode: Some(base_mode.into()), + uncapped: Some(base_uncapped), + }, + ..Default::default() + }; + + let expected_mode: types::config::TimestampingMode = new_mode.into(); + + let reconfig = types::config::StreamReconfiguration { + timestamping: Maybe::Specified(Some(types::config::TimestampingReconfiguration { + mode: Maybe::Specified(Some(expected_mode)), + uncapped: Maybe::Unspecified, + })), + ..Default::default() + }; + let result = base.reconfigure(reconfig); + + prop_assert_eq!(result.timestamping.mode, Some(expected_mode)); + prop_assert_eq!(result.timestamping.uncapped, Some(base_uncapped)); + } + } + + #[test] + fn to_opt_returns_none_for_defaults() { + // default stream config -> None + assert!(StreamConfig::to_opt(types::config::OptionalStreamConfig::default()).is_none()); + + // delete_on_empty: None or Some(ZERO) -> None + let doe_none = types::config::OptionalDeleteOnEmptyConfig { min_age: None }; + let doe_zero = types::config::OptionalDeleteOnEmptyConfig { + min_age: Some(Duration::ZERO), + }; + assert!(DeleteOnEmptyConfig::to_opt(doe_none).is_none()); + assert!(DeleteOnEmptyConfig::to_opt(doe_zero).is_none()); + + // default timestamping -> None + assert!( + TimestampingConfig::to_opt(types::config::OptionalTimestampingConfig::default()) + .is_none() + ); + } + + #[test] + fn empty_json_converts_to_all_none() { + let json = serde_json::json!({}); + let parsed: StreamConfig = serde_json::from_value(json).unwrap(); + let internal: types::config::OptionalStreamConfig = parsed.try_into().unwrap(); + + assert!( + internal.storage_class.is_none(), + "storage_class should be None" + ); + assert!( + internal.retention_policy.is_none(), + "retention_policy should be None" + ); + assert!( + internal.timestamping.mode.is_none(), + "timestamping.mode should be None" + ); + assert!( + internal.timestamping.uncapped.is_none(), + "timestamping.uncapped should be None" + ); + assert!( + internal.delete_on_empty.min_age.is_none(), + "delete_on_empty.min_age should be None" + ); + } +} From 5678a6cf7c163e4de88eaf384f31073857705a09 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Fri, 19 Dec 2025 13:41:05 -0500 Subject: [PATCH 014/700] chore: `TryFrom for EnvelopedRecord` should not panic (#9) still a bad thing if there is truncated data but leave panicking to the caller --- common/src/record/envelope.rs | 55 ++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/common/src/record/envelope.rs b/common/src/record/envelope.rs index a1132b2d..f7a6d574 100644 --- a/common/src/record/envelope.rs +++ b/common/src/record/envelope.rs @@ -107,7 +107,6 @@ impl TryFrom for EnvelopeRecord { .try_into() .map_err(|info| InternalRecordError::InvalidValue("HeaderFlag", info))?; if flag.num_headers_length_bytes == 0 { - // No headers. return Ok(Self { encoding_info: EMPTY_HEADERS_ENCODING_INFO, headers: vec![], @@ -115,15 +114,31 @@ impl TryFrom for EnvelopeRecord { }); } - let num_headers = buf.get_uint(flag.num_headers_length_bytes as usize); + let num_headers = buf + .try_get_uint(flag.num_headers_length_bytes as usize) + .map_err(|_| InternalRecordError::Truncated("NumHeaders"))?; let mut headers_total_bytes = 0; let mut headers: Vec

= Vec::with_capacity(num_headers as usize); for _ in 0..num_headers { - let name_len = buf.get_uint(flag.name_length_bytes.get() as usize); - let name = buf.copy_to_bytes(name_len as usize); - let value_len = buf.get_uint(flag.value_length_bytes.get() as usize); - let value = buf.copy_to_bytes(value_len as usize); + let name_len = buf + .try_get_uint(flag.name_length_bytes.get() as usize) + .map_err(|_| InternalRecordError::Truncated("HeaderNameLen"))? + as usize; + if buf.remaining() < name_len { + return Err(InternalRecordError::Truncated("HeaderName")); + } + let name = buf.split_to(name_len); + + let value_len = buf + .try_get_uint(flag.value_length_bytes.get() as usize) + .map_err(|_| InternalRecordError::Truncated("HeaderValueLen"))? + as usize; + if buf.remaining() < value_len { + return Err(InternalRecordError::Truncated("HeaderValue")); + } + let value = buf.split_to(value_len); + headers_total_bytes += name.len() + value.len(); headers.push(Header { name, value }) } @@ -256,7 +271,7 @@ mod test { use bytes::Bytes; - use super::{Encodable as _, EnvelopeRecord, Header, HeaderFlag}; + use super::{Encodable as _, EnvelopeRecord, Header, HeaderFlag, InternalRecordError}; fn roundtrip_parts(headers: Vec
, body: Bytes) { let encoded: Bytes = EnvelopeRecord::try_from_parts(headers.clone(), body.clone()) @@ -369,4 +384,30 @@ mod test { .len() ); } + + #[test] + fn truncated_returns_error() { + let record = EnvelopeRecord::try_from_parts( + vec![Header { + name: Bytes::from("key"), + value: Bytes::from("value"), + }], + Bytes::new(), + ) + .unwrap(); + let encoded = record.to_bytes(); + + // Truncation anywhere before the end should error + // (with empty body, there's no trailing data that can be truncated safely). + for len in 1..encoded.len() { + let truncated = encoded.slice(..len); + assert!( + matches!( + EnvelopeRecord::try_from(truncated), + Err(InternalRecordError::Truncated(_)) + ), + "expected Truncated error for len {len}" + ); + } + } } From c2c8e40c379c9569c6b7c51abe0b0439428246de Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Sun, 21 Dec 2025 14:36:12 -0500 Subject: [PATCH 015/700] fix: basin name min len for openapi (#10) --- common/src/types/basin.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/types/basin.rs b/common/src/types/basin.rs index 32e18048..f5ae085b 100644 --- a/common/src/types/basin.rs +++ b/common/src/types/basin.rs @@ -23,7 +23,7 @@ where fn schema() -> utoipa::openapi::RefOr { utoipa::openapi::Object::builder() .schema_type(utoipa::openapi::Type::String) - .min_length((!T::IS_PREFIX).then_some(1)) + .min_length((!T::IS_PREFIX).then_some(Self::MIN_LENGTH)) .max_length(Some(Self::MAX_LENGTH)) .into() } From a4b2b403c746c0626165e906f248c0c8095e0a25 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Tue, 23 Dec 2025 17:51:01 -0500 Subject: [PATCH 016/700] chore: don't stringly type timestamps in `s2-api` (#12) --- Cargo.toml | 2 +- api/src/v1/access.rs | 81 +++++++++++----------------------------- api/src/v1/stream/mod.rs | 45 ++++++---------------- 3 files changed, 35 insertions(+), 93 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index cdab63e2..077eb07e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,5 +36,5 @@ thiserror = "2.0.17" time = { version = "0.3.44", features = ["serde", "parsing", "formatting"] } tokio = "1.48.0" tokio-util = "0.7.17" -utoipa = { git = "https://github.com/infiniteregrets/utoipa", rev = "9cd181d40ac0a50551ebe1995a4d73e8685890d6" } +utoipa = { git = "https://github.com/infiniteregrets/utoipa", rev = "9cd181d40ac0a50551ebe1995a4d73e8685890d6", features = ["time"] } zstd = "0.13.3" diff --git a/api/src/v1/access.rs b/api/src/v1/access.rs index 1431c48c..4844d651 100644 --- a/api/src/v1/access.rs +++ b/api/src/v1/access.rs @@ -42,7 +42,7 @@ where } } -use time::{OffsetDateTime, format_description::well_known::Iso8601}; +use time::OffsetDateTime; #[cfg(feature = "utoipa")] use utoipa::{IntoParams, ToSchema}; @@ -159,10 +159,10 @@ pub struct AccessTokenInfo { /// Access token ID. /// It must be unique to the account and between 1 and 96 bytes in length. pub id: types::access::AccessTokenId, - /// Expiration time in ISO 8601 format. + /// Expiration time in RFC 3339 format. /// If not set, the expiration will be set to that of the requestor's token. - #[cfg_attr(feature = "utoipa", schema(format = Time))] - pub expires_at: Option, + #[serde(default, with = "time::serde::rfc3339::option")] + pub expires_at: Option, /// Namespace streams based on the configured stream-level scope, which must be a prefix. /// Stream name arguments will be automatically prefixed, and the prefix will be stripped when listing streams. #[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))] @@ -175,62 +175,33 @@ impl TryFrom for types::access::IssueAccessTokenRequest { type Error = types::ValidationError; fn try_from(value: AccessTokenInfo) -> Result { - let AccessTokenInfo { - id, - expires_at, - auto_prefix_streams, - scope, - } = value; - Ok(Self { - id, - expires_at: expires_at - .map(|e| OffsetDateTime::parse(&e, &Iso8601::DEFAULT)) - .transpose() - .map_err(|_| "Invalid ISO-8601 formatted `expires_at` time")?, - auto_prefix_streams: auto_prefix_streams.unwrap_or_default(), - scope: scope.try_into()?, + id: value.id, + expires_at: value.expires_at, + auto_prefix_streams: value.auto_prefix_streams.unwrap_or_default(), + scope: value.scope.try_into()?, }) } } impl From for AccessTokenInfo { fn from(value: types::access::AccessTokenInfo) -> Self { - let types::access::AccessTokenInfo { - id, - expires_at, - auto_prefix_streams, - scope, - } = value; - Self { - id, - expires_at: Some( - expires_at - .format(&Iso8601::DEFAULT) - .expect("valid iso8601 time"), - ), - auto_prefix_streams: Some(auto_prefix_streams), - scope: scope.into(), + id: value.id, + expires_at: Some(value.expires_at), + auto_prefix_streams: Some(value.auto_prefix_streams), + scope: value.scope.into(), } } } impl From for AccessTokenInfo { fn from(value: types::access::IssueAccessTokenRequest) -> Self { - let types::access::IssueAccessTokenRequest { - id, - expires_at, - auto_prefix_streams, - scope, - } = value; - Self { - id, - expires_at: expires_at - .map(|e| e.format(&Iso8601::DEFAULT).expect("valid iso8601 time")), - auto_prefix_streams: Some(auto_prefix_streams), - scope: scope.into(), + id: value.id, + expires_at: value.expires_at, + auto_prefix_streams: Some(value.auto_prefix_streams), + scope: value.scope.into(), } } } @@ -239,21 +210,13 @@ impl TryFrom for types::access::AccessTokenInfo { type Error = types::ValidationError; fn try_from(value: AccessTokenInfo) -> Result { - let AccessTokenInfo { - id, - expires_at, - auto_prefix_streams, - scope, - } = value; - Ok(Self { - id, - expires_at: expires_at.ok_or("Missing `expires_at`").and_then(|e| { - OffsetDateTime::parse(&e, &Iso8601::DEFAULT) - .map_err(|_| "Invalid ISO-8601 formatted `expires_at` time") - })?, - auto_prefix_streams: auto_prefix_streams.unwrap_or_default(), - scope: scope.try_into()?, + id: value.id, + expires_at: value + .expires_at + .ok_or_else(|| types::ValidationError("Missing `expires_at`".to_owned()))?, + auto_prefix_streams: value.auto_prefix_streams.unwrap_or_default(), + scope: value.scope.try_into()?, }) } } diff --git a/api/src/v1/stream/mod.rs b/api/src/v1/stream/mod.rs index f03a4194..a31296c8 100644 --- a/api/src/v1/stream/mod.rs +++ b/api/src/v1/stream/mod.rs @@ -29,51 +29,30 @@ pub const S2_FORMAT_HEADER: http::HeaderName = http::HeaderName::from_static("s2 pub struct StreamInfo { /// Stream name. pub name: StreamName, - /// Creation time in ISO 8601 format. - #[cfg_attr(feature = "utoipa", schema(format = Time))] - pub created_at: String, - /// Deletion time in ISO 8601 format, if the stream is being deleted. - #[cfg_attr(feature = "utoipa", schema(format = Time))] - pub deleted_at: Option, + /// Creation time in RFC 3339 format. + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + /// Deletion time in RFC 3339 format, if the stream is being deleted. + #[serde(with = "time::serde::rfc3339::option")] + pub deleted_at: Option, } impl From for StreamInfo { fn from(value: types::stream::StreamInfo) -> Self { - use time::format_description::well_known::Iso8601; - - let types::stream::StreamInfo { - name, - created_at, - deleted_at, - } = value; - Self { - name, - created_at: created_at - .format(&Iso8601::DEFAULT) - .expect("valid iso8601 time"), - deleted_at: deleted_at - .map(|d| d.format(&Iso8601::DEFAULT).expect("valid iso8601 time")), + name: value.name, + created_at: value.created_at, + deleted_at: value.deleted_at, } } } impl From for types::stream::StreamInfo { fn from(value: StreamInfo) -> Self { - use time::format_description::well_known::Iso8601; - - let StreamInfo { - name, - created_at, - deleted_at, - } = value; - Self { - name, - created_at: OffsetDateTime::parse(&created_at, &Iso8601::DEFAULT) - .expect("valid iso8601 time"), - deleted_at: deleted_at - .map(|d| OffsetDateTime::parse(&d, &Iso8601::DEFAULT).expect("valid iso8601 time")), + name: value.name, + created_at: value.created_at, + deleted_at: value.deleted_at, } } } From a0523ba0f9d001ce6155eeccd3bb7d0866028ca9 Mon Sep 17 00:00:00 2001 From: quettabit <27509167+quettabit@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:35:59 -0700 Subject: [PATCH 017/700] chore: remove conversions and builders that are no longer used (#6) --- api/src/v1/access.rs | 15 ----- api/src/v1/basin.rs | 22 ------- api/src/v1/metrics.rs | 117 --------------------------------- api/src/v1/mod.rs | 12 ---- api/src/v1/stream/mod.rs | 37 ----------- api/src/v1/stream/proto/mod.rs | 85 ------------------------ common/src/types/access.rs | 99 ---------------------------- common/src/types/config.rs | 59 ----------------- common/src/types/resources.rs | 37 +---------- 9 files changed, 1 insertion(+), 482 deletions(-) diff --git a/api/src/v1/access.rs b/api/src/v1/access.rs index 4844d651..22953b94 100644 --- a/api/src/v1/access.rs +++ b/api/src/v1/access.rs @@ -206,21 +206,6 @@ impl From for AccessTokenInfo { } } -impl TryFrom for types::access::AccessTokenInfo { - type Error = types::ValidationError; - - fn try_from(value: AccessTokenInfo) -> Result { - Ok(Self { - id: value.id, - expires_at: value - .expires_at - .ok_or_else(|| types::ValidationError("Missing `expires_at`".to_owned()))?, - auto_prefix_streams: value.auto_prefix_streams.unwrap_or_default(), - scope: value.scope.try_into()?, - }) - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] diff --git a/api/src/v1/basin.rs b/api/src/v1/basin.rs index 485b4551..a5116b72 100644 --- a/api/src/v1/basin.rs +++ b/api/src/v1/basin.rs @@ -66,18 +66,6 @@ impl From for BasinInfo { } } -impl From for types::basin::BasinInfo { - fn from(value: BasinInfo) -> Self { - let BasinInfo { name, scope, state } = value; - - Self { - name, - scope: scope.into(), - state: state.into(), - } - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] @@ -126,16 +114,6 @@ impl From for BasinState { } } -impl From for types::basin::BasinState { - fn from(value: BasinState) -> Self { - match value { - BasinState::Active => Self::Active, - BasinState::Creating => Self::Creating, - BasinState::Deleting => Self::Deleting, - } - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] diff --git a/api/src/v1/metrics.rs b/api/src/v1/metrics.rs index f0305666..632b5ee5 100644 --- a/api/src/v1/metrics.rs +++ b/api/src/v1/metrics.rs @@ -63,20 +63,6 @@ impl From for types::metrics::AccountMetricsRequest { } } -impl From for AccountMetricSetRequest { - fn from(value: types::metrics::AccountMetricsRequest) -> Self { - Self { - set: match value.set { - types::metrics::AccountMetricSet::ActiveBasins => AccountMetricSet::ActiveBasins, - types::metrics::AccountMetricSet::AccountOps => AccountMetricSet::AccountOps, - }, - start: value.start, - end: value.end, - interval: value.interval.map(Into::into), - } - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] @@ -123,26 +109,6 @@ impl From for types::metrics::BasinMetricsRequest { } } -impl From for BasinMetricSetRequest { - fn from(value: types::metrics::BasinMetricsRequest) -> Self { - Self { - set: match value.set { - types::metrics::BasinMetricSet::AppendOps => BasinMetricSet::AppendOps, - types::metrics::BasinMetricSet::AppendThroughput => { - BasinMetricSet::AppendThroughput - } - types::metrics::BasinMetricSet::BasinOps => BasinMetricSet::BasinOps, - types::metrics::BasinMetricSet::ReadOps => BasinMetricSet::ReadOps, - types::metrics::BasinMetricSet::ReadThroughput => BasinMetricSet::ReadThroughput, - types::metrics::BasinMetricSet::Storage => BasinMetricSet::Storage, - }, - start: value.start, - end: value.end, - interval: value.interval.map(Into::into), - } - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] @@ -190,19 +156,6 @@ impl From for types::metrics::StreamMetricsRequest { } } -impl From for StreamMetricSetRequest { - fn from(value: types::metrics::StreamMetricsRequest) -> Self { - Self { - set: match value.set { - types::metrics::StreamMetricSet::Storage => StreamMetricSet::Storage, - }, - start: value.start, - end: value.end, - interval: value.interval.map(Into::into), - } - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] @@ -230,15 +183,6 @@ impl From for MetricUnit { } } -impl From for types::metrics::MetricUnit { - fn from(value: MetricUnit) -> Self { - match value { - MetricUnit::Bytes => types::metrics::MetricUnit::Bytes, - MetricUnit::Operations => types::metrics::MetricUnit::Operations, - } - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] @@ -262,16 +206,6 @@ impl From for ScalarMetric { } } -impl From for types::metrics::ScalarMetric { - fn from(value: ScalarMetric) -> Self { - Self { - name: value.name, - unit: value.unit.into(), - value: value.value, - } - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] @@ -300,17 +234,6 @@ impl From for AccumulationMetric { } } -impl From for types::metrics::AccumulationMetric { - fn from(value: AccumulationMetric) -> Self { - Self { - name: value.name, - unit: value.unit.into(), - bucket_length: value.bucket_length.into(), - values: value.values, - } - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] @@ -336,16 +259,6 @@ impl From for GaugeMetric { } } -impl From for types::metrics::GaugeMetric { - fn from(value: GaugeMetric) -> Self { - Self { - name: value.name, - unit: value.unit.into(), - values: value.values, - } - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] @@ -366,15 +279,6 @@ impl From for LabelMetric { } } -impl From for types::metrics::LabelMetric { - fn from(value: LabelMetric) -> Self { - Self { - name: value.name, - values: value.values, - } - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] @@ -404,19 +308,6 @@ impl From for Metric { } } -impl From for types::metrics::Metric { - fn from(value: Metric) -> Self { - match value { - Metric::Scalar(scalar) => types::metrics::Metric::Scalar(scalar.into()), - Metric::Accumulation(timeseries) => { - types::metrics::Metric::Accumulation(timeseries.into()) - } - Metric::Gauge(timeseries) => types::metrics::Metric::Gauge(timeseries.into()), - Metric::Label(label) => types::metrics::Metric::Label(label.into()), - } - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] @@ -432,11 +323,3 @@ impl From for MetricSetResponse { } } } - -impl From for types::metrics::MetricsResponse { - fn from(value: MetricSetResponse) -> Self { - Self { - values: value.values.into_iter().map(Into::into).collect(), - } - } -} diff --git a/api/src/v1/mod.rs b/api/src/v1/mod.rs index 6b316186..5e822fda 100644 --- a/api/src/v1/mod.rs +++ b/api/src/v1/mod.rs @@ -65,18 +65,6 @@ macro_rules! impl_list_request_conversions { })?) } } - - impl From> for $name { - fn from(value: types::resources::ListItemsRequest<$prefix, $start_after>) -> Self { - let parts: types::resources::ListItemsRequestParts<$prefix, $start_after> = - value.into(); - Self { - prefix: Some(parts.prefix), - start_after: Some(parts.start_after), - limit: Some(parts.limit.into()), - } - } - } }; } diff --git a/api/src/v1/stream/mod.rs b/api/src/v1/stream/mod.rs index a31296c8..fb7c9f52 100644 --- a/api/src/v1/stream/mod.rs +++ b/api/src/v1/stream/mod.rs @@ -47,16 +47,6 @@ impl From for StreamInfo { } } -impl From for types::stream::StreamInfo { - fn from(value: StreamInfo) -> Self { - Self { - name: value.name, - created_at: value.created_at, - deleted_at: value.deleted_at, - } - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(IntoParams))] @@ -180,22 +170,6 @@ impl TryFrom for types::stream::ReadStart { } } -impl From for ReadStart { - fn from(types::stream::ReadStart { from, clamp }: types::stream::ReadStart) -> Self { - let (seq_num, timestamp, tail_offset) = match from { - types::stream::ReadFrom::SeqNum(seq_num) => (Some(seq_num), None, None), - types::stream::ReadFrom::Timestamp(timestamp) => (None, Some(timestamp), None), - types::stream::ReadFrom::TailOffset(tail_offset) => (None, None, Some(tail_offset)), - }; - Self { - seq_num, - timestamp, - tail_offset, - clamp: Some(clamp), - } - } -} - #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(IntoParams))] @@ -233,17 +207,6 @@ impl From for types::stream::ReadEnd { } } -impl From for ReadEnd { - fn from(value: types::stream::ReadEnd) -> Self { - Self { - count: value.limit.count(), - bytes: value.limit.bytes(), - until: value.until.into(), - wait: value.wait.map(|w| w.as_secs() as u32), - } - } -} - #[derive(Debug, Clone, Copy)] pub enum ReadResponseKind { /// Unary response diff --git a/api/src/v1/stream/proto/mod.rs b/api/src/v1/stream/proto/mod.rs index 63e348f4..6a51d19a 100644 --- a/api/src/v1/stream/proto/mod.rs +++ b/api/src/v1/stream/proto/mod.rs @@ -3,12 +3,6 @@ use s2_common::{record, types}; include!("s2.v1.rs"); -impl From for types::stream::StreamPosition { - fn from(StreamPosition { seq_num, timestamp }: StreamPosition) -> Self { - Self { seq_num, timestamp } - } -} - impl From for StreamPosition { fn from( types::stream::StreamPosition { seq_num, timestamp }: types::stream::StreamPosition, @@ -77,35 +71,6 @@ impl TryFrom for types::stream::AppendInput { } } -impl From for AppendInput { - fn from( - types::stream::AppendInput { - records, - match_seq_num, - fencing_token, - }: types::stream::AppendInput, - ) -> Self { - let records = records - .into_iter() - .map(|record| { - let types::stream::AppendRecordParts { timestamp, record } = record.into(); - let (headers, body) = record.into_inner().into_parts(); - AppendRecord { - timestamp, - headers: headers.into_iter().map(Into::into).collect(), - body, - } - }) - .collect(); - - Self { - records, - match_seq_num, - fencing_token: fencing_token.as_ref().map(|t| t.to_string()), - } - } -} - impl From for AppendAck { fn from(types::stream::AppendAck { start, end, tail }: types::stream::AppendAck) -> Self { Self { @@ -116,16 +81,6 @@ impl From for AppendAck { } } -impl From for types::stream::AppendAck { - fn from(AppendAck { start, end, tail }: AppendAck) -> Self { - Self { - start: start.unwrap_or_default().into(), - end: end.unwrap_or_default().into(), - tail: tail.unwrap_or_default().into(), - } - } -} - impl From for SequencedRecord { fn from( record::SequencedRecord { @@ -144,29 +99,6 @@ impl From for SequencedRecord { } } -impl TryFrom for record::SequencedRecord { - type Error = types::ValidationError; - - fn try_from( - SequencedRecord { - seq_num, - timestamp, - headers, - body, - }: SequencedRecord, - ) -> Result { - let record = - record::Record::try_from_parts(headers.into_iter().map(Into::into).collect(), body) - .map_err(|e| e.to_string())?; - - Ok(Self { - seq_num, - timestamp, - record, - }) - } -} - impl From for ReadBatch { fn from(batch: types::stream::ReadBatch) -> Self { Self { @@ -175,20 +107,3 @@ impl From for ReadBatch { } } } - -impl TryFrom for types::stream::ReadBatch { - type Error = types::ValidationError; - - fn try_from(batch: ReadBatch) -> Result { - let records: Vec<_> = batch - .records - .into_iter() - .map(record::SequencedRecord::try_from) - .collect::>()?; - - Ok(Self { - records: records.into(), - tail: batch.tail.map(Into::into), - }) - } -} diff --git a/common/src/types/access.rs b/common/src/types/access.rs index fab24285..efc7ceaf 100644 --- a/common/src/types/access.rs +++ b/common/src/types/access.rs @@ -190,20 +190,6 @@ pub struct ReadWritePermissions { pub write: bool, } -impl ReadWritePermissions { - pub fn new() -> Self { - Self::default() - } - - pub fn with_read(self, read: bool) -> Self { - Self { read, ..self } - } - - pub fn with_write(self, write: bool) -> Self { - Self { write, ..self } - } -} - #[derive(Debug, Clone, Default)] pub struct PermittedOperationGroups { pub account: ReadWritePermissions, @@ -211,24 +197,6 @@ pub struct PermittedOperationGroups { pub stream: ReadWritePermissions, } -impl PermittedOperationGroups { - pub fn new() -> Self { - Self::default() - } - - pub fn with_account(self, account: ReadWritePermissions) -> Self { - Self { account, ..self } - } - - pub fn with_basin(self, basin: ReadWritePermissions) -> Self { - Self { basin, ..self } - } - - pub fn with_stream(self, stream: ReadWritePermissions) -> Self { - Self { stream, ..self } - } -} - #[derive(Debug, Clone, Default)] pub struct AccessTokenScope { pub basins: BasinResourceSet, @@ -238,44 +206,6 @@ pub struct AccessTokenScope { pub ops: EnumSet, } -impl AccessTokenScope { - pub fn new() -> Self { - Self::default() - } - - pub fn with_basins(self, basins: BasinResourceSet) -> Self { - Self { basins, ..self } - } - - pub fn with_streams(self, streams: StreamResourceSet) -> Self { - Self { streams, ..self } - } - - pub fn with_tokens(self, access_tokens: AccessTokenResourceSet) -> Self { - Self { - access_tokens, - ..self - } - } - - pub fn with_op_groups(self, op_groups: PermittedOperationGroups) -> Self { - Self { op_groups, ..self } - } - - pub fn with_ops(self, ops: impl IntoIterator) -> Self { - Self { - ops: ops.into_iter().collect(), - ..self - } - } - - pub fn with_op(self, op: Operation) -> Self { - let mut ops = self.ops; - ops.insert(op); - Self { ops, ..self } - } -} - #[derive(Debug, Clone)] pub struct AccessTokenInfo { pub id: AccessTokenId, @@ -292,33 +222,4 @@ pub struct IssueAccessTokenRequest { pub scope: AccessTokenScope, } -impl IssueAccessTokenRequest { - pub fn new(id: impl AsRef) -> Result { - Ok(Self { - id: id.as_ref().parse()?, - expires_at: None, - auto_prefix_streams: false, - scope: AccessTokenScope::default(), - }) - } - - pub fn with_expires_at(self, expires_at: time::OffsetDateTime) -> Self { - Self { - expires_at: Some(expires_at), - ..self - } - } - - pub fn with_auto_prefix_streams(self, auto_prefix_streams: bool) -> Self { - Self { - auto_prefix_streams, - ..self - } - } - - pub fn with_scope(self, scope: AccessTokenScope) -> Self { - Self { scope, ..self } - } -} - pub type ListAccessTokensRequest = ListItemsRequest; diff --git a/common/src/types/config.rs b/common/src/types/config.rs index 52bef84d..3ee43c75 100644 --- a/common/src/types/config.rs +++ b/common/src/types/config.rs @@ -78,40 +78,6 @@ pub struct StreamConfig { pub delete_on_empty: DeleteOnEmptyConfig, } -impl StreamConfig { - pub fn new() -> Self { - Self::default() - } - - pub fn with_storage_class(self, storage_class: StorageClass) -> Self { - Self { - storage_class, - ..self - } - } - - pub fn with_retention_policy(self, retention_policy: RetentionPolicy) -> Self { - Self { - retention_policy, - ..self - } - } - - pub fn with_timestamping(self, timestamping: TimestampingConfig) -> Self { - Self { - timestamping, - ..self - } - } - - pub fn with_delete_on_empty(self, delete_on_empty: DeleteOnEmptyConfig) -> Self { - Self { - delete_on_empty, - ..self - } - } -} - #[derive(Debug, Clone, Default)] pub struct TimestampingReconfiguration { pub mode: Maybe>, @@ -349,31 +315,6 @@ pub struct BasinConfig { } impl BasinConfig { - pub fn new() -> Self { - Self::default() - } - - pub fn with_default_stream_config(self, default_stream_config: StreamConfig) -> Self { - Self { - default_stream_config: default_stream_config.into(), - ..self - } - } - - pub fn with_create_stream_on_append(self, create_stream_on_append: bool) -> Self { - Self { - create_stream_on_append, - ..self - } - } - - pub fn with_create_stream_on_read(self, create_stream_on_read: bool) -> Self { - Self { - create_stream_on_read, - ..self - } - } - pub fn reconfigure(mut self, reconfiguration: BasinReconfiguration) -> Self { let BasinReconfiguration { default_stream_config, diff --git a/common/src/types/resources.rs b/common/src/types/resources.rs index f034a092..f4fa0da3 100644 --- a/common/src/types/resources.rs +++ b/common/src/types/resources.rs @@ -1,6 +1,4 @@ -use std::{fmt::Debug, num::NonZeroUsize, ops::Deref, str::FromStr}; - -use crate::types::ValidationError; +use std::{fmt::Debug, num::NonZeroUsize, ops::Deref}; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Page { @@ -70,39 +68,6 @@ where P: Default, S: Default; -impl ListItemsRequest -where - P: Default + FromStr, - S: Default + FromStr, -

::Err: Debug, - ::Err: Debug, -{ - pub fn new() -> Self { - Self::default() - } - - pub fn with_prefix(mut self, prefix: impl AsRef) -> Result { - self.0.prefix = prefix.as_ref().parse().map_err(|e| format!("{:?}", e))?; - Ok(self) - } - - pub fn with_start_after( - mut self, - start_after: impl AsRef, - ) -> Result { - self.0.start_after = start_after - .as_ref() - .parse() - .map_err(|e| format!("{:?}", e))?; - Ok(self) - } - - pub fn with_limit(mut self, limit: impl Into) -> Self { - self.0.limit = limit.into(); - self - } -} - impl ListItemsRequest where P: Default, From 98bca860a5d3e8fc299286e445ab1aa7554a11d2 Mon Sep 17 00:00:00 2001 From: quettabit <27509167+quettabit@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:57:29 -0700 Subject: [PATCH 018/700] chore: derive `Deserialize` for `AppendConditionFailed` (#13) --- api/src/v1/stream/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/v1/stream/mod.rs b/api/src/v1/stream/mod.rs index fb7c9f52..2d151734 100644 --- a/api/src/v1/stream/mod.rs +++ b/api/src/v1/stream/mod.rs @@ -454,7 +454,7 @@ impl From for AppendAck { #[rustfmt::skip] /// Aborted due to a failed condition. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] #[serde(rename_all = "snake_case")] pub enum AppendConditionFailed { From b69e012f07290c9fb6ada77552362a93634287c7 Mon Sep 17 00:00:00 2001 From: quettabit <27509167+quettabit@users.noreply.github.com> Date: Tue, 23 Dec 2025 21:12:58 -0700 Subject: [PATCH 019/700] chore: add `*Name` -> `*NameStartAfter` mappers (#14) --- common/src/types/basin.rs | 6 ++++++ common/src/types/stream.rs | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/common/src/types/basin.rs b/common/src/types/basin.rs index f5ae085b..d760c01d 100644 --- a/common/src/types/basin.rs +++ b/common/src/types/basin.rs @@ -180,6 +180,12 @@ impl Default for BasinNameStartAfter { } } +impl From for BasinNameStartAfter { + fn from(value: BasinName) -> Self { + Self(value.0, PhantomData) + } +} + pub type ListBasinsRequest = ListItemsRequest; #[derive(Debug, Clone, Copy)] diff --git a/common/src/types/stream.rs b/common/src/types/stream.rs index 6fe9f991..421e9bc9 100644 --- a/common/src/types/stream.rs +++ b/common/src/types/stream.rs @@ -129,18 +129,18 @@ pub type StreamName = StreamNameStr; pub type StreamNamePrefix = StreamNameStr; -impl From for StreamNamePrefix { - fn from(value: StreamName) -> Self { - Self(value.0, PhantomData) - } -} - impl Default for StreamNamePrefix { fn default() -> Self { StreamNameStr(CompactString::default(), PhantomData) } } +impl From for StreamNamePrefix { + fn from(value: StreamName) -> Self { + Self(value.0, PhantomData) + } +} + pub type StreamNameStartAfter = StreamNameStr; impl Default for StreamNameStartAfter { @@ -149,6 +149,12 @@ impl Default for StreamNameStartAfter { } } +impl From for StreamNameStartAfter { + fn from(value: StreamName) -> Self { + Self(value.0, PhantomData) + } +} + #[derive(Debug, Clone)] pub struct StreamInfo { pub name: StreamName, From d377b2d3bc7ccbfadbaf6c8f39d1cf5dabe1fb6e Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Thu, 25 Dec 2025 16:24:18 -0500 Subject: [PATCH 020/700] chore: use `StreamPosition` instead of `(SeqNum, Timestamp)` tuples (#16) --- Cargo.lock | 302 ++++++++++++++++++++++++--------- Cargo.toml | 7 +- api/Cargo.toml | 2 +- api/src/v1/stream/mod.rs | 20 ++- api/src/v1/stream/proto/mod.rs | 9 +- common/Cargo.toml | 1 + common/src/maybe.rs | 2 + common/src/record/batcher.rs | 154 ++++++++--------- common/src/record/envelope.rs | 2 +- common/src/record/mod.rs | 62 +++++-- common/src/types/stream.rs | 14 +- lite/src/backend/ops.rs | 4 +- lite/src/backend/slatedb.rs | 9 +- 13 files changed, 372 insertions(+), 216 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16a7677d..0e6b0e00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,9 +109,12 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] [[package]] name = "arrayref" @@ -211,9 +214,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "bytes", @@ -338,9 +341,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytecheck" @@ -391,9 +394,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.49" +version = "1.2.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" dependencies = [ "find-msvc-tools", "jobserver", @@ -424,7 +427,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -746,12 +749,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "endian-type" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" - [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -887,9 +884,9 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flatbuffers" -version = "25.9.23" +version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b6620799e7340ebd9968d2e0708eb82cf1971e9a16821e2091b6d6e475eed5" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ "bitflags", "rustc_version", @@ -1394,7 +1391,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1571,9 +1568,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" [[package]] name = "jobserver" @@ -1686,7 +1683,7 @@ dependencies = [ "spin", "tokio", "tokio-util", - "toml 0.9.8", + "toml 0.9.10+spec-1.1.0", "tracing", "tracing-subscriber", ] @@ -1824,12 +1821,12 @@ dependencies = [ ] [[package]] -name = "nibble_vec" -version = "0.1.0" +name = "ntapi" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" dependencies = [ - "smallvec", + "winapi", ] [[package]] @@ -1856,6 +1853,25 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "object_store" version = "0.12.4" @@ -1975,7 +1991,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2101,7 +2117,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.9", + "toml_edit 0.23.10+spec-1.0.0", ] [[package]] @@ -2303,16 +2319,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "radix_trie" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" -dependencies = [ - "endian-type", - "nibble_vec", -] - [[package]] name = "rancor" version = "0.1.1" @@ -2463,9 +2469,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.25" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -2603,9 +2609,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags", "errno", @@ -2642,9 +2648,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "web-time", "zeroize", @@ -2681,9 +2687,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" [[package]] name = "s2-api" @@ -2726,6 +2732,7 @@ dependencies = [ "rkyv", "rstest", "serde", + "serde_json", "strum", "thiserror 2.0.17", "time", @@ -2836,15 +2843,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.147" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "6af14725505314343e673e9ecb7cd7e8a36aa9791eb936235a3567cc31447ae4" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -2869,9 +2876,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] @@ -2918,10 +2925,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -2952,8 +2960,7 @@ checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "slatedb" version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c3a10e8975744fddd018b3f442e6309f2a24713c4f1d65341ca0c472bc9c07" +source = "git+https://github.com/slatedb/slatedb?rev=4296c696b8b7c1cdf2ad79a9900de0d769a75576#4296c696b8b7c1cdf2ad79a9900de0d769a75576" dependencies = [ "anyhow", "async-trait", @@ -2977,12 +2984,12 @@ dependencies = [ "once_cell", "ouroboros", "parking_lot", - "radix_trie", "rand 0.9.2", "rand_xoshiro 0.7.0", "serde", "serde_json", "siphasher", + "sysinfo", "thiserror 1.0.69", "thread_local", "tokio", @@ -3113,10 +3120,24 @@ dependencies = [ ] [[package]] -name = "tempfile" -version = "3.23.0" +name = "sysinfo" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "3c3ffa3e4ff2b324a57f7aeb3c349656c7b127c3c189520251a648102a92496e" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom 0.3.4", @@ -3296,14 +3317,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.8" +version = "0.9.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.0.3", - "toml_datetime 0.7.3", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.14", @@ -3320,9 +3341,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] @@ -3343,21 +3364,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.9" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", - "toml_datetime 0.7.3", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.14", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow 0.7.14", ] @@ -3370,9 +3391,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tower" @@ -3422,9 +3443,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -3445,9 +3466,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -3752,6 +3773,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -3761,6 +3798,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3769,9 +3847,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -3796,19 +3885,53 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -3817,7 +3940,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3853,7 +3976,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3878,7 +4001,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -3889,6 +4012,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4124,6 +4256,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "zmij" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0095ecd462946aa3927d9297b63ef82fb9a5316d7a37d134eeb36e58228615a" + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 077eb07e..53cc7371 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,9 @@ license = "MIT" version = "0.1.0" [workspace.dependencies] +async-stream = "0.3.6" async-trait = "0.1.89" -axum = "0.8.7" +axum = "0.8.8" base64ct = { version = "1.8.1", features = ["alloc"] } blake3 = "1.8.2" bytes = "1.11.0" @@ -27,10 +28,12 @@ itertools = "0.14.0" proptest = "1.9.0" prost = "0.14.1" prost-build = "0.14.1" +rand = "0.9.2" rkyv = "0.8.12" rstest = "0.26.1" serde = { version = "1.0.228", features = ["derive"] } -slatedb = "0.9.2" +serde_json = "1.0.147" +slatedb = { git = "https://github.com/slatedb/slatedb", rev = "4296c696b8b7c1cdf2ad79a9900de0d769a75576" } strum = { version = "0.27", features = ["derive"] } thiserror = "2.0.17" time = { version = "0.3.44", features = ["serde", "parsing", "formatting"] } diff --git a/api/Cargo.toml b/api/Cargo.toml index bbaed512..817599c4 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -33,4 +33,4 @@ prost-build = { workspace = true, optional = true } [dev-dependencies] proptest = { workspace = true } -serde_json = "1" +serde_json = { workspace = true } diff --git a/api/src/v1/stream/mod.rs b/api/src/v1/stream/mod.rs index 2d151734..deb1d97c 100644 --- a/api/src/v1/stream/mod.rs +++ b/api/src/v1/stream/mod.rs @@ -104,8 +104,8 @@ pub struct StreamPosition { pub timestamp: record::Timestamp, } -impl From for StreamPosition { - fn from(pos: types::stream::StreamPosition) -> Self { +impl From for StreamPosition { + fn from(pos: record::StreamPosition) -> Self { Self { seq_num: pos.seq_num, timestamp: pos.timestamp, @@ -113,7 +113,7 @@ impl From for StreamPosition { } } -impl From for types::stream::StreamPosition { +impl From for record::StreamPosition { fn from(pos: StreamPosition) -> Self { Self { seq_num: pos.seq_num, @@ -315,11 +315,17 @@ impl S2Format { } } - pub fn encode_read(self, record: record::SequencedRecord) -> SequencedRecord { - let (headers, body) = record.record.into_parts(); + pub fn encode_read( + self, + record::SequencedRecord { + position: record::StreamPosition { seq_num, timestamp }, + record, + }: record::SequencedRecord, + ) -> SequencedRecord { + let (headers, body) = record.into_parts(); SequencedRecord { - seq_num: record.seq_num, - timestamp: record.timestamp, + seq_num, + timestamp, headers: headers .into_iter() .map(|h| Header(self.encode(&h.name), self.encode(&h.value))) diff --git a/api/src/v1/stream/proto/mod.rs b/api/src/v1/stream/proto/mod.rs index 6a51d19a..3ce75ea5 100644 --- a/api/src/v1/stream/proto/mod.rs +++ b/api/src/v1/stream/proto/mod.rs @@ -3,10 +3,8 @@ use s2_common::{record, types}; include!("s2.v1.rs"); -impl From for StreamPosition { - fn from( - types::stream::StreamPosition { seq_num, timestamp }: types::stream::StreamPosition, - ) -> Self { +impl From for StreamPosition { + fn from(record::StreamPosition { seq_num, timestamp }: record::StreamPosition) -> Self { Self { seq_num, timestamp } } } @@ -84,8 +82,7 @@ impl From for AppendAck { impl From for SequencedRecord { fn from( record::SequencedRecord { - seq_num, - timestamp, + position: record::StreamPosition { seq_num, timestamp }, record, }: record::SequencedRecord, ) -> Self { diff --git a/common/Cargo.toml b/common/Cargo.toml index 25f0e7af..d1e460c8 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -27,3 +27,4 @@ utoipa = { workspace = true, optional = true } [dev-dependencies] proptest = { workspace = true } rstest = { workspace = true } +serde_json = { workspace = true } diff --git a/common/src/maybe.rs b/common/src/maybe.rs index 560c3c26..5129e81c 100644 --- a/common/src/maybe.rs +++ b/common/src/maybe.rs @@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize}; /// # Examples /// /// ``` +/// use s2_common::maybe::Maybe; +/// /// #[derive(Debug, PartialEq, Eq, serde::Deserialize)] /// pub struct MyStruct { /// #[serde(default)] diff --git a/common/src/record/batcher.rs b/common/src/record/batcher.rs index 9a30c131..b5eb7aa5 100644 --- a/common/src/record/batcher.rs +++ b/common/src/record/batcher.rs @@ -6,7 +6,7 @@ use super::InternalRecordError; use crate::{ caps, read_extent::{EvaluatedReadLimit, ReadLimit, ReadUntil}, - record::{MeteredRecord, MeteredSequencedRecords, MeteredSize, SeqNum, Timestamp}, + record::{MeteredRecord, MeteredSequencedRecords, MeteredSize, StreamPosition}, }; #[derive(Debug)] @@ -17,7 +17,7 @@ pub struct RecordBatch { pub struct RecordBatcher where - I: Iterator>, + I: Iterator>, E: Into, { record_iterator: I, @@ -41,7 +41,7 @@ fn make_records(read_limit: &EvaluatedReadLimit) -> MeteredSequencedRecords { impl RecordBatcher where - I: Iterator>, + I: Iterator>, E: std::fmt::Debug + Into, { pub fn new(record_iterator: I, read_limit: ReadLimit, until: ReadUntil) -> Self { @@ -64,9 +64,9 @@ where let mut stashed_record = None; while self.buffered_error.is_none() { match self.record_iterator.next() { - Some(Ok((seq_num, timestamp, data))) => { + Some(Ok((position, data))) => { let record = match MeteredRecord::try_from(data) { - Ok(record) => record.sequenced(seq_num, timestamp), + Ok(record) => record.sequenced(position), Err(err) => { self.buffered_error = Some(err); break; @@ -76,7 +76,7 @@ where if remaining_limit.deny( self.buffered_records.len() + 1, self.buffered_records.metered_size() + record.metered_size(), - ) || self.until.deny(timestamp) + ) || self.until.deny(position.timestamp) { self.read_limit = EvaluatedReadLimit::Exhausted; break; @@ -137,7 +137,7 @@ where impl Iterator for RecordBatcher where - I: Iterator>, + I: Iterator>, E: std::fmt::Debug + Into, { type Item = Result; @@ -154,7 +154,7 @@ where impl FusedIterator for RecordBatcher where - I: Iterator>, + I: Iterator>, E: std::fmt::Debug + Into, { } @@ -167,64 +167,45 @@ mod tests { use crate::{ caps, read_extent::{ReadLimit, ReadUntil}, - record::{CommandRecord, Encodable, MeteredRecord, MeteredSize, Record}, + record::{ + CommandRecord, Encodable, MeteredSize, Record, SeqNum, SequencedRecord, Timestamp, + }, }; - type BatchItem = (SeqNum, Timestamp, Bytes); - - #[derive(Clone)] - struct TestRecord { - seq_num: SeqNum, - timestamp: Timestamp, - record: Record, - bytes: Bytes, - metered_size: usize, + fn test_record(seq_num: SeqNum, timestamp: Timestamp) -> SequencedRecord { + Record::Command(CommandRecord::Trim(seq_num)) + .sequenced(StreamPosition { seq_num, timestamp }) } - impl TestRecord { - fn trim(seq_num: SeqNum, timestamp: Timestamp) -> Self { - let record = Record::Command(CommandRecord::Trim(seq_num)); - let metered: MeteredRecord = record.clone().into(); - Self { - seq_num, - timestamp, - record, - bytes: metered.to_bytes(), - metered_size: metered.metered_size(), - } - } - - fn into_item(self) -> (SeqNum, Timestamp, Bytes) { - (self.seq_num, self.timestamp, self.bytes) - } + fn to_iter( + records: Vec, + ) -> impl Iterator> { + records + .into_iter() + .map(|SequencedRecord { position, record }| { + (position, MeteredRecord::from(record).to_bytes()) + }) + .map(Ok) } - fn to_ok(record: TestRecord) -> Result { - Ok(record.into_item()) - } - - fn assert_batch(batch: &RecordBatch, expected: &[TestRecord], is_terminal: bool) { + fn assert_batch(batch: &RecordBatch, expected: &[SequencedRecord], is_terminal: bool) { assert_eq!(batch.is_terminal, is_terminal); assert_eq!(batch.records.len(), expected.len()); - let expected_size: usize = expected.iter().map(|r| r.metered_size).sum(); + let expected_size: usize = expected.iter().map(|r| r.metered_size()).sum(); assert_eq!(batch.records.metered_size(), expected_size); for (actual, expected) in batch.records.iter().zip(expected.iter()) { - assert_eq!(actual.seq_num, expected.seq_num); - assert_eq!(actual.timestamp, expected.timestamp); - assert_eq!(actual.record, expected.record); + assert_eq!(actual, expected); } } #[test] fn collects_records_until_iterator_ends() { - let expected = vec![ - TestRecord::trim(1, 10), - TestRecord::trim(2, 11), - TestRecord::trim(3, 12), - ]; - let iterator = expected.clone().into_iter().map(to_ok); - let mut batcher = RecordBatcher::new(iterator, ReadLimit::Unbounded, ReadUntil::Unbounded); - + let expected = vec![test_record(1, 10), test_record(2, 11), test_record(3, 12)]; + let mut batcher = RecordBatcher::new( + to_iter(expected.clone()), + ReadLimit::Unbounded, + ReadUntil::Unbounded, + ); let batch = batcher.next().expect("batch expected").expect("ok batch"); assert_batch(&batch, &expected, false); assert!(batcher.next().is_none()); @@ -232,13 +213,12 @@ mod tests { #[test] fn stops_at_count_read_limit() { - let expected = vec![ - TestRecord::trim(1, 10), - TestRecord::trim(2, 11), - TestRecord::trim(3, 12), - ]; - let iterator = expected.clone().into_iter().map(to_ok); - let mut batcher = RecordBatcher::new(iterator, ReadLimit::Count(2), ReadUntil::Unbounded); + let expected = vec![test_record(1, 10), test_record(2, 11), test_record(3, 12)]; + let mut batcher = RecordBatcher::new( + to_iter(expected.clone()), + ReadLimit::Count(2), + ReadUntil::Unbounded, + ); let batch = batcher.next().expect("batch expected").expect("ok batch"); assert_batch(&batch, &expected[..2], true); @@ -247,11 +227,13 @@ mod tests { #[test] fn stops_at_byte_read_limit() { - let expected = vec![TestRecord::trim(1, 10), TestRecord::trim(2, 11)]; - let first_size = expected[0].metered_size; - let iterator = expected.clone().into_iter().map(to_ok); - let mut batcher = - RecordBatcher::new(iterator, ReadLimit::Bytes(first_size), ReadUntil::Unbounded); + let expected = vec![test_record(1, 10), test_record(2, 11)]; + let first_size = expected[0].metered_size(); + let mut batcher = RecordBatcher::new( + to_iter(expected.clone()), + ReadLimit::Bytes(first_size), + ReadUntil::Unbounded, + ); let batch = batcher.next().expect("batch expected").expect("ok batch"); assert_batch(&batch, &expected[..1], true); @@ -260,14 +242,12 @@ mod tests { #[test] fn stops_at_timestamp_limit() { - let expected = vec![ - TestRecord::trim(1, 10), - TestRecord::trim(2, 19), - TestRecord::trim(3, 20), - ]; - let iterator = expected.clone().into_iter().map(to_ok); - let mut batcher = - RecordBatcher::new(iterator, ReadLimit::Unbounded, ReadUntil::Timestamp(20)); + let expected = vec![test_record(1, 10), test_record(2, 19), test_record(3, 20)]; + let mut batcher = RecordBatcher::new( + to_iter(expected.clone()), + ReadLimit::Unbounded, + ReadUntil::Timestamp(20), + ); let batch = batcher.next().expect("batch expected").expect("ok batch"); assert_batch(&batch, &expected[..2], true); @@ -278,10 +258,13 @@ mod tests { fn splits_batches_when_caps_are_hit() { let mut records = Vec::with_capacity(caps::RECORD_BATCH_MAX.count + 1); for index in 0..=(caps::RECORD_BATCH_MAX.count as SeqNum) { - records.push(TestRecord::trim(index, index + 10)); + records.push(test_record(index, index + 10)); } - let iterator = records.clone().into_iter().map(to_ok); - let mut batcher = RecordBatcher::new(iterator, ReadLimit::Unbounded, ReadUntil::Unbounded); + let mut batcher = RecordBatcher::new( + to_iter(records.clone()), + ReadLimit::Unbounded, + ReadUntil::Unbounded, + ); let first_batch = batcher .next() @@ -307,15 +290,20 @@ mod tests { #[test] fn surfaces_decode_errors_after_draining_buffer() { - let records = vec![TestRecord::trim(1, 10), TestRecord::trim(2, 11)]; - let invalid_data = (3, 12, Bytes::new()); + let records = vec![test_record(1, 10), test_record(2, 11)]; + let invalid_data = ( + StreamPosition { + seq_num: 3, + timestamp: 12, + }, + Bytes::new(), + ); - let iterator = records - .clone() - .into_iter() - .map(to_ok) - .chain(std::iter::once(Ok(invalid_data))); - let mut batcher = RecordBatcher::new(iterator, ReadLimit::Unbounded, ReadUntil::Unbounded); + let mut batcher = RecordBatcher::new( + to_iter(records.clone()).chain(std::iter::once(Ok(invalid_data))), + ReadLimit::Unbounded, + ReadUntil::Unbounded, + ); let batch = batcher.next().expect("batch expected").expect("ok batch"); assert_batch(&batch, &records, false); @@ -330,7 +318,7 @@ mod tests { #[test] fn surfaces_iterator_errors_immediately() { - let iterator = std::iter::once::>( + let iterator = std::iter::once::>( Err(InternalRecordError::InvalidValue("test", "boom")), ); let mut batcher = RecordBatcher::new(iterator, ReadLimit::Unbounded, ReadUntil::Unbounded); diff --git a/common/src/record/envelope.rs b/common/src/record/envelope.rs index f7a6d574..5e021d50 100644 --- a/common/src/record/envelope.rs +++ b/common/src/record/envelope.rs @@ -15,7 +15,7 @@ pub enum HeaderValidationError { NameEmpty, } -#[derive(PartialEq, Clone)] +#[derive(PartialEq, Eq, Clone)] pub struct EnvelopeRecord { headers: Vec

, body: Bytes, diff --git a/common/src/record/mod.rs b/common/src/record/mod.rs index 39900760..a8997926 100644 --- a/common/src/record/mod.rs +++ b/common/src/record/mod.rs @@ -20,6 +20,31 @@ pub type SeqNum = u64; pub type NonZeroSeqNum = std::num::NonZeroU64; pub type Timestamp = u64; +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct StreamPosition { + pub seq_num: SeqNum, + pub timestamp: Timestamp, +} + +impl StreamPosition { + pub const MIN: StreamPosition = StreamPosition { + seq_num: SeqNum::MIN, + timestamp: Timestamp::MIN, + }; +} + +impl std::fmt::Display for StreamPosition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} @ {}", self.seq_num, self.timestamp) + } +} + +impl DeepSize for StreamPosition { + fn deep_size(&self) -> usize { + self.seq_num.deep_size() + self.timestamp.deep_size() + } +} + #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum InternalRecordError { #[error("Truncated: {0}")] @@ -123,7 +148,7 @@ impl From for u8 { } } -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Eq, Clone)] pub enum Record { Command(CommandRecord), Envelope(EnvelopeRecord), @@ -154,10 +179,9 @@ impl Record { Ok(Self::Envelope(envelope)) } - pub fn sequenced(self, seq_num: SeqNum, timestamp: Timestamp) -> SequencedRecord { + pub fn sequenced(self, position: StreamPosition) -> SequencedRecord { SequencedRecord { - seq_num, - timestamp, + position, record: self, } } @@ -234,10 +258,9 @@ impl Encodable for MeteredRecord { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SequencedRecord { - pub seq_num: SeqNum, - pub timestamp: Timestamp, + pub position: StreamPosition, pub record: Record, } @@ -249,17 +272,17 @@ impl MeteredSize for SequencedRecord { impl DeepSize for SequencedRecord { fn deep_size(&self) -> usize { - self.seq_num.deep_size() + self.timestamp.deep_size() + self.record.deep_size() + self.position.deep_size() + self.record.deep_size() } } pub type MeteredRecord = Metered; impl MeteredRecord { - pub fn sequenced(self, seq_num: SeqNum, timestamp: Timestamp) -> MeteredSequencedRecord { + pub fn sequenced(self, position: StreamPosition) -> MeteredSequencedRecord { MeteredSequencedRecord { size: self.metered_size(), - inner: self.inner.sequenced(seq_num, timestamp), + inner: self.inner.sequenced(position), } } @@ -347,9 +370,8 @@ mod test { let as_bytes = metered_record.clone().to_bytes(); let decoded_record = MeteredRecord::try_from(as_bytes).unwrap(); prop_assert_eq!(&decoded_record, &metered_record); - let sequenced = decoded_record.sequenced(seq_num, timestamp); - assert_eq!(sequenced.seq_num, seq_num); - assert_eq!(sequenced.timestamp, timestamp); + let sequenced = decoded_record.sequenced(StreamPosition { seq_num, timestamp }); + assert_eq!(sequenced.position, StreamPosition {seq_num, timestamp}); assert_eq!(sequenced.record, record); } ); @@ -424,11 +446,19 @@ mod test { } Record::Envelope(e) => panic!("Command expected, got Envelope: {e:?}"), } - let sequenced_record = record.sequenced(42, 100_000); + let sequenced_record = record.sequenced(StreamPosition { + seq_num: 42, + timestamp: 100_000, + }); let sequenced_metered = sequenced_record.metered_size(); assert_eq!(record_metered, sequenced_metered); - assert_eq!(sequenced_record.seq_num, 42); - assert_eq!(sequenced_record.timestamp, 100_000); + assert_eq!( + sequenced_record.position, + StreamPosition { + seq_num: 42, + timestamp: 100_000, + } + ); assert_eq!( sequenced_record.record, Record::try_from_parts(headers, body).unwrap() diff --git a/common/src/types/stream.rs b/common/src/types/stream.rs index 421e9bc9..5fb2649e 100644 --- a/common/src/types/stream.rs +++ b/common/src/types/stream.rs @@ -12,7 +12,7 @@ use crate::{ read_extent::{ReadLimit, ReadUntil}, record::{ FencingToken, Metered, MeteredRecord, MeteredSequencedRecords, MeteredSize, SeqNum, - Timestamp, + StreamPosition, Timestamp, }, types::resources::ListItemsRequest, }; @@ -162,18 +162,6 @@ pub struct StreamInfo { pub deleted_at: Option, } -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub struct StreamPosition { - pub seq_num: SeqNum, - pub timestamp: Timestamp, -} - -impl std::fmt::Display for StreamPosition { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} @ {}", self.seq_num, self.timestamp) - } -} - #[derive(Debug, Clone)] pub struct AppendRecord(AppendRecordParts); diff --git a/lite/src/backend/ops.rs b/lite/src/backend/ops.rs index ef2fba7e..2ede46fc 100644 --- a/lite/src/backend/ops.rs +++ b/lite/src/backend/ops.rs @@ -1,14 +1,14 @@ use bytes::Bytes; use futures::stream::BoxStream; use s2_common::{ - record::{FencingToken, SeqNum}, + record::{FencingToken, SeqNum, StreamPosition}, types::{ basin::{BasinInfo, BasinName, BasinScope, ListBasinsRequest}, config::{BasinConfig, BasinReconfiguration, OptionalStreamConfig, StreamReconfiguration}, resources::{CreateMode, Page}, stream::{ AppendAck, AppendInput, ListStreamsRequest, ReadBatch, ReadEnd, ReadStart, StreamInfo, - StreamName, StreamPosition, + StreamName, }, }, }; diff --git a/lite/src/backend/slatedb.rs b/lite/src/backend/slatedb.rs index dcc11c92..61a509b8 100644 --- a/lite/src/backend/slatedb.rs +++ b/lite/src/backend/slatedb.rs @@ -2,9 +2,12 @@ use std::sync::Arc; use dashmap::DashMap; use futures::stream::BoxStream; -use s2_common::types::{ - basin::BasinName, - stream::{AppendAck, AppendInput, ReadBatch, ReadEnd, ReadStart, StreamName, StreamPosition}, +use s2_common::{ + record::StreamPosition, + types::{ + basin::BasinName, + stream::{AppendAck, AppendInput, ReadBatch, ReadEnd, ReadStart, StreamName}, + }, }; use tokio::sync::{broadcast, mpsc}; From 7f5092b874c1d8e084fde3703a76b2cbd6dc7823 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Fri, 26 Dec 2025 20:18:21 -0500 Subject: [PATCH 021/700] chore: make `BasinInfo.scope` optional (#17) `scope` does not really make sense for `s2-lite` which will ignore it, so make it optional some other small misc changes --- api/src/v1/basin.rs | 4 ++-- common/src/caps.rs | 1 + common/src/record/mod.rs | 12 ++++++++++++ common/src/types/basin.rs | 22 ++++++++-------------- common/src/types/stream.rs | 12 ++++-------- 5 files changed, 27 insertions(+), 24 deletions(-) diff --git a/api/src/v1/basin.rs b/api/src/v1/basin.rs index a5116b72..d5cab603 100644 --- a/api/src/v1/basin.rs +++ b/api/src/v1/basin.rs @@ -49,7 +49,7 @@ pub struct BasinInfo { /// Basin name. pub name: BasinName, /// Basin scope. - pub scope: BasinScope, + pub scope: Option, /// Basin state. pub state: BasinState, } @@ -60,7 +60,7 @@ impl From for BasinInfo { Self { name, - scope: scope.into(), + scope: scope.map(Into::into), state: state.into(), } } diff --git a/common/src/caps.rs b/common/src/caps.rs index dd5b7421..1860f676 100644 --- a/common/src/caps.rs +++ b/common/src/caps.rs @@ -1,6 +1,7 @@ pub const MIN_BASIN_NAME_LEN: usize = 8; pub const MAX_BASIN_NAME_LEN: usize = 48; +pub const MIN_STREAM_NAME_LEN: usize = 1; pub const MAX_STREAM_NAME_LEN: usize = 512; pub const MAX_TOKEN_ID_LEN: usize = 96; diff --git a/common/src/record/mod.rs b/common/src/record/mod.rs index a8997926..dce0af71 100644 --- a/common/src/record/mod.rs +++ b/common/src/record/mod.rs @@ -329,6 +329,18 @@ pub type MeteredSequencedRecord = Metered; pub type MeteredSequencedRecords = Metered>; +impl MeteredSequencedRecord { + pub fn into_parts(self) -> (StreamPosition, MeteredRecord) { + ( + self.position, + MeteredRecord { + size: self.size, + inner: self.inner.record, + }, + ) + } +} + #[cfg(test)] mod test { use proptest::prelude::*; diff --git a/common/src/types/basin.rs b/common/src/types/basin.rs index d760c01d..19669803 100644 --- a/common/src/types/basin.rs +++ b/common/src/types/basin.rs @@ -23,8 +23,8 @@ where fn schema() -> utoipa::openapi::RefOr { utoipa::openapi::Object::builder() .schema_type(utoipa::openapi::Type::String) - .min_length((!T::IS_PREFIX).then_some(Self::MIN_LENGTH)) - .max_length(Some(Self::MAX_LENGTH)) + .min_length((!T::IS_PREFIX).then_some(caps::MIN_BASIN_NAME_LEN)) + .max_length(Some(caps::MAX_BASIN_NAME_LEN)) .into() } } @@ -51,11 +51,6 @@ impl<'de, T: StrProps> serde::Deserialize<'de> for BasinNameStr { } } -impl BasinNameStr { - const MIN_LENGTH: usize = 8; - const MAX_LENGTH: usize = caps::MAX_BASIN_NAME_LEN; -} - impl AsRef for BasinNameStr { fn as_ref(&self) -> &str { &self.0 @@ -74,20 +69,20 @@ impl TryFrom for BasinNameStr { type Error = ValidationError; fn try_from(name: CompactString) -> Result { - if name.len() > Self::MAX_LENGTH { + if name.len() > caps::MAX_BASIN_NAME_LEN { return Err(format!( "Basin {} must not exceed {} characters in length", T::FIELD_NAME, - Self::MAX_LENGTH + caps::MAX_BASIN_NAME_LEN ) .into()); } - if !T::IS_PREFIX && name.len() < Self::MIN_LENGTH { + if !T::IS_PREFIX && name.len() < caps::MIN_BASIN_NAME_LEN { return Err(format!( "Basin {} should be at least {} characters in length", T::FIELD_NAME, - Self::MIN_LENGTH + caps::MIN_BASIN_NAME_LEN ) .into()); } @@ -195,16 +190,15 @@ pub enum BasinState { Deleting, } -#[derive(Debug, strum::Display, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, strum::Display, Clone, Copy, PartialEq, Eq)] pub enum BasinScope { #[strum(serialize = "aws:us-east-1")] - #[default] AwsUsEast1, } #[derive(Debug, Clone)] pub struct BasinInfo { pub name: BasinName, - pub scope: BasinScope, + pub scope: Option, pub state: BasinState, } diff --git a/common/src/types/stream.rs b/common/src/types/stream.rs index 5fb2649e..6906726b 100644 --- a/common/src/types/stream.rs +++ b/common/src/types/stream.rs @@ -32,8 +32,8 @@ where fn schema() -> utoipa::openapi::RefOr { utoipa::openapi::Object::builder() .schema_type(utoipa::openapi::Type::String) - .min_length((!T::IS_PREFIX).then_some(1)) - .max_length(Some(Self::MAX_LENGTH)) + .min_length((!T::IS_PREFIX).then_some(caps::MIN_STREAM_NAME_LEN)) + .max_length(Some(caps::MAX_STREAM_NAME_LEN)) .into() } } @@ -60,10 +60,6 @@ impl<'de, T: StrProps> serde::Deserialize<'de> for StreamNameStr { } } -impl StreamNameStr { - const MAX_LENGTH: usize = caps::MAX_STREAM_NAME_LEN; -} - impl AsRef for StreamNameStr { fn as_ref(&self) -> &str { &self.0 @@ -86,11 +82,11 @@ impl TryFrom for StreamNameStr { return Err(format!("Stream {} must not be empty", T::FIELD_NAME).into()); } - if name.len() > Self::MAX_LENGTH { + if name.len() > caps::MAX_STREAM_NAME_LEN { return Err(format!( "Stream {} must not exceed {} characters in length", T::FIELD_NAME, - Self::MAX_LENGTH + caps::MAX_STREAM_NAME_LEN ) .into()); } From 43ae9f35187821845e3c11c4b10e21c38723552d Mon Sep 17 00:00:00 2001 From: shikhar Date: Fri, 26 Dec 2025 20:51:24 -0500 Subject: [PATCH 022/700] chore: rm redundant utoipa attr --- api/src/v1/basin.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/src/v1/basin.rs b/api/src/v1/basin.rs index d5cab603..af61536a 100644 --- a/api/src/v1/basin.rs +++ b/api/src/v1/basin.rs @@ -122,7 +122,6 @@ pub struct CreateOrReconfigureBasinRequest { pub config: Option, /// Basin scope. /// This cannot be reconfigured. - #[cfg_attr(feature = "utoipa", schema(value_type = BasinScope, default = "aws:us-east-1", required = false))] pub scope: Option, } @@ -137,6 +136,5 @@ pub struct CreateBasinRequest { /// Basin configuration. pub config: Option, /// Basin scope. - #[cfg_attr(feature = "utoipa", schema(value_type = BasinScope, default = "aws:us-east-1", required = false))] pub scope: Option, } From ad69f7bad36241b3e6e5ee972c27515881c4e23a Mon Sep 17 00:00:00 2001 From: shikhar Date: Sat, 27 Dec 2025 00:01:02 -0500 Subject: [PATCH 023/700] doc: append tail latency expectation --- api/src/v1/config.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/v1/config.rs b/api/src/v1/config.rs index 2ea92832..f7ed9594 100644 --- a/api/src/v1/config.rs +++ b/api/src/v1/config.rs @@ -10,9 +10,9 @@ use utoipa::ToSchema; #[cfg_attr(feature = "utoipa", derive(ToSchema))] #[serde(rename_all = "kebab-case")] pub enum StorageClass { - /// Append tail latency under 500 ms. + /// Append tail latency under 400 milliseconds with s2.dev. Standard, - /// Append tail latency under 50 ms. + /// Append tail latency under 40 milliseconds with s2.dev. Express, } From 8f4bb15594d987da516d32e29579f593dd2e3327 Mon Sep 17 00:00:00 2001 From: quettabit <27509167+quettabit@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:41:54 -0700 Subject: [PATCH 024/700] refactor: rename `bucket_length` as `interval` in `AccumulationMetric` (#18) --- api/src/v1/metrics.rs | 11 ++++++----- common/src/types/metrics.rs | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/api/src/v1/metrics.rs b/api/src/v1/metrics.rs index 632b5ee5..5e909335 100644 --- a/api/src/v1/metrics.rs +++ b/api/src/v1/metrics.rs @@ -215,11 +215,12 @@ pub struct AccumulationMetric { pub name: CompactString, /// Unit of the metric. pub unit: MetricUnit, - /// The duration of bucket for the accumulation. - pub bucket_length: TimeseriesInterval, + /// The interval at which data points are accumulated. + pub interval: TimeseriesInterval, /// Timeseries values. /// Each element is a tuple of a timestamp in Unix epoch seconds and a data point. - /// The data point represents the accumulated value for a bucket of time starting at the provided timestamp, lasting for the duration of the `bucket_length` parameter. + /// The data point represents the accumulated value for the time period starting at the timestamp, + /// spanning one `interval`. pub values: Vec<(u32, f64)>, } @@ -228,7 +229,7 @@ impl From for AccumulationMetric { Self { name: value.name, unit: value.unit.into(), - bucket_length: value.bucket_length.into(), + interval: value.interval.into(), values: value.values, } } @@ -287,7 +288,7 @@ pub enum Metric { /// Single named value. Scalar(ScalarMetric), /// Named series of `(timestamp, value)` points representing an accumulation over a specified - /// bucket. + /// interval. Accumulation(AccumulationMetric), /// Named series of `(timestamp, value)` points each representing an instantaneous value. Gauge(GaugeMetric), diff --git a/common/src/types/metrics.rs b/common/src/types/metrics.rs index 2e7efdb5..9fd68b99 100644 --- a/common/src/types/metrics.rs +++ b/common/src/types/metrics.rs @@ -27,7 +27,7 @@ pub struct ScalarMetric { pub struct AccumulationMetric { pub name: CompactString, pub unit: MetricUnit, - pub bucket_length: TimeseriesInterval, + pub interval: TimeseriesInterval, pub values: Vec<(u32, f64)>, } From 4a47efbec2824d145eff704f6d72dc4311ffc2db Mon Sep 17 00:00:00 2001 From: quettabit <27509167+quettabit@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:35:51 -0700 Subject: [PATCH 025/700] chore: switch to lowercase error msgs as per rust api guideline doc (#19) --- api/src/v1/config.rs | 2 +- api/src/v1/stream/mod.rs | 2 +- api/src/v1/stream/sse.rs | 4 ++-- common/src/record/command.rs | 4 ++-- common/src/record/envelope.rs | 6 +++--- common/src/record/fencing.rs | 2 +- common/src/record/mod.rs | 10 +++++----- common/src/types/access.rs | 4 ++-- common/src/types/basin.rs | 10 +++++----- common/src/types/stream.rs | 12 ++++++------ 10 files changed, 28 insertions(+), 28 deletions(-) diff --git a/api/src/v1/config.rs b/api/src/v1/config.rs index f7ed9594..a72f1e2a 100644 --- a/api/src/v1/config.rs +++ b/api/src/v1/config.rs @@ -57,7 +57,7 @@ impl TryFrom for types::config::RetentionPolicy { fn try_from(value: RetentionPolicy) -> Result { match value { RetentionPolicy::Age(0) => Err(types::ValidationError( - "Age must be greater than 0 seconds".to_string(), + "age must be greater than 0 seconds".to_string(), )), RetentionPolicy::Age(age) => Ok(Self::Age(Duration::from_secs(age))), RetentionPolicy::Infinite(_) => Ok(Self::Infinite()), diff --git a/api/src/v1/stream/mod.rs b/api/src/v1/stream/mod.rs index deb1d97c..728636d3 100644 --- a/api/src/v1/stream/mod.rs +++ b/api/src/v1/stream/mod.rs @@ -338,7 +338,7 @@ impl S2Format { Ok(match self { S2Format::Raw => s.into_bytes().into(), S2Format::Base64 => Base64::decode_vec(&s) - .map_err(|_| types::ValidationError("Invalid Base64 encoding".to_owned()))? + .map_err(|_| types::ValidationError("invalid Base64 encoding".to_owned()))? .into(), }) } diff --git a/api/src/v1/stream/sse.rs b/api/src/v1/stream/sse.rs index e19294be..d66218ff 100644 --- a/api/src/v1/stream/sse.rs +++ b/api/src/v1/stream/sse.rs @@ -50,9 +50,9 @@ impl FromStr for LastEventId { { let item = iter .next() - .ok_or_else(|| format!("Missing {field} in Last-Event-Id"))?; + .ok_or_else(|| format!("missing {field} in Last-Event-Id"))?; item.parse() - .map_err(|e| format!("Invalid {field} in Last-Event-ID: {e}").into()) + .map_err(|e| format!("invalid {field} in Last-Event-ID: {e}").into()) } let seq_num = get_next(&mut iter, "seq_num")?; diff --git a/common/src/record/command.rs b/common/src/record/command.rs index 3b4d53f2..ad4234a5 100644 --- a/common/src/record/command.rs +++ b/common/src/record/command.rs @@ -129,11 +129,11 @@ impl Encodable for CommandRecord { #[derive(Debug, PartialEq, thiserror::Error)] pub enum CommandPayloadError { - #[error("Invalid UTF-8")] + #[error("invalid UTF-8")] InvalidUtf8(Utf8Error), #[error(transparent)] FencingTokenTooLong(#[from] FencingTokenTooLongError), - #[error("Earliest sequence number to trim to was {0} bytes, must be 8")] + #[error("earliest sequence number to trim to was {0} bytes, must be 8")] TrimPointSize(usize), } diff --git a/common/src/record/envelope.rs b/common/src/record/envelope.rs index 5e021d50..f4c1632b 100644 --- a/common/src/record/envelope.rs +++ b/common/src/record/envelope.rs @@ -7,11 +7,11 @@ use crate::deep_size::DeepSize; #[derive(Debug, PartialEq, thiserror::Error)] pub enum HeaderValidationError { - #[error("Too many")] + #[error("too many")] TooMany, - #[error("Too long")] + #[error("too long")] TooLong, - #[error("Empty name")] + #[error("empty name")] NameEmpty, } diff --git a/common/src/record/fencing.rs b/common/src/record/fencing.rs index 64273c85..dccb25b5 100644 --- a/common/src/record/fencing.rs +++ b/common/src/record/fencing.rs @@ -7,7 +7,7 @@ use crate::deep_size::DeepSize; pub const MAX_FENCING_TOKEN_LENGTH: usize = 36; #[derive(Debug, PartialEq, Eq, thiserror::Error)] -#[error("Fencing token was longer than {MAX_FENCING_TOKEN_LENGTH} bytes in length: {0}")] +#[error("fencing token must not exceed {MAX_FENCING_TOKEN_LENGTH} characters in length")] pub struct FencingTokenTooLongError(pub usize); #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] diff --git a/common/src/record/mod.rs b/common/src/record/mod.rs index dce0af71..9e3fc5fb 100644 --- a/common/src/record/mod.rs +++ b/common/src/record/mod.rs @@ -47,20 +47,20 @@ impl DeepSize for StreamPosition { #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum InternalRecordError { - #[error("Truncated: {0}")] + #[error("truncated: {0}")] Truncated(&'static str), - #[error("Invalid value [{0}]: {1}")] + #[error("invalid value [{0}]: {1}")] InvalidValue(&'static str, &'static str), } /// `impl Display` can be safely returned to the client without leaking internal details. #[derive(Debug, PartialEq, thiserror::Error)] pub enum PublicRecordError { - #[error("Unknown command")] + #[error("unknown command")] UnknownCommand, - #[error("Invalid `{0}` command: {1}")] + #[error("invalid `{0}` command: {1}")] CommandPayload(CommandOp, CommandPayloadError), - #[error("Invalid header: {0}")] + #[error("invalid header: {0}")] Header(#[from] HeaderValidationError), } diff --git a/common/src/types/access.rs b/common/src/types/access.rs index efc7ceaf..0f002ed5 100644 --- a/common/src/types/access.rs +++ b/common/src/types/access.rs @@ -77,12 +77,12 @@ impl TryFrom for AccessTokenIdStr { fn try_from(name: CompactString) -> Result { if !T::IS_PREFIX && name.is_empty() { - return Err(format!("Access token {} must not be empty", T::FIELD_NAME).into()); + return Err(format!("access token {} must not be empty", T::FIELD_NAME).into()); } if name.len() > Self::MAX_LENGTH { return Err(format!( - "Access token {} must not exceed {} characters in length", + "access token {} must not exceed {} characters in length", T::FIELD_NAME, Self::MAX_LENGTH ) diff --git a/common/src/types/basin.rs b/common/src/types/basin.rs index 19669803..eec90d7c 100644 --- a/common/src/types/basin.rs +++ b/common/src/types/basin.rs @@ -71,7 +71,7 @@ impl TryFrom for BasinNameStr { fn try_from(name: CompactString) -> Result { if name.len() > caps::MAX_BASIN_NAME_LEN { return Err(format!( - "Basin {} must not exceed {} characters in length", + "basin {} must not exceed {} characters in length", T::FIELD_NAME, caps::MAX_BASIN_NAME_LEN ) @@ -80,7 +80,7 @@ impl TryFrom for BasinNameStr { if !T::IS_PREFIX && name.len() < caps::MIN_BASIN_NAME_LEN { return Err(format!( - "Basin {} should be at least {} characters in length", + "basin {} should be at least {} characters in length", T::FIELD_NAME, caps::MIN_BASIN_NAME_LEN ) @@ -95,7 +95,7 @@ impl TryFrom for BasinNameStr { if !first_char.is_ascii_lowercase() && !first_char.is_ascii_digit() { return Err(format!( - "Basin {} must begin with a lowercase letter or number", + "basin {} must begin with a lowercase letter or number", T::FIELD_NAME ) .into()); @@ -107,7 +107,7 @@ impl TryFrom for BasinNameStr { && !last_char.is_ascii_digit() { return Err(format!( - "Basin {} must end with a lowercase letter or number", + "basin {} must end with a lowercase letter or number", T::FIELD_NAME ) .into()); @@ -115,7 +115,7 @@ impl TryFrom for BasinNameStr { if chars.any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-') { return Err(format!( - "Basin {} must comprise lowercase letters, numbers, and hyphens", + "basin {} must comprise lowercase letters, numbers, and hyphens", T::FIELD_NAME ) .into()); diff --git a/common/src/types/stream.rs b/common/src/types/stream.rs index 6906726b..1454bfad 100644 --- a/common/src/types/stream.rs +++ b/common/src/types/stream.rs @@ -79,12 +79,12 @@ impl TryFrom for StreamNameStr { fn try_from(name: CompactString) -> Result { if !T::IS_PREFIX && name.is_empty() { - return Err(format!("Stream {} must not be empty", T::FIELD_NAME).into()); + return Err(format!("stream {} must not be empty", T::FIELD_NAME).into()); } if name.len() > caps::MAX_STREAM_NAME_LEN { return Err(format!( - "Stream {} must not exceed {} characters in length", + "stream {} must not exceed {} characters in length", T::FIELD_NAME, caps::MAX_STREAM_NAME_LEN ) @@ -199,7 +199,7 @@ impl TryFrom for AppendRecord { fn try_from(parts: AppendRecordParts) -> Result { if parts.metered_size() > caps::RECORD_BATCH_MAX.bytes { - Err("Record must have metered size less than 1 MiB") + Err("record must have metered size less than 1 MiB") } else { Ok(Self(parts)) } @@ -237,15 +237,15 @@ impl TryFrom>> for AppendRecordBatch { fn try_from(records: Metered>) -> Result { if records.is_empty() { - return Err("Record batch must not be empty"); + return Err("record batch must not be empty"); } if records.len() > caps::RECORD_BATCH_MAX.count { - return Err("Record batch must not exceed 1000 records"); + return Err("record batch must not exceed 1000 records"); } if records.metered_size() > caps::RECORD_BATCH_MAX.bytes { - return Err("Record batch must not exceed a metered size of 1 MiB"); + return Err("record batch must not exceed a metered size of 1 MiB"); } Ok(Self(records)) From f71d1c7136d7eb9cc85c69817d31e4f2f0b7dd61 Mon Sep 17 00:00:00 2001 From: quettabit <27509167+quettabit@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:22:57 -0700 Subject: [PATCH 026/700] fix: incorrect docs and error msgs around byte length (#20) --- api/src/v1/basin.rs | 2 +- common/src/record/fencing.rs | 2 +- common/src/types/access.rs | 2 +- common/src/types/basin.rs | 4 ++-- common/src/types/stream.rs | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/src/v1/basin.rs b/api/src/v1/basin.rs index af61536a..eb1f379e 100644 --- a/api/src/v1/basin.rs +++ b/api/src/v1/basin.rs @@ -130,7 +130,7 @@ pub struct CreateOrReconfigureBasinRequest { #[cfg_attr(feature = "utoipa", derive(ToSchema))] pub struct CreateBasinRequest { /// Basin name which must be globally unique. - /// It can be between 8 and 48 characters in length, and comprise lowercase letters, numbers and hyphens. + /// It can be between 8 and 48 bytes in length, and comprise lowercase letters, numbers and hyphens. /// It cannot begin or end with a hyphen. pub basin: BasinName, /// Basin configuration. diff --git a/common/src/record/fencing.rs b/common/src/record/fencing.rs index dccb25b5..62c5f72d 100644 --- a/common/src/record/fencing.rs +++ b/common/src/record/fencing.rs @@ -7,7 +7,7 @@ use crate::deep_size::DeepSize; pub const MAX_FENCING_TOKEN_LENGTH: usize = 36; #[derive(Debug, PartialEq, Eq, thiserror::Error)] -#[error("fencing token must not exceed {MAX_FENCING_TOKEN_LENGTH} characters in length")] +#[error("fencing token must not exceed {MAX_FENCING_TOKEN_LENGTH} bytes in length")] pub struct FencingTokenTooLongError(pub usize); #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] diff --git a/common/src/types/access.rs b/common/src/types/access.rs index 0f002ed5..b0c5be41 100644 --- a/common/src/types/access.rs +++ b/common/src/types/access.rs @@ -82,7 +82,7 @@ impl TryFrom for AccessTokenIdStr { if name.len() > Self::MAX_LENGTH { return Err(format!( - "access token {} must not exceed {} characters in length", + "access token {} must not exceed {} bytes in length", T::FIELD_NAME, Self::MAX_LENGTH ) diff --git a/common/src/types/basin.rs b/common/src/types/basin.rs index eec90d7c..6f61124e 100644 --- a/common/src/types/basin.rs +++ b/common/src/types/basin.rs @@ -71,7 +71,7 @@ impl TryFrom for BasinNameStr { fn try_from(name: CompactString) -> Result { if name.len() > caps::MAX_BASIN_NAME_LEN { return Err(format!( - "basin {} must not exceed {} characters in length", + "basin {} must not exceed {} bytes in length", T::FIELD_NAME, caps::MAX_BASIN_NAME_LEN ) @@ -80,7 +80,7 @@ impl TryFrom for BasinNameStr { if !T::IS_PREFIX && name.len() < caps::MIN_BASIN_NAME_LEN { return Err(format!( - "basin {} should be at least {} characters in length", + "basin {} should be at least {} bytes in length", T::FIELD_NAME, caps::MIN_BASIN_NAME_LEN ) diff --git a/common/src/types/stream.rs b/common/src/types/stream.rs index 1454bfad..fd6c9c4d 100644 --- a/common/src/types/stream.rs +++ b/common/src/types/stream.rs @@ -84,7 +84,7 @@ impl TryFrom for StreamNameStr { if name.len() > caps::MAX_STREAM_NAME_LEN { return Err(format!( - "stream {} must not exceed {} characters in length", + "stream {} must not exceed {} bytes in length", T::FIELD_NAME, caps::MAX_STREAM_NAME_LEN ) From 6f50d0a1a133b8b4ee0fef900e586a0f10d75637 Mon Sep 17 00:00:00 2001 From: quettabit <27509167+quettabit@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:39:13 -0700 Subject: [PATCH 027/700] fix: unwanted new line in generated API docs (#21) --- api/src/v1/metrics.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/api/src/v1/metrics.rs b/api/src/v1/metrics.rs index 5e909335..64d49f89 100644 --- a/api/src/v1/metrics.rs +++ b/api/src/v1/metrics.rs @@ -219,8 +219,7 @@ pub struct AccumulationMetric { pub interval: TimeseriesInterval, /// Timeseries values. /// Each element is a tuple of a timestamp in Unix epoch seconds and a data point. - /// The data point represents the accumulated value for the time period starting at the timestamp, - /// spanning one `interval`. + /// The data point represents the accumulated value for the time period starting at the timestamp, spanning one `interval`. pub values: Vec<(u32, f64)>, } @@ -287,8 +286,7 @@ impl From for LabelMetric { pub enum Metric { /// Single named value. Scalar(ScalarMetric), - /// Named series of `(timestamp, value)` points representing an accumulation over a specified - /// interval. + /// Named series of `(timestamp, value)` points representing an accumulation over a specified interval. Accumulation(AccumulationMetric), /// Named series of `(timestamp, value)` points each representing an instantaneous value. Gauge(GaugeMetric), From f19a1945af60f70f9c1d7a0e5cecfe74152af820 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Sun, 4 Jan 2026 23:37:38 -0500 Subject: [PATCH 028/700] `s2-lite` backend (#11) HTTP API handlers to follow --- AGENTS.md | 4 + CLAUDE.md | 1 + Cargo.lock | 456 +++++++-- Cargo.toml | 21 +- api/src/lib.rs | 6 - api/src/v1/access.rs | 20 +- api/src/v1/basin.rs | 16 +- api/src/v1/config.rs | 26 +- api/src/v1/extractors.rs | 94 ++ api/src/v1/metrics.rs | 30 +- api/src/v1/mod.rs | 14 +- api/src/v1/stream/mod.rs | 108 +- api/src/v1/stream/sse.rs | 10 +- common/Cargo.toml | 4 +- common/src/bash.rs | 75 ++ common/src/caps.rs | 2 +- common/src/header.rs | 8 + common/src/lib.rs | 2 + common/src/record/batcher.rs | 24 +- common/src/record/metering.rs | 31 + common/src/record/mod.rs | 44 +- common/src/types/access.rs | 10 +- common/src/types/basin.rs | 8 + common/src/types/resources.rs | 111 ++- common/src/types/stream.rs | 8 +- lite/Cargo.toml | 20 +- lite/docs/notes.md | 75 -- lite/docs/todo.md | 21 + lite/src/backend/append.rs | 254 +++++ lite/src/backend/basins.rs | 213 ++++ lite/src/backend/core.rs | 334 +++++++ lite/src/backend/error.rs | 424 ++++++++ lite/src/backend/kv/basin_deletion_pending.rs | 70 ++ lite/src/backend/kv/basin_meta.rs | 400 ++++++++ lite/src/backend/kv/mod.rs | 256 +++++ lite/src/backend/kv/stream_fencing_token.rs | 85 ++ lite/src/backend/kv/stream_meta.rs | 310 ++++++ lite/src/backend/kv/stream_record_data.rs | 106 ++ .../src/backend/kv/stream_record_timestamp.rs | 71 ++ lite/src/backend/kv/stream_tail_position.rs | 82 ++ lite/src/backend/kv/stream_trim_point.rs | 67 ++ lite/src/backend/mod.rs | 14 +- lite/src/backend/ops.rs | 150 --- lite/src/backend/read.rs | 284 ++++++ lite/src/backend/slatedb.rs | 81 -- lite/src/backend/store.rs | 78 ++ lite/src/backend/stream_id.rs | 75 +- lite/src/backend/streamer.rs | 765 ++++++++++++++ lite/src/backend/streams.rs | 264 +++++ lite/src/bin/server.rs | 171 ++++ lite/src/handlers/mod.rs | 6 +- lite/src/handlers/{v1.rs => v1/basins.rs} | 0 lite/src/handlers/v1/mod.rs | 15 + lite/src/handlers/v1/records.rs | 36 + lite/src/handlers/v1/streams.rs | 1 + lite/src/lib.rs | 2 - lite/src/service.rs | 0 lite/tests/backend/common.rs | 184 ++++ lite/tests/backend/control_plane/basin.rs | 474 +++++++++ lite/tests/backend/control_plane/mod.rs | 4 + lite/tests/backend/control_plane/stream.rs | 921 +++++++++++++++++ lite/tests/backend/data_plane/append.rs | 551 ++++++++++ lite/tests/backend/data_plane/mixed.rs | 266 +++++ lite/tests/backend/data_plane/mod.rs | 6 + lite/tests/backend/data_plane/read.rs | 943 ++++++++++++++++++ lite/tests/backend/data_plane/read_follow.rs | 535 ++++++++++ lite/tests/backend/mod.rs | 3 + lite/tests/backend_tests.rs | 1 + 68 files changed, 9126 insertions(+), 625 deletions(-) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 api/src/v1/extractors.rs create mode 100644 common/src/bash.rs create mode 100644 common/src/header.rs delete mode 100644 lite/docs/notes.md create mode 100644 lite/docs/todo.md create mode 100644 lite/src/backend/append.rs create mode 100644 lite/src/backend/basins.rs create mode 100644 lite/src/backend/core.rs create mode 100644 lite/src/backend/error.rs create mode 100644 lite/src/backend/kv/basin_deletion_pending.rs create mode 100644 lite/src/backend/kv/basin_meta.rs create mode 100644 lite/src/backend/kv/mod.rs create mode 100644 lite/src/backend/kv/stream_fencing_token.rs create mode 100644 lite/src/backend/kv/stream_meta.rs create mode 100644 lite/src/backend/kv/stream_record_data.rs create mode 100644 lite/src/backend/kv/stream_record_timestamp.rs create mode 100644 lite/src/backend/kv/stream_tail_position.rs create mode 100644 lite/src/backend/kv/stream_trim_point.rs delete mode 100644 lite/src/backend/ops.rs create mode 100644 lite/src/backend/read.rs delete mode 100644 lite/src/backend/slatedb.rs create mode 100644 lite/src/backend/store.rs create mode 100644 lite/src/backend/streamer.rs create mode 100644 lite/src/backend/streams.rs create mode 100644 lite/src/bin/server.rs rename lite/src/handlers/{v1.rs => v1/basins.rs} (100%) create mode 100644 lite/src/handlers/v1/mod.rs create mode 100644 lite/src/handlers/v1/records.rs create mode 100644 lite/src/handlers/v1/streams.rs delete mode 100644 lite/src/service.rs create mode 100644 lite/tests/backend/common.rs create mode 100644 lite/tests/backend/control_plane/basin.rs create mode 100644 lite/tests/backend/control_plane/mod.rs create mode 100644 lite/tests/backend/control_plane/stream.rs create mode 100644 lite/tests/backend/data_plane/append.rs create mode 100644 lite/tests/backend/data_plane/mixed.rs create mode 100644 lite/tests/backend/data_plane/mod.rs create mode 100644 lite/tests/backend/data_plane/read.rs create mode 100644 lite/tests/backend/data_plane/read_follow.rs create mode 100644 lite/tests/backend/mod.rs create mode 100644 lite/tests/backend_tests.rs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8d544885 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,4 @@ +## Local Conventions + +- Formatting: run `cargo +nightly fmt`. +- Tests: prefer `cargo nextest`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 0e6b0e00..dd64f9e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,6 +128,45 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.17", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.113", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.113", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -159,7 +198,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -176,7 +215,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -203,7 +242,7 @@ dependencies = [ "derive_utils", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -212,6 +251,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.8.8" @@ -219,6 +280,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", + "axum-macros", "bytes", "form_urlencoded", "futures-util", @@ -247,9 +309,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", @@ -264,6 +326,60 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.113", +] + +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "backon" version = "1.6.0" @@ -283,9 +399,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82" [[package]] name = "bincode" @@ -365,7 +481,7 @@ checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -394,9 +510,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.50" +version = "1.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" dependencies = [ "find-msvc-tools", "jobserver", @@ -432,9 +548,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", "clap_derive", @@ -442,9 +558,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -461,7 +577,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -470,6 +586,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "cmsketch" version = "0.2.4" @@ -637,7 +762,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -659,7 +784,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -676,6 +801,26 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.5" @@ -694,7 +839,7 @@ checksum = "ccfae181bab5ab6c5478b2ccb69e4c68a02f8c3ec72f6616bfec9dbc599d2ee0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -715,7 +860,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -730,6 +875,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "duration-str" version = "0.11.3" @@ -766,7 +917,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -787,7 +938,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -803,7 +954,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -872,9 +1023,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" [[package]] name = "fixedbitset" @@ -1041,6 +1192,16 @@ dependencies = [ "zstd", ] +[[package]] +name = "fs-err" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf68cef89750956493a66a10f512b9e58d9db21f2a573c079c0bdf1207a54a7" +dependencies = [ + "autocfg", + "tokio", +] + [[package]] name = "fs4" version = "0.13.1" @@ -1051,6 +1212,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.31" @@ -1107,7 +1274,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -1543,9 +1710,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -1568,9 +1735,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jobserver" @@ -1600,9 +1767,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.178" +version = "0.2.179" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" [[package]] name = "linux-raw-sys" @@ -1748,6 +1915,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1802,7 +1975,7 @@ checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -1820,6 +1993,16 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "ntapi" version = "0.4.2" @@ -1835,7 +2018,17 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", ] [[package]] @@ -1844,6 +2037,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1908,6 +2110,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1922,9 +2133,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" [[package]] name = "ordered_hash_map" @@ -1956,7 +2167,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -2020,7 +2231,17 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.111", + "syn 2.0.113", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", ] [[package]] @@ -2056,7 +2277,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -2108,7 +2329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -2122,9 +2343,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" dependencies = [ "unicode-ident", ] @@ -2137,7 +2358,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", "version_check", "yansi", ] @@ -2187,7 +2408,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.111", + "syn 2.0.113", "tempfile", ] @@ -2201,7 +2422,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -2230,7 +2451,7 @@ checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -2301,7 +2522,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -2414,6 +2635,20 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rcgen" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ec0a99f2de91c3cddc84b37e7db80e4d96b743e05607f647eb236fc0455907f" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2550,7 +2785,7 @@ checksum = "bd83f5f173ff41e00337d97f6572e416d022ef8a19f371817259ae960324c482" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -2578,7 +2813,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.111", + "syn 2.0.113", "unicode-ident", ] @@ -2607,6 +2842,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.3" @@ -2617,7 +2861,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2626,6 +2870,7 @@ version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -2636,9 +2881,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2662,6 +2907,7 @@ version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2687,9 +2933,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "s2-api" @@ -2721,6 +2967,7 @@ dependencies = [ name = "s2-common" version = "0.1.0" dependencies = [ + "blake3", "bytes", "clap", "compact_str", @@ -2728,6 +2975,7 @@ dependencies = [ "enum-ordinalize", "enumset", "futures", + "http", "proptest", "rkyv", "rstest", @@ -2743,19 +2991,35 @@ dependencies = [ name = "s2-lite" version = "0.1.0" dependencies = [ + "async-stream", "async-trait", + "axum", + "axum-extra", + "axum-server", "blake3", "bytes", "clap", "compact_str", "dashmap", + "enum-ordinalize", "eyre", "futures", + "http", + "parking_lot", + "proptest", + "rand 0.9.2", + "rcgen", "s2-api", "s2-common", + "serde", + "serde_json", "slatedb", "thiserror 2.0.17", + "time", "tokio", + "tracing", + "tracing-subscriber", + "uuid", ] [[package]] @@ -2838,14 +3102,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] name = "serde_json" -version = "1.0.147" +version = "1.0.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af14725505314343e673e9ecb7cd7e8a36aa9791eb936235a3567cc31447ae4" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" dependencies = [ "itoa", "memchr", @@ -2959,8 +3223,9 @@ checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "slatedb" -version = "0.9.2" -source = "git+https://github.com/slatedb/slatedb?rev=4296c696b8b7c1cdf2ad79a9900de0d769a75576#4296c696b8b7c1cdf2ad79a9900de0d769a75576" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bab45c2940c214e8f9e80d100f74b90cc3f1cc3b62b9caef0369060612c83b9" dependencies = [ "anyhow", "async-trait", @@ -3068,7 +3333,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -3090,9 +3355,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" dependencies = [ "proc-macro2", "quote", @@ -3116,7 +3381,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -3143,7 +3408,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3172,7 +3437,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -3183,7 +3448,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -3253,9 +3518,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -3275,7 +3540,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -3290,9 +3555,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -3461,7 +3726,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -3612,7 +3877,7 @@ source = "git+https://github.com/infiniteregrets/utoipa?rev=9cd181d40ac0a50551eb dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -3727,7 +3992,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", "wasm-bindgen-shared", ] @@ -3795,7 +4060,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3871,7 +4136,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -3882,7 +4147,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -4147,12 +4412,39 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x509-parser" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3e137310115a65136898d2079f003ce33331a6c4b0d51f1531d1be082b6425" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.17", + "time", +] + [[package]] name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" @@ -4172,7 +4464,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", "synstructure", ] @@ -4193,7 +4485,7 @@ checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] @@ -4213,7 +4505,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", "synstructure", ] @@ -4253,14 +4545,14 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.113", ] [[package]] name = "zmij" -version = "0.1.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0095ecd462946aa3927d9297b63ef82fb9a5316d7a37d134eeb36e58228615a" +checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868" [[package]] name = "zstd" diff --git a/Cargo.toml b/Cargo.toml index 53cc7371..f3aaa9d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["common", "api", "lite"] +members = ["api", "common", "lite"] [workspace.package] edition = "2024" @@ -11,11 +11,13 @@ version = "0.1.0" async-stream = "0.3.6" async-trait = "0.1.89" axum = "0.8.8" -base64ct = { version = "1.8.1", features = ["alloc"] } +axum-extra = "0.12" +axum-server = "0.8.0" +base64ct = { version = "1.8.2", features = ["alloc"] } blake3 = "1.8.2" bytes = "1.11.0" +clap = { version = "4.5.54", features = ["derive"] } compact_str = "0.9.0" -clap = { version = "4.5.53", features = ["derive"] } const_format = "0.2.35" dashmap = "6.1.0" enum-ordinalize = "4.3.2" @@ -25,19 +27,24 @@ flate2 = "1.1.5" futures = "0.3.31" http = "1.4.0" itertools = "0.14.0" +parking_lot = "0.12.5" proptest = "1.9.0" prost = "0.14.1" prost-build = "0.14.1" rand = "0.9.2" +rcgen = "0.14" rkyv = "0.8.12" rstest = "0.26.1" serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.147" -slatedb = { git = "https://github.com/slatedb/slatedb", rev = "4296c696b8b7c1cdf2ad79a9900de0d769a75576" } +serde_json = "1.0.148" +slatedb = "0.10.0" strum = { version = "0.27", features = ["derive"] } thiserror = "2.0.17" time = { version = "0.3.44", features = ["serde", "parsing", "formatting"] } -tokio = "1.48.0" -tokio-util = "0.7.17" +tokio = "1.49.0" +tokio-util = "0.7.18" +tracing = "0.1" +tracing-subscriber = "0.3" utoipa = { git = "https://github.com/infiniteregrets/utoipa", rev = "9cd181d40ac0a50551ebe1995a4d73e8685890d6", features = ["time"] } +uuid = { version = "1.19.0", features = ["v4"] } zstd = "0.13.3" diff --git a/api/src/lib.rs b/api/src/lib.rs index aefbbadf..a3a6d96c 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,7 +1 @@ pub mod v1; - -pub mod spec { - pub fn generate() -> String { - "OpenAPI 3.0 spec".to_string() - } -} diff --git a/api/src/v1/access.rs b/api/src/v1/access.rs index 22953b94..2f8578dc 100644 --- a/api/src/v1/access.rs +++ b/api/src/v1/access.rs @@ -43,12 +43,10 @@ where } use time::OffsetDateTime; -#[cfg(feature = "utoipa")] -use utoipa::{IntoParams, ToSchema}; #[rustfmt::skip] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum Operation { /// List basins. @@ -154,7 +152,7 @@ impl From for Operation { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct AccessTokenInfo { /// Access token ID. /// It must be unique to the account and between 1 and 96 bytes in length. @@ -208,7 +206,7 @@ impl From for AccessTokenInfo { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct AccessTokenScope { /// Basin names allowed. pub basins: Option, BasinNamePrefix>>, @@ -270,7 +268,7 @@ impl From for AccessTokenScope { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum ResourceSet { /// Match only the resource with this exact name. @@ -307,7 +305,7 @@ impl From, P>> for types::access::ResourceSet, @@ -351,7 +349,7 @@ impl From for PermittedOperationGroups #[rustfmt::skip] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct ReadWritePermissions { /// Read permission. #[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))] @@ -385,7 +383,7 @@ impl From for ReadWritePermissions { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] #[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] pub struct ListAccessTokensRequest { /// Filter to access tokens whose ID begins with this prefix. @@ -407,7 +405,7 @@ super::impl_list_request_conversions!( #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct ListAccessTokensResponse { /// Matching access tokens. #[cfg_attr(feature = "utoipa", schema(max_items = 1000))] @@ -418,7 +416,7 @@ pub struct ListAccessTokensResponse { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct IssueAccessTokenResponse { /// Created access token. pub access_token: String, diff --git a/api/src/v1/basin.rs b/api/src/v1/basin.rs index eb1f379e..7948008c 100644 --- a/api/src/v1/basin.rs +++ b/api/src/v1/basin.rs @@ -3,14 +3,12 @@ use s2_common::types::{ basin::{BasinName, BasinNamePrefix, BasinNameStartAfter}, }; use serde::{Deserialize, Serialize}; -#[cfg(feature = "utoipa")] -use utoipa::{IntoParams, ToSchema}; use super::config::BasinConfig; #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] #[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] pub struct ListBasinsRequest { /// Filter to basins whose names begin with this prefix. @@ -33,7 +31,7 @@ super::impl_list_request_conversions!( #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct ListBasinsResponse { /// Matching basins. #[cfg_attr(feature = "utoipa", schema(max_items = 1000))] @@ -44,7 +42,7 @@ pub struct ListBasinsResponse { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct BasinInfo { /// Basin name. pub name: BasinName, @@ -68,7 +66,7 @@ impl From for BasinInfo { #[rustfmt::skip] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub enum BasinScope { /// AWS `us-east-1` region. #[serde(rename = "aws:us-east-1")] @@ -93,7 +91,7 @@ impl From for BasinScope { #[rustfmt::skip] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum BasinState { /// Basin is active. @@ -116,7 +114,7 @@ impl From for BasinState { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct CreateOrReconfigureBasinRequest { /// Basin configuration. pub config: Option, @@ -127,7 +125,7 @@ pub struct CreateOrReconfigureBasinRequest { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct CreateBasinRequest { /// Basin name which must be globally unique. /// It can be between 8 and 48 bytes in length, and comprise lowercase letters, numbers and hyphens. diff --git a/api/src/v1/config.rs b/api/src/v1/config.rs index a72f1e2a..3eeeadf6 100644 --- a/api/src/v1/config.rs +++ b/api/src/v1/config.rs @@ -2,12 +2,10 @@ use std::time::Duration; use s2_common::{maybe::Maybe, types}; use serde::{Deserialize, Serialize}; -#[cfg(feature = "utoipa")] -use utoipa::ToSchema; #[rustfmt::skip] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum StorageClass { /// Append tail latency under 400 milliseconds with s2.dev. @@ -36,7 +34,7 @@ impl From for StorageClass { #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum RetentionPolicy { /// Age in seconds for automatic trimming of records older than this threshold. @@ -47,7 +45,7 @@ pub enum RetentionPolicy { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub struct InfiniteRetention {} @@ -76,7 +74,7 @@ impl From for RetentionPolicy { #[rustfmt::skip] #[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum TimestampingMode { /// Prefer client-specified timestamp if present otherwise use arrival time. @@ -110,7 +108,7 @@ impl From for TimestampingMode { #[rustfmt::skip] #[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct TimestampingConfig { /// Timestamping mode for appends that influences how timestamps are handled. pub mode: Option, @@ -153,7 +151,7 @@ impl From for types::config::OptionalTimestampingConfig { #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct TimestampingReconfiguration { /// Timestamping mode for appends that influences how timestamps are handled. #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] @@ -185,7 +183,7 @@ impl From for TimestampingReconfigur #[rustfmt::skip] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct DeleteOnEmptyConfig { /// Minimum age in seconds before an empty stream can be deleted. /// Set to 0 (default) to disable delete-on-empty (don't delete automatically). @@ -232,7 +230,7 @@ impl From for types::config::OptionalDeleteOnEmptyConfig { #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct DeleteOnEmptyReconfiguration { /// Minimum age in seconds before an empty stream can be deleted. /// Set to 0 to disable delete-on-empty (don't delete automatically). @@ -259,7 +257,7 @@ impl From for DeleteOnEmptyReconfig #[rustfmt::skip] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct StreamConfig { /// Storage class for recent writes. pub storage_class: Option, @@ -341,7 +339,7 @@ impl TryFrom for types::config::OptionalStreamConfig { #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct StreamReconfiguration { /// Storage class for recent writes. #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] @@ -402,7 +400,7 @@ impl From for StreamReconfiguration { #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct BasinConfig { /// Default stream configuration. pub default_stream_config: Option, @@ -453,7 +451,7 @@ impl From for BasinConfig { #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct BasinReconfiguration { /// Basin configuration. #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] diff --git a/api/src/v1/extractors.rs b/api/src/v1/extractors.rs new file mode 100644 index 00000000..253bfeb9 --- /dev/null +++ b/api/src/v1/extractors.rs @@ -0,0 +1,94 @@ +use std::fmt::Display; + +use axum::{ + Json, + extract::FromRequestParts, + http::{StatusCode, request::Parts}, + response::{IntoResponse, Response}, +}; +use s2_common::header::ExtractableHeader; + +use super::ErrorInfo; + +#[derive(Debug)] +pub enum HeaderExtractionError { + MissingHeader(&'static str), + InvalidUtf8(&'static str), + InvalidHeaderValue(&'static str, String), +} + +impl IntoResponse for HeaderExtractionError { + fn into_response(self) -> Response { + let (code, message) = match self { + HeaderExtractionError::MissingHeader(header) => ( + "missing_header", + format!("Missing required header: {}", header), + ), + HeaderExtractionError::InvalidUtf8(header) => ( + "invalid_header_utf8", + format!("Header {} contains invalid UTF-8", header), + ), + HeaderExtractionError::InvalidHeaderValue(header, error) => ( + "invalid_header_value", + format!("Invalid header value for {}: {}", header, error), + ), + }; + (StatusCode::BAD_REQUEST, Json(ErrorInfo { code, message })).into_response() + } +} + +fn parse_header(parts: &Parts) -> Result, HeaderExtractionError> +where + T: ExtractableHeader, + T::Err: Display, +{ + let name = T::name(); + let Some(value) = parts.headers.get(name) else { + return Ok(None); + }; + + let value_str = value + .to_str() + .map_err(|_| HeaderExtractionError::InvalidUtf8(name.as_str()))?; + + let parsed = value_str + .parse::() + .map_err(|e| HeaderExtractionError::InvalidHeaderValue(name.as_str(), e.to_string()))?; + + Ok(Some(parsed)) +} + +#[derive(Debug, Clone)] +pub struct RequiredHeader(pub T); + +impl FromRequestParts for RequiredHeader +where + S: Send + Sync, + T: ExtractableHeader, + T::Err: Display, +{ + type Rejection = HeaderExtractionError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let value = parse_header(parts)? + .ok_or_else(|| HeaderExtractionError::MissingHeader(T::name().as_str()))?; + Ok(RequiredHeader(value)) + } +} + +#[derive(Debug, Clone)] +pub struct OptionalHeader(pub Option); + +impl FromRequestParts for OptionalHeader +where + S: Send + Sync, + T: ExtractableHeader, + T::Err: Display, +{ + type Rejection = HeaderExtractionError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let value = parse_header(parts)?; + Ok(OptionalHeader(value)) + } +} diff --git a/api/src/v1/metrics.rs b/api/src/v1/metrics.rs index 64d49f89..2ff59b47 100644 --- a/api/src/v1/metrics.rs +++ b/api/src/v1/metrics.rs @@ -1,12 +1,10 @@ use compact_str::CompactString; use s2_common::types; use serde::{Deserialize, Serialize}; -#[cfg(feature = "utoipa")] -use utoipa::{IntoParams, ToSchema}; #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum TimeseriesInterval { Minute, @@ -36,7 +34,7 @@ impl From for TimeseriesInterval { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema, IntoParams))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))] #[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] pub struct AccountMetricSetRequest { /// Metric set to return. @@ -65,7 +63,7 @@ impl From for types::metrics::AccountMetricsRequest { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum AccountMetricSet { /// Set of all basins that had at least one stream during the specified period. @@ -76,7 +74,7 @@ pub enum AccountMetricSet { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema, IntoParams))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))] #[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] pub struct BasinMetricSetRequest { /// Metric set to return. @@ -111,7 +109,7 @@ impl From for types::metrics::BasinMetricsRequest { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum BasinMetricSet { /// Amount of stored data, per hour, aggregated over all streams in a basin. @@ -130,7 +128,7 @@ pub enum BasinMetricSet { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema, IntoParams))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))] #[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] pub struct StreamMetricSetRequest { /// Metric set to return. @@ -158,7 +156,7 @@ impl From for types::metrics::StreamMetricsRequest { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum StreamMetricSet { /// Amount of stored data, per minute, for a specific stream. @@ -167,7 +165,7 @@ pub enum StreamMetricSet { #[rustfmt::skip] #[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum MetricUnit { Bytes, @@ -185,7 +183,7 @@ impl From for MetricUnit { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct ScalarMetric { /// Metric name. #[cfg_attr(feature = "utoipa", schema(value_type = String))] @@ -208,7 +206,7 @@ impl From for ScalarMetric { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct AccumulationMetric { /// Timeseries name. #[cfg_attr(feature = "utoipa", schema(value_type = String))] @@ -236,7 +234,7 @@ impl From for AccumulationMetric { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct GaugeMetric { /// Timeseries name. #[cfg_attr(feature = "utoipa", schema(value_type = String))] @@ -261,7 +259,7 @@ impl From for GaugeMetric { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct LabelMetric { /// Label name. #[cfg_attr(feature = "utoipa", schema(value_type = String))] @@ -281,7 +279,7 @@ impl From for LabelMetric { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum Metric { /// Single named value. @@ -309,7 +307,7 @@ impl From for Metric { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct MetricSetResponse { /// Metrics comprising the set. pub values: Vec, diff --git a/api/src/v1/mod.rs b/api/src/v1/mod.rs index 5e822fda..f7fc9410 100644 --- a/api/src/v1/mod.rs +++ b/api/src/v1/mod.rs @@ -1,17 +1,17 @@ pub mod access; pub mod basin; pub mod config; +#[cfg(feature = "axum")] +pub mod extractors; pub mod metrics; pub mod stream; use s2_common::types; use serde::{Deserialize, Serialize}; -#[cfg(feature = "utoipa")] -use utoipa::{IntoParams, ToSchema}; #[rustfmt::skip] #[derive(Debug)] -#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] #[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))] pub struct S2RequestTokenHeader { /// Client-specified request token for idempotent retries. @@ -21,7 +21,7 @@ pub struct S2RequestTokenHeader { #[rustfmt::skip] #[derive(Debug)] -#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] #[cfg_attr(feature = "utoipa", into_params(parameter_in = Path))] pub struct AccessTokenIdPathSegment { /// Access token ID. @@ -30,7 +30,7 @@ pub struct AccessTokenIdPathSegment { #[rustfmt::skip] #[derive(Debug)] -#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] #[cfg_attr(feature = "utoipa", into_params(parameter_in = Path))] pub struct BasinNamePathSegment { /// Basin name. @@ -39,7 +39,7 @@ pub struct BasinNamePathSegment { #[rustfmt::skip] #[derive(Debug)] -#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] #[cfg_attr(feature = "utoipa", into_params(parameter_in = Path))] pub struct StreamNamePathSegment { /// Stream name. @@ -71,7 +71,7 @@ macro_rules! impl_list_request_conversions { pub(crate) use impl_list_request_conversions; #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct ErrorInfo { pub code: &'static str, pub message: String, diff --git a/api/src/v1/stream/mod.rs b/api/src/v1/stream/mod.rs index 728636d3..b4644a5b 100644 --- a/api/src/v1/stream/mod.rs +++ b/api/src/v1/stream/mod.rs @@ -2,7 +2,7 @@ pub mod proto; pub mod s2s; pub mod sse; -use std::time::Duration; +use std::{str::FromStr, time::Duration}; use base64ct::{Base64, Encoding as _}; use bytes::Bytes; @@ -10,22 +10,31 @@ use itertools::Itertools as _; use s2_common::{ record, types::{ - self, + self, ValidationError, stream::{StreamName, StreamNamePrefix, StreamNameStartAfter}, }, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; -#[cfg(feature = "utoipa")] -use utoipa::{IntoParams, ToSchema}; use super::config::StreamConfig; -pub const S2_FORMAT_HEADER: http::HeaderName = http::HeaderName::from_static("s2-format"); +pub static FORMAT_HEADER: http::HeaderName = http::HeaderName::from_static("s2-format"); + +#[rustfmt::skip] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))] +pub struct S2FormatHeader { + /// Defines the interpretation of record data (header name, header value, and body) with the JSON content type. + /// Use `raw` (default) for efficient transmission and storage of Unicode data — storage will be in UTF-8. + /// Use `base64` for safe transmission with efficient storage of binary data. + #[cfg_attr(feature = "utoipa", param(required = false, rename = "s2-format"))] + pub s2_format: Format, +} #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct StreamInfo { /// Stream name. pub name: StreamName, @@ -49,7 +58,7 @@ impl From for StreamInfo { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] #[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] pub struct ListStreamsRequest { /// Filter to streams whose name begins with this prefix. @@ -72,7 +81,7 @@ super::impl_list_request_conversions!( #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct ListStreamsResponse { /// Matching streams. #[cfg_attr(feature = "utoipa", schema(max_items = 1000))] @@ -83,7 +92,7 @@ pub struct ListStreamsResponse { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct CreateStreamRequest { /// Stream name that is unique to the basin. /// It can be between 1 and 512 bytes in length. @@ -94,7 +103,7 @@ pub struct CreateStreamRequest { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] /// Position of a record in a stream. pub struct StreamPosition { /// Sequence number assigned by the service. @@ -124,7 +133,7 @@ impl From for record::StreamPosition { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct TailResponse { /// Sequence number that will be assigned to the next record on the stream, and timestamp of the last record. pub tail: StreamPosition, @@ -132,7 +141,7 @@ pub struct TailResponse { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] #[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] pub struct ReadStart { /// Start from a sequence number. @@ -172,7 +181,7 @@ impl TryFrom for types::stream::ReadStart { #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(IntoParams))] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] #[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] pub struct ReadEnd { /// Record count limit. @@ -269,21 +278,10 @@ impl AppendResponseKind { } } -#[rustfmt::skip] -#[cfg_attr(feature = "utoipa", derive(IntoParams))] -#[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))] -pub struct FormatHeader { - /// Defines the interpretation of record data (header name, header value, and body) with the JSON content type. - /// Use `raw` (default) for efficient transmission and storage of Unicode data — storage will be in UTF-8. - /// Use `base64` for safe transmission with efficient storage of binary data. - #[cfg_attr(feature = "utoipa", param(required = false, rename = "s2-format"))] - pub s2_format: S2Format, -} - #[rustfmt::skip] #[derive(Default, Clone, Copy)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] -pub enum S2Format { +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub enum Format { #[default] #[cfg_attr(feature = "utoipa", schema(rename = "raw"))] Raw, @@ -291,27 +289,17 @@ pub enum S2Format { Base64, } -impl S2Format { - /// Returns `None` in case of more than 1 header or an invalid header value. - pub fn from_headers(headers: &http::HeaderMap) -> Option { - let mut formats = headers.get_all(S2_FORMAT_HEADER).iter(); - let Some(format) = formats.next() else { - return Some(S2Format::default()); - }; - if formats.next().is_some() { - return None; - } - format.to_str().ok().and_then(|fmt| match fmt.trim() { - "raw" | "json" => Some(Self::Raw), - "base64" | "json-binsafe" => Some(Self::Base64), - _ => None, - }) +impl s2_common::header::ExtractableHeader for Format { + fn name() -> &'static http::HeaderName { + &FORMAT_HEADER } +} +impl Format { fn encode(self, bytes: &[u8]) -> String { match self { - S2Format::Raw => String::from_utf8_lossy(bytes).into_owned(), - S2Format::Base64 => Base64::encode_string(bytes), + Format::Raw => String::from_utf8_lossy(bytes).into_owned(), + Format::Base64 => Base64::encode_string(bytes), } } @@ -336,8 +324,8 @@ impl S2Format { fn decode(self, s: String) -> Result { Ok(match self { - S2Format::Raw => s.into_bytes().into(), - S2Format::Base64 => Base64::decode_vec(&s) + Format::Raw => s.into_bytes().into(), + Format::Base64 => Base64::decode_vec(&s) .map_err(|_| types::ValidationError("invalid Base64 encoding".to_owned()))? .into(), }) @@ -374,17 +362,29 @@ impl S2Format { } } +impl FromStr for Format { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + match s.trim() { + "raw" | "json" => Ok(Self::Raw), + "base64" | "json-binsafe" => Ok(Self::Base64), + _ => Err(ValidationError(s.to_string())), + } + } +} + /// Headers add structured information to a record as name-value pairs. /// /// The name cannot be empty, with the exception of an S2 command record. #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct Header(pub String, pub String); #[rustfmt::skip] /// Record that is durably sequenced on a stream. #[derive(Debug, Clone, Serialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct SequencedRecord { /// Sequence number assigned by the service. pub seq_num: record::SeqNum, @@ -403,7 +403,7 @@ pub struct SequencedRecord { #[rustfmt::skip] /// Record to be appended to a stream. #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct AppendRecord { /// Timestamp for this record. /// The service will always ensure monotonicity by adjusting it up if necessary to the maximum observed timestamp. @@ -422,7 +422,7 @@ pub struct AppendRecord { #[rustfmt::skip] /// Payload of an `append` request. #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct AppendInput { /// Batch of records to append atomically, which must contain at least one record, and no more than 1000. /// The total size of a batch of records may not exceed 1 MiB of metered bytes. @@ -436,7 +436,7 @@ pub struct AppendInput { #[rustfmt::skip] /// Success response to an `append` request. #[derive(Debug, Clone, Serialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct AppendAck { /// Sequence number and timestamp of the first record that was appended. pub start: StreamPosition, @@ -461,7 +461,7 @@ impl From for AppendAck { #[rustfmt::skip] /// Aborted due to a failed condition. #[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "snake_case")] pub enum AppendConditionFailed { /// Fencing token did not match. @@ -476,7 +476,7 @@ pub enum AppendConditionFailed { #[rustfmt::skip] #[derive(Debug, Clone, Serialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct ReadBatch { /// Records that are durably sequenced on the stream, retrieved based on the requested criteria. /// This can only be empty in response to a unary read (i.e. not SSE), if the request cannot be satisfied without violating an explicit bound (`count`, `bytes`, or `until`). @@ -487,8 +487,8 @@ pub struct ReadBatch { pub tail: Option, } -impl From<(S2Format, types::stream::ReadBatch)> for ReadBatch { - fn from((s2_format, batch): (S2Format, types::stream::ReadBatch)) -> Self { +impl From<(Format, types::stream::ReadBatch)> for ReadBatch { + fn from((s2_format, batch): (Format, types::stream::ReadBatch)) -> Self { Self { records: batch .records diff --git a/api/src/v1/stream/sse.rs b/api/src/v1/stream/sse.rs index d66218ff..7c241091 100644 --- a/api/src/v1/stream/sse.rs +++ b/api/src/v1/stream/sse.rs @@ -2,8 +2,6 @@ use std::{str::FromStr, time::Duration}; use s2_common::types; use serde::Serialize; -#[cfg(feature = "utoipa")] -use utoipa::ToSchema; use super::ReadBatch; @@ -70,7 +68,7 @@ impl FromStr for LastEventId { macro_rules! event { ($name:ident, $val:expr) => { #[derive(Serialize)] - #[cfg_attr(feature = "utoipa", derive(ToSchema))] + #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "snake_case")] pub enum $name { $name, @@ -89,7 +87,7 @@ event!(Error, "error"); event!(Ping, "ping"); #[derive(Serialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(untagged)] pub enum ReadEvent { #[cfg_attr(feature = "utoipa", schema(title = "batch"))] @@ -176,7 +174,7 @@ impl TryFrom for axum::response::sse::Event { } #[derive(Debug, Clone, Serialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename = "[DONE]")] pub struct DoneEventData; @@ -188,7 +186,7 @@ impl AsRef for DoneEventData { #[rustfmt::skip] #[derive(Debug, Clone, Serialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct PingEventData { pub timestamp: u64, } diff --git a/common/Cargo.toml b/common/Cargo.toml index d1e460c8..316bcee9 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -5,18 +5,20 @@ license.workspace = true version.workspace = true [features] +clap = ["dep:clap"] rkyv = ["dep:rkyv", "compact_str/rkyv"] utoipa = ["dep:utoipa"] -clap = ["dep:clap"] [dependencies] bytes = { workspace = true } +blake3 = { workspace = true } clap = { workspace = true, optional = true } compact_str = { workspace = true, features = ["serde"] } const_format = { workspace = true } enum-ordinalize = { workspace = true } enumset = { workspace = true } futures = { workspace = true } +http = { workspace = true } rkyv = { workspace = true, optional = true } serde = { workspace = true } strum = { workspace = true } diff --git a/common/src/bash.rs b/common/src/bash.rs new file mode 100644 index 00000000..96e7719e --- /dev/null +++ b/common/src/bash.rs @@ -0,0 +1,75 @@ +use bytes::Bytes; + +/// BLAKE3 hash (32 bytes) of any number of fields. +/// +/// Default SerDe implementation uses hex representation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Bash(blake3::Hash); + +impl Bash { + pub const LEN: usize = 32; + const SEPARATOR: u8 = 0u8; + + pub fn new(components: &[&[u8]]) -> Self { + let mut hasher = blake3::Hasher::new(); + for component in components { + hasher.update(component); + hasher.update(&[Self::SEPARATOR]); + } + Self(hasher.finalize()) + } + + pub fn as_bytes(&self) -> &[u8; Bash::LEN] { + self.0.as_bytes() + } +} + +impl std::fmt::Display for Bash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0.to_hex().as_str()) + } +} + +impl AsRef<[u8]> for Bash { + fn as_ref(&self) -> &[u8] { + self.as_bytes() + } +} + +impl From for [u8; Bash::LEN] { + fn from(bash: Bash) -> Self { + bash.0.into() + } +} + +impl From<[u8; Bash::LEN]> for Bash { + fn from(bytes: [u8; Bash::LEN]) -> Self { + Self(blake3::Hash::from_bytes(bytes)) + } +} + +impl From for Bytes { + fn from(bash: Bash) -> Self { + Bytes::copy_from_slice(bash.as_bytes()) + } +} + +impl serde::Serialize for Bash { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0.to_hex()) + } +} + +impl<'de> serde::Deserialize<'de> for Bash { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let hash = blake3::Hash::from_hex(s.as_bytes()).map_err(serde::de::Error::custom)?; + Ok(Self(hash)) + } +} diff --git a/common/src/caps.rs b/common/src/caps.rs index 1860f676..d78c61c5 100644 --- a/common/src/caps.rs +++ b/common/src/caps.rs @@ -4,7 +4,7 @@ pub const MAX_BASIN_NAME_LEN: usize = 48; pub const MIN_STREAM_NAME_LEN: usize = 1; pub const MAX_STREAM_NAME_LEN: usize = 512; -pub const MAX_TOKEN_ID_LEN: usize = 96; +pub const MAX_ACCESS_TOKEN_ID_LEN: usize = 96; /// All record batches in the system are limited to 1000 records. /// Batches are limited to a collective size of 1 MiB, which is also the maximum size of a single diff --git a/common/src/header.rs b/common/src/header.rs new file mode 100644 index 00000000..4f62ea80 --- /dev/null +++ b/common/src/header.rs @@ -0,0 +1,8 @@ +use std::{fmt::Display, str::FromStr}; + +pub trait ExtractableHeader: FromStr +where + Self::Err: Display, +{ + fn name() -> &'static http::HeaderName; +} diff --git a/common/src/lib.rs b/common/src/lib.rs index 22155fa1..da60971e 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,5 +1,7 @@ +pub mod bash; pub mod caps; pub mod deep_size; +pub mod header; pub mod maybe; pub mod read_extent; pub mod record; diff --git a/common/src/record/batcher.rs b/common/src/record/batcher.rs index b5eb7aa5..b444a781 100644 --- a/common/src/record/batcher.rs +++ b/common/src/record/batcher.rs @@ -6,12 +6,12 @@ use super::InternalRecordError; use crate::{ caps, read_extent::{EvaluatedReadLimit, ReadLimit, ReadUntil}, - record::{MeteredRecord, MeteredSequencedRecords, MeteredSize, StreamPosition}, + record::{Metered, MeteredSize, SequencedRecord, StreamPosition}, }; #[derive(Debug)] pub struct RecordBatch { - pub records: MeteredSequencedRecords, + pub records: Metered>, pub is_terminal: bool, } @@ -21,21 +21,21 @@ where E: Into, { record_iterator: I, - buffered_records: MeteredSequencedRecords, + buffered_records: Metered>, buffered_error: Option, read_limit: EvaluatedReadLimit, until: ReadUntil, is_terminated: bool, } -fn make_records(read_limit: &EvaluatedReadLimit) -> MeteredSequencedRecords { +fn make_records(read_limit: &EvaluatedReadLimit) -> Metered> { match read_limit { - EvaluatedReadLimit::Remaining(limit) => MeteredSequencedRecords::with_capacity( - limit.count().map_or(caps::RECORD_BATCH_MAX.count, |n| { + EvaluatedReadLimit::Remaining(limit) => { + Metered::with_capacity(limit.count().map_or(caps::RECORD_BATCH_MAX.count, |n| { n.min(caps::RECORD_BATCH_MAX.count) - }), - ), - EvaluatedReadLimit::Exhausted => MeteredSequencedRecords::default(), + })) + } + EvaluatedReadLimit::Exhausted => Metered::default(), } } @@ -65,7 +65,7 @@ where while self.buffered_error.is_none() { match self.record_iterator.next() { Some(Ok((position, data))) => { - let record = match MeteredRecord::try_from(data) { + let record = match Metered::try_from(data) { Ok(record) => record.sequenced(position), Err(err) => { self.buffered_error = Some(err); @@ -114,7 +114,7 @@ where let records = std::mem::replace( &mut self.buffered_records, if is_terminal || self.buffered_error.is_some() { - MeteredSequencedRecords::default() + Metered::default() } else { let mut buf = make_records(&self.read_limit); if let Some(record) = stashed_record.take() { @@ -183,7 +183,7 @@ mod tests { records .into_iter() .map(|SequencedRecord { position, record }| { - (position, MeteredRecord::from(record).to_bytes()) + (position, Metered::from(record).as_ref().to_bytes()) }) .map(Ok) } diff --git a/common/src/record/metering.rs b/common/src/record/metering.rs index 6ebeec4d..5e7be883 100644 --- a/common/src/record/metering.rs +++ b/common/src/record/metering.rs @@ -3,6 +3,15 @@ pub trait MeteredSize { fn metered_size(&self) -> usize; } +impl MeteredSize for &T +where + T: MeteredSize, +{ + fn metered_size(&self) -> usize { + (**self).metered_size() + } +} + impl MeteredSize for &[T] { fn metered_size(&self) -> usize { self.iter().fold(0, |acc, item| acc + item.metered_size()) @@ -24,6 +33,13 @@ impl Metered { pub fn into_inner(self) -> T { self.inner } + + pub const fn as_ref(&self) -> Metered<&T> { + Metered { + size: self.size, + inner: &self.inner, + } + } } impl std::fmt::Debug for Metered @@ -122,6 +138,21 @@ where } } +impl FromIterator> for Metered> +where + T: MeteredSize, +{ + fn from_iter>>(iterable: I) -> Self { + let it = iterable.into_iter(); + let (cap_lower, cap_upper) = it.size_hint(); + let mut buf = Self::with_capacity(cap_upper.unwrap_or(cap_lower)); + for item in it { + buf.push(item); + } + buf + } +} + impl IntoIterator for Metered> { type Item = T; type IntoIter = std::vec::IntoIter; diff --git a/common/src/record/mod.rs b/common/src/record/mod.rs index 9e3fc5fb..5cf0d8ad 100644 --- a/common/src/record/mod.rs +++ b/common/src/record/mod.rs @@ -235,7 +235,7 @@ pub trait Encodable { fn encode_into(&self, buf: &mut impl BufMut); } -impl Encodable for MeteredRecord { +impl Encodable for Metered<&Record> { fn encoded_size(&self) -> usize { 1 + self.magic_byte().metered_size_varlen as usize + match &**self { @@ -276,16 +276,16 @@ impl DeepSize for SequencedRecord { } } -pub type MeteredRecord = Metered; - -impl MeteredRecord { - pub fn sequenced(self, position: StreamPosition) -> MeteredSequencedRecord { - MeteredSequencedRecord { +impl Metered { + pub fn sequenced(self, position: StreamPosition) -> Metered { + Metered { size: self.metered_size(), inner: self.inner.sequenced(position), } } +} +impl Metered<&Record> { fn magic_byte(&self) -> MagicByte { let metered_size = self.metered_size(); let metered_size_varlen = 8 - (metered_size.leading_zeros() / 8) as u8; @@ -303,7 +303,7 @@ impl MeteredRecord { } } -impl TryFrom for MeteredRecord { +impl TryFrom for Metered { type Error = InternalRecordError; fn try_from(mut buf: Bytes) -> Result { @@ -325,15 +325,21 @@ impl TryFrom for MeteredRecord { } } -pub type MeteredSequencedRecord = Metered; - -pub type MeteredSequencedRecords = Metered>; - -impl MeteredSequencedRecord { - pub fn into_parts(self) -> (StreamPosition, MeteredRecord) { +impl Metered { + pub fn parts(&self) -> (StreamPosition, Metered<&Record>) { ( self.position, - MeteredRecord { + Metered { + size: self.size, + inner: &self.inner.record, + }, + ) + } + + pub fn into_parts(self) -> (StreamPosition, Metered) { + ( + self.position, + Metered { size: self.size, inner: self.inner.record, }, @@ -378,9 +384,9 @@ mod test { body in bytes_strategy(true), ) { let record = Record::try_from_parts(headers, body).unwrap(); - let metered_record: MeteredRecord = record.clone().into(); - let as_bytes = metered_record.clone().to_bytes(); - let decoded_record = MeteredRecord::try_from(as_bytes).unwrap(); + let metered_record: Metered = record.clone().into(); + let encoded_record = metered_record.as_ref().to_bytes(); + let decoded_record = Metered::try_from(encoded_record).unwrap(); prop_assert_eq!(&decoded_record, &metered_record); let sequenced = decoded_record.sequenced(StreamPosition { seq_num, timestamp }); assert_eq!(sequenced.position, StreamPosition {seq_num, timestamp}); @@ -396,8 +402,8 @@ mod test { body in bytes_strategy(true), ) { let record = Record::try_from_parts(headers.clone(), body.clone()).unwrap(); - let data_as_bytes = MeteredRecord::from(record.clone()).to_bytes(); - assert_eq!(record.metered_size(), try_metered_size(data_as_bytes.as_ref()).unwrap() as usize); + let encoded_record = Metered::from(&record).to_bytes(); + assert_eq!(record.metered_size(), try_metered_size(encoded_record.as_ref()).unwrap() as usize); } ); diff --git a/common/src/types/access.rs b/common/src/types/access.rs index b0c5be41..9af76826 100644 --- a/common/src/types/access.rs +++ b/common/src/types/access.rs @@ -27,7 +27,7 @@ where utoipa::openapi::Object::builder() .schema_type(utoipa::openapi::Type::String) .min_length((!T::IS_PREFIX).then_some(1)) - .max_length(Some(Self::MAX_LENGTH)) + .max_length(Some(caps::MAX_ACCESS_TOKEN_ID_LEN)) .into() } } @@ -54,10 +54,6 @@ impl<'de, T: StrProps> serde::Deserialize<'de> for AccessTokenIdStr { } } -impl AccessTokenIdStr { - const MAX_LENGTH: usize = caps::MAX_TOKEN_ID_LEN; -} - impl AsRef for AccessTokenIdStr { fn as_ref(&self) -> &str { &self.0 @@ -80,11 +76,11 @@ impl TryFrom for AccessTokenIdStr { return Err(format!("access token {} must not be empty", T::FIELD_NAME).into()); } - if name.len() > Self::MAX_LENGTH { + if name.len() > caps::MAX_ACCESS_TOKEN_ID_LEN { return Err(format!( "access token {} must not exceed {} bytes in length", T::FIELD_NAME, - Self::MAX_LENGTH + caps::MAX_ACCESS_TOKEN_ID_LEN ) .into()); } diff --git a/common/src/types/basin.rs b/common/src/types/basin.rs index 6f61124e..b9d6fedc 100644 --- a/common/src/types/basin.rs +++ b/common/src/types/basin.rs @@ -8,6 +8,8 @@ use super::{ }; use crate::{caps, types::resources::ListItemsRequest}; +pub static BASIN_HEADER: http::HeaderName = http::HeaderName::from_static("s2-basin"); + #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr( feature = "rkyv", @@ -181,6 +183,12 @@ impl From for BasinNameStartAfter { } } +impl crate::header::ExtractableHeader for BasinName { + fn name() -> &'static http::HeaderName { + &BASIN_HEADER + } +} + pub type ListBasinsRequest = ListItemsRequest; #[derive(Debug, Clone, Copy)] diff --git a/common/src/types/resources.rs b/common/src/types/resources.rs index f4fa0da3..7d4208fd 100644 --- a/common/src/types/resources.rs +++ b/common/src/types/resources.rs @@ -1,4 +1,6 @@ -use std::{fmt::Debug, num::NonZeroUsize, ops::Deref}; +use std::{fmt::Debug, num::NonZeroUsize, ops::Deref, str::FromStr}; + +use compact_str::{CompactString, ToCompactString}; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Page { @@ -111,9 +113,110 @@ where } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum CreateMode { - CreateOnly, - #[default] + /// Create a new resource. + /// + /// HTTP POST semantics – idempotent if a request token is provided and the resource was + /// previously created using the same token. + CreateOnly(Option), + /// Create a new resource or reconfigure if the resource already exists. + /// + /// HTTP PUT semantics – always idempotent. CreateOrReconfigure, } + +pub static REQUEST_TOKEN_HEADER: http::HeaderName = + http::HeaderName::from_static("s2-request-token"); + +pub const MAX_REQUEST_TOKEN_LENGTH: usize = 36; + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[error("request token was longer than {MAX_REQUEST_TOKEN_LENGTH} bytes in length: {0}")] +pub struct RequestTokenTooLongError(pub usize); + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct RequestToken(CompactString); + +#[cfg(feature = "utoipa")] +impl utoipa::PartialSchema for RequestToken { + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::Object::builder() + .schema_type(utoipa::openapi::Type::String) + .max_length(Some(MAX_REQUEST_TOKEN_LENGTH)) + .into() + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::ToSchema for RequestToken {} + +impl serde::Serialize for RequestToken { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for RequestToken { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = CompactString::deserialize(deserializer)?; + RequestToken::try_from(s).map_err(serde::de::Error::custom) + } +} + +impl std::fmt::Display for RequestToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for RequestToken { + type Error = RequestTokenTooLongError; + + fn try_from(input: CompactString) -> Result { + if input.len() > MAX_REQUEST_TOKEN_LENGTH { + return Err(RequestTokenTooLongError(input.len())); + } + Ok(RequestToken(input)) + } +} + +impl FromStr for RequestToken { + type Err = RequestTokenTooLongError; + + fn from_str(s: &str) -> Result { + s.to_compact_string().try_into() + } +} + +impl From for CompactString { + fn from(token: RequestToken) -> Self { + token.0 + } +} + +impl AsRef for RequestToken { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for RequestToken { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl crate::header::ExtractableHeader for RequestToken { + fn name() -> &'static http::HeaderName { + &REQUEST_TOKEN_HEADER + } +} diff --git a/common/src/types/stream.rs b/common/src/types/stream.rs index fd6c9c4d..b349d156 100644 --- a/common/src/types/stream.rs +++ b/common/src/types/stream.rs @@ -11,8 +11,8 @@ use crate::{ caps, read_extent::{ReadLimit, ReadUntil}, record::{ - FencingToken, Metered, MeteredRecord, MeteredSequencedRecords, MeteredSize, SeqNum, - StreamPosition, Timestamp, + FencingToken, Metered, MeteredSize, Record, SeqNum, SequencedRecord, StreamPosition, + Timestamp, }, types::resources::ListItemsRequest, }; @@ -179,7 +179,7 @@ impl MeteredSize for AppendRecord { #[derive(Debug, Clone)] pub struct AppendRecordParts { pub timestamp: Option, - pub record: MeteredRecord, + pub record: Metered, } impl MeteredSize for AppendRecordParts { @@ -325,7 +325,7 @@ impl ReadEnd { #[derive(Default, Clone)] pub struct ReadBatch { - pub records: MeteredSequencedRecords, + pub records: Metered>, pub tail: Option, } diff --git a/lite/Cargo.toml b/lite/Cargo.toml index 8500c1c6..ccc3cb53 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -5,16 +5,34 @@ license.workspace = true version.workspace = true [dependencies] +async-stream = { workspace = true } async-trait = { workspace = true } +axum = { workspace = true, features = ["macros"] } +axum-extra = { workspace = true } +axum-server = { workspace = true, features = ["tls-rustls"] } blake3 = { workspace = true } bytes = { workspace = true } clap = { workspace = true } compact_str = { workspace = true } dashmap = { workspace = true } +enum-ordinalize = { workspace = true } eyre = { workspace = true } futures = { workspace = true } -s2-api = { path = "../api" } +http = { workspace = true } +parking_lot = { workspace = true, features = ["arc_lock"] } +rand = { workspace = true } +rcgen = { workspace = true } +s2-api = { path = "../api", features = ["axum"] } s2-common = { path = "../common", features = ["clap"] } +serde = { workspace = true } +serde_json = { workspace = true } slatedb = { workspace = true } thiserror = { workspace = true } +time = { workspace = true } tokio = { workspace = true, features = ["sync"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +uuid = { workspace = true } diff --git a/lite/docs/notes.md b/lite/docs/notes.md deleted file mode 100644 index c2ce04af..00000000 --- a/lite/docs/notes.md +++ /dev/null @@ -1,75 +0,0 @@ -Following live updates is easy because it'd be a single node that is aware of what's being written on a stream, so always just publish to a tailer broadcast channel. - -TBD: Task per stream and communicate over a channel, or shared state? - -Basins supported via `S2-Basin` header. If missing, `default` basin used. - -Granular access tokens can be supported by storing them in the same DB. - -## Data model - -`StreamID = Blake3(BasinName, StreamName)` - -1 byte prefix for each key identifying the type of data: -``` -BC Basin config upd per-basin -SC Stream config upd per-stream -SD Stream data imm per-record -ST Stream timestamp imm per-record -SP Stream tail position upd per-stream -``` - -Mappings -``` -BC "{BasinName}" -> BasinConfig -SC "{BasinName}#{StreamName}" -> StreamConfig -SD StreamID.SeqNum -> Record -ST StreamID.Timestamp -> SeqNum -SP StreamID -> SeqNum.Timestamp -``` - -Appends: Every AppendInput maps to a WriteBatch, with per-record SR and ST key-value pairs, and an SP update - -Reads: -- By seq num – straightforward, scan >= SD key -- By timestamp – find first record >= ST key, and then look by seq num -- If no record found, figure out the tail if possible to follow - -Figuring out the tail: -- Init SP if it isn't already - -Retention: -- TTL set on each SD & ST row if configured - -## API surface - -``` -DataOps /records - append # (basin, stream) - read # (basin, stream) - check_tail # (basin, stream) - -StreamOps /streams - list_streams # (basin) - create_stream # (basin, stream) - get_stream_config # (basin, stream) - reconfigure_stream # (basin, stream) - delete_stream # (basin, stream) - -BasinOps /basins - list_basins # () - create_basin # (basin) - get_basin_config # (basin) - reconfigure_basin # (basin) - delete_basin # (basin) - -AccessTokenOps /access-tokens # not initially supported - list_access_tokens # (id) - issue_access_token # (id) - revoke_access_token # (id) - -MetricOps /metrics # not initially supported - get_account_metrics # () - get_basin_metrics # (basin) - get_stream_metrics # (basin, stream) -``` diff --git a/lite/docs/todo.md b/lite/docs/todo.md new file mode 100644 index 00000000..1f51bcc9 --- /dev/null +++ b/lite/docs/todo.md @@ -0,0 +1,21 @@ +# TODO + +## bgtasks + +### Explicit trim + +- STP key used for trawling, deleted when processed +- bgtask processes trims concurrently by deleting data +- Stream deletion: explicit trim to SeqNum::MAX, which when complete deletes *all* stream-level keys including metadata + - Stream stays in tombstoned state while pending. + - N.B. Stream meta `deleted_at` is not set transactionally with STP, but as a subsequent step in initiating deletion, so may be temporarily out of sync + +### Basin deletion + +- BDP key used for trawling for basin deletions +- BDP value used for keeping track of streams to be deleted for each basin as it is a `start_after` cursor for listing streams +- bgtask initiates stream deletion for every stream in the basin, completes and deletes *all* basin-level keys when all streams have been deleted + +### Delete-on-empty + +TODO. Probably some kinda deadline key that is trawled by a process-level bgtask, only set it for first batch in N minutes (in-mem state). diff --git a/lite/src/backend/append.rs b/lite/src/backend/append.rs new file mode 100644 index 00000000..4545987a --- /dev/null +++ b/lite/src/backend/append.rs @@ -0,0 +1,254 @@ +use std::{ + collections::VecDeque, + ops::{DerefMut as _, Range, RangeTo}, + sync::Arc, +}; + +use futures::{FutureExt as _, Stream, StreamExt as _, stream::FuturesOrdered}; +use s2_common::{ + record::{MeteredSize as _, SeqNum, StreamPosition}, + types::{ + basin::BasinName, + stream::{AppendAck, AppendInput, StreamName}, + }, +}; +use tokio::sync::oneshot; + +use super::Backend; +use crate::backend::error::{AppendError, AppendErrorInternal, StorageError}; + +// N.B. Ultimately capped by streamer::MAX_INFLIGHT_APPENDS +const MAX_INFLIGHT_BATCHES: usize = 100; +const MAX_INFLIGHT_BYTES: usize = 10 * 1024 * 1024; + +impl Backend { + pub async fn append( + &self, + basin: BasinName, + stream: StreamName, + input: AppendInput, + ) -> Result { + let client = self + .streamer_client_with_auto_create::(&basin, &stream, |config| { + config.create_stream_on_append + }) + .await?; + let ack = client.append(input, None).await?; + Ok(ack) + } + + pub async fn append_session( + &self, + basin: BasinName, + stream: StreamName, + inputs: impl Stream, + ) -> Result>, AppendError> { + let client = self.streamer_client(&basin, &stream).await?; + let session = SessionHandle::new(); + Ok(async_stream::stream! { + tokio::pin!(inputs); + let mut futs = FuturesOrdered::new(); + let mut inflight_bytes = 0; + loop { + tokio::select! { + Some(input) = inputs.next(), if ( + futs.len() < MAX_INFLIGHT_BATCHES + && inflight_bytes <= MAX_INFLIGHT_BYTES + ) => { + let metered_size = input.records.metered_size(); + inflight_bytes += metered_size; + let fut = client.append(input, Some(session.clone())).map(move |res| (metered_size, res)); + futs.push_back(fut); + } + Some((metered_size, res)) = futs.next() => { + inflight_bytes -= metered_size; + yield res.map_err(AppendError::from); + } + else => { + break; + } + } + } + }) + } +} + +#[derive(Debug)] +struct SessionState { + last_ack_end: RangeTo, + poisoned: bool, +} + +#[derive(Debug, Clone)] +pub struct SessionHandle(Arc>); + +impl SessionHandle { + pub fn new() -> Self { + Self(Arc::new(parking_lot::Mutex::new(SessionState { + last_ack_end: ..SeqNum::MIN, + poisoned: false, + }))) + } +} + +#[must_use] +pub fn admit( + tx: oneshot::Sender>, + session: Option, +) -> Option { + if tx.is_closed() { + return None; + } + match session { + None => Some(Ticket { tx, session: None }), + Some(session) => { + let session = session.0.lock_arc(); + if session.poisoned { + None + } else { + Some(Ticket { + tx, + session: Some(session), + }) + } + } + } +} + +#[derive(Debug, Default)] +pub struct PendingAppends { + queue: VecDeque, + next_ack_pos: Option, +} + +impl PendingAppends { + pub fn new(capacity: usize) -> Self { + Self { + queue: VecDeque::with_capacity(capacity), + next_ack_pos: None, + } + } + + pub fn next_ack_pos(&self) -> Option { + self.next_ack_pos + } + + pub fn accept(&mut self, ticket: Ticket, ack_range: Range) { + if let Some(prev_pos) = self.next_ack_pos.replace(StreamPosition { + seq_num: ack_range.end.seq_num, + timestamp: ack_range.end.timestamp, + }) { + assert_eq!(ack_range.start.seq_num, prev_pos.seq_num); + assert!(ack_range.start.timestamp >= prev_pos.timestamp); + } + let sender = ticket.accept(ack_range); + if let Some(prev) = self.queue.back() { + assert!(prev.durability_dependency.end < sender.durability_dependency.end); + } + self.queue.push_back(sender); + } + + pub fn reject(&mut self, ticket: Ticket, err: AppendErrorInternal, stable_pos: StreamPosition) { + if let Some(sender) = ticket.reject(err, stable_pos) { + let dd = sender.durability_dependency; + let insert_pos = self + .queue + .partition_point(|x| x.durability_dependency.end <= dd.end); + self.queue.insert(insert_pos, sender); + } + } + + pub fn on_stable(&mut self, stable_pos: StreamPosition) { + let completable = self + .queue + .iter() + .take_while(|sender| sender.durability_dependency.end <= stable_pos.seq_num) + .count(); + for sender in self.queue.drain(..completable) { + sender.unblock(Ok(stable_pos)); + } + } + + pub fn on_durability_failed(self, err: slatedb::Error) { + let err = StorageError::from(err); + for sender in self.queue { + sender.unblock(Err(err.clone())); + } + } +} + +pub struct Ticket { + tx: oneshot::Sender>, + session: Option>, +} + +impl Ticket { + #[must_use] + fn accept(self, ack_range: Range) -> BlockedReplySender { + let durability_dependency = ..ack_range.end.seq_num; + if let Some(mut session) = self.session { + let session = session.deref_mut(); + assert!(!session.poisoned, "thanks to typestate"); + session.last_ack_end = durability_dependency; + } + BlockedReplySender { + reply: Ok(ack_range), + durability_dependency, + tx: self.tx, + } + } + + #[must_use] + fn reject( + self, + append_err: AppendErrorInternal, + stable_pos: StreamPosition, + ) -> Option { + let mut durability_dependency = + if let AppendErrorInternal::ConditionFailed(cond_fail) = &append_err { + cond_fail.durability_dependency() + } else { + ..0 + }; + if let Some(mut session) = self.session { + let session = session.deref_mut(); + assert!(!session.poisoned, "thanks to typestate"); + session.poisoned = true; + durability_dependency = ..durability_dependency.end.max(session.last_ack_end.end); + } + if durability_dependency.end <= stable_pos.seq_num { + let _ = self.tx.send(Err(append_err)); + None + } else { + Some(BlockedReplySender { + reply: Err(append_err), + durability_dependency, + tx: self.tx, + }) + } + } +} + +#[derive(Debug)] +struct BlockedReplySender { + reply: Result, AppendErrorInternal>, + durability_dependency: RangeTo, + tx: oneshot::Sender>, +} + +impl BlockedReplySender { + fn unblock(self, stable_pos: Result) { + let reply = match stable_pos { + Ok(tail) => { + assert!(self.durability_dependency.end <= tail.seq_num); + self.reply.map(|ack| AppendAck { + start: ack.start, + end: ack.end, + tail, + }) + } + Err(e) => Err(e.into()), + }; + let _ = self.tx.send(reply); + } +} diff --git a/lite/src/backend/basins.rs b/lite/src/backend/basins.rs new file mode 100644 index 00000000..336b045d --- /dev/null +++ b/lite/src/backend/basins.rs @@ -0,0 +1,213 @@ +use s2_common::{ + bash::Bash, + types::{ + basin::{BasinInfo, BasinName, BasinState, ListBasinsRequest}, + config::{BasinConfig, BasinReconfiguration}, + resources::{CreateMode, ListItemsRequestParts, Page, RequestToken}, + stream::StreamNameStartAfter, + }, +}; +use slatedb::{ + IsolationLevel, + config::{DurabilityLevel, ScanOptions, WriteOptions}, +}; +use time::OffsetDateTime; + +use super::{Backend, store::db_txn_get}; +use crate::backend::{ + error::{ + BasinAlreadyExistsError, BasinDeletionInProgressError, BasinNotFoundError, + CreateBasinError, DeleteBasinError, GetBasinConfigError, ListBasinsError, + ReconfigureBasinError, + }, + kv, +}; + +impl Backend { + pub async fn list_basins( + &self, + request: ListBasinsRequest, + ) -> Result, ListBasinsError> { + let ListItemsRequestParts { + prefix, + start_after, + limit, + } = request.into(); + + let key_range = kv::basin_meta::ser_key_range(&prefix, &start_after); + if key_range.is_empty() { + return Ok(Page::new_empty()); + } + + const SCAN_OPTS: ScanOptions = ScanOptions { + durability_filter: DurabilityLevel::Remote, + dirty: false, + read_ahead_bytes: 1, + cache_blocks: false, + max_fetch_tasks: 1, + }; + let mut it = self.db.scan_with_options(key_range, &SCAN_OPTS).await?; + + let mut basins = Vec::with_capacity(limit.as_usize()); + let mut has_more = false; + while let Some(kv) = it.next().await? { + let basin = kv::basin_meta::deser_key(kv.key)?; + assert!(basin.as_ref() > start_after.as_ref()); + assert!(basin.as_ref() >= prefix.as_ref()); + if basins.len() == limit.as_usize() { + has_more = true; + break; + } + let meta = kv::basin_meta::deser_value(kv.value)?; + let state = if meta.deleted_at.is_some() { + BasinState::Deleting + } else { + BasinState::Active + }; + basins.push(BasinInfo { + name: basin, + scope: None, + state, + }); + } + Ok(Page::new(basins, has_more)) + } + + pub async fn create_basin( + &self, + basin: BasinName, + config: BasinConfig, + mode: CreateMode, + ) -> Result { + let meta_key = kv::basin_meta::ser_key(&basin); + + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + + let creation_idempotency_key = match &mode { + CreateMode::CreateOnly(Some(req_token)) => { + Some(creation_idempotency_key(req_token, &config)) + } + _ => None, + }; + + let mut existing_created_at = None; + if let Some(existing_meta) = + db_txn_get(&txn, &meta_key, kv::basin_meta::deser_value).await? + { + if existing_meta.deleted_at.is_some() { + return Err(BasinDeletionInProgressError { basin }.into()); + } + match mode { + CreateMode::CreateOnly(_) => { + return if creation_idempotency_key.is_some() + && existing_meta.creation_idempotency_key == creation_idempotency_key + { + Ok(BasinInfo { + name: basin, + scope: None, + state: BasinState::Active, + }) + } else { + Err(BasinAlreadyExistsError { basin }.into()) + }; + } + CreateMode::CreateOrReconfigure => { + existing_created_at = Some(existing_meta.created_at); + } + } + } + + let created_at = existing_created_at.unwrap_or_else(OffsetDateTime::now_utc); + + let meta = kv::BasinMeta { + config, + created_at, + deleted_at: None, + creation_idempotency_key, + }; + + txn.put(&meta_key, kv::basin_meta::ser_value(&meta))?; + + static WRITE_OPTS: WriteOptions = WriteOptions { + await_durable: true, + }; + txn.commit_with_options(&WRITE_OPTS).await?; + + Ok(BasinInfo { + name: basin, + scope: None, + state: BasinState::Active, + }) + } + + pub async fn get_basin_config( + &self, + basin: BasinName, + ) -> Result { + let Some(meta) = self + .db_get(kv::basin_meta::ser_key(&basin), kv::basin_meta::deser_value) + .await? + else { + return Err(BasinNotFoundError { basin }.into()); + }; + Ok(meta.config) + } + + pub async fn reconfigure_basin( + &self, + basin: BasinName, + reconfig: BasinReconfiguration, + ) -> Result { + let meta_key = kv::basin_meta::ser_key(&basin); + + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + + let Some(mut meta) = db_txn_get(&txn, &meta_key, kv::basin_meta::deser_value).await? else { + return Err(BasinNotFoundError { basin }.into()); + }; + + if meta.deleted_at.is_some() { + return Err(BasinDeletionInProgressError { basin }.into()); + } + + meta.config = meta.config.reconfigure(reconfig); + + txn.put(&meta_key, kv::basin_meta::ser_value(&meta))?; + + static WRITE_OPTS: WriteOptions = WriteOptions { + await_durable: true, + }; + txn.commit_with_options(&WRITE_OPTS).await?; + + Ok(meta.config) + } + + pub async fn delete_basin(&self, basin: BasinName) -> Result<(), DeleteBasinError> { + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + let meta_key = kv::basin_meta::ser_key(&basin); + let Some(mut meta) = db_txn_get(&txn, &meta_key, kv::basin_meta::deser_value).await? else { + return Err(BasinNotFoundError { basin }.into()); + }; + if meta.deleted_at.is_none() { + meta.deleted_at = Some(OffsetDateTime::now_utc()); + txn.put(&meta_key, kv::basin_meta::ser_value(&meta))?; + txn.put( + kv::basin_deletion_pending::ser_key(&basin), + kv::basin_deletion_pending::ser_value(&StreamNameStartAfter::default()), + )?; + static WRITE_OPTS: WriteOptions = WriteOptions { + await_durable: true, + }; + txn.commit_with_options(&WRITE_OPTS).await?; + } + Ok(()) + } +} + +fn creation_idempotency_key(req_token: &RequestToken, config: &BasinConfig) -> Bash { + Bash::new(&[ + req_token.as_bytes(), + &serde_json::to_vec(&s2_api::v1::config::BasinConfig::from(config.clone())) + .expect("serializable"), + ]) +} diff --git a/lite/src/backend/core.rs b/lite/src/backend/core.rs new file mode 100644 index 00000000..d5a1e114 --- /dev/null +++ b/lite/src/backend/core.rs @@ -0,0 +1,334 @@ +use std::{sync::Arc, time::Duration}; + +use dashmap::DashMap; +use enum_ordinalize::Ordinalize; +use s2_common::{ + record::{SeqNum, StreamPosition}, + types::{ + basin::BasinName, + config::{BasinConfig, OptionalStreamConfig}, + resources::CreateMode, + stream::StreamName, + }, +}; +use slatedb::config::{DurabilityLevel, ScanOptions}; +use tokio::time::Instant; + +use super::{ + error::{ + BasinDeletionInProgressError, BasinNotFoundError, CreateStreamError, GetBasinConfigError, + StorageError, StreamDeletionInProgressError, StreamNotFoundError, StreamerError, + TransactionConflictError, UnavailableError, + }, + kv, + stream_id::StreamId, + streamer::{StreamerClient, StreamerClientState}, +}; + +pub const FOLLOWER_MAX_LAG: usize = 25; + +#[derive(Clone)] +pub struct Backend { + pub(super) db: slatedb::Db, + client_states: Arc>, +} + +impl Backend { + const FAILED_INIT_MEMORY: Duration = Duration::from_secs(1); + + pub fn new(db: slatedb::Db) -> Self { + Self { + db, + client_states: Arc::new(DashMap::new()), + } + } + + async fn start_streamer( + &self, + basin: BasinName, + stream: StreamName, + ) -> Result { + let stream_id = StreamId::new(&basin, &stream); + + let (meta, tail_pos, fencing_token, trim_point) = tokio::try_join!( + self.db_get( + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::deser_value, + ), + self.db_get( + kv::stream_tail_position::ser_key(stream_id), + kv::stream_tail_position::deser_value, + ), + self.db_get( + kv::stream_fencing_token::ser_key(stream_id), + kv::stream_fencing_token::deser_value, + ), + self.db_get( + kv::stream_trim_point::ser_key(stream_id), + kv::stream_trim_point::deser_value, + ) + )?; + + let Some(meta) = meta else { + return Err(StreamNotFoundError { basin, stream }.into()); + }; + + let tail_pos = tail_pos.unwrap_or(StreamPosition::MIN); + self.assert_no_records_following_tail(stream_id, &basin, &stream, tail_pos) + .await?; + + let fencing_token = fencing_token.unwrap_or_default(); + let trim_point = trim_point.unwrap_or(..SeqNum::MIN); + + if trim_point.end == SeqNum::MAX { + return Err(StreamDeletionInProgressError { basin, stream }.into()); + } + + let client_states = self.client_states.clone(); + Ok(super::streamer::spawn_streamer( + self.db.clone(), + stream_id, + meta.config, + tail_pos, + fencing_token, + trim_point, + client_states, + )) + } + + async fn assert_no_records_following_tail( + &self, + stream_id: StreamId, + basin: &BasinName, + stream: &StreamName, + tail_pos: StreamPosition, + ) -> Result<(), StorageError> { + let start_key = kv::stream_record_data::ser_key( + stream_id, + StreamPosition { + seq_num: tail_pos.seq_num, + timestamp: 0, + }, + ); + static SCAN_OPTS: ScanOptions = ScanOptions { + durability_filter: DurabilityLevel::Remote, + dirty: false, + read_ahead_bytes: 1, + cache_blocks: false, + max_fetch_tasks: 1, + }; + let mut it = self.db.scan_with_options(start_key.., &SCAN_OPTS).await?; + let Some(kv) = it.next().await? else { + return Ok(()); + }; + if kv.key.first().copied() != Some(kv::KeyType::StreamRecordData.ordinal()) { + return Ok(()); + } + let (deser_stream_id, pos) = kv::stream_record_data::deser_key(kv.key)?; + assert!( + deser_stream_id != stream_id, + "invariant violation: stream `{basin}/{stream}` tail_pos {tail_pos:?} but found record at {pos:?}" + ); + Ok(()) + } + + fn streamer_client_state(&self, basin: &BasinName, stream: &StreamName) -> StreamerClientState { + match self.client_states.entry(StreamId::new(basin, stream)) { + dashmap::Entry::Occupied(oe) => oe.get().clone(), + dashmap::Entry::Vacant(ve) => { + let this = self.clone(); + let stream_id = *(ve.key()); + let basin = basin.clone(); + let stream = stream.clone(); + tokio::spawn(async move { + let state = match this.start_streamer(basin, stream).await { + Ok(client) => StreamerClientState::Ready { client }, + Err(error) => StreamerClientState::InitError { + error: Box::new(error), + timestamp: Instant::now(), + }, + }; + let replaced_state = this.client_states.insert(stream_id, state); + let Some(StreamerClientState::Blocked { notify }) = replaced_state else { + panic!("expected Blocked client but replaced: {replaced_state:?}"); + }; + notify.notify_waiters(); + }); + ve.insert(StreamerClientState::Blocked { + notify: Default::default(), + }) + .value() + .clone() + } + } + } + + fn streamer_remove_unready(&self, stream_id: StreamId) { + if let dashmap::Entry::Occupied(oe) = self.client_states.entry(stream_id) + && let StreamerClientState::InitError { .. } = oe.get() + { + oe.remove(); + } + } + + pub(super) async fn streamer_client( + &self, + basin: &BasinName, + stream: &StreamName, + ) -> Result { + let mut waited = false; + loop { + match self.streamer_client_state(basin, stream) { + StreamerClientState::Blocked { notify } => { + notify.notified().await; + waited = true; + } + StreamerClientState::InitError { error, timestamp } => { + if !waited || timestamp.elapsed() > Self::FAILED_INIT_MEMORY { + self.streamer_remove_unready(StreamId::new(basin, stream)); + } else { + return Err(*error); + } + } + StreamerClientState::Ready { client } => { + return Ok(client); + } + } + } + } + + pub(super) fn streamer_client_if_active( + &self, + basin: &BasinName, + stream: &StreamName, + ) -> Option { + match self.streamer_client_state(basin, stream) { + StreamerClientState::Ready { client } => Some(client), + _ => None, + } + } + + pub(super) async fn streamer_client_with_auto_create( + &self, + basin: &BasinName, + stream: &StreamName, + should_auto_create: impl FnOnce(&BasinConfig) -> bool, + ) -> Result + where + E: From + + From + + From + + From + + From + + From + + From + + From, + { + match self.streamer_client(basin, stream).await { + Ok(client) => Ok(client), + Err(StreamerError::StreamNotFound(e)) => { + let config = match self.get_basin_config(basin.clone()).await { + Ok(config) => config, + Err(GetBasinConfigError::Storage(e)) => Err(e)?, + Err(GetBasinConfigError::BasinNotFound(e)) => Err(e)?, + }; + if should_auto_create(&config) { + if let Err(e) = self + .create_stream( + basin.clone(), + stream.clone(), + OptionalStreamConfig::default(), + CreateMode::CreateOnly(None), + ) + .await + { + match e { + CreateStreamError::Storage(e) => Err(e)?, + CreateStreamError::TransactionConflict(e) => Err(e)?, + CreateStreamError::Unavailable(e) => Err(e)?, + CreateStreamError::BasinDeletionInProgress(e) => Err(e)?, + CreateStreamError::StreamDeletionInProgress(e) => Err(e)?, + CreateStreamError::BasinNotFound(e) => Err(e)?, + CreateStreamError::StreamAlreadyExists(_) => {} + } + } + Ok(self.streamer_client(basin, stream).await?) + } else { + Err(e.into()) + } + } + Err(e) => Err(e.into()), + } + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr as _; + + use bytes::Bytes; + use s2_common::record::{Metered, Record, StreamPosition}; + use slatedb::{WriteBatch, config::WriteOptions, object_store}; + use time::OffsetDateTime; + + use super::*; + + #[tokio::test] + #[should_panic(expected = "invariant violation: stream `testbasin1/stream1` tail_pos")] + async fn start_streamer_fails_if_records_exist_after_tail_pos() { + let object_store: Arc = + Arc::new(object_store::memory::InMemory::new()); + let db = slatedb::Db::builder("test", object_store) + .build() + .await + .unwrap(); + + let backend = Backend::new(db.clone()); + + let basin = BasinName::from_str("testbasin1").unwrap(); + let stream = StreamName::from_str("stream1").unwrap(); + let stream_id = StreamId::new(&basin, &stream); + + let meta = kv::StreamMeta { + config: OptionalStreamConfig::default(), + created_at: OffsetDateTime::now_utc(), + deleted_at: None, + creation_idempotency_key: None, + }; + + let tail_pos = StreamPosition { + seq_num: 1, + timestamp: 123, + }; + let record_pos = StreamPosition { + seq_num: tail_pos.seq_num, + timestamp: tail_pos.timestamp, + }; + + let record = Record::try_from_parts(vec![], Bytes::from_static(b"hello")).unwrap(); + let metered_record: Metered = record.into(); + + let mut wb = WriteBatch::new(); + wb.put( + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::ser_value(&meta), + ); + wb.put( + kv::stream_tail_position::ser_key(stream_id), + kv::stream_tail_position::ser_value(tail_pos), + ); + wb.put( + kv::stream_record_data::ser_key(stream_id, record_pos), + kv::stream_record_data::ser_value(metered_record.as_ref()), + ); + static WRITE_OPTS: WriteOptions = WriteOptions { + await_durable: true, + }; + db.write_with_options(wb, &WRITE_OPTS).await.unwrap(); + + backend + .start_streamer(basin.clone(), stream.clone()) + .await + .unwrap(); + } +} diff --git a/lite/src/backend/error.rs b/lite/src/backend/error.rs new file mode 100644 index 00000000..b8f89e78 --- /dev/null +++ b/lite/src/backend/error.rs @@ -0,0 +1,424 @@ +use std::{ops::RangeTo, sync::Arc}; + +use s2_common::{ + record::{FencingToken, SeqNum, StreamPosition}, + types::{basin::BasinName, stream::StreamName}, +}; + +use crate::backend::kv; + +#[derive(Debug, Clone, thiserror::Error)] +pub enum StorageError { + #[error("deserialization: {0}")] + Deserialization(#[from] kv::DeserializationError), + #[error("database: {0}")] + Database(Arc), +} + +impl From for StorageError { + fn from(error: slatedb::Error) -> Self { + StorageError::Database(Arc::new(error)) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("basin `{basin}` not found")] +pub struct BasinNotFoundError { + pub basin: BasinName, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("stream `{stream}` in basin `{basin}` not found")] +pub struct StreamNotFoundError { + pub basin: BasinName, + pub stream: StreamName, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("basin `{basin}` already exists")] +pub struct BasinAlreadyExistsError { + pub basin: BasinName, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("stream `{stream}` in basin `{basin}` already exists")] +pub struct StreamAlreadyExistsError { + pub basin: BasinName, + pub stream: StreamName, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("basin `{basin}` is being deleted")] +pub struct BasinDeletionInProgressError { + pub basin: BasinName, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("stream `{stream}` in basin `{basin}` is being deleted")] +pub struct StreamDeletionInProgressError { + pub basin: BasinName, + pub stream: StreamName, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("tail position exceeded: {0}")] +pub struct TailExceededError(pub StreamPosition); + +#[derive(Debug, Clone, thiserror::Error)] +pub enum UnavailableError { + #[error("unavailable: missing in action")] + MissingInAction, + #[error("unavailable: request drop")] + RequestDrop, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("record timestamp was required but was missing")] +pub struct AppendTimestampRequiredError; + +#[derive(Debug, Clone, thiserror::Error)] +#[error("transaction conflict occurred – this is usually retriable")] +pub struct TransactionConflictError; + +#[derive(Debug, Clone, thiserror::Error)] +pub(super) enum StreamerError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), + #[error(transparent)] + StreamDeletionInProgress(#[from] StreamDeletionInProgressError), +} + +#[derive(Debug, Clone, thiserror::Error)] +pub(super) enum AppendErrorInternal { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + Unavailable(#[from] UnavailableError), + #[error(transparent)] + ConditionFailed(#[from] AppendConditionFailedError), + #[error(transparent)] + TimestampMissing(#[from] AppendTimestampRequiredError), +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum CheckTailError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + Unavailable(#[from] UnavailableError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), + #[error(transparent)] + BasinDeletionInProgress(#[from] BasinDeletionInProgressError), + #[error(transparent)] + StreamDeletionInProgress(#[from] StreamDeletionInProgressError), +} + +impl From for CheckTailError { + fn from(e: StreamerError) -> Self { + match e { + StreamerError::StreamNotFound(e) => Self::StreamNotFound(e), + StreamerError::Storage(e) => Self::Storage(e), + StreamerError::StreamDeletionInProgress(e) => Self::StreamDeletionInProgress(e), + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum AppendError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + Unavailable(#[from] UnavailableError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), + #[error(transparent)] + BasinDeletionInProgress(#[from] BasinDeletionInProgressError), + #[error(transparent)] + StreamDeletionInProgress(#[from] StreamDeletionInProgressError), + #[error(transparent)] + ConditionFailed(#[from] AppendConditionFailedError), + #[error(transparent)] + TimestampMissing(#[from] AppendTimestampRequiredError), +} + +impl From for AppendError { + fn from(e: AppendErrorInternal) -> Self { + match e { + AppendErrorInternal::Storage(e) => AppendError::Storage(e), + AppendErrorInternal::Unavailable(e) => AppendError::Unavailable(e), + AppendErrorInternal::ConditionFailed(e) => AppendError::ConditionFailed(e), + AppendErrorInternal::TimestampMissing(e) => AppendError::TimestampMissing(e), + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum AppendConditionFailedError { + #[error("fencing token mismatch: expected `{expected}`, actual `{actual}`")] + FencingTokenMismatch { + expected: FencingToken, + actual: FencingToken, + applied_point: RangeTo, + }, + #[error("sequence number mismatch: expected {match_seq_num}, actual {assigned_seq_num}")] + SeqNumMismatch { + assigned_seq_num: SeqNum, + match_seq_num: SeqNum, + }, +} + +impl AppendConditionFailedError { + pub fn durability_dependency(&self) -> RangeTo { + use AppendConditionFailedError::*; + match self { + SeqNumMismatch { + assigned_seq_num, .. + } => ..*assigned_seq_num, + FencingTokenMismatch { applied_point, .. } => *applied_point, + } + } +} + +impl From for AppendError { + fn from(e: StreamerError) -> Self { + match e { + StreamerError::StreamNotFound(e) => Self::StreamNotFound(e), + StreamerError::Storage(e) => Self::Storage(e), + StreamerError::StreamDeletionInProgress(e) => Self::StreamDeletionInProgress(e), + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ReadError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + Unavailable(#[from] UnavailableError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), + #[error(transparent)] + BasinDeletionInProgress(#[from] BasinDeletionInProgressError), + #[error(transparent)] + StreamDeletionInProgress(#[from] StreamDeletionInProgressError), + #[error(transparent)] + TailExceeded(#[from] TailExceededError), +} + +impl From for ReadError { + fn from(e: StreamerError) -> Self { + match e { + StreamerError::StreamNotFound(e) => Self::StreamNotFound(e), + StreamerError::Storage(e) => Self::Storage(e), + StreamerError::StreamDeletionInProgress(e) => Self::StreamDeletionInProgress(e), + } + } +} + +impl From for ReadError { + fn from(e: kv::DeserializationError) -> Self { + Self::Storage(e.into()) + } +} + +impl From for ReadError { + fn from(e: slatedb::Error) -> Self { + Self::Storage(e.into()) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ListStreamsError { + #[error(transparent)] + Storage(#[from] StorageError), +} + +impl From for ListStreamsError { + fn from(e: slatedb::Error) -> Self { + Self::Storage(e.into()) + } +} + +impl From for ListStreamsError { + fn from(e: kv::DeserializationError) -> Self { + Self::Storage(e.into()) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum CreateStreamError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + Unavailable(#[from] UnavailableError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), + #[error(transparent)] + BasinDeletionInProgress(#[from] BasinDeletionInProgressError), + #[error(transparent)] + StreamAlreadyExists(#[from] StreamAlreadyExistsError), + #[error(transparent)] + StreamDeletionInProgress(#[from] StreamDeletionInProgressError), +} + +impl From for CreateStreamError { + fn from(err: slatedb::Error) -> Self { + if err.kind() == slatedb::ErrorKind::Transaction { + Self::TransactionConflict(TransactionConflictError) + } else { + Self::Storage(err.into()) + } + } +} + +impl From for CreateStreamError { + fn from(err: GetBasinConfigError) -> Self { + match err { + GetBasinConfigError::Storage(e) => Self::Storage(e), + GetBasinConfigError::BasinNotFound(e) => Self::BasinNotFound(e), + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum GetStreamConfigError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum DeleteStreamError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + Unavailable(#[from] UnavailableError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), +} + +impl From for DeleteStreamError { + fn from(err: slatedb::Error) -> Self { + Self::Storage(err.into()) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ListBasinsError { + #[error(transparent)] + Storage(#[from] StorageError), +} + +impl From for ListBasinsError { + fn from(err: slatedb::Error) -> Self { + Self::Storage(err.into()) + } +} + +impl From for ListBasinsError { + fn from(e: kv::DeserializationError) -> Self { + Self::Storage(e.into()) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum CreateBasinError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + BasinAlreadyExists(#[from] BasinAlreadyExistsError), + #[error(transparent)] + BasinDeletionInProgress(#[from] BasinDeletionInProgressError), +} + +impl From for CreateBasinError { + fn from(err: slatedb::Error) -> Self { + Self::Storage(err.into()) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum GetBasinConfigError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ReconfigureBasinError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), + #[error(transparent)] + BasinDeletionInProgress(#[from] BasinDeletionInProgressError), +} + +impl From for ReconfigureBasinError { + fn from(err: slatedb::Error) -> Self { + if err.kind() == slatedb::ErrorKind::Transaction { + Self::TransactionConflict(TransactionConflictError) + } else { + Self::Storage(err.into()) + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ReconfigureStreamError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + Unavailable(#[from] UnavailableError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), + #[error(transparent)] + StreamDeletionInProgress(#[from] StreamDeletionInProgressError), +} + +impl From for ReconfigureStreamError { + fn from(err: slatedb::Error) -> Self { + if err.kind() == slatedb::ErrorKind::Transaction { + Self::TransactionConflict(TransactionConflictError) + } else { + Self::Storage(err.into()) + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum DeleteBasinError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), +} + +impl From for DeleteBasinError { + fn from(err: slatedb::Error) -> Self { + Self::Storage(err.into()) + } +} diff --git a/lite/src/backend/kv/basin_deletion_pending.rs b/lite/src/backend/kv/basin_deletion_pending.rs new file mode 100644 index 00000000..f22dc367 --- /dev/null +++ b/lite/src/backend/kv/basin_deletion_pending.rs @@ -0,0 +1,70 @@ +use std::str::FromStr; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use enum_ordinalize::Ordinalize; +use s2_common::{ + caps::MIN_BASIN_NAME_LEN, + types::{basin::BasinName, stream::StreamNameStartAfter}, +}; + +use super::{DeserializationError, KeyType, check_min_size, invalid_value_err}; + +pub fn ser_key(basin: &BasinName) -> Bytes { + let basin_bytes = basin.as_bytes(); + let capacity = 1 + basin_bytes.len(); + let mut buf = BytesMut::with_capacity(capacity); + buf.put_u8(KeyType::BasinDeletionPending.ordinal()); + buf.put_slice(basin_bytes); + debug_assert_eq!(buf.len(), capacity, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_key(mut bytes: Bytes) -> Result { + check_min_size(&bytes, 1 + MIN_BASIN_NAME_LEN)?; + let ordinal = bytes.get_u8(); + if ordinal != KeyType::BasinDeletionPending.ordinal() { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let basin_str = std::str::from_utf8(&bytes).map_err(|e| invalid_value_err("basin", e))?; + BasinName::from_str(basin_str).map_err(|e| invalid_value_err("basin", e)) +} + +pub fn ser_value(cursor: &StreamNameStartAfter) -> Bytes { + let cursor_bytes = cursor.as_bytes(); + let capacity = cursor_bytes.len(); + let mut buf = BytesMut::with_capacity(capacity); + buf.put_slice(cursor_bytes); + debug_assert_eq!(buf.len(), capacity, "serialized length mismatch"); + buf.freeze() +} + +#[allow(unused)] // TODO: will be used as cursor by basin deletion bgtask +pub fn deser_value(bytes: Bytes) -> Result { + let cursor_str = std::str::from_utf8(&bytes).map_err(|e| invalid_value_err("cursor", e))?; + StreamNameStartAfter::from_str(cursor_str).map_err(|e| invalid_value_err("cursor", e)) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use s2_common::types::stream::StreamNameStartAfter; + + use crate::backend::kv::proptest_strategies::{basin_name_strategy, stream_name_strategy}; + + proptest! { + #[test] + fn roundtrip_basin_deletion_pending_key(basin in basin_name_strategy()) { + let bytes = super::ser_key(&basin); + let decoded = super::deser_key(bytes).unwrap(); + prop_assert_eq!(basin.as_ref(), decoded.as_ref()); + } + + #[test] + fn roundtrip_basin_deletion_pending_value(stream in stream_name_strategy(),) { + let cursor = StreamNameStartAfter::from(stream.clone()); + let bytes = super::ser_value(&cursor); + let decoded = super::deser_value(bytes).unwrap(); + prop_assert_eq!(cursor.as_ref(), decoded.as_ref()); + } + } +} diff --git a/lite/src/backend/kv/basin_meta.rs b/lite/src/backend/kv/basin_meta.rs new file mode 100644 index 00000000..26373e0a --- /dev/null +++ b/lite/src/backend/kv/basin_meta.rs @@ -0,0 +1,400 @@ +use std::{ops::Range, str::FromStr}; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use enum_ordinalize::Ordinalize; +use s2_common::{ + bash::Bash, + caps::MIN_BASIN_NAME_LEN, + types::{ + basin::{BasinName, BasinNamePrefix, BasinNameStartAfter}, + config::BasinConfig, + }, +}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +use super::{ + DeserializationError, KeyType, check_min_size, deser_json_value, increment_bytes, + invalid_value_err, ser_json_value, +}; + +#[derive(Debug, Clone)] +pub struct BasinMeta { + pub config: BasinConfig, + pub created_at: OffsetDateTime, + pub deleted_at: Option, + pub creation_idempotency_key: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct BasinMetaSerde { + config: Option, + #[serde(with = "time::serde::rfc3339")] + created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339::option")] + deleted_at: Option, + creation_idempotency_key: Option, +} + +impl From for BasinMetaSerde { + fn from(meta: BasinMeta) -> Self { + Self { + config: Some(meta.config.into()), + created_at: meta.created_at, + deleted_at: meta.deleted_at, + creation_idempotency_key: meta.creation_idempotency_key, + } + } +} + +impl TryFrom for BasinMeta { + type Error = s2_common::types::ValidationError; + + fn try_from(serde: BasinMetaSerde) -> Result { + let config = match serde.config { + Some(api_config) => api_config.try_into()?, + None => BasinConfig::default(), + }; + + Ok(Self { + config, + created_at: serde.created_at, + deleted_at: serde.deleted_at, + creation_idempotency_key: serde.creation_idempotency_key, + }) + } +} + +pub fn ser_key_prefix(prefix: &BasinNamePrefix) -> Bytes { + ser_key_internal(prefix.as_bytes()).freeze() +} + +pub fn ser_key_prefix_end(prefix: &BasinNamePrefix) -> Bytes { + increment_bytes(ser_key_internal(prefix.as_bytes())).expect("non-empty") +} + +pub fn ser_key_start_after(start_after: &BasinNameStartAfter) -> Bytes { + let start_after_bytes = start_after.as_bytes(); + let mut bytes = Vec::with_capacity(start_after_bytes.len() + 1); + bytes.extend_from_slice(start_after_bytes); + bytes.push(b'\0'); + ser_key_internal(&bytes).freeze() +} + +pub fn ser_key_range(prefix: &BasinNamePrefix, start_after: &BasinNameStartAfter) -> Range { + let start = if !start_after.is_empty() { + ser_key_start_after(start_after) + } else { + ser_key_prefix(prefix) + }; + let end = ser_key_prefix_end(prefix); + start..end +} + +pub fn ser_key(basin: &BasinName) -> Bytes { + ser_key_internal(basin.as_bytes()).freeze() +} + +fn ser_key_internal(basin: &[u8]) -> BytesMut { + let capacity = 1 + basin.len(); + let mut buf = BytesMut::with_capacity(capacity); + buf.put_u8(KeyType::BasinMeta.ordinal()); + buf.put_slice(basin); + debug_assert_eq!(buf.len(), capacity, "serialized length mismatch"); + buf +} + +pub fn deser_key(mut bytes: Bytes) -> Result { + check_min_size(&bytes, 1 + MIN_BASIN_NAME_LEN)?; + let ordinal = bytes.get_u8(); + if ordinal != KeyType::BasinMeta.ordinal() { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let basin_str = std::str::from_utf8(&bytes).map_err(|e| invalid_value_err("basin", e))?; + BasinName::from_str(basin_str).map_err(|e| invalid_value_err("basin", e)) +} + +pub fn ser_value(basin_meta: &BasinMeta) -> Bytes { + ser_json_value::(basin_meta, "BasinMeta") +} + +pub fn deser_value(bytes: Bytes) -> Result { + deser_json_value::(bytes, "basin_meta") +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use bytes::Bytes; + use enum_ordinalize::Ordinalize; + use proptest::prelude::*; + use s2_common::{ + bash::Bash, + types::{ + basin::{BasinName, BasinNamePrefix, BasinNameStartAfter}, + config::BasinConfig, + }, + }; + use time::OffsetDateTime; + + use crate::backend::kv::{ + DeserializationError, KeyType, proptest_strategies::basin_name_strategy, + }; + + fn basin(name: &str) -> BasinName { + BasinName::from_str(name).unwrap() + } + + fn basin_prefix(prefix: &str) -> BasinNamePrefix { + BasinNamePrefix::from_str(prefix).unwrap() + } + + fn basin_start_after(name: &str) -> BasinNameStartAfter { + BasinNameStartAfter::from_str(name).unwrap() + } + + #[test] + fn basin_meta_ser_key_prefix() { + let prefix = basin_prefix("test-prefix"); + let key = super::ser_key_prefix(&prefix); + + assert_eq!(key[0], KeyType::BasinMeta.ordinal()); + assert_eq!(&key[1..], b"test-prefix"); + } + + #[test] + fn basin_meta_ser_key_prefix_empty() { + let prefix = BasinNamePrefix::default(); + let key = super::ser_key_prefix(&prefix); + + assert_eq!(key.len(), 1); + assert_eq!(key[0], KeyType::BasinMeta.ordinal()); + } + + #[test] + fn basin_meta_ser_key_prefix_end_empty() { + let prefix = BasinNamePrefix::default(); + let end_key = super::ser_key_prefix_end(&prefix); + + assert_eq!(end_key.len(), 1); + assert_eq!(end_key[0], KeyType::BasinMeta.ordinal() + 1); + } + + #[test] + fn basin_meta_ser_key_prefix_end_advances() { + for (input, expected_suffix) in [("test-a", &b"test-b"[..]), ("test-abc", &b"test-abd"[..])] + { + let end_key = super::ser_key_prefix_end(&basin_prefix(input)); + assert_eq!(end_key[0], KeyType::BasinMeta.ordinal()); + assert_eq!(&end_key[1..], expected_suffix); + } + } + + #[test] + fn basin_meta_ser_key_start_after() { + let key = super::ser_key_start_after(&basin_start_after("my-basin")); + + assert_eq!(key[0], KeyType::BasinMeta.ordinal()); + assert_eq!(&key[1..key.len() - 1], b"my-basin"); + assert_eq!( + key[key.len() - 1], + b'\0', + "should end with null byte for exclusion" + ); + } + + #[test] + fn basin_meta_key_range_handles_pagination() { + let prefix = basin_prefix("test-"); + let basin1 = basin("test-aaa"); + let basin2 = basin("test-bbb"); + let basin3 = basin("test-ccc"); + let outside = basin("staging-service1"); + + let page1 = super::ser_key_range(&prefix, &BasinNameStartAfter::default()); + assert_eq!(page1.start, super::ser_key_prefix(&prefix)); + assert_eq!(page1.end, super::ser_key_prefix_end(&prefix)); + + let key1 = super::ser_key(&basin1); + let key2 = super::ser_key(&basin2); + let key3 = super::ser_key(&basin3); + let key_outside = super::ser_key(&outside); + + assert!(key1 >= page1.start && key1 < page1.end); + assert!(key2 >= page1.start && key2 < page1.end); + assert!(key3 >= page1.start && key3 < page1.end); + assert!(key_outside < page1.start || key_outside >= page1.end); + + let start_after = BasinNameStartAfter::from(basin1.clone()); + let cursor_key = super::ser_key_start_after(&start_after); + assert!(cursor_key > key1); + assert!(cursor_key < key2); + + let page2 = super::ser_key_range(&prefix, &start_after); + assert_eq!(page2.start, super::ser_key_start_after(&start_after)); + assert_eq!(page2.end, super::ser_key_prefix_end(&prefix)); + + assert!(key1 < page2.start); + assert!(key2 >= page2.start && key2 < page2.end); + assert!(key3 >= page2.start && key3 < page2.end); + } + + #[test] + fn value_roundtrip_basin_meta() { + let config = BasinConfig { + create_stream_on_append: true, + ..Default::default() + }; + let created_at = OffsetDateTime::from_unix_timestamp(1234567890) + .unwrap() + .replace_nanosecond(123456789) + .unwrap(); + let deleted_at = Some( + OffsetDateTime::from_unix_timestamp(1234567890) + .unwrap() + .replace_nanosecond(123456789) + .unwrap(), + ); + let basin_meta = super::BasinMeta { + config: config.clone(), + created_at, + deleted_at, + creation_idempotency_key: Some(Bash::new(&[b"test-basin", b"request-token-123"])), + }; + + let bytes = super::ser_value(&basin_meta); + let decoded = super::deser_value(bytes).unwrap(); + + assert_eq!( + basin_meta.config.create_stream_on_append, + decoded.config.create_stream_on_append + ); + assert_eq!( + basin_meta.config.create_stream_on_read, + decoded.config.create_stream_on_read + ); + assert_eq!(basin_meta.created_at, decoded.created_at); + assert_eq!(basin_meta.deleted_at, decoded.deleted_at); + } + + #[test] + fn basin_meta_deser_defaults_config_missing() { + let serde_value = super::BasinMetaSerde { + config: None, + created_at: OffsetDateTime::from_unix_timestamp(1_234_567).unwrap(), + deleted_at: None, + creation_idempotency_key: Some(Bash::new(&[b"my-basin", b"req-789"])), + }; + let bytes = Bytes::from(serde_json::to_vec(&serde_value).unwrap()); + let decoded = super::deser_value(bytes).unwrap(); + let default_config = BasinConfig::default(); + + assert_eq!( + decoded.config.create_stream_on_append, + default_config.create_stream_on_append + ); + assert_eq!( + decoded.config.create_stream_on_read, + default_config.create_stream_on_read + ); + assert_eq!( + decoded.config.default_stream_config.storage_class, + default_config.default_stream_config.storage_class + ); + assert_eq!( + decoded.config.default_stream_config.retention_policy, + default_config.default_stream_config.retention_policy + ); + assert_eq!( + decoded.config.default_stream_config.timestamping.mode, + default_config.default_stream_config.timestamping.mode + ); + assert_eq!( + decoded.config.default_stream_config.timestamping.uncapped, + default_config.default_stream_config.timestamping.uncapped + ); + assert_eq!( + decoded.config.default_stream_config.delete_on_empty.min_age, + default_config.default_stream_config.delete_on_empty.min_age + ); + assert_eq!(decoded.created_at, serde_value.created_at); + assert_eq!(decoded.deleted_at, serde_value.deleted_at); + } + + #[test] + fn basin_meta_deser_invalid_json() { + let err = super::deser_value(Bytes::from_static(b"{")).unwrap_err(); + assert!(matches!(err, DeserializationError::JsonDeserialization(_))); + } + + fn basin_name_prefix_strategy() -> impl Strategy { + prop_oneof![ + Just(BasinNamePrefix::default()), + "[a-z][a-z0-9-]{0,46}".prop_map(|s| BasinNamePrefix::from_str(&s).unwrap()), + ] + } + + proptest! { + #[test] + fn roundtrip_basin_meta_key(basin in basin_name_strategy()) { + let bytes = super::ser_key(&basin); + let decoded = super::deser_key(bytes).unwrap(); + prop_assert_eq!(basin.as_ref(), decoded.as_ref()); + } + + #[test] + fn basin_meta_range_contains_prefixed_keys( + prefix in basin_name_prefix_strategy(), + basin in basin_name_strategy(), + ) { + let prefix_str = prefix.as_ref(); + let basin_str = basin.as_ref(); + let matches_prefix = prefix_str.is_empty() || basin_str.starts_with(prefix_str); + + let range = super::ser_key_range(&prefix, &BasinNameStartAfter::default()); + let key = super::ser_key(&basin); + + if matches_prefix { + prop_assert!(key >= range.start, "key {:?} should be >= range.start {:?}", key, range.start); + prop_assert!(key < range.end, "key {:?} should be < range.end {:?}", key, range.end); + } else { + prop_assert!(key < range.start || key >= range.end); + } + } + + #[test] + fn basin_meta_keys_preserve_ordering( + basin1 in basin_name_strategy(), + basin2 in basin_name_strategy(), + ) { + let key1 = super::ser_key(&basin1); + let key2 = super::ser_key(&basin2); + + let basin_cmp = basin1.as_ref().cmp(basin2.as_ref()); + let key_cmp = key1.cmp(&key2); + + prop_assert_eq!(basin_cmp, key_cmp, "ordering should be preserved"); + } + + #[test] + fn basin_meta_start_after_excludes_cursor( + prefix in basin_name_prefix_strategy(), + basin1 in basin_name_strategy(), + basin2 in basin_name_strategy(), + ) { + if basin1.as_ref() >= basin2.as_ref() { + return Ok(()); + } + + let start_after = BasinNameStartAfter::from(basin1.clone()); + let range = super::ser_key_range(&prefix, &start_after); + + let key1 = super::ser_key(&basin1); + let key2 = super::ser_key(&basin2); + + prop_assert!(key1 < range.start, "cursor basin should be excluded (before range.start)"); + prop_assert!(key2 >= range.start, "later basin should be included (at or after range.start)"); + } + } +} diff --git a/lite/src/backend/kv/mod.rs b/lite/src/backend/kv/mod.rs new file mode 100644 index 00000000..fb347b45 --- /dev/null +++ b/lite/src/backend/kv/mod.rs @@ -0,0 +1,256 @@ +pub mod basin_deletion_pending; +pub mod basin_meta; +pub mod stream_fencing_token; +pub mod stream_meta; +pub mod stream_record_data; +pub mod stream_record_timestamp; +pub mod stream_tail_position; +pub mod stream_trim_point; + +pub use basin_meta::BasinMeta; +use bytes::{Buf, Bytes, BytesMut}; +use enum_ordinalize::Ordinalize; +use s2_common::{ + record::{SeqNum, StreamPosition, Timestamp}, + types::{basin::BasinName, stream::StreamName}, +}; +pub use stream_meta::StreamMeta; +use thiserror::Error; + +use crate::backend::stream_id::StreamId; + +#[derive(Debug, Clone, Error)] +pub enum DeserializationError { + #[error("invalid ordinal: {0}")] + InvalidOrdinal(u8), + #[error("invalid size: expected {expected} bytes, got {actual}")] + InvalidSize { expected: usize, actual: usize }, + #[error("invalid value '{name}': {error}")] + InvalidValue { name: &'static str, error: String }, + #[error("missing field separator")] + MissingFieldSeparator, + #[error("json serialization error: {0}")] + JsonSerialization(String), + #[error("json deserialization error: {0}")] + JsonDeserialization(String), +} + +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ordinalize)] +pub enum KeyType { + BasinMeta = 1, + StreamMeta = 2, + StreamTailPosition = 3, + StreamFencingToken = 4, + StreamTrimPoint = 5, + StreamRecordData = 6, + StreamRecordTimestamp = 7, + BasinDeletionPending = 8, +} + +#[derive(Debug, Clone)] +pub enum Key { + /// (BM) per-basin, updatable + /// Key: BasinName + /// Value: BasinMeta + BasinMeta(BasinName), + /// (SM) per-stream, updatable + /// Key: BasinName \0 StreamName + /// Value: StreamMeta + StreamMeta(BasinName, StreamName), + /// (SP) per-stream, updatable + /// Key: StreamID + /// Value: SeqNum Timestamp + /// TODO: unnecessary once sl8 supports reverse scans + StreamTailPosition(StreamId), + /// (SFT) per-stream, updatable, optional, default empty + /// Key: StreamID + /// Value: FencingToken + StreamFencingToken(StreamId), + /// (STP) per-stream, updatable, optional, default 0, only present while trim pending + /// Key: StreamID + /// Value: SeqNum + StreamTrimPoint(StreamId), + /// (SRD) per-record, immutable + /// Key: StreamID StreamPosition + /// Value: EnvelopedRecord + StreamRecordData(StreamId, StreamPosition), + /// (SRT) per-record, immutable + /// Key: StreamID Timestamp SeqNum + /// Value: empty + StreamRecordTimestamp(StreamId, Timestamp, SeqNum), + /// (BDP) per-basin, deletable, only present while basin deletion pending + /// Key: BasinName + /// Value: StreamNameStartAfter (cursor for resumable deletion) + BasinDeletionPending(BasinName), +} + +impl From for Bytes { + fn from(value: Key) -> Self { + match value { + Key::BasinMeta(basin) => basin_meta::ser_key(&basin), + Key::BasinDeletionPending(basin) => basin_deletion_pending::ser_key(&basin), + Key::StreamMeta(basin, stream) => stream_meta::ser_key(&basin, &stream), + Key::StreamTailPosition(stream_id) => stream_tail_position::ser_key(stream_id), + Key::StreamFencingToken(stream_id) => stream_fencing_token::ser_key(stream_id), + Key::StreamTrimPoint(stream_id) => stream_trim_point::ser_key(stream_id), + Key::StreamRecordData(stream_id, pos) => stream_record_data::ser_key(stream_id, pos), + Key::StreamRecordTimestamp(stream_id, timestamp, seq_num) => { + stream_record_timestamp::ser_key(stream_id, timestamp, seq_num) + } + } + } +} + +impl TryFrom for Key { + type Error = DeserializationError; + + fn try_from(bytes: Bytes) -> Result { + check_min_size(&bytes, 1)?; + let ordinal = KeyType::from_ordinal(bytes[0]) + .ok_or_else(|| DeserializationError::InvalidOrdinal(bytes[0]))?; + match ordinal { + KeyType::BasinMeta => basin_meta::deser_key(bytes).map(Key::BasinMeta), + KeyType::BasinDeletionPending => { + basin_deletion_pending::deser_key(bytes).map(Key::BasinDeletionPending) + } + KeyType::StreamMeta => { + stream_meta::deser_key(bytes).map(|(basin, stream)| Key::StreamMeta(basin, stream)) + } + KeyType::StreamTailPosition => { + stream_tail_position::deser_key(bytes).map(Key::StreamTailPosition) + } + KeyType::StreamFencingToken => { + stream_fencing_token::deser_key(bytes).map(Key::StreamFencingToken) + } + KeyType::StreamTrimPoint => { + stream_trim_point::deser_key(bytes).map(Key::StreamTrimPoint) + } + KeyType::StreamRecordData => stream_record_data::deser_key(bytes) + .map(|(stream_id, pos)| Key::StreamRecordData(stream_id, pos)), + KeyType::StreamRecordTimestamp => { + stream_record_timestamp::deser_key(bytes).map(|(stream_id, timestamp, seq_num)| { + Key::StreamRecordTimestamp(stream_id, timestamp, seq_num) + }) + } + } + } +} + +fn check_exact_size(bytes: &Bytes, expected: usize) -> Result<(), DeserializationError> { + if bytes.remaining() != expected { + return Err(DeserializationError::InvalidSize { + expected, + actual: bytes.remaining(), + }); + } + Ok(()) +} + +fn check_min_size(bytes: &Bytes, min: usize) -> Result<(), DeserializationError> { + if bytes.remaining() < min { + return Err(DeserializationError::InvalidSize { + expected: min, + actual: bytes.remaining(), + }); + } + Ok(()) +} + +fn increment_bytes(mut buf: BytesMut) -> Option { + for i in (0..buf.len()).rev() { + if buf[i] < 0xFF { + buf[i] += 1; + buf.truncate(i + 1); + return Some(buf.freeze()); + } + } + None +} + +fn invalid_value_err(name: &'static str, e: E) -> DeserializationError { + DeserializationError::InvalidValue { + name, + error: e.to_string(), + } +} + +fn ser_json_value(value: &T, type_name: &str) -> Bytes +where + T: Clone + Into, + S: serde::Serialize, +{ + let serde_value: S = value.clone().into(); + serde_json::to_vec(&serde_value) + .unwrap_or_else(|_| panic!("failed to serialize {}", type_name)) + .into() +} + +fn deser_json_value(bytes: Bytes, name: &'static str) -> Result +where + S: serde::de::DeserializeOwned, + T: TryFrom, + T::Error: std::fmt::Display, +{ + let serde_value: S = serde_json::from_slice(&bytes) + .map_err(|e| DeserializationError::JsonDeserialization(e.to_string()))?; + T::try_from(serde_value).map_err(|e| invalid_value_err(name, e)) +} + +#[cfg(test)] +pub(crate) mod proptest_strategies { + use std::str::FromStr; + + use proptest::prelude::*; + use s2_common::types::{basin::BasinName, stream::StreamName}; + + pub(crate) fn basin_name_strategy() -> impl Strategy { + "[a-z][a-z0-9-]{6,46}[a-z0-9]".prop_map(|s| BasinName::from_str(&s).unwrap()) + } + + pub(crate) fn stream_name_strategy() -> impl Strategy { + "[a-zA-Z0-9_-]{1,100}".prop_map(|s| StreamName::from_str(&s).unwrap()) + } +} + +#[cfg(test)] +mod tests { + use bytes::{BufMut, Bytes, BytesMut}; + use enum_ordinalize::Ordinalize; + + use super::{DeserializationError, Key, KeyType}; + + #[test] + fn error_on_invalid_ordinal() { + let bytes = Bytes::from(vec![255u8]); + let result = Key::try_from(bytes); + assert!(matches!( + result, + Err(DeserializationError::InvalidOrdinal(255)) + )); + } + + #[test] + fn error_on_insufficient_data() { + let bytes = Bytes::from(vec![KeyType::StreamTailPosition.ordinal(), 1, 2, 3]); + let result = Key::try_from(bytes); + assert!(matches!( + result, + Err(DeserializationError::InvalidSize { .. }) + )); + } + + #[test] + fn error_on_missing_separator() { + let mut buf = BytesMut::new(); + buf.put_u8(KeyType::StreamMeta.ordinal()); + buf.put_slice(b"basin-without-separator"); + let bytes = buf.freeze(); + + let result = Key::try_from(bytes); + assert!(matches!( + result, + Err(DeserializationError::MissingFieldSeparator) + )); + } +} diff --git a/lite/src/backend/kv/stream_fencing_token.rs b/lite/src/backend/kv/stream_fencing_token.rs new file mode 100644 index 00000000..68c78515 --- /dev/null +++ b/lite/src/backend/kv/stream_fencing_token.rs @@ -0,0 +1,85 @@ +use std::str::FromStr; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use enum_ordinalize::Ordinalize; +use s2_common::record::FencingToken; + +use super::{DeserializationError, KeyType, check_exact_size, invalid_value_err}; +use crate::backend::stream_id::StreamId; + +const KEY_LEN: usize = 1 + StreamId::LEN; + +pub fn ser_key(stream_id: StreamId) -> Bytes { + let mut buf = BytesMut::with_capacity(KEY_LEN); + buf.put_u8(KeyType::StreamFencingToken.ordinal()); + buf.put_slice(stream_id.as_bytes()); + debug_assert_eq!(buf.len(), KEY_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_key(mut bytes: Bytes) -> Result { + check_exact_size(&bytes, KEY_LEN)?; + let ordinal = bytes.get_u8(); + if ordinal != KeyType::StreamFencingToken.ordinal() { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let mut stream_id_bytes = [0u8; StreamId::LEN]; + bytes.copy_to_slice(&mut stream_id_bytes); + Ok(stream_id_bytes.into()) +} + +pub fn ser_value(token: &FencingToken) -> Bytes { + let token_bytes = token.as_bytes(); + let capacity = token_bytes.len(); + let mut buf = BytesMut::with_capacity(capacity); + buf.put_slice(token_bytes); + debug_assert_eq!(buf.len(), capacity, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_value(bytes: Bytes) -> Result { + let token_str = + std::str::from_utf8(&bytes).map_err(|e| invalid_value_err("fencing_token", e))?; + FencingToken::from_str(token_str).map_err(|e| invalid_value_err("fencing_token", e)) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use bytes::Bytes; + use proptest::prelude::*; + use s2_common::record::FencingToken; + + use crate::backend::{kv::DeserializationError, stream_id::StreamId}; + + #[test] + fn stream_fencing_token_rejects_invalid_utf8() { + let err = super::deser_value(Bytes::from_static(&[0xFF])).unwrap_err(); + assert!(matches!( + err, + DeserializationError::InvalidValue { + name: "fencing_token", + .. + } + )); + } + + proptest! { + #[test] + fn roundtrip_stream_fencing_token_key(stream_id_bytes in any::<[u8; StreamId::LEN]>()) { + let stream_id = StreamId::from(stream_id_bytes); + let bytes = super::ser_key(stream_id); + let decoded = super::deser_key(bytes).unwrap(); + prop_assert_eq!(stream_id, decoded); + } + + #[test] + fn roundtrip_stream_fencing_token_value(token_str in "[a-zA-Z0-9_-]{0,36}") { + let token = FencingToken::from_str(&token_str).unwrap(); + let bytes = super::ser_value(&token); + let decoded = super::deser_value(bytes).unwrap(); + prop_assert_eq!(token.as_ref(), decoded.as_ref()); + } + } +} diff --git a/lite/src/backend/kv/stream_meta.rs b/lite/src/backend/kv/stream_meta.rs new file mode 100644 index 00000000..454353be --- /dev/null +++ b/lite/src/backend/kv/stream_meta.rs @@ -0,0 +1,310 @@ +use std::{ops::Range, str::FromStr}; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use enum_ordinalize::Ordinalize; +use s2_common::{ + bash::Bash, + caps::{MIN_BASIN_NAME_LEN, MIN_STREAM_NAME_LEN}, + types::{ + basin::BasinName, + config::OptionalStreamConfig, + stream::{StreamName, StreamNamePrefix, StreamNameStartAfter}, + }, +}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +use super::{ + DeserializationError, KeyType, check_min_size, deser_json_value, increment_bytes, + invalid_value_err, ser_json_value, +}; + +const FIELD_SEPARATOR: u8 = b'\0'; + +#[derive(Debug, Clone)] +pub struct StreamMeta { + pub config: OptionalStreamConfig, + pub created_at: OffsetDateTime, + pub deleted_at: Option, + pub creation_idempotency_key: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct StreamMetaSerde { + config: Option, + #[serde(with = "time::serde::rfc3339")] + created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339::option")] + deleted_at: Option, + creation_idempotency_key: Option, +} + +impl From for StreamMetaSerde { + fn from(meta: StreamMeta) -> Self { + Self { + config: s2_api::v1::config::StreamConfig::to_opt(meta.config), + created_at: meta.created_at, + deleted_at: meta.deleted_at, + creation_idempotency_key: meta.creation_idempotency_key, + } + } +} + +impl TryFrom for StreamMeta { + type Error = s2_common::types::ValidationError; + + fn try_from(serde: StreamMetaSerde) -> Result { + let config = match serde.config { + Some(api_config) => api_config.try_into()?, + None => OptionalStreamConfig::default(), + }; + + Ok(Self { + config, + created_at: serde.created_at, + deleted_at: serde.deleted_at, + creation_idempotency_key: serde.creation_idempotency_key, + }) + } +} + +pub fn ser_key_prefix(basin: &BasinName, prefix: &StreamNamePrefix) -> Bytes { + ser_key_internal(basin.as_bytes(), prefix.as_bytes()).freeze() +} + +pub fn ser_key_prefix_end(basin: &BasinName, prefix: &StreamNamePrefix) -> Bytes { + increment_bytes(ser_key_internal(basin.as_bytes(), prefix.as_bytes())).expect("non-empty") +} + +pub fn ser_key_start_after(basin: &BasinName, start_after: &StreamNameStartAfter) -> Bytes { + let start_after_bytes = start_after.as_bytes(); + let mut bytes = Vec::with_capacity(start_after_bytes.len() + 1); + bytes.extend_from_slice(start_after_bytes); + bytes.push(FIELD_SEPARATOR); + ser_key_internal(basin.as_bytes(), &bytes).freeze() +} + +pub fn ser_key_range( + basin: &BasinName, + prefix: &StreamNamePrefix, + start_after: &StreamNameStartAfter, +) -> Range { + let start = if !start_after.is_empty() { + ser_key_start_after(basin, start_after) + } else { + ser_key_prefix(basin, prefix) + }; + let end = ser_key_prefix_end(basin, prefix); + start..end +} + +pub fn ser_key(basin: &BasinName, stream: &StreamName) -> Bytes { + ser_key_internal(basin.as_bytes(), stream.as_bytes()).freeze() +} + +fn ser_key_internal(basin_bytes: &[u8], stream_bytes: &[u8]) -> BytesMut { + let capacity = 1 + basin_bytes.len() + 1 + stream_bytes.len(); + let mut buf = BytesMut::with_capacity(capacity); + buf.put_u8(KeyType::StreamMeta.ordinal()); + buf.put_slice(basin_bytes); + buf.put_u8(FIELD_SEPARATOR); + buf.put_slice(stream_bytes); + debug_assert_eq!(buf.len(), capacity, "serialized length mismatch"); + buf +} + +pub fn deser_key(mut bytes: Bytes) -> Result<(BasinName, StreamName), DeserializationError> { + check_min_size(&bytes, 1 + MIN_BASIN_NAME_LEN + 1 + MIN_STREAM_NAME_LEN)?; + let ordinal = bytes.get_u8(); + if ordinal != KeyType::StreamMeta.ordinal() { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let sep_pos = bytes + .iter() + .position(|&b| b == FIELD_SEPARATOR) + .ok_or(DeserializationError::MissingFieldSeparator)?; + + let basin_str = + std::str::from_utf8(&bytes[..sep_pos]).map_err(|e| invalid_value_err("basin", e))?; + let stream_str = + std::str::from_utf8(&bytes[sep_pos + 1..]).map_err(|e| invalid_value_err("stream", e))?; + + let basin = BasinName::from_str(basin_str).map_err(|e| invalid_value_err("basin", e))?; + let stream = StreamName::from_str(stream_str).map_err(|e| invalid_value_err("stream", e))?; + + Ok((basin, stream)) +} + +pub fn ser_value(stream_meta: &StreamMeta) -> Bytes { + ser_json_value::(stream_meta, "StreamMeta") +} + +pub fn deser_value(bytes: Bytes) -> Result { + deser_json_value::(bytes, "stream_meta") +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use bytes::Bytes; + use proptest::prelude::*; + use s2_common::{ + bash::Bash, + types::{ + config::{OptionalStreamConfig, StorageClass}, + stream::{StreamNamePrefix, StreamNameStartAfter}, + }, + }; + use time::OffsetDateTime; + + use crate::backend::kv::proptest_strategies::{basin_name_strategy, stream_name_strategy}; + + #[test] + fn value_roundtrip_stream_meta() { + let config = OptionalStreamConfig { + storage_class: Some(StorageClass::Express), + ..Default::default() + }; + let created_at = OffsetDateTime::from_unix_timestamp(1234567890) + .unwrap() + .replace_nanosecond(123456789) + .unwrap(); + let deleted_at = Some( + OffsetDateTime::from_unix_timestamp(1234567890) + .unwrap() + .replace_nanosecond(123456789) + .unwrap(), + ); + let stream_meta = super::StreamMeta { + config: config.clone(), + created_at, + deleted_at, + creation_idempotency_key: Some(Bash::new(&[ + b"test-basin", + b"test-stream", + b"request-token-456", + ])), + }; + + let bytes = super::ser_value(&stream_meta); + let decoded = super::deser_value(bytes).unwrap(); + + assert_eq!( + stream_meta.config.storage_class, + decoded.config.storage_class + ); + assert_eq!(stream_meta.created_at, decoded.created_at); + assert_eq!(stream_meta.deleted_at, decoded.deleted_at); + } + + #[test] + fn stream_meta_deser_defaults_config_missing() { + let serde_value = super::StreamMetaSerde { + config: None, + created_at: OffsetDateTime::from_unix_timestamp(2_345_678).unwrap(), + deleted_at: None, + creation_idempotency_key: Some(Bash::new(&[b"my-basin", b"my-stream", b"req-abc"])), + }; + let bytes = Bytes::from(serde_json::to_vec(&serde_value).unwrap()); + let decoded = super::deser_value(bytes).unwrap(); + let default_config = OptionalStreamConfig::default(); + + assert_eq!(decoded.config.storage_class, default_config.storage_class); + assert_eq!( + decoded.config.retention_policy, + default_config.retention_policy + ); + assert_eq!( + decoded.config.timestamping.mode, + default_config.timestamping.mode + ); + assert_eq!( + decoded.config.timestamping.uncapped, + default_config.timestamping.uncapped + ); + assert_eq!( + decoded.config.delete_on_empty.min_age, + default_config.delete_on_empty.min_age + ); + assert_eq!(decoded.created_at, serde_value.created_at); + assert_eq!(decoded.deleted_at, serde_value.deleted_at); + } + + fn stream_name_prefix_strategy() -> impl Strategy { + prop_oneof![ + Just(StreamNamePrefix::default()), + "[a-zA-Z0-9_-]{0,100}".prop_map(|s| StreamNamePrefix::from_str(&s).unwrap()), + ] + } + + proptest! { + #[test] + fn roundtrip_stream_meta_key( + basin in basin_name_strategy(), + stream in stream_name_strategy(), + ) { + let bytes = super::ser_key(&basin, &stream); + let (decoded_basin, decoded_stream) = super::deser_key(bytes).unwrap(); + prop_assert_eq!(basin.as_ref(), decoded_basin.as_ref()); + prop_assert_eq!(stream.as_ref(), decoded_stream.as_ref()); + } + + #[test] + fn stream_meta_range_contains_prefixed_keys( + basin in basin_name_strategy(), + prefix in stream_name_prefix_strategy(), + stream in stream_name_strategy(), + ) { + let prefix_str = prefix.as_ref(); + let stream_str = stream.as_ref(); + let matches_prefix = prefix_str.is_empty() || stream_str.starts_with(prefix_str); + + let range = super::ser_key_range(&basin, &prefix, &StreamNameStartAfter::default()); + let key = super::ser_key(&basin, &stream); + + if matches_prefix { + prop_assert!(key >= range.start, "key {:?} should be >= range.start {:?}", key, range.start); + prop_assert!(key < range.end, "key {:?} should be < range.end {:?}", key, range.end); + } else { + prop_assert!(key < range.start || key >= range.end); + } + } + + #[test] + fn stream_meta_keys_preserve_ordering( + basin in basin_name_strategy(), + stream1 in stream_name_strategy(), + stream2 in stream_name_strategy(), + ) { + let key1 = super::ser_key(&basin, &stream1); + let key2 = super::ser_key(&basin, &stream2); + + let stream_cmp = stream1.as_ref().cmp(stream2.as_ref()); + let key_cmp = key1.cmp(&key2); + + prop_assert_eq!(stream_cmp, key_cmp, "ordering should be preserved"); + } + + #[test] + fn stream_meta_start_after_excludes_cursor( + basin in basin_name_strategy(), + prefix in stream_name_prefix_strategy(), + stream1 in stream_name_strategy(), + stream2 in stream_name_strategy(), + ) { + if stream1.as_ref() >= stream2.as_ref() { + return Ok(()); + } + + let start_after = StreamNameStartAfter::from(stream1.clone()); + let range = super::ser_key_range(&basin, &prefix, &start_after); + + let key1 = super::ser_key(&basin, &stream1); + let key2 = super::ser_key(&basin, &stream2); + + prop_assert!(key1 < range.start, "cursor stream should be excluded (before range.start)"); + prop_assert!(key2 >= range.start, "later stream should be included (at or after range.start)"); + } + } +} diff --git a/lite/src/backend/kv/stream_record_data.rs b/lite/src/backend/kv/stream_record_data.rs new file mode 100644 index 00000000..96881ae9 --- /dev/null +++ b/lite/src/backend/kv/stream_record_data.rs @@ -0,0 +1,106 @@ +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use enum_ordinalize::Ordinalize; +use s2_common::record::{Encodable, Metered, Record, StreamPosition}; + +use super::{DeserializationError, KeyType, check_exact_size, invalid_value_err}; +use crate::backend::stream_id::StreamId; + +const KEY_LEN: usize = 1 + StreamId::LEN + 8 + 8; + +pub fn ser_key(stream_id: StreamId, pos: StreamPosition) -> Bytes { + let mut buf = BytesMut::with_capacity(KEY_LEN); + buf.put_u8(KeyType::StreamRecordData.ordinal()); + buf.put_slice(stream_id.as_bytes()); + buf.put_u64(pos.seq_num); + buf.put_u64(pos.timestamp); + debug_assert_eq!(buf.len(), KEY_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_key(mut bytes: Bytes) -> Result<(StreamId, StreamPosition), DeserializationError> { + check_exact_size(&bytes, KEY_LEN)?; + let ordinal = bytes.get_u8(); + if ordinal != KeyType::StreamRecordData.ordinal() { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let mut stream_id_bytes = [0u8; StreamId::LEN]; + bytes.copy_to_slice(&mut stream_id_bytes); + let seq_num = bytes.get_u64(); + let timestamp = bytes.get_u64(); + Ok(( + stream_id_bytes.into(), + StreamPosition { seq_num, timestamp }, + )) +} + +pub fn ser_value(record: Metered<&Record>) -> Bytes { + record.to_bytes() +} + +pub fn deser_value(bytes: Bytes) -> Result, DeserializationError> { + Metered::try_from(bytes).map_err(|e| invalid_value_err("record", e)) +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use proptest::prelude::*; + use s2_common::record::{SeqNum, StreamPosition, Timestamp}; + + use crate::backend::{kv::DeserializationError, stream_id::StreamId}; + + #[test] + fn stream_record_data_rejects_invalid_payload() { + let err = super::deser_value(Bytes::from_static(&[0x00])).unwrap_err(); + assert!(matches!( + err, + DeserializationError::InvalidValue { name: "record", .. } + )); + } + + proptest! { + #[test] + fn roundtrip_stream_record_data_key( + stream_id_bytes in any::<[u8; StreamId::LEN]>(), + seq_num in any::(), + timestamp in any::(), + ) { + let stream_id = StreamId::from(stream_id_bytes); + let pos = StreamPosition { seq_num, timestamp }; + let key_bytes = super::ser_key(stream_id, pos); + let (decoded_stream_id, decoded_pos) = super::deser_key(key_bytes).unwrap(); + prop_assert_eq!(stream_id, decoded_stream_id); + prop_assert_eq!(pos, decoded_pos); + } + + #[test] + fn roundtrip_stream_record_data_value( + header_name in prop::collection::vec(any::(), 1..20), + header_value in prop::collection::vec(any::(), 0..50), + body in prop::collection::vec(any::(), 0..200), + ) { + use s2_common::record::{Header, MeteredSize, Record}; + + let header_name = Bytes::from(header_name); + let header_value = Bytes::from(header_value); + let headers = vec![Header { + name: header_name.clone(), + value: header_value.clone(), + }]; + let body = Bytes::from(body); + let expected_headers = headers.clone(); + let expected_body = body.clone(); + let record = Record::try_from_parts(headers.clone(), body).unwrap(); + let metered_record: s2_common::record::Metered = record.into(); + let original_size = metered_record.metered_size(); + + let bytes = super::ser_value(metered_record.as_ref()); + let decoded = super::deser_value(bytes).unwrap(); + + prop_assert_eq!(original_size, decoded.metered_size()); + let (decoded_headers, decoded_body) = decoded.into_inner().into_parts(); + prop_assert_eq!(decoded_headers, expected_headers); + prop_assert_eq!(decoded_body, expected_body); + } + } +} diff --git a/lite/src/backend/kv/stream_record_timestamp.rs b/lite/src/backend/kv/stream_record_timestamp.rs new file mode 100644 index 00000000..eba6582f --- /dev/null +++ b/lite/src/backend/kv/stream_record_timestamp.rs @@ -0,0 +1,71 @@ +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use enum_ordinalize::Ordinalize; +use s2_common::record::{SeqNum, Timestamp}; + +use super::{DeserializationError, KeyType, check_exact_size}; +use crate::backend::stream_id::StreamId; + +const KEY_LEN: usize = 1 + StreamId::LEN + 8 + 8; + +pub fn ser_key(stream_id: StreamId, timestamp: Timestamp, seq_num: SeqNum) -> Bytes { + let mut buf = BytesMut::with_capacity(KEY_LEN); + buf.put_u8(KeyType::StreamRecordTimestamp.ordinal()); + buf.put_slice(stream_id.as_bytes()); + buf.put_u64(timestamp); + buf.put_u64(seq_num); + debug_assert_eq!(buf.len(), KEY_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_key(mut bytes: Bytes) -> Result<(StreamId, Timestamp, SeqNum), DeserializationError> { + check_exact_size(&bytes, KEY_LEN)?; + let ordinal = bytes.get_u8(); + if ordinal != KeyType::StreamRecordTimestamp.ordinal() { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let mut stream_id_bytes = [0u8; StreamId::LEN]; + bytes.copy_to_slice(&mut stream_id_bytes); + let timestamp = bytes.get_u64(); + let seq_num = bytes.get_u64(); + Ok((stream_id_bytes.into(), timestamp, seq_num)) +} + +pub fn ser_value() -> Bytes { + Bytes::new() +} + +pub fn deser_value(bytes: Bytes) -> Result<(), DeserializationError> { + check_exact_size(&bytes, 0)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use s2_common::record::{SeqNum, Timestamp}; + + use crate::backend::stream_id::StreamId; + + #[test] + fn roundtrip_stream_record_timestamp_value() { + let bytes = super::ser_value(); + super::deser_value(bytes).unwrap(); + } + + proptest! { + #[test] + fn roundtrip_stream_record_timestamp_key( + stream_id_bytes in any::<[u8; StreamId::LEN]>(), + timestamp in any::(), + seq_num in any::(), + ) { + let stream_id = StreamId::from(stream_id_bytes); + let key_bytes = super::ser_key(stream_id, timestamp, seq_num); + let (decoded_stream_id, decoded_timestamp, decoded_seq_num) = + super::deser_key(key_bytes).unwrap(); + prop_assert_eq!(stream_id, decoded_stream_id); + prop_assert_eq!(timestamp, decoded_timestamp); + prop_assert_eq!(seq_num, decoded_seq_num); + } + } +} diff --git a/lite/src/backend/kv/stream_tail_position.rs b/lite/src/backend/kv/stream_tail_position.rs new file mode 100644 index 00000000..b56b6d44 --- /dev/null +++ b/lite/src/backend/kv/stream_tail_position.rs @@ -0,0 +1,82 @@ +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use enum_ordinalize::Ordinalize; +use s2_common::record::StreamPosition; + +use super::{DeserializationError, KeyType, check_exact_size}; +use crate::backend::stream_id::StreamId; + +const KEY_LEN: usize = 1 + StreamId::LEN; +const VALUE_LEN: usize = 8 + 8; + +pub fn ser_key(stream_id: StreamId) -> Bytes { + let mut buf = BytesMut::with_capacity(KEY_LEN); + buf.put_u8(KeyType::StreamTailPosition.ordinal()); + buf.put_slice(stream_id.as_bytes()); + debug_assert_eq!(buf.len(), KEY_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_key(mut bytes: Bytes) -> Result { + check_exact_size(&bytes, KEY_LEN)?; + let ordinal = bytes.get_u8(); + if ordinal != KeyType::StreamTailPosition.ordinal() { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let mut stream_id_bytes = [0u8; StreamId::LEN]; + bytes.copy_to_slice(&mut stream_id_bytes); + Ok(stream_id_bytes.into()) +} + +pub fn ser_value(pos: StreamPosition) -> Bytes { + let mut buf = BytesMut::with_capacity(VALUE_LEN); + buf.put_u64(pos.seq_num); + buf.put_u64(pos.timestamp); + debug_assert_eq!(buf.len(), VALUE_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_value(mut bytes: Bytes) -> Result { + check_exact_size(&bytes, VALUE_LEN)?; + let seq_num = bytes.get_u64(); + let timestamp = bytes.get_u64(); + Ok(StreamPosition { seq_num, timestamp }) +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use proptest::prelude::*; + use s2_common::record::{SeqNum, Timestamp}; + + use crate::backend::{kv::DeserializationError, stream_id::StreamId}; + + #[test] + fn stream_tail_position_value_requires_exact_size() { + let err = super::deser_value(Bytes::from_static(&[0u8; 15])).unwrap_err(); + assert!(matches!( + err, + DeserializationError::InvalidSize { + expected: super::VALUE_LEN, + .. + } + )); + } + + proptest! { + #[test] + fn roundtrip_stream_tail_position_key(stream_id_bytes in any::<[u8; StreamId::LEN]>()) { + let stream_id = StreamId::from(stream_id_bytes); + let bytes = super::ser_key(stream_id); + let decoded = super::deser_key(bytes).unwrap(); + prop_assert_eq!(stream_id, decoded); + } + + #[test] + fn roundtrip_stream_tail_position_value(seq_num in any::(), timestamp in any::()) { + let pos = s2_common::record::StreamPosition { seq_num, timestamp }; + let bytes = super::ser_value(pos); + let decoded_pos = super::deser_value(bytes).unwrap(); + prop_assert_eq!(pos, decoded_pos); + } + } +} diff --git a/lite/src/backend/kv/stream_trim_point.rs b/lite/src/backend/kv/stream_trim_point.rs new file mode 100644 index 00000000..5b030753 --- /dev/null +++ b/lite/src/backend/kv/stream_trim_point.rs @@ -0,0 +1,67 @@ +use std::ops::RangeTo; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use enum_ordinalize::Ordinalize; +use s2_common::record::SeqNum; + +use super::{DeserializationError, KeyType, check_exact_size}; +use crate::backend::stream_id::StreamId; + +const KEY_LEN: usize = 1 + StreamId::LEN; +const VALUE_LEN: usize = 8; + +pub fn ser_key(stream_id: StreamId) -> Bytes { + let mut buf = BytesMut::with_capacity(KEY_LEN); + buf.put_u8(KeyType::StreamTrimPoint.ordinal()); + buf.put_slice(stream_id.as_bytes()); + debug_assert_eq!(buf.len(), KEY_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_key(mut bytes: Bytes) -> Result { + check_exact_size(&bytes, KEY_LEN)?; + let ordinal = bytes.get_u8(); + if ordinal != KeyType::StreamTrimPoint.ordinal() { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let mut stream_id_bytes = [0u8; StreamId::LEN]; + bytes.copy_to_slice(&mut stream_id_bytes); + Ok(stream_id_bytes.into()) +} + +pub fn ser_value(trim_point: RangeTo) -> Bytes { + let mut buf = BytesMut::with_capacity(VALUE_LEN); + buf.put_u64(trim_point.end); + debug_assert_eq!(buf.len(), VALUE_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_value(mut bytes: Bytes) -> Result, DeserializationError> { + check_exact_size(&bytes, VALUE_LEN)?; + Ok(..bytes.get_u64()) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use s2_common::record::SeqNum; + + use crate::backend::stream_id::StreamId; + + proptest! { + #[test] + fn roundtrip_stream_trim_point_key(stream_id_bytes in any::<[u8; StreamId::LEN]>()) { + let stream_id = StreamId::from(stream_id_bytes); + let bytes = super::ser_key(stream_id); + let decoded = super::deser_key(bytes).unwrap(); + prop_assert_eq!(stream_id, decoded); + } + + #[test] + fn roundtrip_stream_trim_point_value(seq_num in any::()) { + let bytes = super::ser_value(..seq_num); + let decoded = super::deser_value(bytes).unwrap(); + prop_assert_eq!(..seq_num, decoded); + } + } +} diff --git a/lite/src/backend/mod.rs b/lite/src/backend/mod.rs index 96f40b91..41e89dbf 100644 --- a/lite/src/backend/mod.rs +++ b/lite/src/backend/mod.rs @@ -1,4 +1,14 @@ -pub(crate) mod ops; -pub mod slatedb; +pub mod error; +mod basins; +mod core; +mod read; +mod store; +mod streamer; +mod streams; + +mod append; +mod kv; mod stream_id; + +pub use core::{Backend, FOLLOWER_MAX_LAG}; diff --git a/lite/src/backend/ops.rs b/lite/src/backend/ops.rs deleted file mode 100644 index 2ede46fc..00000000 --- a/lite/src/backend/ops.rs +++ /dev/null @@ -1,150 +0,0 @@ -use bytes::Bytes; -use futures::stream::BoxStream; -use s2_common::{ - record::{FencingToken, SeqNum, StreamPosition}, - types::{ - basin::{BasinInfo, BasinName, BasinScope, ListBasinsRequest}, - config::{BasinConfig, BasinReconfiguration, OptionalStreamConfig, StreamReconfiguration}, - resources::{CreateMode, Page}, - stream::{ - AppendAck, AppendInput, ListStreamsRequest, ReadBatch, ReadEnd, ReadStart, StreamInfo, - StreamName, - }, - }, -}; - -// TODO Create/Reconfigure/Delete Ok() variants -// TODO spec out all the errors properly - -#[derive(Debug, Clone, thiserror::Error)] -pub enum CheckTailError { - #[error("Stream not found")] - StreamNotFound, -} - -#[derive(Debug, Clone, thiserror::Error)] -pub enum AppendError { - #[error("Stream not found")] - StreamNotFound, - #[error("Sequence number mismatch: expected {expected}, actual {actual}")] - SeqNumMismatch { expected: SeqNum, actual: SeqNum }, - #[error("Fencing token mismatch: expected {expected}, actual {actual}")] - FencingTokenMismatch { - expected: FencingToken, - actual: FencingToken, - }, -} - -#[derive(Debug, Clone, thiserror::Error)] -pub enum ReadError { - #[error("Stream not found")] - StreamNotFound, -} - -pub trait DataOps { - async fn check_tail( - &self, - basin: BasinName, - stream: StreamName, - ) -> Result; - - async fn read( - &self, - basin: BasinName, - stream: StreamName, - start: ReadStart, - end: ReadEnd, - ) -> Result>, ReadError>; - - async fn append( - &self, - basin: BasinName, - stream: StreamName, - input: AppendInput, - ) -> Result; - - async fn append_session( - &self, - basin: BasinName, - stream: StreamName, - requests: BoxStream<'static, AppendInput>, - ) -> Result>, AppendError>; -} - -#[derive(Debug, Clone, thiserror::Error)] -pub enum ListStreamsError {} - -#[derive(Debug, Clone, thiserror::Error)] -pub enum CreateStreamError {} - -#[derive(Debug, Clone, thiserror::Error)] -pub enum ReconfigureStreamError {} - -#[derive(Debug, Clone, thiserror::Error)] -pub enum DeleteStreamError {} - -pub trait StreamOps { - async fn list_streams( - &self, - basin: BasinName, - request: ListStreamsRequest, - ) -> Result, ListStreamsError>; - - async fn create_stream( - &self, - basin: BasinName, - stream: StreamName, - config: OptionalStreamConfig, - mode: CreateMode, - idempotence_key: Option, - ) -> Result<(), CreateStreamError>; - - async fn reconfigure_stream( - &self, - basin: BasinName, - stream: StreamName, - config: StreamReconfiguration, - ) -> Result<(), ReconfigureStreamError>; - - async fn delete_stream( - &self, - basin: BasinName, - stream: StreamName, - ) -> Result<(), DeleteStreamError>; -} - -#[derive(Debug, Clone, thiserror::Error)] -pub enum ListBasinsError {} - -#[derive(Debug, Clone, thiserror::Error)] -pub enum CreateBasinError {} - -#[derive(Debug, Clone, thiserror::Error)] -pub enum ReconfigureBasinError {} - -#[derive(Debug, Clone, thiserror::Error)] -pub enum DeleteBasinError {} - -pub trait BasinOps { - async fn list_basins( - &self, - request: ListBasinsRequest, - ) -> Result, ListBasinsError>; - - async fn create_basin( - &self, - basin: BasinName, - scope: BasinScope, - config: BasinConfig, - mode: CreateMode, - idempotence_key: Option, - ) -> Result<(), CreateBasinError>; - - async fn reconfigure_basin( - &self, - basin: BasinName, - config: BasinReconfiguration, - ) -> Result<(), ReconfigureBasinError>; - - async fn delete_basin(&self, basin: BasinName) -> Result<(), DeleteBasinError>; -} diff --git a/lite/src/backend/read.rs b/lite/src/backend/read.rs new file mode 100644 index 00000000..66789bfb --- /dev/null +++ b/lite/src/backend/read.rs @@ -0,0 +1,284 @@ +use std::time::Duration; + +use futures::Stream; +use s2_common::{ + caps, + read_extent::{EvaluatedReadLimit, ReadLimit, ReadUntil}, + record::{Metered, MeteredSize as _, SeqNum, SequencedRecord, StreamPosition}, + types::{ + basin::BasinName, + stream::{ReadBatch, ReadEnd, ReadPosition, ReadSessionOutput, ReadStart, StreamName}, + }, +}; +use slatedb::config::{DurabilityLevel, ScanOptions}; +use tokio::sync::broadcast; + +use super::Backend; +use crate::backend::{ + error::{CheckTailError, ReadError, TailExceededError, UnavailableError}, + kv, + stream_id::StreamId, +}; + +impl Backend { + async fn read_start_seq_num( + &self, + stream_id: StreamId, + start: ReadStart, + tail: StreamPosition, + ) -> Result { + let mut read_pos = match start.from { + s2_common::types::stream::ReadFrom::SeqNum(seq_num) => ReadPosition::SeqNum(seq_num), + s2_common::types::stream::ReadFrom::Timestamp(timestamp) => { + ReadPosition::Timestamp(timestamp) + } + s2_common::types::stream::ReadFrom::TailOffset(tail_offset) => { + ReadPosition::SeqNum(tail.seq_num.saturating_sub(tail_offset)) + } + }; + if match read_pos { + ReadPosition::SeqNum(start_seq_num) => start_seq_num > tail.seq_num, + ReadPosition::Timestamp(start_timestamp) => start_timestamp > tail.timestamp, + } { + if start.clamp { + read_pos = ReadPosition::SeqNum(tail.seq_num); + } else { + return Err(TailExceededError(tail).into()); + } + } + Ok(match read_pos { + ReadPosition::SeqNum(start_seq_num) => start_seq_num, + ReadPosition::Timestamp(start_timestamp) => { + self.resolve_timestamp(stream_id, start_timestamp) + .await? + .unwrap_or(tail) + .seq_num + } + }) + } + + pub async fn check_tail( + &self, + basin: BasinName, + stream: StreamName, + ) -> Result { + let client = self + .streamer_client_with_auto_create::(&basin, &stream, |config| { + config.create_stream_on_read + }) + .await?; + let tail = client.check_tail().await?; + Ok(tail) + } + + pub async fn read( + &self, + basin: BasinName, + stream: StreamName, + start: ReadStart, + end: ReadEnd, + ) -> Result> + 'static, ReadError> { + let client = self + .streamer_client_with_auto_create::(&basin, &stream, |config| { + config.create_stream_on_read + }) + .await?; + let stream_id = client.stream_id(); + let tail = client.check_tail().await?; + let mut state = ReadSessionState { + start_seq_num: self.read_start_seq_num(stream_id, start, tail).await?, + limit: EvaluatedReadLimit::Remaining(end.limit), + until: end.until, + tail, + }; + let db = self.db.clone(); + let session = async_stream::try_stream! { + 'session: while let EvaluatedReadLimit::Remaining(limit) = state.limit { + if state.start_seq_num < state.tail.seq_num { + let start_key = kv::stream_record_data::ser_key( + stream_id, + StreamPosition { + seq_num: state.start_seq_num, + timestamp: 0, + }, + ); + let end_key = kv::stream_record_data::ser_key( + stream_id, + StreamPosition { + seq_num: state.tail.seq_num, + timestamp: 0, + }, + ); + static SCAN_OPTS: ScanOptions = ScanOptions { + durability_filter: DurabilityLevel::Remote, + dirty: false, + read_ahead_bytes: 1024 * 1024, + cache_blocks: true, + max_fetch_tasks: 8, + }; + let mut it = db + .scan_with_options(start_key..end_key, &SCAN_OPTS) + .await?; + + let mut records = Metered::with_capacity( + limit.count() + .unwrap_or(usize::MAX) + .min(caps::RECORD_BATCH_MAX.count), + ); + + while let EvaluatedReadLimit::Remaining(limit) = state.limit { + let Some(kv) = it.next().await? else { + break; + }; + let (deser_stream_id, pos) = kv::stream_record_data::deser_key(kv.key)?; + assert_eq!(deser_stream_id, stream_id); + + let record = kv::stream_record_data::deser_value(kv.value)?.sequenced(pos); + + if end.until.deny(pos.timestamp) + || limit.deny(records.len() + 1, records.metered_size() + record.metered_size()) { + if records.is_empty() { + break 'session; + } else { + break; + } + } + + if records.len() == caps::RECORD_BATCH_MAX.count + || records.metered_size() + record.metered_size() > caps::RECORD_BATCH_MAX.bytes + { + let new_records_buf = Metered::with_capacity( + limit.count() + .map_or(usize::MAX, |n| n.saturating_sub(records.len())) + .min(caps::RECORD_BATCH_MAX.count), + ); + yield state.on_batch(ReadBatch { + records: std::mem::replace(&mut records, new_records_buf), + tail: None, + }); + } + + records.push(record); + } + + if !records.is_empty() { + yield state.on_batch(ReadBatch { + records, + tail: None, + }); + } + } else { + assert_eq!(state.start_seq_num, state.tail.seq_num); + if !end.may_follow() { + break; + } + match client.follow(state.start_seq_num).await? { + Ok(mut follow_rx) => { + yield ReadSessionOutput::Heartbeat(state.tail); + while let EvaluatedReadLimit::Remaining(limit) = state.limit { + tokio::select! { + biased; + msg = follow_rx.recv() => { + match msg { + Ok(mut records) => { + let count = records.len(); + let tail = super::streamer::next_pos(&records); + let allowed_count = count_allowed_records(limit, end.until, &records); + if allowed_count > 0 { + yield state.on_batch(ReadBatch { + records: records.drain(..allowed_count).collect(), + tail: Some(tail), + }); + } + if allowed_count < count { + break 'session; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => { + // Catch up using DB + continue 'session; + } + Err(broadcast::error::RecvError::Closed) => { + break; + } + } + } + _ = new_heartbeat_sleep() => { + yield ReadSessionOutput::Heartbeat(state.tail); + } + _ = wait_sleep(end.wait) => { + break 'session; + } + } + } + Err(UnavailableError::MissingInAction)?; + } + Err(tail) => { + assert!(state.tail.seq_num < tail.seq_num, "tail cannot regress"); + state.tail = tail; + } + } + } + } + }; + Ok(session) + } +} + +struct ReadSessionState { + start_seq_num: u64, + limit: EvaluatedReadLimit, + until: ReadUntil, + tail: StreamPosition, +} + +impl ReadSessionState { + fn on_batch(&mut self, batch: ReadBatch) -> ReadSessionOutput { + if let Some(tail) = batch.tail { + self.tail = tail; + } + let last_record = batch.records.last().expect("non-empty"); + let EvaluatedReadLimit::Remaining(limit) = self.limit else { + panic!("batch after exhausted limit"); + }; + let count = batch.records.len(); + let bytes = batch.records.metered_size(); + assert!(limit.allow(count, bytes)); + assert!(self.until.allow(last_record.position.timestamp)); + self.start_seq_num = last_record.position.seq_num + 1; + self.limit = limit.remaining(count, bytes); + ReadSessionOutput::Batch(batch) + } +} + +fn count_allowed_records( + limit: ReadLimit, + until: ReadUntil, + records: &[Metered], +) -> usize { + let mut acc_size = 0; + let mut acc_count = 0; + for record in records { + if limit.deny(acc_count + 1, acc_size + record.metered_size()) + || until.deny(record.position.timestamp) + { + break; + } + acc_count += 1; + acc_size += record.metered_size(); + } + acc_count +} + +fn new_heartbeat_sleep() -> tokio::time::Sleep { + tokio::time::sleep(Duration::from_millis(rand::random_range(5_000..15_000))) +} + +async fn wait_sleep(wait: Option) { + match wait { + Some(wait) => tokio::time::sleep(wait).await, + None => { + std::future::pending::<()>().await; + } + } +} diff --git a/lite/src/backend/slatedb.rs b/lite/src/backend/slatedb.rs deleted file mode 100644 index 61a509b8..00000000 --- a/lite/src/backend/slatedb.rs +++ /dev/null @@ -1,81 +0,0 @@ -use std::sync::Arc; - -use dashmap::DashMap; -use futures::stream::BoxStream; -use s2_common::{ - record::StreamPosition, - types::{ - basin::BasinName, - stream::{AppendAck, AppendInput, ReadBatch, ReadEnd, ReadStart, StreamName}, - }, -}; -use tokio::sync::{broadcast, mpsc}; - -use super::ops::{CheckTailError, DataOps}; -use crate::backend::{ - ops::{AppendError, ReadError}, - stream_id::StreamId, -}; - -struct StreamTailState { - tail: StreamPosition, - follower_tx: broadcast::Sender, -} - -struct StreamState { - tail_state: Option, -} - -impl StreamState {} - -pub struct SlateDbBackend { - db: slatedb::Db, - streams: DashMap, -} - -impl SlateDbBackend { - pub fn new(db: slatedb::Db) -> Self { - Self { - db, - streams: DashMap::default(), - } - } -} - -impl DataOps for SlateDbBackend { - async fn check_tail( - &self, - basin: BasinName, - stream: StreamName, - ) -> Result { - todo!() - } - - async fn read( - &self, - basin: BasinName, - stream: StreamName, - start: ReadStart, - end: ReadEnd, - ) -> Result>, ReadError> { - todo!() - } - - async fn append( - &self, - basin: BasinName, - stream: StreamName, - input: AppendInput, - ) -> Result { - todo!() - } - - async fn append_session( - &self, - basin: BasinName, - stream: StreamName, - requests: BoxStream<'static, AppendInput>, - ) -> Result>, AppendError> { - todo!() - } -} diff --git a/lite/src/backend/store.rs b/lite/src/backend/store.rs new file mode 100644 index 00000000..7b2d038d --- /dev/null +++ b/lite/src/backend/store.rs @@ -0,0 +1,78 @@ +use bytes::Bytes; +use s2_common::record::{SeqNum, StreamPosition, Timestamp}; +use slatedb::{ + DbTransaction, + config::{DurabilityLevel, ReadOptions, ScanOptions}, +}; + +use super::Backend; +use crate::backend::{error::StorageError, kv, stream_id::StreamId}; + +impl Backend { + pub(super) async fn db_get + Send, V>( + &self, + key: K, + deser: impl FnOnce(Bytes) -> Result, + ) -> Result, StorageError> { + static READ_OPTS: ReadOptions = ReadOptions { + durability_filter: DurabilityLevel::Remote, + dirty: false, + cache_blocks: true, + }; + let value = self + .db + .get_with_options(key, &READ_OPTS) + .await? + .map(deser) + .transpose()?; + Ok(value) + } + + pub(super) async fn resolve_timestamp( + &self, + stream_id: StreamId, + timestamp: Timestamp, + ) -> Result, StorageError> { + let start_key = kv::stream_record_timestamp::ser_key(stream_id, timestamp, SeqNum::MIN); + static SCAN_OPTS: ScanOptions = ScanOptions { + durability_filter: DurabilityLevel::Remote, + dirty: false, + read_ahead_bytes: 1, + cache_blocks: false, + max_fetch_tasks: 1, + }; + let mut it = self.db.scan_with_options(start_key.., &SCAN_OPTS).await?; + Ok(match it.next().await? { + Some(kv) => { + let (deser_stream_id, deser_timestamp, deser_seq_num) = + kv::stream_record_timestamp::deser_key(kv.key)?; + assert_eq!(deser_stream_id, stream_id); + assert!(deser_timestamp >= timestamp); + kv::stream_record_timestamp::deser_value(kv.value)?; + Some(StreamPosition { + seq_num: deser_seq_num, + timestamp: deser_timestamp, + }) + } + None => None, + }) + } +} + +pub(super) async fn db_txn_get + Send, V>( + txn: &DbTransaction, + key: K, + deser: impl FnOnce(Bytes) -> Result, +) -> Result, StorageError> { + static READ_OPTS: ReadOptions = ReadOptions { + durability_filter: DurabilityLevel::Memory, + dirty: false, + cache_blocks: true, + }; + let value = txn + .get_with_options(key, &READ_OPTS) + .await? + .map(deser) + .transpose()?; + Ok(value) +} diff --git a/lite/src/backend/stream_id.rs b/lite/src/backend/stream_id.rs index 43e7f5e2..92118505 100644 --- a/lite/src/backend/stream_id.rs +++ b/lite/src/backend/stream_id.rs @@ -1,69 +1,38 @@ -use s2_common::types::{basin::BasinName, stream::StreamName}; - -const FIELD_SEPARATOR: u8 = 0x00; +use s2_common::{ + bash::Bash, + types::{basin::BasinName, stream::StreamName}, +}; /// Unique identifier for a stream scoped by its basin. -/// -/// The identifier is the Blake3 hash of the string representations of the -/// basin and stream names with a separator byte to avoid collisions when -/// concatenating variable length fields. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] -pub struct StreamId(blake3::Hash); +pub struct StreamId(Bash); + +impl std::fmt::Display for StreamId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} impl StreamId { + pub const LEN: usize = 32; + pub fn new(basin: &BasinName, stream: &StreamName) -> Self { - let mut hasher = blake3::Hasher::new(); - hasher.update(basin.as_ref().as_bytes()); - hasher.update(&[FIELD_SEPARATOR]); - hasher.update(stream.as_ref().as_bytes()); - - Self(hasher.finalize()) + Self(Bash::new(&[basin.as_bytes(), stream.as_bytes()])) } - /// Access the raw 32-byte hash. - pub fn as_hash(&self) -> blake3::Hash { - self.0 - } - - /// Borrow the identifier as raw bytes. - pub fn as_bytes(&self) -> &[u8; 32] { + pub fn as_bytes(&self) -> &[u8; Self::LEN] { self.0.as_bytes() } +} - /// Consume the identifier and return the raw bytes. - pub fn into_bytes(self) -> [u8; 32] { - *self.0.as_bytes() +impl From<[u8; StreamId::LEN]> for StreamId { + fn from(bytes: [u8; StreamId::LEN]) -> Self { + Self(bytes.into()) } } -#[cfg(test)] -mod tests { - use std::str::FromStr; - - use s2_common::types::{basin::BasinName, stream::StreamName}; - - use super::StreamId; - - #[test] - fn deterministic_for_same_inputs() { - let basin = BasinName::from_str("basin-alpha").unwrap(); - let stream = StreamName::from_str("stream-main").unwrap(); - - let first = StreamId::new(&basin, &stream); - let second = StreamId::new(&basin, &stream); - - assert_eq!(first, second); - } - - #[test] - fn distinct_for_different_inputs() { - let basin = BasinName::from_str("basin-alpha").unwrap(); - let stream_a = StreamName::from_str("stream-main").unwrap(); - let stream_b = StreamName::from_str("stream-aux").unwrap(); - - let id_a = StreamId::new(&basin, &stream_a); - let id_b = StreamId::new(&basin, &stream_b); - - assert_ne!(id_a, id_b); +impl From for [u8; StreamId::LEN] { + fn from(id: StreamId) -> Self { + id.0.into() } } diff --git a/lite/src/backend/streamer.rs b/lite/src/backend/streamer.rs new file mode 100644 index 00000000..561ab434 --- /dev/null +++ b/lite/src/backend/streamer.rs @@ -0,0 +1,765 @@ +use std::{ + ops::{Range, RangeTo}, + sync::Arc, + time::Duration, +}; + +use dashmap::DashMap; +use futures::{FutureExt as _, StreamExt as _, future::BoxFuture, stream::FuturesOrdered}; +use s2_common::{ + record::{ + CommandRecord, FencingToken, Metered, Record, SeqNum, SequencedRecord, StreamPosition, + Timestamp, + }, + types::{ + config::{ + OptionalStreamConfig, OptionalTimestampingConfig, RetentionPolicy, TimestampingMode, + }, + stream::{AppendAck, AppendInput, AppendRecord, AppendRecordBatch, AppendRecordParts}, + }, +}; +use slatedb::{ + WriteBatch, + config::{PutOptions, Ttl, WriteOptions}, +}; +use tokio::{ + sync::{self, broadcast, mpsc, oneshot}, + time::Instant, +}; + +use crate::backend::{ + append, + error::{ + AppendConditionFailedError, AppendErrorInternal, AppendTimestampRequiredError, + DeleteStreamError, StreamerError, UnavailableError, + }, + kv, + stream_id::StreamId, +}; + +// TODO: https://github.com/slatedb/slatedb/issues/1138 +const MAX_INFLIGHT_APPENDS: usize = 1; + +const DORMANT_TIMEOUT: Duration = Duration::from_secs(60); + +pub(super) fn spawn_streamer( + db: slatedb::Db, + stream_id: StreamId, + config: OptionalStreamConfig, + tail_pos: StreamPosition, + fencing_token: FencingToken, + trim_point: RangeTo, + client_states: Arc>, +) -> StreamerClient { + let (append_tx, append_rx) = mpsc::channel(MAX_INFLIGHT_APPENDS); + let (control_tx, control_rx) = mpsc::unbounded_channel(); + + let streamer = Streamer { + db, + stream_id, + config, + fencing_token: CommandState { + state: fencing_token, + applied_point: ..tail_pos.seq_num, + }, + trim_point: CommandState { + state: trim_point, + applied_point: ..tail_pos.seq_num, + }, + append_futs: FuturesOrdered::new(), + pending_appends: append::PendingAppends::new(MAX_INFLIGHT_APPENDS), + stable_pos: tail_pos, + follow_tx: broadcast::Sender::new(super::FOLLOWER_MAX_LAG), + }; + + let task_stream_id = stream_id; + tokio::spawn(async move { + streamer.run(append_rx, control_rx).await; + client_states.remove(&task_stream_id); + }); + + StreamerClient { + stream_id, + append_tx, + control_tx, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AppendType { + Regular, + Terminal, +} + +#[derive(Debug, Clone)] +struct CommandState { + applied_point: RangeTo, + state: T, +} + +impl CommandState { + fn is_applied_in(&self, seq_num_range: &Range) -> bool { + seq_num_range.start <= self.applied_point.end && self.applied_point.end <= seq_num_range.end + } +} + +struct Streamer { + db: slatedb::Db, + stream_id: StreamId, + config: OptionalStreamConfig, + fencing_token: CommandState, + trim_point: CommandState>, + append_futs: + FuturesOrdered>, slatedb::Error>>>, + pending_appends: append::PendingAppends, + stable_pos: StreamPosition, + follow_tx: broadcast::Sender>>, +} + +impl Streamer { + fn next_assignable_pos(&self) -> StreamPosition { + self.pending_appends + .next_ack_pos() + .unwrap_or(self.stable_pos) + } + + fn sequence_records( + &self, + AppendInput { + records, + match_seq_num, + fencing_token, + }: AppendInput, + ) -> Result>, AppendErrorInternal> { + if let Some(provided_token) = fencing_token + && provided_token != self.fencing_token.state + { + Err(AppendConditionFailedError::FencingTokenMismatch { + expected: provided_token, + actual: self.fencing_token.state.clone(), + applied_point: self.fencing_token.applied_point, + })?; + } + let next_assignable_pos = self.next_assignable_pos(); + let first_seq_num = next_assignable_pos.seq_num; + if let Some(match_seq_num) = match_seq_num + && match_seq_num != first_seq_num + { + Err(AppendConditionFailedError::SeqNumMismatch { + assigned_seq_num: first_seq_num, + match_seq_num, + })?; + } + sequenced_records( + records, + first_seq_num, + next_assignable_pos.timestamp, + &self.config.timestamping, + ) + } + + fn apply_command(&mut self, seq_num: SeqNum, cmd: &CommandRecord, append_type: AppendType) { + let new_applied_point = ..(seq_num + 1); + match cmd { + CommandRecord::Fence(token) => { + self.fencing_token = CommandState { + applied_point: new_applied_point, + state: token.clone(), + }; + } + CommandRecord::Trim(trim_point) => { + let trim_point = ..(*trim_point).min(match append_type { + AppendType::Regular => new_applied_point.end, + AppendType::Terminal => SeqNum::MAX, + }); + if self.trim_point.state.end < trim_point.end { + self.trim_point = CommandState { + applied_point: new_applied_point, + state: trim_point, + }; + } + } + } + } + + fn handle_append(&mut self, msg: AppendMessage) { + let Some(ticket) = append::admit(msg.reply_tx, msg.session) else { + return; + }; + match self.sequence_records(msg.input) { + Ok(sequenced_records) => { + if msg.append_type == AppendType::Terminal { + assert_eq!(sequenced_records.len(), 1); + assert_eq!( + sequenced_records[0].record, + Record::Command(CommandRecord::Trim(SeqNum::MAX)) + ); + } + for sr in sequenced_records.iter() { + if let Record::Command(cmd) = &sr.record { + self.apply_command(sr.position.seq_num, cmd, msg.append_type); + } + } + let first_pos = sequenced_records.first().expect("non-empty").position; + let next_pos = next_pos(&sequenced_records); + let seq_num_range = first_pos.seq_num..next_pos.seq_num; + self.append_futs.push_back( + db_write_records( + self.db.clone(), + self.stream_id, + self.config.retention_policy.unwrap_or_default(), + sequenced_records, + self.fencing_token + .is_applied_in(&seq_num_range) + .then(|| self.fencing_token.state.clone()), + self.trim_point + .is_applied_in(&seq_num_range) + .then_some(self.trim_point.state), + ) + .boxed(), + ); + self.pending_appends.accept(ticket, first_pos..next_pos); + } + Err(e) => { + self.pending_appends.reject(ticket, e, self.stable_pos); + } + } + } + + fn handle_control(&mut self, msg: ControlMessage) { + match msg { + ControlMessage::Follow { + start_seq_num, + reply_tx, + } => { + let reply = if start_seq_num == self.stable_pos.seq_num { + Ok(self.follow_tx.subscribe()) + } else { + Err(self.stable_pos) + }; + let _ = reply_tx.send(reply); + } + ControlMessage::CheckTail { reply_tx } => { + let _ = reply_tx.send(self.stable_pos); + } + ControlMessage::Reconfigure { config } => { + self.config = config; + } + } + } + + async fn run( + mut self, + mut appends_rx: mpsc::Receiver, + mut control_rx: mpsc::UnboundedReceiver, + ) { + let dormancy = tokio::time::sleep(Duration::MAX); + tokio::pin!(dormancy); + loop { + if self.trim_point.state.end == SeqNum::MAX { + if self.trim_point.applied_point.end == self.stable_pos.seq_num { + break; + } else { + assert!(self.stable_pos.seq_num < self.trim_point.applied_point.end); + } + } + dormancy.as_mut().reset(Instant::now() + DORMANT_TIMEOUT); + tokio::select! { + Some(msg) = appends_rx.recv(), if ( + self.trim_point.state.end < SeqNum::MAX && + self.append_futs.len() < MAX_INFLIGHT_APPENDS + ) => { + self.handle_append(msg); + } + Some(msg) = control_rx.recv() => { + self.handle_control(msg); + } + Some(res) = self.append_futs.next() => { + match res { + Ok(records) => { + let last_pos = records.last().expect("non-empty").position; + let stable_pos = StreamPosition { seq_num: last_pos.seq_num + 1, timestamp: last_pos.timestamp }; + self.pending_appends.on_stable(stable_pos); + self.stable_pos = stable_pos; + let _ = self.follow_tx.send(records); + }, + Err(db_err) => { + self.pending_appends.on_durability_failed(db_err); + break; + }, + } + } + _ = dormancy.as_mut() => { + break; + } + } + } + } +} + +struct AppendMessage { + input: AppendInput, + session: Option, + reply_tx: oneshot::Sender>, + append_type: AppendType, +} + +enum ControlMessage { + Follow { + start_seq_num: SeqNum, + reply_tx: oneshot::Sender< + Result>>, StreamPosition>, + >, + }, + CheckTail { + reply_tx: oneshot::Sender, + }, + Reconfigure { + config: OptionalStreamConfig, + }, +} + +#[derive(Debug, Clone)] +pub(super) struct StreamerClient { + stream_id: StreamId, + append_tx: mpsc::Sender, + control_tx: mpsc::UnboundedSender, +} + +impl StreamerClient { + pub fn stream_id(&self) -> StreamId { + self.stream_id + } + + pub async fn check_tail(&self) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self.control_tx + .send(ControlMessage::CheckTail { reply_tx }) + .map_err(|_| UnavailableError::MissingInAction)?; + reply_rx.await.map_err(|_| UnavailableError::RequestDrop) + } + + pub async fn follow( + &self, + start_seq_num: SeqNum, + ) -> Result< + Result>>, StreamPosition>, + UnavailableError, + > { + let (reply_tx, reply_rx) = oneshot::channel(); + self.control_tx + .send(ControlMessage::Follow { + start_seq_num, + reply_tx, + }) + .map_err(|_| UnavailableError::MissingInAction)?; + reply_rx.await.map_err(|_| UnavailableError::RequestDrop) + } + + pub async fn append( + &self, + input: AppendInput, + session: Option, + ) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self.append_tx + .send(AppendMessage { + input, + session, + reply_tx, + append_type: AppendType::Regular, + }) + .await + .map_err(|_| UnavailableError::MissingInAction)?; + let ack = reply_rx + .await + .map_err(|_| UnavailableError::RequestDrop)??; + Ok(ack) + } + + pub async fn reconfigure(&self, config: OptionalStreamConfig) -> Result<(), UnavailableError> { + self.control_tx + .send(ControlMessage::Reconfigure { config }) + .map_err(|_| UnavailableError::MissingInAction) + } + + pub async fn terminal_trim(&self) -> Result<(), DeleteStreamError> { + let (reply_tx, reply_rx) = oneshot::channel(); + let record: AppendRecord = AppendRecordParts { + timestamp: Some(Timestamp::MAX), + record: Record::Command(CommandRecord::Trim(SeqNum::MAX)).into(), + } + .try_into() + .expect("valid append record"); + let input = AppendInput { + records: vec![record].try_into().expect("valid append batch"), + match_seq_num: None, + fencing_token: None, + }; + self.append_tx + .send(AppendMessage { + input, + session: None, + reply_tx, + append_type: AppendType::Terminal, + }) + .await + .map_err(|_| UnavailableError::MissingInAction)?; + match reply_rx.await.map_err(|_| UnavailableError::RequestDrop)? { + Ok(_ack) => Ok(()), + Err(e) => Err(match e { + AppendErrorInternal::Storage(e) => DeleteStreamError::Storage(e), + AppendErrorInternal::Unavailable(e) => DeleteStreamError::Unavailable(e), + AppendErrorInternal::ConditionFailed(_) => unreachable!("unconditional write"), + AppendErrorInternal::TimestampMissing(_) => unreachable!("Timestamp::MAX used"), + }), + } + } +} + +#[derive(Debug, Clone)] +pub enum StreamerClientState { + /// in the process of init + Blocked { notify: Arc }, + /// failed to init, but the event could be stale + InitError { + error: Box, + timestamp: Instant, + }, + /// active and ready to talk + Ready { client: StreamerClient }, +} + +fn timestamp_now() -> Timestamp { + std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_millis() + .try_into() + .expect("Milliseconds since Unix epoch fits into a u64") +} + +pub fn next_pos(records: &[T]) -> StreamPosition +where + T: std::ops::Deref, +{ + let last_pos = records.last().expect("non-empty").position; + StreamPosition { + seq_num: last_pos.seq_num + 1, + timestamp: last_pos.timestamp, + } +} + +fn sequenced_records( + batch: AppendRecordBatch, + first_seq_num: SeqNum, + prev_max_timestamp: Timestamp, + config: &OptionalTimestampingConfig, +) -> Result>, AppendErrorInternal> { + let mode = config.mode.unwrap_or_default(); + let uncapped = config.uncapped.unwrap_or_default(); + let mut sequenced_records = Vec::with_capacity(batch.len()); + let mut max_timestamp = prev_max_timestamp; + let now = timestamp_now(); + for (i, AppendRecordParts { timestamp, record }) in + batch.into_iter().map(Into::into).enumerate() + { + let mut timestamp = match mode { + TimestampingMode::ClientPrefer => timestamp.unwrap_or(now), + TimestampingMode::ClientRequire => timestamp.ok_or(AppendTimestampRequiredError)?, + TimestampingMode::Arrival => now, + }; + if !uncapped && timestamp > now { + timestamp = now; + } + if timestamp < max_timestamp { + timestamp = max_timestamp; + } else { + max_timestamp = timestamp; + } + + sequenced_records.push(record.sequenced(StreamPosition { + seq_num: first_seq_num + i as u64, + timestamp, + })); + } + Ok(sequenced_records) +} + +async fn db_write_records( + db: slatedb::Db, + stream_id: StreamId, + retention: RetentionPolicy, + records: Vec>, + fencing_token: Option, + trim_point: Option>, +) -> Result>, slatedb::Error> { + let ttl = match retention { + RetentionPolicy::Age(age) => Ttl::ExpireAfter(age.as_millis() as u64), + RetentionPolicy::Infinite() => Ttl::NoExpiry, + }; + let ttl_put_opts = PutOptions { ttl }; + let mut wb = WriteBatch::new(); + for (position, record) in records.iter().map(|msr| msr.parts()) { + wb.put_with_options( + kv::stream_record_data::ser_key(stream_id, position), + kv::stream_record_data::ser_value(record), + &ttl_put_opts, + ); + wb.put_with_options( + kv::stream_record_timestamp::ser_key(stream_id, position.timestamp, position.seq_num), + kv::stream_record_timestamp::ser_value(), + &ttl_put_opts, + ); + } + if let Some(fencing_token) = fencing_token { + wb.put( + kv::stream_fencing_token::ser_key(stream_id), + kv::stream_fencing_token::ser_value(&fencing_token), + ); + } + if let Some(trim_point) = trim_point { + wb.put( + kv::stream_trim_point::ser_key(stream_id), + kv::stream_trim_point::ser_value(trim_point), + ); + } + wb.put( + kv::stream_tail_position::ser_key(stream_id), + kv::stream_tail_position::ser_value(next_pos(&records)), + ); + static WRITE_OPTIONS: WriteOptions = WriteOptions { + await_durable: true, + }; + db.write_with_options(wb, &WRITE_OPTIONS).await?; + Ok(records) +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use s2_common::{ + record::{EnvelopeRecord, Metered, Record}, + types::stream::{AppendRecord, AppendRecordParts}, + }; + + use super::*; + + fn test_record(body: Bytes, timestamp: Option) -> AppendRecord { + let envelope = EnvelopeRecord::try_from_parts(vec![], body).unwrap(); + let record: Metered = Record::Envelope(envelope).into(); + let parts = AppendRecordParts { timestamp, record }; + parts.try_into().unwrap() + } + + #[test] + fn sequenced_records_client_prefer_with_timestamps() { + let config = OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientPrefer), + uncapped: Some(false), + }; + + let records: AppendRecordBatch = vec![ + test_record(vec![1, 2, 3].into(), Some(900)), + test_record(vec![4, 5, 6].into(), Some(950)), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].position.seq_num, 100); + assert_eq!(result[0].position.timestamp, 900); + assert_eq!(result[1].position.seq_num, 101); + assert_eq!(result[1].position.timestamp, 950); + } + + #[test] + fn sequenced_records_client_prefer_without_timestamps() { + let now = timestamp_now(); + let config = OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientPrefer), + uncapped: Some(false), + }; + + let records: AppendRecordBatch = vec![ + test_record(vec![1, 2, 3].into(), None), + test_record(vec![4, 5, 6].into(), None), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].position.seq_num, 100); + assert!(result[0].position.timestamp >= now); + assert_eq!(result[1].position.seq_num, 101); + assert!(result[1].position.timestamp >= now); + } + + #[test] + fn sequenced_records_client_require_missing_timestamp() { + let config = OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + uncapped: Some(false), + }; + + let records: AppendRecordBatch = vec![test_record(vec![1, 2, 3].into(), None)] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config); + + assert!(matches!( + result, + Err(AppendErrorInternal::TimestampMissing(_)) + )); + } + + #[test] + fn sequenced_records_client_require_with_timestamps() { + let config = OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + uncapped: Some(false), + }; + + let records: AppendRecordBatch = vec![ + test_record(vec![1, 2, 3].into(), Some(900)), + test_record(vec![4, 5, 6].into(), Some(950)), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].position.timestamp, 900); + assert_eq!(result[1].position.timestamp, 950); + } + + #[test] + fn sequenced_records_arrival_mode() { + let now = timestamp_now(); + let config = OptionalTimestampingConfig { + mode: Some(TimestampingMode::Arrival), + uncapped: Some(false), + }; + + let records: AppendRecordBatch = vec![ + test_record(vec![1, 2, 3].into(), Some(900)), + test_record(vec![4, 5, 6].into(), Some(950)), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 2); + assert!(result[0].position.timestamp >= now); + assert!(result[1].position.timestamp >= now); + } + + #[test] + fn sequenced_records_timestamp_monotonicity() { + let config = OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientPrefer), + uncapped: Some(false), + }; + + let records: AppendRecordBatch = vec![ + test_record(vec![1, 2, 3].into(), Some(1000)), + test_record(vec![4, 5, 6].into(), Some(900)), + test_record(vec![7, 8, 9].into(), Some(1100)), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 3); + assert_eq!(result[0].position.timestamp, 1000); + assert_eq!(result[1].position.timestamp, 1000); + assert_eq!(result[2].position.timestamp, 1100); + } + + #[test] + fn sequenced_records_prev_max_timestamp_enforced() { + let config = OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientPrefer), + uncapped: Some(false), + }; + + let records: AppendRecordBatch = vec![ + test_record(vec![1, 2, 3].into(), Some(500)), + test_record(vec![4, 5, 6].into(), Some(600)), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 1000, &config).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].position.timestamp, 1000); + assert_eq!(result[1].position.timestamp, 1000); + } + + #[test] + fn sequenced_records_future_timestamp_capped() { + let now = timestamp_now(); + let config = OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientPrefer), + uncapped: Some(false), + }; + + let future = now + 10_000; + let records: AppendRecordBatch = vec![test_record(vec![1, 2, 3].into(), Some(future))] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 1); + assert!(result[0].position.timestamp <= now + 100); + } + + #[test] + fn sequenced_records_future_timestamp_uncapped() { + let now = timestamp_now(); + let config = OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientPrefer), + uncapped: Some(true), + }; + + let future = now + 10_000; + let records: AppendRecordBatch = vec![test_record(vec![1, 2, 3].into(), Some(future))] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].position.timestamp, future); + } + + #[test] + fn sequenced_records_seq_num_assignment() { + let config = OptionalTimestampingConfig::default(); + + let records: AppendRecordBatch = vec![ + test_record(vec![1].into(), None), + test_record(vec![2].into(), None), + test_record(vec![3].into(), None), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 42, 0, &config).unwrap(); + + assert_eq!(result.len(), 3); + assert_eq!(result[0].position.seq_num, 42); + assert_eq!(result[1].position.seq_num, 43); + assert_eq!(result[2].position.seq_num, 44); + } +} diff --git a/lite/src/backend/streams.rs b/lite/src/backend/streams.rs new file mode 100644 index 00000000..58968ccd --- /dev/null +++ b/lite/src/backend/streams.rs @@ -0,0 +1,264 @@ +use s2_common::{ + bash::Bash, + types::{ + basin::BasinName, + config::{OptionalStreamConfig, StreamReconfiguration}, + resources::{CreateMode, ListItemsRequestParts, Page, RequestToken}, + stream::{ListStreamsRequest, StreamInfo, StreamName}, + }, +}; +use slatedb::{ + IsolationLevel, + config::{DurabilityLevel, ScanOptions, WriteOptions}, +}; +use time::OffsetDateTime; + +use super::{Backend, store::db_txn_get}; +use crate::backend::{ + error::{ + BasinDeletionInProgressError, BasinNotFoundError, CreateStreamError, DeleteStreamError, + GetStreamConfigError, ListStreamsError, ReconfigureStreamError, StreamAlreadyExistsError, + StreamDeletionInProgressError, StreamNotFoundError, StreamerError, + }, + kv, +}; + +impl Backend { + pub async fn list_streams( + &self, + basin: BasinName, + request: ListStreamsRequest, + ) -> Result, ListStreamsError> { + let ListItemsRequestParts { + prefix, + start_after, + limit, + } = request.into(); + + let key_range = kv::stream_meta::ser_key_range(&basin, &prefix, &start_after); + + const SCAN_OPTS: ScanOptions = ScanOptions { + durability_filter: DurabilityLevel::Remote, + dirty: false, + read_ahead_bytes: 1, + cache_blocks: false, + max_fetch_tasks: 1, + }; + let mut it = self.db.scan_with_options(key_range, &SCAN_OPTS).await?; + + let mut streams = Vec::with_capacity(limit.as_usize()); + let mut has_more = false; + while let Some(kv) = it.next().await? { + let (deser_basin, stream) = kv::stream_meta::deser_key(kv.key)?; + assert_eq!(deser_basin.as_ref(), basin.as_ref()); + assert!(stream.as_ref() > start_after.as_ref()); + assert!(stream.as_ref() >= prefix.as_ref()); + if streams.len() == limit.as_usize() { + has_more = true; + break; + } + let meta = kv::stream_meta::deser_value(kv.value)?; + streams.push(StreamInfo { + name: stream, + created_at: meta.created_at, + deleted_at: meta.deleted_at, + }); + } + Ok(Page::new(streams, has_more)) + } + + pub async fn create_stream( + &self, + basin: BasinName, + stream: StreamName, + mut config: OptionalStreamConfig, + mode: CreateMode, + ) -> Result { + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + + let Some(basin_meta) = db_txn_get( + &txn, + kv::basin_meta::ser_key(&basin), + kv::basin_meta::deser_value, + ) + .await? + else { + return Err(BasinNotFoundError { basin }.into()); + }; + + if basin_meta.deleted_at.is_some() { + return Err(BasinDeletionInProgressError { basin }.into()); + } + + let stream_meta_key = kv::stream_meta::ser_key(&basin, &stream); + + let creation_idempotency_key = match &mode { + CreateMode::CreateOnly(Some(req_token)) => { + Some(creation_idempotency_key(req_token, &config)) + } + _ => None, + }; + + let mut existing_created_at = None; + + if let Some(existing_meta) = + db_txn_get(&txn, &stream_meta_key, kv::stream_meta::deser_value).await? + { + if existing_meta.deleted_at.is_some() { + return Err(CreateStreamError::StreamDeletionInProgress( + StreamDeletionInProgressError { basin, stream }, + )); + } + match mode { + CreateMode::CreateOnly(_) => { + return if creation_idempotency_key.is_some() + && existing_meta.creation_idempotency_key == creation_idempotency_key + { + Ok(StreamInfo { + name: stream, + created_at: existing_meta.created_at, + deleted_at: None, + }) + } else { + Err(StreamAlreadyExistsError { basin, stream }.into()) + }; + } + CreateMode::CreateOrReconfigure => { + existing_created_at = Some(existing_meta.created_at); + } + } + } + + config = config.merge(basin_meta.config.default_stream_config).into(); + + let created_at = existing_created_at.unwrap_or_else(OffsetDateTime::now_utc); + let meta = kv::StreamMeta { + config: config.clone(), + created_at, + deleted_at: None, + creation_idempotency_key, + }; + + txn.put(&stream_meta_key, kv::stream_meta::ser_value(&meta))?; + + static WRITE_OPTS: WriteOptions = WriteOptions { + await_durable: true, + }; + txn.commit_with_options(&WRITE_OPTS).await?; + + if existing_created_at.is_some() + && let Some(client) = self.streamer_client_if_active(&basin, &stream) + { + client.reconfigure(config).await?; + } + + Ok(StreamInfo { + name: stream, + created_at, + deleted_at: None, + }) + } + + pub async fn get_stream_config( + &self, + basin: BasinName, + stream: StreamName, + ) -> Result { + let meta = self + .db_get( + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::deser_value, + ) + .await? + .ok_or_else(|| StreamNotFoundError { basin, stream })?; + Ok(meta.config) + } + + pub async fn reconfigure_stream( + &self, + basin: BasinName, + stream: StreamName, + reconfig: StreamReconfiguration, + ) -> Result { + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + + let meta_key = kv::stream_meta::ser_key(&basin, &stream); + + let mut meta = db_txn_get(&txn, &meta_key, kv::stream_meta::deser_value) + .await? + .ok_or_else(|| StreamNotFoundError { + basin: basin.clone(), + stream: stream.clone(), + })?; + + if meta.deleted_at.is_some() { + return Err(StreamDeletionInProgressError { basin, stream }.into()); + } + + meta.config = meta.config.reconfigure(reconfig); + + txn.put(&meta_key, kv::stream_meta::ser_value(&meta))?; + + static WRITE_OPTS: WriteOptions = WriteOptions { + await_durable: true, + }; + txn.commit_with_options(&WRITE_OPTS).await?; + + if let Some(client) = self.streamer_client_if_active(&basin, &stream) { + client.reconfigure(meta.config.clone()).await?; + } + + Ok(meta.config) + } + + pub async fn delete_stream( + &self, + basin: BasinName, + stream: StreamName, + ) -> Result<(), DeleteStreamError> { + match self.streamer_client(&basin, &stream).await { + Ok(client) => { + client.terminal_trim().await?; + } + Err(StreamerError::Storage(e)) => { + return Err(DeleteStreamError::Storage(e)); + } + Err(StreamerError::StreamNotFound(e)) => { + return Err(DeleteStreamError::StreamNotFound(e)); + } + Err(StreamerError::StreamDeletionInProgress(e)) => { + assert_eq!(e.basin, basin); + assert_eq!(e.stream, stream); + } + } + + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + let meta_key = kv::stream_meta::ser_key(&basin, &stream); + let mut meta = db_txn_get(&txn, &meta_key, kv::stream_meta::deser_value) + .await? + .ok_or_else(|| StreamNotFoundError { + basin, + stream: stream.clone(), + })?; + if meta.deleted_at.is_none() { + meta.deleted_at = Some(OffsetDateTime::now_utc()); + txn.put(&meta_key, kv::stream_meta::ser_value(&meta))?; + static WRITE_OPTS: WriteOptions = WriteOptions { + await_durable: true, + }; + txn.commit_with_options(&WRITE_OPTS).await?; + } + + Ok(()) + } +} + +fn creation_idempotency_key(req_token: &RequestToken, config: &OptionalStreamConfig) -> Bash { + Bash::new(&[ + req_token.as_bytes(), + &s2_api::v1::config::StreamConfig::to_opt(config.clone()) + .as_ref() + .map(|v| serde_json::to_vec(v).expect("serializable")) + .unwrap_or_default(), + ]) +} diff --git a/lite/src/bin/server.rs b/lite/src/bin/server.rs new file mode 100644 index 00000000..b3489209 --- /dev/null +++ b/lite/src/bin/server.rs @@ -0,0 +1,171 @@ +use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; + +use axum_server::tls_rustls::RustlsConfig; +use clap::Parser as _; +use s2_lite::{backend::Backend, handlers}; +use slatedb::object_store; +use tracing::info; + +#[derive(clap::Args, Debug, Clone)] +struct TlsConfig { + /// Use a self-signed certificate for TLS + #[arg(long, conflicts_with_all = ["tls_cert", "tls_key"])] + tls_self: bool, + + /// Path to the TLS certificate file (e.g., cert.pem) + /// Must be used together with --tls-key + #[arg(long, requires = "tls_key")] + tls_cert: Option, + + /// Path to the private key file (e.g., key.pem) + /// Must be used together with --tls-cert + #[arg(long, requires = "tls_cert")] + tls_key: Option, +} + +#[derive(clap::Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Name of the S3 bucket to back the database. + /// If not specified, in-memory storage is used. + #[arg(long)] + bucket: Option, + + /// Path on object storage. + #[arg(long, default_value = "")] + path: String, + + /// TLS configuration (defaults to plain HTTP if not specified). + #[command(flatten)] + tls: TlsConfig, + + /// Port to listen on [default: 443 if HTTPS configured, otherwise 80 for HTTP] + #[arg(long)] + port: Option, +} + +#[tokio::main] +async fn main() -> eyre::Result<()> { + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + + info!(?args); + + let addr = { + let port = args.port.unwrap_or_else(|| { + if args.tls.tls_self || args.tls.tls_cert.is_some() { + 443 + } else { + 80 + } + }); + format!("0.0.0.0:{port}") + }; + + let server_handle = axum_server::Handle::new(); + + tokio::spawn(shutdown_signal(server_handle.clone())); + + let object_store: Arc = match args.bucket { + Some(bucket) if !bucket.is_empty() => { + info!(bucket, "using s3 object store"); + let store = object_store::aws::AmazonS3Builder::from_env() + .with_bucket_name(bucket) + .build()?; + Arc::new(store) + } + _ => { + info!("using in-memory object store"); + Arc::new(object_store::memory::InMemory::new()) + } + }; + + let db_settings = slatedb::Settings::from_env("SL8_")?; + let db = slatedb::Db::builder(args.path, object_store) + .with_settings(db_settings) + .build() + .await?; + + let app = handlers::router(Backend::new(db)); + + match ( + args.tls.tls_self, + args.tls.tls_cert.clone(), + args.tls.tls_key.clone(), + ) { + (false, Some(cert_path), Some(key_path)) => { + info!( + addr, + ?cert_path, + "starting https server with provided certificate" + ); + let rustls_config = RustlsConfig::from_pem_file(cert_path, key_path).await?; + axum_server::bind_rustls(addr.parse()?, rustls_config) + .handle(server_handle) + .serve(app.into_make_service()) + .await?; + } + (true, None, None) => { + info!( + addr, + "starting https server with self-signed certificate, clients will need to use --insecure" + ); + let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed([ + "localhost".to_string(), + "127.0.0.1".to_string(), + "::1".to_string(), + ])?; + let rustls_config = RustlsConfig::from_pem( + cert.pem().into_bytes(), + signing_key.serialize_pem().into_bytes(), + ) + .await?; + axum_server::bind_rustls(addr.parse()?, rustls_config) + .handle(server_handle) + .serve(app.into_make_service()) + .await?; + } + (false, None, None) => { + info!(addr, "starting plain http server"); + axum_server::bind(addr.parse()?) + .handle(server_handle) + .serve(app.into_make_service()) + .await?; + } + _ => { + // This shouldn't happen due to clap validation... + return Err(eyre::eyre!("Invalid TLS configuration")); + } + } + + Ok(()) +} + +async fn shutdown_signal(handle: axum_server::Handle) { + let ctrl_c = async { + tokio::signal::ctrl_c().await.expect("ctrl-c"); + }; + + #[cfg(unix)] + let term = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("SIGTERM") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => { + info!("received Ctrl+C, starting graceful shutdown"); + }, + _ = term => { + info!("received SIGTERM, starting graceful shutdown"); + }, + } + + handle.graceful_shutdown(Some(Duration::from_secs(30))); +} diff --git a/lite/src/handlers/mod.rs b/lite/src/handlers/mod.rs index a3a6d96c..a31e565a 100644 --- a/lite/src/handlers/mod.rs +++ b/lite/src/handlers/mod.rs @@ -1 +1,5 @@ -pub mod v1; +mod v1; + +pub fn router(backend: crate::backend::Backend) -> axum::Router { + axum::Router::new().nest("/v1", v1::router(backend)) +} diff --git a/lite/src/handlers/v1.rs b/lite/src/handlers/v1/basins.rs similarity index 100% rename from lite/src/handlers/v1.rs rename to lite/src/handlers/v1/basins.rs diff --git a/lite/src/handlers/v1/mod.rs b/lite/src/handlers/v1/mod.rs new file mode 100644 index 00000000..4ec9ab42 --- /dev/null +++ b/lite/src/handlers/v1/mod.rs @@ -0,0 +1,15 @@ +mod basins; +mod records; +mod streams; + +use crate::backend::Backend; + +pub fn router(backend: Backend) -> axum::Router { + use axum::routing::{get, post}; + + axum::Router::new() + .route("/streams/{stream}/records/tail", get(records::check_tail)) + .route("/streams/{stream}/records", get(records::read)) + .route("/streams/{stream}/records", post(records::append)) + .with_state(backend) +} diff --git a/lite/src/handlers/v1/records.rs b/lite/src/handlers/v1/records.rs new file mode 100644 index 00000000..dd15898b --- /dev/null +++ b/lite/src/handlers/v1/records.rs @@ -0,0 +1,36 @@ +use axum::{ + body::Body, + extract::{Path, Query, State}, + response::Response, +}; +use s2_api::v1::{self as v1t, extractors::RequiredHeader}; +use s2_common::types::{basin::BasinName, stream::StreamName}; + +use crate::backend::Backend; + +pub async fn append( + RequiredHeader(_basin): RequiredHeader, + Path(_stream): Path, + State(_backend): State, + _body: Body, +) -> Response { + todo!() +} + +pub async fn read( + RequiredHeader(_basin): RequiredHeader, + Path(_stream): Path, + Query(_start): Query, + Query(_end): Query, + State(_backend): State, +) -> Response { + todo!() +} + +pub async fn check_tail( + RequiredHeader(_basin): RequiredHeader, + Path(_stream): Path, + State(_backend): State, +) -> Response { + todo!() +} diff --git a/lite/src/handlers/v1/streams.rs b/lite/src/handlers/v1/streams.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/lite/src/handlers/v1/streams.rs @@ -0,0 +1 @@ + diff --git a/lite/src/lib.rs b/lite/src/lib.rs index 6255b247..5f457cf4 100644 --- a/lite/src/lib.rs +++ b/lite/src/lib.rs @@ -1,4 +1,2 @@ -// TODO temporary -#[allow(unused)] pub mod backend; pub mod handlers; diff --git a/lite/src/service.rs b/lite/src/service.rs deleted file mode 100644 index e69de29b..00000000 diff --git a/lite/tests/backend/common.rs b/lite/tests/backend/common.rs new file mode 100644 index 00000000..51b7a5a1 --- /dev/null +++ b/lite/tests/backend/common.rs @@ -0,0 +1,184 @@ +use std::{pin::Pin, sync::Arc}; + +use bytes::Bytes; +use futures::StreamExt; +use s2_common::{ + record::{CommandRecord, FencingToken, Metered, Record, SequencedRecord, Timestamp}, + types::{ + basin::BasinName, + config::{BasinConfig, OptionalStreamConfig}, + resources::CreateMode, + stream::{ + AppendInput, AppendRecord, AppendRecordBatch, AppendRecordParts, ReadSessionOutput, + StreamName, + }, + }, +}; +use s2_lite::backend::{Backend, error::ReadError}; +use slatedb::{Db, config::Settings, object_store::memory::InMemory}; +use uuid::Uuid; + +pub async fn create_in_memory_db() -> Db { + let object_store = Arc::new(InMemory::new()); + let db_path = format!("/tmp/test_{}", Uuid::new_v4()); + + Db::builder(db_path, object_store) + .with_settings(Settings::default()) + .build() + .await + .expect("Failed to create in-memory database") +} + +pub async fn create_backend() -> Backend { + let db = create_in_memory_db().await; + Backend::new(db) +} + +pub fn test_basin_name(suffix: &str) -> BasinName { + format!("test-basin-{}", suffix).parse().unwrap() +} + +pub fn test_stream_name(suffix: &str) -> StreamName { + format!("test-stream-{}", suffix).parse().unwrap() +} + +pub fn create_test_record(body: Bytes) -> AppendRecord { + create_test_record_with_optional_timestamp(body, None) +} + +pub fn create_test_record_with_optional_timestamp( + body: Bytes, + timestamp: Option, +) -> AppendRecord { + let envelope = s2_common::record::EnvelopeRecord::try_from_parts(vec![], body).unwrap(); + let record: Metered = Record::Envelope(envelope).into(); + let parts = AppendRecordParts { timestamp, record }; + parts.try_into().unwrap() +} + +pub fn create_test_record_with_timestamp(body: Bytes, timestamp: Timestamp) -> AppendRecord { + create_test_record_with_optional_timestamp(body, Some(timestamp)) +} + +pub fn create_fencing_command_record(token: FencingToken) -> AppendRecord { + let record: Metered = Record::Command(CommandRecord::Fence(token)).into(); + let parts = AppendRecordParts { + timestamp: None, + record, + }; + parts.try_into().unwrap() +} + +pub fn create_test_record_batch(bodies: Vec) -> AppendRecordBatch { + let records: Vec = bodies.into_iter().map(create_test_record).collect(); + records.try_into().unwrap() +} + +pub fn create_test_record_batch_with_timestamps( + items: Vec<(Bytes, Timestamp)>, +) -> AppendRecordBatch { + let records: Vec = items + .into_iter() + .map(|(body, timestamp)| create_test_record_with_timestamp(body, timestamp)) + .collect(); + records.try_into().unwrap() +} + +pub async fn create_test_basin(backend: &Backend, suffix: &str, config: BasinConfig) -> BasinName { + let basin_name = test_basin_name(suffix); + backend + .create_basin(basin_name.clone(), config, CreateMode::CreateOnly(None)) + .await + .expect("Failed to create basin"); + basin_name +} + +pub async fn create_test_stream( + backend: &Backend, + basin: &BasinName, + suffix: &str, + config: OptionalStreamConfig, +) -> StreamName { + let stream_name = test_stream_name(suffix); + backend + .create_stream( + basin.clone(), + stream_name.clone(), + config, + CreateMode::CreateOnly(None), + ) + .await + .expect("Failed to create stream"); + stream_name +} + +pub async fn setup_backend_with_stream( + basin_suffix: &str, + stream_suffix: &str, + stream_config: OptionalStreamConfig, +) -> (Backend, BasinName, StreamName) { + let backend = create_backend().await; + let basin_name = create_test_basin(&backend, basin_suffix, BasinConfig::default()).await; + let stream_name = create_test_stream(&backend, &basin_name, stream_suffix, stream_config).await; + (backend, basin_name, stream_name) +} + +pub async fn append_payloads( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + payloads: &[&[u8]], +) -> s2_common::types::stream::AppendAck { + let bodies = payloads + .iter() + .map(|bytes| Bytes::copy_from_slice(bytes)) + .collect::>(); + let input = AppendInput { + records: create_test_record_batch(bodies), + match_seq_num: None, + fencing_token: None, + }; + backend + .append(basin.clone(), stream.clone(), input) + .await + .expect("Failed to append payloads") +} + +pub async fn append_repeat( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + payload: &[u8], + count: usize, +) { + for _ in 0..count { + append_payloads(backend, basin, stream, &[payload]).await; + } +} + +pub async fn collect_records(session: &mut Pin>) -> Vec +where + S: futures::Stream>, +{ + let mut records = Vec::new(); + while let Some(output) = session.as_mut().next().await { + match output { + Ok(ReadSessionOutput::Batch(batch)) => { + records.extend(batch.records.iter().cloned()); + } + Ok(ReadSessionOutput::Heartbeat(_)) => {} + Err(e) => panic!("Read error: {:?}", e), + } + } + records +} + +pub fn envelope_bodies(records: &[SequencedRecord]) -> Vec> { + records + .iter() + .map(|record| match &record.record { + Record::Envelope(envelope) => envelope.body().to_vec(), + other => panic!("Unexpected record type: {:?}", other), + }) + .collect() +} diff --git a/lite/tests/backend/control_plane/basin.rs b/lite/tests/backend/control_plane/basin.rs new file mode 100644 index 00000000..d006a08a --- /dev/null +++ b/lite/tests/backend/control_plane/basin.rs @@ -0,0 +1,474 @@ +use s2_common::{ + maybe::Maybe, + types::{ + basin::{BasinNamePrefix, BasinNameStartAfter, BasinState, ListBasinsRequest}, + config::{ + BasinConfig, BasinReconfiguration, RetentionPolicy, StorageClass, + StreamReconfiguration, TimestampingMode, TimestampingReconfiguration, + }, + resources::{CreateMode, ListItemsRequestParts, RequestToken}, + }, +}; +use s2_lite::backend::error::{ + CreateBasinError, DeleteBasinError, GetBasinConfigError, ReconfigureBasinError, +}; + +use super::common::*; + +#[tokio::test] +async fn test_create_basin_idempotency_respects_request_token() { + let backend = create_backend().await; + let basin_name = test_basin_name("basin-idempotency"); + let config = BasinConfig { + create_stream_on_append: true, + ..Default::default() + }; + + let token1: RequestToken = "token-1".parse().unwrap(); + + backend + .create_basin( + basin_name.clone(), + config.clone(), + CreateMode::CreateOnly(Some(token1.clone())), + ) + .await + .expect("Failed to create basin"); + + backend + .create_basin( + basin_name.clone(), + config.clone(), + CreateMode::CreateOnly(Some(token1.clone())), + ) + .await + .expect("Idempotent create should succeed with same request token"); + + let different_token: RequestToken = "token-2".parse().unwrap(); + let different_token_result = backend + .create_basin( + basin_name.clone(), + config.clone(), + CreateMode::CreateOnly(Some(different_token)), + ) + .await; + assert!(matches!( + different_token_result, + Err(CreateBasinError::BasinAlreadyExists(_)) + )); + + let mut different_config = config.clone(); + different_config.create_stream_on_append = false; + let different_config_result = backend + .create_basin( + basin_name, + different_config, + CreateMode::CreateOnly(Some(token1)), + ) + .await; + assert!(matches!( + different_config_result, + Err(CreateBasinError::BasinAlreadyExists(_)) + )); +} + +#[tokio::test] +async fn test_create_basin_create_or_reconfigure_updates_config() { + let backend = create_backend().await; + let basin_name = test_basin_name("basin-recreate"); + let initial_config = BasinConfig { + create_stream_on_append: false, + create_stream_on_read: false, + ..Default::default() + }; + + backend + .create_basin( + basin_name.clone(), + initial_config.clone(), + CreateMode::CreateOnly(None), + ) + .await + .expect("Failed to create basin"); + + let mut updated_config = initial_config.clone(); + updated_config.create_stream_on_append = true; + updated_config.create_stream_on_read = true; + updated_config.default_stream_config.storage_class = Some(StorageClass::Standard); + + backend + .create_basin( + basin_name.clone(), + updated_config.clone(), + CreateMode::CreateOrReconfigure, + ) + .await + .expect("CreateOrReconfigure should update basin config"); + + let stored_config = backend + .get_basin_config(basin_name.clone()) + .await + .expect("Failed to fetch basin config"); + assert!(stored_config.create_stream_on_append); + assert!(stored_config.create_stream_on_read); + assert_eq!( + stored_config.default_stream_config.storage_class, + Some(StorageClass::Standard) + ); + + backend + .create_basin( + basin_name.clone(), + updated_config, + CreateMode::CreateOnly(None), + ) + .await + .expect_err("CreateOnly without request token should not be idempotent"); +} + +#[tokio::test] +async fn test_reconfigure_basin_updates_nested_defaults() { + let backend = create_backend().await; + let basin_name = test_basin_name("basin-reconfigure"); + let mut initial_config = BasinConfig::default(); + initial_config.default_stream_config.storage_class = Some(StorageClass::Standard); + + backend + .create_basin( + basin_name.clone(), + initial_config.clone(), + CreateMode::CreateOnly(None), + ) + .await + .expect("Failed to create basin"); + + let timestamping_reconfig = TimestampingReconfiguration { + mode: Maybe::from(Some(TimestampingMode::Arrival)), + ..Default::default() + }; + let mut stream_reconfig = StreamReconfiguration { + storage_class: Maybe::from(Some(StorageClass::Express)), + retention_policy: Maybe::from(Some(RetentionPolicy::Infinite())), + ..Default::default() + }; + stream_reconfig.timestamping = Maybe::from(Some(timestamping_reconfig)); + + let reconfig = BasinReconfiguration { + default_stream_config: Maybe::from(Some(stream_reconfig)), + create_stream_on_append: Maybe::from(true), + create_stream_on_read: Maybe::from(true), + }; + + let updated = backend + .reconfigure_basin(basin_name.clone(), reconfig) + .await + .expect("Failed to reconfigure basin"); + + assert!(updated.create_stream_on_append); + assert!(updated.create_stream_on_read); + assert_eq!( + updated.default_stream_config.storage_class, + Some(StorageClass::Express) + ); + assert_eq!( + updated.default_stream_config.retention_policy, + Some(RetentionPolicy::Infinite()) + ); + assert_eq!( + updated.default_stream_config.timestamping.mode, + Some(TimestampingMode::Arrival) + ); + + let fetched = backend + .get_basin_config(basin_name) + .await + .expect("Failed to fetch basin config after reconfigure"); + assert_eq!( + fetched.default_stream_config.storage_class, + Some(StorageClass::Express) + ); + assert_eq!( + fetched.default_stream_config.retention_policy, + Some(RetentionPolicy::Infinite()) + ); + assert_eq!( + fetched.default_stream_config.timestamping.mode, + Some(TimestampingMode::Arrival) + ); + assert!(fetched.create_stream_on_append); + assert!(fetched.create_stream_on_read); +} + +#[tokio::test] +async fn test_delete_basin_marks_deleting_and_blocks_create() { + let backend = create_backend().await; + let basin_name = test_basin_name("basin-delete"); + + backend + .create_basin( + basin_name.clone(), + BasinConfig::default(), + CreateMode::CreateOnly(None), + ) + .await + .expect("Failed to create basin"); + + backend + .delete_basin(basin_name.clone()) + .await + .expect("Failed to delete basin"); + + let page = backend + .list_basins(ListBasinsRequest::default()) + .await + .expect("Failed to list basins"); + let info = page + .values + .iter() + .find(|info| info.name == basin_name) + .expect("Deleted basin should appear in listing"); + assert!(matches!(info.state, BasinState::Deleting)); + + let reconfigure_result = backend + .reconfigure_basin(basin_name.clone(), BasinReconfiguration::default()) + .await; + assert!(matches!( + reconfigure_result, + Err(ReconfigureBasinError::BasinDeletionInProgress(_)) + )); + + let recreate_result = backend + .create_basin( + basin_name.clone(), + BasinConfig::default(), + CreateMode::CreateOnly(None), + ) + .await; + assert!(matches!( + recreate_result, + Err(CreateBasinError::BasinDeletionInProgress(_)) + )); + + backend + .delete_basin(basin_name) + .await + .expect("Second delete should be idempotent"); +} + +#[tokio::test] +async fn test_get_nonexistent_basin_config() { + let backend = create_backend().await; + let basin_name = test_basin_name("nonexistent-basin"); + + let result = backend.get_basin_config(basin_name).await; + + assert!(matches!(result, Err(GetBasinConfigError::BasinNotFound(_)))); +} + +#[tokio::test] +async fn test_delete_nonexistent_basin_returns_not_found() { + let backend = create_backend().await; + let basin_name = test_basin_name("delete-missing-basin"); + + let result = backend.delete_basin(basin_name).await; + + assert!(matches!(result, Err(DeleteBasinError::BasinNotFound(_)))); +} + +#[tokio::test] +async fn test_list_basins_empty() { + let backend = create_backend().await; + + let page = backend + .list_basins(ListBasinsRequest::default()) + .await + .expect("Failed to list basins"); + + assert!(page.values.is_empty()); + assert!(!page.has_more); +} + +#[tokio::test] +async fn test_list_basins_multiple() { + let backend = create_backend().await; + + for i in 0..5 { + create_test_basin(&backend, &format!("list-{}", i), BasinConfig::default()).await; + } + + let page = backend + .list_basins(ListBasinsRequest::default()) + .await + .expect("Failed to list basins"); + + assert_eq!(page.values.len(), 5); + assert!(!page.has_more); +} + +#[tokio::test] +async fn test_list_basins_pagination() { + let backend = create_backend().await; + + for i in 0..15 { + create_test_basin( + &backend, + &format!("paginated-{:02}", i), + BasinConfig::default(), + ) + .await; + } + + let page1 = backend + .list_basins( + ListItemsRequestParts { + prefix: BasinNamePrefix::default(), + start_after: BasinNameStartAfter::default(), + limit: 5.into(), + } + .try_into() + .unwrap(), + ) + .await + .expect("Failed to list basins page 1"); + + assert_eq!(page1.values.len(), 5); + assert!(page1.has_more); + assert_eq!(page1.values[0].name.as_ref(), "test-basin-paginated-00"); + assert_eq!(page1.values[4].name.as_ref(), "test-basin-paginated-04"); + + let page2 = backend + .list_basins( + ListItemsRequestParts { + prefix: BasinNamePrefix::default(), + start_after: page1.values.last().unwrap().name.clone().into(), + limit: 5.into(), + } + .try_into() + .unwrap(), + ) + .await + .expect("Failed to list basins page 2"); + + assert_eq!(page2.values.len(), 5); + assert!(page2.has_more); + assert_eq!(page2.values[0].name.as_ref(), "test-basin-paginated-05"); + assert_eq!(page2.values[4].name.as_ref(), "test-basin-paginated-09"); + + let page3 = backend + .list_basins( + ListItemsRequestParts { + prefix: BasinNamePrefix::default(), + start_after: page2.values.last().unwrap().name.clone().into(), + limit: 5.into(), + } + .try_into() + .unwrap(), + ) + .await + .expect("Failed to list basins page 3"); + + assert_eq!(page3.values.len(), 5); + assert!(!page3.has_more); + assert_eq!(page3.values[0].name.as_ref(), "test-basin-paginated-10"); + assert_eq!(page3.values[4].name.as_ref(), "test-basin-paginated-14"); +} + +#[tokio::test] +async fn test_list_basins_prefix_filter() { + let backend = create_backend().await; + + create_test_basin(&backend, "prod-app-1", BasinConfig::default()).await; + create_test_basin(&backend, "prod-app-2", BasinConfig::default()).await; + create_test_basin(&backend, "dev-app-1", BasinConfig::default()).await; + create_test_basin(&backend, "staging-app-1", BasinConfig::default()).await; + + let prod_basins = backend + .list_basins( + ListItemsRequestParts { + prefix: "test-basin-prod-".parse().unwrap(), + start_after: BasinNameStartAfter::default(), + limit: Default::default(), + } + .try_into() + .unwrap(), + ) + .await + .expect("Failed to list basins with prefix"); + + assert_eq!(prod_basins.values.len(), 2); + assert!( + prod_basins + .values + .iter() + .all(|b| b.name.as_ref().starts_with("test-basin-prod-")) + ); +} + +#[tokio::test] +async fn test_list_basins_prefix_with_pagination() { + let backend = create_backend().await; + + for i in 0..10 { + create_test_basin( + &backend, + &format!("prefixed-{:02}", i), + BasinConfig::default(), + ) + .await; + } + create_test_basin(&backend, "other-basin", BasinConfig::default()).await; + + let page1 = backend + .list_basins( + ListItemsRequestParts { + prefix: "test-basin-prefixed-".parse().unwrap(), + start_after: BasinNameStartAfter::default(), + limit: 4.into(), + } + .try_into() + .unwrap(), + ) + .await + .expect("Failed to list basins"); + + assert_eq!(page1.values.len(), 4); + assert!(page1.has_more); + assert!( + page1 + .values + .iter() + .all(|b| b.name.as_ref().starts_with("test-basin-prefixed-")) + ); + + let page2 = backend + .list_basins( + ListItemsRequestParts { + prefix: "test-basin-prefixed-".parse().unwrap(), + start_after: page1.values.last().unwrap().name.clone().into(), + limit: 4.into(), + } + .try_into() + .unwrap(), + ) + .await + .expect("Failed to list basins"); + + assert_eq!(page2.values.len(), 4); + assert!(page2.has_more); + + let page3 = backend + .list_basins( + ListItemsRequestParts { + prefix: "test-basin-prefixed-".parse().unwrap(), + start_after: page2.values.last().unwrap().name.clone().into(), + limit: 4.into(), + } + .try_into() + .unwrap(), + ) + .await + .expect("Failed to list basins"); + + assert_eq!(page3.values.len(), 2); + assert!(!page3.has_more); +} diff --git a/lite/tests/backend/control_plane/mod.rs b/lite/tests/backend/control_plane/mod.rs new file mode 100644 index 00000000..2eec5394 --- /dev/null +++ b/lite/tests/backend/control_plane/mod.rs @@ -0,0 +1,4 @@ +use super::common; + +mod basin; +mod stream; diff --git a/lite/tests/backend/control_plane/stream.rs b/lite/tests/backend/control_plane/stream.rs new file mode 100644 index 00000000..e2cc5a73 --- /dev/null +++ b/lite/tests/backend/control_plane/stream.rs @@ -0,0 +1,921 @@ +use std::time::Duration; + +use bytes::Bytes; +use s2_common::{ + maybe::Maybe, + read_extent::{ReadLimit, ReadUntil}, + record::StreamPosition, + types::{ + config::{ + BasinConfig, OptionalStreamConfig, OptionalTimestampingConfig, RetentionPolicy, + StorageClass, StreamReconfiguration, TimestampingMode, TimestampingReconfiguration, + }, + resources::{CreateMode, ListItemsRequestParts, RequestToken}, + stream::{ + AppendInput, ListStreamsRequest, ReadEnd, ReadFrom, ReadStart, StreamNamePrefix, + StreamNameStartAfter, + }, + }, +}; +use s2_lite::backend::{ + Backend, + error::{ + AppendError, CheckTailError, CreateStreamError, DeleteStreamError, GetStreamConfigError, + ReadError, ReconfigureStreamError, StreamDeletionInProgressError, + }, +}; + +use super::common::*; + +#[tokio::test] +async fn test_create_stream_honors_basin_defaults() { + let backend = create_backend().await; + let basin_name = test_basin_name("stream-defaults"); + + let basin_config = BasinConfig { + default_stream_config: OptionalStreamConfig { + storage_class: Some(StorageClass::Standard), + retention_policy: Some(RetentionPolicy::Infinite()), + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + backend + .create_basin( + basin_name.clone(), + basin_config, + CreateMode::CreateOnly(None), + ) + .await + .expect("Failed to create basin"); + + let stream_name = test_stream_name("stream-defaults"); + + backend + .create_stream( + basin_name.clone(), + stream_name.clone(), + OptionalStreamConfig::default(), + CreateMode::CreateOnly(None), + ) + .await + .expect("Failed to create stream"); + + let config = backend + .get_stream_config(basin_name, stream_name) + .await + .expect("Failed to fetch stream config"); + assert_eq!(config.storage_class, Some(StorageClass::Standard)); + assert_eq!(config.retention_policy, Some(RetentionPolicy::Infinite())); + assert_eq!( + config.timestamping.mode, + Some(TimestampingMode::ClientRequire) + ); +} + +#[tokio::test] +async fn test_get_nonexistent_stream_config() { + let backend = create_backend().await; + let basin_name = + create_test_basin(&backend, "basin-for-missing-stream", BasinConfig::default()).await; + let stream_name = test_stream_name("nonexistent-stream"); + + let result = backend.get_stream_config(basin_name, stream_name).await; + + assert!(matches!( + result, + Err(GetStreamConfigError::StreamNotFound(_)) + )); +} + +#[tokio::test] +async fn test_create_stream_idempotency_and_request_token() { + let backend = create_backend().await; + let basin_name = + create_test_basin(&backend, "stream-idempotency", BasinConfig::default()).await; + let stream_name = test_stream_name("stream-idempotency"); + + let config = OptionalStreamConfig { + storage_class: Some(StorageClass::Express), + ..Default::default() + }; + + let token1: RequestToken = "stream-token-1".parse().unwrap(); + + backend + .create_stream( + basin_name.clone(), + stream_name.clone(), + config.clone(), + CreateMode::CreateOnly(Some(token1.clone())), + ) + .await + .expect("Failed to create stream"); + + let stored_config = backend + .get_stream_config(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to fetch stored stream config"); + assert_eq!(stored_config.storage_class, Some(StorageClass::Express)); + + backend + .create_stream( + basin_name.clone(), + stream_name.clone(), + config.clone(), + CreateMode::CreateOnly(Some(token1.clone())), + ) + .await + .expect("Idempotent create should succeed with same request token"); + + let different_token_result = backend + .create_stream( + basin_name.clone(), + stream_name.clone(), + config.clone(), + CreateMode::CreateOnly(Some("stream-token-2".parse().unwrap())), + ) + .await; + assert!(matches!( + different_token_result, + Err(CreateStreamError::StreamAlreadyExists(_)) + )); + + let mut different_config = config.clone(); + different_config.timestamping.mode = Some(TimestampingMode::Arrival); + let different_config_result = backend + .create_stream( + basin_name.clone(), + stream_name.clone(), + different_config, + CreateMode::CreateOnly(Some(token1)), + ) + .await; + assert!(matches!( + different_config_result, + Err(CreateStreamError::StreamAlreadyExists(_)) + )); +} + +#[tokio::test] +async fn test_reconfigure_stream_updates_selected_fields() { + let backend = create_backend().await; + let basin_name = test_basin_name("stream-reconfigure"); + + let mut basin_config = BasinConfig::default(); + basin_config.default_stream_config.storage_class = Some(StorageClass::Standard); + + backend + .create_basin( + basin_name.clone(), + basin_config, + CreateMode::CreateOnly(None), + ) + .await + .expect("Failed to create basin"); + + let stream_name = test_stream_name("stream-reconfigure"); + let initial_config = OptionalStreamConfig { + retention_policy: Some(RetentionPolicy::Age(Duration::from_secs(60))), + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + ..Default::default() + }, + ..Default::default() + }; + + backend + .create_stream( + basin_name.clone(), + stream_name.clone(), + initial_config, + CreateMode::CreateOnly(None), + ) + .await + .expect("Failed to create stream"); + + let ts_reconfig = TimestampingReconfiguration { + mode: Maybe::from(Some(TimestampingMode::Arrival)), + uncapped: Maybe::from(Some(true)), + }; + let mut stream_reconfig = StreamReconfiguration { + storage_class: Maybe::from(Some(StorageClass::Express)), + retention_policy: Maybe::from(Some(RetentionPolicy::Infinite())), + ..Default::default() + }; + stream_reconfig.timestamping = Maybe::from(Some(ts_reconfig)); + + let updated = backend + .reconfigure_stream(basin_name.clone(), stream_name.clone(), stream_reconfig) + .await + .expect("Failed to reconfigure stream"); + + assert_eq!(updated.storage_class, Some(StorageClass::Express)); + assert_eq!(updated.retention_policy, Some(RetentionPolicy::Infinite())); + assert_eq!(updated.timestamping.mode, Some(TimestampingMode::Arrival)); + assert_eq!(updated.timestamping.uncapped, Some(true)); + + let fetched = backend + .get_stream_config(basin_name, stream_name) + .await + .expect("Failed to fetch stream config after reconfigure"); + assert_eq!(fetched.storage_class, Some(StorageClass::Express)); + assert_eq!(fetched.retention_policy, Some(RetentionPolicy::Infinite())); + assert_eq!(fetched.timestamping.mode, Some(TimestampingMode::Arrival)); + assert_eq!(fetched.timestamping.uncapped, Some(true)); +} + +#[tokio::test] +async fn test_reconfigure_stream_updates_active_streamer() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "stream-reconfigure-active", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"seed"]).await; + + let ts_reconfig = TimestampingReconfiguration { + mode: Maybe::from(Some(TimestampingMode::ClientRequire)), + uncapped: Maybe::default(), + }; + let reconfig = StreamReconfiguration { + timestamping: Maybe::from(Some(ts_reconfig)), + ..Default::default() + }; + + backend + .reconfigure_stream(basin_name.clone(), stream_name.clone(), reconfig) + .await + .expect("Failed to reconfigure stream"); + + backend + .check_tail(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail"); + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"missing timestamp")]), + match_seq_num: None, + fencing_token: None, + }; + let result = backend.append(basin_name, stream_name, input).await; + assert!(matches!(result, Err(AppendError::TimestampMissing(_)))); +} + +#[tokio::test] +async fn test_create_stream_create_or_reconfigure_updates_active_streamer() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "stream-create-or-reconfigure-active", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"seed"]).await; + + let config = OptionalStreamConfig { + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + ..Default::default() + }, + ..Default::default() + }; + + backend + .create_stream( + basin_name.clone(), + stream_name.clone(), + config, + CreateMode::CreateOrReconfigure, + ) + .await + .expect("CreateOrReconfigure should succeed for an existing stream"); + + backend + .check_tail(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail"); + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"missing timestamp")]), + match_seq_num: None, + fencing_token: None, + }; + let result = backend.append(basin_name, stream_name, input).await; + assert!(matches!(result, Err(AppendError::TimestampMissing(_)))); +} + +#[tokio::test] +async fn test_create_stream_fails_when_basin_deleting() { + let backend = create_backend().await; + let basin_name = + create_test_basin(&backend, "stream-basin-deleting", BasinConfig::default()).await; + + backend + .delete_basin(basin_name.clone()) + .await + .expect("Failed to delete basin"); + + let stream_name = test_stream_name("blocked"); + let result = backend + .create_stream( + basin_name, + stream_name, + OptionalStreamConfig::default(), + CreateMode::CreateOnly(None), + ) + .await; + + assert!(matches!( + result, + Err(CreateStreamError::BasinDeletionInProgress(_)) + )); +} + +#[tokio::test] +async fn test_delete_stream_marks_deleted_and_blocks_recreation() { + let backend = create_backend().await; + let basin_name = create_test_basin(&backend, "stream-delete", BasinConfig::default()).await; + let stream_name = create_test_stream( + &backend, + &basin_name, + "stream-delete", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"seed data"]).await; + + backend + .delete_stream(basin_name.clone(), stream_name.clone()) + .await + .unwrap(); + + let page = backend + .list_streams(basin_name.clone(), ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + let info = page + .values + .iter() + .find(|info| info.name == stream_name) + .expect("Deleted stream should appear in listing"); + assert!(info.deleted_at.is_some()); + + let recreate_result = backend + .create_stream( + basin_name.clone(), + stream_name.clone(), + OptionalStreamConfig::default(), + CreateMode::CreateOnly(None), + ) + .await; + assert!(matches!( + recreate_result, + Err(CreateStreamError::StreamDeletionInProgress( + StreamDeletionInProgressError { basin, stream } + )) if basin == basin_name && stream == stream_name + )); + + let reconfigure_result = backend + .reconfigure_stream( + basin_name.clone(), + stream_name.clone(), + StreamReconfiguration::default(), + ) + .await; + assert!(matches!( + reconfigure_result, + Err(ReconfigureStreamError::StreamDeletionInProgress(_)) + )); + + backend + .delete_stream(basin_name.clone(), stream_name.clone()) + .await + .expect("Second delete should be idempotent"); +} + +#[tokio::test] +async fn test_delete_stream_blocks_data_operations() { + let db = create_in_memory_db().await; + let backend_delete = Backend::new(db.clone()); + let backend_new_client = Backend::new(db); + + let basin_name = create_test_basin( + &backend_delete, + "stream-delete-blocks", + BasinConfig::default(), + ) + .await; + let stream_name = create_test_stream( + &backend_delete, + &basin_name, + "stream-delete-blocks", + OptionalStreamConfig::default(), + ) + .await; + + backend_delete + .delete_stream(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to delete stream"); + + let tail = backend_new_client + .check_tail(basin_name.clone(), stream_name.clone()) + .await; + assert!(matches!( + tail, + Err(CheckTailError::StreamDeletionInProgress(_)) + )); + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"should fail")]), + match_seq_num: None, + fencing_token: None, + }; + let append_result = backend_new_client + .append(basin_name.clone(), stream_name.clone(), input) + .await; + assert!(matches!( + append_result, + Err(AppendError::StreamDeletionInProgress(_)) + )); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd::default(); + let read_result = backend_new_client + .read(basin_name, stream_name, start, end) + .await; + assert!(matches!( + read_result, + Err(ReadError::StreamDeletionInProgress(_)) + )); +} + +#[tokio::test] +async fn test_delete_stream_nonexistent_returns_not_found() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "stream-delete-nonexistent", + BasinConfig::default(), + ) + .await; + let stream_name = test_stream_name("missing"); + + let result = backend.delete_stream(basin_name, stream_name).await; + assert!(matches!(result, Err(DeleteStreamError::StreamNotFound(_)))); +} + +#[tokio::test] +async fn test_list_streams_empty() { + let backend = create_backend().await; + let basin_name = create_test_basin(&backend, "empty-streams", BasinConfig::default()).await; + + let page = backend + .list_streams(basin_name.clone(), ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + + assert!(page.values.is_empty()); + assert!(!page.has_more); +} + +#[tokio::test] +async fn test_list_streams_multiple() { + let backend = create_backend().await; + let basin_name = create_test_basin(&backend, "list-streams", BasinConfig::default()).await; + + for i in 0..5 { + create_test_stream( + &backend, + &basin_name, + &format!("list-{}", i), + OptionalStreamConfig::default(), + ) + .await; + } + + let page = backend + .list_streams(basin_name.clone(), ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + + assert_eq!(page.values.len(), 5); + assert!(!page.has_more); +} + +#[tokio::test] +async fn test_auto_create_stream_on_append() { + let backend = create_backend().await; + let basin_config = BasinConfig { + create_stream_on_append: true, + ..Default::default() + }; + let basin_name = create_test_basin(&backend, "auto-create-append", basin_config).await; + let stream_name = test_stream_name("auto"); + + let stream_list = backend + .list_streams(basin_name.clone(), ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + assert_eq!(stream_list.values.len(), 0); + + let ack = append_payloads(&backend, &basin_name, &stream_name, &[b"auto created"]).await; + assert_eq!(ack.start.seq_num, 0); + + let stream_list = backend + .list_streams(basin_name.clone(), ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + assert_eq!(stream_list.values.len(), 1); + assert_eq!(stream_list.values[0].name, stream_name); + + let config = backend + .get_stream_config(basin_name, stream_name) + .await + .expect("Failed to get stream config"); + assert!(config.storage_class.is_some()); +} + +#[tokio::test] +async fn test_auto_create_stream_on_read() { + let backend = create_backend().await; + let basin_config = BasinConfig { + create_stream_on_read: true, + ..Default::default() + }; + let basin_name = create_test_basin(&backend, "auto-create-read", basin_config).await; + let stream_name = test_stream_name("auto"); + + let stream_list = backend + .list_streams(basin_name.clone(), ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + assert_eq!(stream_list.values.len(), 0); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + + let session = backend + .read(basin_name.clone(), stream_name.clone(), start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + assert_eq!(records.len(), 0); + + let stream_list = backend + .list_streams(basin_name.clone(), ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + assert_eq!(stream_list.values.len(), 1); + assert_eq!(stream_list.values[0].name, stream_name); +} + +#[tokio::test] +async fn test_auto_create_disabled_append_fails() { + let backend = create_backend().await; + let basin_config = BasinConfig { + create_stream_on_append: false, + ..Default::default() + }; + let basin_name = create_test_basin(&backend, "no-auto-create-append", basin_config).await; + let stream_name = test_stream_name("missing"); + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"should fail")]), + match_seq_num: None, + fencing_token: None, + }; + + let result = backend.append(basin_name, stream_name, input).await; + + assert!(matches!(result, Err(AppendError::StreamNotFound(_)))); +} + +#[tokio::test] +async fn test_auto_create_disabled_read_fails() { + let backend = create_backend().await; + let basin_config = BasinConfig { + create_stream_on_read: false, + ..Default::default() + }; + let basin_name = create_test_basin(&backend, "no-auto-create-read", basin_config).await; + let stream_name = test_stream_name("missing"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd::default(); + + let result = backend.read(basin_name, stream_name, start, end).await; + + assert!(matches!(result, Err(ReadError::StreamNotFound(_)))); +} + +#[tokio::test] +async fn test_auto_create_check_tail() { + let backend = create_backend().await; + let basin_config = BasinConfig { + create_stream_on_read: true, + ..Default::default() + }; + let basin_name = create_test_basin(&backend, "auto-create-tail", basin_config).await; + let stream_name = test_stream_name("auto"); + + let tail = backend + .check_tail(basin_name.clone(), stream_name.clone()) + .await + .expect("check_tail should auto-create stream"); + assert_eq!(tail, StreamPosition::MIN); + + let stream_list = backend + .list_streams(basin_name, ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + assert_eq!(stream_list.values.len(), 1); + assert_eq!(stream_list.values[0].name, stream_name); +} + +#[tokio::test] +async fn test_auto_create_race_condition_append() { + let backend = create_backend().await; + let basin_config = BasinConfig { + create_stream_on_append: true, + ..Default::default() + }; + let basin_name = create_test_basin(&backend, "auto-race-append", basin_config).await; + let stream_name = test_stream_name("racing"); + + let mut handles = vec![]; + for i in 0..10 { + let backend = backend.clone(); + let basin_name = basin_name.clone(); + let stream_name = stream_name.clone(); + let handle = tokio::spawn(async move { + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from(format!("racer-{}", i))]), + match_seq_num: None, + fencing_token: None, + }; + for _ in 0..5 { + match backend + .append(basin_name.clone(), stream_name.clone(), input.clone()) + .await + { + Ok(ack) => return Ok(ack), + Err(AppendError::TransactionConflict(_)) + | Err(AppendError::StreamNotFound(_)) => { + tokio::time::sleep(Duration::from_millis(20)).await; + continue; + } + Err(e) => return Err(e), + } + } + backend.append(basin_name, stream_name, input).await + }); + handles.push(handle); + } + + let mut success_count = 0; + let mut errors = vec![]; + for handle in handles { + match handle.await.unwrap() { + Ok(_) => success_count += 1, + Err(e) => errors.push(format!("{:?}", e)), + } + } + + if success_count != 10 { + eprintln!("Success count: {}, errors: {:?}", success_count, errors); + } + assert_eq!(success_count, 10); + + let tail = backend + .check_tail(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, 10); + + let stream_list = backend + .list_streams(basin_name, ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + assert_eq!(stream_list.values.len(), 1); +} + +#[tokio::test] +async fn test_auto_create_race_condition_read() { + let backend = create_backend().await; + let basin_config = BasinConfig { + create_stream_on_read: true, + ..Default::default() + }; + let basin_name = create_test_basin(&backend, "auto-race-read", basin_config).await; + let stream_name = test_stream_name("racing"); + + let mut handles = vec![]; + for _ in 0..10 { + let backend = backend.clone(); + let basin_name = basin_name.clone(); + let stream_name = stream_name.clone(); + let handle = tokio::spawn(async move { + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + for _ in 0..5 { + match backend + .read(basin_name.clone(), stream_name.clone(), start, end) + .await + { + Ok(session) => { + drop(session); + return Ok::<(), ReadError>(()); + } + Err(ReadError::TransactionConflict(_)) | Err(ReadError::StreamNotFound(_)) => { + tokio::time::sleep(Duration::from_millis(20)).await; + continue; + } + Err(e) => return Err(e), + } + } + match backend.read(basin_name, stream_name, start, end).await { + Ok(session) => { + drop(session); + Ok::<(), ReadError>(()) + } + Err(e) => Err(e), + } + }); + handles.push(handle); + } + + let mut success_count = 0; + let mut errors = vec![]; + for handle in handles { + match handle.await.unwrap() { + Ok(_) => success_count += 1, + Err(e) => errors.push(format!("{:?}", e)), + } + } + + if success_count != 10 { + eprintln!("Success count: {}, errors: {:?}", success_count, errors); + } + assert_eq!(success_count, 10); + + let stream_list = backend + .list_streams(basin_name, ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + assert_eq!(stream_list.values.len(), 1); +} + +#[tokio::test] +async fn test_list_streams_pagination() { + let backend = create_backend().await; + let basin_name = create_test_basin(&backend, "stream-pagination", BasinConfig::default()).await; + + for i in 0..12 { + create_test_stream( + &backend, + &basin_name, + &format!("stream-{:02}", i), + OptionalStreamConfig::default(), + ) + .await; + } + + let page1 = backend + .list_streams( + basin_name.clone(), + ListItemsRequestParts { + prefix: StreamNamePrefix::default(), + start_after: StreamNameStartAfter::default(), + limit: 5.into(), + } + .try_into() + .unwrap(), + ) + .await + .expect("Failed to list streams page 1"); + + assert_eq!(page1.values.len(), 5); + assert!(page1.has_more); + + let page2 = backend + .list_streams( + basin_name.clone(), + ListItemsRequestParts { + prefix: StreamNamePrefix::default(), + start_after: page1.values.last().unwrap().name.clone().into(), + limit: 5.into(), + } + .try_into() + .unwrap(), + ) + .await + .expect("Failed to list streams page 2"); + + assert_eq!(page2.values.len(), 5); + assert!(page2.has_more); + + let page3 = backend + .list_streams( + basin_name.clone(), + ListItemsRequestParts { + prefix: StreamNamePrefix::default(), + start_after: page2.values.last().unwrap().name.clone().into(), + limit: 5.into(), + } + .try_into() + .unwrap(), + ) + .await + .expect("Failed to list streams page 3"); + + assert_eq!(page3.values.len(), 2); + assert!(!page3.has_more); +} + +#[tokio::test] +async fn test_list_streams_prefix_filter() { + let backend = create_backend().await; + let basin_name = create_test_basin(&backend, "stream-prefix", BasinConfig::default()).await; + + create_test_stream( + &backend, + &basin_name, + "metrics-cpu", + OptionalStreamConfig::default(), + ) + .await; + create_test_stream( + &backend, + &basin_name, + "metrics-memory", + OptionalStreamConfig::default(), + ) + .await; + create_test_stream( + &backend, + &basin_name, + "logs-app", + OptionalStreamConfig::default(), + ) + .await; + create_test_stream( + &backend, + &basin_name, + "traces-span", + OptionalStreamConfig::default(), + ) + .await; + + let metrics_streams = backend + .list_streams( + basin_name.clone(), + ListItemsRequestParts { + prefix: "test-stream-metrics-".parse().unwrap(), + start_after: StreamNameStartAfter::default(), + limit: Default::default(), + } + .try_into() + .unwrap(), + ) + .await + .expect("Failed to list streams with prefix"); + + assert_eq!(metrics_streams.values.len(), 2); + assert!( + metrics_streams + .values + .iter() + .all(|s| s.name.as_ref().starts_with("test-stream-metrics-")) + ); +} diff --git a/lite/tests/backend/data_plane/append.rs b/lite/tests/backend/data_plane/append.rs new file mode 100644 index 00000000..01eb482c --- /dev/null +++ b/lite/tests/backend/data_plane/append.rs @@ -0,0 +1,551 @@ +use std::time::Duration; + +use bytes::Bytes; +use futures::StreamExt; +use s2_common::{ + read_extent::{ReadLimit, ReadUntil}, + record::FencingToken, + types::{ + config::{OptionalStreamConfig, OptionalTimestampingConfig, TimestampingMode}, + stream::{AppendInput, AppendRecord, AppendRecordBatch, ReadEnd, ReadFrom, ReadStart}, + }, +}; +use s2_lite::backend::error::{AppendConditionFailedError, AppendError}; + +use super::common::*; + +#[tokio::test] +async fn test_append_multiple_records() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-multiple", + "multiple", + OptionalStreamConfig::default(), + ) + .await; + + let ack = append_payloads( + &backend, + &basin_name, + &stream_name, + &[b"record 1", b"record 2", b"record 3"], + ) + .await; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 3); +} + +#[tokio::test] +async fn test_append_empty_batch() { + let empty_batch: Result = Vec::::new().try_into(); + + assert!( + empty_batch.is_err(), + "Empty batches should be rejected by AppendRecordBatch" + ); +} + +#[tokio::test] +async fn test_append_fencing_token_conditions() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-fencing", + "mismatch", + OptionalStreamConfig::default(), + ) + .await; + + let matching_token = FencingToken::default(); + + let matching_input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"matched token")]), + match_seq_num: None, + fencing_token: Some(matching_token.clone()), + }; + + let ack = backend + .append(basin_name.clone(), stream_name.clone(), matching_input) + .await + .expect("Expected append to succeed with matching fencing token"); + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let new_token: FencingToken = "updated-token".parse().unwrap(); + let command_batch: AppendRecordBatch = vec![create_fencing_command_record(new_token.clone())] + .try_into() + .unwrap(); + + let command_input = AppendInput { + records: command_batch, + match_seq_num: Some(ack.end.seq_num), + fencing_token: Some(matching_token.clone()), + }; + + let command_ack = backend + .append(basin_name.clone(), stream_name.clone(), command_input) + .await + .expect("Expected fencing command to succeed"); + + assert_eq!(command_ack.start.seq_num, ack.end.seq_num); + assert_eq!(command_ack.end.seq_num, ack.end.seq_num + 1); + + let mismatched_input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"mismatched token")]), + match_seq_num: Some(command_ack.end.seq_num), + fencing_token: Some(matching_token.clone()), + }; + + let result = backend + .append(basin_name.clone(), stream_name.clone(), mismatched_input) + .await; + + let Err(AppendError::ConditionFailed(AppendConditionFailedError::FencingTokenMismatch { + expected, + actual, + .. + })) = result + else { + panic!("Expected fencing token mismatch"); + }; + assert_eq!(expected, matching_token); + assert_eq!(actual, new_token); + + let refreshed_input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"updated token accepted")]), + match_seq_num: Some(command_ack.end.seq_num), + fencing_token: Some(new_token.clone()), + }; + + let refreshed_ack = backend + .append(basin_name, stream_name, refreshed_input) + .await + .expect("Expected append to succeed with updated fencing token"); + + assert_eq!(refreshed_ack.start.seq_num, command_ack.end.seq_num); + assert_eq!(refreshed_ack.end.seq_num, command_ack.end.seq_num + 1); +} + +#[tokio::test] +async fn test_append_requires_timestamp() { + let stream_config = OptionalStreamConfig { + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + ..Default::default() + }, + ..Default::default() + }; + + let (backend, basin_name, stream_name) = + setup_backend_with_stream("append-timestamp", "require", stream_config).await; + + let missing_timestamp = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"missing timestamp")]), + match_seq_num: None, + fencing_token: None, + }; + + let result = backend + .append(basin_name.clone(), stream_name.clone(), missing_timestamp) + .await; + + assert!(matches!(result, Err(AppendError::TimestampMissing(_)))); + + let with_timestamp = AppendInput { + records: create_test_record_batch_with_timestamps(vec![( + Bytes::from_static(b"with timestamp"), + 123, + )]), + match_seq_num: None, + fencing_token: None, + }; + + let ack = backend + .append(basin_name, stream_name, with_timestamp) + .await + .expect("Expected append to succeed when timestamp is provided"); + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); +} + +#[tokio::test] +async fn test_append_with_seq_num_match() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("seq-num-match", "match", OptionalStreamConfig::default()).await; + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"first record")]), + match_seq_num: Some(0), + fencing_token: None, + }; + + let ack = backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append with matching seq_num"); + + assert_eq!(ack.start.seq_num, 0); + + let input2 = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"second record")]), + match_seq_num: Some(1), + fencing_token: None, + }; + + let ack2 = backend + .append(basin_name.clone(), stream_name.clone(), input2) + .await + .expect("Failed to append with matching seq_num"); + + assert_eq!(ack2.start.seq_num, 1); +} + +#[tokio::test] +async fn test_append_with_seq_num_mismatch() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "seq-num-mismatch", + "mismatch", + OptionalStreamConfig::default(), + ) + .await; + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"first record")]), + match_seq_num: Some(0), + fencing_token: None, + }; + + backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append first record"); + + let input2 = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"second record")]), + match_seq_num: Some(0), + fencing_token: None, + }; + + let result = backend + .append(basin_name.clone(), stream_name.clone(), input2) + .await; + + assert!(matches!( + result, + Err(AppendError::ConditionFailed( + AppendConditionFailedError::SeqNumMismatch { .. } + )) + )); +} + +#[tokio::test] +async fn test_append_session_basic() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-basic", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let inputs = futures::stream::iter(vec![ + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"batch 1")]), + match_seq_num: None, + fencing_token: None, + }, + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"batch 2")]), + match_seq_num: None, + fencing_token: None, + }, + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"batch 3")]), + match_seq_num: None, + fencing_token: None, + }, + ]); + + let session = backend + .append_session(basin_name.clone(), stream_name.clone(), inputs) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let mut acks = Vec::new(); + while let Some(result) = session.next().await { + let ack = result.expect("Append should succeed"); + acks.push(ack); + } + + assert_eq!(acks.len(), 3); + assert_eq!(acks[0].start.seq_num, 0); + assert_eq!(acks[0].end.seq_num, 1); + assert_eq!(acks[1].start.seq_num, 1); + assert_eq!(acks[1].end.seq_num, 2); + assert_eq!(acks[2].start.seq_num, 2); + assert_eq!(acks[2].end.seq_num, 3); + + let tail = backend + .check_tail(basin_name, stream_name) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, 3); +} + +#[tokio::test] +async fn test_append_session_empty() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-empty", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let inputs = futures::stream::iter(vec![]); + + let session = backend + .append_session(basin_name.clone(), stream_name.clone(), inputs) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let ack = session.next().await; + assert!(ack.is_none()); + + let tail = backend + .check_tail(basin_name, stream_name) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, 0); +} + +#[tokio::test] +async fn test_append_session_multiple_records_per_batch() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-multi", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let inputs = futures::stream::iter(vec![ + AppendInput { + records: create_test_record_batch(vec![ + Bytes::from_static(b"record 1"), + Bytes::from_static(b"record 2"), + ]), + match_seq_num: None, + fencing_token: None, + }, + AppendInput { + records: create_test_record_batch(vec![ + Bytes::from_static(b"record 3"), + Bytes::from_static(b"record 4"), + Bytes::from_static(b"record 5"), + ]), + match_seq_num: None, + fencing_token: None, + }, + ]); + + let session = backend + .append_session(basin_name.clone(), stream_name.clone(), inputs) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let ack1 = session + .next() + .await + .expect("Should have first ack") + .expect("First append should succeed"); + assert_eq!(ack1.start.seq_num, 0); + assert_eq!(ack1.end.seq_num, 2); + + let ack2 = session + .next() + .await + .expect("Should have second ack") + .expect("Second append should succeed"); + assert_eq!(ack2.start.seq_num, 2); + assert_eq!(ack2.end.seq_num, 5); + + let tail = backend + .check_tail(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, 5); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + + let read_session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut read_session = Box::pin(read_session); + let records = collect_records(&mut read_session).await; + + assert_eq!(records.len(), 5); +} + +#[tokio::test] +async fn test_append_session_with_seq_num_conditions() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-seqnum", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let inputs = futures::stream::iter(vec![ + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"batch 1")]), + match_seq_num: Some(0), + fencing_token: None, + }, + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"batch 2")]), + match_seq_num: Some(1), + fencing_token: None, + }, + ]); + + let session = backend + .append_session(basin_name.clone(), stream_name.clone(), inputs) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let ack1 = session + .next() + .await + .expect("Should have first ack") + .expect("First append should succeed"); + assert_eq!(ack1.start.seq_num, 0); + + let ack2 = session + .next() + .await + .expect("Should have second ack") + .expect("Second append should succeed"); + assert_eq!(ack2.start.seq_num, 1); +} + +#[tokio::test] +async fn test_append_session_seq_num_mismatch() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-mismatch", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"existing data"]).await; + + let inputs = futures::stream::iter(vec![AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"batch 1")]), + match_seq_num: Some(0), + fencing_token: None, + }]); + + let session = backend + .append_session(basin_name, stream_name, inputs) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let result = session.next().await.expect("Should have result"); + assert!(matches!(result, Err(AppendError::ConditionFailed(_)))); +} + +#[tokio::test] +async fn test_append_session_with_fencing_token() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-fence", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let token = FencingToken::default(); + + let inputs = futures::stream::iter(vec![ + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"batch 1")]), + match_seq_num: None, + fencing_token: Some(token.clone()), + }, + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"batch 2")]), + match_seq_num: None, + fencing_token: Some(token.clone()), + }, + ]); + + let session = backend + .append_session(basin_name, stream_name, inputs) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let ack1 = session + .next() + .await + .expect("Should have first ack") + .expect("First append should succeed"); + assert_eq!(ack1.start.seq_num, 0); + + let ack2 = session + .next() + .await + .expect("Should have second ack") + .expect("Second append should succeed"); + assert_eq!(ack2.start.seq_num, 1); +} + +#[tokio::test] +async fn test_append_session_large_batches() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-large", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let large_record = vec![0u8; 100_000]; + let batch_count = 50; + + let inputs = futures::stream::iter((0..batch_count).map(|_| AppendInput { + records: create_test_record_batch(vec![Bytes::from(large_record.clone())]), + match_seq_num: None, + fencing_token: None, + })); + + let session = backend + .append_session(basin_name.clone(), stream_name.clone(), inputs) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let mut ack_count = 0; + while let Some(result) = session.next().await { + result.expect("Append should succeed"); + ack_count += 1; + } + + assert_eq!(ack_count, batch_count); + + let tail = backend + .check_tail(basin_name, stream_name) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, batch_count); +} diff --git a/lite/tests/backend/data_plane/mixed.rs b/lite/tests/backend/data_plane/mixed.rs new file mode 100644 index 00000000..c4d566ad --- /dev/null +++ b/lite/tests/backend/data_plane/mixed.rs @@ -0,0 +1,266 @@ +use std::time::Duration; + +use bytes::Bytes; +use s2_common::{ + read_extent::{ReadLimit, ReadUntil}, + types::{ + config::{OptionalStreamConfig, RetentionPolicy, StorageClass, StreamReconfiguration}, + stream::{AppendInput, ReadEnd, ReadFrom, ReadStart}, + }, +}; +use s2_lite::backend::error::{AppendError, CheckTailError, ReadError}; + +use super::common::*; + +#[tokio::test] +async fn test_operations_on_nonexistent_basin() { + let backend = create_backend().await; + let basin_name = test_basin_name("nonexistent"); + let stream_name = test_stream_name("nonexistent"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: None, + }; + + let read_result = backend + .read(basin_name.clone(), stream_name.clone(), start, end) + .await; + assert!(matches!(read_result, Err(ReadError::BasinNotFound(_)))); + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"test data")]), + match_seq_num: None, + fencing_token: None, + }; + let append_result = backend + .append(basin_name.clone(), stream_name.clone(), input) + .await; + assert!(matches!(append_result, Err(AppendError::BasinNotFound(_)))); + + let check_tail_result = backend.check_tail(basin_name, stream_name).await; + assert!(matches!( + check_tail_result, + Err(CheckTailError::BasinNotFound(_)) + )); +} + +#[tokio::test] +async fn test_concurrent_appends_to_same_stream() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "concurrent-append", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let mut handles = vec![]; + for i in 0..20 { + let backend = backend.clone(); + let basin_name = basin_name.clone(); + let stream_name = stream_name.clone(); + let handle = tokio::spawn(async move { + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from(format!("concurrent-{}", i))]), + match_seq_num: None, + fencing_token: None, + }; + backend.append(basin_name, stream_name, input).await + }); + handles.push(handle); + } + + let mut success_count = 0; + for handle in handles { + if handle.await.unwrap().is_ok() { + success_count += 1; + } + } + + assert_eq!(success_count, 20); + + let tail = backend + .check_tail(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, 20); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + assert_eq!(records.len(), 20); +} + +#[tokio::test] +async fn test_read_while_appending() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-while-append", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"initial"]).await; + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + + let append_handle = tokio::spawn(async move { + for i in 0..10 { + append_payloads( + &backend_clone, + &basin_clone, + &stream_clone, + &[format!("append-{}", i).as_bytes()], + ) + .await; + tokio::time::sleep(Duration::from_millis(10)).await; + } + }); + + tokio::time::sleep(Duration::from_millis(20)).await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Count(5), + until: ReadUntil::Unbounded, + wait: None, + }; + + let session = backend + .read(basin_name.clone(), stream_name.clone(), start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + assert!(!records.is_empty()); + assert!(records.len() <= 5); + + append_handle.await.unwrap(); + + let tail = backend + .check_tail(basin_name, stream_name) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, 11); +} + +#[tokio::test] +async fn test_concurrent_reconfigure_during_append() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "concurrent-reconfig", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let backend_append = backend.clone(); + let basin_append = basin_name.clone(); + let stream_append = stream_name.clone(); + + let append_handle = tokio::spawn(async move { + for i in 0..10 { + append_payloads( + &backend_append, + &basin_append, + &stream_append, + &[format!("data-{}", i).as_bytes()], + ) + .await; + tokio::time::sleep(Duration::from_millis(5)).await; + } + }); + + tokio::time::sleep(Duration::from_millis(10)).await; + + let reconfig = StreamReconfiguration { + storage_class: s2_common::maybe::Maybe::from(Some(StorageClass::Express)), + retention_policy: s2_common::maybe::Maybe::from(Some(RetentionPolicy::Infinite())), + timestamping: s2_common::maybe::Maybe::default(), + delete_on_empty: s2_common::maybe::Maybe::default(), + }; + + let updated_config = backend + .reconfigure_stream(basin_name.clone(), stream_name.clone(), reconfig) + .await + .expect("Failed to reconfigure stream during appends"); + assert_eq!(updated_config.storage_class, Some(StorageClass::Express)); + + append_handle.await.unwrap(); + + let tail = backend + .check_tail(basin_name, stream_name) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, 10); +} + +#[tokio::test] +async fn test_concurrent_reads_same_stream() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "concurrent-reads", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + for i in 0..20 { + append_payloads( + &backend, + &basin_name, + &stream_name, + &[format!("record-{}", i).as_bytes()], + ) + .await; + } + + let mut handles = vec![]; + for _ in 0..10 { + let backend = backend.clone(); + let basin_name = basin_name.clone(); + let stream_name = stream_name.clone(); + let handle = tokio::spawn(async move { + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + let session = backend.read(basin_name, stream_name, start, end).await?; + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + Ok::(records.len()) + }); + handles.push(handle); + } + + for handle in handles { + let count = handle.await.unwrap().expect("Read should succeed"); + assert_eq!(count, 20); + } +} diff --git a/lite/tests/backend/data_plane/mod.rs b/lite/tests/backend/data_plane/mod.rs new file mode 100644 index 00000000..6cb1196f --- /dev/null +++ b/lite/tests/backend/data_plane/mod.rs @@ -0,0 +1,6 @@ +use super::common; + +mod append; +mod mixed; +mod read; +mod read_follow; diff --git a/lite/tests/backend/data_plane/read.rs b/lite/tests/backend/data_plane/read.rs new file mode 100644 index 00000000..93515a9d --- /dev/null +++ b/lite/tests/backend/data_plane/read.rs @@ -0,0 +1,943 @@ +use std::time::Duration; + +use bytes::Bytes; +use futures::StreamExt; +use s2_common::{ + read_extent::{ReadLimit, ReadUntil}, + record::{MeteredSize, StreamPosition}, + types::{ + config::{OptionalStreamConfig, OptionalTimestampingConfig, TimestampingMode}, + stream::{AppendInput, ReadEnd, ReadFrom, ReadSessionOutput, ReadStart}, + }, +}; +use s2_lite::backend::error::{CheckTailError, ReadError}; + +use super::common::*; + +#[tokio::test] +async fn test_check_tail_scenarios() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("check-tail", "stream", OptionalStreamConfig::default()).await; + + let empty_tail = backend + .check_tail(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail on empty stream"); + assert_eq!(empty_tail, StreamPosition::MIN); + + let ack = append_payloads(&backend, &basin_name, &stream_name, &[b"test data"]).await; + + let tail_after_append = backend + .check_tail(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail after append"); + assert_eq!(tail_after_append, ack.end); + + let missing_backend = create_backend().await; + let missing_result = missing_backend + .check_tail( + test_basin_name("check-tail-missing"), + test_stream_name("missing"), + ) + .await; + + assert!(matches!( + missing_result, + Err(CheckTailError::BasinNotFound(_)) + )); +} + +#[tokio::test] +async fn test_read_from_beginning() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-from-beginning", + "read", + OptionalStreamConfig::default(), + ) + .await; + + append_repeat(&backend, &basin_name, &stream_name, b"test data", 5).await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + + let session = backend + .read(basin_name.clone(), stream_name.clone(), start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + assert_eq!(records.len(), 5); +} + +#[tokio::test] +async fn test_read_with_limit() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("read-with-limit", "limit", OptionalStreamConfig::default()) + .await; + + append_repeat(&backend, &basin_name, &stream_name, b"test data", 10).await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Count(5), + until: ReadUntil::Unbounded, + wait: None, + }; + + let session = backend + .read(basin_name.clone(), stream_name.clone(), start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + assert!(records.len() <= 5); +} + +#[tokio::test] +async fn test_read_tail_exceeded_without_clamp() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-tail-exceeded", + "no-clamp", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"initial record"]).await; + + let start = ReadStart { + from: ReadFrom::SeqNum(10), + clamp: false, + }; + let end = ReadEnd::default(); + + let result = backend.read(basin_name, stream_name, start, end).await; + + assert!(matches!(result, Err(ReadError::TailExceeded(_)))); +} + +#[tokio::test] +async fn test_read_tail_exceeded_with_clamp() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-tail-clamped", + "clamp", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads( + &backend, + &basin_name, + &stream_name, + &[b"record 1", b"record 2", b"record 3"], + ) + .await; + + let start = ReadStart { + from: ReadFrom::SeqNum(100), + clamp: true, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Read should succeed with clamp when tail exceeded"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + assert!(records.is_empty()); +} + +#[tokio::test] +async fn test_read_from_tail_offset() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-tail-offset", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + for payload in ["record 1", "record 2", "record 3", "record 4", "record 5"] { + append_payloads(&backend, &basin_name, &stream_name, &[payload.as_bytes()]).await; + } + + let start = ReadStart { + from: ReadFrom::TailOffset(2), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + let bodies = envelope_bodies(&records); + + assert_eq!(bodies, vec![b"record 4".to_vec(), b"record 5".to_vec()]); +} + +#[tokio::test] +async fn test_read_timestamp_range() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("read-timestamp", "range", OptionalStreamConfig::default()).await; + + let timestamped_records = vec![ + (Bytes::from_static(b"ts-100"), 100), + (Bytes::from_static(b"ts-200"), 200), + (Bytes::from_static(b"ts-300"), 300), + ]; + + let input = AppendInput { + records: create_test_record_batch_with_timestamps(timestamped_records), + match_seq_num: None, + fencing_token: None, + }; + + backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append records with timestamps"); + + let start = ReadStart { + from: ReadFrom::Timestamp(150), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Timestamp(300), + wait: Some(Duration::ZERO), + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + + let mut session = Box::pin(session); + let records = loop { + let output = tokio::time::timeout(Duration::from_secs(1), session.as_mut().next()) + .await + .expect("Timed out waiting for read output"); + match output { + Some(Ok(ReadSessionOutput::Batch(batch))) => { + break batch.records.iter().cloned().collect::>(); + } + Some(Ok(ReadSessionOutput::Heartbeat(_))) => continue, + Some(Err(e)) => panic!("Read error: {:?}", e), + None => panic!("Read session ended without delivering expected batch"), + } + }; + drop(session); + + let bodies = envelope_bodies(&records); + + assert_eq!(bodies, vec![b"ts-200".to_vec()]); + assert!(records.iter().all(|record| record.position.timestamp < 300)); +} + +#[tokio::test] +async fn test_read_from_timestamp_includes_duplicate_timestamps() { + let stream_config = OptionalStreamConfig { + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + ..Default::default() + }, + ..Default::default() + }; + + let (backend, basin_name, stream_name) = + setup_backend_with_stream("read-dupe-timestamp", "stream", stream_config).await; + + let timestamp = 1000; + let input = AppendInput { + records: create_test_record_batch_with_timestamps(vec![ + (Bytes::from_static(b"dup-1"), timestamp), + (Bytes::from_static(b"dup-2"), timestamp), + (Bytes::from_static(b"dup-3"), timestamp), + ]), + match_seq_num: None, + fencing_token: None, + }; + + backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append duplicate timestamp records"); + + let start = ReadStart { + from: ReadFrom::Timestamp(timestamp), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + let bodies = envelope_bodies(&records); + assert_eq!( + bodies, + vec![b"dup-1".to_vec(), b"dup-2".to_vec(), b"dup-3".to_vec()] + ); +} + +#[tokio::test] +async fn test_read_from_tail_times_out_without_new_data() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("read-tail-wait", "idle", OptionalStreamConfig::default()).await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"seed data"]).await; + + let start = ReadStart { + from: ReadFrom::TailOffset(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::from_millis(100)), + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + tokio::pin!(session); + + let first_output = tokio::time::timeout(Duration::from_secs(1), session.next()) + .await + .expect("Timed out waiting for initial heartbeat") + .expect("Read session ended unexpectedly"); + + match first_output { + Ok(ReadSessionOutput::Heartbeat(_)) => {} + other => panic!("Unexpected first output: {:?}", other), + } + + let second_output = tokio::time::timeout(Duration::from_secs(1), session.next()) + .await + .expect("Timed out waiting for read session to finish"); + + assert!( + second_output.is_none(), + "Read session produced unexpected additional output" + ); +} + +#[tokio::test] +async fn test_read_with_bytes_limit() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-bytes-limit", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let large_body = vec![b'X'; 5000]; + for _i in 0..10 { + append_payloads(&backend, &basin_name, &stream_name, &[&large_body]).await; + } + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Bytes(15000), + until: ReadUntil::Unbounded, + wait: None, + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + assert!(records.len() >= 2); + assert!(records.len() <= 4); + + let total_bytes: usize = records.iter().map(|r| r.record.metered_size()).sum(); + assert!(total_bytes <= 20000); +} + +#[tokio::test] +async fn test_read_with_count_or_bytes_limit_count_wins() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-count-or-bytes-count", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let small_body = vec![b'Y'; 100]; + for _i in 0..20 { + append_payloads(&backend, &basin_name, &stream_name, &[&small_body]).await; + } + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::from_count_and_bytes(Some(5), Some(1_000_000)), + until: ReadUntil::Unbounded, + wait: None, + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + assert_eq!(records.len(), 5); +} + +#[tokio::test] +async fn test_read_with_count_or_bytes_limit_bytes_wins() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-count-or-bytes-bytes", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let large_body = vec![b'Z'; 10000]; + for _i in 0..20 { + append_payloads(&backend, &basin_name, &stream_name, &[&large_body]).await; + } + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::from_count_and_bytes(Some(100), Some(35000)), + until: ReadUntil::Unbounded, + wait: None, + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + assert!(records.len() >= 2); + assert!(records.len() <= 5); + assert!(records.len() < 100); + + let total_bytes: usize = records.iter().map(|r| r.record.metered_size()).sum(); + assert!(total_bytes <= 50000); +} + +#[tokio::test] +async fn test_read_until_timestamp_basic() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-until-timestamp", + "basic", + OptionalStreamConfig::default(), + ) + .await; + + let timestamped_records = vec![ + (Bytes::from_static(b"ts-1000"), 1000), + (Bytes::from_static(b"ts-2000"), 2000), + (Bytes::from_static(b"ts-3000"), 3000), + (Bytes::from_static(b"ts-4000"), 4000), + (Bytes::from_static(b"ts-5000"), 5000), + ]; + + let input = AppendInput { + records: create_test_record_batch_with_timestamps(timestamped_records), + match_seq_num: None, + fencing_token: None, + }; + + backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append timestamped records"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Timestamp(3500), + wait: None, + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + let bodies = envelope_bodies(&records); + assert_eq!( + bodies, + vec![ + b"ts-1000".to_vec(), + b"ts-2000".to_vec(), + b"ts-3000".to_vec() + ] + ); + assert!( + records + .iter() + .all(|record| record.position.timestamp < 3500) + ); +} + +#[tokio::test] +async fn test_read_until_timestamp_exact_boundary() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-until-boundary", + "exact", + OptionalStreamConfig::default(), + ) + .await; + + let timestamped_records = vec![ + (Bytes::from_static(b"ts-1000"), 1000), + (Bytes::from_static(b"ts-2000"), 2000), + (Bytes::from_static(b"ts-3000"), 3000), + (Bytes::from_static(b"ts-4000"), 4000), + ]; + + let input = AppendInput { + records: create_test_record_batch_with_timestamps(timestamped_records), + match_seq_num: None, + fencing_token: None, + }; + + backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append timestamped records"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Timestamp(3000), + wait: None, + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + let bodies = envelope_bodies(&records); + assert_eq!(bodies, vec![b"ts-1000".to_vec(), b"ts-2000".to_vec()]); + assert!( + records + .iter() + .all(|record| record.position.timestamp < 3000) + ); +} + +#[tokio::test] +async fn test_read_until_timestamp_before_all_records() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("read-until-before", "all", OptionalStreamConfig::default()) + .await; + + let timestamped_records = vec![ + (Bytes::from_static(b"ts-1000"), 1000), + (Bytes::from_static(b"ts-2000"), 2000), + (Bytes::from_static(b"ts-3000"), 3000), + ]; + + let input = AppendInput { + records: create_test_record_batch_with_timestamps(timestamped_records), + match_seq_num: None, + fencing_token: None, + }; + + backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append timestamped records"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Timestamp(500), + wait: None, + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + assert_eq!(records.len(), 0); +} + +#[tokio::test] +async fn test_read_until_timestamp_after_all_records() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("read-until-after", "all", OptionalStreamConfig::default()).await; + + let timestamped_records = vec![ + (Bytes::from_static(b"ts-1000"), 1000), + (Bytes::from_static(b"ts-2000"), 2000), + (Bytes::from_static(b"ts-3000"), 3000), + ]; + + let input = AppendInput { + records: create_test_record_batch_with_timestamps(timestamped_records), + match_seq_num: None, + fencing_token: None, + }; + + backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append timestamped records"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Timestamp(5000), + wait: None, + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + let bodies = envelope_bodies(&records); + assert_eq!( + bodies, + vec![ + b"ts-1000".to_vec(), + b"ts-2000".to_vec(), + b"ts-3000".to_vec() + ] + ); +} + +#[tokio::test] +async fn test_read_until_with_count_limit_count_wins() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-until-count", + "count-wins", + OptionalStreamConfig::default(), + ) + .await; + + let timestamped_records = vec![ + (Bytes::from_static(b"ts-1000"), 1000), + (Bytes::from_static(b"ts-2000"), 2000), + (Bytes::from_static(b"ts-3000"), 3000), + (Bytes::from_static(b"ts-4000"), 4000), + (Bytes::from_static(b"ts-5000"), 5000), + ]; + + let input = AppendInput { + records: create_test_record_batch_with_timestamps(timestamped_records), + match_seq_num: None, + fencing_token: None, + }; + + backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append timestamped records"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Count(2), + until: ReadUntil::Timestamp(5000), + wait: None, + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + assert_eq!(records.len(), 2); + let bodies = envelope_bodies(&records); + assert_eq!(bodies, vec![b"ts-1000".to_vec(), b"ts-2000".to_vec()]); +} + +#[tokio::test] +async fn test_read_until_with_count_limit_timestamp_wins() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-until-count", + "timestamp-wins", + OptionalStreamConfig::default(), + ) + .await; + + let timestamped_records = vec![ + (Bytes::from_static(b"ts-1000"), 1000), + (Bytes::from_static(b"ts-2000"), 2000), + (Bytes::from_static(b"ts-3000"), 3000), + (Bytes::from_static(b"ts-4000"), 4000), + (Bytes::from_static(b"ts-5000"), 5000), + ]; + + let input = AppendInput { + records: create_test_record_batch_with_timestamps(timestamped_records), + match_seq_num: None, + fencing_token: None, + }; + + backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append timestamped records"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Count(10), + until: ReadUntil::Timestamp(3500), + wait: None, + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + assert_eq!(records.len(), 3); + let bodies = envelope_bodies(&records); + assert_eq!( + bodies, + vec![ + b"ts-1000".to_vec(), + b"ts-2000".to_vec(), + b"ts-3000".to_vec() + ] + ); +} + +#[tokio::test] +async fn test_read_until_with_bytes_limit_bytes_wins() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-until-bytes", + "bytes-wins", + OptionalStreamConfig::default(), + ) + .await; + + let large_body = vec![b'X'; 5000]; + let timestamped_records = vec![ + (Bytes::from(large_body.clone()), 1000), + (Bytes::from(large_body.clone()), 2000), + (Bytes::from(large_body.clone()), 3000), + (Bytes::from(large_body.clone()), 4000), + (Bytes::from(large_body), 5000), + ]; + + let input = AppendInput { + records: create_test_record_batch_with_timestamps(timestamped_records), + match_seq_num: None, + fencing_token: None, + }; + + backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append timestamped records"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Bytes(12000), + until: ReadUntil::Timestamp(5000), + wait: None, + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + assert!(records.len() >= 2); + assert!(records.len() <= 3); + + let total_bytes: usize = records.iter().map(|r| r.record.metered_size()).sum(); + assert!(total_bytes <= 15000); +} + +#[tokio::test] +async fn test_read_until_with_bytes_limit_timestamp_wins() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-until-bytes", + "timestamp-wins", + OptionalStreamConfig::default(), + ) + .await; + + let small_body = vec![b'Y'; 100]; + let timestamped_records = vec![ + (Bytes::from(small_body.clone()), 1000), + (Bytes::from(small_body.clone()), 2000), + (Bytes::from(small_body.clone()), 3000), + (Bytes::from(small_body.clone()), 4000), + (Bytes::from(small_body), 5000), + ]; + + let input = AppendInput { + records: create_test_record_batch_with_timestamps(timestamped_records), + match_seq_num: None, + fencing_token: None, + }; + + backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append timestamped records"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Bytes(100000), + until: ReadUntil::Timestamp(3500), + wait: None, + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + assert_eq!(records.len(), 3); + assert!( + records + .iter() + .all(|record| record.position.timestamp < 3500) + ); +} + +#[tokio::test] +async fn test_read_timestamp_range_with_from_and_until() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-timestamp-range", + "from-until", + OptionalStreamConfig::default(), + ) + .await; + + let timestamped_records = vec![ + (Bytes::from_static(b"ts-500"), 500), + (Bytes::from_static(b"ts-1500"), 1500), + (Bytes::from_static(b"ts-2500"), 2500), + (Bytes::from_static(b"ts-3500"), 3500), + (Bytes::from_static(b"ts-4500"), 4500), + (Bytes::from_static(b"ts-5500"), 5500), + ]; + + let input = AppendInput { + records: create_test_record_batch_with_timestamps(timestamped_records), + match_seq_num: None, + fencing_token: None, + }; + + backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append timestamped records"); + + let start = ReadStart { + from: ReadFrom::Timestamp(2000), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Timestamp(4500), + wait: None, + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + + assert_eq!(records.len(), 2); + let bodies = envelope_bodies(&records); + assert_eq!(bodies, vec![b"ts-2500".to_vec(), b"ts-3500".to_vec()]); + assert!( + records + .iter() + .all(|record| record.position.timestamp >= 2000 && record.position.timestamp < 4500) + ); +} diff --git a/lite/tests/backend/data_plane/read_follow.rs b/lite/tests/backend/data_plane/read_follow.rs new file mode 100644 index 00000000..01d2ed26 --- /dev/null +++ b/lite/tests/backend/data_plane/read_follow.rs @@ -0,0 +1,535 @@ +use std::time::Duration; + +use bytes::Bytes; +use futures::StreamExt; +use s2_common::{ + read_extent::{ReadLimit, ReadUntil}, + types::{ + config::OptionalStreamConfig, + stream::{AppendInput, ReadEnd, ReadFrom, ReadSessionOutput, ReadStart}, + }, +}; +use s2_lite::backend::FOLLOWER_MAX_LAG; + +use super::common::*; + +#[tokio::test] +async fn test_follow_mode_wait_duration() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-wait-duration", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"initial"]).await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let wait_duration = Duration::from_millis(500); + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(wait_duration), + }; + + let start_time = tokio::time::Instant::now(); + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + + let mut batch_count = 0; + + while let Some(result) = session.as_mut().next().await { + match result { + Ok(ReadSessionOutput::Batch(_)) => batch_count += 1, + Ok(ReadSessionOutput::Heartbeat(_)) => {} + Err(e) => panic!("Read error: {:?}", e), + } + } + + let elapsed = start_time.elapsed(); + assert_eq!(batch_count, 1); + assert!(elapsed >= wait_duration, "Session ended too quickly"); + assert!( + elapsed < wait_duration + Duration::from_secs(1), + "Session took too long to end" + ); +} + +#[tokio::test] +async fn test_follow_mode_heartbeats() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-heartbeat", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"initial"]).await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(1)), + }; + + let session = backend + .read(basin_name, stream_name, start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + + let mut heartbeat_count = 0; + let mut batch_count = 0; + let timeout = Duration::from_secs(2); + let start_time = tokio::time::Instant::now(); + + while start_time.elapsed() < timeout { + match tokio::time::timeout(Duration::from_millis(500), session.as_mut().next()).await { + Ok(Some(Ok(ReadSessionOutput::Batch(_)))) => { + batch_count += 1; + } + Ok(Some(Ok(ReadSessionOutput::Heartbeat(_)))) => { + heartbeat_count += 1; + } + Ok(Some(Err(e))) => panic!("Read error: {:?}", e), + Ok(None) => break, + Err(_) => continue, + } + } + + assert_eq!(batch_count, 1); + assert!(heartbeat_count > 0); +} + +#[tokio::test] +async fn test_follow_mode_receives_new_data() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("follow-new-data", "stream", OptionalStreamConfig::default()) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"initial"]).await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(3)), + }; + + let session = backend + .read(basin_name.clone(), stream_name.clone(), start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + + let append_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(500)).await; + append_payloads(&backend_clone, &basin_clone, &stream_clone, &[b"follow-1"]).await; + tokio::time::sleep(Duration::from_millis(200)).await; + append_payloads(&backend_clone, &basin_clone, &stream_clone, &[b"follow-2"]).await; + }); + + let mut all_records = Vec::new(); + let timeout = Duration::from_secs(4); + let start_time = tokio::time::Instant::now(); + + while start_time.elapsed() < timeout && all_records.len() < 3 { + match tokio::time::timeout(Duration::from_millis(500), session.as_mut().next()).await { + Ok(Some(Ok(ReadSessionOutput::Batch(batch)))) => { + all_records.extend(batch.records.iter().cloned()); + } + Ok(Some(Ok(ReadSessionOutput::Heartbeat(_)))) => continue, + Ok(Some(Err(e))) => panic!("Read error: {:?}", e), + Ok(None) => break, + Err(_) => continue, + } + } + + append_handle.await.unwrap(); + + let bodies = envelope_bodies(&all_records); + assert_eq!( + bodies, + vec![ + b"initial".to_vec(), + b"follow-1".to_vec(), + b"follow-2".to_vec() + ] + ); +} + +#[tokio::test] +async fn test_follow_mode_with_multiple_appends() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("follow-multiple", "stream", OptionalStreamConfig::default()) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"initial"]).await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(3)), + }; + + let session = backend + .read(basin_name.clone(), stream_name.clone(), start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + + let append_handle = tokio::spawn(async move { + for i in 0..5 { + tokio::time::sleep(Duration::from_millis(100)).await; + let payload = format!("follow-{}", i); + append_payloads( + &backend_clone, + &basin_clone, + &stream_clone, + &[payload.as_bytes()], + ) + .await; + } + }); + + let mut all_records = Vec::new(); + let timeout = Duration::from_secs(4); + let start_time = tokio::time::Instant::now(); + + while start_time.elapsed() < timeout && all_records.len() < 6 { + match tokio::time::timeout(Duration::from_millis(500), session.as_mut().next()).await { + Ok(Some(Ok(ReadSessionOutput::Batch(batch)))) => { + all_records.extend(batch.records.iter().cloned()); + } + Ok(Some(Ok(ReadSessionOutput::Heartbeat(_)))) => continue, + Ok(Some(Err(e))) => panic!("Read error: {:?}", e), + Ok(None) => break, + Err(_) => continue, + } + } + + append_handle.await.unwrap(); + + let bodies = envelope_bodies(&all_records); + assert_eq!( + bodies, + vec![ + b"initial".to_vec(), + b"follow-0".to_vec(), + b"follow-1".to_vec(), + b"follow-2".to_vec(), + b"follow-3".to_vec(), + b"follow-4".to_vec(), + ] + ); +} + +#[tokio::test] +async fn test_follow_mode_broadcast_lag_falls_back_to_db_catchup() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-broadcast-lag", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let message_count = FOLLOWER_MAX_LAG + 25; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Count(message_count), + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(3)), + }; + + let session = backend + .read(basin_name.clone(), stream_name.clone(), start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + + let first_output = tokio::time::timeout(Duration::from_secs(1), session.as_mut().next()) + .await + .expect("Timed out waiting for initial heartbeat") + .expect("Read session ended unexpectedly") + .expect("Read session error"); + assert!(matches!(first_output, ReadSessionOutput::Heartbeat(_))); + + let mut expected = Vec::with_capacity(message_count); + for i in 0..message_count { + let payload = format!("msg-{}", i); + expected.push(payload.as_bytes().to_vec()); + append_payloads(&backend, &basin_name, &stream_name, &[payload.as_bytes()]).await; + } + + let records = tokio::time::timeout(Duration::from_secs(5), collect_records(&mut session)) + .await + .expect("Timed out waiting for records"); + let bodies = envelope_bodies(&records); + + assert_eq!(bodies, expected); +} + +#[tokio::test] +async fn test_transition_from_catchup_to_follow() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "catchup-to-follow", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads( + &backend, + &basin_name, + &stream_name, + &[b"record-0", b"record-1", b"record-2"], + ) + .await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(3)), + }; + + let session = backend + .read(basin_name.clone(), stream_name.clone(), start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + + let append_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(800)).await; + append_payloads(&backend_clone, &basin_clone, &stream_clone, &[b"live-1"]).await; + tokio::time::sleep(Duration::from_millis(200)).await; + append_payloads(&backend_clone, &basin_clone, &stream_clone, &[b"live-2"]).await; + }); + + let mut all_records = Vec::new(); + let timeout = Duration::from_secs(4); + let start_time = tokio::time::Instant::now(); + + while start_time.elapsed() < timeout && all_records.len() < 5 { + match tokio::time::timeout(Duration::from_millis(500), session.as_mut().next()).await { + Ok(Some(Ok(ReadSessionOutput::Batch(batch)))) => { + all_records.extend(batch.records.iter().cloned()); + } + Ok(Some(Ok(ReadSessionOutput::Heartbeat(_)))) => continue, + Ok(Some(Err(e))) => panic!("Read error: {:?}", e), + Ok(None) => break, + Err(_) => continue, + } + } + + append_handle.await.unwrap(); + + let bodies = envelope_bodies(&all_records); + assert_eq!( + bodies, + vec![ + b"record-0".to_vec(), + b"record-1".to_vec(), + b"record-2".to_vec(), + b"live-1".to_vec(), + b"live-2".to_vec(), + ] + ); +} + +#[tokio::test] +async fn test_follow_mode_with_count_limit() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-count-limit", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"initial"]).await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Count(3), + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(3)), + }; + + let session = backend + .read(basin_name.clone(), stream_name.clone(), start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + + let append_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(300)).await; + append_payloads( + &backend_clone, + &basin_clone, + &stream_clone, + &[b"follow-1", b"follow-2", b"follow-3"], + ) + .await; + }); + + let mut all_records = Vec::new(); + let timeout = Duration::from_secs(4); + let start_time = tokio::time::Instant::now(); + + while start_time.elapsed() < timeout { + match tokio::time::timeout(Duration::from_millis(500), session.as_mut().next()).await { + Ok(Some(Ok(ReadSessionOutput::Batch(batch)))) => { + all_records.extend(batch.records.iter().cloned()); + } + Ok(Some(Ok(ReadSessionOutput::Heartbeat(_)))) => continue, + Ok(Some(Err(e))) => panic!("Read error: {:?}", e), + Ok(None) => break, + Err(_) => continue, + } + } + + append_handle.await.unwrap(); + + assert_eq!(all_records.len(), 3); + let bodies = envelope_bodies(&all_records); + assert_eq!( + bodies, + vec![ + b"initial".to_vec(), + b"follow-1".to_vec(), + b"follow-2".to_vec() + ] + ); +} + +#[tokio::test] +async fn test_follow_mode_with_timestamp_until() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-timestamp-until", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let initial_records = vec![(Bytes::from_static(b"initial"), 1000)]; + let input = AppendInput { + records: create_test_record_batch_with_timestamps(initial_records), + match_seq_num: None, + fencing_token: None, + }; + backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("Failed to append initial record"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Timestamp(2500), + wait: Some(Duration::from_secs(2)), + }; + + let session = backend + .read(basin_name.clone(), stream_name.clone(), start, end) + .await + .expect("Failed to create read session"); + let mut session = Box::pin(session); + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + + let append_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(300)).await; + let timestamped_records = vec![(Bytes::from_static(b"before-cutoff"), 2000)]; + let input = AppendInput { + records: create_test_record_batch_with_timestamps(timestamped_records), + match_seq_num: None, + fencing_token: None, + }; + backend_clone + .append(basin_clone.clone(), stream_clone.clone(), input) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(200)).await; + let timestamped_records = vec![(Bytes::from_static(b"after-cutoff"), 3000)]; + let input = AppendInput { + records: create_test_record_batch_with_timestamps(timestamped_records), + match_seq_num: None, + fencing_token: None, + }; + backend_clone + .append(basin_clone, stream_clone, input) + .await + .unwrap(); + }); + + let mut all_records = Vec::new(); + let timeout = Duration::from_secs(3); + let start_time = tokio::time::Instant::now(); + + while start_time.elapsed() < timeout && all_records.len() < 3 { + match tokio::time::timeout(Duration::from_millis(500), session.as_mut().next()).await { + Ok(Some(Ok(ReadSessionOutput::Batch(batch)))) => { + all_records.extend(batch.records.iter().cloned()); + } + Ok(Some(Ok(ReadSessionOutput::Heartbeat(_)))) => continue, + Ok(Some(Err(e))) => panic!("Read error: {:?}", e), + Ok(None) => break, + Err(_) => continue, + } + } + + append_handle.await.unwrap(); + + assert_eq!(all_records.len(), 2); + let bodies = envelope_bodies(&all_records); + assert_eq!(bodies, vec![b"initial".to_vec(), b"before-cutoff".to_vec()]); +} diff --git a/lite/tests/backend/mod.rs b/lite/tests/backend/mod.rs new file mode 100644 index 00000000..eee8411f --- /dev/null +++ b/lite/tests/backend/mod.rs @@ -0,0 +1,3 @@ +mod common; +mod control_plane; +mod data_plane; diff --git a/lite/tests/backend_tests.rs b/lite/tests/backend_tests.rs new file mode 100644 index 00000000..cdf5dd7d --- /dev/null +++ b/lite/tests/backend_tests.rs @@ -0,0 +1 @@ +mod backend; From ef4ac43421b0b214e74944a2a94429d9519f3eb2 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Mon, 5 Jan 2026 12:11:41 -0500 Subject: [PATCH 029/700] chore: updated error codes from header extraction (#22) --- api/src/v1/extractors.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/src/v1/extractors.rs b/api/src/v1/extractors.rs index 253bfeb9..c8c42610 100644 --- a/api/src/v1/extractors.rs +++ b/api/src/v1/extractors.rs @@ -21,15 +21,15 @@ impl IntoResponse for HeaderExtractionError { fn into_response(self) -> Response { let (code, message) = match self { HeaderExtractionError::MissingHeader(header) => ( - "missing_header", + "header_missing", format!("Missing required header: {}", header), ), HeaderExtractionError::InvalidUtf8(header) => ( - "invalid_header_utf8", + "header_invalid", format!("Header {} contains invalid UTF-8", header), ), HeaderExtractionError::InvalidHeaderValue(header, error) => ( - "invalid_header_value", + "header_invalid", format!("Invalid header value for {}: {}", header, error), ), }; From e5d13fbae02b8f9b08b1fd8be568817a14054079 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Mon, 5 Jan 2026 14:14:57 -0500 Subject: [PATCH 030/700] chore: avoid copying when a string fails validation (#23) and add some tests around basin name validation --- common/src/types/access.rs | 36 +++++--- common/src/types/basin.rs | 178 ++++++++++++++++++++++++++----------- common/src/types/stream.rs | 36 +++++--- 3 files changed, 169 insertions(+), 81 deletions(-) diff --git a/common/src/types/access.rs b/common/src/types/access.rs index 9af76826..701fc427 100644 --- a/common/src/types/access.rs +++ b/common/src/types/access.rs @@ -18,6 +18,25 @@ use crate::{caps, types::resources::ListItemsRequest}; )] pub struct AccessTokenIdStr(CompactString, PhantomData); +impl AccessTokenIdStr { + fn validate_str(id: &str) -> Result<(), ValidationError> { + if !T::IS_PREFIX && id.is_empty() { + return Err(format!("access token {} must not be empty", T::FIELD_NAME).into()); + } + + if id.len() > caps::MAX_ACCESS_TOKEN_ID_LEN { + return Err(format!( + "access token {} must not exceed {} bytes in length", + T::FIELD_NAME, + caps::MAX_ACCESS_TOKEN_ID_LEN + ) + .into()); + } + + Ok(()) + } +} + #[cfg(feature = "utoipa")] impl utoipa::PartialSchema for AccessTokenIdStr where @@ -72,19 +91,7 @@ impl TryFrom for AccessTokenIdStr { type Error = ValidationError; fn try_from(name: CompactString) -> Result { - if !T::IS_PREFIX && name.is_empty() { - return Err(format!("access token {} must not be empty", T::FIELD_NAME).into()); - } - - if name.len() > caps::MAX_ACCESS_TOKEN_ID_LEN { - return Err(format!( - "access token {} must not exceed {} bytes in length", - T::FIELD_NAME, - caps::MAX_ACCESS_TOKEN_ID_LEN - ) - .into()); - } - + Self::validate_str(&name)?; Ok(Self(name, PhantomData)) } } @@ -93,7 +100,8 @@ impl FromStr for AccessTokenIdStr { type Err = ValidationError; fn from_str(s: &str) -> Result { - s.to_compact_string().try_into() + Self::validate_str(s)?; + Ok(Self(s.to_compact_string(), PhantomData)) } } diff --git a/common/src/types/basin.rs b/common/src/types/basin.rs index b9d6fedc..dabba952 100644 --- a/common/src/types/basin.rs +++ b/common/src/types/basin.rs @@ -17,6 +17,64 @@ pub static BASIN_HEADER: http::HeaderName = http::HeaderName::from_static("s2-ba )] pub struct BasinNameStr(CompactString, PhantomData); +impl BasinNameStr { + fn validate_str(name: &str) -> Result<(), ValidationError> { + if name.len() > caps::MAX_BASIN_NAME_LEN { + return Err(format!( + "basin {} must not exceed {} bytes in length", + T::FIELD_NAME, + caps::MAX_BASIN_NAME_LEN + ) + .into()); + } + + if !T::IS_PREFIX && name.len() < caps::MIN_BASIN_NAME_LEN { + return Err(format!( + "basin {} should be at least {} bytes in length", + T::FIELD_NAME, + caps::MIN_BASIN_NAME_LEN + ) + .into()); + } + + let mut chars = name.chars(); + + let Some(first_char) = chars.next() else { + return Ok(()); + }; + + if !first_char.is_ascii_lowercase() && !first_char.is_ascii_digit() { + return Err(format!( + "basin {} must begin with a lowercase letter or number", + T::FIELD_NAME + ) + .into()); + } + + if !T::IS_PREFIX + && let Some(last_char) = chars.next_back() + && !last_char.is_ascii_lowercase() + && !last_char.is_ascii_digit() + { + return Err(format!( + "basin {} must end with a lowercase letter or number", + T::FIELD_NAME + ) + .into()); + } + + if chars.any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-') { + return Err(format!( + "basin {} must comprise lowercase letters, numbers, and hyphens", + T::FIELD_NAME + ) + .into()); + } + + Ok(()) + } +} + #[cfg(feature = "utoipa")] impl utoipa::PartialSchema for BasinNameStr where @@ -71,58 +129,7 @@ impl TryFrom for BasinNameStr { type Error = ValidationError; fn try_from(name: CompactString) -> Result { - if name.len() > caps::MAX_BASIN_NAME_LEN { - return Err(format!( - "basin {} must not exceed {} bytes in length", - T::FIELD_NAME, - caps::MAX_BASIN_NAME_LEN - ) - .into()); - } - - if !T::IS_PREFIX && name.len() < caps::MIN_BASIN_NAME_LEN { - return Err(format!( - "basin {} should be at least {} bytes in length", - T::FIELD_NAME, - caps::MIN_BASIN_NAME_LEN - ) - .into()); - } - - let mut chars = name.chars(); - - let Some(first_char) = chars.next() else { - return Ok(Self(name, PhantomData)); - }; - - if !first_char.is_ascii_lowercase() && !first_char.is_ascii_digit() { - return Err(format!( - "basin {} must begin with a lowercase letter or number", - T::FIELD_NAME - ) - .into()); - } - - if !T::IS_PREFIX - && let Some(last_char) = chars.next_back() - && !last_char.is_ascii_lowercase() - && !last_char.is_ascii_digit() - { - return Err(format!( - "basin {} must end with a lowercase letter or number", - T::FIELD_NAME - ) - .into()); - } - - if chars.any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-') { - return Err(format!( - "basin {} must comprise lowercase letters, numbers, and hyphens", - T::FIELD_NAME - ) - .into()); - } - + Self::validate_str(&name)?; Ok(Self(name, PhantomData)) } } @@ -131,7 +138,8 @@ impl FromStr for BasinNameStr { type Err = ValidationError; fn from_str(s: &str) -> Result { - s.to_compact_string().try_into() + Self::validate_str(s)?; + Ok(Self(s.to_compact_string(), PhantomData)) } } @@ -210,3 +218,67 @@ pub struct BasinInfo { pub scope: Option, pub state: BasinState, } + +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::{BasinNameStr, NameProps, PrefixProps, StartAfterProps}; + + #[rstest] + #[case::min_len("abcdefgh".to_owned())] + #[case::starts_with_digit("1abcdefg".to_owned())] + #[case::contains_hyphen("abcd-efg".to_owned())] + #[case::max_len("a".repeat(crate::caps::MAX_BASIN_NAME_LEN))] + fn validate_name_ok(#[case] name: String) { + assert_eq!(BasinNameStr::::validate_str(&name), Ok(())); + } + + #[rstest] + #[case::too_long("a".repeat(crate::caps::MAX_BASIN_NAME_LEN + 1))] + #[case::too_short("abcdefg".to_owned())] + #[case::empty("".to_owned())] + #[case::invalid_first_char("Abcdefgh".to_owned())] + #[case::invalid_last_char("abcdefg-".to_owned())] + #[case::invalid_characters("abcd_efg".to_owned())] + fn validate_name_err(#[case] name: String) { + BasinNameStr::::validate_str(&name).expect_err("expected validation error"); + } + + #[rstest] + #[case::empty("".to_owned())] + #[case::single_char("a".to_owned())] + #[case::trailing_hyphen("abcdefg-".to_owned())] + #[case::max_len("a".repeat(crate::caps::MAX_BASIN_NAME_LEN))] + fn validate_prefix_ok(#[case] prefix: String) { + assert_eq!(BasinNameStr::::validate_str(&prefix), Ok(())); + } + + #[rstest] + #[case::too_long("a".repeat(crate::caps::MAX_BASIN_NAME_LEN + 1))] + #[case::invalid_first_char("-abc".to_owned())] + #[case::invalid_characters("ab_cd".to_owned())] + fn validate_prefix_err(#[case] prefix: String) { + BasinNameStr::::validate_str(&prefix).expect_err("expected validation error"); + } + + #[rstest] + #[case::empty("".to_owned())] + #[case::single_char("a".to_owned())] + #[case::trailing_hyphen("abcdefg-".to_owned())] + fn validate_start_after_ok(#[case] start_after: String) { + assert_eq!( + BasinNameStr::::validate_str(&start_after), + Ok(()) + ); + } + + #[rstest] + #[case::too_long("a".repeat(crate::caps::MAX_BASIN_NAME_LEN + 1))] + #[case::invalid_first_char("-abc".to_owned())] + #[case::invalid_characters("ab_cd".to_owned())] + fn validate_start_after_err(#[case] start_after: String) { + BasinNameStr::::validate_str(&start_after) + .expect_err("expected validation error"); + } +} diff --git a/common/src/types/stream.rs b/common/src/types/stream.rs index b349d156..c83ad103 100644 --- a/common/src/types/stream.rs +++ b/common/src/types/stream.rs @@ -24,6 +24,25 @@ use crate::{ )] pub struct StreamNameStr(CompactString, PhantomData); +impl StreamNameStr { + fn validate_str(name: &str) -> Result<(), ValidationError> { + if !T::IS_PREFIX && name.is_empty() { + return Err(format!("stream {} must not be empty", T::FIELD_NAME).into()); + } + + if name.len() > caps::MAX_STREAM_NAME_LEN { + return Err(format!( + "stream {} must not exceed {} bytes in length", + T::FIELD_NAME, + caps::MAX_STREAM_NAME_LEN + ) + .into()); + } + + Ok(()) + } +} + #[cfg(feature = "utoipa")] impl utoipa::PartialSchema for StreamNameStr where @@ -78,19 +97,7 @@ impl TryFrom for StreamNameStr { type Error = ValidationError; fn try_from(name: CompactString) -> Result { - if !T::IS_PREFIX && name.is_empty() { - return Err(format!("stream {} must not be empty", T::FIELD_NAME).into()); - } - - if name.len() > caps::MAX_STREAM_NAME_LEN { - return Err(format!( - "stream {} must not exceed {} bytes in length", - T::FIELD_NAME, - caps::MAX_STREAM_NAME_LEN - ) - .into()); - } - + Self::validate_str(&name)?; Ok(Self(name, PhantomData)) } } @@ -99,7 +106,8 @@ impl FromStr for StreamNameStr { type Err = ValidationError; fn from_str(s: &str) -> Result { - s.to_compact_string().try_into() + Self::validate_str(s)?; + Ok(Self(s.to_compact_string(), PhantomData)) } } From 4b16e32a9b025fbf7e8106fbb67c844fecc4365a Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Fri, 9 Jan 2026 00:42:28 -0500 Subject: [PATCH 031/700] `s2-lite` http route handlers etc (#24) --- Cargo.lock | 192 +++++++------ Cargo.toml | 8 +- api/Cargo.toml | 9 +- api/src/data.rs | 206 +++++++++++++ api/src/lib.rs | 2 + api/src/mime.rs | 82 ++++++ api/src/v1/error.rs | 124 ++++++++ api/src/v1/extractors.rs | 94 ------ api/src/v1/mod.rs | 15 +- api/src/v1/stream/extract.rs | 172 +++++++++++ api/src/v1/stream/mod.rs | 289 ++++++++----------- api/src/v1/stream/sse.rs | 12 +- common/Cargo.toml | 2 + common/src/header.rs | 8 - common/src/http.rs | 111 ++++++++ common/src/lib.rs | 2 +- common/src/types/basin.rs | 2 +- common/src/types/resources.rs | 2 +- lite/Cargo.toml | 10 + lite/src/backend/append.rs | 6 +- lite/src/backend/basins.rs | 27 +- lite/src/backend/core.rs | 16 +- lite/src/backend/error.rs | 34 +-- lite/src/backend/kv/mod.rs | 4 +- lite/src/backend/mod.rs | 18 ++ lite/src/backend/read.rs | 3 + lite/src/backend/streamer.rs | 6 +- lite/src/backend/streams.rs | 35 ++- lite/src/bin/server.rs | 17 +- lite/src/handlers/mod.rs | 4 +- lite/src/handlers/v1/basins.rs | 142 +++++++++ lite/src/handlers/v1/error.rs | 268 +++++++++++++++++ lite/src/handlers/v1/mod.rs | 24 +- lite/src/handlers/v1/records.rs | 317 +++++++++++++++++++-- lite/src/handlers/v1/streams.rs | 168 +++++++++++ lite/tests/backend/control_plane/basin.rs | 4 +- lite/tests/backend/control_plane/stream.rs | 16 +- lite/tests/backend/data_plane/append.rs | 4 + 38 files changed, 1985 insertions(+), 470 deletions(-) create mode 100644 api/src/data.rs create mode 100644 api/src/mime.rs create mode 100644 api/src/v1/error.rs delete mode 100644 api/src/v1/extractors.rs create mode 100644 api/src/v1/stream/extract.rs delete mode 100644 common/src/header.rs create mode 100644 common/src/http.rs create mode 100644 lite/src/handlers/v1/error.rs diff --git a/Cargo.lock b/Cargo.lock index dd64f9e2..c78fa58d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,7 +152,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", "synstructure", ] @@ -164,7 +164,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -198,7 +198,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -215,7 +215,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -242,7 +242,7 @@ dependencies = [ "derive_utils", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -355,7 +355,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -435,15 +435,16 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "blake3" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", + "cpufeatures", ] [[package]] @@ -481,7 +482,7 @@ checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -577,7 +578,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -654,9 +655,9 @@ dependencies = [ [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "core-foundation" @@ -674,6 +675,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -762,7 +772,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -784,7 +794,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -839,7 +849,7 @@ checksum = "ccfae181bab5ab6c5478b2ccb69e4c68a02f8c3ec72f6616bfec9dbc599d2ee0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -860,7 +870,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -917,7 +927,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -938,7 +948,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -954,7 +964,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]] @@ -1274,7 +1284,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1370,9 +1380,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -1686,9 +1696,9 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -1767,9 +1777,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.179" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "linux-raw-sys" @@ -1878,6 +1888,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.8.4" @@ -1975,7 +1994,7 @@ checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2018,7 +2037,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2167,7 +2186,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2231,7 +2250,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2277,7 +2296,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2329,7 +2348,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2343,9 +2362,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.104" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] @@ -2358,7 +2377,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", "version_check", "yansi", ] @@ -2408,7 +2427,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.113", + "syn 2.0.114", "tempfile", ] @@ -2422,7 +2441,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2451,7 +2470,7 @@ checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2522,14 +2541,14 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -2760,13 +2779,13 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.8.12" +version = "0.8.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a640b26f007713818e9a9b65d34da1cf58538207b052916a83d80e43f3ffa4" +checksum = "8b2e88acca7157d83d789836a3987dafc12bc3d88a050e54b8fe9ea4aaa29d20" dependencies = [ "bytecheck", "bytes", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "indexmap", "munge", "ptr_meta", @@ -2779,13 +2798,13 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.8.12" +version = "0.8.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd83f5f173ff41e00337d97f6572e416d022ef8a19f371817259ae960324c482" +checksum = "7f6dffea3c91fa91a3c0fc8a061b0e27fef25c6304728038a6d6bcb1c58ba9bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2813,7 +2832,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.113", + "syn 2.0.114", "unicode-ident", ] @@ -2861,14 +2880,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "aws-lc-rs", "once_cell", @@ -2950,12 +2969,15 @@ dependencies = [ "futures", "http", "itertools", + "mime", "proptest", "prost", "prost-build", + "rstest", "s2-common", "serde", "serde_json", + "strum", "thiserror 2.0.17", "time", "tokio-util", @@ -2967,6 +2989,7 @@ dependencies = [ name = "s2-common" version = "0.1.0" dependencies = [ + "axum", "blake3", "bytes", "clap", @@ -3005,8 +3028,10 @@ dependencies = [ "eyre", "futures", "http", + "itertools", "parking_lot", "proptest", + "prost", "rand 0.9.2", "rcgen", "s2-api", @@ -3017,6 +3042,8 @@ dependencies = [ "thiserror 2.0.17", "time", "tokio", + "tokio-util", + "tower-http", "tracing", "tracing-subscriber", "uuid", @@ -3102,14 +3129,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -3333,7 +3360,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3355,9 +3382,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.113" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -3381,7 +3408,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3408,7 +3435,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3437,7 +3464,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3448,7 +3475,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3540,7 +3567,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3692,6 +3719,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3726,7 +3754,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3756,10 +3784,14 @@ version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] @@ -3837,9 +3869,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -3877,7 +3909,7 @@ source = "git+https://github.com/infiniteregrets/utoipa?rev=9cd181d40ac0a50551eb dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3992,7 +4024,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", "wasm-bindgen-shared", ] @@ -4060,7 +4092,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4136,7 +4168,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4147,7 +4179,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4464,28 +4496,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4505,7 +4537,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", "synstructure", ] @@ -4545,14 +4577,14 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] name = "zmij" -version = "1.0.10" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" [[package]] name = "zstd" diff --git a/Cargo.toml b/Cargo.toml index f3aaa9d3..7e0e308c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,24 +27,26 @@ flate2 = "1.1.5" futures = "0.3.31" http = "1.4.0" itertools = "0.14.0" +mime = "0.3.17" parking_lot = "0.12.5" proptest = "1.9.0" prost = "0.14.1" prost-build = "0.14.1" rand = "0.9.2" rcgen = "0.14" -rkyv = "0.8.12" +rkyv = "0.8.13" rstest = "0.26.1" serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.148" +serde_json = "1.0.149" slatedb = "0.10.0" strum = { version = "0.27", features = ["derive"] } thiserror = "2.0.17" time = { version = "0.3.44", features = ["serde", "parsing", "formatting"] } tokio = "1.49.0" tokio-util = "0.7.18" +tower-http = { version = "0.6.8", features = ["trace"] } tracing = "0.1" -tracing-subscriber = "0.3" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } utoipa = { git = "https://github.com/infiniteregrets/utoipa", rev = "9cd181d40ac0a50551ebe1995a4d73e8685890d6", features = ["time"] } uuid = { version = "1.19.0", features = ["v4"] } zstd = "0.13.3" diff --git a/api/Cargo.toml b/api/Cargo.toml index 817599c4..a8406fd1 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -5,7 +5,7 @@ license.workspace = true version.workspace = true [features] -axum = ["dep:axum"] +axum = ["dep:axum", "s2-common/axum"] utoipa = ["dep:utoipa"] codegen = ["dep:prost-build"] @@ -19,12 +19,15 @@ flate2 = { workspace = true } futures = { workspace = true } http = { workspace = true } itertools = { workspace = true } +mime = { workspace = true } prost = { workspace = true } s2-common = { path = "../common" } serde = { workspace = true } +serde_json = { workspace = true } +strum = { workspace = true } thiserror = { workspace = true } time = { workspace = true } -tokio-util = { workspace = true, features = ["rt", "codec"] } +tokio-util = { workspace = true, features = ["rt", "codec", "io"] } utoipa = { workspace = true, optional = true } zstd = { workspace = true } @@ -33,4 +36,4 @@ prost-build = { workspace = true, optional = true } [dev-dependencies] proptest = { workspace = true } -serde_json = { workspace = true } +rstest = { workspace = true } diff --git a/api/src/data.rs b/api/src/data.rs new file mode 100644 index 00000000..f612239e --- /dev/null +++ b/api/src/data.rs @@ -0,0 +1,206 @@ +use std::str::FromStr; + +use base64ct::{Base64, Encoding as _}; +use bytes::Bytes; +use s2_common::types::ValidationError; + +#[derive(Debug)] +pub struct Json(pub T); + +#[cfg(feature = "axum")] +impl axum::response::IntoResponse for Json +where + T: serde::Serialize, +{ + fn into_response(self) -> axum::response::Response { + let Self(value) = self; + axum::Json(value).into_response() + } +} + +#[derive(Debug)] +pub struct Proto(pub T); + +#[cfg(feature = "axum")] +impl axum::response::IntoResponse for Proto +where + T: prost::Message, +{ + fn into_response(self) -> axum::response::Response { + let headers = [( + http::header::CONTENT_TYPE, + http::header::HeaderValue::from_static("application/protobuf"), + )]; + let body = self.0.encode_to_vec(); + (headers, body).into_response() + } +} + +#[rustfmt::skip] +#[derive(Debug, Default, Clone, Copy)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub enum Format { + #[default] + #[cfg_attr(feature = "utoipa", schema(rename = "raw"))] + Raw, + #[cfg_attr(feature = "utoipa", schema(rename = "base64"))] + Base64, +} + +impl s2_common::http::ParseableHeader for Format { + fn name() -> &'static http::HeaderName { + &FORMAT_HEADER + } +} + +impl Format { + pub fn encode(self, bytes: &[u8]) -> String { + match self { + Format::Raw => String::from_utf8_lossy(bytes).into_owned(), + Format::Base64 => Base64::encode_string(bytes), + } + } + + pub fn decode(self, s: String) -> Result { + Ok(match self { + Format::Raw => s.into_bytes().into(), + Format::Base64 => Base64::decode_vec(&s) + .map_err(|_| ValidationError("invalid Base64 encoding".to_owned()))? + .into(), + }) + } +} + +impl FromStr for Format { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + match s.trim() { + "raw" | "json" => Ok(Self::Raw), + "base64" | "json-binsafe" => Ok(Self::Base64), + _ => Err(ValidationError(s.to_string())), + } + } +} + +pub static FORMAT_HEADER: http::HeaderName = http::HeaderName::from_static("s2-format"); + +#[rustfmt::skip] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))] +pub struct S2FormatHeader { + /// Defines the interpretation of record data (header name, header value, and body) with the JSON content type. + /// Use `raw` (default) for efficient transmission and storage of Unicode data — storage will be in UTF-8. + /// Use `base64` for safe transmission with efficient storage of binary data. + #[cfg_attr(feature = "utoipa", param(required = false, rename = "s2-format"))] + pub s2_format: Format, +} + +#[cfg(feature = "axum")] +pub mod extract { + use axum::{ + extract::{ + FromRequest, OptionalFromRequest, Request, + rejection::{BytesRejection, JsonRejection}, + }, + response::{IntoResponse, Response}, + }; + use bytes::Bytes; + use serde::de::DeserializeOwned; + + impl FromRequest for super::Json + where + S: Send + Sync, + T: DeserializeOwned, + { + type Rejection = JsonRejection; + + async fn from_request(req: Request, state: &S) -> Result { + let axum::Json(value) = + as FromRequest>::from_request(req, state).await?; + Ok(Self(value)) + } + } + + impl OptionalFromRequest for super::Json + where + S: Send + Sync, + T: DeserializeOwned, + { + type Rejection = JsonRejection; + + async fn from_request(req: Request, state: &S) -> Result, Self::Rejection> { + let Some(ctype) = req.headers().get(http::header::CONTENT_TYPE) else { + return Ok(None); + }; + if !crate::mime::parse(ctype) + .as_ref() + .is_some_and(crate::mime::is_json) + { + Err(JsonRejection::MissingJsonContentType(Default::default()))?; + } + let bytes = Bytes::from_request(req, state) + .await + .map_err(JsonRejection::BytesRejection)?; + if bytes.is_empty() { + return Ok(None); + } + let value = axum::Json::::from_bytes(&bytes)?.0; + Ok(Some(Self(value))) + } + } + + /// Workaround for https://github.com/tokio-rs/axum/issues/3623 + #[derive(Debug)] + pub struct JsonOpt(pub Option); + + impl FromRequest for JsonOpt + where + S: Send + Sync, + T: DeserializeOwned, + { + type Rejection = JsonRejection; + + async fn from_request(req: Request, state: &S) -> Result { + match as OptionalFromRequest>::from_request(req, state).await { + Ok(Some(super::Json(value))) => Ok(Self(Some(value))), + Ok(None) => Ok(Self(None)), + Err(e) => Err(e), + } + } + } + + #[derive(Debug, thiserror::Error)] + pub enum ProtoRejection { + #[error(transparent)] + BytesRejection(#[from] BytesRejection), + #[error(transparent)] + Decode(#[from] prost::DecodeError), + } + + impl IntoResponse for ProtoRejection { + fn into_response(self) -> Response { + match self { + ProtoRejection::BytesRejection(e) => e.into_response(), + ProtoRejection::Decode(e) => ( + http::StatusCode::BAD_REQUEST, + format!("Invalid protobuf body: {e}"), + ) + .into_response(), + } + } + } + + impl FromRequest for super::Proto + where + S: Send + Sync, + T: prost::Message + Default, + { + type Rejection = ProtoRejection; + + async fn from_request(req: Request, state: &S) -> Result { + let bytes = Bytes::from_request(req, state).await?; + Ok(super::Proto(T::decode(bytes)?)) + } + } +} diff --git a/api/src/lib.rs b/api/src/lib.rs index a3a6d96c..618ab508 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1 +1,3 @@ +pub mod data; +pub mod mime; pub mod v1; diff --git a/api/src/mime.rs b/api/src/mime.rs new file mode 100644 index 00000000..3305c204 --- /dev/null +++ b/api/src/mime.rs @@ -0,0 +1,82 @@ +use mime::Mime; + +#[derive(Debug, Clone, Copy)] +pub enum JsonOrProto { + Json, + Proto, +} + +impl JsonOrProto { + pub fn from_mime(mime: &Mime) -> Option { + if is_json(mime) { + Some(JsonOrProto::Json) + } else if is_protobuf(mime) { + Some(JsonOrProto::Proto) + } else { + None + } + } +} + +/// MIME type parsed from `Content-Type` header. +pub fn content_type(headers: &http::HeaderMap) -> Option { + headers.get(http::header::CONTENT_TYPE).and_then(parse) +} + +/// First MIME type present in the `Accept` header. +pub fn accept(headers: &http::HeaderMap) -> Option { + headers.get(http::header::ACCEPT).and_then(parse) +} + +/// Parse the **first** MIME type from a header value. +pub fn parse(header: &http::HeaderValue) -> Option { + header.to_str().ok()?.split(',').next()?.trim().parse().ok() +} + +pub fn is_json(mime: &mime::Mime) -> bool { + mime.type_() == mime::APPLICATION + && (mime.subtype() == mime::JSON || mime.suffix() == Some(mime::JSON)) +} + +pub fn is_protobuf(mime: &mime::Mime) -> bool { + mime.type_() == mime::APPLICATION && { + let s = mime.subtype().as_str(); + s.eq_ignore_ascii_case("protobuf") || s.eq_ignore_ascii_case("x-protobuf") + } +} + +pub fn is_s2s_proto(mime: &Mime) -> bool { + mime.type_().as_str().eq_ignore_ascii_case("s2s") + && mime.subtype().as_str().eq_ignore_ascii_case("proto") +} + +pub fn is_event_stream(mime: &mime::Mime) -> bool { + mime.type_() == mime::TEXT && mime.subtype() == mime::EVENT_STREAM +} + +#[cfg(test)] +mod tests { + use mime::Mime; + use rstest::rstest; + + #[rstest] + #[case("application/json", Some("application/json"))] + #[case(" application/json , application/protobuf", Some("application/json"))] + #[case( + "application/json; charset=utf-8", + Some("application/json;charset=utf-8") + )] + #[case("", None)] + #[case("not/a/mime, application/json", None)] + fn parse(#[case] header: &'static str, #[case] expected: Option<&'static str>) { + let header = http::HeaderValue::from_static(header); + let expected = expected.map(|s| s.parse::().unwrap()); + assert_eq!(super::parse(&header), expected); + } + + #[test] + fn parse_returns_none_for_non_utf8_header_values() { + let header = http::HeaderValue::from_bytes(b"\xFF\xFF").unwrap(); + assert_eq!(super::parse(&header), None); + } +} diff --git a/api/src/v1/error.rs b/api/src/v1/error.rs new file mode 100644 index 00000000..627be5c5 --- /dev/null +++ b/api/src/v1/error.rs @@ -0,0 +1,124 @@ +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + strum::Display, + strum::EnumString, + strum::IntoStaticStr, +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[strum(serialize_all = "snake_case")] +// Keep this alphabetized. +pub enum ErrorCode { + AccessTokenNotFound, + BadFrame, + BadHeader, + BadJson, + BadPath, + BadProto, + BadQuery, + BasinDeletionPending, + BasinNotFound, + ClientHangup, + HotServer, + Invalid, + Other, + PermissionDenied, + QuotaExhausted, + RateLimited, + ResourceAlreadyExists, + Storage, + StreamDeletionPending, + StreamNotFound, + Timeout, + TransactionConflict, + Unavailable, +} + +impl ErrorCode { + pub fn status(self) -> http::StatusCode { + match self { + Self::BadFrame + | Self::BadHeader + | Self::BadJson + | Self::BadPath + | Self::BadProto + | Self::BadQuery => http::StatusCode::BAD_REQUEST, + Self::Invalid => http::StatusCode::UNPROCESSABLE_ENTITY, + Self::AccessTokenNotFound | Self::BasinNotFound | Self::StreamNotFound => { + http::StatusCode::NOT_FOUND + } + Self::BasinDeletionPending + | Self::ResourceAlreadyExists + | Self::StreamDeletionPending + | Self::TransactionConflict => http::StatusCode::CONFLICT, + Self::ClientHangup => http::StatusCode::from_u16(499).expect("valid status code"), + Self::PermissionDenied | Self::QuotaExhausted => http::StatusCode::FORBIDDEN, + Self::RateLimited => http::StatusCode::TOO_MANY_REQUESTS, + Self::HotServer | Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE, + Self::Other | Self::Storage => http::StatusCode::INTERNAL_SERVER_ERROR, + Self::Timeout => http::StatusCode::REQUEST_TIMEOUT, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct ErrorInfo { + pub code: &'static str, + pub message: String, +} + +#[derive(Debug, Clone)] +pub struct GenericError { + pub status: http::StatusCode, + pub info: ErrorInfo, +} + +#[derive(Debug, Clone)] +pub enum ErrorResponse { + AppendConditionFailed(super::stream::AppendConditionFailed), + TailExceeded(super::stream::TailResponse), + Generic(GenericError), +} + +impl ErrorResponse { + pub fn to_parts(&self) -> (http::StatusCode, String) { + let (status, res) = match self { + ErrorResponse::AppendConditionFailed(payload) => ( + http::StatusCode::PRECONDITION_FAILED, + serde_json::to_string(&payload), + ), + ErrorResponse::TailExceeded(payload) => ( + http::StatusCode::RANGE_NOT_SATISFIABLE, + serde_json::to_string(&payload), + ), + ErrorResponse::Generic(err) => (err.status, serde_json::to_string(&err.info)), + }; + (status, res.expect("basic json ser")) + } +} + +#[cfg(feature = "axum")] +impl axum::response::IntoResponse for ErrorResponse { + fn into_response(self) -> axum::response::Response { + let (status, json_str) = self.to_parts(); + let mut response = ( + [( + http::header::CONTENT_TYPE, + http::header::HeaderValue::from_static(mime::APPLICATION_JSON.as_ref()), + )], + json_str, + ) + .into_response(); + *response.status_mut() = status; + response + } +} diff --git a/api/src/v1/extractors.rs b/api/src/v1/extractors.rs deleted file mode 100644 index c8c42610..00000000 --- a/api/src/v1/extractors.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::fmt::Display; - -use axum::{ - Json, - extract::FromRequestParts, - http::{StatusCode, request::Parts}, - response::{IntoResponse, Response}, -}; -use s2_common::header::ExtractableHeader; - -use super::ErrorInfo; - -#[derive(Debug)] -pub enum HeaderExtractionError { - MissingHeader(&'static str), - InvalidUtf8(&'static str), - InvalidHeaderValue(&'static str, String), -} - -impl IntoResponse for HeaderExtractionError { - fn into_response(self) -> Response { - let (code, message) = match self { - HeaderExtractionError::MissingHeader(header) => ( - "header_missing", - format!("Missing required header: {}", header), - ), - HeaderExtractionError::InvalidUtf8(header) => ( - "header_invalid", - format!("Header {} contains invalid UTF-8", header), - ), - HeaderExtractionError::InvalidHeaderValue(header, error) => ( - "header_invalid", - format!("Invalid header value for {}: {}", header, error), - ), - }; - (StatusCode::BAD_REQUEST, Json(ErrorInfo { code, message })).into_response() - } -} - -fn parse_header(parts: &Parts) -> Result, HeaderExtractionError> -where - T: ExtractableHeader, - T::Err: Display, -{ - let name = T::name(); - let Some(value) = parts.headers.get(name) else { - return Ok(None); - }; - - let value_str = value - .to_str() - .map_err(|_| HeaderExtractionError::InvalidUtf8(name.as_str()))?; - - let parsed = value_str - .parse::() - .map_err(|e| HeaderExtractionError::InvalidHeaderValue(name.as_str(), e.to_string()))?; - - Ok(Some(parsed)) -} - -#[derive(Debug, Clone)] -pub struct RequiredHeader(pub T); - -impl FromRequestParts for RequiredHeader -where - S: Send + Sync, - T: ExtractableHeader, - T::Err: Display, -{ - type Rejection = HeaderExtractionError; - - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - let value = parse_header(parts)? - .ok_or_else(|| HeaderExtractionError::MissingHeader(T::name().as_str()))?; - Ok(RequiredHeader(value)) - } -} - -#[derive(Debug, Clone)] -pub struct OptionalHeader(pub Option); - -impl FromRequestParts for OptionalHeader -where - S: Send + Sync, - T: ExtractableHeader, - T::Err: Display, -{ - type Rejection = HeaderExtractionError; - - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - let value = parse_header(parts)?; - Ok(OptionalHeader(value)) - } -} diff --git a/api/src/v1/mod.rs b/api/src/v1/mod.rs index f7fc9410..789fedc5 100644 --- a/api/src/v1/mod.rs +++ b/api/src/v1/mod.rs @@ -1,13 +1,11 @@ pub mod access; pub mod basin; pub mod config; -#[cfg(feature = "axum")] -pub mod extractors; +pub mod error; pub mod metrics; pub mod stream; -use s2_common::types; -use serde::{Deserialize, Serialize}; +use s2_common::types::{self, resources::RequestToken}; #[rustfmt::skip] #[derive(Debug)] @@ -16,7 +14,7 @@ use serde::{Deserialize, Serialize}; pub struct S2RequestTokenHeader { /// Client-specified request token for idempotent retries. #[cfg_attr(feature = "utoipa", param(required = false, rename = "s2-request-token"))] - pub s2_request_token: String, + pub s2_request_token: RequestToken, } #[rustfmt::skip] @@ -69,10 +67,3 @@ macro_rules! impl_list_request_conversions { } pub(crate) use impl_list_request_conversions; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] -pub struct ErrorInfo { - pub code: &'static str, - pub message: String, -} diff --git a/api/src/v1/stream/extract.rs b/api/src/v1/stream/extract.rs new file mode 100644 index 00000000..7b9ce684 --- /dev/null +++ b/api/src/v1/stream/extract.rs @@ -0,0 +1,172 @@ +use axum::{ + Json, + extract::{FromRequest, FromRequestParts, Request, rejection::JsonRejection}, + response::{IntoResponse, Response}, +}; +use futures::StreamExt as _; +use http::{StatusCode, request::Parts}; +use s2_common::{ + http::{ParseableHeader, extract::HeaderRejection}, + types, +}; +use tokio_util::{codec::FramedRead, io::StreamReader}; + +use super::{AppendInput, AppendInputStreamError, AppendRequest, ReadRequest, proto, s2s}; +use crate::{ + data::{Format, Proto, extract::ProtoRejection}, + mime::JsonOrProto, + v1::stream::sse::LastEventId, +}; + +#[derive(Debug, thiserror::Error)] +pub enum AppendRequestRejection { + #[error(transparent)] + HeaderRejection(#[from] HeaderRejection), + #[error(transparent)] + JsonRejection(#[from] JsonRejection), + #[error(transparent)] + ProtoRejection(#[from] ProtoRejection), + #[error(transparent)] + Validation(#[from] types::ValidationError), +} + +impl IntoResponse for AppendRequestRejection { + fn into_response(self) -> Response { + match self { + AppendRequestRejection::HeaderRejection(e) => e.into_response(), + AppendRequestRejection::JsonRejection(e) => e.into_response(), + AppendRequestRejection::ProtoRejection(e) => e.into_response(), + AppendRequestRejection::Validation(e) => { + (StatusCode::UNPROCESSABLE_ENTITY, e.to_string()).into_response() + } + } + } +} + +impl FromRequest for AppendRequest +where + S: Send + Sync, +{ + type Rejection = AppendRequestRejection; + + async fn from_request(req: Request, state: &S) -> Result { + let content_type = crate::mime::content_type(req.headers()); + + if content_type.as_ref().is_some_and(crate::mime::is_s2s_proto) { + let response_compression = + s2s::CompressionAlgorithm::from_accept_encoding(req.headers()); + + let body_reader = StreamReader::new( + req.into_body() + .into_data_stream() + .map(|result| result.map_err(std::io::Error::other)), + ); + + let framed = FramedRead::new(body_reader, s2s::FrameDecoder); + + let inputs = futures::stream::try_unfold(framed, |mut framed| async move { + let Some(msg) = framed.next().await else { + return Ok(None); + }; + match msg? { + s2s::SessionMessage::Regular(data) => { + let input = data.try_into_proto::()?; + let input = types::stream::AppendInput::try_from(input)?; + Ok(Some((input, framed))) + } + s2s::SessionMessage::Terminal(_) => { + Err(AppendInputStreamError::FrameDecode(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Unexpected terminal frame as input", + ))) + } + } + }); + + return Ok(Self::S2s { + inputs: Box::pin(inputs), + response_compression, + }); + } + + let request_mime = content_type + .as_ref() + .and_then(JsonOrProto::from_mime) + .unwrap_or(JsonOrProto::Json); + + let response_mime = crate::mime::accept(req.headers()) + .as_ref() + .and_then(JsonOrProto::from_mime) + .unwrap_or(JsonOrProto::Json); + + let input = match request_mime { + JsonOrProto::Proto => { + let Proto(input) = Proto::::from_request(req, state).await?; + input.try_into()? + } + JsonOrProto::Json => { + let format = parse_header_opt::(req.headers())?.unwrap_or_default(); + let Json(input) = Json::::from_request(req, state).await?; + input.decode(format)? + } + }; + + Ok(Self::Unary { + input, + response_mime, + }) + } +} + +impl FromRequestParts for ReadRequest +where + S: Send + Sync, +{ + type Rejection = HeaderRejection; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let content_type = crate::mime::content_type(&parts.headers); + + if content_type.as_ref().is_some_and(crate::mime::is_s2s_proto) { + let response_compression = + s2s::CompressionAlgorithm::from_accept_encoding(&parts.headers); + return Ok(Self::S2s { + response_compression, + }); + } + + let format = parse_header_opt::(&parts.headers)?.unwrap_or_default(); + + let accept = crate::mime::accept(&parts.headers); + + if accept.as_ref().is_some_and(crate::mime::is_event_stream) { + let last_event_id = parse_header_opt::(&parts.headers)?; + return Ok(Self::EventStream { + format, + last_event_id, + }); + } + + let response_mime = accept + .as_ref() + .and_then(JsonOrProto::from_mime) + .unwrap_or(JsonOrProto::Json); + + Ok(Self::Unary { + format, + response_mime, + }) + } +} + +fn parse_header_opt(headers: &http::HeaderMap) -> Result, HeaderRejection> +where + T: ParseableHeader, + T::Err: std::fmt::Display, +{ + match s2_common::http::extract::parse_header(headers) { + Ok(value) => Ok(Some(value)), + Err(HeaderRejection::MissingHeader(_)) => Ok(None), + Err(e) => Err(e)?, + } +} diff --git a/api/src/v1/stream/mod.rs b/api/src/v1/stream/mod.rs index b4644a5b..4c896573 100644 --- a/api/src/v1/stream/mod.rs +++ b/api/src/v1/stream/mod.rs @@ -1,16 +1,18 @@ +#[cfg(feature = "axum")] +pub mod extract; + pub mod proto; pub mod s2s; pub mod sse; -use std::{str::FromStr, time::Duration}; +use std::time::Duration; -use base64ct::{Base64, Encoding as _}; -use bytes::Bytes; +use futures::stream::BoxStream; use itertools::Itertools as _; use s2_common::{ record, types::{ - self, ValidationError, + self, stream::{StreamName, StreamNamePrefix, StreamNameStartAfter}, }, }; @@ -18,19 +20,7 @@ use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use super::config::StreamConfig; - -pub static FORMAT_HEADER: http::HeaderName = http::HeaderName::from_static("s2-format"); - -#[rustfmt::skip] -#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] -#[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))] -pub struct S2FormatHeader { - /// Defines the interpretation of record data (header name, header value, and body) with the JSON content type. - /// Use `raw` (default) for efficient transmission and storage of Unicode data — storage will be in UTF-8. - /// Use `base64` for safe transmission with efficient storage of binary data. - #[cfg_attr(feature = "utoipa", param(required = false, rename = "s2-format"))] - pub s2_format: Format, -} +use crate::{data::Format, mime::JsonOrProto}; #[rustfmt::skip] #[derive(Debug, Clone, Serialize, Deserialize)] @@ -217,161 +207,58 @@ impl From for types::stream::ReadEnd { } #[derive(Debug, Clone, Copy)] -pub enum ReadResponseKind { - /// Unary response - Unary, - /// Server-sent events streaming response - EventStream, +pub enum ReadRequest { + /// Unary + Unary { + format: Format, + response_mime: JsonOrProto, + }, + /// Server-Sent Events streaming response + EventStream { + format: Format, + last_event_id: Option, + }, /// S2S streaming response - S2sStream, + S2s { + response_compression: s2s::CompressionAlgorithm, + }, } -impl ReadResponseKind { - /// Determines the response kind based on the `Content-Type` or `Accept` headers. - pub fn from_headers(headers: &http::HeaderMap) -> Self { - headers - .get(http::header::CONTENT_TYPE) - .and_then(|ct| ct.to_str().ok()) - .and_then(|ct| { - if ct == "s2s/proto" { - return Some(Self::S2sStream); - } - None - }) - .or_else(|| { - headers - .get(http::header::ACCEPT) - .and_then(|accept| accept.to_str().ok()) - .map(|accept| { - if accept == "text/event-stream" { - Self::EventStream - } else { - Self::Unary - } - }) - }) - .unwrap_or(Self::Unary) - } +pub enum AppendRequest { + /// Unary + Unary { + input: types::stream::AppendInput, + response_mime: JsonOrProto, + }, + /// S2S bi-directional streaming + S2s { + inputs: BoxStream<'static, Result>, + response_compression: s2s::CompressionAlgorithm, + }, } -#[derive(Debug, Clone, Copy)] -pub enum AppendResponseKind { - /// Unary response - Unary, - /// S2S streaming response - S2sStream, -} - -impl AppendResponseKind { - pub fn from_content_type(headers: &http::HeaderMap) -> Self { - headers - .get(http::header::CONTENT_TYPE) - .and_then(|ct| ct.to_str().ok()) - .map(|ct| { - if ct == "s2s/proto" { - Self::S2sStream - } else { - Self::Unary - } - }) - .unwrap_or(Self::Unary) - } -} - -#[rustfmt::skip] -#[derive(Default, Clone, Copy)] -#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] -pub enum Format { - #[default] - #[cfg_attr(feature = "utoipa", schema(rename = "raw"))] - Raw, - #[cfg_attr(feature = "utoipa", schema(rename = "base64"))] - Base64, -} - -impl s2_common::header::ExtractableHeader for Format { - fn name() -> &'static http::HeaderName { - &FORMAT_HEADER - } -} - -impl Format { - fn encode(self, bytes: &[u8]) -> String { +impl std::fmt::Debug for AppendRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Format::Raw => String::from_utf8_lossy(bytes).into_owned(), - Format::Base64 => Base64::encode_string(bytes), + AppendRequest::Unary { + input, + response_mime: response, + } => f + .debug_struct("AppendRequest::Unary") + .field("input", input) + .field("response", response) + .finish(), + AppendRequest::S2s { .. } => f.debug_struct("AppendRequest::S2s").finish(), } } - - pub fn encode_read( - self, - record::SequencedRecord { - position: record::StreamPosition { seq_num, timestamp }, - record, - }: record::SequencedRecord, - ) -> SequencedRecord { - let (headers, body) = record.into_parts(); - SequencedRecord { - seq_num, - timestamp, - headers: headers - .into_iter() - .map(|h| Header(self.encode(&h.name), self.encode(&h.value))) - .collect(), - body: self.encode(&body), - } - } - - fn decode(self, s: String) -> Result { - Ok(match self { - Format::Raw => s.into_bytes().into(), - Format::Base64 => Base64::decode_vec(&s) - .map_err(|_| types::ValidationError("invalid Base64 encoding".to_owned()))? - .into(), - }) - } - - pub fn decode_append( - self, - AppendRecord { - timestamp, - headers, - body, - }: AppendRecord, - ) -> Result { - let headers = headers - .into_iter() - .map(|Header(name, value)| { - Ok::(record::Header { - name: self.decode(name)?, - value: self.decode(value)?, - }) - }) - .try_collect()?; - - let body = self.decode(body)?; - - let record = record::Record::try_from_parts(headers, body) - .map_err(|e| e.to_string())? - .into(); - - let parts = types::stream::AppendRecordParts { timestamp, record }; - - types::stream::AppendRecord::try_from(parts) - .map_err(|e| types::ValidationError(e.to_string())) - } } -impl FromStr for Format { - type Err = ValidationError; - - fn from_str(s: &str) -> Result { - match s.trim() { - "raw" | "json" => Ok(Self::Raw), - "base64" | "json-binsafe" => Ok(Self::Base64), - _ => Err(ValidationError(s.to_string())), - } - } +#[derive(Debug, thiserror::Error)] +pub enum AppendInputStreamError { + #[error("Failed to decode S2S frame: {0}")] + FrameDecode(#[from] std::io::Error), + #[error(transparent)] + Validation(#[from] types::ValidationError), } /// Headers add structured information to a record as name-value pairs. @@ -400,6 +287,27 @@ pub struct SequencedRecord { pub body: String, } +impl SequencedRecord { + pub fn encode( + format: Format, + record::SequencedRecord { + position: record::StreamPosition { seq_num, timestamp }, + record, + }: record::SequencedRecord, + ) -> Self { + let (headers, body) = record.into_parts(); + Self { + seq_num, + timestamp, + headers: headers + .into_iter() + .map(|h| Header(format.encode(&h.name), format.encode(&h.value))) + .collect(), + body: format.encode(&body), + } + } +} + #[rustfmt::skip] /// Record to be appended to a stream. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -419,6 +327,38 @@ pub struct AppendRecord { pub body: String, } +impl AppendRecord { + pub fn decode( + self, + format: Format, + ) -> Result { + let headers = self + .headers + .into_iter() + .map(|Header(name, value)| { + Ok::(record::Header { + name: format.decode(name)?, + value: format.decode(value)?, + }) + }) + .try_collect()?; + + let body = format.decode(self.body)?; + + let record = record::Record::try_from_parts(headers, body) + .map_err(|e| e.to_string())? + .into(); + + let parts = types::stream::AppendRecordParts { + timestamp: self.timestamp, + record, + }; + + types::stream::AppendRecord::try_from(parts) + .map_err(|e| types::ValidationError(e.to_string())) + } +} + #[rustfmt::skip] /// Payload of an `append` request. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -433,6 +373,25 @@ pub struct AppendInput { pub fencing_token: Option, } +impl AppendInput { + pub fn decode( + self, + format: Format, + ) -> Result { + let records: Vec = self + .records + .into_iter() + .map(|record| record.decode(format)) + .try_collect()?; + + Ok(types::stream::AppendInput { + records: types::stream::AppendRecordBatch::try_from(records)?, + match_seq_num: self.match_seq_num, + fencing_token: self.fencing_token, + }) + } +} + #[rustfmt::skip] /// Success response to an `append` request. #[derive(Debug, Clone, Serialize)] @@ -487,13 +446,13 @@ pub struct ReadBatch { pub tail: Option, } -impl From<(Format, types::stream::ReadBatch)> for ReadBatch { - fn from((s2_format, batch): (Format, types::stream::ReadBatch)) -> Self { +impl ReadBatch { + pub fn encode(format: Format, batch: types::stream::ReadBatch) -> Self { Self { records: batch .records .into_iter() - .map(|record| s2_format.encode_read(record)) + .map(|record| SequencedRecord::encode(format, record)) .collect(), tail: batch.tail.map(Into::into), } diff --git a/api/src/v1/stream/sse.rs b/api/src/v1/stream/sse.rs index 7c241091..26d331f9 100644 --- a/api/src/v1/stream/sse.rs +++ b/api/src/v1/stream/sse.rs @@ -1,10 +1,12 @@ use std::{str::FromStr, time::Duration}; -use s2_common::types; +use s2_common::{http::ParseableHeader, types}; use serde::Serialize; use super::ReadBatch; +static LAST_EVENT_ID_HEADER: http::HeaderName = http::HeaderName::from_static("last-event-id"); + #[derive(Debug, Clone, Copy)] pub struct LastEventId { pub seq_num: u64, @@ -12,6 +14,12 @@ pub struct LastEventId { pub bytes: usize, } +impl ParseableHeader for LastEventId { + fn name() -> &'static http::HeaderName { + &LAST_EVENT_ID_HEADER + } +} + impl Serialize for LastEventId { fn serialize(&self, serializer: S) -> Result where @@ -121,7 +129,7 @@ pub enum ReadEvent { fn elapsed_since_epoch() -> Duration { std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH) - .unwrap() + .expect("healthy clock") } impl ReadEvent { diff --git a/common/Cargo.toml b/common/Cargo.toml index 316bcee9..be71d70e 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -5,11 +5,13 @@ license.workspace = true version.workspace = true [features] +axum = ["dep:axum"] clap = ["dep:clap"] rkyv = ["dep:rkyv", "compact_str/rkyv"] utoipa = ["dep:utoipa"] [dependencies] +axum = { workspace = true, optional = true } bytes = { workspace = true } blake3 = { workspace = true } clap = { workspace = true, optional = true } diff --git a/common/src/header.rs b/common/src/header.rs deleted file mode 100644 index 4f62ea80..00000000 --- a/common/src/header.rs +++ /dev/null @@ -1,8 +0,0 @@ -use std::{fmt::Display, str::FromStr}; - -pub trait ExtractableHeader: FromStr -where - Self::Err: Display, -{ - fn name() -> &'static http::HeaderName; -} diff --git a/common/src/http.rs b/common/src/http.rs new file mode 100644 index 00000000..6dbab0b1 --- /dev/null +++ b/common/src/http.rs @@ -0,0 +1,111 @@ +/// An HTTP header that can be parsed from a UTF8 string value. +pub trait ParseableHeader: std::str::FromStr +where + Self::Err: std::fmt::Display, +{ + fn name() -> &'static http::HeaderName; +} + +#[cfg(feature = "axum")] +pub mod extract { + use axum::{ + extract::{FromRequestParts, OptionalFromRequestParts}, + response::{IntoResponse, Response}, + }; + + #[derive(Debug, thiserror::Error)] + pub enum HeaderRejection { + #[error("Missing header `{0}`")] + MissingHeader(&'static http::HeaderName), + #[error("Invalid header `{0}`: not UTF-8")] + InvalidUtf8(&'static http::HeaderName), + #[error("Invalid header `{0}`: {1}")] + InvalidHeaderValue(&'static http::HeaderName, String), + } + + impl IntoResponse for HeaderRejection { + fn into_response(self) -> Response { + (http::StatusCode::BAD_REQUEST, self.to_string()).into_response() + } + } + + pub fn parse_header(headers: &http::HeaderMap) -> Result + where + T: super::ParseableHeader, + T::Err: std::fmt::Display, + { + let name = T::name(); + let Some(value) = headers.get(name) else { + return Err(HeaderRejection::MissingHeader(name)); + }; + let value_str = value + .to_str() + .map_err(|_| HeaderRejection::InvalidUtf8(name))?; + let parsed = value_str + .parse::() + .map_err(|e| HeaderRejection::InvalidHeaderValue(name, e.to_string()))?; + Ok(parsed) + } + + #[derive(Debug, Clone)] + pub struct Header(pub T); + + impl FromRequestParts for Header + where + S: Send + Sync, + T: super::ParseableHeader, + T::Err: std::fmt::Display, + { + type Rejection = HeaderRejection; + + async fn from_request_parts( + parts: &mut http::request::Parts, + _state: &S, + ) -> Result { + parse_header(&parts.headers).map(Self) + } + } + + impl OptionalFromRequestParts for Header + where + S: Send + Sync, + T: super::ParseableHeader, + T::Err: std::fmt::Display, + { + type Rejection = HeaderRejection; + + async fn from_request_parts( + parts: &mut http::request::Parts, + _state: &S, + ) -> Result, Self::Rejection> { + match parse_header(&parts.headers) { + Ok(value) => Ok(Some(Header(value))), + Err(HeaderRejection::MissingHeader(_)) => Ok(None), + Err(e) => Err(e), + } + } + } + + /// Workaround for https://github.com/tokio-rs/axum/issues/3623 + pub struct HeaderOpt(pub Option); + + impl FromRequestParts for HeaderOpt + where + S: Send + Sync, + T: super::ParseableHeader, + T::Err: std::fmt::Display, + { + type Rejection = HeaderRejection; + + async fn from_request_parts( + parts: &mut http::request::Parts, + _state: &S, + ) -> Result { + match parse_header(&parts.headers) { + Ok(value) => Ok(Self(Some(value))), + Err(HeaderRejection::MissingHeader(_)) => Ok(Self(None)), + Err(e) => Err(e), + } + } + } +} diff --git a/common/src/lib.rs b/common/src/lib.rs index da60971e..b9364d0e 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,7 +1,7 @@ pub mod bash; pub mod caps; pub mod deep_size; -pub mod header; +pub mod http; pub mod maybe; pub mod read_extent; pub mod record; diff --git a/common/src/types/basin.rs b/common/src/types/basin.rs index dabba952..048982da 100644 --- a/common/src/types/basin.rs +++ b/common/src/types/basin.rs @@ -191,7 +191,7 @@ impl From for BasinNameStartAfter { } } -impl crate::header::ExtractableHeader for BasinName { +impl crate::http::ParseableHeader for BasinName { fn name() -> &'static http::HeaderName { &BASIN_HEADER } diff --git a/common/src/types/resources.rs b/common/src/types/resources.rs index 7d4208fd..b0284469 100644 --- a/common/src/types/resources.rs +++ b/common/src/types/resources.rs @@ -215,7 +215,7 @@ impl Deref for RequestToken { } } -impl crate::header::ExtractableHeader for RequestToken { +impl crate::http::ParseableHeader for RequestToken { fn name() -> &'static http::HeaderName { &REQUEST_TOKEN_HEADER } diff --git a/lite/Cargo.toml b/lite/Cargo.toml index ccc3cb53..e4f85ca6 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -19,7 +19,9 @@ enum-ordinalize = { workspace = true } eyre = { workspace = true } futures = { workspace = true } http = { workspace = true } +itertools = { workspace = true } parking_lot = { workspace = true, features = ["arc_lock"] } +prost = { workspace = true } rand = { workspace = true } rcgen = { workspace = true } s2-api = { path = "../api", features = ["axum"] } @@ -30,9 +32,17 @@ slatedb = { workspace = true } thiserror = { workspace = true } time = { workspace = true } tokio = { workspace = true, features = ["sync"] } +tokio-util = { workspace = true } +tower-http = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } [dev-dependencies] proptest = { workspace = true } uuid = { workspace = true } + +[profile.dev] +panic = "abort" + +[profile.release] +panic = "abort" diff --git a/lite/src/backend/append.rs b/lite/src/backend/append.rs index 4545987a..8d34c2d1 100644 --- a/lite/src/backend/append.rs +++ b/lite/src/backend/append.rs @@ -38,7 +38,7 @@ impl Backend { } pub async fn append_session( - &self, + self, basin: BasinName, stream: StreamName, inputs: impl Stream, @@ -57,7 +57,9 @@ impl Backend { ) => { let metered_size = input.records.metered_size(); inflight_bytes += metered_size; - let fut = client.append(input, Some(session.clone())).map(move |res| (metered_size, res)); + let fut = client + .append(input, Some(session.clone())) + .map(move |res| (metered_size, res)); futs.push_back(fut); } Some((metered_size, res)) = futs.next() => { diff --git a/lite/src/backend/basins.rs b/lite/src/backend/basins.rs index 336b045d..bcfe8ba6 100644 --- a/lite/src/backend/basins.rs +++ b/lite/src/backend/basins.rs @@ -13,12 +13,11 @@ use slatedb::{ }; use time::OffsetDateTime; -use super::{Backend, store::db_txn_get}; +use super::{Backend, CreatedOrReconfigured, store::db_txn_get}; use crate::backend::{ error::{ - BasinAlreadyExistsError, BasinDeletionInProgressError, BasinNotFoundError, - CreateBasinError, DeleteBasinError, GetBasinConfigError, ListBasinsError, - ReconfigureBasinError, + BasinAlreadyExistsError, BasinDeletionPendingError, BasinNotFoundError, CreateBasinError, + DeleteBasinError, GetBasinConfigError, ListBasinsError, ReconfigureBasinError, }, kv, }; @@ -78,7 +77,7 @@ impl Backend { basin: BasinName, config: BasinConfig, mode: CreateMode, - ) -> Result { + ) -> Result, CreateBasinError> { let meta_key = kv::basin_meta::ser_key(&basin); let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; @@ -95,18 +94,18 @@ impl Backend { db_txn_get(&txn, &meta_key, kv::basin_meta::deser_value).await? { if existing_meta.deleted_at.is_some() { - return Err(BasinDeletionInProgressError { basin }.into()); + return Err(BasinDeletionPendingError { basin }.into()); } match mode { CreateMode::CreateOnly(_) => { return if creation_idempotency_key.is_some() && existing_meta.creation_idempotency_key == creation_idempotency_key { - Ok(BasinInfo { + Ok(CreatedOrReconfigured::Created(BasinInfo { name: basin, scope: None, state: BasinState::Active, - }) + })) } else { Err(BasinAlreadyExistsError { basin }.into()) }; @@ -119,7 +118,7 @@ impl Backend { let created_at = existing_created_at.unwrap_or_else(OffsetDateTime::now_utc); - let meta = kv::BasinMeta { + let meta = kv::basin_meta::BasinMeta { config, created_at, deleted_at: None, @@ -133,10 +132,16 @@ impl Backend { }; txn.commit_with_options(&WRITE_OPTS).await?; - Ok(BasinInfo { + let info = BasinInfo { name: basin, scope: None, state: BasinState::Active, + }; + + Ok(if existing_created_at.is_some() { + CreatedOrReconfigured::Reconfigured(info) + } else { + CreatedOrReconfigured::Created(info) }) } @@ -167,7 +172,7 @@ impl Backend { }; if meta.deleted_at.is_some() { - return Err(BasinDeletionInProgressError { basin }.into()); + return Err(BasinDeletionPendingError { basin }.into()); } meta.config = meta.config.reconfigure(reconfig); diff --git a/lite/src/backend/core.rs b/lite/src/backend/core.rs index d5a1e114..f2c7462c 100644 --- a/lite/src/backend/core.rs +++ b/lite/src/backend/core.rs @@ -16,8 +16,8 @@ use tokio::time::Instant; use super::{ error::{ - BasinDeletionInProgressError, BasinNotFoundError, CreateStreamError, GetBasinConfigError, - StorageError, StreamDeletionInProgressError, StreamNotFoundError, StreamerError, + BasinDeletionPendingError, BasinNotFoundError, CreateStreamError, GetBasinConfigError, + StorageError, StreamDeletionPendingError, StreamNotFoundError, StreamerError, TransactionConflictError, UnavailableError, }, kv, @@ -81,7 +81,7 @@ impl Backend { let trim_point = trim_point.unwrap_or(..SeqNum::MIN); if trim_point.end == SeqNum::MAX { - return Err(StreamDeletionInProgressError { basin, stream }.into()); + return Err(StreamDeletionPendingError { basin, stream }.into()); } let client_states = self.client_states.clone(); @@ -220,8 +220,8 @@ impl Backend { + From + From + From - + From - + From + + From + + From + From, { match self.streamer_client(basin, stream).await { @@ -246,8 +246,8 @@ impl Backend { CreateStreamError::Storage(e) => Err(e)?, CreateStreamError::TransactionConflict(e) => Err(e)?, CreateStreamError::Unavailable(e) => Err(e)?, - CreateStreamError::BasinDeletionInProgress(e) => Err(e)?, - CreateStreamError::StreamDeletionInProgress(e) => Err(e)?, + CreateStreamError::BasinDeletionPending(e) => Err(e)?, + CreateStreamError::StreamDeletionPending(e) => Err(e)?, CreateStreamError::BasinNotFound(e) => Err(e)?, CreateStreamError::StreamAlreadyExists(_) => {} } @@ -289,7 +289,7 @@ mod tests { let stream = StreamName::from_str("stream1").unwrap(); let stream_id = StreamId::new(&basin, &stream); - let meta = kv::StreamMeta { + let meta = kv::stream_meta::StreamMeta { config: OptionalStreamConfig::default(), created_at: OffsetDateTime::now_utc(), deleted_at: None, diff --git a/lite/src/backend/error.rs b/lite/src/backend/error.rs index b8f89e78..2dfec4c2 100644 --- a/lite/src/backend/error.rs +++ b/lite/src/backend/error.rs @@ -49,13 +49,13 @@ pub struct StreamAlreadyExistsError { #[derive(Debug, Clone, thiserror::Error)] #[error("basin `{basin}` is being deleted")] -pub struct BasinDeletionInProgressError { +pub struct BasinDeletionPendingError { pub basin: BasinName, } #[derive(Debug, Clone, thiserror::Error)] #[error("stream `{stream}` in basin `{basin}` is being deleted")] -pub struct StreamDeletionInProgressError { +pub struct StreamDeletionPendingError { pub basin: BasinName, pub stream: StreamName, } @@ -87,7 +87,7 @@ pub(super) enum StreamerError { #[error(transparent)] StreamNotFound(#[from] StreamNotFoundError), #[error(transparent)] - StreamDeletionInProgress(#[from] StreamDeletionInProgressError), + StreamDeletionPending(#[from] StreamDeletionPendingError), } #[derive(Debug, Clone, thiserror::Error)] @@ -115,9 +115,9 @@ pub enum CheckTailError { #[error(transparent)] StreamNotFound(#[from] StreamNotFoundError), #[error(transparent)] - BasinDeletionInProgress(#[from] BasinDeletionInProgressError), + BasinDeletionPending(#[from] BasinDeletionPendingError), #[error(transparent)] - StreamDeletionInProgress(#[from] StreamDeletionInProgressError), + StreamDeletionPending(#[from] StreamDeletionPendingError), } impl From for CheckTailError { @@ -125,7 +125,7 @@ impl From for CheckTailError { match e { StreamerError::StreamNotFound(e) => Self::StreamNotFound(e), StreamerError::Storage(e) => Self::Storage(e), - StreamerError::StreamDeletionInProgress(e) => Self::StreamDeletionInProgress(e), + StreamerError::StreamDeletionPending(e) => Self::StreamDeletionPending(e), } } } @@ -143,9 +143,9 @@ pub enum AppendError { #[error(transparent)] StreamNotFound(#[from] StreamNotFoundError), #[error(transparent)] - BasinDeletionInProgress(#[from] BasinDeletionInProgressError), + BasinDeletionPending(#[from] BasinDeletionPendingError), #[error(transparent)] - StreamDeletionInProgress(#[from] StreamDeletionInProgressError), + StreamDeletionPending(#[from] StreamDeletionPendingError), #[error(transparent)] ConditionFailed(#[from] AppendConditionFailedError), #[error(transparent)] @@ -195,7 +195,7 @@ impl From for AppendError { match e { StreamerError::StreamNotFound(e) => Self::StreamNotFound(e), StreamerError::Storage(e) => Self::Storage(e), - StreamerError::StreamDeletionInProgress(e) => Self::StreamDeletionInProgress(e), + StreamerError::StreamDeletionPending(e) => Self::StreamDeletionPending(e), } } } @@ -213,9 +213,9 @@ pub enum ReadError { #[error(transparent)] StreamNotFound(#[from] StreamNotFoundError), #[error(transparent)] - BasinDeletionInProgress(#[from] BasinDeletionInProgressError), + BasinDeletionPending(#[from] BasinDeletionPendingError), #[error(transparent)] - StreamDeletionInProgress(#[from] StreamDeletionInProgressError), + StreamDeletionPending(#[from] StreamDeletionPendingError), #[error(transparent)] TailExceeded(#[from] TailExceededError), } @@ -225,7 +225,7 @@ impl From for ReadError { match e { StreamerError::StreamNotFound(e) => Self::StreamNotFound(e), StreamerError::Storage(e) => Self::Storage(e), - StreamerError::StreamDeletionInProgress(e) => Self::StreamDeletionInProgress(e), + StreamerError::StreamDeletionPending(e) => Self::StreamDeletionPending(e), } } } @@ -271,11 +271,11 @@ pub enum CreateStreamError { #[error(transparent)] BasinNotFound(#[from] BasinNotFoundError), #[error(transparent)] - BasinDeletionInProgress(#[from] BasinDeletionInProgressError), + BasinDeletionPending(#[from] BasinDeletionPendingError), #[error(transparent)] StreamAlreadyExists(#[from] StreamAlreadyExistsError), #[error(transparent)] - StreamDeletionInProgress(#[from] StreamDeletionInProgressError), + StreamDeletionPending(#[from] StreamDeletionPendingError), } impl From for CreateStreamError { @@ -346,7 +346,7 @@ pub enum CreateBasinError { #[error(transparent)] BasinAlreadyExists(#[from] BasinAlreadyExistsError), #[error(transparent)] - BasinDeletionInProgress(#[from] BasinDeletionInProgressError), + BasinDeletionPending(#[from] BasinDeletionPendingError), } impl From for CreateBasinError { @@ -372,7 +372,7 @@ pub enum ReconfigureBasinError { #[error(transparent)] BasinNotFound(#[from] BasinNotFoundError), #[error(transparent)] - BasinDeletionInProgress(#[from] BasinDeletionInProgressError), + BasinDeletionPending(#[from] BasinDeletionPendingError), } impl From for ReconfigureBasinError { @@ -396,7 +396,7 @@ pub enum ReconfigureStreamError { #[error(transparent)] StreamNotFound(#[from] StreamNotFoundError), #[error(transparent)] - StreamDeletionInProgress(#[from] StreamDeletionInProgressError), + StreamDeletionPending(#[from] StreamDeletionPendingError), } impl From for ReconfigureStreamError { diff --git a/lite/src/backend/kv/mod.rs b/lite/src/backend/kv/mod.rs index fb347b45..b247c328 100644 --- a/lite/src/backend/kv/mod.rs +++ b/lite/src/backend/kv/mod.rs @@ -7,14 +7,12 @@ pub mod stream_record_timestamp; pub mod stream_tail_position; pub mod stream_trim_point; -pub use basin_meta::BasinMeta; use bytes::{Buf, Bytes, BytesMut}; use enum_ordinalize::Ordinalize; use s2_common::{ record::{SeqNum, StreamPosition, Timestamp}, types::{basin::BasinName, stream::StreamName}, }; -pub use stream_meta::StreamMeta; use thiserror::Error; use crate::backend::stream_id::StreamId; @@ -61,7 +59,7 @@ pub enum Key { /// (SP) per-stream, updatable /// Key: StreamID /// Value: SeqNum Timestamp - /// TODO: unnecessary once sl8 supports reverse scans + // TODO: unnecessary once sl8 supports reverse order scan (https://github.com/slatedb/slatedb/issues/438) StreamTailPosition(StreamId), /// (SFT) per-stream, updatable, optional, default empty /// Key: StreamID diff --git a/lite/src/backend/mod.rs b/lite/src/backend/mod.rs index 41e89dbf..3ea61386 100644 --- a/lite/src/backend/mod.rs +++ b/lite/src/backend/mod.rs @@ -12,3 +12,21 @@ mod kv; mod stream_id; pub use core::{Backend, FOLLOWER_MAX_LAG}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreatedOrReconfigured { + Created(T), + Reconfigured(T), +} + +impl CreatedOrReconfigured { + pub fn is_created(&self) -> bool { + matches!(self, Self::Created(_)) + } + + pub fn into_inner(self) -> T { + match self { + Self::Created(v) | Self::Reconfigured(v) => v, + } + } +} diff --git a/lite/src/backend/read.rs b/lite/src/backend/read.rs index 66789bfb..87eb9f9d 100644 --- a/lite/src/backend/read.rs +++ b/lite/src/backend/read.rs @@ -91,6 +91,9 @@ impl Backend { until: end.until, tail, }; + if state.start_seq_num == tail.seq_num && !end.may_follow() { + return Err(TailExceededError(tail).into()); + } let db = self.db.clone(); let session = async_stream::try_stream! { 'session: while let EvaluatedReadLimit::Remaining(limit) = state.limit { diff --git a/lite/src/backend/streamer.rs b/lite/src/backend/streamer.rs index 561ab434..4373def2 100644 --- a/lite/src/backend/streamer.rs +++ b/lite/src/backend/streamer.rs @@ -37,7 +37,7 @@ use crate::backend::{ stream_id::StreamId, }; -// TODO: https://github.com/slatedb/slatedb/issues/1138 +// TODO: bump once sl8 supports pipelining (https://github.com/slatedb/slatedb/issues/1138) const MAX_INFLIGHT_APPENDS: usize = 1; const DORMANT_TIMEOUT: Duration = Duration::from_secs(60); @@ -528,10 +528,10 @@ async fn db_write_records( kv::stream_tail_position::ser_key(stream_id), kv::stream_tail_position::ser_value(next_pos(&records)), ); - static WRITE_OPTIONS: WriteOptions = WriteOptions { + static WRITE_OPTS: WriteOptions = WriteOptions { await_durable: true, }; - db.write_with_options(wb, &WRITE_OPTIONS).await?; + db.write_with_options(wb, &WRITE_OPTS).await?; Ok(records) } diff --git a/lite/src/backend/streams.rs b/lite/src/backend/streams.rs index 58968ccd..7bd8b782 100644 --- a/lite/src/backend/streams.rs +++ b/lite/src/backend/streams.rs @@ -13,12 +13,12 @@ use slatedb::{ }; use time::OffsetDateTime; -use super::{Backend, store::db_txn_get}; +use super::{Backend, CreatedOrReconfigured, store::db_txn_get}; use crate::backend::{ error::{ - BasinDeletionInProgressError, BasinNotFoundError, CreateStreamError, DeleteStreamError, + BasinDeletionPendingError, BasinNotFoundError, CreateStreamError, DeleteStreamError, GetStreamConfigError, ListStreamsError, ReconfigureStreamError, StreamAlreadyExistsError, - StreamDeletionInProgressError, StreamNotFoundError, StreamerError, + StreamDeletionPendingError, StreamNotFoundError, StreamerError, }, kv, }; @@ -36,6 +36,9 @@ impl Backend { } = request.into(); let key_range = kv::stream_meta::ser_key_range(&basin, &prefix, &start_after); + if key_range.is_empty() { + return Ok(Page::new_empty()); + } const SCAN_OPTS: ScanOptions = ScanOptions { durability_filter: DurabilityLevel::Remote, @@ -73,7 +76,7 @@ impl Backend { stream: StreamName, mut config: OptionalStreamConfig, mode: CreateMode, - ) -> Result { + ) -> Result, CreateStreamError> { let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; let Some(basin_meta) = db_txn_get( @@ -87,7 +90,7 @@ impl Backend { }; if basin_meta.deleted_at.is_some() { - return Err(BasinDeletionInProgressError { basin }.into()); + return Err(BasinDeletionPendingError { basin }.into()); } let stream_meta_key = kv::stream_meta::ser_key(&basin, &stream); @@ -105,8 +108,8 @@ impl Backend { db_txn_get(&txn, &stream_meta_key, kv::stream_meta::deser_value).await? { if existing_meta.deleted_at.is_some() { - return Err(CreateStreamError::StreamDeletionInProgress( - StreamDeletionInProgressError { basin, stream }, + return Err(CreateStreamError::StreamDeletionPending( + StreamDeletionPendingError { basin, stream }, )); } match mode { @@ -114,11 +117,11 @@ impl Backend { return if creation_idempotency_key.is_some() && existing_meta.creation_idempotency_key == creation_idempotency_key { - Ok(StreamInfo { + Ok(CreatedOrReconfigured::Created(StreamInfo { name: stream, created_at: existing_meta.created_at, deleted_at: None, - }) + })) } else { Err(StreamAlreadyExistsError { basin, stream }.into()) }; @@ -132,7 +135,7 @@ impl Backend { config = config.merge(basin_meta.config.default_stream_config).into(); let created_at = existing_created_at.unwrap_or_else(OffsetDateTime::now_utc); - let meta = kv::StreamMeta { + let meta = kv::stream_meta::StreamMeta { config: config.clone(), created_at, deleted_at: None, @@ -152,10 +155,16 @@ impl Backend { client.reconfigure(config).await?; } - Ok(StreamInfo { + let info = StreamInfo { name: stream, created_at, deleted_at: None, + }; + + Ok(if existing_created_at.is_some() { + CreatedOrReconfigured::Reconfigured(info) + } else { + CreatedOrReconfigured::Created(info) }) } @@ -192,7 +201,7 @@ impl Backend { })?; if meta.deleted_at.is_some() { - return Err(StreamDeletionInProgressError { basin, stream }.into()); + return Err(StreamDeletionPendingError { basin, stream }.into()); } meta.config = meta.config.reconfigure(reconfig); @@ -226,7 +235,7 @@ impl Backend { Err(StreamerError::StreamNotFound(e)) => { return Err(DeleteStreamError::StreamNotFound(e)); } - Err(StreamerError::StreamDeletionInProgress(e)) => { + Err(StreamerError::StreamDeletionPending(e)) => { assert_eq!(e.basin, basin); assert_eq!(e.stream, stream); } diff --git a/lite/src/bin/server.rs b/lite/src/bin/server.rs index b3489209..96672a6d 100644 --- a/lite/src/bin/server.rs +++ b/lite/src/bin/server.rs @@ -4,7 +4,9 @@ use axum_server::tls_rustls::RustlsConfig; use clap::Parser as _; use s2_lite::{backend::Backend, handlers}; use slatedb::object_store; +use tower_http::trace::{DefaultMakeSpan, DefaultOnRequest, DefaultOnResponse, TraceLayer}; use tracing::info; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[derive(clap::Args, Debug, Clone)] struct TlsConfig { @@ -46,7 +48,13 @@ struct Args { #[tokio::main] async fn main() -> eyre::Result<()> { - tracing_subscriber::fmt::init(); + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "server=info,s2_lite=info,tower_http=info".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); let args = Args::parse(); @@ -87,7 +95,12 @@ async fn main() -> eyre::Result<()> { .build() .await?; - let app = handlers::router(Backend::new(db)); + let app = handlers::router(Backend::new(db)).layer( + TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO)) + .on_request(DefaultOnRequest::new().level(tracing::Level::DEBUG)) + .on_response(DefaultOnResponse::new().level(tracing::Level::INFO)), + ); match ( args.tls.tls_self, diff --git a/lite/src/handlers/mod.rs b/lite/src/handlers/mod.rs index a31e565a..01802e19 100644 --- a/lite/src/handlers/mod.rs +++ b/lite/src/handlers/mod.rs @@ -1,5 +1,7 @@ mod v1; pub fn router(backend: crate::backend::Backend) -> axum::Router { - axum::Router::new().nest("/v1", v1::router(backend)) + axum::Router::new() + .route("/ping", axum::routing::get(|| async { "pong" })) + .nest("/v1", v1::router(backend)) } diff --git a/lite/src/handlers/v1/basins.rs b/lite/src/handlers/v1/basins.rs index 8b137891..08168913 100644 --- a/lite/src/handlers/v1/basins.rs +++ b/lite/src/handlers/v1/basins.rs @@ -1 +1,143 @@ +use axum::extract::{FromRequest, Path, Query, State}; +use http::StatusCode; +use s2_api::{ + data::{Json, extract::JsonOpt}, + v1 as v1t, +}; +use s2_common::{ + http::extract::HeaderOpt, + types::{ + basin::{BasinName, ListBasinsRequest}, + config::{BasinConfig, BasinReconfiguration}, + resources::{CreateMode, Page, RequestToken}, + }, +}; +use crate::{backend::Backend, handlers::v1::error::ServiceError}; + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct ListArgs { + #[from_request(via(Query))] + request: v1t::basin::ListBasinsRequest, +} + +pub async fn list( + State(backend): State, + ListArgs { request }: ListArgs, +) -> Result, ServiceError> { + let request: ListBasinsRequest = request.try_into()?; + let Page { values, has_more } = backend.list_basins(request).await?; + Ok(Json(v1t::basin::ListBasinsResponse { + basins: values.into_iter().map(Into::into).collect(), + has_more, + })) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct CreateArgs { + request_token: HeaderOpt, + #[from_request(via(Json))] + request: v1t::basin::CreateBasinRequest, +} + +pub async fn create( + State(backend): State, + CreateArgs { + request_token: HeaderOpt(request_token), + request, + }: CreateArgs, +) -> Result<(StatusCode, Json), ServiceError> { + let config: BasinConfig = request + .config + .map(TryInto::try_into) + .transpose()? + .unwrap_or_default(); + let info = backend + .create_basin(request.basin, config, CreateMode::CreateOnly(request_token)) + .await?; + Ok((StatusCode::CREATED, Json(info.into_inner().into()))) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct GetConfigArgs { + #[from_request(via(Path))] + basin: BasinName, +} + +pub async fn get_config( + State(backend): State, + GetConfigArgs { basin }: GetConfigArgs, +) -> Result, ServiceError> { + let config = backend.get_basin_config(basin).await?; + Ok(Json(config.into())) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct CreateOrReconfigureArgs { + #[from_request(via(Path))] + basin: BasinName, + request: JsonOpt, +} + +pub async fn create_or_reconfigure( + State(backend): State, + CreateOrReconfigureArgs { + basin, + request: JsonOpt(request), + }: CreateOrReconfigureArgs, +) -> Result<(StatusCode, Json), ServiceError> { + let config: BasinConfig = request + .and_then(|req| req.config) + .map(TryInto::try_into) + .transpose()? + .unwrap_or_default(); + let info = backend + .create_basin(basin, config, CreateMode::CreateOrReconfigure) + .await?; + let status = if info.is_created() { + StatusCode::CREATED + } else { + StatusCode::OK + }; + Ok((status, Json(info.into_inner().into()))) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct DeleteArgs { + #[from_request(via(Path))] + basin: BasinName, +} + +pub async fn delete( + State(backend): State, + DeleteArgs { basin }: DeleteArgs, +) -> Result { + backend.delete_basin(basin).await?; + Ok(StatusCode::ACCEPTED) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct ReconfigureArgs { + #[from_request(via(Path))] + basin: BasinName, + #[from_request(via(Json))] + reconfiguration: v1t::config::BasinReconfiguration, +} + +pub async fn reconfigure( + State(backend): State, + ReconfigureArgs { + basin, + reconfiguration, + }: ReconfigureArgs, +) -> Result, ServiceError> { + let reconfiguration: BasinReconfiguration = reconfiguration.try_into()?; + let config = backend.reconfigure_basin(basin, reconfiguration).await?; + Ok(Json(config.into())) +} diff --git a/lite/src/handlers/v1/error.rs b/lite/src/handlers/v1/error.rs new file mode 100644 index 00000000..9840f804 --- /dev/null +++ b/lite/src/handlers/v1/error.rs @@ -0,0 +1,268 @@ +use axum::{ + extract::rejection::{JsonRejection, PathRejection, QueryRejection}, + response::{IntoResponse, Response}, +}; +use s2_api::{ + data::extract::ProtoRejection, + v1::{ + self as v1t, + error::{ErrorCode, ErrorInfo, ErrorResponse, GenericError}, + stream::{AppendInputStreamError, extract::AppendRequestRejection, s2s}, + }, +}; +use s2_common::{http::extract::HeaderRejection, types::ValidationError}; + +use crate::backend::error::{ + AppendConditionFailedError, AppendError, CheckTailError, CreateBasinError, CreateStreamError, + DeleteBasinError, DeleteStreamError, GetBasinConfigError, GetStreamConfigError, + ListBasinsError, ListStreamsError, ReadError, ReconfigureBasinError, ReconfigureStreamError, +}; + +#[derive(Debug, thiserror::Error)] +pub enum ServiceError { + #[error(transparent)] + HeaderRejection(#[from] HeaderRejection), + #[error(transparent)] + PathRejection(#[from] PathRejection), + #[error(transparent)] + QueryRejection(#[from] QueryRejection), + #[error(transparent)] + JsonRejection(#[from] JsonRejection), + #[error(transparent)] + ProtoRejection(#[from] ProtoRejection), + #[error(transparent)] + AppendInputStream(#[from] AppendInputStreamError), + #[error(transparent)] + Validation(#[from] ValidationError), + #[error(transparent)] + ListBasins(#[from] ListBasinsError), + #[error(transparent)] + CreateBasin(#[from] CreateBasinError), + #[error(transparent)] + GetBasinConfig(#[from] GetBasinConfigError), + #[error(transparent)] + DeleteBasin(#[from] DeleteBasinError), + #[error(transparent)] + ReconfigureBasin(#[from] ReconfigureBasinError), + #[error(transparent)] + ListStreams(#[from] ListStreamsError), + #[error(transparent)] + CreateStream(#[from] CreateStreamError), + #[error(transparent)] + GetStreamConfig(#[from] GetStreamConfigError), + #[error(transparent)] + DeleteStream(#[from] DeleteStreamError), + #[error(transparent)] + ReconfigureStream(#[from] ReconfigureStreamError), + #[error(transparent)] + CheckTail(#[from] CheckTailError), + #[error(transparent)] + Append(#[from] AppendError), + #[error(transparent)] + Read(#[from] ReadError), +} + +impl From for ServiceError { + fn from(value: AppendRequestRejection) -> Self { + match value { + AppendRequestRejection::HeaderRejection(e) => ServiceError::from(e), + AppendRequestRejection::JsonRejection(e) => ServiceError::from(e), + AppendRequestRejection::ProtoRejection(e) => ServiceError::from(e), + AppendRequestRejection::Validation(e) => ServiceError::Validation(e), + } + } +} + +impl ServiceError { + pub fn to_response(&self) -> ErrorResponse { + match self { + ServiceError::HeaderRejection(e) => generic(ErrorCode::BadHeader, e.to_string()), + ServiceError::PathRejection(e) => generic(ErrorCode::BadPath, e.body_text()), + ServiceError::QueryRejection(e) => generic(ErrorCode::BadQuery, e.body_text()), + ServiceError::JsonRejection(e) => generic(ErrorCode::BadJson, e.body_text()), + ServiceError::ProtoRejection(e) => generic(ErrorCode::BadProto, e.to_string()), + ServiceError::AppendInputStream(e) => match e { + AppendInputStreamError::FrameDecode(e) => { + generic(ErrorCode::BadFrame, e.to_string()) + } + AppendInputStreamError::Validation(e) => generic(ErrorCode::Invalid, e.to_string()), + }, + ServiceError::Validation(e) => generic(ErrorCode::Invalid, e.to_string()), + ServiceError::ListBasins(e) => match e { + ListBasinsError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + }, + ServiceError::CreateBasin(e) => match e { + CreateBasinError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + CreateBasinError::BasinAlreadyExists(e) => { + generic(ErrorCode::ResourceAlreadyExists, e.to_string()) + } + CreateBasinError::BasinDeletionPending(e) => { + generic(ErrorCode::BasinDeletionPending, e.to_string()) + } + }, + ServiceError::GetBasinConfig(e) => match e { + GetBasinConfigError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + GetBasinConfigError::BasinNotFound(e) => { + generic(ErrorCode::BasinNotFound, e.to_string()) + } + }, + ServiceError::DeleteBasin(e) => match e { + DeleteBasinError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + DeleteBasinError::BasinNotFound(e) => { + generic(ErrorCode::BasinNotFound, e.to_string()) + } + }, + ServiceError::ReconfigureBasin(e) => match e { + ReconfigureBasinError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + ReconfigureBasinError::TransactionConflict(e) => { + generic(ErrorCode::TransactionConflict, e.to_string()) + } + ReconfigureBasinError::BasinNotFound(e) => { + generic(ErrorCode::BasinNotFound, e.to_string()) + } + ReconfigureBasinError::BasinDeletionPending(e) => { + generic(ErrorCode::BasinDeletionPending, e.to_string()) + } + }, + ServiceError::ListStreams(e) => match e { + ListStreamsError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + }, + ServiceError::CreateStream(e) => match e { + CreateStreamError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + CreateStreamError::TransactionConflict(e) => { + generic(ErrorCode::TransactionConflict, e.to_string()) + } + CreateStreamError::Unavailable(e) => generic(ErrorCode::Unavailable, e.to_string()), + CreateStreamError::BasinNotFound(e) => { + generic(ErrorCode::BasinNotFound, e.to_string()) + } + CreateStreamError::BasinDeletionPending(e) => { + generic(ErrorCode::BasinDeletionPending, e.to_string()) + } + CreateStreamError::StreamAlreadyExists(e) => { + generic(ErrorCode::ResourceAlreadyExists, e.to_string()) + } + CreateStreamError::StreamDeletionPending(e) => { + generic(ErrorCode::StreamDeletionPending, e.to_string()) + } + }, + ServiceError::GetStreamConfig(e) => match e { + GetStreamConfigError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + GetStreamConfigError::StreamNotFound(e) => { + generic(ErrorCode::StreamNotFound, e.to_string()) + } + }, + ServiceError::DeleteStream(e) => match e { + DeleteStreamError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + DeleteStreamError::Unavailable(e) => generic(ErrorCode::Unavailable, e.to_string()), + DeleteStreamError::StreamNotFound(e) => { + generic(ErrorCode::StreamNotFound, e.to_string()) + } + }, + ServiceError::ReconfigureStream(e) => match e { + ReconfigureStreamError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + ReconfigureStreamError::TransactionConflict(e) => { + generic(ErrorCode::TransactionConflict, e.to_string()) + } + ReconfigureStreamError::Unavailable(e) => { + generic(ErrorCode::Unavailable, e.to_string()) + } + ReconfigureStreamError::StreamNotFound(e) => { + generic(ErrorCode::StreamNotFound, e.to_string()) + } + ReconfigureStreamError::StreamDeletionPending(e) => { + generic(ErrorCode::StreamDeletionPending, e.to_string()) + } + }, + ServiceError::CheckTail(e) => match e { + CheckTailError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + CheckTailError::TransactionConflict(e) => { + generic(ErrorCode::TransactionConflict, e.to_string()) + } + CheckTailError::Unavailable(e) => generic(ErrorCode::Unavailable, e.to_string()), + CheckTailError::BasinNotFound(e) => { + generic(ErrorCode::BasinNotFound, e.to_string()) + } + CheckTailError::StreamNotFound(e) => { + generic(ErrorCode::StreamNotFound, e.to_string()) + } + CheckTailError::BasinDeletionPending(e) => { + generic(ErrorCode::BasinDeletionPending, e.to_string()) + } + CheckTailError::StreamDeletionPending(e) => { + generic(ErrorCode::StreamDeletionPending, e.to_string()) + } + }, + ServiceError::Append(e) => match e { + AppendError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + AppendError::TransactionConflict(e) => { + generic(ErrorCode::TransactionConflict, e.to_string()) + } + AppendError::Unavailable(e) => generic(ErrorCode::Unavailable, e.to_string()), + AppendError::BasinNotFound(e) => generic(ErrorCode::BasinNotFound, e.to_string()), + AppendError::StreamNotFound(e) => generic(ErrorCode::StreamNotFound, e.to_string()), + AppendError::BasinDeletionPending(e) => { + generic(ErrorCode::BasinDeletionPending, e.to_string()) + } + AppendError::StreamDeletionPending(e) => { + generic(ErrorCode::StreamDeletionPending, e.to_string()) + } + AppendError::ConditionFailed(e) => ErrorResponse::AppendConditionFailed(match e { + AppendConditionFailedError::FencingTokenMismatch { actual, .. } => { + v1t::stream::AppendConditionFailed::FencingTokenMismatch(actual.clone()) + } + AppendConditionFailedError::SeqNumMismatch { + assigned_seq_num, .. + } => v1t::stream::AppendConditionFailed::SeqNumMismatch(*assigned_seq_num), + }), + AppendError::TimestampMissing(e) => generic(ErrorCode::Invalid, e.to_string()), + }, + ServiceError::Read(e) => match e { + ReadError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + ReadError::TransactionConflict(e) => { + generic(ErrorCode::TransactionConflict, e.to_string()) + } + ReadError::Unavailable(e) => generic(ErrorCode::Unavailable, e.to_string()), + ReadError::BasinNotFound(e) => generic(ErrorCode::BasinNotFound, e.to_string()), + ReadError::StreamNotFound(e) => generic(ErrorCode::StreamNotFound, e.to_string()), + ReadError::BasinDeletionPending(e) => { + generic(ErrorCode::BasinDeletionPending, e.to_string()) + } + ReadError::StreamDeletionPending(e) => { + generic(ErrorCode::StreamDeletionPending, e.to_string()) + } + ReadError::TailExceeded(tail) => { + ErrorResponse::TailExceeded(v1t::stream::TailResponse { + tail: tail.0.into(), + }) + } + }, + } + } +} + +impl IntoResponse for ServiceError { + fn into_response(self) -> Response { + self.to_response().into_response() + } +} + +impl From for s2s::TerminalMessage { + fn from(value: ServiceError) -> Self { + let (status, body) = value.to_response().to_parts(); + s2s::TerminalMessage { + status: status.as_u16(), + body, + } + } +} + +fn generic(code: ErrorCode, message: impl Into) -> ErrorResponse { + ErrorResponse::Generic(GenericError { + status: code.status(), + info: ErrorInfo { + code: code.into(), + message: message.into(), + }, + }) +} diff --git a/lite/src/handlers/v1/mod.rs b/lite/src/handlers/v1/mod.rs index 4ec9ab42..80b0ea70 100644 --- a/lite/src/handlers/v1/mod.rs +++ b/lite/src/handlers/v1/mod.rs @@ -1,13 +1,31 @@ mod basins; +mod error; mod records; mod streams; -use crate::backend::Backend; +const MAX_UNARY_READ_WAIT: std::time::Duration = std::time::Duration::from_secs(60); -pub fn router(backend: Backend) -> axum::Router { - use axum::routing::{get, post}; +pub fn router(backend: crate::backend::Backend) -> axum::Router { + use axum::routing::{delete, get, patch, post, put}; + + // TODO: timeout layer that respects long-poll read wait axum::Router::new() + // Basin ops + .route("/basins", get(basins::list)) + .route("/basins", post(basins::create)) + .route("/basins/{basin}", get(basins::get_config)) + .route("/basins/{basin}", put(basins::create_or_reconfigure)) + .route("/basins/{basin}", delete(basins::delete)) + .route("/basins/{basin}", patch(basins::reconfigure)) + // Stream ops + .route("/streams", get(streams::list)) + .route("/streams", post(streams::create)) + .route("/streams/{stream}", get(streams::get_config)) + .route("/streams/{stream}", put(streams::create_or_reconfigure)) + .route("/streams/{stream}", delete(streams::delete)) + .route("/streams/{stream}", patch(streams::reconfigure)) + // Record ops .route("/streams/{stream}/records/tail", get(records::check_tail)) .route("/streams/{stream}/records", get(records::read)) .route("/streams/{stream}/records", post(records::append)) diff --git a/lite/src/handlers/v1/records.rs b/lite/src/handlers/v1/records.rs index dd15898b..2e7ec3c2 100644 --- a/lite/src/handlers/v1/records.rs +++ b/lite/src/handlers/v1/records.rs @@ -1,36 +1,299 @@ +use std::time::Duration; + use axum::{ body::Body, - extract::{Path, Query, State}, - response::Response, + extract::{FromRequest, Path, Query, State}, + response::{IntoResponse, Response}, +}; +use futures::{Stream, StreamExt, TryStreamExt}; +use http::StatusCode; +use s2_api::{ + data::{Json, Proto}, + mime::JsonOrProto, + v1::{self as v1t, stream::s2s}, +}; +use s2_common::{ + caps::RECORD_BATCH_MAX, + http::extract::Header, + read_extent::{CountOrBytes, ReadLimit}, + record::{Metered, MeteredSize as _}, + types::{ + basin::BasinName, + stream::{ReadBatch, ReadEnd, ReadFrom, ReadSessionOutput, ReadStart, StreamName}, + }, }; -use s2_api::v1::{self as v1t, extractors::RequiredHeader}; -use s2_common::types::{basin::BasinName, stream::StreamName}; -use crate::backend::Backend; +use crate::{ + backend::{Backend, error::ReadError}, + handlers::v1::error::ServiceError, +}; -pub async fn append( - RequiredHeader(_basin): RequiredHeader, - Path(_stream): Path, - State(_backend): State, - _body: Body, -) -> Response { - todo!() -} - -pub async fn read( - RequiredHeader(_basin): RequiredHeader, - Path(_stream): Path, - Query(_start): Query, - Query(_end): Query, - State(_backend): State, -) -> Response { - todo!() +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct CheckTailArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, } pub async fn check_tail( - RequiredHeader(_basin): RequiredHeader, - Path(_stream): Path, - State(_backend): State, -) -> Response { - todo!() + State(backend): State, + CheckTailArgs { basin, stream }: CheckTailArgs, +) -> Result, ServiceError> { + let tail = backend.check_tail(basin, stream).await?; + Ok(Json(v1t::stream::TailResponse { tail: tail.into() })) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct ReadArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, + #[from_request(via(Query))] + start: v1t::stream::ReadStart, + #[from_request(via(Query))] + end: v1t::stream::ReadEnd, + request: v1t::stream::ReadRequest, +} + +pub async fn read( + State(backend): State, + ReadArgs { + basin, + stream, + start, + end, + request, + }: ReadArgs, +) -> Result { + match request { + v1t::stream::ReadRequest::Unary { + format, + response_mime, + } => { + let start: ReadStart = start.try_into()?; + let mut end: ReadEnd = end.into(); + end.limit = ReadLimit::CountOrBytes(end.limit.into_allowance(RECORD_BATCH_MAX)); + end.wait = end.wait.map(|d| d.min(super::MAX_UNARY_READ_WAIT)); + let session = backend.read(basin, stream, start, end).await?; + let batch = merge_read_session(session, end.wait).await?; + match response_mime { + JsonOrProto::Json => { + let batch = v1t::stream::ReadBatch::encode(format, batch); + Ok(Json(batch).into_response()) + } + JsonOrProto::Proto => { + let batch: v1t::stream::proto::ReadBatch = batch.into(); + Ok(Proto(batch).into_response()) + } + } + } + v1t::stream::ReadRequest::EventStream { + format, + last_event_id, + } => { + let mut start: ReadStart = start.try_into()?; + let mut end = end; + if let Some(v1t::stream::sse::LastEventId { + seq_num, + count, + bytes, + }) = last_event_id + { + start.from = ReadFrom::SeqNum(seq_num + 1); + end.count = end.count.map(|c| c.saturating_sub(count)); + end.bytes = end.bytes.map(|c| c.saturating_sub(bytes)); + } + let session = backend.read(basin, stream, start, end.into()).await?; + let events = async_stream::stream! { + let mut processed = CountOrBytes::ZERO; + tokio::pin!(session); + let mut errored = false; + while let Some(output) = session.next().await { + match output { + Ok(ReadSessionOutput::Heartbeat(_tail)) => { + yield v1t::stream::sse::ReadEvent::ping().try_into(); + }, + Ok(ReadSessionOutput::Batch(batch)) => { + let Some(last_record) = batch.records.last() else { + continue; + }; + processed.count += batch.records.len(); + processed.bytes += batch.records.metered_size(); + let id = v1t::stream::sse::LastEventId { + seq_num: last_record.position.seq_num, + count: processed.count, + bytes: processed.bytes, + }; + let batch = v1t::stream::ReadBatch::encode(format, batch); + yield v1t::stream::sse::ReadEvent::batch(batch, id).try_into(); + }, + Err(err) => { + let (_, body) = ServiceError::from(err).to_response().to_parts(); + yield v1t::stream::sse::ReadEvent::error(body).try_into(); + errored = true; + } + } + } + if !errored { + yield v1t::stream::sse::ReadEvent::done().try_into(); + } + }; + + Ok(axum::response::Sse::new(events).into_response()) + } + v1t::stream::ReadRequest::S2s { + response_compression, + } => { + let s2s_stream = backend + .read(basin, stream, start.try_into()?, end.into()) + .await? + .map_ok(|msg| match msg { + ReadSessionOutput::Heartbeat(tail) => v1t::stream::proto::ReadBatch { + records: vec![], + tail: Some(tail.into()), + }, + ReadSessionOutput::Batch(batch) => v1t::stream::proto::ReadBatch::from(batch), + }); + let response_stream = s2s::FramedMessageStream::<_>::new( + response_compression, + Box::pin(s2s_stream.map_err(ServiceError::from)), + ); + Ok(Response::builder() + .status(StatusCode::OK) + .header(http::header::CONTENT_TYPE, "s2s/proto") + .body(Body::from_stream(response_stream)) + .expect("valid response builder")) + } + } +} + +async fn merge_read_session( + session: impl Stream>, + wait: Option, +) -> Result { + let mut acc = ReadBatch { + records: Metered::with_capacity(RECORD_BATCH_MAX.count), + tail: None, + }; + let mut wait_mode = false; + tokio::pin!(session); + while let Some(output) = session.next().await { + match output? { + ReadSessionOutput::Batch(batch) => { + assert!(!batch.records.is_empty(), "unexpected empty batch"); + assert!( + (acc.records.metered_size() + batch.records.metered_size()) + <= RECORD_BATCH_MAX.bytes + && acc.records.len() + batch.records.len() <= RECORD_BATCH_MAX.count, + "cannot accumulate more than limit" + ); + acc.records.append(batch.records); + acc.tail = batch.tail; + if wait_mode { + break; + } + } + ReadSessionOutput::Heartbeat(pos) => { + assert!( + wait.is_some_and(|d| d > Duration::ZERO), + "heartbeat {pos} only if non-zero wait" + ); + if !acc.records.is_empty() { + break; + } + wait_mode = true; + } + } + } + Ok(acc) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct AppendArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, + request: v1t::stream::AppendRequest, +} + +pub async fn append( + State(backend): State, + AppendArgs { + basin, + stream, + request, + }: AppendArgs, +) -> Result { + match request { + v1t::stream::AppendRequest::Unary { + input, + response_mime, + } => { + let ack = backend.append(basin, stream, input).await?; + match response_mime { + JsonOrProto::Json => { + let ack: v1t::stream::AppendAck = ack.into(); + Ok(Json(ack).into_response()) + } + JsonOrProto::Proto => { + let ack: v1t::stream::proto::AppendAck = ack.into(); + Ok(Proto(ack).into_response()) + } + } + } + v1t::stream::AppendRequest::S2s { + inputs, + response_compression, + } => { + let (err_tx, err_rx) = tokio::sync::oneshot::channel(); + + let inputs = async_stream::stream! { + tokio::pin!(inputs); + let mut err_tx = Some(err_tx); + while let Some(input) = inputs.next().await { + match input { + Ok(input) => yield input, + Err(e) => { + if let Some(tx) = err_tx.take() { + let _ = tx.send(e); + } + break; + } + } + } + }; + + let ack_stream = backend + .append_session(basin, stream, inputs) + .await? + .map(|res| { + res.map(v1t::stream::proto::AppendAck::from) + .map_err(ServiceError::from) + }); + + let input_err_stream = futures::stream::once(err_rx).filter_map(|res| async move { + match res { + Ok(err) => Some(Err(err.into())), + Err(_) => None, + } + }); + + let response_stream = s2s::FramedMessageStream::<_>::new( + response_compression, + Box::pin(ack_stream.chain(input_err_stream)), + ); + + Ok(Response::builder() + .status(StatusCode::OK) + .header(http::header::CONTENT_TYPE, "s2s/proto") + .body(Body::from_stream(response_stream)) + .expect("valid response builder")) + } + } } diff --git a/lite/src/handlers/v1/streams.rs b/lite/src/handlers/v1/streams.rs index 8b137891..017694e6 100644 --- a/lite/src/handlers/v1/streams.rs +++ b/lite/src/handlers/v1/streams.rs @@ -1 +1,169 @@ +use axum::extract::{FromRequest, Path, Query, State}; +use http::StatusCode; +use s2_api::{ + data::{Json, extract::JsonOpt}, + v1 as v1t, +}; +use s2_common::{ + http::extract::{Header, HeaderOpt}, + types::{ + basin::BasinName, + config::{OptionalStreamConfig, StreamReconfiguration}, + resources::{CreateMode, Page, RequestToken}, + stream::{ListStreamsRequest, StreamName}, + }, +}; +use crate::{backend::Backend, handlers::v1::error::ServiceError}; + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct ListArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Query))] + request: v1t::stream::ListStreamsRequest, +} + +pub async fn list( + State(backend): State, + ListArgs { basin, request }: ListArgs, +) -> Result, ServiceError> { + let request: ListStreamsRequest = request.try_into()?; + let Page { values, has_more } = backend.list_streams(basin, request).await?; + Ok(Json(v1t::stream::ListStreamsResponse { + streams: values.into_iter().map(Into::into).collect(), + has_more, + })) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct CreateArgs { + request_token: HeaderOpt, + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Json))] + request: v1t::stream::CreateStreamRequest, +} + +pub async fn create( + State(backend): State, + CreateArgs { + request_token: HeaderOpt(request_token), + basin, + request, + }: CreateArgs, +) -> Result<(StatusCode, Json), ServiceError> { + let config: OptionalStreamConfig = request + .config + .map(TryInto::try_into) + .transpose()? + .unwrap_or_default(); + let info = backend + .create_stream( + basin, + request.stream, + config, + CreateMode::CreateOnly(request_token), + ) + .await?; + Ok((StatusCode::CREATED, Json(info.into_inner().into()))) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct GetConfigArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, +} + +pub async fn get_config( + State(backend): State, + GetConfigArgs { basin, stream }: GetConfigArgs, +) -> Result, ServiceError> { + let config = backend.get_stream_config(basin, stream).await?; + Ok(Json( + v1t::config::StreamConfig::to_opt(config).unwrap_or_default(), + )) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct CreateOrReconfigureArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, + config: JsonOpt, +} + +pub async fn create_or_reconfigure( + State(backend): State, + CreateOrReconfigureArgs { + basin, + stream, + config: JsonOpt(config), + }: CreateOrReconfigureArgs, +) -> Result<(StatusCode, Json), ServiceError> { + let config: OptionalStreamConfig = config + .map(TryInto::try_into) + .transpose()? + .unwrap_or_default(); + let info = backend + .create_stream(basin, stream, config, CreateMode::CreateOrReconfigure) + .await?; + let status = if info.is_created() { + StatusCode::CREATED + } else { + StatusCode::OK + }; + Ok((status, Json(info.into_inner().into()))) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct DeleteArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, +} + +pub async fn delete( + State(backend): State, + DeleteArgs { basin, stream }: DeleteArgs, +) -> Result { + backend.delete_stream(basin, stream).await?; + Ok(StatusCode::ACCEPTED) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct ReconfigureArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, + #[from_request(via(Json))] + reconfiguration: v1t::config::StreamReconfiguration, +} + +pub async fn reconfigure( + State(backend): State, + ReconfigureArgs { + basin, + stream, + reconfiguration, + }: ReconfigureArgs, +) -> Result, ServiceError> { + let reconfiguration: StreamReconfiguration = reconfiguration.try_into()?; + let config = backend + .reconfigure_stream(basin, stream, reconfiguration) + .await?; + Ok(Json( + v1t::config::StreamConfig::to_opt(config).unwrap_or_default(), + )) +} diff --git a/lite/tests/backend/control_plane/basin.rs b/lite/tests/backend/control_plane/basin.rs index d006a08a..d7d37f1c 100644 --- a/lite/tests/backend/control_plane/basin.rs +++ b/lite/tests/backend/control_plane/basin.rs @@ -234,7 +234,7 @@ async fn test_delete_basin_marks_deleting_and_blocks_create() { .await; assert!(matches!( reconfigure_result, - Err(ReconfigureBasinError::BasinDeletionInProgress(_)) + Err(ReconfigureBasinError::BasinDeletionPending(_)) )); let recreate_result = backend @@ -246,7 +246,7 @@ async fn test_delete_basin_marks_deleting_and_blocks_create() { .await; assert!(matches!( recreate_result, - Err(CreateBasinError::BasinDeletionInProgress(_)) + Err(CreateBasinError::BasinDeletionPending(_)) )); backend diff --git a/lite/tests/backend/control_plane/stream.rs b/lite/tests/backend/control_plane/stream.rs index e2cc5a73..d119954c 100644 --- a/lite/tests/backend/control_plane/stream.rs +++ b/lite/tests/backend/control_plane/stream.rs @@ -21,7 +21,7 @@ use s2_lite::backend::{ Backend, error::{ AppendError, CheckTailError, CreateStreamError, DeleteStreamError, GetStreamConfigError, - ReadError, ReconfigureStreamError, StreamDeletionInProgressError, + ReadError, ReconfigureStreamError, StreamDeletionPendingError, }, }; @@ -335,7 +335,7 @@ async fn test_create_stream_fails_when_basin_deleting() { assert!(matches!( result, - Err(CreateStreamError::BasinDeletionInProgress(_)) + Err(CreateStreamError::BasinDeletionPending(_)) )); } @@ -379,8 +379,8 @@ async fn test_delete_stream_marks_deleted_and_blocks_recreation() { .await; assert!(matches!( recreate_result, - Err(CreateStreamError::StreamDeletionInProgress( - StreamDeletionInProgressError { basin, stream } + Err(CreateStreamError::StreamDeletionPending( + StreamDeletionPendingError { basin, stream } )) if basin == basin_name && stream == stream_name )); @@ -393,7 +393,7 @@ async fn test_delete_stream_marks_deleted_and_blocks_recreation() { .await; assert!(matches!( reconfigure_result, - Err(ReconfigureStreamError::StreamDeletionInProgress(_)) + Err(ReconfigureStreamError::StreamDeletionPending(_)) )); backend @@ -432,7 +432,7 @@ async fn test_delete_stream_blocks_data_operations() { .await; assert!(matches!( tail, - Err(CheckTailError::StreamDeletionInProgress(_)) + Err(CheckTailError::StreamDeletionPending(_)) )); let input = AppendInput { @@ -445,7 +445,7 @@ async fn test_delete_stream_blocks_data_operations() { .await; assert!(matches!( append_result, - Err(AppendError::StreamDeletionInProgress(_)) + Err(AppendError::StreamDeletionPending(_)) )); let start = ReadStart { @@ -458,7 +458,7 @@ async fn test_delete_stream_blocks_data_operations() { .await; assert!(matches!( read_result, - Err(ReadError::StreamDeletionInProgress(_)) + Err(ReadError::StreamDeletionPending(_)) )); } diff --git a/lite/tests/backend/data_plane/append.rs b/lite/tests/backend/data_plane/append.rs index 01eb482c..a769d4d2 100644 --- a/lite/tests/backend/data_plane/append.rs +++ b/lite/tests/backend/data_plane/append.rs @@ -266,6 +266,7 @@ async fn test_append_session_basic() { ]); let session = backend + .clone() .append_session(basin_name.clone(), stream_name.clone(), inputs) .await .expect("Failed to create append session"); @@ -304,6 +305,7 @@ async fn test_append_session_empty() { let inputs = futures::stream::iter(vec![]); let session = backend + .clone() .append_session(basin_name.clone(), stream_name.clone(), inputs) .await .expect("Failed to create append session"); @@ -349,6 +351,7 @@ async fn test_append_session_multiple_records_per_batch() { ]); let session = backend + .clone() .append_session(basin_name.clone(), stream_name.clone(), inputs) .await .expect("Failed to create append session"); @@ -530,6 +533,7 @@ async fn test_append_session_large_batches() { })); let session = backend + .clone() .append_session(basin_name.clone(), stream_name.clone(), inputs) .await .expect("Failed to create append session"); From 7617a379353bf37d1c72ce44b538f12ea84100ec Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Fri, 9 Jan 2026 08:57:15 -0500 Subject: [PATCH 032/700] test: error on reading unwritten when can't follow (#29) rename from `TailExceeded` to `Unwritten` --- api/src/v1/error.rs | 4 +- lite/src/backend/error.rs | 6 +- lite/src/backend/read.rs | 16 ++-- lite/src/handlers/v1/error.rs | 8 +- lite/tests/backend/data_plane/read.rs | 121 ++++++++++++++++++-------- 5 files changed, 105 insertions(+), 50 deletions(-) diff --git a/api/src/v1/error.rs b/api/src/v1/error.rs index 627be5c5..77e1f7ce 100644 --- a/api/src/v1/error.rs +++ b/api/src/v1/error.rs @@ -85,7 +85,7 @@ pub struct GenericError { #[derive(Debug, Clone)] pub enum ErrorResponse { AppendConditionFailed(super::stream::AppendConditionFailed), - TailExceeded(super::stream::TailResponse), + Unwritten(super::stream::TailResponse), Generic(GenericError), } @@ -96,7 +96,7 @@ impl ErrorResponse { http::StatusCode::PRECONDITION_FAILED, serde_json::to_string(&payload), ), - ErrorResponse::TailExceeded(payload) => ( + ErrorResponse::Unwritten(payload) => ( http::StatusCode::RANGE_NOT_SATISFIABLE, serde_json::to_string(&payload), ), diff --git a/lite/src/backend/error.rs b/lite/src/backend/error.rs index 2dfec4c2..ed3ef069 100644 --- a/lite/src/backend/error.rs +++ b/lite/src/backend/error.rs @@ -61,8 +61,8 @@ pub struct StreamDeletionPendingError { } #[derive(Debug, Clone, thiserror::Error)] -#[error("tail position exceeded: {0}")] -pub struct TailExceededError(pub StreamPosition); +#[error("unwritten position: {0}")] +pub struct UnwrittenError(pub StreamPosition); #[derive(Debug, Clone, thiserror::Error)] pub enum UnavailableError { @@ -217,7 +217,7 @@ pub enum ReadError { #[error(transparent)] StreamDeletionPending(#[from] StreamDeletionPendingError), #[error(transparent)] - TailExceeded(#[from] TailExceededError), + Unwritten(#[from] UnwrittenError), } impl From for ReadError { diff --git a/lite/src/backend/read.rs b/lite/src/backend/read.rs index 87eb9f9d..86aa1bc8 100644 --- a/lite/src/backend/read.rs +++ b/lite/src/backend/read.rs @@ -15,7 +15,7 @@ use tokio::sync::broadcast; use super::Backend; use crate::backend::{ - error::{CheckTailError, ReadError, TailExceededError, UnavailableError}, + error::{CheckTailError, ReadError, UnavailableError, UnwrittenError}, kv, stream_id::StreamId, }; @@ -25,6 +25,7 @@ impl Backend { &self, stream_id: StreamId, start: ReadStart, + end: ReadEnd, tail: StreamPosition, ) -> Result { let mut read_pos = match start.from { @@ -43,9 +44,15 @@ impl Backend { if start.clamp { read_pos = ReadPosition::SeqNum(tail.seq_num); } else { - return Err(TailExceededError(tail).into()); + return Err(UnwrittenError(tail).into()); } } + if let ReadPosition::SeqNum(start_seq_num) = read_pos + && start_seq_num == tail.seq_num + && !end.may_follow() + { + return Err(UnwrittenError(tail).into()); + } Ok(match read_pos { ReadPosition::SeqNum(start_seq_num) => start_seq_num, ReadPosition::Timestamp(start_timestamp) => { @@ -86,14 +93,11 @@ impl Backend { let stream_id = client.stream_id(); let tail = client.check_tail().await?; let mut state = ReadSessionState { - start_seq_num: self.read_start_seq_num(stream_id, start, tail).await?, + start_seq_num: self.read_start_seq_num(stream_id, start, end, tail).await?, limit: EvaluatedReadLimit::Remaining(end.limit), until: end.until, tail, }; - if state.start_seq_num == tail.seq_num && !end.may_follow() { - return Err(TailExceededError(tail).into()); - } let db = self.db.clone(); let session = async_stream::try_stream! { 'session: while let EvaluatedReadLimit::Remaining(limit) = state.limit { diff --git a/lite/src/handlers/v1/error.rs b/lite/src/handlers/v1/error.rs index 9840f804..c812cadb 100644 --- a/lite/src/handlers/v1/error.rs +++ b/lite/src/handlers/v1/error.rs @@ -231,11 +231,9 @@ impl ServiceError { ReadError::StreamDeletionPending(e) => { generic(ErrorCode::StreamDeletionPending, e.to_string()) } - ReadError::TailExceeded(tail) => { - ErrorResponse::TailExceeded(v1t::stream::TailResponse { - tail: tail.0.into(), - }) - } + ReadError::Unwritten(tail) => ErrorResponse::Unwritten(v1t::stream::TailResponse { + tail: tail.0.into(), + }), }, } } diff --git a/lite/tests/backend/data_plane/read.rs b/lite/tests/backend/data_plane/read.rs index 93515a9d..0f6da1f7 100644 --- a/lite/tests/backend/data_plane/read.rs +++ b/lite/tests/backend/data_plane/read.rs @@ -10,7 +10,7 @@ use s2_common::{ stream::{AppendInput, ReadEnd, ReadFrom, ReadSessionOutput, ReadStart}, }, }; -use s2_lite::backend::error::{CheckTailError, ReadError}; +use s2_lite::backend::error::{CheckTailError, ReadError, UnwrittenError}; use super::common::*; @@ -107,44 +107,32 @@ async fn test_read_with_limit() { } #[tokio::test] -async fn test_read_tail_exceeded_without_clamp() { +async fn test_read_unwritten_clamp_behavior() { let (backend, basin_name, stream_name) = setup_backend_with_stream( - "read-tail-exceeded", - "no-clamp", + "read-unwritten-clamp", + "stream", OptionalStreamConfig::default(), ) .await; - append_payloads(&backend, &basin_name, &stream_name, &[b"initial record"]).await; + append_payloads(&backend, &basin_name, &stream_name, &[b"record"]).await; + // Without clamp: returns Unwritten error let start = ReadStart { - from: ReadFrom::SeqNum(10), + from: ReadFrom::SeqNum(100), clamp: false, }; - let end = ReadEnd::default(); - - let result = backend.read(basin_name, stream_name, start, end).await; - - assert!(matches!(result, Err(ReadError::TailExceeded(_)))); -} - -#[tokio::test] -async fn test_read_tail_exceeded_with_clamp() { - let (backend, basin_name, stream_name) = setup_backend_with_stream( - "read-tail-clamped", - "clamp", - OptionalStreamConfig::default(), - ) - .await; - - append_payloads( - &backend, - &basin_name, - &stream_name, - &[b"record 1", b"record 2", b"record 3"], - ) - .await; + let result = backend + .read( + basin_name.clone(), + stream_name.clone(), + start, + ReadEnd::default(), + ) + .await; + assert!(matches!(result, Err(ReadError::Unwritten(_)))); + // With clamp: succeeds with empty result let start = ReadStart { from: ReadFrom::SeqNum(100), clamp: true, @@ -154,17 +142,82 @@ async fn test_read_tail_exceeded_with_clamp() { until: ReadUntil::Unbounded, wait: Some(Duration::ZERO), }; - let session = backend .read(basin_name, stream_name, start, end) .await - .expect("Read should succeed with clamp when tail exceeded"); - let mut session = Box::pin(session); - let records = collect_records(&mut session).await; - + .expect("should succeed with clamp"); + let records = collect_records(&mut Box::pin(session)).await; assert!(records.is_empty()); } +#[tokio::test] +async fn test_read_at_tail_without_follow_returns_unwritten() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-at-tail-no-follow", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let input = AppendInput { + records: create_test_record_batch_with_timestamps(vec![ + (Bytes::from_static(b"record 1"), 1000), + (Bytes::from_static(b"record 2"), 2000), + ]), + match_seq_num: None, + fencing_token: None, + }; + let ack = backend + .append(basin_name.clone(), stream_name.clone(), input) + .await + .expect("append"); + + let starts = [ + ReadFrom::TailOffset(0), + ReadFrom::SeqNum(ack.end.seq_num), + ReadFrom::Timestamp(ack.end.timestamp + 1), + ]; + let ends = [ + ReadEnd { + limit: ReadLimit::Count(10), + until: ReadUntil::Unbounded, + wait: None, + }, + ReadEnd { + limit: ReadLimit::Count(10), + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }, + ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Timestamp(u64::MAX), + wait: None, + }, + ]; + + for from in starts { + for end in ends { + for clamp in [false, true] { + let start = ReadStart { from, clamp }; + let result = backend + .read(basin_name.clone(), stream_name.clone(), start, end) + .await; + match result { + Err(ReadError::Unwritten(UnwrittenError(tail))) => { + assert_eq!(tail, ack.end); + } + Ok(_) => panic!( + "Expected Unwritten error for {from:?} / clamp={clamp} / {end:?}, got Ok" + ), + Err(e) => panic!( + "Expected Unwritten error for {from:?} / clamp={clamp} / {end:?}, got: {e:?}" + ), + } + } + } + } +} + #[tokio::test] async fn test_read_from_tail_offset() { let (backend, basin_name, stream_name) = setup_backend_with_stream( From 103a3db601d47b75df20ae1d119ea972e0ac1131 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Fri, 9 Jan 2026 09:31:45 -0500 Subject: [PATCH 033/700] chore: prep for releasing (#30) --- Cargo.lock | 14 ++++++++------ Cargo.toml | 7 ++++--- api/Cargo.toml | 9 +++++++-- common/Cargo.toml | 6 +++++- lite/Cargo.toml | 11 ++++++++--- 5 files changed, 32 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c78fa58d..450ef47b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -511,9 +511,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.51" +version = "1.2.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ "find-msvc-tools", "jobserver", @@ -1033,9 +1033,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" [[package]] name = "fixedbitset" @@ -3894,7 +3894,8 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" version = "5.4.0" -source = "git+https://github.com/infiniteregrets/utoipa?rev=9cd181d40ac0a50551ebe1995a4d73e8685890d6#9cd181d40ac0a50551ebe1995a4d73e8685890d6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" dependencies = [ "indexmap", "serde", @@ -3905,7 +3906,8 @@ dependencies = [ [[package]] name = "utoipa-gen" version = "5.4.0" -source = "git+https://github.com/infiniteregrets/utoipa?rev=9cd181d40ac0a50551ebe1995a4d73e8685890d6#9cd181d40ac0a50551ebe1995a4d73e8685890d6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 7e0e308c..75b0b9c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,8 @@ members = ["api", "common", "lite"] [workspace.package] edition = "2024" license = "MIT" -version = "0.1.0" +repository = "https://github.com/s2-streamstore/s2" +homepage = "https://s2.dev" [workspace.dependencies] async-stream = "0.3.6" @@ -14,7 +15,7 @@ axum = "0.8.8" axum-extra = "0.12" axum-server = "0.8.0" base64ct = { version = "1.8.2", features = ["alloc"] } -blake3 = "1.8.2" +blake3 = "1.8.3" bytes = "1.11.0" clap = { version = "4.5.54", features = ["derive"] } compact_str = "0.9.0" @@ -47,6 +48,6 @@ tokio-util = "0.7.18" tower-http = { version = "0.6.8", features = ["trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -utoipa = { git = "https://github.com/infiniteregrets/utoipa", rev = "9cd181d40ac0a50551ebe1995a4d73e8685890d6", features = ["time"] } +utoipa = { version = "5.4.0", features = ["time"] } uuid = { version = "1.19.0", features = ["v4"] } zstd = "0.13.3" diff --git a/api/Cargo.toml b/api/Cargo.toml index a8406fd1..ce1a01de 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -1,8 +1,13 @@ [package] name = "s2-api" +description = "API types for S2, the durable streams API" edition.workspace = true license.workspace = true -version.workspace = true +version = "0.1.0" +repository.workspace = true +homepage.workspace = true +keywords = ["s2", "durable", "streams", "log", "api"] +categories = ["api-bindings"] [features] axum = ["dep:axum", "s2-common/axum"] @@ -21,7 +26,7 @@ http = { workspace = true } itertools = { workspace = true } mime = { workspace = true } prost = { workspace = true } -s2-common = { path = "../common" } +s2-common = { version = "0.1.0", path = "../common" } serde = { workspace = true } serde_json = { workspace = true } strum = { workspace = true } diff --git a/common/Cargo.toml b/common/Cargo.toml index be71d70e..7049bbb6 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,8 +1,12 @@ [package] name = "s2-common" +description = "Common stuff for client and servers for S2, the durable streams API" edition.workspace = true license.workspace = true -version.workspace = true +version = "0.1.0" +repository.workspace = true +homepage.workspace = true +keywords = ["s2", "durable", "streams", "log", "api"] [features] axum = ["dep:axum"] diff --git a/lite/Cargo.toml b/lite/Cargo.toml index e4f85ca6..f661bdb9 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -1,8 +1,13 @@ [package] name = "s2-lite" +description = "Lightweight server implementation of S2, the durable streams API, backed by object storage" edition.workspace = true license.workspace = true -version.workspace = true +version = "0.1.0" +repository.workspace = true +homepage.workspace = true +keywords = ["s2", "durable", "streams", "object", "storage"] +categories = ["database-implementations"] [dependencies] async-stream = { workspace = true } @@ -24,8 +29,8 @@ parking_lot = { workspace = true, features = ["arc_lock"] } prost = { workspace = true } rand = { workspace = true } rcgen = { workspace = true } -s2-api = { path = "../api", features = ["axum"] } -s2-common = { path = "../common", features = ["clap"] } +s2-api = { version = "0.1.0", path = "../api", features = ["axum"] } +s2-common = { version = "0.1.0", path = "../common", features = ["clap"] } serde = { workspace = true } serde_json = { workspace = true } slatedb = { workspace = true } From 32268ef48113ce505abeff82374f523378df0689 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Fri, 9 Jan 2026 09:49:11 -0500 Subject: [PATCH 034/700] chore: update `s2-protos` submodule (#32) will be automated / made the source of truth, TBD --- api/protos | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/protos b/api/protos index 1c719609..1ea0452c 160000 --- a/api/protos +++ b/api/protos @@ -1 +1 @@ -Subproject commit 1c719609c03abbd0eb1214c899b03f0c6571d907 +Subproject commit 1ea0452c9a333f9b746ff87f57a32d02087ca94b From 3fe2957cee4dd6defca4f95ef256acbade8e6463 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Fri, 9 Jan 2026 11:55:50 -0500 Subject: [PATCH 035/700] chore: only keep versions in workspace `Cargo.toml`, feature deps in pkgs (#33) --- Cargo.lock | 56 ++-------------------------------- Cargo.toml | 78 +++++++++++++++++++++++------------------------ api/Cargo.toml | 12 ++++---- common/Cargo.toml | 14 ++++----- lite/Cargo.toml | 13 ++++---- 5 files changed, 59 insertions(+), 114 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 450ef47b..a8cc73fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -326,27 +326,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-extra" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" -dependencies = [ - "axum", - "axum-core", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "axum-macros" version = "0.5.0" @@ -633,26 +612,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const_format" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" -dependencies = [ - "const_format_proc_macros", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1860,7 +1819,7 @@ dependencies = [ "spin", "tokio", "tokio-util", - "toml 0.9.10+spec-1.1.0", + "toml 0.9.11+spec-1.1.0", "tracing", "tracing-subscriber", ] @@ -2994,10 +2953,8 @@ dependencies = [ "bytes", "clap", "compact_str", - "const_format", "enum-ordinalize", "enumset", - "futures", "http", "proptest", "rkyv", @@ -3017,7 +2974,6 @@ dependencies = [ "async-stream", "async-trait", "axum", - "axum-extra", "axum-server", "blake3", "bytes", @@ -3609,9 +3565,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.10+spec-1.1.0" +version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ "indexmap", "serde_core", @@ -3849,12 +3805,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index 75b0b9c6..66cb4f2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,45 +9,43 @@ repository = "https://github.com/s2-streamstore/s2" homepage = "https://s2.dev" [workspace.dependencies] -async-stream = "0.3.6" -async-trait = "0.1.89" -axum = "0.8.8" -axum-extra = "0.12" -axum-server = "0.8.0" -base64ct = { version = "1.8.2", features = ["alloc"] } -blake3 = "1.8.3" -bytes = "1.11.0" -clap = { version = "4.5.54", features = ["derive"] } -compact_str = "0.9.0" -const_format = "0.2.35" -dashmap = "6.1.0" -enum-ordinalize = "4.3.2" -enumset = "1.1.10" -eyre = "0.6.12" -flate2 = "1.1.5" -futures = "0.3.31" -http = "1.4.0" -itertools = "0.14.0" -mime = "0.3.17" -parking_lot = "0.12.5" -proptest = "1.9.0" -prost = "0.14.1" -prost-build = "0.14.1" -rand = "0.9.2" +async-stream = "0.3" +async-trait = "0.1" +axum = "0.8" +axum-server = "0.8" +base64ct = "1.8" +blake3 = "1.8" +bytes = "1.11" +clap = "4.5" +compact_str = "0.9" +dashmap = "6.1" +enum-ordinalize = "4.3" +enumset = "1.1" +eyre = "0.6" +flate2 = "1.1" +futures = "0.3" +http = "1.4" +itertools = "0.14" +mime = "0.3" +parking_lot = "0.12" +proptest = "1.9" +prost = "0.14" +prost-build = "0.14" +rand = "0.9" rcgen = "0.14" -rkyv = "0.8.13" -rstest = "0.26.1" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -slatedb = "0.10.0" -strum = { version = "0.27", features = ["derive"] } -thiserror = "2.0.17" -time = { version = "0.3.44", features = ["serde", "parsing", "formatting"] } -tokio = "1.49.0" -tokio-util = "0.7.18" -tower-http = { version = "0.6.8", features = ["trace"] } +rkyv = "0.8" +rstest = "0.26" +serde = "1.0" +serde_json = "1.0" +slatedb = "0.10" +strum = "0.27" +thiserror = "2.0" +time = "0.3" +tokio = "1.49" +tokio-util = "0.7" +tower-http = "0.6" tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -utoipa = { version = "5.4.0", features = ["time"] } -uuid = { version = "1.19.0", features = ["v4"] } -zstd = "0.13.3" +tracing-subscriber = "0.3" +utoipa = "5.4" +uuid = "1.19" +zstd = "0.13" diff --git a/api/Cargo.toml b/api/Cargo.toml index ce1a01de..e9aa9676 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -16,7 +16,7 @@ codegen = ["dep:prost-build"] [dependencies] axum = { workspace = true, optional = true } -base64ct = { workspace = true } +base64ct = { workspace = true, features = ["alloc"] } bytes = { workspace = true } compact_str = { workspace = true, features = ["serde"] } enum-ordinalize = { workspace = true } @@ -27,13 +27,13 @@ itertools = { workspace = true } mime = { workspace = true } prost = { workspace = true } s2-common = { version = "0.1.0", path = "../common" } -serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -strum = { workspace = true } +strum = { workspace = true, features = ["derive"] } thiserror = { workspace = true } -time = { workspace = true } -tokio-util = { workspace = true, features = ["rt", "codec", "io"] } -utoipa = { workspace = true, optional = true } +time = { workspace = true, features = ["serde", "formatting", "parsing"] } +tokio-util = { workspace = true, features = ["codec", "io"] } +utoipa = { workspace = true, optional = true, features = ["time"] } zstd = { workspace = true } [build-dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 7049bbb6..00aed639 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -15,22 +15,20 @@ rkyv = ["dep:rkyv", "compact_str/rkyv"] utoipa = ["dep:utoipa"] [dependencies] -axum = { workspace = true, optional = true } -bytes = { workspace = true } +axum = { workspace = true, optional = true } blake3 = { workspace = true } -clap = { workspace = true, optional = true } +bytes = { workspace = true } +clap = { workspace = true, optional = true, features = ["derive"] } compact_str = { workspace = true, features = ["serde"] } -const_format = { workspace = true } enum-ordinalize = { workspace = true } enumset = { workspace = true } -futures = { workspace = true } http = { workspace = true } rkyv = { workspace = true, optional = true } -serde = { workspace = true } -strum = { workspace = true } +serde = { workspace = true, features = ["derive"] } +strum = { workspace = true, features = ["derive"] } thiserror = { workspace = true } time = { workspace = true } -utoipa = { workspace = true, optional = true } +utoipa = { workspace = true, optional = true, features = ["time"] } [dev-dependencies] proptest = { workspace = true } diff --git a/lite/Cargo.toml b/lite/Cargo.toml index f661bdb9..c0c53411 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -13,11 +13,10 @@ categories = ["database-implementations"] async-stream = { workspace = true } async-trait = { workspace = true } axum = { workspace = true, features = ["macros"] } -axum-extra = { workspace = true } axum-server = { workspace = true, features = ["tls-rustls"] } blake3 = { workspace = true } bytes = { workspace = true } -clap = { workspace = true } +clap = { workspace = true, features = ["derive"] } compact_str = { workspace = true } dashmap = { workspace = true } enum-ordinalize = { workspace = true } @@ -31,20 +30,20 @@ rand = { workspace = true } rcgen = { workspace = true } s2-api = { version = "0.1.0", path = "../api", features = ["axum"] } s2-common = { version = "0.1.0", path = "../common", features = ["clap"] } -serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } slatedb = { workspace = true } thiserror = { workspace = true } -time = { workspace = true } +time = { workspace = true, features = ["serde", "formatting", "parsing"] } tokio = { workspace = true, features = ["sync"] } tokio-util = { workspace = true } -tower-http = { workspace = true } +tower-http = { workspace = true, features = ["trace"] } tracing = { workspace = true } -tracing-subscriber = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } [dev-dependencies] proptest = { workspace = true } -uuid = { workspace = true } +uuid = { workspace = true, features = ["v4"] } [profile.dev] panic = "abort" From b4cb18c231664cc04b3f31eaa15ea51b32e43862 Mon Sep 17 00:00:00 2001 From: shikhar Date: Fri, 9 Jan 2026 11:58:05 -0500 Subject: [PATCH 036/700] release: 0.1.1 --- Cargo.lock | 6 +++--- api/Cargo.toml | 4 ++-- common/Cargo.toml | 2 +- lite/Cargo.toml | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8cc73fb..ef2cba55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2917,7 +2917,7 @@ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "s2-api" -version = "0.1.0" +version = "0.1.1" dependencies = [ "axum", "base64ct", @@ -2946,7 +2946,7 @@ dependencies = [ [[package]] name = "s2-common" -version = "0.1.0" +version = "0.1.1" dependencies = [ "axum", "blake3", @@ -2969,7 +2969,7 @@ dependencies = [ [[package]] name = "s2-lite" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-stream", "async-trait", diff --git a/api/Cargo.toml b/api/Cargo.toml index e9aa9676..da01c4ac 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -3,7 +3,7 @@ name = "s2-api" description = "API types for S2, the durable streams API" edition.workspace = true license.workspace = true -version = "0.1.0" +version = "0.1.1" repository.workspace = true homepage.workspace = true keywords = ["s2", "durable", "streams", "log", "api"] @@ -26,7 +26,7 @@ http = { workspace = true } itertools = { workspace = true } mime = { workspace = true } prost = { workspace = true } -s2-common = { version = "0.1.0", path = "../common" } +s2-common = { version = "0.1.1", path = "../common" } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum = { workspace = true, features = ["derive"] } diff --git a/common/Cargo.toml b/common/Cargo.toml index 00aed639..cbf2399e 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -3,7 +3,7 @@ name = "s2-common" description = "Common stuff for client and servers for S2, the durable streams API" edition.workspace = true license.workspace = true -version = "0.1.0" +version = "0.1.1" repository.workspace = true homepage.workspace = true keywords = ["s2", "durable", "streams", "log", "api"] diff --git a/lite/Cargo.toml b/lite/Cargo.toml index c0c53411..7d8c261d 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -3,7 +3,7 @@ name = "s2-lite" description = "Lightweight server implementation of S2, the durable streams API, backed by object storage" edition.workspace = true license.workspace = true -version = "0.1.0" +version = "0.1.1" repository.workspace = true homepage.workspace = true keywords = ["s2", "durable", "streams", "object", "storage"] @@ -28,8 +28,8 @@ parking_lot = { workspace = true, features = ["arc_lock"] } prost = { workspace = true } rand = { workspace = true } rcgen = { workspace = true } -s2-api = { version = "0.1.0", path = "../api", features = ["axum"] } -s2-common = { version = "0.1.0", path = "../common", features = ["clap"] } +s2-api = { version = "0.1.1", path = "../api", features = ["axum"] } +s2-common = { version = "0.1.1", path = "../common", features = ["clap"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } slatedb = { workspace = true } From 09fb511d94f6f29906be5c06200bf2e61f84ac20 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Fri, 9 Jan 2026 13:37:13 -0500 Subject: [PATCH 037/700] Remap `hot_server` error code to http 502 (#34) --- api/src/v1/error.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/src/v1/error.rs b/api/src/v1/error.rs index 77e1f7ce..8b74e28d 100644 --- a/api/src/v1/error.rs +++ b/api/src/v1/error.rs @@ -62,7 +62,8 @@ impl ErrorCode { Self::ClientHangup => http::StatusCode::from_u16(499).expect("valid status code"), Self::PermissionDenied | Self::QuotaExhausted => http::StatusCode::FORBIDDEN, Self::RateLimited => http::StatusCode::TOO_MANY_REQUESTS, - Self::HotServer | Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE, + Self::HotServer => http::StatusCode::BAD_GATEWAY, + Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE, Self::Other | Self::Storage => http::StatusCode::INTERNAL_SERVER_ERROR, Self::Timeout => http::StatusCode::REQUEST_TIMEOUT, } From 374d5e8ef5edf90d3ad509bf021e2fd570801456 Mon Sep 17 00:00:00 2001 From: shikhar Date: Fri, 9 Jan 2026 13:38:52 -0500 Subject: [PATCH 038/700] release: 0.1.2 --- Cargo.lock | 6 +++--- api/Cargo.toml | 4 ++-- common/Cargo.toml | 2 +- lite/Cargo.toml | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef2cba55..fe971b84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2917,7 +2917,7 @@ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "s2-api" -version = "0.1.1" +version = "0.1.2" dependencies = [ "axum", "base64ct", @@ -2946,7 +2946,7 @@ dependencies = [ [[package]] name = "s2-common" -version = "0.1.1" +version = "0.1.2" dependencies = [ "axum", "blake3", @@ -2969,7 +2969,7 @@ dependencies = [ [[package]] name = "s2-lite" -version = "0.1.1" +version = "0.1.2" dependencies = [ "async-stream", "async-trait", diff --git a/api/Cargo.toml b/api/Cargo.toml index da01c4ac..bca4ac7a 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -3,7 +3,7 @@ name = "s2-api" description = "API types for S2, the durable streams API" edition.workspace = true license.workspace = true -version = "0.1.1" +version = "0.1.2" repository.workspace = true homepage.workspace = true keywords = ["s2", "durable", "streams", "log", "api"] @@ -26,7 +26,7 @@ http = { workspace = true } itertools = { workspace = true } mime = { workspace = true } prost = { workspace = true } -s2-common = { version = "0.1.1", path = "../common" } +s2-common = { version = "0.1.2", path = "../common" } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum = { workspace = true, features = ["derive"] } diff --git a/common/Cargo.toml b/common/Cargo.toml index cbf2399e..ddc616d4 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -3,7 +3,7 @@ name = "s2-common" description = "Common stuff for client and servers for S2, the durable streams API" edition.workspace = true license.workspace = true -version = "0.1.1" +version = "0.1.2" repository.workspace = true homepage.workspace = true keywords = ["s2", "durable", "streams", "log", "api"] diff --git a/lite/Cargo.toml b/lite/Cargo.toml index 7d8c261d..897042a0 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -3,7 +3,7 @@ name = "s2-lite" description = "Lightweight server implementation of S2, the durable streams API, backed by object storage" edition.workspace = true license.workspace = true -version = "0.1.1" +version = "0.1.2" repository.workspace = true homepage.workspace = true keywords = ["s2", "durable", "streams", "object", "storage"] @@ -28,8 +28,8 @@ parking_lot = { workspace = true, features = ["arc_lock"] } prost = { workspace = true } rand = { workspace = true } rcgen = { workspace = true } -s2-api = { version = "0.1.1", path = "../api", features = ["axum"] } -s2-common = { version = "0.1.1", path = "../common", features = ["clap"] } +s2-api = { version = "0.1.2", path = "../api", features = ["axum"] } +s2-common = { version = "0.1.2", path = "../common", features = ["clap"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } slatedb = { workspace = true } From a7167a4140bc5c97dc5ef407aa907a290c0dbb2d Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Fri, 9 Jan 2026 16:19:40 -0500 Subject: [PATCH 039/700] chore: rename `GenericError` to `StandardError` (#35) --- api/src/v1/error.rs | 6 +- lite/src/handlers/v1/error.rs | 134 ++++++++++++++++++---------------- 2 files changed, 74 insertions(+), 66 deletions(-) diff --git a/api/src/v1/error.rs b/api/src/v1/error.rs index 8b74e28d..2adc9874 100644 --- a/api/src/v1/error.rs +++ b/api/src/v1/error.rs @@ -78,7 +78,7 @@ pub struct ErrorInfo { } #[derive(Debug, Clone)] -pub struct GenericError { +pub struct StandardError { pub status: http::StatusCode, pub info: ErrorInfo, } @@ -87,7 +87,7 @@ pub struct GenericError { pub enum ErrorResponse { AppendConditionFailed(super::stream::AppendConditionFailed), Unwritten(super::stream::TailResponse), - Generic(GenericError), + Standard(StandardError), } impl ErrorResponse { @@ -101,7 +101,7 @@ impl ErrorResponse { http::StatusCode::RANGE_NOT_SATISFIABLE, serde_json::to_string(&payload), ), - ErrorResponse::Generic(err) => (err.status, serde_json::to_string(&err.info)), + ErrorResponse::Standard(err) => (err.status, serde_json::to_string(&err.info)), }; (status, res.expect("basic json ser")) } diff --git a/lite/src/handlers/v1/error.rs b/lite/src/handlers/v1/error.rs index c812cadb..2a2b749a 100644 --- a/lite/src/handlers/v1/error.rs +++ b/lite/src/handlers/v1/error.rs @@ -6,7 +6,7 @@ use s2_api::{ data::extract::ProtoRejection, v1::{ self as v1t, - error::{ErrorCode, ErrorInfo, ErrorResponse, GenericError}, + error::{ErrorCode, ErrorInfo, ErrorResponse, StandardError}, stream::{AppendInputStreamError, extract::AppendRequestRejection, s2s}, }, }; @@ -76,136 +76,144 @@ impl From for ServiceError { impl ServiceError { pub fn to_response(&self) -> ErrorResponse { match self { - ServiceError::HeaderRejection(e) => generic(ErrorCode::BadHeader, e.to_string()), - ServiceError::PathRejection(e) => generic(ErrorCode::BadPath, e.body_text()), - ServiceError::QueryRejection(e) => generic(ErrorCode::BadQuery, e.body_text()), - ServiceError::JsonRejection(e) => generic(ErrorCode::BadJson, e.body_text()), - ServiceError::ProtoRejection(e) => generic(ErrorCode::BadProto, e.to_string()), + ServiceError::HeaderRejection(e) => standard(ErrorCode::BadHeader, e.to_string()), + ServiceError::PathRejection(e) => standard(ErrorCode::BadPath, e.body_text()), + ServiceError::QueryRejection(e) => standard(ErrorCode::BadQuery, e.body_text()), + ServiceError::JsonRejection(e) => standard(ErrorCode::BadJson, e.body_text()), + ServiceError::ProtoRejection(e) => standard(ErrorCode::BadProto, e.to_string()), ServiceError::AppendInputStream(e) => match e { AppendInputStreamError::FrameDecode(e) => { - generic(ErrorCode::BadFrame, e.to_string()) + standard(ErrorCode::BadFrame, e.to_string()) + } + AppendInputStreamError::Validation(e) => { + standard(ErrorCode::Invalid, e.to_string()) } - AppendInputStreamError::Validation(e) => generic(ErrorCode::Invalid, e.to_string()), }, - ServiceError::Validation(e) => generic(ErrorCode::Invalid, e.to_string()), + ServiceError::Validation(e) => standard(ErrorCode::Invalid, e.to_string()), ServiceError::ListBasins(e) => match e { - ListBasinsError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + ListBasinsError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), }, ServiceError::CreateBasin(e) => match e { - CreateBasinError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + CreateBasinError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), CreateBasinError::BasinAlreadyExists(e) => { - generic(ErrorCode::ResourceAlreadyExists, e.to_string()) + standard(ErrorCode::ResourceAlreadyExists, e.to_string()) } CreateBasinError::BasinDeletionPending(e) => { - generic(ErrorCode::BasinDeletionPending, e.to_string()) + standard(ErrorCode::BasinDeletionPending, e.to_string()) } }, ServiceError::GetBasinConfig(e) => match e { - GetBasinConfigError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + GetBasinConfigError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), GetBasinConfigError::BasinNotFound(e) => { - generic(ErrorCode::BasinNotFound, e.to_string()) + standard(ErrorCode::BasinNotFound, e.to_string()) } }, ServiceError::DeleteBasin(e) => match e { - DeleteBasinError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + DeleteBasinError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), DeleteBasinError::BasinNotFound(e) => { - generic(ErrorCode::BasinNotFound, e.to_string()) + standard(ErrorCode::BasinNotFound, e.to_string()) } }, ServiceError::ReconfigureBasin(e) => match e { - ReconfigureBasinError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + ReconfigureBasinError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), ReconfigureBasinError::TransactionConflict(e) => { - generic(ErrorCode::TransactionConflict, e.to_string()) + standard(ErrorCode::TransactionConflict, e.to_string()) } ReconfigureBasinError::BasinNotFound(e) => { - generic(ErrorCode::BasinNotFound, e.to_string()) + standard(ErrorCode::BasinNotFound, e.to_string()) } ReconfigureBasinError::BasinDeletionPending(e) => { - generic(ErrorCode::BasinDeletionPending, e.to_string()) + standard(ErrorCode::BasinDeletionPending, e.to_string()) } }, ServiceError::ListStreams(e) => match e { - ListStreamsError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + ListStreamsError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), }, ServiceError::CreateStream(e) => match e { - CreateStreamError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + CreateStreamError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), CreateStreamError::TransactionConflict(e) => { - generic(ErrorCode::TransactionConflict, e.to_string()) + standard(ErrorCode::TransactionConflict, e.to_string()) + } + CreateStreamError::Unavailable(e) => { + standard(ErrorCode::Unavailable, e.to_string()) } - CreateStreamError::Unavailable(e) => generic(ErrorCode::Unavailable, e.to_string()), CreateStreamError::BasinNotFound(e) => { - generic(ErrorCode::BasinNotFound, e.to_string()) + standard(ErrorCode::BasinNotFound, e.to_string()) } CreateStreamError::BasinDeletionPending(e) => { - generic(ErrorCode::BasinDeletionPending, e.to_string()) + standard(ErrorCode::BasinDeletionPending, e.to_string()) } CreateStreamError::StreamAlreadyExists(e) => { - generic(ErrorCode::ResourceAlreadyExists, e.to_string()) + standard(ErrorCode::ResourceAlreadyExists, e.to_string()) } CreateStreamError::StreamDeletionPending(e) => { - generic(ErrorCode::StreamDeletionPending, e.to_string()) + standard(ErrorCode::StreamDeletionPending, e.to_string()) } }, ServiceError::GetStreamConfig(e) => match e { - GetStreamConfigError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + GetStreamConfigError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), GetStreamConfigError::StreamNotFound(e) => { - generic(ErrorCode::StreamNotFound, e.to_string()) + standard(ErrorCode::StreamNotFound, e.to_string()) } }, ServiceError::DeleteStream(e) => match e { - DeleteStreamError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), - DeleteStreamError::Unavailable(e) => generic(ErrorCode::Unavailable, e.to_string()), + DeleteStreamError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + DeleteStreamError::Unavailable(e) => { + standard(ErrorCode::Unavailable, e.to_string()) + } DeleteStreamError::StreamNotFound(e) => { - generic(ErrorCode::StreamNotFound, e.to_string()) + standard(ErrorCode::StreamNotFound, e.to_string()) } }, ServiceError::ReconfigureStream(e) => match e { - ReconfigureStreamError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + ReconfigureStreamError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), ReconfigureStreamError::TransactionConflict(e) => { - generic(ErrorCode::TransactionConflict, e.to_string()) + standard(ErrorCode::TransactionConflict, e.to_string()) } ReconfigureStreamError::Unavailable(e) => { - generic(ErrorCode::Unavailable, e.to_string()) + standard(ErrorCode::Unavailable, e.to_string()) } ReconfigureStreamError::StreamNotFound(e) => { - generic(ErrorCode::StreamNotFound, e.to_string()) + standard(ErrorCode::StreamNotFound, e.to_string()) } ReconfigureStreamError::StreamDeletionPending(e) => { - generic(ErrorCode::StreamDeletionPending, e.to_string()) + standard(ErrorCode::StreamDeletionPending, e.to_string()) } }, ServiceError::CheckTail(e) => match e { - CheckTailError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + CheckTailError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), CheckTailError::TransactionConflict(e) => { - generic(ErrorCode::TransactionConflict, e.to_string()) + standard(ErrorCode::TransactionConflict, e.to_string()) } - CheckTailError::Unavailable(e) => generic(ErrorCode::Unavailable, e.to_string()), + CheckTailError::Unavailable(e) => standard(ErrorCode::Unavailable, e.to_string()), CheckTailError::BasinNotFound(e) => { - generic(ErrorCode::BasinNotFound, e.to_string()) + standard(ErrorCode::BasinNotFound, e.to_string()) } CheckTailError::StreamNotFound(e) => { - generic(ErrorCode::StreamNotFound, e.to_string()) + standard(ErrorCode::StreamNotFound, e.to_string()) } CheckTailError::BasinDeletionPending(e) => { - generic(ErrorCode::BasinDeletionPending, e.to_string()) + standard(ErrorCode::BasinDeletionPending, e.to_string()) } CheckTailError::StreamDeletionPending(e) => { - generic(ErrorCode::StreamDeletionPending, e.to_string()) + standard(ErrorCode::StreamDeletionPending, e.to_string()) } }, ServiceError::Append(e) => match e { - AppendError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + AppendError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), AppendError::TransactionConflict(e) => { - generic(ErrorCode::TransactionConflict, e.to_string()) + standard(ErrorCode::TransactionConflict, e.to_string()) + } + AppendError::Unavailable(e) => standard(ErrorCode::Unavailable, e.to_string()), + AppendError::BasinNotFound(e) => standard(ErrorCode::BasinNotFound, e.to_string()), + AppendError::StreamNotFound(e) => { + standard(ErrorCode::StreamNotFound, e.to_string()) } - AppendError::Unavailable(e) => generic(ErrorCode::Unavailable, e.to_string()), - AppendError::BasinNotFound(e) => generic(ErrorCode::BasinNotFound, e.to_string()), - AppendError::StreamNotFound(e) => generic(ErrorCode::StreamNotFound, e.to_string()), AppendError::BasinDeletionPending(e) => { - generic(ErrorCode::BasinDeletionPending, e.to_string()) + standard(ErrorCode::BasinDeletionPending, e.to_string()) } AppendError::StreamDeletionPending(e) => { - generic(ErrorCode::StreamDeletionPending, e.to_string()) + standard(ErrorCode::StreamDeletionPending, e.to_string()) } AppendError::ConditionFailed(e) => ErrorResponse::AppendConditionFailed(match e { AppendConditionFailedError::FencingTokenMismatch { actual, .. } => { @@ -215,21 +223,21 @@ impl ServiceError { assigned_seq_num, .. } => v1t::stream::AppendConditionFailed::SeqNumMismatch(*assigned_seq_num), }), - AppendError::TimestampMissing(e) => generic(ErrorCode::Invalid, e.to_string()), + AppendError::TimestampMissing(e) => standard(ErrorCode::Invalid, e.to_string()), }, ServiceError::Read(e) => match e { - ReadError::Storage(e) => generic(ErrorCode::Storage, e.to_string()), + ReadError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), ReadError::TransactionConflict(e) => { - generic(ErrorCode::TransactionConflict, e.to_string()) + standard(ErrorCode::TransactionConflict, e.to_string()) } - ReadError::Unavailable(e) => generic(ErrorCode::Unavailable, e.to_string()), - ReadError::BasinNotFound(e) => generic(ErrorCode::BasinNotFound, e.to_string()), - ReadError::StreamNotFound(e) => generic(ErrorCode::StreamNotFound, e.to_string()), + ReadError::Unavailable(e) => standard(ErrorCode::Unavailable, e.to_string()), + ReadError::BasinNotFound(e) => standard(ErrorCode::BasinNotFound, e.to_string()), + ReadError::StreamNotFound(e) => standard(ErrorCode::StreamNotFound, e.to_string()), ReadError::BasinDeletionPending(e) => { - generic(ErrorCode::BasinDeletionPending, e.to_string()) + standard(ErrorCode::BasinDeletionPending, e.to_string()) } ReadError::StreamDeletionPending(e) => { - generic(ErrorCode::StreamDeletionPending, e.to_string()) + standard(ErrorCode::StreamDeletionPending, e.to_string()) } ReadError::Unwritten(tail) => ErrorResponse::Unwritten(v1t::stream::TailResponse { tail: tail.0.into(), @@ -255,8 +263,8 @@ impl From for s2s::TerminalMessage { } } -fn generic(code: ErrorCode, message: impl Into) -> ErrorResponse { - ErrorResponse::Generic(GenericError { +fn standard(code: ErrorCode, message: impl Into) -> ErrorResponse { + ErrorResponse::Standard(StandardError { status: code.status(), info: ErrorInfo { code: code.into(), From b39e72122dc1c742a1e39efd5e00fb77c63fbf67 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Fri, 9 Jan 2026 23:11:57 -0500 Subject: [PATCH 040/700] centralize package versions in workspace (#37) --- Cargo.toml | 2 ++ api/Cargo.toml | 2 +- lite/Cargo.toml | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 66cb4f2f..26f1e2d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,8 @@ rand = "0.9" rcgen = "0.14" rkyv = "0.8" rstest = "0.26" +s2-api = { version = "0.1.2", path = "api" } +s2-common = { version = "0.1.2", path = "common" } serde = "1.0" serde_json = "1.0" slatedb = "0.10" diff --git a/api/Cargo.toml b/api/Cargo.toml index bca4ac7a..e383c532 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -26,7 +26,7 @@ http = { workspace = true } itertools = { workspace = true } mime = { workspace = true } prost = { workspace = true } -s2-common = { version = "0.1.2", path = "../common" } +s2-common = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum = { workspace = true, features = ["derive"] } diff --git a/lite/Cargo.toml b/lite/Cargo.toml index 897042a0..f7cfd15e 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -28,8 +28,8 @@ parking_lot = { workspace = true, features = ["arc_lock"] } prost = { workspace = true } rand = { workspace = true } rcgen = { workspace = true } -s2-api = { version = "0.1.2", path = "../api", features = ["axum"] } -s2-common = { version = "0.1.2", path = "../common", features = ["clap"] } +s2-api = { workspace = true, features = ["axum"] } +s2-common = { workspace = true, features = ["clap"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } slatedb = { workspace = true } From 55906c4cd2ea6b727c04186d2eb3c662bd3f0725 Mon Sep 17 00:00:00 2001 From: quettabit <27509167+quettabit@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:27:45 -0700 Subject: [PATCH 041/700] chore: add `AccessTokenId` -> `AccessTokenIdStartAfter` conversion (#39) --- common/src/types/access.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/common/src/types/access.rs b/common/src/types/access.rs index 701fc427..a28f0afc 100644 --- a/common/src/types/access.rs +++ b/common/src/types/access.rs @@ -147,6 +147,12 @@ impl Default for AccessTokenIdStartAfter { } } +impl From for AccessTokenIdStartAfter { + fn from(value: AccessTokenId) -> Self { + Self(value.0, PhantomData) + } +} + #[derive(Debug, Hash, EnumSetType, strum::EnumCount)] pub enum Operation { ListBasins = 1, From 631969f1aa1ab024c818f05bb86fdb55569147aa Mon Sep 17 00:00:00 2001 From: Cristian C Date: Sat, 10 Jan 2026 14:44:44 +0200 Subject: [PATCH 042/700] chore: add workflow to auto-update protos submodule (#36) --- .github/workflows/update_protos.yaml | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/update_protos.yaml diff --git a/.github/workflows/update_protos.yaml b/.github/workflows/update_protos.yaml new file mode 100644 index 00000000..0c4538cf --- /dev/null +++ b/.github/workflows/update_protos.yaml @@ -0,0 +1,33 @@ +name: Update protos submodule + +permissions: + contents: write + pull-requests: write + +on: + repository_dispatch: + types: [s2-proto-update] + +jobs: + update-submodule: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Update Submodules + id: submodules + uses: sgoudham/update-git-submodules@v2.1.1 + with: + submodules: protos + + - name: Create Pull Request + if: ${{ steps.submodules.outputs['protos--updated'] }} + uses: peter-evans/create-pull-request@v7 + with: + committer: s2-helper[bot] <194906454+s2-helper[bot]@users.noreply.github.com> + author: s2-helper[bot] <194906454+s2-helper[bot]@users.noreply.github.com> + title: "chore: update s2-protos submodule" + branch: "protos/update-${{ steps.submodules.outputs['protos--latestShortCommitSha'] }}" + body: ${{ steps.submodules.outputs.prBody }} From 23ff081f30711266ff09b184817c81bd22c585e0 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Sat, 10 Jan 2026 08:58:56 -0500 Subject: [PATCH 043/700] `Dockerfile` for `s2-lite` (#38) Co-authored-by: cc Co-authored-by: Claude Opus 4.5 --- lite/Dockerfile | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 lite/Dockerfile diff --git a/lite/Dockerfile b/lite/Dockerfile new file mode 100644 index 00000000..81382a34 --- /dev/null +++ b/lite/Dockerfile @@ -0,0 +1,34 @@ +# Build stage +FROM rust:latest AS builder + +WORKDIR /build + +# Use Docker BuildKit cache mounts for faster builds +RUN --mount=type=bind,source=api,target=/build/api \ + --mount=type=bind,source=common,target=/build/common \ + --mount=type=bind,source=lite,target=/build/lite \ + --mount=type=bind,source=Cargo.toml,target=/build/Cargo.toml \ + --mount=type=bind,source=Cargo.lock,target=/build/Cargo.lock \ + --mount=type=cache,id=s2-rust,sharing=locked,target=/build/target \ + --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ + --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ + cargo build --locked --release --package s2-lite --bin server + +# Copy the binary from the cache volume +RUN --mount=type=cache,id=s2-rust,sharing=locked,target=/cache \ + mkdir -p /build/target/release/ && \ + cp /cache/release/server /build/target/release/server + +# Runtime stage +FROM ubuntu:latest + +RUN apt-get update && \ + apt-get install -y ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy the binary from builder stage +COPY --from=builder /build/target/release/server /app/s2-lite + +CMD ["./s2-lite"] From 07ba62e4ec7024a1e7e2ee6e63915c1c4d780c90 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Sat, 10 Jan 2026 22:03:38 -0500 Subject: [PATCH 044/700] revise `timeout` error codes (#41) 408 vs 504 --- api/src/v1/error.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/api/src/v1/error.rs b/api/src/v1/error.rs index 2adc9874..a7179762 100644 --- a/api/src/v1/error.rs +++ b/api/src/v1/error.rs @@ -33,13 +33,14 @@ pub enum ErrorCode { PermissionDenied, QuotaExhausted, RateLimited, + RequestTimeout, ResourceAlreadyExists, Storage, StreamDeletionPending, StreamNotFound, - Timeout, TransactionConflict, Unavailable, + UpstreamTimeout, } impl ErrorCode { @@ -51,21 +52,22 @@ impl ErrorCode { | Self::BadPath | Self::BadProto | Self::BadQuery => http::StatusCode::BAD_REQUEST, - Self::Invalid => http::StatusCode::UNPROCESSABLE_ENTITY, + Self::PermissionDenied | Self::QuotaExhausted => http::StatusCode::FORBIDDEN, Self::AccessTokenNotFound | Self::BasinNotFound | Self::StreamNotFound => { http::StatusCode::NOT_FOUND } + Self::RequestTimeout => http::StatusCode::REQUEST_TIMEOUT, Self::BasinDeletionPending | Self::ResourceAlreadyExists | Self::StreamDeletionPending | Self::TransactionConflict => http::StatusCode::CONFLICT, - Self::ClientHangup => http::StatusCode::from_u16(499).expect("valid status code"), - Self::PermissionDenied | Self::QuotaExhausted => http::StatusCode::FORBIDDEN, + Self::Invalid => http::StatusCode::UNPROCESSABLE_ENTITY, Self::RateLimited => http::StatusCode::TOO_MANY_REQUESTS, + Self::ClientHangup => http::StatusCode::from_u16(499).expect("valid status code"), + Self::Other | Self::Storage => http::StatusCode::INTERNAL_SERVER_ERROR, Self::HotServer => http::StatusCode::BAD_GATEWAY, Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE, - Self::Other | Self::Storage => http::StatusCode::INTERNAL_SERVER_ERROR, - Self::Timeout => http::StatusCode::REQUEST_TIMEOUT, + Self::UpstreamTimeout => http::StatusCode::GATEWAY_TIMEOUT, } } } From 25d3400aa84a8be5313b2f4da0530bbcc138aa1a Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Sat, 10 Jan 2026 22:07:23 -0500 Subject: [PATCH 045/700] lite: lower default flush interval from 100ms to 40ms (#42) --- lite/src/bin/server.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lite/src/bin/server.rs b/lite/src/bin/server.rs index 96672a6d..8dc14298 100644 --- a/lite/src/bin/server.rs +++ b/lite/src/bin/server.rs @@ -89,7 +89,14 @@ async fn main() -> eyre::Result<()> { } }; - let db_settings = slatedb::Settings::from_env("SL8_")?; + let db_settings = slatedb::Settings::from_env_with_default( + "SL8_", + slatedb::Settings { + flush_interval: Some(Duration::from_millis(40)), + ..Default::default() + }, + )?; + let db = slatedb::Db::builder(args.path, object_store) .with_settings(db_settings) .build() From 14992793988788c2e1091a46579c8ba23086affb Mon Sep 17 00:00:00 2001 From: shikhar Date: Sat, 10 Jan 2026 22:11:20 -0500 Subject: [PATCH 046/700] release: 0.2.0 --- Cargo.lock | 6 +++--- Cargo.toml | 4 ++-- api/Cargo.toml | 2 +- common/Cargo.toml | 2 +- lite/Cargo.toml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe971b84..210d2eb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2917,7 +2917,7 @@ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "s2-api" -version = "0.1.2" +version = "0.2.0" dependencies = [ "axum", "base64ct", @@ -2946,7 +2946,7 @@ dependencies = [ [[package]] name = "s2-common" -version = "0.1.2" +version = "0.2.0" dependencies = [ "axum", "blake3", @@ -2969,7 +2969,7 @@ dependencies = [ [[package]] name = "s2-lite" -version = "0.1.2" +version = "0.2.0" dependencies = [ "async-stream", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 26f1e2d2..446ec3cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,8 +35,8 @@ rand = "0.9" rcgen = "0.14" rkyv = "0.8" rstest = "0.26" -s2-api = { version = "0.1.2", path = "api" } -s2-common = { version = "0.1.2", path = "common" } +s2-api = { version = "0.2.0", path = "api" } +s2-common = { version = "0.2.0", path = "common" } serde = "1.0" serde_json = "1.0" slatedb = "0.10" diff --git a/api/Cargo.toml b/api/Cargo.toml index e383c532..0a78deb2 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -3,7 +3,7 @@ name = "s2-api" description = "API types for S2, the durable streams API" edition.workspace = true license.workspace = true -version = "0.1.2" +version = "0.2.0" repository.workspace = true homepage.workspace = true keywords = ["s2", "durable", "streams", "log", "api"] diff --git a/common/Cargo.toml b/common/Cargo.toml index ddc616d4..332643c4 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -3,7 +3,7 @@ name = "s2-common" description = "Common stuff for client and servers for S2, the durable streams API" edition.workspace = true license.workspace = true -version = "0.1.2" +version = "0.2.0" repository.workspace = true homepage.workspace = true keywords = ["s2", "durable", "streams", "log", "api"] diff --git a/lite/Cargo.toml b/lite/Cargo.toml index f7cfd15e..86d293e8 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -3,7 +3,7 @@ name = "s2-lite" description = "Lightweight server implementation of S2, the durable streams API, backed by object storage" edition.workspace = true license.workspace = true -version = "0.1.2" +version = "0.2.0" repository.workspace = true homepage.workspace = true keywords = ["s2", "durable", "streams", "object", "storage"] From 9f74461489ea51e1569303a71ed2f7c5d02bc5c6 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Sun, 11 Jan 2026 08:26:49 -0500 Subject: [PATCH 047/700] lite: support aws credentials (#43) get `AWS_PROFILE` working --- Cargo.lock | 473 +++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 2 + lite/Cargo.toml | 2 + lite/src/bin/server.rs | 54 ++++- 4 files changed, 499 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 210d2eb7..cca061c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -251,6 +251,48 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-config" +version = "1.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96571e6996817bf3d58f6b569e4b9fd2e9d2fcf9f7424eed07b2ce9bb87535e5" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "ring", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.15.2" @@ -273,6 +315,290 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-runtime" +version = "1.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81b5b2898f6798ad58f484856768bca817e3cd9de0974c24ae0f1113fe88f1b" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.91.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee6402a36f27b52fe67661c6732d684b2635152b676aa2babbfb5204f99115d" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.93.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45a7f750bbd170ee3677671ad782d90b894548f4e4ae168302c57ec9de5cb3e" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.95.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55542378e419558e6b1f398ca70adb0b2088077e79ad9f14eb09441f2f7b2164" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e523e1c4e8e7e8ff219d732988e22bfeae8a1cafdbe6d9eca1546fa080be7c" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ee19095c7c4dda59f1697d028ce704c24b2d33c6718790c7f1d5a3015b4107c" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.62.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e62db736db19c488966c8d787f52e6270be565727236fd5579eaa301e7bc4a" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.4.0", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.61.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f616c3f2260612fe44cede278bafa18e73e6479c4e393e2c4518cf2a9a228a" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5d689cf437eae90460e944a58b5668530d433b4ff85789e69d2f2a556e057d" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a392db6c583ea4a912538afb86b7be7c5d8887d91604f50eb55c262ee1b4a5f5" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab0d43d899f9e508300e587bf582ba54c27a452dd0a9ea294690669138ae14a2" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "905cb13a9895626d49cf2ced759b062d913834c7482c38e49557eac4e6193f01" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.8" @@ -284,8 +610,8 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-util", @@ -315,8 +641,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -347,8 +673,8 @@ dependencies = [ "bytes", "either", "fs-err", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "hyper", "hyper-util", "pin-project-lite", @@ -376,6 +702,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.2" @@ -479,6 +815,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "castaway" version = "0.2.4" @@ -819,6 +1165,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1348,7 +1695,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -1400,6 +1747,32 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -1410,6 +1783,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1417,7 +1801,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1428,8 +1812,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1462,8 +1846,8 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1480,7 +1864,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "rustls", @@ -1502,8 +1886,8 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "hyper", "ipnet", "libc", @@ -2064,7 +2448,7 @@ dependencies = [ "chrono", "form_urlencoded", "futures", - "http", + "http 1.4.0", "http-body-util", "humantime", "hyper", @@ -2148,6 +2532,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "panic-message" version = "0.3.0" @@ -2659,6 +3049,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + [[package]] name = "regex-syntax" version = "0.8.8" @@ -2691,8 +3087,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -2926,7 +3322,7 @@ dependencies = [ "enum-ordinalize", "flate2", "futures", - "http", + "http 1.4.0", "itertools", "mime", "proptest", @@ -2955,7 +3351,7 @@ dependencies = [ "compact_str", "enum-ordinalize", "enumset", - "http", + "http 1.4.0", "proptest", "rkyv", "rstest", @@ -2973,6 +3369,8 @@ version = "0.2.0" dependencies = [ "async-stream", "async-trait", + "aws-config", + "aws-credential-types", "axum", "axum-server", "blake3", @@ -2983,7 +3381,7 @@ dependencies = [ "enum-ordinalize", "eyre", "futures", - "http", + "http 1.4.0", "itertools", "parking_lot", "proptest", @@ -3155,6 +3553,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3668,8 +4077,8 @@ dependencies = [ "bitflags", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -3829,6 +4238,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3888,6 +4303,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -4414,6 +4835,12 @@ dependencies = [ "time", ] +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 446ec3cf..3de34182 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ repository = "https://github.com/s2-streamstore/s2" homepage = "https://s2.dev" [workspace.dependencies] +aws-config = "1" +aws-credential-types = "1" async-stream = "0.3" async-trait = "0.1" axum = "0.8" diff --git a/lite/Cargo.toml b/lite/Cargo.toml index 86d293e8..6ffe5584 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -12,6 +12,8 @@ categories = ["database-implementations"] [dependencies] async-stream = { workspace = true } async-trait = { workspace = true } +aws-config = { workspace = true } +aws-credential-types = { workspace = true } axum = { workspace = true, features = ["macros"] } axum-server = { workspace = true, features = ["tls-rustls"] } blake3 = { workspace = true } diff --git a/lite/src/bin/server.rs b/lite/src/bin/server.rs index 8dc14298..abdc6fc4 100644 --- a/lite/src/bin/server.rs +++ b/lite/src/bin/server.rs @@ -1,9 +1,10 @@ use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; +use aws_credential_types::provider::{ProvideCredentials, SharedCredentialsProvider}; use axum_server::tls_rustls::RustlsConfig; use clap::Parser as _; use s2_lite::{backend::Backend, handlers}; -use slatedb::object_store; +use slatedb::object_store::{self, CredentialProvider, aws::AwsCredential}; use tower_http::trace::{DefaultMakeSpan, DefaultOnRequest, DefaultOnResponse, TraceLayer}; use tracing::info; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -71,16 +72,23 @@ async fn main() -> eyre::Result<()> { format!("0.0.0.0:{port}") }; - let server_handle = axum_server::Handle::new(); - - tokio::spawn(shutdown_signal(server_handle.clone())); - let object_store: Arc = match args.bucket { Some(bucket) if !bucket.is_empty() => { info!(bucket, "using s3 object store"); - let store = object_store::aws::AmazonS3Builder::from_env() - .with_bucket_name(bucket) - .build()?; + let aws_config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + let mut builder = + object_store::aws::AmazonS3Builder::from_env().with_bucket_name(&bucket); + if let Some(region) = aws_config.region() { + info!(region = region.as_ref()); + builder = builder.with_region(region.to_string()); + } + if let Some(credentials_provider) = aws_config.credentials_provider() { + info!("using aws-config credentials provider"); + builder = builder.with_credentials(Arc::new(S3CredentialProvider { + credentials: credentials_provider.clone(), + })); + } + let store = builder.build()?; Arc::new(store) } _ => { @@ -97,6 +105,8 @@ async fn main() -> eyre::Result<()> { }, )?; + info!("sl8 init"); + let db = slatedb::Db::builder(args.path, object_store) .with_settings(db_settings) .build() @@ -109,6 +119,8 @@ async fn main() -> eyre::Result<()> { .on_response(DefaultOnResponse::new().level(tracing::Level::INFO)), ); + let server_handle = axum_server::Handle::new(); + tokio::spawn(shutdown_signal(server_handle.clone())); match ( args.tls.tls_self, args.tls.tls_cert.clone(), @@ -187,5 +199,29 @@ async fn shutdown_signal(handle: axum_server::Handle) { }, } - handle.graceful_shutdown(Some(Duration::from_secs(30))); + handle.graceful_shutdown(Some(Duration::from_secs(10))); +} + +#[derive(Debug)] +struct S3CredentialProvider { + credentials: SharedCredentialsProvider, +} + +#[async_trait::async_trait] +impl CredentialProvider for S3CredentialProvider { + type Credential = AwsCredential; + + async fn get_credential(&self) -> object_store::Result> { + let creds = self.credentials.provide_credentials().await.map_err(|e| { + object_store::Error::Generic { + store: "S3", + source: Box::new(e), + } + })?; + Ok(Arc::new(AwsCredential { + key_id: creds.access_key_id().to_string(), + secret_key: creds.secret_access_key().to_string(), + token: creds.session_token().map(ToString::to_string), + })) + } } From 2b6c41758cded44016ab938daf2ee9b964b32374 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Sun, 11 Jan 2026 08:46:17 -0500 Subject: [PATCH 048/700] shared version for all 3 crates (#44) --- Cargo.toml | 1 + api/Cargo.toml | 2 +- common/Cargo.toml | 2 +- lite/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3de34182..8e231579 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = ["api", "common", "lite"] [workspace.package] +version = "0.2.0" edition = "2024" license = "MIT" repository = "https://github.com/s2-streamstore/s2" diff --git a/api/Cargo.toml b/api/Cargo.toml index 0a78deb2..e2f8ae0e 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -3,7 +3,7 @@ name = "s2-api" description = "API types for S2, the durable streams API" edition.workspace = true license.workspace = true -version = "0.2.0" +version.workspace = true repository.workspace = true homepage.workspace = true keywords = ["s2", "durable", "streams", "log", "api"] diff --git a/common/Cargo.toml b/common/Cargo.toml index 332643c4..293758d5 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -3,7 +3,7 @@ name = "s2-common" description = "Common stuff for client and servers for S2, the durable streams API" edition.workspace = true license.workspace = true -version = "0.2.0" +version.workspace = true repository.workspace = true homepage.workspace = true keywords = ["s2", "durable", "streams", "log", "api"] diff --git a/lite/Cargo.toml b/lite/Cargo.toml index 6ffe5584..d00ec3f2 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -3,7 +3,7 @@ name = "s2-lite" description = "Lightweight server implementation of S2, the durable streams API, backed by object storage" edition.workspace = true license.workspace = true -version = "0.2.0" +version.workspace = true repository.workspace = true homepage.workspace = true keywords = ["s2", "durable", "streams", "object", "storage"] From 754289b4a7b26a9e4cb7275c0868e62b2a9e6cdb Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Sun, 11 Jan 2026 11:07:57 -0500 Subject: [PATCH 049/700] prometheus endpoint (#45) --- Cargo.lock | 111 +++++++++++++++++++++++++++++++------ Cargo.toml | 1 + lite/Cargo.toml | 1 + lite/src/backend/append.rs | 18 +++++- lite/src/bin/server.rs | 2 +- lite/src/handlers/mod.rs | 12 ++++ lite/src/lib.rs | 1 + lite/src/metrics.rs | 56 +++++++++++++++++++ 8 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 lite/src/metrics.rs diff --git a/Cargo.lock b/Cargo.lock index cca061c1..cc7f2ac2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1118,9 +1118,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "der-parser" @@ -1524,7 +1524,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" dependencies = [ - "rustix", + "rustix 1.1.3", "windows-sys 0.59.0", ] @@ -2124,6 +2124,12 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2620,11 +2626,12 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petgraph" -version = "0.7.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", + "hashbrown 0.15.5", "indexmap", ] @@ -2731,6 +2738,45 @@ dependencies = [ "yansi", ] +[[package]] +name = "procfs" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" +dependencies = [ + "bitflags", + "hex", + "procfs-core", + "rustix 0.38.44", +] + +[[package]] +name = "procfs-core" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" +dependencies = [ + "bitflags", + "hex", +] + +[[package]] +name = "prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "libc", + "memchr", + "parking_lot", + "procfs", + "protobuf", + "thiserror 2.0.17", +] + [[package]] name = "proptest" version = "1.9.0" @@ -2752,9 +2798,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", "prost-derive", @@ -2762,15 +2808,14 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck 0.5.0", "itertools", "log", "multimap", - "once_cell", "petgraph", "prettyplease", "prost", @@ -2782,9 +2827,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", "itertools", @@ -2795,13 +2840,33 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ "prost", ] +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "ptr_meta" version = "0.3.1" @@ -3225,6 +3290,19 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -3234,7 +3312,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -3384,6 +3462,7 @@ dependencies = [ "http 1.4.0", "itertools", "parking_lot", + "prometheus", "proptest", "prost", "rand 0.9.2", @@ -3799,7 +3878,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index 8e231579..8028954a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ http = "1.4" itertools = "0.14" mime = "0.3" parking_lot = "0.12" +prometheus = "0.14" proptest = "1.9" prost = "0.14" prost-build = "0.14" diff --git a/lite/Cargo.toml b/lite/Cargo.toml index d00ec3f2..e7b60f2f 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -27,6 +27,7 @@ futures = { workspace = true } http = { workspace = true } itertools = { workspace = true } parking_lot = { workspace = true, features = ["arc_lock"] } +prometheus = { workspace = true, features = ["process"] } prost = { workspace = true } rand = { workspace = true } rcgen = { workspace = true } diff --git a/lite/src/backend/append.rs b/lite/src/backend/append.rs index 8d34c2d1..d6034dc5 100644 --- a/lite/src/backend/append.rs +++ b/lite/src/backend/append.rs @@ -2,6 +2,7 @@ use std::{ collections::VecDeque, ops::{DerefMut as _, Range, RangeTo}, sync::Arc, + time::Instant, }; use futures::{FutureExt as _, Stream, StreamExt as _, stream::FuturesOrdered}; @@ -15,7 +16,10 @@ use s2_common::{ use tokio::sync::oneshot; use super::Backend; -use crate::backend::error::{AppendError, AppendErrorInternal, StorageError}; +use crate::{ + backend::error::{AppendError, AppendErrorInternal, StorageError}, + metrics, +}; // N.B. Ultimately capped by streamer::MAX_INFLIGHT_APPENDS const MAX_INFLIGHT_BATCHES: usize = 100; @@ -28,12 +32,15 @@ impl Backend { stream: StreamName, input: AppendInput, ) -> Result { + metrics::observe_append_batch_size(input.records.metered_size(), input.records.len()); + let start = Instant::now(); let client = self .streamer_client_with_auto_create::(&basin, &stream, |config| { config.create_stream_on_append }) .await?; let ack = client.append(input, None).await?; + metrics::observe_append_ack_latency(start.elapsed()); Ok(ack) } @@ -56,14 +63,19 @@ impl Backend { && inflight_bytes <= MAX_INFLIGHT_BYTES ) => { let metered_size = input.records.metered_size(); + metrics::observe_append_batch_size(input.records.len(), metered_size); inflight_bytes += metered_size; + let start = Instant::now(); let fut = client .append(input, Some(session.clone())) - .map(move |res| (metered_size, res)); + .map(move |res| (metered_size, start, res)); futs.push_back(fut); } - Some((metered_size, res)) = futs.next() => { + Some((metered_size, start, res)) = futs.next() => { inflight_bytes -= metered_size; + if res.is_ok() { + metrics::observe_append_ack_latency(start.elapsed()); + } yield res.map_err(AppendError::from); } else => { diff --git a/lite/src/bin/server.rs b/lite/src/bin/server.rs index abdc6fc4..1ee019ed 100644 --- a/lite/src/bin/server.rs +++ b/lite/src/bin/server.rs @@ -105,7 +105,7 @@ async fn main() -> eyre::Result<()> { }, )?; - info!("sl8 init"); + info!(?db_settings, "sl8 init"); let db = slatedb::Db::builder(args.path, object_store) .with_settings(db_settings) diff --git a/lite/src/handlers/mod.rs b/lite/src/handlers/mod.rs index 01802e19..55e505d6 100644 --- a/lite/src/handlers/mod.rs +++ b/lite/src/handlers/mod.rs @@ -1,7 +1,19 @@ mod v1; +async fn metrics() -> impl axum::response::IntoResponse { + let body = crate::metrics::gather(); + ( + [( + axum::http::header::CONTENT_TYPE, + "text/plain; version=0.0.4", + )], + body, + ) +} + pub fn router(backend: crate::backend::Backend) -> axum::Router { axum::Router::new() .route("/ping", axum::routing::get(|| async { "pong" })) + .route("/metrics", axum::routing::get(metrics)) .nest("/v1", v1::router(backend)) } diff --git a/lite/src/lib.rs b/lite/src/lib.rs index 5f457cf4..be3f8188 100644 --- a/lite/src/lib.rs +++ b/lite/src/lib.rs @@ -1,2 +1,3 @@ pub mod backend; pub mod handlers; +pub mod metrics; diff --git a/lite/src/metrics.rs b/lite/src/metrics.rs new file mode 100644 index 00000000..76552515 --- /dev/null +++ b/lite/src/metrics.rs @@ -0,0 +1,56 @@ +use std::{sync::LazyLock, time::Duration}; + +use bytes::{BufMut, Bytes, BytesMut}; +use prometheus::{Encoder, Histogram, TextEncoder, register_histogram}; + +pub fn observe_append_ack_latency(latency: Duration) { + static HISTOGRAM: LazyLock = LazyLock::new(|| { + register_histogram!( + "s2_append_ack_latency_seconds", + "Append ack latency in seconds", + vec![ + 0.005, 0.010, 0.025, 0.050, 0.100, 0.250, 0.500, 1.000, 2.500 + ] + ) + .unwrap() + }); + HISTOGRAM.observe(latency.as_secs_f64()); +} + +pub fn observe_append_batch_size(count: usize, bytes: usize) { + static BYTES: LazyLock = LazyLock::new(|| { + register_histogram!( + "s2_append_batch_bytes", + "Append batch size in bytes", + vec![ + 512.0, + 1024.0, + (4 * 1024) as f64, + (16 * 1024) as f64, + (64 * 1024) as f64, + (256 * 1024) as f64, + (512 * 1024) as f64, + (1024 * 1024) as f64, + ] + ) + .unwrap() + }); + static RECORDS: LazyLock = LazyLock::new(|| { + register_histogram!( + "s2_append_batch_records", + "Append batch size in number of records", + vec![1.0, 10.0, 50.0, 100.0, 250.0, 500.0, 1000.0] + ) + .unwrap() + }); + RECORDS.observe(count as f64); + BYTES.observe(bytes as f64); +} + +pub fn gather() -> Bytes { + let encoder = TextEncoder::new(); + let metric_families = prometheus::gather(); + let mut buffer = BytesMut::new().writer(); + encoder.encode(&metric_families, &mut buffer).unwrap(); + buffer.into_inner().freeze() +} From ba686067591e0a35c02a9b981f278fa378ec2976 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Tue, 13 Jan 2026 00:00:02 -0500 Subject: [PATCH 050/700] append flow improvements & metrics (#46) --- Cargo.lock | 49 ++-- Cargo.toml | 1 + lite/Cargo.toml | 1 + lite/src/backend/append.rs | 70 +++-- lite/src/backend/core.rs | 20 +- lite/src/backend/streamer.rs | 307 +++++++++++++-------- lite/src/bin/server.rs | 22 +- lite/src/metrics.rs | 33 ++- lite/tests/backend/common.rs | 3 +- lite/tests/backend/control_plane/stream.rs | 35 +-- 10 files changed, 326 insertions(+), 215 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc7f2ac2..c63f6e3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -714,9 +714,9 @@ dependencies = [ [[package]] name = "base64ct" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bincode" @@ -825,6 +825,12 @@ dependencies = [ "either", ] +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + [[package]] name = "castaway" version = "0.2.4" @@ -908,9 +914,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "cmake" @@ -1361,9 +1367,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", "miniz_oxide", @@ -1641,9 +1647,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -2358,7 +2364,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] @@ -3000,7 +3006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.4", ] [[package]] @@ -3020,7 +3026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.4", ] [[package]] @@ -3029,14 +3035,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "4f1b3bc831f92381018fd9c6350b917c7b21f1eed35a65a51900e0e55a3d7afa" dependencies = [ "getrandom 0.3.4", ] @@ -3047,7 +3053,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core 0.9.3", + "rand_core 0.9.4", ] [[package]] @@ -3065,7 +3071,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" dependencies = [ - "rand_core 0.9.3", + "rand_core 0.9.4", ] [[package]] @@ -3191,7 +3197,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -3453,6 +3459,7 @@ dependencies = [ "axum-server", "blake3", "bytes", + "bytesize", "clap", "compact_str", "dashmap", @@ -4133,9 +4140,9 @@ checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -5040,9 +5047,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "ac93432f5b761b22864c774aac244fa5c0fd877678a4c37ebf6cf42208f9c9ec" [[package]] name = "zstd" diff --git a/Cargo.toml b/Cargo.toml index 8028954a..da8299bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ axum-server = "0.8" base64ct = "1.8" blake3 = "1.8" bytes = "1.11" +bytesize = "2.3" clap = "4.5" compact_str = "0.9" dashmap = "6.1" diff --git a/lite/Cargo.toml b/lite/Cargo.toml index e7b60f2f..42bb03a6 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -18,6 +18,7 @@ axum = { workspace = true, features = ["macros"] } axum-server = { workspace = true, features = ["tls-rustls"] } blake3 = { workspace = true } bytes = { workspace = true } +bytesize = { workspace = true } clap = { workspace = true, features = ["derive"] } compact_str = { workspace = true } dashmap = { workspace = true } diff --git a/lite/src/backend/append.rs b/lite/src/backend/append.rs index d6034dc5..122d87e1 100644 --- a/lite/src/backend/append.rs +++ b/lite/src/backend/append.rs @@ -2,12 +2,11 @@ use std::{ collections::VecDeque, ops::{DerefMut as _, Range, RangeTo}, sync::Arc, - time::Instant, }; -use futures::{FutureExt as _, Stream, StreamExt as _, stream::FuturesOrdered}; +use futures::{Stream, StreamExt as _, future::OptionFuture, stream::FuturesOrdered}; use s2_common::{ - record::{MeteredSize as _, SeqNum, StreamPosition}, + record::{SeqNum, StreamPosition}, types::{ basin::BasinName, stream::{AppendAck, AppendInput, StreamName}, @@ -16,14 +15,7 @@ use s2_common::{ use tokio::sync::oneshot; use super::Backend; -use crate::{ - backend::error::{AppendError, AppendErrorInternal, StorageError}, - metrics, -}; - -// N.B. Ultimately capped by streamer::MAX_INFLIGHT_APPENDS -const MAX_INFLIGHT_BATCHES: usize = 100; -const MAX_INFLIGHT_BYTES: usize = 10 * 1024 * 1024; +use crate::backend::error::{AppendError, AppendErrorInternal, StorageError}; impl Backend { pub async fn append( @@ -32,15 +24,12 @@ impl Backend { stream: StreamName, input: AppendInput, ) -> Result { - metrics::observe_append_batch_size(input.records.metered_size(), input.records.len()); - let start = Instant::now(); let client = self .streamer_client_with_auto_create::(&basin, &stream, |config| { config.create_stream_on_append }) .await?; - let ack = client.append(input, None).await?; - metrics::observe_append_ack_latency(start.elapsed()); + let ack = client.append_permit(input).await?.submit().await?; Ok(ack) } @@ -54,29 +43,33 @@ impl Backend { let session = SessionHandle::new(); Ok(async_stream::stream! { tokio::pin!(inputs); - let mut futs = FuturesOrdered::new(); - let mut inflight_bytes = 0; + let mut permit_opt = None; + let mut append_futs = FuturesOrdered::new(); loop { tokio::select! { - Some(input) = inputs.next(), if ( - futs.len() < MAX_INFLIGHT_BATCHES - && inflight_bytes <= MAX_INFLIGHT_BYTES - ) => { - let metered_size = input.records.metered_size(); - metrics::observe_append_batch_size(input.records.len(), metered_size); - inflight_bytes += metered_size; - let start = Instant::now(); - let fut = client - .append(input, Some(session.clone())) - .map(move |res| (metered_size, start, res)); - futs.push_back(fut); + Some(input) = inputs.next(), if permit_opt.is_none() => { + permit_opt = Some(Box::pin(client.append_permit(input))); } - Some((metered_size, start, res)) = futs.next() => { - inflight_bytes -= metered_size; - if res.is_ok() { - metrics::observe_append_ack_latency(start.elapsed()); + Some(res) = OptionFuture::from(permit_opt.as_mut()) => { + permit_opt = None; + match res { + Ok(permit) => append_futs.push_back(permit.submit_session(session.clone())), + Err(e) => { + yield Err(e.into()); + break; + } + } + } + Some(res) = append_futs.next(), if !append_futs.is_empty() => { + match res { + Ok(ack) => { + yield Ok(ack); + }, + Err(e) => { + yield Err(e.into()); + break; + } } - yield res.map_err(AppendError::from); } else => { break; @@ -136,9 +129,9 @@ pub struct PendingAppends { } impl PendingAppends { - pub fn new(capacity: usize) -> Self { + pub fn new() -> Self { Self { - queue: VecDeque::with_capacity(capacity), + queue: VecDeque::new(), next_ack_pos: None, } } @@ -181,6 +174,11 @@ impl PendingAppends { for sender in self.queue.drain(..completable) { sender.unblock(Ok(stable_pos)); } + // Lots of small appends could cause this, + // as we bound only on total bytes not num batches. + if self.queue.capacity() >= 4 * self.queue.len() { + self.queue.shrink_to(self.queue.len() * 2); + } } pub fn on_durability_failed(self, err: slatedb::Error) { diff --git a/lite/src/backend/core.rs b/lite/src/backend/core.rs index f2c7462c..030248d2 100644 --- a/lite/src/backend/core.rs +++ b/lite/src/backend/core.rs @@ -1,5 +1,6 @@ use std::{sync::Arc, time::Duration}; +use bytesize::ByteSize; use dashmap::DashMap; use enum_ordinalize::Ordinalize; use s2_common::{ @@ -31,15 +32,17 @@ pub const FOLLOWER_MAX_LAG: usize = 25; pub struct Backend { pub(super) db: slatedb::Db, client_states: Arc>, + append_inflight_max: ByteSize, } impl Backend { const FAILED_INIT_MEMORY: Duration = Duration::from_secs(1); - pub fn new(db: slatedb::Db) -> Self { + pub fn new(db: slatedb::Db, append_inflight_max: ByteSize) -> Self { Self { db, client_states: Arc::new(DashMap::new()), + append_inflight_max, } } @@ -85,15 +88,18 @@ impl Backend { } let client_states = self.client_states.clone(); - Ok(super::streamer::spawn_streamer( - self.db.clone(), + Ok(super::streamer::Spawner { + db: self.db.clone(), stream_id, - meta.config, + config: meta.config, tail_pos, fencing_token, trim_point, - client_states, - )) + append_inflight_max: self.append_inflight_max, + } + .spawn(move || { + client_states.remove(&stream_id); + })) } async fn assert_no_records_following_tail( @@ -283,7 +289,7 @@ mod tests { .await .unwrap(); - let backend = Backend::new(db.clone()); + let backend = Backend::new(db.clone(), ByteSize::b(1)); let basin = BasinName::from_str("testbasin1").unwrap(); let stream = StreamName::from_str("stream1").unwrap(); diff --git a/lite/src/backend/streamer.rs b/lite/src/backend/streamer.rs index 4373def2..474db16a 100644 --- a/lite/src/backend/streamer.rs +++ b/lite/src/backend/streamer.rs @@ -4,12 +4,12 @@ use std::{ time::Duration, }; -use dashmap::DashMap; +use bytesize::ByteSize; use futures::{FutureExt as _, StreamExt as _, future::BoxFuture, stream::FuturesOrdered}; use s2_common::{ record::{ - CommandRecord, FencingToken, Metered, Record, SeqNum, SequencedRecord, StreamPosition, - Timestamp, + CommandRecord, FencingToken, Metered, MeteredSize, Record, SeqNum, SequencedRecord, + StreamPosition, Timestamp, }, types::{ config::{ @@ -23,65 +23,83 @@ use slatedb::{ config::{PutOptions, Ttl, WriteOptions}, }; use tokio::{ - sync::{self, broadcast, mpsc, oneshot}, + sync::{self, Semaphore, SemaphorePermit, broadcast, mpsc, oneshot}, time::Instant, }; -use crate::backend::{ - append, - error::{ - AppendConditionFailedError, AppendErrorInternal, AppendTimestampRequiredError, - DeleteStreamError, StreamerError, UnavailableError, +use crate::{ + backend::{ + append, + error::{ + AppendConditionFailedError, AppendErrorInternal, AppendTimestampRequiredError, + DeleteStreamError, StreamerError, UnavailableError, + }, + kv, + stream_id::StreamId, }, - kv, - stream_id::StreamId, + metrics, }; -// TODO: bump once sl8 supports pipelining (https://github.com/slatedb/slatedb/issues/1138) -const MAX_INFLIGHT_APPENDS: usize = 1; - const DORMANT_TIMEOUT: Duration = Duration::from_secs(60); -pub(super) fn spawn_streamer( - db: slatedb::Db, - stream_id: StreamId, - config: OptionalStreamConfig, - tail_pos: StreamPosition, - fencing_token: FencingToken, - trim_point: RangeTo, - client_states: Arc>, -) -> StreamerClient { - let (append_tx, append_rx) = mpsc::channel(MAX_INFLIGHT_APPENDS); - let (control_tx, control_rx) = mpsc::unbounded_channel(); +pub(super) struct Spawner { + pub db: slatedb::Db, + pub stream_id: StreamId, + pub config: OptionalStreamConfig, + pub tail_pos: StreamPosition, + pub fencing_token: FencingToken, + pub trim_point: RangeTo, + pub append_inflight_max: ByteSize, +} - let streamer = Streamer { - db, - stream_id, - config, - fencing_token: CommandState { - state: fencing_token, - applied_point: ..tail_pos.seq_num, - }, - trim_point: CommandState { - state: trim_point, - applied_point: ..tail_pos.seq_num, - }, - append_futs: FuturesOrdered::new(), - pending_appends: append::PendingAppends::new(MAX_INFLIGHT_APPENDS), - stable_pos: tail_pos, - follow_tx: broadcast::Sender::new(super::FOLLOWER_MAX_LAG), - }; +impl Spawner { + pub fn spawn(self, on_exit: impl FnOnce() + Send + 'static) -> StreamerClient { + let Self { + db, + stream_id, + config, + tail_pos, + fencing_token, + trim_point, + append_inflight_max, + } = self; - let task_stream_id = stream_id; - tokio::spawn(async move { - streamer.run(append_rx, control_rx).await; - client_states.remove(&task_stream_id); - }); + let (msg_tx, msg_rx) = mpsc::unbounded_channel(); - StreamerClient { - stream_id, - append_tx, - control_tx, + let append_inflight_bytes_max = + (append_inflight_max.as_u64() as usize).min(Semaphore::MAX_PERMITS); + + let append_inflight_bytes_sema = Arc::new(Semaphore::new(append_inflight_bytes_max)); + + let streamer = Streamer { + db, + stream_id, + config, + fencing_token: CommandState { + state: fencing_token, + applied_point: ..tail_pos.seq_num, + }, + trim_point: CommandState { + state: trim_point, + applied_point: ..tail_pos.seq_num, + }, + append_futs: FuturesOrdered::new(), + pending_appends: append::PendingAppends::new(), + stable_pos: tail_pos, + follow_tx: broadcast::Sender::new(super::FOLLOWER_MAX_LAG), + }; + + tokio::spawn(async move { + streamer.run(msg_rx).await; + on_exit(); + }); + + StreamerClient { + stream_id, + msg_tx, + append_inflight_bytes_sema, + append_inflight_bytes_max, + } } } @@ -182,13 +200,19 @@ impl Streamer { } } - fn handle_append(&mut self, msg: AppendMessage) { - let Some(ticket) = append::admit(msg.reply_tx, msg.session) else { + fn handle_append( + &mut self, + input: AppendInput, + session: Option, + reply_tx: oneshot::Sender>, + append_type: AppendType, + ) { + let Some(ticket) = append::admit(reply_tx, session) else { return; }; - match self.sequence_records(msg.input) { + match self.sequence_records(input) { Ok(sequenced_records) => { - if msg.append_type == AppendType::Terminal { + if append_type == AppendType::Terminal { assert_eq!(sequenced_records.len(), 1); assert_eq!( sequenced_records[0].record, @@ -197,7 +221,7 @@ impl Streamer { } for sr in sequenced_records.iter() { if let Record::Command(cmd) = &sr.record { - self.apply_command(sr.position.seq_num, cmd, msg.append_type); + self.apply_command(sr.position.seq_num, cmd, append_type); } } let first_pos = sequenced_records.first().expect("non-empty").position; @@ -226,9 +250,19 @@ impl Streamer { } } - fn handle_control(&mut self, msg: ControlMessage) { + fn handle_message(&mut self, msg: Message) { match msg { - ControlMessage::Follow { + Message::Append { + input, + session, + reply_tx, + append_type, + } => { + if self.trim_point.state.end < SeqNum::MAX { + self.handle_append(input, session, reply_tx, append_type); + } + } + Message::Follow { start_seq_num, reply_tx, } => { @@ -239,25 +273,22 @@ impl Streamer { }; let _ = reply_tx.send(reply); } - ControlMessage::CheckTail { reply_tx } => { + Message::CheckTail { reply_tx } => { let _ = reply_tx.send(self.stable_pos); } - ControlMessage::Reconfigure { config } => { + Message::Reconfigure { config } => { self.config = config; } } } - async fn run( - mut self, - mut appends_rx: mpsc::Receiver, - mut control_rx: mpsc::UnboundedReceiver, - ) { + async fn run(mut self, mut msg_rx: mpsc::UnboundedReceiver) { let dormancy = tokio::time::sleep(Duration::MAX); tokio::pin!(dormancy); loop { if self.trim_point.state.end == SeqNum::MAX { if self.trim_point.applied_point.end == self.stable_pos.seq_num { + // Terminal trim is durable. break; } else { assert!(self.stable_pos.seq_num < self.trim_point.applied_point.end); @@ -265,14 +296,8 @@ impl Streamer { } dormancy.as_mut().reset(Instant::now() + DORMANT_TIMEOUT); tokio::select! { - Some(msg) = appends_rx.recv(), if ( - self.trim_point.state.end < SeqNum::MAX && - self.append_futs.len() < MAX_INFLIGHT_APPENDS - ) => { - self.handle_append(msg); - } - Some(msg) = control_rx.recv() => { - self.handle_control(msg); + Some(msg) = msg_rx.recv() => { + self.handle_message(msg); } Some(res) = self.append_futs.next() => { match res { @@ -297,14 +322,13 @@ impl Streamer { } } -struct AppendMessage { - input: AppendInput, - session: Option, - reply_tx: oneshot::Sender>, - append_type: AppendType, -} - -enum ControlMessage { +enum Message { + Append { + input: AppendInput, + session: Option, + reply_tx: oneshot::Sender>, + append_type: AppendType, + }, Follow { start_seq_num: SeqNum, reply_tx: oneshot::Sender< @@ -322,8 +346,9 @@ enum ControlMessage { #[derive(Debug, Clone)] pub(super) struct StreamerClient { stream_id: StreamId, - append_tx: mpsc::Sender, - control_tx: mpsc::UnboundedSender, + msg_tx: mpsc::UnboundedSender, + append_inflight_bytes_sema: Arc, + append_inflight_bytes_max: usize, } impl StreamerClient { @@ -333,8 +358,8 @@ impl StreamerClient { pub async fn check_tail(&self) -> Result { let (reply_tx, reply_rx) = oneshot::channel(); - self.control_tx - .send(ControlMessage::CheckTail { reply_tx }) + self.msg_tx + .send(Message::CheckTail { reply_tx }) .map_err(|_| UnavailableError::MissingInAction)?; reply_rx.await.map_err(|_| UnavailableError::RequestDrop) } @@ -347,8 +372,8 @@ impl StreamerClient { UnavailableError, > { let (reply_tx, reply_rx) = oneshot::channel(); - self.control_tx - .send(ControlMessage::Follow { + self.msg_tx + .send(Message::Follow { start_seq_num, reply_tx, }) @@ -356,35 +381,38 @@ impl StreamerClient { reply_rx.await.map_err(|_| UnavailableError::RequestDrop) } - pub async fn append( + pub async fn append_permit( &self, input: AppendInput, - session: Option, - ) -> Result { - let (reply_tx, reply_rx) = oneshot::channel(); - self.append_tx - .send(AppendMessage { - input, - session, - reply_tx, - append_type: AppendType::Regular, - }) - .await - .map_err(|_| UnavailableError::MissingInAction)?; - let ack = reply_rx - .await - .map_err(|_| UnavailableError::RequestDrop)??; - Ok(ack) + ) -> Result, UnavailableError> { + let metered_size = input.records.metered_size(); + metrics::observe_append_batch_size(input.records.len(), metered_size); + let start = Instant::now(); + // Allow admitting at least one batch if none are in flight. + let num_permits = metered_size.clamp(1, self.append_inflight_bytes_max) as u32; + let sema_permit = tokio::select! { + res = self.append_inflight_bytes_sema.acquire_many(num_permits) => { + res.map_err(|_| UnavailableError::MissingInAction) + } + _ = self.msg_tx.closed() => { + Err(UnavailableError::MissingInAction) + } + }?; + metrics::observe_append_permit_latency(start.elapsed()); + Ok(AppendPermit { + sema_permit, + msg_tx: &self.msg_tx, + input, + }) } pub async fn reconfigure(&self, config: OptionalStreamConfig) -> Result<(), UnavailableError> { - self.control_tx - .send(ControlMessage::Reconfigure { config }) + self.msg_tx + .send(Message::Reconfigure { config }) .map_err(|_| UnavailableError::MissingInAction) } pub async fn terminal_trim(&self) -> Result<(), DeleteStreamError> { - let (reply_tx, reply_rx) = oneshot::channel(); let record: AppendRecord = AppendRecordParts { timestamp: Some(Timestamp::MAX), record: Record::Command(CommandRecord::Trim(SeqNum::MAX)).into(), @@ -396,16 +424,12 @@ impl StreamerClient { match_seq_num: None, fencing_token: None, }; - self.append_tx - .send(AppendMessage { - input, - session: None, - reply_tx, - append_type: AppendType::Terminal, - }) + match self + .append_permit(input) + .await? + .submit_internal(None, AppendType::Terminal) .await - .map_err(|_| UnavailableError::MissingInAction)?; - match reply_rx.await.map_err(|_| UnavailableError::RequestDrop)? { + { Ok(_ack) => Ok(()), Err(e) => Err(match e { AppendErrorInternal::Storage(e) => DeleteStreamError::Storage(e), @@ -433,12 +457,61 @@ pub enum StreamerClientState { fn timestamp_now() -> Timestamp { std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH) - .unwrap() + .expect("21st century") .as_millis() .try_into() .expect("Milliseconds since Unix epoch fits into a u64") } +#[derive(Debug)] +pub struct AppendPermit<'a> { + sema_permit: SemaphorePermit<'a>, + msg_tx: &'a mpsc::UnboundedSender, + input: AppendInput, +} + +impl AppendPermit<'_> { + pub async fn submit(self) -> Result { + self.submit_internal(None, AppendType::Regular).await + } + + pub async fn submit_session( + self, + session: append::SessionHandle, + ) -> Result { + self.submit_internal(Some(session), AppendType::Regular) + .await + } + + async fn submit_internal( + self, + session: Option, + append_type: AppendType, + ) -> Result { + let start = Instant::now(); + let AppendPermit { + sema_permit, + msg_tx, + input, + } = self; + let (reply_tx, reply_rx) = oneshot::channel(); + msg_tx + .send(Message::Append { + input, + session, + reply_tx, + append_type, + }) + .map_err(|_| UnavailableError::MissingInAction)?; + let ack = reply_rx + .await + .map_err(|_| UnavailableError::RequestDrop)??; + drop(sema_permit); + metrics::observe_append_ack_latency(start.elapsed()); + Ok(ack) + } +} + pub fn next_pos(records: &[T]) -> StreamPosition where T: std::ops::Deref, diff --git a/lite/src/bin/server.rs b/lite/src/bin/server.rs index 1ee019ed..b26253c3 100644 --- a/lite/src/bin/server.rs +++ b/lite/src/bin/server.rs @@ -2,6 +2,7 @@ use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; use aws_credential_types::provider::{ProvideCredentials, SharedCredentialsProvider}; use axum_server::tls_rustls::RustlsConfig; +use bytesize::ByteSize; use clap::Parser as _; use s2_lite::{backend::Backend, handlers}; use slatedb::object_store::{self, CredentialProvider, aws::AwsCredential}; @@ -45,6 +46,15 @@ struct Args { /// Port to listen on [default: 443 if HTTPS configured, otherwise 80 for HTTP] #[arg(long)] port: Option, + + /// Whether to pipeline appends per stream. + /// + /// This improves performance with high-latency object storage, + /// however it is unsafe for now (risk of data loss). + /// + /// It will be enabled by default in future and this knob removed. + #[arg(long, default_value_t = false)] + pipeline: bool, } #[tokio::main] @@ -112,7 +122,17 @@ async fn main() -> eyre::Result<()> { .build() .await?; - let app = handlers::router(Backend::new(db)).layer( + let backend = Backend::new( + db, + if args.pipeline { + ByteSize::mib(10) + } else { + // No pipelining + ByteSize::b(1) + }, + ); + + let app = handlers::router(backend).layer( TraceLayer::new_for_http() .make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO)) .on_request(DefaultOnRequest::new().level(tracing::Level::DEBUG)) diff --git a/lite/src/metrics.rs b/lite/src/metrics.rs index 76552515..8836c88a 100644 --- a/lite/src/metrics.rs +++ b/lite/src/metrics.rs @@ -3,6 +3,20 @@ use std::{sync::LazyLock, time::Duration}; use bytes::{BufMut, Bytes, BytesMut}; use prometheus::{Encoder, Histogram, TextEncoder, register_histogram}; +pub fn observe_append_permit_latency(latency: Duration) { + static HISTOGRAM: LazyLock = LazyLock::new(|| { + register_histogram!( + "s2_append_permit_latency_seconds", + "Append permit latency in seconds", + vec![ + 0.005, 0.010, 0.025, 0.050, 0.100, 0.250, 0.500, 1.000, 2.500 + ] + ) + .unwrap() + }); + HISTOGRAM.observe(latency.as_secs_f64()); +} + pub fn observe_append_ack_latency(latency: Duration) { static HISTOGRAM: LazyLock = LazyLock::new(|| { register_histogram!( @@ -18,6 +32,16 @@ pub fn observe_append_ack_latency(latency: Duration) { } pub fn observe_append_batch_size(count: usize, bytes: usize) { + static RECORDS: LazyLock = LazyLock::new(|| { + register_histogram!( + "s2_append_batch_records", + "Append batch size in number of records", + vec![1.0, 10.0, 50.0, 100.0, 250.0, 500.0, 1000.0] + ) + .unwrap() + }); + RECORDS.observe(count as f64); + static BYTES: LazyLock = LazyLock::new(|| { register_histogram!( "s2_append_batch_bytes", @@ -35,15 +59,6 @@ pub fn observe_append_batch_size(count: usize, bytes: usize) { ) .unwrap() }); - static RECORDS: LazyLock = LazyLock::new(|| { - register_histogram!( - "s2_append_batch_records", - "Append batch size in number of records", - vec![1.0, 10.0, 50.0, 100.0, 250.0, 500.0, 1000.0] - ) - .unwrap() - }); - RECORDS.observe(count as f64); BYTES.observe(bytes as f64); } diff --git a/lite/tests/backend/common.rs b/lite/tests/backend/common.rs index 51b7a5a1..345b9a1d 100644 --- a/lite/tests/backend/common.rs +++ b/lite/tests/backend/common.rs @@ -1,6 +1,7 @@ use std::{pin::Pin, sync::Arc}; use bytes::Bytes; +use bytesize::ByteSize; use futures::StreamExt; use s2_common::{ record::{CommandRecord, FencingToken, Metered, Record, SequencedRecord, Timestamp}, @@ -31,7 +32,7 @@ pub async fn create_in_memory_db() -> Db { pub async fn create_backend() -> Backend { let db = create_in_memory_db().await; - Backend::new(db) + Backend::new(db, ByteSize::mib(10)) } pub fn test_basin_name(suffix: &str) -> BasinName { diff --git a/lite/tests/backend/control_plane/stream.rs b/lite/tests/backend/control_plane/stream.rs index d119954c..15b9dcfa 100644 --- a/lite/tests/backend/control_plane/stream.rs +++ b/lite/tests/backend/control_plane/stream.rs @@ -17,12 +17,9 @@ use s2_common::{ }, }, }; -use s2_lite::backend::{ - Backend, - error::{ - AppendError, CheckTailError, CreateStreamError, DeleteStreamError, GetStreamConfigError, - ReadError, ReconfigureStreamError, StreamDeletionPendingError, - }, +use s2_lite::backend::error::{ + AppendError, CheckTailError, CreateStreamError, DeleteStreamError, GetStreamConfigError, + ReadError, ReconfigureStreamError, StreamDeletionPendingError, }; use super::common::*; @@ -404,43 +401,37 @@ async fn test_delete_stream_marks_deleted_and_blocks_recreation() { #[tokio::test] async fn test_delete_stream_blocks_data_operations() { - let db = create_in_memory_db().await; - let backend_delete = Backend::new(db.clone()); - let backend_new_client = Backend::new(db); + let backend = create_backend().await; - let basin_name = create_test_basin( - &backend_delete, - "stream-delete-blocks", - BasinConfig::default(), - ) - .await; + let basin_name = + create_test_basin(&backend, "stream-delete-blocks", BasinConfig::default()).await; let stream_name = create_test_stream( - &backend_delete, + &backend, &basin_name, "stream-delete-blocks", OptionalStreamConfig::default(), ) .await; - backend_delete + backend .delete_stream(basin_name.clone(), stream_name.clone()) .await .expect("Failed to delete stream"); - let tail = backend_new_client + let tail = backend .check_tail(basin_name.clone(), stream_name.clone()) .await; assert!(matches!( tail, Err(CheckTailError::StreamDeletionPending(_)) - )); + ),); let input = AppendInput { records: create_test_record_batch(vec![Bytes::from_static(b"should fail")]), match_seq_num: None, fencing_token: None, }; - let append_result = backend_new_client + let append_result = backend .append(basin_name.clone(), stream_name.clone(), input) .await; assert!(matches!( @@ -453,9 +444,7 @@ async fn test_delete_stream_blocks_data_operations() { clamp: false, }; let end = ReadEnd::default(); - let read_result = backend_new_client - .read(basin_name, stream_name, start, end) - .await; + let read_result = backend.read(basin_name, stream_name, start, end).await; assert!(matches!( read_result, Err(ReadError::StreamDeletionPending(_)) From 809216e0ed27d98fcb521bb1393aa0516f6c070b Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Tue, 13 Jan 2026 12:10:06 -0500 Subject: [PATCH 051/700] feat: openapi spec gen (#47) Co-authored-by: shikhar --- .gitmodules | 3 - Cargo.lock | 37 +- Cargo.toml | 7 +- api/build.rs | 2 +- api/protos | 1 - api/specs/s2/v1/openapi.json | 3269 +++++++++++++++++++++++++ api/specs/s2/v1/s2.proto | 81 + api/specs/s2/v1alpha/s2.proto | 734 ++++++ api/src/v1/stream/s2s.rs | 5 +- lite/Cargo.toml | 17 +- lite/src/bin/openapi.rs | 156 ++ lite/src/handlers/mod.rs | 2 +- lite/src/handlers/v1/access_tokens.rs | 88 + lite/src/handlers/v1/basins.rs | 98 +- lite/src/handlers/v1/error.rs | 5 + lite/src/handlers/v1/metrics.rs | 90 + lite/src/handlers/v1/mod.rs | 68 +- lite/src/handlers/v1/paths.rs | 56 + lite/src/handlers/v1/records.rs | 77 + lite/src/handlers/v1/streams.rs | 146 +- 20 files changed, 4876 insertions(+), 66 deletions(-) delete mode 100644 .gitmodules delete mode 160000 api/protos create mode 100644 api/specs/s2/v1/openapi.json create mode 100644 api/specs/s2/v1/s2.proto create mode 100644 api/specs/s2/v1alpha/s2.proto create mode 100644 lite/src/bin/openapi.rs create mode 100644 lite/src/handlers/v1/access_tokens.rs create mode 100644 lite/src/handlers/v1/metrics.rs create mode 100644 lite/src/handlers/v1/paths.rs diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index f2d798f1..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "protos"] - path = api/protos - url = https://github.com/s2-streamstore/s2-protos diff --git a/Cargo.lock b/Cargo.lock index c63f6e3d..30e56b8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3006,7 +3006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.4", + "rand_core 0.9.5", ] [[package]] @@ -3026,7 +3026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.4", + "rand_core 0.9.5", ] [[package]] @@ -3040,9 +3040,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1b3bc831f92381018fd9c6350b917c7b21f1eed35a65a51900e0e55a3d7afa" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -3053,7 +3053,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core 0.9.4", + "rand_core 0.9.5", ] [[package]] @@ -3071,7 +3071,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" dependencies = [ - "rand_core 0.9.4", + "rand_core 0.9.5", ] [[package]] @@ -3486,6 +3486,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "utoipa", "uuid", ] @@ -3940,30 +3941,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", @@ -4345,8 +4346,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +source = "git+https://github.com/infiniteregrets/utoipa?rev=9cd181d40ac0a50551ebe1995a4d73e8685890d6#9cd181d40ac0a50551ebe1995a4d73e8685890d6" dependencies = [ "indexmap", "serde", @@ -4357,8 +4357,7 @@ dependencies = [ [[package]] name = "utoipa-gen" version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +source = "git+https://github.com/infiniteregrets/utoipa?rev=9cd181d40ac0a50551ebe1995a4d73e8685890d6#9cd181d40ac0a50551ebe1995a4d73e8685890d6" dependencies = [ "proc-macro2", "quote", @@ -5047,9 +5046,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac93432f5b761b22864c774aac244fa5c0fd877678a4c37ebf6cf42208f9c9ec" +checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" [[package]] name = "zstd" diff --git a/Cargo.toml b/Cargo.toml index da8299bf..9f1ed0c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,10 +10,10 @@ repository = "https://github.com/s2-streamstore/s2" homepage = "https://s2.dev" [workspace.dependencies] -aws-config = "1" -aws-credential-types = "1" async-stream = "0.3" async-trait = "0.1" +aws-config = "1" +aws-credential-types = "1" axum = "0.8" axum-server = "0.8" base64ct = "1.8" @@ -56,3 +56,6 @@ tracing-subscriber = "0.3" utoipa = "5.4" uuid = "1.19" zstd = "0.13" + +[patch.crates-io] +utoipa = { git = "https://github.com/infiniteregrets/utoipa", rev = "9cd181d40ac0a50551ebe1995a4d73e8685890d6" } diff --git a/api/build.rs b/api/build.rs index f377df69..2f9d53c7 100644 --- a/api/build.rs +++ b/api/build.rs @@ -4,7 +4,7 @@ fn main() -> Result<(), Box> { prost_build::Config::new() .bytes(["."]) .out_dir("src/v1/stream/proto") - .compile_protos(&["protos/s2/v1/s2.proto"], &["protos/s2/v1"])?; + .compile_protos(&["specs/s2/v1/s2.proto"], &["specs/s2/v1"])?; } Ok(()) } diff --git a/api/protos b/api/protos deleted file mode 160000 index 1ea0452c..00000000 --- a/api/protos +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1ea0452c9a333f9b746ff87f57a32d02087ca94b diff --git a/api/specs/s2/v1/openapi.json b/api/specs/s2/v1/openapi.json new file mode 100644 index 00000000..b6eb28ab --- /dev/null +++ b/api/specs/s2/v1/openapi.json @@ -0,0 +1,3269 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "S2, the durable streams API", + "description": "Streams as a cloud storage primitive.", + "termsOfService": "https://s2.dev/terms", + "contact": { + "email": "support@s2.dev" + }, + "license": { + "name": "MIT" + }, + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://aws.s2.dev/v1" + } + ], + "paths": { + "/access-tokens": { + "get": { + "tags": [ + "access-tokens" + ], + "summary": "List access tokens.", + "operationId": "list_access_tokens", + "parameters": [ + { + "name": "prefix", + "in": "query", + "description": "Filter to access tokens whose ID begins with this prefix.", + "required": false, + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "start_after", + "in": "query", + "description": "Filter to access tokens whose ID lexicographically starts after this string.", + "required": false, + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "limit", + "in": "query", + "description": "Number of results, up to a maximum of 1000.", + "required": false, + "schema": { + "type": "integer", + "default": 1000, + "maximum": 1000, + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListAccessTokensResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + }, + "post": { + "tags": [ + "access-tokens" + ], + "summary": "Issue a new access token.", + "operationId": "issue_access_token", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessTokenInfo" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueAccessTokenResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + } + }, + "/access-tokens/{id}": { + "delete": { + "tags": [ + "access-tokens" + ], + "summary": "Revoke an access token.", + "operationId": "revoke_access_token", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Access token ID.", + "required": true, + "schema": { + "$ref": "#/components/schemas/AccessTokenIdStr" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + } + }, + "/basins": { + "get": { + "tags": [ + "basins" + ], + "summary": "List basins.", + "operationId": "list_basins", + "parameters": [ + { + "name": "prefix", + "in": "query", + "description": "Filter to basins whose names begin with this prefix.", + "required": false, + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "start_after", + "in": "query", + "description": "Filter to basins whose names lexicographically start after this string.\nIt must be greater than or equal to the `prefix` if specified.", + "required": false, + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "limit", + "in": "query", + "description": "Number of results, up to a maximum of 1000.", + "required": false, + "schema": { + "type": "integer", + "default": 1000, + "maximum": 1000, + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBasinsResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + }, + "post": { + "tags": [ + "basins" + ], + "summary": "Create a basin.", + "operationId": "create_basin", + "parameters": [ + { + "name": "s2-request-token", + "in": "header", + "description": "Client-specified request token for idempotent retries.", + "required": false, + "schema": { + "$ref": "#/components/schemas/RequestToken" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBasinRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasinInfo" + } + } + } + }, + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasinInfo" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + } + }, + "/basins/{basin}": { + "get": { + "tags": [ + "basins" + ], + "summary": "Get basin configuration.", + "operationId": "get_basin_config", + "parameters": [ + { + "name": "basin", + "in": "path", + "description": "Basin name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/BasinNameStr" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasinConfig" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + }, + "put": { + "tags": [ + "basins" + ], + "summary": "Create or reconfigure a basin.", + "operationId": "create_or_reconfigure_basin", + "parameters": [ + { + "name": "basin", + "in": "path", + "description": "Basin name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/BasinNameStr" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CreateOrReconfigureBasinRequest" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasinInfo" + } + } + } + }, + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasinInfo" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + }, + "delete": { + "tags": [ + "basins" + ], + "summary": "Delete a basin.", + "operationId": "delete_basin", + "parameters": [ + { + "name": "basin", + "in": "path", + "description": "Basin name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/BasinNameStr" + } + } + ], + "responses": { + "202": { + "description": "" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + }, + "patch": { + "tags": [ + "basins" + ], + "summary": "Reconfigure a basin.", + "operationId": "reconfigure_basin", + "parameters": [ + { + "name": "basin", + "in": "path", + "description": "Basin name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/BasinNameStr" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasinReconfiguration" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasinConfig" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + } + }, + "/metrics": { + "get": { + "tags": [ + "metrics" + ], + "summary": "Account-level metrics.", + "operationId": "account_metrics", + "parameters": [ + { + "name": "set", + "in": "query", + "description": "Metric set to return.", + "required": true, + "schema": { + "$ref": "#/components/schemas/AccountMetricSet" + } + }, + { + "name": "start", + "in": "query", + "description": "Start timestamp as Unix epoch seconds, if applicable for the metric set.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "end", + "in": "query", + "description": "End timestamp as Unix epoch seconds, if applicable for the metric set.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "interval", + "in": "query", + "description": "Interval to aggregate over for timeseries metric sets.", + "required": false, + "schema": { + "$ref": "#/components/schemas/TimeseriesInterval" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetricSetResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + } + }, + "/metrics/{basin}": { + "get": { + "tags": [ + "metrics" + ], + "summary": "Basin-level metrics.", + "operationId": "basin_metrics", + "parameters": [ + { + "name": "set", + "in": "query", + "description": "Metric set to return.", + "required": true, + "schema": { + "$ref": "#/components/schemas/BasinMetricSet" + } + }, + { + "name": "start", + "in": "query", + "description": "Start timestamp as Unix epoch seconds, if applicable for the metric set.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "end", + "in": "query", + "description": "End timestamp as Unix epoch seconds, if applicable for the metric set.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "interval", + "in": "query", + "description": "Interval to aggregate over for timeseries metric sets.", + "required": false, + "schema": { + "$ref": "#/components/schemas/TimeseriesInterval" + } + }, + { + "name": "basin", + "in": "path", + "description": "Basin name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/BasinNameStr" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetricSetResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + } + }, + "/metrics/{basin}/{stream}": { + "get": { + "tags": [ + "metrics" + ], + "summary": "Stream-level metrics.", + "operationId": "stream_metrics", + "parameters": [ + { + "name": "set", + "in": "query", + "description": "Metric set to return.", + "required": true, + "schema": { + "$ref": "#/components/schemas/StreamMetricSet" + } + }, + { + "name": "start", + "in": "query", + "description": "Start timestamp as Unix epoch seconds, if applicable for the metric set.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "end", + "in": "query", + "description": "End timestamp as Unix epoch seconds, if applicable for metric set.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "interval", + "in": "query", + "description": "Interval to aggregate over for timeseries metric sets.", + "required": false, + "schema": { + "$ref": "#/components/schemas/TimeseriesInterval" + } + }, + { + "name": "basin", + "in": "path", + "description": "Basin name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/BasinNameStr" + } + }, + { + "name": "stream", + "in": "path", + "description": "Stream name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/StreamNameStr" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetricSetResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + } + }, + "/streams": { + "servers": [ + { + "url": "https://{basin}.b.aws.s2.dev/v1", + "description": "Endpoint for the basin", + "variables": { + "basin": { + "default": "", + "description": "Basin name" + } + } + } + ], + "get": { + "tags": [ + "streams" + ], + "summary": "List streams.", + "operationId": "list_streams", + "parameters": [ + { + "name": "prefix", + "in": "query", + "description": "Filter to streams whose name begins with this prefix.", + "required": false, + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "start_after", + "in": "query", + "description": "Filter to streams whose name begins with this prefix.\nIt must be greater than or equal to the `prefix` if specified.", + "required": false, + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "limit", + "in": "query", + "description": "Number of results, up to a maximum of 1000.", + "required": false, + "schema": { + "type": "integer", + "default": 1000, + "maximum": 1000, + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListStreamsResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + }, + "post": { + "tags": [ + "streams" + ], + "summary": "Create a stream.", + "operationId": "create_stream", + "parameters": [ + { + "name": "s2-request-token", + "in": "header", + "description": "Client-specified request token for idempotent retries.", + "required": false, + "schema": { + "$ref": "#/components/schemas/RequestToken" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateStreamRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamInfo" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + } + }, + "/streams/{stream}": { + "servers": [ + { + "url": "https://{basin}.b.aws.s2.dev/v1", + "description": "Endpoint for the basin", + "variables": { + "basin": { + "default": "", + "description": "Basin name" + } + } + } + ], + "get": { + "tags": [ + "streams" + ], + "summary": "Get stream configuration.", + "operationId": "get_stream_config", + "parameters": [ + { + "name": "stream", + "in": "path", + "description": "Stream name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/StreamNameStr" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamConfig" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + }, + "put": { + "tags": [ + "streams" + ], + "summary": "Create or reconfigure a stream.", + "operationId": "create_or_reconfigure_stream", + "parameters": [ + { + "name": "stream", + "in": "path", + "description": "Stream name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/StreamNameStr" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/StreamConfig" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamInfo" + } + } + } + }, + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamInfo" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + }, + "delete": { + "tags": [ + "streams" + ], + "summary": "Delete a stream.", + "operationId": "delete_stream", + "parameters": [ + { + "name": "stream", + "in": "path", + "description": "Stream name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/StreamNameStr" + } + } + ], + "responses": { + "202": { + "description": "" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + }, + "patch": { + "tags": [ + "streams" + ], + "summary": "Reconfigure a stream.", + "operationId": "reconfigure_stream", + "parameters": [ + { + "name": "stream", + "in": "path", + "description": "Stream name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/StreamNameStr" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamReconfiguration" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamConfig" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + } + }, + "/streams/{stream}/records": { + "servers": [ + { + "url": "https://{basin}.b.aws.s2.dev/v1", + "description": "Endpoint for the basin", + "variables": { + "basin": { + "default": "", + "description": "Basin name" + } + } + } + ], + "get": { + "tags": [ + "records" + ], + "summary": "Read records.", + "operationId": "read", + "parameters": [ + { + "name": "stream", + "in": "path", + "description": "Stream name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/StreamNameStr" + } + }, + { + "name": "s2-format", + "in": "header", + "description": "Defines the interpretation of record data (header name, header value, and body) with the JSON content type.\nUse `raw` (default) for efficient transmission and storage of Unicode data — storage will be in UTF-8.\nUse `base64` for safe transmission with efficient storage of binary data.", + "required": false, + "schema": { + "$ref": "#/components/schemas/Format" + } + }, + { + "name": "seq_num", + "in": "query", + "description": "Start from a sequence number.", + "required": false, + "schema": { + "$ref": "#/components/schemas/u64" + } + }, + { + "name": "timestamp", + "in": "query", + "description": "Start from a timestamp.", + "required": false, + "schema": { + "$ref": "#/components/schemas/u64" + } + }, + { + "name": "tail_offset", + "in": "query", + "description": "Start from number of records before the next sequence number.", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "clamp", + "in": "query", + "description": "Start reading from the tail if the requested position is beyond it.\nOtherwise, a `416 Range Not Satisfiable` response is returned.", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "count", + "in": "query", + "description": "Record count limit.\nNon-streaming reads are capped by the default limit of 1000 records.", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "bytes", + "in": "query", + "description": "Metered bytes limit.\nNon-streaming reads are capped by the default limit of 1 MiB.", + "required": false, + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "until", + "in": "query", + "description": "Exclusive timestamp to read until.", + "required": false, + "schema": { + "$ref": "#/components/schemas/u64" + } + }, + { + "name": "wait", + "in": "query", + "description": "Duration in seconds to wait for new records.\nThe default duration is 0 if there is a bound on `count`, `bytes`, or `until`, and otherwise infinite.\nNon-streaming reads are always bounded on `count` and `bytes`, so you can achieve long poll semantics by specifying a non-zero duration up to 60 seconds.\nIn the context of an SSE or S2S streaming read, the duration will bound how much time can elapse between records throughout the lifetime of the session.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadBatch" + } + }, + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/ReadEvent" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "416": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TailResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "records" + ], + "summary": "Append records.", + "operationId": "append", + "parameters": [ + { + "name": "stream", + "in": "path", + "description": "Stream name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/StreamNameStr" + } + }, + { + "name": "s2-format", + "in": "header", + "description": "Defines the interpretation of record data (header name, header value, and body) with the JSON content type.\nUse `raw` (default) for efficient transmission and storage of Unicode data — storage will be in UTF-8.\nUse `base64` for safe transmission with efficient storage of binary data.", + "required": false, + "schema": { + "$ref": "#/components/schemas/Format" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppendInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppendAck" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "412": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppendConditionFailed" + } + } + } + } + } + } + }, + "/streams/{stream}/records/tail": { + "servers": [ + { + "url": "https://{basin}.b.aws.s2.dev/v1", + "description": "Endpoint for the basin", + "variables": { + "basin": { + "default": "", + "description": "Basin name" + } + } + } + ], + "get": { + "tags": [ + "records" + ], + "summary": "Check the tail.", + "operationId": "check_tail", + "parameters": [ + { + "name": "stream", + "in": "path", + "description": "Stream name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/StreamNameStr" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TailResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "408": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorInfo" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AccessTokenIdStr": { + "type": "string", + "maxLength": 96, + "minLength": 1 + }, + "AccessTokenInfo": { + "type": "object", + "required": [ + "id", + "scope" + ], + "properties": { + "auto_prefix_streams": { + "type": "boolean", + "description": "Namespace streams based on the configured stream-level scope, which must be a prefix.\nStream name arguments will be automatically prefixed, and the prefix will be stripped when listing streams.", + "default": false + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Expiration time in RFC 3339 format.\nIf not set, the expiration will be set to that of the requestor's token." + }, + "id": { + "$ref": "#/components/schemas/AccessTokenIdStr", + "description": "Access token ID.\nIt must be unique to the account and between 1 and 96 bytes in length." + }, + "scope": { + "$ref": "#/components/schemas/AccessTokenScope", + "description": "Access token scope." + } + } + }, + "AccessTokenScope": { + "type": "object", + "properties": { + "access_tokens": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ResourceSet", + "description": "Token IDs allowed." + } + ] + }, + "basins": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ResourceSet", + "description": "Basin names allowed." + } + ] + }, + "op_groups": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PermittedOperationGroups", + "description": "Access permissions at operation group level." + } + ] + }, + "ops": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Operation" + }, + "description": "Operations allowed for the token.\nA union of allowed operations and groups is used as an effective set of allowed operations." + }, + "streams": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ResourceSet", + "description": "Stream names allowed." + } + ] + } + } + }, + "AccountMetricSet": { + "type": "string", + "enum": [ + "active-basins", + "account-ops" + ] + }, + "AccumulationMetric": { + "type": "object", + "required": [ + "name", + "unit", + "interval", + "values" + ], + "properties": { + "interval": { + "$ref": "#/components/schemas/TimeseriesInterval", + "description": "The interval at which data points are accumulated." + }, + "name": { + "type": "string", + "description": "Timeseries name." + }, + "unit": { + "$ref": "#/components/schemas/MetricUnit", + "description": "Unit of the metric." + }, + "values": { + "type": "array", + "items": { + "type": "array", + "items": false, + "prefixItems": [ + { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + { + "type": "number", + "format": "double" + } + ] + }, + "description": "Timeseries values.\nEach element is a tuple of a timestamp in Unix epoch seconds and a data point.\nThe data point represents the accumulated value for the time period starting at the timestamp, spanning one `interval`." + } + } + }, + "AppendAck": { + "type": "object", + "description": "Success response to an `append` request.", + "required": [ + "start", + "end", + "tail" + ], + "properties": { + "end": { + "$ref": "#/components/schemas/StreamPosition", + "description": "Sequence number of the last record that was appended `+ 1`, and timestamp of the last record that was appended.\nThe difference between `end.seq_num` and `start.seq_num` will be the number of records appended." + }, + "start": { + "$ref": "#/components/schemas/StreamPosition", + "description": "Sequence number and timestamp of the first record that was appended." + }, + "tail": { + "$ref": "#/components/schemas/StreamPosition", + "description": "Sequence number that will be assigned to the next record on the stream, and timestamp of the last record on the stream.\nThis can be greater than the `end` position in case of concurrent appends." + } + } + }, + "AppendConditionFailed": { + "oneOf": [ + { + "type": "object", + "title": "fencing token", + "description": "Fencing token did not match.\nThe expected fencing token is returned.", + "required": [ + "fencing_token_mismatch" + ], + "properties": { + "fencing_token_mismatch": { + "$ref": "#/components/schemas/FencingToken", + "description": "Fencing token did not match.\nThe expected fencing token is returned." + } + } + }, + { + "type": "object", + "title": "seq num", + "description": "Sequence number did not match the tail of the stream.\nThe expected next sequence number is returned.", + "required": [ + "seq_num_mismatch" + ], + "properties": { + "seq_num_mismatch": { + "$ref": "#/components/schemas/u64", + "description": "Sequence number did not match the tail of the stream.\nThe expected next sequence number is returned." + } + } + } + ], + "description": "Aborted due to a failed condition." + }, + "AppendInput": { + "type": "object", + "description": "Payload of an `append` request.", + "required": [ + "records" + ], + "properties": { + "fencing_token": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/FencingToken", + "description": "Enforce a fencing token, which starts out as an empty string that can be overridden by a `fence` command record." + } + ] + }, + "match_seq_num": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/u64", + "description": "Enforce that the sequence number assigned to the first record matches." + } + ] + }, + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppendRecord" + }, + "description": "Batch of records to append atomically, which must contain at least one record, and no more than 1000.\nThe total size of a batch of records may not exceed 1 MiB of metered bytes." + } + } + }, + "AppendRecord": { + "type": "object", + "description": "Record to be appended to a stream.", + "properties": { + "body": { + "type": "string", + "description": "Body of the record." + }, + "headers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Header" + }, + "description": "Series of name-value pairs for this record." + }, + "timestamp": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/u64", + "description": "Timestamp for this record.\nThe service will always ensure monotonicity by adjusting it up if necessary to the maximum observed timestamp.\nRefer to stream timestamping configuration for the finer semantics around whether a client-specified timestamp is required, and whether it will be capped at the arrival time." + } + ] + } + } + }, + "BasinConfig": { + "type": "object", + "properties": { + "create_stream_on_append": { + "type": "boolean", + "description": "Create stream on append if it doesn't exist, using the default stream configuration." + }, + "create_stream_on_read": { + "type": "boolean", + "description": "Create stream on read if it doesn't exist, using the default stream configuration." + }, + "default_stream_config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/StreamConfig", + "description": "Default stream configuration." + } + ] + } + } + }, + "BasinInfo": { + "type": "object", + "required": [ + "name", + "state" + ], + "properties": { + "name": { + "$ref": "#/components/schemas/BasinNameStr", + "description": "Basin name." + }, + "scope": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BasinScope", + "description": "Basin scope." + } + ] + }, + "state": { + "$ref": "#/components/schemas/BasinState", + "description": "Basin state." + } + } + }, + "BasinMetricSet": { + "type": "string", + "enum": [ + "storage", + "append-ops", + "read-ops", + "read-throughput", + "append-throughput", + "basin-ops" + ] + }, + "BasinNameStr": { + "type": "string", + "maxLength": 48, + "minLength": 8 + }, + "BasinReconfiguration": { + "type": "object", + "properties": { + "create_stream_on_append": { + "type": [ + "boolean", + "null" + ], + "description": "Create a stream on append." + }, + "create_stream_on_read": { + "type": [ + "boolean", + "null" + ], + "description": "Create a stream on read." + }, + "default_stream_config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/StreamReconfiguration", + "description": "Basin configuration." + } + ] + } + } + }, + "BasinScope": { + "type": "string", + "enum": [ + "aws:us-east-1" + ] + }, + "BasinState": { + "type": "string", + "enum": [ + "active", + "creating", + "deleting" + ] + }, + "CreateBasinRequest": { + "type": "object", + "required": [ + "basin" + ], + "properties": { + "basin": { + "$ref": "#/components/schemas/BasinNameStr", + "description": "Basin name which must be globally unique.\nIt can be between 8 and 48 bytes in length, and comprise lowercase letters, numbers and hyphens.\nIt cannot begin or end with a hyphen." + }, + "config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BasinConfig", + "description": "Basin configuration." + } + ] + }, + "scope": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BasinScope", + "description": "Basin scope." + } + ] + } + } + }, + "CreateOrReconfigureBasinRequest": { + "type": "object", + "properties": { + "config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BasinConfig", + "description": "Basin configuration." + } + ] + }, + "scope": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BasinScope", + "description": "Basin scope.\nThis cannot be reconfigured." + } + ] + } + } + }, + "CreateStreamRequest": { + "type": "object", + "required": [ + "stream" + ], + "properties": { + "config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/StreamConfig", + "description": "Stream configuration." + } + ] + }, + "stream": { + "$ref": "#/components/schemas/StreamNameStr", + "description": "Stream name that is unique to the basin.\nIt can be between 1 and 512 bytes in length." + } + } + }, + "DeleteOnEmptyConfig": { + "type": "object", + "properties": { + "min_age_secs": { + "type": "integer", + "format": "int64", + "description": "Minimum age in seconds before an empty stream can be deleted.\nSet to 0 (default) to disable delete-on-empty (don't delete automatically).", + "minimum": 0 + } + } + }, + "DeleteOnEmptyReconfiguration": { + "type": "object", + "properties": { + "min_age_secs": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Minimum age in seconds before an empty stream can be deleted.\nSet to 0 to disable delete-on-empty (don't delete automatically).", + "minimum": 0 + } + } + }, + "ErrorInfo": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "FencingToken": { + "type": "string", + "maxLength": 36 + }, + "Format": { + "type": "string", + "enum": [ + "raw", + "base64" + ] + }, + "GaugeMetric": { + "type": "object", + "required": [ + "name", + "unit", + "values" + ], + "properties": { + "name": { + "type": "string", + "description": "Timeseries name." + }, + "unit": { + "$ref": "#/components/schemas/MetricUnit", + "description": "Unit of the metric." + }, + "values": { + "type": "array", + "items": { + "type": "array", + "items": false, + "prefixItems": [ + { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + { + "type": "number", + "format": "double" + } + ] + }, + "description": "Timeseries values.\nEach element is a tuple of a timestamp in Unix epoch seconds and a data point.\nThe data point represents the value at the instant of the timestamp." + } + } + }, + "Header": { + "type": "array", + "items": { + "type": "string", + "description": "Headers add structured information to a record as name-value pairs.\n\nThe name cannot be empty, with the exception of an S2 command record." + }, + "description": "Headers add structured information to a record as name-value pairs.\n\nThe name cannot be empty, with the exception of an S2 command record.", + "maxItems": 2, + "minItems": 2 + }, + "InfiniteRetention": { + "type": "object" + }, + "IssueAccessTokenResponse": { + "type": "object", + "required": [ + "access_token" + ], + "properties": { + "access_token": { + "type": "string", + "description": "Created access token." + } + } + }, + "LabelMetric": { + "type": "object", + "required": [ + "name", + "values" + ], + "properties": { + "name": { + "type": "string", + "description": "Label name." + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Label values." + } + } + }, + "ListAccessTokensResponse": { + "type": "object", + "required": [ + "access_tokens", + "has_more" + ], + "properties": { + "access_tokens": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AccessTokenInfo" + }, + "description": "Matching access tokens.", + "maxItems": 1000 + }, + "has_more": { + "type": "boolean", + "description": "Indicates that there are more access tokens that match the criteria." + } + } + }, + "ListBasinsResponse": { + "type": "object", + "required": [ + "basins", + "has_more" + ], + "properties": { + "basins": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BasinInfo" + }, + "description": "Matching basins.", + "maxItems": 1000 + }, + "has_more": { + "type": "boolean", + "description": "Indicates that there are more basins that match the criteria." + } + } + }, + "ListStreamsResponse": { + "type": "object", + "required": [ + "streams", + "has_more" + ], + "properties": { + "has_more": { + "type": "boolean", + "description": "Indicates that there are more results that match the criteria." + }, + "streams": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StreamInfo" + }, + "description": "Matching streams.", + "maxItems": 1000 + } + } + }, + "Metric": { + "oneOf": [ + { + "type": "object", + "description": "Single named value.", + "required": [ + "scalar" + ], + "properties": { + "scalar": { + "$ref": "#/components/schemas/ScalarMetric", + "description": "Single named value." + } + } + }, + { + "type": "object", + "description": "Named series of `(timestamp, value)` points representing an accumulation over a specified interval.", + "required": [ + "accumulation" + ], + "properties": { + "accumulation": { + "$ref": "#/components/schemas/AccumulationMetric", + "description": "Named series of `(timestamp, value)` points representing an accumulation over a specified interval." + } + } + }, + { + "type": "object", + "description": "Named series of `(timestamp, value)` points each representing an instantaneous value.", + "required": [ + "gauge" + ], + "properties": { + "gauge": { + "$ref": "#/components/schemas/GaugeMetric", + "description": "Named series of `(timestamp, value)` points each representing an instantaneous value." + } + } + }, + { + "type": "object", + "description": "Set of string labels.", + "required": [ + "label" + ], + "properties": { + "label": { + "$ref": "#/components/schemas/LabelMetric", + "description": "Set of string labels." + } + } + } + ] + }, + "MetricSetResponse": { + "type": "object", + "required": [ + "values" + ], + "properties": { + "values": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Metric" + }, + "description": "Metrics comprising the set." + } + } + }, + "MetricUnit": { + "type": "string", + "enum": [ + "bytes", + "operations" + ] + }, + "Operation": { + "type": "string", + "enum": [ + "list-basins", + "create-basin", + "delete-basin", + "reconfigure-basin", + "get-basin-config", + "issue-access-token", + "revoke-access-token", + "list-access-tokens", + "list-streams", + "create-stream", + "delete-stream", + "get-stream-config", + "reconfigure-stream", + "check-tail", + "append", + "read", + "trim", + "fence", + "account-metrics", + "basin-metrics", + "stream-metrics" + ] + }, + "PermittedOperationGroups": { + "type": "object", + "properties": { + "account": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ReadWritePermissions", + "description": "Account-level access permissions." + } + ] + }, + "basin": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ReadWritePermissions", + "description": "Basin-level access permissions." + } + ] + }, + "stream": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ReadWritePermissions", + "description": "Stream-level access permissions." + } + ] + } + } + }, + "PingEventData": { + "type": "object", + "required": [ + "timestamp" + ], + "properties": { + "timestamp": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ReadBatch": { + "type": "object", + "required": [ + "records" + ], + "properties": { + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SequencedRecord" + }, + "description": "Records that are durably sequenced on the stream, retrieved based on the requested criteria.\nThis can only be empty in response to a unary read (i.e. not SSE), if the request cannot be satisfied without violating an explicit bound (`count`, `bytes`, or `until`)." + }, + "tail": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/StreamPosition", + "description": "Sequence number that will be assigned to the next record on the stream, and timestamp of the last record.\nThis will only be present when reading recent records." + } + ] + } + } + }, + "ReadEvent": { + "oneOf": [ + { + "type": "object", + "title": "batch", + "required": [ + "event", + "data", + "id" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/ReadBatch" + }, + "event": { + "type": "string", + "enum": [ + "batch" + ] + }, + "id": { + "type": "string", + "pattern": "^[0-9]+,[0-9]+,[0-9]+$" + } + } + }, + { + "type": "object", + "title": "error", + "required": [ + "event", + "data" + ], + "properties": { + "data": { + "type": "string" + }, + "event": { + "type": "string", + "enum": [ + "error" + ] + } + } + }, + { + "type": "object", + "title": "ping", + "required": [ + "event", + "data" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/PingEventData" + }, + "event": { + "type": "string", + "enum": [ + "ping" + ] + } + } + } + ] + }, + "ReadWritePermissions": { + "type": "object", + "properties": { + "read": { + "type": "boolean", + "description": "Read permission.", + "default": false + }, + "write": { + "type": "boolean", + "description": "Write permission.", + "default": false + } + } + }, + "RequestToken": { + "type": "string", + "maxLength": 36 + }, + "ResourceSet": { + "oneOf": [ + { + "type": "object", + "title": "exact", + "description": "Match only the resource with this exact name.\nUse an empty string to match no resources.", + "required": [ + "exact" + ], + "properties": { + "exact": { + "type": "string", + "description": "Match only the resource with this exact name.\nUse an empty string to match no resources." + } + } + }, + { + "type": "object", + "title": "prefix", + "description": "Match all resources that start with this prefix.\nUse an empty string to match all resource.", + "required": [ + "prefix" + ], + "properties": { + "prefix": { + "type": "string", + "description": "Match all resources that start with this prefix.\nUse an empty string to match all resource." + } + } + } + ] + }, + "RetentionPolicy": { + "oneOf": [ + { + "type": "object", + "description": "Age in seconds for automatic trimming of records older than this threshold.\nThis must be set to a value greater than 0 seconds.", + "required": [ + "age" + ], + "properties": { + "age": { + "type": "integer", + "format": "int64", + "description": "Age in seconds for automatic trimming of records older than this threshold.\nThis must be set to a value greater than 0 seconds.", + "minimum": 0 + } + } + }, + { + "type": "object", + "description": "Retain records unless explicitly trimmed.", + "required": [ + "infinite" + ], + "properties": { + "infinite": { + "$ref": "#/components/schemas/InfiniteRetention", + "description": "Retain records unless explicitly trimmed." + } + } + } + ] + }, + "ScalarMetric": { + "type": "object", + "required": [ + "name", + "unit", + "value" + ], + "properties": { + "name": { + "type": "string", + "description": "Metric name." + }, + "unit": { + "$ref": "#/components/schemas/MetricUnit", + "description": "Unit of the metric." + }, + "value": { + "type": "number", + "format": "double", + "description": "Metric value." + } + } + }, + "SequencedRecord": { + "type": "object", + "description": "Record that is durably sequenced on a stream.", + "required": [ + "seq_num", + "timestamp" + ], + "properties": { + "body": { + "type": "string", + "description": "Body of the record." + }, + "headers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Header" + }, + "description": "Series of name-value pairs for this record." + }, + "seq_num": { + "$ref": "#/components/schemas/u64", + "description": "Sequence number assigned by the service." + }, + "timestamp": { + "$ref": "#/components/schemas/u64", + "description": "Timestamp for this record." + } + } + }, + "StorageClass": { + "type": "string", + "enum": [ + "standard", + "express" + ] + }, + "StreamConfig": { + "type": "object", + "properties": { + "delete_on_empty": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeleteOnEmptyConfig", + "description": "Delete-on-empty configuration." + } + ] + }, + "retention_policy": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RetentionPolicy", + "description": "Retention policy for the stream.\nIf unspecified, the default is to retain records for 7 days." + } + ] + }, + "storage_class": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/StorageClass", + "description": "Storage class for recent writes." + } + ] + }, + "timestamping": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TimestampingConfig", + "description": "Timestamping behavior." + } + ] + } + } + }, + "StreamInfo": { + "type": "object", + "required": [ + "name", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time", + "description": "Creation time in RFC 3339 format." + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Deletion time in RFC 3339 format, if the stream is being deleted." + }, + "name": { + "$ref": "#/components/schemas/StreamNameStr", + "description": "Stream name." + } + } + }, + "StreamMetricSet": { + "type": "string", + "enum": [ + "storage" + ] + }, + "StreamNameStr": { + "type": "string", + "maxLength": 512, + "minLength": 1 + }, + "StreamPosition": { + "type": "object", + "description": "Position of a record in a stream.", + "required": [ + "seq_num", + "timestamp" + ], + "properties": { + "seq_num": { + "$ref": "#/components/schemas/u64", + "description": "Sequence number assigned by the service." + }, + "timestamp": { + "$ref": "#/components/schemas/u64", + "description": "Timestamp, which may be client-specified or assigned by the service.\nIf it is assigned by the service, it will represent milliseconds since Unix epoch." + } + } + }, + "StreamReconfiguration": { + "type": "object", + "properties": { + "delete_on_empty": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeleteOnEmptyReconfiguration", + "description": "Delete-on-empty configuration." + } + ] + }, + "retention_policy": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RetentionPolicy", + "description": "Retention policy for the stream.\nIf unspecified, the default is to retain records for 7 days." + } + ] + }, + "storage_class": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/StorageClass", + "description": "Storage class for recent writes." + } + ] + }, + "timestamping": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TimestampingReconfiguration", + "description": "Timestamping behavior." + } + ] + } + } + }, + "TailResponse": { + "type": "object", + "required": [ + "tail" + ], + "properties": { + "tail": { + "$ref": "#/components/schemas/StreamPosition", + "description": "Sequence number that will be assigned to the next record on the stream, and timestamp of the last record." + } + } + }, + "TimeseriesInterval": { + "type": "string", + "enum": [ + "minute", + "hour", + "day" + ] + }, + "TimestampingConfig": { + "type": "object", + "properties": { + "mode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TimestampingMode", + "description": "Timestamping mode for appends that influences how timestamps are handled." + } + ] + }, + "uncapped": { + "type": [ + "boolean", + "null" + ], + "description": "Allow client-specified timestamps to exceed the arrival time.\nIf this is `false` or not set, client timestamps will be capped at the arrival time." + } + } + }, + "TimestampingMode": { + "type": "string", + "enum": [ + "client-prefer", + "client-require", + "arrival" + ] + }, + "TimestampingReconfiguration": { + "type": "object", + "properties": { + "mode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TimestampingMode", + "description": "Timestamping mode for appends that influences how timestamps are handled." + } + ] + }, + "uncapped": { + "type": [ + "boolean", + "null" + ], + "description": "Allow client-specified timestamps to exceed the arrival time." + } + } + }, + "u64": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + "securitySchemes": { + "access_token": { + "type": "http", + "scheme": "bearer", + "description": "Bearer authentication header of the form `Bearer `, where `` is your access token." + } + } + }, + "security": [ + { + "access_token": [] + } + ], + "tags": [ + { + "name": "metrics", + "description": "Usage metrics and data." + }, + { + "name": "basins", + "description": "Manage basins" + }, + { + "name": "access-tokens", + "description": "Manage access tokens" + }, + { + "name": "streams", + "description": "Manage streams" + }, + { + "name": "records", + "description": "Manage records" + } + ] +} diff --git a/api/specs/s2/v1/s2.proto b/api/specs/s2/v1/s2.proto new file mode 100644 index 00000000..91a289f8 --- /dev/null +++ b/api/specs/s2/v1/s2.proto @@ -0,0 +1,81 @@ +syntax = "proto3"; + +package s2.v1; + +option java_multiple_files = true; +option java_package = "s2.v1"; + +// Position of a record in a stream. +message StreamPosition { + // Sequence number assigned by the service. + uint64 seq_num = 1; + // Timestamp, which may be user-specified or assigned by the service. + // If it is assigned by the service, it will represent milliseconds since Unix epoch. + uint64 timestamp = 2; +} + +// Headers add structured information to a record as name-value pairs. +message Header { + // Header name blob. + // The name cannot be empty, with the exception of an S2 command record. + bytes name = 1; + // Header value blob. + bytes value = 2; +} + +// Record to be appended to a stream. +message AppendRecord { + // Timestamp for this record. + // Precise semantics depend on the stream's `timestamping` config. + optional uint64 timestamp = 1; + // Series of name-value pairs for this record. + repeated Header headers = 2; + // Body of this record. + bytes body = 3; +} + +// Payload of an Append request message. +message AppendInput { + // Batch of records to append atomically, which must contain at least one record, and no more than 1000. + // The total size of a batch of records may not exceed 1MiB of metered bytes. + repeated AppendRecord records = 1; + // Enforce that the sequence number issued to the first record matches. + optional uint64 match_seq_num = 2; + // Enforce a fencing token which must have been previously set by a `fence` command record. + optional string fencing_token = 3; +} + +// Success response message to an Append request. +message AppendAck { + // Sequence number and timestamp of the first record that was appended. + StreamPosition start = 1; + // Sequence number of the last record that was appended + 1, and timestamp of the last record that was appended. + // The difference between `end.seq_num` and `start.seq_num` will be the number of records appended. + StreamPosition end = 2; + // Sequence number that will be assigned to the next record on the stream, and timestamp of the last record on the stream. + // This can be greater than the `end` position in case of concurrent appends. + StreamPosition tail = 3; +} + +// Record that is durably sequenced on a stream. +message SequencedRecord { + // Sequence number assigned to this record. + uint64 seq_num = 1; + // Timestamp for this record. + uint64 timestamp = 2; + // Series of name-value pairs for this record. + repeated Header headers = 3; + // Body of this record. + bytes body = 4; +} + +// Success response message to a Read request. +message ReadBatch { + // Records that are durably sequenced on the stream, retrieved based on the requested criteria. + // This can only be empty in response to a unary read if the request cannot be satisfied without violating an explicit bound (`count`, `bytes`, or `until`). + // In the context of a session, it can be empty as a heartbeat message. A heartbeat will be sent whenever a switch to following in real-time happens, and then at a randomized gap between 5 and 15 seconds if no records have become available. + repeated SequencedRecord records = 1; + // Sequence number that will be assigned to the next record on the stream, and timestamp of the last record. + // It will only be present when reading recent records. + optional StreamPosition tail = 2; +} diff --git a/api/specs/s2/v1alpha/s2.proto b/api/specs/s2/v1alpha/s2.proto new file mode 100644 index 00000000..27f38aab --- /dev/null +++ b/api/specs/s2/v1alpha/s2.proto @@ -0,0 +1,734 @@ +syntax = "proto3"; + +package s2.v1alpha; + +import "google/protobuf/field_mask.proto"; + +option java_multiple_files = true; +option java_package = "s2.v1alpha"; + +// Operate on an S2 account. +service AccountService { + // List basins. + rpc ListBasins(ListBasinsRequest) returns (ListBasinsResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } + + // Create a new basin. + // Provide a client request token with the `S2-Request-Token` header for idempotent retry behaviour. + rpc CreateBasin(CreateBasinRequest) returns (CreateBasinResponse) { + option idempotency_level = IDEMPOTENT; + } + + // Delete a basin. + // Basin deletion is asynchronous, and may take a few minutes to complete. + rpc DeleteBasin(DeleteBasinRequest) returns (DeleteBasinResponse) { + option idempotency_level = IDEMPOTENT; + } + + // Update basin configuration. + rpc ReconfigureBasin(ReconfigureBasinRequest) returns (ReconfigureBasinResponse) { + option idempotency_level = IDEMPOTENT; + } + + // Get basin configuration. + rpc GetBasinConfig(GetBasinConfigRequest) returns (GetBasinConfigResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } + + // Issue a new access token. + rpc IssueAccessToken(IssueAccessTokenRequest) returns (IssueAccessTokenResponse); + + // Revoke an access token. + rpc RevokeAccessToken(RevokeAccessTokenRequest) returns (RevokeAccessTokenResponse) { + option idempotency_level = IDEMPOTENT; + } + + // List access tokens. + rpc ListAccessTokens(ListAccessTokensRequest) returns (ListAccessTokensResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } +} + +// Operate on an S2 basin. +service BasinService { + // List streams. + rpc ListStreams(ListStreamsRequest) returns (ListStreamsResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } + + // Create a stream. + // Provide a client request token with the `S2-Request-Token` header for idempotent retry behaviour. + rpc CreateStream(CreateStreamRequest) returns (CreateStreamResponse) { + option idempotency_level = IDEMPOTENT; + } + + // Delete a stream. + // Stream deletion is asynchronous, and may take a few minutes to complete. + rpc DeleteStream(DeleteStreamRequest) returns (DeleteStreamResponse) { + option idempotency_level = IDEMPOTENT; + } + + // Get stream configuration. + rpc GetStreamConfig(GetStreamConfigRequest) returns (GetStreamConfigResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } + + // Update stream configuration. + rpc ReconfigureStream(ReconfigureStreamRequest) returns (ReconfigureStreamResponse) { + option idempotency_level = IDEMPOTENT; + } +} + +// Operate on an S2 stream. +service StreamService { + // Check the tail of the stream. + rpc CheckTail(CheckTailRequest) returns (CheckTailResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } + + // Append a batch of records to a stream. + rpc Append(AppendRequest) returns (AppendResponse); + + // Append batches of records to a stream continuously, while guaranteeing pipelined requests are processed in order. + // If any request fails, the session is terminated. + rpc AppendSession(stream AppendSessionRequest) returns (stream AppendSessionResponse); + + // Retrieve a batch of records from a stream. + rpc Read(ReadRequest) returns (ReadResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } + + // Retrieve batches of records from a stream continuously. + rpc ReadSession(ReadSessionRequest) returns (stream ReadSessionResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } +} + +/// AccountService /// + +// List basins request. +message ListBasinsRequest { + // List basin names that begin with this prefix. + string prefix = 1; + // Only return basins names that lexicographically start after this name. + // This can be the last basin name seen in a previous listing, to continue from there. + // It must be greater than or equal to the prefix if specified. + string start_after = 2; + // Number of results, up to a maximum of 1000. + optional uint64 limit = 3; +} + +// List basins response. +message ListBasinsResponse { + // Matching basins. + repeated BasinInfo basins = 1; + // If set, indicates there are more results that can be listed with `start_after`. + bool has_more = 2; +} + +// Create basin request. +message CreateBasinRequest { + // Basin name, which must be globally unique. + // The name must be between 8 and 48 characters, comprising lowercase letters, numbers and hyphens. + // It cannot begin or end with a hyphen. + string basin = 1; + // Basin configuration. + BasinConfig config = 2; + // Basin scope. + BasinScope scope = 3; +} + +// Basin scope. +enum BasinScope { + // Unspecified basin scope. + BASIN_SCOPE_UNSPECIFIED = 0; + // aws us-east-1 region. + BASIN_SCOPE_AWS_US_EAST_1 = 1; +} + +// Create basin response. +message CreateBasinResponse { + // Information about the newly created basin. + BasinInfo info = 1; +} + +// Delete basin request. +message DeleteBasinRequest { + // Name of the basin to delete. + string basin = 1; +} + +// Delete basin response. +message DeleteBasinResponse {} + +// Get basin configuration request. +message GetBasinConfigRequest { + // Basin name. + string basin = 1; +} + +// Get basin configuration response. +message GetBasinConfigResponse { + // Basin configuration. + BasinConfig config = 1; +} + +// Reconfigure basin request. +message ReconfigureBasinRequest { + // Basin name. + string basin = 1; + // Basin configuration. + BasinConfig config = 2; + // Specifies the pieces of configuration being updated. + // See https://protobuf.dev/reference/protobuf/google.protobuf/#field-mask + google.protobuf.FieldMask mask = 3; +} + +// Reconfigure basin response. +message ReconfigureBasinResponse { + // Basin configuration. + BasinConfig config = 1; +} + +// Issue access token request. +message IssueAccessTokenRequest { + // Access token information. + AccessTokenInfo info = 1; +} + +// API operations. +enum Operation { + // Unspecified operation. + OPERATION_UNSPECIFIED = 0; + // List basins. + OPERATION_LIST_BASINS = 1; + // Create a basin. + OPERATION_CREATE_BASIN = 2; + // Delete a basin. + OPERATION_DELETE_BASIN = 3; + // Update basin configuration. + OPERATION_RECONFIGURE_BASIN = 4; + // Get basin configuration. + OPERATION_GET_BASIN_CONFIG = 5; + // Issue an access token. + OPERATION_ISSUE_ACCESS_TOKEN = 6; + // Revoke an access token. + OPERATION_REVOKE_ACCESS_TOKEN = 7; + // List access tokens. + OPERATION_LIST_ACCESS_TOKENS = 8; + // List streams. + OPERATION_LIST_STREAMS = 9; + // Create a stream. + OPERATION_CREATE_STREAM = 10; + // Delete a stream. + OPERATION_DELETE_STREAM = 11; + // Get stream configuration. + OPERATION_GET_STREAM_CONFIG = 12; + // Update stream configuration. + OPERATION_RECONFIGURE_STREAM = 13; + // Check tail of a stream. + OPERATION_CHECK_TAIL = 14; + // Append records to a stream. + OPERATION_APPEND = 15; + // Read records from a stream. + OPERATION_READ = 16; + // Trim records up to a sequence number. + OPERATION_TRIM = 17; + // Set a fencing token for a stream. + OPERATION_FENCE = 18; + // Retrieve account-level metrics. + OPERATION_ACCOUNT_METRICS = 19; + // Retrieve basin-level metrics. + OPERATION_BASIN_METRICS = 20; + // Retrieve stream-level metrics. + OPERATION_STREAM_METRICS = 21; +} + +// Read/Write permissions. +message ReadWritePermissions { + // Read permission. + bool read = 1; + // Write permission. + bool write = 2; +} + +// Access permissions for a group. +message PermittedOperationGroups { + // Access permissions at account level. + ReadWritePermissions account = 1; + // Access permissions at basin level. + ReadWritePermissions basin = 2; + // Access permissions at stream level. + ReadWritePermissions stream = 3; +} + +// Revoke access token request. +message RevokeAccessTokenRequest { + // Token to revoke. + string id = 1; +} + +// Revoke access token response. +message RevokeAccessTokenResponse { + // Access token information. + AccessTokenInfo info = 1; +} + +// List access tokens request. +message ListAccessTokensRequest { + // List access tokens that begin with this prefix. + string prefix = 1; + // Only return access tokens that lexicographically start after this ID. + string start_after = 2; + // Number of results, up to a maximum of 1000. + optional uint64 limit = 3; +} + +// List access tokens response. +message ListAccessTokensResponse { + // Access tokens information. + repeated AccessTokenInfo access_tokens = 1; + // If set, indicates there are more results that can be listed with `start_after`. + bool has_more = 2; +} + +// Access token information. +message AccessTokenInfo { + // Access token ID. + // It must be unique to the account and between 1 and 96 characters. + string id = 1; + // Expiration time in seconds since Unix epoch. + // If not set, the expiration will be set to that of the requestor's token. + optional uint32 expires_at = 2; + // Namespace streams based on the configured stream-level scope, which must be a prefix. + // Stream name arguments will be automatically prefixed, and the prefix will be stripped + // when listing streams. + bool auto_prefix_streams = 3; + // Access token scope. + AccessTokenScope scope = 4; +} + +// Access token scope. +message AccessTokenScope { + // Basin names allowed. + ResourceSet basins = 1; + // Stream names allowed. + ResourceSet streams = 2; + // Token IDs allowed. + ResourceSet access_tokens = 3; + // Access permissions at operation group level. + PermittedOperationGroups op_groups = 4; + // Operations allowed for the token. + // A union of allowed operations and groups is used as an effective set of allowed operations. + repeated Operation ops = 5; +} + +// Set of named resources. +message ResourceSet { + // Matching rule. + oneof matching { + // Match only the resource with this exact name. + // Use an empty string to match no resources. + string exact = 1; + // Match all resources that start with this prefix. + // Use an empty string to match all resource. + string prefix = 2; + } +} + +// Issue access token response. +message IssueAccessTokenResponse { + // Created access token. + string access_token = 1; +} + +/// BasinService /// + +// Stream information. +message StreamInfo { + // Stream name. + string name = 1; + // Creation time in seconds since Unix epoch. + uint32 created_at = 2; + // Deletion time in seconds since Unix epoch, if the stream is being deleted. + optional uint32 deleted_at = 3; +} + +// List streams request. +message ListStreamsRequest { + // List stream names that begin with this prefix. + string prefix = 1; + // Only return stream names that lexicographically start after this name. + // This can be the last stream name seen in a previous listing, to continue from there. + // It must be greater than or equal to the prefix if specified. + string start_after = 2; + // Number of results, up to a maximum of 1000. + optional uint64 limit = 3; +} + +// List streams response. +message ListStreamsResponse { + // Matching streams. + repeated StreamInfo streams = 1; + // If set, indicates there are more results that can be listed with `start_after`. + bool has_more = 2; +} + +// Create stream request. +message CreateStreamRequest { + // Stream name, which must be unique within the basin. + // It can be an arbitrary string upto 512 characters. + // Backslash (`/`) is recommended as a delimiter for hierarchical naming. + string stream = 1; + // Configuration for the new stream. + StreamConfig config = 2; +} + +// Create stream response. +message CreateStreamResponse { + // Information about the newly created stream. + StreamInfo info = 1; +} + +// Delete stream request. +message DeleteStreamRequest { + // Stream name. + string stream = 1; +} + +// Delete stream response. +message DeleteStreamResponse {} + +// Get stream configuration request. +message GetStreamConfigRequest { + // Stream name. + string stream = 1; +} + +// Get stream configuration response. +message GetStreamConfigResponse { + // Stream configuration. + StreamConfig config = 1; +} + +// Reconfigure stream request. +message ReconfigureStreamRequest { + // Stream name. + string stream = 1; + // Stream configuration with updated values. + StreamConfig config = 2; + // Specifies the pieces of configuration being updated. + // See https://protobuf.dev/reference/protobuf/google.protobuf/#field-mask + google.protobuf.FieldMask mask = 3; +} + +// Reconfigure stream response. +message ReconfigureStreamResponse { + // Stream configuration. + StreamConfig config = 1; +} + +/// StreamService /// + +// Check tail request. +message CheckTailRequest { + // Stream name. + string stream = 1; +} + +// Check tail response. +message CheckTailResponse { + // Sequence number that will be assigned to the next record on the stream. + // It will be 0 for a stream that has not been written to. + uint64 next_seq_num = 1; + // Timestamp of the last durable record on the stream. + // It starts out as 0 for a new stream. + uint64 last_timestamp = 2; +} + +// Input for append requests. +message AppendInput { + // Stream name. Optional for subsequent requests in the session. + string stream = 1; + // Batch of records to append atomically, which must contain at least one record, and no more than 1000. + // The total size of a batch of records may not exceed 1MiB of metered bytes. + repeated AppendRecord records = 2; + // Enforce that the sequence number issued to the first record matches. + optional uint64 match_seq_num = 3; + // Enforce a fencing token which must have been previously set by a `fence` command record. + optional string fencing_token = 4; +} + +// Output from append response. +message AppendOutput { + // Sequence number of first record appended. + uint64 start_seq_num = 1; + // Timestamp of the first record appended. + uint64 start_timestamp = 4; + // Sequence number of last record appended + 1. + // `end_seq_num - start_seq_num` will be the number of records in the batch. + uint64 end_seq_num = 2; + // Timestamp of the last record appended. + uint64 end_timestamp = 5; + // Tail of the stream, i.e. sequence number that will be assigned to the next record. + // This can be greater than `end_seq_num` in case of concurrent appends. + uint64 next_seq_num = 3; + // Timestamp of the last durable record on the stream. + uint64 last_timestamp = 6; +} + +// Append request. +message AppendRequest { + // Request parameters for an append. + AppendInput input = 1; +} + +// Append response. +message AppendResponse { + // Response details for an append. + AppendOutput output = 1; +} + +// Append session request. +message AppendSessionRequest { + // Request parameters for an append. + AppendInput input = 1; +} + +// Append session response. +message AppendSessionResponse { + // Response details for an append. + AppendOutput output = 1; +} + +// Output of a read. +message ReadOutput { + // Batch of records or the next sequence number on the stream. + oneof output { + // Batch of records. + // It can only be empty when not in a session context, + // if the request cannot be satisfied without violating its limit. + SequencedRecordBatch batch = 1; + // Tail of the stream, i.e. sequence number that will be assigned to the next record. + // It will be returned if the requested starting position is greater than the tail, + // or only in case of a limited read, equal to it. + // It will also be returned if there are no records on the stream between the + // requested starting position and the tail. + uint64 next_seq_num = 3; + } + // first_seq_num. + reserved 2; +} + +// Read request. +message ReadRequest { + // Stream name. + string stream = 1; + // Starting position for records. + // Retrieved batches will start at the first record whose position is greater than or equal to it. + oneof start { + // Sequence number. + uint64 seq_num = 2; + // Timestamp. + uint64 timestamp = 4; + // Number of records before the tail, i.e. before the next sequence number. + uint64 tail_offset = 5; + } + // Limit how many records can be returned. + // This will get capped at the default limit, + // which is up to 1000 records or 1MiB of metered bytes. + ReadLimit limit = 3; + // Exclusive timestamp to read until. + // If provided, this is applied as an additional constraint on top of the `limit`, + // and will guarantee that all records returned have timestamps < the provided `until`. + optional uint64 until = 6; + // Start reading from the tail if the requested position is beyond it. + // Otherwise, the next sequence number will be returned. + bool clamp = 7; +} + +// Read response. +message ReadResponse { + // Response details for a read. + ReadOutput output = 1; +} + +// Limit how many records can be retrieved. +// If both count and bytes are specified, either limit may be hit. +message ReadLimit { + // Record count limit. + optional uint64 count = 1; + // Metered bytes limit. + optional uint64 bytes = 2; +} + +// Read session request. +message ReadSessionRequest { + // Stream name. + string stream = 1; + // Starting position for records. + // Retrieved batches will start at the first record whose position is greater than or equal to it. + oneof start { + // Sequence number. + uint64 seq_num = 2; + // Timestamp. + uint64 timestamp = 5; + // Number of records before the tail, i.e. the next sequence number. + uint64 tail_offset = 6; + } + // Limit on how many records can be returned. When a limit is specified, the session will be terminated as soon as + // the limit is met, or when the current tail of the stream is reached -- whichever occurs first. + // If no limit is specified, the session will remain open after catching up to the tail, and continue following + // in real-time as new messages are written to the stream. + ReadLimit limit = 3; + // Heartbeats can be enabled to monitor end-to-end session health. + // A heartbeat will be sent when the initial switch to following in real-time happens, + // as well as when no records are available at a randomized interval between 5 and 15 seconds. + bool heartbeats = 4; + // Exclusive timestamp to read until. + // If provided, this is applied as an additional constraint on top of the `limit`, + // and will guarantee that all records returned have timestamps < the provided `until`. + optional uint64 until = 7; + // Start reading from the tail if the requested position is beyond it. + // Otherwise, the next sequence number will be returned. + bool clamp = 8; +} + +// Read session response. +message ReadSessionResponse { + // Response details for a read. + // This will not be set in case of a heartbeat message. + optional ReadOutput output = 1; +} + +/// Common types /// + +// Storage class for recent writes. +enum StorageClass { + // Defaults to `STORAGE_CLASS_EXPRESS`. + STORAGE_CLASS_UNSPECIFIED = 0; + // Standard, which offers end-to-end latencies under 500 ms. + STORAGE_CLASS_STANDARD = 1; + // Express, which offers end-to-end latencies under 50 ms. + STORAGE_CLASS_EXPRESS = 2; +} + +// Timestamping mode. +// Note that arrival time is always in milliseconds since Unix epoch. +enum TimestampingMode { + // Defaults to `TIMESTAMPING_MODE_CLIENT_PREFER`. + TIMESTAMPING_MODE_UNSPECIFIED = 0; + // Prefer client-specified timestamp if present otherwise use arrival time. + TIMESTAMPING_MODE_CLIENT_PREFER = 1; + // Require a client-specified timestamp and reject the append if it is missing. + TIMESTAMPING_MODE_CLIENT_REQUIRE = 2; + // Use the arrival time and ignore any client-specified timestamp. + TIMESTAMPING_MODE_ARRIVAL = 3; +} + +// Stream configuration. +message StreamConfig { + message Timestamping { + // Timestamping mode for appends that influences how timestamps are handled. + TimestampingMode mode = 1; + // Allow client-specified timestamps to exceed the arrival time. + // If this is false or not set, client timestamps will be capped at the arrival time. + optional bool uncapped = 2; + } + message DeleteOnEmpty { + // Minimum age in seconds before an empty stream can be deleted. + // Set to 0 (default) to disable (don't delete automatically). + uint64 min_age_secs = 1; + } + message InfiniteRetention {} + // Storage class for recent writes. + StorageClass storage_class = 1; + // Retention policy for the stream. + // If unspecified, the default is to retain records for 7 days. + oneof retention_policy { + // Age in seconds for automatic trimming of records older than this threshold. + // This must be set to a value greater than 0 seconds. + uint64 age = 2; + // Retain records unless explicitly trimmed. + InfiniteRetention infinite = 7; + } + // Replaced with `timestamping`. + reserved 3, 4; + // Timestamping behavior. + Timestamping timestamping = 5; + // Delete-on-empty configuration. + DeleteOnEmpty delete_on_empty = 6; +} + +// Basin configuration. +message BasinConfig { + // Default stream configuration. + StreamConfig default_stream_config = 1; + // Create stream on append if it doesn't exist, + // using the default stream configuration. + bool create_stream_on_append = 2; + // Create stream on read if it doesn't exist, + // using the default stream configuration. + bool create_stream_on_read = 3; +} + +// Current state of the basin. +enum BasinState { + // Unspecified. + BASIN_STATE_UNSPECIFIED = 0; + // Basin is active. + BASIN_STATE_ACTIVE = 1; + // Basin is being created. + BASIN_STATE_CREATING = 2; + // Basin is being deleted. + BASIN_STATE_DELETING = 3; +} + +// Basin information. +message BasinInfo { + // Basin name. + string name = 1; + // Basin scope. + BasinScope scope = 5; + // Basin state. + BasinState state = 4; +} + +// Headers add structured information to a record as name-value pairs. +message Header { + // Header name blob. + // The name cannot be empty, with the exception of an S2 command record. + bytes name = 1; + // Header value blob. + bytes value = 2; +} + +// Record to be appended to a stream. +message AppendRecord { + // Timestamp for this record. + // Precise semantics depend on the stream's `timestamping` config. + optional uint64 timestamp = 3; + // Series of name-value pairs for this record. + repeated Header headers = 1; + // Body of this record. + bytes body = 2; +} + +// Record retrieved from a stream. +message SequencedRecord { + // Sequence number assigned to this record. + uint64 seq_num = 1; + // Timestamp for this record. + uint64 timestamp = 4; + // Series of name-value pairs for this record. + repeated Header headers = 2; + // Body of this record. + bytes body = 3; +} + +// A batch of sequenced records. +message SequencedRecordBatch { + // Batch of sequenced records. + repeated SequencedRecord records = 1; +} diff --git a/api/src/v1/stream/s2s.rs b/api/src/v1/stream/s2s.rs index 316a38ad..8c28b601 100644 --- a/api/src/v1/stream/s2s.rs +++ b/api/src/v1/stream/s2s.rs @@ -8,7 +8,6 @@ use bytes::{Buf, BufMut, Bytes, BytesMut}; use enum_ordinalize::Ordinalize; use flate2::{Compression, read::GzDecoder, write::GzEncoder}; use futures::Stream; -use zstd::zstd_safe::WriteBuf; /* REGULAR MESSAGE: @@ -124,14 +123,14 @@ impl CompressedData { match self.compression { CompressionAlgorithm::None => Ok(self.payload), CompressionAlgorithm::Gzip => { - let mut decoder = GzDecoder::new(self.payload.as_slice()); + let mut decoder = GzDecoder::new(&self.payload[..]); let mut buf = Vec::with_capacity(self.payload.len() * 2); decoder.read_to_end(&mut buf)?; Ok(Bytes::from(buf.into_boxed_slice())) } CompressionAlgorithm::Zstd => { let mut buf = Vec::with_capacity(self.payload.len() * 2); - zstd::stream::copy_decode(self.payload.as_slice(), &mut buf)?; + zstd::stream::copy_decode(&self.payload[..], &mut buf)?; Ok(Bytes::from(buf.into_boxed_slice())) } } diff --git a/lite/Cargo.toml b/lite/Cargo.toml index 42bb03a6..5cfc4cec 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -9,6 +9,16 @@ homepage.workspace = true keywords = ["s2", "durable", "streams", "object", "storage"] categories = ["database-implementations"] +[[bin]] +name = "openapi" +required-features = ["utoipa"] + +[[bin]] +name = "server" + +[features] +utoipa = ["dep:utoipa", "s2-api/utoipa", "s2-common/utoipa"] + [dependencies] async-stream = { workspace = true } async-trait = { workspace = true } @@ -44,13 +54,8 @@ tokio-util = { workspace = true } tower-http = { workspace = true, features = ["trace"] } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } +utoipa = { version = "5.4", optional = true, features = ["time"] } [dev-dependencies] proptest = { workspace = true } uuid = { workspace = true, features = ["v4"] } - -[profile.dev] -panic = "abort" - -[profile.release] -panic = "abort" diff --git a/lite/src/bin/openapi.rs b/lite/src/bin/openapi.rs new file mode 100644 index 00000000..fce3cb33 --- /dev/null +++ b/lite/src/bin/openapi.rs @@ -0,0 +1,156 @@ +use s2_api::{ + data::Format, + v1::metrics::{AccountMetricSet, BasinMetricSet, StreamMetricSet}, +}; +use s2_common::types::resources::RequestToken; +use s2_lite::handlers::v1::{ + access_tokens::{ + __path_issue_access_token, __path_list_access_tokens, __path_revoke_access_token, + }, + basins::{ + __path_create_basin, __path_create_or_reconfigure_basin, __path_delete_basin, + __path_get_basin_config, __path_list_basins, __path_reconfigure_basin, + }, + metrics::{__path_account_metrics, __path_basin_metrics, __path_stream_metrics}, + paths::{self, cloud_endpoints}, + records::{__path_append, __path_check_tail, __path_read}, + streams::{ + __path_create_or_reconfigure_stream, __path_create_stream, __path_delete_stream, + __path_get_stream_config, __path_list_streams, __path_reconfigure_stream, + }, +}; +use utoipa::{ + Modify, OpenApi, + openapi::{ + path::Operation, + security::{Http, HttpAuthScheme, SecurityScheme}, + }, +}; + +#[derive(OpenApi)] +#[openapi( + info( + title = "S2, the durable streams API", + description = "Streams as a cloud storage primitive.", + version = "1.0.0", + license(name = "MIT"), + terms_of_service = "https://s2.dev/terms", + contact(email = "support@s2.dev") + ), + servers( + (url = cloud_endpoints::ACCOUNT) + ), + modifiers(&SecurityAddon, &PathLevelServersAddon), + security(("access_token" = [])), + tags( + (name = paths::metrics::TAG, description = paths::metrics::DESCRIPTION), + (name = paths::basins::TAG, description = paths::basins::DESCRIPTION), + (name = paths::access_tokens::TAG, description = paths::access_tokens::DESCRIPTION), + (name = paths::streams::TAG, description = paths::streams::DESCRIPTION), + (name = paths::streams::records::TAG, description = paths::streams::records::DESCRIPTION), + ), + paths( + // Record ops + append, + read, + check_tail, + // Stream ops + list_streams, + create_stream, + get_stream_config, + create_or_reconfigure_stream, + delete_stream, + reconfigure_stream, + // Basin ops + list_basins, + create_basin, + get_basin_config, + create_or_reconfigure_basin, + delete_basin, + reconfigure_basin, + // Access token ops + list_access_tokens, + issue_access_token, + revoke_access_token, + // Metrics ops + account_metrics, + basin_metrics, + stream_metrics, + ), + components(schemas(Format, RequestToken, AccountMetricSet, BasinMetricSet, StreamMetricSet)) +)] +pub struct ApiDoc; + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "access_token", + SecurityScheme::Http( + Http::builder() + .scheme(HttpAuthScheme::Bearer) + .description(Some(concat!( + "Bearer authentication header of the form `Bearer `, ", + "where `` is your access token." + ))) + .build(), + ), + ) + } + } +} + +struct PathLevelServersAddon; + +impl PathLevelServersAddon { + fn get_operations_mut(path_item: &mut utoipa::openapi::PathItem) -> Vec<&mut Operation> { + [ + path_item.get.as_mut(), + path_item.put.as_mut(), + path_item.post.as_mut(), + path_item.delete.as_mut(), + path_item.options.as_mut(), + path_item.head.as_mut(), + path_item.patch.as_mut(), + path_item.trace.as_mut(), + ] + .into_iter() + .flatten() + .collect() + } +} + +impl Modify for PathLevelServersAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + for path_item in openapi.paths.paths.values_mut() { + let operations = Self::get_operations_mut(path_item); + + if operations.is_empty() { + continue; + } + + let all_servers: Vec<_> = operations.iter().map(|op| op.servers.as_ref()).collect(); + + let first_servers = all_servers.first().copied().flatten(); + let all_same = all_servers + .iter() + .all(|s| s.as_ref() == first_servers.as_ref()); + + if all_same && let Some(servers) = first_servers.cloned() { + path_item.servers = Some(servers); + + for op in Self::get_operations_mut(path_item) { + op.servers = None; + } + } + } + } +} + +fn main() -> eyre::Result<()> { + let json = ApiDoc::openapi().to_pretty_json()?; + println!("{json}"); + Ok(()) +} diff --git a/lite/src/handlers/mod.rs b/lite/src/handlers/mod.rs index 55e505d6..96e6e789 100644 --- a/lite/src/handlers/mod.rs +++ b/lite/src/handlers/mod.rs @@ -1,4 +1,4 @@ -mod v1; +pub mod v1; async fn metrics() -> impl axum::response::IntoResponse { let body = crate::metrics::gather(); diff --git a/lite/src/handlers/v1/access_tokens.rs b/lite/src/handlers/v1/access_tokens.rs new file mode 100644 index 00000000..004e240a --- /dev/null +++ b/lite/src/handlers/v1/access_tokens.rs @@ -0,0 +1,88 @@ +use axum::extract::{FromRequest, Path, Query, State}; +use http::StatusCode; +use s2_api::{data::Json, v1 as v1t}; +use s2_common::types::access::AccessTokenId; + +use crate::{backend::Backend, handlers::v1::error::ServiceError}; + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct ListArgs { + #[from_request(via(Query))] + _request: v1t::access::ListAccessTokensRequest, +} + +/// List access tokens. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::access_tokens::LIST, + tag = super::paths::access_tokens::TAG, + responses( + (status = StatusCode::OK, body = v1t::access::ListAccessTokensResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::access::ListAccessTokensRequest), +))] +pub async fn list_access_tokens( + State(_backend): State, + ListArgs { .. }: ListArgs, +) -> Result, ServiceError> { + Err(ServiceError::NotImplemented) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct IssueArgs { + #[from_request(via(Json))] + _request: v1t::access::AccessTokenInfo, +} + +/// Issue a new access token. +#[cfg_attr(feature = "utoipa", utoipa::path( + post, + path = super::paths::access_tokens::ISSUE, + tag = super::paths::access_tokens::TAG, + request_body = v1t::access::AccessTokenInfo, + responses( + (status = StatusCode::CREATED, body = v1t::access::IssueAccessTokenResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + ), +))] +pub async fn issue_access_token( + State(_backend): State, + IssueArgs { .. }: IssueArgs, +) -> Result<(StatusCode, Json), ServiceError> { + Err(ServiceError::NotImplemented) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct RevokeArgs { + #[from_request(via(Path))] + _id: AccessTokenId, +} + +/// Revoke an access token. +#[cfg_attr(feature = "utoipa", utoipa::path( + delete, + path = super::paths::access_tokens::REVOKE, + tag = super::paths::access_tokens::TAG, + responses( + (status = StatusCode::NO_CONTENT), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::AccessTokenIdPathSegment), +))] +pub async fn revoke_access_token( + State(_backend): State, + RevokeArgs { .. }: RevokeArgs, +) -> Result { + Err(ServiceError::NotImplemented) +} diff --git a/lite/src/handlers/v1/basins.rs b/lite/src/handlers/v1/basins.rs index 08168913..b5affc62 100644 --- a/lite/src/handlers/v1/basins.rs +++ b/lite/src/handlers/v1/basins.rs @@ -22,7 +22,20 @@ pub struct ListArgs { request: v1t::basin::ListBasinsRequest, } -pub async fn list( +/// List basins. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::basins::LIST, + tag = super::paths::basins::TAG, + responses( + (status = StatusCode::OK, body = v1t::basin::ListBasinsResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::basin::ListBasinsRequest), +))] +pub async fn list_basins( State(backend): State, ListArgs { request }: ListArgs, ) -> Result, ServiceError> { @@ -42,7 +55,23 @@ pub struct CreateArgs { request: v1t::basin::CreateBasinRequest, } -pub async fn create( +/// Create a basin. +#[cfg_attr(feature = "utoipa", utoipa::path( + post, + path = super::paths::basins::CREATE, + tag = super::paths::basins::TAG, + params(v1t::S2RequestTokenHeader), + request_body = v1t::basin::CreateBasinRequest, + responses( + (status = StatusCode::OK, body = v1t::basin::BasinInfo), + (status = StatusCode::CREATED, body = v1t::basin::BasinInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), +))] +pub async fn create_basin( State(backend): State, CreateArgs { request_token: HeaderOpt(request_token), @@ -67,7 +96,21 @@ pub struct GetConfigArgs { basin: BasinName, } -pub async fn get_config( +/// Get basin configuration. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::basins::GET_CONFIG, + tag = super::paths::basins::TAG, + responses( + (status = StatusCode::OK, body = v1t::config::BasinConfig), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::BasinNamePathSegment), +))] +pub async fn get_basin_config( State(backend): State, GetConfigArgs { basin }: GetConfigArgs, ) -> Result, ServiceError> { @@ -83,7 +126,21 @@ pub struct CreateOrReconfigureArgs { request: JsonOpt, } -pub async fn create_or_reconfigure( +/// Create or reconfigure a basin. +#[cfg_attr(feature = "utoipa", utoipa::path( + put, + path = super::paths::basins::CREATE_OR_RECONFIGURE, + tag = super::paths::basins::TAG, + request_body = Option, + params(v1t::BasinNamePathSegment), + responses( + (status = StatusCode::OK, body = v1t::basin::BasinInfo), + (status = StatusCode::CREATED, body = v1t::basin::BasinInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), +))] +pub async fn create_or_reconfigure_basin( State(backend): State, CreateOrReconfigureArgs { basin, @@ -113,7 +170,21 @@ pub struct DeleteArgs { basin: BasinName, } -pub async fn delete( +/// Delete a basin. +#[cfg_attr(feature = "utoipa", utoipa::path( + delete, + path = super::paths::basins::DELETE, + tag = super::paths::basins::TAG, + responses( + (status = StatusCode::ACCEPTED), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::BasinNamePathSegment), +))] +pub async fn delete_basin( State(backend): State, DeleteArgs { basin }: DeleteArgs, ) -> Result { @@ -130,7 +201,22 @@ pub struct ReconfigureArgs { reconfiguration: v1t::config::BasinReconfiguration, } -pub async fn reconfigure( +/// Reconfigure a basin. +#[cfg_attr(feature = "utoipa", utoipa::path( + patch, + path = super::paths::basins::RECONFIGURE, + tag = super::paths::basins::TAG, + request_body = v1t::config::BasinReconfiguration, + responses( + (status = StatusCode::OK, body = v1t::config::BasinConfig), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::BasinNamePathSegment), +))] +pub async fn reconfigure_basin( State(backend): State, ReconfigureArgs { basin, diff --git a/lite/src/handlers/v1/error.rs b/lite/src/handlers/v1/error.rs index 2a2b749a..ad0b94e6 100644 --- a/lite/src/handlers/v1/error.rs +++ b/lite/src/handlers/v1/error.rs @@ -60,6 +60,8 @@ pub enum ServiceError { Append(#[from] AppendError), #[error(transparent)] Read(#[from] ReadError), + #[error("Not implemented")] + NotImplemented, } impl From for ServiceError { @@ -243,6 +245,9 @@ impl ServiceError { tail: tail.0.into(), }), }, + ServiceError::NotImplemented => { + standard(ErrorCode::PermissionDenied, "Not implemented".to_string()) + } } } } diff --git a/lite/src/handlers/v1/metrics.rs b/lite/src/handlers/v1/metrics.rs new file mode 100644 index 00000000..bac44e64 --- /dev/null +++ b/lite/src/handlers/v1/metrics.rs @@ -0,0 +1,90 @@ +use axum::extract::{FromRequest, Path, Query, State}; +use s2_api::{data::Json, v1 as v1t}; +use s2_common::types::{basin::BasinName, stream::StreamName}; + +use crate::{backend::Backend, handlers::v1::error::ServiceError}; + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct AccountArgs { + #[from_request(via(Query))] + _request: v1t::metrics::AccountMetricSetRequest, +} + +/// Account-level metrics. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::metrics::ACCOUNT, + tag = super::paths::metrics::TAG, + responses( + (status = StatusCode::OK, body = v1t::metrics::MetricSetResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::metrics::AccountMetricSetRequest) +))] +pub async fn account_metrics( + State(_backend): State, + AccountArgs { .. }: AccountArgs, +) -> Result, ServiceError> { + Err(ServiceError::NotImplemented) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct BasinArgs { + #[from_request(via(Path))] + _basin: BasinName, + #[from_request(via(Query))] + _request: v1t::metrics::BasinMetricSetRequest, +} + +/// Basin-level metrics. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::metrics::BASIN, + tag = super::paths::metrics::TAG, + responses( + (status = StatusCode::OK, body = v1t::metrics::MetricSetResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::metrics::BasinMetricSetRequest, v1t::BasinNamePathSegment), +))] +pub async fn basin_metrics( + State(_backend): State, + BasinArgs { .. }: BasinArgs, +) -> Result, ServiceError> { + Err(ServiceError::NotImplemented) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct StreamArgs { + #[from_request(via(Path))] + _basin_and_stream: (BasinName, StreamName), + #[from_request(via(Query))] + _request: v1t::metrics::StreamMetricSetRequest, +} + +/// Stream-level metrics. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::metrics::STREAM, + tag = super::paths::metrics::TAG, + responses( + (status = StatusCode::OK, body = v1t::metrics::MetricSetResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::metrics::StreamMetricSetRequest, v1t::BasinNamePathSegment, v1t::StreamNamePathSegment), +))] +pub async fn stream_metrics( + State(_backend): State, + StreamArgs { .. }: StreamArgs, +) -> Result, ServiceError> { + Err(ServiceError::NotImplemented) +} diff --git a/lite/src/handlers/v1/mod.rs b/lite/src/handlers/v1/mod.rs index 80b0ea70..945a3bec 100644 --- a/lite/src/handlers/v1/mod.rs +++ b/lite/src/handlers/v1/mod.rs @@ -1,7 +1,10 @@ -mod basins; +pub mod access_tokens; +pub mod basins; mod error; -mod records; -mod streams; +pub mod metrics; +pub mod paths; +pub mod records; +pub mod streams; const MAX_UNARY_READ_WAIT: std::time::Duration = std::time::Duration::from_secs(60); @@ -12,22 +15,51 @@ pub fn router(backend: crate::backend::Backend) -> axum::Router { axum::Router::new() // Basin ops - .route("/basins", get(basins::list)) - .route("/basins", post(basins::create)) - .route("/basins/{basin}", get(basins::get_config)) - .route("/basins/{basin}", put(basins::create_or_reconfigure)) - .route("/basins/{basin}", delete(basins::delete)) - .route("/basins/{basin}", patch(basins::reconfigure)) + .route(paths::basins::LIST, get(basins::list_basins)) + .route(paths::basins::CREATE, post(basins::create_basin)) + .route(paths::basins::GET_CONFIG, get(basins::get_basin_config)) + .route( + paths::basins::CREATE_OR_RECONFIGURE, + put(basins::create_or_reconfigure_basin), + ) + .route(paths::basins::DELETE, delete(basins::delete_basin)) + .route(paths::basins::RECONFIGURE, patch(basins::reconfigure_basin)) // Stream ops - .route("/streams", get(streams::list)) - .route("/streams", post(streams::create)) - .route("/streams/{stream}", get(streams::get_config)) - .route("/streams/{stream}", put(streams::create_or_reconfigure)) - .route("/streams/{stream}", delete(streams::delete)) - .route("/streams/{stream}", patch(streams::reconfigure)) + .route(paths::streams::LIST, get(streams::list_streams)) + .route(paths::streams::CREATE, post(streams::create_stream)) + .route(paths::streams::GET_CONFIG, get(streams::get_stream_config)) + .route( + paths::streams::CREATE_OR_RECONFIGURE, + put(streams::create_or_reconfigure_stream), + ) + .route(paths::streams::DELETE, delete(streams::delete_stream)) + .route( + paths::streams::RECONFIGURE, + patch(streams::reconfigure_stream), + ) // Record ops - .route("/streams/{stream}/records/tail", get(records::check_tail)) - .route("/streams/{stream}/records", get(records::read)) - .route("/streams/{stream}/records", post(records::append)) + .route( + paths::streams::records::CHECK_TAIL, + get(records::check_tail), + ) + .route(paths::streams::records::READ, get(records::read)) + .route(paths::streams::records::APPEND, post(records::append)) + // Access token ops + .route( + paths::access_tokens::LIST, + get(access_tokens::list_access_tokens), + ) + .route( + paths::access_tokens::ISSUE, + post(access_tokens::issue_access_token), + ) + .route( + paths::access_tokens::REVOKE, + delete(access_tokens::revoke_access_token), + ) + // Metric ops + .route(paths::metrics::ACCOUNT, get(metrics::account_metrics)) + .route(paths::metrics::BASIN, get(metrics::basin_metrics)) + .route(paths::metrics::STREAM, get(metrics::stream_metrics)) .with_state(backend) } diff --git a/lite/src/handlers/v1/paths.rs b/lite/src/handlers/v1/paths.rs new file mode 100644 index 00000000..d0f59577 --- /dev/null +++ b/lite/src/handlers/v1/paths.rs @@ -0,0 +1,56 @@ +pub mod basins { + pub const TAG: &str = "basins"; + pub const DESCRIPTION: &str = "Manage basins"; + + pub const LIST: &str = "/basins"; + pub const CREATE: &str = "/basins"; + pub const CREATE_OR_RECONFIGURE: &str = "/basins/{basin}"; + pub const DELETE: &str = "/basins/{basin}"; + pub const GET_CONFIG: &str = "/basins/{basin}"; + pub const RECONFIGURE: &str = "/basins/{basin}"; + pub const METRICS: &str = "/basins/metrics"; +} + +pub mod metrics { + pub const TAG: &str = "metrics"; + pub const DESCRIPTION: &str = "Usage metrics and data."; + + pub const ACCOUNT: &str = "/metrics"; + pub const BASIN: &str = "/metrics/{basin}"; + pub const STREAM: &str = "/metrics/{basin}/{stream}"; +} + +pub mod access_tokens { + pub const TAG: &str = "access-tokens"; + pub const DESCRIPTION: &str = "Manage access tokens"; + + pub const LIST: &str = "/access-tokens"; + pub const ISSUE: &str = "/access-tokens"; + pub const REVOKE: &str = "/access-tokens/{id}"; +} + +pub mod streams { + pub const TAG: &str = "streams"; + pub const DESCRIPTION: &str = "Manage streams"; + + pub const LIST: &str = "/streams"; + pub const CREATE: &str = "/streams"; + pub const CREATE_OR_RECONFIGURE: &str = "/streams/{stream}"; + pub const DELETE: &str = "/streams/{stream}"; + pub const GET_CONFIG: &str = "/streams/{stream}"; + pub const RECONFIGURE: &str = "/streams/{stream}"; + + pub mod records { + pub const TAG: &str = "records"; + pub const DESCRIPTION: &str = "Manage records"; + + pub const CHECK_TAIL: &str = "/streams/{stream}/records/tail"; + pub const READ: &str = "/streams/{stream}/records"; + pub const APPEND: &str = "/streams/{stream}/records"; + } +} + +pub mod cloud_endpoints { + pub const ACCOUNT: &str = "https://aws.s2.dev/v1"; + pub const BASIN: &str = "https://{basin}.b.aws.s2.dev/v1"; +} diff --git a/lite/src/handlers/v1/records.rs b/lite/src/handlers/v1/records.rs index 2e7ec3c2..b690a249 100644 --- a/lite/src/handlers/v1/records.rs +++ b/lite/src/handlers/v1/records.rs @@ -37,6 +37,28 @@ pub struct CheckTailArgs { stream: StreamName, } +/// Check the tail. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::streams::records::CHECK_TAIL, + tag = super::paths::streams::records::TAG, + responses( + (status = StatusCode::OK, body = v1t::stream::TailResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::StreamNamePathSegment), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] pub async fn check_tail( State(backend): State, CheckTailArgs { basin, stream }: CheckTailArgs, @@ -59,6 +81,37 @@ pub struct ReadArgs { request: v1t::stream::ReadRequest, } +/// Read records. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::streams::records::READ, + tag = super::paths::streams::records::TAG, + responses( + (status = StatusCode::OK, content( + (v1t::stream::ReadBatch = "application/json"), + (v1t::stream::sse::ReadEvent = "text/event-stream"), + )), + (status = StatusCode::RANGE_NOT_SATISFIABLE, body = v1t::stream::TailResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params( + v1t::StreamNamePathSegment, + s2_api::data::S2FormatHeader, + v1t::stream::ReadStart, + v1t::stream::ReadEnd, + ), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] pub async fn read( State(backend): State, ReadArgs { @@ -222,6 +275,30 @@ pub struct AppendArgs { request: v1t::stream::AppendRequest, } +/// Append records. +#[cfg_attr(feature = "utoipa", utoipa::path( + post, + path = super::paths::streams::records::APPEND, + tag = super::paths::streams::records::TAG, + request_body(content = v1t::stream::AppendInput, content_type = "application/json"), + responses( + (status = StatusCode::OK, body = v1t::stream::AppendAck), + (status = StatusCode::PRECONDITION_FAILED, body = v1t::stream::AppendConditionFailed), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::StreamNamePathSegment, s2_api::data::S2FormatHeader), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] pub async fn append( State(backend): State, AppendArgs { diff --git a/lite/src/handlers/v1/streams.rs b/lite/src/handlers/v1/streams.rs index 017694e6..f0dc09d3 100644 --- a/lite/src/handlers/v1/streams.rs +++ b/lite/src/handlers/v1/streams.rs @@ -25,7 +25,28 @@ pub struct ListArgs { request: v1t::stream::ListStreamsRequest, } -pub async fn list( +/// List streams. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::streams::LIST, + tag = super::paths::streams::TAG, + responses( + (status = StatusCode::OK, body = v1t::stream::ListStreamsResponse), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::stream::ListStreamsRequest), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn list_streams( State(backend): State, ListArgs { basin, request }: ListArgs, ) -> Result, ServiceError> { @@ -47,7 +68,30 @@ pub struct CreateArgs { request: v1t::stream::CreateStreamRequest, } -pub async fn create( +/// Create a stream. +#[cfg_attr(feature = "utoipa", utoipa::path( + post, + path = super::paths::streams::CREATE, + tag = super::paths::streams::TAG, + params(v1t::S2RequestTokenHeader), + request_body = v1t::stream::CreateStreamRequest, + responses( + (status = StatusCode::CREATED, body = v1t::stream::StreamInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn create_stream( State(backend): State, CreateArgs { request_token: HeaderOpt(request_token), @@ -80,7 +124,29 @@ pub struct GetConfigArgs { stream: StreamName, } -pub async fn get_config( +/// Get stream configuration. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::streams::GET_CONFIG, + tag = super::paths::streams::TAG, + responses( + (status = StatusCode::OK, body = v1t::config::StreamConfig), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::StreamNamePathSegment), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn get_stream_config( State(backend): State, GetConfigArgs { basin, stream }: GetConfigArgs, ) -> Result, ServiceError> { @@ -100,7 +166,31 @@ pub struct CreateOrReconfigureArgs { config: JsonOpt, } -pub async fn create_or_reconfigure( +/// Create or reconfigure a stream. +#[cfg_attr(feature = "utoipa", utoipa::path( + put, + path = super::paths::streams::CREATE_OR_RECONFIGURE, + tag = super::paths::streams::TAG, + request_body = Option, + params(v1t::StreamNamePathSegment), + responses( + (status = StatusCode::OK, body = v1t::stream::StreamInfo), + (status = StatusCode::CREATED, body = v1t::stream::StreamInfo), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn create_or_reconfigure_stream( State(backend): State, CreateOrReconfigureArgs { basin, @@ -132,7 +222,28 @@ pub struct DeleteArgs { stream: StreamName, } -pub async fn delete( +/// Delete a stream. +#[cfg_attr(feature = "utoipa", utoipa::path( + delete, + path = super::paths::streams::DELETE, + tag = super::paths::streams::TAG, + responses( + (status = StatusCode::ACCEPTED), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::StreamNamePathSegment), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn delete_stream( State(backend): State, DeleteArgs { basin, stream }: DeleteArgs, ) -> Result { @@ -151,7 +262,30 @@ pub struct ReconfigureArgs { reconfiguration: v1t::config::StreamReconfiguration, } -pub async fn reconfigure( +/// Reconfigure a stream. +#[cfg_attr(feature = "utoipa", utoipa::path( + patch, + path = super::paths::streams::RECONFIGURE, + tag = super::paths::streams::TAG, + request_body = v1t::config::StreamReconfiguration, + responses( + (status = StatusCode::OK, body = v1t::config::StreamConfig), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::StreamNamePathSegment), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn reconfigure_stream( State(backend): State, ReconfigureArgs { basin, From 0c7d982ca5d0623b276e77368326574353f9059a Mon Sep 17 00:00:00 2001 From: shikhar Date: Tue, 13 Jan 2026 15:47:43 -0500 Subject: [PATCH 052/700] sl8 update to 0.10.1 --- Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30e56b8c..07dbfa14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3205,9 +3205,9 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.8.13" +version = "0.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2e88acca7157d83d789836a3987dafc12bc3d88a050e54b8fe9ea4aaa29d20" +checksum = "360b333c61ae24e5af3ae7c8660bd6b21ccd8200dbbc5d33c2454421e85b9c69" dependencies = [ "bytecheck", "bytes", @@ -3224,9 +3224,9 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.8.13" +version = "0.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6dffea3c91fa91a3c0fc8a061b0e27fef25c6304728038a6d6bcb1c58ba9bd" +checksum = "7c02f8cdd12b307ab69fe0acf4cd2249c7460eb89dce64a0febadf934ebb6a9e" dependencies = [ "proc-macro2", "quote", @@ -3702,9 +3702,9 @@ checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "slatedb" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bab45c2940c214e8f9e80d100f74b90cc3f1cc3b62b9caef0369060612c83b9" +checksum = "588e9ae32019696205a05e54e4456486e8d11b69c72662739be853736833b3dd" dependencies = [ "anyhow", "async-trait", From c37c2294ab455df1dd712536438d84abc6f85344 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Tue, 13 Jan 2026 18:48:27 -0500 Subject: [PATCH 053/700] Deconstruct `UnavailableError` (#54) --- lite/src/backend/core.rs | 4 +--- lite/src/backend/error.rs | 37 +++++++++++++++++++-------------- lite/src/backend/read.rs | 4 ++-- lite/src/backend/streamer.rs | 39 ++++++++++++++++++----------------- lite/src/backend/streams.rs | 4 ++-- lite/src/bin/server.rs | 9 ++++---- lite/src/handlers/v1/error.rs | 30 ++++++++++++++++++--------- 7 files changed, 70 insertions(+), 57 deletions(-) diff --git a/lite/src/backend/core.rs b/lite/src/backend/core.rs index 030248d2..d304ecab 100644 --- a/lite/src/backend/core.rs +++ b/lite/src/backend/core.rs @@ -19,7 +19,7 @@ use super::{ error::{ BasinDeletionPendingError, BasinNotFoundError, CreateStreamError, GetBasinConfigError, StorageError, StreamDeletionPendingError, StreamNotFoundError, StreamerError, - TransactionConflictError, UnavailableError, + TransactionConflictError, }, kv, stream_id::StreamId, @@ -225,7 +225,6 @@ impl Backend { + From + From + From - + From + From + From + From, @@ -251,7 +250,6 @@ impl Backend { match e { CreateStreamError::Storage(e) => Err(e)?, CreateStreamError::TransactionConflict(e) => Err(e)?, - CreateStreamError::Unavailable(e) => Err(e)?, CreateStreamError::BasinDeletionPending(e) => Err(e)?, CreateStreamError::StreamDeletionPending(e) => Err(e)?, CreateStreamError::BasinNotFound(e) => Err(e)?, diff --git a/lite/src/backend/error.rs b/lite/src/backend/error.rs index ed3ef069..c71202d8 100644 --- a/lite/src/backend/error.rs +++ b/lite/src/backend/error.rs @@ -65,12 +65,12 @@ pub struct StreamDeletionPendingError { pub struct UnwrittenError(pub StreamPosition); #[derive(Debug, Clone, thiserror::Error)] -pub enum UnavailableError { - #[error("unavailable: missing in action")] - MissingInAction, - #[error("unavailable: request drop")] - RequestDrop, -} +#[error("streamer missing in action")] +pub struct StreamerMissingInActionError; + +#[derive(Debug, Clone, thiserror::Error)] +#[error("request dropped")] +pub struct RequestDroppedError; #[derive(Debug, Clone, thiserror::Error)] #[error("record timestamp was required but was missing")] @@ -95,7 +95,9 @@ pub(super) enum AppendErrorInternal { #[error(transparent)] Storage(#[from] StorageError), #[error(transparent)] - Unavailable(#[from] UnavailableError), + StreamerMissingInActionError(#[from] StreamerMissingInActionError), + #[error(transparent)] + RequestDroppedError(#[from] RequestDroppedError), #[error(transparent)] ConditionFailed(#[from] AppendConditionFailedError), #[error(transparent)] @@ -109,7 +111,7 @@ pub enum CheckTailError { #[error(transparent)] TransactionConflict(#[from] TransactionConflictError), #[error(transparent)] - Unavailable(#[from] UnavailableError), + StreamerMissingInActionError(#[from] StreamerMissingInActionError), #[error(transparent)] BasinNotFound(#[from] BasinNotFoundError), #[error(transparent)] @@ -137,7 +139,9 @@ pub enum AppendError { #[error(transparent)] TransactionConflict(#[from] TransactionConflictError), #[error(transparent)] - Unavailable(#[from] UnavailableError), + StreamerMissingInActionError(#[from] StreamerMissingInActionError), + #[error(transparent)] + RequestDroppedError(#[from] RequestDroppedError), #[error(transparent)] BasinNotFound(#[from] BasinNotFoundError), #[error(transparent)] @@ -156,7 +160,10 @@ impl From for AppendError { fn from(e: AppendErrorInternal) -> Self { match e { AppendErrorInternal::Storage(e) => AppendError::Storage(e), - AppendErrorInternal::Unavailable(e) => AppendError::Unavailable(e), + AppendErrorInternal::StreamerMissingInActionError(e) => { + AppendError::StreamerMissingInActionError(e) + } + AppendErrorInternal::RequestDroppedError(e) => AppendError::RequestDroppedError(e), AppendErrorInternal::ConditionFailed(e) => AppendError::ConditionFailed(e), AppendErrorInternal::TimestampMissing(e) => AppendError::TimestampMissing(e), } @@ -207,7 +214,7 @@ pub enum ReadError { #[error(transparent)] TransactionConflict(#[from] TransactionConflictError), #[error(transparent)] - Unavailable(#[from] UnavailableError), + StreamerMissingInActionError(#[from] StreamerMissingInActionError), #[error(transparent)] BasinNotFound(#[from] BasinNotFoundError), #[error(transparent)] @@ -267,8 +274,6 @@ pub enum CreateStreamError { #[error(transparent)] TransactionConflict(#[from] TransactionConflictError), #[error(transparent)] - Unavailable(#[from] UnavailableError), - #[error(transparent)] BasinNotFound(#[from] BasinNotFoundError), #[error(transparent)] BasinDeletionPending(#[from] BasinDeletionPendingError), @@ -310,7 +315,9 @@ pub enum DeleteStreamError { #[error(transparent)] Storage(#[from] StorageError), #[error(transparent)] - Unavailable(#[from] UnavailableError), + StreamerMissingInActionError(#[from] StreamerMissingInActionError), + #[error(transparent)] + RequestDroppedError(#[from] RequestDroppedError), #[error(transparent)] StreamNotFound(#[from] StreamNotFoundError), } @@ -392,8 +399,6 @@ pub enum ReconfigureStreamError { #[error(transparent)] TransactionConflict(#[from] TransactionConflictError), #[error(transparent)] - Unavailable(#[from] UnavailableError), - #[error(transparent)] StreamNotFound(#[from] StreamNotFoundError), #[error(transparent)] StreamDeletionPending(#[from] StreamDeletionPendingError), diff --git a/lite/src/backend/read.rs b/lite/src/backend/read.rs index 86aa1bc8..44f646ce 100644 --- a/lite/src/backend/read.rs +++ b/lite/src/backend/read.rs @@ -15,7 +15,7 @@ use tokio::sync::broadcast; use super::Backend; use crate::backend::{ - error::{CheckTailError, ReadError, UnavailableError, UnwrittenError}, + error::{CheckTailError, ReadError, StreamerMissingInActionError, UnwrittenError}, kv, stream_id::StreamId, }; @@ -218,7 +218,7 @@ impl Backend { } } } - Err(UnavailableError::MissingInAction)?; + Err(StreamerMissingInActionError)?; } Err(tail) => { assert!(state.tail.seq_num < tail.seq_num, "tail cannot regress"); diff --git a/lite/src/backend/streamer.rs b/lite/src/backend/streamer.rs index 474db16a..2381a313 100644 --- a/lite/src/backend/streamer.rs +++ b/lite/src/backend/streamer.rs @@ -32,7 +32,7 @@ use crate::{ append, error::{ AppendConditionFailedError, AppendErrorInternal, AppendTimestampRequiredError, - DeleteStreamError, StreamerError, UnavailableError, + DeleteStreamError, RequestDroppedError, StreamerError, StreamerMissingInActionError, }, kv, stream_id::StreamId, @@ -356,12 +356,12 @@ impl StreamerClient { self.stream_id } - pub async fn check_tail(&self) -> Result { + pub async fn check_tail(&self) -> Result { let (reply_tx, reply_rx) = oneshot::channel(); self.msg_tx .send(Message::CheckTail { reply_tx }) - .map_err(|_| UnavailableError::MissingInAction)?; - reply_rx.await.map_err(|_| UnavailableError::RequestDrop) + .map_err(|_| StreamerMissingInActionError)?; + reply_rx.await.map_err(|_| StreamerMissingInActionError) } pub async fn follow( @@ -369,7 +369,7 @@ impl StreamerClient { start_seq_num: SeqNum, ) -> Result< Result>>, StreamPosition>, - UnavailableError, + StreamerMissingInActionError, > { let (reply_tx, reply_rx) = oneshot::channel(); self.msg_tx @@ -377,14 +377,14 @@ impl StreamerClient { start_seq_num, reply_tx, }) - .map_err(|_| UnavailableError::MissingInAction)?; - reply_rx.await.map_err(|_| UnavailableError::RequestDrop) + .map_err(|_| StreamerMissingInActionError)?; + reply_rx.await.map_err(|_| StreamerMissingInActionError) } pub async fn append_permit( &self, input: AppendInput, - ) -> Result, UnavailableError> { + ) -> Result, StreamerMissingInActionError> { let metered_size = input.records.metered_size(); metrics::observe_append_batch_size(input.records.len(), metered_size); let start = Instant::now(); @@ -392,10 +392,10 @@ impl StreamerClient { let num_permits = metered_size.clamp(1, self.append_inflight_bytes_max) as u32; let sema_permit = tokio::select! { res = self.append_inflight_bytes_sema.acquire_many(num_permits) => { - res.map_err(|_| UnavailableError::MissingInAction) + res.map_err(|_| StreamerMissingInActionError) } _ = self.msg_tx.closed() => { - Err(UnavailableError::MissingInAction) + Err(StreamerMissingInActionError) } }?; metrics::observe_append_permit_latency(start.elapsed()); @@ -406,10 +406,8 @@ impl StreamerClient { }) } - pub async fn reconfigure(&self, config: OptionalStreamConfig) -> Result<(), UnavailableError> { - self.msg_tx - .send(Message::Reconfigure { config }) - .map_err(|_| UnavailableError::MissingInAction) + pub fn advise_reconfig(&self, config: OptionalStreamConfig) -> bool { + self.msg_tx.send(Message::Reconfigure { config }).is_ok() } pub async fn terminal_trim(&self) -> Result<(), DeleteStreamError> { @@ -433,7 +431,12 @@ impl StreamerClient { Ok(_ack) => Ok(()), Err(e) => Err(match e { AppendErrorInternal::Storage(e) => DeleteStreamError::Storage(e), - AppendErrorInternal::Unavailable(e) => DeleteStreamError::Unavailable(e), + AppendErrorInternal::StreamerMissingInActionError(e) => { + DeleteStreamError::StreamerMissingInActionError(e) + } + AppendErrorInternal::RequestDroppedError(e) => { + DeleteStreamError::RequestDroppedError(e) + } AppendErrorInternal::ConditionFailed(_) => unreachable!("unconditional write"), AppendErrorInternal::TimestampMissing(_) => unreachable!("Timestamp::MAX used"), }), @@ -502,10 +505,8 @@ impl AppendPermit<'_> { reply_tx, append_type, }) - .map_err(|_| UnavailableError::MissingInAction)?; - let ack = reply_rx - .await - .map_err(|_| UnavailableError::RequestDrop)??; + .map_err(|_| StreamerMissingInActionError)?; + let ack = reply_rx.await.map_err(|_| RequestDroppedError)??; drop(sema_permit); metrics::observe_append_ack_latency(start.elapsed()); Ok(ack) diff --git a/lite/src/backend/streams.rs b/lite/src/backend/streams.rs index 7bd8b782..497357c2 100644 --- a/lite/src/backend/streams.rs +++ b/lite/src/backend/streams.rs @@ -152,7 +152,7 @@ impl Backend { if existing_created_at.is_some() && let Some(client) = self.streamer_client_if_active(&basin, &stream) { - client.reconfigure(config).await?; + client.advise_reconfig(config); } let info = StreamInfo { @@ -214,7 +214,7 @@ impl Backend { txn.commit_with_options(&WRITE_OPTS).await?; if let Some(client) = self.streamer_client_if_active(&basin, &stream) { - client.reconfigure(meta.config.clone()).await?; + client.advise_reconfig(meta.config.clone()); } Ok(meta.config) diff --git a/lite/src/bin/server.rs b/lite/src/bin/server.rs index b26253c3..5ca448a7 100644 --- a/lite/src/bin/server.rs +++ b/lite/src/bin/server.rs @@ -31,11 +31,12 @@ struct TlsConfig { #[command(author, version, about, long_about = None)] struct Args { /// Name of the S3 bucket to back the database. + /// /// If not specified, in-memory storage is used. #[arg(long)] bucket: Option, - /// Path on object storage. + /// Base path on object storage. #[arg(long, default_value = "")] path: String, @@ -49,10 +50,8 @@ struct Args { /// Whether to pipeline appends per stream. /// - /// This improves performance with high-latency object storage, - /// however it is unsafe for now (risk of data loss). - /// - /// It will be enabled by default in future and this knob removed. + /// This improves performance with high-latency object storage, however it is unsafe for now + /// (risk of data loss). It will be enabled by default in future and this knob removed. #[arg(long, default_value_t = false)] pipeline: bool, } diff --git a/lite/src/handlers/v1/error.rs b/lite/src/handlers/v1/error.rs index ad0b94e6..53cb1d08 100644 --- a/lite/src/handlers/v1/error.rs +++ b/lite/src/handlers/v1/error.rs @@ -136,9 +136,6 @@ impl ServiceError { CreateStreamError::TransactionConflict(e) => { standard(ErrorCode::TransactionConflict, e.to_string()) } - CreateStreamError::Unavailable(e) => { - standard(ErrorCode::Unavailable, e.to_string()) - } CreateStreamError::BasinNotFound(e) => { standard(ErrorCode::BasinNotFound, e.to_string()) } @@ -160,9 +157,14 @@ impl ServiceError { }, ServiceError::DeleteStream(e) => match e { DeleteStreamError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), - DeleteStreamError::Unavailable(e) => { + DeleteStreamError::StreamerMissingInActionError(e) => { standard(ErrorCode::Unavailable, e.to_string()) } + DeleteStreamError::RequestDroppedError(e) => { + // Unavailable error code promised to be side-effect free, + // AppendType::Terminal may have become durable prior to drop. + standard(ErrorCode::Other, e.to_string()) + } DeleteStreamError::StreamNotFound(e) => { standard(ErrorCode::StreamNotFound, e.to_string()) } @@ -172,9 +174,6 @@ impl ServiceError { ReconfigureStreamError::TransactionConflict(e) => { standard(ErrorCode::TransactionConflict, e.to_string()) } - ReconfigureStreamError::Unavailable(e) => { - standard(ErrorCode::Unavailable, e.to_string()) - } ReconfigureStreamError::StreamNotFound(e) => { standard(ErrorCode::StreamNotFound, e.to_string()) } @@ -187,7 +186,9 @@ impl ServiceError { CheckTailError::TransactionConflict(e) => { standard(ErrorCode::TransactionConflict, e.to_string()) } - CheckTailError::Unavailable(e) => standard(ErrorCode::Unavailable, e.to_string()), + CheckTailError::StreamerMissingInActionError(_) => { + standard(ErrorCode::Unavailable, e.to_string()) + } CheckTailError::BasinNotFound(e) => { standard(ErrorCode::BasinNotFound, e.to_string()) } @@ -206,7 +207,14 @@ impl ServiceError { AppendError::TransactionConflict(e) => { standard(ErrorCode::TransactionConflict, e.to_string()) } - AppendError::Unavailable(e) => standard(ErrorCode::Unavailable, e.to_string()), + AppendError::StreamerMissingInActionError(e) => { + standard(ErrorCode::Unavailable, e.to_string()) + } + AppendError::RequestDroppedError(e) => { + // Unavailable error code promised to be side-effect free, + // AppendType::Regular may have become durable prior to drop. + standard(ErrorCode::Other, e.to_string()) + } AppendError::BasinNotFound(e) => standard(ErrorCode::BasinNotFound, e.to_string()), AppendError::StreamNotFound(e) => { standard(ErrorCode::StreamNotFound, e.to_string()) @@ -232,7 +240,9 @@ impl ServiceError { ReadError::TransactionConflict(e) => { standard(ErrorCode::TransactionConflict, e.to_string()) } - ReadError::Unavailable(e) => standard(ErrorCode::Unavailable, e.to_string()), + ReadError::StreamerMissingInActionError(_) => { + standard(ErrorCode::Unavailable, e.to_string()) + } ReadError::BasinNotFound(e) => standard(ErrorCode::BasinNotFound, e.to_string()), ReadError::StreamNotFound(e) => standard(ErrorCode::StreamNotFound, e.to_string()), ReadError::BasinDeletionPending(e) => { From 444560c467feac42916c8f56dea1e80ca1b8687b Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Tue, 13 Jan 2026 21:03:29 -0500 Subject: [PATCH 054/700] chore: run sdk tests in CI (#49) --- .github/workflows/ci.yml | 55 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83ca7ac9..accd03e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,58 @@ jobs: - uses: arduino/setup-protoc@v3 - uses: taiki-e/install-action@nextest - run: cargo nextest run --workspace --all-features + + build-server: + name: Build s2-lite + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: arduino/setup-protoc@v3 + - name: Build s2-lite + run: cargo build --release -p s2-lite + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: server-binary + path: target/release/server + retention-days: 1 + + sdk-integration-tests: + name: SDK Integration Tests + needs: [build-server] + uses: s2-streamstore/s2-ci/.github/workflows/sdk-tests.yml@main + with: + mode: local + server-binary: server + server-args: "--port 8080" + server-port: 8080 + sdks: | + [ + { + "name": "go", + "repo": "s2-streamstore/s2-sdk-go", + "ref": "main", + "lang": "go", + "go-version": "1.24", + "test_cmd": "go test -v -count=1 -skip 'WithScope' ./s2/..." + }, + { + "name": "typescript", + "repo": "s2-streamstore/s2-sdk-typescript", + "ref": "main", + "lang": "bun", + "bun-version": "latest", + "test_cmd": "bun run vitest --run --exclude '**/account-basin*'" + }, + { + "name": "rust", + "repo": "s2-streamstore/s2-sdk-rust", + "ref": "main", + "lang": "rust", + "test_cmd": "cargo test --all-features -- --skip access_token" + } + ] From 7c26e0836f5a94fddb540c82b679f20798218227 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Wed, 14 Jan 2026 11:19:28 -0500 Subject: [PATCH 055/700] make `api/specs` a submodule (#55) https://github.com/s2-streamstore/s2-specs as source of truth since we need it in multiple repos --- .gitmodules | 3 + api/specs | 1 + api/specs/s2/v1/openapi.json | 3269 --------------------------------- api/specs/s2/v1/s2.proto | 81 - api/specs/s2/v1alpha/s2.proto | 734 -------- 5 files changed, 4 insertions(+), 4084 deletions(-) create mode 100644 .gitmodules create mode 160000 api/specs delete mode 100644 api/specs/s2/v1/openapi.json delete mode 100644 api/specs/s2/v1/s2.proto delete mode 100644 api/specs/s2/v1alpha/s2.proto diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..5d16ac2b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "api/specs"] + path = api/specs + url = git@github.com:s2-streamstore/s2-specs.git diff --git a/api/specs b/api/specs new file mode 160000 index 00000000..394385e9 --- /dev/null +++ b/api/specs @@ -0,0 +1 @@ +Subproject commit 394385e914781937ecedeed0de2ce8e4da46959e diff --git a/api/specs/s2/v1/openapi.json b/api/specs/s2/v1/openapi.json deleted file mode 100644 index b6eb28ab..00000000 --- a/api/specs/s2/v1/openapi.json +++ /dev/null @@ -1,3269 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "S2, the durable streams API", - "description": "Streams as a cloud storage primitive.", - "termsOfService": "https://s2.dev/terms", - "contact": { - "email": "support@s2.dev" - }, - "license": { - "name": "MIT" - }, - "version": "1.0.0" - }, - "servers": [ - { - "url": "https://aws.s2.dev/v1" - } - ], - "paths": { - "/access-tokens": { - "get": { - "tags": [ - "access-tokens" - ], - "summary": "List access tokens.", - "operationId": "list_access_tokens", - "parameters": [ - { - "name": "prefix", - "in": "query", - "description": "Filter to access tokens whose ID begins with this prefix.", - "required": false, - "schema": { - "type": "string", - "default": "" - } - }, - { - "name": "start_after", - "in": "query", - "description": "Filter to access tokens whose ID lexicographically starts after this string.", - "required": false, - "schema": { - "type": "string", - "default": "" - } - }, - { - "name": "limit", - "in": "query", - "description": "Number of results, up to a maximum of 1000.", - "required": false, - "schema": { - "type": "integer", - "default": 1000, - "maximum": 1000, - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListAccessTokensResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - }, - "post": { - "tags": [ - "access-tokens" - ], - "summary": "Issue a new access token.", - "operationId": "issue_access_token", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AccessTokenInfo" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/IssueAccessTokenResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - } - }, - "/access-tokens/{id}": { - "delete": { - "tags": [ - "access-tokens" - ], - "summary": "Revoke an access token.", - "operationId": "revoke_access_token", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Access token ID.", - "required": true, - "schema": { - "$ref": "#/components/schemas/AccessTokenIdStr" - } - } - ], - "responses": { - "204": { - "description": "" - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - } - }, - "/basins": { - "get": { - "tags": [ - "basins" - ], - "summary": "List basins.", - "operationId": "list_basins", - "parameters": [ - { - "name": "prefix", - "in": "query", - "description": "Filter to basins whose names begin with this prefix.", - "required": false, - "schema": { - "type": "string", - "default": "" - } - }, - { - "name": "start_after", - "in": "query", - "description": "Filter to basins whose names lexicographically start after this string.\nIt must be greater than or equal to the `prefix` if specified.", - "required": false, - "schema": { - "type": "string", - "default": "" - } - }, - { - "name": "limit", - "in": "query", - "description": "Number of results, up to a maximum of 1000.", - "required": false, - "schema": { - "type": "integer", - "default": 1000, - "maximum": 1000, - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListBasinsResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - }, - "post": { - "tags": [ - "basins" - ], - "summary": "Create a basin.", - "operationId": "create_basin", - "parameters": [ - { - "name": "s2-request-token", - "in": "header", - "description": "Client-specified request token for idempotent retries.", - "required": false, - "schema": { - "$ref": "#/components/schemas/RequestToken" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateBasinRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BasinInfo" - } - } - } - }, - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BasinInfo" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - } - }, - "/basins/{basin}": { - "get": { - "tags": [ - "basins" - ], - "summary": "Get basin configuration.", - "operationId": "get_basin_config", - "parameters": [ - { - "name": "basin", - "in": "path", - "description": "Basin name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/BasinNameStr" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BasinConfig" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - }, - "put": { - "tags": [ - "basins" - ], - "summary": "Create or reconfigure a basin.", - "operationId": "create_or_reconfigure_basin", - "parameters": [ - { - "name": "basin", - "in": "path", - "description": "Basin name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/BasinNameStr" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/CreateOrReconfigureBasinRequest" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BasinInfo" - } - } - } - }, - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BasinInfo" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - }, - "delete": { - "tags": [ - "basins" - ], - "summary": "Delete a basin.", - "operationId": "delete_basin", - "parameters": [ - { - "name": "basin", - "in": "path", - "description": "Basin name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/BasinNameStr" - } - } - ], - "responses": { - "202": { - "description": "" - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - }, - "patch": { - "tags": [ - "basins" - ], - "summary": "Reconfigure a basin.", - "operationId": "reconfigure_basin", - "parameters": [ - { - "name": "basin", - "in": "path", - "description": "Basin name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/BasinNameStr" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BasinReconfiguration" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BasinConfig" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - } - }, - "/metrics": { - "get": { - "tags": [ - "metrics" - ], - "summary": "Account-level metrics.", - "operationId": "account_metrics", - "parameters": [ - { - "name": "set", - "in": "query", - "description": "Metric set to return.", - "required": true, - "schema": { - "$ref": "#/components/schemas/AccountMetricSet" - } - }, - { - "name": "start", - "in": "query", - "description": "Start timestamp as Unix epoch seconds, if applicable for the metric set.", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - }, - { - "name": "end", - "in": "query", - "description": "End timestamp as Unix epoch seconds, if applicable for the metric set.", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - }, - { - "name": "interval", - "in": "query", - "description": "Interval to aggregate over for timeseries metric sets.", - "required": false, - "schema": { - "$ref": "#/components/schemas/TimeseriesInterval" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetricSetResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - } - }, - "/metrics/{basin}": { - "get": { - "tags": [ - "metrics" - ], - "summary": "Basin-level metrics.", - "operationId": "basin_metrics", - "parameters": [ - { - "name": "set", - "in": "query", - "description": "Metric set to return.", - "required": true, - "schema": { - "$ref": "#/components/schemas/BasinMetricSet" - } - }, - { - "name": "start", - "in": "query", - "description": "Start timestamp as Unix epoch seconds, if applicable for the metric set.", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - }, - { - "name": "end", - "in": "query", - "description": "End timestamp as Unix epoch seconds, if applicable for the metric set.", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - }, - { - "name": "interval", - "in": "query", - "description": "Interval to aggregate over for timeseries metric sets.", - "required": false, - "schema": { - "$ref": "#/components/schemas/TimeseriesInterval" - } - }, - { - "name": "basin", - "in": "path", - "description": "Basin name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/BasinNameStr" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetricSetResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - } - }, - "/metrics/{basin}/{stream}": { - "get": { - "tags": [ - "metrics" - ], - "summary": "Stream-level metrics.", - "operationId": "stream_metrics", - "parameters": [ - { - "name": "set", - "in": "query", - "description": "Metric set to return.", - "required": true, - "schema": { - "$ref": "#/components/schemas/StreamMetricSet" - } - }, - { - "name": "start", - "in": "query", - "description": "Start timestamp as Unix epoch seconds, if applicable for the metric set.", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - }, - { - "name": "end", - "in": "query", - "description": "End timestamp as Unix epoch seconds, if applicable for metric set.", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - }, - { - "name": "interval", - "in": "query", - "description": "Interval to aggregate over for timeseries metric sets.", - "required": false, - "schema": { - "$ref": "#/components/schemas/TimeseriesInterval" - } - }, - { - "name": "basin", - "in": "path", - "description": "Basin name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/BasinNameStr" - } - }, - { - "name": "stream", - "in": "path", - "description": "Stream name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/StreamNameStr" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetricSetResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - } - }, - "/streams": { - "servers": [ - { - "url": "https://{basin}.b.aws.s2.dev/v1", - "description": "Endpoint for the basin", - "variables": { - "basin": { - "default": "", - "description": "Basin name" - } - } - } - ], - "get": { - "tags": [ - "streams" - ], - "summary": "List streams.", - "operationId": "list_streams", - "parameters": [ - { - "name": "prefix", - "in": "query", - "description": "Filter to streams whose name begins with this prefix.", - "required": false, - "schema": { - "type": "string", - "default": "" - } - }, - { - "name": "start_after", - "in": "query", - "description": "Filter to streams whose name begins with this prefix.\nIt must be greater than or equal to the `prefix` if specified.", - "required": false, - "schema": { - "type": "string", - "default": "" - } - }, - { - "name": "limit", - "in": "query", - "description": "Number of results, up to a maximum of 1000.", - "required": false, - "schema": { - "type": "integer", - "default": 1000, - "maximum": 1000, - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListStreamsResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - }, - "post": { - "tags": [ - "streams" - ], - "summary": "Create a stream.", - "operationId": "create_stream", - "parameters": [ - { - "name": "s2-request-token", - "in": "header", - "description": "Client-specified request token for idempotent retries.", - "required": false, - "schema": { - "$ref": "#/components/schemas/RequestToken" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateStreamRequest" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StreamInfo" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - } - }, - "/streams/{stream}": { - "servers": [ - { - "url": "https://{basin}.b.aws.s2.dev/v1", - "description": "Endpoint for the basin", - "variables": { - "basin": { - "default": "", - "description": "Basin name" - } - } - } - ], - "get": { - "tags": [ - "streams" - ], - "summary": "Get stream configuration.", - "operationId": "get_stream_config", - "parameters": [ - { - "name": "stream", - "in": "path", - "description": "Stream name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/StreamNameStr" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StreamConfig" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - }, - "put": { - "tags": [ - "streams" - ], - "summary": "Create or reconfigure a stream.", - "operationId": "create_or_reconfigure_stream", - "parameters": [ - { - "name": "stream", - "in": "path", - "description": "Stream name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/StreamNameStr" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/StreamConfig" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StreamInfo" - } - } - } - }, - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StreamInfo" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - }, - "delete": { - "tags": [ - "streams" - ], - "summary": "Delete a stream.", - "operationId": "delete_stream", - "parameters": [ - { - "name": "stream", - "in": "path", - "description": "Stream name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/StreamNameStr" - } - } - ], - "responses": { - "202": { - "description": "" - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - }, - "patch": { - "tags": [ - "streams" - ], - "summary": "Reconfigure a stream.", - "operationId": "reconfigure_stream", - "parameters": [ - { - "name": "stream", - "in": "path", - "description": "Stream name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/StreamNameStr" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StreamReconfiguration" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StreamConfig" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - } - }, - "/streams/{stream}/records": { - "servers": [ - { - "url": "https://{basin}.b.aws.s2.dev/v1", - "description": "Endpoint for the basin", - "variables": { - "basin": { - "default": "", - "description": "Basin name" - } - } - } - ], - "get": { - "tags": [ - "records" - ], - "summary": "Read records.", - "operationId": "read", - "parameters": [ - { - "name": "stream", - "in": "path", - "description": "Stream name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/StreamNameStr" - } - }, - { - "name": "s2-format", - "in": "header", - "description": "Defines the interpretation of record data (header name, header value, and body) with the JSON content type.\nUse `raw` (default) for efficient transmission and storage of Unicode data — storage will be in UTF-8.\nUse `base64` for safe transmission with efficient storage of binary data.", - "required": false, - "schema": { - "$ref": "#/components/schemas/Format" - } - }, - { - "name": "seq_num", - "in": "query", - "description": "Start from a sequence number.", - "required": false, - "schema": { - "$ref": "#/components/schemas/u64" - } - }, - { - "name": "timestamp", - "in": "query", - "description": "Start from a timestamp.", - "required": false, - "schema": { - "$ref": "#/components/schemas/u64" - } - }, - { - "name": "tail_offset", - "in": "query", - "description": "Start from number of records before the next sequence number.", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "clamp", - "in": "query", - "description": "Start reading from the tail if the requested position is beyond it.\nOtherwise, a `416 Range Not Satisfiable` response is returned.", - "required": false, - "schema": { - "type": "boolean" - } - }, - { - "name": "count", - "in": "query", - "description": "Record count limit.\nNon-streaming reads are capped by the default limit of 1000 records.", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "bytes", - "in": "query", - "description": "Metered bytes limit.\nNon-streaming reads are capped by the default limit of 1 MiB.", - "required": false, - "schema": { - "type": "integer", - "minimum": 0 - } - }, - { - "name": "until", - "in": "query", - "description": "Exclusive timestamp to read until.", - "required": false, - "schema": { - "$ref": "#/components/schemas/u64" - } - }, - { - "name": "wait", - "in": "query", - "description": "Duration in seconds to wait for new records.\nThe default duration is 0 if there is a bound on `count`, `bytes`, or `until`, and otherwise infinite.\nNon-streaming reads are always bounded on `count` and `bytes`, so you can achieve long poll semantics by specifying a non-zero duration up to 60 seconds.\nIn the context of an SSE or S2S streaming read, the duration will bound how much time can elapse between records throughout the lifetime of the session.", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReadBatch" - } - }, - "text/event-stream": { - "schema": { - "$ref": "#/components/schemas/ReadEvent" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "416": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TailResponse" - } - } - } - } - } - }, - "post": { - "tags": [ - "records" - ], - "summary": "Append records.", - "operationId": "append", - "parameters": [ - { - "name": "stream", - "in": "path", - "description": "Stream name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/StreamNameStr" - } - }, - { - "name": "s2-format", - "in": "header", - "description": "Defines the interpretation of record data (header name, header value, and body) with the JSON content type.\nUse `raw` (default) for efficient transmission and storage of Unicode data — storage will be in UTF-8.\nUse `base64` for safe transmission with efficient storage of binary data.", - "required": false, - "schema": { - "$ref": "#/components/schemas/Format" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AppendInput" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AppendAck" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "412": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AppendConditionFailed" - } - } - } - } - } - } - }, - "/streams/{stream}/records/tail": { - "servers": [ - { - "url": "https://{basin}.b.aws.s2.dev/v1", - "description": "Endpoint for the basin", - "variables": { - "basin": { - "default": "", - "description": "Basin name" - } - } - } - ], - "get": { - "tags": [ - "records" - ], - "summary": "Check the tail.", - "operationId": "check_tail", - "parameters": [ - { - "name": "stream", - "in": "path", - "description": "Stream name.", - "required": true, - "schema": { - "$ref": "#/components/schemas/StreamNameStr" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TailResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "408": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorInfo" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "AccessTokenIdStr": { - "type": "string", - "maxLength": 96, - "minLength": 1 - }, - "AccessTokenInfo": { - "type": "object", - "required": [ - "id", - "scope" - ], - "properties": { - "auto_prefix_streams": { - "type": "boolean", - "description": "Namespace streams based on the configured stream-level scope, which must be a prefix.\nStream name arguments will be automatically prefixed, and the prefix will be stripped when listing streams.", - "default": false - }, - "expires_at": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Expiration time in RFC 3339 format.\nIf not set, the expiration will be set to that of the requestor's token." - }, - "id": { - "$ref": "#/components/schemas/AccessTokenIdStr", - "description": "Access token ID.\nIt must be unique to the account and between 1 and 96 bytes in length." - }, - "scope": { - "$ref": "#/components/schemas/AccessTokenScope", - "description": "Access token scope." - } - } - }, - "AccessTokenScope": { - "type": "object", - "properties": { - "access_tokens": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ResourceSet", - "description": "Token IDs allowed." - } - ] - }, - "basins": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ResourceSet", - "description": "Basin names allowed." - } - ] - }, - "op_groups": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/PermittedOperationGroups", - "description": "Access permissions at operation group level." - } - ] - }, - "ops": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/Operation" - }, - "description": "Operations allowed for the token.\nA union of allowed operations and groups is used as an effective set of allowed operations." - }, - "streams": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ResourceSet", - "description": "Stream names allowed." - } - ] - } - } - }, - "AccountMetricSet": { - "type": "string", - "enum": [ - "active-basins", - "account-ops" - ] - }, - "AccumulationMetric": { - "type": "object", - "required": [ - "name", - "unit", - "interval", - "values" - ], - "properties": { - "interval": { - "$ref": "#/components/schemas/TimeseriesInterval", - "description": "The interval at which data points are accumulated." - }, - "name": { - "type": "string", - "description": "Timeseries name." - }, - "unit": { - "$ref": "#/components/schemas/MetricUnit", - "description": "Unit of the metric." - }, - "values": { - "type": "array", - "items": { - "type": "array", - "items": false, - "prefixItems": [ - { - "type": "integer", - "format": "int32", - "minimum": 0 - }, - { - "type": "number", - "format": "double" - } - ] - }, - "description": "Timeseries values.\nEach element is a tuple of a timestamp in Unix epoch seconds and a data point.\nThe data point represents the accumulated value for the time period starting at the timestamp, spanning one `interval`." - } - } - }, - "AppendAck": { - "type": "object", - "description": "Success response to an `append` request.", - "required": [ - "start", - "end", - "tail" - ], - "properties": { - "end": { - "$ref": "#/components/schemas/StreamPosition", - "description": "Sequence number of the last record that was appended `+ 1`, and timestamp of the last record that was appended.\nThe difference between `end.seq_num` and `start.seq_num` will be the number of records appended." - }, - "start": { - "$ref": "#/components/schemas/StreamPosition", - "description": "Sequence number and timestamp of the first record that was appended." - }, - "tail": { - "$ref": "#/components/schemas/StreamPosition", - "description": "Sequence number that will be assigned to the next record on the stream, and timestamp of the last record on the stream.\nThis can be greater than the `end` position in case of concurrent appends." - } - } - }, - "AppendConditionFailed": { - "oneOf": [ - { - "type": "object", - "title": "fencing token", - "description": "Fencing token did not match.\nThe expected fencing token is returned.", - "required": [ - "fencing_token_mismatch" - ], - "properties": { - "fencing_token_mismatch": { - "$ref": "#/components/schemas/FencingToken", - "description": "Fencing token did not match.\nThe expected fencing token is returned." - } - } - }, - { - "type": "object", - "title": "seq num", - "description": "Sequence number did not match the tail of the stream.\nThe expected next sequence number is returned.", - "required": [ - "seq_num_mismatch" - ], - "properties": { - "seq_num_mismatch": { - "$ref": "#/components/schemas/u64", - "description": "Sequence number did not match the tail of the stream.\nThe expected next sequence number is returned." - } - } - } - ], - "description": "Aborted due to a failed condition." - }, - "AppendInput": { - "type": "object", - "description": "Payload of an `append` request.", - "required": [ - "records" - ], - "properties": { - "fencing_token": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/FencingToken", - "description": "Enforce a fencing token, which starts out as an empty string that can be overridden by a `fence` command record." - } - ] - }, - "match_seq_num": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/u64", - "description": "Enforce that the sequence number assigned to the first record matches." - } - ] - }, - "records": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AppendRecord" - }, - "description": "Batch of records to append atomically, which must contain at least one record, and no more than 1000.\nThe total size of a batch of records may not exceed 1 MiB of metered bytes." - } - } - }, - "AppendRecord": { - "type": "object", - "description": "Record to be appended to a stream.", - "properties": { - "body": { - "type": "string", - "description": "Body of the record." - }, - "headers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Header" - }, - "description": "Series of name-value pairs for this record." - }, - "timestamp": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/u64", - "description": "Timestamp for this record.\nThe service will always ensure monotonicity by adjusting it up if necessary to the maximum observed timestamp.\nRefer to stream timestamping configuration for the finer semantics around whether a client-specified timestamp is required, and whether it will be capped at the arrival time." - } - ] - } - } - }, - "BasinConfig": { - "type": "object", - "properties": { - "create_stream_on_append": { - "type": "boolean", - "description": "Create stream on append if it doesn't exist, using the default stream configuration." - }, - "create_stream_on_read": { - "type": "boolean", - "description": "Create stream on read if it doesn't exist, using the default stream configuration." - }, - "default_stream_config": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/StreamConfig", - "description": "Default stream configuration." - } - ] - } - } - }, - "BasinInfo": { - "type": "object", - "required": [ - "name", - "state" - ], - "properties": { - "name": { - "$ref": "#/components/schemas/BasinNameStr", - "description": "Basin name." - }, - "scope": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/BasinScope", - "description": "Basin scope." - } - ] - }, - "state": { - "$ref": "#/components/schemas/BasinState", - "description": "Basin state." - } - } - }, - "BasinMetricSet": { - "type": "string", - "enum": [ - "storage", - "append-ops", - "read-ops", - "read-throughput", - "append-throughput", - "basin-ops" - ] - }, - "BasinNameStr": { - "type": "string", - "maxLength": 48, - "minLength": 8 - }, - "BasinReconfiguration": { - "type": "object", - "properties": { - "create_stream_on_append": { - "type": [ - "boolean", - "null" - ], - "description": "Create a stream on append." - }, - "create_stream_on_read": { - "type": [ - "boolean", - "null" - ], - "description": "Create a stream on read." - }, - "default_stream_config": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/StreamReconfiguration", - "description": "Basin configuration." - } - ] - } - } - }, - "BasinScope": { - "type": "string", - "enum": [ - "aws:us-east-1" - ] - }, - "BasinState": { - "type": "string", - "enum": [ - "active", - "creating", - "deleting" - ] - }, - "CreateBasinRequest": { - "type": "object", - "required": [ - "basin" - ], - "properties": { - "basin": { - "$ref": "#/components/schemas/BasinNameStr", - "description": "Basin name which must be globally unique.\nIt can be between 8 and 48 bytes in length, and comprise lowercase letters, numbers and hyphens.\nIt cannot begin or end with a hyphen." - }, - "config": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/BasinConfig", - "description": "Basin configuration." - } - ] - }, - "scope": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/BasinScope", - "description": "Basin scope." - } - ] - } - } - }, - "CreateOrReconfigureBasinRequest": { - "type": "object", - "properties": { - "config": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/BasinConfig", - "description": "Basin configuration." - } - ] - }, - "scope": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/BasinScope", - "description": "Basin scope.\nThis cannot be reconfigured." - } - ] - } - } - }, - "CreateStreamRequest": { - "type": "object", - "required": [ - "stream" - ], - "properties": { - "config": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/StreamConfig", - "description": "Stream configuration." - } - ] - }, - "stream": { - "$ref": "#/components/schemas/StreamNameStr", - "description": "Stream name that is unique to the basin.\nIt can be between 1 and 512 bytes in length." - } - } - }, - "DeleteOnEmptyConfig": { - "type": "object", - "properties": { - "min_age_secs": { - "type": "integer", - "format": "int64", - "description": "Minimum age in seconds before an empty stream can be deleted.\nSet to 0 (default) to disable delete-on-empty (don't delete automatically).", - "minimum": 0 - } - } - }, - "DeleteOnEmptyReconfiguration": { - "type": "object", - "properties": { - "min_age_secs": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "description": "Minimum age in seconds before an empty stream can be deleted.\nSet to 0 to disable delete-on-empty (don't delete automatically).", - "minimum": 0 - } - } - }, - "ErrorInfo": { - "type": "object", - "required": [ - "code", - "message" - ], - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - } - } - }, - "FencingToken": { - "type": "string", - "maxLength": 36 - }, - "Format": { - "type": "string", - "enum": [ - "raw", - "base64" - ] - }, - "GaugeMetric": { - "type": "object", - "required": [ - "name", - "unit", - "values" - ], - "properties": { - "name": { - "type": "string", - "description": "Timeseries name." - }, - "unit": { - "$ref": "#/components/schemas/MetricUnit", - "description": "Unit of the metric." - }, - "values": { - "type": "array", - "items": { - "type": "array", - "items": false, - "prefixItems": [ - { - "type": "integer", - "format": "int32", - "minimum": 0 - }, - { - "type": "number", - "format": "double" - } - ] - }, - "description": "Timeseries values.\nEach element is a tuple of a timestamp in Unix epoch seconds and a data point.\nThe data point represents the value at the instant of the timestamp." - } - } - }, - "Header": { - "type": "array", - "items": { - "type": "string", - "description": "Headers add structured information to a record as name-value pairs.\n\nThe name cannot be empty, with the exception of an S2 command record." - }, - "description": "Headers add structured information to a record as name-value pairs.\n\nThe name cannot be empty, with the exception of an S2 command record.", - "maxItems": 2, - "minItems": 2 - }, - "InfiniteRetention": { - "type": "object" - }, - "IssueAccessTokenResponse": { - "type": "object", - "required": [ - "access_token" - ], - "properties": { - "access_token": { - "type": "string", - "description": "Created access token." - } - } - }, - "LabelMetric": { - "type": "object", - "required": [ - "name", - "values" - ], - "properties": { - "name": { - "type": "string", - "description": "Label name." - }, - "values": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Label values." - } - } - }, - "ListAccessTokensResponse": { - "type": "object", - "required": [ - "access_tokens", - "has_more" - ], - "properties": { - "access_tokens": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AccessTokenInfo" - }, - "description": "Matching access tokens.", - "maxItems": 1000 - }, - "has_more": { - "type": "boolean", - "description": "Indicates that there are more access tokens that match the criteria." - } - } - }, - "ListBasinsResponse": { - "type": "object", - "required": [ - "basins", - "has_more" - ], - "properties": { - "basins": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BasinInfo" - }, - "description": "Matching basins.", - "maxItems": 1000 - }, - "has_more": { - "type": "boolean", - "description": "Indicates that there are more basins that match the criteria." - } - } - }, - "ListStreamsResponse": { - "type": "object", - "required": [ - "streams", - "has_more" - ], - "properties": { - "has_more": { - "type": "boolean", - "description": "Indicates that there are more results that match the criteria." - }, - "streams": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StreamInfo" - }, - "description": "Matching streams.", - "maxItems": 1000 - } - } - }, - "Metric": { - "oneOf": [ - { - "type": "object", - "description": "Single named value.", - "required": [ - "scalar" - ], - "properties": { - "scalar": { - "$ref": "#/components/schemas/ScalarMetric", - "description": "Single named value." - } - } - }, - { - "type": "object", - "description": "Named series of `(timestamp, value)` points representing an accumulation over a specified interval.", - "required": [ - "accumulation" - ], - "properties": { - "accumulation": { - "$ref": "#/components/schemas/AccumulationMetric", - "description": "Named series of `(timestamp, value)` points representing an accumulation over a specified interval." - } - } - }, - { - "type": "object", - "description": "Named series of `(timestamp, value)` points each representing an instantaneous value.", - "required": [ - "gauge" - ], - "properties": { - "gauge": { - "$ref": "#/components/schemas/GaugeMetric", - "description": "Named series of `(timestamp, value)` points each representing an instantaneous value." - } - } - }, - { - "type": "object", - "description": "Set of string labels.", - "required": [ - "label" - ], - "properties": { - "label": { - "$ref": "#/components/schemas/LabelMetric", - "description": "Set of string labels." - } - } - } - ] - }, - "MetricSetResponse": { - "type": "object", - "required": [ - "values" - ], - "properties": { - "values": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Metric" - }, - "description": "Metrics comprising the set." - } - } - }, - "MetricUnit": { - "type": "string", - "enum": [ - "bytes", - "operations" - ] - }, - "Operation": { - "type": "string", - "enum": [ - "list-basins", - "create-basin", - "delete-basin", - "reconfigure-basin", - "get-basin-config", - "issue-access-token", - "revoke-access-token", - "list-access-tokens", - "list-streams", - "create-stream", - "delete-stream", - "get-stream-config", - "reconfigure-stream", - "check-tail", - "append", - "read", - "trim", - "fence", - "account-metrics", - "basin-metrics", - "stream-metrics" - ] - }, - "PermittedOperationGroups": { - "type": "object", - "properties": { - "account": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ReadWritePermissions", - "description": "Account-level access permissions." - } - ] - }, - "basin": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ReadWritePermissions", - "description": "Basin-level access permissions." - } - ] - }, - "stream": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ReadWritePermissions", - "description": "Stream-level access permissions." - } - ] - } - } - }, - "PingEventData": { - "type": "object", - "required": [ - "timestamp" - ], - "properties": { - "timestamp": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - }, - "ReadBatch": { - "type": "object", - "required": [ - "records" - ], - "properties": { - "records": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SequencedRecord" - }, - "description": "Records that are durably sequenced on the stream, retrieved based on the requested criteria.\nThis can only be empty in response to a unary read (i.e. not SSE), if the request cannot be satisfied without violating an explicit bound (`count`, `bytes`, or `until`)." - }, - "tail": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/StreamPosition", - "description": "Sequence number that will be assigned to the next record on the stream, and timestamp of the last record.\nThis will only be present when reading recent records." - } - ] - } - } - }, - "ReadEvent": { - "oneOf": [ - { - "type": "object", - "title": "batch", - "required": [ - "event", - "data", - "id" - ], - "properties": { - "data": { - "$ref": "#/components/schemas/ReadBatch" - }, - "event": { - "type": "string", - "enum": [ - "batch" - ] - }, - "id": { - "type": "string", - "pattern": "^[0-9]+,[0-9]+,[0-9]+$" - } - } - }, - { - "type": "object", - "title": "error", - "required": [ - "event", - "data" - ], - "properties": { - "data": { - "type": "string" - }, - "event": { - "type": "string", - "enum": [ - "error" - ] - } - } - }, - { - "type": "object", - "title": "ping", - "required": [ - "event", - "data" - ], - "properties": { - "data": { - "$ref": "#/components/schemas/PingEventData" - }, - "event": { - "type": "string", - "enum": [ - "ping" - ] - } - } - } - ] - }, - "ReadWritePermissions": { - "type": "object", - "properties": { - "read": { - "type": "boolean", - "description": "Read permission.", - "default": false - }, - "write": { - "type": "boolean", - "description": "Write permission.", - "default": false - } - } - }, - "RequestToken": { - "type": "string", - "maxLength": 36 - }, - "ResourceSet": { - "oneOf": [ - { - "type": "object", - "title": "exact", - "description": "Match only the resource with this exact name.\nUse an empty string to match no resources.", - "required": [ - "exact" - ], - "properties": { - "exact": { - "type": "string", - "description": "Match only the resource with this exact name.\nUse an empty string to match no resources." - } - } - }, - { - "type": "object", - "title": "prefix", - "description": "Match all resources that start with this prefix.\nUse an empty string to match all resource.", - "required": [ - "prefix" - ], - "properties": { - "prefix": { - "type": "string", - "description": "Match all resources that start with this prefix.\nUse an empty string to match all resource." - } - } - } - ] - }, - "RetentionPolicy": { - "oneOf": [ - { - "type": "object", - "description": "Age in seconds for automatic trimming of records older than this threshold.\nThis must be set to a value greater than 0 seconds.", - "required": [ - "age" - ], - "properties": { - "age": { - "type": "integer", - "format": "int64", - "description": "Age in seconds for automatic trimming of records older than this threshold.\nThis must be set to a value greater than 0 seconds.", - "minimum": 0 - } - } - }, - { - "type": "object", - "description": "Retain records unless explicitly trimmed.", - "required": [ - "infinite" - ], - "properties": { - "infinite": { - "$ref": "#/components/schemas/InfiniteRetention", - "description": "Retain records unless explicitly trimmed." - } - } - } - ] - }, - "ScalarMetric": { - "type": "object", - "required": [ - "name", - "unit", - "value" - ], - "properties": { - "name": { - "type": "string", - "description": "Metric name." - }, - "unit": { - "$ref": "#/components/schemas/MetricUnit", - "description": "Unit of the metric." - }, - "value": { - "type": "number", - "format": "double", - "description": "Metric value." - } - } - }, - "SequencedRecord": { - "type": "object", - "description": "Record that is durably sequenced on a stream.", - "required": [ - "seq_num", - "timestamp" - ], - "properties": { - "body": { - "type": "string", - "description": "Body of the record." - }, - "headers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Header" - }, - "description": "Series of name-value pairs for this record." - }, - "seq_num": { - "$ref": "#/components/schemas/u64", - "description": "Sequence number assigned by the service." - }, - "timestamp": { - "$ref": "#/components/schemas/u64", - "description": "Timestamp for this record." - } - } - }, - "StorageClass": { - "type": "string", - "enum": [ - "standard", - "express" - ] - }, - "StreamConfig": { - "type": "object", - "properties": { - "delete_on_empty": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/DeleteOnEmptyConfig", - "description": "Delete-on-empty configuration." - } - ] - }, - "retention_policy": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/RetentionPolicy", - "description": "Retention policy for the stream.\nIf unspecified, the default is to retain records for 7 days." - } - ] - }, - "storage_class": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/StorageClass", - "description": "Storage class for recent writes." - } - ] - }, - "timestamping": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/TimestampingConfig", - "description": "Timestamping behavior." - } - ] - } - } - }, - "StreamInfo": { - "type": "object", - "required": [ - "name", - "created_at" - ], - "properties": { - "created_at": { - "type": "string", - "format": "date-time", - "description": "Creation time in RFC 3339 format." - }, - "deleted_at": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Deletion time in RFC 3339 format, if the stream is being deleted." - }, - "name": { - "$ref": "#/components/schemas/StreamNameStr", - "description": "Stream name." - } - } - }, - "StreamMetricSet": { - "type": "string", - "enum": [ - "storage" - ] - }, - "StreamNameStr": { - "type": "string", - "maxLength": 512, - "minLength": 1 - }, - "StreamPosition": { - "type": "object", - "description": "Position of a record in a stream.", - "required": [ - "seq_num", - "timestamp" - ], - "properties": { - "seq_num": { - "$ref": "#/components/schemas/u64", - "description": "Sequence number assigned by the service." - }, - "timestamp": { - "$ref": "#/components/schemas/u64", - "description": "Timestamp, which may be client-specified or assigned by the service.\nIf it is assigned by the service, it will represent milliseconds since Unix epoch." - } - } - }, - "StreamReconfiguration": { - "type": "object", - "properties": { - "delete_on_empty": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/DeleteOnEmptyReconfiguration", - "description": "Delete-on-empty configuration." - } - ] - }, - "retention_policy": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/RetentionPolicy", - "description": "Retention policy for the stream.\nIf unspecified, the default is to retain records for 7 days." - } - ] - }, - "storage_class": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/StorageClass", - "description": "Storage class for recent writes." - } - ] - }, - "timestamping": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/TimestampingReconfiguration", - "description": "Timestamping behavior." - } - ] - } - } - }, - "TailResponse": { - "type": "object", - "required": [ - "tail" - ], - "properties": { - "tail": { - "$ref": "#/components/schemas/StreamPosition", - "description": "Sequence number that will be assigned to the next record on the stream, and timestamp of the last record." - } - } - }, - "TimeseriesInterval": { - "type": "string", - "enum": [ - "minute", - "hour", - "day" - ] - }, - "TimestampingConfig": { - "type": "object", - "properties": { - "mode": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/TimestampingMode", - "description": "Timestamping mode for appends that influences how timestamps are handled." - } - ] - }, - "uncapped": { - "type": [ - "boolean", - "null" - ], - "description": "Allow client-specified timestamps to exceed the arrival time.\nIf this is `false` or not set, client timestamps will be capped at the arrival time." - } - } - }, - "TimestampingMode": { - "type": "string", - "enum": [ - "client-prefer", - "client-require", - "arrival" - ] - }, - "TimestampingReconfiguration": { - "type": "object", - "properties": { - "mode": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/TimestampingMode", - "description": "Timestamping mode for appends that influences how timestamps are handled." - } - ] - }, - "uncapped": { - "type": [ - "boolean", - "null" - ], - "description": "Allow client-specified timestamps to exceed the arrival time." - } - } - }, - "u64": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - "securitySchemes": { - "access_token": { - "type": "http", - "scheme": "bearer", - "description": "Bearer authentication header of the form `Bearer `, where `` is your access token." - } - } - }, - "security": [ - { - "access_token": [] - } - ], - "tags": [ - { - "name": "metrics", - "description": "Usage metrics and data." - }, - { - "name": "basins", - "description": "Manage basins" - }, - { - "name": "access-tokens", - "description": "Manage access tokens" - }, - { - "name": "streams", - "description": "Manage streams" - }, - { - "name": "records", - "description": "Manage records" - } - ] -} diff --git a/api/specs/s2/v1/s2.proto b/api/specs/s2/v1/s2.proto deleted file mode 100644 index 91a289f8..00000000 --- a/api/specs/s2/v1/s2.proto +++ /dev/null @@ -1,81 +0,0 @@ -syntax = "proto3"; - -package s2.v1; - -option java_multiple_files = true; -option java_package = "s2.v1"; - -// Position of a record in a stream. -message StreamPosition { - // Sequence number assigned by the service. - uint64 seq_num = 1; - // Timestamp, which may be user-specified or assigned by the service. - // If it is assigned by the service, it will represent milliseconds since Unix epoch. - uint64 timestamp = 2; -} - -// Headers add structured information to a record as name-value pairs. -message Header { - // Header name blob. - // The name cannot be empty, with the exception of an S2 command record. - bytes name = 1; - // Header value blob. - bytes value = 2; -} - -// Record to be appended to a stream. -message AppendRecord { - // Timestamp for this record. - // Precise semantics depend on the stream's `timestamping` config. - optional uint64 timestamp = 1; - // Series of name-value pairs for this record. - repeated Header headers = 2; - // Body of this record. - bytes body = 3; -} - -// Payload of an Append request message. -message AppendInput { - // Batch of records to append atomically, which must contain at least one record, and no more than 1000. - // The total size of a batch of records may not exceed 1MiB of metered bytes. - repeated AppendRecord records = 1; - // Enforce that the sequence number issued to the first record matches. - optional uint64 match_seq_num = 2; - // Enforce a fencing token which must have been previously set by a `fence` command record. - optional string fencing_token = 3; -} - -// Success response message to an Append request. -message AppendAck { - // Sequence number and timestamp of the first record that was appended. - StreamPosition start = 1; - // Sequence number of the last record that was appended + 1, and timestamp of the last record that was appended. - // The difference between `end.seq_num` and `start.seq_num` will be the number of records appended. - StreamPosition end = 2; - // Sequence number that will be assigned to the next record on the stream, and timestamp of the last record on the stream. - // This can be greater than the `end` position in case of concurrent appends. - StreamPosition tail = 3; -} - -// Record that is durably sequenced on a stream. -message SequencedRecord { - // Sequence number assigned to this record. - uint64 seq_num = 1; - // Timestamp for this record. - uint64 timestamp = 2; - // Series of name-value pairs for this record. - repeated Header headers = 3; - // Body of this record. - bytes body = 4; -} - -// Success response message to a Read request. -message ReadBatch { - // Records that are durably sequenced on the stream, retrieved based on the requested criteria. - // This can only be empty in response to a unary read if the request cannot be satisfied without violating an explicit bound (`count`, `bytes`, or `until`). - // In the context of a session, it can be empty as a heartbeat message. A heartbeat will be sent whenever a switch to following in real-time happens, and then at a randomized gap between 5 and 15 seconds if no records have become available. - repeated SequencedRecord records = 1; - // Sequence number that will be assigned to the next record on the stream, and timestamp of the last record. - // It will only be present when reading recent records. - optional StreamPosition tail = 2; -} diff --git a/api/specs/s2/v1alpha/s2.proto b/api/specs/s2/v1alpha/s2.proto deleted file mode 100644 index 27f38aab..00000000 --- a/api/specs/s2/v1alpha/s2.proto +++ /dev/null @@ -1,734 +0,0 @@ -syntax = "proto3"; - -package s2.v1alpha; - -import "google/protobuf/field_mask.proto"; - -option java_multiple_files = true; -option java_package = "s2.v1alpha"; - -// Operate on an S2 account. -service AccountService { - // List basins. - rpc ListBasins(ListBasinsRequest) returns (ListBasinsResponse) { - option idempotency_level = NO_SIDE_EFFECTS; - } - - // Create a new basin. - // Provide a client request token with the `S2-Request-Token` header for idempotent retry behaviour. - rpc CreateBasin(CreateBasinRequest) returns (CreateBasinResponse) { - option idempotency_level = IDEMPOTENT; - } - - // Delete a basin. - // Basin deletion is asynchronous, and may take a few minutes to complete. - rpc DeleteBasin(DeleteBasinRequest) returns (DeleteBasinResponse) { - option idempotency_level = IDEMPOTENT; - } - - // Update basin configuration. - rpc ReconfigureBasin(ReconfigureBasinRequest) returns (ReconfigureBasinResponse) { - option idempotency_level = IDEMPOTENT; - } - - // Get basin configuration. - rpc GetBasinConfig(GetBasinConfigRequest) returns (GetBasinConfigResponse) { - option idempotency_level = NO_SIDE_EFFECTS; - } - - // Issue a new access token. - rpc IssueAccessToken(IssueAccessTokenRequest) returns (IssueAccessTokenResponse); - - // Revoke an access token. - rpc RevokeAccessToken(RevokeAccessTokenRequest) returns (RevokeAccessTokenResponse) { - option idempotency_level = IDEMPOTENT; - } - - // List access tokens. - rpc ListAccessTokens(ListAccessTokensRequest) returns (ListAccessTokensResponse) { - option idempotency_level = NO_SIDE_EFFECTS; - } -} - -// Operate on an S2 basin. -service BasinService { - // List streams. - rpc ListStreams(ListStreamsRequest) returns (ListStreamsResponse) { - option idempotency_level = NO_SIDE_EFFECTS; - } - - // Create a stream. - // Provide a client request token with the `S2-Request-Token` header for idempotent retry behaviour. - rpc CreateStream(CreateStreamRequest) returns (CreateStreamResponse) { - option idempotency_level = IDEMPOTENT; - } - - // Delete a stream. - // Stream deletion is asynchronous, and may take a few minutes to complete. - rpc DeleteStream(DeleteStreamRequest) returns (DeleteStreamResponse) { - option idempotency_level = IDEMPOTENT; - } - - // Get stream configuration. - rpc GetStreamConfig(GetStreamConfigRequest) returns (GetStreamConfigResponse) { - option idempotency_level = NO_SIDE_EFFECTS; - } - - // Update stream configuration. - rpc ReconfigureStream(ReconfigureStreamRequest) returns (ReconfigureStreamResponse) { - option idempotency_level = IDEMPOTENT; - } -} - -// Operate on an S2 stream. -service StreamService { - // Check the tail of the stream. - rpc CheckTail(CheckTailRequest) returns (CheckTailResponse) { - option idempotency_level = NO_SIDE_EFFECTS; - } - - // Append a batch of records to a stream. - rpc Append(AppendRequest) returns (AppendResponse); - - // Append batches of records to a stream continuously, while guaranteeing pipelined requests are processed in order. - // If any request fails, the session is terminated. - rpc AppendSession(stream AppendSessionRequest) returns (stream AppendSessionResponse); - - // Retrieve a batch of records from a stream. - rpc Read(ReadRequest) returns (ReadResponse) { - option idempotency_level = NO_SIDE_EFFECTS; - } - - // Retrieve batches of records from a stream continuously. - rpc ReadSession(ReadSessionRequest) returns (stream ReadSessionResponse) { - option idempotency_level = NO_SIDE_EFFECTS; - } -} - -/// AccountService /// - -// List basins request. -message ListBasinsRequest { - // List basin names that begin with this prefix. - string prefix = 1; - // Only return basins names that lexicographically start after this name. - // This can be the last basin name seen in a previous listing, to continue from there. - // It must be greater than or equal to the prefix if specified. - string start_after = 2; - // Number of results, up to a maximum of 1000. - optional uint64 limit = 3; -} - -// List basins response. -message ListBasinsResponse { - // Matching basins. - repeated BasinInfo basins = 1; - // If set, indicates there are more results that can be listed with `start_after`. - bool has_more = 2; -} - -// Create basin request. -message CreateBasinRequest { - // Basin name, which must be globally unique. - // The name must be between 8 and 48 characters, comprising lowercase letters, numbers and hyphens. - // It cannot begin or end with a hyphen. - string basin = 1; - // Basin configuration. - BasinConfig config = 2; - // Basin scope. - BasinScope scope = 3; -} - -// Basin scope. -enum BasinScope { - // Unspecified basin scope. - BASIN_SCOPE_UNSPECIFIED = 0; - // aws us-east-1 region. - BASIN_SCOPE_AWS_US_EAST_1 = 1; -} - -// Create basin response. -message CreateBasinResponse { - // Information about the newly created basin. - BasinInfo info = 1; -} - -// Delete basin request. -message DeleteBasinRequest { - // Name of the basin to delete. - string basin = 1; -} - -// Delete basin response. -message DeleteBasinResponse {} - -// Get basin configuration request. -message GetBasinConfigRequest { - // Basin name. - string basin = 1; -} - -// Get basin configuration response. -message GetBasinConfigResponse { - // Basin configuration. - BasinConfig config = 1; -} - -// Reconfigure basin request. -message ReconfigureBasinRequest { - // Basin name. - string basin = 1; - // Basin configuration. - BasinConfig config = 2; - // Specifies the pieces of configuration being updated. - // See https://protobuf.dev/reference/protobuf/google.protobuf/#field-mask - google.protobuf.FieldMask mask = 3; -} - -// Reconfigure basin response. -message ReconfigureBasinResponse { - // Basin configuration. - BasinConfig config = 1; -} - -// Issue access token request. -message IssueAccessTokenRequest { - // Access token information. - AccessTokenInfo info = 1; -} - -// API operations. -enum Operation { - // Unspecified operation. - OPERATION_UNSPECIFIED = 0; - // List basins. - OPERATION_LIST_BASINS = 1; - // Create a basin. - OPERATION_CREATE_BASIN = 2; - // Delete a basin. - OPERATION_DELETE_BASIN = 3; - // Update basin configuration. - OPERATION_RECONFIGURE_BASIN = 4; - // Get basin configuration. - OPERATION_GET_BASIN_CONFIG = 5; - // Issue an access token. - OPERATION_ISSUE_ACCESS_TOKEN = 6; - // Revoke an access token. - OPERATION_REVOKE_ACCESS_TOKEN = 7; - // List access tokens. - OPERATION_LIST_ACCESS_TOKENS = 8; - // List streams. - OPERATION_LIST_STREAMS = 9; - // Create a stream. - OPERATION_CREATE_STREAM = 10; - // Delete a stream. - OPERATION_DELETE_STREAM = 11; - // Get stream configuration. - OPERATION_GET_STREAM_CONFIG = 12; - // Update stream configuration. - OPERATION_RECONFIGURE_STREAM = 13; - // Check tail of a stream. - OPERATION_CHECK_TAIL = 14; - // Append records to a stream. - OPERATION_APPEND = 15; - // Read records from a stream. - OPERATION_READ = 16; - // Trim records up to a sequence number. - OPERATION_TRIM = 17; - // Set a fencing token for a stream. - OPERATION_FENCE = 18; - // Retrieve account-level metrics. - OPERATION_ACCOUNT_METRICS = 19; - // Retrieve basin-level metrics. - OPERATION_BASIN_METRICS = 20; - // Retrieve stream-level metrics. - OPERATION_STREAM_METRICS = 21; -} - -// Read/Write permissions. -message ReadWritePermissions { - // Read permission. - bool read = 1; - // Write permission. - bool write = 2; -} - -// Access permissions for a group. -message PermittedOperationGroups { - // Access permissions at account level. - ReadWritePermissions account = 1; - // Access permissions at basin level. - ReadWritePermissions basin = 2; - // Access permissions at stream level. - ReadWritePermissions stream = 3; -} - -// Revoke access token request. -message RevokeAccessTokenRequest { - // Token to revoke. - string id = 1; -} - -// Revoke access token response. -message RevokeAccessTokenResponse { - // Access token information. - AccessTokenInfo info = 1; -} - -// List access tokens request. -message ListAccessTokensRequest { - // List access tokens that begin with this prefix. - string prefix = 1; - // Only return access tokens that lexicographically start after this ID. - string start_after = 2; - // Number of results, up to a maximum of 1000. - optional uint64 limit = 3; -} - -// List access tokens response. -message ListAccessTokensResponse { - // Access tokens information. - repeated AccessTokenInfo access_tokens = 1; - // If set, indicates there are more results that can be listed with `start_after`. - bool has_more = 2; -} - -// Access token information. -message AccessTokenInfo { - // Access token ID. - // It must be unique to the account and between 1 and 96 characters. - string id = 1; - // Expiration time in seconds since Unix epoch. - // If not set, the expiration will be set to that of the requestor's token. - optional uint32 expires_at = 2; - // Namespace streams based on the configured stream-level scope, which must be a prefix. - // Stream name arguments will be automatically prefixed, and the prefix will be stripped - // when listing streams. - bool auto_prefix_streams = 3; - // Access token scope. - AccessTokenScope scope = 4; -} - -// Access token scope. -message AccessTokenScope { - // Basin names allowed. - ResourceSet basins = 1; - // Stream names allowed. - ResourceSet streams = 2; - // Token IDs allowed. - ResourceSet access_tokens = 3; - // Access permissions at operation group level. - PermittedOperationGroups op_groups = 4; - // Operations allowed for the token. - // A union of allowed operations and groups is used as an effective set of allowed operations. - repeated Operation ops = 5; -} - -// Set of named resources. -message ResourceSet { - // Matching rule. - oneof matching { - // Match only the resource with this exact name. - // Use an empty string to match no resources. - string exact = 1; - // Match all resources that start with this prefix. - // Use an empty string to match all resource. - string prefix = 2; - } -} - -// Issue access token response. -message IssueAccessTokenResponse { - // Created access token. - string access_token = 1; -} - -/// BasinService /// - -// Stream information. -message StreamInfo { - // Stream name. - string name = 1; - // Creation time in seconds since Unix epoch. - uint32 created_at = 2; - // Deletion time in seconds since Unix epoch, if the stream is being deleted. - optional uint32 deleted_at = 3; -} - -// List streams request. -message ListStreamsRequest { - // List stream names that begin with this prefix. - string prefix = 1; - // Only return stream names that lexicographically start after this name. - // This can be the last stream name seen in a previous listing, to continue from there. - // It must be greater than or equal to the prefix if specified. - string start_after = 2; - // Number of results, up to a maximum of 1000. - optional uint64 limit = 3; -} - -// List streams response. -message ListStreamsResponse { - // Matching streams. - repeated StreamInfo streams = 1; - // If set, indicates there are more results that can be listed with `start_after`. - bool has_more = 2; -} - -// Create stream request. -message CreateStreamRequest { - // Stream name, which must be unique within the basin. - // It can be an arbitrary string upto 512 characters. - // Backslash (`/`) is recommended as a delimiter for hierarchical naming. - string stream = 1; - // Configuration for the new stream. - StreamConfig config = 2; -} - -// Create stream response. -message CreateStreamResponse { - // Information about the newly created stream. - StreamInfo info = 1; -} - -// Delete stream request. -message DeleteStreamRequest { - // Stream name. - string stream = 1; -} - -// Delete stream response. -message DeleteStreamResponse {} - -// Get stream configuration request. -message GetStreamConfigRequest { - // Stream name. - string stream = 1; -} - -// Get stream configuration response. -message GetStreamConfigResponse { - // Stream configuration. - StreamConfig config = 1; -} - -// Reconfigure stream request. -message ReconfigureStreamRequest { - // Stream name. - string stream = 1; - // Stream configuration with updated values. - StreamConfig config = 2; - // Specifies the pieces of configuration being updated. - // See https://protobuf.dev/reference/protobuf/google.protobuf/#field-mask - google.protobuf.FieldMask mask = 3; -} - -// Reconfigure stream response. -message ReconfigureStreamResponse { - // Stream configuration. - StreamConfig config = 1; -} - -/// StreamService /// - -// Check tail request. -message CheckTailRequest { - // Stream name. - string stream = 1; -} - -// Check tail response. -message CheckTailResponse { - // Sequence number that will be assigned to the next record on the stream. - // It will be 0 for a stream that has not been written to. - uint64 next_seq_num = 1; - // Timestamp of the last durable record on the stream. - // It starts out as 0 for a new stream. - uint64 last_timestamp = 2; -} - -// Input for append requests. -message AppendInput { - // Stream name. Optional for subsequent requests in the session. - string stream = 1; - // Batch of records to append atomically, which must contain at least one record, and no more than 1000. - // The total size of a batch of records may not exceed 1MiB of metered bytes. - repeated AppendRecord records = 2; - // Enforce that the sequence number issued to the first record matches. - optional uint64 match_seq_num = 3; - // Enforce a fencing token which must have been previously set by a `fence` command record. - optional string fencing_token = 4; -} - -// Output from append response. -message AppendOutput { - // Sequence number of first record appended. - uint64 start_seq_num = 1; - // Timestamp of the first record appended. - uint64 start_timestamp = 4; - // Sequence number of last record appended + 1. - // `end_seq_num - start_seq_num` will be the number of records in the batch. - uint64 end_seq_num = 2; - // Timestamp of the last record appended. - uint64 end_timestamp = 5; - // Tail of the stream, i.e. sequence number that will be assigned to the next record. - // This can be greater than `end_seq_num` in case of concurrent appends. - uint64 next_seq_num = 3; - // Timestamp of the last durable record on the stream. - uint64 last_timestamp = 6; -} - -// Append request. -message AppendRequest { - // Request parameters for an append. - AppendInput input = 1; -} - -// Append response. -message AppendResponse { - // Response details for an append. - AppendOutput output = 1; -} - -// Append session request. -message AppendSessionRequest { - // Request parameters for an append. - AppendInput input = 1; -} - -// Append session response. -message AppendSessionResponse { - // Response details for an append. - AppendOutput output = 1; -} - -// Output of a read. -message ReadOutput { - // Batch of records or the next sequence number on the stream. - oneof output { - // Batch of records. - // It can only be empty when not in a session context, - // if the request cannot be satisfied without violating its limit. - SequencedRecordBatch batch = 1; - // Tail of the stream, i.e. sequence number that will be assigned to the next record. - // It will be returned if the requested starting position is greater than the tail, - // or only in case of a limited read, equal to it. - // It will also be returned if there are no records on the stream between the - // requested starting position and the tail. - uint64 next_seq_num = 3; - } - // first_seq_num. - reserved 2; -} - -// Read request. -message ReadRequest { - // Stream name. - string stream = 1; - // Starting position for records. - // Retrieved batches will start at the first record whose position is greater than or equal to it. - oneof start { - // Sequence number. - uint64 seq_num = 2; - // Timestamp. - uint64 timestamp = 4; - // Number of records before the tail, i.e. before the next sequence number. - uint64 tail_offset = 5; - } - // Limit how many records can be returned. - // This will get capped at the default limit, - // which is up to 1000 records or 1MiB of metered bytes. - ReadLimit limit = 3; - // Exclusive timestamp to read until. - // If provided, this is applied as an additional constraint on top of the `limit`, - // and will guarantee that all records returned have timestamps < the provided `until`. - optional uint64 until = 6; - // Start reading from the tail if the requested position is beyond it. - // Otherwise, the next sequence number will be returned. - bool clamp = 7; -} - -// Read response. -message ReadResponse { - // Response details for a read. - ReadOutput output = 1; -} - -// Limit how many records can be retrieved. -// If both count and bytes are specified, either limit may be hit. -message ReadLimit { - // Record count limit. - optional uint64 count = 1; - // Metered bytes limit. - optional uint64 bytes = 2; -} - -// Read session request. -message ReadSessionRequest { - // Stream name. - string stream = 1; - // Starting position for records. - // Retrieved batches will start at the first record whose position is greater than or equal to it. - oneof start { - // Sequence number. - uint64 seq_num = 2; - // Timestamp. - uint64 timestamp = 5; - // Number of records before the tail, i.e. the next sequence number. - uint64 tail_offset = 6; - } - // Limit on how many records can be returned. When a limit is specified, the session will be terminated as soon as - // the limit is met, or when the current tail of the stream is reached -- whichever occurs first. - // If no limit is specified, the session will remain open after catching up to the tail, and continue following - // in real-time as new messages are written to the stream. - ReadLimit limit = 3; - // Heartbeats can be enabled to monitor end-to-end session health. - // A heartbeat will be sent when the initial switch to following in real-time happens, - // as well as when no records are available at a randomized interval between 5 and 15 seconds. - bool heartbeats = 4; - // Exclusive timestamp to read until. - // If provided, this is applied as an additional constraint on top of the `limit`, - // and will guarantee that all records returned have timestamps < the provided `until`. - optional uint64 until = 7; - // Start reading from the tail if the requested position is beyond it. - // Otherwise, the next sequence number will be returned. - bool clamp = 8; -} - -// Read session response. -message ReadSessionResponse { - // Response details for a read. - // This will not be set in case of a heartbeat message. - optional ReadOutput output = 1; -} - -/// Common types /// - -// Storage class for recent writes. -enum StorageClass { - // Defaults to `STORAGE_CLASS_EXPRESS`. - STORAGE_CLASS_UNSPECIFIED = 0; - // Standard, which offers end-to-end latencies under 500 ms. - STORAGE_CLASS_STANDARD = 1; - // Express, which offers end-to-end latencies under 50 ms. - STORAGE_CLASS_EXPRESS = 2; -} - -// Timestamping mode. -// Note that arrival time is always in milliseconds since Unix epoch. -enum TimestampingMode { - // Defaults to `TIMESTAMPING_MODE_CLIENT_PREFER`. - TIMESTAMPING_MODE_UNSPECIFIED = 0; - // Prefer client-specified timestamp if present otherwise use arrival time. - TIMESTAMPING_MODE_CLIENT_PREFER = 1; - // Require a client-specified timestamp and reject the append if it is missing. - TIMESTAMPING_MODE_CLIENT_REQUIRE = 2; - // Use the arrival time and ignore any client-specified timestamp. - TIMESTAMPING_MODE_ARRIVAL = 3; -} - -// Stream configuration. -message StreamConfig { - message Timestamping { - // Timestamping mode for appends that influences how timestamps are handled. - TimestampingMode mode = 1; - // Allow client-specified timestamps to exceed the arrival time. - // If this is false or not set, client timestamps will be capped at the arrival time. - optional bool uncapped = 2; - } - message DeleteOnEmpty { - // Minimum age in seconds before an empty stream can be deleted. - // Set to 0 (default) to disable (don't delete automatically). - uint64 min_age_secs = 1; - } - message InfiniteRetention {} - // Storage class for recent writes. - StorageClass storage_class = 1; - // Retention policy for the stream. - // If unspecified, the default is to retain records for 7 days. - oneof retention_policy { - // Age in seconds for automatic trimming of records older than this threshold. - // This must be set to a value greater than 0 seconds. - uint64 age = 2; - // Retain records unless explicitly trimmed. - InfiniteRetention infinite = 7; - } - // Replaced with `timestamping`. - reserved 3, 4; - // Timestamping behavior. - Timestamping timestamping = 5; - // Delete-on-empty configuration. - DeleteOnEmpty delete_on_empty = 6; -} - -// Basin configuration. -message BasinConfig { - // Default stream configuration. - StreamConfig default_stream_config = 1; - // Create stream on append if it doesn't exist, - // using the default stream configuration. - bool create_stream_on_append = 2; - // Create stream on read if it doesn't exist, - // using the default stream configuration. - bool create_stream_on_read = 3; -} - -// Current state of the basin. -enum BasinState { - // Unspecified. - BASIN_STATE_UNSPECIFIED = 0; - // Basin is active. - BASIN_STATE_ACTIVE = 1; - // Basin is being created. - BASIN_STATE_CREATING = 2; - // Basin is being deleted. - BASIN_STATE_DELETING = 3; -} - -// Basin information. -message BasinInfo { - // Basin name. - string name = 1; - // Basin scope. - BasinScope scope = 5; - // Basin state. - BasinState state = 4; -} - -// Headers add structured information to a record as name-value pairs. -message Header { - // Header name blob. - // The name cannot be empty, with the exception of an S2 command record. - bytes name = 1; - // Header value blob. - bytes value = 2; -} - -// Record to be appended to a stream. -message AppendRecord { - // Timestamp for this record. - // Precise semantics depend on the stream's `timestamping` config. - optional uint64 timestamp = 3; - // Series of name-value pairs for this record. - repeated Header headers = 1; - // Body of this record. - bytes body = 2; -} - -// Record retrieved from a stream. -message SequencedRecord { - // Sequence number assigned to this record. - uint64 seq_num = 1; - // Timestamp for this record. - uint64 timestamp = 4; - // Series of name-value pairs for this record. - repeated Header headers = 2; - // Body of this record. - bytes body = 3; -} - -// A batch of sequenced records. -message SequencedRecordBatch { - // Batch of sequenced records. - repeated SequencedRecord records = 1; -} From f3220c4abe06b4471e8c0c8882b7365a31c806f1 Mon Sep 17 00:00:00 2001 From: shikhar Date: Wed, 14 Jan 2026 16:55:10 -0500 Subject: [PATCH 056/700] release: 0.2.1 --- Cargo.lock | 6 +++--- Cargo.toml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 07dbfa14..a7d1125c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3397,7 +3397,7 @@ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "s2-api" -version = "0.2.0" +version = "0.2.1" dependencies = [ "axum", "base64ct", @@ -3426,7 +3426,7 @@ dependencies = [ [[package]] name = "s2-common" -version = "0.2.0" +version = "0.2.1" dependencies = [ "axum", "blake3", @@ -3449,7 +3449,7 @@ dependencies = [ [[package]] name = "s2-lite" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-stream", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 9f1ed0c3..003f2314 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["api", "common", "lite"] [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2024" license = "MIT" repository = "https://github.com/s2-streamstore/s2" @@ -40,8 +40,8 @@ rand = "0.9" rcgen = "0.14" rkyv = "0.8" rstest = "0.26" -s2-api = { version = "0.2.0", path = "api" } -s2-common = { version = "0.2.0", path = "common" } +s2-api = { version = "0.2.1", path = "api" } +s2-common = { version = "0.2.1", path = "common" } serde = "1.0" serde_json = "1.0" slatedb = "0.10" From 010a9280664bec0f9ca996128e53e8a5464e2d69 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Wed, 14 Jan 2026 23:31:23 -0500 Subject: [PATCH 057/700] chore: updated GH workflow to sync specs submodule (#56) --- .../{update_protos.yaml => sync_specs.yaml} | 18 +++++++++--------- .gitmodules | 4 ++-- api/specs | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) rename .github/workflows/{update_protos.yaml => sync_specs.yaml} (60%) diff --git a/.github/workflows/update_protos.yaml b/.github/workflows/sync_specs.yaml similarity index 60% rename from .github/workflows/update_protos.yaml rename to .github/workflows/sync_specs.yaml index 0c4538cf..09450b85 100644 --- a/.github/workflows/update_protos.yaml +++ b/.github/workflows/sync_specs.yaml @@ -1,4 +1,4 @@ -name: Update protos submodule +name: Sync specs submodule permissions: contents: write @@ -6,28 +6,28 @@ permissions: on: repository_dispatch: - types: [s2-proto-update] + types: [s2-specs-update] jobs: - update-submodule: + sync-submodule: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: submodules: true - - name: Update Submodules + - name: Update submodule id: submodules uses: sgoudham/update-git-submodules@v2.1.1 with: - submodules: protos + submodules: specs - - name: Create Pull Request - if: ${{ steps.submodules.outputs['protos--updated'] }} + - name: Create pull request + if: ${{ steps.submodules.outputs['specs--updated'] }} uses: peter-evans/create-pull-request@v7 with: committer: s2-helper[bot] <194906454+s2-helper[bot]@users.noreply.github.com> author: s2-helper[bot] <194906454+s2-helper[bot]@users.noreply.github.com> - title: "chore: update s2-protos submodule" - branch: "protos/update-${{ steps.submodules.outputs['protos--latestShortCommitSha'] }}" + title: "chore: sync specs submodule" + branch: "specs/sync-${{ steps.submodules.outputs['specs--latestShortCommitSha'] }}" body: ${{ steps.submodules.outputs.prBody }} diff --git a/.gitmodules b/.gitmodules index 5d16ac2b..1cdceb7b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "api/specs"] +[submodule "specs"] path = api/specs - url = git@github.com:s2-streamstore/s2-specs.git + url = https://github.com/s2-streamstore/s2-specs.git diff --git a/api/specs b/api/specs index 394385e9..7af374cc 160000 --- a/api/specs +++ b/api/specs @@ -1 +1 @@ -Subproject commit 394385e914781937ecedeed0de2ce8e4da46959e +Subproject commit 7af374ccbd8e43a2042c26853d40b0891d423f1b From 1181c96d3b735cf7e3a695fd631d577e60371876 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Wed, 14 Jan 2026 23:47:18 -0500 Subject: [PATCH 058/700] chore: fix submodule sync workflow (#57) --- .github/workflows/sync_specs.yaml | 6 +++--- .gitmodules | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sync_specs.yaml b/.github/workflows/sync_specs.yaml index 09450b85..27f6aa73 100644 --- a/.github/workflows/sync_specs.yaml +++ b/.github/workflows/sync_specs.yaml @@ -20,14 +20,14 @@ jobs: id: submodules uses: sgoudham/update-git-submodules@v2.1.1 with: - submodules: specs + submodules: api/specs - name: Create pull request - if: ${{ steps.submodules.outputs['specs--updated'] }} + if: ${{ steps.submodules.outputs['api/specs--updated'] }} uses: peter-evans/create-pull-request@v7 with: committer: s2-helper[bot] <194906454+s2-helper[bot]@users.noreply.github.com> author: s2-helper[bot] <194906454+s2-helper[bot]@users.noreply.github.com> title: "chore: sync specs submodule" - branch: "specs/sync-${{ steps.submodules.outputs['specs--latestShortCommitSha'] }}" + branch: "specs/sync-${{ steps.submodules.outputs['api/specs--latestShortCommitSha'] }}" body: ${{ steps.submodules.outputs.prBody }} diff --git a/.gitmodules b/.gitmodules index 1cdceb7b..a2252d13 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "specs"] +[submodule "api/specs"] path = api/specs url = https://github.com/s2-streamstore/s2-specs.git From 5509c5ce7e60909bc404bffc43c35a02f42c2dd1 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Thu, 15 Jan 2026 10:28:14 -0500 Subject: [PATCH 059/700] lite: compression support (`gzip`, `zstd`) (#59) Co-authored-by: Mehul Arora --- Cargo.lock | 36 ++++++++++++++ lite/Cargo.toml | 8 +++- lite/src/bin/server.rs | 3 +- lite/src/handlers/v1/access_tokens.rs | 11 +++++ lite/src/handlers/v1/basins.rs | 14 ++++++ lite/src/handlers/v1/metrics.rs | 8 ++++ lite/src/handlers/v1/mod.rs | 67 +++++++-------------------- lite/src/handlers/v1/records.rs | 8 ++++ lite/src/handlers/v1/streams.rs | 17 +++++++ 9 files changed, 120 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a7d1125c..79d2f324 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -955,6 +967,25 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compression-codecs" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +dependencies = [ + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -4161,13 +4192,18 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", "futures-util", "http 1.4.0", "http-body 1.0.1", + "http-body-util", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", diff --git a/lite/Cargo.toml b/lite/Cargo.toml index 5cfc4cec..d427bdcd 100644 --- a/lite/Cargo.toml +++ b/lite/Cargo.toml @@ -51,7 +51,13 @@ thiserror = { workspace = true } time = { workspace = true, features = ["serde", "formatting", "parsing"] } tokio = { workspace = true, features = ["sync"] } tokio-util = { workspace = true } -tower-http = { workspace = true, features = ["trace"] } +tower-http = { workspace = true, features = [ + "trace", + "compression-zstd", + "compression-gzip", + "decompression-zstd", + "decompression-gzip", +] } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } utoipa = { version = "5.4", optional = true, features = ["time"] } diff --git a/lite/src/bin/server.rs b/lite/src/bin/server.rs index 5ca448a7..039720b4 100644 --- a/lite/src/bin/server.rs +++ b/lite/src/bin/server.rs @@ -60,8 +60,7 @@ struct Args { async fn main() -> eyre::Result<()> { tracing_subscriber::registry() .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "server=info,s2_lite=info,tower_http=info".into()), + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), ) .with(tracing_subscriber::fmt::layer()) .init(); diff --git a/lite/src/handlers/v1/access_tokens.rs b/lite/src/handlers/v1/access_tokens.rs index 004e240a..60ad3684 100644 --- a/lite/src/handlers/v1/access_tokens.rs +++ b/lite/src/handlers/v1/access_tokens.rs @@ -5,6 +5,17 @@ use s2_common::types::access::AccessTokenId; use crate::{backend::Backend, handlers::v1::error::ServiceError}; +pub fn router() -> axum::Router { + use axum::routing::{delete, get, post}; + axum::Router::new() + .route(super::paths::access_tokens::LIST, get(list_access_tokens)) + .route(super::paths::access_tokens::ISSUE, post(issue_access_token)) + .route( + super::paths::access_tokens::REVOKE, + delete(revoke_access_token), + ) +} + #[derive(FromRequest)] #[from_request(rejection(ServiceError))] pub struct ListArgs { diff --git a/lite/src/handlers/v1/basins.rs b/lite/src/handlers/v1/basins.rs index b5affc62..a5b8d2bf 100644 --- a/lite/src/handlers/v1/basins.rs +++ b/lite/src/handlers/v1/basins.rs @@ -15,6 +15,20 @@ use s2_common::{ use crate::{backend::Backend, handlers::v1::error::ServiceError}; +pub fn router() -> axum::Router { + use axum::routing::{delete, get, patch, post, put}; + axum::Router::new() + .route(super::paths::basins::LIST, get(list_basins)) + .route(super::paths::basins::CREATE, post(create_basin)) + .route(super::paths::basins::GET_CONFIG, get(get_basin_config)) + .route( + super::paths::basins::CREATE_OR_RECONFIGURE, + put(create_or_reconfigure_basin), + ) + .route(super::paths::basins::DELETE, delete(delete_basin)) + .route(super::paths::basins::RECONFIGURE, patch(reconfigure_basin)) +} + #[derive(FromRequest)] #[from_request(rejection(ServiceError))] pub struct ListArgs { diff --git a/lite/src/handlers/v1/metrics.rs b/lite/src/handlers/v1/metrics.rs index bac44e64..4df63ecf 100644 --- a/lite/src/handlers/v1/metrics.rs +++ b/lite/src/handlers/v1/metrics.rs @@ -4,6 +4,14 @@ use s2_common::types::{basin::BasinName, stream::StreamName}; use crate::{backend::Backend, handlers::v1::error::ServiceError}; +pub fn router() -> axum::Router { + use axum::routing::get; + axum::Router::new() + .route(super::paths::metrics::ACCOUNT, get(account_metrics)) + .route(super::paths::metrics::BASIN, get(basin_metrics)) + .route(super::paths::metrics::STREAM, get(stream_metrics)) +} + #[derive(FromRequest)] #[from_request(rejection(ServiceError))] pub struct AccountArgs { diff --git a/lite/src/handlers/v1/mod.rs b/lite/src/handlers/v1/mod.rs index 945a3bec..396e0479 100644 --- a/lite/src/handlers/v1/mod.rs +++ b/lite/src/handlers/v1/mod.rs @@ -1,3 +1,5 @@ +use tower_http::{compression::CompressionLayer, decompression::RequestDecompressionLayer}; + pub mod access_tokens; pub mod basins; mod error; @@ -9,57 +11,24 @@ pub mod streams; const MAX_UNARY_READ_WAIT: std::time::Duration = std::time::Duration::from_secs(60); pub fn router(backend: crate::backend::Backend) -> axum::Router { - use axum::routing::{delete, get, patch, post, put}; - // TODO: timeout layer that respects long-poll read wait + let compress_when = { + use tower_http::compression::predicate::{NotForContentType, Predicate, SizeAbove}; + SizeAbove::new(1024) + .and(NotForContentType::SSE) + .and(NotForContentType::const_new("s2s/proto")) + }; + axum::Router::new() - // Basin ops - .route(paths::basins::LIST, get(basins::list_basins)) - .route(paths::basins::CREATE, post(basins::create_basin)) - .route(paths::basins::GET_CONFIG, get(basins::get_basin_config)) - .route( - paths::basins::CREATE_OR_RECONFIGURE, - put(basins::create_or_reconfigure_basin), - ) - .route(paths::basins::DELETE, delete(basins::delete_basin)) - .route(paths::basins::RECONFIGURE, patch(basins::reconfigure_basin)) - // Stream ops - .route(paths::streams::LIST, get(streams::list_streams)) - .route(paths::streams::CREATE, post(streams::create_stream)) - .route(paths::streams::GET_CONFIG, get(streams::get_stream_config)) - .route( - paths::streams::CREATE_OR_RECONFIGURE, - put(streams::create_or_reconfigure_stream), - ) - .route(paths::streams::DELETE, delete(streams::delete_stream)) - .route( - paths::streams::RECONFIGURE, - patch(streams::reconfigure_stream), - ) - // Record ops - .route( - paths::streams::records::CHECK_TAIL, - get(records::check_tail), - ) - .route(paths::streams::records::READ, get(records::read)) - .route(paths::streams::records::APPEND, post(records::append)) - // Access token ops - .route( - paths::access_tokens::LIST, - get(access_tokens::list_access_tokens), - ) - .route( - paths::access_tokens::ISSUE, - post(access_tokens::issue_access_token), - ) - .route( - paths::access_tokens::REVOKE, - delete(access_tokens::revoke_access_token), - ) - // Metric ops - .route(paths::metrics::ACCOUNT, get(metrics::account_metrics)) - .route(paths::metrics::BASIN, get(metrics::basin_metrics)) - .route(paths::metrics::STREAM, get(metrics::stream_metrics)) + .merge(basins::router()) + .merge(streams::router()) + .merge(records::router()) + .merge(access_tokens::router()) + .merge(metrics::router()) .with_state(backend) + .route_layer(( + CompressionLayer::new().compress_when(compress_when), + RequestDecompressionLayer::new(), + )) } diff --git a/lite/src/handlers/v1/records.rs b/lite/src/handlers/v1/records.rs index b690a249..5e1c488b 100644 --- a/lite/src/handlers/v1/records.rs +++ b/lite/src/handlers/v1/records.rs @@ -28,6 +28,14 @@ use crate::{ handlers::v1::error::ServiceError, }; +pub fn router() -> axum::Router { + use axum::routing::{get, post}; + axum::Router::new() + .route(super::paths::streams::records::CHECK_TAIL, get(check_tail)) + .route(super::paths::streams::records::READ, get(read)) + .route(super::paths::streams::records::APPEND, post(append)) +} + #[derive(FromRequest)] #[from_request(rejection(ServiceError))] pub struct CheckTailArgs { diff --git a/lite/src/handlers/v1/streams.rs b/lite/src/handlers/v1/streams.rs index f0dc09d3..f11f186a 100644 --- a/lite/src/handlers/v1/streams.rs +++ b/lite/src/handlers/v1/streams.rs @@ -16,6 +16,23 @@ use s2_common::{ use crate::{backend::Backend, handlers::v1::error::ServiceError}; +pub fn router() -> axum::Router { + use axum::routing::{delete, get, patch, post, put}; + axum::Router::new() + .route(super::paths::streams::LIST, get(list_streams)) + .route(super::paths::streams::CREATE, post(create_stream)) + .route(super::paths::streams::GET_CONFIG, get(get_stream_config)) + .route( + super::paths::streams::CREATE_OR_RECONFIGURE, + put(create_or_reconfigure_stream), + ) + .route(super::paths::streams::DELETE, delete(delete_stream)) + .route( + super::paths::streams::RECONFIGURE, + patch(reconfigure_stream), + ) +} + #[derive(FromRequest)] #[from_request(rejection(ServiceError))] pub struct ListArgs { From f71a422d6befdb9b57ec0171bfe07f6e6f26889e Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Thu, 15 Jan 2026 11:16:38 -0500 Subject: [PATCH 060/700] [ci] add lockfile check (#60) --- .github/workflows/ci.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index accd03e5..8b335485 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,17 @@ jobs: components: rustfmt - run: cargo +nightly fmt --all --check + lockfile: + name: Lockfile + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Verify Cargo.lock is up-to-date + run: cargo metadata --locked --format-version 1 >/dev/null + clippy: name: Clippy runs-on: ubuntu-latest @@ -38,7 +49,7 @@ jobs: - run: cargo clippy --workspace --all-features --all-targets -- -D warnings --allow deprecated test: - name: Test + name: Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -47,6 +58,8 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: taiki-e/install-action@nextest - run: cargo nextest run --workspace --all-features @@ -60,6 +73,8 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Build s2-lite run: cargo build --release -p s2-lite - name: Upload binary @@ -71,7 +86,7 @@ jobs: sdk-integration-tests: name: SDK Integration Tests - needs: [build-server] + needs: [build-server, test, clippy] uses: s2-streamstore/s2-ci/.github/workflows/sdk-tests.yml@main with: mode: local From ace91915dc7f9952af6d1259c52c89c94c054655 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Thu, 15 Jan 2026 12:32:46 -0500 Subject: [PATCH 061/700] readme (#61) --- README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a18cc188..1bf48881 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,69 @@ -# s2-lite +# S2, the durable streams API -This is the home of `s2-lite` — an open source, self-hostable implementation of the [S2 API](https://s2.dev/docs/rest) for durable streams. +[s2.dev](https://s2.dev) is a serverless datastore for real-time, streaming data. -_It will be backed by [SlateDB](https://slatedb.io), and it is currently in development._ +## s2-lite -Check out [s2.dev](https://s2.dev/) for the serverless cloud offering. +`s2-lite` is an open source, self-hostable server implementation of the [S2 API](https://s2.dev/docs/api). + +It uses [SlateDB](https://slatedb.io) as its storage engine, which relies entirely on object storage for durability. + +It is easy to run `s2-lite` against object stores like AWS S3, GCP GCS, and Cloudflare R2. It is a single-node binary with no other external dependencies. Just like [s2.dev](https://s2.dev), data is always durable on object storage before being acknowledged or returned to readers. + +You can also simply not specify a `--bucket`, which makes it operate entirely in-memory. This is great for integration tests involving S2. + +### Try it out + +_TODO: `docker run` cmd & quickstart_ + +### Design + +[Concepts](https://s2.dev/docs/concepts) + +- HTTP API is implemented using [axum](https://github.com/tokio-rs/axum). +- Each stream maps to a Tokio task called [`streamer`](lite/src/backend/streamer.rs) that owns the current tail position, serializes appends, and broadcasts acknowledged records to followers. +- Appends are pipelined to improve performance against high-latency object storage. +- [`lite::backend::kv::Key`](lite/src/backend/kv/mod.rs) documents the data model in sl8. + +### SlateDB settings + +[Settings reference](https://docs.rs/slatedb/latest/slatedb/config/struct.Settings.html#fields) + +Use `SL8_` prefixed environment variables, e.g.: + +```bash +SL8_FLUSH_INTERVAL=10ms +``` + +### API Coverage + +> [!TIP] +> Complete [specs](api/specs/s2/v1) are available: [OpenAPI](https://s2.dev/docs/api) for the REST-ful core, [Protobuf](https://buf.build/streamstore/s2/docs/main:s2.v1) definitions for binary efficiency, and [S2S](https://s2.dev/docs/api/records/overview#s2s-spec) for the streaming session protocol supported by S2. + +**Fully supported** +- `/basins` +- `/streams` +- `/streams/{stream}/records` + +> [!IMPORTANT] +> Unlike the cloud service where the basin is implicit as a subdomain, `/streams/*` requests **must** specify the basin using the `S2-Basin` header. S2 SDKs take care of this automatically. + +**Not supported** +- `/access-tokens` https://github.com/s2-streamstore/s2/issues/28 +- `/metrics` + +### Current limitations + +- Deletion is not fully plumbed up yet + - basins https://github.com/s2-streamstore/s2/issues/53 + - streams https://github.com/s2-streamstore/s2/issues/52 + - records https://github.com/s2-streamstore/s2/issues/51 +- Pipelining needs to be made safe and default https://github.com/s2-streamstore/s2/issues/48 + +**Compatibility** +- [CLI](https://github.com/s2-streamstore/s2-cli) ✅ v0.23+ +- [TypeScript SDK](https://github.com/s2-streamstore/s2-sdk-typescript) ✅ v0.22+ +- [Go SDK](https://github.com/s2-streamstore/s2-sdk-go) ✅ v0.11+ +- [Rust SDK](https://github.com/s2-streamstore/s2-sdk-rust) ✅ v0.22+ +- [Python](https://github.com/s2-streamstore/s2-sdk-python) 🚧 _needs to be migrated to v1 API_ +- [Java](https://github.com/s2-streamstore/s2-sdk-java) 🚧 _needs to be migrated to v1 API_ From bd42185931e03373de58cb7502734835f8473e3a Mon Sep 17 00:00:00 2001 From: Cristian C Date: Thu, 15 Jan 2026 21:34:46 +0200 Subject: [PATCH 062/700] feat: add release automation and justfile (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `justfile` with dev commands (`build`, `clippy`, `fmt`, `test`, `sync`, `clean`) and release helpers (`publish`, `tag`) - Add release workflow for multi-arch Docker images and GitHub releases - Includes safety checks: clean workdir, main branch, cargo credentials, semver validation ## Dev Commands ``` just build # Build s2-lite server just clippy # Run linter just fmt # Format with nightly rustfmt just test # Run tests with nextest just sync # Sync git submodules ``` ## Release Flow 1. Bump `workspace.package.version` in `Cargo.toml` 2. Commit: `git commit -m "release: X.Y.Z"` 3. Publish: `just publish` 4. Tag: `just tag X.Y.Z` 5. Workflow auto-builds Docker images + creates GitHub release Closes #31 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- .github/workflows/release.yml | 98 +++++++++++++++++++++++++++++ justfile | 115 ++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 justfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..3e59dee2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +name: release + +permissions: + contents: write + packages: write + +on: + push: + tags: ["[0-9]+.[0-9]+*"] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: s2-streamstore/s2-lite + +jobs: + build-images: + name: Build ${{ matrix.arch }} image + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: x86-64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from tag + id: version + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + else + echo "version=dev-$(date +%s)" >> $GITHUB_OUTPUT + fi + + - name: Build and push architecture-specific image + run: | + docker build \ + -f lite/Dockerfile \ + -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-${{ matrix.arch }} \ + . + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-${{ matrix.arch }} + + create-manifest: + name: Create multi-arch manifest + needs: build-images + runs-on: ubuntu-latest + steps: + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from tag + id: version + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + else + echo "version=dev-$(date +%s)" >> $GITHUB_OUTPUT + fi + + - name: Create and push multi-arch manifest + run: | + docker manifest create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-x86-64 \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64 + docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + + create-release: + name: Create GitHub release + needs: [build-images, create-manifest] + runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/justfile b/justfile new file mode 100644 index 00000000..9798e293 --- /dev/null +++ b/justfile @@ -0,0 +1,115 @@ +# List available commands +default: + @just --list + +# Sync git submodules +sync: + git submodule update --init --recursive + +# Build the s2-lite server binary +build: sync + cargo build --locked --release -p s2-lite + +# Run clippy linter +clippy: sync + cargo clippy --workspace --all-features --all-targets -- -D warnings --allow deprecated + +# Ensure nightly toolchain is installed +_ensure-nightly: + @rustup toolchain list | grep -q nightly || (echo "❌ Nightly toolchain required. Run: rustup toolchain install nightly" && exit 1) + +# Format code with rustfmt +fmt: _ensure-nightly + cargo +nightly fmt + +# Ensure cargo-nextest is installed +_ensure-nextest: + @cargo nextest --version > /dev/null 2>&1 || cargo install cargo-nextest + +# Run tests with nextest +test: sync _ensure-nextest + cargo nextest run --workspace --all-features + +# Verify Cargo.lock is up-to-date +check-locked: + cargo metadata --locked --format-version 1 >/dev/null + +# Clean build artifacts +clean: + cargo clean + +# Publish crates to crates.io (in dependency order) +publish: + #!/usr/bin/env bash + set -euo pipefail + + # Check for cargo credentials + if [[ -z "${CARGO_REGISTRY_TOKEN:-}" ]] && [[ ! -f ~/.cargo/credentials.toml ]]; then + echo "❌ No cargo credentials found. Run 'cargo login' or set CARGO_REGISTRY_TOKEN." + exit 1 + fi + + # Verify clean working directory + if ! git diff-index --quiet HEAD --; then + echo "❌ Working directory not clean. Commit or stash changes first." + exit 1 + fi + + # Verify on main branch + if [[ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]]; then + echo "❌ Publish must be run from main branch." + exit 1 + fi + + # Verify lockfile is up-to-date + just check-locked + + echo "Publishing crates to crates.io..." + + echo "→ Publishing s2-common" + cargo publish -p s2-common + + echo "Waiting for crates.io to index..." + sleep 15 + + echo "→ Publishing s2-api" + cargo publish -p s2-api + + echo "Waiting for crates.io to index..." + sleep 15 + + echo "→ Publishing s2-lite" + cargo publish -p s2-lite + + echo "✓ All crates published successfully" + +# Create and push a release tag +tag TAG: + #!/usr/bin/env bash + set -euo pipefail + + # Validate version format + if ! [[ "{{TAG}}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ Invalid version format. Use semver: X.Y.Z" + exit 1 + fi + + # Verify clean working directory + if ! git diff-index --quiet HEAD --; then + echo "❌ Working directory not clean. Commit or stash changes first." + exit 1 + fi + + # Verify on main branch + if [[ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]]; then + echo "❌ Releases must be tagged from main branch." + exit 1 + fi + + # Verify lockfile is up-to-date + just check-locked + + echo "Creating release tag: {{TAG}}" + git tag "{{TAG}}" + git push origin "{{TAG}}" + echo "✓ Tag {{TAG}} created and pushed - release workflow will start" From 3e8fd2f01096c43b09c110de71956e86c7e97ea0 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 15 Jan 2026 21:37:20 +0200 Subject: [PATCH 063/700] release: 0.2.2 --- Cargo.lock | 75 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 6 ++--- 2 files changed, 41 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 79d2f324..ddd15f4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -307,9 +307,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.2" +version = "1.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +checksum = "e84ce723ab67259cfeb9877c6a639ee9eb7a27b28123abd71db7f0d5d0cc9d86" dependencies = [ "aws-lc-sys", "zeroize", @@ -317,9 +317,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.35.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +checksum = "43a442ece363113bd4bd4c8b18977a7798dd4d3c3383f34fb61936960e8f4ad8" dependencies = [ "cc", "cmake", @@ -507,9 +507,9 @@ dependencies = [ [[package]] name = "aws-smithy-observability" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f616c3f2260612fe44cede278bafa18e73e6479c4e393e2c4518cf2a9a228a" +checksum = "ef1fcbefc7ece1d70dcce29e490f269695dfca2d2bacdeaf9e5c3f799e4e6a42" dependencies = [ "aws-smithy-runtime-api", ] @@ -526,9 +526,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.9.5" +version = "1.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a392db6c583ea4a912538afb86b7be7c5d8887d91604f50eb55c262ee1b4a5f5" +checksum = "bb5b6167fcdf47399024e81ac08e795180c576a20e4d4ce67949f9a88ae37dc1" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -550,9 +550,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.9.3" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab0d43d899f9e508300e587bf582ba54c27a452dd0a9ea294690669138ae14a2" +checksum = "efce7aaaf59ad53c5412f14fc19b2d5c6ab2c3ec688d272fd31f76ec12f44fb0" dependencies = [ "aws-smithy-async", "aws-smithy-types", @@ -567,9 +567,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.3.5" +version = "1.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "905cb13a9895626d49cf2ced759b062d913834c7482c38e49557eac4e6193f01" +checksum = "65f172bcb02424eb94425db8aed1b6d583b5104d4d5ddddf22402c661a320048" dependencies = [ "base64-simd", "bytes", @@ -878,9 +878,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -2141,9 +2141,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -3295,9 +3295,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.39.0" +version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" dependencies = [ "arrayvec", "num-traits", @@ -3428,7 +3428,7 @@ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "s2-api" -version = "0.2.1" +version = "0.2.2" dependencies = [ "axum", "base64ct", @@ -3457,7 +3457,7 @@ dependencies = [ [[package]] name = "s2-common" -version = "0.2.1" +version = "0.2.2" dependencies = [ "axum", "blake3", @@ -3480,7 +3480,7 @@ dependencies = [ [[package]] name = "s2-lite" -version = "0.2.1" +version = "0.2.2" dependencies = [ "async-stream", "async-trait", @@ -4466,18 +4466,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -4488,11 +4488,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -4501,9 +4502,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4511,9 +4512,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -4524,9 +4525,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -4546,9 +4547,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -4928,9 +4929,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "writeable" diff --git a/Cargo.toml b/Cargo.toml index 003f2314..531af2a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["api", "common", "lite"] [workspace.package] -version = "0.2.1" +version = "0.2.2" edition = "2024" license = "MIT" repository = "https://github.com/s2-streamstore/s2" @@ -40,8 +40,8 @@ rand = "0.9" rcgen = "0.14" rkyv = "0.8" rstest = "0.26" -s2-api = { version = "0.2.1", path = "api" } -s2-common = { version = "0.2.1", path = "common" } +s2-api = { version = "0.2.2", path = "api" } +s2-common = { version = "0.2.2", path = "common" } serde = "1.0" serde_json = "1.0" slatedb = "0.10" From 516df6b8524514d84b383b2ecedb6b1e83dc9f0e Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Thu, 15 Jan 2026 21:44:37 -0500 Subject: [PATCH 064/700] ci: build s2-lite workflow (#73) --- .github/workflows/build-s2-lite.yml | 63 ++++++++++++ .github/workflows/ci.yml | 23 +---- .github/workflows/sdk-tests.yml | 152 ++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/build-s2-lite.yml create mode 100644 .github/workflows/sdk-tests.yml diff --git a/.github/workflows/build-s2-lite.yml b/.github/workflows/build-s2-lite.yml new file mode 100644 index 00000000..32154f18 --- /dev/null +++ b/.github/workflows/build-s2-lite.yml @@ -0,0 +1,63 @@ +name: Build s2-lite + +on: + workflow_call: + inputs: + ref: + description: "Git ref to checkout" + required: false + type: string + default: "main" + artifact-name: + description: "Name for the uploaded artifact" + required: false + type: string + default: "server-binary" + binary-name: + description: "Name of the binary in the artifact" + required: false + type: string + default: "server" + retention-days: + description: "Number of days to retain the artifact" + required: false + type: number + default: 1 + runner: + description: "Runner to use" + required: false + type: string + default: "ubuntu-latest" + +jobs: + build: + name: Build s2-lite + runs-on: ${{ inputs.runner }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + repository: s2-streamstore/s2 + ref: ${{ inputs.ref }} + submodules: true + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Setup Protoc + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build s2-lite + run: cargo build --release -p s2-lite + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: target/release/${{ inputs.binary-name }} + retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b335485..6d3effd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,29 +65,14 @@ jobs: build-server: name: Build s2-lite - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - uses: arduino/setup-protoc@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Build s2-lite - run: cargo build --release -p s2-lite - - name: Upload binary - uses: actions/upload-artifact@v4 - with: - name: server-binary - path: target/release/server - retention-days: 1 + uses: ./.github/workflows/build-s2-lite.yml + with: + ref: ${{ github.sha }} sdk-integration-tests: name: SDK Integration Tests needs: [build-server, test, clippy] - uses: s2-streamstore/s2-ci/.github/workflows/sdk-tests.yml@main + uses: ./.github/workflows/sdk-tests.yml with: mode: local server-binary: server diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml new file mode 100644 index 00000000..4c1fbc8a --- /dev/null +++ b/.github/workflows/sdk-tests.yml @@ -0,0 +1,152 @@ +name: SDK Integration Tests + +on: + workflow_call: + inputs: + mode: + description: "local or remote" + required: false + type: string + default: "local" + + server-binary: + description: "Binary name in downloaded artifact" + required: false + type: string + default: "s2-mem" + server-args: + description: "Arguments to pass to server" + required: false + type: string + default: "" + server-port: + description: "Port the server listens on (local mode)" + required: false + type: number + default: 4243 + + account-endpoint: + description: "Account endpoint" + required: false + type: string + basin-endpoint: + description: "Basin endpoint" + required: false + type: string + + sdks: + description: "JSON array of SDK configs" + required: true + type: string + runner: + description: "Runner to use" + required: false + type: string + default: "ubuntu-latest" + + secrets: + GH_TOKEN: + required: false + S2_ACCESS_TOKEN: + required: false + +jobs: + test: + name: ${{ matrix.sdk.name }} + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + sdk: ${{ fromJson(inputs.sdks) }} + steps: + - name: Checkout SDK + uses: actions/checkout@v4 + with: + repository: ${{ matrix.sdk.repo }} + ref: ${{ matrix.sdk.ref }} + token: ${{ secrets.GH_TOKEN || github.token }} + path: sdk + + - name: Setup Go + if: matrix.sdk.lang == 'go' + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.sdk.go-version }} + cache-dependency-path: sdk/go.sum + + - name: Setup Python + if: matrix.sdk.lang == 'python' + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.sdk.python-version }} + + - name: Setup Node + if: matrix.sdk.lang == 'node' + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.sdk.node-version }} + cache: npm + cache-dependency-path: sdk/package-lock.json + + - name: Setup Bun + if: matrix.sdk.lang == 'bun' + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ matrix.sdk.bun-version }} + - name: Install dependencies (Bun) + if: matrix.sdk.lang == 'bun' + working-directory: sdk + run: bun install + + - name: Setup Rust + if: matrix.sdk.lang == 'rust' + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ matrix.sdk.rust-version || 'stable' }} + + - name: Download server binary + if: inputs.mode == 'local' + uses: actions/download-artifact@v4 + with: + name: server-binary + path: server + + - name: Start server + if: inputs.mode == 'local' + run: | + chmod +x server/${{ inputs.server-binary }} + server/${{ inputs.server-binary }} ${{ inputs.server-args }} > /tmp/server.log 2>&1 & + echo $! > /tmp/server.pid + for i in {1..30}; do + if curl -sf http://localhost:${{ inputs.server-port }}/ping; then + echo "Server is ready" + exit 0 + fi + echo "Waiting for server... ($i/30)" + sleep 1 + done + echo "Server failed to start" + cat /tmp/server.log + exit 1 + + - name: Extract token (local) + if: inputs.mode == 'local' + id: local-token + run: | + token=$(sed -n 's/.*S2_ACCESS_TOKEN="\([^"]*\)".*/\1/p' /tmp/server.log | head -1) + token="${token:-test}" + echo "::add-mask::$token" + echo "value=$token" >> "$GITHUB_OUTPUT" + + - name: Run tests + working-directory: sdk + env: + S2_ACCESS_TOKEN: ${{ inputs.mode == 'local' && steps.local-token.outputs.value || secrets.S2_ACCESS_TOKEN }} + S2_ACCOUNT_ENDPOINT: ${{ inputs.mode == 'local' && format('http://localhost:{0}', inputs.server-port) || inputs.account-endpoint }} + S2_BASIN_ENDPOINT: ${{ inputs.mode == 'local' && format('http://localhost:{0}', inputs.server-port) || inputs.basin-endpoint }} + run: ${{ matrix.sdk.test_cmd }} + + - name: Dump server logs + if: failure() && inputs.mode == 'local' + run: cat /tmp/server.log From 189b194d73c95a5b448bcbc6fd9583f959fbff2f Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Thu, 15 Jan 2026 22:31:04 -0500 Subject: [PATCH 065/700] readme tweaks (#64) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1bf48881..cb94b733 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ SL8_FLUSH_INTERVAL=10ms ### API Coverage > [!TIP] -> Complete [specs](api/specs/s2/v1) are available: [OpenAPI](https://s2.dev/docs/api) for the REST-ful core, [Protobuf](https://buf.build/streamstore/s2/docs/main:s2.v1) definitions for binary efficiency, and [S2S](https://s2.dev/docs/api/records/overview#s2s-spec) for the streaming session protocol supported by S2. +> Complete [specs](api/specs/s2/v1) are available: [OpenAPI](https://s2.dev/docs/api) for the REST-ful core, [Protobuf](https://buf.build/streamstore/s2/docs/main:s2.v1) definitions, and [S2S](https://s2.dev/docs/api/records/overview#s2s-spec) which is the streaming session protocol. **Fully supported** - `/basins` @@ -46,13 +46,13 @@ SL8_FLUSH_INTERVAL=10ms - `/streams/{stream}/records` > [!IMPORTANT] -> Unlike the cloud service where the basin is implicit as a subdomain, `/streams/*` requests **must** specify the basin using the `S2-Basin` header. S2 SDKs take care of this automatically. +> Unlike the cloud service where the basin is implicit as a subdomain, `/streams/*` requests **must** specify the basin using the `S2-Basin` header. The SDKs take care of this automatically. **Not supported** - `/access-tokens` https://github.com/s2-streamstore/s2/issues/28 - `/metrics` -### Current limitations +### Limitations - Deletion is not fully plumbed up yet - basins https://github.com/s2-streamstore/s2/issues/53 From d8f61155f31b83e662bb573feda9dd41623edbee Mon Sep 17 00:00:00 2001 From: Cristian C Date: Fri, 16 Jan 2026 19:51:04 +0200 Subject: [PATCH 066/700] ci: enable signed commits in sync_specs workflow (#75) - Update peter-evans/create-pull-request from v7 to v8 - Enable `sign-commits: true` for verified commit signatures on automated PRs ## Test plan - [x] Triggered workflow manually, confirmed `pull-request-commits-verified = true` - [x] Verified commit signature via API shows `verified: true` --------- Co-authored-by: Claude Opus 4.5 --- .github/workflows/sync_specs.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync_specs.yaml b/.github/workflows/sync_specs.yaml index 27f6aa73..0671a93f 100644 --- a/.github/workflows/sync_specs.yaml +++ b/.github/workflows/sync_specs.yaml @@ -24,10 +24,11 @@ jobs: - name: Create pull request if: ${{ steps.submodules.outputs['api/specs--updated'] }} - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@v8 with: committer: s2-helper[bot] <194906454+s2-helper[bot]@users.noreply.github.com> author: s2-helper[bot] <194906454+s2-helper[bot]@users.noreply.github.com> + sign-commits: true title: "chore: sync specs submodule" branch: "specs/sync-${{ steps.submodules.outputs['api/specs--latestShortCommitSha'] }}" body: ${{ steps.submodules.outputs.prBody }} From 011ca8e5ed613479c2d3031cef0b318c622d8291 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Fri, 16 Jan 2026 14:31:42 -0500 Subject: [PATCH 067/700] patch sl8 to git rev (#76) to get the fix for https://github.com/slatedb/slatedb/issues/1188 --- Cargo.lock | 66 ++++++++++++++++++++++++++++++++++++++---------------- Cargo.toml | 1 + 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ddd15f4f..3a4ef476 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,9 +329,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.5.17" +version = "1.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d81b5b2898f6798ad58f484856768bca817e3cd9de0974c24ae0f1113fe88f1b" +checksum = "959dab27ce613e6c9658eb3621064d0e2027e5f2acb65bc526a43577facea557" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -353,15 +353,16 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.91.0" +version = "1.92.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee6402a36f27b52fe67661c6732d684b2635152b676aa2babbfb5204f99115d" +checksum = "b7d63bd2bdeeb49aa3f9b00c15e18583503b778b2e792fc06284d54e7d5b6566" dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", "aws-smithy-http", "aws-smithy-json", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -375,15 +376,16 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.93.0" +version = "1.94.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45a7f750bbd170ee3677671ad782d90b894548f4e4ae168302c57ec9de5cb3e" +checksum = "532d93574bf731f311bafb761366f9ece345a0416dbcc273d81d6d1a1205239b" dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", "aws-smithy-http", "aws-smithy-json", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -397,15 +399,16 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.95.0" +version = "1.96.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55542378e419558e6b1f398ca70adb0b2088077e79ad9f14eb09441f2f7b2164" +checksum = "357e9a029c7524db6a0099cd77fbd5da165540339e7296cca603531bc783b56c" dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", "aws-smithy-http", "aws-smithy-json", + "aws-smithy-observability", "aws-smithy-query", "aws-smithy-runtime", "aws-smithy-runtime-api", @@ -854,9 +857,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.52" +version = "1.2.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", "jobserver", @@ -1376,9 +1379,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "fixedbitset" @@ -3382,9 +3385,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -3392,9 +3395,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "aws-lc-rs", "ring", @@ -3733,9 +3736,8 @@ checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "slatedb" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "588e9ae32019696205a05e54e4456486e8d11b69c72662739be853736833b3dd" +version = "0.10.0" +source = "git+https://github.com/slatedb/slatedb?rev=5281e6e0990477b1176cb49e907e26e706a39ac4#5281e6e0990477b1176cb49e907e26e706a39ac4" dependencies = [ "anyhow", "async-trait", @@ -3764,6 +3766,8 @@ dependencies = [ "serde", "serde_json", "siphasher", + "slatedb-common", + "slatedb-txn-obj", "sysinfo", "thiserror 1.0.69", "thread_local", @@ -3776,6 +3780,30 @@ dependencies = [ "walkdir", ] +[[package]] +name = "slatedb-common" +version = "0.10.0" +source = "git+https://github.com/slatedb/slatedb?rev=5281e6e0990477b1176cb49e907e26e706a39ac4#5281e6e0990477b1176cb49e907e26e706a39ac4" +dependencies = [ + "chrono", + "tokio", +] + +[[package]] +name = "slatedb-txn-obj" +version = "0.10.0" +source = "git+https://github.com/slatedb/slatedb?rev=5281e6e0990477b1176cb49e907e26e706a39ac4#5281e6e0990477b1176cb49e907e26e706a39ac4" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "log", + "object_store", + "slatedb-common", + "thiserror 1.0.69", +] + [[package]] name = "smallvec" version = "1.15.1" diff --git a/Cargo.toml b/Cargo.toml index 531af2a8..12615362 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,4 +58,5 @@ uuid = "1.19" zstd = "0.13" [patch.crates-io] +slatedb = { git = "https://github.com/slatedb/slatedb", rev = "5281e6e0990477b1176cb49e907e26e706a39ac4" } utoipa = { git = "https://github.com/infiniteregrets/utoipa", rev = "9cd181d40ac0a50551ebe1995a4d73e8685890d6" } From f48d4025e2e1511c2a1489147e19ca65ccd3f9e5 Mon Sep 17 00:00:00 2001 From: Cristian C Date: Fri, 16 Jan 2026 23:05:17 +0200 Subject: [PATCH 068/700] chore: tag Docker image as latest on release (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add step to tag Docker image as `latest` when releasing - Only applies to tag pushes (not workflow_dispatch) - Creates multi-arch manifest for `latest` pointing to same arch-specific images - Use `ENTRYPOINT` instead of `CMD` so args are appended to binary ## Test plan - [ ] Verify next release tags image with both version and `latest` - [ ] Verify `docker run s2-lite --help` passes `--help` to binary 🤖 Generated with [Claude Code](https://claude.ai/claude-code) --------- Co-authored-by: Claude Opus 4.5 --- .github/workflows/release.yml | 8 ++++++++ AGENTS.md | 32 ++++++++++++++++++++++++++++++-- lite/Dockerfile | 2 +- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e59dee2..e250bb37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,6 +83,14 @@ jobs: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64 docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + - name: Tag as latest + if: github.event_name != 'workflow_dispatch' + run: | + docker manifest create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-x86-64 \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64 + docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + create-release: name: Create GitHub release needs: [build-images, create-manifest] diff --git a/AGENTS.md b/AGENTS.md index 8d544885..87c2ee97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,32 @@ ## Local Conventions -- Formatting: run `cargo +nightly fmt`. -- Tests: prefer `cargo nextest`. +- Formatting: run `cargo +nightly fmt` or `just fmt` +- Tests: prefer `cargo nextest` or `just test` + +## Release Process + +1. Switch to main and pull latest: + ```bash + git checkout main && git pull + ``` + +2. Bump version in root `Cargo.toml` (updates `workspace.package.version` and workspace deps): + ```bash + # Edit Cargo.toml: change version = "X.Y.Z" in all 3 places + cargo generate-lockfile + git add Cargo.toml Cargo.lock + git commit -m "release: X.Y.Z" + git push + ``` + +3. Publish to crates.io: + ```bash + just publish + ``` + +4. Tag and trigger release workflow: + ```bash + just tag X.Y.Z + ``` + +This builds multi-arch Docker images and creates a GitHub release automatically. diff --git a/lite/Dockerfile b/lite/Dockerfile index 81382a34..f2ea33a0 100644 --- a/lite/Dockerfile +++ b/lite/Dockerfile @@ -31,4 +31,4 @@ WORKDIR /app # Copy the binary from builder stage COPY --from=builder /build/target/release/server /app/s2-lite -CMD ["./s2-lite"] +ENTRYPOINT ["./s2-lite"] From f7a166e29dd221da498191ac31338950d6130c1a Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 16 Jan 2026 23:08:20 +0200 Subject: [PATCH 069/700] release: 0.2.3 --- Cargo.lock | 6 +++--- Cargo.toml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a4ef476..78894c6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3431,7 +3431,7 @@ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "s2-api" -version = "0.2.2" +version = "0.2.3" dependencies = [ "axum", "base64ct", @@ -3460,7 +3460,7 @@ dependencies = [ [[package]] name = "s2-common" -version = "0.2.2" +version = "0.2.3" dependencies = [ "axum", "blake3", @@ -3483,7 +3483,7 @@ dependencies = [ [[package]] name = "s2-lite" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-stream", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 12615362..e625bb18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["api", "common", "lite"] [workspace.package] -version = "0.2.2" +version = "0.2.3" edition = "2024" license = "MIT" repository = "https://github.com/s2-streamstore/s2" @@ -40,8 +40,8 @@ rand = "0.9" rcgen = "0.14" rkyv = "0.8" rstest = "0.26" -s2-api = { version = "0.2.2", path = "api" } -s2-common = { version = "0.2.2", path = "common" } +s2-api = { version = "0.2.3", path = "api" } +s2-common = { version = "0.2.3", path = "common" } serde = "1.0" serde_json = "1.0" slatedb = "0.10" From 3be8da8ea163dd4256d2b1534855535f733b2bf2 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Sat, 17 Jan 2026 10:47:28 -0500 Subject: [PATCH 070/700] [lite] fix: credential caching (#78) also folding in tweak to make `--pipeline` an env var as it's intended to be temporary, `S2LITE_PIPELINE` --- lite/src/bin/server.rs | 107 ++++++++++++++++++++++++++++------------- 1 file changed, 73 insertions(+), 34 deletions(-) diff --git a/lite/src/bin/server.rs b/lite/src/bin/server.rs index 039720b4..4b6b128b 100644 --- a/lite/src/bin/server.rs +++ b/lite/src/bin/server.rs @@ -1,11 +1,16 @@ -use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; +use std::{ + net::SocketAddr, + path::PathBuf, + sync::Arc, + time::{Duration, SystemTime}, +}; -use aws_credential_types::provider::{ProvideCredentials, SharedCredentialsProvider}; use axum_server::tls_rustls::RustlsConfig; use bytesize::ByteSize; use clap::Parser as _; use s2_lite::{backend::Backend, handlers}; -use slatedb::object_store::{self, CredentialProvider, aws::AwsCredential}; +use slatedb::object_store; +use tokio::time::Instant; use tower_http::trace::{DefaultMakeSpan, DefaultOnRequest, DefaultOnResponse, TraceLayer}; use tracing::info; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -47,13 +52,6 @@ struct Args { /// Port to listen on [default: 443 if HTTPS configured, otherwise 80 for HTTP] #[arg(long)] port: Option, - - /// Whether to pipeline appends per stream. - /// - /// This improves performance with high-latency object storage, however it is unsafe for now - /// (risk of data loss). It will be enabled by default in future and this knob removed. - #[arg(long, default_value_t = false)] - pipeline: bool, } #[tokio::main] @@ -93,7 +91,8 @@ async fn main() -> eyre::Result<()> { if let Some(credentials_provider) = aws_config.credentials_provider() { info!("using aws-config credentials provider"); builder = builder.with_credentials(Arc::new(S3CredentialProvider { - credentials: credentials_provider.clone(), + aws: credentials_provider.clone(), + cache: tokio::sync::Mutex::new(None), })); } let store = builder.build()?; @@ -113,22 +112,24 @@ async fn main() -> eyre::Result<()> { }, )?; - info!(?db_settings, "sl8 init"); - let db = slatedb::Db::builder(args.path, object_store) .with_settings(db_settings) .build() .await?; - let backend = Backend::new( - db, - if args.pipeline { + let append_inflight_max = { + let pipeline = std::env::var("S2LITE_PIPELINE") + .is_ok_and(|v| v.eq_ignore_ascii_case("true") || v == "1"); + if pipeline { + info!("pipelining enabled"); ByteSize::mib(10) } else { - // No pipelining + info!("pipelining disabled"); ByteSize::b(1) - }, - ); + } + }; + + let backend = Backend::new(db, append_inflight_max); let app = handlers::router(backend).layer( TraceLayer::new_for_http() @@ -220,26 +221,64 @@ async fn shutdown_signal(handle: axum_server::Handle) { handle.graceful_shutdown(Some(Duration::from_secs(10))); } +#[derive(Debug)] +struct CachedCredential { + credential: Arc, + expiry: Option, +} + +impl CachedCredential { + fn is_valid(&self) -> bool { + self.expiry + .is_none_or(|exp| exp > SystemTime::now() + Duration::from_secs(60)) + } +} + #[derive(Debug)] struct S3CredentialProvider { - credentials: SharedCredentialsProvider, + aws: aws_credential_types::provider::SharedCredentialsProvider, + cache: tokio::sync::Mutex>, } #[async_trait::async_trait] -impl CredentialProvider for S3CredentialProvider { - type Credential = AwsCredential; +impl object_store::CredentialProvider for S3CredentialProvider { + type Credential = object_store::aws::AwsCredential; - async fn get_credential(&self) -> object_store::Result> { - let creds = self.credentials.provide_credentials().await.map_err(|e| { - object_store::Error::Generic { - store: "S3", - source: Box::new(e), - } - })?; - Ok(Arc::new(AwsCredential { - key_id: creds.access_key_id().to_string(), - secret_key: creds.secret_access_key().to_string(), - token: creds.session_token().map(ToString::to_string), - })) + async fn get_credential(&self) -> object_store::Result> { + let mut cached = self.cache.lock().await; + if let Some(cached) = cached.as_ref().filter(|c| c.is_valid()) { + return Ok(cached.credential.clone()); + } + + use aws_credential_types::provider::ProvideCredentials as _; + + let start = Instant::now(); + let creds = + self.aws + .provide_credentials() + .await + .map_err(|e| object_store::Error::Generic { + store: "S3", + source: Box::new(e), + })?; + info!( + key_id = creds.access_key_id(), + expiry_s = creds + .expiry() + .and_then(|t| t.duration_since(SystemTime::now()).ok()) + .map(|d| d.as_secs()), + elapsed_ms = start.elapsed().as_millis(), + "fetched credentials" + ); + let credential = Arc::new(object_store::aws::AwsCredential { + key_id: creds.access_key_id().to_owned(), + secret_key: creds.secret_access_key().to_owned(), + token: creds.session_token().map(|s| s.to_owned()), + }); + *cached = Some(CachedCredential { + credential: credential.clone(), + expiry: creds.expiry(), + }); + Ok(credential) } } From babd73955882103c0dd82e1f0189ae5ce3206c6a Mon Sep 17 00:00:00 2001 From: shikhar Date: Sat, 17 Jan 2026 10:58:04 -0500 Subject: [PATCH 071/700] claude skill for /release --- .claude/skills/release/SKILL.md | 61 +++++++++++++++++++++++++++++++++ AGENTS.md | 28 --------------- 2 files changed, 61 insertions(+), 28 deletions(-) create mode 100644 .claude/skills/release/SKILL.md diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 00000000..0430f57f --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,61 @@ +--- +name: release +description: Release a new version of the project with semver validation, Cargo.toml updates, and crates.io publishing +--- + +# /release + +Release a new version of the project. + +## Usage + +``` +/release [version] +``` + +If version is not provided, prompt the user for it. + +## Steps + +1. **Verify prerequisites** + - Confirm on `main` branch + - Confirm working directory is clean (`git status`) + - Pull latest (`git pull`) + +2. **Validate version** + - Version should follow semver (X.Y.Z) + - Confirm it's greater than current version in Cargo.toml + +3. **Update Cargo.toml** + - Edit root `Cargo.toml` to update version in all 3 places: + - `workspace.package.version` + - `workspace.dependencies.s2-api` + - `workspace.dependencies.s2-common` + +4. **Update lockfile** + ```bash + cargo generate-lockfile + ``` + +5. **Commit and push** + ```bash + git add Cargo.toml Cargo.lock + git commit -m "release: X.Y.Z" + git push + ``` + +6. **Publish to crates.io** + ```bash + just publish + ``` + Wait for this to complete successfully before proceeding. + +7. **Tag and trigger release** + ```bash + just tag X.Y.Z + ``` + +## Notes + +- If any step fails, stop and report the error +- The `just tag` command triggers GitHub Actions to build Docker images and create the GitHub release diff --git a/AGENTS.md b/AGENTS.md index 87c2ee97..59e90d5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,31 +2,3 @@ - Formatting: run `cargo +nightly fmt` or `just fmt` - Tests: prefer `cargo nextest` or `just test` - -## Release Process - -1. Switch to main and pull latest: - ```bash - git checkout main && git pull - ``` - -2. Bump version in root `Cargo.toml` (updates `workspace.package.version` and workspace deps): - ```bash - # Edit Cargo.toml: change version = "X.Y.Z" in all 3 places - cargo generate-lockfile - git add Cargo.toml Cargo.lock - git commit -m "release: X.Y.Z" - git push - ``` - -3. Publish to crates.io: - ```bash - just publish - ``` - -4. Tag and trigger release workflow: - ```bash - just tag X.Y.Z - ``` - -This builds multi-arch Docker images and creates a GitHub release automatically. From f22db952ebed6cba8783414e69c841e27d3e452c Mon Sep 17 00:00:00 2001 From: shikhar Date: Sat, 17 Jan 2026 10:58:42 -0500 Subject: [PATCH 072/700] release: 0.2.4 --- Cargo.lock | 6 +++--- Cargo.toml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78894c6c..47780d5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3431,7 +3431,7 @@ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "s2-api" -version = "0.2.3" +version = "0.2.4" dependencies = [ "axum", "base64ct", @@ -3460,7 +3460,7 @@ dependencies = [ [[package]] name = "s2-common" -version = "0.2.3" +version = "0.2.4" dependencies = [ "axum", "blake3", @@ -3483,7 +3483,7 @@ dependencies = [ [[package]] name = "s2-lite" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-stream", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index e625bb18..0e14d1b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["api", "common", "lite"] [workspace.package] -version = "0.2.3" +version = "0.2.4" edition = "2024" license = "MIT" repository = "https://github.com/s2-streamstore/s2" @@ -40,8 +40,8 @@ rand = "0.9" rcgen = "0.14" rkyv = "0.8" rstest = "0.26" -s2-api = { version = "0.2.3", path = "api" } -s2-common = { version = "0.2.3", path = "common" } +s2-api = { version = "0.2.4", path = "api" } +s2-common = { version = "0.2.4", path = "common" } serde = "1.0" serde_json = "1.0" slatedb = "0.10" From f23f5c0f278eb5d9aa19d6059bcabf33d95566c4 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Sat, 17 Jan 2026 16:29:01 -0500 Subject: [PATCH 073/700] readme updates & support for static credentials (#79) --- README.md | 75 +++++++++++++++++++++++++++++++++++++-- lite/src/bin/server.rs | 79 +++++++++++++++++++++++++++++------------- 2 files changed, 127 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index cb94b733..defc7207 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,80 @@ It is easy to run `s2-lite` against object stores like AWS S3, GCP GCS, and Clou You can also simply not specify a `--bucket`, which makes it operate entirely in-memory. This is great for integration tests involving S2. -### Try it out +### Quickstart -_TODO: `docker run` cmd & quickstart_ +> [!NOTE] +> Point CLI or SDKs at your lite instance like this: +> ```bash +> export S2_ACCOUNT_ENDPOINT="http://localhost:8080" +> export S2_BASIN_ENDPOINT="http://localhost:8080" +> export S2_ACCESS_TOKEN="redundant" +> ``` + +Here's how you can run in-memory without any external dependency: +```bash +docker run -p 8080:80 ghcr.io/s2-streamstore/s2-lite +``` + +
+AWS S3 bucket example + +```bash +docker run -p 8080:80 \ + -e AWS_PROFILE=${AWS_PROFILE} \ + -v ~/.aws:/root/.aws:ro \ + ghcr.io/s2-streamstore/s2-lite \ + --bucket ${S3_BUCKET} \ + --path s2lite +``` +
+ +
+Static credentials example (Tigris, R2 etc) + +```bash +docker run -p 8080:80 \ + -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \ + -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \ + -e AWS_ENDPOINT_URL_S3=${AWS_ENDPOINT_URL_S3} \ + ghcr.io/s2-streamstore/s2-lite \ + --bucket ${S3_BUCKET} \ + --path s2lite +``` +
+ +Let's make sure the server is ready: +```bash +while ! curl -sf ${S2_ACCOUNT_ENDPOINT}/ping -o /dev/null; do echo Waiting...; sleep 2; done && echo Up! +``` + +[Install the CLI](https://github.com/s2-streamstore/s2-cli?tab=readme-ov-file#installation) or upgrade it if `s2 --version` is older than `0.23` + +Let's create a [basin](https://s2.dev/docs/concepts) with auto-creation of streams enabled: +```bash +s2 create-basin liteness --create-stream-on-append --create-stream-on-read +``` + +Test your latencies: +```bash +s2 ping s2://liteness/pinger +``` + +Now let's try streaming sessions. In one or more new terminals (make sure you re-export the env vars noted above), +```bash +s2 read s2://liteness/starwars 2> /dev/null +``` + +Now back from your original terminal, let's write to the stream: +```bash +nc starwars.s2.dev 23 | s2 append s2://liteness/starwars +``` + +### Monitoring + +**Readiness** `/ping` will `pong` + +**Metrics** `/metrics` returns Prometheus text format ### Design diff --git a/lite/src/bin/server.rs b/lite/src/bin/server.rs index 4b6b128b..c96d0948 100644 --- a/lite/src/bin/server.rs +++ b/lite/src/bin/server.rs @@ -78,31 +78,7 @@ async fn main() -> eyre::Result<()> { format!("0.0.0.0:{port}") }; - let object_store: Arc = match args.bucket { - Some(bucket) if !bucket.is_empty() => { - info!(bucket, "using s3 object store"); - let aws_config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; - let mut builder = - object_store::aws::AmazonS3Builder::from_env().with_bucket_name(&bucket); - if let Some(region) = aws_config.region() { - info!(region = region.as_ref()); - builder = builder.with_region(region.to_string()); - } - if let Some(credentials_provider) = aws_config.credentials_provider() { - info!("using aws-config credentials provider"); - builder = builder.with_credentials(Arc::new(S3CredentialProvider { - aws: credentials_provider.clone(), - cache: tokio::sync::Mutex::new(None), - })); - } - let store = builder.build()?; - Arc::new(store) - } - _ => { - info!("using in-memory object store"); - Arc::new(object_store::memory::InMemory::new()) - } - }; + let object_store = init_object_store(args.bucket).await?; let db_settings = slatedb::Settings::from_env_with_default( "SL8_", @@ -193,6 +169,59 @@ async fn main() -> eyre::Result<()> { Ok(()) } +async fn init_object_store( + bucket: Option, +) -> eyre::Result> { + match bucket { + Some(bucket) if !bucket.is_empty() => { + info!(bucket, "using s3 object store"); + let mut builder = + object_store::aws::AmazonS3Builder::from_env().with_bucket_name(&bucket); + match ( + std::env::var_os("AWS_ENDPOINT_URL_S3").and_then(|s| s.into_string().ok()), + std::env::var_os("AWS_ACCESS_KEY_ID").and_then(|s| s.into_string().ok()), + std::env::var_os("AWS_SECRET_ACCESS_KEY").and_then(|s| s.into_string().ok()), + ) { + (endpoint, Some(key_id), Some(secret_key)) => { + info!(key_id, "using static credentials from env vars"); + if let Some(endpoint) = endpoint { + builder = builder.with_endpoint(endpoint); + } + builder = builder.with_credentials(Arc::new( + object_store::StaticCredentialProvider::new( + object_store::aws::AwsCredential { + key_id, + secret_key, + token: None, + }, + ), + )); + } + _ => { + let aws_config = + aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + if let Some(region) = aws_config.region() { + info!(region = region.as_ref()); + builder = builder.with_region(region.to_string()); + } + if let Some(credentials_provider) = aws_config.credentials_provider() { + info!("using aws-config credentials provider"); + builder = builder.with_credentials(Arc::new(S3CredentialProvider { + aws: credentials_provider.clone(), + cache: tokio::sync::Mutex::new(None), + })); + } + } + } + Ok(Arc::new(builder.build()?)) + } + _ => { + info!("using in-memory object store"); + Ok(Arc::new(object_store::memory::InMemory::new())) + } + } +} + async fn shutdown_signal(handle: axum_server::Handle) { let ctrl_c = async { tokio::signal::ctrl_c().await.expect("ctrl-c"); From 805001125011b2072182b5e15d59f5cb7cf1a9d1 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Sat, 17 Jan 2026 16:43:02 -0500 Subject: [PATCH 074/700] readme (#80) --- README.md | 31 +++++++++++++++++++++++++++---- assets/s2-black.png | Bin 0 -> 48308 bytes assets/s2-white.png | Bin 0 -> 48201 bytes 3 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 assets/s2-black.png create mode 100644 assets/s2-white.png diff --git a/README.md b/README.md index defc7207..297f50ab 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,26 @@ -# S2, the durable streams API +
+

+ + + + + + + + +

+ +

S2, the durable streams API

+ +

+ + + + + + +

+
[s2.dev](https://s2.dev) is a serverless datastore for real-time, streaming data. @@ -92,7 +114,7 @@ nc starwars.s2.dev 23 | s2 append s2://liteness/starwars [Concepts](https://s2.dev/docs/concepts) - HTTP API is implemented using [axum](https://github.com/tokio-rs/axum). -- Each stream maps to a Tokio task called [`streamer`](lite/src/backend/streamer.rs) that owns the current tail position, serializes appends, and broadcasts acknowledged records to followers. +- Each stream maps to a Tokio task called [`streamer`](lite/src/backend/streamer.rs) that owns the current `tail` position, serializes appends, and broadcasts acknowledged records to followers. - Appends are pipelined to improve performance against high-latency object storage. - [`lite::backend::kv::Key`](lite/src/backend/kv/mod.rs) documents the data model in sl8. @@ -123,7 +145,7 @@ SL8_FLUSH_INTERVAL=10ms - `/access-tokens` https://github.com/s2-streamstore/s2/issues/28 - `/metrics` -### Limitations +### Caveats - Deletion is not fully plumbed up yet - basins https://github.com/s2-streamstore/s2/issues/53 @@ -131,7 +153,8 @@ SL8_FLUSH_INTERVAL=10ms - records https://github.com/s2-streamstore/s2/issues/51 - Pipelining needs to be made safe and default https://github.com/s2-streamstore/s2/issues/48 -**Compatibility** +### Compatibility + - [CLI](https://github.com/s2-streamstore/s2-cli) ✅ v0.23+ - [TypeScript SDK](https://github.com/s2-streamstore/s2-sdk-typescript) ✅ v0.22+ - [Go SDK](https://github.com/s2-streamstore/s2-sdk-go) ✅ v0.11+ diff --git a/assets/s2-black.png b/assets/s2-black.png new file mode 100644 index 0000000000000000000000000000000000000000..aab3f8162ae4b5b0134894d686e0eb57fad521df GIT binary patch literal 48308 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 W`!Thl!Pzhd*|ybd%gs%c)Bgc)m3j*R literal 0 HcmV?d00001 diff --git a/assets/s2-white.png b/assets/s2-white.png new file mode 100644 index 0000000000000000000000000000000000000000..01dd8be0f7c097628f81833e82d570c34655ffdc GIT binary patch literal 48201 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�l6!n?^GoOEAH@*MYMV!{!dQJ|jm$yKE5`3{c# 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 B1(^T< literal 0 HcmV?d00001 From 2e00ba65ad07a480547043c7aae964ce25392967 Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Sat, 17 Jan 2026 16:56:44 -0500 Subject: [PATCH 075/700] readme tweaks (#81) --- README.md | 56 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 297f50ab..7e6088e2 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ It uses [SlateDB](https://slatedb.io) as its storage engine, which relies entirely on object storage for durability. -It is easy to run `s2-lite` against object stores like AWS S3, GCP GCS, and Cloudflare R2. It is a single-node binary with no other external dependencies. Just like [s2.dev](https://s2.dev), data is always durable on object storage before being acknowledged or returned to readers. +It is easy to run `s2-lite` against object stores like AWS S3 and Tigris. It is a single-node binary with no other external dependencies. Just like [s2.dev](https://s2.dev), data is always durable on object storage before being acknowledged or returned to readers. You can also simply not specify a `--bucket`, which makes it operate entirely in-memory. This is great for integration tests involving S2. @@ -105,45 +105,32 @@ nc starwars.s2.dev 23 | s2 append s2://liteness/starwars ### Monitoring -**Readiness** `/ping` will `pong` +*Readiness* `/ping` will `pong` -**Metrics** `/metrics` returns Prometheus text format +*Metrics* `/metrics` returns Prometheus text format -### Design +### Internals -[Concepts](https://s2.dev/docs/concepts) - -- HTTP API is implemented using [axum](https://github.com/tokio-rs/axum). -- Each stream maps to a Tokio task called [`streamer`](lite/src/backend/streamer.rs) that owns the current `tail` position, serializes appends, and broadcasts acknowledged records to followers. -- Appends are pipelined to improve performance against high-latency object storage. -- [`lite::backend::kv::Key`](lite/src/backend/kv/mod.rs) documents the data model in sl8. - -### SlateDB settings +#### SlateDB settings [Settings reference](https://docs.rs/slatedb/latest/slatedb/config/struct.Settings.html#fields) Use `SL8_` prefixed environment variables, e.g.: ```bash +# defaults to 40ms SL8_FLUSH_INTERVAL=10ms ``` -### API Coverage +#### Design -> [!TIP] -> Complete [specs](api/specs/s2/v1) are available: [OpenAPI](https://s2.dev/docs/api) for the REST-ful core, [Protobuf](https://buf.build/streamstore/s2/docs/main:s2.v1) definitions, and [S2S](https://s2.dev/docs/api/records/overview#s2s-spec) which is the streaming session protocol. +[Concepts](https://s2.dev/docs/concepts) -**Fully supported** -- `/basins` -- `/streams` -- `/streams/{stream}/records` - -> [!IMPORTANT] -> Unlike the cloud service where the basin is implicit as a subdomain, `/streams/*` requests **must** specify the basin using the `S2-Basin` header. The SDKs take care of this automatically. - -**Not supported** -- `/access-tokens` https://github.com/s2-streamstore/s2/issues/28 -- `/metrics` +- HTTP serving is implemented using [axum](https://github.com/tokio-rs/axum) +- Each stream corresponds to a Tokio task called [`streamer`](lite/src/backend/streamer.rs) that owns the current `tail` position, serializes appends, and broadcasts acknowledged records to followers +- Appends are pipelined to improve performance against high-latency object storage + - **Temporary** [disabled by default](https://github.com/s2-streamstore/s2/issues/48), you can try it with `S2LITE_PIPELINE=true` +- [`lite::backend::kv::Key`](lite/src/backend/kv/mod.rs) documents the data modeling in SlateDB ### Caveats @@ -161,3 +148,20 @@ SL8_FLUSH_INTERVAL=10ms - [Rust SDK](https://github.com/s2-streamstore/s2-sdk-rust) ✅ v0.22+ - [Python](https://github.com/s2-streamstore/s2-sdk-python) 🚧 _needs to be migrated to v1 API_ - [Java](https://github.com/s2-streamstore/s2-sdk-java) 🚧 _needs to be migrated to v1 API_ + +### API Coverage + +> [!TIP] +> Complete [specs](api/specs/s2/v1) are available: [OpenAPI](https://s2.dev/docs/api) for the REST-ful core, [Protobuf](https://buf.build/streamstore/s2/docs/main:s2.v1) definitions, and [S2S](https://s2.dev/docs/api/records/overview#s2s-spec) which is the streaming session protocol. + +**Fully supported** +- `/basins` +- `/streams` +- `/streams/{stream}/records` + +> [!IMPORTANT] +> Unlike the cloud service where the basin is implicit as a subdomain, `/streams/*` requests **must** specify the basin using the `S2-Basin` header. The SDKs take care of this automatically. + +**Not supported** +- `/access-tokens` https://github.com/s2-streamstore/s2/issues/28 +- `/metrics` From 75657fcb132ae4214556d701ef20547f055cd460 Mon Sep 17 00:00:00 2001 From: shikhar Date: Sat, 17 Jan 2026 17:01:36 -0500 Subject: [PATCH 076/700] release: 0.3.0 --- Cargo.lock | 6 +++--- Cargo.toml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47780d5f..1b78f178 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3431,7 +3431,7 @@ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "s2-api" -version = "0.2.4" +version = "0.3.0" dependencies = [ "axum", "base64ct", @@ -3460,7 +3460,7 @@ dependencies = [ [[package]] name = "s2-common" -version = "0.2.4" +version = "0.3.0" dependencies = [ "axum", "blake3", @@ -3483,7 +3483,7 @@ dependencies = [ [[package]] name = "s2-lite" -version = "0.2.4" +version = "0.3.0" dependencies = [ "async-stream", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 0e14d1b7..44910dd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["api", "common", "lite"] [workspace.package] -version = "0.2.4" +version = "0.3.0" edition = "2024" license = "MIT" repository = "https://github.com/s2-streamstore/s2" @@ -40,8 +40,8 @@ rand = "0.9" rcgen = "0.14" rkyv = "0.8" rstest = "0.26" -s2-api = { version = "0.2.4", path = "api" } -s2-common = { version = "0.2.4", path = "common" } +s2-api = { version = "0.3.0", path = "api" } +s2-common = { version = "0.3.0", path = "common" } serde = "1.0" serde_json = "1.0" slatedb = "0.10" From fbca283be7829cf325228056f367d0617d8f8dbd Mon Sep 17 00:00:00 2001 From: Shikhar Bhushan Date: Sat, 17 Jan 2026 17:03:56 -0500 Subject: [PATCH 077/700] update copyright line in license --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index c535bbb5..53b0c74c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Bandar Systems Inc. +Copyright (c) 2026 Bandar Systems Inc. and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 4bd7d11c4c36d5a8e1de9eb3a584d2f0a7981a19 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Sat, 17 Jan 2026 23:01:03 -0500 Subject: [PATCH 078/700] chore: update readme with cli gifs (#82) --- README.md | 4 ++++ assets/pingtest.gif | Bin 0 -> 118992 bytes assets/starwars.gif | Bin 0 -> 2543033 bytes 3 files changed, 4 insertions(+) create mode 100644 assets/pingtest.gif create mode 100644 assets/starwars.gif diff --git a/README.md b/README.md index 7e6088e2..9e48f3ea 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ Test your latencies: s2 ping s2://liteness/pinger ``` +![S2 Ping Test](./assets/pingtest.gif) + Now let's try streaming sessions. In one or more new terminals (make sure you re-export the env vars noted above), ```bash s2 read s2://liteness/starwars 2> /dev/null @@ -103,6 +105,8 @@ Now back from your original terminal, let's write to the stream: nc starwars.s2.dev 23 | s2 append s2://liteness/starwars ``` +![S2 Star Wars Streaming](./assets/starwars.gif) + ### Monitoring *Readiness* `/ping` will `pong` diff --git a/assets/pingtest.gif b/assets/pingtest.gif new file mode 100644 index 0000000000000000000000000000000000000000..141dccb1581f55e838aaaa03154547d3ba84b16c GIT binary patch literal 118992 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 z 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 Gb^RA7#k}qS literal 0 HcmV?d00001 diff --git a/assets/starwars.gif b/assets/starwars.gif new file mode 100644 index 0000000000000000000000000000000000000000..4ddaf54c125b02227c7b314f9061075d6fb7c4c1 GIT binary patch literal 2543033 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

pM&&R3~HRj?4I+=Z#ORSCI6;Ivv@2+yR& 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�A-v*yC9_Q&}@9;hcCK!-BL;k-GrH1iblxX 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

  • ncA6WlO4p<>? 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

    AbyzKw4r`-Oa2&)CxkUk{jwpn?On8+U!A4# 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

    #Dt-$wi{FY~(^C89YadMG80-g`(m0Owg#a`2` zDpLD9tQ<5R(MgG_P;;Y8U37O)O-lRh%%hG)D9Aq$qG9wQ@wLn-q3CJlq|e}%%vPIDuI)KwJNT=ha*nXdj2G7tqmE#S4RQB|aJ-;kC7vL|C z;^ShPJ={fF)}P@p(sR>#`NK);vpt+Xzl21Z-SrmCV0+PllJ~=d_N+Z>=j~gTdDZVt zkXmtbqTU2dGtIFI))B?16*CIr*irT zdk^=R!CLfh(aa>Z{c0c7{qiVB>I|@M_LDt(I|9JT0+O#*<@ioPNQ`Zz6O+Bg^j36M z(kuI|c|h#FllSV(e^8wIqa)r~X<+7Sih$Y`VQQuQ@^y1`(%*NcJ}lekR$zkDCaqrgOs-yuzH4NW%>@MI7F{;t>g-NKp???yT zP7Ay*?X-$V5l`C@97R<-6^c)WZPPyO@Z>XohAG}n04TMHDo$>0CQr`Iza3G-A}{bs zg@)zjH9eZi$NUh1m~!lMZJ_0s8|z^d(gzg_1_>|J}5%*D;_4v(}X=c#a! z-ylX?Ebb194OAQBnh4dJ;)yZ$8R7RE2ri0Fzf<=a3;I<@7wJ!Ek{*CkQ)=fqY&YM} z2nJ~zov!8;3s>ZqSiP554Uf%a%@&o~S4np~!#WtV6(u{y=ki~UMA0_s+a?`9cWS|z zavWf$7n+Y+ZGDvB(cW7&291Sf$N#+Lud~X)&uR;o$L}e)`kuwqT2W-Co6|oAb_f5r z*j=pc@ai36Z?5-nE*t^bc^JW2hcQMoX9~?%uCSM-I!O-S6o1hZ#>b;L_vxp*euvf=3NdLDi0!&r5Dj`fw%t%ClwWzan)oWpb_I`gA~g zLpbbR7fzW}W`+w%WC0jX<19TaxWNbR5w$GWM&R)GbMj<1#{fUw;9asKuJcUWWNC}ATz*7!) z@ARg7`^R=fuFxKTC~y|+<6O}#nf{^IrpxQ_llBn@DZ#P#LiyGcvFzD@b*Nf?^fl79 z&zQX-|Kjx@*F^yl2`}sZz5m~jZy0C#^7MdhC+g>W@4qxFqXH}Ni0vBbvRp=vo5xR1 z>GCR{_#+MBSbdmC{PJKC)$%4GvQVA3a%^PY^?#qNtR9*F$Cw{@^ue~XUtxUK8PVkWZ* z?I)zRg7dNR6T=JU8;KU9YwZ~LThGoj)N=BDLL}dA=a|>#hlJOGq|&x|1!Ej-&(2eO zV6krJXfr=Iq^iYd7o;ROnjjoI0{54Yw`!^T-6Cb+0zI`Bp_S{%R6a3A;XVVP)#_+_ z0jl+G#jl^JRdVVaQ=X5EsFu?XXlaW9LMTEMnu!(q*jj*^WCZt}z(?7CwT=d$6&Q6t zpOl^)FET(YP1H>$Vz`2sb)#UWg1EFHa~585z?i@0&U@U*xTa;GS|a*4_Xd>^ZzNCq zn_8(SdFcqzW@v(*;xLQ0T?+-u@gIlOX6g|+tz1uPu7-v8*U+@!6g>n3&|eKIJpLe& zQ_6vwVub9iaL0LT{y)_6OVk|z@jgd8MWUSXsn`NSm7+EhBY$KLDW$Ox>k{fLX z>QVM_5y_X227xvL9d#6CWMRBwVz_HzXryU1#mfe~85evD<29~sb-r->Gw)1P8avHwT~d00V5GbiNr+~^TTck&Ll!N-Tnb9}LGD_>mQVJZ;1S6utv8Yf0aiJ- zhj@RDJ0Ehu_aP17R+#kxVcwB3RwJ7IGw1_G20kd&hcSXI`ppOQta~W+xSWN9)L+hu zcfKX!g_KWPHhx@C%qN9?YxJ`^pir=eMXc+)DK2Q<3UhL+nSPZIm&zfW&3}Y4JLS|$ z1uI=E*=^?ZiD=Dc&LcTBSO)4)Myrl~4rV?A7)t?=K1BMwgi)cOn=r;A{h75E_(>Tqnj^jM*=vMe*T&(;Om5u$NO`$ytk{@yFV;w1$OeC2GnUlAI~6K*)@} z@(goP&yp(_w_WiB2_hNwqt0)Nmi`R=$R*AB1uG8gAsokgZ-+ZbUkIYDD|X? zxNiv9r)T!*DVd_wHZ7TGF;j4gTrC3gkftpn=-Yv^fBpq4w5(qgc*pR3pd>F}M~ykg z*sG(Kz+}84@)JtkEy7d?X&{i8W1^OcsD&_DFx%OkPw2;(E7Y-4-T27O{$TglT$lBnnpO@vI8|n^7+z zZ0YCPz>V&6<&<@L>Hwdy@J|9^A9<0Pl&NLhku&0)h}|gjvW!I4g7}PE{G=d_J^7N69o#Y+8GHi{qal%7`33)uLmRAV3z%HkfI)-^jN7O{+9>+rNTzg#Ji$Cb0&v=verO-8wU) zYd(dkrNo`5cDl3M%p@V2D=t0eX@t(n*|`YR*{t#w9T}71vlgPy7;A?ScrwcQ#OHig zFssb-6OHt;*{m*vI@t%FqHlHCCbZL0m+q!aG8!UfqXE5A-q@wzugGrDB`L_ z!sjAph8$d=VBgeJC+g{8L3D2|G;5K=H8bay0^-Zb*>hmU1Cp-^5{Q69M%KX!|1BoG zfMa=US zhW{qpOYak$OudiF31KjJNWnfLqMkyc27#V<88v7&{j!-+2G4qp@3SX(m6(`UVWRYe zN`8r=p2NCq(a|bI)a4kM0WhBU?j2Ati!d-sS9cU)U6&gPLj>C0)>)WwpF6wMNDM*F zmP&{Mlw$Y%Y=jofSFo4RXwMO5zlp{*LhE$Qn`YXcgY=2Ihc^?w-YuqXEMVTmm;-X^ z1a!zR8Snq}IV;NiBm|j^I&5OB6ZrOaeltfo{*uZ_sHqzjLe^nB$1XU#UMOL z@f@MqyyQLC(a9hf4^WL-hEEPt+XSsqu&*JTpJPnCAmI%YViiPBJ~i$&WrB7wshSC; z^zT;!I;{WLosexh{MCo~%*Z$-Ctnm3N=#FaYZ;r@{hZ4l#}o?iK$!q!1~0L!FhXQxpa=qg4m zc!56&(c9b@gC-k6)Ocl`!ePE0F<(YqC!$mVeuvC}*aQXZZ(r6@UF3ORP8+{{nBcCU z-IuYO9vBX}n%>G78;q{2Ua&67>DLtWn|O*y2Ku3-+Xzcv$lQZ~5A%fk1Ho2+oq$na zndy6sjQ8kp14e6 zi=MUO2j@B;hj+pFx4_10x2Bsxm4bB@V?30zp`)ifbrgf1`N{ke6+u@?E&vtX3dU?+ z4QKxqzq8VI%`abvOJ*wO!5enK%YXW|F~C30gWS$K!bIVj_!<(u;v}XOJ`Z*X2}>_21)PU)!Ma1;Fcv8JWfj z?Ip-%(J@9Pv)Mcrp@S6I?IHlev$TwO*tnFk4JJ1XeK=f68JcS%AAW(ebD1!+Uqola z#N!BisbwzhF3ehu#r(5^(Wf9BHL|yu!2Kw@W^=h;o_PZMhO?GCT~F9*9HYv^gW20Vkb4UD z&1yT99(#1x)7BM>3nkn3xRYu;=Cw)w3-P>BY3=g!mhqeKkL+WIyP)r4$||UC0qxZM zU~-k8;?k#({?zQJe#p`0(|!cP+a(tx1~0Gr?=QTKy5+-*8wVF{Sm74lQVSj}n#A@& zPn-G={y)`#PwBT^-h9oR_UosNTSctT$>mWrxxeNK1QJT_aNO#bUoUZ4`Zastf+g%5 zR-Jm+Q>O9z{&2x2=9P?EAoKnO;bY>%iY>-Gfimer(v;cn5qaMNIQ-Gs55D>d%l)UZ z2eLPlN1|A6)BV$~O^nu*v46SfFjZjrKKM1%pFefAT}ZMRB0B+LHxDfHHl*)g6}=y& zx~A%m9#~1m&Ho(*$;yKnuCv^CUPx_20qF6$?8skpdO!-x44>E=?5{IG;;#RTt8Mmm{X@?BR zwMt9pQktB%;5J;=IEq{{<}Mbpd%ieEGLm{g*}yn4h8pUjZbaFp3A6`tvYs}@S!KDM zQY`hRr61s3`XUsU)IZIV+lHUHTkh{g4|rD0Ce^-rQgR&D;H`s1aX zb8G$xB2O8v4qQov3|p=U{z-LzDs3#KkcOSxc@jc2EqFjC9~7A+Gn)@DeJ0@HHq~An z5&Me4p?3*#i+4E~kvL(s>^9%Q1nztKq!r6e*=(Kmn3mj-YCuCw*SDS?fldw-gv@0m z3Ur6oHFT@xUQ5Rt?_ePbS4|OY8pDWN*mLm&*eDu99!fGZFyN#CRrJ9Hk3Mz7^5kL8 z<%D=zKRg`nC#`bg*H*5x-76HEFtg5QqFr)ZWc5jI3Yg6rg~+AtZ0a)?KCL(%OW|mvCx)eFDOl_f zPq&t@46@Z|$dh|^?`Sua`ExVO4zzdP1CdAj)EsMSKCU23jPz7#z5BlJj*15buS88I zQ&|=jp`|{oML2UWBGA(ss!`eLnDx{&9tS|BdRb7QA8J98qt&h{AwENK5K$$zDFx%l zhx#P8#Sqe1ip=>{Jjx@GVLqzA>+niho&%eUnzaCwu!YZx zQ7kjf=Vl<|5);mb^JMI#A@W20U008UGHlTxWt9HVjMGO^braB$r=>To&ob|1K7)D<%VJN}i z2c$Ey;$l^D8wCExM6CTUWo3?9i~$R4)a3M<1il7-&We@V2~@Zw(FumuF4w6sTV{@i zJPH!~cO9zsplQ&qwVVKjpNUy}8!|uPK_uT-9KvMRJe(?X_TM9$XoeDsl%0O+FBtz^ zrsQ)(;-jd6&!Dqu^X=X%C`L-6p!047g!(=< z=HZQ}rp3epbfr)h`s`wOND>pCHU$rT%)r}}vynz(R`QR%64y|eb(H>tgXR6_X?ipJ z*X>Ex4Nv_AicHHt1*O1MAz@qhu-p47)SO zn;Q}D;@+Vq_4xeh<7>w5BA{tZUS2_aXbCu~$x1qv4tP1k|Qiz+PA_c;3z&x?-(@ z`_RM^cS!Fce3=R5GIIs*a(K>n~>P`x;odUj$ysR8&+Aj8F!7CGlmaI95Yt=B=2XNm2?5-R<}%8%U(S@_~s$@hQMT5CzP@;MTdCxP3$xV zkWa@(ISmM*Q-RU&5(C^Nl(asstqD66RpG{Dp{(THh_rU8TcQXPwLcy1p!;9_Gd%gsCA$>@QCl@n3lTIu6OG=zt}EoW zx3n&RXS%zH8b4|8!|WK^JqEMMdh@jgwR7zGF9}{~Fj=h-0rQEl3(pOJtw4*h(gWAI zNPr+veHZhy?fbH%6Y?AFwzT1N)P9u8IB66L@l$~9 z#BE#()T{_%r58h1m961Fz@e z70k}sJGQNLHe(P}3e;5~#1v7e+aN@N_>Un9Ywro9P-HLfxI&*hTp%_V2;Y7bAsd9C z&g<+F043Be`x0)e5~k!}pRE^`OhmeoMQetI^dhL4F9fgfv^U(Ly0Jkg)?H$&G3R32 zq)>N5&Ut#A6SqDnT&jEW5pBAMGp9ut3Fk^R)fR5RhaAdPA^yX!2$zJaNdZ<_jvdqM zxJk|QmP%WpII>&f)a`eZ&uJH4?o*rUd7VA-%1MFy+|)`=@xkE2&hFztR8_z~MIwud zn-_mU{1xz&SQ(#&OhHSUS}P-pJA>QWXZIX_sJk*J*R{F+c5aQn<$ zhO}`>F=I>s*i8V*-w%TP^QE}X0`9%SqgJ#m(*Y%F3!VTKNL76mdvDER-(# zm{KLg#g>q@_CxocSl{nZ-TQF4d*Ewl-9YsAwwV05@W{e@hdb{#^xi)*GOsZAfMY0h zCl_-NI={Zq9kd$oUss+>oS)2%a)dl+=Ti=DadCgIw`~Xzqi{opJK<`+D-Dr%sQa1ulF= zj4Pi;-QM4`t>I|CiTNKEELguE>J=-t zPYjn20lD0V$!*=Qio0rRA3mCn%Py=06%xztKj@HNz7;6G?IgYf@3H@Ne?rR8lA*d> zt4^mUj=Z?l#PkNU>t{NE4%YJU776?=Ocl^g-a`W6ojJoz)?5iCj%U#dqqFQoYb z#8#ofe9tvCK#Ucc(*ct5QVm1|vbsU7!sJ8j=tsb6Om8rMSkn*CS|R@_8Vr6N8b9-H zp=GQeV&qXLk7Pau`Tq1yyFgDaeN27|xD-ial#&z;;9D$q1L6Apk54pHuXT&aV?rvZ z0ZG|oGp7Gf=>QP{j$&KY9x5ML_w$B4mtrLL=xn-yi~$1W+#WnaWWm$e$pI2S0V~(z zeMQIeKOeb80Sw?&6ml7f{;K|6f=4pE4pbcMWh zMLgWC{ozesy>U0AZ{tEqP_5X1Fj+u>M=B*HJh8PSbW;$F)t>A^Q(RAZelM3`LF;pB z?^@RI%vKNe8AAu?p)>)(S|G`s)3?zS)^{`1e@a|Jg!>7hwsfe4TQaL37Owp7A8svl zMb(QG8(N=ltEBLgc>CRO|0xtlFDc=RjZWgN|3bF~Nt}u(R_cBq$g-?8U0t>o%0#pH{jNJ-IiX*bjfq>X^dBn2xSXlB72rCo>nJ_Ds z3Sd~?9=G^XZ8&O`5WJ2WT@dV01evXnZD@y)Qk-mQKv1`ASrLrd8fPf3GGeb^|4aCA;MB!R^Xfd=o2!P4{Uw_UG0l9 zuPcjCMVc$6mMhOKp-ae2NPJOCNhoT-wrtLnkW7;-SNWFv!G<1*W(j2Wb5S`0IEKcw z9J7D+0y!rX_+21_^bHimqSlN-37_COL@xnNN+Dh_vIJ#?p@YlRVkl^)2=#1MNFq0g z0)5HBk4xQzqWM<_BEiWgbtEzx2#uBH(2?s?5aF$9HYre*Z);}1?BVdK*xK@CW2M=x zNdMYZZx!2ffEq#)K$p;A=O-Bs`c`ABv<(v1Hm^igFp0K~_l zm|)v!jg?iuFhmJb`r){#E_<;mdzayM5#Yj;vQs3qDU?kSl;Z|ptwb(Rh!?fBk2b&j zSGC%|b$13+meqaA4g&mN-N*?vw5MOPiH}@9fyh>txe~?q1_(CF+U4j|g(+pM_QSZN zl^4!JQ}xIU9&$AuR9Sx((LrZ`dMq;@ox~T}2*qG?dTmkZGQrytA`spUDnv?`hsp{S zA@4QFs!2qKQkGRK!7?i{YY`{3rFcQ~rm3F1wd2*?uyUWZekaZ;?*c3D22-* zsEx>XWSW>FfXc4Vf8-|eg&sB$b#TkdXd@#iH@xb%_vmf>s(FTDsg)Qj_u_r51=#WJN+m zjIj@yz5;$La*wYlk}jP?lrB~}Ts|jEh;Q)ghZ)nxWHGg3EFB)Ngd&S1Z^BFCzlq~( zCC)TS_YBIi2D)Mf?#e&P6T(P{adyaePE6tKzI7kmt@t(&M0k!xwf)1t;j?IRaUn8}O#@ zZF-=Npn`7^O0A0kTnW}n0gY9ne1>R+0vXpJbZM7vVxqS5%5s@frzyZ%4&MX{HTq@W zHea)ufTWF$Zp^!KQ3FrtmT)b7a!v!ZA_!6~-cb*u>P6cwfa)56qLzSi9oE#=$wLT& z8+`HzVi!0Z69TjexN!@u)NMlO)(X!TNU?vs#l#9-d7lY{i$5XVuhfkW`kUeQ9_r7md$S$p^1nMwTKYf3ry=X*KT-uzQhb7!l<5F z)1=1*5}Hb~VWKn`BJzj@D=t5{7SkHnY3*7@oih|qhv&!UZe~li)t1gxzzW=C8Nymi zbSdwD^ftvu#fy%vqJ%+BnVXmH_EPo;kH0{sq(HH;l7a~YlP}5RA^E-tJTWZm-h5*l z0?34-LZ+C1{w=alKx1NM`?HZNX|j}1DOL$hiI**-gI`W$))dT4CE^+oX-wIgcKEE^ z11_^RZobxz5lFN9RUmdm2)}C=3MH$>1&{-Y>j+A?$i?@gpkG|#Dwq$Ed)`s_J-p(C zAUVvTPR35D^%s{W^fo*=u-@8*5HaI7-aQdT%fA8NoT7+F@A2$!^@2W=umiI$ZL(d` z7gK>Dl_ak_&Yfp*CDNmKDl~%|eWKv||vP!ZCVN6*BmNV4b;8%#zWr69`T-W`2Y%s%f8zXCt>o>_J|@6)fK@%~F|pP%3GJNWa!jji84 zfBGFVVbGSD`{YGzz9+Rom+y-@#i_9`7`Wfm>}lbbb>2nAcUb@O>V_VSMs@Mk@q3O| z)FYKYPDpD?qYv^vY>C?gGuz(wwrS+yA3l23GZxOI@mhQiK7X$T1?^r)&vb2>AcWo^ zUt$v}y?U<0&LRPa`VbDoqW$C(PiQa0o{0%YqDE)Z5r6zX*;g8!gead*eB`vCo&HK> z^Fsi~rZhi)Xy-7_+@Ih@G+W_8>eS!$>x2=$;07s$Ix@*P8_dQ>t{rkQTRR$kv zX(erEPeKwy91rvoNo+_uCipq|A~CL;4twOMZ`c=W?k<=>`@I%&Ox;%%sff=#$kK?BJ8IhhN4 zWFv9onEGugr1xW2WMw?|E6UYN@Q!L0A@os?ILaiFmyY*4TTH_G-!-sVm5qK`udd%C zmBH}b)4#ntzC4)ivk6^S=Y8w)m6hJ>e%Fs~ybWL@d^Lz`S8IvvRP}&Ip`7@qP0y>u zO8d^~;rY-2p7?|BeWD|l;?cYQnZ(wX#AXZ`zNQh3Y#O+`AFq~};3LLPEnIlZKpL9` z-}UloPSu~p%GsGxp7ijB$#@-VZTm3Rj$!kZgLm!7#{|5tHOKj1iRZydf%{oK{(EqH zhzWKR;(l@{jltzR42VoTMK}?x7WH6RR`~sKKQUf>n#Zvo6)eG;&s8z+`t^Lzcw}=q z_vPCsg$|I4(B-&Gi=3mF+C|Rm>&6q(36}uMOow@0u5REtf#^tf;Fxl9x?ncVs5L_Z0%k>*#4;6X zqwkhui+Mmmp~UjJu+wxV5Ob{haoXvp8Wc@qx`eAi2o_lBx3@}29fRwNc$!}RMCoR* zy>-g~t_%ID&`w-b7X9!`EB1ccBh`kD*ysE`4E3?dNo~@FEQFwf(-<(1 zGjv4t<*r!~zLmyoS|{s1L;Lo|9t2l^9FORiEajfMCf%>Z?wA)> z;Rk~jJJ8-1iD5jJ>FVNiM7sDY#3Wua}_48m%97%j}!ttJfuIA!+?x zf&POcPcVO4+p11ch)kD)6*9yjfSpQr7m%Aw0`-9DB7J$g`19M|G+3MZ6 zI7lqKNnwE6@j4@mzo1BMYTV_qD4*J%%V)LtsX>U1H|5fzBrs?A6_RzBM~coPd8l6q-Nk=w>Bf`brN(T_ z<_WdM4N}zt&@K6C3SsBs3tJA!q&caUZOs!%7@a&uker3}Y?bGAIAH1Ed~t=aBRfJ1 zb8The!a` z8#EzIb8NuE73``2zq&Ua2$?7qGJRouUzpX7r=>x08mWy+32Wvd!zv6=M{suLH6}zI zHHf14nUwVgA<846dQGYv?xkXxt{_UOS#%f@zi>k)5wIMO;vS6Ez!xaqov`|WqW$kA zt!dR2z3k+&#D99$5`NL))#yTZw3ijNGu{4YQ8ZeGK`hY0HkQofS_<(>^e@gOj2NofYb1A@uzaWq?~Y%*!%q2?4fSzU*4 z?K%uX*MwnmK`qC2j9!W_Vo`gGj0mbH%XCfEVB_(ih3m@WfvC7lY8twVnEH^|e*sb~rCb-nOUv{sMGwvC{IBc>Wd8rDjJmuy@rHa0VIk8Y+Bs(=N zA7+OZ`DF_@+l{{sl?9Ltw2{f34XU4m+gJnvpKUrW#02#4uw0zX(Zg37k(-VPiNEW= z>uKpYa^C4MueM_gNMoF=6}?@NbMwsp{7nl6v!`yQ$Y{>G1(A-wj*HlHKR;bJQgG*Y z#J@s%7l8i~lxh7LQVL@~uhORs+aE8gBMTU}C&>PU*{Xx*1Z&1E;Iqy8`@14-^pSdr zW9)v5bV*RIBrH{8R}3-7vBQ|)hyvl6#C+mhzb@$|v|St4rm3v0eZ$4tNG0$lNq`D( z(*k3;!NSOg{Ib-zPFUD9CUAQ7>@k+vcOkk>>@)pWaIwT14pT!T~~iGFxsG zT4%w+3AVVg)QXRfj{%5CU;PuEe&DsU0}x zAl1zej1N?65PXqqo8i|USon9gU7^svol`W;`F)ygrdFF7pkZ+w*czQ%F=f&F-bK|f z2)Ru60nS{aJ-_H!*wNuzPA|S{;*Z<1CpWOal*+KJths8V#Fr1bGYup2kA?6Z!)F|s z4<4erS^m@aO59+<0xhf%LW$7&h3fp;#kh$5m_m(5s3c%Y>$@a5f5?c##sLn2jC>Oa)>GYu3xXz!xKQK@No_rhNkSBI&BSs$%@{-@P4n6Y0{%PWTT6WXPZxRaLsB5H_rTp60oa4l0qB=l7Mjy zqL=A-Ky97{4J6mP;sEqG)07MNNM$}FVh1i0#JD5e#7SxQsI@}RA*gj?4RL%A9j6QA zjd;p6aJ8D8Bu@Lf2Dl~G^|FFPIlxctEG5fC%_J(dTlTJT;X=?W;ulk`@cSXysZ8d~ zDV3K!(%&B6F2w4YbInRcW(W9gD$%H;Fj^&@MB<#{!AXjVTD|b!qn3dh9UYBtW|}vP zEaYIU65DFBjITIsw{)yDgTGCoj#Thby?mLh3*RBcC`9DqvFk^W3B77WGkZ3gZ9_eg zU}tnT?H-FXyo{VyW1E>4@tVpRiJ(GVdbB&T^}s%FY1M62^)!r|?rvJRx8pq%{II$+ z>!2nF(H*BvT9-oKlDYXSC7!1U?Npoh8sA)w4;g9)0?(%b)I?THyUIk*hyJ;mkUEAX zW)O%7oLp$ptSKZR=G|qUz>ClZ(}ZSWxmIZXFN}{DSrm=^U5dn2@hzrxoKj>azwgKg zCVQM|;zT?ui^hW>?0%8WcmB=q5Yrxh(-vX!i56qfHmY$mGDK&mvXfYvd- z9Cv*<6!J?=m{v=7oo!sV{u!BaZOhqfdyxr+>(}u<*ow~HynQy;CK=ab{MgZ_{gVL@nzHYHn$@|*?0y%s&Er+V2gog%XO8#yBU$ua zb|pR{7t_f+@b>+^6-+A?G+4lSR>yG=Fq=Ot>~zSzRUcD{_G(71bFD@u#2vhoJka-G z9AEXJrw$!cCEFHJhe@(*naQ2Ce8aiV>W&ZU>t9w=%ckt6|X&NofFQCZe+0=Vw&p~#1Nyj zPF1|-m7_QAl8Vn)5=ASMKDXb9K5zgvlJQaGRMj^+{5Hd5T!#FZc%DZCD)ycyDpeh% zjr@kZb%UQH`RDmgogc-IBzjNz$p#0W)+vc)g8uAF67lxueHk4-*b)8C|8IVk&$`f! z>-PK1r@xPc`}9X_JR<#DCEobN@4`x7pNaEl35DlZ!dpg=tq_7QV9^Tv)ipDt`|NUo zq`JTtoPT%Yg4)W5L%i@F1a+PH{DJEH-3sxnGjDy;%bqi`jzfP(LMASoMg4vC{NLL* zPT(&PJX^Oc72y8b^Yv(_ZI}Qx>%y&u*SbWtNo;o81M7)b8?R3o1;gi&zw3~{YGkn8 z$lsT|VaLu-f6n{2?(@H+6Qr>V*l!n%b^yk{mA2c@^58|wlJ#cC1fND1ZmjZXdAvbV zhdpU_&iZnfjYZk3GYf1Veo{>iTu!|3WZi`Ee15((e+_Z>WyAovI4Pc!$= z=U&R|zvO7VxM)zlDg3L-Tk5j z+fKR=L%v1Enpmy|r~}M>VlPA%lT^hDwExDRwxXtLq;c78dB$&%ki5$2Pmx>^t%Zr4 zt`Gm`a-=bUNeBqrsR?R6#ZMCPokWh)oZxARUnt=H5Q)-@_@SK8cBtimhozH*6{Cxk ziuO?_d;CwPMD1dN*q>rfkRsJPV z9_>fx1P(A~g-KFiO6I??6;|I+Q=0H9WKe|o98%=ysq+tY4B(5CX0vAVbjcwS&xwje zpWw`ptC^Bli*K`ndqjpkqmdwY9^}_g8#xcRA^Zkdu^+oCA3+_mSoYJ}NSuV0#IzDf z>{}pKpx%Njq2+FZM5cKqL9Na-P&aXnFp_J1FZpsDneE~XxG6q*YJrSOa(Ci1~H1M zjf8VtYauqnNPeJ($OYkO>>Q&G?F5j)V_P7L5wDA!5-IxpIu_XqL3F#4^%?F>_GMwz zOzSCauqTu}B9tz;WIfF>o91l257{v-vCdV~KuH5=;%R0FO^Y2<)cCf4unj`SG|bnD zJ>DF$$HI7hkNr7R}R%wPz|*8q+D7=7SGgFR2|)jI|6Uw>1s zYIg^BAbiM@g_}Q#99!4{0tt#c%k2*#8X^uFVwu$zxi9|Oyj{ddg04^0q6Na|J$;l~ z$pMKNfws8W0C9nY*-C@=E)GAjEc}G)5-t3b!P&An6$rP{IVhkHDLUVHX31aC?=~^* zYGH-@v^OvMAUZVgA?>2Mn?|;H<6?mgEBdXoAZEE2iCIGPmbamyBAd_GokCvORq1@< zb#7z;e001$wX?>p{hPILG+@S^MeXJJ1lnC%d82*z?CZwMe+&vRdQs3Mn*?e*s*Eh` zQLW`mwy%@q^tb3UMp1-<6VO}%vjNN|gd{mDuyrv-xw!o1tn&**vlXz5yx$kF009nw zySUH}jfu?;zmWP}M=Jacz#4-ntY7N^5(5RMU4?n3QWjy6?$ABdm`gD@*Y`U38+hkx zXGy_Es2=?@UHzSjQK)IcBJWy}$#)2z4202Peqfar&hg!U1O8X^ffo?-c#(CjIJj3! z0i7@LYU&g;C{LAFJ;4!l}#QK)fDkp#4{+CbD|12j;{u?>9l(ma40BqDdPJo%Ed z%b2-8%iVH;wMs1vZE>2s*P5K%{@l~_y4rREuy>O^Ip&AN8&xKQYBC&Z-w8<4X4)NJ zTM4H;xth{T4^5Sl(wzW8E}zw)9@ZgkYalq)I_o4(u&)-?p{Az7f=0AduJDh_Ssm>C zpCnAofRK5J-F6k?&4dN@iclR8Q(C0+L8b}(U+6e1WSZ?@!^{mkXdWR-6|slbYldw& zR3{>3&fLd`^#m!zOAU)?)g+bAwOqSv`bh+{_4=urKBWEsZw-j5wD8OBZQqj_v)kS^ zZC>)iz6Q6IS8;w9M_&7{oHb4VeXtuDTk14E9+qI}blC8cPEt8k)_X-HG!?D;!YNyL zYVn-8KVzCI99>PQJ?X)pj1)BSn3A!w(0N_0)56A+OK&EpW5U9*Evn{fMH4Ki%jHu(F$T}^HF2db z%ejBjDzSM?ZIW=~>DvP(s^!iI>koX_YkwAEC4byM|iFb><0i2~pB$ z?7~38vbeD}(k+caNy|6(B;2zNVmnLbP56}^DHuJp_Hb;didC5~_VZpt{)j<@R@n(g z?6*D+yJx$0m$k#*ZT!GazBXzXDq+k6kHv2lF`6Xz>!&3I$aOAOfE3L zXR%8)a?JB`M>L*Vq)M+|G3xj3Sk`T($Ru(s|J|`|^1ztK_{i5^P<9V^j(BJ3*z+e# z5*A)*q^`=^9OE{`gH<^uoqN%@a<+A*hij)=yKMD^T6U@JmUb2{;#I#E=5P|62|9Q2 zj}s#F@(eT$#1B@cmHewUq3JshoTJZdgYBOCs=djClEELsFjuOc+wKIb5Egj@ z{UiGllH~}gptYy;;3_wutb{$Rxn!TBq@1ys(F~S4DR^R8NfG+xXbErT6#VECWVu|j z9sLW{bogB8ItxYKt04PgWWCAJol`vsyP2mVgwxCyO}1jyQOmcroW2#GuOHc~VBB<- z9sYdl=c0hmZMwW5p2H*uI(%ijT7^&q~iM27Vde?^*t-KO6tt_V9`8jrOTO-=4nk=GVuIPmV^uZHyVnw=cT0W8(FK$493oR!`5__G!JVc3bI< zwF$qnUtaiY=J%H;tBseQYD19boBlauL&UB=&N9EF;P;;)WW zT#ouW8q@~Zem9^pdnFdROgP$4gH+T>OjDI6iM|@trAac-1lISaz}Bh+Ms1mpnUG&2 zf+v7BUnI(Y>K-xv2C z_)Wxym?{TGh~6PqeVO*=2n26T>9h~mM=;t9*eyw1)TL9fAA8i;-9o8jY;A;tiixRI z8R0HsA#7N*XALMRbiyq+iNT8z+*lTd_(dMa%pjU5N8A$*&0iYb3STF`7ul7C&r8y- zT_nF3F|r3Ql{di`TZ-sA=u%?tpw%MiT}1smG2Nh@*A8)-6wZU&)k3If(AG&_E{e|` zp0{A?o|AjIgw#A}{f;T0J*ufJA!-&p7xYG68kDD<-+S~&+q*fge>~V&B_x*w z#1c8$q@#RkFt%v0%6dYLKHb~p-sC1r^W;#^6h(Q`MPiEqq4h&O!qK>&r5G{nJH2r8 zhb4=nB+mT|jNMcW!^}Z!iFUwXQvjz;`_QYyli=HU(E*zzeg(o_<-<_8k{=B~Fl9dUG6DYnYwF*cU=TgK2VyG&NWrmFMpI* zclmsy2DO5R3@l<>U)Mv2!LEyAEt7=)sbe1-f|0o_O5a4Ocb%pjh1MYayWydm(y>|X z8PLK5geejML1XubNUM;9W^H`=E)oy?H+pd$oY-wKuy=dSK^kBEgjJZm(GFeG1NL)fL8 zo+4Lz!l(s^cVBlYWt(h{n_i+LSMP2u<@1l5o}$Y(ubcnG%w~&auUkxDJgbc0)Mz>y z8qHubxrdaqxbStvR=@Ina^C~D^ni$ zBVMM^kQnaGJnSdB%nS1rn-yr0-eA^NwAW?i?wiznA5L~>C+0;mJ#V#S6Y zHnv*FG7kmt>wUGb!yrpuw&o| z7?Od%&5QQ=poIE>+=)`Y&Qs}U8sQ6|J|i4K*fV@iF9TbkhX)nzLE~GPpm2j`uco40D)ds9hPThG=4s76@^P#C zhb#h*IU|USZ7Zi7Li&pks7;^$e(48bTz#e9;)g$ZagG*twH)u(+{z5N#Ii@p#wZ`( z&EP_zOVAvXxq1-Fg)%qBLdj-CfKZkGOw1RZ81}ZVc$?`zpFaD|&8bQ1kG0XiQy%@e zNcaoaE3)Xd>zrly7e(TUY`f7ih6P{}-aF#%?yIw(p_k2ryr!%c0+tht@i-7JFS2-J zKv)sBfML3DC3a#lVNz`|1H1;Y;xkMTD~?arSaqITYgSw7&BUc@2%`p44eNTJ8g3M; zNytY0sM4ZkkfvnTIzbInoDD9S_DW@vmQZ!oXRyg9_{QIsxF(dO2bhG|kEGi*NUhvh zaG9Dg(AX+>vVF!Nk3DgxD`6f=D2|C6H#Nu6kz6I#9ITzQtTPzECIc+}9+0Snf@pC! z15Mp%K~*Btn0QwRun4f}PDc=HkA|x8`)f_E4BA69a2|-s2jUt!EhY?z*vE`qK}7FK za$tf^Ch zpQ1$de?Vx}R2T~$Uv>_Z4!HaecYKr2mWsa*_Mbr+_o_*u46{Vx){`XEf9&yeWQ{?4 z8@;{*LSB(Tu6TkJKu|I|DS|R2l@BNr!J?T30hsd;?$u zVgsIR=k9gy_sF#|04dL$ERI9z5NMLRJ@Gwi7X%BZ66x_ZgS`0rPrrKRWMQ9HGXp3CNY^_ekx_xj0C?J^4(l&|&H zpm)+Tw!-)M0099(1p$%V!O#sJeoIR1Tn~`*0>32A3JVBYS`ZX69Pk$95ugixe;h7E?>}8|&n}pyKfrqmQ-E@;nr%>8T9JSY! zwx2odf{I8Y&Vb$cdZx#ZGWjkcX8K!O?XdlrkcbxHwR95@Gr#vgjVckR!S^Xm9(YBT zx5e)KwvGyGa%0)t7$mcWXzFp~7RXsEZV0Oh<)Oy6ux#4N44ww~wH|@gydgSRHwckQ zZ3TsNM6=rBS);Wg$)*or)X+h_vW4M$;$ynWv)*0JS$uwD zA5ZNwb455bTGutc4u&A!2yK%^eIpK^v}7dGK9K3hM`%4w7(|Mdh`GE)iE0aw=UySiG@5Fv2@lohwVz( zn1R;+!y*kJBthXlOzRpJHFt3NCn5an)q-8+3(JCNa)t$FaFRLsKNxe6@XcD^xW5vV zmiViQ^?~M~Pz1w(YZK61H;jHGWznoGF@zi_vOG#bXpsdkw(bn5%m7a`X9h{hYRh_- zIa`S0Ra-4e2euCu&g*jDwTHKJkhV)iyP9sz*1$dKA0JoH-gwwNWAaMN5YLi9HH_6& z4NXQzCPB6x)QAo@{zo-$*)K8H4BOM`rUFstC5Sa|%Z_{qh9pWCHNu>R@RHR+V?=~hr2B|}N=aoY7bk zHIV*xOJiyyB+qazTU4_uY|F0q;WzbAuntzAZq+Y`@2wsK+31fS)LsY^cPcS)pcp5e zGE!$LXPAxVC%yvL4viJ%Zg_X_AT1-M#Nz+;(tJJJ#0E?=RsnqjC|Xq!bM+zlRi`%hYY zy=9xooT&bO2Cz&_M`Wmx6DJURhxgUfLj;<8-^~vMNKh?wa72Sh{G-YE7D*obEPpu^ zIH~!a6MNx83|@M})PImc;8P83XPCElJ?FOu(2M?XOhu800t5cc`7$~(ji2GlW6(Z{{9(He|P>nfMtx;Upc^1EJdO2aTVMaM9M`R~P;epZ_PFo-Au! zb8jg$>AVlD^&5-EX5PAR%G^$!E&SU=E3ynsw_kY@KHmV}D*``ay-%FVb=vtfz$o?Px@e0Rbt7 zy%AcUpAS_ki9LX2jl_!ok{E8l=^M3-th`h7RQRh~eiGD4gPlQRJyCx<6GfggqMoBg z^waGPtlNzp+Y2A&&o^z~sWD@#QH4Tx5T_$!Eei|0;3|fER~LgKoS8vVz2UupiQMJ& zc#VeWK;J+6l$pOmk-vGlCw^N=Hn0>>9Y|x}FCgpa)dGcE;Ex^SmV!N zUPbzz{p$*HHEw1fm&3q;dW<)flSf2EvDi&6+7HWezx^ic_V8-deLqR?VA-41F(c;M z5$CzTm~V!c-+t@5_N^`KE#O*`l{R`EeIWhK+s(V)g^U(gxE4J78A4g-7c;Y~H)fCU zXtBZrgl%uNJMyk^?uW?EckTs8PI4Pb1-oXxS0~-uRg+dfZCX$v8~N8S@Z>4KfwCQ> zv=1+De*&eCi9+beQRqzLUq`!8##jhE!hmUpB0VlTZh~xPPTD6MFnLPYmQ81V*kO;- zp*c!t(Na{=DWvKfrh?~@lQVHa`T6DBT2?n&ZU}c*!?J{*%YucI1xf=y)$K=j&7D7l`G*|p+!ZcRVV;uXQF46)TTk}rgTo<%#TmT znOQ@qWKYYw^p$}2)z#(JGbi|FEwrl}UMu&U{^HZrSx%m{V*1qadu72SgjjN=vy&KM znAyPTYw4@72`^49Hkt&S>^T&;{?))!*uCm?J#l&1YbT3b`E9FrQX5+RoZeOEc4tM&AzEBy;Ey`HM|{|Q zsClirlVDV&8z1={aEgbAzQiSp@qgcqF7(X3fq{lnlx%so1p z|Lr!GS&`y7*2<6gtlC>vNK7zKO8QpmxVAJTbMJib`&s)I%{5uGKk;_+ON(29j+L{H z=<_v%@B^G4kETxXe2YzU=y?B1^F>TVkiLg) z;;obdx=7!uue$>_(1)tR_)etb$3CV*q8Ot+;!<>B;Ww-cWvo_WvM`BLwKbByZr$1Q zw=wHmEC3?dbdwz`aDgL& zQh&|ZCF41UC6UEF7vES5QyQ2nd;B}Uo0Z~&iii)d-tk;gj$;!=b?w6C-<}|6Po*#N zEd@Q}B!vmnxjk+JG}_XY|TAzf%p*aQ>=( zb<2v!!I@b1=WC94BjUj|ng5@GRS&KWW=tm%ggLsZ`)8+hkKDq>C!zz@NlPC2cSLAl zx4mFq2jPy&biDtKks+MFhhxhG+1QK`OGfs5V84cEm~I`|(HOP$;pV^YYO&tkkGB4m zL=th#lH-6Vi}JwnZn)OW5ldb%CXNdy=F+U(f6d)Kd}cYL+jz$ATCh{mRv6ZWX~E6eOG zcM5IWLi|YVM5p)GdBnV4DV*hpplhoi>ols>cEPf5a%ervO;&_?HiMx?*zNUQo zkmRB;+Pj%2{(j|zIY^l&HciO=XUI2|WP%*)isZ~uJ`BtdcHIml=KI^81u|sOMuC`PAT^VHKBdU(oC)5 ze{5!K;t4CsYb=AyGh?R1Oy8E;vP>zIFp1|B4g=->aeXUaEIim%%K{#7 zcBOaapgjl`Gn$lAOYR9fZBzI311;3KN{kqjYklQ@t@x+R&cb`_ zE4r1&M@Ct{q?fn+rw1*@T=|WYgy5f_D_y$Os?aJ4u^m&hq;|yWAsU^J!9ef(U2U`& zg@1lFm7yfyN)3c4c~ye6I>nTkkdia6%$BfwD+IYXo=G@3wyTnep-+k{!e0#U%GM6! zOp_Apg&inrf70;`!ax43sEVd#(>hhRqFzXL#x<%#rL0@wu6%kD)rGLG>lv;X4c~B6 zo6d>&W~EBSYT_fuH$!u*mi+giQlv%W+_4e;9rR*!IOLCZOx*}D+pLi}a(7mcdu5PU zmkV8J05V6(Prbecy6)Rwlk-%7cZ%6`asID-f>fypoyayzYvQBo3*y=fLkn8n7=F`iHs6?cZRzCunFr<9!){D` z@Za|JD`eNsCMCx4Nq23`oef$X_B3UQb)pP(Yx6EhSWfb+IAB9nkvvKO$%`~Y8GGoa z#ckf2!&)qJk~1S*zx4Un*HViVRafx&^~)>{aP0poSrgTZO8iC(=b5Ut5o*a-AJ?&o zx5CtzpM>MSMduvn=G~6Me~CFzd%z(#uV>-M)`K^xuW2M6amle>Rar^yH_w1<5&dFJ zPv+~}--P{Z&tE+HsrJnc?bEWAeruPG_%00no^fZ}u{hm${hOHSTkqGNC$%k^%Bynk z$8s>mrpy8Xcsg!Lwh7(6*OtdPwq5Y}`FC*Bnp$hY?!&Ds{{d)1@w?dn)dtXmmV6DB zdb?*s`*l1bUtR9fzfmvvCZPnw(2Oyyboeu{;k5dC`sUpPmr2A*r2&4%qOIsd8y|(P zeoqq#U)XWpwrse*`ok+M&P9dN*UCSZ^lf`Nv8$P+^?2tBODzs-x}sEuBaWC6^Wz$< z`+4oOkePP41wy&UY#s>~G(f`<5{I7+Rf!lruHwT>+5!$yx-`c;OY|w~0V-k$Mw}BS z2`82n6b1;vfXdr3Rm&N;IN=M6Tc79C^vL>nbng4${BQSn@0ys9tj3}-(D1t^7y6b- zK84fNVW$S@>x*R$r-fkQ3kzM_KOGferuPmpldL5=m z34P6u37fwVsSF{0>)! zh-_Pv43xpNehz9}5&lq8H(~&bZ1Nl-Y0yA@yAPet{Xc#k0}9Yn9*U`R1jJiLvqM^J z=A6PmPLk8C@H7G8u7Og*hEk0K) zVia(9tNvHMeGwHYmOd2pr@9?yh0Q*#L#~4l>H&3E;VA7i|O0lp;kTh zp@HPefaVawA~4V*C1nUEZ>pq@vI#yIaBs}K!&1-w~sK2Blv~o~8!pv*+#B z6WViVH`q9D&8|av1gwT|2cD0@WNIVM1ygfNRohodOhlk$J^h-7hzRLj0xBwcsl#fW zNqb$wZ6XA$E?|++M$_VvjsPO!GK4frkou=>sqcW90TRC?tkCO7NlH=}m-s1_q-= zmT%dVO$N*wV`-%jW75-FGfaf(FwML;<`xG%4}$JuXeVLNR%10JAX2mxlajJ$4#AZJ z+H*i>C1x8tsv5@h3{iJ$kNLkPs|V>?22~*h`WVzUW6c(GP{O9_#KhE;W2Ur>sQA?d zar8bRiN?UAKAPQx*@T#i=rPf1%Fv)xpr)3Kv;Vjl zaZ63avmw+hWb?rrTI!G(nxm$&o?w-aaE@x+5)SC5!K(Beh4{pn`WOwycWD|)M(hi< zqq`CxTulnAwsB#TrZi@!5P(lccz4e1{EA7{P#&=<+ciW-7Rl9s9o0K;Fq#c9P2_!l zTVrA3n-iq-cIG=Yz!Ek2HJgHngYOG)Ty0%Z5YcxoUC*X$&=8j-llaM`7kVmUBy}P5 zbw;RrNg_vBa>lR(eL6B2$$3VrHuRz*gxxR=m0KP3*d~;V)DwO|n0ZR`(gxt*;WJ16 zJ9F~QnIFH3@R*&~)Fp3W+HuVMAcU0dZvN05`Y+s)YQP=W&@9YjXZ!|<)YLy5BsoNF(&dEC3%kl?`*J0G!WHBvlB)$ zGARdox#F8ms8^DYGDzPVuB`qH`LY2NL_Uyz`R~|cXOD;e@b_W?9oY|?{WR*(Y+NYI03;!K}9&;bV8YcQ?? zNR$k@BA5hyHLAh79Eq7_gdDX1&xoBN#5fo=XZHVoap~{B)tHCt0lEQ;9yYayTuYJU z{`%LoiP*3oGgn`K1#;D<72%umN?e2{afrVm{l&w4Lx9$Lk0my7IrtjfWwlyl?ndY@ zFd1eWC|Va^-A?>$+zV!c!p3XLV(M&y@6^JC1Dpp6YoAQ>1ia3zwllnNWgLR_pJtBI z$zlE06`oi(E2r6!%SG8(*cEp6eCO zckEJcoqKP(Gm&bag&|b433;f`A+ttPP*NdbH3RBcYPs#=|4zU1VVs{H3fE%umH2H! zqHL+_ygWj_0oU8SBuj|DTS!=CG@Gfsoz|7lW#jUVc!iL#t{j@r>Ay=QWOJ}w1PX=m zYmK;7N_@T%_w2tr-}~>S$*tKKLce}fegz?QTGnAU-5{nv*5fY>TjNz=C_;Fwq?68( z=Bs=2h4jDwSL{Q(p@+x+uER~39eG1J&xE`U)WVJQXBfI0w*T!Y7^4Rk!X(EvbQ6c1 zC2cr9o+;ftU?@quDXskB?7@2p1A}pOmI)X#s(e~rrrF1vUsanZrm>Cdc-ZZ2 zjKOSL*)8DOZ{Cd@%B(%YKZ2?@$2-nu(PtGSB#u|80>fOf@5fcH}Sgp^z**~Fkq zjqz*NXIl!1f2dC{M>SvuB^kAh#gw%$CRH44;$)io!p8VuDrdoYF?39y7_k@p%}6j) zgFhGN7&gzjt)`hL1M8I3M`H4FRHPLW=ON%)gq$zNEEiB3pEU$>s3w@aQA3cb37M%+ zKhgeVEdq;es5J~ANAFg~*!+rZ9%@6?z-LnKn|9PH>Az@j)(GJl3iIj#Zvo&6gFzVL zBQrw~NjJz#1mNod$6HEDt-AEztz$<&zFoAJV{6#k zhoL{$$Q?9LE=<2^G!bv;DNha^44_#u)kGtKRHUTT5J@)T*K--zYO&u@4MzGpY!(bI zhl4L5xL=Vs7cpSz4r~BSP@lBu`nj-@!RZ!LhY)%T1Mh+Hi^Sk+h4e4q0FD8BnQ^Er zj^3`tazDTQ?Dr{Q?^qI>y#6-jwm6D41AjZ0@>D=rWcOFoACI;es4rl&$;$Sj=C9rm zi`QD(c?}ef@r$7cFH%xpXi#cgq8F2AvY8hHA^b8n(mPhf$D-mf7t*@OZMl9d=5SDW^n zfdRX!S4+-Y&%&UHy7Ic7(yF1}#Son}P_&Vd|Asu>Xy;{`$#+57(yw2c4FPOs9{lA! z_(l9_ArAR4s3$-|La%^UZ@}DnPk4`^9S}mL_3c)Az*d6`CL6qWPaL}NtteU5ir|g5 zR1-orGhqM1P_Jpg1Pt|rzhdouFoXg5Zh@xj@sE_$ZCW6dLuigBqdT>sLTIklzacPa zEru3|p$@IlTL~s}XsAM(X8i7>USeG~hoB!B&=asiLVPueZU7XFkdqql81;(}2YVp1 z-3n)Dz47gnnJFb3&oDd;42PsfGp(Lvropyj7}`S9oVOGH=}uNAreEHWemHv8vKYB! zQFkXR<5A{0#&GPBkc^UdgSVqSZbjF5r=HJy`ruW<0Z-M?)7BBZci!C-U7COo&Vlfn zcT=DD#?0OA#M$q+;@RNa2b7I<8@G*h-H*ztCbqBoE%9-E=+^gf5|{Et`#lSCen77) z<-31b|LX3krB6OCYv1zE$5+pvd|IxR;LJQThs4<_P(O9U_-XmhD9X9)zf$>xEWosN ze`o%O;}_#>^1T8H^M|jj8D?h8ez9cL?@P$J=Z=JKv1?`qj3>(S2QFUTbmDO4re*d1 zWQT?DkBj(^&f$xesW|*wwNF>Atn%SD%w2m#x9YljkdtM4Ikjct?V}4T|9tsl+vMjr z|4k8G=bz8LTmR$8qLJm*F68johK%Z73HNup*v)$FL|?ZVF-O(GC@lRv=MZIIfIQ^e z5}BHm+eMeaE>tQEFPZTg4;HLs});0F1VmZP6NJiJg@<> z{CR)4-1dRKlR{spUQb}fFgoO6!-+fs_G%*~Nl_HnL7DaQo29p7(d0v{c?9wh%cq6- zS2+LVkqPF9<2p%9p|NgZ{2~TZVYiTzT^0QEtv1RufA!^m7H4jFv@tEKC6RcF1;>dh zLf^z!M)FfJyav0W(xM91iJiQ1%iP{ePfq2C;>wwEY?mc*8#A8l31f{v%@2+3*?69^!X@Q^iddd`KH~MxbJp7mTl+%4 zzt`4pI-K41u7KtJOU;bXk4@A39>4x=Rbktj^6C{&*O^>D#y(%;oW7|k+i?e{RNP~~ zd1`&{{KZE;l|MdqHrrw109q*iCtE}DEpw*8sCair?dFX|i1x39ci;k2nqBDKV>%j*83{k&F* z7GuYhQSyY4<0O1qKI(iQgTpTT4Vzmz?tEwRmQCa%COyX0&h<`gs3(B4V|3tBVO&`J zP?ft@xG9pQb8a)=xim#sI-Aj97u$BcunnDh7@nCJu6uS`)1t`%YG?C>8bw~-sNF(= zBi2zWvyeAf$JOGe`A(Wqcf`7&C5u)AC9%P$*NbPN<%2os9JLU#(`&j2KZoTT(>rIb zKg0q#68a}0WZTD)Ly7G2If7B=f%Kk0js&S1I71x5;5nI}&D>LFN&gk&1}k5L7WqlxNr8R9P4x?&qbL`OA!o%#l1LgTykZt z;RM-_t)rY)R;@{SU}dZF81OX-D^|VF#;uveg_L|Qw?G?dA9G`!lO(lewGFvv>|;W6 zw#asz{tOPp^i2ENF_0105!H!c?M`G+bEaHkZ|AK_SoYt-$@l8IkG*s3VRmJ~l#bz8 z$+O&x$SheW_U_T|TckEZ_S?}a+({6?Hqi3fCy4Q>s8FSZ$XbcrV`3~v7y!75EQF16 zzC{EQR}hnHW+s+~s2a#-ktb~RyQ0e5C5+X*OHV-?Rks?8ji8 z(6~Y6o=UHdl-7Nx3Rjr;kQY2Z$`K&(qJqtbx$s?aR#d!2Uk`>S>FF=F*B zP42ZoV@yb;q#El^jZf#|L+i4c_dCY28~?_*Hvzk1YLWlHPBx%s1p~6*55o*h_O3K0 z%H@jZU=^_!E6gPXrm{1%8K9UewN!G3+~@FTN@2flsR%twsD{ZR?kK(|MdGagT*L1b zGF?7)VdYwxhkTUvga^@XJYCMI$mpMON>(2)#uuYCwe7w5|6>8Wo0{?2%Gbdfv6^^u zEIZN&xVEj|H8;2cJFTtSzbJqbU&*z^8l@pc0Sc=jo!xm3W~Z=pg;SG@!}DI86|4uj zg`=zss*lwhcv5P=)E-f5r|oT*+A#M4qP0DTkKV5hlUeosr|*f{)j(p7ZRzjy+|*(p zcg-_K=lGgK;8vQ~N`uc5uZz#3VWp_--(m>CYK*ID{jMZm$vsO(OjvaXEjEv5{}7QA z7yx~FI^VL(OA*L+k=hx~FXyUZyIs7Ud4(Ot>~U#mrTH;?hg`dh>VRO?XHxvo$hFfV zK6&B~IFY3$9TXEH`desq(h)#j4OsmuP%a;GDKqutZ|Ph1v(2eVNKA$yyH@4HdFg7@ z*2abI+Jj45ao#(E#hY`c6-zN9#IcXD>n}guS;)@8?Z@28JcMc#0tNIf-%*=sEEgfU zR;iU3v#l7Zhv=m3|H@zmXUOEqjT-|Ek%FBPQ23skIL+Ueq2r zI4$dQ%@bSVVLguQE`0Y)*=-lghO#hjtnHOL>nj}jqZ?R442-x)lXosqyO7h=QksvN zP#}UVOeI}WiqCiphSa8|q}zA=LoIralu-BlNtdq>zcgiZdU7S-MqxDbap=4ssFm^Y z4PZnUXmbH=XKg)3Y0a#c92A?hqi+7PM8Pqte~Hws-nbk0p6Ak*Zaw;;tZeFd4Up9~ z$9JJQWu+RwkZ~izt0Mkzd4{2UNgQ^nsA~FBX-X4bXe`H$;Zo9N}KA2!*{e=1`Cmg8FERO_ddF1JKgy>f{9k zxJL)YbfWJlTe*Y{+=@4d{QBQ~AvHET(Jb%~5Hv2Eqm_AyLCU1WSt;??f~)FdeFVTr zF3F}D^lbwbLtrrWS05OPdUB_ z&NbgNyl=*&bcP71p6BL&MB)$kgtvkDG1X%{S=qQObW%EL8(Y&JSL;r-nUpxF0Z*g1 zjUYmm>|!%Xu;I?LX*zM*>-Kbhbwnh|S^bOlTYb(JN#S>!N7;bFBnIqQb`QMgwHYMk z>f7nx*#rxR0~1UKq0~E0TE?0+aFy!WMs4ViJKG-Le2LPC01v7)w_EB@jXP-^Ztr8< zx%sVLd;iest37ddfZ4gk!-e~n4?1^9`kWAeg>ihm-p=3hX7f~nwmrTt!(pC7{H6MY zcHyCxLiWu;=M!7H%T;!r)Wkc@{aT0q>(l!O?kC(7^smlK?8;N$iXq)jaq5elKiCzQ zma)%ivbC&9!fojjODt|67G41d%aIE~4FeZj=D+i1mLnGM@BrSG`KNnbS2B^m-fHnv zhmpkj-@zzLaB5jK|Fw^y(c_qhT5}}Eto|70$?t(wQNzAp#mrOmaWWjY^ihn`p7|tn%a%?5uR4Az0#~k|nar6O9nrU>EmI;?;zW^OFDC zT;q=TdzL_P>GDLe+#d!UOIQ}@S%4^cffyUye*3U(o%~fPgW#O63h(8=^qwFooaTR z#6+npwkQe9YVkZR#!e%N6yWnsK^28!tTE<)Fmf8keT80stqr1k0fAy%k$Bf?jVy@? zTBn2QY{KR^!j?(J7K=suhB5s*dC??6%&M4|&MR-iB&qP5loiG5iZJ3soSlTG!j|g0 z=XRrb9VWIGhx&-cs){TXXxAhO8OIe1h-;g$*2tqMTH5;UYIvi3qfj1U#IA;CQMZF> zP55OR>~cY+XK#W78!fQX3Y9oJOqyz3ZfQW6*%k34xbX29mQXsIOUP&Aq7ZB@hg3Ge z@~*^fBI1QAJlrd>W7h>}6#2?>9t+LKNUK)VGutpc7Cu!_jtaB|%JN79!WLt{+Lg~T zHOZIgrDa4gSqU%b#SOlbvbayz&sx%0UT^jT3{RI}wbBLSid40X#(dK)NpMCCS0%aDZ)-J6HcJHw5j?C0 z-rWXLwRp6h6jzA}NC)?=VAI7?QLS|LWO;!`?$U(#x1q=;&Q{ru9^tKS^~&&)&P`Vc zCo4|OU-D?#A1}X2_KpF}5^2h~Vo|yb&w@5j;@~zQOWjIB0Q)%i=IMTlJ=-NqC zqQ*E00cIN#jw*u~EESdnaVi%yK~WlrTmrD#fMPB_-iS%}!KYOwKVBmw?5dAwmKEyp z3F7^sVnPN+9xo(pO(-W+N@l7RxguFAjL%?PyC$YQh>$WwvSLxzh4PoE!Po9nk=G{o zWG`*`jA`VsY}o{(m5tR<;Bu~_s286y4(5C~T7O4I=BRwXV40mD69$6Y(w7?W_M5Pt zh-5lUSmuR&r~WNlje)icgOf%N&pS$ulcBa?Hbce`L~qdG78-G>lem1wDXU6cPP!c1 z_iaVQ<2sy%@-+BDlzBvI9MeU5%EiVW&8t_^SQ%wcF4?)fbCjE zD&v+=t4L!3i9NQKLRVKVn1mnETIC4x72zt8`_Hb(?&x2FEKwGS0tR z!7cY?$$8Rp!emZJ?#yMdB5-zY9(~oGp8caU%h#gSsIbIl2>mXDHl^d2YGmvtMVK2f zU|JZIqy8<2NC>(@;ok-vhLBeua844*=wFKbII67xe{UQKZ?`qEnRZ?hD@F*6VHP}X z6`_}0jLR1^;#zSL48ReV1T>Ye)nL605<8Y8NLZ0yg2m{;WnSm8C19}^9_>1lY0LNg zT&;a<#WaHHB2lDL;)xyuI6*}vI;x1dOn|k}LMUgtT!Y(6QKXUw8u}#eLeLYnNXN^= zF&{>kD)JE=)(c#z@}#rv-+cp4*P^zrg3k~Xd?Vyn;%zH2ndo-&uTg)#1ryB&d+=D& zq@>%kB3~`15M?=%Y)`%154IqHYrju z@{CEZ8ET2&Bs5DenKXv(9+16K_gf1x~{jVqWeU(ke&YQy+&a2@>E0yJ&RUfB8; zDTC$l8hJh&|3x7eNw8~03jR2>4B6$`E9owE*f2?OGDw(cArZG?i9p8SNS!J}GRGB4 z1pBsHP8NYFF$>*UO|$@|a5Cl%=iPe}uW3OTQcb2fQbG zea-oV8N+0;6epw}9Mjl;+mVEkQ<5;TZC zTC=C0x#x_80wVXjGINKhOVJ6VAhZK;k@ zn$1VpCiU~k@K-(Fccy(d!=m%?hI!~OAhiLuLPSn?aL`~R%NNYz{Zm%9wP`c?nD@SW z<|;{eLh~xy>mrrRgg=*WdA%gAPtZcN>bg2m3fa{deB% zzUK}h+h2bAaZqI$^?6_Xic0j;N@q!}5o7yMsJTFbdgp23j5eIiLjtu)BiQBTz zS_S-TdV9NZNMghNS;moAmHxQ+^vagxW((oI-R1#|ju;}ylpP$N* z$!SLCUG9r}o9A&?sI~5^Uoig5^{g^eSo`Pn$K_VrMpR+%zuoIj=y1;)XZ<&di{IsB zyIfc6y}@(fa46w*Tu#qUbxd(T$K@MZ?h{n?v+Ypp=&ga%Nyqs+mt4F>@yhzbYPD35 zXb$rq>s?|hlT>^B%9P($#@##Y>32%EbNXh#Kdx>1G%|2@v;Utrc9=lAZwY5uCrCD= zOIKxtt?#u+ZA#C*XH*;Ar%|tk_ z-{pAc-pK_QcFg+z_2ZwNyZy^<^jny&7=B&cd}s#$mu>H1bir%9=#I}dWp>uG>kY>^l=jQxDX`33u8wQq;!?;f)l zIm?xTHjXERTyYTH^_pL5#Lqmd9_kOhXh3A}qp6GWegnaadh)|_4xKu%wCL%ngUhA1 zr&E^6N8){=$mlK_w;9tHc#W0QVNsvn04>u@jlxdniq|9dR|Jb{?957di9zR^5d!y) zX);?X&3U|^YG;aDpWsA|<5h<|?Ek!*d~xbzqIqsi?trUckYV3=XU2VP%^>DPde~V# zAhFjpuh&|4`sPv`9l3kv*Yc)Zl&;>*hnrjb0-rZuyvKij3>933Fmq(+%dJ&hZa`9P zP;m>E=s%Q^MsO4hLE^&5g*5+lT*I88{uB$q+Q1FEMcEzuy^d^CiN4^L6h50vKECAd zJ2a;W@UClOBRS{h)>Uo{XovD7#fijnz3a2KSP5c18#OxjsAn#GO}B^-T7e#cE&V|c zGcOK=jrKb+xkQ&3(rV$6*N4OEENgvRIYE66p~i%R=COIkZXXN3WU2KblW<>x%P(wM z0)uN%ZHU$X=#(*Hry#!Nan8O0>sb>>W#EAC2~yDA%+Cvka{4^(h&M5mirtM<`)bbI zGpuT+_04ga)3x!lJM_`_qH@p9CW?qQ`8A?PhI<=Ed zC+xdK<>YG?*hImoP5U_RbrVBo1qRgSIJnvN5zp|O4AHZZ=lAGM5e~jaXChlNLuyX5 zFkFzLLs^eY9n`oI3;}(Ju+;)Pmw_9U?-I?|ms#eR^q^UQW>HsX21+V55U)K+UTD z+@ClLQ)Nd~v07%=g z=4q#6&jHdFG@tG6Y2_tG7=NgHrlWP~vtDGAtvNF1WHc_J9%UVCTKTguw>Q@?e8xnK zezptPna3R_KbgGc+oJNKu^?SJt-=* z^U||Qg%g{h$P(t-3JPLPahi%X#Ru&A)Un~Lmc+EYTwBxl%lJpe-Hh>JyUs_kLCx9K zoVte&y{z8QUBDp^*2#-jGJHYm>ZgC72)q7nyEXQPwr2S_-{uCYGG_(=EO&r$OW*BZ zntjs_3GmTOqLxQCZ8@jWx=3Yhy|sa&@YOb{6BKMObww__gnb{z2BjIuKWC@2llZUK z1+s}tr(C-)z@50m2vOAa#QFxe2L(exABS-plM%;G6u$fi4DaPT6_pklrDb*O+w(jR z!lo=3^2c8G^JMj!iG;@wJz$3hmq?f|7z%TT@UpZkUlK~gc`|5$zUnKcaG{G{q?5pNx3)Yu4pOLxtw4EfJ43H!9EOuK8=}sNIvxPQA?= zW{qbBTp)uxx>_qN^jwN?$=-4*vl)dMwt4&mF+6D#2J4Fm@)*Ju7@ zMb{R+Jg(Nw{$QK-gL{}VUB36JTK0Thp|V08lWVi?2f^j%o}H-ZNDXKUO{@|~ImVF> z_YGLAzzvs~H~QgeWo%&V`@cKgW`B0Elh_Tl>|B#ByL7w_p)XNatia^j55YaWR41Eg zf#X_d)C)xbs({OG+^&f&tVym-_ticYV7D)Gw`Wp}sUFjpF^;{K2I@ zC~}o!+o!^%%x+wQf8@;yk^G*snyXFAyLfJ<&G*GJx z_J!CCdU!w2qV33vGrXDX&S}h~y2K7j%<_OoH-!|U6ckf7a-1pG>$JcjSCYh3Be^gvOss*S4Zr3{G)1q;Csn8 zrPf+7kAsn611zQg=%Oaj_j#Fju!P)vBw-`Q9gm-%z}=A1u+ z3m;jznwc43#-MKYZ9eN==KES5tse|dkM?7y*tZTcP{^#y#kvyXIBw;cf>;iM>#j<@ zUZ~&GMgjwKgXmp(mv|cwNhuS^nqFO$@kx{~pEj19DTc|Kga}w_H^mQn?@St!%$%X~ z&NB~g;uBOOQI!re#b?zNAKna5{5HK8ku@11++Rpyr$r#oQlNon{P-?kk| z=!FA=JAIUZi*ILEczp^dI&(jb63O#qpYTvhExt;f-4T|+CajBZp5ifU{)sm%9)msX zFOqsp@j2XRPl1+%nx=`J!M$+6e;{x1V!IUBG83GsmZ3IE#wHy!j#woEOaaf-!ib)h z$XMpOvS{nl!l~Loq|-NsM{2{&<90+K`$?q_qYS$2J2*4W>FI^m0d31U%4i>^)}}Z* z$f&b?p$&x_h{c|NDwon0LxF8c93~W;2;r34f*i=s7vpbS{H)%FIs`L&J6N^6|6|a> zbi|gXLTJ9xoT<+BJ}VUkqGlS8kje9Bk2n~{qNqHwh8~d42O|NFXc{STNv5xa>??7j zLg-n6FQzjv4{{5}*rq)3?!~a>cbq0(IX_Nh5@RT1yq!1rGYoHn+xfrz1ELzC`6Y>8 zI^>Qm-$P#%5F>?xc{TzVd;$O73)FM058kTJz{?K3^yvrv zyL8T#T3Sh;e;0ol<~FPXh*+K#w}W;GcwMC* z?3~pDQw<%V44G%3EIdXMbk2N5D!jW(=hY74_VP(8#8MvZ+okhm{1H_mseimmLtef0 zm4ucEq8+<4+n`x(P`JDFWr%IjOg!fJ=PgH1v#?u^foO{&zp&^t!KjbN;()n zp_yEny$TuHc$e#?BaR^yBW6>6^vq0%SU*5Tz=+1_GZ|7^m;H+S7;;HRNI%3ic>16_ z5(e06JUSxr?95V`!=-m7O-^A?{9-D%!zNS0nu>mzDY0&ZX+v78dY*GJnfC(s)Vu?? z-Pa0@`x-GMZU+kuy~cFVyO7_%?6jSVCjaQkEA6l;0Z8=&*D^aCdIuRA7wguPeU}j1 z3Bdh5T1qa#sbeXnJbQ!0IkUsMURxn5e0umpKts=*HZ5>MLE#83raCEV`%LZ;vqJXQ ztMcbNDk+c1o5iz$fahNVkq$k8t=pJmdG1l!D*`VuXJnQyPCc3>3Q)L*M4;Ay;}2+ zen-s4&#$<;+H`Ni?$e#MpY1M;>(AeN>390q`=1o%pI<-Ug1f+=`v2|p>7SfW1Nx8W zGZv=3D{s7x9QOAAlKu>iv!m9*^xDH8_F6Pw?)uzT_~Nucb0+!gvrj)_`z{S*C}`bH zskY{XHY%|rzfipl<#h{T(^vm!gBJZcGY-ftXfN^;A1QNyzhz1f7_}QG?n>)D`0B$jnE7 zJ&UmzL`WrodkUXC8}uORqK0~0^Q4}oI=89L!4LN$19g_GKSv4q_(7P4;d`WmL_faC z>Jld5hXNvEJp?+pN&u|i`6)&cxYLsj*IxVqGxKz=OexK>$YteibIl&>erdQ$=1r7u znUIFp%TT=51L0Ww8ABNB2v&89g7L?Sb3~OgZsPPmKbTPydH!lyXh~5dW<5>Z%I)op zDun#{wNdYn1T-L&V!$V*wuCCPlVdDu5$kp^qF5H$8|^0oo{wnFRMPOmXg}65m!*G) zoSa)S;c>)^AA*SvD&-R=&@*<_%9MFc@B^lzZQScJ5nWUY;)pdn-ou{}2NEg~`!2|5 zyu-bCaWIn?vYXDmD-H3I`E-GPTK?_RaHLksX7T-qSi4IOrogIN=g61SDy2cikcSo_ z4XT;5il-T*Rt{{s5@DBIB;DbJ{yvd#uZK8+pto^oNf5UJ*+;DLeF1y4Ia~cX@7NHa z_|`#-?68&tw*?ZX6ae%EJgBQwYZegVI(%pI%^gB)QCnDxIF)vwX<=o7Tf#;O5thyz z1MQfSX(|17nOd6|C`8F;FtpT0AZ=`6pfD?-ng2#Nu^hUx9EFcXAgV3CkO!GD=Fixo zo!)kQdfRxWp$ZxT%93>sY6&%1OIODdH87Rh;VO^?_VV`Rv<^_eF4_w^UE(nv;9nV% zl_=BM-w{w4?d&D_=Wwze7VxO=G zrHmB`b2o3g*G(DHI``jk5GUu%<%Lh_Trs*!vC8;Ib0Yteu1by$&ePe|C841fMk8W_ zA^SIDo*xSxfPZC99sd$8^PXH)IEANDCEjpns1W!}w%D+i!=X&nWf0hc;_RR*)w|5jgYTU4qgqUN?kr$aMr6zPC2Elb%MCDH|}q<+lk zK%rM=6W?|Uns1RcgDVRaMf>z(%qEdNRvlid&fpj75C4P>sK0RXqGxz@hWajK3jNGV zFm5o!&D5C~oVnxwEdV%D!Vb`5T-VO!1ND-bD8}h73*_!*N5W!8w2LnsaY2XcT3O?^ z^+Z^P#bifhGvC?J5sdZxJr;;y5MN@#{TZpmzL**8rxq0wVO56{DtEd^J8BTKb}h4n zzyDteDbwZ1y+rCD#&Vp8Zpb)BN<)MY;UBn=4Td3*N3+WTw|y*XbZ#ZYMe{^nd_evx zuwKWag8L1(?bR|rjLyL;I$((JL{y%r`QCODA&Dd&Dkxwa3}QqF6w^Q=mV=eK6k}@E zt~}5ggA**H)nf4NllV)#u*7JWQlu(&GJwU$H(ro!P9kB5)XDF}QuO;zZ{}U=gQH4A z1kuy5Jd!c&#;1E>=R4dHRKo>76G7<3mY_%wBa-;}%BBl2?uF4o&D(yr=?sf`-1c|m ziFGk9Le-4s-~Z~+K~vG;Y}t&seMPhMnOGFTJ;W@1N_?La!i8L6*fK>r^U?waR`Qxt z`lhyvp#*59T87doxRj4t63%rH`q{Q>hA9{JH2-yPTHQa4_gW}XOJ+-NH3Hc2k|2Q& zCzn{gS*$`0pop3{&(*t5pc;e_8rhqr)aTS#A)(gBTk zco_gEfzH45aa20u$W{0?8QeHhyY~q{5v|3b&cyELxeS4UwK9jc{&ruPhg#?{h#ETD zNzk0CM$jnRYJXjTs?*H~lj^6tj_`_|quncEntrDP+4D@2B3qRz92N-x6OMI*r)`8_9YW9EO;db<}mZZiGc^j}!x+}&4f9wYqy@q-?-=4DYEpgaDa_YaP@ z>pN^N>n#>>mnC)2GiS19>f}8W2Om0n1~2^g*k335JknYO^Osy4t}wGpoIv9D`|Lep zHhcSoI>6nc&>?F3=6`=WmX>@Ty?rpO%xU}9Oa7aPANPz%Wo1#7Q+@Y&KS@My*M!*V zrsJ#_l@LE|F`||_6eBz=!2vdOl(EO*iid-v33Ap#u^Kn6o}-pN-Q?B5Ll?ea9d<^$ zQRlQ{93!80L)C#XfA%-pmh7a_YVi@1!_NQaRDJ@icSc&=cHf`5_qONJ(g){zsnyNb z%0%oFvoE)~RVr+*JTGFuo06FV{k(Cy2DwdXU{gfUZTEE=n|N(@qQtec1LttM?!iFV zk10z%jB~8h^BWrX&h-6j$=~YPwA6%(F|B*={ki{C`!1CGTz+_S{)`{YLBSly{euhd zb$+Q#Nwtc*FOf4w&h;)ib?Iit{8{rJ`~G|BpU3+&!Me({mWdSh@r5DdsS+i8;?eS* z46mnIr;Z+YR&efmXi7@ztgpv2c2{0GmuUhvsef)YSUs0^IsW^!=-#7{W%7Ef1H>HJ z#Bk+$^f8Cvv`ch=DBOaCzLe5tvxenXS<_qXVl)_R<-G|PoMoVB7%Rysq4YTlHHh3Y zsh5$wd*#fJuuIom+Wf05R>Wd$Q6C6ZFHE+e`_VLH%_Uv!d93^6sG|8nT9oRdva~?ZgR%Pgr&1!!8 z^Z3RqYRf(93uDXk+N z7U%C%^YU`bK2)cfWeO?^({rt^auw*sl$)a+4+c$l)UE)tD2*1k6zL8x?wGqFF9+)- zfWnGesx#nR`>T_;h{rU$oX?N29wT_mT2}Oe9Ts&BV@GZM#N{Gvr^QBgyqXSVl^=)njyDH75WNcb_F4*YpG!8Njqm zDOroQk)!>kDzRl@@Bh=NaV2F~g!g0M^a6}lie>NWNy!EqLLa_w1&43Sa3aH2s zjWVTf(O&Nj@_?=!+bg{?V%8x7zQ4#;J1HZ3dc@acF;CiFe}ucCrY^(q@qwZaXonuM z5*tJ&PFJ91wk-4TDWFV`54rNM+$wQ^7%7stXxG;YF`ZVHY_OT^3HqeVY%eN1EbB3D z!c$sWztNkc%}A6Dz}R24v3}JCAXT%7Ofzb?Y8eLKv1_@hzkztMN#SRa zEMcYySB9}-LIw*ik{v!^w^fQsy=D_3} z+XFq;SmFu$Yw6|w>VWECr5exC>O$*Uh#4Go%`7CH(4^j(+swDttI8dc(4c>_+U9Wy zy10I*yJVx*>~?yOn{NYUZGCp!Ne;og)mXJ=NI=KIF%gRcD){v>R+nHNJxhQ~E=BiX z)pC53+wA^lN~&}6`_i{dmw(^!27UDR2-pG8R@Ze}anzC(5)GkKG&ckBd z|NnvCbI-mu(>l|7PkTvCds;NpI+9Eq37M&+5=KG@_pI$q)*?&`5+`HNKGTjk6Cvc7 z2yt-6a?a`FV1D=a_jmJ0*EP%azF+U>>+vi~bp)~t_*S+Cj?yN9qc~I0gH}-92cE%7 zp9ZwCDt-)xO(L^!NNQ@Zgs?%)Z1hP-AMgYrAsDG`n$P8Ie53F4s+b_tO?jY7Jm?iP zl9wBlXHJ-sIKM;fC>RFGk~62wDRsi5&u#83dh})A&x4jqx}tP_OC^o+27>^4 zfg48T>t<$o`a&_Xt0Q&U$uS4U;689vU%y0+B46 z^k9JdXMn8{QXd->f%K9$L>_+rY?CRbKYG$1w;oW;lYg1}1rW~@-8?o3QM&bCc zn9dt|DKT>1Z^Th)LwvVx#zhgyJ4|c-Or!LxfeFuUOMtDyO6m)-a(~BUrh7MxXSPt6 z{m@~ej3M3g*$T^`B~NEP{@=aWMKaqCA0Fcz_O7QsdCl-?C-^#;K@!( zD2c8R`DS@2mLsWO=a|<_%WUJ zbACVdySl!J;|#7DigRoHFMYXXOi{twVj2wvco& z@z=!(C$Wh9#kD+KNZf<66S7g~PiOb&%n~5tdy~+9a;NPnw(X<=UkDLqa8I7q^_PgV zZ|IT|xRz%{%|TuEbQCE;jZ~kqCV#i(`(VUEB6q^+0Pj^diorqljMbP;f?pXT?7wOU z$6?HJqj`us#*D$RXvoN6n?YR40FSjp$EpzhuTV|A+%ou!#|rT`C)LiBz9j~r?mN#l zb5zGv-05}ovho3lC)`-Sc$hK+#(u)7VuD$rV+08CW!g&_YIK5*wTp+#UYp%@e0d7j zvSh^O7~3Krz?Z2JPtt0KWake9=3*NfdEV&w%!G94!z1<0JoBv+R(dt2QBC9cSB9jr zoF&DU498cgj<@hQm?5&x@Uk;i;2@>ij@T&9!oY0;|jFYD{62GKleQr z-`#q)Tby02!Iv4ZTrGK6jXwODU9I&~>&7 z5*NR%=l?j@;R0e3jQf=e9ak2F!-a1~Ja?LVjkIaf*5E5ruzU!cp^mXVwpqXWV8x%% z76v9mOOFl+(09dDHDq(*@fW?kXH2?m?Ri@<*XQKX82JuNh8nj)n6()R{1^)KXLCNJ zO_A=zr1|+`doFd{_eK`eGyz@~=}19JM6+V?v5%QK{oTwrOPzOjAQQDJl4!UupvW!o zZ`xwmw0u#vTL6sB;-ACAZka$qz*RS4^0{DEddQ01AI7=1EL<_r{Y(}?Ot@YJ!qaqaOT%#XZsdWdKL-Kg`BpCIA`J8=l1@dG_?dfw?pRq z;B~}=+swiAl~2vt70!{D1yL6)X1_|G9sGK!v#0Ov-O$soQNLlM*L`LG8z=(yKw}oJ z&n=?fTJS7JJ^$Q=3DBau5Z}QNG<=lj1)=|liS^{hhFW5{+<<^@m@YM8$3V{Jqh2ey zu~_H$Km$IR5+4YWTD4!3&MP#o7lJZyp28XA+}PkCEG3mE%izCiAWUIsU2MB%9;7w_ zVN3gHH`umfQT(G;@R!=Yl|9GAw(HW-V<7G$VD8&{%mdo(FWo3fQ0V|W`5fE!2FlXA z9d`h*X%&6yVN;Xdw+19)hSb(Q6HcuX3y2%gY;b61&v_DLn~lOK_oUicVOmnfF?IMY zJo4ydl+u78rL0r!5fW!c-F*5Rl-=Z-rn4e?f*R8*%=k@YH^gRj!YMD^tU8sXF;Rx; z|Exs21QbpX#NwXHd^|zjC5-Ux#9UNc?-Q}g>9#%-=pS#KPU&nPeL5pRVc@>hbD4|V zsFVblB#_u{Lz%A@(h_u}ZRU6-Y|+_)h^Z;zR-X0{HB$2ZfQigRcuH>{i;ELX>lVUO zz`7Yun7f?Wr$!%ubDVUar6OCS+6&J{&{fMmKkGh-XfWI33(%gnux7VVU5l*S)tG%; zOUDU@r^w3j4xMY@UV6BE*`wBg7CTH2lvYbuF#UoS8e}lmsEe zV4|j;c^+lu1KH)xvvL-qJcWcMq?|;oi~F3l$PqLPDbfx4DH?#wH%KEI=<5Q&&=+vY9D5i(Hh&J}vf?jOh5z zGGD?Rzp#9M4>4JYFNJZQ!1--Fw`7x+GWpTU2W5j4R8PMup4%T(3Lz;lQip_@CIOKs zYW6BCA2#A2Q8I+9HmzVq?#GaH_Ud79N^J*=%spN42gt@1+ zm$I4DpO_gFW%If8)$?)XC|15CbSwr#e@m!?NEtf&4ABG^p)7cv$1c1$toD4+51wC+ zwSVO_40->LVC$It>(>P3KpJ<`3e*q4VOtA*wbr5&<*xDMH^SxzkdLQ&z6mGN|6z}7HnHtG30Cf0s2m_U*4ab~ zWl_xf2i@7FhRt^;3Np1+Sj1W)0gdpoV4eAn|L&dnwL9?*fMxOKU97e}-a=TQwm#6p+NW(j-@^Q*Lme=fkE0y4BD)$1 zlRJUxY{PytoN?A+ycgE5TeN?kqJB{8P$MzN!6ed&k0f4gHR?o73$sUp5pQy}--RC6 z+90z&XJGE+CEEi$ONs&aevP#^wCgvlWU_!zptEfjVN136peEL49okxJRVA_QFbPxr zy>Uv2q!&`o8l1N27|7PX6dAAaY?Q$M4XhT6E7?h0O3y?AZw>1J%9_hHD{isQ6Ia++*#%k;zJb_*y66rNcR&{kPO=rbgUe9lkI{c?(PingjtprF9j@m ztY~~Kq3jCID9l(mVRZ~;Z>{^uDV{Hya7traH%n}hA$+L`CR$6DjX>+}Wu2V+?EKD2 zuo$M~iX3qrNcx;ms9sjC#bz7q#(1o9ZBu-VBhDG~59~PAVo{p4K&&PSd5*(6`-><` zrNM)m;JpuW9An$+Ke7MYVX^0#{XLXTr#i`pq`GFXZSHXxW?M<2b%k1zUgyxo!~Uo| zKY#I!w~HfNwT^Gk5(WS>qYyCw+}<6`k znrorBHz`YCd?Q565(*ED&MU5PrF-(fnOx@&D>9cDuod=0l?Srl^dT?0{alu5bR{;P zU+hzZTLKV2Sl}Clq+RgfpRZC%&qa%u+FZI&$+G^eu8G$01;b!|1&Dhf5U?{W>44eElHt`J*+@XQqr?+HfZ3R~SAQ z^5|VOR~(1;9$1rqY;0X=QFc^@c_JRZzL3#ByIAeG|M_n3NvCp$XZY5by6c;V;~$4Q zJRaOSz8$GbR{Qu0(T}!bG=F153}Cwn+W4(~W`%a#AHsI1aUlketx{YFjBbYzhjA@q zz@yZFei^mVb|W%dCjFWK1zP>mDx84({P9ohuWo!2fMyosZ^sPH)trAsoB8VC50TRmS#P{@J2C)xR{Ebyu-pRC60_z zeEK$sWNSt*ho7Y8{Pm;)onW9ETQ2NFn)u?QU~#+?Ud38~X7r_B#e0 zj!l>+Yt3H^?Qy2U%c+vq!;TsDz+Bs>_E%Qe2_;Xe)TAzv-Tjtp^*UllmS_n?-5|j~ zX|b7t(G|Iv^N_WEqWn#Z%`xCckpaI1<#4gZ#(mpg2c2Fg8pz5Cs|?BDYClG}a9@3w z^$(b_nSJ9CXdyK?jSCUK+KSZjW2K1f4U_mN`_dLVUt+t5N69e%7Tu3Iz_pkT{uy}lk@d_I;a-Pw45xIPI3X_JJ<4Z;l zCtWyyZAv1OtBbWYw@OFa5(kfPWh95B^Bp5?{N3J5Z=XN$;JBbI=}^*z&W9(n6gvoC zuy^*FLN4yRMcS2HeP;Q^8Xq+WmsEeW0GeXV@&peTUw^-ZlUL7jBQ5HjWtS0Cx${(2 zb&U$FZ}Kur-S;!^n_qm__LHl}RTt{bEi0>I7dlMD%|Dp?A~}e(a|TXqC19mz>OM-fKeAacN_`#AO$K^ zm8?L1C~3XjMFqMk?@@wPQ`^jgwzhX=Hz%Neo1o12^CD{K`gTF@4BXO&c;7eEqQ%>g zU00MDo5HOMb6ciG3@b&ju#{1j2Q{RHzuXSDL3P%UeIg>QUFd!KsA6TaB%E{A=P=J@j(>S9M;O?bECA zGm*pv(Q8bv^m_mk>pfF0hhd*Q&eccSRhV z60l?JBHCfp{=tCv@V5m8N18Xhe|KE1^nTwFsD$I@JC}XF6VRQ{eL($)ktO@x_DQdI zaph$8(#kpLl<Hw-~c%VN3Ga=ky0eZ1|KI?Pscb za5uVb2<|;Ml5=zr&9~=yD$Ag7(DoB#k?i{z=Q0!>FdCE0CL7P@qSqWG^q_uYV(9Q}+pcN-Z_xyMJAVsyG8&taJM^!H+_zPL* zYAn6_R*m{?%0RRP0s%S0T4}ZHh`W&>Y<==d49fKxr;Y62XPG{13bh{)2&(5YO|;wmD*bMEb?&0ATueX* zie%^6PtSz4PFlv1Sy?f5BgRxPrpfiS+`M%K*3dC*Z-U3O0 zW`Z0EyktIO_FD=$eKp;$oRYaZ8~4vu={~VGZO9I-6WxH#DvPx)hx^VtH*`i%@yxae zaQ-C?l_Az&_H-m4^|%=e1_&N9I(iwvZM3i>q!;=L36i4hugW%62yjqgNtMpX|Ze_9*cF0M}g5o@_y980WV zRwuVRTh(>Th;^`>>Dp?3Vj6F91^tsN{{gT?-5B>{;hBOT5_FhQ>KOjnZ2l5wDjSlj zF~M@aIEE1kABlT>`_aWK7gT=J_!o2-X7s7set*xyJ3=zMFf}@k`!4O7=q5_OswMH&|pi7L+vTmy`8g}cgLBde8PVB$CzM*&V{ z4Q9*8$g1sZ!Jmsa-g)-N_lv78Uw;x?-iNla7kNkWyBMHd!^>Z>1fGwt*$(ZJD(1wx zRaTY{FK&?={(%!s23@anX`u1fKi8(_o!%Vv`gv93WADgL-?#zqI9Ec_4HKtl-MJZ< zPic3res`Er#x|wp{OtNyOGwd3ce*U82^#ad{Bf21S@=YC`M)~yJ&iJSSahP|ymRC< zUfE>6gso)ac_)n7TeWD*#ohfWe`@MhB)_uFYsUv6O`HXKDMM>0aHgYn#&h|tdNtaA zI+2V?LVNstHPfpQ#rAIo8RG*R0y~mPFTWpjKB>i+Pgvj&`lK?e08DIFDY0vbEYM7Z zbNT*x_gwlU?MEA0c=62Pe}`ARxbI5LSIgMnxH$J|96lN;A;n!+y4Pj7RAirrrt}K@ zPZy$S2+j7aM^As`l1=nD$038=B}u`Ea=OxnGV|qj_yB#A>f~;dzhb-g`~C1S%ZQ^I zoQY@AL(MLRJ5$m9 z3$-KuLz1UiqdcU8)s@-K!ywJ%%mP$HX1NZmtd<5lwblmZ12?IE!gg9l!!klorm0&p zqc637dr&XU^hxAq`)lXw4-**nhAHxF4qfo&PGG9kubh^>_=f^stt{1=mfGA^4_aWJ z>8Vhz-APE{QzP1u+-*RwFeqp09VzPh6WElF{(x& zQZD!98+O9%4k5vn%h?mXq=(9bBOcx40ul7Klk2mv!No1g?Ijb;(Q>RUUsS*Sdp5sM&2$gkAO%IQ|tb*{|4CK_)pHah9KvB=|675hithYmTw=C&x%LoGHB z!xXIXUSfm~k8*~4$I07($>GQ>H$w!(_&RiP6RsR440+M?n-cR-MS5gzhj)O`Pq>sL zYA{1%S8d$NWuTn}2&{z86cS&mNSXBFCol1#Vv-5rOsfpQGOjqUl{m_wHo+CQD>+d- zz7+uSG#FSw9nrH*oULs}vprghNk}qrLBu#cXFw?cr;{A47rgEGD9YJr<_>}LA<{z) zX*C4y#)5$cj7(+gKC=C$kq|uwUfB(oh9PUfPU$e10+Vu63CD%hcBFnvk7-u4QlW0I|}-YgO2%T8iFCvJ`^|k+nyKy{QeqtidcC+V~*@{WXAW5E2Dibd&+igh)s9 zlr>UR5QH}MYAA@`byiCVHRP_Ju16|ts?MO|fdB7{B2t(>(M6B;)??19c5F>0a5yWY z*`9PQ@tu%5$|a@=L01Fet(ep%jMb^5$q@dnirgfOy$Mr0IWQL(bxWJ`2e# zw9{)Xc@DHAVbdP|3CjjL65|7x@u@ep}89_i# z?AxPZ9Xz{&aV}QxSmITf12gQ&SD7>xN3%DEi9! zqzC`fJz(NP4W&*D`U}zh_rZB|w7VWvz%A=A0!f`NGc}kKc(SXynHAn4Kd@;FongNaxiiM_30Nt6GJ)9!EA(BUGxrV62oaZdcT+`;&`1@Q*S|doDhFSKy8I^?NG`> z0V-2SzPb^+PeXn3FL-O=F~aV}-YR^D2A537?Ez?rs`q>Tk?VBqPMG>Yk6YYH8iuJ^ zT7v1GhPqXVzWh``hY5cIcIzShb}oLk2!HbcCI=>&jN~00R2rQm(qqarNIC@`b`~ z6VTGXnGFMEE2!yRDY%CG3iR^~w}+_HbkH#y{W+F9Ou z>`67b?u!{L$midC*7_>rv}D)S8pz zo`oPsjDBJySDz#UFI^WLywH8{D&b|JXatsQ+nnXB$+l{K$lAA-FP_D%h>AAS^2zGTl%FCSJ4|* zmy>wK^74h=*wNlA#d#5wzFAB95}I>nH(SOHC(RvS5j)?Y$Rjl>!JTO-BKxrtj5tT$+xVthi?41LtaN^NJu$a z&^j24RLA7*93c*aqD4~5a8$Ikr*BI{ANuMNN5mAS)6H5n)a5cUCOnDSg7 za6iG0%;--fmXR1*A@z|^5`bmgfk(m{7A1?0pVUyV>d2+qyKf3ca06BieuAfI@QPf4U~gzn6lz!{E{wR-Xz_n$Qgk0il!@6KpPg59ATp4 zeWC|_EdDdr(})(RFzXb~;0`bne9ngsliKL>M`a?j#$)yI|HhYc$ zTq@4EEvD_!DDn)n{B>AgJIW#Gg!^|wih8(T?;WkNoTo%P>2dEkWK$^zd>#-KhQnTX zO3pQcm))P>)5$F3_^&PK2`b^$PoJ`M5ElHlPK&w3el@-u?PkErRYYnE(O!#M0ZnW* zV(k}GY<^*;^vVFk*?yn{|Htbbn*E~d_(_d-8y!2k82LP3YcQ_+sf9EKwFW-BuWs%mi?!i1(38*~3GdO;U8ocl`7Vc+ zqD9kVC!fYPIT`R{>J48ez*!vpClz_+A8&??Z?a(870z2$J>?n~wg1QKi`)K8Zg{wj zW45xBQVyf$!1xbp@<|nW&a3@obo+b zML>zj__I65i1V|&jKyXH_--||-GDVSPWI_39RloFBMCveC$%*Lbj(dvYvg%L&Nhq> zFy+yOR{X+7Z=@E8=Wbdaab2s3;0Ev5_F#*Prz;129H4#_;FE+HzZZgcH0%Pck!JS! zTa2c!uD(_Ec}hU((|;b3k@3s^UNGO%R||$2L2EVMvW-BYPq?1JWaz2ZpR%W5q#6we zsWx{3i{`Wp53a=60#j>@s36rR(_$5cdkM?Z#PQ5R9~jMC1ZdZr;LaTcHzO#$4{EeK zOT}QE`Shthe>=HVN3I}HxPGHz(p&+y-~}<|^glVO8U3mg&D;L&T=OSKfJV?=M(;NdRng9+yJeZa|Un2!OTbajzaQQBPNA|%7nUUROz1Np;I*2RRE>WNJ!OSJ7Yt7z$TkfT$#ATurk`@Ioa!(s>lz-cMwsKzX zwf6Md{l3D!e7D{po$6xl&HTFu6mtbchp^{eAO8MbZn^MUJ|p(n$ptgbiyC?44I8Xa z&q*%+R(>$+^G&wu?8AWIxlMXE=Q?M%*CkInEjiLSY{+^R$Gvvmk%U9pe-}UcZRt&= z!{UEFG|%BgMXp%=NAN7q&7)ag-yexs_xMJ)u_gA7=VsGz{kd4Q{~dGtFXs=w-Qf_i zaay=sEI)lH;>*`ZvvTMD7ZiG#b3T9NJ=&p&&7b*WMUQtpH*Wft#*^@(P<}BJmCuH6 z%{;X7gHVSzkGLhkQ4p z>%9w9T}p?;%vVmVIYvRH(_#9nBOwsaPYb^*d}SyXD6xysY*6b@|H?zr*|(XTPPd&i32noqCCJvQK_l9cX{xex+kK z#(ZbUFRw2{vuu{1Pfc`E1f|WH_hCY@eV0&LZIh!Fh1-Vx#Gi3IcJiCTwW?gA+~>2E zfVasJvlrMTP3a$Q&%eI+;hfCU%mp^Tye1afOZ)$RxHPpFRp}H!6^{zm8orGRoNU)W z*e=NYJ8RXY*B2MBG2O(5_S+Y#Tkh20?oK$Dt+HJ|-^uG~;F7(Y#sA{VqxOWJS$p|> z)gO*$k8T)U|JAKqC^Jh46UkpiEfm%=j}^^l%i*w$&7U(HU8=nX#$MJ0?s%71>txQ# z-#tHJ*7Cgzx6RF$Sh-!!YYOS=UfQ(i*yX%~i&o!y{VwUvtiqN`_bY{a|8fOEG*{20>Y(3rPes$HQ7s`lFV-;LB zh8i@c#%wX^L^3nA{-uJN`(1~nBsI1Ytz!(jSjk!;3Qe6ow)pjp=)xGXO^6otXX<|g zTS_j6zSm)?360LgdnOOkgwsrA_xiFYD~?~^@NC}8GF%vG(_};ZI0so=UC=nmm^XXV z6mG>LDGK9f-7-0yXkrc|?QOdKjP4MDB!1s|tJV2O&8@{#L%BIoAH&G(I#k zmAwzsgQ3yok&|n6tHN9u)NV(z6s}zdqiw7KC#&Ix9Smu&ZO0V+y-t7_P=mSB8wYlXP5zO_46HA;!-IHC20?s(srPFu6fuuYw4M-kL(EuYe2 zUZKdQD9G8)j4dN&IoE9GXz8B~a{KU+y5hbV+|V2y{X0PW{~=3`l%9r!EKjAH)r8>! zHER|3J_5MB#sv1EwP;7C4u0A=wBibnl0|5>=+^X*^HrqcsUC&~7zmhx27Mph-*>6c zt?J5|n(bnUu&F#Xw1iDyOageG9;uPifXR>xvs#0iD@NRja-JEFueF|?O3k^Z3-y9} z{ZFM1#pe1gcPNYru;9qzgYs-;ocpv5#+tQY-tISX2J8)_Lqp|Ez%=q~gZ|#Rmj23u zHP#a?<^8rKWVFRh;hDmjHgiO7IjE2awv`mWJEzk!cNDk3u+DGZ6HT_&0)QE9-^|8+djt+QnW}J4G1q$oYmd z+XLg~cH;t4Qsp5*ZLCfF#c}=N3(oF)=)$IFn8j&uB9gRiwliqf%&BWg;-&bo_R`A% zY2;L^?TIi6d6xjcVe+LQ^zOUc?)yM*r1AY=@>Ap=#jINrHk#49}37v4+j3oW4+_x105Try+e<*_yn=_TH)gH!)MUuuaH*x7HTo) zB_@bJW3KxGFree5)Ahn$vQQ85kdm`Zcf&-}ry->!X$nhW;_l%!TA3wo`~JjK!r*OvR<{0)57c`V^? zA<~)1#l~jILo~W7CZBWFnZdJ6ZrHJ8ss-(|1A?OtY&`W*yP1Il_Q4WKiazac!C8O-g!_&(3 zx9uljS8fY&1ENMy{L!9zAz{CMz&~KE%$?6BhDO|SWa_c$jV?A@8+mj!B!{4v%;1Od z{!&T(Ldq3At9KyUSF<}kJ(tMiI}pFAYL|@lSoeG4f=;b{x5kc>cvszr?V;DD?myr# z2=t*2s6LANo*M9p)0avacv@IX`H`#y4*xSDJ`Ny%VxIV`B6RNlI4$F4meObZUqWID z51T*T>oh5>S{5Yh_YGMTGzmz9%Gy{r)$;#-ea12Q?AMz$G9NV~->vNZ-BMY6XuhN} zQ6OVAbifv-m#LIR!y?9LR)S|?lelZ+^FKYM1JQ91=zl8K-qev0ypLH=F^C+CY%0Sh z8)(0si=)#xHDR4C_1$;!?6(+TWbz@w_@G$NtKz}BJ!e_W82hIk2`}D_R9iN}X!}M0 zOhM5~PI2MBD7Ek8VOnv;%lkZSU%)T_e~VA`kiEJ8JYF8X<>^%%erY$xgQFup&vQ0+ z=EQog?%k3sbT-?Vd5fS_;RTU$3$yAz+Z>~;eMCsQJYP;2TcEKS6H@OUA9DMEbbq+c z!-w%P9=;=T=cfTy4dPxr9gR&ad+F5Ljj>;%mW@6B!(_s95Asn21c4F*7*8#^$|Rw| z0Dc;e++HxaH}*_RLL#_$PcGg7)1c_DZ74hpli>ruA{uR{q1q|H!86)fpd-Nm0Xm=j ztQF6#vMK~rT!J-$mcE-AJ$6#5%i{>594T zYinsUW#7a;>+RFy`>n?hQ|;MIID_havWeC3nc?fx8=N2O+}v2HJsf(zF;(YSv5PqC z$HhO3I)^vgMFl|zt7GH8<7dTzuG1HzW=Z2R`a<&i5-8kRA6#eE^@N`yI+j;Ox`GjM zyJ#PXo9xP~h(+Wvm#{b|L0^1XUq(j1+-|QWT|x+Fs@0%(Nv87srjTi!) z3=%6QMjTeTj$gS(3^vYfDk>+4)h14&5stbAgoPgind=aL?=AUawlwM;>U-CzVVo7L^lEX5+=w(UDk+w zCm1$b&i8!^1j5)rAw07Kkt7w7D#%_X$pUbzc4Lht)kw8diU!S^uFT9*u6_yohACEY zP7yk!f2W&b6-8139pf$UFp?FPkmND2GX_QQ=$;dKgAF z+Z|$*OT4DLgal=QIK>%|kcMEEr<5n0A8r6xOr@|~Zl{u1mjk~2BMIwATNu)IHfUcZ zU8q)O>ZPvlz}zq#zY&|S!am8Uu6_pu4#}-nDd!3SItRUKT9G+@YN5u2HXoDxo7RZz z1adp&sh&uuqJk)pFsob_(ZiP+WmZu1(z+b=+b!Bhg9>8hJ}k^8AYw<7voYcC^vBAuHAzp{&0{Md;2z60*w@1m{Xxv z7BpggG~ON^*jzn!lM6n_vwq#)Cr$CJiH6(jU|dEO3fTlk?hlq0%AAK3+APpfR81(q zgTQ(#Im%}r(FTHB?-zfp=i}_`u9Ydp;N~ zto+*Pi0HvFV@hEbw#5aP%fYp+B9i@47=koWugq7;@YCSIbqcT>we|p(R;Al>Vi*sg zA~m?xT#UIu!Y{>{WdWH&