From 229bb685567f25a19d1e4603eca8aadcc6fe06d5 Mon Sep 17 00:00:00 2001 From: Junbo Wang Date: Wed, 12 Aug 2026 20:09:54 +0800 Subject: [PATCH 1/9] [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 2/9] [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 3/9] [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 4/9] [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 5/9] [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 6/9] [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 7/9] [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 8/9] [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 9/9] [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/**