From 229bb685567f25a19d1e4603eca8aadcc6fe06d5 Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Wed, 12 Aug 2026 20:09:54 +0800 Subject: [PATCH 01/15] [gateway] Add the Gateway runtime, common HTTP layer, and lifecycle Introduces fluss-gateway as an independent Cargo workspace with library and executable entry points: strict configuration with stable exit codes, the shared error envelope, request-id/body-size/deadline middleware, the FIP-49 GET /health endpoint, REST and metrics listeners, task supervision with SIGTERM draining, and the OpenAPI 3.1 document generated from the typed router. Co-authored-by: gstamatakis95 <126914070+gstamatakis95@users.noreply.github.com> --- fluss-gateway/Cargo.lock | 4381 ++++++++++++++++++++ fluss-gateway/Cargo.toml | 62 +- fluss-gateway/clippy.toml | 18 + fluss-gateway/justfile | 16 +- fluss-gateway/openapi.yaml | 133 + fluss-gateway/src/config.rs | 1011 +++++ fluss-gateway/src/error.rs | 661 +++ fluss-gateway/src/lib.rs | 29 +- fluss-gateway/src/lifecycle.rs | 547 +++ fluss-gateway/src/main.rs | 61 +- fluss-gateway/src/observability.rs | 499 +++ fluss-gateway/src/protocol/mod.rs | 20 + fluss-gateway/src/protocol/rest/health.rs | 130 + fluss-gateway/src/protocol/rest/mod.rs | 694 ++++ fluss-gateway/src/protocol/rest/openapi.rs | 291 ++ 15 files changed, 8514 insertions(+), 39 deletions(-) create mode 100644 fluss-gateway/clippy.toml create mode 100644 fluss-gateway/openapi.yaml create mode 100644 fluss-gateway/src/config.rs create mode 100644 fluss-gateway/src/error.rs create mode 100644 fluss-gateway/src/lifecycle.rs create mode 100644 fluss-gateway/src/observability.rs create mode 100644 fluss-gateway/src/protocol/mod.rs create mode 100644 fluss-gateway/src/protocol/rest/health.rs create mode 100644 fluss-gateway/src/protocol/rest/mod.rs create mode 100644 fluss-gateway/src/protocol/rest/openapi.rs diff --git a/fluss-gateway/Cargo.lock b/fluss-gateway/Cargo.lock index a8b82e7177a..ee5f1b27ded 100644 --- a/fluss-gateway/Cargo.lock +++ b/fluss-gateway/Cargo.lock @@ -2,6 +2,4387 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrow" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61d285d16bce7d0be61912f7928342b673067b6b7d7ef6cc179258ba7de1fecf" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-csv", + "arrow-data", + "arrow-ipc", + "arrow-json", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757ef1836251e88222542a7da2623bc1c9cb9e20afefa6db2c41e79991cd91d4" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9a4a4b2b5ecd0e04df03471661cb61f28bed3c7fd50994715129b01b2edb97" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "libc", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c12b576ef18c1deb80925a248b25ad84f419198d791b8e293fc6aaa60441fe90" +dependencies = [ + "bytes", + "half", + "num-bigint 0.5.1", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68338a9096a5dc9bc11927c58c43a8526d96bf6abd2012ef6c0c9f505991cc79" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.23.1", + "chrono", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-csv" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25011b52b346407d497ef0030e12b45e4f2d0cc279efc09c4f3d09106db30e36" +dependencies = [ + "arrow-array", + "arrow-cast", + "arrow-schema", + "chrono", + "csv", + "csv-core", + "regex", +] + +[[package]] +name = "arrow-data" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "723fe4aeed7604e00b9883a465af4ff0a0e6c44c03e41a68c3d1cbc403e0e44d" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ipc" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149437b14371f5b9ec60f5ddc751483ae99d7a7072653c0075e5e469156eea7b" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "flatbuffers", + "lz4_flex", + "zstd", +] + +[[package]] +name = "arrow-json" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18b9123ccfec418a663f821c9a034af339711678c11ffe00d3ec07da5ff9f7e" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-ord", + "arrow-schema", + "arrow-select", + "chrono", + "half", + "indexmap 2.14.0", + "itoa", + "lexical-core", + "memchr", + "num-traits", + "ryu", + "serde_core", + "serde_json", + "simdutf8", +] + +[[package]] +name = "arrow-ord" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c08dff0686cf23ca4f562803f191ccbeb726dbae6309cd4b4aaf65e0f2c979" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbec439386df71ad570e6758a946111322b9e9dc8db83b5527321f0b4c9119c2" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6fed2ca0d1eade57e811cbe73b98ad50cc08a1183e13b2d2aa43a7df593f40e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "arrow-select" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "466b19cf75130b891dc1b23a84b343c714c62c64c9c62e365c76aa0ff90a53fb" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c838a25bb3691e919e0f617616ac51a4ff8517a952e29ca133cf0c22b2ce65b1" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + +[[package]] +name = "astral-tokio-tar" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" +dependencies = [ + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "rustix", + "tokio", + "tokio-stream", + "xattr", +] + +[[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.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[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 = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint 0.4.8", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[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 = "bollard" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags 2.13.1", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.5", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost", + "serde", + "serde_json", + "serde_repr", + "time", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[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 = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[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 = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ferroid" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" +dependencies = [ + "portable-atomic", + "rand 0.10.2", + "web-time", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags 2.13.1", + "rustc_version", +] + [[package]] name = "fluss-gateway" version = "1.0.0" +dependencies = [ + "axum", + "clap", + "fluss-test-cluster", + "futures", + "http-body-util", + "libc", + "log", + "metrics", + "metrics-exporter-prometheus", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "serde_yaml", + "tempfile", + "tokio", + "tokio-util", + "toml", + "tower", + "utoipa", + "utoipa-axum", + "uuid", +] + +[[package]] +name = "fluss-rs" +version = "1.0.0" +dependencies = [ + "arrow", + "arrow-schema", + "bigdecimal", + "bitvec", + "byteorder", + "bytes", + "clap", + "crc32c", + "dashmap", + "delegate", + "futures", + "jiff", + "linked-hash-map", + "log", + "metrics", + "opendal", + "ordered-float", + "parking_lot", + "parse-display 0.10.0", + "prost", + "prost-build", + "rand 0.9.5", + "scopeguard", + "serde", + "serde_json", + "snafu", + "strum", + "strum_macros", + "tempfile", + "thiserror 1.0.69", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "fluss-test-cluster" +version = "1.0.0" +dependencies = [ + "clap", + "fluss-rs", + "serde", + "serde_json", + "testcontainers", + "tokio", +] + +[[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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +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", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[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.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[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 = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +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 = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +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 = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "js-sys", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +dependencies = [ + "value-bag", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4_flex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226" +dependencies = [ + "twox-hash", +] + +[[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.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b166dea96003ee2531cf14833efedced545751d800f03535801d833313f8c15" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "metrics", + "metrics-util", + "quanta", + "thiserror 2.0.20", +] + +[[package]] +name = "metrics-util" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.16.1", + "metrics", + "quanta", + "rand 0.9.5", + "rand_xoshiro", + "rapidhash", + "sketches-ddsketch", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.8", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.8", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opendal" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d075ab8a203a6ab4bc1bce0a4b9fe486a72bf8b939037f4b78d95386384bc80a" +dependencies = [ + "anyhow", + "backon", + "base64 0.22.1", + "bytes", + "crc32c", + "futures", + "getrandom 0.2.17", + "http", + "http-body", + "jiff", + "log", + "md-5", + "percent-encoding", + "quick-xml 0.38.4", + "reqsign", + "reqwest", + "serde", + "serde_json", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", + "rand 0.8.7", + "serde", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[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 = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive 0.9.1", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287d8d3ebdce117b8539f59411e4ed9ec226e0a4153c7f55495c6070d68e6f72" +dependencies = [ + "parse-display-derive 0.10.0", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.119", +] + +[[package]] +name = "parse-display-derive" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc048687be30d79502dea2f623d052f3a074012c6eac41726b7ab17213616b1" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.119", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[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.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "serde", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[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.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", + "serde", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[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.5", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqsign" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43451dbf3590a7590684c25fb8d12ecdcc90ed3ac123433e500447c7d77ed701" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "form_urlencoded", + "getrandom 0.2.17", + "hex", + "hmac", + "home", + "http", + "log", + "once_cell", + "percent-encoding", + "quick-xml 0.37.5", + "rand 0.8.7", + "reqwest", + "rust-ini", + "serde", + "serde_json", + "sha1", + "sha2", + "tokio", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "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", + "webpki-roots", +] + +[[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.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[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.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_fmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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_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_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.119", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sval" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4a2a7d92fa86fcc6222e4c3845f8486cff899d9db32480b26c91a5dbf2e22d" + +[[package]] +name = "sval_buffer" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4324db9ac500c609d659b752edf9c8abbf2233f8afd61a503fd6f88ed625032" +dependencies = [ + "sval", + "sval_ref", + "zerocopy", +] + +[[package]] +name = "sval_dynamic" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4046add0eecf55e680b9e207edf5fc7737b18a1d950db363d97e7f1b2d7c629c" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911a3486b5984a0a4f25edefcf2c2dba23654c29f63e75493b671d338bf24243" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53aae7c737b5b5f1be4bcb0ff20e057bf6b2ee4e9d025560075c5830d09f95" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df24df43cbdc4bb8c9f5ed19d0d57dc8f60a1a4259cdce52d597fe774ad3a71f" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bebc17f0f1fad060e57b778728d41ef87627e9111a6365d7463472cb58fc1b3" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f26fe3f6a68b40e6c8d654ea48c00e4316272fddf68c80493714c1b034ae70b" +dependencies = [ + "serde_core", + "sval", + "sval_nested", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +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.119", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "testcontainers" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera", + "ferroid", + "futures", + "http", + "itertools", + "log", + "memchr", + "parse-display 0.9.1", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[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.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "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", + "toml_datetime", + "toml_edit", +] + +[[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_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 2.14.0", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-axum" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c25bae5bccc842449ec0c5ddc5cbb6a3a1eaeac4503895dc105a1138f8234a0" +dependencies = [ + "axum", + "paste", + "tower-layer", + "tower-service", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.119", +] + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "value-bag" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417d6197dd0ee696783d6be4276ac6ea74b985e00024c85ccfb37aff4f2bed82" +dependencies = [ + "erased-serde", + "serde_core", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61f7251ecde2c9ed431bbe0659853e7991753447447bbf1ae59d8b31c578d4e" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +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 = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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", +] + +[[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", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[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/fluss-gateway/Cargo.toml b/fluss-gateway/Cargo.toml index 40afecfa18b..c1cd810c2d6 100644 --- a/fluss-gateway/Cargo.toml +++ b/fluss-gateway/Cargo.toml @@ -15,22 +15,21 @@ # specific language governing permissions and limitations # under the License. -# The gateway is its own Cargo workspace and deliberately stays out of the -# workspace rooted at ../fluss-rust so that gateway dependencies never touch -# that workspace's lock file or its generated dependency inventories. +# The gateway is its own Cargo workspace. It depends on the Rust client by +# path but deliberately stays out of the workspace rooted at ../fluss-rust so +# that gateway dependencies never touch that workspace's lock file or its +# generated dependency inventories. [workspace] -resolver = "3" +resolver = "2" [package] name = "fluss-gateway" edition = "2024" version = "1.0.0" license = "Apache-2.0" -# Keep in sync with the `gateway-msrv` CI job, which pins this exact toolchain. -rust-version = "1.88" +rust-version = "1.85" authors = ["Apache Fluss "] repository = "https://github.com/apache/fluss" -# The gateway ships as an executable, not a library on crates.io. publish = false description = "Stateless REST gateway for Apache Fluss" @@ -38,11 +37,50 @@ description = "Stateless REST gateway for Apache Fluss" name = "fluss-gateway" path = "src/main.rs" -# Internal testability boundary for the executable; this is not a published or supported Rust SDK. [lib] name = "fluss_gateway" -# This change only introduces the module scaffolding: manifest, toolchain, lint -# and license configuration, and placeholder targets, with no dependencies. -# The runtime (configuration, lifecycle, HTTP layer, and tests) arrives with -# the Gateway foundation change (FIP-49) and brings its dependencies with it. +[features] +# Compiles the end-to-end suites in tests/, which start a dockerized Fluss +# cluster. Mirrors the `integration_tests` feature of the fluss-rs crate and +# is never enabled by default. +integration_tests = [] + +[dependencies] +axum = { version = "0.8", default-features = false, features = ["http1", "matched-path", "tokio"] } +clap = { version = "4.5.37", features = ["derive"] } +toml = "0.8" +futures = "0.3" +# Process CPU/RSS/fd sampling for the FIP-49 process_* metric families. +libc = "0.2" +log = "0.4" +metrics = "0.24" +metrics-exporter-prometheus = { version = "0.17", default-features = false } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +# The shared query-string parser of the REST layer (`parse_query`). +serde_urlencoded = "0.7" +# The gateway.yaml configuration file is YAML with flat dotted keys (FIP-49). +serde_yaml = "0.9" +tokio = { version = "1.44.2", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } +utoipa = { version = "5", features = ["axum_extras"] } +utoipa-axum = "0.2" +uuid = { version = "1.10", features = ["v4"] } + +[dev-dependencies] +fluss-test-cluster = { path = "../fluss-rust/crates/fluss-test-cluster" } +http-body-util = "0.1" +reqwest = { version = "0.12", default-features = false, features = ["json", "blocking"] } +tempfile = "3" +tokio = { version = "1.44.2", features = ["full", "test-util"] } +tower = { version = "0.5", features = ["util"] } + +[[test]] +name = "http_api" + +[[test]] +name = "process" + +[[test]] +name = "e2e_harness" diff --git a/fluss-gateway/clippy.toml b/fluss-gateway/clippy.toml new file mode 100644 index 00000000000..ac48d614df9 --- /dev/null +++ b/fluss-gateway/clippy.toml @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +cognitive-complexity-threshold = 20 diff --git a/fluss-gateway/justfile b/fluss-gateway/justfile index bbeb59088f2..b48c864df79 100644 --- a/fluss-gateway/justfile +++ b/fluss-gateway/justfile @@ -26,10 +26,14 @@ build: build-release: cargo build --release -# Run all tests of the gateway. +# Run all unit and protocol tests of the gateway. test: cargo test --all-targets +# Run the end-to-end suite against a dockerized Fluss cluster. Requires Docker. +test-e2e: + cargo test --features integration_tests --test e2e_harness + # Format the gateway sources in place. fmt: cargo fmt --all @@ -46,10 +50,18 @@ clippy: doc: RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -# Run the gateway binary, forwarding any flags, e.g. just run --version +# Regenerate the checked-in OpenAPI 3.1 document from the typed contract. +openapi: + cargo test --lib protocol::rest::openapi::tests::export_checked_in_document -- --ignored --exact + +# Run the gateway binary, forwarding any flags, e.g. just run --config gateway.yaml run *flags: cargo run -- {{flags}} # Check that every dependency license is compatible with the Apache License. licenses: cargo deny check licenses + +# Regenerate the checked-in dependency license inventory. +deps: + cargo deny list -f tsv -t 0.6 > DEPENDENCIES.rust.tsv diff --git a/fluss-gateway/openapi.yaml b/fluss-gateway/openapi.yaml new file mode 100644 index 00000000000..0bbbbbad445 --- /dev/null +++ b/fluss-gateway/openapi.yaml @@ -0,0 +1,133 @@ +components: + schemas: + ErrorBody: + description: Body of the shared error envelope. + properties: + code: + $ref: '#/components/schemas/ErrorCode' + details: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/ErrorDetailsSchema' + message: + type: string + request_id: + format: uuid + type: string + retryable: + description: Whether repeating an otherwise unchanged request may succeed. + type: boolean + required: + - code + - message + - request_id + - retryable + type: object + ErrorCode: + description: |- + Stable error codes of the gateway: the FIP-49 vocabulary, resource-specific where the error + names a resource, exactly as serialized on the wire. + enum: + - invalid_argument + - unauthenticated + - unauthorized + - not_found + - cluster_not_found + - database_not_found + - table_not_found + - partition_not_found + - already_exists + - cluster_already_exists + - database_already_exists + - table_already_exists + - partition_already_exists + - failed_precondition + - database_not_empty + - unsupported + - unsupported_media_type + - not_acceptable + - limit_exceeded + - resource_exhausted + - timeout + - cancelled + - unavailable + - backend + - internal + - storage_backpressure + type: string + ErrorDetailsSchema: + description: Machine-readable resource context carried by resource-naming errors. + properties: + resource_kind: + type: + - string + - 'null' + resource_name: + type: + - string + - 'null' + type: object + ErrorEnvelope: + description: The envelope every failing response uses. + examples: + - error: + code: table_not_found + details: + resource_kind: table + message: table does not exist + request_id: 8f6c7f4a-f9b8-4c71-91ec-6e5578d7a913 + retryable: false + properties: + error: + $ref: '#/components/schemas/ErrorBody' + required: + - error + type: object + HealthResponse: + description: 'Response of `GET /health` (FIP-49): liveness plus process uptime.' + properties: + status: + type: string + uptime_ms: + description: Milliseconds since the gateway process started. + format: int64 + minimum: 0 + type: integer + required: + - status + - uptime_ms + type: object +info: + description: Stateless REST gateway for Apache Fluss + license: + name: Apache-2.0 + url: https://www.apache.org/licenses/LICENSE-2.0 + title: fluss-gateway + version: 1.0.0 +openapi: 3.1.0 +paths: + /health: + get: + operationId: getHealth + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + description: Gateway liveness and uptime + summary: 'The FIP-49 health summary: `{status, uptime_ms}`, always 200 while the process answers.' + tags: + - health + /v1/openapi.json: + get: + operationId: getOpenApi + responses: + '200': + description: OpenAPI 3.1 document + summary: Serves the generated OpenAPI 3.1 document as JSON. + tags: + - metadata +security: [] +servers: +- url: / diff --git a/fluss-gateway/src/config.rs b/fluss-gateway/src/config.rs new file mode 100644 index 00000000000..fa1f920909a --- /dev/null +++ b/fluss-gateway/src/config.rs @@ -0,0 +1,1011 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Gateway configuration for the REST service. +//! +//! One `gateway.yaml` file plus complete env overrides plus targeted CLI overrides. Precedence: +//! CLI > env > file > defaults. Parsing is strict: unknown keys (file or env) are rejected, durations must be +//! ``, byte sizes are plain integers or ``, and both reject zero. +//! +//! # Schema shape +//! +//! The file is YAML whose top level is a mapping of **flat dotted keys**, exactly as documented by FIP-49 +//! §Gateway Configuration and aligned with the Fluss `server.yaml` convention: +//! +//! ```yaml +//! gateway.rest.listen: 0.0.0.0:8080 +//! gateway.rest.write.max-request-bytes: 32MiB +//! ``` +//! +//! Keys named by the FIP keep their FIP spelling; internal keys the FIP does not cover (shutdown draining) +//! follow the same `gateway..` style. Each flat key is +//! translated to a field of the typed sections below before deserialization, so `deny_unknown_fields` stays +//! meaningful per subsystem and an unrecognised flat key is rejected with the exact name the operator wrote. +//! This supersedes the earlier sectioned TOML schema by explicit user decision: the REST contract and the +//! configuration surface should quote one vocabulary, the FIP's. +//! +//! There is deliberately **no TLS section**: transport +//! security terminates at a fronting proxy. +//! +//! Env override convention (unchanged): `FLUSS_GATEWAY__
__`, with `__` separating path +//! components of the *internal* sections. For example, `FLUSS_GATEWAY__SERVER_REST__BIND_ADDRESS` overrides +//! the REST listener. + +use serde::Deserialize; +use serde::de::{self, Deserializer}; +use std::collections::BTreeMap; +use std::fmt; +use std::net::SocketAddr; +use std::path::Path; +use std::time::Duration; +use toml::Value; + +/// Environment variable prefix for overrides. +pub const ENV_PREFIX: &str = "FLUSS_GATEWAY__"; + +/// A strictly parsed duration: `` (e.g. `"60s"`, `"15m"`). No floats, no whitespace, no +/// compound values. Deserialization rejects zero because every configured duration is a deadline or an interval. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConfigDuration(Duration); + +impl ConfigDuration { + /// Builds a duration directly, bypassing the string syntax used by configuration sources. + pub const fn from_secs(secs: u64) -> Self { + Self(Duration::from_secs(secs)) + } + + /// Builds a sub-second duration without going through the string syntax. + pub const fn from_millis(millis: u64) -> Self { + Self(Duration::from_millis(millis)) + } + + /// Hands out the value for use with timers and deadlines. + pub fn get(self) -> Duration { + self.0 + } + + /// Parses the strict integer-plus-unit syntax and rejects a zero result. + pub(crate) fn parse(s: &str) -> Result { + let (digits, unit) = split_number_and_unit(s); + if digits.is_empty() { + return Err(format!( + "invalid duration {s:?}: expected " + )); + } + let value: u64 = digits + .parse() + .map_err(|e| format!("invalid duration {s:?}: {e}"))?; + let duration = match unit { + "ms" => Duration::from_millis(value), + "s" => Duration::from_secs(value), + "m" => Duration::from_secs(value.saturating_mul(60)), + "h" => Duration::from_secs(value.saturating_mul(3600)), + _ => { + return Err(format!( + "invalid duration {s:?}: unit must be one of ms, s, m, h" + )); + } + }; + if duration.is_zero() { + return Err(format!("invalid duration {s:?}: must be greater than zero")); + } + Ok(Self(duration)) + } +} + +impl<'de> Deserialize<'de> for ConfigDuration { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(de::Error::custom) + } +} + +/// A strictly parsed byte size: a plain integer, or an integer with one of the suffixes `B`, `KB`, `KiB`, `MB`, +/// `MiB`, `GB`, `GiB` (e.g. `4194304` or `"4MiB"`). Deserialization rejects zero because every configured size is +/// a budget that must admit at least one byte. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ByteSize(u64); + +impl ByteSize { + /// Builds a size directly, bypassing the syntax and non-zero rule applied to configuration sources. + pub const fn new(bytes: u64) -> Self { + Self(bytes) + } + + /// Hands out the value for use in size comparisons and buffer budgets. + pub fn bytes(self) -> u64 { + self.0 + } + + /// Parses an integer size with an optional supported suffix and rejects a zero result. + pub(crate) fn parse(s: &str) -> Result { + let (digits, unit) = split_number_and_unit(s); + if digits.is_empty() { + return Err(format!("invalid byte size {s:?}: expected [unit]")); + } + let value: u64 = digits + .parse() + .map_err(|e| format!("invalid byte size {s:?}: {e}"))?; + let multiplier: u64 = match unit { + "" | "B" => 1, + "KB" => 1000, + "KiB" => 1024, + "MB" => 1_000_000, + "MiB" => 1024 * 1024, + "GB" => 1_000_000_000, + "GiB" => 1024 * 1024 * 1024, + _ => { + return Err(format!( + "invalid byte size {s:?}: unit must be one of B, KB, KiB, MB, MiB, GB, GiB" + )); + } + }; + let bytes = value + .checked_mul(multiplier) + .ok_or_else(|| format!("invalid byte size {s:?}: overflows u64"))?; + Self::checked(bytes).ok_or_else(|| format!("invalid byte size {s:?}: must be non-zero")) + } + + /// Returns the size unless it is zero. + fn checked(bytes: u64) -> Option { + (bytes != 0).then_some(Self(bytes)) + } +} + +/// Splits a strictly formatted numeric value from its optional unit suffix. +fn split_number_and_unit(value: &str) -> (&str, &str) { + let split = value + .char_indices() + .find(|(_, character)| !character.is_ascii_digit()) + .map_or(value.len(), |(index, _)| index); + value.split_at(split) +} + +impl<'de> Deserialize<'de> for ByteSize { + fn deserialize>(deserializer: D) -> Result { + struct Visitor; + impl de::Visitor<'_> for Visitor { + type Value = ByteSize; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a positive integer or a string like \"4MiB\"") + } + + fn visit_i64(self, v: i64) -> Result { + let bytes = u64::try_from(v) + .map_err(|_| E::custom(format!("byte size must be non-negative, got {v}")))?; + self.visit_u64(bytes) + } + + fn visit_u64(self, v: u64) -> Result { + ByteSize::checked(v).ok_or_else(|| E::custom("byte size must be non-zero")) + } + + fn visit_str(self, v: &str) -> Result { + ByteSize::parse(v).map_err(E::custom) + } + } + deserializer.deserialize_any(Visitor) + } +} + +/// `[server]` table. +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +#[serde(deny_unknown_fields, default)] +pub struct ServerConfig { + /// Optional operator-chosen identity used in logs and diagnostics only. + /// + /// Nothing in the gateway depends on it: the process is stateless, so no response, token, or handle is ever + /// scoped to an instance. It is never required. + pub instance_id: Option, + pub rest: RestServerConfig, + pub metrics: MetricsServerConfig, +} + +/// `[server.rest]`, the REST listener and its input-validation limits. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct RestServerConfig { + /// Loopback by default because the gateway has no transport security. + pub bind_address: SocketAddr, + /// Per-request server-side deadline. Exceeding it yields 504. + pub request_timeout: ConfigDuration, + /// Maximum accepted request body size. Exceeding it yields 413. + pub max_body_bytes: ByteSize, +} + +impl Default for RestServerConfig { + fn default() -> Self { + Self { + bind_address: "127.0.0.1:8080".parse().expect("valid default"), + request_timeout: ConfigDuration::from_secs(30), + max_body_bytes: ByteSize::new(32 * 1024 * 1024), + } + } +} + +/// `[server.metrics]`, the internal Prometheus listener. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct MetricsServerConfig { + pub enabled: bool, + pub bind_address: SocketAddr, +} + +impl Default for MetricsServerConfig { + fn default() -> Self { + Self { + enabled: true, + bind_address: "127.0.0.1:9095".parse().expect("valid default"), + } + } +} + +/// `[shutdown]`, which configures the graceful-shutdown drain deadline. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct ShutdownConfig { + pub drain_timeout: ConfigDuration, +} + +impl Default for ShutdownConfig { + fn default() -> Self { + Self { + drain_timeout: ConfigDuration::from_secs(30), + } + } +} + +/// The validated gateway configuration: everything the process needs before it binds a listener. +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +#[serde(deny_unknown_fields, default)] +pub struct GatewayConfig { + pub server: ServerConfig, + pub shutdown: ShutdownConfig, +} + +impl GatewayConfig { + /// Checks the invariants that span more than one field. Single-field syntax and non-zero rules are enforced + /// while deserializing. Called by [`load`] and exposed for tests and programmatic construction. + pub fn validate(&self) -> Result<(), ConfigError> { + let mut problems = Vec::new(); + self.validate_identity(&mut problems); + if problems.is_empty() { + Ok(()) + } else { + Err(ConfigError::Invalid(problems)) + } + } + + /// Rejects an unusable instance identity or a port clash between the two listeners. + /// + /// A non-loopback listener does **not** require an instance ID. Nothing the gateway returns is scoped to an + /// instance, so there is no identity to pin. + fn validate_identity(&self, problems: &mut Vec) { + let server = &self.server; + let rest_address = server.rest.bind_address; + if let Some(instance_id) = server.instance_id.as_deref() { + let valid = !instance_id.is_empty() + && instance_id.len() <= 128 + && instance_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')); + if !valid { + problems.push( + "server.instance_id must be 1-128 ASCII letters, digits, dots, underscores, or hyphens" + .to_string(), + ); + } + } + if server.metrics.enabled && server.metrics.bind_address == rest_address { + problems.push( + "server.metrics.bind_address must differ from server.rest.bind_address".to_string(), + ); + } + } + + /// Returns non-fatal configuration advisories that should be logged at startup. + pub fn warnings(&self) -> Vec { + let mut warnings = Vec::new(); + if !self.server.rest.bind_address.ip().is_loopback() { + warnings.push(format!( + "server.rest.bind_address {} is not loopback. The REST listener accepts \ + unauthenticated requests and has no TLS", + self.server.rest.bind_address + )); + } + warnings + } +} + +/// Targeted CLI overrides (highest precedence). +#[derive(Debug, Clone, Default)] +pub struct CliOverrides { + /// Overrides `server.rest.bind_address`. + pub bind_address: Option, +} + +/// Configuration loading/validation failure. +#[derive(Debug)] +pub enum ConfigError { + /// The config file could not be read. + Io(String), + /// The config file or an override value could not be parsed. + Parse(String), + /// A `FLUSS_GATEWAY__*` variable does not name a known section/key. + UnknownEnvKey(String), + /// One or more invariants failed validation. + Invalid(Vec), +} + +impl fmt::Display for ConfigError { + /// Renders a concise operator-facing configuration error. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConfigError::Io(msg) => write!(f, "cannot read configuration: {msg}"), + ConfigError::Parse(msg) => write!(f, "invalid configuration: {msg}"), + ConfigError::UnknownEnvKey(key) => { + write!(f, "unknown configuration environment variable: {key}") + } + ConfigError::Invalid(problems) => { + write!(f, "invalid configuration: {}", problems.join(", ")) + } + } + } +} + +impl std::error::Error for ConfigError {} + +/// Translates one `FLUSS_GATEWAY__` suffix into a dotted configuration path. Only the section, which is the first +/// segment, may spell a nested table with an underscore, so `SERVER_REST__BIND_ADDRESS` addresses +/// `server.rest.bind_address` while the key keeps its underscores. +fn env_suffix_to_path(suffix: &str) -> String { + let lowered = suffix.to_ascii_lowercase(); + match lowered.split_once("__") { + Some((section, key)) => format!("{}.{}", section.replace('_', "."), key.replace("__", ".")), + None => lowered, + } +} + +/// Reads one override value the way a TOML right-hand side would be read, so an operator can write an array, a +/// quoted string, a number, or a boolean. A bare value that is not valid TOML stays text, except that an unquoted +/// comma makes it a list, which is how a list-valued key is written outside a file. +fn coerce_override(raw: &str) -> Value { + if let Ok(mut table) = format!("x = {raw}").parse::() + && let Some(value) = table.remove("x") + { + return value; + } + if raw.contains(',') { + return Value::Array( + raw.split(',') + .map(|entry| Value::String(entry.trim().to_string())) + .collect(), + ); + } + Value::String(raw.to_string()) +} + +/// Writes `value` at a dotted path, creating the tables along the way and replacing whatever sat there before. +fn insert_path(table: &mut toml::Table, path: &str, value: Value) { + let mut current = table; + let mut segments = path.split('.').peekable(); + while let Some(segment) = segments.next() { + if segments.peek().is_none() { + current.insert(segment.to_string(), value); + return; + } + let entry = current + .entry(segment.to_string()) + .or_insert_with(|| Value::Table(toml::Table::new())); + if !entry.is_table() { + *entry = Value::Table(toml::Table::new()); + } + current = entry.as_table_mut().expect("table inserted above"); + } +} + +/// Turns a deserialization failure into an error that names the override responsible for it, if one is. Each +/// override is replayed on its own against the defaults, so only the override that actually carries the offending +/// key is blamed and a bad key in the file is never attributed to an unrelated override. +fn attribute(message: String, overrides: &[(String, String, Value)]) -> ConfigError { + for (path, origin, value) in overrides { + let mut probe = toml::Table::new(); + insert_path(&mut probe, path, value.clone()); + let Err(error) = GatewayConfig::deserialize(Value::Table(probe)) else { + continue; + }; + let reason = error.to_string(); + if reason.contains("unknown field") && origin.starts_with(ENV_PREFIX) { + return ConfigError::UnknownEnvKey(origin.clone()); + } + return ConfigError::Parse(format!("{origin}: {reason}")); + } + ConfigError::Parse(message) +} + +/// The flat `gateway.*` file vocabulary, mapped to the dotted paths of the typed sections. FIP-named keys keep +/// their FIP spelling; the remaining internal keys follow the same `gateway..` style. +/// +/// `gateway.rest.write.request-timeout` maps to the shared REST deadline: the gateway runs every request, +/// not only writes, under that server-side budget. +const FLAT_FILE_KEYS: &[(&str, &str)] = &[ + ("gateway.instance-id", "server.instance_id"), + ("gateway.rest.listen", "server.rest.bind_address"), + ( + "gateway.rest.write.request-timeout", + "server.rest.request_timeout", + ), + ( + "gateway.rest.write.max-request-bytes", + "server.rest.max_body_bytes", + ), + ("gateway.metrics.enabled", "server.metrics.enabled"), + ( + "gateway.metrics.exporter.prometheus.listen", + "server.metrics.bind_address", + ), + ("gateway.shutdown.drain-timeout", "shutdown.drain_timeout"), +]; + +/// Resolves one flat file key against the vocabulary, or rejects it with the exact name the operator wrote. +fn resolve_flat_key(key: &str) -> Result { + if let Some((_, path)) = FLAT_FILE_KEYS.iter().find(|(flat, _)| *flat == key) { + return Ok((*path).to_string()); + } + Err(ConfigError::Parse(format!( + "unknown configuration key: {key}" + ))) +} + +/// Converts one YAML scalar or sequence into the internal TOML value model. Nested mappings are rejected +/// because the file contract is flat dotted keys. +fn yaml_to_toml(value: &serde_yaml::Value, key: &str) -> Result { + match value { + serde_yaml::Value::Bool(v) => Ok(Value::Boolean(*v)), + serde_yaml::Value::Number(v) => { + if let Some(int) = v.as_i64() { + Ok(Value::Integer(int)) + } else if let Some(float) = v.as_f64() { + Ok(Value::Float(float)) + } else { + Err(ConfigError::Parse(format!("{key}: unsupported number"))) + } + } + serde_yaml::Value::String(v) => Ok(Value::String(v.clone())), + serde_yaml::Value::Sequence(items) => Ok(Value::Array( + items + .iter() + .map(|item| yaml_to_toml(item, key)) + .collect::>()?, + )), + serde_yaml::Value::Null => Err(ConfigError::Parse(format!("{key}: value is missing"))), + serde_yaml::Value::Mapping(_) | serde_yaml::Value::Tagged(_) => Err(ConfigError::Parse( + format!("{key}: nested values are not allowed, configuration keys are flat"), + )), + } +} + +/// Parses the flat-key YAML file into the internal table model. +fn read_config_file(contents: &str) -> Result { + let document: serde_yaml::Value = + serde_yaml::from_str(contents).map_err(|e| ConfigError::Parse(e.to_string()))?; + let mut table = toml::Table::new(); + if document.is_null() { + return Ok(table); + } + let mapping = document.as_mapping().ok_or_else(|| { + ConfigError::Parse( + "configuration must be a mapping of flat dotted keys (gateway.…: value)".to_string(), + ) + })?; + + for (key, value) in mapping { + let key = key + .as_str() + .ok_or_else(|| ConfigError::Parse("configuration keys must be strings".to_string()))?; + let path = resolve_flat_key(key)?; + insert_path(&mut table, &path, yaml_to_toml(value, key)?); + } + Ok(table) +} + +/// Loads configuration from all sources with precedence CLI > env > file > defaults. +/// +/// `env` is passed explicitly (rather than read from the process environment) so loading is deterministic and +/// testable. +pub fn load( + path: Option<&Path>, + env: &BTreeMap, + cli: &CliOverrides, +) -> Result { + let mut table = toml::Table::new(); + if let Some(path) = path { + let contents = std::fs::read_to_string(path) + .map_err(|e| ConfigError::Io(format!("{}: {e}", path.display())))?; + table = read_config_file(&contents)?; + } + + // Each override is kept with the source that wrote it, so a failure names what the operator wrote. + let mut overrides: Vec<(String, String, Value)> = Vec::new(); + for (key, raw) in env { + let Some(suffix) = key.strip_prefix(ENV_PREFIX) else { + continue; + }; + if suffix.is_empty() { + return Err(ConfigError::UnknownEnvKey(key.clone())); + } + overrides.push(( + env_suffix_to_path(suffix), + key.clone(), + coerce_override(raw), + )); + } + + for (path, flag, value) in [( + "server.rest.bind_address", + "--bind-address", + cli.bind_address.as_ref(), + )] { + if let Some(value) = value { + overrides.push(( + path.to_string(), + flag.to_string(), + Value::String(value.clone()), + )); + } + } + + for (path, _, value) in &overrides { + insert_path(&mut table, path, value.clone()); + } + + let config = GatewayConfig::deserialize(Value::Table(table)) + .map_err(|error| attribute(error.to_string(), &overrides))?; + + config.validate()?; + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn no_env() -> BTreeMap { + BTreeMap::new() + } + + fn write_temp_config(contents: &str) -> tempfile::NamedTempFile { + let mut file = tempfile::NamedTempFile::new().expect("temp file"); + file.write_all(contents.as_bytes()).expect("write"); + file + } + + fn load_file(contents: &str) -> Result { + let file = write_temp_config(contents); + load(Some(file.path()), &no_env(), &CliOverrides::default()) + } + + fn problems(error: ConfigError) -> Vec { + match error { + ConfigError::Invalid(problems) => problems, + other => panic!("expected Invalid, got: {other:?}"), + } + } + + #[test] + fn defaults_when_no_sources() { + let config = load(None, &no_env(), &CliOverrides::default()).unwrap(); + assert_eq!( + config.server.rest.bind_address, + "127.0.0.1:8080".parse().unwrap() + ); + assert_eq!(config.server.rest.max_body_bytes.bytes(), 32 * 1024 * 1024); + assert_eq!( + config.server.rest.request_timeout.get(), + Duration::from_secs(30) + ); + assert_eq!(config.shutdown.drain_timeout.get(), Duration::from_secs(30)); + assert!(config.warnings().is_empty()); + } + + #[test] + fn file_overrides_defaults() { + let config = load_file( + r#" +gateway.rest.listen: 127.0.0.1:18080 +gateway.rest.write.request-timeout: 5s +gateway.rest.write.max-request-bytes: 2MiB +"#, + ) + .unwrap(); + assert_eq!( + config.server.rest.bind_address, + "127.0.0.1:18080".parse().unwrap() + ); + assert_eq!( + config.server.rest.request_timeout.get(), + Duration::from_secs(5) + ); + assert_eq!(config.server.rest.max_body_bytes.bytes(), 2 * 1024 * 1024); + } + + /// The configuration surface documented by FIP-49 §Gateway Configuration, restricted to the keys the + /// gateway implements today, parses as one flat dotted-key YAML document. + #[test] + fn fip_yaml_example_parses_with_flat_dotted_keys() { + let config = load_file( + r#" +gateway.rest.listen: 0.0.0.0:8080 +gateway.rest.write.max-request-bytes: 32MiB +gateway.rest.write.request-timeout: 30s +gateway.metrics.enabled: true +gateway.metrics.exporter.prometheus.listen: 0.0.0.0:9095 +"#, + ) + .unwrap(); + assert_eq!( + config.server.rest.bind_address, + "0.0.0.0:8080".parse().unwrap() + ); + assert_eq!(config.server.rest.max_body_bytes.bytes(), 32 * 1024 * 1024); + assert_eq!( + config.server.rest.request_timeout.get(), + Duration::from_secs(30) + ); + assert!(config.server.metrics.enabled); + assert_eq!( + config.server.metrics.bind_address, + "0.0.0.0:9095".parse().unwrap() + ); + } + + /// A key outside the documented vocabulary is rejected with the exact flat name the operator wrote, + /// not a translated internal path. + #[test] + fn unknown_flat_key_is_rejected_with_its_original_name() { + for contents in [ + "gateway.rest.listenn: 0.0.0.0:8080\n", + "rest.listen: 0.0.0.0:8080\n", + "gateway.rest.lookup.max-keyz: 5\n", + ] { + let error = load_file(contents).unwrap_err(); + assert!(matches!(error, ConfigError::Parse(_)), "got: {error:?}"); + let key = contents.split(':').next().unwrap(); + assert!(error.to_string().contains(key), "{key}: {error}"); + } + } + + #[test] + fn env_overrides_file() { + let file = write_temp_config( + r#" +gateway.rest.listen: 127.0.0.1:18080 +gateway.metrics.enabled: true +"#, + ); + let mut env = no_env(); + env.insert( + "FLUSS_GATEWAY__SERVER_REST__BIND_ADDRESS".to_string(), + "127.0.0.1:28080".to_string(), + ); + env.insert( + "FLUSS_GATEWAY__SERVER_METRICS__ENABLED".to_string(), + "false".to_string(), + ); + env.insert("PATH".to_string(), "/usr/bin".to_string()); + + let config = load(Some(file.path()), &env, &CliOverrides::default()).unwrap(); + assert_eq!( + config.server.rest.bind_address, + "127.0.0.1:28080".parse().unwrap() + ); + assert!(!config.server.metrics.enabled); + } + + #[test] + fn cli_overrides_env_and_file() { + let file = write_temp_config("gateway.rest.listen: 127.0.0.1:18080\n"); + let mut env = no_env(); + env.insert( + "FLUSS_GATEWAY__SERVER_REST__BIND_ADDRESS".to_string(), + "127.0.0.1:28080".to_string(), + ); + let cli = CliOverrides { + bind_address: Some("127.0.0.1:38080".to_string()), + }; + let config = load(Some(file.path()), &env, &cli).unwrap(); + assert_eq!( + config.server.rest.bind_address, + "127.0.0.1:38080".parse().unwrap() + ); + } + + #[test] + fn missing_file_reported() { + let error = load( + Some(Path::new("/nonexistent/gateway.yaml")), + &no_env(), + &CliOverrides::default(), + ) + .unwrap_err(); + assert!(matches!(error, ConfigError::Io(_)), "got: {error:?}"); + } + + #[test] + fn unknown_file_field_rejected() { + let error = load_file("gateway.rest.listenn: 127.0.0.1:8080\n").unwrap_err(); + assert!(matches!(error, ConfigError::Parse(_)), "got: {error:?}"); + assert!( + error.to_string().contains("gateway.rest.listenn"), + "got: {error}" + ); + } + + #[test] + fn malformed_file_reports_position() { + let error = load_file("gateway.rest.listen: [1\n").unwrap_err(); + assert!(matches!(error, ConfigError::Parse(_)), "got: {error:?}"); + assert!(error.to_string().contains("line"), "got: {error}"); + } + + #[test] + fn duplicate_flat_key_rejected() { + let error = + load_file("gateway.rest.listen: 127.0.0.1:8080\ngateway.rest.listen: 127.0.0.1:8081\n") + .unwrap_err(); + assert!(matches!(error, ConfigError::Parse(_)), "got: {error:?}"); + assert!(error.to_string().contains("duplicate"), "got: {error}"); + } + + #[test] + fn unknown_section_rejected() { + let error = load_file("gateway.query.max-concurrent: 32\n").unwrap_err(); + assert!(matches!(error, ConfigError::Parse(_)), "got: {error:?}"); + assert!(error.to_string().contains("query"), "got: {error}"); + } + + #[test] + fn unknown_env_key_rejected() { + let mut env = no_env(); + env.insert( + "FLUSS_GATEWAY__SERVER_REST__BIND_ADDRES".to_string(), + "127.0.0.1:8080".to_string(), + ); + let error = load(None, &env, &CliOverrides::default()).unwrap_err(); + let ConfigError::UnknownEnvKey(key) = &error else { + panic!("expected UnknownEnvKey, got: {error:?}"); + }; + assert_eq!(key, "FLUSS_GATEWAY__SERVER_REST__BIND_ADDRES"); + } + + #[test] + fn unknown_env_section_rejected() { + let mut env = no_env(); + env.insert( + "FLUSS_GATEWAY__QUERY__ENABLED".to_string(), + "true".to_string(), + ); + let error = load(None, &env, &CliOverrides::default()).unwrap_err(); + assert!( + matches!(error, ConfigError::UnknownEnvKey(_)), + "got: {error:?}" + ); + } + + #[test] + fn file_error_under_a_section_with_an_env_override_names_the_file() { + let file = write_temp_config("gateway.shutdown.drain-timeout: 0s\n"); + let mut env = no_env(); + env.insert( + "FLUSS_GATEWAY__SERVER_REST__BIND_ADDRESS".to_string(), + "127.0.0.1:28080".to_string(), + ); + let error = load(Some(file.path()), &env, &CliOverrides::default()).unwrap_err(); + assert!(matches!(error, ConfigError::Parse(_)), "got: {error:?}"); + assert!(error.to_string().contains("drain_timeout"), "got: {error}"); + assert!( + !error.to_string().contains("FLUSS_GATEWAY__"), + "file problem misattributed to the env override: {error}" + ); + } + + #[test] + fn env_string_values_keep_commas_outside_list_keys() { + let mut env = no_env(); + env.insert( + "FLUSS_GATEWAY__SERVER__INSTANCE_ID".to_string(), + "gateway-a".to_string(), + ); + let config = load(None, &env, &CliOverrides::default()).unwrap(); + assert_eq!(config.server.instance_id.as_deref(), Some("gateway-a")); + + env.insert( + "FLUSS_GATEWAY__SERVER__INSTANCE_ID".to_string(), + "a,b".to_string(), + ); + let error = load(None, &env, &CliOverrides::default()).unwrap_err(); + assert!( + error + .to_string() + .contains("FLUSS_GATEWAY__SERVER__INSTANCE_ID") + || error.to_string().contains("instance_id"), + "got: {error}" + ); + } + + #[test] + fn invalid_env_value_names_the_variable() { + let mut env = no_env(); + env.insert( + "FLUSS_GATEWAY__SERVER_REST__MAX_BODY_BYTES".to_string(), + "many".to_string(), + ); + let error = load(None, &env, &CliOverrides::default()).unwrap_err(); + assert!( + error + .to_string() + .contains("FLUSS_GATEWAY__SERVER_REST__MAX_BODY_BYTES"), + "got: {error}" + ); + } + + #[test] + fn invalid_cli_value_names_the_flag() { + let cli = CliOverrides { + bind_address: Some("not-an-address".to_string()), + }; + let error = load(None, &no_env(), &cli).unwrap_err(); + assert!(error.to_string().contains("--bind-address"), "got: {error}"); + } + + #[test] + fn invalid_duration_rejected() { + for bad in ["60", "60 s", "6.5s", "s", "60d", "-1s"] { + let error = + load_file(&format!("gateway.shutdown.drain-timeout: \"{bad}\"\n")).unwrap_err(); + assert!(matches!(error, ConfigError::Parse(_)), "{bad}: {error:?}"); + assert!( + error.to_string().contains("drain_timeout"), + "{bad}: {error}" + ); + } + } + + #[test] + fn invalid_byte_size_rejected() { + for bad in ["\"4Mb\"", "\"MiB\"", "-1", "\"1.5MiB\""] { + let error = + load_file(&format!("gateway.rest.write.max-request-bytes: {bad}\n")).unwrap_err(); + assert!(matches!(error, ConfigError::Parse(_)), "{bad}: {error:?}"); + assert!( + error.to_string().contains("max_body_bytes"), + "{bad}: {error}" + ); + } + } + + #[test] + fn zero_durations_and_sizes_rejected_while_parsing() { + for (key, contents) in [ + ("drain_timeout", "gateway.shutdown.drain-timeout: 0ms\n"), + ( + "max_body_bytes", + "gateway.rest.write.max-request-bytes: 0\n", + ), + ] { + let error = load_file(contents).unwrap_err(); + assert!(matches!(error, ConfigError::Parse(_)), "{key}: {error:?}"); + assert!(error.to_string().contains(key), "{key}: {error}"); + } + } + + #[test] + fn removed_and_out_of_scope_configuration_keys_are_rejected() { + for contents in [ + // Scan and cursor state, dropped with the stateless contract. + "gateway.scan.max-open-global: 8\n", + "gateway.scan.cursor-ttl: 1m\n", + // Transport security, out of scope (TLS terminates at a fronting proxy). + "gateway.tls.cert: /etc/tls.pem\n", + ] { + assert!(load_file(contents).is_err(), "accepted: {contents}"); + } + } + + #[test] + fn metrics_address_must_differ_from_rest_address() { + let error = load_file( + "gateway.rest.listen: 127.0.0.1:9095\ngateway.metrics.exporter.prometheus.listen: 127.0.0.1:9095\n", + ) + .unwrap_err(); + assert!( + problems(error) + .iter() + .any(|p| p.contains("server.metrics.bind_address must differ")) + ); + } + + #[test] + fn non_loopback_bind_is_accepted_without_an_instance_id_but_warns() { + let config = load_file("gateway.rest.listen: 0.0.0.0:8080\n").unwrap(); + assert!(config.server.instance_id.is_none()); + assert_eq!(config.warnings().len(), 1); + assert!(config.warnings()[0].contains("not loopback")); + // The warning calls out the unauthenticated exposure. + assert!( + config.warnings()[0].contains("accepts unauthenticated requests"), + "{:?}", + config.warnings() + ); + } + + #[test] + fn malformed_instance_id_rejected() { + let error = load_file("gateway.instance-id: has space\n").unwrap_err(); + assert!( + problems(error) + .iter() + .any(|p| p.contains("server.instance_id must be 1-128 ASCII")) + ); + } + + #[test] + fn duration_units() { + assert_eq!( + ConfigDuration::parse("250ms").unwrap().get(), + Duration::from_millis(250) + ); + assert_eq!( + ConfigDuration::parse("15m").unwrap().get(), + Duration::from_secs(900) + ); + assert_eq!( + ConfigDuration::parse("2h").unwrap().get(), + Duration::from_secs(7200) + ); + assert!(ConfigDuration::parse("0s").is_err()); + } + + #[test] + fn byte_size_units() { + assert_eq!(ByteSize::parse("512").unwrap().bytes(), 512); + assert_eq!(ByteSize::parse("512B").unwrap().bytes(), 512); + assert_eq!(ByteSize::parse("4KB").unwrap().bytes(), 4000); + assert_eq!(ByteSize::parse("4KiB").unwrap().bytes(), 4096); + assert_eq!(ByteSize::parse("1GiB").unwrap().bytes(), 1024 * 1024 * 1024); + assert!(ByteSize::parse("4TB").is_err()); + assert!(ByteSize::parse("0").is_err()); + } + + #[test] + fn env_suffix_paths() { + assert_eq!( + env_suffix_to_path("SERVER_REST__BIND_ADDRESS"), + "server.rest.bind_address" + ); + assert_eq!( + env_suffix_to_path("SERVER__INSTANCE_ID"), + "server.instance_id" + ); + assert_eq!( + env_suffix_to_path("SHUTDOWN__DRAIN_TIMEOUT"), + "shutdown.drain_timeout" + ); + } +} diff --git a/fluss-gateway/src/error.rs b/fluss-gateway/src/error.rs new file mode 100644 index 00000000000..9d64943d409 --- /dev/null +++ b/fluss-gateway/src/error.rs @@ -0,0 +1,661 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Gateway error taxonomy and the REST error envelope. +//! +//! [`ErrorKind`] represents client-visible failure conditions independently of the HTTP framework. The REST adapter +//! obtains each status code from [`ErrorKind::http_status`]. +//! +//! The taxonomy is deliberately closed at fifteen kinds. There is no `GONE` or `CURSOR_NOT_LOCAL` because the +//! gateway holds no cursors. The gateway applies no request rate limiting — the only per-request bounds are +//! input-validation caps, surfacing as [`ErrorKind::LimitExceeded`] (413) or +//! [`ErrorKind::InvalidArgument`] (400) — but per-user act-as connections are a bounded resource, so +//! [`ErrorKind::ResourceExhausted`] (429, with a `Retry-After` header per FIP-49) reports connection-capacity +//! exhaustion under the user identity mode. +//! +//! FIP-49 error-model notes: the FIP's `database_not_empty` (409) condition is carried by +//! [`ErrorKind::FailedPrecondition`], and its `*_not_found` / `*_already_exists` families collapse onto +//! [`ErrorKind::NotFound`] / [`ErrorKind::AlreadyExists`] with the resource named in +//! [`ErrorDetails`], keeping one stable code per condition kind. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Client-visible condition kinds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ErrorKind { + /// The request contains malformed input, an invalid identifier, or a type mismatch. Maps to HTTP 400. + InvalidArgument, + /// The request carries no usable credential, or the credential failed verification. Maps to HTTP 401. + Unauthenticated, + /// The authenticated principal is not allowed to perform the operation. Maps to HTTP 403. + Unauthorized, + /// The requested database, table, or partition does not exist. Maps to HTTP 404. + NotFound, + /// A create operation conflicts with an existing resource. Maps to HTTP 409. + AlreadyExists, + /// Current resource state prevents the requested operation. Maps to HTTP 409. + FailedPrecondition, + /// The operation or table format is not supported. Maps to HTTP 501. + Unsupported, + /// The request media type is not supported. Maps to HTTP 415. + UnsupportedMediaType, + /// The `Accept` header does not allow a supported response type. Maps to HTTP 406. + NotAcceptable, + /// The request exceeds a configured input-validation size limit. Maps to HTTP 413. + LimitExceeded, + /// A bounded resource (per-user act-as connections) is at capacity. Maps to HTTP 429. + ResourceExhausted, + /// The request exceeded its deadline. Maps to HTTP 504. + DeadlineExceeded, + /// Work was cancelled by the caller or by shutdown. Maps to HTTP 499. + Cancelled, + /// The backend is unavailable or the gateway is not ready. Maps to HTTP 503. + Unavailable, + /// The Fluss backend failed in a way the gateway cannot classify further. Maps to HTTP 500 + /// with the FIP-49 `backend` code, distinguishable from a gateway-internal failure. + Backend, + /// An unexpected internal failure occurred. Maps to HTTP 500. + Internal, +} + +impl ErrorKind { + /// Every kind in declaration order. + /// + /// Kept in sync with the enum by [`ErrorKind::ordinal`], whose exhaustive match stops compiling when a + /// variant is added without extending this table. + pub const ALL: [ErrorKind; 16] = [ + ErrorKind::InvalidArgument, + ErrorKind::Unauthenticated, + ErrorKind::Unauthorized, + ErrorKind::NotFound, + ErrorKind::AlreadyExists, + ErrorKind::FailedPrecondition, + ErrorKind::Unsupported, + ErrorKind::UnsupportedMediaType, + ErrorKind::NotAcceptable, + ErrorKind::LimitExceeded, + ErrorKind::ResourceExhausted, + ErrorKind::DeadlineExceeded, + ErrorKind::Cancelled, + ErrorKind::Unavailable, + ErrorKind::Backend, + ErrorKind::Internal, + ]; + + /// Position of this kind within [`ErrorKind::ALL`]. + pub fn ordinal(self) -> usize { + match self { + ErrorKind::InvalidArgument => 0, + ErrorKind::Unauthenticated => 1, + ErrorKind::Unauthorized => 2, + ErrorKind::NotFound => 3, + ErrorKind::AlreadyExists => 4, + ErrorKind::FailedPrecondition => 5, + ErrorKind::Unsupported => 6, + ErrorKind::UnsupportedMediaType => 7, + ErrorKind::NotAcceptable => 8, + ErrorKind::LimitExceeded => 9, + ErrorKind::ResourceExhausted => 10, + ErrorKind::DeadlineExceeded => 11, + ErrorKind::Cancelled => 12, + ErrorKind::Unavailable => 13, + ErrorKind::Backend => 14, + ErrorKind::Internal => 15, + } + } + + /// Stable machine-readable code carried in the error envelope, for example `not_found`. + pub fn code(self) -> &'static str { + match self { + ErrorKind::InvalidArgument => "invalid_argument", + ErrorKind::Unauthenticated => "unauthenticated", + ErrorKind::Unauthorized => "unauthorized", + ErrorKind::NotFound => "not_found", + ErrorKind::AlreadyExists => "already_exists", + ErrorKind::FailedPrecondition => "failed_precondition", + ErrorKind::Unsupported => "unsupported", + ErrorKind::UnsupportedMediaType => "unsupported_media_type", + ErrorKind::NotAcceptable => "not_acceptable", + ErrorKind::LimitExceeded => "limit_exceeded", + ErrorKind::ResourceExhausted => "resource_exhausted", + ErrorKind::DeadlineExceeded => "timeout", + ErrorKind::Cancelled => "cancelled", + ErrorKind::Unavailable => "unavailable", + ErrorKind::Backend => "backend", + ErrorKind::Internal => "internal", + } + } + + /// The REST HTTP mapping table. + /// + /// Kept as a plain `u16` so this module stays free of HTTP framework types. The REST adapter converts to its own + /// status type. + pub fn http_status(self) -> u16 { + match self { + ErrorKind::InvalidArgument => 400, + ErrorKind::Unauthenticated => 401, + ErrorKind::Unauthorized => 403, + ErrorKind::NotFound => 404, + ErrorKind::AlreadyExists | ErrorKind::FailedPrecondition => 409, + ErrorKind::Unsupported => 501, + ErrorKind::UnsupportedMediaType => 415, + ErrorKind::NotAcceptable => 406, + ErrorKind::LimitExceeded => 413, + ErrorKind::ResourceExhausted => 429, + ErrorKind::DeadlineExceeded => 504, + ErrorKind::Cancelled => 499, + ErrorKind::Unavailable => 503, + ErrorKind::Backend | ErrorKind::Internal => 500, + } + } + + /// Whether repeating an otherwise unchanged request may succeed. + /// + /// This is the default for a kind. A native failure whose `FlussError::is_retriable()` disagrees overrides it + /// per error through [`GatewayError::with_retryable`]. + pub fn default_retryable(self) -> bool { + match self { + ErrorKind::DeadlineExceeded | ErrorKind::Unavailable | ErrorKind::ResourceExhausted => { + true + } + ErrorKind::InvalidArgument + | ErrorKind::Unauthenticated + | ErrorKind::Unauthorized + | ErrorKind::NotFound + | ErrorKind::AlreadyExists + | ErrorKind::FailedPrecondition + | ErrorKind::Unsupported + | ErrorKind::UnsupportedMediaType + | ErrorKind::NotAcceptable + | ErrorKind::LimitExceeded + | ErrorKind::Cancelled + | ErrorKind::Backend + | ErrorKind::Internal => false, + } + } +} + +/// Gateway-internal error: a condition kind plus a client-safe message. +/// +/// Messages must never contain stack traces, internal addresses, or wire payloads. Operational detail belongs in +/// the log. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayError { + kind: ErrorKind, + message: String, + details: Option, + /// Overrides [`ErrorKind::default_retryable`] when the native layer knows better. + retryable: Option, +} + +/// Optional protocol-neutral structured context for a public error. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorDetails { + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_name: Option, +} + +impl GatewayError { + /// The message reaches the client verbatim, so keep it free of internal detail. + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + details: None, + retryable: None, + } + } + + /// A malformed or rejected request argument. Answered with HTTP 400. + pub fn invalid_argument(message: impl Into) -> Self { + Self::new(ErrorKind::InvalidArgument, message) + } + + /// A request without a usable credential, or whose credential failed verification. Answered with HTTP 401. + pub fn unauthenticated(message: impl Into) -> Self { + Self::new(ErrorKind::Unauthenticated, message) + } + + /// An operation the authenticated principal is not allowed to perform. Answered with HTTP 403. + pub fn unauthorized(message: impl Into) -> Self { + Self::new(ErrorKind::Unauthorized, message) + } + + /// A named database, table, or partition that does not exist. Answered with HTTP 404. + pub fn not_found(message: impl Into) -> Self { + Self::new(ErrorKind::NotFound, message) + } + + /// A create operation targeting a resource that already exists. + pub fn already_exists(message: impl Into) -> Self { + Self::new(ErrorKind::AlreadyExists, message) + } + + /// An operation rejected because the current resource state does not permit it. + pub fn failed_precondition(message: impl Into) -> Self { + Self::new(ErrorKind::FailedPrecondition, message) + } + + /// An operation or table format the gateway does not implement. Answered with HTTP 501. + pub fn unsupported(message: impl Into) -> Self { + Self::new(ErrorKind::Unsupported, message) + } + + /// A request-size or configured input-validation limit was exceeded. Answered with HTTP 413. + pub fn limit_exceeded(message: impl Into) -> Self { + Self::new(ErrorKind::LimitExceeded, message) + } + + /// A bounded resource, such as the per-user act-as connection pool, is at capacity. + /// Answered with HTTP 429 and a `Retry-After` header. + pub fn resource_exhausted(message: impl Into) -> Self { + Self::new(ErrorKind::ResourceExhausted, message) + } + + /// The request ran past its deadline. Answered with HTTP 504. + pub fn deadline_exceeded(message: impl Into) -> Self { + Self::new(ErrorKind::DeadlineExceeded, message) + } + + /// Work cancelled by its caller or by gateway shutdown. + pub fn cancelled(message: impl Into) -> Self { + Self::new(ErrorKind::Cancelled, message) + } + + /// Creates a transient backend-unavailable error. + pub fn unavailable(message: impl Into) -> Self { + Self::new(ErrorKind::Unavailable, message) + } + + /// A Fluss backend failure the gateway cannot classify further. Answered with HTTP 500 and the + /// FIP-49 `backend` code, so callers can tell it from a gateway-internal failure. + pub fn backend(message: impl Into) -> Self { + Self::new(ErrorKind::Backend, message) + } + + /// An unexpected failure with no better classification. Answered with HTTP 500 and logged. + pub fn internal(message: impl Into) -> Self { + Self::new(ErrorKind::Internal, message) + } + + /// The condition this error represents, which decides the HTTP status and the envelope code. + pub fn kind(&self) -> ErrorKind { + self.kind + } + + /// Returns the safe client-facing message. + pub fn message(&self) -> &str { + &self.message + } + + /// Stable code carried in the error envelope. + /// + /// Per FIP-49 the vocabulary is resource-specific where a resource is known: an error whose + /// kind names a resource and that carries machine-readable resource context answers + /// `database_not_found`, `table_already_exists`, `database_not_empty`, and so on. `cluster` + /// follows the same `*_not_found` pattern as a natural extension — the FIP table predates the + /// multi-cluster path segment. An error without resource context keeps its kind's generic + /// code, so the gateway never guesses which resource a bare failure was about. + pub fn code(&self) -> &'static str { + let resource = self + .details + .as_ref() + .and_then(|details| details.resource_kind.as_deref()); + match (self.kind, resource) { + (ErrorKind::NotFound, Some("cluster")) => "cluster_not_found", + (ErrorKind::NotFound, Some("database")) => "database_not_found", + (ErrorKind::NotFound, Some("table")) => "table_not_found", + (ErrorKind::NotFound, Some("partition")) => "partition_not_found", + (ErrorKind::AlreadyExists, Some("cluster")) => "cluster_already_exists", + (ErrorKind::AlreadyExists, Some("database")) => "database_already_exists", + (ErrorKind::AlreadyExists, Some("table")) => "table_already_exists", + (ErrorKind::AlreadyExists, Some("partition")) => "partition_already_exists", + // The one precondition the FIP names: dropping a non-empty database. Every other + // precondition failure (e.g. a table changing during write preflight) keeps the + // generic code. + (ErrorKind::FailedPrecondition, Some("database")) => "database_not_empty", + _ => self.kind.code(), + } + } + + /// Whether repeating an otherwise unchanged request may succeed. + /// + /// Defaults to [`ErrorKind::default_retryable`] unless an explicit verdict was recorded. + pub fn retryable(&self) -> bool { + self.retryable + .unwrap_or_else(|| self.kind.default_retryable()) + } + + /// Records an explicit retry verdict, typically `FlussError::is_retriable()` from the native layer. + pub fn with_retryable(mut self, retryable: bool) -> Self { + self.retryable = Some(retryable); + self + } + + /// Adds machine-readable resource context without changing the stable error code. + pub fn with_resource( + mut self, + resource_kind: impl Into, + resource_name: Option>, + ) -> Self { + self.details = Some(ErrorDetails { + resource_kind: Some(resource_kind.into()), + resource_name: resource_name.map(Into::into), + }); + self + } + + /// Returns optional machine-readable context for protocol adapters. + pub fn details(&self) -> Option<&ErrorDetails> { + self.details.as_ref() + } +} + +impl fmt::Display for GatewayError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.code(), self.message) + } +} + +impl std::error::Error for GatewayError {} + +/// REST error envelope: `{"error": {"code", "message", "request_id", "retryable", "details"?}}`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorEnvelope { + pub error: ErrorBody, +} + +/// Body of the REST error envelope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorBody { + pub code: String, + pub message: String, + pub request_id: String, + /// Machine-readable retry guidance, derived from the error kind or from `FlussError::is_retriable()`. + pub retryable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl ErrorEnvelope { + /// Builds a public error envelope with the correlated request ID. + pub fn new(error: &GatewayError, request_id: impl Into) -> Self { + Self { + error: ErrorBody { + code: error.code().to_string(), + message: error.message().to_string(), + request_id: request_id.into(), + retryable: error.retryable(), + details: error.details().cloned(), + }, + } + } + + /// Builds an envelope for a failure that never had a [`GatewayError`], such as a framework-produced status. + /// + /// Routing every construction through a constructor keeps callers from leaving a stale struct literal behind + /// when the envelope gains a field. + pub fn from_parts( + code: impl Into, + message: impl Into, + request_id: impl Into, + retryable: bool, + ) -> Self { + Self { + error: ErrorBody { + code: code.into(), + message: message.into(), + request_id: request_id.into(), + retryable, + details: None, + }, + } + } +} + +/// Attaches machine-readable resource context to the error kinds that name a resource. +#[allow(dead_code)] // The resource-naming emitters arrive with the capability PRs; the wire contract ships now. +pub(crate) fn resource_error( + error: GatewayError, + resource_kind: &'static str, + resource_name: impl Into, +) -> GatewayError { + if error.details().is_some() + || !matches!( + error.kind(), + ErrorKind::NotFound | ErrorKind::AlreadyExists | ErrorKind::FailedPrecondition + ) + { + return error; + } + error.with_resource(resource_kind, Some(resource_name.into())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The frozen taxonomy. Adding a variant breaks [`ErrorKind::ordinal`] first, then this table. + const CONTRACT: [(ErrorKind, u16, &str, bool); 16] = [ + (ErrorKind::InvalidArgument, 400, "invalid_argument", false), + (ErrorKind::Unauthenticated, 401, "unauthenticated", false), + (ErrorKind::Unauthorized, 403, "unauthorized", false), + (ErrorKind::NotFound, 404, "not_found", false), + (ErrorKind::AlreadyExists, 409, "already_exists", false), + ( + ErrorKind::FailedPrecondition, + 409, + "failed_precondition", + false, + ), + (ErrorKind::Unsupported, 501, "unsupported", false), + ( + ErrorKind::UnsupportedMediaType, + 415, + "unsupported_media_type", + false, + ), + (ErrorKind::NotAcceptable, 406, "not_acceptable", false), + (ErrorKind::LimitExceeded, 413, "limit_exceeded", false), + ( + ErrorKind::ResourceExhausted, + 429, + "resource_exhausted", + true, + ), + (ErrorKind::DeadlineExceeded, 504, "timeout", true), + (ErrorKind::Cancelled, 499, "cancelled", false), + (ErrorKind::Unavailable, 503, "unavailable", true), + (ErrorKind::Backend, 500, "backend", false), + (ErrorKind::Internal, 500, "internal", false), + ]; + + #[test] + fn taxonomy_is_frozen_and_exhaustively_mapped() { + assert_eq!(ErrorKind::ALL.len(), CONTRACT.len()); + for (index, (kind, status, code, retryable)) in CONTRACT.into_iter().enumerate() { + assert_eq!(kind.ordinal(), index, "{code} is out of declaration order"); + assert_eq!(ErrorKind::ALL[index], kind, "ALL disagrees for {code}"); + assert_eq!(kind.http_status(), status, "status for {code}"); + assert_eq!(kind.code(), code); + assert_eq!(kind.default_retryable(), retryable, "retryable for {code}"); + } + } + + #[test] + fn only_connection_capacity_maps_to_429_and_nothing_maps_to_a_cursor_status() { + for kind in ErrorKind::ALL { + let status = kind.http_status(); + assert_eq!( + status == 429, + kind == ErrorKind::ResourceExhausted, + "{} unexpectedly maps to 429", + kind.code() + ); + assert_ne!(status, 410, "{} maps to a cursor status", kind.code()); + } + } + + #[test] + fn envelope_shape() { + let err = GatewayError::not_found("table `db.missing` does not exist"); + let envelope = ErrorEnvelope::new(&err, "req-123"); + let json = serde_json::to_value(&envelope).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "error": { + "code": "not_found", + "message": "table `db.missing` does not exist", + "request_id": "req-123", + "retryable": false, + } + }) + ); + } + + #[test] + fn explicit_retry_verdict_overrides_the_kind_default() { + let derived = GatewayError::unavailable("Fluss is unavailable"); + assert!(derived.retryable()); + + let overridden = GatewayError::internal("decode failed").with_retryable(true); + assert!(overridden.retryable()); + assert!( + !GatewayError::unavailable("permanently gone") + .with_retryable(false) + .retryable() + ); + assert_eq!( + serde_json::to_value(ErrorEnvelope::new(&overridden, "req-1")).unwrap()["error"]["retryable"], + serde_json::json!(true) + ); + } + + #[test] + fn retains_protocol_neutral_resource_details() { + let error = GatewayError::not_found("table does not exist") + .with_resource("table", Some("fluss.missing")); + + assert_eq!( + error.details(), + Some(&ErrorDetails { + resource_kind: Some("table".to_string()), + resource_name: Some("fluss.missing".to_string()), + }) + ); + assert_eq!( + serde_json::to_value(ErrorEnvelope::new(&error, "request-7")).unwrap(), + serde_json::json!({ + "error": { + "code": "table_not_found", + "message": "table does not exist", + "request_id": "request-7", + "retryable": false, + "details": { + "resource_kind": "table", + "resource_name": "fluss.missing" + } + } + }) + ); + } + + #[test] + fn framework_failures_get_an_envelope_without_a_gateway_error() { + let envelope = ErrorEnvelope::from_parts( + "method_not_allowed", + "method not allowed", + "request-9", + false, + ); + assert_eq!( + serde_json::to_value(&envelope).unwrap(), + serde_json::json!({ + "error": { + "code": "method_not_allowed", + "message": "method not allowed", + "request_id": "request-9", + "retryable": false, + } + }) + ); + } + + #[test] + fn resource_context_is_added_only_to_resource_naming_kinds() { + let named = resource_error(GatewayError::not_found("gone"), "table", "db.t"); + assert_eq!( + named.details().and_then(|d| d.resource_name.clone()), + Some("db.t".to_string()) + ); + let untouched = resource_error(GatewayError::internal("boom"), "table", "db.t"); + assert!(untouched.details().is_none()); + } + + /// The FIP-49 vocabulary: an error carrying resource context answers the resource-specific + /// code; one without context keeps its kind's generic code. + #[test] + fn resource_context_specialises_the_wire_code() { + let cases: [(GatewayError, &str, &str); 5] = [ + (GatewayError::not_found("x"), "cluster", "cluster_not_found"), + ( + GatewayError::not_found("x"), + "database", + "database_not_found", + ), + (GatewayError::not_found("x"), "table", "table_not_found"), + ( + GatewayError::already_exists("x"), + "partition", + "partition_already_exists", + ), + ( + GatewayError::failed_precondition("x"), + "database", + "database_not_empty", + ), + ]; + for (error, resource, expected) in cases { + let named = error.with_resource(resource, Some("name")); + assert_eq!(named.code(), expected); + let envelope = serde_json::to_value(ErrorEnvelope::new(&named, "r")).unwrap(); + assert_eq!(envelope["error"]["code"], expected); + } + + // Without resource context the generic codes hold — the gateway never guesses. + assert_eq!(GatewayError::not_found("x").code(), "not_found"); + assert_eq!(GatewayError::already_exists("x").code(), "already_exists"); + assert_eq!( + GatewayError::failed_precondition("x").code(), + "failed_precondition" + ); + // A precondition on a table (e.g. it changed during preflight) is not "not empty". + assert_eq!( + GatewayError::failed_precondition("x") + .with_resource("table", Some("db.t")) + .code(), + "failed_precondition" + ); + // Backend and internal stay distinguishable (FIP-49 `backend` / `internal`). + assert_eq!(GatewayError::backend("x").code(), "backend"); + assert_eq!(GatewayError::internal("x").code(), "internal"); + } +} diff --git a/fluss-gateway/src/lib.rs b/fluss-gateway/src/lib.rs index b0fff82dbba..abbfba9cf0c 100644 --- a/fluss-gateway/src/lib.rs +++ b/fluss-gateway/src/lib.rs @@ -15,19 +15,20 @@ // specific language governing permissions and limitations // under the License. -//! Internal implementation crate of the Fluss Gateway. +//! Stateless REST gateway for Apache Fluss. //! -//! The gateway is a stateless REST front end for Apache Fluss (FIP-49): it -//! keeps no session, cursor, or replay state, so any instance can serve any -//! request behind a plain load balancer. This change only reserves the crate -//! layout; the runtime modules arrive with the Gateway foundation change. +//! [`protocol::rest`] serves the HTTP surface — routing, JSON decoding, and the cross-cutting +//! middleware — and [`lifecycle`] owns startup, readiness, background tasks, and graceful shutdown. +//! +//! # Statelessness contract +//! +//! The gateway keeps **no** request-spanning state. There is no session store, no cursor store, and no replay +//! cache — deliberately, there is not even a `store` module for one to be added to. Every response is derivable +//! from the request plus current cluster state, so any instance can serve any request and instances can be added +//! or removed freely behind a plain load balancer. -#[cfg(test)] -mod tests { - // A single smoke test so the CI build-and-test gate proves the test - // harness is wired up from day one; real suites arrive with the runtime. - #[test] - fn crate_layout_is_wired() { - assert_eq!(env!("CARGO_PKG_NAME"), "fluss-gateway"); - } -} +pub mod config; +pub mod error; +pub mod lifecycle; +pub mod observability; +pub mod protocol; diff --git a/fluss-gateway/src/lifecycle.rs b/fluss-gateway/src/lifecycle.rs new file mode 100644 index 00000000000..d63c9270f2b --- /dev/null +++ b/fluss-gateway/src/lifecycle.rs @@ -0,0 +1,547 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Process lifecycle for listeners and graceful shutdown. +//! +//! Listener binding and process readiness are independent from Fluss availability. +//! +//! Shutdown drains in-flight requests. Because the gateway holds no request-spanning +//! state, there is nothing to hand over, flush, or migrate: a terminated instance leaves no work that another +//! instance would have to pick up. + +use crate::config::GatewayConfig; +use crate::error::GatewayError; +use crate::observability; +use crate::protocol::rest::{self, RestOptions, RestState}; +use axum::Router; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use futures::FutureExt; +use metrics_exporter_prometheus::PrometheusHandle; +use std::any::Any; +use std::future::Future; +use std::future::IntoFuture; +use std::panic::AssertUnwindSafe; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +type RunError = Box; + +const MAX_SHUTDOWN_CLEANUP_RESERVE: Duration = Duration::from_secs(5); + +/// Named terminal result from one process-owned asynchronous subsystem. +struct TaskExit { + name: String, + result: Result<(), String>, +} + +/// The shared process acceptance predicate. +#[derive(Debug, Default)] +pub struct Readiness { + serving: AtomicBool, + shutting_down: AtomicBool, +} + +impl Readiness { + /// Starts neither serving nor shutting down, so new work is rejected until startup completes. + pub fn new() -> Self { + Self::default() + } + + /// Marks the gateway ready to serve. Called once after the listeners are bound. + pub fn set_serving(&self) { + self.serving.store(true, Ordering::SeqCst); + } + + /// Flips acceptance off so guarded routes answer 503 and callers stop sending traffic, before + /// draining starts. + pub fn begin_shutdown(&self) { + self.shutting_down.store(true, Ordering::SeqCst); + } + + /// True once startup finished, regardless of whether shutdown has begun. + pub fn is_serving(&self) -> bool { + self.serving.load(Ordering::SeqCst) + } + + /// True once shutdown started. Never returns to false. + pub fn is_shutting_down(&self) -> bool { + self.shutting_down.load(Ordering::SeqCst) + } + + /// The predicate that gates request acceptance: serving and not yet draining. + pub fn is_accepting(&self) -> bool { + self.is_serving() && !self.is_shutting_down() + } + + /// Rejects new application work once startup has not completed or draining has begun. + pub fn ensure_accepting(&self) -> Result<(), GatewayError> { + if self.is_shutting_down() { + return Err(GatewayError::unavailable("gateway is shutting down")); + } + if !self.is_serving() { + return Err(GatewayError::unavailable("gateway is starting")); + } + Ok(()) + } +} + +/// A gateway whose configured listeners are bound and serving. +pub struct RunningGateway { + local_addr: std::net::SocketAddr, + metrics_addr: Option, + readiness: Arc, + drain_timeout: Duration, + shutdown: CancellationToken, + tasks: JoinSet, +} + +impl RunningGateway { + /// The bound REST address, resolved after binding so a configured port of 0 reads back as the real port. + pub fn local_addr(&self) -> std::net::SocketAddr { + self.local_addr + } + + /// The bound metrics address, or `None` when the metrics listener is disabled. + pub fn metrics_addr(&self) -> Option { + self.metrics_addr + } + + /// Begins graceful draining, the same transition SIGTERM triggers. + pub fn begin_shutdown(&self) { + self.readiness.begin_shutdown(); + } + + /// Stops accepting, drains in-flight requests within the configured drain timeout, then closes the + /// background tasks. Consumes the gateway. + pub async fn shutdown(self) -> Result<(), RunError> { + self.finish(None).await + } + + async fn finish(mut self, unexpected_exit: Option) -> Result<(), RunError> { + let shutdown_started = Instant::now(); + self.readiness.begin_shutdown(); + observability::process_draining(); + let (task_deadline, _deadline) = shutdown_deadlines(Instant::now(), self.drain_timeout); + self.shutdown.cancel(); + let cleanup_error = drain_tasks(&mut self.tasks, task_deadline).await; + + if let Some(error) = unexpected_exit { + observability::process_stopped("task_error", shutdown_started.elapsed()); + return Err(error.into()); + } + if let Some(error) = cleanup_error { + observability::process_stopped("cleanup_error", shutdown_started.elapsed()); + return Err(error.into()); + } + observability::process_stopped("success", shutdown_started.elapsed()); + log::info!("fluss-gateway stopped"); + Ok(()) + } +} + +/// Splits one process deadline into request draining and a bounded resource-cleanup tail. +fn shutdown_deadlines(started: Instant, timeout: Duration) -> (Instant, Instant) { + let deadline = started + timeout; + let minimum_reserve = Duration::from_millis(1).min(timeout); + let cleanup_reserve = (timeout / 4) + .max(minimum_reserve) + .min(MAX_SHUTDOWN_CLEANUP_RESERVE); + (deadline - cleanup_reserve, deadline) +} + +/// Runs the gateway until a process shutdown signal or any process-owned task exits unexpectedly. +pub async fn run(config: GatewayConfig) -> Result<(), RunError> { + let mut gateway = start(config).await?; + let unexpected_exit = tokio::select! { + biased; + result = gateway.tasks.join_next() => { + Some(unexpected_task_detail(result)) + } + _ = shutdown_signal() => { + log::info!("shutdown signal received"); + None + } + }; + gateway.finish(unexpected_exit).await +} + +/// Binds listeners and starts serving without requiring Fluss to be available. +pub async fn start(config: GatewayConfig) -> Result { + start_internal(config).await +} + +/// Binds the listeners, installs the router, and spawns every process-owned task. +async fn start_internal(config: GatewayConfig) -> Result { + for warning in config.warnings() { + log::warn!("{warning}"); + } + observability::init_metrics(config.server.metrics.enabled)?; + observability::register_process_metrics(); + + let listener = bind_listener(config.server.rest.bind_address, "REST").await?; + let local_addr = listener + .local_addr() + .map_err(|error| format!("failed to read the bound REST listener address: {error}"))?; + + let metrics_listener = if config.server.metrics.enabled { + Some(bind_listener(config.server.metrics.bind_address, "metrics").await?) + } else { + None + }; + let metrics_addr = metrics_listener + .as_ref() + .map(tokio::net::TcpListener::local_addr) + .transpose() + .map_err(|error| format!("failed to read the bound metrics listener address: {error}"))?; + + let readiness = Arc::new(Readiness::new()); + let state = rest_state(&config, &readiness, local_addr); + let router = rest::build_router(state, &RestOptions::from(&config.server.rest)); + let shutdown = CancellationToken::new(); + let mut tasks = JoinSet::new(); + spawn_named( + &mut tasks, + "REST listener", + serve(listener, router, shutdown.clone()), + ); + if let Some(listener) = metrics_listener { + let handle = observability::metrics_handle(); + spawn_named( + &mut tasks, + "metrics listener", + serve(listener, metrics_router(handle), shutdown.clone()), + ); + // Samples the FIP-49 process_* and tokio_* gauges alongside the exporter they feed. + let sampler_shutdown = shutdown.clone(); + spawn_named(&mut tasks, "runtime metrics sampler", async move { + let mut interval = tokio::time::interval(Duration::from_secs(10)); + loop { + tokio::select! { + () = sampler_shutdown.cancelled() => return Ok(()), + _ = interval.tick() => observability::sample_runtime_metrics(), + } + } + }); + } + + readiness.set_serving(); + observability::process_ready(); + log::info!("fluss-gateway REST listener serving at {local_addr}"); + if let Some(address) = metrics_addr { + log::info!("fluss-gateway metrics listener serving at {address}"); + } + Ok(RunningGateway { + local_addr, + metrics_addr, + readiness, + drain_timeout: config.shutdown.drain_timeout.get(), + shutdown, + tasks, + }) +} + +/// Builds shared handler state from validated configuration and process services. +pub fn rest_state( + _config: &GatewayConfig, + readiness: &Arc, + bind_address: std::net::SocketAddr, +) -> RestState { + RestState { + readiness: readiness.clone(), + bind_address, + started_at: Instant::now(), + openapi: Arc::new(OnceLock::new()), + } +} + +/// Binds one configured HTTP listener and adds a contextual startup error. +async fn bind_listener( + bind_address: std::net::SocketAddr, + name: &str, +) -> Result { + tokio::net::TcpListener::bind(bind_address) + .await + .map_err(|error| { + format!("failed to bind {name} listener on {bind_address}: {error}").into() + }) +} + +/// Serves one Axum listener until process cancellation starts graceful drain. +async fn serve( + listener: tokio::net::TcpListener, + router: Router, + shutdown: CancellationToken, +) -> Result<(), String> { + let server = axum::serve(listener, router).with_graceful_shutdown(async move { + shutdown.cancelled().await; + }); + server + .into_future() + .await + .map_err(|error| error.to_string()) +} + +/// Builds the isolated Prometheus scrape router. +fn metrics_router(handle: Option) -> Router { + Router::new().route( + "/metrics", + get(move || { + let handle = handle.clone(); + async move { metrics_response(handle.as_ref()) } + }), + ) +} + +/// Renders the current Prometheus exposition without API middleware or labels from user input. +fn metrics_response(handle: Option<&PrometheusHandle>) -> Response { + match handle { + Some(handle) => { + let mut response = (StatusCode::OK, handle.render()).into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"), + ); + response + } + None => StatusCode::SERVICE_UNAVAILABLE.into_response(), + } +} + +/// Registers one named process task and converts a panic into a normal named failure. +fn spawn_named(tasks: &mut JoinSet, name: N, future: F) +where + F: Future> + Send + 'static, + N: Into, +{ + let name = name.into(); + tasks.spawn(async move { + let result = match AssertUnwindSafe(future).catch_unwind().await { + Ok(result) => result, + Err(payload) => Err(format!("task panicked: {}", panic_message(payload))), + }; + TaskExit { name, result } + }); +} + +/// Converts a process task ending before shutdown into an operator-facing failure. +fn unexpected_task_detail(result: Option>) -> String { + match result { + Some(Ok(TaskExit { + name, + result: Ok(()), + })) => format!("{name} exited unexpectedly"), + Some(Ok(TaskExit { + name, + result: Err(error), + })) => format!("{name} failed: {error}"), + Some(Err(error)) => format!("gateway task failed: {error}"), + None => "all gateway tasks exited unexpectedly".to_string(), + } +} + +/// Waits for every process task under one absolute deadline, then aborts and joins any stragglers. +async fn drain_tasks(tasks: &mut JoinSet, deadline: Instant) -> Option { + let mut cleanup_error = None; + loop { + match tokio::time::timeout_at(deadline.into(), tasks.join_next()).await { + Ok(Some(Ok(TaskExit { name, result }))) => match result { + Ok(()) => log::info!("{name} stopped"), + Err(error) => { + log::warn!("{name} failed while draining: {error}"); + cleanup_error = Some(format!("{name} failed while draining")); + } + }, + Ok(Some(Err(error))) => { + log::warn!("gateway task failed while draining: {error}"); + cleanup_error = Some("gateway task failed while draining".to_string()); + } + Ok(None) => return cleanup_error, + Err(_) => { + let remaining = tasks.len(); + log::warn!("{remaining} gateway task(s) did not stop before the drain deadline"); + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + return Some(format!( + "{remaining} gateway task(s) exceeded the process drain deadline" + )); + } + } + } +} + +fn panic_message(payload: Box) -> String { + match payload.downcast::() { + Ok(message) => *message, + Err(payload) => match payload.downcast::<&'static str>() { + Ok(message) => (*message).to_string(), + Err(_) => "non-string panic payload".to_string(), + }, + } +} + +/// Resolves when SIGTERM on Unix or Ctrl-C is received. +async fn shutdown_signal() { + #[cfg(unix)] + { + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = sigterm.recv() => {} + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicUsize; + + struct DropGuard(Arc); + + impl Drop for DropGuard { + /// Records that a task-owned guard was dropped. + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + /// Verifies readiness transitions and idempotent shutdown state. + #[test] + fn readiness_predicate() { + let readiness = Readiness::new(); + assert!(!readiness.is_accepting()); + readiness.set_serving(); + assert!(readiness.is_accepting()); + readiness.begin_shutdown(); + assert!(!readiness.is_accepting()); + assert_eq!( + readiness.ensure_accepting().unwrap_err().message(), + "gateway is shutting down" + ); + readiness.begin_shutdown(); + assert!(readiness.is_shutting_down()); + } + + #[test] + fn readiness_rejects_work_before_startup() { + let readiness = Readiness::new(); + let error = readiness.ensure_accepting().unwrap_err(); + assert_eq!(error.kind(), crate::error::ErrorKind::Unavailable); + assert_eq!(error.message(), "gateway is starting"); + + readiness.set_serving(); + readiness.ensure_accepting().unwrap(); + } + + /// Timed-out tasks are aborted and joined before cleanup returns. + #[tokio::test(start_paused = true)] + async fn timed_out_tasks_are_aborted_and_joined_at_the_absolute_deadline() { + let task_drops = Arc::new(AtomicUsize::new(0)); + let background_drops = task_drops.clone(); + let mut tasks = JoinSet::new(); + spawn_named(&mut tasks, "stuck task", async move { + let _guard = DropGuard(background_drops); + std::future::pending::>().await + }); + tokio::task::yield_now().await; + let started = tokio::time::Instant::now(); + let deadline = Instant::now() + Duration::from_secs(5); + let error = drain_tasks(&mut tasks, deadline) + .await + .expect("a stuck task exceeds the deadline"); + + let elapsed = tokio::time::Instant::now().duration_since(started); + assert!(elapsed >= Duration::from_secs(5), "{elapsed:?}"); + assert!(elapsed < Duration::from_millis(5_010), "{elapsed:?}"); + assert!(error.contains("1 gateway task(s)"), "{error}"); + assert_eq!(task_drops.load(Ordering::SeqCst), 1); + assert!(tasks.is_empty()); + } + + /// A stuck request cannot consume the tail reserved for resource cleanup. + #[tokio::test] + async fn stuck_task_leaves_time_for_resource_cleanup() { + let mut tasks = JoinSet::new(); + spawn_named(&mut tasks, "stuck listener", async move { + std::future::pending::>().await + }); + tokio::task::yield_now().await; + let started = Instant::now(); + let (task_deadline, deadline) = shutdown_deadlines(started, Duration::from_millis(200)); + + let task_error = drain_tasks(&mut tasks, task_deadline).await; + assert!(task_error.is_some()); + assert!(task_deadline < deadline); + + let elapsed = started.elapsed(); + assert!(elapsed >= Duration::from_millis(140), "{elapsed:?}"); + assert!(elapsed < Duration::from_millis(500), "{elapsed:?}"); + } + + #[tokio::test] + async fn named_task_panic_is_reported_as_an_unexpected_process_failure() { + let mut tasks = JoinSet::new(); + spawn_named(&mut tasks, "REST listener", async move { + panic!("listener invariant failed"); + #[allow(unreachable_code)] + Ok(()) + }); + + let detail = unexpected_task_detail(tasks.join_next().await); + assert!(detail.contains("REST listener"), "{detail}"); + assert!(detail.contains("listener invariant failed"), "{detail}"); + } + + #[tokio::test] + async fn normal_task_exit_is_reported_as_an_unexpected_process_failure() { + let mut tasks = JoinSet::new(); + spawn_named(&mut tasks, "REST listener", async move { Ok(()) }); + + let detail = unexpected_task_detail(tasks.join_next().await); + + assert_eq!(detail, "REST listener exited unexpectedly"); + } + + #[tokio::test] + async fn process_cancellation_drains_all_named_tasks_cleanly() { + let shutdown = CancellationToken::new(); + let mut tasks = JoinSet::new(); + for name in ["REST listener", "metrics listener"] { + let task_shutdown = shutdown.clone(); + spawn_named(&mut tasks, name, async move { + task_shutdown.cancelled().await; + Ok(()) + }); + } + + shutdown.cancel(); + let error = drain_tasks(&mut tasks, Instant::now() + Duration::from_secs(1)).await; + + assert!(error.is_none(), "{error:?}"); + assert!(tasks.is_empty()); + } +} diff --git a/fluss-gateway/src/main.rs b/fluss-gateway/src/main.rs index 066e7302fbb..c58b0853819 100644 --- a/fluss-gateway/src/main.rs +++ b/fluss-gateway/src/main.rs @@ -15,15 +15,54 @@ // specific language governing permissions and limitations // under the License. -//! Placeholder entry point of the Fluss Gateway executable. -//! -//! This change only reserves the crate layout. The runtime — configuration, -//! lifecycle management, and the REST layer — arrives with the Gateway -//! foundation change described by FIP-49. - -fn main() { - println!( - "fluss-gateway {} (scaffolding only, the runtime is not wired yet)", - env!("CARGO_PKG_VERSION") - ); +//! `fluss-gateway` binary: parse the CLI, load and validate the config, initialise logging, then run the +//! lifecycle. Exits nonzero on configuration errors (2) or startup/serving failures (1), e.g. a listener bind +//! failure. + +use clap::Parser; +use fluss_gateway::config::{self, CliOverrides}; +use fluss_gateway::{lifecycle, observability}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// Command-line arguments. Everything else is configured through the `gateway.yaml` file or the environment. +#[derive(Debug, Parser)] +#[command( + name = "fluss-gateway", + about = "Stateless REST gateway for Apache Fluss", + version +)] +struct Cli { + /// Path to the `gateway.yaml` configuration file (YAML with flat dotted keys). + #[arg(long, value_name = "FILE")] + config: Option, + + /// Overrides `gateway.rest.listen` (e.g. `127.0.0.1:8080`). + #[arg(long, value_name = "ADDR")] + bind_address: Option, +} + +/// Loads configuration and runs the gateway process with stable exit codes. +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + let env: BTreeMap = std::env::vars().collect(); + let overrides = CliOverrides { + bind_address: cli.bind_address, + }; + + let config = match config::load(cli.config.as_deref(), &env, &overrides) { + Ok(config) => config, + Err(error) => { + eprintln!("fluss-gateway: {error}"); + std::process::exit(2); + } + }; + + observability::init_logging(); + + if let Err(error) = lifecycle::run(config).await { + log::error!("fluss-gateway failed: {error}"); + std::process::exit(1); + } } diff --git a/fluss-gateway/src/observability.rs b/fluss-gateway/src/observability.rs new file mode 100644 index 00000000000..7d7dc4d0d03 --- /dev/null +++ b/fluss-gateway/src/observability.rs @@ -0,0 +1,499 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Process logging and the complete gateway metric inventory. +//! +//! [`METRIC_DEFINITIONS`] is the cardinality contract: every gateway-owned metric family is declared here once, +//! with its kind, unit, description, and label set. Emission goes exclusively through the typed helpers in this +//! module so no call site can invent a family or a label that the inventory does not know about. +//! +//! Labels describe an operation or a bounded outcome. `cluster`, sourced from validated configuration, is the +//! only resource-name label; database, table, and partition names are never labels. + +use log::{LevelFilter, Log, Metadata, Record}; +use metrics::Unit; +use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle}; +use std::sync::OnceLock; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Logger that writes one line per record to standard error, with no filtering beyond the global level. +struct StderrLogger; + +impl Log for StderrLogger { + /// Returns whether a record is within the configured global level. + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.level() <= log::max_level() + } + + /// Writes one enabled record to standard error. + fn log(&self, record: &Record<'_>) { + if self.enabled(record.metadata()) { + eprintln!("{} {} {}", record.level(), record.target(), record.args()); + } + } + + /// Flushes buffered output, which is a no-op for direct standard-error writes. + fn flush(&self) {} +} + +static LOGGER: StderrLogger = StderrLogger; +static METRICS_HANDLE: OnceLock = OnceLock::new(); + +/// Which Prometheus instrument a metric family uses. +#[derive(Clone, Copy)] +pub enum MetricKind { + Counter, + Gauge, + Histogram, +} + +/// One declared metric family and its complete label set. +pub struct MetricDefinition { + /// Fully qualified Prometheus family name. + pub name: &'static str, + pub kind: MetricKind, + pub unit: Option, + pub description: &'static str, + /// Every label key the family may carry. Values must come from a bounded vocabulary. + pub labels: &'static [&'static str], +} + +/// The complete inventory of gateway-owned metric families. +/// +/// Adding an emission site means adding its family here first. Nothing in the gateway emits a family absent from +/// this table, and the tests below enforce the label-cardinality rules. +pub const METRIC_DEFINITIONS: &[MetricDefinition] = &[ + metric( + "fluss_gateway_process_start_time_seconds", + MetricKind::Gauge, + Some(Unit::Seconds), + "Gateway process start time since the Unix epoch.", + &[], + ), + metric( + "fluss_gateway_process_ready", + MetricKind::Gauge, + None, + "Whether the gateway accepts requests.", + &[], + ), + metric( + "fluss_gateway_process_shutting_down", + MetricKind::Gauge, + None, + "Whether graceful shutdown has begun.", + &[], + ), + metric( + "fluss_gateway_process_shutdown_total", + MetricKind::Counter, + None, + "Gateway shutdown outcomes: success, task_error, or cleanup_error.", + &["result"], + ), + metric( + "fluss_gateway_process_shutdown_duration_seconds", + MetricKind::Histogram, + Some(Unit::Seconds), + "Gateway graceful-shutdown duration by success, task_error, or cleanup_error.", + &["result"], + ), + metric( + "fluss_gateway_rest_requests_total", + MetricKind::Counter, + None, + "Completed REST requests. `operation` is the matched route template (FIP-49), `code` \ + the HTTP status, and `cluster` the bounded configured-cluster label (`none` for \ + cluster-free routes, `unknown` for unconfigured IDs).", + &["cluster", "method", "operation", "code"], + ), + metric( + "fluss_gateway_rest_request_duration_seconds", + MetricKind::Histogram, + Some(Unit::Seconds), + "REST request duration.", + &["cluster", "method", "operation"], + ), + metric( + "fluss_gateway_rest_inflight_requests", + MetricKind::Gauge, + None, + "REST requests currently executing.", + &[], + ), + metric( + "fluss_gateway_rest_rejections_total", + MetricKind::Counter, + None, + "REST requests rejected by an input-validation limit or the request deadline.", + &["reason"], + ), + // FIP-49 process and Tokio runtime families, sampled periodically by the runtime sampler. + // `process_cpu_seconds_total` is monotonic but published through the gauge instrument because + // the `metrics` counter API is integral; the exposition value is the standard fractional total. + metric( + "process_cpu_seconds_total", + MetricKind::Gauge, + Some(Unit::Seconds), + "Total user and system CPU time spent by the gateway process.", + &[], + ), + metric( + "process_resident_memory_bytes", + MetricKind::Gauge, + Some(Unit::Bytes), + "Resident memory of the gateway process. Linux only; absent elsewhere.", + &[], + ), + metric( + "process_open_fds", + MetricKind::Gauge, + None, + "Open file descriptors of the gateway process.", + &[], + ), + metric( + "tokio_alive_tasks", + MetricKind::Gauge, + None, + "Tokio tasks spawned but not yet finished.", + &[], + ), + metric( + "tokio_global_queue_depth", + MetricKind::Gauge, + None, + "Tasks waiting in the Tokio injection queue.", + &[], + ), + // FIP-49 also lists `tokio_worker_busy_seconds_total`; it needs the `tokio_unstable` runtime + // metrics and is added once the build enables them. +]; + +const fn metric( + name: &'static str, + kind: MetricKind, + unit: Option, + description: &'static str, + labels: &'static [&'static str], +) -> MetricDefinition { + MetricDefinition { + name, + kind, + unit, + description, + labels, + } +} + +/// Initializes the process logger. Repeated calls refresh the global level. +pub fn init_logging() { + let level = std::env::var("RUST_LOG") + .ok() + .as_deref() + .map(parse_level) + .unwrap_or(LevelFilter::Info); + let _ = log::set_logger(&LOGGER); + log::set_max_level(level); +} + +/// Installs the process-wide Prometheus recorder before the Fluss client creates metric handles. +pub fn init_metrics(enabled: bool) -> Result<(), String> { + if !enabled || METRICS_HANDLE.get().is_some() { + return Ok(()); + } + let recorder = PrometheusBuilder::new().build_recorder(); + let handle = recorder.handle(); + metrics::set_global_recorder(recorder) + .map_err(|error| format!("failed to install Prometheus recorder: {error}"))?; + let _ = METRICS_HANDLE.set(handle); + describe_metrics(); + Ok(()) +} + +/// Initializes process series after the recorder is installed. +pub fn register_process_metrics() { + let started = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + metrics::gauge!("fluss_gateway_process_start_time_seconds").set(started); + metrics::gauge!("fluss_gateway_process_ready").set(0.0); + metrics::gauge!("fluss_gateway_process_shutting_down").set(0.0); +} + +/// Records that startup completed and request listeners accept work. +pub fn process_ready() { + metrics::gauge!("fluss_gateway_process_ready").set(1.0); +} + +/// Records the start of graceful shutdown before listeners stop accepting. +pub fn process_draining() { + metrics::gauge!("fluss_gateway_process_ready").set(0.0); + metrics::gauge!("fluss_gateway_process_shutting_down").set(1.0); +} + +/// Records one terminal graceful-shutdown outcome and its bounded duration. +pub fn process_stopped(result: &'static str, duration: Duration) { + metrics::counter!("fluss_gateway_process_shutdown_total", "result" => result).increment(1); + metrics::histogram!("fluss_gateway_process_shutdown_duration_seconds", "result" => result) + .record(duration.as_secs_f64()); +} + +/// Records one completed REST request against the matched route template, never the raw URI. +/// +/// `operation` and `code` are the FIP-49 label names: the operation is the matched route +/// template, the code the HTTP status. `cluster` is already bounded by the caller: a configured +/// cluster ID, `unknown` for a request that named an unconfigured one, or `none` for routes +/// without a cluster segment. +pub fn http_request(cluster: &str, method: &str, operation: &str, code: u16, duration: Duration) { + metrics::counter!( + "fluss_gateway_rest_requests_total", + "cluster" => cluster.to_string(), + "method" => method.to_string(), + "operation" => operation.to_string(), + "code" => code.to_string() + ) + .increment(1); + metrics::histogram!( + "fluss_gateway_rest_request_duration_seconds", + "cluster" => cluster.to_string(), + "method" => method.to_string(), + "operation" => operation.to_string() + ) + .record(duration.as_secs_f64()); +} + +/// Adjusts the in-flight request gauge by one in either direction. +pub fn http_inflight(delta: i8) { + let gauge = metrics::gauge!("fluss_gateway_rest_inflight_requests"); + if delta >= 0 { + gauge.increment(f64::from(delta)); + } else { + gauge.decrement(f64::from(-delta)); + } +} + +/// Records one request rejected before reaching a handler, such as `body_size` or `timeout`. +pub fn http_rejection(reason: &'static str) { + metrics::counter!("fluss_gateway_rest_rejections_total", "reason" => reason).increment(1); +} + +/// Returns the installed recorder handle for the dedicated metrics listener. +pub fn metrics_handle() -> Option { + METRICS_HANDLE.get().cloned() +} + +/// Samples the FIP-49 process and Tokio runtime gauges once. +/// +/// Called periodically by the lifecycle's runtime sampler; each source that a platform cannot +/// provide is skipped rather than published as zero. +pub fn sample_runtime_metrics() { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + let runtime = handle.metrics(); + metrics::gauge!("tokio_alive_tasks").set(runtime.num_alive_tasks() as f64); + metrics::gauge!("tokio_global_queue_depth").set(runtime.global_queue_depth() as f64); + } + if let Some(cpu_seconds) = process_cpu_seconds() { + metrics::gauge!("process_cpu_seconds_total").set(cpu_seconds); + } + if let Some(resident) = process_resident_memory_bytes() { + metrics::gauge!("process_resident_memory_bytes").set(resident); + } + if let Some(fds) = process_open_fds() { + metrics::gauge!("process_open_fds").set(fds); + } +} + +/// Total user plus system CPU seconds of this process, from `getrusage(2)`. +#[cfg(unix)] +fn process_cpu_seconds() -> Option { + let mut usage = std::mem::MaybeUninit::::zeroed(); + // SAFETY: `getrusage` fills the buffer we own; a non-zero return leaves it unread. + let rc = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; + if rc != 0 { + return None; + } + // SAFETY: `getrusage` returned 0, so the buffer is initialized. + let usage = unsafe { usage.assume_init() }; + let seconds = |time: libc::timeval| time.tv_sec as f64 + time.tv_usec as f64 / 1_000_000.0; + Some(seconds(usage.ru_utime) + seconds(usage.ru_stime)) +} + +#[cfg(not(unix))] +fn process_cpu_seconds() -> Option { + None +} + +/// Current resident set size in bytes, from `/proc/self/statm`. Linux only. +#[cfg(target_os = "linux")] +fn process_resident_memory_bytes() -> Option { + let statm = std::fs::read_to_string("/proc/self/statm").ok()?; + let resident_pages: f64 = statm.split_whitespace().nth(1)?.parse().ok()?; + // SAFETY: `sysconf(_SC_PAGESIZE)` reads a process constant. + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + (page_size > 0).then_some(resident_pages * page_size as f64) +} + +#[cfg(not(target_os = "linux"))] +fn process_resident_memory_bytes() -> Option { + None +} + +/// Number of open file descriptors, counted from the per-process descriptor directory. +#[cfg(unix)] +fn process_open_fds() -> Option { + let directory = if cfg!(target_os = "linux") { + "/proc/self/fd" + } else { + "/dev/fd" + }; + let entries = std::fs::read_dir(directory).ok()?; + // The directory handle itself is one of the entries; excluding it keeps the count honest. + Some(entries.count().saturating_sub(1) as f64) +} + +#[cfg(not(unix))] +fn process_open_fds() -> Option { + None +} + +fn describe_metrics() { + for definition in METRIC_DEFINITIONS { + match (definition.kind, definition.unit) { + (MetricKind::Counter, Some(unit)) => { + metrics::describe_counter!(definition.name, unit, definition.description) + } + (MetricKind::Counter, None) => { + metrics::describe_counter!(definition.name, definition.description) + } + (MetricKind::Gauge, Some(unit)) => { + metrics::describe_gauge!(definition.name, unit, definition.description) + } + (MetricKind::Gauge, None) => { + metrics::describe_gauge!(definition.name, definition.description) + } + (MetricKind::Histogram, Some(unit)) => { + metrics::describe_histogram!(definition.name, unit, definition.description) + } + (MetricKind::Histogram, None) => { + metrics::describe_histogram!(definition.name, definition.description) + } + } + debug_assert!(definition.labels.iter().all(|label| !label.is_empty())); + } +} + +/// Parses a supported global level name, defaulting unknown directives to `info`. +fn parse_level(value: &str) -> LevelFilter { + match value.trim().to_ascii_lowercase().as_str() { + "off" => LevelFilter::Off, + "error" => LevelFilter::Error, + "warn" => LevelFilter::Warn, + "info" => LevelFilter::Info, + "debug" => LevelFilter::Debug, + "trace" => LevelFilter::Trace, + _ => LevelFilter::Info, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_supported_global_levels() { + assert_eq!(parse_level("off"), LevelFilter::Off); + assert_eq!(parse_level("ERROR"), LevelFilter::Error); + assert_eq!(parse_level("warn"), LevelFilter::Warn); + assert_eq!(parse_level("info"), LevelFilter::Info); + assert_eq!(parse_level("debug"), LevelFilter::Debug); + assert_eq!(parse_level("trace"), LevelFilter::Trace); + assert_eq!(parse_level("module=debug"), LevelFilter::Info); + } + + #[test] + fn metric_inventory_covers_every_required_subsystem() { + for prefix in ["fluss_gateway_process_", "fluss_gateway_rest_"] { + assert!( + METRIC_DEFINITIONS + .iter() + .any(|definition| definition.name.starts_with(prefix)), + "missing metric family for {prefix}" + ); + } + } + + #[test] + fn inventory_declares_no_scan_or_cursor_family() { + for definition in METRIC_DEFINITIONS { + for forbidden in ["fluss_gateway_scan_", "fluss_gateway_cursor_"] { + assert!( + !definition.name.starts_with(forbidden), + "stateless gateway must not declare {}", + definition.name + ); + } + } + } + + #[test] + fn metric_family_names_are_unique() { + let mut names: Vec<&str> = METRIC_DEFINITIONS + .iter() + .map(|definition| definition.name) + .collect(); + names.sort_unstable(); + let total = names.len(); + names.dedup(); + assert_eq!(names.len(), total, "duplicate metric family declared"); + } + + #[test] + fn metric_labels_cannot_contain_unbounded_resource_names() { + const FORBIDDEN: &[&str] = &[ + "database", + "table", + "partition", + "cursor", + "entry_id", + "request_id", + "raw_uri", + "row", + ]; + for definition in METRIC_DEFINITIONS { + for label in definition.labels { + assert!( + !FORBIDDEN.contains(label), + "metric {} has forbidden label {label}", + definition.name + ); + } + let resource_labels = definition + .labels + .iter() + .filter(|label| matches!(**label, "cluster" | "database" | "table" | "partition")) + .copied() + .collect::>(); + assert!( + resource_labels.is_empty() || resource_labels == ["cluster"], + "metric {} has invalid resource labels {resource_labels:?}", + definition.name + ); + } + } +} diff --git a/fluss-gateway/src/protocol/mod.rs b/fluss-gateway/src/protocol/mod.rs new file mode 100644 index 00000000000..1f15dc3fad9 --- /dev/null +++ b/fluss-gateway/src/protocol/mod.rs @@ -0,0 +1,20 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! The gateway's REST API surface. + +pub mod rest; diff --git a/fluss-gateway/src/protocol/rest/health.rs b/fluss-gateway/src/protocol/rest/health.rs new file mode 100644 index 00000000000..67b4aa927a2 --- /dev/null +++ b/fluss-gateway/src/protocol/rest/health.rs @@ -0,0 +1,130 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Health endpoint. +//! +//! `GET /health` returns the FIP-49 `{status, uptime_ms}` shape and answers from the event loop +//! without a backend RPC; deeper diagnostics live in the Prometheus metrics, not in this payload. + +use crate::protocol::rest::{RestState, json_response}; +use axum::extract::State; +use axum::response::Response; +use serde::Serialize; +use serde_json::json; +use utoipa::ToSchema; +use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::routes; + +/// Health routes merged into the main router by [`crate::protocol::rest::build_router`]. +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(health)) +} + +/// Response of `GET /health` (FIP-49): liveness plus process uptime. +#[derive(Debug, Serialize, ToSchema)] +pub struct HealthResponse { + pub status: String, + /// Milliseconds since the gateway process started. + pub uptime_ms: u64, +} + +/// The FIP-49 health summary: `{status, uptime_ms}`, always 200 while the process answers. +#[utoipa::path( + get, + path = "/health", + operation_id = "getHealth", + tag = "health", + responses((status = 200, description = "Gateway liveness and uptime", body = HealthResponse)) +)] +pub(crate) async fn health(State(state): State) -> Response { + json_response(&json!({ + "status": "ok", + "uptime_ms": u64::try_from(state.started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + })) + .expect("static JSON value is serializable") +} + +#[cfg(test)] +mod tests { + use crate::protocol::rest::test_support; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use axum::response::Response; + use http_body_util::BodyExt; + use tower::ServiceExt; + + /// Builds the production router over serving test state. + fn app() -> axum::Router { + let state = test_support::test_state(); + state.readiness.set_serving(); + crate::protocol::rest::build_router(state, &test_support::test_options()) + } + + async fn get(app: axum::Router, path: &str) -> Response { + app.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap() + } + + async fn body_json(response: Response) -> serde_json::Value { + let bytes = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + serde_json::from_slice(&bytes).expect("json body") + } + + /// `/health` answers the FIP-49 `{status, uptime_ms}` shape and nothing else. + #[tokio::test] + async fn health_answers_status_and_uptime_only() { + let response = get(app(), "/health").await; + assert_eq!(response.status(), StatusCode::OK); + let json = body_json(response).await; + assert_eq!(json["status"], "ok"); + assert!(json["uptime_ms"].is_u64(), "{json}"); + assert_eq!( + json.as_object().expect("object").len(), + 2, + "no diagnostic fields beyond the FIP shape: {json}" + ); + } + + /// `/health` answers before startup completes: it sits outside the acceptance guard, so it + /// never depends on the process having reached the serving state. + #[tokio::test] + async fn health_answers_before_startup_completes() { + let state = test_support::test_state(); + let app = crate::protocol::rest::build_router(state, &test_support::test_options()); + let response = get(app, "/health").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["status"], "ok"); + } + + /// The health endpoint stays reachable while the process is draining. + #[tokio::test] + async fn health_stays_200_during_shutdown() { + let state = test_support::test_state(); + state.readiness.set_serving(); + state.readiness.begin_shutdown(); + let app = crate::protocol::rest::build_router(state, &test_support::test_options()); + let response = get(app, "/health").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["status"], "ok"); + } +} diff --git a/fluss-gateway/src/protocol/rest/mod.rs b/fluss-gateway/src/protocol/rest/mod.rs new file mode 100644 index 00000000000..a24cc4e6a99 --- /dev/null +++ b/fluss-gateway/src/protocol/rest/mod.rs @@ -0,0 +1,694 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! REST adapter: router assembly and cross-cutting middleware. +//! +//! Each endpoint module builds its own [`OpenApiRouter`], and [`build_router`] merges them, splits the result +//! into an Axum router plus the generated OpenAPI document, and wraps the router in the middleware defined +//! here. +//! +//! The middleware applies two per-request input-validation bounds — a maximum body size (413) and the +//! per-request deadline (504). + +pub mod health; +pub mod openapi; + +use crate::config::RestServerConfig; +use crate::error::{ErrorEnvelope, GatewayError}; +use crate::lifecycle::Readiness; +use crate::observability; +use axum::Router; +use axum::body::{Body, Bytes}; +use axum::extract::{DefaultBodyLimit, MatchedPath, Request}; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::net::SocketAddr; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; +use utoipa_axum::router::OpenApiRouter; + +/// Shared state for REST handlers. +/// +/// Everything here is either immutable configuration or a shared process service. Nothing is scoped to a +/// request, a session, or a client. +#[derive(Clone)] +pub struct RestState { + pub readiness: Arc, + pub bind_address: SocketAddr, + pub started_at: Instant, + /// The OpenAPI document generated from the router this state was installed into. + /// + /// [`build_router`] fills it once, after the route modules are merged and split, so the served document is + /// exactly the contract of the routes that are actually mounted. + pub openapi: Arc>, +} + +/// Per-request identifier, generated by the outermost middleware and echoed in the `x-request-id` response header and +/// error envelopes. +#[derive(Clone, Debug)] +pub struct RequestId(Arc); + +impl RequestId { + /// The value echoed in the `x-request-id` header, or `unknown` when no middleware assigned one. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for RequestId { + fn default() -> Self { + Self(Arc::from("unknown")) + } +} + +/// Absolute request deadline assigned by the same middleware that enforces it. +#[derive(Clone, Copy, Debug)] +pub struct RequestDeadline(Instant); + +impl RequestDeadline { + pub fn instant(self) -> Instant { + self.0 + } +} + +/// Middleware limits, extracted from `[server.rest]`. +#[derive(Debug, Clone)] +pub struct RestOptions { + pub request_timeout: Duration, + pub max_body_bytes: u64, +} + +impl From<&RestServerConfig> for RestOptions { + fn from(config: &RestServerConfig) -> Self { + Self { + request_timeout: config.request_timeout.get(), + max_body_bytes: config.max_body_bytes.bytes(), + } + } +} + +/// Marks a response whose body is already in its final shape so the error-normalising middleware leaves it alone. +#[derive(Clone, Copy)] +struct ShapedResponse; + +/// Renders the error envelope with the status its kind maps to, marks the response as already shaped, and adds +/// `Retry-After` to the kinds that are worth retrying after a short pause. +pub fn error_response(error: &GatewayError, request_id: &RequestId) -> Response { + let status = StatusCode::from_u16(error.kind().http_status()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let mut response = + json_response_with_status(status, &ErrorEnvelope::new(error, request_id.as_str())) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()); + response.extensions_mut().insert(ShapedResponse); + if matches!( + error.kind(), + crate::error::ErrorKind::Unavailable | crate::error::ErrorKind::ResourceExhausted + ) { + response + .headers_mut() + .insert(header::RETRY_AFTER, HeaderValue::from_static("1")); + } + response +} + +/// Serializes `value` as a 200 JSON response. Fails only when serialization fails, which is reported as internal. +pub fn json_response(value: &T) -> Result { + json_response_with_status(StatusCode::OK, value) +} + +/// Serializes `value` as a JSON response with the given status. Serialization failures are reported as internal. +pub(crate) fn json_response_with_status( + status: StatusCode, + value: &T, +) -> Result { + let body = serde_json::to_vec(value).map_err(|error| { + GatewayError::internal(format!("failed to serialize JSON response: {error}")) + })?; + let mut response = (status, Body::from(body)).into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + Ok(response) +} + +/// Deserializes a JSON request body, requiring a JSON `Content-Type`. +pub fn parse_json_body( + headers: &HeaderMap, + body: &Bytes, +) -> Result { + validate_json_content_type(headers)?; + serde_json::from_slice(body) + .map_err(|error| GatewayError::invalid_argument(format!("invalid JSON body: {error}"))) +} + +pub(crate) fn validate_json_content_type(headers: &HeaderMap) -> Result<(), GatewayError> { + let Some(value) = headers.get(header::CONTENT_TYPE) else { + return Err(GatewayError::new( + crate::error::ErrorKind::UnsupportedMediaType, + "Content-Type must be application/json or application/*+json", + )); + }; + let media_type = value + .to_str() + .map_err(|_| GatewayError::invalid_argument("unreadable Content-Type header"))? + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + let supported = media_type == "application/json" + || media_type + .strip_prefix("application/") + .is_some_and(|subtype| subtype.ends_with("+json")); + if supported { + Ok(()) + } else { + Err(GatewayError::new( + crate::error::ErrorKind::UnsupportedMediaType, + "Content-Type must be application/json or application/*+json", + )) + } +} + +/// Rejects any query string on endpoints that define no query parameters. +pub fn ensure_no_query(uri: &Uri) -> Result<(), GatewayError> { + if uri.query().is_some() { + return Err(GatewayError::invalid_argument( + "this operation does not accept query parameters", + )); + } + Ok(()) +} + +/// Deserializes the URI query string. Unknown or malformed parameters are rejected as invalid arguments. +pub fn parse_query(uri: &Uri) -> Result { + serde_urlencoded::from_str(uri.query().unwrap_or_default()) + .map_err(|error| GatewayError::invalid_argument(format!("invalid query: {error}"))) +} + +/// Marks a response as final so the error-normalising middleware does not rewrite its body. Use it for handler +/// responses that already carry their own envelope. +pub fn shaped(mut response: Response) -> Response { + response.extensions_mut().insert(ShapedResponse); + response +} + +/// Builds the P1 router: `GET /health` sits outside both the acceptance guard and the body/deadline +/// budget so it answers while draining and never carries a body; every guarded route (today +/// `/v1/openapi.json`, later all data/control routes) carries the request-body size and deadline +/// budget, with the acceptance guard outermost so draining answers 503 without consuming bodies. +pub fn build_router(state: RestState, options: &RestOptions) -> Router { + let (guarded_router, guarded_api) = OpenApiRouter::new() + .merge(openapi::routes()) + .split_for_parts(); + let (open_router, open_api) = OpenApiRouter::new() + .merge(health::routes()) + .split_for_parts(); + + let mut api = guarded_api; + api.merge(open_api); + let _ = state.openapi.set(openapi::finalize(api)); + + // `apply_data_limits` also stamps the request deadline, so the guarded side needs no separate + // `assign_request_deadline` layer. + let guarded = apply_data_limits(guarded_router.with_state(state.clone()), options); + let guarded = apply_acceptance_guard(guarded, state.readiness.clone()); + let open = open_router + .fallback(unknown_route) + .with_state(state) + .layer(middleware::from_fn(assign_request_deadline( + options.request_timeout, + ))); + apply_common_middleware(open.merge(guarded)) +} + +/// Rejects new application work after graceful draining starts while keeping `GET /health` available. +fn apply_acceptance_guard(router: Router, readiness: Arc) -> Router { + router.layer(middleware::from_fn(move |request: Request, next: Next| { + let readiness = readiness.clone(); + async move { + if let Err(error) = readiness.ensure_accepting() { + let request_id = request + .extensions() + .get::() + .cloned() + .unwrap_or_default(); + return error_response(&error, &request_id); + } + next.run(request).await + } + })) +} + +/// Applies the cross-cutting middleware stack to an already-routed app. +/// +/// Exposed separately so tests can wrap purpose-built routers with the production middleware. +/// The body-limit layer is a streaming-body backstop. Requests with a declared length are rejected earlier with an +/// envelope. +/// +/// Order (outermost first): request-id assignment and error normalisation, then access logging, then the +/// body size and deadline limits. +pub fn apply_middleware(router: Router, options: &RestOptions) -> Router { + apply_common_middleware(apply_data_limits(router, options)) +} + +/// Records the absolute deadline of a request that does not pass through the data-limit layer. +fn assign_request_deadline( + request_timeout: Duration, +) -> impl Fn(Request, Next) -> std::pin::Pin + Send>> + Clone { + move |mut request: Request, next: Next| { + Box::pin(async move { + request + .extensions_mut() + .insert(RequestDeadline(Instant::now() + request_timeout)); + next.run(request).await + }) + } +} + +fn apply_data_limits(router: Router, options: &RestOptions) -> Router { + let request_timeout = options.request_timeout; + let max_body_bytes = options.max_body_bytes; + + let limits = move |mut request: Request, next: Next| async move { + let request_id = request + .extensions() + .get::() + .cloned() + .unwrap_or_default(); + request + .extensions_mut() + .insert(RequestDeadline(Instant::now() + request_timeout)); + + let oversized = declared_content_length(&request).filter(|length| *length > max_body_bytes); + if let Some(length) = oversized { + observability::http_rejection("body_size"); + log::warn!( + "request_id={} rejecting body of {} bytes above {} bytes", + request_id.as_str(), + length, + max_body_bytes + ); + return error_response( + &GatewayError::limit_exceeded(format!( + "request body of {length} bytes exceeds the limit of {max_body_bytes} bytes" + )), + &request_id, + ); + } + + observability::http_inflight(1); + let result = tokio::time::timeout(request_timeout, next.run(request)).await; + observability::http_inflight(-1); + match result { + Ok(response) => response, + Err(_) => { + observability::http_rejection("timeout"); + log::warn!( + "request_id={} deadline exceeded after {:?}", + request_id.as_str(), + request_timeout + ); + error_response( + &GatewayError::deadline_exceeded("request deadline exceeded"), + &request_id, + ) + } + } + }; + + router + .layer(DefaultBodyLimit::max( + usize::try_from(max_body_bytes).unwrap_or(usize::MAX), + )) + .layer(middleware::from_fn(limits)) +} + +fn apply_common_middleware(router: Router) -> Router { + router + .layer(middleware::from_fn(request_log)) + .layer(middleware::from_fn(request_context)) +} + +async fn request_log(request: Request, next: Next) -> Response { + let started = Instant::now(); + let method = request.method().clone(); + let route = request + .extensions() + .get::() + .map(MatchedPath::as_str) + .unwrap_or("") + .to_string(); + let request_id = request + .extensions() + .get::() + .cloned() + .unwrap_or_default(); + let response = next.run(request).await; + let elapsed = started.elapsed(); + let status = response.status(); + // No route carries a `{cluster}` segment, so every request gets the static `none` label; + // caller-supplied values never become label values. + observability::http_request("none", method.as_str(), &route, status.as_u16(), elapsed); + log::info!( + "{}", + format_request_log(&method, &route, &request_id, status, elapsed.as_millis()) + ); + response +} + +fn format_request_log( + method: &Method, + route: &str, + request_id: &RequestId, + status: StatusCode, + elapsed_ms: u128, +) -> String { + format!( + "method={method} route={route} request_id={} status={} elapsed_ms={elapsed_ms}", + request_id.as_str(), + status.as_u16() + ) +} + +async fn request_context(mut request: Request, next: Next) -> Response { + let request_id = RequestId(Arc::from(uuid::Uuid::new_v4().to_string())); + request.extensions_mut().insert(request_id.clone()); + + let response = next.run(request).await; + let mut response = normalize_error(response, &request_id); + + if let Ok(value) = HeaderValue::from_str(request_id.as_str()) { + response.headers_mut().insert("x-request-id", value); + } + response +} + +fn normalize_error(response: Response, request_id: &RequestId) -> Response { + let status = response.status(); + if !(status.is_client_error() || status.is_server_error()) { + return response; + } + if response.extensions().get::().is_some() { + return response; + } + + let (status, code, message, retryable) = match status.as_u16() { + 400 | 422 => (400, "invalid_argument", "invalid request", false), + 404 => (404, "not_found", "resource not found", false), + 405 => (405, "method_not_allowed", "method not allowed", false), + 406 => (406, "not_acceptable", "unacceptable accept header", false), + 408 | 504 => (504, "timeout", "request deadline exceeded", true), + 413 => (413, "limit_exceeded", "request body too large", false), + 415 => ( + 415, + "unsupported_media_type", + "unsupported media type", + false, + ), + 501 => (501, "unsupported", "unsupported operation", false), + 503 => (503, "unavailable", "service unavailable", true), + other => ( + other, + if other >= 500 { + "internal" + } else { + "invalid_argument" + }, + "request failed", + false, + ), + }; + + let envelope = ErrorEnvelope::from_parts(code, message, request_id.as_str(), retryable); + json_response_with_status( + StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + &envelope, + ) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} + +async fn unknown_route(method: Method, uri: Uri, request: Request) -> Response { + let request_id = request + .extensions() + .get::() + .cloned() + .unwrap_or_default(); + error_response( + &GatewayError::not_found(format!("no route for {method} {}", uri.path())), + &request_id, + ) +} + +fn declared_content_length(request: &Request) -> Option { + request + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()) +} + +#[cfg(test)] +pub mod test_support { + //! Router and state builders shared by the in-crate protocol tests. + + use super::*; + + /// Middleware options with short, test-friendly bounds. + pub fn test_options() -> RestOptions { + RestOptions { + request_timeout: Duration::from_secs(5), + max_body_bytes: 1024, + } + } + + /// Builds handler state over a fresh readiness gate, which starts neither serving nor draining. + pub fn test_state() -> RestState { + RestState { + readiness: Arc::new(Readiness::new()), + bind_address: "127.0.0.1:0".parse().expect("valid"), + started_at: Instant::now(), + openapi: Arc::new(OnceLock::new()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::routing::{get, post}; + use http_body_util::BodyExt; + use serde::Deserialize; + use tower::ServiceExt; + + #[derive(Debug, Deserialize, PartialEq)] + #[serde(deny_unknown_fields)] + struct BodyFixture { + value: u32, + } + + #[derive(Debug, Deserialize, PartialEq)] + #[serde(deny_unknown_fields)] + struct QueryFixture { + spec: String, + bucket: i32, + } + + async fn body_json(response: Response) -> serde_json::Value { + let bytes = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + serde_json::from_slice(&bytes).expect("json body") + } + + #[test] + fn shared_json_parser_enforces_media_type_and_serde_strictness() { + let body = Bytes::from_static(br#"{"value": 7}"#); + assert!(parse_json_body::(&HeaderMap::new(), &body).is_err()); + + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain")); + assert!(parse_json_body::(&headers, &body).is_err()); + + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/vnd.fluss+json; charset=utf-8"), + ); + assert_eq!( + parse_json_body::(&headers, &body).unwrap(), + BodyFixture { value: 7 } + ); + for bad in [ + br#"{"value": 7, "unknown": true}"#.as_slice(), + br#"{"value": 7, "value": 8}"#.as_slice(), + br#"{"value":"#.as_slice(), + ] { + assert!( + parse_json_body::(&headers, &Bytes::copy_from_slice(bad)).is_err() + ); + } + assert!(parse_json_body::(&headers, &Bytes::new()).is_err()); + } + + #[test] + fn shared_query_parser_decodes_and_rejects_duplicates() { + let uri: Uri = "/?spec=hello%20world&bucket=7".parse().unwrap(); + assert_eq!( + parse_query::(&uri).unwrap(), + QueryFixture { + spec: "hello world".to_string(), + bucket: 7, + } + ); + let repeated: Uri = "/?spec=a&spec=b&bucket=7".parse().unwrap(); + assert!(parse_query::(&repeated).is_err()); + let unknown: Uri = "/?spec=a&bucket=7&extra=1".parse().unwrap(); + assert!(parse_query::(&unknown).is_err()); + } + + #[tokio::test] + async fn unknown_route_yields_404_envelope() { + let app = build_router(test_support::test_state(), &test_support::test_options()); + let response = app + .oneshot(Request::builder().uri("/nope").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let header_id = response + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + .expect("x-request-id header"); + + let json = body_json(response).await; + assert_eq!(json["error"]["code"], "not_found"); + assert_eq!(json["error"]["request_id"], header_id.as_str()); + assert_eq!(json["error"]["retryable"], false); + assert!( + json["error"]["message"].as_str().unwrap().contains("/nope"), + "message names the missing route: {json}" + ); + } + + #[tokio::test] + async fn oversized_body_yields_413_envelope() { + let app = apply_middleware( + Router::new().route("/echo", post(|| async { "ok" })), + &test_support::test_options(), + ); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/echo") + .header(header::CONTENT_LENGTH, "1048576") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + let json = body_json(response).await; + assert_eq!(json["error"]["code"], "limit_exceeded"); + assert!(json["error"]["request_id"].as_str().is_some()); + } + + #[tokio::test] + async fn request_timeout_yields_504_envelope() { + /// Runs longer than the configured test request deadline. + async fn slow() -> &'static str { + tokio::time::sleep(Duration::from_millis(250)).await; + "ok" + } + let options = RestOptions { + request_timeout: Duration::from_millis(50), + max_body_bytes: 1024, + }; + let app = apply_middleware(Router::new().route("/slow", get(slow)), &options); + + let response = app + .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + let json = body_json(response).await; + assert_eq!(json["error"]["code"], "timeout"); + assert_eq!(json["error"]["retryable"], true); + } + + #[tokio::test] + async fn concurrent_requests_are_never_rejected_with_429() { + /// Holds a request open long enough for several to overlap. + async fn slow() -> &'static str { + tokio::time::sleep(Duration::from_millis(50)).await; + "ok" + } + let app = apply_middleware( + Router::new().route("/slow", get(slow)), + &test_support::test_options(), + ); + let request = || Request::builder().uri("/slow").body(Body::empty()).unwrap(); + + let responses = + futures::future::join_all((0..16).map(|_| app.clone().oneshot(request()))).await; + + for response in responses { + assert_eq!(response.unwrap().status(), StatusCode::OK); + } + } + + #[tokio::test] + async fn success_responses_carry_request_id_header() { + let app = build_router(test_support::test_state(), &test_support::test_options()); + let response = app + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().contains_key("x-request-id")); + } + + #[test] + fn request_log_contains_protocol_context() { + let message = format_request_log( + &Method::POST, + "/v1/clusters/{cluster}/databases", + &RequestId(Arc::from("request-7")), + StatusCode::CREATED, + 23, + ); + assert_eq!( + message, + "method=POST route=/v1/clusters/{cluster}/databases request_id=request-7 status=201 elapsed_ms=23" + ); + } +} diff --git a/fluss-gateway/src/protocol/rest/openapi.rs b/fluss-gateway/src/protocol/rest/openapi.rs new file mode 100644 index 00000000000..b1c12ef7a3b --- /dev/null +++ b/fluss-gateway/src/protocol/rest/openapi.rs @@ -0,0 +1,291 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Generated OpenAPI 3.1 document served at `GET /v1/openapi.json`. +//! +//! The document is derived from the routers themselves by +//! [`utoipa_axum::router::OpenApiRouter::split_for_parts`] — there is no hand-maintained list of paths or +//! schemas anywhere in the crate, so the served contract cannot drift from the mounted routes. This module owns +//! only the shared error schemas, the serve handler, and the post-pass hooks applied to the generated value. + +use crate::protocol::rest::{RestState, json_response}; +use axum::extract::State; +use axum::response::Response; +use serde::Serialize; +use serde_json::{Value, json}; +use utoipa::{OpenApi, ToSchema}; +use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::routes; + +/// Stable error codes of the gateway: the FIP-49 vocabulary, resource-specific where the error +/// names a resource, exactly as serialized on the wire. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +#[schema(as = ErrorCode)] +#[allow(dead_code)] // Schema-only enum; real errors use the HTTP-independent ErrorKind. +pub(crate) enum ErrorCodeSchema { + InvalidArgument, + Unauthenticated, + Unauthorized, + NotFound, + ClusterNotFound, + DatabaseNotFound, + TableNotFound, + PartitionNotFound, + AlreadyExists, + ClusterAlreadyExists, + DatabaseAlreadyExists, + TableAlreadyExists, + PartitionAlreadyExists, + FailedPrecondition, + DatabaseNotEmpty, + Unsupported, + UnsupportedMediaType, + NotAcceptable, + LimitExceeded, + ResourceExhausted, + Timeout, + Cancelled, + Unavailable, + Backend, + Internal, + /// Entry-level only: a KV write rejected by storage backpressure (never a request status). + StorageBackpressure, +} + +/// Machine-readable resource context carried by resource-naming errors. +#[derive(Debug, Serialize, ToSchema)] +pub(crate) struct ErrorDetailsSchema { + pub resource_kind: Option, + pub resource_name: Option, +} + +/// Body of the shared error envelope. +#[derive(Debug, Serialize, ToSchema)] +#[schema(as = ErrorBody)] +pub(crate) struct ErrorBodySchema { + pub code: ErrorCodeSchema, + pub message: String, + #[schema(value_type = String, format = "uuid")] + pub request_id: String, + /// Whether repeating an otherwise unchanged request may succeed. + pub retryable: bool, + pub details: Option, +} + +/// The envelope every failing response uses. +#[derive(Debug, Serialize, ToSchema)] +#[schema( + as = ErrorEnvelope, + examples(json!({ + "error": { + "code": "table_not_found", + "message": "table does not exist", + "request_id": "8f6c7f4a-f9b8-4c71-91ec-6e5578d7a913", + "retryable": false, + "details": {"resource_kind": "table"} + } + })) +)] +pub(crate) struct ErrorEnvelopeSchema { + pub error: ErrorBodySchema, +} + +/// Seeds the generated document with the schemas that no single handler owns. +#[derive(OpenApi)] +#[openapi(components(schemas( + ErrorCodeSchema, + ErrorDetailsSchema, + ErrorBodySchema, + ErrorEnvelopeSchema +)))] +struct SharedSchemas; + +/// OpenAPI routes, merged into the main router by [`crate::protocol::rest::build_router`]. +pub fn routes() -> OpenApiRouter { + OpenApiRouter::with_openapi(SharedSchemas::openapi()).routes(routes!(serve)) +} + +/// Applies the gateway's post-passes to the router-generated document. +/// +/// Called once by [`crate::protocol::rest::build_router`]. The passes are deliberately separate so that +/// documentation work can extend them without touching router assembly. +pub(crate) fn finalize(api: utoipa::openapi::OpenApi) -> Value { + let mut document = serde_json::to_value(api).expect("generated OpenAPI is serializable"); + apply_info(&mut document); + apply_servers(&mut document); + apply_security(&mut document); + apply_tags(&mut document); + apply_response_headers(&mut document); + document +} + +/// Replaces the utoipa-axum library defaults in `info` with this crate's own metadata. +fn apply_info(document: &mut Value) { + document["info"] = json!({ + "title": "fluss-gateway", + "description": "Stateless REST gateway for Apache Fluss", + "version": env!("CARGO_PKG_VERSION"), + "license": { + "name": "Apache-2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0" + } + }); +} + +/// The gateway serves the API at the listener root; a relative server keeps the document +/// host-agnostic. +fn apply_servers(document: &mut Value) { + document["servers"] = json!([{"url": "/"}]); +} + +/// An explicit empty root security array: honest for this PR — no authentication exists yet. +/// The authentication capability PR will introduce securitySchemes and per-operation requirements. +fn apply_security(document: &mut Value) { + document["security"] = json!([]); +} + +/// Post-pass hook for tag descriptions. Intentionally empty until the documentation pass lands. +fn apply_tags(_document: &mut Value) {} + +/// Post-pass hook for shared response headers. Intentionally empty until the documentation pass lands. +fn apply_response_headers(_document: &mut Value) {} + +/// Serves the generated OpenAPI 3.1 document as JSON. +#[utoipa::path( + get, + path = "/v1/openapi.json", + operation_id = "getOpenApi", + tag = "metadata", + responses((status = 200, description = "OpenAPI 3.1 document")) +)] +pub(crate) async fn serve(State(state): State) -> Response { + let document = state.openapi.get().cloned().unwrap_or_else(|| json!({})); + json_response(&document).expect("OpenAPI JSON is serializable") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::rest::test_support; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use tower::ServiceExt; + + /// Fetches the document exactly as the gateway serves it. + async fn served_document() -> Value { + let state = test_support::test_state(); + state.readiness.set_serving(); + let app = crate::protocol::rest::build_router(state, &test_support::test_options()); + let response = app + .oneshot( + Request::builder() + .uri("/v1/openapi.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() + } + + /// The checked-in `openapi.yaml` next to this crate's `Cargo.toml` (FIP-49). + fn checked_in_path() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("openapi.yaml") + } + + /// Regenerates the checked-in `openapi.yaml` from the typed contract: `just openapi`. + #[tokio::test] + #[ignore = "rewrites openapi.yaml in the working tree; run via `just openapi`"] + async fn export_checked_in_document() { + let yaml = + serde_yaml::to_string(&served_document().await).expect("the document serializes"); + std::fs::write(checked_in_path(), yaml).expect("openapi.yaml is writable"); + } + + /// The checked-in document always matches the served one, so the published specification + /// cannot drift from the implementation (FIP-49 schema-validation contract). + #[tokio::test] + async fn the_checked_in_document_matches_the_served_one() { + let checked_in = std::fs::read_to_string(checked_in_path()) + .expect("openapi.yaml is checked in; regenerate it with `just openapi`"); + let checked_in: Value = + serde_yaml::from_str(&checked_in).expect("openapi.yaml parses as YAML"); + assert_eq!( + checked_in, + served_document().await, + "openapi.yaml is stale; regenerate it with `just openapi`" + ); + } + + #[tokio::test] + async fn served_document_is_generated_from_the_mounted_routes() { + let document = served_document().await; + + assert_eq!(document["openapi"], "3.1.0"); + assert_eq!(document["info"]["title"], "fluss-gateway"); + assert_eq!(document["info"]["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(document["info"]["license"]["name"], "Apache-2.0"); + assert!( + document["info"].get("contact").is_none(), + "the library-default contact must not leak" + ); + assert!( + !document["servers"] + .as_array() + .expect("servers array") + .is_empty(), + "a relative root server is declared" + ); + assert!( + document["security"] + .as_array() + .expect("security array") + .is_empty(), + "root security is explicitly empty until authentication lands" + ); + assert_eq!( + document["paths"]["/v1/openapi.json"]["get"]["operationId"], + "getOpenApi" + ); + assert!( + document["components"]["schemas"]["ErrorEnvelope"].is_object(), + "the shared error envelope is registered" + ); + assert_eq!( + document["components"]["schemas"]["ErrorBody"]["properties"]["retryable"]["type"], + "boolean" + ); + } + + #[tokio::test] + async fn the_document_declares_no_scan_or_cursor_path() { + let document = served_document().await; + let paths = document["paths"].as_object().expect("paths object"); + for path in paths.keys() { + assert!(!path.contains("/scan"), "stateless gateway exposes {path}"); + assert!(!path.contains("cursor"), "stateless gateway exposes {path}"); + assert!( + !path.contains("offsets"), + "stateless gateway exposes {path}" + ); + } + } +} From 87a54f49bd19f52a28727f5cdfb7116d9d64eb6b Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Wed, 12 Aug 2026 20:10:08 +0800 Subject: [PATCH 02/15] [gateway] Add the Gateway test suites: HTTP contract, process, and cluster harness Covers the shared HTTP contract over a real listener, the compiled binary's startup, health, SIGTERM draining and exit codes, and a self-test of the fixed-version dockerized Fluss cluster harness that later capabilities reuse for their end-to-end suites. Co-authored-by: gstamatakis95 <126914070+gstamatakis95@users.noreply.github.com> --- fluss-gateway/tests/e2e_harness.rs | 49 +++++++++++ fluss-gateway/tests/http_api.rs | 123 +++++++++++++++++++++++++++ fluss-gateway/tests/process.rs | 132 +++++++++++++++++++++++++++++ fluss-gateway/tests/support/mod.rs | 77 +++++++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 fluss-gateway/tests/e2e_harness.rs create mode 100644 fluss-gateway/tests/http_api.rs create mode 100644 fluss-gateway/tests/process.rs create mode 100644 fluss-gateway/tests/support/mod.rs diff --git a/fluss-gateway/tests/e2e_harness.rs b/fluss-gateway/tests/e2e_harness.rs new file mode 100644 index 00000000000..a603d639e1e --- /dev/null +++ b/fluss-gateway/tests/e2e_harness.rs @@ -0,0 +1,49 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +#![cfg(feature = "integration_tests")] + +//! Self-test of the shared end-to-end harness: starts the fixed-version dockerized Fluss cluster +//! and proves the test environment can reach it. The gateway's own production connection to Fluss +//! is exercised by the authentication/service-identity capability, not here. +//! +//! Gated behind `integration_tests` because it needs Docker. The CI gate runs it with +//! `cargo test --features integration_tests --test e2e_harness` and fails loudly when the fixture +//! cannot start — a selected scenario must never skip silently. + +use fluss_test_cluster::FlussTestingClusterBuilder; +use std::net::TcpStream; +use std::time::Duration; + +#[tokio::test] +async fn the_fixed_version_fluss_fixture_starts_and_is_reachable() { + // Port 19123 keeps the fixture clear of the fluss-rs integration suite's + // default cluster on 9123 (host ports are fixed, not ephemeral). + let mut builder = FlussTestingClusterBuilder::new("gateway-harness-selftest").with_port(19123); + let cluster = builder.build().await; + // Bare host:port, e.g. "127.0.0.1:19123" — no scheme prefix to strip. + let address = cluster.plaintext_bootstrap_servers().to_string(); + let reachable = TcpStream::connect_timeout( + &address.parse().expect("bootstrap address parses"), + Duration::from_secs(10), + ); + assert!( + reachable.is_ok(), + "fixture bootstrap {address} accepts TCP connections" + ); + cluster.stop(); +} diff --git a/fluss-gateway/tests/http_api.rs b/fluss-gateway/tests/http_api.rs new file mode 100644 index 00000000000..bf287f81fb1 --- /dev/null +++ b/fluss-gateway/tests/http_api.rs @@ -0,0 +1,123 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! End-to-end HTTP tests over the full production wiring. +//! +//! These drive a real listener through a real HTTP client, so they exercise the lifecycle, the middleware stack, +//! and the router exactly as deployed. + +mod support; + +use fluss_gateway::lifecycle::RunningGateway; +use support::{Api, start_gateway}; + +/// One in-process gateway plus a client bound to its address. +async fn gateway() -> (RunningGateway, Api) { + let gateway = start_gateway().await; + let api = Api::new(format!("http://{}", gateway.local_addr())); + (gateway, api) +} + +#[tokio::test] +async fn health_answers_with_status_and_uptime() { + let (gateway, api) = gateway().await; + + let health = api.get_ok("/health").await; + assert_eq!(health["status"], "ok"); + assert!(health["uptime_ms"].is_u64(), "{health}"); + + gateway.shutdown().await.expect("clean shutdown"); +} + +#[tokio::test] +async fn the_openapi_document_is_served_and_generated_from_the_router() { + let (gateway, api) = gateway().await; + + let document = api.get_ok("/v1/openapi.json").await; + assert_eq!(document["openapi"], "3.1.0"); + assert_eq!(document["info"]["license"]["name"], "Apache-2.0"); + assert!(document["paths"]["/health"]["get"].is_object()); + assert!(document["components"]["schemas"]["ErrorEnvelope"].is_object()); + + gateway.shutdown().await.expect("clean shutdown"); +} + +#[tokio::test] +async fn an_unknown_route_returns_the_shared_error_envelope() { + let (gateway, api) = gateway().await; + + let response = api.get("/v1/nope").await; + assert_eq!(response.status(), 404); + assert!(response.headers().contains_key("x-request-id")); + let body: serde_json::Value = response.json().await.expect("JSON body"); + assert_eq!(body["error"]["code"], "not_found"); + assert_eq!(body["error"]["retryable"], false); + assert!(body["error"]["request_id"].as_str().is_some()); + + gateway.shutdown().await.expect("clean shutdown"); +} + +/// A request whose declared body exceeds the configured limit answers 413 with the shared envelope. +/// +/// Sent as a raw HTTP request that never writes the body: the gateway answers 413 (never 429) +/// before any payload exists, and reading instead of writing avoids racing the early close. +#[tokio::test] +async fn an_oversized_body_is_rejected_with_413_and_never_429() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let (gateway, _api) = gateway().await; + let address = gateway.local_addr(); + + let mut stream = tokio::net::TcpStream::connect(address) + .await + .expect("connect"); + let request = format!( + "POST /v1/openapi.json HTTP/1.1\r\n\ + Host: {address}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + \r\n", + 64 * 1024 * 1024 + ); + stream + .write_all(request.as_bytes()) + .await + .expect("send headers"); + + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("read response"); + let response = String::from_utf8_lossy(&response); + assert!( + response.starts_with("HTTP/1.1 413"), + "expected 413, got: {response}" + ); + assert!(response.contains("limit_exceeded"), "got: {response}"); + + gateway.shutdown().await.expect("clean shutdown"); +} + +#[tokio::test] +async fn draining_rejects_guarded_routes_but_keeps_health_answering() { + let gateway = support::start_gateway().await; + let api = Api::new(format!("http://{}", gateway.local_addr())); + gateway.begin_shutdown(); + assert_eq!(api.get("/health").await.status(), 200); + assert_eq!(api.get("/v1/openapi.json").await.status(), 503); +} diff --git a/fluss-gateway/tests/process.rs b/fluss-gateway/tests/process.rs new file mode 100644 index 00000000000..0fb3d6694c1 --- /dev/null +++ b/fluss-gateway/tests/process.rs @@ -0,0 +1,132 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! End-to-end checks of the compiled binary: startup, health, SIGTERM draining, and exit codes. +//! +//! These spawn the real `fluss-gateway` executable (CARGO_BIN_EXE), so they exercise CLI parsing, +//! config loading, logging setup, and the production lifecycle exactly as an operator would. + +use std::io::Write; +use std::process::{Child, Command}; +use std::time::{Duration, Instant}; + +fn binary() -> Command { + Command::new(env!("CARGO_BIN_EXE_fluss-gateway")) +} + +/// Polls `url` until it answers 200 or the deadline passes. +fn await_http_ok(url: &str, deadline: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < deadline { + if let Ok(response) = reqwest::blocking::get(url) + && response.status() == 200 + { + return true; + } + std::thread::sleep(Duration::from_millis(50)); + } + false +} + +fn write_config(dir: &tempfile::TempDir, port: u16) -> std::path::PathBuf { + let path = dir.path().join("gateway.yaml"); + let mut file = std::fs::File::create(&path).expect("config file"); + writeln!(file, "gateway.rest.listen: 127.0.0.1:{port}").expect("write"); + writeln!(file, "gateway.metrics.enabled: false").expect("write"); + path +} + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind") + .local_addr() + .expect("addr") + .port() +} + +fn send_sigterm(child: &Child) { + // SAFETY: kill(2) with a live child pid owned by this test. + unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }; +} + +/// Kills the child on drop so a failing assertion never leaks a running gateway +/// that could hold its port into later tests. +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[test] +fn an_invalid_configuration_fails_before_binding_with_exit_code_2() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("gateway.yaml"); + std::fs::write(&path, "gateway.unknown.key: true\n").expect("write"); + let output = binary().arg("--config").arg(&path).output().expect("run"); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("gateway.unknown.key"), + "stderr names the offending key: {stderr}" + ); +} + +#[test] +fn the_binary_starts_serves_health_and_drains_on_sigterm_with_exit_code_0() { + let dir = tempfile::tempdir().expect("tempdir"); + let port = free_port(); + let config = write_config(&dir, port); + // The gateway inherits the test's stdout/stderr: piping without draining could fill the pipe + // buffer and stall the child, and its few startup/drain log lines are useful on failure. + let child = binary() + .arg("--config") + .arg(&config) + .spawn() + .expect("spawn"); + let mut guard = ChildGuard(child); + let base = format!("http://127.0.0.1:{port}"); + assert!( + await_http_ok(&format!("{base}/health"), Duration::from_secs(15)), + "health" + ); + send_sigterm(&guard.0); + let start = Instant::now(); + let status = loop { + if let Some(status) = guard.0.try_wait().expect("wait") { + break status; + } + assert!( + start.elapsed() < Duration::from_secs(35), + "SIGTERM drain finished in time" + ); + std::thread::sleep(Duration::from_millis(100)); + }; + assert_eq!(status.code(), Some(0), "clean drain exits 0"); +} + +#[test] +fn a_bind_conflict_fails_serving_with_exit_code_1() { + let holder = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = holder.local_addr().expect("addr").port(); + let dir = tempfile::tempdir().expect("tempdir"); + let config = write_config(&dir, port); + let output = binary().arg("--config").arg(&config).output().expect("run"); + assert_eq!(output.status.code(), Some(1)); +} diff --git a/fluss-gateway/tests/support/mod.rs b/fluss-gateway/tests/support/mod.rs new file mode 100644 index 00000000000..110c4311e04 --- /dev/null +++ b/fluss-gateway/tests/support/mod.rs @@ -0,0 +1,77 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! HTTP helpers shared by the gateway test suites. +//! +//! The suites differ only in what serves the requests — an in-process gateway or the compiled binary — +//! so the client side lives here once. + +// Each test binary uses a different subset of these helpers. +#![allow(dead_code)] + +use fluss_gateway::config::GatewayConfig; +use fluss_gateway::lifecycle::RunningGateway; +use serde_json::Value; + +/// A thin REST client bound to one gateway base URL. +/// +/// The gateway has no authentication yet, so every request is sent bare. +pub struct Api { + client: reqwest::Client, + base: String, +} + +impl Api { + /// Creates a test client bound to `base_url`. + pub fn new(base_url: impl Into) -> Self { + Self { + client: reqwest::Client::new(), + base: base_url.into(), + } + } + + /// Resolves one absolute request URL against the configured base URL. + pub fn url(&self, path: &str) -> String { + format!("{}{path}", self.base) + } + + /// Sends a GET request and returns the raw response. + pub async fn get(&self, path: &str) -> reqwest::Response { + self.client + .get(self.url(path)) + .send() + .await + .expect("GET request") + } + + /// GET expecting 200, returning the parsed body. + pub async fn get_ok(&self, path: &str) -> Value { + let response = self.get(path).await; + assert_eq!(response.status(), 200, "GET {path}"); + response.json().await.expect("JSON body") + } +} + +/// Starts an in-process gateway over `lifecycle::start` with an ephemeral port and no metrics listener. +pub async fn start_gateway() -> RunningGateway { + let mut config = GatewayConfig::default(); + config.server.rest.bind_address = "127.0.0.1:0".parse().expect("valid"); + config.server.metrics.enabled = false; + fluss_gateway::lifecycle::start(config) + .await + .expect("gateway starts") +} From 1602fdaac73f3e23fcbe30cecc21a0bfd2ab0de2 Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Wed, 12 Aug 2026 20:10:08 +0800 Subject: [PATCH 03/15] [build] Add gateway CI gates: build, lint, OpenAPI drift, no-skip E2E Runs the gateway workspace's build, unit tests, license headers, formatting, clippy and rustdoc, checks the checked-in OpenAPI document for drift and validates it externally, and runs the end-to-end suite in a job that fails rather than skipping silently when a selected scenario cannot start. Co-authored-by: gstamatakis95 <126914070+gstamatakis95@users.noreply.github.com> --- .github/workflows/rust-build-and-test.yml | 81 +++++++++++++++++++ .github/workflows/rust-license-and-format.yml | 49 +++++++++++ 2 files changed, 130 insertions(+) diff --git a/.github/workflows/rust-build-and-test.yml b/.github/workflows/rust-build-and-test.yml index d59fadce592..c25bba723f8 100644 --- a/.github/workflows/rust-build-and-test.yml +++ b/.github/workflows/rust-build-and-test.yml @@ -28,6 +28,7 @@ on: - 'fluss-rust/rust-toolchain.toml' - 'fluss-rust/.cargo/**' - 'fluss-rpc/src/main/proto/**' + - 'fluss-gateway/**' - '.github/workflows/rust-build-and-test.yml' pull_request: branches: @@ -39,6 +40,7 @@ on: - 'fluss-rust/rust-toolchain.toml' - 'fluss-rust/.cargo/**' - 'fluss-rpc/src/main/proto/**' + - 'fluss-gateway/**' - '.github/workflows/rust-build-and-test.yml' workflow_dispatch: @@ -80,3 +82,82 @@ jobs: env: RUST_LOG: DEBUG RUST_BACKTRACE: full + + gateway-build-and-unit-test: + name: "Gateway Build and Tests" + timeout-minutes: 60 + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + # fluss-gateway is its own Cargo workspace, so this job must override the + # workflow-level fluss-rust working directory. Without this it would silently + # build the client workspace and pass without testing the gateway at all. + defaults: + run: + working-directory: fluss-gateway + steps: + - uses: actions/checkout@v6 + + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Rust Cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: fluss-gateway + + - name: Build + run: cargo build --all-targets + + - name: Unit Test + run: cargo test --all-targets + env: + RUST_LOG: DEBUG + RUST_BACKTRACE: full + + - name: OpenAPI drift check + run: | + cargo test --lib protocol::rest::openapi::tests::export_checked_in_document -- --ignored --exact + git diff --exit-code openapi.yaml + + - name: OpenAPI external validation + run: npx --yes @redocly/cli@1 lint openapi.yaml + + gateway-e2e: + name: "Gateway E2E (fixed-version cluster)" + timeout-minutes: 30 + runs-on: ubuntu-latest + defaults: + run: + working-directory: fluss-gateway + steps: + - uses: actions/checkout@v6 + + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Rust Cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: fluss-gateway + + # A selected scenario must exist: an empty test list means the suite was compiled away, + # which is a silent skip and therefore a failure. + - name: Assert the E2E scenarios are selected + run: | + count=$(cargo test --features integration_tests --test e2e_harness -- --list 2>/dev/null | grep -c ': test$') + echo "selected scenarios: $count" + test "$count" -ge 1 + + - name: Run the E2E suite + run: cargo test --features integration_tests --test e2e_harness + env: + RUST_LOG: DEBUG + RUST_BACKTRACE: full diff --git a/.github/workflows/rust-license-and-format.yml b/.github/workflows/rust-license-and-format.yml index 2c2d4f6b413..85650417d57 100644 --- a/.github/workflows/rust-license-and-format.yml +++ b/.github/workflows/rust-license-and-format.yml @@ -31,6 +31,7 @@ on: - 'fluss-rust/rustfmt.toml' - 'fluss-rust/rust-toolchain.toml' - 'fluss-rpc/src/main/proto/**' + - 'fluss-gateway/**' - '.github/workflows/rust-license-and-format.yml' pull_request: branches: @@ -45,6 +46,7 @@ on: - 'fluss-rust/rustfmt.toml' - 'fluss-rust/rust-toolchain.toml' - 'fluss-rpc/src/main/proto/**' + - 'fluss-gateway/**' - '.github/workflows/rust-license-and-format.yml' workflow_dispatch: @@ -96,3 +98,50 @@ jobs: run: cargo doc --workspace --no-deps --exclude fluss_python env: RUSTDOCFLAGS: -D warnings + + gateway-check-license-and-formatting: + name: "Gateway License and Formatting Check" + runs-on: ubuntu-latest + # fluss-gateway is its own Cargo workspace, so this job must override the + # workflow-level fluss-rust working directory. Without this it would silently + # lint the client workspace and pass without checking the gateway at all. + # `uses:` step inputs stay relative to the repository root. + defaults: + run: + working-directory: fluss-gateway + steps: + - uses: actions/checkout@v6 + + - name: Check License Header + uses: apache/skywalking-eyes/header@61275cc80d0798a405cb070f7d3a8aaf7cf2c2c1 # v0.8.0 + with: + config: fluss-gateway/.licenserc.yaml + + - name: Install cargo-deny + uses: taiki-e/install-action@v2 + with: + tool: cargo-deny@0.14.22 + + - name: Check dependency licenses (Apache-compatible) + run: cargo deny check licenses + + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Rust Cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: fluss-gateway + + - name: Format + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Rustdoc + run: cargo doc --no-deps + env: + RUSTDOCFLAGS: -D warnings From c5364f572c3a126682c931c6ee6c4fab6843fff5 Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Wed, 12 Aug 2026 21:09:43 +0800 Subject: [PATCH 04/15] [gateway] Correct the MSRV to 1.88, reject overflowing durations, and rename the harness job Co-authored-by: gstamatakis95 <126914070+gstamatakis95@users.noreply.github.com> --- .github/workflows/rust-build-and-test.yml | 39 ++++++++++++++++++++--- fluss-gateway/Cargo.toml | 2 +- fluss-gateway/src/config.rs | 13 ++++++-- fluss-gateway/src/lifecycle.rs | 6 +++- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/.github/workflows/rust-build-and-test.yml b/.github/workflows/rust-build-and-test.yml index c25bba723f8..18a0783c842 100644 --- a/.github/workflows/rust-build-and-test.yml +++ b/.github/workflows/rust-build-and-test.yml @@ -128,8 +128,39 @@ jobs: - name: OpenAPI external validation run: npx --yes @redocly/cli@1 lint openapi.yaml - gateway-e2e: - name: "Gateway E2E (fixed-version cluster)" + gateway-msrv: + name: "Gateway MSRV (1.88)" + # The rust-toolchain.toml floats to stable, so a plain build never catches use of features newer + # than the declared rust-version. Pin the MSRV explicitly here; `+toolchain` overrides the file. + timeout-minutes: 60 + runs-on: ubuntu-latest + defaults: + run: + working-directory: fluss-gateway + steps: + - uses: actions/checkout@v6 + + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install the MSRV toolchain + run: rustup toolchain install 1.88.0 --profile minimal + + - name: Rust Cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: fluss-gateway + + - name: Check on the declared MSRV + run: cargo +1.88.0 check --all-targets + + gateway-harness-self-test: + name: "Gateway harness self-test (fixed-version cluster)" + # This proves the fixed-version Fluss fixture starts and is reachable, the shared harness later + # capabilities reuse. It does not start the Gateway or drive Fluss through it: the Gateway-to-Fluss + # integration is covered by the authentication/backend issue that first connects to Fluss. timeout-minutes: 30 runs-on: ubuntu-latest defaults: @@ -150,13 +181,13 @@ jobs: # A selected scenario must exist: an empty test list means the suite was compiled away, # which is a silent skip and therefore a failure. - - name: Assert the E2E scenarios are selected + - name: Assert the harness scenarios are selected run: | count=$(cargo test --features integration_tests --test e2e_harness -- --list 2>/dev/null | grep -c ': test$') echo "selected scenarios: $count" test "$count" -ge 1 - - name: Run the E2E suite + - name: Run the harness self-test run: cargo test --features integration_tests --test e2e_harness env: RUST_LOG: DEBUG diff --git a/fluss-gateway/Cargo.toml b/fluss-gateway/Cargo.toml index c1cd810c2d6..43d68039739 100644 --- a/fluss-gateway/Cargo.toml +++ b/fluss-gateway/Cargo.toml @@ -27,7 +27,7 @@ name = "fluss-gateway" edition = "2024" version = "1.0.0" license = "Apache-2.0" -rust-version = "1.85" +rust-version = "1.88" authors = ["Apache Fluss "] repository = "https://github.com/apache/fluss" publish = false diff --git a/fluss-gateway/src/config.rs b/fluss-gateway/src/config.rs index fa1f920909a..6cd9473f97b 100644 --- a/fluss-gateway/src/config.rs +++ b/fluss-gateway/src/config.rs @@ -89,11 +89,12 @@ impl ConfigDuration { let value: u64 = digits .parse() .map_err(|e| format!("invalid duration {s:?}: {e}"))?; + let overflow = || format!("invalid duration {s:?}: value is too large"); let duration = match unit { "ms" => Duration::from_millis(value), "s" => Duration::from_secs(value), - "m" => Duration::from_secs(value.saturating_mul(60)), - "h" => Duration::from_secs(value.saturating_mul(3600)), + "m" => Duration::from_secs(value.checked_mul(60).ok_or_else(overflow)?), + "h" => Duration::from_secs(value.checked_mul(3600).ok_or_else(overflow)?), _ => { return Err(format!( "invalid duration {s:?}: unit must be one of ms, s, m, h" @@ -887,6 +888,14 @@ gateway.metrics.enabled: true } } + #[test] + fn overflowing_duration_is_rejected_rather_than_saturated() { + // A syntactically valid but astronomically large duration must be refused at parse time, + // not silently clamped, so it can never reach an `Instant + Duration` overflow at runtime. + let error = ConfigDuration::parse("18446744073709551615h").unwrap_err(); + assert!(error.contains("too large"), "got: {error}"); + } + #[test] fn invalid_byte_size_rejected() { for bad in ["\"4Mb\"", "\"MiB\"", "-1", "\"1.5MiB\""] { diff --git a/fluss-gateway/src/lifecycle.rs b/fluss-gateway/src/lifecycle.rs index d63c9270f2b..7a2de6bc38e 100644 --- a/fluss-gateway/src/lifecycle.rs +++ b/fluss-gateway/src/lifecycle.rs @@ -160,7 +160,11 @@ impl RunningGateway { /// Splits one process deadline into request draining and a bounded resource-cleanup tail. fn shutdown_deadlines(started: Instant, timeout: Duration) -> (Instant, Instant) { - let deadline = started + timeout; + // Configuration rejects overflowing durations, so this is defence in depth: an instant that + // cannot represent `started + timeout` falls back to a one-hour drain rather than panicking. + let deadline = started + .checked_add(timeout) + .unwrap_or_else(|| started + Duration::from_secs(3600)); let minimum_reserve = Duration::from_millis(1).min(timeout); let cleanup_reserve = (timeout / 4) .max(minimum_reserve) From 67f73f8dc7fe06cfbfb2df6397185e9c7f879874 Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Wed, 12 Aug 2026 22:45:51 +0800 Subject: [PATCH 05/15] [gateway] Note the follow-ups for source-tree integration testing and artifact release --- fluss-gateway/Cargo.toml | 4 ++++ fluss-gateway/tests/e2e_harness.rs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/fluss-gateway/Cargo.toml b/fluss-gateway/Cargo.toml index 43d68039739..96c9636b1a6 100644 --- a/fluss-gateway/Cargo.toml +++ b/fluss-gateway/Cargo.toml @@ -30,6 +30,10 @@ license = "Apache-2.0" rust-version = "1.88" authors = ["Apache Fluss "] repository = "https://github.com/apache/fluss" +# The gateway ships as an executable, not a library on crates.io, so `rust-release.yml` does not +# apply to it. +# TODO: add a release path for the gateway's own artifacts (Linux binary, container image) and the +# dependency inventory that goes with them; no workflow publishes them today. publish = false description = "Stateless REST gateway for Apache Fluss" diff --git a/fluss-gateway/tests/e2e_harness.rs b/fluss-gateway/tests/e2e_harness.rs index a603d639e1e..e69cbf50ca3 100644 --- a/fluss-gateway/tests/e2e_harness.rs +++ b/fluss-gateway/tests/e2e_harness.rs @@ -24,6 +24,10 @@ //! Gated behind `integration_tests` because it needs Docker. The CI gate runs it with //! `cargo test --features integration_tests --test e2e_harness` and fails loudly when the fixture //! cannot start — a selected scenario must never skip silently. +//! +//! TODO: once the gateway connects to Fluss, move its integration suites onto the server image that +//! `client-integration.yml` builds from the current source tree, so they verify the gateway against +//! this revision of the server rather than only against a released fixture image. use fluss_test_cluster::FlussTestingClusterBuilder; use std::net::TcpStream; From 6635862ed21f5b2875ffdf270b7d1a085c552f93 Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Thu, 13 Aug 2026 10:43:34 +0800 Subject: [PATCH 06/15] [gateway] Bound every configured duration and guard the request deadlines against overflow --- fluss-gateway/src/config.rs | 46 +++++++++++++++++++++----- fluss-gateway/src/protocol/rest/mod.rs | 21 ++++++++++-- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/fluss-gateway/src/config.rs b/fluss-gateway/src/config.rs index 6cd9473f97b..a9a6a4925d7 100644 --- a/fluss-gateway/src/config.rs +++ b/fluss-gateway/src/config.rs @@ -58,10 +58,16 @@ use toml::Value; pub const ENV_PREFIX: &str = "FLUSS_GATEWAY__"; /// A strictly parsed duration: `` (e.g. `"60s"`, `"15m"`). No floats, no whitespace, no -/// compound values. Deserialization rejects zero because every configured duration is a deadline or an interval. +/// compound values. Deserialization rejects zero because every configured duration is a deadline or an interval, +/// and rejects anything above [`MAX_CONFIG_DURATION`] because such a value is a configuration mistake that would +/// otherwise overflow the instant arithmetic every deadline performs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ConfigDuration(Duration); +/// The upper bound of any configured duration: one year, far beyond any meaningful gateway deadline or +/// interval, and small enough that adding it to an [`std::time::Instant`] can never overflow. +pub const MAX_CONFIG_DURATION: Duration = Duration::from_secs(365 * 24 * 60 * 60); + impl ConfigDuration { /// Builds a duration directly, bypassing the string syntax used by configuration sources. pub const fn from_secs(secs: u64) -> Self { @@ -78,7 +84,7 @@ impl ConfigDuration { self.0 } - /// Parses the strict integer-plus-unit syntax and rejects a zero result. + /// Parses the strict integer-plus-unit syntax and rejects a zero or out-of-range result. pub(crate) fn parse(s: &str) -> Result { let (digits, unit) = split_number_and_unit(s); if digits.is_empty() { @@ -89,12 +95,17 @@ impl ConfigDuration { let value: u64 = digits .parse() .map_err(|e| format!("invalid duration {s:?}: {e}"))?; - let overflow = || format!("invalid duration {s:?}: value is too large"); + let too_large = || { + format!( + "invalid duration {s:?}: must not exceed {} seconds", + MAX_CONFIG_DURATION.as_secs() + ) + }; let duration = match unit { "ms" => Duration::from_millis(value), "s" => Duration::from_secs(value), - "m" => Duration::from_secs(value.checked_mul(60).ok_or_else(overflow)?), - "h" => Duration::from_secs(value.checked_mul(3600).ok_or_else(overflow)?), + "m" => Duration::from_secs(value.checked_mul(60).ok_or_else(too_large)?), + "h" => Duration::from_secs(value.checked_mul(3600).ok_or_else(too_large)?), _ => { return Err(format!( "invalid duration {s:?}: unit must be one of ms, s, m, h" @@ -104,6 +115,9 @@ impl ConfigDuration { if duration.is_zero() { return Err(format!("invalid duration {s:?}: must be greater than zero")); } + if duration > MAX_CONFIG_DURATION { + return Err(too_large()); + } Ok(Self(duration)) } } @@ -890,10 +904,24 @@ gateway.metrics.enabled: true #[test] fn overflowing_duration_is_rejected_rather_than_saturated() { - // A syntactically valid but astronomically large duration must be refused at parse time, - // not silently clamped, so it can never reach an `Instant + Duration` overflow at runtime. - let error = ConfigDuration::parse("18446744073709551615h").unwrap_err(); - assert!(error.contains("too large"), "got: {error}"); + // Syntactically valid but astronomically large durations must be refused at parse time, not + // silently clamped, so they can never reach an `Instant + Duration` overflow at runtime. Every + // unit needs its own case: the unmultiplied ones overflow without any arithmetic at all. + for bad in [ + "18446744073709551615ms", + "18446744073709551615s", + "18446744073709551615m", + "18446744073709551615h", + ] { + let error = ConfigDuration::parse(bad).unwrap_err(); + assert!(error.contains("must not exceed"), "{bad}: {error}"); + } + // The bound itself is accepted, one second past it is not. + assert_eq!( + ConfigDuration::parse("31536000s").unwrap().get(), + MAX_CONFIG_DURATION + ); + assert!(ConfigDuration::parse("31536001s").is_err()); } #[test] diff --git a/fluss-gateway/src/protocol/rest/mod.rs b/fluss-gateway/src/protocol/rest/mod.rs index a24cc4e6a99..c3eb4ac7f0d 100644 --- a/fluss-gateway/src/protocol/rest/mod.rs +++ b/fluss-gateway/src/protocol/rest/mod.rs @@ -44,6 +44,11 @@ use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use utoipa_axum::router::OpenApiRouter; +/// The deadline handed to a request whose configured timeout cannot be added to the current instant. +/// +/// Only reachable if a duration slips past configuration validation; one hour keeps such a request bounded. +const MAX_REQUEST_DEADLINE: Duration = Duration::from_secs(3600); + /// Shared state for REST handlers. /// /// Everything here is either immutable configuration or a shared process service. Nothing is scoped to a @@ -270,6 +275,18 @@ pub fn apply_middleware(router: Router, options: &RestOptions) -> Router { apply_common_middleware(apply_data_limits(router, options)) } +/// The absolute deadline of a request starting now, saturating instead of overflowing. +/// +/// Configuration caps every duration well below the instant-arithmetic limit, so this is defence in depth: +/// a request must never panic a worker thread over a deadline it cannot represent. +fn deadline_from_now(request_timeout: Duration) -> RequestDeadline { + let now = Instant::now(); + RequestDeadline( + now.checked_add(request_timeout) + .unwrap_or_else(|| now + MAX_REQUEST_DEADLINE), + ) +} + /// Records the absolute deadline of a request that does not pass through the data-limit layer. fn assign_request_deadline( request_timeout: Duration, @@ -278,7 +295,7 @@ fn assign_request_deadline( Box::pin(async move { request .extensions_mut() - .insert(RequestDeadline(Instant::now() + request_timeout)); + .insert(deadline_from_now(request_timeout)); next.run(request).await }) } @@ -296,7 +313,7 @@ fn apply_data_limits(router: Router, options: &RestOptions) -> Router { .unwrap_or_default(); request .extensions_mut() - .insert(RequestDeadline(Instant::now() + request_timeout)); + .insert(deadline_from_now(request_timeout)); let oversized = declared_content_length(&request).filter(|length| *length > max_body_bytes); if let Some(length) = oversized { From 403ceb45ba43900de530bdb792e0cbe6e4c5e052 Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Thu, 13 Aug 2026 22:50:20 +0800 Subject: [PATCH 07/15] [gateway] Unify the error contract and harden the runtime foundation --- .github/workflows/rust-build-and-test.yml | 26 +- .github/workflows/rust-license-and-format.yml | 2 + fluss-gateway/Cargo.lock | 129 +-- fluss-gateway/Cargo.toml | 43 +- fluss-gateway/justfile | 6 +- fluss-gateway/openapi.yaml | 100 ++- fluss-gateway/src/config.rs | 802 ++++++++++-------- fluss-gateway/src/error.rs | 720 +++++++--------- fluss-gateway/src/lib.rs | 7 +- fluss-gateway/src/lifecycle.rs | 306 ++++--- fluss-gateway/src/observability.rs | 212 +++-- fluss-gateway/src/protocol/rest/health.rs | 22 +- fluss-gateway/src/protocol/rest/mod.rs | 136 ++- fluss-gateway/src/protocol/rest/openapi.rs | 200 ++--- fluss-gateway/tests/e2e_cluster.rs | 99 +++ fluss-gateway/tests/e2e_harness.rs | 53 -- fluss-gateway/tests/http_api.rs | 36 +- fluss-gateway/tests/process.rs | 88 +- fluss-gateway/tests/support/mod.rs | 98 ++- 19 files changed, 1557 insertions(+), 1528 deletions(-) create mode 100644 fluss-gateway/tests/e2e_cluster.rs delete mode 100644 fluss-gateway/tests/e2e_harness.rs diff --git a/.github/workflows/rust-build-and-test.yml b/.github/workflows/rust-build-and-test.yml index 18a0783c842..92d66861c3b 100644 --- a/.github/workflows/rust-build-and-test.yml +++ b/.github/workflows/rust-build-and-test.yml @@ -101,6 +101,8 @@ jobs: steps: - uses: actions/checkout@v6 + # The dockerized cluster fixture is an unconditional dev-dependency, so it is part of the + # `--all-targets` graph and its proto codegen runs even when the e2e suites are compiled away. - name: Install protoc uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 with: @@ -156,11 +158,13 @@ jobs: - name: Check on the declared MSRV run: cargo +1.88.0 check --all-targets - gateway-harness-self-test: - name: "Gateway harness self-test (fixed-version cluster)" - # This proves the fixed-version Fluss fixture starts and is reachable, the shared harness later - # capabilities reuse. It does not start the Gateway or drive Fluss through it: the Gateway-to-Fluss - # integration is covered by the authentication/backend issue that first connects to Fluss. + gateway-e2e-cluster: + name: "Gateway E2E (dockerized Fluss cluster)" + # Not a pull-request gate yet: the gateway does not talk to Fluss until the FlussBackend capability + # lands, so the suite would spend minutes pulling a Docker image to check the startup and draining the + # process suite already covers without one. Run it from the Actions tab meanwhile; to make it a + # required gate later, delete the `if:` below. + if: github.event_name == 'workflow_dispatch' timeout-minutes: 30 runs-on: ubuntu-latest defaults: @@ -179,16 +183,16 @@ jobs: with: workspaces: fluss-gateway - # A selected scenario must exist: an empty test list means the suite was compiled away, - # which is a silent skip and therefore a failure. - - name: Assert the harness scenarios are selected + # A selected scenario must exist: an empty test list means the suite was compiled away, which is a + # silent skip and therefore a failure. + - name: Assert the cluster scenarios are selected run: | - count=$(cargo test --features integration_tests --test e2e_harness -- --list 2>/dev/null | grep -c ': test$') + count=$(cargo test --features integration_tests --test e2e_cluster -- --list 2>/dev/null | grep -c ': test$') echo "selected scenarios: $count" test "$count" -ge 1 - - name: Run the harness self-test - run: cargo test --features integration_tests --test e2e_harness + - name: Run the cluster suite + run: cargo test --features integration_tests --test e2e_cluster env: RUST_LOG: DEBUG RUST_BACKTRACE: full diff --git a/.github/workflows/rust-license-and-format.yml b/.github/workflows/rust-license-and-format.yml index 85650417d57..36d8c2b7a76 100644 --- a/.github/workflows/rust-license-and-format.yml +++ b/.github/workflows/rust-license-and-format.yml @@ -125,6 +125,8 @@ jobs: - name: Check dependency licenses (Apache-compatible) run: cargo deny check licenses + # The dockerized cluster fixture is an unconditional dev-dependency, so `--all-targets` builds its + # proto codegen even when the e2e suites are compiled away. - name: Install protoc uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 with: diff --git a/fluss-gateway/Cargo.lock b/fluss-gateway/Cargo.lock index ee5f1b27ded..bd5ce34cac1 100644 --- a/fluss-gateway/Cargo.lock +++ b/fluss-gateway/Cargo.lock @@ -1052,7 +1052,7 @@ dependencies = [ "axum", "clap", "fluss-test-cluster", - "futures", + "futures-util", "http-body-util", "libc", "log", @@ -1061,12 +1061,11 @@ dependencies = [ "reqwest", "serde", "serde_json", - "serde_urlencoded", - "serde_yaml", + "serde_path_to_error", + "serde_yaml_ng", "tempfile", "tokio", "tokio-util", - "toml", "tower", "utoipa", "utoipa-axum", @@ -1425,9 +1424,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1577,13 +1576,12 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", - "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1591,9 +1589,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -1604,9 +1602,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1618,15 +1616,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1638,15 +1636,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", @@ -1676,9 +1674,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -2738,7 +2736,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "futures-channel", "futures-core", "futures-util", "http", @@ -3004,6 +3001,17 @@ dependencies = [ "zmij", ] +[[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_repr" version = "0.1.21" @@ -3015,15 +3023,6 @@ dependencies = [ "syn 3.0.3", ] -[[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_urlencoded" version = "0.7.1" @@ -3070,10 +3069,10 @@ dependencies = [ ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "serde_yaml_ng" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ "indexmap 2.14.0", "itoa", @@ -3574,47 +3573,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[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_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap 2.14.0", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "tonic" version = "0.14.6" @@ -3943,9 +3901,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.4+wasi-0.2.12" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] @@ -4210,20 +4168,11 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "wit-bindgen" -version = "0.57.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" diff --git a/fluss-gateway/Cargo.toml b/fluss-gateway/Cargo.toml index 96c9636b1a6..85308bdc2d8 100644 --- a/fluss-gateway/Cargo.toml +++ b/fluss-gateway/Cargo.toml @@ -15,18 +15,21 @@ # specific language governing permissions and limitations # under the License. -# The gateway is its own Cargo workspace. It depends on the Rust client by -# path but deliberately stays out of the workspace rooted at ../fluss-rust so -# that gateway dependencies never touch that workspace's lock file or its -# generated dependency inventories. +# The gateway is its own Cargo workspace and deliberately stays out of the +# workspace rooted at ../fluss-rust so that gateway dependencies never touch +# that workspace's lock file or its generated dependency inventories. [workspace] -resolver = "2" +resolver = "3" [package] name = "fluss-gateway" edition = "2024" version = "1.0.0" license = "Apache-2.0" +# The shipped dependency set only needs 1.85, the same floor as fluss-rust. The declared MSRV is 1.88 +# because the dockerized cluster fixture used by the `integration_tests` suites depends on +# testcontainers, which requires 1.88; dev-dependencies cannot be made optional, so they are part of the +# `--all-targets` graph. Dropping back to 1.85 means isolating that fixture, not changing the gateway. rust-version = "1.88" authors = ["Apache Fluss "] repository = "https://github.com/apache/fluss" @@ -41,20 +44,21 @@ description = "Stateless REST gateway for Apache Fluss" name = "fluss-gateway" path = "src/main.rs" +# Internal testability boundary for the executable; this is not a published or supported Rust SDK. [lib] name = "fluss_gateway" [features] -# Compiles the end-to-end suites in tests/, which start a dockerized Fluss -# cluster. Mirrors the `integration_tests` feature of the fluss-rs crate and -# is never enabled by default. +# Compiles the end-to-end suites that drive a dockerized Fluss cluster. Mirrors the `integration_tests` +# feature of the fluss-rs crate and the name FIP-49's test plan uses, and is never enabled by default: +# the suites need Docker, so CI runs them on demand rather than on every pull request. integration_tests = [] [dependencies] axum = { version = "0.8", default-features = false, features = ["http1", "matched-path", "tokio"] } clap = { version = "4.5.37", features = ["derive"] } -toml = "0.8" -futures = "0.3" +# Only FutureExt/join_all are used; avoid the larger futures facade crate. +futures-util = "0.3" # Process CPU/RSS/fd sampling for the FIP-49 process_* metric families. libc = "0.2" log = "0.4" @@ -62,22 +66,27 @@ metrics = "0.24" metrics-exporter-prometheus = { version = "0.17", default-features = false } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -# The shared query-string parser of the REST layer (`parse_query`). -serde_urlencoded = "0.7" +# Preserves the nested typed field path after file, env, and CLI values are merged. +serde_path_to_error = "0.1" # The gateway.yaml configuration file is YAML with flat dotted keys (FIP-49). -serde_yaml = "0.9" -tokio = { version = "1.44.2", features = ["full"] } +# serde_yaml_ng is the maintained, API-compatible fork of the archived serde_yaml crate. +serde_yaml_ng = "0.10" +# Runtime capabilities used by the server; test-only I/O/time controls are enabled below. +tokio = { version = "1.44.2", features = ["macros", "net", "rt-multi-thread", "signal", "time"] } tokio-util = { version = "0.7", features = ["rt"] } utoipa = { version = "5", features = ["axum_extras"] } utoipa-axum = "0.2" uuid = { version = "1.10", features = ["v4"] } [dev-dependencies] +# The dockerized fixed-version Fluss cluster of the Rust client, reused as-is so the gateway's end-to-end +# suites and the client's own agree on how a test cluster is started. fluss-test-cluster = { path = "../fluss-rust/crates/fluss-test-cluster" } http-body-util = "0.1" -reqwest = { version = "0.12", default-features = false, features = ["json", "blocking"] } +# No `blocking`: every suite is async, and the blocking client panics when dropped inside a tokio context. +reqwest = { version = "0.12", default-features = false, features = ["json"] } tempfile = "3" -tokio = { version = "1.44.2", features = ["full", "test-util"] } +tokio = { version = "1.44.2", features = ["io-util", "test-util"] } tower = { version = "0.5", features = ["util"] } [[test]] @@ -87,4 +96,4 @@ name = "http_api" name = "process" [[test]] -name = "e2e_harness" +name = "e2e_cluster" diff --git a/fluss-gateway/justfile b/fluss-gateway/justfile index b48c864df79..e4d04e4a6fc 100644 --- a/fluss-gateway/justfile +++ b/fluss-gateway/justfile @@ -30,9 +30,9 @@ build-release: test: cargo test --all-targets -# Run the end-to-end suite against a dockerized Fluss cluster. Requires Docker. -test-e2e: - cargo test --features integration_tests --test e2e_harness +# Run the end-to-end suite against a dockerized Fluss cluster. Needs a running Docker daemon. +e2e: + cargo test --features integration_tests --test e2e_cluster # Format the gateway sources in place. fmt: diff --git a/fluss-gateway/openapi.yaml b/fluss-gateway/openapi.yaml index 0bbbbbad445..156aa5832be 100644 --- a/fluss-gateway/openapi.yaml +++ b/fluss-gateway/openapi.yaml @@ -1,82 +1,58 @@ components: schemas: ErrorBody: - description: Body of the shared error envelope. + description: Body of the REST error envelope. properties: code: $ref: '#/components/schemas/ErrorCode' - details: - oneOf: - - type: 'null' - - $ref: '#/components/schemas/ErrorDetailsSchema' message: type: string request_id: + description: Correlates the response with the `x-request-id` header and the access log. format: uuid type: string - retryable: - description: Whether repeating an otherwise unchanged request may succeed. - type: boolean required: - code - message - request_id - - retryable type: object ErrorCode: - description: |- - Stable error codes of the gateway: the FIP-49 vocabulary, resource-specific where the error - names a resource, exactly as serialized on the wire. + description: Stable error code, resource-specific where the error names a resource. enum: - - invalid_argument - - unauthenticated - - unauthorized - - not_found - - cluster_not_found - - database_not_found - - table_not_found - - partition_not_found - already_exists + - backend + - cancelled - cluster_already_exists + - cluster_not_found - database_already_exists - - table_already_exists - - partition_already_exists - - failed_precondition - database_not_empty - - unsupported - - unsupported_media_type - - not_acceptable + - database_not_found + - failed_precondition + - internal + - invalid_argument - limit_exceeded + - method_not_allowed + - not_acceptable + - not_found + - partition_already_exists + - partition_not_found - resource_exhausted + - table_already_exists + - table_not_found - timeout - - cancelled + - unauthenticated + - unauthorized - unavailable - - backend - - internal - - storage_backpressure + - unsupported + - unsupported_media_type type: string - ErrorDetailsSchema: - description: Machine-readable resource context carried by resource-naming errors. - properties: - resource_kind: - type: - - string - - 'null' - resource_name: - type: - - string - - 'null' - type: object ErrorEnvelope: - description: The envelope every failing response uses. + description: 'REST error envelope: `{"error": {"code", "message", "request_id"}}`.' examples: - error: code: table_not_found - details: - resource_kind: table - message: table does not exist + message: table `mydb.orders` does not exist request_id: 8f6c7f4a-f9b8-4c71-91ec-6e5578d7a913 - retryable: false properties: error: $ref: '#/components/schemas/ErrorBody' @@ -116,6 +92,12 @@ paths: schema: $ref: '#/components/schemas/HealthResponse' description: Gateway liveness and uptime + '405': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Wrong method for this route summary: 'The FIP-49 health summary: `{status, uptime_ms}`, always 200 while the process answers.' tags: - health @@ -125,6 +107,30 @@ paths: responses: '200': description: OpenAPI 3.1 document + '405': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Wrong method for this route + '413': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Request body above the configured limit + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Gateway starting or shutting down + '504': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Request deadline exceeded summary: Serves the generated OpenAPI 3.1 document as JSON. tags: - metadata diff --git a/fluss-gateway/src/config.rs b/fluss-gateway/src/config.rs index a9a6a4925d7..d65bf847d0c 100644 --- a/fluss-gateway/src/config.rs +++ b/fluss-gateway/src/config.rs @@ -15,57 +15,35 @@ // specific language governing permissions and limitations // under the License. -//! Gateway configuration for the REST service. +//! Gateway configuration loaded with precedence CLI > environment > YAML file > defaults. //! -//! One `gateway.yaml` file plus complete env overrides plus targeted CLI overrides. Precedence: -//! CLI > env > file > defaults. Parsing is strict: unknown keys (file or env) are rejected, durations must be -//! ``, byte sizes are plain integers or ``, and both reject zero. -//! -//! # Schema shape -//! -//! The file is YAML whose top level is a mapping of **flat dotted keys**, exactly as documented by FIP-49 -//! §Gateway Configuration and aligned with the Fluss `server.yaml` convention: +//! YAML uses the flat dotted keys documented by FIP-49: //! //! ```yaml //! gateway.rest.listen: 0.0.0.0:8080 //! gateway.rest.write.max-request-bytes: 32MiB //! ``` //! -//! Keys named by the FIP keep their FIP spelling; internal keys the FIP does not cover (shutdown draining) -//! follow the same `gateway..` style. Each flat key is -//! translated to a field of the typed sections below before deserialization, so `deny_unknown_fields` stays -//! meaningful per subsystem and an unrecognised flat key is rejected with the exact name the operator wrote. -//! This supersedes the earlier sectioned TOML schema by explicit user decision: the REST contract and the -//! configuration surface should quote one vocabulary, the FIP's. -//! -//! There is deliberately **no TLS section**: transport -//! security terminates at a fronting proxy. -//! -//! Env override convention (unchanged): `FLUSS_GATEWAY__
__`, with `__` separating path -//! components of the *internal* sections. For example, `FLUSS_GATEWAY__SERVER_REST__BIND_ADDRESS` overrides -//! the REST listener. +//! Environment variable names are derived from these public keys; for example, +//! `gateway.rest.listen` becomes `FLUSS_GATEWAY__REST__LISTEN`. use serde::Deserialize; use serde::de::{self, Deserializer}; +use serde_yaml_ng::{Mapping, Value}; use std::collections::BTreeMap; use std::fmt; -use std::net::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::Path; use std::time::Duration; -use toml::Value; /// Environment variable prefix for overrides. pub const ENV_PREFIX: &str = "FLUSS_GATEWAY__"; -/// A strictly parsed duration: `` (e.g. `"60s"`, `"15m"`). No floats, no whitespace, no -/// compound values. Deserialization rejects zero because every configured duration is a deadline or an interval, -/// and rejects anything above [`MAX_CONFIG_DURATION`] because such a value is a configuration mistake that would -/// otherwise overflow the instant arithmetic every deadline performs. +/// A duration written as ``. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ConfigDuration(Duration); -/// The upper bound of any configured duration: one year, far beyond any meaningful gateway deadline or -/// interval, and small enough that adding it to an [`std::time::Instant`] can never overflow. +/// Maximum configured duration, bounded to keep deadline arithmetic safe. pub const MAX_CONFIG_DURATION: Duration = Duration::from_secs(365 * 24 * 60 * 60); impl ConfigDuration { @@ -129,9 +107,7 @@ impl<'de> Deserialize<'de> for ConfigDuration { } } -/// A strictly parsed byte size: a plain integer, or an integer with one of the suffixes `B`, `KB`, `KiB`, `MB`, -/// `MiB`, `GB`, `GiB` (e.g. `4194304` or `"4MiB"`). Deserialization rejects zero because every configured size is -/// a budget that must admit at least one byte. +/// A positive byte size with an optional decimal or binary unit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ByteSize(u64); @@ -175,13 +151,11 @@ impl ByteSize { Self::checked(bytes).ok_or_else(|| format!("invalid byte size {s:?}: must be non-zero")) } - /// Returns the size unless it is zero. fn checked(bytes: u64) -> Option { (bytes != 0).then_some(Self(bytes)) } } -/// Splits a strictly formatted numeric value from its optional unit suffix. fn split_number_and_unit(value: &str) -> (&str, &str) { let split = value .char_indices() @@ -218,7 +192,59 @@ impl<'de> Deserialize<'de> for ByteSize { } } -/// `[server]` table. +const INSTANCE_ID_KEY: &str = "gateway.instance-id"; +const REST_LISTEN_KEY: &str = "gateway.rest.listen"; +const REST_REQUEST_TIMEOUT_KEY: &str = "gateway.rest.write.request-timeout"; +const REST_MAX_REQUEST_BYTES_KEY: &str = "gateway.rest.write.max-request-bytes"; +const METRICS_ENABLED_KEY: &str = "gateway.metrics.enabled"; +const METRICS_LISTEN_KEY: &str = "gateway.metrics.exporter.prometheus.listen"; +const SHUTDOWN_DRAIN_TIMEOUT_KEY: &str = "gateway.shutdown.drain-timeout"; + +const DEFAULT_REST_LISTEN: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080); +const DEFAULT_REST_REQUEST_TIMEOUT: ConfigDuration = ConfigDuration::from_secs(30); +const DEFAULT_REST_MAX_REQUEST_BYTES: ByteSize = ByteSize::new(32 * 1024 * 1024); +const DEFAULT_METRICS_ENABLED: bool = true; +const DEFAULT_METRICS_LISTEN: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9095); +const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT: ConfigDuration = ConfigDuration::from_secs(30); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ConfigEntry { + key: &'static str, + internal_path: &'static str, +} + +const CONFIG_ENTRIES: &[ConfigEntry] = &[ + ConfigEntry { + key: INSTANCE_ID_KEY, + internal_path: "server.instance_id", + }, + ConfigEntry { + key: REST_LISTEN_KEY, + internal_path: "server.rest.bind_address", + }, + ConfigEntry { + key: REST_REQUEST_TIMEOUT_KEY, + internal_path: "server.rest.request_timeout", + }, + ConfigEntry { + key: REST_MAX_REQUEST_BYTES_KEY, + internal_path: "server.rest.max_body_bytes", + }, + ConfigEntry { + key: METRICS_ENABLED_KEY, + internal_path: "server.metrics.enabled", + }, + ConfigEntry { + key: METRICS_LISTEN_KEY, + internal_path: "server.metrics.bind_address", + }, + ConfigEntry { + key: SHUTDOWN_DRAIN_TIMEOUT_KEY, + internal_path: "shutdown.drain_timeout", + }, +]; + +/// Gateway listeners and instance identity. #[derive(Debug, Clone, PartialEq, Deserialize, Default)] #[serde(deny_unknown_fields, default)] pub struct ServerConfig { @@ -231,7 +257,7 @@ pub struct ServerConfig { pub metrics: MetricsServerConfig, } -/// `[server.rest]`, the REST listener and its input-validation limits. +/// REST listener and request limits. #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct RestServerConfig { @@ -246,14 +272,29 @@ pub struct RestServerConfig { impl Default for RestServerConfig { fn default() -> Self { Self { - bind_address: "127.0.0.1:8080".parse().expect("valid default"), - request_timeout: ConfigDuration::from_secs(30), - max_body_bytes: ByteSize::new(32 * 1024 * 1024), + bind_address: DEFAULT_REST_LISTEN, + request_timeout: DEFAULT_REST_REQUEST_TIMEOUT, + max_body_bytes: DEFAULT_REST_MAX_REQUEST_BYTES, } } } -/// `[server.metrics]`, the internal Prometheus listener. +impl RestServerConfig { + fn validate(&self, problems: &mut Vec) { + validate_duration( + REST_REQUEST_TIMEOUT_KEY, + self.request_timeout.get(), + problems, + ); + if self.max_body_bytes.bytes() == 0 { + problems.push(format!( + "{REST_MAX_REQUEST_BYTES_KEY} must be greater than zero" + )); + } + } +} + +/// Prometheus listener configuration. #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct MetricsServerConfig { @@ -264,13 +305,13 @@ pub struct MetricsServerConfig { impl Default for MetricsServerConfig { fn default() -> Self { Self { - enabled: true, - bind_address: "127.0.0.1:9095".parse().expect("valid default"), + enabled: DEFAULT_METRICS_ENABLED, + bind_address: DEFAULT_METRICS_LISTEN, } } } -/// `[shutdown]`, which configures the graceful-shutdown drain deadline. +/// Graceful-shutdown configuration. #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct ShutdownConfig { @@ -280,11 +321,21 @@ pub struct ShutdownConfig { impl Default for ShutdownConfig { fn default() -> Self { Self { - drain_timeout: ConfigDuration::from_secs(30), + drain_timeout: DEFAULT_SHUTDOWN_DRAIN_TIMEOUT, } } } +impl ShutdownConfig { + fn validate(&self, problems: &mut Vec) { + validate_duration( + SHUTDOWN_DRAIN_TIMEOUT_KEY, + self.drain_timeout.get(), + problems, + ); + } +} + /// The validated gateway configuration: everything the process needs before it binds a listener. #[derive(Debug, Clone, PartialEq, Deserialize, Default)] #[serde(deny_unknown_fields, default)] @@ -294,10 +345,11 @@ pub struct GatewayConfig { } impl GatewayConfig { - /// Checks the invariants that span more than one field. Single-field syntax and non-zero rules are enforced - /// while deserializing. Called by [`load`] and exposed for tests and programmatic construction. + /// Checks invariants, including values supplied programmatically. pub fn validate(&self) -> Result<(), ConfigError> { let mut problems = Vec::new(); + self.server.rest.validate(&mut problems); + self.shutdown.validate(&mut problems); self.validate_identity(&mut problems); if problems.is_empty() { Ok(()) @@ -320,16 +372,22 @@ impl GatewayConfig { .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')); if !valid { - problems.push( - "server.instance_id must be 1-128 ASCII letters, digits, dots, underscores, or hyphens" - .to_string(), - ); + problems.push(format!( + "{} must be 1-128 ASCII letters, digits, dots, underscores, or hyphens", + INSTANCE_ID_KEY + )); } } - if server.metrics.enabled && server.metrics.bind_address == rest_address { - problems.push( - "server.metrics.bind_address must differ from server.rest.bind_address".to_string(), - ); + // Port 0 asks the OS for a free port, so two ephemeral listeners never collide even though the + // configured addresses are equal. + if server.metrics.enabled + && server.metrics.bind_address == rest_address + && rest_address.port() != 0 + { + problems.push(format!( + "{} must differ from {}", + METRICS_LISTEN_KEY, REST_LISTEN_KEY + )); } } @@ -338,19 +396,31 @@ impl GatewayConfig { let mut warnings = Vec::new(); if !self.server.rest.bind_address.ip().is_loopback() { warnings.push(format!( - "server.rest.bind_address {} is not loopback. The REST listener accepts \ + "{} {} is not loopback. The REST listener accepts \ unauthenticated requests and has no TLS", - self.server.rest.bind_address + REST_LISTEN_KEY, self.server.rest.bind_address )); } warnings } } +fn validate_duration(key: &str, duration: Duration, problems: &mut Vec) { + if duration.is_zero() { + problems.push(format!("{key} must be greater than zero")); + } else if duration > MAX_CONFIG_DURATION { + problems.push(format!( + "{} must not exceed {} seconds", + key, + MAX_CONFIG_DURATION.as_secs() + )); + } +} + /// Targeted CLI overrides (highest precedence). #[derive(Debug, Clone, Default)] pub struct CliOverrides { - /// Overrides `server.rest.bind_address`. + /// Overrides `gateway.rest.listen`. pub bind_address: Option, } @@ -385,141 +455,121 @@ impl fmt::Display for ConfigError { impl std::error::Error for ConfigError {} -/// Translates one `FLUSS_GATEWAY__` suffix into a dotted configuration path. Only the section, which is the first -/// segment, may spell a nested table with an underscore, so `SERVER_REST__BIND_ADDRESS` addresses -/// `server.rest.bind_address` while the key keeps its underscores. -fn env_suffix_to_path(suffix: &str) -> String { - let lowered = suffix.to_ascii_lowercase(); - match lowered.split_once("__") { - Some((section, key)) => format!("{}.{}", section.replace('_', "."), key.replace("__", ".")), - None => lowered, - } -} - -/// Reads one override value the way a TOML right-hand side would be read, so an operator can write an array, a -/// quoted string, a number, or a boolean. A bare value that is not valid TOML stays text, except that an unquoted -/// comma makes it a list, which is how a list-valued key is written outside a file. -fn coerce_override(raw: &str) -> Value { - if let Ok(mut table) = format!("x = {raw}").parse::() - && let Some(value) = table.remove("x") - { - return value; - } - if raw.contains(',') { - return Value::Array( - raw.split(',') - .map(|entry| Value::String(entry.trim().to_string())) - .collect(), - ); - } - Value::String(raw.to_string()) -} - -/// Writes `value` at a dotted path, creating the tables along the way and replacing whatever sat there before. -fn insert_path(table: &mut toml::Table, path: &str, value: Value) { +/// Writes `value` at a dotted path, creating mappings along the way and replacing whatever sat there before. +fn insert_path(table: &mut Mapping, path: &str, value: Value) { let mut current = table; let mut segments = path.split('.').peekable(); while let Some(segment) = segments.next() { + let key = Value::String(segment.to_string()); if segments.peek().is_none() { - current.insert(segment.to_string(), value); + current.insert(key, value); return; } let entry = current - .entry(segment.to_string()) - .or_insert_with(|| Value::Table(toml::Table::new())); - if !entry.is_table() { - *entry = Value::Table(toml::Table::new()); + .entry(key) + .or_insert_with(|| Value::Mapping(Mapping::new())); + if !entry.is_mapping() { + *entry = Value::Mapping(Mapping::new()); } - current = entry.as_table_mut().expect("table inserted above"); + current = entry.as_mapping_mut().expect("mapping inserted above"); } } -/// Turns a deserialization failure into an error that names the override responsible for it, if one is. Each -/// override is replayed on its own against the defaults, so only the override that actually carries the offending -/// key is blamed and a bad key in the file is never attributed to an unrelated override. -fn attribute(message: String, overrides: &[(String, String, Value)]) -> ConfigError { - for (path, origin, value) in overrides { - let mut probe = toml::Table::new(); - insert_path(&mut probe, path, value.clone()); - let Err(error) = GatewayConfig::deserialize(Value::Table(probe)) else { - continue; - }; - let reason = error.to_string(); - if reason.contains("unknown field") && origin.starts_with(ENV_PREFIX) { - return ConfigError::UnknownEnvKey(origin.clone()); +/// Attributes a typed error to the override that supplied the failing option. +fn attribute( + message: String, + overrides: &[(&'static str, &'static str, String, Value)], +) -> ConfigError { + for (_, key, origin, _) in overrides.iter().rev() { + if message.starts_with(key) { + return ConfigError::Parse(format!("{origin}: {message}")); } - return ConfigError::Parse(format!("{origin}: {reason}")); } ConfigError::Parse(message) } -/// The flat `gateway.*` file vocabulary, mapped to the dotted paths of the typed sections. FIP-named keys keep -/// their FIP spelling; the remaining internal keys follow the same `gateway..` style. -/// -/// `gateway.rest.write.request-timeout` maps to the shared REST deadline: the gateway runs every request, -/// not only writes, under that server-side budget. -const FLAT_FILE_KEYS: &[(&str, &str)] = &[ - ("gateway.instance-id", "server.instance_id"), - ("gateway.rest.listen", "server.rest.bind_address"), - ( - "gateway.rest.write.request-timeout", - "server.rest.request_timeout", - ), - ( - "gateway.rest.write.max-request-bytes", - "server.rest.max_body_bytes", - ), - ("gateway.metrics.enabled", "server.metrics.enabled"), - ( - "gateway.metrics.exporter.prometheus.listen", - "server.metrics.bind_address", - ), - ("gateway.shutdown.drain-timeout", "shutdown.drain_timeout"), -]; +/// Deserializes the merged YAML value while retaining the nested field path in any error. +fn deserialize_config(value: Value) -> Result { + serde_path_to_error::deserialize(value) + .map_err(|error| ConfigError::Parse(publicize_error_path(error.to_string()))) +} + +/// Rewrites Serde's internal typed path to the stable public option name used by operators. +fn publicize_error_path(message: String) -> String { + for entry in CONFIG_ENTRIES { + if let Some(reason) = message.strip_prefix(entry.internal_path) { + return format!("{}{reason}", entry.key); + } + } + message +} + +fn config_entry(key: &str) -> Option<&'static ConfigEntry> { + CONFIG_ENTRIES.iter().find(|entry| entry.key == key) +} + +fn environment_variable(key: &str) -> String { + let suffix = key + .strip_prefix("gateway.") + .expect("configuration keys use the gateway prefix") + .split('.') + .map(|segment| segment.replace('-', "_").to_ascii_uppercase()) + .collect::>() + .join("__"); + format!("{ENV_PREFIX}{suffix}") +} + +fn environment_entry(variable: &str) -> Option<&'static ConfigEntry> { + CONFIG_ENTRIES + .iter() + .find(|entry| environment_variable(entry.key) == variable) +} -/// Resolves one flat file key against the vocabulary, or rejects it with the exact name the operator wrote. -fn resolve_flat_key(key: &str) -> Result { - if let Some((_, path)) = FLAT_FILE_KEYS.iter().find(|(flat, _)| *flat == key) { - return Ok((*path).to_string()); +fn convert_environment_value(entry: &ConfigEntry, raw: &str) -> Result { + match entry.key { + METRICS_ENABLED_KEY => raw + .parse::() + .map(Value::Bool) + .map_err(|_| "expected true or false".to_string()), + _ => Ok(Value::String(raw.to_string())), } - Err(ConfigError::Parse(format!( - "unknown configuration key: {key}" - ))) } -/// Converts one YAML scalar or sequence into the internal TOML value model. Nested mappings are rejected -/// because the file contract is flat dotted keys. -fn yaml_to_toml(value: &serde_yaml::Value, key: &str) -> Result { +fn convert_file_value(entry: &ConfigEntry, value: &Value) -> Result { + match entry.key { + METRICS_ENABLED_KEY => scalar(value).cloned(), + REST_MAX_REQUEST_BYTES_KEY => match scalar(value)? { + Value::Number(_) | Value::String(_) => Ok(value.clone()), + _ => Err("expected an integer or byte-size string".to_string()), + }, + _ => scalar_text(value).map(Value::String), + } +} + +fn scalar(value: &Value) -> Result<&Value, String> { match value { - serde_yaml::Value::Bool(v) => Ok(Value::Boolean(*v)), - serde_yaml::Value::Number(v) => { - if let Some(int) = v.as_i64() { - Ok(Value::Integer(int)) - } else if let Some(float) = v.as_f64() { - Ok(Value::Float(float)) - } else { - Err(ConfigError::Parse(format!("{key}: unsupported number"))) - } + Value::Bool(_) | Value::Number(_) | Value::String(_) => Ok(value), + Value::Null => Err("value is missing".to_string()), + Value::Sequence(_) | Value::Mapping(_) | Value::Tagged(_) => { + Err("expected a scalar value".to_string()) } - serde_yaml::Value::String(v) => Ok(Value::String(v.clone())), - serde_yaml::Value::Sequence(items) => Ok(Value::Array( - items - .iter() - .map(|item| yaml_to_toml(item, key)) - .collect::>()?, - )), - serde_yaml::Value::Null => Err(ConfigError::Parse(format!("{key}: value is missing"))), - serde_yaml::Value::Mapping(_) | serde_yaml::Value::Tagged(_) => Err(ConfigError::Parse( - format!("{key}: nested values are not allowed, configuration keys are flat"), - )), } } -/// Parses the flat-key YAML file into the internal table model. -fn read_config_file(contents: &str) -> Result { - let document: serde_yaml::Value = - serde_yaml::from_str(contents).map_err(|e| ConfigError::Parse(e.to_string()))?; - let mut table = toml::Table::new(); +fn scalar_text(value: &Value) -> Result { + match scalar(value)? { + Value::Bool(value) => Ok(value.to_string()), + Value::Number(value) => Ok(value.to_string()), + Value::String(value) => Ok(value.clone()), + _ => unreachable!("scalar rejects compound values"), + } +} + +/// Parses the flat-key YAML file into the nested mapping deserialized by [`GatewayConfig`]. +fn read_config_file(contents: &str) -> Result { + let document: Value = + serde_yaml_ng::from_str(contents).map_err(|e| ConfigError::Parse(e.to_string()))?; + let mut table = Mapping::new(); if document.is_null() { return Ok(table); } @@ -533,22 +583,24 @@ fn read_config_file(contents: &str) -> Result { let key = key .as_str() .ok_or_else(|| ConfigError::Parse("configuration keys must be strings".to_string()))?; - let path = resolve_flat_key(key)?; - insert_path(&mut table, &path, yaml_to_toml(value, key)?); + let entry = config_entry(key) + .ok_or_else(|| ConfigError::Parse(format!("unknown configuration key: {key}")))?; + let value = convert_file_value(entry, value) + .map_err(|reason| ConfigError::Parse(format!("{key}: {reason}")))?; + insert_path(&mut table, entry.internal_path, value); } Ok(table) } /// Loads configuration from all sources with precedence CLI > env > file > defaults. /// -/// `env` is passed explicitly (rather than read from the process environment) so loading is deterministic and -/// testable. +/// `env` is explicit so loading remains deterministic and testable. pub fn load( path: Option<&Path>, env: &BTreeMap, cli: &CliOverrides, ) -> Result { - let mut table = toml::Table::new(); + let mut table = Mapping::new(); if let Some(path) = path { let contents = std::fs::read_to_string(path) .map_err(|e| ConfigError::Io(format!("{}: {e}", path.display())))?; @@ -556,41 +608,42 @@ pub fn load( } // Each override is kept with the source that wrote it, so a failure names what the operator wrote. - let mut overrides: Vec<(String, String, Value)> = Vec::new(); + let mut overrides: Vec<(&'static str, &'static str, String, Value)> = Vec::new(); for (key, raw) in env { - let Some(suffix) = key.strip_prefix(ENV_PREFIX) else { + if !key.starts_with(ENV_PREFIX) { continue; - }; - if suffix.is_empty() { - return Err(ConfigError::UnknownEnvKey(key.clone())); } + let entry = + environment_entry(key).ok_or_else(|| ConfigError::UnknownEnvKey(key.clone()))?; overrides.push(( - env_suffix_to_path(suffix), + entry.internal_path, + entry.key, key.clone(), - coerce_override(raw), + convert_environment_value(entry, raw) + .map_err(|reason| ConfigError::Parse(format!("{key}: {}: {reason}", entry.key)))?, )); } - for (path, flag, value) in [( - "server.rest.bind_address", - "--bind-address", - cli.bind_address.as_ref(), - )] { - if let Some(value) = value { - overrides.push(( - path.to_string(), - flag.to_string(), - Value::String(value.clone()), - )); - } + if let Some(value) = &cli.bind_address { + let entry = config_entry(REST_LISTEN_KEY).expect("REST listen option is registered"); + overrides.push(( + entry.internal_path, + entry.key, + "--bind-address".to_string(), + Value::String(value.clone()), + )); } - for (path, _, value) in &overrides { + for (path, _, _, value) in &overrides { insert_path(&mut table, path, value.clone()); } - let config = GatewayConfig::deserialize(Value::Table(table)) - .map_err(|error| attribute(error.to_string(), &overrides))?; + let config = deserialize_config(Value::Mapping(table)).map_err(|error| { + let ConfigError::Parse(message) = error else { + unreachable!("deserialization only creates parse errors") + }; + attribute(message, &overrides) + })?; config.validate()?; Ok(config) @@ -635,45 +688,30 @@ mod tests { config.server.rest.request_timeout.get(), Duration::from_secs(30) ); - assert_eq!(config.shutdown.drain_timeout.get(), Duration::from_secs(30)); - assert!(config.warnings().is_empty()); - } - - #[test] - fn file_overrides_defaults() { - let config = load_file( - r#" -gateway.rest.listen: 127.0.0.1:18080 -gateway.rest.write.request-timeout: 5s -gateway.rest.write.max-request-bytes: 2MiB -"#, - ) - .unwrap(); - assert_eq!( - config.server.rest.bind_address, - "127.0.0.1:18080".parse().unwrap() - ); + assert!(config.server.metrics.enabled); assert_eq!( - config.server.rest.request_timeout.get(), - Duration::from_secs(5) + config.server.metrics.bind_address, + "127.0.0.1:9095".parse().unwrap() ); - assert_eq!(config.server.rest.max_body_bytes.bytes(), 2 * 1024 * 1024); + assert_eq!(config.shutdown.drain_timeout.get(), Duration::from_secs(30)); + assert!(config.warnings().is_empty()); } - /// The configuration surface documented by FIP-49 §Gateway Configuration, restricted to the keys the - /// gateway implements today, parses as one flat dotted-key YAML document. #[test] - fn fip_yaml_example_parses_with_flat_dotted_keys() { + fn public_yaml_options_are_loaded() { let config = load_file( r#" -gateway.rest.listen: 0.0.0.0:8080 -gateway.rest.write.max-request-bytes: 32MiB -gateway.rest.write.request-timeout: 30s -gateway.metrics.enabled: true -gateway.metrics.exporter.prometheus.listen: 0.0.0.0:9095 -"#, + gateway.instance-id: gateway-1 + gateway.rest.listen: 0.0.0.0:8080 + gateway.rest.write.max-request-bytes: 32MiB + gateway.rest.write.request-timeout: 30s + gateway.metrics.enabled: true + gateway.metrics.exporter.prometheus.listen: 0.0.0.0:9095 + gateway.shutdown.drain-timeout: 10s + "#, ) .unwrap(); + assert_eq!(config.server.instance_id.as_deref(), Some("gateway-1")); assert_eq!( config.server.rest.bind_address, "0.0.0.0:8080".parse().unwrap() @@ -688,16 +726,17 @@ gateway.metrics.exporter.prometheus.listen: 0.0.0.0:9095 config.server.metrics.bind_address, "0.0.0.0:9095".parse().unwrap() ); + assert_eq!(config.shutdown.drain_timeout.get(), Duration::from_secs(10)); } - /// A key outside the documented vocabulary is rejected with the exact flat name the operator wrote, - /// not a translated internal path. #[test] - fn unknown_flat_key_is_rejected_with_its_original_name() { + fn unknown_file_keys_name_the_original_key() { for contents in [ "gateway.rest.listenn: 0.0.0.0:8080\n", "rest.listen: 0.0.0.0:8080\n", "gateway.rest.lookup.max-keyz: 5\n", + "gateway.scan.cursor-ttl: 1m\n", + "gateway.tls.cert: /etc/tls.pem\n", ] { let error = load_file(contents).unwrap_err(); assert!(matches!(error, ConfigError::Parse(_)), "got: {error:?}"); @@ -707,48 +746,37 @@ gateway.metrics.exporter.prometheus.listen: 0.0.0.0:9095 } #[test] - fn env_overrides_file() { + fn source_precedence_is_cli_then_env_then_file_then_defaults() { let file = write_temp_config( r#" -gateway.rest.listen: 127.0.0.1:18080 -gateway.metrics.enabled: true -"#, + gateway.rest.listen: 127.0.0.1:18080 + gateway.metrics.enabled: true + "#, ); let mut env = no_env(); env.insert( - "FLUSS_GATEWAY__SERVER_REST__BIND_ADDRESS".to_string(), + "FLUSS_GATEWAY__REST__LISTEN".to_string(), "127.0.0.1:28080".to_string(), ); env.insert( - "FLUSS_GATEWAY__SERVER_METRICS__ENABLED".to_string(), + "FLUSS_GATEWAY__METRICS__ENABLED".to_string(), "false".to_string(), ); env.insert("PATH".to_string(), "/usr/bin".to_string()); - let config = load(Some(file.path()), &env, &CliOverrides::default()).unwrap(); - assert_eq!( - config.server.rest.bind_address, - "127.0.0.1:28080".parse().unwrap() - ); - assert!(!config.server.metrics.enabled); - } - - #[test] - fn cli_overrides_env_and_file() { - let file = write_temp_config("gateway.rest.listen: 127.0.0.1:18080\n"); - let mut env = no_env(); - env.insert( - "FLUSS_GATEWAY__SERVER_REST__BIND_ADDRESS".to_string(), - "127.0.0.1:28080".to_string(), - ); - let cli = CliOverrides { - bind_address: Some("127.0.0.1:38080".to_string()), - }; - let config = load(Some(file.path()), &env, &cli).unwrap(); + let config = load( + Some(file.path()), + &env, + &CliOverrides { + bind_address: Some("127.0.0.1:38080".to_string()), + }, + ) + .unwrap(); assert_eq!( config.server.rest.bind_address, "127.0.0.1:38080".parse().unwrap() ); + assert!(!config.server.metrics.enabled); } #[test] @@ -762,16 +790,6 @@ gateway.metrics.enabled: true assert!(matches!(error, ConfigError::Io(_)), "got: {error:?}"); } - #[test] - fn unknown_file_field_rejected() { - let error = load_file("gateway.rest.listenn: 127.0.0.1:8080\n").unwrap_err(); - assert!(matches!(error, ConfigError::Parse(_)), "got: {error:?}"); - assert!( - error.to_string().contains("gateway.rest.listenn"), - "got: {error}" - ); - } - #[test] fn malformed_file_reports_position() { let error = load_file("gateway.rest.listen: [1\n").unwrap_err(); @@ -789,38 +807,21 @@ gateway.metrics.enabled: true } #[test] - fn unknown_section_rejected() { - let error = load_file("gateway.query.max-concurrent: 32\n").unwrap_err(); - assert!(matches!(error, ConfigError::Parse(_)), "got: {error:?}"); - assert!(error.to_string().contains("query"), "got: {error}"); - } - - #[test] - fn unknown_env_key_rejected() { - let mut env = no_env(); - env.insert( - "FLUSS_GATEWAY__SERVER_REST__BIND_ADDRES".to_string(), - "127.0.0.1:8080".to_string(), - ); - let error = load(None, &env, &CliOverrides::default()).unwrap_err(); - let ConfigError::UnknownEnvKey(key) = &error else { - panic!("expected UnknownEnvKey, got: {error:?}"); - }; - assert_eq!(key, "FLUSS_GATEWAY__SERVER_REST__BIND_ADDRES"); - } - - #[test] - fn unknown_env_section_rejected() { - let mut env = no_env(); - env.insert( - "FLUSS_GATEWAY__QUERY__ENABLED".to_string(), - "true".to_string(), - ); - let error = load(None, &env, &CliOverrides::default()).unwrap_err(); - assert!( - matches!(error, ConfigError::UnknownEnvKey(_)), - "got: {error:?}" - ); + fn unknown_environment_variables_are_rejected() { + for key in [ + "FLUSS_GATEWAY__REST__LISTENN", + "FLUSS_GATEWAY__QUERY__ENABLED", + "FLUSS_GATEWAY__SERVER_REST__BIND_ADDRESS", + ] { + let mut env = no_env(); + env.insert(key.to_string(), "value".to_string()); + let error = load(None, &env, &CliOverrides::default()).unwrap_err(); + assert!( + matches!(error, ConfigError::UnknownEnvKey(_)), + "{key}: {error:?}" + ); + assert!(error.to_string().contains(key), "{key}: {error}"); + } } #[test] @@ -828,12 +829,15 @@ gateway.metrics.enabled: true let file = write_temp_config("gateway.shutdown.drain-timeout: 0s\n"); let mut env = no_env(); env.insert( - "FLUSS_GATEWAY__SERVER_REST__BIND_ADDRESS".to_string(), + "FLUSS_GATEWAY__REST__LISTEN".to_string(), "127.0.0.1:28080".to_string(), ); let error = load(Some(file.path()), &env, &CliOverrides::default()).unwrap_err(); assert!(matches!(error, ConfigError::Parse(_)), "got: {error:?}"); - assert!(error.to_string().contains("drain_timeout"), "got: {error}"); + assert!( + error.to_string().contains("gateway.shutdown.drain-timeout"), + "got: {error}" + ); assert!( !error.to_string().contains("FLUSS_GATEWAY__"), "file problem misattributed to the env override: {error}" @@ -841,41 +845,66 @@ gateway.metrics.enabled: true } #[test] - fn env_string_values_keep_commas_outside_list_keys() { - let mut env = no_env(); - env.insert( - "FLUSS_GATEWAY__SERVER__INSTANCE_ID".to_string(), - "gateway-a".to_string(), - ); - let config = load(None, &env, &CliOverrides::default()).unwrap(); - assert_eq!(config.server.instance_id.as_deref(), Some("gateway-a")); + fn public_environment_options_are_loaded_by_type() { + let env = BTreeMap::from([ + ("FLUSS_GATEWAY__INSTANCE_ID".to_string(), "123".to_string()), + ( + "FLUSS_GATEWAY__REST__LISTEN".to_string(), + "127.0.0.1:18080".to_string(), + ), + ( + "FLUSS_GATEWAY__REST__WRITE__REQUEST_TIMEOUT".to_string(), + "5s".to_string(), + ), + ( + "FLUSS_GATEWAY__REST__WRITE__MAX_REQUEST_BYTES".to_string(), + "2MiB".to_string(), + ), + ( + "FLUSS_GATEWAY__METRICS__ENABLED".to_string(), + "false".to_string(), + ), + ( + "FLUSS_GATEWAY__METRICS__EXPORTER__PROMETHEUS__LISTEN".to_string(), + "127.0.0.1:19095".to_string(), + ), + ( + "FLUSS_GATEWAY__SHUTDOWN__DRAIN_TIMEOUT".to_string(), + "10s".to_string(), + ), + ]); - env.insert( - "FLUSS_GATEWAY__SERVER__INSTANCE_ID".to_string(), - "a,b".to_string(), + let config = load(None, &env, &CliOverrides::default()).unwrap(); + assert_eq!(config.server.instance_id.as_deref(), Some("123")); + assert_eq!( + config.server.rest.bind_address, + "127.0.0.1:18080".parse().unwrap() ); - let error = load(None, &env, &CliOverrides::default()).unwrap_err(); - assert!( - error - .to_string() - .contains("FLUSS_GATEWAY__SERVER__INSTANCE_ID") - || error.to_string().contains("instance_id"), - "got: {error}" + assert_eq!( + config.server.rest.request_timeout.get(), + Duration::from_secs(5) ); + assert_eq!(config.server.rest.max_body_bytes.bytes(), 2 * 1024 * 1024); + assert!(!config.server.metrics.enabled); + assert_eq!( + config.server.metrics.bind_address, + "127.0.0.1:19095".parse().unwrap() + ); + assert_eq!(config.shutdown.drain_timeout.get(), Duration::from_secs(10)); } #[test] fn invalid_env_value_names_the_variable() { let mut env = no_env(); env.insert( - "FLUSS_GATEWAY__SERVER_REST__MAX_BODY_BYTES".to_string(), + "FLUSS_GATEWAY__REST__WRITE__MAX_REQUEST_BYTES".to_string(), "many".to_string(), ); let error = load(None, &env, &CliOverrides::default()).unwrap_err(); assert!( error .to_string() - .contains("FLUSS_GATEWAY__SERVER_REST__MAX_BODY_BYTES"), + .contains("FLUSS_GATEWAY__REST__WRITE__MAX_REQUEST_BYTES"), "got: {error}" ); } @@ -891,12 +920,12 @@ gateway.metrics.enabled: true #[test] fn invalid_duration_rejected() { - for bad in ["60", "60 s", "6.5s", "s", "60d", "-1s"] { + for bad in ["0ms", "60", "60 s", "6.5s", "s", "60d", "-1s"] { let error = load_file(&format!("gateway.shutdown.drain-timeout: \"{bad}\"\n")).unwrap_err(); assert!(matches!(error, ConfigError::Parse(_)), "{bad}: {error:?}"); assert!( - error.to_string().contains("drain_timeout"), + error.to_string().contains("gateway.shutdown.drain-timeout"), "{bad}: {error}" ); } @@ -904,9 +933,6 @@ gateway.metrics.enabled: true #[test] fn overflowing_duration_is_rejected_rather_than_saturated() { - // Syntactically valid but astronomically large durations must be refused at parse time, not - // silently clamped, so they can never reach an `Instant + Duration` overflow at runtime. Every - // unit needs its own case: the unmultiplied ones overflow without any arithmetic at all. for bad in [ "18446744073709551615ms", "18446744073709551615s", @@ -916,7 +942,6 @@ gateway.metrics.enabled: true let error = ConfigDuration::parse(bad).unwrap_err(); assert!(error.contains("must not exceed"), "{bad}: {error}"); } - // The bound itself is accepted, one second past it is not. assert_eq!( ConfigDuration::parse("31536000s").unwrap().get(), MAX_CONFIG_DURATION @@ -924,58 +949,76 @@ gateway.metrics.enabled: true assert!(ConfigDuration::parse("31536001s").is_err()); } + #[test] + fn programmatically_constructed_durations_are_validated() { + let mut config = GatewayConfig::default(); + config.server.rest.request_timeout = ConfigDuration::from_millis(0); + config.shutdown.drain_timeout = + ConfigDuration::from_secs(MAX_CONFIG_DURATION.as_secs() + 1); + + let errors = problems(config.validate().unwrap_err()); + assert!( + errors.iter().any(|error| { + error == "gateway.rest.write.request-timeout must be greater than zero" + }), + "got: {errors:?}" + ); + assert!( + errors.iter().any(|error| { + error == "gateway.shutdown.drain-timeout must not exceed 31536000 seconds" + }), + "got: {errors:?}" + ); + } + + #[test] + fn programmatically_constructed_zero_byte_limit_is_validated() { + let mut config = GatewayConfig::default(); + config.server.rest.max_body_bytes = ByteSize::new(0); + + let errors = problems(config.validate().unwrap_err()); + assert_eq!( + errors, + vec!["gateway.rest.write.max-request-bytes must be greater than zero"] + ); + } + #[test] fn invalid_byte_size_rejected() { - for bad in ["\"4Mb\"", "\"MiB\"", "-1", "\"1.5MiB\""] { + for bad in ["0", "\"4Mb\"", "\"MiB\"", "-1", "\"1.5MiB\""] { let error = load_file(&format!("gateway.rest.write.max-request-bytes: {bad}\n")).unwrap_err(); assert!(matches!(error, ConfigError::Parse(_)), "{bad}: {error:?}"); assert!( - error.to_string().contains("max_body_bytes"), + error + .to_string() + .contains("gateway.rest.write.max-request-bytes"), "{bad}: {error}" ); } } - #[test] - fn zero_durations_and_sizes_rejected_while_parsing() { - for (key, contents) in [ - ("drain_timeout", "gateway.shutdown.drain-timeout: 0ms\n"), - ( - "max_body_bytes", - "gateway.rest.write.max-request-bytes: 0\n", - ), - ] { - let error = load_file(contents).unwrap_err(); - assert!(matches!(error, ConfigError::Parse(_)), "{key}: {error:?}"); - assert!(error.to_string().contains(key), "{key}: {error}"); - } - } - - #[test] - fn removed_and_out_of_scope_configuration_keys_are_rejected() { - for contents in [ - // Scan and cursor state, dropped with the stateless contract. - "gateway.scan.max-open-global: 8\n", - "gateway.scan.cursor-ttl: 1m\n", - // Transport security, out of scope (TLS terminates at a fronting proxy). - "gateway.tls.cert: /etc/tls.pem\n", - ] { - assert!(load_file(contents).is_err(), "accepted: {contents}"); - } - } - #[test] fn metrics_address_must_differ_from_rest_address() { let error = load_file( "gateway.rest.listen: 127.0.0.1:9095\ngateway.metrics.exporter.prometheus.listen: 127.0.0.1:9095\n", ) .unwrap_err(); - assert!( - problems(error) - .iter() - .any(|p| p.contains("server.metrics.bind_address must differ")) - ); + assert!(problems(error).iter().any(|problem| { + problem.contains( + "gateway.metrics.exporter.prometheus.listen must differ from gateway.rest.listen", + ) + })); + } + + /// Two ephemeral listeners are not a clash: the OS hands out a different port to each. + #[test] + fn both_listeners_may_ask_for_an_ephemeral_port() { + let config = load_file( + "gateway.rest.listen: 127.0.0.1:0\ngateway.metrics.exporter.prometheus.listen: 127.0.0.1:0\n", + ) + .unwrap(); + assert_eq!(config.server.rest.bind_address.port(), 0); } #[test] @@ -984,7 +1027,6 @@ gateway.metrics.enabled: true assert!(config.server.instance_id.is_none()); assert_eq!(config.warnings().len(), 1); assert!(config.warnings()[0].contains("not loopback")); - // The warning calls out the unauthenticated exposure. assert!( config.warnings()[0].contains("accepts unauthenticated requests"), "{:?}", @@ -998,7 +1040,7 @@ gateway.metrics.enabled: true assert!( problems(error) .iter() - .any(|p| p.contains("server.instance_id must be 1-128 ASCII")) + .any(|problem| problem.contains("gateway.instance-id must be 1-128 ASCII")) ); } @@ -1031,18 +1073,30 @@ gateway.metrics.enabled: true } #[test] - fn env_suffix_paths() { - assert_eq!( - env_suffix_to_path("SERVER_REST__BIND_ADDRESS"), - "server.rest.bind_address" - ); - assert_eq!( - env_suffix_to_path("SERVER__INSTANCE_ID"), - "server.instance_id" - ); - assert_eq!( - env_suffix_to_path("SHUTDOWN__DRAIN_TIMEOUT"), - "shutdown.drain_timeout" - ); + fn options_are_complete_and_unambiguous() { + let mut public_keys = std::collections::BTreeSet::new(); + let mut internal_paths = std::collections::BTreeSet::new(); + let mut environment_variables = std::collections::BTreeSet::new(); + + for entry in CONFIG_ENTRIES { + assert!(entry.key.starts_with("gateway."), "{entry:?}"); + assert!( + public_keys.insert(entry.key), + "duplicate key: {}", + entry.key + ); + assert!( + internal_paths.insert(entry.internal_path), + "duplicate path: {}", + entry.internal_path + ); + assert!( + environment_variables.insert(environment_variable(entry.key)), + "duplicate environment variable for {}", + entry.key + ); + } + + assert_eq!(CONFIG_ENTRIES.len(), 7); } } diff --git a/fluss-gateway/src/error.rs b/fluss-gateway/src/error.rs index 9d64943d409..cbb6ccab7e8 100644 --- a/fluss-gateway/src/error.rs +++ b/fluss-gateway/src/error.rs @@ -17,199 +17,222 @@ //! Gateway error taxonomy and the REST error envelope. //! -//! [`ErrorKind`] represents client-visible failure conditions independently of the HTTP framework. The REST adapter -//! obtains each status code from [`ErrorKind::http_status`]. +//! [`ErrorKind`] represents client-visible failure conditions. Its variants, HTTP statuses, wire codes, and +//! `Retry-After` rule are declared once by the `error_kinds!` table below — the table-driven form +//! `http::StatusCode` uses for the same problem — so a new condition cannot be added to one mapping and +//! forgotten in another. //! -//! The taxonomy is deliberately closed at fifteen kinds. There is no `GONE` or `CURSOR_NOT_LOCAL` because the -//! gateway holds no cursors. The gateway applies no request rate limiting — the only per-request bounds are -//! input-validation caps, surfacing as [`ErrorKind::LimitExceeded`] (413) or -//! [`ErrorKind::InvalidArgument`] (400) — but per-user act-as connections are a bounded resource, so -//! [`ErrorKind::ResourceExhausted`] (429, with a `Retry-After` header per FIP-49) reports connection-capacity -//! exhaustion under the user identity mode. +//! The wire vocabulary has a single source too: [`wire_codes`] enumerates every code the gateway can emit, +//! and the OpenAPI `ErrorCode` schema is generated from it, so the published contract cannot drift from the +//! taxonomy. //! //! FIP-49 error-model notes: the FIP's `database_not_empty` (409) condition is carried by -//! [`ErrorKind::FailedPrecondition`], and its `*_not_found` / `*_already_exists` families collapse onto -//! [`ErrorKind::NotFound`] / [`ErrorKind::AlreadyExists`] with the resource named in -//! [`ErrorDetails`], keeping one stable code per condition kind. +//! [`ErrorKind::FailedPrecondition`], and its `*_not_found` / `*_already_exists` families are the +//! [`ErrorKind::NotFound`] / [`ErrorKind::AlreadyExists`] kinds qualified by a [`Resource`], keeping one +//! stable code per condition. -use serde::{Deserialize, Serialize}; +use serde::Serialize; use std::fmt; +use utoipa::openapi::schema::Type; +use utoipa::openapi::{ObjectBuilder, RefOr, Schema}; +use utoipa::{PartialSchema, ToSchema}; -/// Client-visible condition kinds. +/// Declares the error taxonomy: one row per condition, carrying its HTTP status, its stable wire code, and +/// whether a response advertises `Retry-After`. +/// +/// Generating [`ErrorKind`] and its mappings from the same rows keeps them in step by construction: the +/// generated matches are exhaustive, so a row is the only way to add a variant. +macro_rules! error_kinds { + ( + $( + $(#[$docs:meta])* + $variant:ident => $status:literal, $code:literal, retry_after: $retry_after:literal; + )+ + ) => { + /// Client-visible condition kinds, ordered by HTTP status. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum ErrorKind { + $( + $(#[$docs])* + $variant, + )+ + } + + impl ErrorKind { + /// Every kind, in declaration order. + pub const ALL: &'static [ErrorKind] = &[$( ErrorKind::$variant, )+]; + + /// Stable machine-readable code carried in the error envelope, for example `not_found`. + pub fn code(self) -> &'static str { + match self { + $( Self::$variant => $code, )+ + } + } + + /// The REST HTTP mapping. + /// + /// Kept as a plain `u16` so this module stays free of HTTP framework types. The REST adapter + /// converts to its own status type. + pub fn http_status(self) -> u16 { + match self { + $( Self::$variant => $status, )+ + } + } + + /// Whether a response carrying this kind advertises `Retry-After`, which FIP-49 requires of + /// every 429 and which the gateway also sends for a transient backend outage. + pub fn retry_after(self) -> bool { + match self { + $( Self::$variant => $retry_after, )+ + } + } + } + }; +} + +error_kinds! { + /// The request contains malformed input, an invalid identifier, or a type mismatch. + InvalidArgument => 400, "invalid_argument", retry_after: false; + /// The request carries no usable credential, or the credential failed verification. + Unauthenticated => 401, "unauthenticated", retry_after: false; + /// The authenticated principal is not allowed to perform the operation. + Unauthorized => 403, "unauthorized", retry_after: false; + /// The requested cluster, database, table, or partition does not exist. + NotFound => 404, "not_found", retry_after: false; + /// The route exists but not for the request method. + MethodNotAllowed => 405, "method_not_allowed", retry_after: false; + /// The `Accept` header does not allow a supported response type. + NotAcceptable => 406, "not_acceptable", retry_after: false; + /// A create operation conflicts with an existing resource. + AlreadyExists => 409, "already_exists", retry_after: false; + /// Current resource state prevents the requested operation. + FailedPrecondition => 409, "failed_precondition", retry_after: false; + /// The request exceeds a configured input-validation size limit. + LimitExceeded => 413, "limit_exceeded", retry_after: false; + /// The request media type is not supported. + UnsupportedMediaType => 415, "unsupported_media_type", retry_after: false; + /// A bounded resource (per-user act-as connections) is at capacity. + ResourceExhausted => 429, "resource_exhausted", retry_after: true; + /// Work was cancelled by the caller or by shutdown. + Cancelled => 499, "cancelled", retry_after: false; + /// The Fluss backend failed in a way the gateway cannot classify further, distinguishable from a + /// gateway-internal failure. + Backend => 500, "backend", retry_after: false; + /// An unexpected internal failure occurred. + Internal => 500, "internal", retry_after: false; + /// The operation or table format is not supported. + Unsupported => 501, "unsupported", retry_after: false; + /// The backend is unavailable or the gateway is not ready. + Unavailable => 503, "unavailable", retry_after: true; + /// The request exceeded its deadline. + DeadlineExceeded => 504, "timeout", retry_after: false; +} + +/// A resource a failure can name, selecting the FIP-49 resource-specific wire code. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ErrorKind { - /// The request contains malformed input, an invalid identifier, or a type mismatch. Maps to HTTP 400. - InvalidArgument, - /// The request carries no usable credential, or the credential failed verification. Maps to HTTP 401. - Unauthenticated, - /// The authenticated principal is not allowed to perform the operation. Maps to HTTP 403. - Unauthorized, - /// The requested database, table, or partition does not exist. Maps to HTTP 404. - NotFound, - /// A create operation conflicts with an existing resource. Maps to HTTP 409. - AlreadyExists, - /// Current resource state prevents the requested operation. Maps to HTTP 409. - FailedPrecondition, - /// The operation or table format is not supported. Maps to HTTP 501. - Unsupported, - /// The request media type is not supported. Maps to HTTP 415. - UnsupportedMediaType, - /// The `Accept` header does not allow a supported response type. Maps to HTTP 406. - NotAcceptable, - /// The request exceeds a configured input-validation size limit. Maps to HTTP 413. - LimitExceeded, - /// A bounded resource (per-user act-as connections) is at capacity. Maps to HTTP 429. - ResourceExhausted, - /// The request exceeded its deadline. Maps to HTTP 504. - DeadlineExceeded, - /// Work was cancelled by the caller or by shutdown. Maps to HTTP 499. - Cancelled, - /// The backend is unavailable or the gateway is not ready. Maps to HTTP 503. - Unavailable, - /// The Fluss backend failed in a way the gateway cannot classify further. Maps to HTTP 500 - /// with the FIP-49 `backend` code, distinguishable from a gateway-internal failure. - Backend, - /// An unexpected internal failure occurred. Maps to HTTP 500. - Internal, +pub enum Resource { + Cluster, + Database, + Table, + Partition, } -impl ErrorKind { - /// Every kind in declaration order. - /// - /// Kept in sync with the enum by [`ErrorKind::ordinal`], whose exhaustive match stops compiling when a - /// variant is added without extending this table. - pub const ALL: [ErrorKind; 16] = [ - ErrorKind::InvalidArgument, - ErrorKind::Unauthenticated, - ErrorKind::Unauthorized, +/// The FIP-49 resource-specific codes. +/// +/// Only the combinations the FIP names appear here; any other kind/resource pair keeps the kind's generic +/// code, so the gateway never invents a code for a condition the contract does not describe. +const RESOURCE_CODES: &[(ErrorKind, Resource, &str)] = &[ + (ErrorKind::NotFound, Resource::Cluster, "cluster_not_found"), + ( ErrorKind::NotFound, + Resource::Database, + "database_not_found", + ), + (ErrorKind::NotFound, Resource::Table, "table_not_found"), + ( + ErrorKind::NotFound, + Resource::Partition, + "partition_not_found", + ), + ( + ErrorKind::AlreadyExists, + Resource::Cluster, + "cluster_already_exists", + ), + ( + ErrorKind::AlreadyExists, + Resource::Database, + "database_already_exists", + ), + ( ErrorKind::AlreadyExists, + Resource::Table, + "table_already_exists", + ), + ( + ErrorKind::AlreadyExists, + Resource::Partition, + "partition_already_exists", + ), + // The one precondition the FIP names: dropping a non-empty database. + ( ErrorKind::FailedPrecondition, - ErrorKind::Unsupported, - ErrorKind::UnsupportedMediaType, - ErrorKind::NotAcceptable, - ErrorKind::LimitExceeded, - ErrorKind::ResourceExhausted, - ErrorKind::DeadlineExceeded, - ErrorKind::Cancelled, - ErrorKind::Unavailable, - ErrorKind::Backend, - ErrorKind::Internal, - ]; - - /// Position of this kind within [`ErrorKind::ALL`]. - pub fn ordinal(self) -> usize { - match self { - ErrorKind::InvalidArgument => 0, - ErrorKind::Unauthenticated => 1, - ErrorKind::Unauthorized => 2, - ErrorKind::NotFound => 3, - ErrorKind::AlreadyExists => 4, - ErrorKind::FailedPrecondition => 5, - ErrorKind::Unsupported => 6, - ErrorKind::UnsupportedMediaType => 7, - ErrorKind::NotAcceptable => 8, - ErrorKind::LimitExceeded => 9, - ErrorKind::ResourceExhausted => 10, - ErrorKind::DeadlineExceeded => 11, - ErrorKind::Cancelled => 12, - ErrorKind::Unavailable => 13, - ErrorKind::Backend => 14, - ErrorKind::Internal => 15, - } - } - - /// Stable machine-readable code carried in the error envelope, for example `not_found`. - pub fn code(self) -> &'static str { - match self { - ErrorKind::InvalidArgument => "invalid_argument", - ErrorKind::Unauthenticated => "unauthenticated", - ErrorKind::Unauthorized => "unauthorized", - ErrorKind::NotFound => "not_found", - ErrorKind::AlreadyExists => "already_exists", - ErrorKind::FailedPrecondition => "failed_precondition", - ErrorKind::Unsupported => "unsupported", - ErrorKind::UnsupportedMediaType => "unsupported_media_type", - ErrorKind::NotAcceptable => "not_acceptable", - ErrorKind::LimitExceeded => "limit_exceeded", - ErrorKind::ResourceExhausted => "resource_exhausted", - ErrorKind::DeadlineExceeded => "timeout", - ErrorKind::Cancelled => "cancelled", - ErrorKind::Unavailable => "unavailable", - ErrorKind::Backend => "backend", - ErrorKind::Internal => "internal", - } - } + Resource::Database, + "database_not_empty", + ), +]; + +fn resource_code(kind: ErrorKind, resource: Resource) -> Option<&'static str> { + RESOURCE_CODES + .iter() + .find(|(row_kind, row_resource, _)| *row_kind == kind && *row_resource == resource) + .map(|(_, _, code)| *code) +} - /// The REST HTTP mapping table. - /// - /// Kept as a plain `u16` so this module stays free of HTTP framework types. The REST adapter converts to its own - /// status type. - pub fn http_status(self) -> u16 { - match self { - ErrorKind::InvalidArgument => 400, - ErrorKind::Unauthenticated => 401, - ErrorKind::Unauthorized => 403, - ErrorKind::NotFound => 404, - ErrorKind::AlreadyExists | ErrorKind::FailedPrecondition => 409, - ErrorKind::Unsupported => 501, - ErrorKind::UnsupportedMediaType => 415, - ErrorKind::NotAcceptable => 406, - ErrorKind::LimitExceeded => 413, - ErrorKind::ResourceExhausted => 429, - ErrorKind::DeadlineExceeded => 504, - ErrorKind::Cancelled => 499, - ErrorKind::Unavailable => 503, - ErrorKind::Backend | ErrorKind::Internal => 500, - } - } +/// Every code the gateway can put on the wire, sorted and deduplicated. +/// +/// The OpenAPI `ErrorCode` schema is built from this, so the published vocabulary is the taxonomy itself +/// rather than a hand-maintained copy of it. +pub fn wire_codes() -> Vec<&'static str> { + let mut codes: Vec<&'static str> = ErrorKind::ALL.iter().map(|kind| kind.code()).collect(); + codes.extend(RESOURCE_CODES.iter().map(|(_, _, code)| *code)); + codes.sort_unstable(); + codes.dedup(); + codes +} - /// Whether repeating an otherwise unchanged request may succeed. - /// - /// This is the default for a kind. A native failure whose `FlussError::is_retriable()` disagrees overrides it - /// per error through [`GatewayError::with_retryable`]. - pub fn default_retryable(self) -> bool { - match self { - ErrorKind::DeadlineExceeded | ErrorKind::Unavailable | ErrorKind::ResourceExhausted => { - true - } - ErrorKind::InvalidArgument - | ErrorKind::Unauthenticated - | ErrorKind::Unauthorized - | ErrorKind::NotFound - | ErrorKind::AlreadyExists - | ErrorKind::FailedPrecondition - | ErrorKind::Unsupported - | ErrorKind::UnsupportedMediaType - | ErrorKind::NotAcceptable - | ErrorKind::LimitExceeded - | ErrorKind::Cancelled - | ErrorKind::Backend - | ErrorKind::Internal => false, - } +/// Schema handle for the `code` field: a string restricted to [`wire_codes`]. +/// +/// A marker type rather than a mirrored enum, so the documented vocabulary is generated from the taxonomy +/// and cannot fall behind it. +#[derive(Debug)] +pub struct ErrorCode; + +impl PartialSchema for ErrorCode { + fn schema() -> RefOr { + ObjectBuilder::new() + .schema_type(Type::String) + .description(Some( + "Stable error code, resource-specific where the error names a resource.", + )) + .enum_values(Some(wire_codes())) + .into() } } +impl ToSchema for ErrorCode {} + /// Gateway-internal error: a condition kind plus a client-safe message. /// -/// Messages must never contain stack traces, internal addresses, or wire payloads. Operational detail belongs in -/// the log. +/// Messages must never contain stack traces, internal addresses, or wire payloads. Operational detail belongs +/// in the log. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GatewayError { kind: ErrorKind, message: String, - details: Option, - /// Overrides [`ErrorKind::default_retryable`] when the native layer knows better. - retryable: Option, -} - -/// Optional protocol-neutral structured context for a public error. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ErrorDetails { - #[serde(skip_serializing_if = "Option::is_none")] - pub resource_kind: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub resource_name: Option, + /// Selects the resource-specific wire code. Never serialized: the resource is already named in the + /// message, and FIP-49's envelope carries no structured details. + resource: Option, } impl GatewayError { @@ -218,8 +241,7 @@ impl GatewayError { Self { kind, message: message.into(), - details: None, - retryable: None, + resource: None, } } @@ -307,65 +329,21 @@ impl GatewayError { /// Stable code carried in the error envelope. /// - /// Per FIP-49 the vocabulary is resource-specific where a resource is known: an error whose - /// kind names a resource and that carries machine-readable resource context answers - /// `database_not_found`, `table_already_exists`, `database_not_empty`, and so on. `cluster` - /// follows the same `*_not_found` pattern as a natural extension — the FIP table predates the - /// multi-cluster path segment. An error without resource context keeps its kind's generic - /// code, so the gateway never guesses which resource a bare failure was about. + /// An error that names a resource answers the resource-specific code that FIP-49 defines for the pair; + /// any other error keeps its kind's generic code. pub fn code(&self) -> &'static str { - let resource = self - .details - .as_ref() - .and_then(|details| details.resource_kind.as_deref()); - match (self.kind, resource) { - (ErrorKind::NotFound, Some("cluster")) => "cluster_not_found", - (ErrorKind::NotFound, Some("database")) => "database_not_found", - (ErrorKind::NotFound, Some("table")) => "table_not_found", - (ErrorKind::NotFound, Some("partition")) => "partition_not_found", - (ErrorKind::AlreadyExists, Some("cluster")) => "cluster_already_exists", - (ErrorKind::AlreadyExists, Some("database")) => "database_already_exists", - (ErrorKind::AlreadyExists, Some("table")) => "table_already_exists", - (ErrorKind::AlreadyExists, Some("partition")) => "partition_already_exists", - // The one precondition the FIP names: dropping a non-empty database. Every other - // precondition failure (e.g. a table changing during write preflight) keeps the - // generic code. - (ErrorKind::FailedPrecondition, Some("database")) => "database_not_empty", - _ => self.kind.code(), - } + self.resource + .and_then(|resource| resource_code(self.kind, resource)) + .unwrap_or_else(|| self.kind.code()) } - /// Whether repeating an otherwise unchanged request may succeed. + /// Names the resource this error is about, selecting the resource-specific code. /// - /// Defaults to [`ErrorKind::default_retryable`] unless an explicit verdict was recorded. - pub fn retryable(&self) -> bool { - self.retryable - .unwrap_or_else(|| self.kind.default_retryable()) - } - - /// Records an explicit retry verdict, typically `FlussError::is_retriable()` from the native layer. - pub fn with_retryable(mut self, retryable: bool) -> Self { - self.retryable = Some(retryable); + /// The metadata and DDL capabilities are the first emitters; the vocabulary ships with the wire contract. + pub fn with_resource(mut self, resource: Resource) -> Self { + self.resource = Some(resource); self } - - /// Adds machine-readable resource context without changing the stable error code. - pub fn with_resource( - mut self, - resource_kind: impl Into, - resource_name: Option>, - ) -> Self { - self.details = Some(ErrorDetails { - resource_kind: Some(resource_kind.into()), - resource_name: resource_name.map(Into::into), - }); - self - } - - /// Returns optional machine-readable context for protocol adapters. - pub fn details(&self) -> Option<&ErrorDetails> { - self.details.as_ref() - } } impl fmt::Display for GatewayError { @@ -376,22 +354,28 @@ impl fmt::Display for GatewayError { impl std::error::Error for GatewayError {} -/// REST error envelope: `{"error": {"code", "message", "request_id", "retryable", "details"?}}`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// REST error envelope: `{"error": {"code", "message", "request_id"}}`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] +#[schema(examples(json!({ + "error": { + "code": "table_not_found", + "message": "table `mydb.orders` does not exist", + "request_id": "8f6c7f4a-f9b8-4c71-91ec-6e5578d7a913" + } +})))] pub struct ErrorEnvelope { pub error: ErrorBody, } /// Body of the REST error envelope. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] pub struct ErrorBody { + #[schema(value_type = ErrorCode)] pub code: String, pub message: String, + /// Correlates the response with the `x-request-id` header and the access log. + #[schema(value_type = String, format = "uuid")] pub request_id: String, - /// Machine-readable retry guidance, derived from the error kind or from `FlussError::is_retriable()`. - pub retryable: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub details: Option, } impl ErrorEnvelope { @@ -402,255 +386,128 @@ impl ErrorEnvelope { code: error.code().to_string(), message: error.message().to_string(), request_id: request_id.into(), - retryable: error.retryable(), - details: error.details().cloned(), - }, - } - } - - /// Builds an envelope for a failure that never had a [`GatewayError`], such as a framework-produced status. - /// - /// Routing every construction through a constructor keeps callers from leaving a stale struct literal behind - /// when the envelope gains a field. - pub fn from_parts( - code: impl Into, - message: impl Into, - request_id: impl Into, - retryable: bool, - ) -> Self { - Self { - error: ErrorBody { - code: code.into(), - message: message.into(), - request_id: request_id.into(), - retryable, - details: None, }, } } } -/// Attaches machine-readable resource context to the error kinds that name a resource. -#[allow(dead_code)] // The resource-naming emitters arrive with the capability PRs; the wire contract ships now. -pub(crate) fn resource_error( - error: GatewayError, - resource_kind: &'static str, - resource_name: impl Into, -) -> GatewayError { - if error.details().is_some() - || !matches!( - error.kind(), - ErrorKind::NotFound | ErrorKind::AlreadyExists | ErrorKind::FailedPrecondition - ) - { - return error; - } - error.with_resource(resource_kind, Some(resource_name.into())) -} - #[cfg(test)] mod tests { use super::*; - /// The frozen taxonomy. Adding a variant breaks [`ErrorKind::ordinal`] first, then this table. - const CONTRACT: [(ErrorKind, u16, &str, bool); 16] = [ - (ErrorKind::InvalidArgument, 400, "invalid_argument", false), - (ErrorKind::Unauthenticated, 401, "unauthenticated", false), - (ErrorKind::Unauthorized, 403, "unauthorized", false), - (ErrorKind::NotFound, 404, "not_found", false), - (ErrorKind::AlreadyExists, 409, "already_exists", false), - ( - ErrorKind::FailedPrecondition, - 409, - "failed_precondition", - false, - ), - (ErrorKind::Unsupported, 501, "unsupported", false), - ( - ErrorKind::UnsupportedMediaType, - 415, - "unsupported_media_type", - false, - ), - (ErrorKind::NotAcceptable, 406, "not_acceptable", false), - (ErrorKind::LimitExceeded, 413, "limit_exceeded", false), - ( - ErrorKind::ResourceExhausted, - 429, - "resource_exhausted", - true, - ), - (ErrorKind::DeadlineExceeded, 504, "timeout", true), - (ErrorKind::Cancelled, 499, "cancelled", false), - (ErrorKind::Unavailable, 503, "unavailable", true), - (ErrorKind::Backend, 500, "backend", false), - (ErrorKind::Internal, 500, "internal", false), - ]; - + /// The mappings FIP-49 pins down. The table generates every accessor, so this guards the table's own + /// rows against an accidental edit. #[test] - fn taxonomy_is_frozen_and_exhaustively_mapped() { - assert_eq!(ErrorKind::ALL.len(), CONTRACT.len()); - for (index, (kind, status, code, retryable)) in CONTRACT.into_iter().enumerate() { - assert_eq!(kind.ordinal(), index, "{code} is out of declaration order"); - assert_eq!(ErrorKind::ALL[index], kind, "ALL disagrees for {code}"); + fn the_fip_mappings_are_frozen() { + for (kind, status, code) in [ + (ErrorKind::InvalidArgument, 400, "invalid_argument"), + (ErrorKind::Unauthenticated, 401, "unauthenticated"), + (ErrorKind::Unauthorized, 403, "unauthorized"), + (ErrorKind::NotFound, 404, "not_found"), + (ErrorKind::MethodNotAllowed, 405, "method_not_allowed"), + (ErrorKind::LimitExceeded, 413, "limit_exceeded"), + (ErrorKind::ResourceExhausted, 429, "resource_exhausted"), + (ErrorKind::Cancelled, 499, "cancelled"), + (ErrorKind::Backend, 500, "backend"), + (ErrorKind::Internal, 500, "internal"), + (ErrorKind::Unsupported, 501, "unsupported"), + (ErrorKind::Unavailable, 503, "unavailable"), + (ErrorKind::DeadlineExceeded, 504, "timeout"), + ] { assert_eq!(kind.http_status(), status, "status for {code}"); assert_eq!(kind.code(), code); - assert_eq!(kind.default_retryable(), retryable, "retryable for {code}"); } } #[test] - fn only_connection_capacity_maps_to_429_and_nothing_maps_to_a_cursor_status() { - for kind in ErrorKind::ALL { - let status = kind.http_status(); + fn the_taxonomy_table_is_well_formed() { + let mut codes: Vec<&str> = Vec::new(); + for kind in ErrorKind::ALL.iter().copied() { + let code = kind.code(); + assert!( + (400..=599).contains(&kind.http_status()), + "{code} maps to {}", + kind.http_status() + ); + assert!( + code.chars().all(|c| c.is_ascii_lowercase() || c == '_'), + "{code} is not snake_case" + ); + // `Retry-After` is meaningful only where the caller is meant to come back. assert_eq!( - status == 429, - kind == ErrorKind::ResourceExhausted, - "{} unexpectedly maps to 429", - kind.code() + kind.retry_after(), + matches!(kind, ErrorKind::ResourceExhausted | ErrorKind::Unavailable), + "retry_after for {code}" ); - assert_ne!(status, 410, "{} maps to a cursor status", kind.code()); + codes.push(code); } + let total = codes.len(); + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), total, "duplicate wire code declared"); } #[test] fn envelope_shape() { - let err = GatewayError::not_found("table `db.missing` does not exist"); - let envelope = ErrorEnvelope::new(&err, "req-123"); - let json = serde_json::to_value(&envelope).unwrap(); + let error = GatewayError::not_found("table `db.missing` does not exist"); + let envelope = ErrorEnvelope::new(&error, "req-123"); assert_eq!( - json, + serde_json::to_value(&envelope).unwrap(), serde_json::json!({ "error": { "code": "not_found", "message": "table `db.missing` does not exist", "request_id": "req-123", - "retryable": false, - } - }) - ); - } - - #[test] - fn explicit_retry_verdict_overrides_the_kind_default() { - let derived = GatewayError::unavailable("Fluss is unavailable"); - assert!(derived.retryable()); - - let overridden = GatewayError::internal("decode failed").with_retryable(true); - assert!(overridden.retryable()); - assert!( - !GatewayError::unavailable("permanently gone") - .with_retryable(false) - .retryable() - ); - assert_eq!( - serde_json::to_value(ErrorEnvelope::new(&overridden, "req-1")).unwrap()["error"]["retryable"], - serde_json::json!(true) - ); - } - - #[test] - fn retains_protocol_neutral_resource_details() { - let error = GatewayError::not_found("table does not exist") - .with_resource("table", Some("fluss.missing")); - - assert_eq!( - error.details(), - Some(&ErrorDetails { - resource_kind: Some("table".to_string()), - resource_name: Some("fluss.missing".to_string()), - }) - ); - assert_eq!( - serde_json::to_value(ErrorEnvelope::new(&error, "request-7")).unwrap(), - serde_json::json!({ - "error": { - "code": "table_not_found", - "message": "table does not exist", - "request_id": "request-7", - "retryable": false, - "details": { - "resource_kind": "table", - "resource_name": "fluss.missing" - } - } - }) - ); - } - - #[test] - fn framework_failures_get_an_envelope_without_a_gateway_error() { - let envelope = ErrorEnvelope::from_parts( - "method_not_allowed", - "method not allowed", - "request-9", - false, - ); - assert_eq!( - serde_json::to_value(&envelope).unwrap(), - serde_json::json!({ - "error": { - "code": "method_not_allowed", - "message": "method not allowed", - "request_id": "request-9", - "retryable": false, } }) ); } - #[test] - fn resource_context_is_added_only_to_resource_naming_kinds() { - let named = resource_error(GatewayError::not_found("gone"), "table", "db.t"); - assert_eq!( - named.details().and_then(|d| d.resource_name.clone()), - Some("db.t".to_string()) - ); - let untouched = resource_error(GatewayError::internal("boom"), "table", "db.t"); - assert!(untouched.details().is_none()); - } - - /// The FIP-49 vocabulary: an error carrying resource context answers the resource-specific - /// code; one without context keeps its kind's generic code. + /// The FIP-49 vocabulary: an error naming a resource answers the resource-specific code; one without a + /// resource keeps its kind's generic code. #[test] fn resource_context_specialises_the_wire_code() { - let cases: [(GatewayError, &str, &str); 5] = [ - (GatewayError::not_found("x"), "cluster", "cluster_not_found"), + let cases: [(GatewayError, Resource, &str); 5] = [ ( GatewayError::not_found("x"), - "database", + Resource::Cluster, + "cluster_not_found", + ), + ( + GatewayError::not_found("x"), + Resource::Database, "database_not_found", ), - (GatewayError::not_found("x"), "table", "table_not_found"), + ( + GatewayError::not_found("x"), + Resource::Table, + "table_not_found", + ), ( GatewayError::already_exists("x"), - "partition", + Resource::Partition, "partition_already_exists", ), ( GatewayError::failed_precondition("x"), - "database", + Resource::Database, "database_not_empty", ), ]; for (error, resource, expected) in cases { - let named = error.with_resource(resource, Some("name")); + let named = error.with_resource(resource); assert_eq!(named.code(), expected); let envelope = serde_json::to_value(ErrorEnvelope::new(&named, "r")).unwrap(); assert_eq!(envelope["error"]["code"], expected); } - // Without resource context the generic codes hold — the gateway never guesses. + // Without a resource the generic codes hold — the gateway never guesses. assert_eq!(GatewayError::not_found("x").code(), "not_found"); assert_eq!(GatewayError::already_exists("x").code(), "already_exists"); - assert_eq!( - GatewayError::failed_precondition("x").code(), - "failed_precondition" - ); // A precondition on a table (e.g. it changed during preflight) is not "not empty". assert_eq!( GatewayError::failed_precondition("x") - .with_resource("table", Some("db.t")) + .with_resource(Resource::Table) .code(), "failed_precondition" ); @@ -658,4 +515,25 @@ mod tests { assert_eq!(GatewayError::backend("x").code(), "backend"); assert_eq!(GatewayError::internal("x").code(), "internal"); } + + /// Everything the taxonomy can emit is publishable, and nothing else is. + #[test] + fn wire_codes_cover_the_kinds_and_the_resource_forms() { + let codes = wire_codes(); + for kind in ErrorKind::ALL.iter().copied() { + assert!(codes.contains(&kind.code()), "missing {}", kind.code()); + } + for (_, _, code) in RESOURCE_CODES { + assert!(codes.contains(code), "missing {code}"); + } + assert_eq!( + codes.len(), + ErrorKind::ALL.len() + RESOURCE_CODES.len(), + "wire codes are unique across kinds and resource forms" + ); + assert!(codes.windows(2).all(|pair| pair[0] < pair[1]), "sorted"); + // The write path's entry-level `storage_backpressure` is not a request status and arrives with the + // capability that emits it. + assert!(!codes.contains(&"storage_backpressure")); + } } diff --git a/fluss-gateway/src/lib.rs b/fluss-gateway/src/lib.rs index abbfba9cf0c..bd9c0d40bad 100644 --- a/fluss-gateway/src/lib.rs +++ b/fluss-gateway/src/lib.rs @@ -15,10 +15,11 @@ // specific language governing permissions and limitations // under the License. -//! Stateless REST gateway for Apache Fluss. +//! Internal implementation of the stateless REST gateway for Apache Fluss. //! -//! [`protocol::rest`] serves the HTTP surface — routing, JSON decoding, and the cross-cutting -//! middleware — and [`lifecycle`] owns startup, readiness, background tasks, and graceful shutdown. +//! The library target keeps the executable entry point thin and lets integration tests exercise the production +//! router and lifecycle in process. The package is not published as a reusable crate and makes no public API +//! compatibility commitment. //! //! # Statelessness contract //! diff --git a/fluss-gateway/src/lifecycle.rs b/fluss-gateway/src/lifecycle.rs index 7a2de6bc38e..fa973f32180 100644 --- a/fluss-gateway/src/lifecycle.rs +++ b/fluss-gateway/src/lifecycle.rs @@ -31,13 +31,13 @@ use axum::Router; use axum::http::{HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::get; -use futures::FutureExt; +use futures_util::FutureExt; use metrics_exporter_prometheus::PrometheusHandle; use std::any::Any; use std::future::Future; use std::future::IntoFuture; use std::panic::AssertUnwindSafe; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use tokio::task::JoinSet; @@ -45,62 +45,117 @@ use tokio_util::sync::CancellationToken; type RunError = Box; -const MAX_SHUTDOWN_CLEANUP_RESERVE: Duration = Duration::from_secs(5); - /// Named terminal result from one process-owned asynchronous subsystem. struct TaskExit { name: String, result: Result<(), String>, } -/// The shared process acceptance predicate. -#[derive(Debug, Default)] +/// Monotonic process states; discriminants define their transition order. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +#[repr(u8)] +enum LifecycleState { + Starting = 0, + Serving = 1, + Quiescing = 2, + Draining = 3, + Stopped = 4, +} + +impl LifecycleState { + fn from_u8(value: u8) -> Self { + match value { + 0 => Self::Starting, + 1 => Self::Serving, + 2 => Self::Quiescing, + 3 => Self::Draining, + 4 => Self::Stopped, + _ => unreachable!("invalid lifecycle state"), + } + } +} + +/// The shared process acceptance state. +#[derive(Debug)] pub struct Readiness { - serving: AtomicBool, - shutting_down: AtomicBool, + state: AtomicU8, +} + +impl Default for Readiness { + fn default() -> Self { + Self { + state: AtomicU8::new(LifecycleState::Starting as u8), + } + } } impl Readiness { - /// Starts neither serving nor shutting down, so new work is rejected until startup completes. + /// Starts in the non-accepting startup state. pub fn new() -> Self { Self::default() } - /// Marks the gateway ready to serve. Called once after the listeners are bound. - pub fn set_serving(&self) { - self.serving.store(true, Ordering::SeqCst); + pub(crate) fn set_serving(&self) { + self.transition(LifecycleState::Starting, LifecycleState::Serving); } - /// Flips acceptance off so guarded routes answer 503 and callers stop sending traffic, before - /// draining starts. - pub fn begin_shutdown(&self) { - self.shutting_down.store(true, Ordering::SeqCst); + pub(crate) fn begin_quiescing(&self) { + self.transition(LifecycleState::Serving, LifecycleState::Quiescing); + } + + pub(crate) fn begin_draining(&self) { + self.transition(LifecycleState::Quiescing, LifecycleState::Draining); } - /// True once startup finished, regardless of whether shutdown has begun. - pub fn is_serving(&self) -> bool { - self.serving.load(Ordering::SeqCst) + pub(crate) fn set_stopped(&self) { + self.transition(LifecycleState::Draining, LifecycleState::Stopped); } - /// True once shutdown started. Never returns to false. + /// True once startup completed, including while shutting down. + pub fn has_started(&self) -> bool { + self.state() >= LifecycleState::Serving + } + + /// True once shutdown started. pub fn is_shutting_down(&self) -> bool { - self.shutting_down.load(Ordering::SeqCst) + self.state() >= LifecycleState::Quiescing } - /// The predicate that gates request acceptance: serving and not yet draining. + /// True only while the gateway accepts application work. pub fn is_accepting(&self) -> bool { - self.is_serving() && !self.is_shutting_down() + self.state() == LifecycleState::Serving } - /// Rejects new application work once startup has not completed or draining has begun. + /// Rejects new application work before startup completes or after shutdown begins. pub fn ensure_accepting(&self) -> Result<(), GatewayError> { - if self.is_shutting_down() { - return Err(GatewayError::unavailable("gateway is shutting down")); + match self.state() { + LifecycleState::Starting => Err(GatewayError::unavailable("gateway is starting")), + LifecycleState::Serving => Ok(()), + LifecycleState::Quiescing | LifecycleState::Draining | LifecycleState::Stopped => { + Err(GatewayError::unavailable("gateway is shutting down")) + } } - if !self.is_serving() { - return Err(GatewayError::unavailable("gateway is starting")); + } + + fn state(&self) -> LifecycleState { + LifecycleState::from_u8(self.state.load(Ordering::SeqCst)) + } + + fn transition(&self, current: LifecycleState, next: LifecycleState) { + assert_eq!(next as u8, current as u8 + 1); + match self.state.compare_exchange( + current as u8, + next as u8, + Ordering::SeqCst, + Ordering::SeqCst, + ) { + Ok(_) => {} + Err(actual) if actual >= next as u8 => {} + Err(actual) => panic!( + "invalid lifecycle transition from {:?} to {next:?}", + LifecycleState::from_u8(actual) + ), } - Ok(()) } } @@ -125,9 +180,9 @@ impl RunningGateway { self.metrics_addr } - /// Begins graceful draining, the same transition SIGTERM triggers. + /// Stops accepting application work without closing the listeners. pub fn begin_shutdown(&self) { - self.readiness.begin_shutdown(); + self.readiness.begin_quiescing(); } /// Stops accepting, drains in-flight requests within the configured drain timeout, then closes the @@ -138,40 +193,32 @@ impl RunningGateway { async fn finish(mut self, unexpected_exit: Option) -> Result<(), RunError> { let shutdown_started = Instant::now(); - self.readiness.begin_shutdown(); - observability::process_draining(); - let (task_deadline, _deadline) = shutdown_deadlines(Instant::now(), self.drain_timeout); + self.readiness.begin_quiescing(); + // Draining gets the whole configured budget: the gateway holds no request-spanning state, so there is + // no cleanup step after the tasks stop that would need a reserved tail. The deadline comes from the + // timer's own clock, so it cannot skew against it. + let deadline = tokio::time::Instant::now() + self.drain_timeout; + self.readiness.begin_draining(); self.shutdown.cancel(); - let cleanup_error = drain_tasks(&mut self.tasks, task_deadline).await; + let cleanup_error = drain_tasks(&mut self.tasks, deadline).await; + self.readiness.set_stopped(); + // The metrics listener is one of the drained tasks, so nothing recorded from here on could ever be + // scraped: the shutdown outcome is reported through this log line and the process exit code. + let elapsed = shutdown_started.elapsed(); if let Some(error) = unexpected_exit { - observability::process_stopped("task_error", shutdown_started.elapsed()); + log::error!("fluss-gateway stopped after {elapsed:?}: {error}"); return Err(error.into()); } if let Some(error) = cleanup_error { - observability::process_stopped("cleanup_error", shutdown_started.elapsed()); + log::error!("fluss-gateway stopped after {elapsed:?}: {error}"); return Err(error.into()); } - observability::process_stopped("success", shutdown_started.elapsed()); - log::info!("fluss-gateway stopped"); + log::info!("fluss-gateway stopped after {elapsed:?}"); Ok(()) } } -/// Splits one process deadline into request draining and a bounded resource-cleanup tail. -fn shutdown_deadlines(started: Instant, timeout: Duration) -> (Instant, Instant) { - // Configuration rejects overflowing durations, so this is defence in depth: an instant that - // cannot represent `started + timeout` falls back to a one-hour drain rather than panicking. - let deadline = started - .checked_add(timeout) - .unwrap_or_else(|| started + Duration::from_secs(3600)); - let minimum_reserve = Duration::from_millis(1).min(timeout); - let cleanup_reserve = (timeout / 4) - .max(minimum_reserve) - .min(MAX_SHUTDOWN_CLEANUP_RESERVE); - (deadline - cleanup_reserve, deadline) -} - /// Runs the gateway until a process shutdown signal or any process-owned task exits unexpectedly. pub async fn run(config: GatewayConfig) -> Result<(), RunError> { let mut gateway = start(config).await?; @@ -188,8 +235,10 @@ pub async fn run(config: GatewayConfig) -> Result<(), RunError> { gateway.finish(unexpected_exit).await } -/// Binds listeners and starts serving without requiring Fluss to be available. +/// Validates the complete configuration, then binds listeners and starts serving without requiring Fluss to be +/// available. pub async fn start(config: GatewayConfig) -> Result { + config.validate()?; start_internal(config).await } @@ -199,7 +248,6 @@ async fn start_internal(config: GatewayConfig) -> Result Result, deadline: Instant) -> Option { +async fn drain_tasks( + tasks: &mut JoinSet, + deadline: tokio::time::Instant, +) -> Option { let mut cleanup_error = None; loop { - match tokio::time::timeout_at(deadline.into(), tasks.join_next()).await { + match tokio::time::timeout_at(deadline, tasks.join_next()).await { Ok(Some(Ok(TaskExit { name, result }))) => match result { Ok(()) => log::info!("{name} stopped"), Err(error) => { @@ -433,32 +483,110 @@ mod tests { } } - /// Verifies readiness transitions and idempotent shutdown state. + fn assert_readiness( + readiness: &Readiness, + state: LifecycleState, + has_started: bool, + is_accepting: bool, + is_shutting_down: bool, + rejection: Option<&str>, + ) { + assert_eq!(readiness.state(), state); + assert_eq!(readiness.has_started(), has_started); + assert_eq!(readiness.is_accepting(), is_accepting); + assert_eq!(readiness.is_shutting_down(), is_shutting_down); + match rejection { + Some(message) => { + let error = readiness.ensure_accepting().unwrap_err(); + assert_eq!(error.kind(), crate::error::ErrorKind::Unavailable); + assert_eq!(error.message(), message); + } + None => readiness.ensure_accepting().unwrap(), + } + } + #[test] - fn readiness_predicate() { + fn readiness_lifecycle() { let readiness = Readiness::new(); - assert!(!readiness.is_accepting()); + assert_readiness( + &readiness, + LifecycleState::Starting, + false, + false, + false, + Some("gateway is starting"), + ); + readiness.set_serving(); - assert!(readiness.is_accepting()); - readiness.begin_shutdown(); - assert!(!readiness.is_accepting()); - assert_eq!( - readiness.ensure_accepting().unwrap_err().message(), - "gateway is shutting down" + assert_readiness(&readiness, LifecycleState::Serving, true, true, false, None); + + readiness.begin_quiescing(); + assert_readiness( + &readiness, + LifecycleState::Quiescing, + true, + false, + true, + Some("gateway is shutting down"), + ); + + readiness.begin_draining(); + assert_readiness( + &readiness, + LifecycleState::Draining, + true, + false, + true, + Some("gateway is shutting down"), + ); + + readiness.set_stopped(); + assert_readiness( + &readiness, + LifecycleState::Stopped, + true, + false, + true, + Some("gateway is shutting down"), ); - readiness.begin_shutdown(); - assert!(readiness.is_shutting_down()); } #[test] - fn readiness_rejects_work_before_startup() { + fn lifecycle_transitions_are_idempotent_and_never_move_backwards() { let readiness = Readiness::new(); - let error = readiness.ensure_accepting().unwrap_err(); - assert_eq!(error.kind(), crate::error::ErrorKind::Unavailable); - assert_eq!(error.message(), "gateway is starting"); - readiness.set_serving(); - readiness.ensure_accepting().unwrap(); + readiness.set_serving(); + readiness.begin_quiescing(); + readiness.set_serving(); + readiness.begin_quiescing(); + readiness.begin_draining(); + readiness.begin_quiescing(); + readiness.set_stopped(); + readiness.begin_draining(); + + assert_eq!(readiness.state(), LifecycleState::Stopped); + } + + #[test] + #[should_panic(expected = "invalid lifecycle transition")] + fn lifecycle_transition_cannot_skip_a_state() { + Readiness::new().begin_draining(); + } + + #[tokio::test] + async fn start_rejects_programmatically_invalid_config_before_binding() { + let mut config = GatewayConfig::default(); + config.server.rest.bind_address = "127.0.0.1:0".parse().expect("valid address"); + config.server.metrics.enabled = false; + config.server.rest.request_timeout = crate::config::ConfigDuration::from_millis(0); + + let error = start(config).await.err().expect("invalid config"); + assert!( + error + .to_string() + .contains("gateway.rest.write.request-timeout must be greater than zero"), + "got: {error}" + ); } /// Timed-out tasks are aborted and joined before cleanup returns. @@ -473,7 +601,7 @@ mod tests { }); tokio::task::yield_now().await; let started = tokio::time::Instant::now(); - let deadline = Instant::now() + Duration::from_secs(5); + let deadline = started + Duration::from_secs(5); let error = drain_tasks(&mut tasks, deadline) .await .expect("a stuck task exceeds the deadline"); @@ -486,26 +614,6 @@ mod tests { assert!(tasks.is_empty()); } - /// A stuck request cannot consume the tail reserved for resource cleanup. - #[tokio::test] - async fn stuck_task_leaves_time_for_resource_cleanup() { - let mut tasks = JoinSet::new(); - spawn_named(&mut tasks, "stuck listener", async move { - std::future::pending::>().await - }); - tokio::task::yield_now().await; - let started = Instant::now(); - let (task_deadline, deadline) = shutdown_deadlines(started, Duration::from_millis(200)); - - let task_error = drain_tasks(&mut tasks, task_deadline).await; - assert!(task_error.is_some()); - assert!(task_deadline < deadline); - - let elapsed = started.elapsed(); - assert!(elapsed >= Duration::from_millis(140), "{elapsed:?}"); - assert!(elapsed < Duration::from_millis(500), "{elapsed:?}"); - } - #[tokio::test] async fn named_task_panic_is_reported_as_an_unexpected_process_failure() { let mut tasks = JoinSet::new(); @@ -543,7 +651,11 @@ mod tests { } shutdown.cancel(); - let error = drain_tasks(&mut tasks, Instant::now() + Duration::from_secs(1)).await; + let error = drain_tasks( + &mut tasks, + tokio::time::Instant::now() + Duration::from_secs(1), + ) + .await; assert!(error.is_none(), "{error:?}"); assert!(tasks.is_empty()); diff --git a/fluss-gateway/src/observability.rs b/fluss-gateway/src/observability.rs index 7d7dc4d0d03..c4bec85042a 100644 --- a/fluss-gateway/src/observability.rs +++ b/fluss-gateway/src/observability.rs @@ -15,20 +15,22 @@ // specific language governing permissions and limitations // under the License. -//! Process logging and the complete gateway metric inventory. +//! Process logging and the gateway metric inventory. //! -//! [`METRIC_DEFINITIONS`] is the cardinality contract: every gateway-owned metric family is declared here once, -//! with its kind, unit, description, and label set. Emission goes exclusively through the typed helpers in this -//! module so no call site can invent a family or a label that the inventory does not know about. +//! [`METRIC_DEFINITIONS`] tracks the FIP-49 metric table: every family declared here is one the +//! specification names, with its kind, unit, description, and label set. Families the FIP defines for +//! capabilities that do not exist yet — the backend write and connection-pool families, the re-exported +//! client backpressure families — arrive with those capabilities. The inventory never grows a family of its +//! own; the tests below enforce that against the table. //! //! Labels describe an operation or a bounded outcome. `cluster`, sourced from validated configuration, is the -//! only resource-name label; database, table, and partition names are never labels. +//! only resource-name label the gateway itself emits. use log::{LevelFilter, Log, Metadata, Record}; use metrics::Unit; use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle}; use std::sync::OnceLock; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; /// Logger that writes one line per record to standard error, with no filtering beyond the global level. struct StderrLogger; @@ -53,6 +55,16 @@ impl Log for StderrLogger { static LOGGER: StderrLogger = StderrLogger; static METRICS_HANDLE: OnceLock = OnceLock::new(); +/// Buckets for the duration histograms, spanning a fast local answer to a request that runs into the +/// configured deadline. +/// +/// Without explicit buckets `metrics-exporter-prometheus` renders every histogram as a summary with +/// pre-computed quantiles, which cannot be aggregated across gateway instances — and FIP-49 asks for a +/// histogram. +const DURATION_BUCKETS: &[f64] = &[ + 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, +]; + /// Which Prometheus instrument a metric family uses. #[derive(Clone, Copy)] pub enum MetricKind { @@ -72,46 +84,11 @@ pub struct MetricDefinition { pub labels: &'static [&'static str], } -/// The complete inventory of gateway-owned metric families. +/// The gateway's slice of the FIP-49 metric table. /// -/// Adding an emission site means adding its family here first. Nothing in the gateway emits a family absent from -/// this table, and the tests below enforce the label-cardinality rules. +/// Adding an emission site means adding its family here first, and a family may only be added if the FIP +/// declares it. pub const METRIC_DEFINITIONS: &[MetricDefinition] = &[ - metric( - "fluss_gateway_process_start_time_seconds", - MetricKind::Gauge, - Some(Unit::Seconds), - "Gateway process start time since the Unix epoch.", - &[], - ), - metric( - "fluss_gateway_process_ready", - MetricKind::Gauge, - None, - "Whether the gateway accepts requests.", - &[], - ), - metric( - "fluss_gateway_process_shutting_down", - MetricKind::Gauge, - None, - "Whether graceful shutdown has begun.", - &[], - ), - metric( - "fluss_gateway_process_shutdown_total", - MetricKind::Counter, - None, - "Gateway shutdown outcomes: success, task_error, or cleanup_error.", - &["result"], - ), - metric( - "fluss_gateway_process_shutdown_duration_seconds", - MetricKind::Histogram, - Some(Unit::Seconds), - "Gateway graceful-shutdown duration by success, task_error, or cleanup_error.", - &["result"], - ), metric( "fluss_gateway_rest_requests_total", MetricKind::Counter, @@ -128,26 +105,10 @@ pub const METRIC_DEFINITIONS: &[MetricDefinition] = &[ "REST request duration.", &["cluster", "method", "operation"], ), - metric( - "fluss_gateway_rest_inflight_requests", - MetricKind::Gauge, - None, - "REST requests currently executing.", - &[], - ), - metric( - "fluss_gateway_rest_rejections_total", - MetricKind::Counter, - None, - "REST requests rejected by an input-validation limit or the request deadline.", - &["reason"], - ), // FIP-49 process and Tokio runtime families, sampled periodically by the runtime sampler. - // `process_cpu_seconds_total` is monotonic but published through the gauge instrument because - // the `metrics` counter API is integral; the exposition value is the standard fractional total. metric( "process_cpu_seconds_total", - MetricKind::Gauge, + MetricKind::Counter, Some(Unit::Seconds), "Total user and system CPU time spent by the gateway process.", &[], @@ -166,6 +127,13 @@ pub const METRIC_DEFINITIONS: &[MetricDefinition] = &[ "Open file descriptors of the gateway process.", &[], ), + metric( + "process_max_fds", + MetricKind::Gauge, + None, + "File descriptor limit of the gateway process.", + &[], + ), metric( "tokio_alive_tasks", MetricKind::Gauge, @@ -216,7 +184,10 @@ pub fn init_metrics(enabled: bool) -> Result<(), String> { if !enabled || METRICS_HANDLE.get().is_some() { return Ok(()); } - let recorder = PrometheusBuilder::new().build_recorder(); + let recorder = PrometheusBuilder::new() + .set_buckets(DURATION_BUCKETS) + .map_err(|error| format!("failed to configure histogram buckets: {error}"))? + .build_recorder(); let handle = recorder.handle(); metrics::set_global_recorder(recorder) .map_err(|error| format!("failed to install Prometheus recorder: {error}"))?; @@ -225,35 +196,6 @@ pub fn init_metrics(enabled: bool) -> Result<(), String> { Ok(()) } -/// Initializes process series after the recorder is installed. -pub fn register_process_metrics() { - let started = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs_f64(); - metrics::gauge!("fluss_gateway_process_start_time_seconds").set(started); - metrics::gauge!("fluss_gateway_process_ready").set(0.0); - metrics::gauge!("fluss_gateway_process_shutting_down").set(0.0); -} - -/// Records that startup completed and request listeners accept work. -pub fn process_ready() { - metrics::gauge!("fluss_gateway_process_ready").set(1.0); -} - -/// Records the start of graceful shutdown before listeners stop accepting. -pub fn process_draining() { - metrics::gauge!("fluss_gateway_process_ready").set(0.0); - metrics::gauge!("fluss_gateway_process_shutting_down").set(1.0); -} - -/// Records one terminal graceful-shutdown outcome and its bounded duration. -pub fn process_stopped(result: &'static str, duration: Duration) { - metrics::counter!("fluss_gateway_process_shutdown_total", "result" => result).increment(1); - metrics::histogram!("fluss_gateway_process_shutdown_duration_seconds", "result" => result) - .record(duration.as_secs_f64()); -} - /// Records one completed REST request against the matched route template, never the raw URI. /// /// `operation` and `code` are the FIP-49 label names: the operation is the matched route @@ -278,21 +220,6 @@ pub fn http_request(cluster: &str, method: &str, operation: &str, code: u16, dur .record(duration.as_secs_f64()); } -/// Adjusts the in-flight request gauge by one in either direction. -pub fn http_inflight(delta: i8) { - let gauge = metrics::gauge!("fluss_gateway_rest_inflight_requests"); - if delta >= 0 { - gauge.increment(f64::from(delta)); - } else { - gauge.decrement(f64::from(-delta)); - } -} - -/// Records one request rejected before reaching a handler, such as `body_size` or `timeout`. -pub fn http_rejection(reason: &'static str) { - metrics::counter!("fluss_gateway_rest_rejections_total", "reason" => reason).increment(1); -} - /// Returns the installed recorder handle for the dedicated metrics listener. pub fn metrics_handle() -> Option { METRICS_HANDLE.get().cloned() @@ -309,7 +236,10 @@ pub fn sample_runtime_metrics() { metrics::gauge!("tokio_global_queue_depth").set(runtime.global_queue_depth() as f64); } if let Some(cpu_seconds) = process_cpu_seconds() { - metrics::gauge!("process_cpu_seconds_total").set(cpu_seconds); + // Whole seconds: the `metrics` counter API is integral, and a `_total` family must keep counter + // semantics so `rate()` and the Prometheus/OTLP conversion stay correct. The sub-second remainder + // is carried into the next sample rather than lost, since the source is an absolute total. + metrics::counter!("process_cpu_seconds_total").absolute(cpu_seconds as u64); } if let Some(resident) = process_resident_memory_bytes() { metrics::gauge!("process_resident_memory_bytes").set(resident); @@ -317,6 +247,9 @@ pub fn sample_runtime_metrics() { if let Some(fds) = process_open_fds() { metrics::gauge!("process_open_fds").set(fds); } + if let Some(limit) = process_max_fds() { + metrics::gauge!("process_max_fds").set(limit); + } } /// Total user plus system CPU seconds of this process, from `getrusage(2)`. @@ -372,6 +305,26 @@ fn process_open_fds() -> Option { None } +/// The soft file descriptor limit, from `getrlimit(2)`. FIP-49 pairs it with `process_open_fds` so the count +/// can be read against its ceiling. +#[cfg(unix)] +fn process_max_fds() -> Option { + let mut limit = std::mem::MaybeUninit::::zeroed(); + // SAFETY: `getrlimit` fills the buffer we own; a non-zero return leaves it unread. + let rc = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, limit.as_mut_ptr()) }; + if rc != 0 { + return None; + } + // SAFETY: `getrlimit` returned 0, so the buffer is initialized. + let limit = unsafe { limit.assume_init() }; + Some(limit.rlim_cur as f64) +} + +#[cfg(not(unix))] +fn process_max_fds() -> Option { + None +} + fn describe_metrics() { for definition in METRIC_DEFINITIONS { match (definition.kind, definition.unit) { @@ -426,25 +379,47 @@ mod tests { assert_eq!(parse_level("module=debug"), LevelFilter::Info); } + /// The FIP-49 metric table, verbatim. The gateway may implement a subset of it and nothing outside it. + const FIP_49_FAMILIES: &[&str] = &[ + "fluss_gateway_rest_requests_total", + "fluss_gateway_rest_request_duration_seconds", + "fluss_gateway_backend_write_rows_total", + "fluss_gateway_backend_write_bytes_total", + "fluss_gateway_connections_active", + "fluss_gateway_connections_created_total", + "fluss_gateway_connections_closed_total", + "fluss_client_writer_kv_backpressure_pressure", + "fluss_client_writer_kv_backpressure_throttle_seconds_total", + "process_cpu_seconds_total", + "process_resident_memory_bytes", + "process_open_fds", + "process_max_fds", + "tokio_alive_tasks", + "tokio_global_queue_depth", + "tokio_worker_busy_seconds_total", + ]; + + /// Every declared family is one FIP-49 names — the inventory tracks the specification instead of + /// growing families of its own. #[test] - fn metric_inventory_covers_every_required_subsystem() { - for prefix in ["fluss_gateway_process_", "fluss_gateway_rest_"] { + fn the_inventory_declares_nothing_the_fip_does_not() { + for definition in METRIC_DEFINITIONS { assert!( - METRIC_DEFINITIONS - .iter() - .any(|definition| definition.name.starts_with(prefix)), - "missing metric family for {prefix}" + FIP_49_FAMILIES.contains(&definition.name), + "{} is not in the FIP-49 metric table", + definition.name ); } } + /// A `_total` family must be a counter, or `rate()` and the Prometheus/OTLP conversion misread it. #[test] - fn inventory_declares_no_scan_or_cursor_family() { + fn total_families_are_counters() { for definition in METRIC_DEFINITIONS { - for forbidden in ["fluss_gateway_scan_", "fluss_gateway_cursor_"] { + if definition.name.ends_with("_total") { assert!( - !definition.name.starts_with(forbidden), - "stateless gateway must not declare {}", + matches!(definition.kind, MetricKind::Counter), + "{} carries the _total suffix without counter semantics", definition.name ); } @@ -463,6 +438,9 @@ mod tests { assert_eq!(names.len(), total, "duplicate metric family declared"); } + /// Gateway-owned families keep their label sets bounded. The FIP's re-exported + /// `fluss_client_writer_kv_backpressure_*` families do carry `database` / `table`; they come from the + /// client recorder, not from here, and this rule is relaxed for them when they arrive. #[test] fn metric_labels_cannot_contain_unbounded_resource_names() { const FORBIDDEN: &[&str] = &[ diff --git a/fluss-gateway/src/protocol/rest/health.rs b/fluss-gateway/src/protocol/rest/health.rs index 67b4aa927a2..3c61320f804 100644 --- a/fluss-gateway/src/protocol/rest/health.rs +++ b/fluss-gateway/src/protocol/rest/health.rs @@ -20,11 +20,11 @@ //! `GET /health` returns the FIP-49 `{status, uptime_ms}` shape and answers from the event loop //! without a backend RPC; deeper diagnostics live in the Prometheus metrics, not in this payload. +use crate::error::ErrorEnvelope; use crate::protocol::rest::{RestState, json_response}; use axum::extract::State; use axum::response::Response; use serde::Serialize; -use serde_json::json; use utoipa::ToSchema; use utoipa_axum::router::OpenApiRouter; use utoipa_axum::routes; @@ -37,7 +37,7 @@ pub fn routes() -> OpenApiRouter { /// Response of `GET /health` (FIP-49): liveness plus process uptime. #[derive(Debug, Serialize, ToSchema)] pub struct HealthResponse { - pub status: String, + pub status: &'static str, /// Milliseconds since the gateway process started. pub uptime_ms: u64, } @@ -48,14 +48,18 @@ pub struct HealthResponse { path = "/health", operation_id = "getHealth", tag = "health", - responses((status = 200, description = "Gateway liveness and uptime", body = HealthResponse)) + responses( + (status = 200, description = "Gateway liveness and uptime", body = HealthResponse), + (status = 405, description = "Wrong method for this route", body = ErrorEnvelope), + ) )] pub(crate) async fn health(State(state): State) -> Response { - json_response(&json!({ - "status": "ok", - "uptime_ms": u64::try_from(state.started_at.elapsed().as_millis()).unwrap_or(u64::MAX), - })) - .expect("static JSON value is serializable") + // The response type is the documented schema, so the payload cannot drift from the contract. + json_response(&HealthResponse { + status: "ok", + uptime_ms: u64::try_from(state.started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + }) + .expect("the health response is serializable") } #[cfg(test)] @@ -121,7 +125,7 @@ mod tests { async fn health_stays_200_during_shutdown() { let state = test_support::test_state(); state.readiness.set_serving(); - state.readiness.begin_shutdown(); + state.readiness.begin_quiescing(); let app = crate::protocol::rest::build_router(state, &test_support::test_options()); let response = get(app, "/health").await; assert_eq!(response.status(), StatusCode::OK); diff --git a/fluss-gateway/src/protocol/rest/mod.rs b/fluss-gateway/src/protocol/rest/mod.rs index c3eb4ac7f0d..b67174ed4d1 100644 --- a/fluss-gateway/src/protocol/rest/mod.rs +++ b/fluss-gateway/src/protocol/rest/mod.rs @@ -28,7 +28,7 @@ pub mod health; pub mod openapi; use crate::config::RestServerConfig; -use crate::error::{ErrorEnvelope, GatewayError}; +use crate::error::{ErrorEnvelope, ErrorKind, GatewayError}; use crate::lifecycle::Readiness; use crate::observability; use axum::Router; @@ -114,7 +114,7 @@ impl From<&RestServerConfig> for RestOptions { struct ShapedResponse; /// Renders the error envelope with the status its kind maps to, marks the response as already shaped, and adds -/// `Retry-After` to the kinds that are worth retrying after a short pause. +/// `Retry-After` where the taxonomy calls for it. pub fn error_response(error: &GatewayError, request_id: &RequestId) -> Response { let status = StatusCode::from_u16(error.kind().http_status()) .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); @@ -122,10 +122,7 @@ pub fn error_response(error: &GatewayError, request_id: &RequestId) -> Response json_response_with_status(status, &ErrorEnvelope::new(error, request_id.as_str())) .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()); response.extensions_mut().insert(ShapedResponse); - if matches!( - error.kind(), - crate::error::ErrorKind::Unavailable | crate::error::ErrorKind::ResourceExhausted - ) { + if error.kind().retry_after() { response .headers_mut() .insert(header::RETRY_AFTER, HeaderValue::from_static("1")); @@ -203,12 +200,6 @@ pub fn ensure_no_query(uri: &Uri) -> Result<(), GatewayError> { Ok(()) } -/// Deserializes the URI query string. Unknown or malformed parameters are rejected as invalid arguments. -pub fn parse_query(uri: &Uri) -> Result { - serde_urlencoded::from_str(uri.query().unwrap_or_default()) - .map_err(|error| GatewayError::invalid_argument(format!("invalid query: {error}"))) -} - /// Marks a response as final so the error-normalising middleware does not rewrite its body. Use it for handler /// responses that already carry their own envelope. pub fn shaped(mut response: Response) -> Response { @@ -317,7 +308,6 @@ fn apply_data_limits(router: Router, options: &RestOptions) -> Router { let oversized = declared_content_length(&request).filter(|length| *length > max_body_bytes); if let Some(length) = oversized { - observability::http_rejection("body_size"); log::warn!( "request_id={} rejecting body of {} bytes above {} bytes", request_id.as_str(), @@ -332,13 +322,12 @@ fn apply_data_limits(router: Router, options: &RestOptions) -> Router { ); } - observability::http_inflight(1); + // Rejections are not counted separately: they leave through the access-log layer above, which + // records them in `fluss_gateway_rest_requests_total` under their status code. let result = tokio::time::timeout(request_timeout, next.run(request)).await; - observability::http_inflight(-1); match result { Ok(response) => response, Err(_) => { - observability::http_rejection("timeout"); log::warn!( "request_id={} deadline exceeded after {:?}", request_id.as_str(), @@ -419,6 +408,33 @@ async fn request_context(mut request: Request, next: Next) -> Response { response } +/// Classifies a status the framework produced on its own — a rejected extractor, a route that exists for +/// another method — so it can be answered with the same envelope as a handler failure. +fn framework_error_kind(status: StatusCode) -> ErrorKind { + match status.as_u16() { + 400 | 422 => ErrorKind::InvalidArgument, + 404 => ErrorKind::NotFound, + 405 => ErrorKind::MethodNotAllowed, + 406 => ErrorKind::NotAcceptable, + 408 | 504 => ErrorKind::DeadlineExceeded, + 413 => ErrorKind::LimitExceeded, + 415 => ErrorKind::UnsupportedMediaType, + 429 => ErrorKind::ResourceExhausted, + 501 => ErrorKind::Unsupported, + 503 => ErrorKind::Unavailable, + server_error if server_error >= 500 => ErrorKind::Internal, + _ => ErrorKind::InvalidArgument, + } +} + +/// The reason phrase of the status a kind maps to, so this layer keeps no message table of its own. +fn framework_error_message(kind: ErrorKind) -> &'static str { + StatusCode::from_u16(kind.http_status()) + .ok() + .and_then(|status| status.canonical_reason()) + .unwrap_or("request failed") +} + fn normalize_error(response: Response, request_id: &RequestId) -> Response { let status = response.status(); if !(status.is_client_error() || status.is_server_error()) { @@ -428,39 +444,11 @@ fn normalize_error(response: Response, request_id: &RequestId) -> Response { return response; } - let (status, code, message, retryable) = match status.as_u16() { - 400 | 422 => (400, "invalid_argument", "invalid request", false), - 404 => (404, "not_found", "resource not found", false), - 405 => (405, "method_not_allowed", "method not allowed", false), - 406 => (406, "not_acceptable", "unacceptable accept header", false), - 408 | 504 => (504, "timeout", "request deadline exceeded", true), - 413 => (413, "limit_exceeded", "request body too large", false), - 415 => ( - 415, - "unsupported_media_type", - "unsupported media type", - false, - ), - 501 => (501, "unsupported", "unsupported operation", false), - 503 => (503, "unavailable", "service unavailable", true), - other => ( - other, - if other >= 500 { - "internal" - } else { - "invalid_argument" - }, - "request failed", - false, - ), - }; - - let envelope = ErrorEnvelope::from_parts(code, message, request_id.as_str(), retryable); - json_response_with_status( - StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - &envelope, + let kind = framework_error_kind(status); + error_response( + &GatewayError::new(kind, framework_error_message(kind)), + request_id, ) - .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) } async fn unknown_route(method: Method, uri: Uri, request: Request) -> Response { @@ -522,13 +510,6 @@ mod tests { value: u32, } - #[derive(Debug, Deserialize, PartialEq)] - #[serde(deny_unknown_fields)] - struct QueryFixture { - spec: String, - bucket: i32, - } - async fn body_json(response: Response) -> serde_json::Value { let bytes = response .into_body() @@ -568,22 +549,6 @@ mod tests { assert!(parse_json_body::(&headers, &Bytes::new()).is_err()); } - #[test] - fn shared_query_parser_decodes_and_rejects_duplicates() { - let uri: Uri = "/?spec=hello%20world&bucket=7".parse().unwrap(); - assert_eq!( - parse_query::(&uri).unwrap(), - QueryFixture { - spec: "hello world".to_string(), - bucket: 7, - } - ); - let repeated: Uri = "/?spec=a&spec=b&bucket=7".parse().unwrap(); - assert!(parse_query::(&repeated).is_err()); - let unknown: Uri = "/?spec=a&bucket=7&extra=1".parse().unwrap(); - assert!(parse_query::(&unknown).is_err()); - } - #[tokio::test] async fn unknown_route_yields_404_envelope() { let app = build_router(test_support::test_state(), &test_support::test_options()); @@ -603,7 +568,6 @@ mod tests { let json = body_json(response).await; assert_eq!(json["error"]["code"], "not_found"); assert_eq!(json["error"]["request_id"], header_id.as_str()); - assert_eq!(json["error"]["retryable"], false); assert!( json["error"]["message"].as_str().unwrap().contains("/nope"), "message names the missing route: {json}" @@ -654,7 +618,31 @@ mod tests { assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); let json = body_json(response).await; assert_eq!(json["error"]["code"], "timeout"); - assert_eq!(json["error"]["retryable"], true); + } + + /// A route that exists for another method answers the shared envelope with a code the published + /// vocabulary contains. + #[tokio::test] + async fn wrong_method_yields_a_405_envelope() { + let app = build_router(test_support::test_state(), &test_support::test_options()); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + let json = body_json(response).await; + assert_eq!(json["error"]["code"], "method_not_allowed"); + assert!( + crate::error::wire_codes().contains(&"method_not_allowed"), + "the published vocabulary declares every code the gateway emits" + ); } #[tokio::test] @@ -671,7 +659,7 @@ mod tests { let request = || Request::builder().uri("/slow").body(Body::empty()).unwrap(); let responses = - futures::future::join_all((0..16).map(|_| app.clone().oneshot(request()))).await; + futures_util::future::join_all((0..16).map(|_| app.clone().oneshot(request()))).await; for response in responses { assert_eq!(response.unwrap().status(), StatusCode::OK); diff --git a/fluss-gateway/src/protocol/rest/openapi.rs b/fluss-gateway/src/protocol/rest/openapi.rs index b1c12ef7a3b..09a4f0c9fbd 100644 --- a/fluss-gateway/src/protocol/rest/openapi.rs +++ b/fluss-gateway/src/protocol/rest/openapi.rs @@ -19,100 +19,22 @@ //! //! The document is derived from the routers themselves by //! [`utoipa_axum::router::OpenApiRouter::split_for_parts`] — there is no hand-maintained list of paths or -//! schemas anywhere in the crate, so the served contract cannot drift from the mounted routes. This module owns -//! only the shared error schemas, the serve handler, and the post-pass hooks applied to the generated value. +//! schemas anywhere in the crate, so the served contract cannot drift from the mounted routes. The error +//! schemas are the live wire types from [`crate::error`], and the `ErrorCode` vocabulary is generated from +//! the taxonomy, so the contract cannot drift from the implementation either. +use crate::error::{ErrorCode, ErrorEnvelope}; use crate::protocol::rest::{RestState, json_response}; use axum::extract::State; use axum::response::Response; -use serde::Serialize; -use serde_json::{Value, json}; -use utoipa::{OpenApi, ToSchema}; +use serde_json::Value; +use utoipa::{OpenApi, openapi::OpenApiBuilder}; use utoipa_axum::router::OpenApiRouter; use utoipa_axum::routes; -/// Stable error codes of the gateway: the FIP-49 vocabulary, resource-specific where the error -/// names a resource, exactly as serialized on the wire. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "snake_case")] -#[schema(as = ErrorCode)] -#[allow(dead_code)] // Schema-only enum; real errors use the HTTP-independent ErrorKind. -pub(crate) enum ErrorCodeSchema { - InvalidArgument, - Unauthenticated, - Unauthorized, - NotFound, - ClusterNotFound, - DatabaseNotFound, - TableNotFound, - PartitionNotFound, - AlreadyExists, - ClusterAlreadyExists, - DatabaseAlreadyExists, - TableAlreadyExists, - PartitionAlreadyExists, - FailedPrecondition, - DatabaseNotEmpty, - Unsupported, - UnsupportedMediaType, - NotAcceptable, - LimitExceeded, - ResourceExhausted, - Timeout, - Cancelled, - Unavailable, - Backend, - Internal, - /// Entry-level only: a KV write rejected by storage backpressure (never a request status). - StorageBackpressure, -} - -/// Machine-readable resource context carried by resource-naming errors. -#[derive(Debug, Serialize, ToSchema)] -pub(crate) struct ErrorDetailsSchema { - pub resource_kind: Option, - pub resource_name: Option, -} - -/// Body of the shared error envelope. -#[derive(Debug, Serialize, ToSchema)] -#[schema(as = ErrorBody)] -pub(crate) struct ErrorBodySchema { - pub code: ErrorCodeSchema, - pub message: String, - #[schema(value_type = String, format = "uuid")] - pub request_id: String, - /// Whether repeating an otherwise unchanged request may succeed. - pub retryable: bool, - pub details: Option, -} - -/// The envelope every failing response uses. -#[derive(Debug, Serialize, ToSchema)] -#[schema( - as = ErrorEnvelope, - examples(json!({ - "error": { - "code": "table_not_found", - "message": "table does not exist", - "request_id": "8f6c7f4a-f9b8-4c71-91ec-6e5578d7a913", - "retryable": false, - "details": {"resource_kind": "table"} - } - })) -)] -pub(crate) struct ErrorEnvelopeSchema { - pub error: ErrorBodySchema, -} - -/// Seeds the generated document with the schemas that no single handler owns. +/// Seeds the generated document with the shared error schemas, which no single handler owns. #[derive(OpenApi)] -#[openapi(components(schemas( - ErrorCodeSchema, - ErrorDetailsSchema, - ErrorBodySchema, - ErrorEnvelopeSchema -)))] +#[openapi(components(schemas(ErrorCode, ErrorEnvelope)))] struct SharedSchemas; /// OpenAPI routes, merged into the main router by [`crate::protocol::rest::build_router`]. @@ -120,62 +42,56 @@ pub fn routes() -> OpenApiRouter { OpenApiRouter::with_openapi(SharedSchemas::openapi()).routes(routes!(serve)) } -/// Applies the gateway's post-passes to the router-generated document. +/// Applies the gateway's own metadata to the router-generated document. /// -/// Called once by [`crate::protocol::rest::build_router`]. The passes are deliberately separate so that -/// documentation work can extend them without touching router assembly. +/// Called once by [`crate::protocol::rest::build_router`]. pub(crate) fn finalize(api: utoipa::openapi::OpenApi) -> Value { - let mut document = serde_json::to_value(api).expect("generated OpenAPI is serializable"); - apply_info(&mut document); - apply_servers(&mut document); - apply_security(&mut document); - apply_tags(&mut document); - apply_response_headers(&mut document); - document + let api = OpenApiBuilder::from(api) + .info( + utoipa::openapi::InfoBuilder::new() + .title("fluss-gateway") + .description(Some("Stateless REST gateway for Apache Fluss")) + .version(env!("CARGO_PKG_VERSION")) + .license(Some( + utoipa::openapi::LicenseBuilder::new() + .name("Apache-2.0") + .url(Some("https://www.apache.org/licenses/LICENSE-2.0")) + .build(), + )) + .build(), + ) + // The gateway serves the API at the listener root; a relative server keeps the document + // host-agnostic. + .servers(Some([utoipa::openapi::ServerBuilder::new() + .url("/") + .build()])) + // An explicit empty root security array: honest for this PR — no authentication exists yet. The + // authentication capability adds securitySchemes and per-operation requirements. + .security(Some(Vec::new())) + .build(); + serde_json::to_value(api).expect("generated OpenAPI is serializable") } -/// Replaces the utoipa-axum library defaults in `info` with this crate's own metadata. -fn apply_info(document: &mut Value) { - document["info"] = json!({ - "title": "fluss-gateway", - "description": "Stateless REST gateway for Apache Fluss", - "version": env!("CARGO_PKG_VERSION"), - "license": { - "name": "Apache-2.0", - "url": "https://www.apache.org/licenses/LICENSE-2.0" - } - }); -} - -/// The gateway serves the API at the listener root; a relative server keeps the document -/// host-agnostic. -fn apply_servers(document: &mut Value) { - document["servers"] = json!([{"url": "/"}]); -} - -/// An explicit empty root security array: honest for this PR — no authentication exists yet. -/// The authentication capability PR will introduce securitySchemes and per-operation requirements. -fn apply_security(document: &mut Value) { - document["security"] = json!([]); -} - -/// Post-pass hook for tag descriptions. Intentionally empty until the documentation pass lands. -fn apply_tags(_document: &mut Value) {} - -/// Post-pass hook for shared response headers. Intentionally empty until the documentation pass lands. -fn apply_response_headers(_document: &mut Value) {} - /// Serves the generated OpenAPI 3.1 document as JSON. #[utoipa::path( get, path = "/v1/openapi.json", operation_id = "getOpenApi", tag = "metadata", - responses((status = 200, description = "OpenAPI 3.1 document")) + responses( + (status = 200, description = "OpenAPI 3.1 document"), + (status = 405, description = "Wrong method for this route", body = ErrorEnvelope), + (status = 413, description = "Request body above the configured limit", body = ErrorEnvelope), + (status = 503, description = "Gateway starting or shutting down", body = ErrorEnvelope), + (status = 504, description = "Request deadline exceeded", body = ErrorEnvelope), + ) )] pub(crate) async fn serve(State(state): State) -> Response { - let document = state.openapi.get().cloned().unwrap_or_else(|| json!({})); - json_response(&document).expect("OpenAPI JSON is serializable") + let document = state + .openapi + .get() + .expect("build_router fills the document before the router serves"); + json_response(document).expect("OpenAPI JSON is serializable") } #[cfg(test)] @@ -216,7 +132,7 @@ mod tests { #[ignore = "rewrites openapi.yaml in the working tree; run via `just openapi`"] async fn export_checked_in_document() { let yaml = - serde_yaml::to_string(&served_document().await).expect("the document serializes"); + serde_yaml_ng::to_string(&served_document().await).expect("the document serializes"); std::fs::write(checked_in_path(), yaml).expect("openapi.yaml is writable"); } @@ -227,7 +143,7 @@ mod tests { let checked_in = std::fs::read_to_string(checked_in_path()) .expect("openapi.yaml is checked in; regenerate it with `just openapi`"); let checked_in: Value = - serde_yaml::from_str(&checked_in).expect("openapi.yaml parses as YAML"); + serde_yaml_ng::from_str(&checked_in).expect("openapi.yaml parses as YAML"); assert_eq!( checked_in, served_document().await, @@ -270,11 +186,27 @@ mod tests { "the shared error envelope is registered" ); assert_eq!( - document["components"]["schemas"]["ErrorBody"]["properties"]["retryable"]["type"], - "boolean" + document["components"]["schemas"]["ErrorBody"]["properties"]["code"]["$ref"], + "#/components/schemas/ErrorCode", + "the envelope code refers to the generated vocabulary: {}", + document["components"]["schemas"]["ErrorBody"] ); } + /// The published `ErrorCode` vocabulary is generated from the taxonomy, so adding an [`ErrorKind`] + /// without regenerating the document fails here rather than shipping a stale contract. + #[tokio::test] + async fn the_published_vocabulary_is_the_taxonomy() { + let document = served_document().await; + let published: Vec<&str> = document["components"]["schemas"]["ErrorCode"]["enum"] + .as_array() + .expect("ErrorCode enum values") + .iter() + .map(|value| value.as_str().expect("code is a string")) + .collect(); + assert_eq!(published, crate::error::wire_codes()); + } + #[tokio::test] async fn the_document_declares_no_scan_or_cursor_path() { let document = served_document().await; diff --git a/fluss-gateway/tests/e2e_cluster.rs b/fluss-gateway/tests/e2e_cluster.rs new file mode 100644 index 00000000000..28c9173a243 --- /dev/null +++ b/fluss-gateway/tests/e2e_cluster.rs @@ -0,0 +1,99 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +#![cfg(feature = "integration_tests")] + +//! End-to-end suite: the compiled gateway running next to a real Fluss cluster. +//! +//! The cluster is the dockerized fixed-version fixture of the Rust client +//! (`fluss_test_cluster::FlussTestingClusterBuilder`), reused rather than reimplemented, so both projects +//! start a test cluster the same way. `FLUSS_IMAGE` and `FLUSS_VERSION` override the image. +//! +//! # Why this is gated and not enabled yet +//! +//! The suite needs a Docker daemon, so it sits behind the `integration_tests` feature (the name FIP-49's +//! test plan uses) and is compiled away by default. CI has a matching job that only runs on +//! `workflow_dispatch`; enabling it on every pull request is a one-line change there once the gateway has +//! behaviour worth checking against a cluster. +//! +//! # What arrives here next +//! +//! The gateway cannot be pointed at the cluster yet: `gateway.cluster..bootstrap.servers` is part of +//! the FlussBackend capability, so the configuration schema has no key for it. This suite therefore proves +//! today that the fixture and the gateway process come up together and shut down cleanly, and it is where +//! the FIP-49 cluster scenarios land as the capabilities arrive — REST write against a real cluster, +//! reconnection after a TabletServer is killed, partitioned-table lifecycle, dual-cluster routing, and KV +//! backpressure. Each of those is a test function added below, not new infrastructure. + +mod support; + +use fluss_test_cluster::FlussTestingClusterBuilder; +use std::time::Duration; +use support::{Api, ChildGuard, await_http_ok, binary, free_port, write_config}; + +/// Port 19123 keeps the fixture clear of the fluss-rs integration suite's default cluster on 9123: the +/// fixture binds fixed host ports, not ephemeral ones. +const CLUSTER_PORT: u16 = 19123; + +#[tokio::test] +async fn the_gateway_serves_alongside_a_real_cluster_and_shuts_down_cleanly() { + let mut builder = + FlussTestingClusterBuilder::new("gateway-e2e-cluster").with_port(CLUSTER_PORT); + let cluster = builder.build().await; + + // Bare host:port, e.g. "127.0.0.1:19123" — no scheme prefix to strip. Connect with the async client: + // every wait in this test has to yield, or it stalls the runtime the fixture also runs on. + let bootstrap = cluster.plaintext_bootstrap_servers().to_string(); + let reachable = tokio::time::timeout( + Duration::from_secs(10), + tokio::net::TcpStream::connect(&bootstrap), + ) + .await; + assert!( + matches!(reachable, Ok(Ok(_))), + "fixture bootstrap {bootstrap} accepts TCP connections" + ); + + let directory = tempfile::tempdir().expect("tempdir"); + let port = free_port(); + // TODO: pass `bootstrap` through `gateway.cluster.default.bootstrap.servers` once the FlussBackend + // capability adds that option, and assert a write lands in the cluster. + let config = write_config(&directory, port); + let mut gateway = ChildGuard( + binary() + .arg("--config") + .arg(&config) + .spawn() + .expect("spawn the gateway"), + ); + + let base = format!("http://127.0.0.1:{port}"); + assert!( + await_http_ok(&format!("{base}/health"), Duration::from_secs(15)).await, + "the gateway serves /health while the cluster runs" + ); + let health = Api::new(base).get_ok("/health").await; + assert_eq!(health["status"], "ok"); + + gateway.send_sigterm(); + assert_eq!( + gateway.wait_for_exit(Duration::from_secs(35)).await.code(), + Some(0), + "the gateway drains and exits 0 with the cluster still up" + ); + cluster.stop(); +} diff --git a/fluss-gateway/tests/e2e_harness.rs b/fluss-gateway/tests/e2e_harness.rs deleted file mode 100644 index e69cbf50ca3..00000000000 --- a/fluss-gateway/tests/e2e_harness.rs +++ /dev/null @@ -1,53 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -#![cfg(feature = "integration_tests")] - -//! Self-test of the shared end-to-end harness: starts the fixed-version dockerized Fluss cluster -//! and proves the test environment can reach it. The gateway's own production connection to Fluss -//! is exercised by the authentication/service-identity capability, not here. -//! -//! Gated behind `integration_tests` because it needs Docker. The CI gate runs it with -//! `cargo test --features integration_tests --test e2e_harness` and fails loudly when the fixture -//! cannot start — a selected scenario must never skip silently. -//! -//! TODO: once the gateway connects to Fluss, move its integration suites onto the server image that -//! `client-integration.yml` builds from the current source tree, so they verify the gateway against -//! this revision of the server rather than only against a released fixture image. - -use fluss_test_cluster::FlussTestingClusterBuilder; -use std::net::TcpStream; -use std::time::Duration; - -#[tokio::test] -async fn the_fixed_version_fluss_fixture_starts_and_is_reachable() { - // Port 19123 keeps the fixture clear of the fluss-rs integration suite's - // default cluster on 9123 (host ports are fixed, not ephemeral). - let mut builder = FlussTestingClusterBuilder::new("gateway-harness-selftest").with_port(19123); - let cluster = builder.build().await; - // Bare host:port, e.g. "127.0.0.1:19123" — no scheme prefix to strip. - let address = cluster.plaintext_bootstrap_servers().to_string(); - let reachable = TcpStream::connect_timeout( - &address.parse().expect("bootstrap address parses"), - Duration::from_secs(10), - ); - assert!( - reachable.is_ok(), - "fixture bootstrap {address} accepts TCP connections" - ); - cluster.stop(); -} diff --git a/fluss-gateway/tests/http_api.rs b/fluss-gateway/tests/http_api.rs index bf287f81fb1..ea5ac75f960 100644 --- a/fluss-gateway/tests/http_api.rs +++ b/fluss-gateway/tests/http_api.rs @@ -65,8 +65,42 @@ async fn an_unknown_route_returns_the_shared_error_envelope() { assert!(response.headers().contains_key("x-request-id")); let body: serde_json::Value = response.json().await.expect("JSON body"); assert_eq!(body["error"]["code"], "not_found"); - assert_eq!(body["error"]["retryable"], false); assert!(body["error"]["request_id"].as_str().is_some()); + assert_eq!( + body["error"].as_object().expect("error object").len(), + 3, + "the FIP-49 envelope carries code, message, and the correlating request id: {body}" + ); + + gateway.shutdown().await.expect("clean shutdown"); +} + +/// The duration families are exported as Prometheus histograms, which aggregate across gateway instances. +/// Without explicit buckets the exporter emits pre-computed summary quantiles instead, which do not. +#[tokio::test] +async fn request_durations_are_exported_as_histograms() { + let gateway = support::start_gateway_with_metrics().await; + let api = Api::new(format!("http://{}", gateway.local_addr())); + let metrics_address = gateway + .metrics_addr() + .expect("the metrics listener is bound"); + + api.get_ok("/health").await; + let exposition = Api::new(format!("http://{metrics_address}")) + .get("/metrics") + .await + .text() + .await + .expect("metrics body"); + + assert!( + exposition.contains("# TYPE fluss_gateway_rest_request_duration_seconds histogram"), + "duration is a histogram: {exposition}" + ); + assert!( + exposition.contains("fluss_gateway_rest_request_duration_seconds_bucket"), + "histogram buckets are exported: {exposition}" + ); gateway.shutdown().await.expect("clean shutdown"); } diff --git a/fluss-gateway/tests/process.rs b/fluss-gateway/tests/process.rs index 0fb3d6694c1..80ca730d995 100644 --- a/fluss-gateway/tests/process.rs +++ b/fluss-gateway/tests/process.rs @@ -17,65 +17,17 @@ //! End-to-end checks of the compiled binary: startup, health, SIGTERM draining, and exit codes. //! -//! These spawn the real `fluss-gateway` executable (CARGO_BIN_EXE), so they exercise CLI parsing, -//! config loading, logging setup, and the production lifecycle exactly as an operator would. +//! These spawn the real `fluss-gateway` executable, so they exercise CLI parsing, config loading, logging +//! setup, and the production lifecycle exactly as an operator would. No Fluss cluster is involved; the +//! suite that adds one lives in `e2e_cluster.rs`. -use std::io::Write; -use std::process::{Child, Command}; -use std::time::{Duration, Instant}; +mod support; -fn binary() -> Command { - Command::new(env!("CARGO_BIN_EXE_fluss-gateway")) -} - -/// Polls `url` until it answers 200 or the deadline passes. -fn await_http_ok(url: &str, deadline: Duration) -> bool { - let start = Instant::now(); - while start.elapsed() < deadline { - if let Ok(response) = reqwest::blocking::get(url) - && response.status() == 200 - { - return true; - } - std::thread::sleep(Duration::from_millis(50)); - } - false -} - -fn write_config(dir: &tempfile::TempDir, port: u16) -> std::path::PathBuf { - let path = dir.path().join("gateway.yaml"); - let mut file = std::fs::File::create(&path).expect("config file"); - writeln!(file, "gateway.rest.listen: 127.0.0.1:{port}").expect("write"); - writeln!(file, "gateway.metrics.enabled: false").expect("write"); - path -} - -fn free_port() -> u16 { - std::net::TcpListener::bind("127.0.0.1:0") - .expect("bind") - .local_addr() - .expect("addr") - .port() -} - -fn send_sigterm(child: &Child) { - // SAFETY: kill(2) with a live child pid owned by this test. - unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }; -} - -/// Kills the child on drop so a failing assertion never leaks a running gateway -/// that could hold its port into later tests. -struct ChildGuard(Child); - -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} +use std::time::Duration; +use support::{ChildGuard, await_http_ok, binary, free_port, write_config}; -#[test] -fn an_invalid_configuration_fails_before_binding_with_exit_code_2() { +#[tokio::test] +async fn an_invalid_configuration_fails_before_binding_with_exit_code_2() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("gateway.yaml"); std::fs::write(&path, "gateway.unknown.key: true\n").expect("write"); @@ -88,8 +40,8 @@ fn an_invalid_configuration_fails_before_binding_with_exit_code_2() { ); } -#[test] -fn the_binary_starts_serves_health_and_drains_on_sigterm_with_exit_code_0() { +#[tokio::test] +async fn the_binary_starts_serves_health_and_drains_on_sigterm_with_exit_code_0() { let dir = tempfile::tempdir().expect("tempdir"); let port = free_port(); let config = write_config(&dir, port); @@ -103,26 +55,16 @@ fn the_binary_starts_serves_health_and_drains_on_sigterm_with_exit_code_0() { let mut guard = ChildGuard(child); let base = format!("http://127.0.0.1:{port}"); assert!( - await_http_ok(&format!("{base}/health"), Duration::from_secs(15)), + await_http_ok(&format!("{base}/health"), Duration::from_secs(15)).await, "health" ); - send_sigterm(&guard.0); - let start = Instant::now(); - let status = loop { - if let Some(status) = guard.0.try_wait().expect("wait") { - break status; - } - assert!( - start.elapsed() < Duration::from_secs(35), - "SIGTERM drain finished in time" - ); - std::thread::sleep(Duration::from_millis(100)); - }; + guard.send_sigterm(); + let status = guard.wait_for_exit(Duration::from_secs(35)).await; assert_eq!(status.code(), Some(0), "clean drain exits 0"); } -#[test] -fn a_bind_conflict_fails_serving_with_exit_code_1() { +#[tokio::test] +async fn a_bind_conflict_fails_serving_with_exit_code_1() { let holder = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); let port = holder.local_addr().expect("addr").port(); let dir = tempfile::tempdir().expect("tempdir"); diff --git a/fluss-gateway/tests/support/mod.rs b/fluss-gateway/tests/support/mod.rs index 110c4311e04..44b8c2b33ce 100644 --- a/fluss-gateway/tests/support/mod.rs +++ b/fluss-gateway/tests/support/mod.rs @@ -15,10 +15,10 @@ // specific language governing permissions and limitations // under the License. -//! HTTP helpers shared by the gateway test suites. +//! HTTP and process helpers shared by the gateway test suites. //! -//! The suites differ only in what serves the requests — an in-process gateway or the compiled binary — -//! so the client side lives here once. +//! The suites differ only in what serves the requests — an in-process gateway, the compiled binary, or the +//! binary alongside a dockerized Fluss cluster — so the client side and the process handling live here once. // Each test binary uses a different subset of these helpers. #![allow(dead_code)] @@ -26,6 +26,9 @@ use fluss_gateway::config::GatewayConfig; use fluss_gateway::lifecycle::RunningGateway; use serde_json::Value; +use std::io::Write; +use std::process::{Child, Command, ExitStatus}; +use std::time::{Duration, Instant}; /// A thin REST client bound to one gateway base URL. /// @@ -68,10 +71,97 @@ impl Api { /// Starts an in-process gateway over `lifecycle::start` with an ephemeral port and no metrics listener. pub async fn start_gateway() -> RunningGateway { + start(false).await +} + +/// Starts an in-process gateway with the Prometheus listener bound to an ephemeral port. +pub async fn start_gateway_with_metrics() -> RunningGateway { + start(true).await +} + +async fn start(metrics: bool) -> RunningGateway { let mut config = GatewayConfig::default(); config.server.rest.bind_address = "127.0.0.1:0".parse().expect("valid"); - config.server.metrics.enabled = false; + config.server.metrics.enabled = metrics; + config.server.metrics.bind_address = "127.0.0.1:0".parse().expect("valid"); fluss_gateway::lifecycle::start(config) .await .expect("gateway starts") } + +/// A command that runs the compiled gateway executable, so the suites exercise CLI parsing, configuration +/// loading, logging setup, and the production lifecycle exactly as an operator would. +pub fn binary() -> Command { + Command::new(env!("CARGO_BIN_EXE_fluss-gateway")) +} + +/// Polls `url` until it answers 200 or the deadline passes. +/// +/// Async on purpose, and the only variant: `reqwest::blocking` panics with "Cannot drop a runtime in a +/// context where blocking is not allowed" when it is called from inside a tokio context, so a synchronous +/// helper is a trap for any suite that later becomes a `#[tokio::test]`. +pub async fn await_http_ok(url: &str, deadline: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < deadline { + if reqwest::get(url) + .await + .is_ok_and(|response| response.status() == 200) + { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +/// Writes a `gateway.yaml` that serves REST on `port` with the metrics listener off. +pub fn write_config(dir: &tempfile::TempDir, port: u16) -> std::path::PathBuf { + let path = dir.path().join("gateway.yaml"); + let mut file = std::fs::File::create(&path).expect("config file"); + writeln!(file, "gateway.rest.listen: 127.0.0.1:{port}").expect("write"); + writeln!(file, "gateway.metrics.enabled: false").expect("write"); + path +} + +/// A port that was free a moment ago; the gateway binds it as a real listener afterwards. +pub fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind") + .local_addr() + .expect("addr") + .port() +} + +/// Kills the child on drop so a failing assertion never leaks a running gateway that could hold its port +/// into later tests. +pub struct ChildGuard(pub Child); + +impl ChildGuard { + /// Asks the gateway to shut down the way an orchestrator would. + pub fn send_sigterm(&self) { + // SAFETY: kill(2) with a live child pid owned by this test. + unsafe { libc::kill(self.0.id() as i32, libc::SIGTERM) }; + } + + /// Waits for the process to exit, failing the test if it outlasts `within`. + pub async fn wait_for_exit(&mut self, within: Duration) -> ExitStatus { + let start = Instant::now(); + loop { + if let Some(status) = self.0.try_wait().expect("wait") { + return status; + } + assert!( + start.elapsed() < within, + "the gateway exited within {within:?}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} From 012d60f1489b917066a28899935a3bb4897a1e3b Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Fri, 14 Aug 2026 18:05:43 +0800 Subject: [PATCH 08/15] [gateway] Check in the dependency license inventory --- fluss-gateway/DEPENDENCIES.rust.tsv | 142 ++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 fluss-gateway/DEPENDENCIES.rust.tsv diff --git a/fluss-gateway/DEPENDENCIES.rust.tsv b/fluss-gateway/DEPENDENCIES.rust.tsv new file mode 100644 index 00000000000..23e842220b7 --- /dev/null +++ b/fluss-gateway/DEPENDENCIES.rust.tsv @@ -0,0 +1,142 @@ +crate Apache-2.0 Apache-2.0 WITH LLVM-exception BSD-2-Clause BSD-3-Clause BSL-1.0 LGPL-2.1-or-later MIT Unicode-3.0 Unlicense Zlib +aho-corasick@1.1.5 X X +anstream@1.0.0 X X +anstyle@1.0.14 X X +anstyle-parse@1.0.0 X X +anstyle-query@1.1.5 X X +anstyle-wincon@3.0.11 X X +atomic-waker@1.1.2 X X +axum@0.8.9 X +axum-core@0.5.6 X +base64@0.22.1 X X +bitflags@2.13.1 X X +bumpalo@3.20.3 X X +bytes@1.12.1 X +cfg-if@1.0.4 X X +clap@4.6.6 X X +clap_builder@4.6.6 X X +clap_derive@4.6.4 X X +clap_lex@1.1.0 X X +colorchoice@1.0.5 X X +crossbeam-epoch@0.9.20 X X +crossbeam-utils@0.8.22 X X +equivalent@1.0.2 X X +errno@0.3.14 X X +fluss-gateway@1.0.0 X +fnv@1.0.7 X X +foldhash@0.2.0 X +futures-channel@0.3.34 X X +futures-core@0.3.34 X X +futures-io@0.3.34 X X +futures-macro@0.3.34 X X +futures-sink@0.3.34 X X +futures-task@0.3.34 X X +futures-util@0.3.34 X X +getrandom@0.3.4 X X +getrandom@0.4.3 X X +h2@0.4.15 X +hashbrown@0.16.1 X X +hashbrown@0.17.1 X X +heck@0.5.0 X X +http@1.5.0 X X +http-body@1.1.0 X +http-body-util@0.1.5 X +httparse@1.10.1 X X +httpdate@1.0.3 X X +hyper@1.11.0 X +hyper-util@0.1.20 X +indexmap@2.14.0 X X +ipnet@2.12.1 X X +is_terminal_polyfill@1.70.2 X X +itoa@1.0.18 X X +js-sys@0.3.104 X X +libc@0.2.189 X X +lock_api@0.4.14 X X +log@0.4.33 X X +matchit@0.8.4 X X +memchr@2.8.3 X X +metrics@0.24.6 X +metrics-exporter-prometheus@0.17.2 X +metrics-util@0.20.4 X +mime@0.3.17 X X +mio@1.2.2 X +once_cell@1.21.4 X X +once_cell_polyfill@1.70.2 X X +parking_lot@0.12.5 X X +parking_lot_core@0.9.12 X X +paste@1.0.15 X X +percent-encoding@2.3.2 X X +pin-project-lite@0.2.17 X X +portable-atomic@1.15.0 X X +ppv-lite86@0.2.21 X X +proc-macro2@1.0.107 X X +quanta@0.12.6 X +quote@1.0.47 X X +r-efi@5.3.0 X X X +r-efi@6.0.0 X X X +rand@0.9.5 X X +rand_chacha@0.9.0 X X +rand_core@0.10.1 X X +rand_core@0.9.5 X X +rand_xoshiro@0.7.0 X X +rapidhash@4.5.1 X X +raw-cpuid@11.6.0 X +redox_syscall@0.5.18 X +regex@1.13.1 X X +regex-automata@0.4.18 X X +regex-syntax@0.8.11 X X +rustversion@1.0.23 X X +ryu@1.0.23 X X +scopeguard@1.2.0 X X +serde@1.0.229 X X +serde_core@1.0.229 X X +serde_derive@1.0.229 X X +serde_json@1.0.151 X X +serde_path_to_error@0.1.20 X X +serde_yaml_ng@0.10.0 X +signal-hook-registry@1.4.8 X X +sketches-ddsketch@0.3.1 X +slab@0.4.12 X +smallvec@1.15.2 X X +socket2@0.6.5 X X +strsim@0.11.1 X +syn@2.0.119 X X +syn@3.0.3 X X +sync_wrapper@1.0.2 X +thiserror@2.0.20 X X +thiserror-impl@2.0.20 X X +tokio@1.53.1 X +tokio-macros@2.7.2 X +tokio-util@0.7.19 X +tower@0.5.3 X +tower-layer@0.3.3 X +tower-service@0.3.3 X +tracing@0.1.44 X +tracing-attributes@0.1.31 X +tracing-core@0.1.36 X +try-lock@0.2.5 X +unicode-ident@1.0.24 X X X +unsafe-libyaml@0.2.11 X +utf8parse@0.2.2 X X +utoipa@5.5.0 X X +utoipa-axum@0.2.0 X X +utoipa-gen@5.5.0 X X +uuid@1.24.0 X X +value-bag@1.13.2 X X +want@0.3.1 X +wasi@0.11.1+wasi-snapshot-preview1 X X X +wasip2@1.0.1+wasi-0.2.4 X X X +wasm-bindgen@0.2.127 X X +wasm-bindgen-macro@0.2.127 X X +wasm-bindgen-macro-support@0.2.127 X X +wasm-bindgen-shared@0.2.127 X X +web-sys@0.3.104 X X +winapi@0.3.9 X X +winapi-i686-pc-windows-gnu@0.4.0 X X +winapi-x86_64-pc-windows-gnu@0.4.0 X X +windows-link@0.2.1 X X +windows-sys@0.61.2 X X +wit-bindgen@0.46.0 X X X +zerocopy@0.8.56 X X X +zerocopy-derive@0.8.56 X X X +zmij@1.0.23 X From cbd4ab8f4b35f7955ae5d7cdd5c8f4812606bc8a Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Fri, 14 Aug 2026 18:26:25 +0800 Subject: [PATCH 09/15] [gateway] Exclude fluss-gateway from the RAT scan like fluss-rust --- pom.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 695e4f146fe..ae0e5cf5820 100644 --- a/pom.xml +++ b/pom.xml @@ -683,8 +683,9 @@ website/static/** website/build/** website/node_modules/** - + fluss-rust/** + fluss-gateway/** From 06a9e0885ba2a327ddc41e9d631facc1315c81d2 Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Fri, 14 Aug 2026 22:11:49 +0800 Subject: [PATCH 10/15] [build] Consolidate the gateway CI into gateway-ci.yml and restore the rust workflows Move the gateway jobs out of rust-build-and-test.yml and rust-license-and-format.yml into the dedicated gateway-ci.yml introduced in #4002, and install protoc in every compiling job: the gateway now depends on fluss-rs, whose proto codegen needs protoc, which the scaffold-era workflow steps did not install. The rust workflows are restored to their upstream state so gateway changes never build the fluss-rust workspace and vice versa. --- .github/workflows/gateway-ci.yml | 63 ++++++++++ .github/workflows/rust-build-and-test.yml | 116 ------------------ .github/workflows/rust-license-and-format.yml | 51 -------- 3 files changed, 63 insertions(+), 167 deletions(-) diff --git a/.github/workflows/gateway-ci.yml b/.github/workflows/gateway-ci.yml index f1a897e8019..ed1ee44d755 100644 --- a/.github/workflows/gateway-ci.yml +++ b/.github/workflows/gateway-ci.yml @@ -57,6 +57,13 @@ jobs: steps: - uses: actions/checkout@v6 + # The dockerized cluster fixture is an unconditional dev-dependency, so it is part of the + # `--all-targets` graph and its proto codegen runs even when the e2e suites are compiled away. + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Rust Cache uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: @@ -71,6 +78,14 @@ jobs: RUST_LOG: DEBUG RUST_BACKTRACE: full + - name: OpenAPI drift check + run: | + cargo test --lib protocol::rest::openapi::tests::export_checked_in_document -- --ignored --exact + git diff --exit-code openapi.yaml + + - name: OpenAPI external validation + run: npx --yes @redocly/cli@1 lint openapi.yaml + msrv: name: "Gateway MSRV (1.88)" # The rust-toolchain.toml floats to stable, so a plain build never catches use of features newer @@ -80,6 +95,11 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Install the MSRV toolchain run: rustup toolchain install 1.88.0 --profile minimal @@ -110,6 +130,13 @@ jobs: - name: Check dependency licenses (Apache-compatible) run: cargo deny check licenses + # The dockerized cluster fixture is an unconditional dev-dependency, so `--all-targets` builds its + # proto codegen even when the e2e suites are compiled away. + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Rust Cache uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: @@ -125,3 +152,39 @@ jobs: run: cargo doc --no-deps env: RUSTDOCFLAGS: -D warnings + + e2e-cluster: + name: "Gateway E2E (dockerized Fluss cluster)" + # Not a pull-request gate yet: the gateway does not talk to Fluss until the FlussBackend capability + # lands, so the suite would spend minutes pulling a Docker image to check the startup and draining the + # process suite already covers without one. Run it from the Actions tab meanwhile; to make it a + # required gate later, delete the `if:` below. + if: github.event_name == 'workflow_dispatch' + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Rust Cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: fluss-gateway + + # A selected scenario must exist: an empty test list means the suite was compiled away, which is a + # silent skip and therefore a failure. + - name: Assert the cluster scenarios are selected + run: | + count=$(cargo test --features integration_tests --test e2e_cluster -- --list 2>/dev/null | grep -c ': test$') + echo "selected scenarios: $count" + test "$count" -ge 1 + + - name: Run the cluster suite + run: cargo test --features integration_tests --test e2e_cluster + env: + RUST_LOG: DEBUG + RUST_BACKTRACE: full diff --git a/.github/workflows/rust-build-and-test.yml b/.github/workflows/rust-build-and-test.yml index 92d66861c3b..d59fadce592 100644 --- a/.github/workflows/rust-build-and-test.yml +++ b/.github/workflows/rust-build-and-test.yml @@ -28,7 +28,6 @@ on: - 'fluss-rust/rust-toolchain.toml' - 'fluss-rust/.cargo/**' - 'fluss-rpc/src/main/proto/**' - - 'fluss-gateway/**' - '.github/workflows/rust-build-and-test.yml' pull_request: branches: @@ -40,7 +39,6 @@ on: - 'fluss-rust/rust-toolchain.toml' - 'fluss-rust/.cargo/**' - 'fluss-rpc/src/main/proto/**' - - 'fluss-gateway/**' - '.github/workflows/rust-build-and-test.yml' workflow_dispatch: @@ -82,117 +80,3 @@ jobs: env: RUST_LOG: DEBUG RUST_BACKTRACE: full - - gateway-build-and-unit-test: - name: "Gateway Build and Tests" - timeout-minutes: 60 - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: - - ubuntu-latest - - macos-latest - # fluss-gateway is its own Cargo workspace, so this job must override the - # workflow-level fluss-rust working directory. Without this it would silently - # build the client workspace and pass without testing the gateway at all. - defaults: - run: - working-directory: fluss-gateway - steps: - - uses: actions/checkout@v6 - - # The dockerized cluster fixture is an unconditional dev-dependency, so it is part of the - # `--all-targets` graph and its proto codegen runs even when the e2e suites are compiled away. - - name: Install protoc - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Rust Cache - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - workspaces: fluss-gateway - - - name: Build - run: cargo build --all-targets - - - name: Unit Test - run: cargo test --all-targets - env: - RUST_LOG: DEBUG - RUST_BACKTRACE: full - - - name: OpenAPI drift check - run: | - cargo test --lib protocol::rest::openapi::tests::export_checked_in_document -- --ignored --exact - git diff --exit-code openapi.yaml - - - name: OpenAPI external validation - run: npx --yes @redocly/cli@1 lint openapi.yaml - - gateway-msrv: - name: "Gateway MSRV (1.88)" - # The rust-toolchain.toml floats to stable, so a plain build never catches use of features newer - # than the declared rust-version. Pin the MSRV explicitly here; `+toolchain` overrides the file. - timeout-minutes: 60 - runs-on: ubuntu-latest - defaults: - run: - working-directory: fluss-gateway - steps: - - uses: actions/checkout@v6 - - - name: Install protoc - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Install the MSRV toolchain - run: rustup toolchain install 1.88.0 --profile minimal - - - name: Rust Cache - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - workspaces: fluss-gateway - - - name: Check on the declared MSRV - run: cargo +1.88.0 check --all-targets - - gateway-e2e-cluster: - name: "Gateway E2E (dockerized Fluss cluster)" - # Not a pull-request gate yet: the gateway does not talk to Fluss until the FlussBackend capability - # lands, so the suite would spend minutes pulling a Docker image to check the startup and draining the - # process suite already covers without one. Run it from the Actions tab meanwhile; to make it a - # required gate later, delete the `if:` below. - if: github.event_name == 'workflow_dispatch' - timeout-minutes: 30 - runs-on: ubuntu-latest - defaults: - run: - working-directory: fluss-gateway - steps: - - uses: actions/checkout@v6 - - - name: Install protoc - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Rust Cache - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - workspaces: fluss-gateway - - # A selected scenario must exist: an empty test list means the suite was compiled away, which is a - # silent skip and therefore a failure. - - name: Assert the cluster scenarios are selected - run: | - count=$(cargo test --features integration_tests --test e2e_cluster -- --list 2>/dev/null | grep -c ': test$') - echo "selected scenarios: $count" - test "$count" -ge 1 - - - name: Run the cluster suite - run: cargo test --features integration_tests --test e2e_cluster - env: - RUST_LOG: DEBUG - RUST_BACKTRACE: full diff --git a/.github/workflows/rust-license-and-format.yml b/.github/workflows/rust-license-and-format.yml index 36d8c2b7a76..2c2d4f6b413 100644 --- a/.github/workflows/rust-license-and-format.yml +++ b/.github/workflows/rust-license-and-format.yml @@ -31,7 +31,6 @@ on: - 'fluss-rust/rustfmt.toml' - 'fluss-rust/rust-toolchain.toml' - 'fluss-rpc/src/main/proto/**' - - 'fluss-gateway/**' - '.github/workflows/rust-license-and-format.yml' pull_request: branches: @@ -46,7 +45,6 @@ on: - 'fluss-rust/rustfmt.toml' - 'fluss-rust/rust-toolchain.toml' - 'fluss-rpc/src/main/proto/**' - - 'fluss-gateway/**' - '.github/workflows/rust-license-and-format.yml' workflow_dispatch: @@ -98,52 +96,3 @@ jobs: run: cargo doc --workspace --no-deps --exclude fluss_python env: RUSTDOCFLAGS: -D warnings - - gateway-check-license-and-formatting: - name: "Gateway License and Formatting Check" - runs-on: ubuntu-latest - # fluss-gateway is its own Cargo workspace, so this job must override the - # workflow-level fluss-rust working directory. Without this it would silently - # lint the client workspace and pass without checking the gateway at all. - # `uses:` step inputs stay relative to the repository root. - defaults: - run: - working-directory: fluss-gateway - steps: - - uses: actions/checkout@v6 - - - name: Check License Header - uses: apache/skywalking-eyes/header@61275cc80d0798a405cb070f7d3a8aaf7cf2c2c1 # v0.8.0 - with: - config: fluss-gateway/.licenserc.yaml - - - name: Install cargo-deny - uses: taiki-e/install-action@v2 - with: - tool: cargo-deny@0.14.22 - - - name: Check dependency licenses (Apache-compatible) - run: cargo deny check licenses - - # The dockerized cluster fixture is an unconditional dev-dependency, so `--all-targets` builds its - # proto codegen even when the e2e suites are compiled away. - - name: Install protoc - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Rust Cache - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - workspaces: fluss-gateway - - - name: Format - run: cargo fmt --all -- --check - - - name: Clippy - run: cargo clippy --all-targets -- -D warnings - - - name: Rustdoc - run: cargo doc --no-deps - env: - RUSTDOCFLAGS: -D warnings From 8ee18e63162f8b27b9c0f400935af6aaf0ca621b Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Fri, 14 Aug 2026 22:35:46 +0800 Subject: [PATCH 11/15] [gateway] Defer the dockerized e2e harness to the FlussBackend wiring and slim the gateway CI --- .github/workflows/gateway-ci.yml | 65 +- fluss-gateway/Cargo.lock | 2946 +--------------------------- fluss-gateway/Cargo.toml | 18 +- fluss-gateway/justfile | 4 - fluss-gateway/tests/e2e_cluster.rs | 99 - 5 files changed, 106 insertions(+), 3026 deletions(-) delete mode 100644 fluss-gateway/tests/e2e_cluster.rs diff --git a/.github/workflows/gateway-ci.yml b/.github/workflows/gateway-ci.yml index ed1ee44d755..54c47a5dbeb 100644 --- a/.github/workflows/gateway-ci.yml +++ b/.github/workflows/gateway-ci.yml @@ -15,10 +15,8 @@ # specific language governing permissions and limitations # under the License. -# CI gates for the fluss-gateway module. The gateway is its own Cargo workspace, -# so it gets its own workflow: triggering on gateway changes never builds the -# fluss-rust workspace, and fluss-rust changes never build the gateway. `uses:` -# step inputs stay relative to the repository root. +# Gateway-only CI: gateway changes never build the fluss-rust workspace and vice versa. +# `uses:` step inputs are relative to the repository root. name: Gateway CI on: @@ -57,13 +55,6 @@ jobs: steps: - uses: actions/checkout@v6 - # The dockerized cluster fixture is an unconditional dev-dependency, so it is part of the - # `--all-targets` graph and its proto codegen runs even when the e2e suites are compiled away. - - name: Install protoc - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Rust Cache uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: @@ -88,18 +79,13 @@ jobs: msrv: name: "Gateway MSRV (1.88)" - # The rust-toolchain.toml floats to stable, so a plain build never catches use of features newer - # than the declared rust-version. Pin the MSRV explicitly here; `+toolchain` overrides the file. + # rust-toolchain.toml floats to stable, so a plain build never catches features newer + # than the declared rust-version; `+toolchain` overrides the file here. timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - name: Install protoc - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install the MSRV toolchain run: rustup toolchain install 1.88.0 --profile minimal @@ -130,13 +116,6 @@ jobs: - name: Check dependency licenses (Apache-compatible) run: cargo deny check licenses - # The dockerized cluster fixture is an unconditional dev-dependency, so `--all-targets` builds its - # proto codegen even when the e2e suites are compiled away. - - name: Install protoc - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Rust Cache uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: @@ -152,39 +131,3 @@ jobs: run: cargo doc --no-deps env: RUSTDOCFLAGS: -D warnings - - e2e-cluster: - name: "Gateway E2E (dockerized Fluss cluster)" - # Not a pull-request gate yet: the gateway does not talk to Fluss until the FlussBackend capability - # lands, so the suite would spend minutes pulling a Docker image to check the startup and draining the - # process suite already covers without one. Run it from the Actions tab meanwhile; to make it a - # required gate later, delete the `if:` below. - if: github.event_name == 'workflow_dispatch' - timeout-minutes: 30 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Install protoc - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Rust Cache - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - workspaces: fluss-gateway - - # A selected scenario must exist: an empty test list means the suite was compiled away, which is a - # silent skip and therefore a failure. - - name: Assert the cluster scenarios are selected - run: | - count=$(cargo test --features integration_tests --test e2e_cluster -- --list 2>/dev/null | grep -c ': test$') - echo "selected scenarios: $count" - test "$count" -ge 1 - - - name: Run the cluster suite - run: cargo test --features integration_tests --test e2e_cluster - env: - RUST_LOG: DEBUG - RUST_BACKTRACE: full diff --git a/fluss-gateway/Cargo.lock b/fluss-gateway/Cargo.lock index bd5ce34cac1..bf0adc82dff 100644 --- a/fluss-gateway/Cargo.lock +++ b/fluss-gateway/Cargo.lock @@ -2,20 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "const-random", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.5" @@ -25,15 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -70,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -81,293 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "arrow" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61d285d16bce7d0be61912f7928342b673067b6b7d7ef6cc179258ba7de1fecf" -dependencies = [ - "arrow-arith", - "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-csv", - "arrow-data", - "arrow-ipc", - "arrow-json", - "arrow-ord", - "arrow-row", - "arrow-schema", - "arrow-select", - "arrow-string", -] - -[[package]] -name = "arrow-arith" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "757ef1836251e88222542a7da2623bc1c9cb9e20afefa6db2c41e79991cd91d4" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "chrono", - "num-traits", -] - -[[package]] -name = "arrow-array" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9a4a4b2b5ecd0e04df03471661cb61f28bed3c7fd50994715129b01b2edb97" -dependencies = [ - "ahash", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "chrono", - "half", - "hashbrown 0.17.1", - "libc", - "num-complex", - "num-integer", - "num-traits", -] - -[[package]] -name = "arrow-buffer" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c12b576ef18c1deb80925a248b25ad84f419198d791b8e293fc6aaa60441fe90" -dependencies = [ - "bytes", - "half", - "num-bigint 0.5.1", - "num-traits", -] - -[[package]] -name = "arrow-cast" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68338a9096a5dc9bc11927c58c43a8526d96bf6abd2012ef6c0c9f505991cc79" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-ord", - "arrow-schema", - "arrow-select", - "atoi", - "base64 0.23.1", - "chrono", - "half", - "lexical-core", - "num-traits", - "ryu", -] - -[[package]] -name = "arrow-csv" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25011b52b346407d497ef0030e12b45e4f2d0cc279efc09c4f3d09106db30e36" -dependencies = [ - "arrow-array", - "arrow-cast", - "arrow-schema", - "chrono", - "csv", - "csv-core", - "regex", -] - -[[package]] -name = "arrow-data" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "723fe4aeed7604e00b9883a465af4ff0a0e6c44c03e41a68c3d1cbc403e0e44d" -dependencies = [ - "arrow-buffer", - "arrow-schema", - "half", - "num-integer", - "num-traits", -] - -[[package]] -name = "arrow-ipc" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "149437b14371f5b9ec60f5ddc751483ae99d7a7072653c0075e5e469156eea7b" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "arrow-select", - "flatbuffers", - "lz4_flex", - "zstd", -] - -[[package]] -name = "arrow-json" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f18b9123ccfec418a663f821c9a034af339711678c11ffe00d3ec07da5ff9f7e" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-ord", - "arrow-schema", - "arrow-select", - "chrono", - "half", - "indexmap 2.14.0", - "itoa", - "lexical-core", - "memchr", - "num-traits", - "ryu", - "serde_core", - "serde_json", - "simdutf8", -] - -[[package]] -name = "arrow-ord" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c08dff0686cf23ca4f562803f191ccbeb726dbae6309cd4b4aaf65e0f2c979" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "arrow-select", -] - -[[package]] -name = "arrow-row" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbec439386df71ad570e6758a946111322b9e9dc8db83b5527321f0b4c9119c2" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "half", -] - -[[package]] -name = "arrow-schema" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6fed2ca0d1eade57e811cbe73b98ad50cc08a1183e13b2d2aa43a7df593f40e" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "arrow-select" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "466b19cf75130b891dc1b23a84b343c714c62c64c9c62e365c76aa0ff90a53fb" -dependencies = [ - "ahash", - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "num-traits", -] - -[[package]] -name = "arrow-string" -version = "59.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c838a25bb3691e919e0f617616ac51a4ff8517a952e29ca133cf0c22b2ce65b1" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "arrow-select", - "memchr", - "num-traits", - "regex", - "regex-syntax", -] - -[[package]] -name = "astral-tokio-tar" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" -dependencies = [ - "futures-core", - "libc", - "portable-atomic", - "rustc-hash", - "rustix", - "tokio", - "tokio-stream", - "xattr", -] - -[[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.119", -] - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", + "windows-sys", ] [[package]] @@ -376,12 +67,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - [[package]] name = "axum" version = "0.8.9" @@ -428,226 +113,36 @@ dependencies = [ "tower-service", ] -[[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 = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "bigdecimal" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" -dependencies = [ - "autocfg", - "libm", - "num-bigint 0.4.8", - "num-integer", - "num-traits", - "serde", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - -[[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 = "bollard" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" -dependencies = [ - "async-stream", - "base64 0.22.1", - "bitflags 2.13.1", - "bollard-buildkit-proto", - "bollard-stubs", - "bytes", - "futures-core", - "futures-util", - "hex", - "home", - "http", - "http-body-util", - "hyper", - "hyper-named-pipe", - "hyper-rustls", - "hyper-util", - "hyperlocal", - "log", - "num", - "pin-project-lite", - "rand 0.9.5", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "serde", - "serde_derive", - "serde_json", - "serde_urlencoded", - "thiserror 2.0.20", - "time", - "tokio", - "tokio-stream", - "tokio-util", - "tonic", - "tower-service", - "url", - "winapi", -] - -[[package]] -name = "bollard-buildkit-proto" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" -dependencies = [ - "prost", - "prost-types", - "tonic", - "tonic-prost", - "ureq", -] - -[[package]] -name = "bollard-stubs" -version = "1.52.1-rc.29.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" -dependencies = [ - "base64 0.22.1", - "bollard-buildkit-proto", - "bytes", - "prost", - "serde", - "serde_json", - "serde_repr", - "time", -] - -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "cc" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" -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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "clap" version = "4.6.6" @@ -695,237 +190,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "const-random" -version = "0.1.18" +name = "crossbeam-epoch" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ - "const-random-macro", + "crossbeam-utils", ] [[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - -[[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 = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32c" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" -dependencies = [ - "rustc_version", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.22" +name = "crossbeam-utils" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.119", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "dashmap" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "defmt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror 2.0.20", -] - -[[package]] -name = "delegate" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "serde_core", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", -] - [[package]] name = "displaydoc" version = "0.2.7" @@ -937,55 +215,12 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", -] - -[[package]] -name = "docker_credential" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" -dependencies = [ - "base64 0.22.1", - "serde", - "serde_json", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "either" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" - [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - [[package]] name = "errno" version = "0.3.14" @@ -993,17 +228,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "etcetera" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" -dependencies = [ - "cfg-if", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1012,46 +237,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "ferroid" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" -dependencies = [ - "portable-atomic", - "rand 0.10.2", - "web-time", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[package]] -name = "flatbuffers" -version = "25.12.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" -dependencies = [ - "bitflags 2.13.1", - "rustc_version", -] - [[package]] name = "fluss-gateway" version = "1.0.0" dependencies = [ "axum", "clap", - "fluss-test-cluster", "futures-util", "http-body-util", "libc", @@ -1072,69 +263,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "fluss-rs" -version = "1.0.0" -dependencies = [ - "arrow", - "arrow-schema", - "bigdecimal", - "bitvec", - "byteorder", - "bytes", - "clap", - "crc32c", - "dashmap", - "delegate", - "futures", - "jiff", - "linked-hash-map", - "log", - "metrics", - "opendal", - "ordered-float", - "parking_lot", - "parse-display 0.10.0", - "prost", - "prost-build", - "rand 0.9.5", - "scopeguard", - "serde", - "serde_json", - "snafu", - "strum", - "strum_macros", - "tempfile", - "thiserror 1.0.69", - "tokio", - "url", - "uuid", -] - -[[package]] -name = "fluss-test-cluster" -version = "1.0.0" -dependencies = [ - "clap", - "fluss-rs", - "serde", - "serde_json", - "testcontainers", - "tokio", -] - -[[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 = "foldhash" version = "0.2.0" @@ -1150,27 +278,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.34" @@ -1178,7 +285,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -1187,23 +293,6 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - [[package]] name = "futures-macro" version = "0.3.34" @@ -1233,128 +322,34 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ - "futures-channel", "futures-core", - "futures-io", "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] [[package]] -name = "generic-array" -version = "0.14.7" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "typenum", - "version_check", + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", ] [[package]] name = "getrandom" -version = "0.2.17" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" 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", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", -] - -[[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.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "num-traits", - "zerocopy", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[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 = [ - "foldhash 0.1.5", ] [[package]] @@ -1363,7 +358,7 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1378,30 +373,6 @@ 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 = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "http" version = "1.5.0" @@ -1457,7 +428,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -1469,56 +439,13 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-named-pipe" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" -dependencies = [ - "hex", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", @@ -1535,45 +462,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hyperlocal" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" -dependencies = [ - "hex", - "http-body-util", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.1.1" @@ -1655,12 +543,6 @@ dependencies = [ "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" @@ -1682,17 +564,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -1717,86 +588,12 @@ 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.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jiff" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" -dependencies = [ - "defmt", - "jiff-core", - "jiff-static", - "jiff-tzdb-platform", - "js-sys", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "jiff-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" -dependencies = [ - "defmt", -] - -[[package]] -name = "jiff-static" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" -dependencies = [ - "jiff-core", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - [[package]] name = "js-sys" version = "0.3.104" @@ -1808,81 +605,12 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lexical-core" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" -dependencies = [ - "lexical-parse-float", - "lexical-parse-integer", - "lexical-util", - "lexical-write-float", - "lexical-write-integer", -] - -[[package]] -name = "lexical-parse-float" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" -dependencies = [ - "lexical-parse-integer", - "lexical-util", -] - -[[package]] -name = "lexical-parse-integer" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" -dependencies = [ - "lexical-util", -] - -[[package]] -name = "lexical-util" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" - -[[package]] -name = "lexical-write-float" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" -dependencies = [ - "lexical-util", - "lexical-write-integer", -] - -[[package]] -name = "lexical-write-integer" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" -dependencies = [ - "lexical-util", -] - [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1895,38 +623,11 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[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.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -dependencies = [ - "value-bag", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lz4_flex" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226" -dependencies = [ - "twox-hash", -] [[package]] name = "matchit" @@ -1934,16 +635,6 @@ 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.8.3" @@ -1966,12 +657,12 @@ version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b166dea96003ee2531cf14833efedced545751d800f03535801d833313f8c15" dependencies = [ - "base64 0.22.1", - "indexmap 2.14.0", + "base64", + "indexmap", "metrics", "metrics-util", "quanta", - "thiserror 2.0.20", + "thiserror", ] [[package]] @@ -1985,7 +676,7 @@ dependencies = [ "hashbrown 0.16.1", "metrics", "quanta", - "rand 0.9.5", + "rand", "rand_xoshiro", "rapidhash", "sketches-ddsketch", @@ -2005,245 +696,21 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] -name = "multimap" -version = "0.10.1" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint 0.4.8", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint 0.4.8", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "opendal" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d075ab8a203a6ab4bc1bce0a4b9fe486a72bf8b939037f4b78d95386384bc80a" -dependencies = [ - "anyhow", - "backon", - "base64 0.22.1", - "bytes", - "crc32c", - "futures", - "getrandom 0.2.17", - "http", - "http-body", - "jiff", - "log", - "md-5", - "percent-encoding", - "quick-xml 0.38.4", - "reqsign", - "reqwest", - "serde", - "serde_json", - "tokio", - "url", - "uuid", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "ordered-float" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" -dependencies = [ - "num-traits", - "rand 0.8.7", - "serde", -] - -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown 0.14.5", -] - -[[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 = "parse-display" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" -dependencies = [ - "parse-display-derive 0.9.1", - "regex", - "regex-syntax", -] - -[[package]] -name = "parse-display" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "287d8d3ebdce117b8539f59411e4ed9ec226e0a4153c7f55495c6070d68e6f72" -dependencies = [ - "parse-display-derive 0.10.0", - "regex", - "regex-syntax", -] - -[[package]] -name = "parse-display-derive" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "regex-syntax", - "structmeta", - "syn 2.0.119", -] - -[[package]] -name = "parse-display-derive" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fc048687be30d79502dea2f623d052f3a074012c6eac41726b7ab17213616b1" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "regex-syntax", - "structmeta", - "syn 2.0.119", -] - [[package]] name = "paste" version = "1.0.15" @@ -2256,64 +723,18 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap 2.14.0", -] - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - [[package]] name = "portable-atomic" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -2323,12 +744,6 @@ 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" @@ -2338,16 +753,6 @@ 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.119", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -2357,57 +762,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prost" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-build" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" -dependencies = [ - "heck", - "itertools", - "log", - "multimap", - "petgraph", - "prettyplease", - "prost", - "prost-types", - "regex", - "syn 2.0.119", - "tempfile", -] - -[[package]] -name = "prost-derive" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "prost-types" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" -dependencies = [ - "prost", -] - [[package]] name = "quanta" version = "0.12.6" @@ -2423,82 +777,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.20", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.20", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - [[package]] name = "quote" version = "1.0.47" @@ -2520,53 +798,14 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", - "serde", -] - [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[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", + "rand_chacha", + "rand_core", ] [[package]] @@ -2576,17 +815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", - "serde", + "rand_core", ] [[package]] @@ -2598,28 +827,13 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - [[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.5", + "rand_core", ] [[package]] @@ -2637,36 +851,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "ref-cast" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "bitflags", ] [[package]] @@ -2689,265 +874,71 @@ checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "reqsign" -version = "0.16.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43451dbf3590a7590684c25fb8d12ecdcc90ed3ac123433e500447c7d77ed701" -dependencies = [ - "anyhow", - "async-trait", - "base64 0.22.1", - "chrono", - "form_urlencoded", - "getrandom 0.2.17", - "hex", - "hmac", - "home", - "http", - "log", - "once_cell", - "percent-encoding", - "quick-xml 0.37.5", - "rand 0.8.7", - "reqwest", - "rust-ini", - "serde", - "serde_json", - "sha1", - "sha2", - "tokio", -] - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "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", - "webpki-roots", -] - -[[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.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rust-ini" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.13.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", + "regex-syntax", ] [[package]] -name = "schemars" -version = "0.9.0" +name = "regex-syntax" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "schemars" -version = "1.2.2" +name = "reqwest" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "dyn-clone", - "ref-cast", + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", "serde", "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[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.7.0" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", - "core-foundation", - "core-foundation-sys", + "bitflags", + "errno", "libc", - "security-framework-sys", + "linux-raw-sys", + "windows-sys", ] [[package]] -name = "security-framework-sys" -version = "2.17.0" +name = "rustversion" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] -name = "semver" -version = "1.0.28" +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "serde" @@ -2979,15 +970,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "serde_fmt" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_json" version = "1.0.151" @@ -3012,17 +994,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_repr" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3035,80 +1006,19 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_with" -version = "3.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" -dependencies = [ - "base64 0.22.1", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "jiff", - "schemars 0.9.0", - "schemars 1.2.2", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "serde_yaml_ng" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ - "indexmap 2.14.0", + "indexmap", "itoa", "ryu", "serde", "unsafe-libyaml", ] -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3119,12 +1029,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "sketches-ddsketch" version = "0.3.1" @@ -3143,27 +1047,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "snafu" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" -dependencies = [ - "snafu-derive", -] - -[[package]] -name = "snafu-derive" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "socket2" version = "0.6.5" @@ -3171,7 +1054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -3186,133 +1069,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "structmeta" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" -dependencies = [ - "proc-macro2", - "quote", - "structmeta-derive", - "syn 2.0.119", -] - -[[package]] -name = "structmeta-derive" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.119", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "sval" -version = "2.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4a2a7d92fa86fcc6222e4c3845f8486cff899d9db32480b26c91a5dbf2e22d" - -[[package]] -name = "sval_buffer" -version = "2.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4324db9ac500c609d659b752edf9c8abbf2233f8afd61a503fd6f88ed625032" -dependencies = [ - "sval", - "sval_ref", - "zerocopy", -] - -[[package]] -name = "sval_dynamic" -version = "2.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4046add0eecf55e680b9e207edf5fc7737b18a1d950db363d97e7f1b2d7c629c" -dependencies = [ - "sval", -] - -[[package]] -name = "sval_fmt" -version = "2.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911a3486b5984a0a4f25edefcf2c2dba23654c29f63e75493b671d338bf24243" -dependencies = [ - "itoa", - "ryu", - "sval", -] - -[[package]] -name = "sval_json" -version = "2.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da53aae7c737b5b5f1be4bcb0ff20e057bf6b2ee4e9d025560075c5830d09f95" -dependencies = [ - "itoa", - "ryu", - "sval", -] - -[[package]] -name = "sval_nested" -version = "2.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df24df43cbdc4bb8c9f5ed19d0d57dc8f60a1a4259cdce52d597fe774ad3a71f" -dependencies = [ - "sval", - "sval_buffer", - "sval_ref", -] - -[[package]] -name = "sval_ref" -version = "2.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bebc17f0f1fad060e57b778728d41ef87627e9111a6365d7463472cb58fc1b3" -dependencies = [ - "sval", -] - -[[package]] -name = "sval_serde" -version = "2.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f26fe3f6a68b40e6c8d654ea48c00e4316272fddf68c80493714c1b034ae70b" -dependencies = [ - "serde_core", - "sval", - "sval_nested", -] - [[package]] name = "syn" version = "2.0.119" @@ -3356,132 +1112,36 @@ dependencies = [ ] [[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "testcontainers" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" -dependencies = [ - "astral-tokio-tar", - "async-trait", - "bollard", - "bytes", - "docker_credential", - "either", - "etcetera", - "ferroid", - "futures", - "http", - "itertools", - "log", - "memchr", - "parse-display 0.9.1", - "pin-project-lite", - "serde", - "serde_json", - "serde_with", - "thiserror 2.0.20", - "tokio", - "tokio-stream", - "tokio-util", - "url", -] - -[[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.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", -] - -[[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.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "time" -version = "0.3.55" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", ] [[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" +name = "thiserror" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "num-conv", - "time-core", + "thiserror-impl", ] [[package]] -name = "tiny-keccak" -version = "2.0.2" +name = "thiserror-impl" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ - "crunchy", + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -3494,21 +1154,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -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.53.1" @@ -3518,12 +1163,11 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -3537,27 +1181,6 @@ dependencies = [ "syn 3.0.3", ] -[[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-stream" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-util" version = "0.7.19" @@ -3568,51 +1191,10 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "libc", "pin-project-lite", "tokio", ] -[[package]] -name = "tonic" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" -dependencies = [ - "async-trait", - "axum", - "base64 0.22.1", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "socket2", - "sync_wrapper", - "tokio", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-prost" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" -dependencies = [ - "bytes", - "prost", - "tonic", -] - [[package]] name = "tower" version = "0.5.3" @@ -3621,15 +1203,11 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.14.0", "pin-project-lite", - "slab", "sync_wrapper", "tokio", - "tokio-util", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -3638,7 +1216,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags", "bytes", "futures-util", "http", @@ -3669,21 +1247,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -3699,24 +1265,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "twox-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -3729,39 +1277,6 @@ 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 = "ureq" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" -dependencies = [ - "base64 0.23.1", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", -] - -[[package]] -name = "ureq-proto" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" -dependencies = [ - "base64 0.23.1", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -3772,15 +1287,8 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -3799,7 +1307,7 @@ version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" dependencies = [ - "indexmap 2.14.0", + "indexmap", "serde", "serde_json", "utoipa-gen", @@ -3838,52 +1346,9 @@ checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", - "serde_core", "wasm-bindgen", ] -[[package]] -name = "value-bag" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" -dependencies = [ - "value-bag-serde1", - "value-bag-sval2", -] - -[[package]] -name = "value-bag-serde1" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417d6197dd0ee696783d6be4276ac6ea74b985e00024c85ccfb37aff4f2bed82" -dependencies = [ - "erased-serde", - "serde_core", - "serde_fmt", -] - -[[package]] -name = "value-bag-sval2" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61f7251ecde2c9ed431bbe0659853e7991753447447bbf1ae59d8b31c578d4e" -dependencies = [ - "sval", - "sval_buffer", - "sval_dynamic", - "sval_fmt", - "sval_json", - "sval_ref", - "sval_serde", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "want" version = "0.3.1" @@ -3963,19 +1428,6 @@ 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.104" @@ -3986,25 +1438,6 @@ dependencies = [ "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 = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi" version = "0.3.9" @@ -4027,74 +1460,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[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", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -4104,70 +1475,6 @@ 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", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[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_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[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_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[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_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "wit-bindgen" version = "0.46.0" @@ -4180,25 +1487,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "yoke" version = "0.8.3" @@ -4263,12 +1551,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - [[package]] name = "zerotrie" version = "0.2.4" @@ -4307,31 +1589,3 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[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/fluss-gateway/Cargo.toml b/fluss-gateway/Cargo.toml index 85308bdc2d8..8b246cccf3d 100644 --- a/fluss-gateway/Cargo.toml +++ b/fluss-gateway/Cargo.toml @@ -26,10 +26,8 @@ name = "fluss-gateway" edition = "2024" version = "1.0.0" license = "Apache-2.0" -# The shipped dependency set only needs 1.85, the same floor as fluss-rust. The declared MSRV is 1.88 -# because the dockerized cluster fixture used by the `integration_tests` suites depends on -# testcontainers, which requires 1.88; dev-dependencies cannot be made optional, so they are part of the -# `--all-targets` graph. Dropping back to 1.85 means isolating that fixture, not changing the gateway. +# The shipped dependency set only needs 1.85; the declared floor stays at 1.88 to match fluss-rust, +# which the gateway depends on once the FlussBackend capability lands. rust-version = "1.88" authors = ["Apache Fluss "] repository = "https://github.com/apache/fluss" @@ -48,12 +46,6 @@ path = "src/main.rs" [lib] name = "fluss_gateway" -[features] -# Compiles the end-to-end suites that drive a dockerized Fluss cluster. Mirrors the `integration_tests` -# feature of the fluss-rs crate and the name FIP-49's test plan uses, and is never enabled by default: -# the suites need Docker, so CI runs them on demand rather than on every pull request. -integration_tests = [] - [dependencies] axum = { version = "0.8", default-features = false, features = ["http1", "matched-path", "tokio"] } clap = { version = "4.5.37", features = ["derive"] } @@ -79,9 +71,6 @@ utoipa-axum = "0.2" uuid = { version = "1.10", features = ["v4"] } [dev-dependencies] -# The dockerized fixed-version Fluss cluster of the Rust client, reused as-is so the gateway's end-to-end -# suites and the client's own agree on how a test cluster is started. -fluss-test-cluster = { path = "../fluss-rust/crates/fluss-test-cluster" } http-body-util = "0.1" # No `blocking`: every suite is async, and the blocking client panics when dropped inside a tokio context. reqwest = { version = "0.12", default-features = false, features = ["json"] } @@ -94,6 +83,3 @@ name = "http_api" [[test]] name = "process" - -[[test]] -name = "e2e_cluster" diff --git a/fluss-gateway/justfile b/fluss-gateway/justfile index e4d04e4a6fc..c14e7c3a2fa 100644 --- a/fluss-gateway/justfile +++ b/fluss-gateway/justfile @@ -30,10 +30,6 @@ build-release: test: cargo test --all-targets -# Run the end-to-end suite against a dockerized Fluss cluster. Needs a running Docker daemon. -e2e: - cargo test --features integration_tests --test e2e_cluster - # Format the gateway sources in place. fmt: cargo fmt --all diff --git a/fluss-gateway/tests/e2e_cluster.rs b/fluss-gateway/tests/e2e_cluster.rs deleted file mode 100644 index 28c9173a243..00000000000 --- a/fluss-gateway/tests/e2e_cluster.rs +++ /dev/null @@ -1,99 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -#![cfg(feature = "integration_tests")] - -//! End-to-end suite: the compiled gateway running next to a real Fluss cluster. -//! -//! The cluster is the dockerized fixed-version fixture of the Rust client -//! (`fluss_test_cluster::FlussTestingClusterBuilder`), reused rather than reimplemented, so both projects -//! start a test cluster the same way. `FLUSS_IMAGE` and `FLUSS_VERSION` override the image. -//! -//! # Why this is gated and not enabled yet -//! -//! The suite needs a Docker daemon, so it sits behind the `integration_tests` feature (the name FIP-49's -//! test plan uses) and is compiled away by default. CI has a matching job that only runs on -//! `workflow_dispatch`; enabling it on every pull request is a one-line change there once the gateway has -//! behaviour worth checking against a cluster. -//! -//! # What arrives here next -//! -//! The gateway cannot be pointed at the cluster yet: `gateway.cluster..bootstrap.servers` is part of -//! the FlussBackend capability, so the configuration schema has no key for it. This suite therefore proves -//! today that the fixture and the gateway process come up together and shut down cleanly, and it is where -//! the FIP-49 cluster scenarios land as the capabilities arrive — REST write against a real cluster, -//! reconnection after a TabletServer is killed, partitioned-table lifecycle, dual-cluster routing, and KV -//! backpressure. Each of those is a test function added below, not new infrastructure. - -mod support; - -use fluss_test_cluster::FlussTestingClusterBuilder; -use std::time::Duration; -use support::{Api, ChildGuard, await_http_ok, binary, free_port, write_config}; - -/// Port 19123 keeps the fixture clear of the fluss-rs integration suite's default cluster on 9123: the -/// fixture binds fixed host ports, not ephemeral ones. -const CLUSTER_PORT: u16 = 19123; - -#[tokio::test] -async fn the_gateway_serves_alongside_a_real_cluster_and_shuts_down_cleanly() { - let mut builder = - FlussTestingClusterBuilder::new("gateway-e2e-cluster").with_port(CLUSTER_PORT); - let cluster = builder.build().await; - - // Bare host:port, e.g. "127.0.0.1:19123" — no scheme prefix to strip. Connect with the async client: - // every wait in this test has to yield, or it stalls the runtime the fixture also runs on. - let bootstrap = cluster.plaintext_bootstrap_servers().to_string(); - let reachable = tokio::time::timeout( - Duration::from_secs(10), - tokio::net::TcpStream::connect(&bootstrap), - ) - .await; - assert!( - matches!(reachable, Ok(Ok(_))), - "fixture bootstrap {bootstrap} accepts TCP connections" - ); - - let directory = tempfile::tempdir().expect("tempdir"); - let port = free_port(); - // TODO: pass `bootstrap` through `gateway.cluster.default.bootstrap.servers` once the FlussBackend - // capability adds that option, and assert a write lands in the cluster. - let config = write_config(&directory, port); - let mut gateway = ChildGuard( - binary() - .arg("--config") - .arg(&config) - .spawn() - .expect("spawn the gateway"), - ); - - let base = format!("http://127.0.0.1:{port}"); - assert!( - await_http_ok(&format!("{base}/health"), Duration::from_secs(15)).await, - "the gateway serves /health while the cluster runs" - ); - let health = Api::new(base).get_ok("/health").await; - assert_eq!(health["status"], "ok"); - - gateway.send_sigterm(); - assert_eq!( - gateway.wait_for_exit(Duration::from_secs(35)).await.code(), - Some(0), - "the gateway drains and exits 0 with the cluster still up" - ); - cluster.stop(); -} From aca3309a3596d980f1bfe178af9938da87e5047d Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Sat, 15 Aug 2026 10:24:47 +0800 Subject: [PATCH 12/15] [gateway] Header timeout, /ready, deadline grace, overlap validation --- fluss-gateway/Cargo.lock | 28 +++++++ fluss-gateway/Cargo.toml | 3 + fluss-gateway/DEPENDENCIES.rust.tsv | 10 --- fluss-gateway/openapi.yaml | 38 +++++++++ fluss-gateway/src/config.rs | 97 ++++++++++++++++++++--- fluss-gateway/src/lifecycle.rs | 58 +++++++++++--- fluss-gateway/src/protocol/rest/health.rs | 81 ++++++++++++++++++- fluss-gateway/src/protocol/rest/mod.rs | 97 ++++++++++++++++++++--- fluss-gateway/tests/http_api.rs | 52 ++++++++++++ 9 files changed, 421 insertions(+), 43 deletions(-) diff --git a/fluss-gateway/Cargo.lock b/fluss-gateway/Cargo.lock index bf0adc82dff..325788474d5 100644 --- a/fluss-gateway/Cargo.lock +++ b/fluss-gateway/Cargo.lock @@ -245,6 +245,7 @@ dependencies = [ "clap", "futures-util", "http-body-util", + "hyper-util", "libc", "log", "metrics", @@ -263,6 +264,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -352,6 +359,25 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -428,6 +454,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1191,6 +1218,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] diff --git a/fluss-gateway/Cargo.toml b/fluss-gateway/Cargo.toml index 8b246cccf3d..aac8cb213c4 100644 --- a/fluss-gateway/Cargo.toml +++ b/fluss-gateway/Cargo.toml @@ -48,6 +48,9 @@ name = "fluss_gateway" [dependencies] axum = { version = "0.8", default-features = false, features = ["http1", "matched-path", "tokio"] } +# The listeners follow axum's official serve-with-hyper example: axum::serve does not expose the +# hyper builder that the header read timeout lives on. +hyper-util = { version = "0.1", default-features = false, features = ["server-auto", "server-graceful", "service", "tokio"] } clap = { version = "4.5.37", features = ["derive"] } # Only FutureExt/join_all are used; avoid the larger futures facade crate. futures-util = "0.3" diff --git a/fluss-gateway/DEPENDENCIES.rust.tsv b/fluss-gateway/DEPENDENCIES.rust.tsv index 23e842220b7..0d497037e40 100644 --- a/fluss-gateway/DEPENDENCIES.rust.tsv +++ b/fluss-gateway/DEPENDENCIES.rust.tsv @@ -27,7 +27,6 @@ fnv@1.0.7 X X foldhash@0.2.0 X futures-channel@0.3.34 X X futures-core@0.3.34 X X -futures-io@0.3.34 X X futures-macro@0.3.34 X X futures-sink@0.3.34 X X futures-task@0.3.34 X X @@ -51,7 +50,6 @@ is_terminal_polyfill@1.70.2 X X itoa@1.0.18 X X js-sys@0.3.104 X X libc@0.2.189 X X -lock_api@0.4.14 X X log@0.4.33 X X matchit@0.8.4 X X memchr@2.8.3 X X @@ -62,8 +60,6 @@ mime@0.3.17 X X mio@1.2.2 X once_cell@1.21.4 X X once_cell_polyfill@1.70.2 X X -parking_lot@0.12.5 X X -parking_lot_core@0.9.12 X X paste@1.0.15 X X percent-encoding@2.3.2 X X pin-project-lite@0.2.17 X X @@ -76,18 +72,15 @@ r-efi@5.3.0 X X X r-efi@6.0.0 X X X rand@0.9.5 X X rand_chacha@0.9.0 X X -rand_core@0.10.1 X X rand_core@0.9.5 X X rand_xoshiro@0.7.0 X X rapidhash@4.5.1 X X raw-cpuid@11.6.0 X -redox_syscall@0.5.18 X regex@1.13.1 X X regex-automata@0.4.18 X X regex-syntax@0.8.11 X X rustversion@1.0.23 X X ryu@1.0.23 X X -scopeguard@1.2.0 X X serde@1.0.229 X X serde_core@1.0.229 X X serde_derive@1.0.229 X X @@ -112,7 +105,6 @@ tower@0.5.3 X tower-layer@0.3.3 X tower-service@0.3.3 X tracing@0.1.44 X -tracing-attributes@0.1.31 X tracing-core@0.1.36 X try-lock@0.2.5 X unicode-ident@1.0.24 X X X @@ -122,7 +114,6 @@ utoipa@5.5.0 X X utoipa-axum@0.2.0 X X utoipa-gen@5.5.0 X X uuid@1.24.0 X X -value-bag@1.13.2 X X want@0.3.1 X wasi@0.11.1+wasi-snapshot-preview1 X X X wasip2@1.0.1+wasi-0.2.4 X X X @@ -138,5 +129,4 @@ windows-link@0.2.1 X X windows-sys@0.61.2 X X wit-bindgen@0.46.0 X X X zerocopy@0.8.56 X X X -zerocopy-derive@0.8.56 X X X zmij@1.0.23 X diff --git a/fluss-gateway/openapi.yaml b/fluss-gateway/openapi.yaml index 156aa5832be..c7736a209eb 100644 --- a/fluss-gateway/openapi.yaml +++ b/fluss-gateway/openapi.yaml @@ -73,6 +73,14 @@ components: - status - uptime_ms type: object + ReadyResponse: + description: 'Response of `GET /ready`: the gateway accepts application traffic.' + properties: + status: + type: string + required: + - status + type: object info: description: Stateless REST gateway for Apache Fluss license: @@ -101,6 +109,36 @@ paths: summary: 'The FIP-49 health summary: `{status, uptime_ms}`, always 200 while the process answers.' tags: - health + /ready: + get: + description: |- + `/health` is liveness and stays 200 while the process answers, so a liveness probe never + restarts a draining process; this route reports acceptance instead. Readiness covers the process + lifecycle only: Fluss cluster availability surfaces through request errors and metrics, not by + unloading every gateway instance at once. + operationId: getReady + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ReadyResponse' + description: The gateway accepts application traffic + '405': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Wrong method for this route + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: The gateway is starting or shutting down + summary: Readiness for load balancers and readiness probes. + tags: + - health /v1/openapi.json: get: operationId: getOpenApi diff --git a/fluss-gateway/src/config.rs b/fluss-gateway/src/config.rs index d65bf847d0c..57d26853cca 100644 --- a/fluss-gateway/src/config.rs +++ b/fluss-gateway/src/config.rs @@ -194,6 +194,7 @@ impl<'de> Deserialize<'de> for ByteSize { const INSTANCE_ID_KEY: &str = "gateway.instance-id"; const REST_LISTEN_KEY: &str = "gateway.rest.listen"; +const REST_HEADER_READ_TIMEOUT_KEY: &str = "gateway.rest.header-read-timeout"; const REST_REQUEST_TIMEOUT_KEY: &str = "gateway.rest.write.request-timeout"; const REST_MAX_REQUEST_BYTES_KEY: &str = "gateway.rest.write.max-request-bytes"; const METRICS_ENABLED_KEY: &str = "gateway.metrics.enabled"; @@ -201,6 +202,7 @@ const METRICS_LISTEN_KEY: &str = "gateway.metrics.exporter.prometheus.listen"; const SHUTDOWN_DRAIN_TIMEOUT_KEY: &str = "gateway.shutdown.drain-timeout"; const DEFAULT_REST_LISTEN: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080); +const DEFAULT_REST_HEADER_READ_TIMEOUT: ConfigDuration = ConfigDuration::from_secs(10); const DEFAULT_REST_REQUEST_TIMEOUT: ConfigDuration = ConfigDuration::from_secs(30); const DEFAULT_REST_MAX_REQUEST_BYTES: ByteSize = ByteSize::new(32 * 1024 * 1024); const DEFAULT_METRICS_ENABLED: bool = true; @@ -222,6 +224,10 @@ const CONFIG_ENTRIES: &[ConfigEntry] = &[ key: REST_LISTEN_KEY, internal_path: "server.rest.bind_address", }, + ConfigEntry { + key: REST_HEADER_READ_TIMEOUT_KEY, + internal_path: "server.rest.header_read_timeout", + }, ConfigEntry { key: REST_REQUEST_TIMEOUT_KEY, internal_path: "server.rest.request_timeout", @@ -263,6 +269,10 @@ pub struct ServerConfig { pub struct RestServerConfig { /// Loopback by default because the gateway has no transport security. pub bind_address: SocketAddr, + /// Closes a connection whose request head is not complete within this budget, counted from + /// connection establishment; the per-request deadline cannot defend here, as it runs only after + /// a complete head. + pub header_read_timeout: ConfigDuration, /// Per-request server-side deadline. Exceeding it yields 504. pub request_timeout: ConfigDuration, /// Maximum accepted request body size. Exceeding it yields 413. @@ -273,6 +283,7 @@ impl Default for RestServerConfig { fn default() -> Self { Self { bind_address: DEFAULT_REST_LISTEN, + header_read_timeout: DEFAULT_REST_HEADER_READ_TIMEOUT, request_timeout: DEFAULT_REST_REQUEST_TIMEOUT, max_body_bytes: DEFAULT_REST_MAX_REQUEST_BYTES, } @@ -281,6 +292,11 @@ impl Default for RestServerConfig { impl RestServerConfig { fn validate(&self, problems: &mut Vec) { + validate_duration( + REST_HEADER_READ_TIMEOUT_KEY, + self.header_read_timeout.get(), + problems, + ); validate_duration( REST_REQUEST_TIMEOUT_KEY, self.request_timeout.get(), @@ -378,15 +394,10 @@ impl GatewayConfig { )); } } - // Port 0 asks the OS for a free port, so two ephemeral listeners never collide even though the - // configured addresses are equal. - if server.metrics.enabled - && server.metrics.bind_address == rest_address - && rest_address.port() != 0 - { + if server.metrics.enabled && addresses_overlap(rest_address, server.metrics.bind_address) { problems.push(format!( - "{} must differ from {}", - METRICS_LISTEN_KEY, REST_LISTEN_KEY + "{} ({}) must differ from {} ({})", + METRICS_LISTEN_KEY, server.metrics.bind_address, REST_LISTEN_KEY, rest_address )); } } @@ -405,6 +416,20 @@ impl GatewayConfig { } } +/// True when two listeners cannot both bind: the addresses are equal, or either is a wildcard +/// (`0.0.0.0`, `::`) claiming the port for its family — `::` for both families, being dual-stack. +/// Port 0 asks the OS for a free port and never collides. +fn addresses_overlap(rest: SocketAddr, metrics: SocketAddr) -> bool { + if rest.port() == 0 || metrics.port() == 0 || rest.port() != metrics.port() { + return false; + } + match (rest.ip(), metrics.ip()) { + (IpAddr::V4(a), IpAddr::V4(b)) => a == b || a.is_unspecified() || b.is_unspecified(), + (IpAddr::V6(a), IpAddr::V6(b)) => a == b || a.is_unspecified() || b.is_unspecified(), + (IpAddr::V6(a), IpAddr::V4(_)) | (IpAddr::V4(_), IpAddr::V6(a)) => a.is_unspecified(), + } +} + fn validate_duration(key: &str, duration: Duration, problems: &mut Vec) { if duration.is_zero() { problems.push(format!("{key} must be greater than zero")); @@ -1006,11 +1031,63 @@ mod tests { .unwrap_err(); assert!(problems(error).iter().any(|problem| { problem.contains( - "gateway.metrics.exporter.prometheus.listen must differ from gateway.rest.listen", + "gateway.metrics.exporter.prometheus.listen (127.0.0.1:9095) must differ from \ + gateway.rest.listen (127.0.0.1:9095)", ) })); } + /// A wildcard listener claims the whole family on the port, so a textual difference between the + /// two addresses is not a usable configuration. + #[test] + fn wildcard_listener_must_differ_from_metrics_on_the_same_port() { + let error = load_file( + "gateway.rest.listen: 0.0.0.0:9095\ngateway.metrics.exporter.prometheus.listen: 127.0.0.1:9095\n", + ) + .unwrap_err(); + assert!(problems(error).iter().any(|problem| { + problem.contains("must differ from gateway.rest.listen (0.0.0.0:9095)") + })); + } + + /// Overlap detection covers the wildcard and dual-stack pairs that differ textually but cannot + /// both bind, plus the pairs that coexist. + #[test] + fn listener_overlap_covers_wildcards_and_dual_stack() { + let clashes = [ + ("0.0.0.0:8080", "127.0.0.1:8080"), + ("127.0.0.1:8080", "0.0.0.0:8080"), + ("0.0.0.0:8080", "0.0.0.0:8080"), + ("[::]:8080", "[::1]:8080"), + ("[::]:8080", "0.0.0.0:8080"), + ("127.0.0.1:8080", "[::]:8080"), + ]; + for (rest, metrics) in clashes { + let rest: SocketAddr = rest.parse().unwrap(); + let metrics: SocketAddr = metrics.parse().unwrap(); + assert!( + addresses_overlap(rest, metrics), + "{rest} and {metrics} cannot both bind" + ); + } + + let coexist = [ + ("127.0.0.1:8080", "192.168.1.2:8080"), + ("127.0.0.1:8080", "[::1]:8080"), + ("0.0.0.0:8080", "127.0.0.1:9095"), + ("0.0.0.0:0", "0.0.0.0:0"), + ("127.0.0.1:0", "0.0.0.0:8080"), + ]; + for (rest, metrics) in coexist { + let rest: SocketAddr = rest.parse().unwrap(); + let metrics: SocketAddr = metrics.parse().unwrap(); + assert!( + !addresses_overlap(rest, metrics), + "{rest} and {metrics} can coexist" + ); + } + } + /// Two ephemeral listeners are not a clash: the OS hands out a different port to each. #[test] fn both_listeners_may_ask_for_an_ephemeral_port() { @@ -1097,6 +1174,6 @@ mod tests { ); } - assert_eq!(CONFIG_ENTRIES.len(), 7); + assert_eq!(CONFIG_ENTRIES.len(), 8); } } diff --git a/fluss-gateway/src/lifecycle.rs b/fluss-gateway/src/lifecycle.rs index fa973f32180..4a1affb4ace 100644 --- a/fluss-gateway/src/lifecycle.rs +++ b/fluss-gateway/src/lifecycle.rs @@ -32,10 +32,11 @@ use axum::http::{HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use futures_util::FutureExt; +use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer}; +use hyper_util::service::TowerToHyperService; use metrics_exporter_prometheus::PrometheusHandle; use std::any::Any; use std::future::Future; -use std::future::IntoFuture; use std::panic::AssertUnwindSafe; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, OnceLock}; @@ -268,19 +269,25 @@ async fn start_internal(config: GatewayConfig) -> Result Result<(), String> { - let server = axum::serve(listener, router).with_graceful_shutdown(async move { - shutdown.cancelled().await; - }); - server - .into_future() - .await - .map_err(|error| error.to_string()) + let graceful = hyper_util::server::graceful::GracefulShutdown::new(); + loop { + let (socket, _remote) = tokio::select! { + biased; + _ = shutdown.cancelled() => break, + accepted = listener.accept() => accepted.map_err(|error| error.to_string())?, + }; + // `axum::serve` sets TCP_NODELAY on accepted sockets; keep that behaviour. + let _ = socket.set_nodelay(true); + let mut builder = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()); + // The header read timeout runs on hyper's background timer, so one must be installed first. + builder + .http1() + .timer(TokioTimer::new()) + .header_read_timeout(header_read_timeout); + let service = TowerToHyperService::new(router.clone()); + // `into_owned` unhooks the connection from the borrowed builder for the `'static` spawn. + let connection = builder + .serve_connection_with_upgrades(TokioIo::new(socket), service) + .into_owned(); + let connection = graceful.watch(connection); + tokio::spawn(async move { + if let Err(error) = connection.await { + log::debug!("connection ended with an error: {error}"); + } + }); + } + // Idle connections close now; in-flight requests run to completion, bounded by the outer drain + // budget that aborts this task. + graceful.shutdown().await; + Ok(()) } /// Builds the isolated Prometheus scrape router. diff --git a/fluss-gateway/src/protocol/rest/health.rs b/fluss-gateway/src/protocol/rest/health.rs index 3c61320f804..9d1f4ffa77a 100644 --- a/fluss-gateway/src/protocol/rest/health.rs +++ b/fluss-gateway/src/protocol/rest/health.rs @@ -15,14 +15,15 @@ // specific language governing permissions and limitations // under the License. -//! Health endpoint. +//! Health endpoints. //! //! `GET /health` returns the FIP-49 `{status, uptime_ms}` shape and answers from the event loop //! without a backend RPC; deeper diagnostics live in the Prometheus metrics, not in this payload. +//! `GET /ready` is the readiness counterpart: 200 only while the gateway accepts traffic. use crate::error::ErrorEnvelope; -use crate::protocol::rest::{RestState, json_response}; -use axum::extract::State; +use crate::protocol::rest::{RequestId, RestState, error_response, json_response}; +use axum::extract::{Request, State}; use axum::response::Response; use serde::Serialize; use utoipa::ToSchema; @@ -31,7 +32,9 @@ use utoipa_axum::routes; /// Health routes merged into the main router by [`crate::protocol::rest::build_router`]. pub fn routes() -> OpenApiRouter { - OpenApiRouter::new().routes(routes!(health)) + OpenApiRouter::new() + .routes(routes!(health)) + .routes(routes!(ready)) } /// Response of `GET /health` (FIP-49): liveness plus process uptime. @@ -62,6 +65,44 @@ pub(crate) async fn health(State(state): State) -> Response { .expect("the health response is serializable") } +/// Response of `GET /ready`: the gateway accepts application traffic. +#[derive(Debug, Serialize, ToSchema)] +pub struct ReadyResponse { + pub status: &'static str, +} + +/// Readiness for load balancers and readiness probes. +/// +/// `/health` is liveness and stays 200 while the process answers, so a liveness probe never +/// restarts a draining process; this route reports acceptance instead. Readiness covers the process +/// lifecycle only: Fluss cluster availability surfaces through request errors and metrics, not by +/// unloading every gateway instance at once. +#[utoipa::path( + get, + path = "/ready", + operation_id = "getReady", + tag = "health", + responses( + (status = 200, description = "The gateway accepts application traffic", body = ReadyResponse), + (status = 405, description = "Wrong method for this route", body = ErrorEnvelope), + (status = 503, description = "The gateway is starting or shutting down", body = ErrorEnvelope), + ) +)] +pub(crate) async fn ready(State(state): State, request: Request) -> Response { + match state.readiness.ensure_accepting() { + Ok(()) => json_response(&ReadyResponse { status: "ready" }) + .expect("the ready response is serializable"), + Err(error) => { + let request_id = request + .extensions() + .get::() + .cloned() + .unwrap_or_default(); + error_response(&error, &request_id) + } + } +} + #[cfg(test)] mod tests { use crate::protocol::rest::test_support; @@ -131,4 +172,36 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); assert_eq!(body_json(response).await["status"], "ok"); } + + /// `/ready` is 200 only while the gateway accepts traffic, so load balancers stop sending + /// traffic the moment shutdown starts instead of feeding requests the acceptance guard rejects. + #[tokio::test] + async fn ready_reflects_acceptance_across_the_lifecycle() { + let state = test_support::test_state(); + + let app = crate::protocol::rest::build_router(state.clone(), &test_support::test_options()); + let response = get(app, "/ready").await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let json = body_json(response).await; + assert_eq!(json["error"]["code"], "unavailable"); + assert!( + json["error"]["message"] + .as_str() + .unwrap() + .contains("starting"), + "the envelope names the state: {json}" + ); + + state.readiness.set_serving(); + let app = crate::protocol::rest::build_router(state.clone(), &test_support::test_options()); + let response = get(app, "/ready").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["status"], "ready"); + + state.readiness.begin_quiescing(); + let app = crate::protocol::rest::build_router(state, &test_support::test_options()); + let response = get(app, "/ready").await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_json(response).await["error"]["code"], "unavailable"); + } } diff --git a/fluss-gateway/src/protocol/rest/mod.rs b/fluss-gateway/src/protocol/rest/mod.rs index b67174ed4d1..cb95e0baad6 100644 --- a/fluss-gateway/src/protocol/rest/mod.rs +++ b/fluss-gateway/src/protocol/rest/mod.rs @@ -49,6 +49,11 @@ use utoipa_axum::router::OpenApiRouter; /// Only reachable if a duration slips past configuration validation; one hour keeps such a request bounded. const MAX_REQUEST_DEADLINE: Duration = Duration::from_secs(3600); +/// Reserved between a request's deadline and the middleware timeout that backstops it, so a +/// response completing at the deadline still gets out instead of racing the timeout. The timeout +/// keeps the configured value; the deadline ends this much earlier. +const RESPONSE_GRACE: Duration = Duration::from_secs(1); + /// Shared state for REST handlers. /// /// Everything here is either immutable configuration or a shared process service. Nothing is scoped to a @@ -207,10 +212,11 @@ pub fn shaped(mut response: Response) -> Response { response } -/// Builds the P1 router: `GET /health` sits outside both the acceptance guard and the body/deadline -/// budget so it answers while draining and never carries a body; every guarded route (today -/// `/v1/openapi.json`, later all data/control routes) carries the request-body size and deadline -/// budget, with the acceptance guard outermost so draining answers 503 without consuming bodies. +/// Builds the P1 router: the health routes (`GET /health` for liveness, `GET /ready` for +/// readiness) sit outside both the acceptance guard and the body/deadline budget so they answer +/// while draining and never carry a body; every guarded route (today `/v1/openapi.json`, later all +/// data/control routes) carries the request-body size and deadline budget, with the acceptance +/// guard outermost so draining answers 503 without consuming bodies. pub fn build_router(state: RestState, options: &RestOptions) -> Router { let (guarded_router, guarded_api) = OpenApiRouter::new() .merge(openapi::routes()) @@ -266,15 +272,22 @@ pub fn apply_middleware(router: Router, options: &RestOptions) -> Router { apply_common_middleware(apply_data_limits(router, options)) } -/// The absolute deadline of a request starting now, saturating instead of overflowing. +/// The deadline of a request starting now, ending [`RESPONSE_GRACE`] before the middleware timeout. /// -/// Configuration caps every duration well below the instant-arithmetic limit, so this is defence in depth: -/// a request must never panic a worker thread over a deadline it cannot represent. +/// The checked additions are defence in depth — configuration caps every duration well below the +/// instant-arithmetic limit — and, like Envoy's `grpc_timeout_offset`, the grace applies only when +/// the timeout is longer than it. fn deadline_from_now(request_timeout: Duration) -> RequestDeadline { + let handler_budget = if request_timeout > RESPONSE_GRACE { + request_timeout - RESPONSE_GRACE + } else { + request_timeout + }; let now = Instant::now(); RequestDeadline( - now.checked_add(request_timeout) - .unwrap_or_else(|| now + MAX_REQUEST_DEADLINE), + now.checked_add(handler_budget) + .or_else(|| now.checked_add(MAX_REQUEST_DEADLINE)) + .unwrap_or(now), ) } @@ -620,6 +633,72 @@ mod tests { assert_eq!(json["error"]["code"], "timeout"); } + /// The deadline ends before the middleware timeout so a response completing at the deadline can + /// still be delivered inside the timeout wrapper. + #[test] + fn deadline_ends_one_grace_before_the_middleware_timeout() { + let timeout = Duration::from_secs(30); + let before = Instant::now(); + let deadline = deadline_from_now(timeout).instant(); + let after = Instant::now(); + + assert!( + deadline >= before + timeout - RESPONSE_GRACE, + "the deadline must sit a grace before the timeout: {deadline:?}" + ); + assert!( + deadline <= after + timeout - RESPONSE_GRACE + Duration::from_millis(50), + "the deadline must not end earlier than one grace before the timeout: {deadline:?}" + ); + } + + /// Timeouts at or below the grace keep their full budget, mirroring Envoy's + /// "offset only applies when the timeout is greater" rule. + #[test] + fn deadline_keeps_the_full_budget_for_short_timeouts() { + let timeout = Duration::from_millis(200); + let before = Instant::now(); + let deadline = deadline_from_now(timeout).instant(); + let after = Instant::now(); + + assert!(deadline >= before + timeout); + assert!(deadline <= after + timeout + Duration::from_millis(50)); + } + + /// A handler that finishes exactly at its deadline still gets its response out: the middleware + /// timeout backstops the deadline plus the response grace instead of racing it. + #[tokio::test] + async fn response_completing_at_the_deadline_is_delivered() { + async fn finishes_at_deadline(request: Request) -> &'static str { + let deadline = request + .extensions() + .get::() + .copied() + .expect("the limits middleware assigns the deadline"); + tokio::time::sleep_until(tokio::time::Instant::from_std(deadline.instant())).await; + "ok" + } + let options = RestOptions { + request_timeout: RESPONSE_GRACE + Duration::from_millis(400), + max_body_bytes: 1024, + }; + let app = apply_middleware( + Router::new().route("/deadline", get(finishes_at_deadline)), + &options, + ); + + let response = app + .oneshot( + Request::builder() + .uri("/deadline") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + /// A route that exists for another method answers the shared envelope with a code the published /// vocabulary contains. #[tokio::test] diff --git a/fluss-gateway/tests/http_api.rs b/fluss-gateway/tests/http_api.rs index ea5ac75f960..c4bc9c8b143 100644 --- a/fluss-gateway/tests/http_api.rs +++ b/fluss-gateway/tests/http_api.rs @@ -147,11 +147,63 @@ async fn an_oversized_body_is_rejected_with_413_and_never_429() { gateway.shutdown().await.expect("clean shutdown"); } +/// `/ready` answers 200 over the real listener once the gateway is serving, alongside `/health`. +#[tokio::test] +async fn ready_and_health_answer_over_the_real_listener() { + let (gateway, api) = gateway().await; + + assert_eq!(api.get("/health").await.status(), 200); + let ready = api.get_ok("/ready").await; + assert_eq!(ready["status"], "ready"); + + gateway.shutdown().await.expect("clean shutdown"); +} + +/// A connection that stalls mid-head is closed when the header read timeout expires. The request +/// deadline cannot defend here: it runs only after hyper has parsed a complete head, so without +/// this timeout such a connection would hold its socket and task forever. +#[tokio::test] +async fn a_stalled_head_connection_is_closed_by_the_header_read_timeout() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let mut config = fluss_gateway::config::GatewayConfig::default(); + config.server.rest.bind_address = "127.0.0.1:0".parse().expect("valid"); + config.server.metrics.enabled = false; + config.server.rest.header_read_timeout = + fluss_gateway::config::ConfigDuration::from_millis(300); + let gateway = fluss_gateway::lifecycle::start(config) + .await + .expect("gateway starts"); + + let mut socket = tokio::net::TcpStream::connect(gateway.local_addr()) + .await + .expect("connect"); + socket + .write_all(b"GET /heal") + .await + .expect("half a request head"); + + // The server closes the connection instead of holding it open forever. + let mut buffer = [0u8; 16]; + let read = + tokio::time::timeout(std::time::Duration::from_secs(5), socket.read(&mut buffer)).await; + match read { + Ok(Ok(0)) | Ok(Err(_)) => {} + Ok(Ok(byte_count)) => { + panic!("the gateway answered a half request head with {byte_count} bytes") + } + Err(_) => panic!("the stalled connection was not closed within the test timeout"), + } + + gateway.shutdown().await.expect("clean shutdown"); +} + #[tokio::test] async fn draining_rejects_guarded_routes_but_keeps_health_answering() { let gateway = support::start_gateway().await; let api = Api::new(format!("http://{}", gateway.local_addr())); gateway.begin_shutdown(); assert_eq!(api.get("/health").await.status(), 200); + assert_eq!(api.get("/ready").await.status(), 503); assert_eq!(api.get("/v1/openapi.json").await.status(), 503); } From 3db46e59a5646a64589d33b5bf2ada2d15b8e5f3 Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Mon, 17 Aug 2026 14:16:31 +0800 Subject: [PATCH 13/15] [gateway] Serve HTTP/1 only and keep the accept loop alive on resource errors --- fluss-gateway/Cargo.lock | 28 +-------- fluss-gateway/Cargo.toml | 7 ++- fluss-gateway/DEPENDENCIES.rust.tsv | 2 - fluss-gateway/src/lifecycle.rs | 69 +++++++++++----------- fluss-gateway/src/protocol/rest/mod.rs | 20 +++++++ fluss-gateway/tests/http_api.rs | 79 +++++++++++++++++++++++--- 6 files changed, 134 insertions(+), 71 deletions(-) diff --git a/fluss-gateway/Cargo.lock b/fluss-gateway/Cargo.lock index 325788474d5..a886ab6b969 100644 --- a/fluss-gateway/Cargo.lock +++ b/fluss-gateway/Cargo.lock @@ -245,6 +245,7 @@ dependencies = [ "clap", "futures-util", "http-body-util", + "hyper", "hyper-util", "libc", "log", @@ -264,12 +265,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.2.0" @@ -359,25 +354,6 @@ dependencies = [ "r-efi 6.0.0", ] -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -454,7 +430,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -1218,7 +1193,6 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "libc", "pin-project-lite", "tokio", ] diff --git a/fluss-gateway/Cargo.toml b/fluss-gateway/Cargo.toml index aac8cb213c4..fce8a119829 100644 --- a/fluss-gateway/Cargo.toml +++ b/fluss-gateway/Cargo.toml @@ -49,8 +49,11 @@ name = "fluss_gateway" [dependencies] axum = { version = "0.8", default-features = false, features = ["http1", "matched-path", "tokio"] } # The listeners follow axum's official serve-with-hyper example: axum::serve does not expose the -# hyper builder that the header read timeout lives on. -hyper-util = { version = "0.1", default-features = false, features = ["server-auto", "server-graceful", "service", "tokio"] } +# hyper builder that the header read timeout lives on. The plain http1 builder (not auto) has no +# HTTP/2 preface sniffing, so the header timer covers a connection from its first byte. +hyper = { version = "1", default-features = false, features = ["http1", "server"] } +# GracefulShutdown and the tower-to-hyper service adapter for the serve loop above. +hyper-util = { version = "0.1", default-features = false, features = ["server-graceful", "service", "tokio"] } clap = { version = "4.5.37", features = ["derive"] } # Only FutureExt/join_all are used; avoid the larger futures facade crate. futures-util = "0.3" diff --git a/fluss-gateway/DEPENDENCIES.rust.tsv b/fluss-gateway/DEPENDENCIES.rust.tsv index 0d497037e40..5d3a4da26b9 100644 --- a/fluss-gateway/DEPENDENCIES.rust.tsv +++ b/fluss-gateway/DEPENDENCIES.rust.tsv @@ -23,7 +23,6 @@ crossbeam-utils@0.8.22 X X equivalent@1.0.2 X X errno@0.3.14 X X fluss-gateway@1.0.0 X -fnv@1.0.7 X X foldhash@0.2.0 X futures-channel@0.3.34 X X futures-core@0.3.34 X X @@ -33,7 +32,6 @@ futures-task@0.3.34 X X futures-util@0.3.34 X X getrandom@0.3.4 X X getrandom@0.4.3 X X -h2@0.4.15 X hashbrown@0.16.1 X X hashbrown@0.17.1 X X heck@0.5.0 X X diff --git a/fluss-gateway/src/lifecycle.rs b/fluss-gateway/src/lifecycle.rs index 4a1affb4ace..ed0497b3f49 100644 --- a/fluss-gateway/src/lifecycle.rs +++ b/fluss-gateway/src/lifecycle.rs @@ -26,20 +26,21 @@ use crate::config::GatewayConfig; use crate::error::GatewayError; use crate::observability; -use crate::protocol::rest::{self, RestOptions, RestState}; +use crate::protocol::rest; use axum::Router; use axum::http::{HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use futures_util::FutureExt; -use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer}; +use hyper::server::conn::http1; +use hyper_util::rt::{TokioIo, TokioTimer}; use hyper_util::service::TowerToHyperService; use metrics_exporter_prometheus::PrometheusHandle; use std::any::Any; use std::future::Future; use std::panic::AssertUnwindSafe; +use std::sync::Arc; use std::sync::atomic::{AtomicU8, Ordering}; -use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; @@ -267,8 +268,7 @@ async fn start_internal(config: GatewayConfig) -> Result Result, - bind_address: std::net::SocketAddr, -) -> RestState { - RestState { - readiness: readiness.clone(), - bind_address, - started_at: Instant::now(), - openapi: Arc::new(OnceLock::new()), - } -} - /// Binds one configured HTTP listener and adds a contextual startup error. async fn bind_listener( bind_address: std::net::SocketAddr, @@ -346,10 +332,10 @@ async fn bind_listener( /// Serves one Axum listener until process cancellation starts graceful drain. /// /// Follows axum's official `serve-with-hyper` example, because `axum::serve` does not expose the -/// hyper builder that the header read timeout lives on — the only defence against connections that -/// stall or dribble the request head, which the per-request deadline cannot see (it runs only after -/// a complete head). Hyper ≥ 1.4 starts that timer at connection establishment, so a client that -/// never sends a byte is covered too. +/// hyper builder that the header read timeout lives on — the only defence against connections +/// that stall or dribble the request head, which the per-request deadline cannot see (it runs +/// only after a complete head). The plain http1 builder (not auto) starts that timer at +/// connection setup, before any byte arrives, and has no HTTP/2 preface sniffing to park on. async fn serve( listener: tokio::net::TcpListener, router: Router, @@ -358,25 +344,28 @@ async fn serve( ) -> Result<(), String> { let graceful = hyper_util::server::graceful::GracefulShutdown::new(); loop { - let (socket, _remote) = tokio::select! { + let socket = tokio::select! { biased; _ = shutdown.cancelled() => break, - accepted = listener.accept() => accepted.map_err(|error| error.to_string())?, + accepted = listener.accept() => match accepted { + Ok((socket, _remote)) => socket, + Err(error) => { + handle_accept_error(error).await; + continue; + } + }, }; // `axum::serve` sets TCP_NODELAY on accepted sockets; keep that behaviour. let _ = socket.set_nodelay(true); - let mut builder = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()); + let mut builder = http1::Builder::new(); // The header read timeout runs on hyper's background timer, so one must be installed first. builder - .http1() .timer(TokioTimer::new()) .header_read_timeout(header_read_timeout); let service = TowerToHyperService::new(router.clone()); - // `into_owned` unhooks the connection from the borrowed builder for the `'static` spawn. - let connection = builder - .serve_connection_with_upgrades(TokioIo::new(socket), service) - .into_owned(); - let connection = graceful.watch(connection); + // The non-upgradeable connection is what `graceful.watch` accepts; the gateway has no + // upgrade-based protocol. + let connection = graceful.watch(builder.serve_connection(TokioIo::new(socket), service)); tokio::spawn(async move { if let Err(error) = connection.await { log::debug!("connection ended with an error: {error}"); @@ -389,6 +378,22 @@ async fn serve( Ok(()) } +/// Copies `axum::serve`'s accept-loop resilience: per-connection hiccups are ignored, and a +/// resource error such as EMFILE (the process hit its open-file limit) logs and waits a second +/// for fds to be released instead of failing the whole listener. +async fn handle_accept_error(error: std::io::Error) { + if matches!( + error.kind(), + std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + ) { + return; + } + log::error!("accept error: {error}"); + tokio::time::sleep(Duration::from_secs(1)).await; +} + /// Builds the isolated Prometheus scrape router. fn metrics_router(handle: Option) -> Router { Router::new().route( diff --git a/fluss-gateway/src/protocol/rest/mod.rs b/fluss-gateway/src/protocol/rest/mod.rs index cb95e0baad6..3792474bdb0 100644 --- a/fluss-gateway/src/protocol/rest/mod.rs +++ b/fluss-gateway/src/protocol/rest/mod.rs @@ -212,6 +212,26 @@ pub fn shaped(mut response: Response) -> Response { response } +/// Assembles the REST frontend from the REST configuration and the shared process services. +/// +/// This is the frontend's entry point for the lifecycle: it owns how the handler state is built and +/// the router is wired, so the lifecycle stays protocol-agnostic and only supervises the listener +/// this router is served on. `local_addr` is the resolved bound address, which differs from the +/// configured one when the port is 0. +pub fn build( + rest_config: &RestServerConfig, + readiness: &Arc, + local_addr: SocketAddr, +) -> Router { + let state = RestState { + readiness: readiness.clone(), + bind_address: local_addr, + started_at: Instant::now(), + openapi: Arc::new(OnceLock::new()), + }; + build_router(state, &RestOptions::from(rest_config)) +} + /// Builds the P1 router: the health routes (`GET /health` for liveness, `GET /ready` for /// readiness) sit outside both the acceptance guard and the body/deadline budget so they answer /// while draining and never carry a body; every guarded route (today `/v1/openapi.json`, later all diff --git a/fluss-gateway/tests/http_api.rs b/fluss-gateway/tests/http_api.rs index c4bc9c8b143..f914d555ab0 100644 --- a/fluss-gateway/tests/http_api.rs +++ b/fluss-gateway/tests/http_api.rs @@ -159,6 +159,18 @@ async fn ready_and_health_answer_over_the_real_listener() { gateway.shutdown().await.expect("clean shutdown"); } +/// A gateway with a short header read timeout, for the connection-level tests. +async fn short_header_timeout_gateway() -> fluss_gateway::lifecycle::RunningGateway { + let mut config = fluss_gateway::config::GatewayConfig::default(); + config.server.rest.bind_address = "127.0.0.1:0".parse().expect("valid"); + config.server.metrics.enabled = false; + config.server.rest.header_read_timeout = + fluss_gateway::config::ConfigDuration::from_millis(300); + fluss_gateway::lifecycle::start(config) + .await + .expect("gateway starts") +} + /// A connection that stalls mid-head is closed when the header read timeout expires. The request /// deadline cannot defend here: it runs only after hyper has parsed a complete head, so without /// this timeout such a connection would hold its socket and task forever. @@ -166,14 +178,7 @@ async fn ready_and_health_answer_over_the_real_listener() { async fn a_stalled_head_connection_is_closed_by_the_header_read_timeout() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let mut config = fluss_gateway::config::GatewayConfig::default(); - config.server.rest.bind_address = "127.0.0.1:0".parse().expect("valid"); - config.server.metrics.enabled = false; - config.server.rest.header_read_timeout = - fluss_gateway::config::ConfigDuration::from_millis(300); - let gateway = fluss_gateway::lifecycle::start(config) - .await - .expect("gateway starts"); + let gateway = short_header_timeout_gateway().await; let mut socket = tokio::net::TcpStream::connect(gateway.local_addr()) .await @@ -198,6 +203,64 @@ async fn a_stalled_head_connection_is_closed_by_the_header_read_timeout() { gateway.shutdown().await.expect("clean shutdown"); } +/// A connection that sends nothing at all is closed too: the plain http1 builder starts the +/// header timer at connection setup, before any byte arrives (the auto builder's HTTP/2 preface +/// sniffing read had no timer on it, so silent sockets stayed parked). +#[tokio::test] +async fn a_silent_connection_is_closed_by_the_header_read_timeout() { + use tokio::io::AsyncReadExt; + + let gateway = short_header_timeout_gateway().await; + + let mut socket = tokio::net::TcpStream::connect(gateway.local_addr()) + .await + .expect("connect"); + + let mut buffer = [0u8; 16]; + let read = + tokio::time::timeout(std::time::Duration::from_secs(5), socket.read(&mut buffer)).await; + match read { + Ok(Ok(0)) | Ok(Err(_)) => {} + Ok(Ok(byte_count)) => { + panic!("the gateway answered a silent connection with {byte_count} bytes") + } + Err(_) => panic!("the silent connection was not closed within the test timeout"), + } + + gateway.shutdown().await.expect("clean shutdown"); +} + +/// The HTTP/2 client preface is not negotiated: the gateway serves HTTP/1 only, so the preface +/// fails head parsing and the connection answers with an HTTP/1 error and closes. +#[tokio::test] +async fn an_http2_preface_connection_is_not_served_as_http2() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let gateway = short_header_timeout_gateway().await; + + let mut socket = tokio::net::TcpStream::connect(gateway.local_addr()) + .await + .expect("connect"); + socket + .write_all(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n") + .await + .expect("HTTP/2 preface"); + + let mut received = Vec::new(); + let read = tokio::time::timeout( + std::time::Duration::from_secs(5), + socket.read_to_end(&mut received), + ) + .await; + let response = String::from_utf8_lossy(&received); + assert!( + read.is_ok() && (received.is_empty() || response.starts_with("HTTP/1.1 4")), + "no HTTP/2 session and no parked connection, at most an HTTP/1 error: {response}" + ); + + gateway.shutdown().await.expect("clean shutdown"); +} + #[tokio::test] async fn draining_rejects_guarded_routes_but_keeps_health_answering() { let gateway = support::start_gateway().await; From 5ce39b5f8b1d193d4bdd3b24395f895fb82bfcc0 Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Mon, 17 Aug 2026 23:17:02 +0800 Subject: [PATCH 14/15] [gateway] Harden the listener logging, features, and shutdown wait Connection errors all logged at debug, so a client sending the HTTP/2 preface looked like a dead gateway from both ends; that case now warns and names the misconfiguration. hyper-util's http1 feature gates the GracefulConnection impl that graceful.watch needs, and it was only enabled because axum asks for it, so it is now declared here. The accept backoff moved inside the future the serve loop selects, so a shutdown signal cancels the wait instead of having to outlast it. --- fluss-gateway/Cargo.toml | 6 ++++-- fluss-gateway/src/lifecycle.rs | 35 ++++++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/fluss-gateway/Cargo.toml b/fluss-gateway/Cargo.toml index fce8a119829..ef031072043 100644 --- a/fluss-gateway/Cargo.toml +++ b/fluss-gateway/Cargo.toml @@ -52,8 +52,10 @@ axum = { version = "0.8", default-features = false, features = ["http1", "matche # hyper builder that the header read timeout lives on. The plain http1 builder (not auto) has no # HTTP/2 preface sniffing, so the header timer covers a connection from its first byte. hyper = { version = "1", default-features = false, features = ["http1", "server"] } -# GracefulShutdown and the tower-to-hyper service adapter for the serve loop above. -hyper-util = { version = "0.1", default-features = false, features = ["server-graceful", "service", "tokio"] } +# GracefulShutdown and the tower-to-hyper service adapter for the serve loop above. `http1` is what +# gates the `GracefulConnection` impl that `graceful.watch` needs, so it is requested here rather +# than inherited from axum's own `http1`, whose feature set is not ours to rely on. +hyper-util = { version = "0.1", default-features = false, features = ["http1", "server-graceful", "service", "tokio"] } clap = { version = "4.5.37", features = ["derive"] } # Only FutureExt/join_all are used; avoid the larger futures facade crate. futures-util = "0.3" diff --git a/fluss-gateway/src/lifecycle.rs b/fluss-gateway/src/lifecycle.rs index ed0497b3f49..527f6f79469 100644 --- a/fluss-gateway/src/lifecycle.rs +++ b/fluss-gateway/src/lifecycle.rs @@ -347,13 +347,7 @@ async fn serve( let socket = tokio::select! { biased; _ = shutdown.cancelled() => break, - accepted = listener.accept() => match accepted { - Ok((socket, _remote)) => socket, - Err(error) => { - handle_accept_error(error).await; - continue; - } - }, + socket = accept_with_retry(&listener) => socket, }; // `axum::serve` sets TCP_NODELAY on accepted sockets; keep that behaviour. let _ = socket.set_nodelay(true); @@ -368,7 +362,17 @@ async fn serve( let connection = graceful.watch(builder.serve_connection(TokioIo::new(socket), service)); tokio::spawn(async move { if let Err(error) = connection.await { - log::debug!("connection ended with an error: {error}"); + // A client speaking HTTP/2 is a configuration problem on the other side, not per- + // connection noise: without this an ingress pointed here over h2c sees connections + // dropped with no explanation on either end. + if error.is_parse_version_h2() { + log::warn!( + "rejected an HTTP/2 connection preface: this listener serves HTTP/1.1 \ + only, so clients and ingresses must not be configured for h2c" + ); + } else { + log::debug!("connection ended with an error: {error}"); + } } }); } @@ -378,6 +382,21 @@ async fn serve( Ok(()) } +/// Accepts one connection, absorbing the accept errors that must not end the listener. +/// +/// The retry loop lives inside this future on purpose: the serve loop selects it against shutdown, +/// so a signal arriving during the backoff drops the wait instead of having to outlast it. Both +/// awaited operations are cancellation-safe, so dropping this future loses nothing. `axum::serve` +/// keeps the same wait inside its `Listener::accept` for the same reason. +async fn accept_with_retry(listener: &tokio::net::TcpListener) -> tokio::net::TcpStream { + loop { + match listener.accept().await { + Ok((socket, _remote)) => return socket, + Err(error) => handle_accept_error(error).await, + } + } +} + /// Copies `axum::serve`'s accept-loop resilience: per-connection hiccups are ignored, and a /// resource error such as EMFILE (the process hit its open-file limit) logs and waits a second /// for fds to be released instead of failing the whole listener. From 7f99a30ffc9ffab50e2d4d10688275fc0c6f07a0 Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Mon, 17 Aug 2026 23:40:23 +0800 Subject: [PATCH 15/15] [gateway] Ban the HTTP/2 stack and answer handler panics with 500 HTTP/2 arrives through Cargo feature unification rather than a code change, so deny.toml bans the h2 crate and CI checks it; tokio-util's unused rt feature goes with it. A panicking handler dropped the connection, leaving the caller without an envelope and the access log and counters without the request. A panic guard inside the access-log layer answers 500 instead, logging the payload with the request id and never returning it. --- .github/workflows/gateway-ci.yml | 3 ++ fluss-gateway/Cargo.lock | 1 - fluss-gateway/Cargo.toml | 16 ++++--- fluss-gateway/deny.toml | 11 +++++ fluss-gateway/justfile | 4 +- fluss-gateway/src/error.rs | 13 ++++++ fluss-gateway/src/lifecycle.rs | 25 +++------- fluss-gateway/src/protocol/rest/mod.rs | 65 +++++++++++++++++++++++++- 8 files changed, 109 insertions(+), 29 deletions(-) diff --git a/.github/workflows/gateway-ci.yml b/.github/workflows/gateway-ci.yml index 54c47a5dbeb..03a65f22753 100644 --- a/.github/workflows/gateway-ci.yml +++ b/.github/workflows/gateway-ci.yml @@ -116,6 +116,9 @@ jobs: - name: Check dependency licenses (Apache-compatible) run: cargo deny check licenses + - name: Check banned dependencies (HTTP/2 stack stays out) + run: cargo deny check bans + - name: Rust Cache uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: diff --git a/fluss-gateway/Cargo.lock b/fluss-gateway/Cargo.lock index a886ab6b969..0e57278e5eb 100644 --- a/fluss-gateway/Cargo.lock +++ b/fluss-gateway/Cargo.lock @@ -1192,7 +1192,6 @@ dependencies = [ "bytes", "futures-core", "futures-sink", - "futures-util", "pin-project-lite", "tokio", ] diff --git a/fluss-gateway/Cargo.toml b/fluss-gateway/Cargo.toml index ef031072043..b2df3ba10ba 100644 --- a/fluss-gateway/Cargo.toml +++ b/fluss-gateway/Cargo.toml @@ -48,21 +48,22 @@ name = "fluss_gateway" [dependencies] axum = { version = "0.8", default-features = false, features = ["http1", "matched-path", "tokio"] } +clap = { version = "4.5.37", features = ["derive"] } +# Only FutureExt/join_all are used; avoid the larger futures facade crate. +futures-util = "0.3" # The listeners follow axum's official serve-with-hyper example: axum::serve does not expose the # hyper builder that the header read timeout lives on. The plain http1 builder (not auto) has no # HTTP/2 preface sniffing, so the header timer covers a connection from its first byte. hyper = { version = "1", default-features = false, features = ["http1", "server"] } -# GracefulShutdown and the tower-to-hyper service adapter for the serve loop above. `http1` is what -# gates the `GracefulConnection` impl that `graceful.watch` needs, so it is requested here rather -# than inherited from axum's own `http1`, whose feature set is not ours to rely on. +# GracefulShutdown and the tower-to-hyper service adapter for the serve loop above. `http1` gates +# the `GracefulConnection` impl `graceful.watch` needs, so it is requested rather than inherited. hyper-util = { version = "0.1", default-features = false, features = ["http1", "server-graceful", "service", "tokio"] } -clap = { version = "4.5.37", features = ["derive"] } -# Only FutureExt/join_all are used; avoid the larger futures facade crate. -futures-util = "0.3" # Process CPU/RSS/fd sampling for the FIP-49 process_* metric families. libc = "0.2" log = "0.4" metrics = "0.24" +# The recorder and the `/metrics` route need no default feature, and those would reach the whole +# graph through `hyper-util/server-auto`; `deny.toml` guards the consequence. metrics-exporter-prometheus = { version = "0.17", default-features = false } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -73,7 +74,8 @@ serde_path_to_error = "0.1" serde_yaml_ng = "0.10" # Runtime capabilities used by the server; test-only I/O/time controls are enabled below. tokio = { version = "1.44.2", features = ["macros", "net", "rt-multi-thread", "signal", "time"] } -tokio-util = { version = "0.7", features = ["rt"] } +# Only `sync::CancellationToken` is used, which needs no feature of its own. +tokio-util = { version = "0.7", default-features = false } utoipa = { version = "5", features = ["axum_extras"] } utoipa-axum = "0.2" uuid = { version = "1.10", features = ["v4"] } diff --git a/fluss-gateway/deny.toml b/fluss-gateway/deny.toml index 18ed544033f..90c2378ef36 100644 --- a/fluss-gateway/deny.toml +++ b/fluss-gateway/deny.toml @@ -33,4 +33,15 @@ exceptions = [ { crate = "webpki-roots", allow = [ "CDLA-Permissive-2.0", ] }, +] + +[bans] +# Duplicate versions are a transitive fact of life, not what this section guards. +multiple-versions = "allow" +deny = [ + # HTTP/2 does not arrive through a decision in this crate but through Cargo feature + # unification: any dependency enabling `hyper-util/server-auto` (for example + # `metrics-exporter-prometheus`'s default features) pulls `hyper/http2` in for the whole graph, + # and no test would fail. Banning the stack keeps adding HTTP/2 a deliberate change. + { crate = "h2", reason = "the gateway listener serves HTTP/1.1 only; see fluss-gateway/src/lifecycle.rs" }, ] \ No newline at end of file diff --git a/fluss-gateway/justfile b/fluss-gateway/justfile index c14e7c3a2fa..6d945925292 100644 --- a/fluss-gateway/justfile +++ b/fluss-gateway/justfile @@ -54,9 +54,11 @@ openapi: run *flags: cargo run -- {{flags}} -# Check that every dependency license is compatible with the Apache License. +# Check that every dependency license is compatible with the Apache License, and that no banned +# crate entered the graph. licenses: cargo deny check licenses + cargo deny check bans # Regenerate the checked-in dependency license inventory. deps: diff --git a/fluss-gateway/src/error.rs b/fluss-gateway/src/error.rs index cbb6ccab7e8..02b0ef5d734 100644 --- a/fluss-gateway/src/error.rs +++ b/fluss-gateway/src/error.rs @@ -32,6 +32,7 @@ //! stable code per condition. use serde::Serialize; +use std::any::Any; use std::fmt; use utoipa::openapi::schema::Type; use utoipa::openapi::{ObjectBuilder, RefOr, Schema}; @@ -354,6 +355,18 @@ impl fmt::Display for GatewayError { impl std::error::Error for GatewayError {} +/// Renders a caught panic payload as a log message, for the callers that report a panic as a named +/// failure instead of losing it. +pub(crate) fn panic_message(payload: Box) -> String { + match payload.downcast::() { + Ok(message) => *message, + Err(payload) => match payload.downcast::<&'static str>() { + Ok(message) => (*message).to_string(), + Err(_) => "non-string panic payload".to_string(), + }, + } +} + /// REST error envelope: `{"error": {"code", "message", "request_id"}}`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] #[schema(examples(json!({ diff --git a/fluss-gateway/src/lifecycle.rs b/fluss-gateway/src/lifecycle.rs index 527f6f79469..d65e9905ba0 100644 --- a/fluss-gateway/src/lifecycle.rs +++ b/fluss-gateway/src/lifecycle.rs @@ -24,7 +24,7 @@ //! instance would have to pick up. use crate::config::GatewayConfig; -use crate::error::GatewayError; +use crate::error::{GatewayError, panic_message}; use crate::observability; use crate::protocol::rest; use axum::Router; @@ -36,7 +36,6 @@ use hyper::server::conn::http1; use hyper_util::rt::{TokioIo, TokioTimer}; use hyper_util::service::TowerToHyperService; use metrics_exporter_prometheus::PrometheusHandle; -use std::any::Any; use std::future::Future; use std::panic::AssertUnwindSafe; use std::sync::Arc; @@ -362,9 +361,8 @@ async fn serve( let connection = graceful.watch(builder.serve_connection(TokioIo::new(socket), service)); tokio::spawn(async move { if let Err(error) = connection.await { - // A client speaking HTTP/2 is a configuration problem on the other side, not per- - // connection noise: without this an ingress pointed here over h2c sees connections - // dropped with no explanation on either end. + // A client speaking HTTP/2 is a misconfiguration on the other side, not per- + // connection noise, and neither end can see it from a dropped connection alone. if error.is_parse_version_h2() { log::warn!( "rejected an HTTP/2 connection preface: this listener serves HTTP/1.1 \ @@ -384,10 +382,9 @@ async fn serve( /// Accepts one connection, absorbing the accept errors that must not end the listener. /// -/// The retry loop lives inside this future on purpose: the serve loop selects it against shutdown, -/// so a signal arriving during the backoff drops the wait instead of having to outlast it. Both -/// awaited operations are cancellation-safe, so dropping this future loses nothing. `axum::serve` -/// keeps the same wait inside its `Listener::accept` for the same reason. +/// The retry loop lives inside this future, as it does in `axum::serve`'s `Listener::accept`, so a +/// signal arriving during the backoff cancels the wait rather than having to outlast it; both +/// awaited operations are cancellation-safe. async fn accept_with_retry(listener: &tokio::net::TcpListener) -> tokio::net::TcpStream { loop { match listener.accept().await { @@ -504,16 +501,6 @@ async fn drain_tasks( } } -fn panic_message(payload: Box) -> String { - match payload.downcast::() { - Ok(message) => *message, - Err(payload) => match payload.downcast::<&'static str>() { - Ok(message) => (*message).to_string(), - Err(_) => "non-string panic payload".to_string(), - }, - } -} - /// Resolves when SIGTERM on Unix or Ctrl-C is received. async fn shutdown_signal() { #[cfg(unix)] diff --git a/fluss-gateway/src/protocol/rest/mod.rs b/fluss-gateway/src/protocol/rest/mod.rs index 3792474bdb0..2194f74f62f 100644 --- a/fluss-gateway/src/protocol/rest/mod.rs +++ b/fluss-gateway/src/protocol/rest/mod.rs @@ -28,7 +28,7 @@ pub mod health; pub mod openapi; use crate::config::RestServerConfig; -use crate::error::{ErrorEnvelope, ErrorKind, GatewayError}; +use crate::error::{ErrorEnvelope, ErrorKind, GatewayError, panic_message}; use crate::lifecycle::Readiness; use crate::observability; use axum::Router; @@ -37,9 +37,11 @@ use axum::extract::{DefaultBodyLimit, MatchedPath, Request}; use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; +use futures_util::FutureExt; use serde::Serialize; use serde::de::DeserializeOwned; use std::net::SocketAddr; +use std::panic::AssertUnwindSafe; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use utoipa_axum::router::OpenApiRouter; @@ -383,10 +385,35 @@ fn apply_data_limits(router: Router, options: &RestOptions) -> Router { fn apply_common_middleware(router: Router) -> Router { router + .layer(middleware::from_fn(catch_panic)) .layer(middleware::from_fn(request_log)) .layer(middleware::from_fn(request_context)) } +/// Turns a panic below this layer into the error envelope a client is promised. +/// +/// Sitting inside the access-log layer, the synthesised 500 is logged and counted like any other +/// response, where a dropped connection would have been recorded nowhere. The payload goes to the +/// log, never into the response, since it can carry internal detail. +async fn catch_panic(request: Request, next: Next) -> Response { + let request_id = request + .extensions() + .get::() + .cloned() + .unwrap_or_default(); + match AssertUnwindSafe(next.run(request)).catch_unwind().await { + Ok(response) => response, + Err(payload) => { + log::error!( + "request_id={} handler panicked: {}", + request_id.as_str(), + panic_message(payload) + ); + error_response(&GatewayError::internal("internal error"), &request_id) + } + } +} + async fn request_log(request: Request, next: Next) -> Response { let started = Instant::now(); let method = request.method().clone(); @@ -781,6 +808,42 @@ mod tests { assert!(response.headers().contains_key("x-request-id")); } + /// A panicking handler answers the shared envelope instead of dropping the connection. + #[tokio::test] + async fn a_panicking_handler_yields_a_500_envelope() { + async fn boom() -> &'static str { + panic!("handler bug"); + } + let app = apply_middleware( + Router::new().route("/boom", get(boom)), + &test_support::test_options(), + ); + + // The default panic hook still prints the payload; that output is expected here. + let response = app + .oneshot(Request::builder().uri("/boom").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let request_id = response + .headers() + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + .expect("x-request-id header"); + let json = body_json(response).await; + assert_eq!(json["error"]["code"], "internal"); + assert_eq!(json["error"]["request_id"], request_id.as_str()); + assert!( + !json["error"]["message"] + .as_str() + .unwrap() + .contains("handler bug"), + "the panic payload stays in the log, never in the response: {json}" + ); + } + #[test] fn request_log_contains_protocol_context() { let message = format_request_log(