diff --git a/.busbar-ref b/.busbar-ref index 3e77816..a483331 100644 --- a/.busbar-ref +++ b/.busbar-ref @@ -1 +1 @@ -e1d3d8b097201859751d77208e66249d98b50a4b 1.6.0 +daf4c0275e734fe1988053e241486042351b8fb3 1.6.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce33fea..064a1e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ permissions: jobs: ci: - uses: GetBusbar/busbar/.github/workflows/plugin-ci.yml@e1d3d8b097201859751d77208e66249d98b50a4b + uses: GetBusbar/busbar/.github/workflows/plugin-ci.yml@daf4c0275e734fe1988053e241486042351b8fb3 with: service: postgres busbar_checkout: true diff --git a/.github/workflows/consumer-verify.yml b/.github/workflows/consumer-verify.yml index 1e7b94a..fca9fd2 100644 --- a/.github/workflows/consumer-verify.yml +++ b/.github/workflows/consumer-verify.yml @@ -21,7 +21,7 @@ permissions: jobs: consumer: - uses: GetBusbar/busbar/.github/workflows/plugin-consumer-verify.yml@e1d3d8b097201859751d77208e66249d98b50a4b + uses: GetBusbar/busbar/.github/workflows/plugin-consumer-verify.yml@daf4c0275e734fe1988053e241486042351b8fb3 with: version: ${{ inputs.version || '' }} asset_prefix: busbar-store-postgres diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d96335..23c1567 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: secrets: inherit release: needs: ci - uses: GetBusbar/busbar/.github/workflows/plugin-release.yml@e1d3d8b097201859751d77208e66249d98b50a4b + uses: GetBusbar/busbar/.github/workflows/plugin-release.yml@daf4c0275e734fe1988053e241486042351b8fb3 with: plugin_crate: busbar-store-postgres-plugin manifest_name: busbar-store-postgres @@ -41,7 +41,7 @@ jobs: consumer-verify: needs: release if: ${{ !cancelled() }} - uses: GetBusbar/busbar/.github/workflows/plugin-consumer-verify.yml@e1d3d8b097201859751d77208e66249d98b50a4b + uses: GetBusbar/busbar/.github/workflows/plugin-consumer-verify.yml@daf4c0275e734fe1988053e241486042351b8fb3 with: version: ${{ github.ref_name }} asset_prefix: busbar-store-postgres diff --git a/.github/workflows/repin.yml b/.github/workflows/repin.yml index bbc104c..df06a5e 100644 --- a/.github/workflows/repin.yml +++ b/.github/workflows/repin.yml @@ -28,7 +28,7 @@ permissions: jobs: repin: - uses: GetBusbar/busbar/.github/workflows/plugin-repin.yml@e1d3d8b097201859751d77208e66249d98b50a4b + uses: GetBusbar/busbar/.github/workflows/plugin-repin.yml@daf4c0275e734fe1988053e241486042351b8fb3 with: busbar_sha: ${{ github.event.client_payload.sha || inputs.busbar_sha }} busbar_version: ${{ github.event.client_payload.version || inputs.busbar_version }} diff --git a/Cargo.lock b/Cargo.lock index a82b003..c771c21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,82 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -25,18 +101,91 @@ 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", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.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", + "tracing", +] + [[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 = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -54,9 +203,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -70,9 +219,10 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "busbar-contract" version = "1.6.0" -source = "git+https://github.com/GetBusbar/busbar?rev=e1d3d8b097201859751d77208e66249d98b50a4b#e1d3d8b097201859751d77208e66249d98b50a4b" +source = "git+https://www.github.com/GetBusbar/busbar?rev=daf4c0275e734fe1988053e241486042351b8fb3#daf4c0275e734fe1988053e241486042351b8fb3" dependencies = [ "async-trait", + "base64 0.22.1", "futures", "hex", "http", @@ -81,7 +231,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "sha2 0.11.0", + "sha2 0.10.9", "smallvec", "tracing", "tracing-core", @@ -89,31 +239,154 @@ dependencies = [ "zeroize", ] +[[package]] +name = "busbar-core-connector" +version = "1.6.0" +source = "git+https://github.com/GetBusbar/busbar?rev=daf4c0275e734fe1988053e241486042351b8fb3#daf4c0275e734fe1988053e241486042351b8fb3" +dependencies = [ + "busbar-contract", + "busbar-kernel", + "crc32fast", + "dimpl", + "futures", + "libc", + "rcgen", + "ring", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "socket2", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots", + "zeroize", +] + +[[package]] +name = "busbar-kernel" +version = "1.6.0" +source = "git+https://github.com/GetBusbar/busbar?rev=daf4c0275e734fe1988053e241486042351b8fb3#daf4c0275e734fe1988053e241486042351b8fb3" +dependencies = [ + "arc-swap", + "async-trait", + "axum", + "base64 0.22.1", + "bumpalo", + "busbar-contract", + "busbar-kernel-audit", + "busbar-kernel-breaker", + "busbar-kernel-egress", + "busbar-kernel-identity", + "busbar-kernel-ledger", + "busbar-kernel-scope", + "busbar-kernel-wal", + "busbar-plugin-loader", + "bytes", + "crc32fast", + "ed25519-dalek", + "futures", + "getrandom 0.3.4", + "hex", + "http", + "http-body", + "http-body-util", + "httpdate", + "hyper", + "hyper-util", + "indexmap", + "metrics", + "metrics-exporter-prometheus", + "metrics-util", + "ring", + "serde", + "serde_json", + "serde_urlencoded", + "serde_yaml_ng", + "sonic-rs", + "tokio", + "tokio-util", + "tower", + "tracing", + "tracing-subscriber", + "url", + "zeroize", +] + +[[package]] +name = "busbar-kernel-audit" +version = "1.6.0" +source = "git+https://github.com/GetBusbar/busbar?rev=daf4c0275e734fe1988053e241486042351b8fb3#daf4c0275e734fe1988053e241486042351b8fb3" +dependencies = [ + "busbar-contract", + "ed25519-dalek", + "ring", + "zeroize", +] + +[[package]] +name = "busbar-kernel-breaker" +version = "1.6.0" +source = "git+https://github.com/GetBusbar/busbar?rev=daf4c0275e734fe1988053e241486042351b8fb3#daf4c0275e734fe1988053e241486042351b8fb3" +dependencies = [ + "busbar-contract", + "httpdate", +] + +[[package]] +name = "busbar-kernel-egress" +version = "1.6.0" +source = "git+https://github.com/GetBusbar/busbar?rev=daf4c0275e734fe1988053e241486042351b8fb3#daf4c0275e734fe1988053e241486042351b8fb3" +dependencies = [ + "busbar-contract", + "bytes", + "futures", +] + +[[package]] +name = "busbar-kernel-identity" +version = "1.6.0" +source = "git+https://github.com/GetBusbar/busbar?rev=daf4c0275e734fe1988053e241486042351b8fb3#daf4c0275e734fe1988053e241486042351b8fb3" +dependencies = [ + "busbar-contract", + "hex", + "ring", + "serde", + "tracing", +] + [[package]] name = "busbar-kernel-ledger" version = "1.6.0" -source = "git+https://github.com/GetBusbar/busbar?rev=e1d3d8b097201859751d77208e66249d98b50a4b#e1d3d8b097201859751d77208e66249d98b50a4b" +source = "git+https://github.com/GetBusbar/busbar?rev=daf4c0275e734fe1988053e241486042351b8fb3#daf4c0275e734fe1988053e241486042351b8fb3" dependencies = [ "busbar-contract", + "ring", "serde", - "sha2 0.11.0", +] + +[[package]] +name = "busbar-kernel-scope" +version = "1.6.0" +source = "git+https://github.com/GetBusbar/busbar?rev=daf4c0275e734fe1988053e241486042351b8fb3#daf4c0275e734fe1988053e241486042351b8fb3" +dependencies = [ + "busbar-contract", ] [[package]] name = "busbar-kernel-wal" version = "1.6.0" -source = "git+https://github.com/GetBusbar/busbar?rev=e1d3d8b097201859751d77208e66249d98b50a4b#e1d3d8b097201859751d77208e66249d98b50a4b" +source = "git+https://github.com/GetBusbar/busbar?rev=daf4c0275e734fe1988053e241486042351b8fb3#daf4c0275e734fe1988053e241486042351b8fb3" dependencies = [ "busbar-contract", - "sha2 0.11.0", + "ring", ] [[package]] name = "busbar-plugin-loader" version = "1.5.0" -source = "git+https://github.com/GetBusbar/busbar?rev=e1d3d8b097201859751d77208e66249d98b50a4b#e1d3d8b097201859751d77208e66249d98b50a4b" +source = "git+https://github.com/GetBusbar/busbar?rev=daf4c0275e734fe1988053e241486042351b8fb3#daf4c0275e734fe1988053e241486042351b8fb3" dependencies = [ - "async-trait", "busbar-contract", "busbar-kernel-ledger", "busbar-kernel-wal", @@ -126,7 +399,6 @@ dependencies = [ "libloading", "serde", "serde_json", - "sha2 0.11.0", "tar", "tokio", "tracing", @@ -137,7 +409,15 @@ name = "busbar-store-postgres" version = "1.0.0" dependencies = [ "busbar-contract", + "busbar-plugin-loader", + "bytes", + "fallible-iterator", "postgres", + "postgres-protocol", + "postgres-types", + "rcgen", + "rustls", + "rustls-pki-types", "serde", "serde_json", ] @@ -146,13 +426,24 @@ dependencies = [ name = "busbar-store-postgres-plugin" version = "1.0.0" dependencies = [ - "base64", + "base64 0.22.1", "busbar-contract", + "busbar-core-connector", "busbar-plugin-loader", "busbar-store-postgres", + "busbar-transport-tcp", "postgres", "reqwest", "serde_json", + "tokio", +] + +[[package]] +name = "busbar-transport-tcp" +version = "1.6.0" +source = "git+https://github.com/GetBusbar/busbar-transport-tcp?rev=89d08ebcb91e5b2d4001b667fba151d9bc09d22d#89d08ebcb91e5b2d4001b667fba151d9bc09d22d" +dependencies = [ + "busbar-contract", ] [[package]] @@ -191,9 +482,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -245,6 +536,21 @@ dependencies = [ "cfg-if", ] +[[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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -300,6 +606,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.7.10" @@ -310,6 +622,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -326,12 +658,27 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] +[[package]] +name = "dimpl" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07d467d37bfdcc290bb8dcbedc8ca356eb730e46459b1687f98c3bae28aa6814" +dependencies = [ + "arrayvec", + "log", + "nom 8.0.0", + "once_cell", + "rand 0.9.4", + "subtle", + "time", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -383,12 +730,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "evmap" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8" +dependencies = [ + "hashbag", + "left-right", + "smallvec", +] + [[package]] name = "fallible-iterator" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "faststr" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca7d44d22004409a61c393afb3369c8f7bb74abcae49fe249ee01dcc3002113" +dependencies = [ + "bytes", + "rkyv", + "serde", + "simdutf8", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -421,6 +791,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -518,6 +900,21 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -567,6 +964,40 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbag" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -627,11 +1058,17 @@ 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 = "hybrid-array" -version = "0.4.12" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] @@ -646,9 +1083,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -678,7 +1117,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -695,88 +1134,6 @@ dependencies = [ "tracing", ] -[[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 = "idna" version = "1.1.0" @@ -790,12 +1147,22 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279259b0ac81c89d11c290495fdcfa96ea3643b7df311c138b6fe8ca5237f0f8" +dependencies = [ + "idna_mapping", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna_mapping" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "11c13906586a4b339310541a274dd927aff6fcbb5b8e3af90634c4b31681c792" dependencies = [ - "icu_normalizer", - "icu_properties", + "unicode-joining-type", ] [[package]] @@ -805,7 +1172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -834,6 +1201,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "left-right" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a" +dependencies = [ + "crossbeam-utils", + "loom", + "slab", +] + [[package]] name = "libc" version = "0.2.189" @@ -859,18 +1243,6 @@ dependencies = [ "libc", ] -[[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" @@ -886,12 +1258,40 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.11.0" @@ -908,6 +1308,60 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[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.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" +dependencies = [ + "base64 0.22.1", + "evmap", + "indexmap", + "metrics", + "metrics-util", + "quanta", + "thiserror", +] + +[[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.4", + "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 = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.9.1" @@ -929,6 +1383,88 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "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.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -947,6 +1483,15 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -976,6 +1521,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1017,6 +1572,12 @@ dependencies = [ "spki", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "postgres" version = "0.19.14" @@ -1037,14 +1598,14 @@ version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" dependencies = [ - "base64", + "base64 0.22.1", "byteorder", "bytes", "fallible-iterator", "hmac", "md-5", "memchr", - "rand", + "rand 0.10.2", "sha2 0.11.0", "stringprep", ] @@ -1061,12 +1622,18 @@ dependencies = [ ] [[package]] -name = "potential_utf" -version = "0.1.5" +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 = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerovec", + "zerocopy", ] [[package]] @@ -1078,6 +1645,41 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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 0.11.1+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1107,7 +1709,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -1153,7 +1755,26 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] [[package]] name = "rand" @@ -1166,6 +1787,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[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" @@ -1175,6 +1806,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[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" @@ -1190,6 +1830,47 @@ 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.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1199,13 +1880,56 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rend" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" + [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -1255,6 +1979,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" +dependencies = [ + "bytes", + "hashbrown 0.17.1", + "indexmap", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1271,16 +2024,12 @@ dependencies = [ ] [[package]] -name = "rustix" -version = "1.1.4" +name = "rusticata-macros" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", + "nom 7.1.3", ] [[package]] @@ -1330,6 +2079,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1385,6 +2140,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_urlencoded" version = "0.7.1" @@ -1432,12 +2198,31 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -1453,12 +2238,24 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +[[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" @@ -1481,6 +2278,45 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sonic-number" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c350a05f295f922fc153a3af8b443c10db70904a9291a624ddb6058c6110d0" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "sonic-rs" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756586caeeb21126a57c17d1ef2b9386e2613ba784aae0f27104f5e9982335ec" +dependencies = [ + "ahash", + "bumpalo", + "bytes", + "cfg-if", + "faststr", + "itoa", + "ref-cast", + "serde", + "simdutf8", + "sonic-number", + "sonic-simd", + "thiserror", + "zmij", +] + +[[package]] +name = "sonic-simd" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f99e664ecd2d85a68c87e3c7a3cfe691f647ea9e835de984aba4d54a41f817d4" +dependencies = [ + "cfg-if", +] + [[package]] name = "spki" version = "0.7.3" @@ -1491,12 +2327,6 @@ dependencies = [ "der", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "stringprep" version = "0.1.5" @@ -1564,7 +2394,6 @@ checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", - "xattr", ] [[package]] @@ -1588,13 +2417,42 @@ dependencies = [ ] [[package]] -name = "tinystr" -version = "0.8.3" +name = "thread_local" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "displaydoc", - "zerovec", + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", ] [[package]] @@ -1622,10 +2480,23 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-postgres" version = "0.7.18" @@ -1645,7 +2516,7 @@ dependencies = [ "pin-project-lite", "postgres-protocol", "postgres-types", - "rand", + "rand 0.10.2", "socket2", "tokio", "tokio-util", @@ -1670,6 +2541,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -1688,6 +2560,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1726,6 +2599,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1763,6 +2637,24 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -1787,6 +2679,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-joining-type" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d00a78170970967fdb83f9d49b92f959ab2bb829186b113e4f4604ad98e180" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -1832,6 +2730,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1996,12 +2904,43 @@ dependencies = [ "web-sys", ] +[[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-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-sys" version = "0.52.0" @@ -2091,63 +3030,51 @@ 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 = "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.2" +name = "x509-parser" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", ] [[package]] -name = "yoke-derive" -version = "0.8.2" +name = "yasna" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", + "bit-vec", + "time", ] [[package]] -name = "zerofrom" -version = "0.1.8" +name = "zerocopy" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" dependencies = [ - "zerofrom-derive", + "zerocopy-derive", ] [[package]] -name = "zerofrom-derive" -version = "0.1.7" +name = "zerocopy-derive" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "synstructure", ] [[package]] @@ -2156,39 +3083,6 @@ 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.117", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index cb33e47..156b8fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ # `.busbar-ref`; no sibling-checkout path dependency ships in any manifest. # # THIRD-PARTY VERSIONS ARE THE FLEET'S, NOT THIS REPO'S. The table below is busbar's own root -# `[workspace.dependencies]` (third-party rows) at busbar e1d3d8b097201859751d77208e66249d98b50a4b, followed by the plugin-only rows -# of busbar's `.github/fleet/deps.toml` at 5489d5f61537027db7c4abf2a049a4c6233735c0. A member takes a crate with +# `[workspace.dependencies]` (third-party rows) at busbar daf4c0275e734fe1988053e241486042351b8fb3, followed by the plugin-only rows +# of busbar's `.github/fleet/deps.toml` at daf4c0275e734fe1988053e241486042351b8fb3. A member takes a crate with # `{ workspace = true }` (adding features if it needs them); a crate in neither table is a new # `deps.toml` row in busbar first. CI holds this repo's Cargo.lock to busbar's lock at the pin. [workspace] @@ -29,15 +29,20 @@ publish = false repository = "https://github.com/GetBusbar/busbar-store-postgres" [workspace.dependencies] -busbar-contract = { git = "https://github.com/GetBusbar/busbar", rev = "e1d3d8b097201859751d77208e66249d98b50a4b" } -busbar-plugin-loader = { git = "https://github.com/GetBusbar/busbar", rev = "e1d3d8b097201859751d77208e66249d98b50a4b" } -# busbar's root [workspace.dependencies], third-party rows, at e1d3d8b097201859751d77208e66249d98b50a4b +busbar-contract = { git = "https://github.com/GetBusbar/busbar", rev = "daf4c0275e734fe1988053e241486042351b8fb3" } +busbar-plugin-loader = { git = "https://github.com/GetBusbar/busbar", rev = "daf4c0275e734fe1988053e241486042351b8fb3" } +# THE SUITE'S HOST CONNECTOR (dev-only; store-postgres-plugin/tests/support/conformance_host.rs, +# rendered because the declares file states network needs): busbar's connector and its TLS +# test kit at the pin, and the transport framers those needs name, at the revs busbar links. +busbar-core-connector = { git = "https://github.com/GetBusbar/busbar", rev = "daf4c0275e734fe1988053e241486042351b8fb3", features = ["test-support"] } +busbar-transport-tcp = { git = "https://github.com/GetBusbar/busbar-transport-tcp", rev = "89d08ebcb91e5b2d4001b667fba151d9bc09d22d" } +# busbar's root [workspace.dependencies], third-party rows, at daf4c0275e734fe1988053e241486042351b8fb3 a2a-lf = "0.3.0" a2a-pb = "0.2.0" arc-swap = "1" async-trait = "0.1" axum = "0.8" -base64 = "0.23" +base64 = "0.22" bumpalo = "3" bytes = "1" core_affinity = "0.8" @@ -50,16 +55,15 @@ futures = "0.3" getrandom = "0.3" h2 = "0.4" hex = "0.4.3" -hmac = "0.13.0" http = "1" http-body = "1" http-body-util = "0.1" httpdate = "1.0" hyper = { version = "1", default-features = false, features = ["server", "client", "http1", "http2"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "webpki-tokio"] } hyper-util = { version = "0.1", default-features = false, features = ["client-legacy", "http1", "http2", "server-auto", "server-graceful", "service", "tokio"] } +idna_adapter = "=1.1.0" indexmap = { version = "2", features = ["serde"] } -jsonschema = "0.49" +jsonschema = { version = "0.49", default-features = false } libc = "0.2" libloading = "0.9" log = "0.4" @@ -68,12 +72,12 @@ memchr = "2" metrics = "0.24.6" metrics-exporter-prometheus = { version = "0.18.3", default-features = false } metrics-util = { version = "0.20.4", default-features = false } -oauth-as = { version = "1.0.0", features = ["http", "jwt", "consent", "resource-metadata"] } +oauth-as = { version = "1.0.0", features = ["http", "jwt", "consent", "resource-metadata", "par", "dpop", "client-assertion"] } opentelemetry-proto = { version = "0.32", default-features = false, features = ["gen-tonic-messages", "trace"] } proc-macro2 = { version = "1", features = ["span-locations"] } proptest = "1" prost = { version = "0.14", default-features = false, features = ["std"] } -rcgen = "0.14" +rcgen = { version = "0.14", default-features = false, features = ["crypto", "ring"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } ring = "0.17" rmcp = { version = "3.1.2", default-features = false } @@ -84,12 +88,12 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_urlencoded = "0.7" serde_yaml = { package = "serde_yaml_ng", version = "0.10" } -sha2 = "0.11.0" +sha2 = "0.10" smallvec = "1" socket2 = { version = "0.6", features = ["all"] } sonic-rs = "0.5" syn = { version = "2", features = ["full", "visit", "parsing", "printing"] } -tar = "0.4" +tar = { version = "0.4", default-features = false } tikv-jemalloc-ctl = "0.6" tikv-jemallocator = "0.6" tokio = "1" @@ -108,11 +112,19 @@ url = "2" webpki = { package = "rustls-webpki", version = "0.103", default-features = false, features = ["std", "ring"] } webpki-roots = "1" zeroize = "1" -# busbar .github/fleet/deps.toml [plugin-deps], at 5489d5f61537027db7c4abf2a049a4c6233735c0 +# busbar .github/fleet/deps.toml [plugin-deps], at daf4c0275e734fe1988053e241486042351b8fb3 ldap3 = { version = "0.12", default-features = false, features = ["sync"] } -mysql = { version = "26", default-features = false, features = ["minimal"] } +mysql = { version = "28", default-features = false, features = ["minimal-rust"] } +mysql_common = { version = "0.35", default-features = false } +percent-encoding = "2" postgres = "0.19" redis = { version = "1", default-features = false, features = ["script"] } rusqlite = { version = "0.40", features = ["bundled"] } ulid = "1" +# ONE busbar-contract (the suite's host connector): the transport framers above name +# busbar-contract at their own revs of busbar; this repo's pin serves them all, as busbar +# patches them to its own tree. The source is busbar spelled through www.github.com: Cargo +# refuses a patch that names the source it patches. +[patch."https://github.com/GetBusbar/busbar"] +busbar-contract = { git = "https://www.github.com/GetBusbar/busbar", rev = "daf4c0275e734fe1988053e241486042351b8fb3" } diff --git a/README.md b/README.md index 8587016..dc89668 100644 --- a/README.md +++ b/README.md @@ -97,13 +97,18 @@ the engine's JSON config (`{"url": "postgres://..."}`) into a ### Known limitations (documented honestly, not papered over) -- **No TLS in this build (`NoTls`).** Run the connection over a trusted - network segment, a local socket, or a TLS-terminating proxy - (pgbouncer/stunnel). -- **No automatic reconnect.** A persistently dropped connection - surfaces as store errors on the write-behind flush path and on admin - operations; a permanently broken connection requires a process - restart (let your supervisor handle it). +- **The store opens no socket of its own.** It reaches Postgres through + busbar's connector (the store declares one outbound `tcp` need in the + `operator-infrastructure` egress class). +- **One kept connection, as 1.5.x.** The store connects and authenticates + once and keeps that connection; operations take turns on it. A + connection that fails (or is left inside a transaction) is closed, and + the next operation connects afresh rather than needing a restart. +- **TLS through busbar.** `sslmode=require`, `verify-ca` and + `verify-full` secure the connection through busbar's connector and its + trust anchors (always verifying the certificate and name); `disable`, + `allow` and `prefer` connect in plaintext, as 1.5.x (`NoTls`) did. A Unix-socket `host` is refused: the store + reaches its server over TCP. See the doc comments at the top of [`store-postgres/src/lib.rs`](store-postgres/src/lib.rs) @@ -115,7 +120,7 @@ thin `cdylib` adapter around it. | Setting | Required | Default | Notes | |---|---|---|---| -| `url` | yes | — | A libpq connection string, e.g. `postgres://user:pass@host:5432/busbar`. Connects `NoTls`; run it over a trusted network segment or a TLS-terminating proxy. **No connect timeout is set by default** — a blackholed host wedges engine boot indefinitely. libpq honors a `connect_timeout` query param in the DSN, e.g. `postgres://user:pass@host:5432/busbar?connect_timeout=10`; set one if boot hanging on a dead host is a concern. | +| `url` | yes | — | A libpq connection string, URL (`postgres://user:pass@host:5432/busbar?sslmode=require`) or keyword form (`host=... user=... password=... dbname=...`). Connections go through busbar's connector, whose deadlines bound every connect (`connect_timeout` is accepted and ignored). `sslmode=require`/`verify-*` secures the connection through busbar; the other modes are plaintext. | ## Build diff --git a/deny.toml b/deny.toml index 44e1617..b935439 100644 --- a/deny.toml +++ b/deny.toml @@ -1,5 +1,5 @@ # RENDERED by `busbar-release plugin sync` from GetBusbar/busbar-release template/ and busbar's -# `.github/fleet/deps.toml` at 5489d5f61537027db7c4abf2a049a4c6233735c0; a hand edit is overwritten by the next sync. CI runs +# `.github/fleet/deps.toml` at daf4c0275e734fe1988053e241486042351b8fb3; a hand edit is overwritten by the next sync. CI runs # `cargo deny check` on it (busbar's plugin-ci.yml at the pin). The license allowlist is busbar's own; # the one reviewed git source is busbar itself, at the pin. [graph] @@ -88,8 +88,6 @@ deny = [ { crate = "h2:>=0.5.0", reason = "busbar resolves h2 to 0.4.18; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "hex:<0.4.0", reason = "busbar resolves hex to 0.4.3; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "hex:>=0.5.0", reason = "busbar resolves hex to 0.4.3; a plugin may not pull a second version (OWNER 2026-10-02)" }, - { crate = "hmac:<0.13.0", reason = "busbar resolves hmac to 0.13.0; a plugin may not pull a second version (OWNER 2026-10-02)" }, - { crate = "hmac:>=0.14.0", reason = "busbar resolves hmac to 0.13.0; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "http:<1.0.0", reason = "busbar resolves http to 1.4.1; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "http:>=2.0.0", reason = "busbar resolves http to 1.4.1; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "http-body:<1.0.0", reason = "busbar resolves http-body to 1.1.0; a plugin may not pull a second version (OWNER 2026-10-02)" }, @@ -100,10 +98,10 @@ deny = [ { crate = "httpdate:>=2.0.0", reason = "busbar resolves httpdate to 1.0.3; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "hyper:<1.0.0", reason = "busbar resolves hyper to 1.11.1; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "hyper:>=2.0.0", reason = "busbar resolves hyper to 1.11.1; a plugin may not pull a second version (OWNER 2026-10-02)" }, - { crate = "hyper-rustls:<0.27.0", reason = "busbar resolves hyper-rustls to 0.27.9; a plugin may not pull a second version (OWNER 2026-10-02)" }, - { crate = "hyper-rustls:>=0.28.0", reason = "busbar resolves hyper-rustls to 0.27.9; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "hyper-util:<0.1.0", reason = "busbar resolves hyper-util to 0.1.20; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "hyper-util:>=0.2.0", reason = "busbar resolves hyper-util to 0.1.20; a plugin may not pull a second version (OWNER 2026-10-02)" }, + { crate = "idna_adapter:<1.0.0", reason = "busbar resolves idna_adapter to 1.1.0; a plugin may not pull a second version (OWNER 2026-10-02)" }, + { crate = "idna_adapter:>=2.0.0", reason = "busbar resolves idna_adapter to 1.1.0; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "indexmap:<2.0.0", reason = "busbar resolves indexmap to 2.14.2; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "indexmap:>=3.0.0", reason = "busbar resolves indexmap to 2.14.2; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "jsonschema:<0.49.0", reason = "busbar resolves jsonschema to 0.49.9; a plugin may not pull a second version (OWNER 2026-10-02)" }, @@ -136,8 +134,8 @@ deny = [ { crate = "prost:>=0.15.0", reason = "busbar resolves prost to 0.14.3; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "rcgen:<0.14.0", reason = "busbar resolves rcgen to 0.14.10; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "rcgen:>=0.15.0", reason = "busbar resolves rcgen to 0.14.10; a plugin may not pull a second version (OWNER 2026-10-02)" }, - { crate = "reqwest:<0.12.0", reason = "busbar resolves reqwest to 0.12.28, 0.13.4; a plugin may not pull a second version (OWNER 2026-10-02)" }, - { crate = "reqwest:>=0.14.0", reason = "busbar resolves reqwest to 0.12.28, 0.13.4; a plugin may not pull a second version (OWNER 2026-10-02)" }, + { crate = "reqwest:<0.12.0", reason = "busbar resolves reqwest to 0.12.28; a plugin may not pull a second version (OWNER 2026-10-02)" }, + { crate = "reqwest:>=0.13.0", reason = "busbar resolves reqwest to 0.12.28; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "ring:<0.17.0", reason = "busbar resolves ring to 0.17.14; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "ring:>=0.18.0", reason = "busbar resolves ring to 0.17.14; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "rmcp:<3.0.0", reason = "busbar resolves rmcp to 3.1.2; a plugin may not pull a second version (OWNER 2026-10-02)" }, @@ -156,8 +154,8 @@ deny = [ { crate = "serde_urlencoded:>=0.8.0", reason = "busbar resolves serde_urlencoded to 0.7.1; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "serde_yaml_ng:<0.10.0", reason = "busbar resolves serde_yaml_ng to 0.10.0; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "serde_yaml_ng:>=0.11.0", reason = "busbar resolves serde_yaml_ng to 0.10.0; a plugin may not pull a second version (OWNER 2026-10-02)" }, - { crate = "sha2:<0.10.0", reason = "busbar resolves sha2 to 0.10.9, 0.11.0; a plugin may not pull a second version (OWNER 2026-10-02)" }, - { crate = "sha2:>=0.12.0", reason = "busbar resolves sha2 to 0.10.9, 0.11.0; a plugin may not pull a second version (OWNER 2026-10-02)" }, + { crate = "sha2:<0.10.0", reason = "busbar resolves sha2 to 0.10.9; a plugin may not pull a second version (OWNER 2026-10-02)" }, + { crate = "sha2:>=0.12.0", reason = "busbar resolves sha2 to 0.10.9; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "smallvec:<1.0.0", reason = "busbar resolves smallvec to 1.16.1; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "smallvec:>=2.0.0", reason = "busbar resolves smallvec to 1.16.1; a plugin may not pull a second version (OWNER 2026-10-02)" }, { crate = "socket2:<0.6.0", reason = "busbar resolves socket2 to 0.6.4; a plugin may not pull a second version (OWNER 2026-10-02)" }, @@ -212,16 +210,8 @@ skip = [ { crate = "base64@0.23.1", reason = "busbar's own lock holds this duplicate" }, { crate = "bit-vec@0.8.0", reason = "busbar's own lock holds this duplicate" }, { crate = "bit-vec@0.9.1", reason = "busbar's own lock holds this duplicate" }, - { crate = "block-buffer@0.10.4", reason = "busbar's own lock holds this duplicate" }, - { crate = "block-buffer@0.12.0", reason = "busbar's own lock holds this duplicate" }, - { crate = "const-oid@0.10.2", reason = "busbar's own lock holds this duplicate" }, - { crate = "const-oid@0.9.6", reason = "busbar's own lock holds this duplicate" }, { crate = "cpufeatures@0.2.17", reason = "busbar's own lock holds this duplicate" }, { crate = "cpufeatures@0.3.0", reason = "busbar's own lock holds this duplicate" }, - { crate = "crypto-common@0.1.7", reason = "busbar's own lock holds this duplicate" }, - { crate = "crypto-common@0.2.2", reason = "busbar's own lock holds this duplicate" }, - { crate = "digest@0.10.7", reason = "busbar's own lock holds this duplicate" }, - { crate = "digest@0.11.3", reason = "busbar's own lock holds this duplicate" }, { crate = "foldhash@0.1.5", reason = "busbar's own lock holds this duplicate" }, { crate = "foldhash@0.2.0", reason = "busbar's own lock holds this duplicate" }, { crate = "getrandom@0.2.17", reason = "busbar's own lock holds this duplicate" }, @@ -241,18 +231,20 @@ skip = [ { crate = "rand_core@0.10.1", reason = "busbar's own lock holds this duplicate" }, { crate = "rand_core@0.6.4", reason = "busbar's own lock holds this duplicate" }, { crate = "rand_core@0.9.5", reason = "busbar's own lock holds this duplicate" }, - { crate = "reqwest@0.12.28", reason = "busbar's own lock holds this duplicate" }, - { crate = "reqwest@0.13.4", reason = "busbar's own lock holds this duplicate" }, - { crate = "sha2@0.10.9", reason = "busbar's own lock holds this duplicate" }, - { crate = "sha2@0.11.0", reason = "busbar's own lock holds this duplicate" }, { crate = "syn@2.0.117", reason = "busbar's own lock holds this duplicate" }, { crate = "syn@3.0.3", reason = "busbar's own lock holds this duplicate" }, { crate = "windows-sys@0.52.0", reason = "busbar's own lock holds this duplicate" }, { crate = "windows-sys@0.61.2", reason = "busbar's own lock holds this duplicate" }, + { crate = "sha2:>=0.11.0, <0.12.0", reason = "the 0.11 line of sha2 is declared for this repo in busbar's .github/fleet/deps.toml [parity-lines]" }, + { crate = "digest:>=0.11.0, <0.12.0", reason = "the 0.11 line of digest is declared for this repo in busbar's .github/fleet/deps.toml [parity-lines]" }, + { crate = "block-buffer:>=0.12.0, <0.13.0", reason = "the 0.12 line of block-buffer is declared for this repo in busbar's .github/fleet/deps.toml [parity-lines]" }, + { crate = "crypto-common:>=0.2.0, <0.3.0", reason = "the 0.2 line of crypto-common is declared for this repo in busbar's .github/fleet/deps.toml [parity-lines]" }, + { crate = "const-oid:>=0.10.0, <0.11.0", reason = "the 0.10 line of const-oid is declared for this repo in busbar's .github/fleet/deps.toml [parity-lines]" }, + { crate = "wasi:>=0.14.0, <0.15.0", reason = "the 0.14 line of wasi is declared for this repo in busbar's .github/fleet/deps.toml [parity-lines]" }, ] [sources] unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] -allow-git = ["https://github.com/GetBusbar/busbar"] +allow-git = ["https://github.com/GetBusbar/busbar", "https://www.github.com/GetBusbar/busbar", "https://github.com/GetBusbar/busbar-transport-tcp"] diff --git a/store-postgres-plugin/Cargo.toml b/store-postgres-plugin/Cargo.toml index 798dfad..2cfa041 100644 --- a/store-postgres-plugin/Cargo.toml +++ b/store-postgres-plugin/Cargo.toml @@ -28,7 +28,7 @@ busbar-contract = { workspace = true } # The conformance and end-to-end tests load the built cdylib over the REAL loader (sign, pack, scan, # open) and the linked row through the same registry — dev-only, never in the shipped artifact. Same # pinned rev as the logic crate's busbar-contract, so one busbar source resolves. -busbar-plugin-loader = { workspace = true } +busbar-plugin-loader = { workspace = true, features = ["test-support", "conformance"] } busbar-contract = { workspace = true } serde_json = { workspace = true } postgres = { workspace = true } @@ -41,3 +41,9 @@ postgres = { workspace = true } # in-process axum server. reqwest = { workspace = true, features = ["blocking", "json"] } base64 = "0.22" +# THE SUITE'S HOST CONNECTOR (tests/support/conformance_host.rs, rendered by the fleet template for a +# repo whose declares state network needs): busbar's connector and its TLS test kit, and the tcp +# framer the store's need names. Dev-only. +busbar-core-connector = { workspace = true } +busbar-transport-tcp = { workspace = true } +tokio = { workspace = true, features = ["rt"] } diff --git a/store-postgres-plugin/src/lib.rs b/store-postgres-plugin/src/lib.rs index ae3899a..050f936 100644 --- a/store-postgres-plugin/src/lib.rs +++ b/store-postgres-plugin/src/lib.rs @@ -10,8 +10,10 @@ //! All the store lives in the `busbar-store-postgres` crate, including its one door (`door`, from //! `store_door!`). This crate re-exports the logic crate so the library it builds carries exactly //! the code a busbar build that links the store runs — one source, both doors (DECISIONS #2 rule -//! (1)) — and exports that same `door` as `busbar_plugin_door`. It also registers the store on the -//! cold store lane the busbar kernel at the pin boots a configured store through (`cold`). +//! (1)) — and exports that same `door` as `busbar_plugin_door`. +//! +//! The library opens no socket: the store reaches its server through the host's connector (its +//! one declared `tcp` need), and the image exports exactly the one door symbol. #![deny(unsafe_code)] @@ -22,23 +24,3 @@ pub use busbar_store_postgres::*; mod exported { busbar_contract::export_door!(busbar_store_postgres::door); } - -/// The store a busbar at the pin BOOTS. Its kernel opens a configured dropped-in store through the -/// cold store lane (`PluginRegistry::open_store` -> `load_store_image`: `busbar_abi`, -/// `busbar_plugin_kind`, `busbar_open`, ...), not through the door; an image that exports only -/// `busbar_plugin_door` answers `busbar_plugin_kind` with NULL there and the boot is refused -/// (BUSBAR-9007 "returned a null kind string"). This registration answers that lane over the same -/// [`PostgresStore`], opened by the door's own `open` slot (same settings, same refusals). -fn open_cold(cfg: &str) -> Result { - use busbar_contract::abi::sdk::store::StoreSlots; - ::open(cfg.as_bytes()) - .map(|s| Box::new(s) as busbar_contract::abi::sdk::StoreHandle) -} - -/// THE COLD LANE's registration (`export_store_plugin!`): the contract SDK's frozen symbols answer -/// through it. The macro's boundary functions are `unsafe extern "C-unwind"` by the cold ABI's own -/// definition, and it registers through a load-time initializer section. -#[allow(unsafe_code)] -mod cold { - busbar_contract::abi::sdk::export_store_plugin!(super::open_cold); -} diff --git a/store-postgres-plugin/tests/admin_api_e2e.rs b/store-postgres-plugin/tests/admin_api_e2e.rs index 280310d..ef8a673 100644 --- a/store-postgres-plugin/tests/admin_api_e2e.rs +++ b/store-postgres-plugin/tests/admin_api_e2e.rs @@ -6,8 +6,8 @@ //! real and still kept as its own proof), but the REAL admin HTTP API: //! //! 1. Boot a real `busbar` process (no `--validate`) with its admin listener up and the compiled-in -//! `memory` store active (`store:` block absent — see `busbar`'s own `StoreCfg` doc: "Absent -//! block = the compiled-in ephemeral RAM store"). +//! `memory` store active (`store: {module: memory}`: busbar 1.6.0 requires the block and names +//! the RAM store this way). //! 2. `POST /api/v1/admin/plugins` with the REAL built cdylib, base64-encoded, guarded by a real //! `x-admin-token` — the exact wire shape `crates/busbar/src/admin/v1/json/handlers.rs`'s //! `install_plugin` and its own admin test (`test_admin_v1_plugin_install_list_reload_remove` @@ -29,7 +29,7 @@ //! 5. Boot a SECOND real `busbar` process against that config. Busbar's own first-boot store //! resolution (`plugin_registry.open_store`, called synchronously during construction, before //! the listener ever binds) dlopens the plugin that landed on disk via the admin API in step 2. -//! Poll — via a RAW independent `postgres::Client`, never `PostgresStore::connect` — for the +//! Poll — via a RAW independent `postgres::Client`, never the store itself — for the //! `keys` table to appear, proving the real dlopen + `Store::connect`/`migrate()` path executed. //! 6. `POST /api/v1/admin/keys` (with `issue_aws_credential: true`) against this SECOND process — //! REAL WORK: mint a virtual key AND a credential through the running instance, over the same @@ -433,11 +433,15 @@ fn install_over_admin_api_then_mint_a_key_and_verify_postgres_directly() { plugins_dir.display() ); - // BOOT #1: no `store:` block at all -- the compiled-in `memory` store, per StoreCfg's own doc - // ("Absent block = the compiled-in ephemeral RAM store"). The postgres plugin is NOT on disk - // yet: this process only exists to serve the admin API that installs it. + // BOOT #1: the compiled-in `memory` store (`store: {module: memory}`; busbar 1.6.0 refuses a + // config without a `store:` block, BUSBAR-9007). The postgres plugin is NOT on disk yet: this + // process only exists to serve the admin API that installs it. let config1 = work.join("config1.yaml"); - std::fs::write(&config1, &providers_and_common).unwrap(); + std::fs::write( + &config1, + format!("{providers_and_common}store:\n module: memory\n"), + ) + .unwrap(); let mut guard1 = spawn_busbar( Command::new(&busbar_bin) diff --git a/store-postgres-plugin/tests/common/mod.rs b/store-postgres-plugin/tests/common/mod.rs index 2c9e065..404b780 100644 --- a/store-postgres-plugin/tests/common/mod.rs +++ b/store-postgres-plugin/tests/common/mod.rs @@ -14,9 +14,11 @@ use std::sync::Arc; use busbar_plugin_loader::dispatch::kinds::store::Store; use busbar_plugin_loader::dispatch::{ - load_dropped, load_linked, rendering_of, Bind, DispatchConfig, Dispatcher, LinkedRow, NoSink, + load_dropped, load_linked, rendering_of, Bind, ConnTable, DispatchConfig, Dispatcher, + LinkedRow, NoSink, }; use busbar_plugin_loader::store_v3::LoadedStore; +use busbar_plugin_loader::tcp_conns::TcpConns; /// This crate's built cdylib (uplifted or under `deps`, newest wins). A missing artifact is a /// failure, never a skip: the dropped-in door is what these tests prove. @@ -41,28 +43,35 @@ pub fn stated() -> Vec { rendering_of(busbar_store_postgres::door).expect("the store renders its Statement") } -/// A node id no other open (in this run or an earlier one) has used. The store dedupes `op_id`s -/// DURABLY and the `LoadedStore` bridge mints them as `(node, counter)` from 0, so two opens -/// sharing a node id against one database would replay or refuse each other's writes; a kernel's -/// node id is unique per node, and this stands in for it. -fn node() -> u64 { +/// A fresh `op_id` for every bridge write: a node half no other open (in this run or an earlier +/// one) used. The store dedupes `op_id`s DURABLY, so two opens sharing a node half against one +/// database would replay or refuse each other's writes; a kernel's node id is unique per node, and +/// this stands in for it. +fn mint() -> busbar_contract::abi::store::OpId { + static NODE: std::sync::OnceLock = std::sync::OnceLock::new(); static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() as u64; - nanos - ^ (u64::from(std::process::id()) << 40) - ^ N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + let node = *NODE.get_or_init(|| { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + nanos ^ (u64::from(std::process::id()) << 40) + }); + busbar_contract::abi::store::OpId::from_parts( + node, + N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1, + ) } +/// The instance's binding: its connections over the loader's test connection table (plain TCP), +/// woken through the dispatcher, as busbar's one connector serves a store's `tcp` need. fn bind(d: &Dispatcher) -> Bind { Bind { instance: Arc::from("store-postgres-test"), max_inflight_cap: 64, sink: Arc::new(NoSink), dispatcher: d.adopter(), - conns: None, + conns: ConnTable::Host(Arc::new(TcpConns::new(d.conn_waker()))), } } @@ -71,7 +80,7 @@ pub fn linked(settings: &str) -> Result { let d = Arc::new(Dispatcher::new(DispatchConfig::default())); let row = LinkedRow::of(busbar_store_postgres::door).map_err(|e| e.to_string())?; let p = load_linked::(&row, bind(&d)).map_err(|e| e.to_string())?; - LoadedStore::open(p, d, settings.as_bytes(), node()) + LoadedStore::open(p, d, settings.as_bytes(), mint) } /// The library at `path` through the DROPPED-IN door, admitted against [`stated`] and opened on @@ -79,7 +88,7 @@ pub fn linked(settings: &str) -> Result { pub fn dropped_at(path: &Path, settings: &str) -> Result { let d = Arc::new(Dispatcher::new(DispatchConfig::default())); let p = load_dropped::(path, &stated(), bind(&d)).map_err(|e| e.to_string())?; - LoadedStore::open(p, d, settings.as_bytes(), node()) + LoadedStore::open(p, d, settings.as_bytes(), mint) } /// This crate's cdylib through the DROPPED-IN door. diff --git a/store-postgres-plugin/tests/conformance.json b/store-postgres-plugin/tests/conformance.json new file mode 100644 index 0000000..89f8c4d --- /dev/null +++ b/store-postgres-plugin/tests/conformance.json @@ -0,0 +1,13 @@ +{ + "settings": { "url": "postgres://busbar:busbar@localhost:52670/busbar_test?sslmode=require&options=-c%20search_path%3D{fold}" }, + "store": { + "credential_kind": "sigv4", + "node": 7, + "caps": { "bucket": "conf-cap", "window_start": 1700000000000, "requests": 3, "input": 100 }, + "draw": { "requests": 2, "input": 60 }, + "usage": { "bucket": "conf-usage", "window": 1700000000 }, + "stream": "conf-stream", + "schema": "conf-schema", + "keys": { "grouped": "vk_conf_grouped", "group": "conf-group", "plain": "vk_conf_plain", "absent": "vk_conf_absent" } + } +} diff --git a/store-postgres-plugin/tests/conformance.rs b/store-postgres-plugin/tests/conformance.rs index 77bf39a..40725da 100644 --- a/store-postgres-plugin/tests/conformance.rs +++ b/store-postgres-plugin/tests/conformance.rs @@ -17,15 +17,108 @@ //! tests use: unset under CI is a FAILURE, unset locally skips only this scenario), one durable //! scenario per door — upsert, point read, a child chain, single-use token redemption, delete. //! -//! RED ARMS, in the same test and always run (no database needed): the library asked for as +//! RED ARMS, each its own test and always run (no database needed): the library asked for as //! `kind: secret` is refused before any slot is called, and DIFFERENT bytes (the cdylib with its //! object magic broken) are not the store — so the comparison above cannot pass vacuously. mod common; +// THE PUBLISHED SUITE (busbar-plugin-loader's `conformance` feature, at the pin): the linked door and +// the built cdylib, each through the one loader, driven by the store kind's script over the live +// Postgres `conformance.json` names; exact crossing counts, the two folds equal, its RED arms. +// +// Each fold opens over its own schema: conformance.json's url names `search_path={fold}`, and the +// hooks below create that schema before the fold and drop it after, on an independent connection +// of the `postgres` driver (the store never creates a schema; nothing in its behaviour changes). +// +// THE HOST (ARCHITECT Q-P4-9): the store's `tcp` need is served by busbar's own connector, composed +// as the root composes it (`conformance_host`, rendered by the fleet template), and the store asks +// for TLS (`sslmode=require`): its `SSLRequest` is answered by the suite's TLS front, whose +// certificate chains to the suite's test CA, the anchors only the HOST's TLS is handed (`tls:`); +// the front carries the secured connection to the live Postgres. So every fold proves the store's +// connection is secured by the host, verified against the anchors. +#[path = "support/conformance_host.rs"] +mod conformance_host; + +busbar_plugin_loader::conformance_suite! { + door: busbar_store_postgres::door, + cdylib: "busbar_store_postgres_plugin", + inputs: include_str!("conformance.json"), + host: host, + tls: conformance_host::anchors(), + namespace: (create_fold_schema, drop_fold_schema), +} + +/// The live Postgres's address (`BUSBAR_TEST_POSTGRES_URL`'s authority; the service container's +/// default when unset). +fn upstream() -> &'static str { + static AT: std::sync::OnceLock = std::sync::OnceLock::new(); + AT.get_or_init(|| { + let url = std::env::var("BUSBAR_TEST_POSTGRES_URL").unwrap_or_default(); + url.split_once("://") + .map(|(_, rest)| rest) + .and_then(|rest| rest.rsplit_once('@').map_or(Some(rest), |(_, at)| Some(at))) + .and_then(|at| at.split('/').next()) + .filter(|at| !at.is_empty()) + .unwrap_or("localhost:5432") + .to_owned() + }) +} + +/// Postgres's cleartext negotiation: the client's `SSLRequest` (length 8, code 80877103) answered +/// `S`; anything else is not a TLS client. +fn ssl_request(tcp: &mut std::net::TcpStream) -> bool { + use std::io::{Read, Write}; + let mut req = [0_u8; 8]; + tcp.read_exact(&mut req).is_ok() + && req == [0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f] + && tcp.write_all(b"S").is_ok() +} + +/// The host the suite binds the store over, with the TLS front its settings name already listening. +fn host( + wake: std::sync::Arc, + anchors: Option<&str>, +) -> std::sync::Arc { + conformance_host::tls_front(ssl_request, upstream()); + conformance_host::host(wake, anchors) +} + +/// The live server a fold's filled settings name, on a connection of the `postgres` driver's own. +fn fold_client(settings: &[u8]) -> postgres::Client { + let v: serde_json::Value = + serde_json::from_slice(settings).expect("conformance.json's settings are JSON"); + let url = v["url"] + .as_str() + .expect("conformance.json's settings name a url"); + // Straight to the live server, in the clear: the TLS front is the store's, not this client's. + let url = url + .replace(conformance_host::FAR_END, upstream()) + .replace("sslmode=require", "sslmode=disable"); + postgres::Client::connect(&url, postgres::NoTls) + .expect("the live Postgres accepts the test client") +} + +/// The suite's namespace hook: the fold's schema, made before its open. +fn create_fold_schema(namespace: &str, settings: &[u8]) { + fold_client(settings) + .batch_execute(&format!("CREATE SCHEMA IF NOT EXISTS \"{namespace}\"")) + .expect("the fold's schema is created"); +} + +/// The suite's namespace hook: the fold's schema and everything the store made in it, dropped after +/// the fold. +fn drop_fold_schema(namespace: &str, settings: &[u8]) { + fold_client(settings) + .batch_execute(&format!("DROP SCHEMA IF EXISTS \"{namespace}\" CASCADE")) + .expect("the fold's schema is dropped"); +} + use busbar_contract::records::{PlaneDisposition, PlaneRecord, PlaneSelector, RecordStore}; use busbar_plugin_loader::dispatch::kinds::secret::Secret; -use busbar_plugin_loader::dispatch::{load_dropped, Bind, DispatchConfig, Dispatcher, NoSink}; +use busbar_plugin_loader::dispatch::{ + load_dropped, Bind, ConnTable, DispatchConfig, Dispatcher, NoSink, +}; use busbar_plugin_loader::store_v3::LoadedStore; use std::sync::Arc; @@ -137,30 +230,11 @@ fn transcript( }) } -/// The Postgres store behaves as ONE store through either door — and the RED arms show the +/// The Postgres store behaves as ONE store through either door; the RED arms below show the /// comparison is not vacuous. #[test] fn the_linked_and_the_dropped_in_postgres_store_are_one_store() { let live = live_url(); - let lib = common::cdylib(); - - // RED ARM 1 — RUN FIRST: DIFFERENT bytes (the object's magic broken) are not the store. It must - // run before ANY good image is loaded from a path the loader may have mapped already. - let mut foreign = std::fs::read(&lib).expect("read the cdylib"); - foreign[..4].copy_from_slice(b"XXXX"); - let dir = std::env::temp_dir().join(format!("store-postgres-conf-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let broken = dir.join(lib.file_name().unwrap()); - std::fs::write(&broken, &foreign).unwrap(); - let red = match common::dropped_at(&broken, "{}") { - Ok(_) => panic!("foreign bytes opened as the store"), - Err(e) => e, - }; - assert!( - !red.contains("requires a \"url\""), - "foreign bytes cannot speak the store's own refusal: {red}" - ); let linked = transcript("linked", &common::linked, live.as_deref()); let dropped_in = transcript("dropped", &common::dropped, live.as_deref()); @@ -197,19 +271,46 @@ fn the_linked_and_the_dropped_in_postgres_store_are_one_store() { }) ); } +} - // RED ARM 2: the store's library asked for as another kind is refused before any slot runs. +/// RED: DIFFERENT bytes (the object's magic broken) are not the store. The foreign image is +/// written under its own directory, so the loader `dlopen`s a path no good image was ever loaded +/// from, whatever order the tests in this target run in. +#[test] +fn foreign_bytes_dropped_in_are_not_the_postgres_store() { + let lib = common::cdylib(); + let mut foreign = std::fs::read(&lib).expect("read the cdylib"); + foreign[..4].copy_from_slice(b"XXXX"); + let dir = std::env::temp_dir().join(format!("store-postgres-conf-red-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let broken = dir.join(lib.file_name().unwrap()); + std::fs::write(&broken, &foreign).unwrap(); + let red = match common::dropped_at(&broken, "{}") { + Ok(_) => panic!("foreign bytes opened as the store"), + Err(e) => e, + }; + let _ = std::fs::remove_dir_all(&dir); + assert!( + !red.contains("requires a \"url\""), + "foreign bytes cannot speak the store's own refusal: {red}" + ); +} + +/// RED: the store's library asked for as another kind is refused before any slot runs. +#[test] +fn the_postgres_store_library_loaded_as_another_kind_is_refused() { + let lib = common::cdylib(); let d = Arc::new(Dispatcher::new(DispatchConfig::default())); let bind = Bind { instance: Arc::from("store-postgres-as-secret"), max_inflight_cap: 64, sink: Arc::new(NoSink), dispatcher: d.adopter(), - conns: None, + conns: ConnTable::Probe, }; assert!( load_dropped::(&lib, &common::stated(), bind).is_err(), "a store library loaded as kind secret" ); - let _ = std::fs::remove_dir_all(&dir); } diff --git a/store-postgres-plugin/tests/e2e.rs b/store-postgres-plugin/tests/e2e.rs index 0457421..146d574 100644 --- a/store-postgres-plugin/tests/e2e.rs +++ b/store-postgres-plugin/tests/e2e.rs @@ -24,14 +24,14 @@ //! Persistence is then proven the same two independent ways the prior direct-call test used (kept — //! this part was always sound, only the LOADING mechanism was wrong): //! 1. The boot runs against a DISPOSABLE, freshly created, genuinely empty database, and is polled -//! via a RAW independent `postgres::Client` connection (never `PostgresStore::connect`, so this +//! via a RAW independent `postgres::Client` connection (never the store itself, so this //! check can't create the schema itself) for the `keys` table to appear within a timeout. The //! empty database is what makes this a proof rather than a formality: against the shared test //! database, every live unit test in this workspace has already migrated a `keys` table into //! existence, so the same poll would break true on its first iteration even if `busbar` had //! failed to load the plugin and exited immediately. Then the schema VERSION the boot wrote is //! read back, and the child is confirmed still running rather than having died after migrating. -//! 2. Only AFTER those proofs, a second, independent `PostgresStore::connect` (bypassing the +//! 2. Only AFTER those proofs, a second, independent open of the LINKED door (bypassing the //! plugin/ABI/loader entirely) confirms the store type itself can talk to the same schema. //! //! The two ABI-contract error-path tests below (`bad_config_fails_over_abi`, `refuses_non_plugin`) @@ -43,7 +43,6 @@ mod common; -use busbar_store_postgres::PostgresStore; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; @@ -484,7 +483,7 @@ fn load_and_exercise_postgres_plugin_via_file_drop() { // REAL BOOT: run the actual gateway process (no --validate) against the same file-dropped // plugin + config, and poll -- via a RAW independent postgres::Client connection, never - // PostgresStore::connect, so this check can't accidentally create the schema itself -- for the + // the store, so this check can't accidentally create the schema itself -- for the // `keys` table to appear. This is the only genuine proof that boot actually dlopened the plugin // and called Store::connect (which runs migrate()) before ever handling a request, and it is a // proof only because the database above was created empty for this test alone: against the @@ -541,7 +540,7 @@ fn load_and_exercise_postgres_plugin_via_file_drop() { // The boot did not just create a table, it landed the CURRENT schema: read the version the // plugin-loaded migrate() wrote, over a raw client. Checked before the direct connect below, - // because `PostgresStore::connect` runs migrate() itself and would write this row if the boot + // because the store's connect step runs migrate() itself and would write this row if the boot // had not. let mut raw = postgres::Client::connect(&url, postgres::NoTls) .expect("raw connect to the fresh database"); @@ -562,10 +561,10 @@ fn load_and_exercise_postgres_plugin_via_file_drop() { guard.output() ); - // Only AFTER those proofs: a second, independent PostgresStore::connect (bypassing the + // Only AFTER those proofs: a second, independent open of the LINKED door (bypassing the // plugin/ABI/loader entirely) confirms the store type itself can talk to the same schema the // real boot process just created. - let _direct = PostgresStore::connect(&url).expect( + let _direct = common::linked(&cfg(&url)).expect( "connect directly, bypassing the plugin entirely, to confirm the schema the real boot \ created is usable", ); @@ -642,7 +641,7 @@ fn plane_decode(b: &[u8]) -> serde_json::Value { /// cdylib, the real store v3 table, the real `LoadedStore`. It writes AT ARITY > 1 (three chained /// records for one principal and one for a second), DROPS the handle — which closes the instance, so /// nothing this process still holds can answer the reads — then `dlopen`s AGAIN over the same file -/// and reads everything back. A third leg reads the same rows through the plain `PostgresStore`, +/// and reads everything back. A third leg reads the same rows through the linked door, /// never touching the cdylib, the door or the loader — so a plugin that answered from its own /// in-process cache still fails here. #[test] @@ -660,7 +659,7 @@ fn mcp_call_log_survives_an_unload_and_reload_over_the_real_plugin_abi() { // assertions below meaningless. A purge at the top of the range is the store's own // contract-level wipe, so this needs no raw-SQL knowledge of the schema. No other test in this // binary touches the `call` kind. - let direct = PostgresStore::connect(&url).expect("connect directly to clean up and verify"); + let direct = common::linked(&cfg).expect("connect directly to clean up and verify"); RecordStore::purge_plane_records_before(&direct, "call", i64::MAX as u64) .expect("wipe the call log before this run"); @@ -800,8 +799,8 @@ fn mcp_call_log_survives_an_unload_and_reload_over_the_real_plugin_abi() { ); drop(store); - // LEG 3 — read the surviving rows through the plain `PostgresStore`, a code path that never - // touches the cdylib, the door or the loader. + // LEG 3 — read the surviving rows through the LINKED door (`common::linked`), a code path + // that never touches the cdylib or its dlopen. let direct_calls = chain(&direct, &p_main); assert_eq!( direct_calls @@ -828,7 +827,7 @@ fn mcp_call_log_survives_an_unload_and_reload_over_the_real_plugin_abi() { /// /// A REAL `dlopen`, the real store v3 table, the real `LoadedStore`. Write at arity > 1 (two tasks, one of them /// UPSERTED a second time, plus two independent provenance chains), DROP the handle, `dlopen` again -/// and read everything back; a third leg reads through the plain `PostgresStore`. +/// and read everything back; a third leg reads through the linked door. #[test] fn task_store_survives_an_unload_and_reload_over_the_real_plugin_abi() { use busbar_contract::records::{PlaneDisposition, PlaneRecord, PlaneSelector, RecordStore}; @@ -842,7 +841,7 @@ fn task_store_survives_an_unload_and_reload_over_the_real_plugin_abi() { // Start from no TERMINAL tasks. The task purge is GLOBAL and terminal-only, and its count is // asserted exactly below, so a leftover terminal row from an earlier run would make that // assertion meaningless. The store's own contract-level sweep of exactly that population. - let direct = PostgresStore::connect(&url).expect("connect directly to clean up and verify"); + let direct = common::linked(&cfg).expect("connect directly to clean up and verify"); RecordStore::purge_plane_records_before(&direct, "task", i64::MAX as u64) .expect("wipe terminal tasks before this run"); @@ -1042,7 +1041,7 @@ fn task_store_survives_an_unload_and_reload_over_the_real_plugin_abi() { ); drop(store); - // LEG 3 — the surviving rows through the plain `PostgresStore`. + // LEG 3 — the surviving rows through the linked door. let direct_task = RecordStore::get_plane_record(&direct, "task", &t_live) .expect("get_plane_record via the direct connection") .expect("the task must be physically present in Postgres, not just cached in-process"); @@ -1075,7 +1074,7 @@ fn task_store_survives_an_unload_and_reload_over_the_real_plugin_abi() { /// written, reported successful and DISCARDED (a restart hands a quarantined upstream the operator's /// approval back), and every redeemer of one single-use approval is told the trait default's answer. /// Two simultaneous loads are the fleet; a drop and a reload is the restart; a third leg reads -/// through the plain `PostgresStore`. +/// through the linked door. /// /// PANICS rather than skipping when no Postgres is configured: these are the only over-the-ABI /// coverage of two properties whose unimplemented form is silently green. @@ -1201,8 +1200,8 @@ fn trust_state_survives_an_unload_and_reload_over_the_real_plugin_abi() { "a freshly minted approval is not the one that was spent" ); - // LEG 3 — the same rows through the plain `PostgresStore`. - let direct = PostgresStore::connect(&url).expect("connect directly to verify and clean up"); + // LEG 3 — the same rows through the linked door. + let direct = common::linked(&cfg).expect("connect directly to verify and clean up"); assert!( demotions(&direct) .iter() diff --git a/store-postgres-plugin/tests/support/conformance_host.rs b/store-postgres-plugin/tests/support/conformance_host.rs new file mode 100644 index 0000000..069e5d3 --- /dev/null +++ b/store-postgres-plugin/tests/support/conformance_host.rs @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors +// +// RENDERED by `busbar-release plugin sync busbar-store-postgres` from GetBusbar/busbar-release +// template/conformance-host/, because this repo's declares file states network needs (`needs`: +// `tcp`); a hand edit is overwritten by the next sync. + +//! **THE SUITE'S HOST CONNECTOR, IN THIS PLUGIN'S OWN TEST** (ARCHITECT Q-P4-9 (d)). busbar keeps +//! only the neutral seam (`busbar_plugin_loader::conformance::Host`, the suite's `host:` and `tls:` +//! arguments); this adapter implements it the way busbar's composition root composes the process's +//! one connector — carrier -> [TLS] -> framer (`BUSBAR-1.6.0.md` THE DESIGN §5): +//! +//! * busbar's own `Connector` (`busbar-core-connector`, a dev-dependency at the pin), its framer +//! entries the transport doors this plugin's needs name (`tcp`), each loaded +//! through the loader's one load and opened as the root opens a transport door; +//! * the connector's own TLS (connsec), trusting the platform roots plus the suite's TEST trust +//! anchors (`tls:`), never handed to the plugin: no plugin holds a key, a certificate or a TLS +//! type (TRANSPORT-STACK (1)); +//! * a dial judge that admits loopback only: the suite dials the REAL local endpoints its settings +//! name, never the network; +//! * a parked read wakes the plugin's ticket through the leg dispatcher's conn waker. +//! +//! [`far_end`] is a TLS far end at [`FAR_END`] whose certificate chains to [`anchors`] (busbar's +//! test kit, `busbar_core_connector::test_support`): the plugin's `conformance.json` settings name +//! it, and its `answer` speaks the plugin's protocol. Its own tests, below, run with the suite: +//! a TLS upgrade verifies against the anchors, and is REFUSED with the anchors withheld (RED). +//! +//! The plugin's `tests/conformance.rs` mounts this file and names it: +//! +//! ```ignore +//! #[path = "support/conformance_host.rs"] +//! mod conformance_host; +//! +//! busbar_plugin_loader::conformance_suite! { +//! door: …, cdylib: …, inputs: include_str!("conformance.json"), +//! host: conformance_host::host, +//! tls: conformance_host::anchors(), +//! } +//! ``` +//! +//! with the dev-dependencies the rendered root `Cargo.toml` states (`{ workspace = true }`): +//! `busbar-core-connector`, `busbar-transport-tcp`, `tokio`. + +#![allow(dead_code)] + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream, ToSocketAddrs}; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; + +use busbar_contract::abi::host::conn::connector::{ + DIRECTION_OUTBOUND, EGRESS_OPERATOR_INFRASTRUCTURE, +}; +use busbar_contract::abi::mechanism::call::{Blob, Outcome, BLOB_ABSENT}; +use busbar_contract::abi::mechanism::door::DoorFn; +use busbar_contract::abi::mechanism::lifecycle::{slot as life, OpenIn, OpenOut}; +use busbar_contract::abi::mechanism::rendering::{ReadBlob, ReadNeed}; +use busbar_contract::abi::sdk::door::{blank_in, blank_out}; +use busbar_contract::abi::transport::slot; +use busbar_contract::conn::{ + ConnError, ConnId, DeclaredConns, InstanceId, NeedId, OpenDesc, PieceKind, NO_TICKET, +}; +use busbar_contract::transport::EgressTrust; +use busbar_core_connector::framer::{Call, Crossed, DoorFacts, FramerDoor}; +use busbar_core_connector::registry::{Entry, Transports}; +use busbar_core_connector::test_support::{private_ca, ServerTls, StdServerSession, TestCa}; +use busbar_core_connector::{Connector, Judged, Verdict}; +use busbar_plugin_loader::dispatch::kinds::transport::{Transport, TransportFacts}; +use busbar_plugin_loader::dispatch::{ + load_linked, Bind, ConnTable, DispatchConfig, Dispatcher, Frame, InFrame, LinkedRow, NoSink, + OutFrame, Plugin, +}; + +/// Where [`far_end`] listens: loopback, a port of this repo's own. +pub const FAR_END: &str = "localhost:52670"; + +/// The framer doors the host serves, as the root links them: (claim, door). One row a line, +/// whatever the claims' lengths (a `vec!` would be reflowed by rustfmt). +#[allow(clippy::vec_init_then_push)] +fn doors() -> Vec<(&'static str, DoorFn)> { + use busbar_transport_tcp::linked::door as tcp_door; + // Each row is compiled against this repo's own pin of busbar-contract (the rendered `[patch]`), + // as busbar compiles the rows it links. + let mut doors: Vec<(&'static str, DoorFn)> = Vec::new(); + doors.push(("tcp", tcp_door)); + doors +} + +/// The suite's test CA: [`anchors`] is its certificate, [`far_end`] serves a leaf it signed. +fn ca() -> &'static TestCa { + static CA: OnceLock = OnceLock::new(); + CA.get_or_init(private_ca) +} + +/// THE SUITE'S TEST TRUST ANCHORS (PEM), for `conformance_suite!`'s `tls:`. +#[must_use] +pub fn anchors() -> &'static str { + &ca().ca_pem +} + +/// THE HOST CONNECTOR (`busbar_plugin_loader::conformance::Host`): one per leg. +/// +/// # Panics +/// The anchors do not parse, a transport door does not load or open, or the view does not compose. +#[must_use] +pub fn host(wake: Arc, anchors: Option<&str>) -> Arc { + // The reactor a socket a plugin opens from a dispatcher worker registers on, as the root + // installs it (`root::connector::io_reactor`): the connector's own I/O thread. + busbar_core_connector::io::install_process_reactor(io_reactor()); + let trust = EgressTrust { + extra_anchors: anchors.map(pem_certs).unwrap_or_default(), + ..EgressTrust::default() + }; + let tls = busbar_core_connector::tls::client::build_client_config(&trust) + .unwrap_or_else(|e| panic!("the host's connection security does not build: {e}")); + let view = Transports::new(entries()) + .unwrap_or_else(|e| panic!("the host's transport doors do not compose: {e}")); + Arc::new(Connector::serving( + view, + Arc::new(loopback), + Some(Arc::new(tls)), + wake, + )) +} + +/// The connector's I/O thread: a single-threaded runtime of its own whose reactor drives every +/// socket the plugin opens from a dispatcher worker. Built once. +fn io_reactor() -> tokio::runtime::Handle { + static IO: OnceLock = OnceLock::new(); + IO.get_or_init(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("the host's I/O runtime builds"); + let handle = rt.handle().clone(); + std::thread::Builder::new() + .name("conformance-host-io".into()) + .spawn(move || rt.block_on(std::future::pending::<()>())) + .expect("the host's I/O thread starts"); + handle + }) + .clone() +} + +/// THE DIAL JUDGE: a loopback address (a literal, or a name that resolves to one) and nothing else. +fn loopback(dest: &str, _class: u32, _done: Judged) -> Option> { + let refused = busbar_contract::abi::host::service::DEST_UNRESOLVABLE; + let at = dest + .to_socket_addrs() + .ok() + .and_then(|mut a| a.find(|a| a.ip().is_loopback())); + Some(at.ok_or(refused)) +} + +/// Every certificate in `pem`, DER. +fn pem_certs(pem: &str) -> Vec> { + let certs = busbar_core_connector::test_support::certs_from_pem(pem); + assert!( + !certs.is_empty(), + "the suite's trust anchors hold no certificate" + ); + certs +} + +/// The dispatcher the host's transport doors are adopted by. +fn transport_dispatcher() -> &'static Dispatcher { + static ONE: OnceLock = OnceLock::new(); + ONE.get_or_init(|| Dispatcher::new(DispatchConfig::default())) +} + +/// The framer entries: each door loaded through the one loader and opened with no settings, as +/// the root opens a transport door that declares none it reads. +fn entries() -> Vec { + doors() + .into_iter() + .map(|(claim, door)| { + let bind = Bind { + instance: Arc::from(claim), + max_inflight_cap: 1024, + sink: Arc::new(NoSink), + dispatcher: transport_dispatcher().adopter(), + // A transport door is a framer the connector drives: it declares no need. + conns: ConnTable::NoNeeds, + }; + let plugin = LinkedRow::of(door) + .and_then(|row| load_linked::(&row, bind)) + .unwrap_or_else(|e| panic!("the host's transport `{claim}` does not load: {e}")); + Entry { + door: Arc::new(Opened::open(plugin, claim)), + alpn: Vec::new(), + } + }) + .collect() +} + +/// One transport door, opened, as the connector reaches it (the root's `doors::Dispatched`). +struct Opened { + plugin: Plugin, + facts: DoorFacts, +} + +impl Opened { + fn open(plugin: Plugin, claim: &str) -> Self { + let stated = plugin + .context::() + .cloned() + .unwrap_or_else(|| panic!("the host's transport `{claim}` states no transport tail")); + let mut i: OpenIn = blank_in(); + i.settings = Blob { + ptr: std::ptr::null(), + len: 0, + fmt: BLOB_ABSENT, + flags: 0, + }; + let mut f = Frame::new(i, blank_out::()); + let opened = plugin.call(life::OPEN, &mut f); + assert_eq!( + opened.outcome, + Outcome::Ready, + "the host's transport `{claim}` does not open" + ); + plugin + .ready(transport_dispatcher(), Duration::from_secs(10)) + .unwrap_or_else(|e| panic!("the host's transport `{claim}` is not ready: {e}")); + let facts = DoorFacts { + name: plugin.name().to_owned(), + claims: stated.claims, + composes_over: stated.composes_over, + }; + Self { plugin, facts } + } +} + +/// One crossing through the dispatcher: the host's `in`/`out` copied in, the answer copied back. +fn go(p: &Plugin, s: u32, i: &mut I, o: &mut O) -> Crossed { + let mut f = Frame::new(*i, *o); + let c = p.call(s, &mut f); + *i = f.input; + *o = f.out; + Crossed { + outcome: c.outcome, + error: c.error, + } +} + +impl FramerDoor for Opened { + fn facts(&self) -> &DoorFacts { + &self.facts + } + + fn cross(&self, call: Call<'_>) -> Crossed { + let p = &self.plugin; + match call { + Call::Locate(i, o) => go(p, slot::LOCATE, i, o), + Call::Begin(i, o) => go(p, slot::BEGIN, i, o), + Call::Ingest(i, o) => go(p, slot::INGEST, i, o), + Call::Emit(i, o) => go(p, slot::EMIT, i, o), + Call::Encode(i, o) => go(p, slot::ENCODE, i, o), + Call::Refuse(i, o) => go(p, slot::REFUSE, i, o), + Call::Finish(i, o) => go(p, slot::FINISH, i, o), + Call::Detach(i, o) => go(p, slot::DETACH, i, o), + Call::Adopt(i, o) => go(p, slot::ADOPT, i, o), + Call::Timer(i, o) => go(p, slot::TIMER, i, o), + } + } +} + +// ---- the far end ---- + +/// What a far end answers a connection: given every byte it has read so far, the bytes to send +/// back and close (`Some`), or `None` to read on. +pub type Answer = fn(&[u8]) -> Option>; + +/// THE SUITE'S TLS FAR END at [`FAR_END`] (started once; later calls are no-ops): every connection's +/// handshake is busbar's test kit's, with a `localhost` certificate [`anchors`] trusts, and its +/// bytes are answered by `answer`. +/// +/// # Panics +/// [`FAR_END`] cannot be bound. +pub fn far_end(answer: Answer) { + static STARTED: OnceLock<()> = OnceLock::new(); + STARTED.get_or_init(|| { + let listener = TcpListener::bind(FAR_END) + .unwrap_or_else(|e| panic!("the suite's far end cannot bind {FAR_END}: {e}")); + serve(listener, answer); + }); +} + +/// Serve `listener` over TLS with a leaf the test CA signed, answering each connection by `answer`. +fn serve(listener: TcpListener, answer: Answer) { + let tls = ServerTls::new(&[ca().leaf_der.clone()], &ca().key_der, None, &[]) + .unwrap_or_else(|e| panic!("the suite's far end has no TLS identity: {e}")); + std::thread::spawn(move || { + for tcp in listener.incoming() { + let Ok(tcp) = tcp else { return }; + let tls = tls.clone(); + std::thread::spawn(move || { + if let Ok(mut s) = tls.accept_std(tcp) { + if s.handshake_ok() { + reply(&mut s, answer); + } + } + }); + } + }); +} + +fn reply(s: &mut StdServerSession, answer: Answer) { + let mut seen = Vec::new(); + let mut buf = [0_u8; 4096]; + loop { + if let Some(out) = answer(&seen) { + let _ = s.write_all(&out); + let _ = s.flush(); + s.close(); + return; + } + match s.read(&mut buf) { + Ok(0) | Err(_) => return, + Ok(n) => seen.extend_from_slice(&buf[..n]), + } + } +} + +/// A far end's cleartext negotiation before TLS (a protocol's own StartTLS: Postgres's +/// `SSLRequest` answered `S`, LDAP's StartTLS extended operation, ...): `true` = go on to TLS. +pub type Preamble = fn(&mut TcpStream) -> bool; + +/// THE SUITE'S TLS FRONT at [`FAR_END`] (started once; later calls are no-ops): each connection's +/// cleartext `preamble` runs, then the TLS handshake (busbar's test kit, a `localhost` certificate +/// [`anchors`] trusts), then its bytes are carried both ways to the REAL backend at `upstream` +/// (the plugin's live service, in the clear on loopback). What it proves: the plugin's own +/// connection is secured by the HOST's TLS against the suite's anchors. +/// +/// # Panics +/// [`FAR_END`] cannot be bound. +pub fn tls_front(preamble: Preamble, upstream: &'static str) { + static STARTED: OnceLock<()> = OnceLock::new(); + STARTED.get_or_init(|| { + let listener = TcpListener::bind(FAR_END) + .unwrap_or_else(|e| panic!("the suite's TLS front cannot bind {FAR_END}: {e}")); + let tls = ServerTls::new(&[ca().leaf_der.clone()], &ca().key_der, None, &[]) + .unwrap_or_else(|e| panic!("the suite's TLS front has no TLS identity: {e}")); + std::thread::spawn(move || { + for tcp in listener.incoming() { + let Ok(tcp) = tcp else { return }; + let tls = tls.clone(); + std::thread::spawn(move || front(tcp, &tls, preamble, upstream)); + } + }); + }); +} + +/// One connection through the TLS front: preamble, handshake, then both directions carried until +/// either side ends. +fn front(mut tcp: TcpStream, tls: &ServerTls, preamble: Preamble, upstream: &str) { + if !preamble(&mut tcp) { + return; + } + let Ok(ctl) = tcp.try_clone() else { return }; + let Ok(mut s) = tls.accept_std(tcp) else { + return; + }; + if !s.handshake_ok() { + return; + } + let Ok(mut up) = TcpStream::connect(upstream) else { + return; + }; + let tick = Some(Duration::from_millis(2)); + if ctl.set_read_timeout(tick).is_err() || up.set_read_timeout(tick).is_err() { + return; + } + let idle = |e: &std::io::Error| { + matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) + }; + let mut buf = [0_u8; 16 * 1024]; + loop { + match s.read(&mut buf) { + Ok(0) => return, + Ok(n) => { + if up.write_all(&buf[..n]).is_err() { + return; + } + } + Err(e) if idle(&e) => {} + Err(_) => return, + } + match up.read(&mut buf) { + Ok(0) => { + s.close(); + return; + } + Ok(n) => { + if s.write_all(&buf[..n]).and_then(|()| s.flush()).is_err() { + return; + } + } + Err(e) if idle(&e) => {} + Err(_) => return, + } + } +} + +// ---- the host's own proof, run with the suite ---- + +const OWNER: InstanceId = InstanceId(7); + +fn need(transport: &str, class: u32) -> ReadNeed { + ReadNeed { + direction: DIRECTION_OUTBOUND, + egress_class: class, + transport: transport.to_owned(), + auth: String::new(), + target_from: String::new(), + trust_from: String::new(), + details: ReadBlob { + fmt: 0, + flags: 0, + bytes: Vec::new(), + }, + timeout_ms: 0, + } +} + +/// `f` until it answers other than PENDING, as a plugin's re-entries on its ticket would. +fn settle(mut f: impl FnMut() -> Result) -> Result { + let until = Instant::now() + Duration::from_secs(10); + loop { + match f() { + Err(ConnError::Pending) if Instant::now() < until => { + std::thread::sleep(Duration::from_millis(2)); + } + answered => return answered, + } + } +} + +/// Read `id` to its completion (or `want` body bytes): its fields pieces' count and its body. +fn drain(c: &dyn DeclaredConns, id: ConnId, want: usize) -> (usize, Vec) { + let (mut fields, mut body) = (0, Vec::new()); + let mut buf = [0_u8; 256]; + while body.len() < want { + let Ok(p) = settle(|| c.read(OWNER, id, NO_TICKET, &mut buf)) else { + break; + }; + match p.kind { + PieceKind::Fields => fields += 1, + PieceKind::Body => body.extend_from_slice(&buf[..p.len]), + PieceKind::Completion => break, + PieceKind::HookReply => {} + } + } + (fields, body) +} + +/// Five bytes back for five bytes in: the TLS echo the host's own proof dials. +fn echo(seen: &[u8]) -> Option> { + (seen.len() >= 5).then(|| seen[..5].to_vec()) +} + +/// A raw `tcp` stream to a TLS echo, upgraded through the host's TLS: what came back. +fn upgraded_echo(c: &dyn DeclaredConns, far: &str) -> Result, ConnError> { + c.declare( + OWNER, + NeedId(0), + &need("tcp", EGRESS_OPERATOR_INFRASTRUCTURE), + None, + None, + )?; + let id = settle(|| { + c.open( + OWNER, + NeedId(0), + &OpenDesc { + target: far, + timeout_ms: 5_000, + ..OpenDesc::default() + }, + ) + })?; + settle(|| c.upgrade_secure(OWNER, id, Some("localhost"), None, false, NO_TICKET))?; + let mut at = 0; + while at < 5 { + at += settle(|| c.write(OWNER, id, &b"hello"[at..], false, false))?; + } + let (_, body) = drain(c, id, 5); + let _ = c.close(OWNER, id); + Ok(body) +} + +/// A TLS echo on a port of its own; its address. +fn tls_echo() -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("a local listener"); + let at = listener.local_addr().expect("its address").to_string(); + serve(listener, echo); + at +} + +/// THE HOST'S TLS TRUSTS THE SUITE'S TEST ANCHORS: a stream to a far end whose certificate chains +/// to the test CA is upgraded and round-trips (the anchors went to the host, never to the plugin). +#[test] +fn the_hosts_tls_upgrade_verifies_against_the_suites_test_anchors() { + let c = host(Arc::new(|_| {}), Some(anchors())); + assert_eq!( + upgraded_echo(c.as_ref(), &tls_echo()), + Ok(b"hello".to_vec()) + ); +} + +/// RED: the same upgrade with the anchors withheld is refused: the platform roots alone do not +/// trust the test CA. +#[test] +fn red_with_the_anchors_withheld_the_hosts_tls_upgrade_is_refused() { + let c = host(Arc::new(|_| {}), None); + let answer = upgraded_echo(c.as_ref(), &tls_echo()); + assert!(answer.is_err(), "upgraded over an untrusted CA: {answer:?}"); +} diff --git a/store-postgres/Cargo.toml b/store-postgres/Cargo.toml index a8dfda8..7e7b799 100644 --- a/store-postgres/Cargo.toml +++ b/store-postgres/Cargo.toml @@ -14,7 +14,14 @@ license = "Apache-2.0" # a busbar build that links this crate `[patch]`es the busbar git source to its own tree, so ONE # copy of the contract links. busbar-contract = { workspace = true } -postgres = { workspace = true } +# The Postgres frontend protocol WITHOUT a socket (busbar THE DESIGN, the connections section: the +# store reaches its server only over the host's connector): the message codec, SCRAM-SHA-256 and md5 +# (`postgres-protocol`) and the binary value encodings (`postgres-types`), the same two crates the +# 1.5.5 `postgres` driver encoded and decoded with, so every parameter and column reads as before. +postgres-protocol = "0.6" +postgres-types = "0.2" +bytes = { workspace = true } +fallible-iterator = "0.2" serde_json = { workspace = true } [dev-dependencies] @@ -24,3 +31,13 @@ serde_json = { workspace = true } # Its plane-record checks serialize stand-in rows with serde derive, hence this one dev-dependency; # no busbar crate beyond `busbar-contract` is needed. serde = { workspace = true } +# The live tests open the store through the REAL loader on a dispatcher, its connections over the +# loader's test connection table (`tcp_conns`, `test-support`), and verify on an independent +# connection of the `postgres` driver's own. +busbar-plugin-loader = { workspace = true, features = ["test-support"] } +postgres = { workspace = true } +# The TLS test (`src/tests/tls.rs`): an in-test TLS proxy in front of the live server, its CA minted +# per run; the store's side is the loader's test table trusting that CA. Test-only. +rustls = { workspace = true } +rustls-pki-types = { workspace = true } +rcgen = { workspace = true } diff --git a/store-postgres/declares.json b/store-postgres/declares.json index 985cfd5..2b63b04 100644 --- a/store-postgres/declares.json +++ b/store-postgres/declares.json @@ -2,5 +2,8 @@ "contract_abi": { "min": 4, "max": 4 - } + }, + "needs": [ + "tcp" + ] } diff --git a/store-postgres/src/lib.rs b/store-postgres/src/lib.rs index 7d6775f..996c2a3 100644 --- a/store-postgres/src/lib.rs +++ b/store-postgres/src/lib.rs @@ -2,8 +2,11 @@ // Copyright (C) 2026 Busbar Inc and contributors //! The **Postgres** backend for busbar's durable governance store — the shared, multi-node `db` -//! plugin. Implements `busbar_contract::records::RecordStore` over a mutex-guarded synchronous `postgres` client, -//! depending only on the `busbar-contract` crate (plus the `postgres` driver), never on the engine. +//! plugin. Serves the store kind's table (`StoreSlots`) over the HOST'S CONNECTOR: every op runs on +//! the instance's kept connection as straight-line async code (`pgwire`, the Postgres frontend +//! protocol over the store SDK's `wire`), so the store opens no socket of its own and no op blocks +//! a thread. Depends only on the `busbar-contract` crate (plus the sans-IO +//! `postgres-protocol`/`postgres-types` codec), never on the engine. //! //! Served through the store kind's ONE door (`store_door!` in `v3`, the store v3 table of busbar //! 1.6.0): linked into a busbar build as `door`, or dropped in as the sibling plugin cdylib. @@ -24,15 +27,24 @@ //! credentials in another would let a `REPEATABLE READ` hydration snapshot land between them and //! observe a "deleted" key whose credential is still live). //! -//! Like the prior schema, this is a **single mutex-guarded connection** used off the request hot -//! path (key CRUD + the write-behind usage flush) — governance is off the reactor entirely. +//! CONNECTIONS. The 1.5.x store held one mutex-guarded connection for its life; this one keeps one +//! too (ARCHITECT ruling 2026-10-03, STORE-KEEP: the store SDK's kept set, bounded at +//! [`KEPT_CONNECTIONS`] = 1, so ops take turns on it as they took the mutex). `open`'s connect step +//! dials through the host (its one declared `tcp` need, `operator-infrastructure`), authenticates +//! (SCRAM-SHA-256, md5 or cleartext, as the server asks) and ensures the schema, so an unreachable +//! or refusing server still fails the load at boot, in the driver's words; the connection is then +//! kept, and every op runs its 1.5.5 SQL body on it. A connection is kept only while its session is +//! idle (no transaction open or failed); one that failed or was left mid-transaction is closed, and +//! the next op connects afresh (where 1.5.x needed a restart). //! //! ## Known limitations (documented honestly, not papered over) //! -//! - **No TLS in this build (`NoTls`).** Run the connection over a trusted network segment, a local -//! socket, or a TLS-terminating proxy (pgbouncer/stunnel). -//! - **No automatic reconnect.** A persistently dropped connection surfaces as store errors; a -//! permanently broken connection requires a process restart. +//! - **TLS through the host.** `sslmode=require` / `verify-ca` / `verify-full` asks the server for +//! TLS and secures the connection through the host's connector (its trust anchors); `disable`, +//! `allow` and `prefer` connect in plaintext, as the 1.5.x build (`NoTls`) did. +//! - **`sslmode=require` verifies.** The host's TLS always verifies the server's certificate and +//! name (libpq's `require` would not); a server that answers the `SSLRequest` with `N` fails the +//! load (`error performing TLS handshake: server does not support TLS`). //! - **No partitioning, no LISTEN/NOTIFY-accelerated hydration, no column-level secret grants in //! this pass.** The design session that produced this schema recommended all three as scale/perf //! layers on top of this contract — deliberately deferred here in favor of getting the @@ -43,24 +55,25 @@ #![forbid(unsafe_code)] +mod pgwire; + use busbar_contract::records::{ AuditRecord, CredentialMeta, CredentialSecret, MeteringDelta, MeteringRow, ModelTokens, - PlaneDisposition, PlaneRecord, PlaneRecordRef, PlaneSelector, RecordStore, RecordStoreError, + PlaneDisposition, PlaneRecord, PlaneRecordRef, PlaneSelector, RecordStoreError, RecordStoreResult, ScopeRef, SecretForm, UsageDelta, UsageLedger, VirtualKey, UNIT_CACHE_READ, UNIT_CACHE_WRITE, UNIT_INPUT, UNIT_OUTPUT, }; -use postgres::types::ToSql; -use postgres::{Client, NoTls, Row, Transaction}; +use pgwire::{Client, Row, Transaction}; +use postgres_types::ToSql; use std::collections::BTreeMap; use std::sync::atomic::AtomicU64; -use std::sync::Mutex; // postgres driver error -> the api's backend-agnostic `RecordStoreError` (the contract crate stays // storage-free, so the `From` impl that powers `?` cannot live there). trait IntoStoreResult { fn store(self) -> RecordStoreResult; } -impl IntoStoreResult for Result { +impl IntoStoreResult for Result { fn store(self) -> RecordStoreResult { self.map_err(|e| RecordStoreError(render_pg_error(&e))) } @@ -76,7 +89,7 @@ impl IntoStoreResult for Result { /// The server's `detail` field is deliberately NOT included: on a unique violation Postgres puts /// the offending ROW VALUES in it, and this string reaches logs. The SQLSTATE, the primary message /// and the constraint name identify the failure without echoing data. -fn render_pg_error(e: &postgres::Error) -> String { +fn render_pg_error(e: &pgwire::Error) -> String { match e.as_db_error() { Some(db) => { let mut out = format!("{}: {}", db.code().code(), db.message()); @@ -93,8 +106,8 @@ fn render_pg_error(e: &postgres::Error) -> String { /// as an unversioned (version 0) database. Every other error class (connection, timeout, permission) /// is transient/fatal and must never be read as "fresh DB": treating a connection or permission /// failure as version 0 would drop and recreate a populated database. -fn is_undefined_table(e: &postgres::Error) -> bool { - e.code() == Some(&postgres::error::SqlState::UNDEFINED_TABLE) +fn is_undefined_table(e: &pgwire::Error) -> bool { + e.code().map(pgwire::SqlState::code) == Some(pgwire::SqlState::UNDEFINED_TABLE) } /// Extract the PASSWORD from a Postgres DSN. Supports both the URL form @@ -537,12 +550,43 @@ ALTER TABLE keys ADD COLUMN IF NOT EXISTS allowed_scopes_by_kind TEXT; ALTER TABLE usage_metering ADD COLUMN IF NOT EXISTS priced_from_ms BIGINT NOT NULL DEFAULT 0; "; -/// Postgres `Store` backend (durable, shared across a cluster). A single mutex-guarded connection — -/// governance is off the request hot path, so serializing access is fine. +/// Postgres `Store` backend (durable, shared across a cluster). The instance holds the parsed +/// connection settings and its kept connection (the store SDK's `wire::Pool`, one connection, as +/// 1.5.x held one), reached over the host's connector, so no socket of the store's own exists and +/// no op blocks a thread (busbar THE DESIGN, the connections section and the plugin ABI). The +/// schema is ensured once, by `open`'s connect step. pub struct PostgresStore { - client: Mutex, + shared: std::sync::Arc, +} + +/// How many connections an instance keeps: the 1.5.5 store held exactly one. +pub const KEPT_CONNECTIONS: usize = 1; + +/// What every op of one instance shares. +pub(crate) struct Shared { + /// The connection settings. + pub(crate) config: pgwire::Config, + /// The DSN's password, scrubbed from every connect-error text. + pub(crate) secret: Option, /// When this instance last swept `store_ops` past its retention (`v3`). - ops_swept_at: AtomicU64, + pub(crate) ops_swept_at: AtomicU64, + /// The instance's KEPT connections (ARCHITECT ruling 2026-10-03, STORE-KEEP): bounded at ONE, + /// the 1.5.5 store's one mutex-guarded connection (it had no pool setting). An op waits for it + /// while another holds it, as 1.5.5's ops waited on the mutex. + pub(crate) pool: std::sync::Arc, +} + +/// ONE OP'S CONNECTION: the 1.5.5 bodies run on it, as they ran on the mutex-guarded client. +pub(crate) struct Session { + client: Client, + shared: std::sync::Arc, +} + +impl std::ops::Deref for Session { + type Target = Shared; + fn deref(&self) -> &Shared { + &self.shared + } } /// Clamp a `u64` into `i64` for a BIGINT column (a value above `i64::MAX` pins to `i64::MAX`, never @@ -579,59 +623,91 @@ fn now_secs() -> u64 { } impl PostgresStore { - /// Connect to Postgres with the given libpq connection string / URL and ensure the schema. TLS - /// is not wired in this build (`NoTls`); front the database with a TLS-terminating proxy or a - /// local socket. - pub fn connect(conn_str: &str) -> RecordStoreResult { + /// The store on the connection string `conn_str`: parsed here, connected by the connect step. A string + /// the driver refuses is refused in its words, scrubbed of the DSN password. + /// + /// # Errors + /// The connection string does not parse. + pub fn new(conn_str: &str) -> RecordStoreResult { let secret = dsn_password(conn_str); - // `render_pg_error`, not `e.to_string()`. A server-side refusal (bad database name, failed - // authentication, an unavailable extension) is a `db_error` whose Display is the literal - // two words "db error" — so the ONE error an operator hits before anything else works - // rendered as the least actionable string in the crate, while every query error had already - // been fixed to carry its SQLSTATE and message. Still scrubbed of the DSN password. - let client = Client::connect(conn_str, NoTls) - .map_err(|e| RecordStoreError(scrub(render_pg_error(&e), secret.as_deref())))?; - let store = Self { - client: Mutex::new(client), - ops_swept_at: AtomicU64::new(0), - }; - store.migrate()?; - Ok(store) + let config = pgwire::Config::parse(conn_str) + .map_err(|e| RecordStoreError(scrub(e.to_string(), secret.as_deref())))?; + Ok(Self { + shared: std::sync::Arc::new(Shared { + config, + secret, + ops_swept_at: AtomicU64::new(0), + pool: busbar_contract::abi::sdk::store::wire::Pool::new(KEPT_CONNECTIONS), + }), + }) + } + + pub(crate) fn shared(&self) -> std::sync::Arc { + self.shared.clone() + } +} + +impl Session { + /// Open one op's connection over `wire`: connect through the host's connector, secure it when + /// the settings ask, authenticate. A failure is the driver's words (`error connecting to + /// server: ...`, or the server's SQLSTATE and message), scrubbed of the DSN password. + pub(crate) async fn open( + wire: busbar_contract::abi::sdk::store::wire::Wire, + shared: std::sync::Arc, + ) -> Result { + match Client::connect(wire, &shared.config).await { + Ok(client) => Ok(Self { client, shared }), + Err(e) => Err(scrub(render_pg_error(&e), shared.secret.as_deref())), + } } - fn lock(&self) -> std::sync::MutexGuard<'_, Client> { - self.client.lock().unwrap_or_else(|p| p.into_inner()) + /// The op is done: its connection is kept for the next op if the session is idle, else + /// discarded ([`Client::release`]). + pub(crate) async fn close(mut self) { + self.client.release(); + } + + fn lock(&mut self) -> &mut Client { + &mut self.client + } + + pub(crate) fn lock_client(&mut self) -> &mut Client { + &mut self.client } /// Open a transaction that gives every statement inside it ONE consistent snapshot, taken at the /// transaction's first statement — REPEATABLE READ, not the default READ COMMITTED (which gives /// each statement its own fresh snapshot, a torn-read hazard for any multi-statement read like /// `get_usage` or the hydration delta queries). - pub(crate) fn snapshot_consistent_tx<'a>( + pub(crate) async fn snapshot_consistent_tx<'a>( client: &'a mut Client, - ) -> RecordStoreResult> { + ) -> RecordStoreResult> { client .build_transaction() - .isolation_level(postgres::IsolationLevel::RepeatableRead) + .isolation_level(pgwire::IsolationLevel::RepeatableRead) .start() + .await .store() } const MIGRATE_LOCK_KEY: i64 = 0x6275_7362_6172_5f70; // ASCII "busbar_p" - fn migrate(&self) -> RecordStoreResult<()> { - let mut client = self.lock(); + pub(crate) async fn migrate(&mut self) -> RecordStoreResult<()> { + let client = self.lock(); client .batch_execute(&format!( "SELECT pg_advisory_lock({})", Self::MIGRATE_LOCK_KEY )) + .await .store()?; - let result = Self::migrate_locked(&mut client); - let unlocked = client.batch_execute(&format!( - "SELECT pg_advisory_unlock({})", - Self::MIGRATE_LOCK_KEY - )); + let result = Self::migrate_locked(client).await; + let unlocked = client + .batch_execute(&format!( + "SELECT pg_advisory_unlock({})", + Self::MIGRATE_LOCK_KEY + )) + .await; // A migration failure is the more important thing to report; don't mask it with an unlock // failure. But if the migration itself SUCCEEDED and the unlock did not, that must not be // swallowed: an un-released session-held advisory lock can hang a sibling node's connect() @@ -646,17 +722,20 @@ impl PostgresStore { } } - fn migrate_locked(client: &mut Client) -> RecordStoreResult<()> { + async fn migrate_locked(client: &mut Client) -> RecordStoreResult<()> { client .batch_execute("CREATE TABLE IF NOT EXISTS busbar_schema (version BIGINT PRIMARY KEY)") + .await .store()?; - let version: i64 = - match client.query_opt("SELECT COALESCE(MAX(version), 0) FROM busbar_schema", &[]) { - Ok(Some(r)) => r.get(0), - Ok(None) => 0, - Err(e) if is_undefined_table(&e) => 0, - Err(e) => return Err(RecordStoreError(e.to_string())), - }; + let version: i64 = match client + .query_opt("SELECT COALESCE(MAX(version), 0) FROM busbar_schema", &[]) + .await + { + Ok(Some(r)) => r.get(0), + Ok(None) => 0, + Err(e) if is_undefined_table(&e) => 0, + Err(e) => return Err(RecordStoreError(render_pg_error(&e))), + }; // ALREADY CURRENT: run no DDL at all. Every node runs `migrate()` on every connect, and // `SCHEMA`'s `CREATE INDEX IF NOT EXISTS` takes a SHARE lock on its table before it // discovers the index exists — first on `keys`, then on `credentials`. A `delete_key` on @@ -667,7 +746,7 @@ impl PostgresStore { if version >= SCHEMA_VERSION { return Ok(()); } - let mut tx = client.transaction().store()?; + let mut tx = client.transaction().await.store()?; if version < 5 { let legacy: bool = tx .query_one( @@ -676,6 +755,7 @@ impl PostgresStore { OR to_regclass('aws_credentials') IS NOT NULL", &[], ) + .await .store()? .get(0); if legacy { @@ -692,10 +772,11 @@ impl PostgresStore { DROP TABLE IF EXISTS denylist; DROP TABLE IF EXISTS store_revision;", ) + .await .store()?; } } - tx.batch_execute(SCHEMA).store()?; + tx.batch_execute(SCHEMA).await.store()?; // v6 one-time backfill — see SCHEMA_VERSION's doc comment for why this is safe as a // gated-once value backfill and would NOT be safe as a repeated/per-boot heuristic. Only // fires when crossing INTO v6 (a store already at v6+ never re-runs this). @@ -705,6 +786,7 @@ impl PostgresStore { WHERE billable_requests = 0 AND requests > 0", &[], ) + .await .store()?; } // v10 — see SCHEMA_VERSION. `SCHEMA`'s `CREATE TABLE IF NOT EXISTS` never alters a table @@ -719,7 +801,7 @@ impl PostgresStore { // also touches). The whole migration is ONE transaction with the version stamp, so a crash // part-way leaves `version < 10` and the next connect re-runs it from the start. if version < 10 { - tx.batch_execute(MIGRATE_V10_COLUMNS).store()?; + tx.batch_execute(MIGRATE_V10_COLUMNS).await.store()?; // The one v10 step that is not a pure addition: `priced_from_ms` joins the metering // primary key. Every existing row carries `priced_from_ms = 0` (the column default), // so the old key `(key_id, bucket, model, provider)` was already unique and the widened @@ -729,14 +811,16 @@ impl PostgresStore { ALTER TABLE usage_metering ADD CONSTRAINT usage_metering_pkey PRIMARY KEY (key_id, bucket, model, provider, priced_from_ms);", ) + .await .store()?; } tx.execute( "INSERT INTO busbar_schema (version) VALUES ($1) ON CONFLICT (version) DO NOTHING", &[&SCHEMA_VERSION], ) + .await .store()?; - tx.commit().store()?; + tx.commit().await.store()?; Ok(()) } @@ -746,11 +830,12 @@ impl PostgresStore { /// and credentials do): calling it first fixes a single lock-acquisition order /// (`store_revision` row, then whatever else the transaction touches) across every mutating /// method, which is what makes cross-method deadlock structurally impossible. - fn next_revision(tx: &mut Transaction<'_>) -> RecordStoreResult { + async fn next_revision(tx: &mut Transaction<'_>) -> RecordStoreResult { tx.query_one( "UPDATE store_revision SET revision = revision + 1 WHERE only_row RETURNING revision", &[], ) + .await .store()? .try_get(0) .store() @@ -895,16 +980,16 @@ fn row_to_cred_meta(r: &Row) -> CredentialMeta { } } -impl RecordStore for PostgresStore { - fn put_key(&self, key: &VirtualKey) -> RecordStoreResult<()> { +impl Session { + pub(crate) async fn put_key(&mut self, key: &VirtualKey) -> RecordStoreResult<()> { let (pools, by_kind) = scopes_to_storage(&key.allowed_scopes); let labels = labels_to_storage(&key.labels); let created = clamp(key.created_at); let expires = key.expires_at.map(clamp); let deleted = key.deleted_at.map(clamp); - let mut client = self.lock(); - let mut tx = client.transaction().store()?; - let rev = Self::next_revision(&mut tx)?; + let client = self.lock(); + let mut tx = client.transaction().await.store()?; + let rev = Self::next_revision(&mut tx).await?; // The `WHERE` on the conflict branch is the TOMBSTONE PRECONDITION (see `Store::put_key`): // a live-shaped write (`EXCLUDED.deleted_at IS NULL`) must not overwrite a tombstoned row, // which would reissue an id the contract says is never reissued and revive every token @@ -932,7 +1017,7 @@ impl RecordStore for PostgresStore { &key.group, &labels, &expires, &deleted, &rev, &key.idp_subject, &key.binding_mode, &key.minted_by, &by_kind, ], - ) + ).await .store()?; if changed == 0 { // The conflict branch matched a row but its WHERE rejected the write: the stored row is @@ -943,32 +1028,35 @@ impl RecordStore for PostgresStore { key.id ))); } - tx.commit().store()?; + tx.commit().await.store()?; Ok(()) } - fn get_key(&self, id: &str) -> RecordStoreResult> { + pub(crate) async fn get_key(&mut self, id: &str) -> RecordStoreResult> { let sql = format!("SELECT {KEY_COLUMNS} FROM keys WHERE id=$1"); - let row = self.lock().query_opt(&sql, &[&id]).store()?; + let row = self.lock().query_opt(&sql, &[&id]).await.store()?; Ok(row.map(|r| row_to_key(&r))) } - fn list_keys(&self) -> RecordStoreResult> { + pub(crate) async fn list_keys(&mut self) -> RecordStoreResult> { // Deliberately UNFILTERED (tombstoned rows included) -- see the trait doc: this serves both // the admin-listing caller (which filters deleted_at.is_none() itself) and list_keys_since's // default fallback, which needs tombstones visible to drive credential eviction downstream. let sql = format!("SELECT {KEY_COLUMNS} FROM keys ORDER BY created_at"); - let rows = self.lock().query(&sql, &[]).store()?; + let rows = self.lock().query(&sql, &[]).await.store()?; Ok(rows.iter().map(row_to_key).collect()) } - fn list_keys_since(&self, since: u64) -> RecordStoreResult> { + pub(crate) async fn list_keys_since( + &mut self, + since: u64, + ) -> RecordStoreResult> { let sql = format!("SELECT {KEY_COLUMNS} FROM keys WHERE revision > $1 ORDER BY revision"); - let rows = self.lock().query(&sql, &[&clamp(since)]).store()?; + let rows = self.lock().query(&sql, &[&clamp(since)]).await.store()?; Ok(rows.iter().map(row_to_key).collect()) } - fn delete_key(&self, id: &str) -> RecordStoreResult<()> { + pub(crate) async fn delete_key(&mut self, id: &str) -> RecordStoreResult<()> { // TOMBSTONE, not a hard delete: the `keys` row survives (billing/audit attribution keeps // resolving it forever) while every credential row for it is destroyed. Both happen in ONE // transaction stamped with the SAME revision, which is the load-bearing property for @@ -977,13 +1065,14 @@ impl RecordStore for PostgresStore { // reacts to "this key's revision-delta shows deleted_at newly set" by evicting all its // cached credentials is provably correct -- there is no window where the credential rows' // own (now-nonexistent) deltas would have been needed to convey the deletion. - let mut client = self.lock(); - let mut tx = client.transaction().store()?; + let client = self.lock(); + let mut tx = client.transaction().await.store()?; let already_deleted: Option = tx .query_opt( "SELECT deleted_at IS NOT NULL FROM keys WHERE id=$1", &[&id], ) + .await .store()? .map(|r| r.get(0)); match already_deleted { @@ -997,12 +1086,12 @@ impl RecordStore for PostgresStore { } Some(true) => { // Already tombstoned: no-op, not an error. - tx.commit().store()?; + tx.commit().await.store()?; return Ok(()); } Some(false) => {} } - let rev = Self::next_revision(&mut tx)?; + let rev = Self::next_revision(&mut tx).await?; // `deleted_at` is a WALL-CLOCK stamp and `revision` is the store-global counter. They are // both BIGINT, so binding one value to both columns compiles, round-trips and satisfies // every "is this key tombstoned" check -- while telling every operator, retention job and @@ -1011,6 +1100,7 @@ impl RecordStore for PostgresStore { // `delete_key_stamps_deleted_at_with_a_wall_clock_time_not_the_revision`. let now = clamp(now_secs()); tx.execute("DELETE FROM credentials WHERE key_id=$1", &[&id]) + .await .store()?; // `AND deleted_at IS NULL` re-states the guard the SELECT above already checked, IN the // UPDATE's own WHERE clause: under Postgres' READ COMMITTED semantics, an UPDATE takes a row @@ -1024,28 +1114,30 @@ impl RecordStore for PostgresStore { WHERE id=$1 AND deleted_at IS NULL", &[&id, &now, &rev], ) + .await .store()?; // A concurrent delete_key committed between our SELECT and this UPDATE: idempotent no-op, // same as the `Some(true)` branch above -- not an error. if changed == 0 { - tx.commit().store()?; + tx.commit().await.store()?; return Ok(()); } - tx.commit().store()?; + tx.commit().await.store()?; Ok(()) } - fn scrub_key(&self, id: &str) -> RecordStoreResult<()> { + pub(crate) async fn scrub_key(&mut self, id: &str) -> RecordStoreResult<()> { // PII-erasure only: null name/labels on an ALREADY-tombstoned key. Errors if unknown or // still live -- scrubbing a live key would be silent, un-auditable data loss on an active // principal (the trait doc's own guard: go through delete_key first). - let mut client = self.lock(); - let mut tx = client.transaction().store()?; + let client = self.lock(); + let mut tx = client.transaction().await.store()?; let deleted: Option = tx .query_opt( "SELECT deleted_at IS NOT NULL FROM keys WHERE id=$1", &[&id], ) + .await .store()? .map(|r| r.get(0)); match deleted { @@ -1057,7 +1149,7 @@ impl RecordStore for PostgresStore { } Some(true) => {} } - let rev = Self::next_revision(&mut tx)?; + let rev = Self::next_revision(&mut tx).await?; // `AND deleted_at IS NOT NULL` re-states the "must already be tombstoned" guard IN the // UPDATE's own WHERE clause, closing the same TOCTOU class as delete_key above: the SELECT // this function just ran is not atomic with this write, so without the re-check here a @@ -1071,6 +1163,7 @@ impl RecordStore for PostgresStore { WHERE id=$1 AND deleted_at IS NOT NULL", &[&id, &rev], ) + .await .store()?; if changed == 0 { return Err(RecordStoreError(format!( @@ -1078,20 +1171,25 @@ impl RecordStore for PostgresStore { call -- refusing to scrub a key that is live by the time the write landed" ))); } - tx.commit().store()?; + tx.commit().await.store()?; Ok(()) } - fn get_usage(&self, bucket_id: &str, window_start: u64) -> RecordStoreResult { + pub(crate) async fn get_usage( + &mut self, + bucket_id: &str, + window_start: u64, + ) -> RecordStoreResult { let ws = clamp(window_start); - let mut client = self.lock(); - let mut tx = Self::snapshot_consistent_tx(&mut client)?; + let client = self.lock(); + let mut tx = Self::snapshot_consistent_tx(client).await?; let (requests, billable_requests): (u64, u64) = tx .query_opt( "SELECT requests, billable_requests FROM usage_windows WHERE bucket_id=$1 AND window_start=$2", &[&bucket_id, &ws], ) + .await .store()? .map(|r| (read_u64(r.get::<_, i64>(0)), read_u64(r.get::<_, i64>(1)))) .unwrap_or((0, 0)); @@ -1101,6 +1199,7 @@ impl RecordStore for PostgresStore { FROM usage_ledger WHERE bucket_id=$1 AND window_start=$2 ORDER BY model", &[&bucket_id, &ws], ) + .await .store()?; let unit_rows = tx .query( @@ -1108,8 +1207,9 @@ impl RecordStore for PostgresStore { WHERE bucket_id=$1 AND window_start=$2 ORDER BY model, unit", &[&bucket_id, &ws], ) + .await .store()?; - tx.commit().store()?; + tx.commit().await.store()?; // One ModelTokens per model, in model order. The four reserved classes come off their // columns and every open class off `usage_ledger_units`, all into the one name-keyed map. // Zero counts are left out, so the map stays sparse the way busbar's own ledger keeps it. @@ -1140,8 +1240,8 @@ impl RecordStore for PostgresStore { }) } - fn put_usage( - &self, + pub(crate) async fn put_usage( + &mut self, bucket_id: &str, window_start: u64, ledger: &UsageLedger, @@ -1149,17 +1249,19 @@ impl RecordStore for PostgresStore { let ws = clamp(window_start); let rq = clamp(ledger.requests); let brq = clamp(ledger.billable_requests); - let mut client = self.lock(); - let mut tx = client.transaction().store()?; + let client = self.lock(); + let mut tx = client.transaction().await.store()?; tx.execute( "DELETE FROM usage_ledger WHERE bucket_id=$1 AND window_start=$2", &[&bucket_id, &ws], ) + .await .store()?; tx.execute( "DELETE FROM usage_ledger_units WHERE bucket_id=$1 AND window_start=$2", &[&bucket_id, &ws], ) + .await .store()?; tx.execute( "INSERT INTO usage_windows (bucket_id, window_start, requests, billable_requests) @@ -1169,6 +1271,7 @@ impl RecordStore for PostgresStore { billable_requests = EXCLUDED.billable_requests", &[&bucket_id, &ws, &rq, &brq], ) + .await .store()?; if !ledger.models.is_empty() { let rows: Vec<[i64; 4]> = ledger @@ -1202,7 +1305,7 @@ impl RecordStore for PostgresStore { params.push(v); } } - tx.execute(&sql, ¶ms).store()?; + tx.execute(&sql, ¶ms).await.store()?; // The OPEN unit classes: an absolute set too (the window's rows were cleared above). let opens: Vec<(&String, &String, i64)> = ledger @@ -1238,38 +1341,22 @@ impl RecordStore for PostgresStore { " ON CONFLICT (bucket_id, window_start, model, unit) DO UPDATE SET \ count = usage_ledger_units.count + EXCLUDED.count", ); - tx.execute(&sql, ¶ms).store()?; + tx.execute(&sql, ¶ms).await.store()?; } } - tx.commit().store()?; + tx.commit().await.store()?; Ok(()) } - fn add_usage( - &self, - bucket_id: &str, - window_start: u64, - delta: &UsageDelta, - ) -> RecordStoreResult<()> { - let mut client = self.lock(); - let mut tx = client.transaction().store()?; - Self::add_usage_in(&mut tx, bucket_id, window_start, delta)?; - tx.commit().store() - } - - fn add_metering(&self, d: &MeteringDelta) -> RecordStoreResult<()> { - let mut client = self.lock(); - let mut tx = client.transaction().store()?; - Self::add_metering_in(&mut tx, d)?; - tx.commit().store() - } - - fn list_metering(&self, bucket: u64) -> RecordStoreResult> { + pub(crate) async fn list_metering( + &mut self, + bucket: u64, + ) -> RecordStoreResult> { let b = clamp(bucket); - let mut client = self.lock(); + let client = self.lock(); // ONE snapshot for both reads, so a row's open classes are never read from a different // moment than its token columns. - let mut tx = Self::snapshot_consistent_tx(&mut client)?; + let mut tx = Self::snapshot_consistent_tx(client).await?; let rows = tx .query( "SELECT key_id, model, provider, @@ -1279,6 +1366,7 @@ impl RecordStore for PostgresStore { ORDER BY key_id, model, provider, priced_from_ms", &[&b], ) + .await .store()?; let unit_rows = tx .query( @@ -1286,8 +1374,9 @@ impl RecordStore for PostgresStore { FROM usage_metering_units WHERE bucket=$1", &[&b], ) + .await .store()?; - tx.commit().store()?; + tx.commit().await.store()?; type RowKey = (String, String, String, i64); let mut units: std::collections::HashMap> = std::collections::HashMap::new(); @@ -1324,25 +1413,28 @@ impl RecordStore for PostgresStore { .collect()) } - fn purge_windows_before(&self, before: u64) -> RecordStoreResult { + pub(crate) async fn purge_windows_before(&mut self, before: u64) -> RecordStoreResult { let b = clamp(before); - let mut client = self.lock(); - let mut tx = client.transaction().store()?; + let client = self.lock(); + let mut tx = client.transaction().await.store()?; let n1 = tx .execute("DELETE FROM usage_windows WHERE window_start < $1", &[&b]) + .await .store()?; tx.execute("DELETE FROM usage_ledger WHERE window_start < $1", &[&b]) + .await .store()?; tx.execute( "DELETE FROM usage_ledger_units WHERE window_start < $1", &[&b], ) + .await .store()?; - tx.commit().store()?; + tx.commit().await.store()?; Ok(n1) } - fn purge_metering_before(&self, bucket: &str) -> RecordStoreResult { + pub(crate) async fn purge_metering_before(&mut self, bucket: &str) -> RecordStoreResult { // The trait's purge_metering_before takes `bucket: &str` while list_metering/add_metering // use `bucket: u64` -- an inconsistency in the core trait itself, not introduced here. // usage_metering.bucket is genuinely BIGINT, so this parses the string form. @@ -1351,27 +1443,32 @@ impl RecordStore for PostgresStore { "purge_metering_before: invalid bucket {bucket:?}, expected an integer" )) })?; - let mut client = self.lock(); - let mut tx = client.transaction().store()?; + let client = self.lock(); + let mut tx = client.transaction().await.store()?; let n = tx .execute("DELETE FROM usage_metering WHERE bucket=$1", &[&b]) + .await .store()?; tx.execute("DELETE FROM usage_metering_units WHERE bucket=$1", &[&b]) + .await .store()?; - tx.commit().store()?; + tx.commit().await.store()?; Ok(n) } - fn put_credential(&self, secret: &CredentialSecret) -> RecordStoreResult<()> { - let mut client = self.lock(); - let mut tx = client.transaction().store()?; - Self::put_credential_tx(&mut tx, secret)?; - tx.commit().store()?; + pub(crate) async fn put_credential( + &mut self, + secret: &CredentialSecret, + ) -> RecordStoreResult<()> { + let client = self.lock(); + let mut tx = client.transaction().await.store()?; + Self::put_credential_tx(&mut tx, secret).await?; + tx.commit().await.store()?; Ok(()) } - fn put_key_with_credential( - &self, + pub(crate) async fn put_key_with_credential( + &mut self, key: &VirtualKey, secret: &CredentialSecret, ) -> RecordStoreResult<()> { @@ -1380,9 +1477,9 @@ impl RecordStore for PostgresStore { let labels = labels_to_storage(&key.labels); let created = clamp(key.created_at); let expires = key.expires_at.map(clamp); - let mut client = self.lock(); - let mut tx = client.transaction().store()?; - let rev = Self::next_revision(&mut tx)?; + let client = self.lock(); + let mut tx = client.transaction().await.store()?; + let rev = Self::next_revision(&mut tx).await?; // Same TOMBSTONE PRECONDITION as `put_key`, and this is the path where it MATTERS MOST: the // conflict branch used to set `deleted_at=NULL` outright, so re-minting over a tombstoned id // deliberately cleared the tombstone. That was written to avoid leaving a row both enabled @@ -1410,7 +1507,7 @@ impl RecordStore for PostgresStore { &key.group, &labels, &expires, &rev, &key.idp_subject, &key.binding_mode, &key.minted_by, &by_kind, ], - ) + ).await .store()?; if changed == 0 { return Err(RecordStoreError(format!( @@ -1419,26 +1516,33 @@ impl RecordStore for PostgresStore { key.id ))); } - Self::put_credential_tx(&mut tx, secret)?; - tx.commit().store()?; + Self::put_credential_tx(&mut tx, secret).await?; + tx.commit().await.store()?; Ok(()) } - fn list_credentials(&self, key_id: &str) -> RecordStoreResult> { + pub(crate) async fn list_credentials( + &mut self, + key_id: &str, + ) -> RecordStoreResult> { let sql = format!("SELECT {CRED_META_COLUMNS} FROM credentials WHERE key_id=$1"); - let rows = self.lock().query(&sql, &[&key_id]).store()?; + let rows = self.lock().query(&sql, &[&key_id]).await.store()?; Ok(rows.iter().map(row_to_cred_meta).collect()) } - fn lookup_credential_secret( - &self, + pub(crate) async fn lookup_credential_secret( + &mut self, kind: &str, public_id: &str, ) -> RecordStoreResult> { let sql = format!( "SELECT {CRED_META_COLUMNS},secret FROM credentials WHERE kind=$1 AND public_id=$2" ); - let row = self.lock().query_opt(&sql, &[&kind, &public_id]).store()?; + let row = self + .lock() + .query_opt(&sql, &[&kind, &public_id]) + .await + .store()?; Ok(row.map(|r| CredentialSecret { meta: row_to_cred_meta(&r), secret: r @@ -1447,14 +1551,19 @@ impl RecordStore for PostgresStore { })) } - fn revoke_credential(&self, id: &str, reason: &str) -> RecordStoreResult<()> { - let mut client = self.lock(); - let mut tx = client.transaction().store()?; + pub(crate) async fn revoke_credential( + &mut self, + id: &str, + reason: &str, + ) -> RecordStoreResult<()> { + let client = self.lock(); + let mut tx = client.transaction().await.store()?; let exists: bool = tx .query_one( "SELECT EXISTS(SELECT 1 FROM credentials WHERE id=$1)", &[&id], ) + .await .store()? .get(0); if !exists { @@ -1469,7 +1578,7 @@ impl RecordStore for PostgresStore { "revoke_credential: unknown credential id {id}; nothing was revoked" ))); } - let rev = Self::next_revision(&mut tx)?; + let rev = Self::next_revision(&mut tx).await?; let now = now_secs(); // `AND revoked_at IS NULL`: mirrors delete_key's `already_deleted` idempotency (see above) -- // a repeat revoke_credential call on an already-revoked row must be a true no-op, not bump @@ -1484,6 +1593,7 @@ impl RecordStore for PostgresStore { WHERE id=$1 AND revoked_at IS NULL", &[&id, &clamp(now), &reason, &rev], ) + .await .store()?; if changed == 0 { // Zero rows means one of two very different things, and the row count alone cannot tell @@ -1499,6 +1609,7 @@ impl RecordStore for PostgresStore { "SELECT EXISTS(SELECT 1 FROM credentials WHERE id=$1)", &[&id], ) + .await .store()? .get(0); if !still_exists { @@ -1512,15 +1623,18 @@ impl RecordStore for PostgresStore { // trait promises. The revision bump above goes unused, which its own doc allows (a // monotonic counter with gaps). } - tx.commit().store()?; + tx.commit().await.store()?; Ok(()) } - fn list_credentials_since(&self, since: u64) -> RecordStoreResult> { + pub(crate) async fn list_credentials_since( + &mut self, + since: u64, + ) -> RecordStoreResult> { let sql = format!( "SELECT {CRED_META_COLUMNS},secret FROM credentials WHERE revision > $1 ORDER BY revision" ); - let rows = self.lock().query(&sql, &[&clamp(since)]).store()?; + let rows = self.lock().query(&sql, &[&clamp(since)]).await.store()?; Ok(rows .iter() .map(|r| CredentialSecret { @@ -1532,45 +1646,28 @@ impl RecordStore for PostgresStore { .collect()) } - fn append_audit(&self, entry: &AuditRecord) -> RecordStoreResult<()> { - // The loop covers the one case the share lock cannot: the conflicting row disappearing - // BEFORE the read takes its lock. Then the seq is genuinely free again and the next - // iteration inserts. Bounded, and exhausting the bound is an error rather than a success, - // so no path here returns Ok without the record being stored. - const MAX_ATTEMPTS: u32 = 3; - for _ in 0..MAX_ATTEMPTS { - let mut client = self.lock(); - let mut tx = client.transaction().store()?; - if Self::append_audit_in(&mut tx, entry)? { - return tx.commit().store(); - } - } - Err(RecordStoreError(format!( - "append_audit: seq {} kept being freed between the insert and the read-back after \ - {MAX_ATTEMPTS} attempts; something is deleting audit rows concurrently and the record \ - was NOT stored", - entry.seq - ))) - } - - fn list_audit(&self) -> RecordStoreResult> { + pub(crate) async fn list_audit(&mut self) -> RecordStoreResult> { let sql = format!("SELECT {AUDIT_COLUMNS} FROM audit_log ORDER BY seq"); - let rows = self.lock().query(&sql, &[]).store()?; + let rows = self.lock().query(&sql, &[]).await.store()?; Ok(rows.iter().map(row_to_audit).collect()) } - fn list_audit_tail(&self, limit: u64) -> RecordStoreResult> { + pub(crate) async fn list_audit_tail( + &mut self, + limit: u64, + ) -> RecordStoreResult> { let sql = format!("SELECT {AUDIT_COLUMNS} FROM audit_log ORDER BY seq DESC LIMIT $1"); let rows = self .lock() .query(&sql, &[&i64::try_from(limit).unwrap_or(i64::MAX)]) + .await .store()?; let mut out: Vec = rows.iter().map(row_to_audit).collect(); out.reverse(); Ok(out) } - fn add_denylist(&self, sub: &str, reason: &str) -> RecordStoreResult<()> { + pub(crate) async fn add_denylist(&mut self, sub: &str, reason: &str) -> RecordStoreResult<()> { // `created_at` is a clock read, not the literal 0 it used to be. Same shape as the // `deleted_at` defect: the row lands, every "is this subject denied" check keeps working, // and only a question about WHEN it was denied reads back the epoch. store-sqlite writes @@ -1581,12 +1678,17 @@ impl RecordStore for PostgresStore { ON CONFLICT (sub) DO UPDATE SET reason = EXCLUDED.reason", &[&sub, &reason, &clamp(now_secs())], ) + .await .store()?; Ok(()) } - fn list_denylist(&self) -> RecordStoreResult> { - let rows = self.lock().query("SELECT sub FROM denylist", &[]).store()?; + pub(crate) async fn list_denylist(&mut self) -> RecordStoreResult> { + let rows = self + .lock() + .query("SELECT sub FROM denylist", &[]) + .await + .store()?; Ok(rows.iter().map(|r| r.get(0)).collect()) } @@ -1596,7 +1698,10 @@ impl RecordStore for PostgresStore { // in `plane_records` keyed `(kind, id)`; an APPENDED one in `plane_chain` keyed // `(kind, parent, seq)`. Identity, ordering and retention read only the typed sidecar columns. - fn upsert_plane_record(&self, record: PlaneRecordRef<'_>) -> RecordStoreResult<()> { + pub(crate) async fn upsert_plane_record( + &mut self, + record: PlaneRecordRef<'_>, + ) -> RecordStoreResult<()> { // This store binds owned rows: the one copy of the borrowed view happens here. let record = &record.to_record(); // REFUSED rather than clamped: `clamp` pins a value above i64::MAX, so the row read back @@ -1623,11 +1728,16 @@ impl RecordStore for PostgresStore { &record.body, ], ) + .await .store()?; Ok(()) } - fn get_plane_record(&self, kind: &str, id: &str) -> RecordStoreResult>> { + pub(crate) async fn get_plane_record( + &mut self, + kind: &str, + id: &str, + ) -> RecordStoreResult>> { // No principal filter, deliberately: caller scoping is ENGINE-side, because an // authorization check living in the backend is one an unauthorized reader bypasses by // configuring a different backend. @@ -1637,50 +1747,32 @@ impl RecordStore for PostgresStore { "SELECT body FROM plane_records WHERE kind=$1 AND id=$2", &[&kind, &id], ) + .await .store()?; Ok(row.map(|r| r.get(0))) } - fn append_plane_record(&self, record: PlaneRecordRef<'_>) -> RecordStoreResult<()> { - // This store binds owned rows: the one copy of the borrowed view happens here. - let record = &record.to_record(); - // The bounded loop covers the conflicting row vanishing before the share lock lands (the - // position is then free, so insert). No path returns Ok without the record being stored. - const MAX_ATTEMPTS: u32 = 3; - for _ in 0..MAX_ATTEMPTS { - let mut client = self.lock(); - let mut tx = client.transaction().store()?; - if Self::append_plane_record_in(&mut tx, record)? { - return tx.commit().store(); - } - } - Err(RecordStoreError(format!( - "append_plane_record: kind '{}' seq {} kept being freed between the insert and the \ - read-back after {MAX_ATTEMPTS} attempts; something is deleting chain rows \ - concurrently and the record was NOT stored", - record.kind, record.seq - ))) - } - - fn list_plane_records( - &self, + pub(crate) async fn list_plane_records( + &mut self, kind: &str, selector: &PlaneSelector<'_>, ) -> RecordStoreResult>> { - let mut client = self.lock(); + let client = self.lock(); // One snapshot across both tables. - let mut tx = Self::snapshot_consistent_tx(&mut client)?; + let mut tx = Self::snapshot_consistent_tx(client).await?; let (records, chain) = match selector { PlaneSelector::All => ( tx.query( "SELECT body FROM plane_records WHERE kind=$1 ORDER BY seq, id", &[&kind], ) + .await .store()?, tx.query( "SELECT body FROM plane_chain WHERE kind=$1 ORDER BY parent, seq", &[&kind], ) + .await .store()?, ), // Oldest-first by seq — the order the engine's chain verifier reads a parent's chain. @@ -1690,17 +1782,17 @@ impl RecordStore for PostgresStore { tx.query( "SELECT body FROM plane_records WHERE kind=$1 AND parent=$2 ORDER BY seq, id", &[&kind, &p], - ) + ).await .store()?, tx.query( "SELECT body FROM plane_chain WHERE kind=$1 AND parent=$2 ORDER BY seq", &[&kind, &p], - ) + ).await .store()?, ) } }; - tx.commit().store()?; + tx.commit().await.store()?; Ok(records .iter() .chain(chain.iter()) @@ -1708,7 +1800,10 @@ impl RecordStore for PostgresStore { .collect()) } - fn list_plane_record_parents(&self, kind: &str) -> RecordStoreResult> { + pub(crate) async fn list_plane_record_parents( + &mut self, + kind: &str, + ) -> RecordStoreResult> { // The boot enumeration a restart resumes chains from: every parent holding a record of // `kind`, each exactly once. let rows = self @@ -1720,18 +1815,23 @@ impl RecordStore for PostgresStore { ORDER BY 1", &[&kind], ) + .await .store()?; Ok(rows.iter().map(|r| r.get(0)).collect()) } - fn purge_plane_records_before(&self, kind: &str, before: u64) -> RecordStoreResult { + pub(crate) async fn purge_plane_records_before( + &mut self, + kind: &str, + before: u64, + ) -> RecordStoreResult { // STRICTLY older than the cutoff: a row exactly at `before` is kept. WHICH rows go is the // kind's own contract, read off the typed `disposition` column and never out of the body — // see TERMINAL_ONLY_RETENTION_KINDS. let cutoff = clamp(before); let terminal_only = TERMINAL_ONLY_RETENTION_KINDS.contains(&kind); - let mut client = self.lock(); - let mut tx = client.transaction().store()?; + let client = self.lock(); + let mut tx = client.transaction().await.store()?; let purged: Vec = tx .query( "DELETE FROM plane_records @@ -1739,6 +1839,7 @@ impl RecordStore for PostgresStore { RETURNING id", &[&kind, &cutoff, &terminal_only], ) + .await .store()? .iter() .map(|r| r.get(0)) @@ -1748,6 +1849,7 @@ impl RecordStore for PostgresStore { "DELETE FROM plane_chain WHERE kind=$1 AND ts < $2", &[&kind, &cutoff], ) + .await .store()?; // CASCADE, in the SAME transaction: a purged parent's child chain goes with it, and only // the chains under a record that actually went — so this can never be a second, wider @@ -1758,36 +1860,43 @@ impl RecordStore for PostgresStore { "DELETE FROM plane_chain WHERE kind=$1 AND parent = ANY($2)", &[child, &purged], ) + .await .store()?; } } - tx.commit().store()?; + tx.commit().await.store()?; // `execute`/`RETURNING` report the rows actually removed, so the count is one performed. Ok(purged.len() as u64 + chain) } - fn delete_plane_record(&self, kind: &str, id: &str) -> RecordStoreResult<()> { + pub(crate) async fn delete_plane_record( + &mut self, + kind: &str, + id: &str, + ) -> RecordStoreResult<()> { // Absent is a NO-OP, not an error: the engine clears on every observation that agrees with // the operator rather than tracking whether it had written one. A chain whose parent is // `id` goes too, so deleting a record cannot leave part of its chain behind. - let mut client = self.lock(); - let mut tx = client.transaction().store()?; + let client = self.lock(); + let mut tx = client.transaction().await.store()?; tx.execute( "DELETE FROM plane_records WHERE kind=$1 AND id=$2", &[&kind, &id], ) + .await .store()?; tx.execute( "DELETE FROM plane_chain WHERE kind=$1 AND parent=$2", &[&kind, &id], ) + .await .store()?; - tx.commit().store()?; + tx.commit().await.store()?; Ok(()) } - fn redeem_plane_token( - &self, + pub(crate) async fn redeem_plane_token( + &mut self, kind: &str, token: &str, expires_at: u64, @@ -1799,8 +1908,8 @@ impl RecordStore for PostgresStore { // every spent token. An error is refused by the engine, so it is the direction to fail in. let expires = as_storable_i64("redeem_plane_token", "expires_at", expires_at)?; let cutoff = as_storable_i64("redeem_plane_token", "now", now)?; - let mut client = self.lock(); - let mut tx = client.transaction().store()?; + let client = self.lock(); + let mut tx = client.transaction().await.store()?; // The eviction sweep the redemption carries, bounding the ledger by one validity window: an // entry recording a token that can no longer be presented protects nothing. STRICTLY // less-than (an entry expiring exactly at `now` is kept), and BEFORE the insert, so it can @@ -1809,6 +1918,7 @@ impl RecordStore for PostgresStore { "DELETE FROM plane_tokens WHERE kind=$1 AND expires_at < $2", &[&kind, &cutoff], ) + .await .store()?; // THE TEST AND SET, as ONE statement: `execute` returns the rows this INSERT wrote, so 1 // means THIS call recorded the redemption and 0 means it was already there. A read then a @@ -1820,13 +1930,14 @@ impl RecordStore for PostgresStore { ON CONFLICT (kind, token) DO NOTHING", &[&kind, &token, &expires], ) + .await .store()?; - tx.commit().store()?; + tx.commit().await.store()?; Ok(inserted == 1) } - fn plane_token_live( - &self, + pub(crate) async fn plane_token_live( + &mut self, kind: &str, token: &str, expires_at: u64, @@ -1845,6 +1956,7 @@ impl RecordStore for PostgresStore { "SELECT disposition FROM plane_records WHERE kind=$1 AND id=$2", &[&kind, &token], ) + .await .store()?; Ok(row.is_some_and(|r| r.get::<_, &str>(0) == "active")) } @@ -1885,19 +1997,19 @@ fn row_to_audit(r: &Row) -> AuditRecord { } } -impl PostgresStore { +impl Session { /// Shared body of `put_credential`/`put_key_with_credential`: upsert on `(key_id, kind, slot)`. /// Minting into an OCCUPIED LIVE slot (revoked_at IS NULL) MUST fail rather than silently /// destroy a working credential mid-overlap-window -- the `WHERE credentials.revoked_at IS NOT /// NULL` guard on the `DO UPDATE` makes that structural: the upsert simply does not apply if /// the existing row is live, and the subsequent `changed` check turns that into a real error /// instead of a silent no-op. - fn put_credential_tx( + async fn put_credential_tx( tx: &mut Transaction<'_>, secret: &CredentialSecret, ) -> RecordStoreResult<()> { let m = &secret.meta; - let rev = Self::next_revision(tx)?; + let rev = Self::next_revision(tx).await?; // The owning key must EXIST and be LIVE (`put_credential`'s precondition, pinned by the // conformance suite). `delete_key` cascades a key's credentials away precisely so the // secret material stops resolving; accepting a mint afterwards puts it back under a key an @@ -1911,6 +2023,7 @@ impl PostgresStore { "SELECT deleted_at IS NULL FROM keys WHERE id=$1", &[&m.key_id], ) + .await .store()? .map(|r| r.get(0)); match owner_live { @@ -1961,7 +2074,7 @@ impl PostgresStore { &expires, &rev, ], - ) + ).await .store()?; if changed == 0 { // Either the slot is occupied by a LIVE credential (the WHERE guard blocked it), or this @@ -1971,7 +2084,7 @@ impl PostgresStore { .query_one( "SELECT EXISTS(SELECT 1 FROM credentials WHERE key_id=$1 AND kind=$2 AND slot=$3)", &[&m.key_id, &m.kind, &(m.slot as i16)], - ) + ).await .store()? .get(0); if exists { @@ -1992,10 +2105,10 @@ impl PostgresStore { } /// The writes the store v3 `op_id` slots run inside their dedupe transaction ([`v3`]). -impl PostgresStore { +impl Session { /// `add_usage` inside the caller's transaction (the store v3 `op_id` writes and batches run /// it with their dedupe record, in one transaction). - pub(crate) fn add_usage_in( + pub(crate) async fn add_usage_in( tx: &mut Transaction<'_>, bucket_id: &str, window_start: u64, @@ -2010,6 +2123,7 @@ impl PostgresStore { billable_requests = GREATEST(0, usage_windows.billable_requests + $4::bigint)", &[&bucket_id, &ws, &delta.requests, &delta.billable_requests], ) + .await .store()?; if !delta.models.is_empty() { // TWO statements, both batched over every model, and the split is what makes a @@ -2068,8 +2182,8 @@ impl PostgresStore { ") AS v(model, di, do_, dcr, dcw) \ WHERE u.bucket_id = $1 AND u.window_start = $2 AND u.model = v.model", ); - tx.execute(&ensure, &ensure_params).store()?; - tx.execute(&update, &update_params).store()?; + tx.execute(&ensure, &ensure_params).await.store()?; + tx.execute(&update, &update_params).await.store()?; // The OPEN unit classes, the same ensure-then-signed-update shape. let opens: Vec<(&String, &String, i64)> = delta @@ -2123,15 +2237,15 @@ impl PostgresStore { WHERE u.bucket_id = $1 AND u.window_start = $2 \ AND u.model = v.model AND u.unit = v.unit", ); - tx.execute(&ensure, &ensure_params).store()?; - tx.execute(&update, &update_params).store()?; + tx.execute(&ensure, &ensure_params).await.store()?; + tx.execute(&update, &update_params).await.store()?; } } Ok(()) } /// `add_metering` inside the caller's transaction. - pub(crate) fn add_metering_in( + pub(crate) async fn add_metering_in( tx: &mut Transaction<'_>, d: &MeteringDelta, ) -> RecordStoreResult<()> { @@ -2164,7 +2278,7 @@ impl PostgresStore { &d.key_id, &bucket, &d.model, &d.provider, &ti, &to, &tcr, &tcw, &requests, &brequests, &d.key_group_at_use, &d.pricing_version, &priced_from, ], - ) + ).await .store()?; // Every ledgered class the token columns do not hold, additive like them, in the SAME // transaction so a metering row never shows its tokens without its other classes. @@ -2198,7 +2312,7 @@ impl PostgresStore { " ON CONFLICT (key_id, bucket, model, provider, priced_from_ms, unit) DO UPDATE SET \ count = usage_metering_units.count + EXCLUDED.count", ); - tx.execute(&sql, ¶ms).store()?; + tx.execute(&sql, ¶ms).await.store()?; } Ok(()) } @@ -2206,7 +2320,7 @@ impl PostgresStore { /// `append_audit` inside the caller's transaction: `Ok(true)` once the record is stored (or an /// identical one already was), `Ok(false)` when the conflicting row vanished before the share /// lock held it (the seq is free again: retry in a fresh transaction), `Err` on a fork. - pub(crate) fn append_audit_in( + pub(crate) async fn append_audit_in( tx: &mut Transaction<'_>, entry: &AuditRecord, ) -> RecordStoreResult { @@ -2266,6 +2380,7 @@ impl PostgresStore { &entry.hash, ], ) + .await .store()?; if inserted == 1 { return Ok(true); @@ -2273,6 +2388,7 @@ impl PostgresStore { let sql = format!("SELECT {AUDIT_COLUMNS} FROM audit_log WHERE seq=$1 FOR SHARE"); let stored = tx .query_opt(&sql, &[&seq]) + .await .store()? .map(|r| row_to_audit(&r)); match stored { @@ -2290,7 +2406,7 @@ impl PostgresStore { /// `append_plane_record` inside the caller's transaction: `Ok(true)` once the record is stored /// (or an identical one already was), `Ok(false)` when the conflicting row vanished before the /// share lock held it (retry in a fresh transaction), `Err` on a fork. - pub(crate) fn append_plane_record_in( + pub(crate) async fn append_plane_record_in( tx: &mut Transaction<'_>, record: &PlaneRecord, ) -> RecordStoreResult { @@ -2325,6 +2441,7 @@ impl PostgresStore { &record.body, ], ) + .await .store()?; if inserted == 1 { return Ok(true); @@ -2335,6 +2452,7 @@ impl PostgresStore { WHERE kind=$1 AND parent=$2 AND seq=$3 FOR SHARE", &[&record.kind, &parent, &seq], ) + .await .store()?; match stored { Some(r) => { diff --git a/store-postgres/src/pgwire.rs b/store-postgres/src/pgwire.rs new file mode 100644 index 0000000..ac0f270 --- /dev/null +++ b/store-postgres/src/pgwire.rs @@ -0,0 +1,1168 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE POSTGRES FRONTEND PROTOCOL OVER THE HOST'S CONNECTOR: a small async client whose every byte +//! goes through the op's one connection ([`Wire`], the store SDK's `wire`), so this store opens no +//! socket of its own (busbar THE DESIGN, the connections section). The codec is +//! `postgres-protocol` (the messages, SCRAM-SHA-256 and md5) and the value encodings are +//! `postgres-types` (the same binary `ToSql`/`FromSql` the `postgres` crate uses), so a parameter +//! or a column reads exactly as it did over the 1.5.5 driver. +//! +//! The surface mirrors the `postgres` crate's (`Client`, `Transaction`, `Row`, `Error`) with every +//! call `async`, so the store's SQL bodies are the 1.5.5 bodies with `.await`. A query is two round +//! trips (Parse/Describe/Sync for the parameter and column types, then Bind/Execute/Sync), every +//! parameter and column in the binary format. A `Transaction` dropped without `commit` is rolled +//! back before the connection's next statement, as the driver's was. + +use std::fmt; +use std::ops::Range; +use std::sync::Arc; + +use bytes::BytesMut; +use fallible_iterator::FallibleIterator; +use postgres_protocol::authentication::sasl::{self, ChannelBinding, ScramSha256}; +use postgres_protocol::message::backend::{DataRowBody, ErrorResponseBody, Message}; +use postgres_protocol::message::frontend; +use postgres_types::{FromSql, Kind, ToSql, Type}; + +use busbar_contract::abi::sdk::store::wire::Wire; + +// ── configuration ─────────────────────────────────────────────────────────────────────────── + +/// Whether the connection is secured (libpq's `sslmode`). `disable`, `allow` and `prefer` connect +/// in plaintext, as the 1.5.5 build (`NoTls`) did for every mode it accepted; `require`, +/// `verify-ca` and `verify-full` ask the server for TLS and secure the connection through the host +/// (its trust anchors), refusing a server that declines. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SslMode { + /// Plaintext. + Plain, + /// TLS, or no connection. + Require, +} + +/// A parsed connection string (the URL or the libpq keyword form). +#[derive(Clone, PartialEq, Eq)] +pub struct Config { + pub host: String, + pub port: u16, + pub user: String, + pub password: Option, + pub dbname: Option, + pub application_name: Option, + pub options: Option, + pub ssl: SslMode, +} + +impl fmt::Debug for Config { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Config") + .field("host", &self.host) + .field("port", &self.port) + .field("user", &self.user) + .field("dbname", &self.dbname) + .finish_non_exhaustive() + } +} + +fn config_err(what: impl Into) -> Error { + Error::new(Kind_::Config(what.into())) +} + +impl Config { + /// Parse a libpq connection string: `postgres[ql]://user:pass@host:port/db?key=value&...` or + /// `host=... port=... user=... password=... dbname=... sslmode=...`. + /// + /// # Errors + /// A malformed string, an unknown option, a Unix-socket host (this store reaches its server + /// over the host's connector, which dials TCP) or no user. + pub fn parse(s: &str) -> Result { + let mut pairs: Vec<(String, String)> = Vec::new(); + if let Some(rest) = s + .strip_prefix("postgresql://") + .or_else(|| s.strip_prefix("postgres://")) + { + let (main, query) = match rest.split_once('?') { + Some((m, q)) => (m, Some(q)), + None => (rest, None), + }; + let (authority, path) = match main.split_once('/') { + Some((a, p)) => (a, Some(p)), + None => (main, None), + }; + let (userinfo, hostport) = match authority.rsplit_once('@') { + Some((u, h)) => (Some(u), h), + None => (None, authority), + }; + if let Some(u) = userinfo { + match u.split_once(':') { + Some((user, pass)) => { + pairs.push(("user".into(), decode(user)?)); + pairs.push(("password".into(), decode(pass)?)); + } + None => pairs.push(("user".into(), decode(u)?)), + } + } + if !hostport.is_empty() { + if hostport.contains(',') { + return Err(config_err("multiple hosts are not supported")); + } + let (host, port) = if let Some(v6) = hostport.strip_prefix('[') { + match v6.split_once(']') { + Some((h, rest)) => (h.to_string(), rest.strip_prefix(':')), + None => return Err(config_err("unterminated IPv6 host")), + } + } else { + match hostport.rsplit_once(':') { + Some((h, p)) => (h.to_string(), Some(p)), + None => (hostport.to_string(), None), + } + }; + if !host.is_empty() { + pairs.push(("host".into(), decode(&host)?)); + } + if let Some(p) = port.filter(|p| !p.is_empty()) { + pairs.push(("port".into(), p.to_string())); + } + } + if let Some(db) = path.filter(|p| !p.is_empty()) { + pairs.push(("dbname".into(), decode(db)?)); + } + if let Some(q) = query { + for kv in q.split('&').filter(|kv| !kv.is_empty()) { + let (k, v) = kv + .split_once('=') + .ok_or_else(|| config_err(format!("invalid query parameter `{kv}`")))?; + pairs.push((decode(k)?, decode(v)?)); + } + } + } else { + pairs = keywords(s)?; + } + let mut c = Config { + host: String::new(), + port: 5432, + user: String::new(), + password: None, + dbname: None, + application_name: None, + options: None, + ssl: SslMode::Plain, + }; + for (k, v) in pairs { + match k.as_str() { + "host" | "hostaddr" => c.host = v, + "port" => { + c.port = v + .parse() + .map_err(|_| config_err("invalid value for option `port`"))?; + } + "user" => c.user = v, + "password" => c.password = Some(v), + "dbname" => c.dbname = Some(v), + "application_name" => c.application_name = Some(v), + "options" => c.options = Some(v), + "sslmode" => { + c.ssl = match v.as_str() { + "disable" | "allow" | "prefer" => SslMode::Plain, + "require" | "verify-ca" | "verify-full" => SslMode::Require, + _ => { + return Err(config_err("invalid value for option `sslmode`")); + } + } + } + // Accepted and served by the host instead: its deadlines bound every connect, and + // its connector keeps the connection. + "connect_timeout" + | "keepalives" + | "keepalives_idle" + | "target_session_attrs" + | "channel_binding" + | "load_balance_hosts" + | "tcp_user_timeout" + | "keepalives_interval" + | "keepalives_retries" + | "sslrootcert" + | "sslnegotiation" => {} + _ => return Err(config_err(format!("unknown option `{k}`"))), + } + } + if c.host.is_empty() { + c.host = "localhost".into(); + } + if c.host.starts_with('/') { + return Err(config_err( + "a Unix-socket host is not supported: the store reaches its server over TCP", + )); + } + if c.user.is_empty() { + return Err(config_err("user missing")); + } + Ok(c) + } + + /// The `host:port` the store's need dials. + #[must_use] + pub fn target(&self) -> String { + if self.host.contains(':') { + format!("[{}]:{}", self.host, self.port) + } else { + format!("{}:{}", self.host, self.port) + } + } +} + +fn decode(s: &str) -> Result { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' { + let hex = |c: u8| (c as char).to_digit(16); + match ( + b.get(i + 1).and_then(|c| hex(*c)), + b.get(i + 2).and_then(|c| hex(*c)), + ) { + (Some(h), Some(l)) => { + out.push((h * 16 + l) as u8); + i += 3; + continue; + } + _ => return Err(config_err("invalid percent encoding")), + } + } + out.push(b[i]); + i += 1; + } + String::from_utf8(out).map_err(|_| config_err("invalid percent encoding")) +} + +/// The libpq keyword form: `key=value` pairs, whitespace around `=` allowed, single-quoted values +/// with `\'` and `\\` escapes. +fn keywords(s: &str) -> Result, Error> { + let mut out = Vec::new(); + let mut it = s.chars().peekable(); + loop { + while it.peek().is_some_and(|c| c.is_whitespace()) { + it.next(); + } + if it.peek().is_none() { + return Ok(out); + } + let mut key = String::new(); + while let Some(&c) = it.peek() { + if c == '=' || c.is_whitespace() { + break; + } + key.push(c); + it.next(); + } + while it.peek().is_some_and(|c| c.is_whitespace()) { + it.next(); + } + if it.next() != Some('=') { + return Err(config_err(format!("unexpected EOF after `{key}`"))); + } + while it.peek().is_some_and(|c| c.is_whitespace()) { + it.next(); + } + let mut value = String::new(); + if it.peek() == Some(&'\'') { + it.next(); + loop { + match it.next() { + None => { + return Err(config_err("unterminated quoted connection parameter value")) + } + Some('\'') => break, + Some('\\') => match it.next() { + Some(c) => value.push(c), + None => { + return Err(config_err( + "unterminated quoted connection parameter value", + )) + } + }, + Some(c) => value.push(c), + } + } + } else { + while let Some(&c) = it.peek() { + if c.is_whitespace() { + break; + } + if c == '\\' { + it.next(); + if let Some(n) = it.next() { + value.push(n); + } + continue; + } + value.push(c); + it.next(); + } + } + out.push((key, value)); + } +} + +// ── errors ────────────────────────────────────────────────────────────────────────────────── + +/// A SQLSTATE. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SqlState(String); + +impl SqlState { + /// `42P01`, `undefined_table`. + pub const UNDEFINED_TABLE: &'static str = "42P01"; + + /// The five-character code. + #[must_use] + pub fn code(&self) -> &str { + &self.0 + } +} + +/// A server-side failure (an `ErrorResponse`). +#[derive(Debug, Clone)] +pub struct DbError { + severity: String, + code: SqlState, + message: String, + constraint: Option, +} + +impl DbError { + fn parse(body: &ErrorResponseBody) -> Self { + let mut e = DbError { + severity: String::new(), + code: SqlState(String::new()), + message: String::new(), + constraint: None, + }; + let mut fields = body.fields(); + while let Ok(Some(f)) = fields.next() { + let v = String::from_utf8_lossy(f.value_bytes()).into_owned(); + match f.type_() { + b'S' => e.severity = v, + b'C' => e.code = SqlState(v), + b'M' => e.message = v, + b'n' => e.constraint = Some(v), + _ => {} + } + } + e + } + + /// The SQLSTATE. + #[must_use] + pub fn code(&self) -> &SqlState { + &self.code + } + + /// The primary message. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + /// The violated constraint, if one. + #[must_use] + pub fn constraint(&self) -> Option<&str> { + self.constraint.as_deref() + } +} + +#[derive(Debug)] +enum Kind_ { + Db(DbError), + Connect(String), + Io(String), + Closed, + Parse(String), + Tls(String), + Authentication(String), + Config(String), + ToSql(usize, String), + FromSql(usize, String), + Column(String), + RowCount, + Parameters(usize, usize), +} + +/// A driver error, in the `postgres` crate's words. +#[derive(Debug)] +pub struct Error(Box); + +impl Error { + fn new(k: Kind_) -> Self { + Self(Box::new(k)) + } + + /// The server's failure, when it is one. + #[must_use] + pub fn as_db_error(&self) -> Option<&DbError> { + match &*self.0 { + Kind_::Db(d) => Some(d), + _ => None, + } + } + + /// The server's SQLSTATE, when it is a server failure. + #[must_use] + pub fn code(&self) -> Option<&SqlState> { + self.as_db_error().map(DbError::code) + } + + /// A server failure with SQLSTATE `code` and `message`, as an `ErrorResponse` decodes. + #[cfg(test)] + #[must_use] + pub fn server(code: &str, message: &str) -> Self { + Self::new(Kind_::Db(DbError { + severity: "ERROR".to_string(), + code: SqlState(code.to_string()), + message: message.to_string(), + constraint: None, + })) + } + + /// A connect failure (the host's connector could not reach the server). + #[must_use] + pub fn connect(cause: impl fmt::Display) -> Self { + Self::new(Kind_::Connect(cause.to_string())) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &*self.0 { + Kind_::Db(d) => write!(f, "db error: {}: {}", d.severity, d.message), + Kind_::Connect(c) => write!(f, "error connecting to server: {c}"), + Kind_::Io(c) => write!(f, "error communicating with the server: {c}"), + Kind_::Closed => f.write_str("connection closed"), + Kind_::Parse(c) => write!(f, "error parsing response from server: {c}"), + Kind_::Tls(c) => write!(f, "error performing TLS handshake: {c}"), + Kind_::Authentication(c) => write!(f, "authentication error: {c}"), + Kind_::Config(c) => write!(f, "invalid configuration: {c}"), + Kind_::ToSql(i, c) => write!(f, "error serializing parameter {i}: {c}"), + Kind_::FromSql(i, c) => write!(f, "error deserializing column {i}: {c}"), + Kind_::Column(c) => write!(f, "invalid column `{c}`"), + Kind_::RowCount => f.write_str("query returned an unexpected number of rows"), + Kind_::Parameters(want, got) => { + write!(f, "expected {want} parameters but got {got}") + } + } + } +} + +impl std::error::Error for Error {} + +fn parse_err(e: impl fmt::Display) -> Error { + Error::new(Kind_::Parse(e.to_string())) +} + +// ── rows ──────────────────────────────────────────────────────────────────────────────────── + +/// A result column. +#[derive(Debug, Clone)] +pub struct Column { + name: String, + type_: Type, +} + +/// What names a column of a [`Row`]: its index or its name. +pub trait RowIndex: fmt::Display { + #[doc(hidden)] + fn index(&self, columns: &[Column]) -> Option; +} + +impl RowIndex for usize { + fn index(&self, columns: &[Column]) -> Option { + (*self < columns.len()).then_some(*self) + } +} + +impl RowIndex for &str { + fn index(&self, columns: &[Column]) -> Option { + columns.iter().position(|c| c.name == *self) + } +} + +/// One result row. +#[derive(Debug)] +pub struct Row { + columns: Arc<[Column]>, + body: DataRowBody, + ranges: Vec>>, +} + +impl Row { + /// Column `idx` as `T`. + /// + /// # Panics + /// An unknown column, or a value that does not convert (the `postgres` crate's `get`). + pub fn get<'a, I: RowIndex, T: FromSql<'a>>(&'a self, idx: I) -> T { + match self.try_get(idx) { + Ok(v) => v, + Err(e) => panic!("error retrieving column: {e}"), + } + } + + /// Column `idx` as `T`. + /// + /// # Errors + /// An unknown column, a type `T` does not accept, or a value that does not convert. + pub fn try_get<'a, I: RowIndex, T: FromSql<'a>>(&'a self, idx: I) -> Result { + let Some(i) = idx.index(&self.columns) else { + return Err(Error::new(Kind_::Column(idx.to_string()))); + }; + let ty = &self.columns[i].type_; + if !T::accepts(ty) { + return Err(Error::new(Kind_::FromSql( + i, + format!( + "cannot convert between the Rust type `{}` and the Postgres type `{}`", + std::any::type_name::(), + ty + ), + ))); + } + let raw = self.ranges[i].clone().map(|r| &self.body.buffer()[r]); + T::from_sql_nullable(ty, raw).map_err(|e| Error::new(Kind_::FromSql(i, e.to_string()))) + } +} + +// ── the client ────────────────────────────────────────────────────────────────────────────── + +/// One connection to the server, over the op's wire. The connection is KEPT for the instance's +/// next op (the store SDK's kept set) only when [`Client::release`] finds the session idle: the +/// last message read was `ReadyForQuery` with status `I` (no transaction open or failed), nothing +/// is left unread and no transaction awaits its rollback. Dropped any other way (an early return, +/// a protocol failure), it is discarded and the next op dials fresh. +pub struct Client { + wire: Wire, + buf: BytesMut, + /// A `Transaction` was dropped uncommitted: roll it back before the next statement. + rollback_pending: bool, + /// The transaction status of the last `ReadyForQuery` (`I` idle, `T` in a transaction, `E` in + /// a failed one). + status: u8, + /// The last message read was `ReadyForQuery` and nothing was sent after it. + at_ready: bool, + /// [`Client::release`] found the session idle: keep the connection. + keep: bool, +} + +impl Drop for Client { + fn drop(&mut self) { + if !self.keep { + self.wire.discard(); + } + } +} + +impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Client").finish_non_exhaustive() + } +} + +/// A parameter. +pub type Param<'a> = &'a (dyn ToSql + Sync); + +impl Client { + /// Connect over `wire` (its need 0) to the server `cfg` names, secure the connection when + /// `cfg` asks, and authenticate. + /// + /// # Errors + /// The connector's failure (as a connect error), a TLS refusal, or the server's refusal. + pub async fn connect(wire: Wire, cfg: &Config) -> Result { + wire.connect(0, Some(&cfg.target())) + .await + .map_err(Error::connect)?; + let reused = wire.reused(); + let mut c = Client { + wire, + buf: BytesMut::new(), + rollback_pending: false, + status: b'I', + at_ready: true, + keep: false, + }; + if reused { + // A kept connection: secured and authenticated by the op that established it, and + // idle when it was kept. + return Ok(c); + } + if cfg.ssl == SslMode::Require { + let mut out = BytesMut::new(); + frontend::ssl_request(&mut out); + c.send(&out).await?; + let answer = c.read_byte().await?; + if answer != b'S' { + return Err(Error::new(Kind_::Tls( + "server does not support TLS".to_string(), + ))); + } + c.wire + .upgrade_secure(Some(&cfg.host)) + .await + .map_err(|e| Error::new(Kind_::Tls(e.to_string())))?; + } + c.startup(cfg).await?; + Ok(c) + } + + async fn startup(&mut self, cfg: &Config) -> Result<(), Error> { + let mut params: Vec<(&str, &str)> = vec![("client_encoding", "UTF8"), ("user", &cfg.user)]; + if let Some(db) = &cfg.dbname { + params.push(("database", db)); + } + if let Some(o) = &cfg.options { + params.push(("options", o)); + } + if let Some(a) = &cfg.application_name { + params.push(("application_name", a)); + } + let mut out = BytesMut::new(); + frontend::startup_message(params.iter().copied(), &mut out).map_err(parse_err)?; + self.send(&out).await?; + let password = || { + cfg.password + .as_deref() + .map(str::as_bytes) + .ok_or_else(|| Error::new(Kind_::Config("password missing".to_string()))) + }; + loop { + match self.read().await? { + Message::AuthenticationOk => break, + Message::AuthenticationCleartextPassword => { + let mut out = BytesMut::new(); + frontend::password_message(password()?, &mut out).map_err(parse_err)?; + self.send(&out).await?; + } + Message::AuthenticationMd5Password(b) => { + let hash = postgres_protocol::authentication::md5_hash( + cfg.user.as_bytes(), + password()?, + b.salt(), + ); + let mut out = BytesMut::new(); + frontend::password_message(hash.as_bytes(), &mut out).map_err(parse_err)?; + self.send(&out).await?; + } + Message::AuthenticationSasl(b) => { + let mut scram = false; + let mut mechs = b.mechanisms(); + while let Some(m) = mechs.next().map_err(parse_err)? { + scram |= m == sasl::SCRAM_SHA_256; + } + if !scram { + return Err(Error::new(Kind_::Authentication( + "unsupported SASL mechanism".to_string(), + ))); + } + let mut s = ScramSha256::new(password()?, ChannelBinding::unsupported()); + let mut out = BytesMut::new(); + frontend::sasl_initial_response(sasl::SCRAM_SHA_256, s.message(), &mut out) + .map_err(parse_err)?; + self.send(&out).await?; + let Message::AuthenticationSaslContinue(cont) = self.read_auth().await? else { + return Err(parse_err("unexpected message during SASL")); + }; + s.update(cont.data()) + .map_err(|e| Error::new(Kind_::Authentication(e.to_string())))?; + let mut out = BytesMut::new(); + frontend::sasl_response(s.message(), &mut out).map_err(parse_err)?; + self.send(&out).await?; + let Message::AuthenticationSaslFinal(fin) = self.read_auth().await? else { + return Err(parse_err("unexpected message during SASL")); + }; + s.finish(fin.data()) + .map_err(|e| Error::new(Kind_::Authentication(e.to_string())))?; + } + Message::ErrorResponse(e) => return Err(Error::new(Kind_::Db(DbError::parse(&e)))), + Message::NoticeResponse(_) => {} + _ => { + return Err(Error::new(Kind_::Authentication( + "unsupported authentication method".to_string(), + ))) + } + } + } + loop { + match self.read().await? { + Message::ReadyForQuery(_) => return Ok(()), + Message::ErrorResponse(e) => return Err(Error::new(Kind_::Db(DbError::parse(&e)))), + _ => {} + } + } + } + + /// An authentication message, a server failure answered as one. + async fn read_auth(&mut self) -> Result { + match self.read().await? { + Message::ErrorResponse(e) => Err(Error::new(Kind_::Db(DbError::parse(&e)))), + m => Ok(m), + } + } + + /// Whether the session is idle and whole: fit to be kept for the next op. + #[must_use] + pub fn is_idle(&self) -> bool { + self.at_ready && self.status == b'I' && !self.rollback_pending && self.buf.is_empty() + } + + /// The op is done with the connection: keep it if the session is idle, else it is discarded + /// when the client drops. + pub fn release(&mut self) { + self.keep = self.is_idle(); + } + + async fn send(&mut self, bytes: &[u8]) -> Result<(), Error> { + self.at_ready = false; + self.wire + .write_all(bytes) + .await + .map_err(|e| Error::new(Kind_::Io(e.to_string()))) + } + + async fn more(&mut self) -> Result<(), Error> { + let n = self + .wire + .fill() + .await + .map_err(|e| Error::new(Kind_::Io(e.to_string())))?; + if n == 0 { + return Err(Error::new(Kind_::Closed)); + } + let buf = &mut self.buf; + self.wire.input(|i| { + buf.extend_from_slice(i); + i.clear(); + }); + Ok(()) + } + + async fn read_byte(&mut self) -> Result { + while self.buf.is_empty() { + self.more().await?; + } + let b = self.buf[0]; + let _ = self.buf.split_to(1); + Ok(b) + } + + /// The next message the server sent; asynchronous notices and parameter changes skipped. + async fn read(&mut self) -> Result { + loop { + match Message::parse(&mut self.buf).map_err(parse_err)? { + Some(Message::ParameterStatus(_) | Message::NoticeResponse(_)) => {} + Some(Message::NotificationResponse(_)) => {} + Some(m) => { + if let Message::ReadyForQuery(b) = &m { + self.status = b.status(); + self.at_ready = true; + } + return Ok(m); + } + None => self.more().await?, + } + } + } + + /// Read to `ReadyForQuery`, after a failure. + async fn drain(&mut self) -> Result<(), Error> { + loop { + if let Message::ReadyForQuery(_) = self.read().await? { + return Ok(()); + } + } + } + + /// Roll back a transaction dropped uncommitted. + async fn settle(&mut self) -> Result<(), Error> { + if std::mem::take(&mut self.rollback_pending) { + self.simple("ROLLBACK").await?; + } + Ok(()) + } + + async fn simple(&mut self, sql: &str) -> Result<(), Error> { + let mut out = BytesMut::new(); + frontend::query(sql, &mut out).map_err(parse_err)?; + self.send(&out).await?; + let mut failed = None; + loop { + match self.read().await? { + Message::ReadyForQuery(_) => break, + Message::ErrorResponse(e) if failed.is_none() => { + failed = Some(Error::new(Kind_::Db(DbError::parse(&e)))); + } + _ => {} + } + } + failed.map_or(Ok(()), Err) + } + + /// Run `sql` (one or more statements, no parameters) through the simple query protocol. + /// + /// # Errors + /// The first statement's failure. + pub async fn batch_execute(&mut self, sql: &str) -> Result<(), Error> { + self.settle().await?; + self.simple(sql).await + } + + /// Run one statement with `params`: its rows and the rows it affected. + async fn run(&mut self, sql: &str, params: &[Param<'_>]) -> Result<(Vec, u64), Error> { + self.settle().await?; + // Round trip 1: the parameter and column types. + let mut out = BytesMut::new(); + frontend::parse("", sql, std::iter::empty(), &mut out).map_err(parse_err)?; + frontend::describe(b'S', "", &mut out).map_err(parse_err)?; + frontend::sync(&mut out); + self.send(&out).await?; + let mut types: Vec = Vec::new(); + let mut columns: Vec = Vec::new(); + loop { + match self.read().await? { + Message::ParseComplete | Message::NoData => {} + Message::ParameterDescription(p) => { + let mut it = p.parameters(); + while let Some(oid) = it.next().map_err(parse_err)? { + types.push(type_of(oid)); + } + } + Message::RowDescription(r) => { + let mut it = r.fields(); + while let Some(f) = it.next().map_err(parse_err)? { + columns.push(Column { + name: f.name().to_string(), + type_: type_of(f.type_oid()), + }); + } + } + Message::ErrorResponse(e) => { + let err = Error::new(Kind_::Db(DbError::parse(&e))); + self.drain().await?; + return Err(err); + } + Message::ReadyForQuery(_) => break, + _ => return Err(parse_err("unexpected message")), + } + } + if types.len() != params.len() { + return Err(Error::new(Kind_::Parameters(types.len(), params.len()))); + } + // Round trip 2: bind, execute. + let mut out = BytesMut::new(); + let mut failed: Option<(usize, String)> = None; + let mut index = 0; + let bound = frontend::bind( + "", + "", + std::iter::repeat_n(1_i16, params.len()), + params.iter().zip(types.iter()), + |(p, ty), buf| { + let i = index; + index += 1; + match p.to_sql_checked(ty, buf) { + Ok(postgres_types::IsNull::No) => Ok(postgres_protocol::IsNull::No), + Ok(postgres_types::IsNull::Yes) => Ok(postgres_protocol::IsNull::Yes), + Err(e) => { + failed = Some((i, e.to_string())); + Err(e) + } + } + }, + std::iter::once(1_i16), + &mut out, + ); + if let Err(e) = bound { + return Err(match failed { + Some((i, t)) => Error::new(Kind_::ToSql(i, t)), + None => match e { + frontend::BindError::Conversion(e) => { + Error::new(Kind_::ToSql(0, e.to_string())) + } + frontend::BindError::Serialization(e) => parse_err(e), + }, + }); + } + frontend::execute("", 0, &mut out).map_err(parse_err)?; + frontend::sync(&mut out); + self.send(&out).await?; + let columns: Arc<[Column]> = columns.into(); + let mut rows = Vec::new(); + let mut affected = 0; + let mut failed = None; + loop { + match self.read().await? { + Message::BindComplete | Message::EmptyQueryResponse => {} + Message::DataRow(body) => { + if failed.is_none() { + let mut ranges = Vec::with_capacity(columns.len()); + let mut it = body.ranges(); + while let Some(r) = it.next().map_err(parse_err)? { + ranges.push(r); + } + rows.push(Row { + columns: columns.clone(), + body, + ranges, + }); + } + } + Message::CommandComplete(c) => { + let tag = c.tag().map_err(parse_err)?; + affected = tag + .rsplit(' ') + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0); + } + Message::ErrorResponse(e) if failed.is_none() => { + failed = Some(Error::new(Kind_::Db(DbError::parse(&e)))); + } + Message::ReadyForQuery(_) => break, + _ => {} + } + } + match failed { + Some(e) => Err(e), + None => Ok((rows, affected)), + } + } + + /// The rows `sql` answers. + /// + /// # Errors + /// The statement's failure. + pub async fn query(&mut self, sql: &str, params: &[Param<'_>]) -> Result, Error> { + self.run(sql, params).await.map(|(r, _)| r) + } + + /// How many rows `sql` affected. + /// + /// # Errors + /// The statement's failure. + pub async fn execute(&mut self, sql: &str, params: &[Param<'_>]) -> Result { + self.run(sql, params).await.map(|(_, n)| n) + } + + /// The one row `sql` answers. + /// + /// # Errors + /// The statement's failure, or not exactly one row. + pub async fn query_one(&mut self, sql: &str, params: &[Param<'_>]) -> Result { + let mut rows = self.query(sql, params).await?; + if rows.len() != 1 { + return Err(Error::new(Kind_::RowCount)); + } + Ok(rows.remove(0)) + } + + /// The row `sql` answers, if one. + /// + /// # Errors + /// The statement's failure, or more than one row. + pub async fn query_opt( + &mut self, + sql: &str, + params: &[Param<'_>], + ) -> Result, Error> { + let mut rows = self.query(sql, params).await?; + match rows.len() { + 0 => Ok(None), + 1 => Ok(Some(rows.remove(0))), + _ => Err(Error::new(Kind_::RowCount)), + } + } + + /// Begin a transaction (the server's default isolation, READ COMMITTED). + /// + /// # Errors + /// The `BEGIN`'s failure. + pub async fn transaction(&mut self) -> Result, Error> { + self.batch_execute("BEGIN").await?; + Ok(Transaction { + client: self, + done: false, + }) + } + + /// A transaction with options. + pub fn build_transaction(&mut self) -> TransactionBuilder<'_> { + TransactionBuilder { + client: self, + isolation: None, + } + } +} + +fn type_of(oid: u32) -> Type { + Type::from_oid(oid) + .unwrap_or_else(|| Type::new(format!("{oid}"), oid, Kind::Simple, String::new())) +} + +/// A transaction's isolation level. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IsolationLevel { + RepeatableRead, +} + +/// The statement that begins a transaction at `isolation` (`None` = the server's default). +#[must_use] +pub fn begin_sql(isolation: Option) -> &'static str { + match isolation { + None => "BEGIN", + Some(IsolationLevel::RepeatableRead) => "BEGIN ISOLATION LEVEL REPEATABLE READ", + } +} + +/// [`Client::build_transaction`]. +pub struct TransactionBuilder<'a> { + client: &'a mut Client, + isolation: Option, +} + +impl<'a> TransactionBuilder<'a> { + /// The isolation level. + #[must_use] + pub fn isolation_level(mut self, level: IsolationLevel) -> Self { + self.isolation = Some(level); + self + } + + /// Begin it. + /// + /// # Errors + /// The `BEGIN`'s failure. + pub async fn start(self) -> Result, Error> { + self.client.batch_execute(begin_sql(self.isolation)).await?; + Ok(Transaction { + client: self.client, + done: false, + }) + } +} + +/// A transaction; rolled back unless committed. +pub struct Transaction<'a> { + client: &'a mut Client, + done: bool, +} + +impl Drop for Transaction<'_> { + fn drop(&mut self) { + if !self.done { + self.client.rollback_pending = true; + } + } +} + +impl Transaction<'_> { + /// Commit. + /// + /// # Errors + /// The `COMMIT`'s failure. + pub async fn commit(mut self) -> Result<(), Error> { + self.done = true; + self.client.batch_execute("COMMIT").await + } + + /// [`Client::batch_execute`] inside the transaction. + /// + /// # Errors + /// As there. + pub async fn batch_execute(&mut self, sql: &str) -> Result<(), Error> { + self.client.batch_execute(sql).await + } + + /// [`Client::query`] inside the transaction. + /// + /// # Errors + /// As there. + pub async fn query(&mut self, sql: &str, params: &[Param<'_>]) -> Result, Error> { + self.client.query(sql, params).await + } + + /// [`Client::execute`] inside the transaction. + /// + /// # Errors + /// As there. + pub async fn execute(&mut self, sql: &str, params: &[Param<'_>]) -> Result { + self.client.execute(sql, params).await + } + + /// [`Client::query_one`] inside the transaction. + /// + /// # Errors + /// As there. + pub async fn query_one(&mut self, sql: &str, params: &[Param<'_>]) -> Result { + self.client.query_one(sql, params).await + } + + /// [`Client::query_opt`] inside the transaction. + /// + /// # Errors + /// As there. + pub async fn query_opt( + &mut self, + sql: &str, + params: &[Param<'_>], + ) -> Result, Error> { + self.client.query_opt(sql, params).await + } +} + +#[cfg(test)] +mod tests { + use super::{begin_sql, Config, IsolationLevel, SslMode}; + + #[test] + fn a_url_parses_into_its_parts() { + let c = Config::parse( + "postgres://u%40x:p%3Ass@db.internal:6543/busbar?sslmode=require&application_name=bb", + ) + .unwrap(); + assert_eq!(c.user, "u@x"); + assert_eq!(c.password.as_deref(), Some("p:ss")); + assert_eq!(c.target(), "db.internal:6543"); + assert_eq!(c.dbname.as_deref(), Some("busbar")); + assert_eq!(c.ssl, SslMode::Require); + assert_eq!(c.application_name.as_deref(), Some("bb")); + let c = Config::parse("postgresql://u@[::1]/d").unwrap(); + assert_eq!(c.target(), "[::1]:5432"); + assert_eq!(c.password, None); + } + + #[test] + fn the_keyword_form_parses_with_spaces_and_quotes() { + let c = + Config::parse("host = db port=5433 user=u password='se cret' dbname=x sslmode=prefer") + .unwrap(); + assert_eq!(c.target(), "db:5433"); + assert_eq!(c.password.as_deref(), Some("se cret")); + assert_eq!(c.ssl, SslMode::Plain); + } + + #[test] + fn what_the_host_cannot_dial_is_refused() { + assert!(Config::parse("host=/var/run/postgresql user=u").is_err()); + assert!(Config::parse("host=db").is_err(), "no user"); + assert!(Config::parse("host=db user=u bogus=1").is_err()); + assert!(Config::parse("host=db user=u sslmode=sometimes").is_err()); + assert!(Config::parse("postgres://u@a,b/d").is_err()); + } + + #[test] + fn a_snapshot_transaction_begins_repeatable_read() { + assert_eq!( + begin_sql(Some(IsolationLevel::RepeatableRead)), + "BEGIN ISOLATION LEVEL REPEATABLE READ" + ); + assert_eq!(begin_sql(None), "BEGIN"); + } +} diff --git a/store-postgres/src/tests.rs b/store-postgres/src/tests.rs index ea13749..9e3afb2 100644 --- a/store-postgres/src/tests.rs +++ b/store-postgres/src/tests.rs @@ -8,9 +8,12 @@ use super::*; use busbar_contract::records::{ CredentialMeta, CredentialSecret, ModelTokensDelta, PlaneDisposition, PlaneRecord, - PlaneSelector, SecretForm, + PlaneSelector, RecordStore, SecretForm, }; +mod harness; +pub(crate) use harness::TestStore; + /// One model's ledger row from the four RESERVED token classes (zero classes left out, the way the /// store reads them back). fn mt( @@ -169,23 +172,19 @@ fn connect_client_with_retry(url: &str) -> postgres::Client { panic!("connect (after retries): {:?}", last_err.unwrap()); } -/// `PostgresStore::connect` with bounded retry-with-backoff. Each connect opens a fresh connection -/// AND runs `migrate()`, so under the core gate's parallel-test load against a shared Postgres a -/// single connect can be TRANSIENTLY refused when the server is momentarily at its connection -/// ceiling (surfacing as `RecordStoreError("db error")` / too-many-clients) -- the exact class of flake -/// the gate hit on the isolation test's connect. Every live-DB test needs at least this one store -/// connection, so footprint reduction alone can't harden the primary connect; retrying ~10 times -/// over a couple of seconds absorbs the transient. Returns the same `RecordStoreResult` `connect` does, so -/// each caller keeps its own `.expect(...)` message. NOT used where a connect is EXPECTED to fail -/// (the permission test asserts `.is_err()` directly and must not spin on a genuine, persistent -/// error). -fn connect_store_with_retry(url: &str) -> RecordStoreResult { +/// The store under test, opened with bounded retry-with-backoff: through the REAL loader on a +/// dispatcher, its connections over the host's connection path (the loader's test `TcpConns`), so +/// every op goes through the door exactly as busbar runs it. Each open runs the connect step (a +/// connection and `migrate()`), so under parallel-test load against a shared Postgres a single +/// open can be TRANSIENTLY refused on connection pressure; retrying ~10 times over a couple of +/// seconds absorbs that. NOT used where an open is EXPECTED to fail. +fn connect_store_with_retry(url: &str) -> Result { let mut last_err = None; for attempt in 0..10u32 { if attempt > 0 { std::thread::sleep(std::time::Duration::from_millis(50 * attempt as u64)); } - match PostgresStore::connect(url) { + match TestStore::open(url) { Ok(store) => return Ok(store), Err(e) => last_err = Some(e), } @@ -198,7 +197,7 @@ fn connect_store_with_retry(url: &str) -> RecordStoreResult { /// re-running the suite (or running it twice in a row) never sees stale state /// from a prior run leaking into the CHECK constraints (e.g. re-minting into a row still marked /// `deleted_at` from a previous run's tombstone would violate `keys_tombstone_disabled`). -fn hard_reset(store: &PostgresStore, id: &str) { +fn hard_reset(store: &TestStore, id: &str) { let mut client = store.lock(); let _ = client.execute("DELETE FROM credentials WHERE key_id=$1", &[&id]); let _ = client.execute("DELETE FROM keys WHERE id=$1", &[&id]); @@ -790,24 +789,22 @@ fn concurrent_delete_of_the_same_key_is_safe_and_idempotent() { fn get_usage_transaction_is_actually_repeatable_read() { let Some(url) = live_url() else { return }; let store = connect_store_with_retry(&url).expect("connect"); - // Drive the isolation check through the store's OWN connection -- the exact same client - // get_usage uses -- rather than opening a second raw `postgres::Client`. That extra, uncounted - // connection was pure connection-footprint overhead here (this assertion never needed a - // *separate* connection, only a real one), and under the core gate's parallel-test load against - // a shared Postgres its `.connect()` transiently failed on connection pressure -- the observed - // flake panicked at this test's connect path (`connect: RecordStoreError("db error")`), never on the - // isolation assertion below. Reusing the store's client (extending what b2f3804 did for the - // sibling torn-read test) halves this test's connection count and removes the refuse-able - // connect, while testing the real helper against a real client just as faithfully. The scope - // guard releases the store lock before `get_usage` (which re-locks the same mutex) is called. + // The store's connections are its ops' own (one per op, over the host's connector), so the + // isolation check runs the helper's exact statement (`pgwire::begin_sql`, the + // `IsolationLevel::RepeatableRead` arm `snapshot_consistent_tx` uses) on an independent driver + // connection and asks the server what it opened. let level: String = { let mut client = store.lock(); - let mut tx = PostgresStore::snapshot_consistent_tx(&mut client).unwrap(); - let level: String = tx + client + .batch_execute(crate::pgwire::begin_sql(Some( + crate::pgwire::IsolationLevel::RepeatableRead, + ))) + .unwrap(); + let level: String = client .query_one("SHOW transaction_isolation", &[]) .unwrap() .get(0); - tx.commit().unwrap(); + client.batch_execute("COMMIT").unwrap(); level }; assert_eq!( @@ -861,7 +858,12 @@ fn get_usage_snapshot_does_not_observe_a_concurrent_add_usage_between_its_two_re // test's -- instead its connect is bounded-retried to absorb transient connection-pressure // refusals under the gate's parallel load. let mut client = connect_client_with_retry(&url); - let mut tx = PostgresStore::snapshot_consistent_tx(&mut client).unwrap(); + // `snapshot_consistent_tx`'s statement, on this independent connection. + let mut tx = client + .build_transaction() + .isolation_level(postgres::IsolationLevel::RepeatableRead) + .start() + .unwrap(); let _requests_row = tx .query_one( "SELECT requests, billable_requests FROM usage_windows WHERE bucket_id=$1 AND window_start=$2", @@ -984,30 +986,25 @@ fn percent_decode_edge_cases() { } /// `is_undefined_table` must discriminate the ONE SQLSTATE (`42P01`/undefined_table) it exists to -/// recognize from every other error class -- pinned against two REAL postgres errors (never a -/// hand-built one, since `postgres::Error` has no public constructor), so neither an inverted -/// comparison nor an unconditional true/false would pass. +/// recognize from every other error class, so neither an inverted comparison nor an unconditional +/// true/false passes. The errors are the store's own driver's server-failure shape (`pgwire`'s +/// `ErrorResponse` decode), carrying the codes a real server sends for a missing table and for a +/// syntax error; the permission test below proves the classification against a live server. #[test] fn is_undefined_table_matches_only_the_real_sqlstate() { - let Some(url) = live_url() else { return }; - let mut client = postgres::Client::connect(&url, postgres::NoTls).unwrap(); - - let missing = client - .query_opt( - "SELECT 1 FROM spg_this_table_definitely_does_not_exist_xyz", - &[], - ) - .unwrap_err(); + let missing = crate::pgwire::Error::server("42P01", "relation \"x\" does not exist"); assert!( is_undefined_table(&missing), - "a query against a genuinely missing table must be classified as undefined_table: {missing}" + "a missing table must be classified as undefined_table: {missing}" ); - - let syntax_err = client.query_opt("SELEC 1", &[]).unwrap_err(); + let syntax_err = crate::pgwire::Error::server("42601", "syntax error at or near \"SELEC\""); assert!( !is_undefined_table(&syntax_err), "a syntax error must NOT be misclassified as undefined_table: {syntax_err}" ); + assert!(!is_undefined_table(&crate::pgwire::Error::connect( + "refused" + ))); } /// `labels_to_storage`'s serialization -- not just the empty-map default -- must round-trip a @@ -1942,11 +1939,14 @@ fn migrate_propagates_a_non_undefined_table_error_and_never_silently_succeeds() "test setup sanity: the probe query must fail with insufficient_privilege, not \ undefined_table, for this test to mean anything: {probe_err}" ); - assert!(!is_undefined_table(&probe_err)); + assert_ne!( + probe_err.code(), + Some(&postgres::error::SqlState::UNDEFINED_TABLE) + ); } assert!( - PostgresStore::connect(&limited_url).is_err(), + TestStore::open(&limited_url).is_err(), "migrate must never silently succeed against a role that can't actually read its own \ bookkeeping table" ); @@ -1970,7 +1970,7 @@ mod store_conformance; mod conformance { use super::plane_records::lock_plane_purge; use super::store_conformance as conf; - use super::{clamp, connect_store_with_retry, live_url, PostgresStore}; + use super::{clamp, connect_store_with_retry, live_url, TestStore}; /// A per-process, PER-CHECK namespace. Short enough for every id column in the schema. /// @@ -1984,7 +1984,7 @@ mod conformance { /// Delete every row this suite is about to write, so a rerun (or a crashed prior run that left /// rows behind) starts from the same state as a first run. Plane rows are matched on the /// EXACT `{ns}_` prefix every plane fixture id carries (not `LIKE`, whose `_` is a wildcard). - fn reset(store: &PostgresStore, ns: &str, seq: u64) { + fn reset(store: &TestStore, ns: &str, seq: u64) { let mut client = store.lock(); for id in conf::key_ids(ns) { let _ = client.execute("DELETE FROM credentials WHERE key_id=$1", &[&id]); @@ -2010,7 +2010,7 @@ mod conformance { ); } - fn setup(check: &str, seq: u64) -> Option<(PostgresStore, String)> { + fn setup(check: &str, seq: u64) -> Option<(TestStore, String)> { let url = live_url()?; let store = connect_store_with_retry(&url).expect("connect"); let ns = ns(check); @@ -2023,7 +2023,7 @@ mod conformance { let Some((store, ns)) = setup("put", 0) else { return; }; - conf::assert_put_key_does_not_resurrect_a_tombstone(&store, &ns); + conf::assert_put_key_does_not_resurrect_a_tombstone(&*store, &ns); } #[test] @@ -2031,7 +2031,7 @@ mod conformance { let Some((store, ns)) = setup("del", 0) else { return; }; - conf::assert_delete_key_unknown_id_is_an_error(&store, &ns); + conf::assert_delete_key_unknown_id_is_an_error(&*store, &ns); } #[test] @@ -2039,7 +2039,7 @@ mod conformance { let Some((store, ns)) = setup("rev", 0) else { return; }; - conf::assert_revoke_credential_unknown_id_is_an_error(&store, &ns); + conf::assert_revoke_credential_unknown_id_is_an_error(&*store, &ns); } #[test] @@ -2047,7 +2047,7 @@ mod conformance { let Some((store, ns)) = setup("live", 0) else { return; }; - conf::assert_put_credential_requires_a_live_key(&store, &ns); + conf::assert_put_credential_requires_a_live_key(&*store, &ns); } #[test] @@ -2055,7 +2055,7 @@ mod conformance { let Some((store, ns)) = setup("atom", 0) else { return; }; - conf::assert_put_key_with_credential_is_atomic(&store, &ns); + conf::assert_put_key_with_credential_is_atomic(&*store, &ns); } #[test] @@ -2067,7 +2067,7 @@ mod conformance { return; }; let _audit_guard = super::lock_audit_table(); - conf::assert_append_audit_duplicate_seq(&store, seq); + conf::assert_append_audit_duplicate_seq(&*store, seq); } #[test] @@ -2075,7 +2075,7 @@ mod conformance { let Some((store, ns)) = setup("ptask", 0) else { return; }; - conf::assert_plane_task_upsert_get_list(&store, &ns); + conf::assert_plane_task_upsert_get_list(&*store, &ns); } #[test] @@ -2083,7 +2083,7 @@ mod conformance { let Some((store, ns)) = setup("pchain", 0) else { return; }; - conf::assert_plane_event_chain_is_ordered_by_seq(&store, &ns); + conf::assert_plane_event_chain_is_ordered_by_seq(&*store, &ns); } #[test] @@ -2091,7 +2091,7 @@ mod conformance { let Some((store, ns)) = setup("pprin", 0) else { return; }; - conf::assert_plane_call_parents_enumerated(&store, &ns); + conf::assert_plane_call_parents_enumerated(&*store, &ns); } #[test] @@ -2099,7 +2099,7 @@ mod conformance { let Some((store, ns)) = setup("pdem", 0) else { return; }; - conf::assert_plane_demotion_upsert_list_delete(&store, &ns); + conf::assert_plane_demotion_upsert_list_delete(&*store, &ns); } #[test] @@ -2110,7 +2110,7 @@ mod conformance { // This binary's own purge tests sweep the same kinds with a HIGHER cutoff, which would // reach this check's newer-than-cutoff survivor; they hold the same lock. let _guard = lock_plane_purge(); - conf::assert_plane_purge_honours_the_cutoff(&store, &ns); + conf::assert_plane_purge_honours_the_cutoff(&*store, &ns); } #[test] @@ -2119,7 +2119,7 @@ mod conformance { return; }; let _guard = lock_plane_purge(); - conf::assert_plane_purge_task_keeps_active_rows(&store, &ns); + conf::assert_plane_purge_task_keeps_active_rows(&*store, &ns); } #[test] @@ -2127,7 +2127,7 @@ mod conformance { let Some((store, ns)) = setup("ptok", 0) else { return; }; - conf::assert_plane_token_is_single_use(&store, &ns); + conf::assert_plane_token_is_single_use(&*store, &ns); } } @@ -2236,3 +2236,6 @@ mod v160_shapes; // ── THE STORE v3 SLOTS (the door's table beyond the 1.5.5 op set) ────────────────────────────── mod v3_slots; + +// ── TLS through the host (the connector's TLS wrap; ARCHITECT ruling 2026-10-03) ───────────── +mod tls; diff --git a/store-postgres/src/tests/harness.rs b/store-postgres/src/tests/harness.rs new file mode 100644 index 0000000..15b68c6 --- /dev/null +++ b/store-postgres/src/tests/harness.rs @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE STORE UNDER TEST, AS BUSBAR RUNS IT: the store's door loaded through the real loader on a +//! dispatcher, every connection over the host's connection path (the loader's test connection +//! table, `tcp_conns::TcpConns`, plain TCP), opened with `open`'s connect step. [`TestStore`] +//! answers the 1.5.5 op set through the loader's synchronous bridge (`RecordStore`, by deref) and +//! the store v3 slots through `StoreCalls`, in the slots' own result types; [`TestStore::lock`] is +//! an INDEPENDENT `postgres` driver connection for the raw SQL a test sets up or verifies with. + +use std::future::Future; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::task::{Context, Poll, Wake, Waker}; + +use busbar_contract::abi::sdk::store::{ + Cap, CapsRefused, Cell, Grant, OpRefused, ReserveRefused, StoreSlots, +}; +use busbar_contract::abi::store::OpId; +use busbar_contract::kinds::{Head, RecordBytes}; +use busbar_contract::records::{AuditRecord, UsageDelta}; +use busbar_contract::store_calls::{StoreCalls, StoreFailure}; +use busbar_plugin_loader::dispatch::kinds::store::Store; +use busbar_plugin_loader::dispatch::{ + load_linked, Bind, ConnTable, DispatchConfig, Dispatcher, LinkedRow, NoSink, +}; +use busbar_plugin_loader::store_v3::LoadedStore; +use busbar_plugin_loader::tcp_conns::TcpConns; + +struct Unpark(std::thread::Thread); +impl Wake for Unpark { + fn wake(self: Arc) { + self.0.unpark(); + } +} + +/// Run `f` to completion on this thread. +pub(crate) fn block_on(f: F) -> F::Output { + let mut f = std::pin::pin!(f); + let waker = Waker::from(Arc::new(Unpark(std::thread::current()))); + let mut cx = Context::from_waker(&waker); + loop { + if let Poll::Ready(v) = f.as_mut().poll(&mut cx) { + return v; + } + std::thread::park(); + } +} + +/// A fresh `op_id` for every bridge write: a node half no other open (in this run or an earlier +/// one) used, the store dedupes `op_id`s DURABLY. +fn mint() -> OpId { + static NODE: std::sync::OnceLock = std::sync::OnceLock::new(); + static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let node = *NODE.get_or_init(|| { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + nanos ^ (u64::from(std::process::id()) << 40) + }); + OpId::from_parts( + node, + N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1, + ) +} + +/// The store's linked door opened on `settings` over the loader, its needs on a [`TcpConns`]. +pub(crate) fn open_loaded(settings: &str) -> Result { + open_loaded_over(settings, |d| Arc::new(TcpConns::new(d.conn_waker()))) +} + +/// [`open_loaded`], its needs on the table `conns` builds (a [`TcpConns`] that trusts a test CA). +pub(crate) fn open_loaded_over( + settings: &str, + conns: impl FnOnce(&Dispatcher) -> Arc, +) -> Result { + let d = Arc::new(Dispatcher::new(DispatchConfig::default())); + let conns = conns(&d); + let row = LinkedRow::of(crate::door).map_err(|e| e.to_string())?; + let p = load_linked::( + &row, + Bind { + instance: Arc::from("store-postgres-test"), + max_inflight_cap: 64, + sink: Arc::new(NoSink), + dispatcher: d.adopter(), + conns: ConnTable::Host(conns), + }, + ) + .map_err(|e| e.to_string())?; + LoadedStore::open(p, d, settings.as_bytes(), mint) +} + +/// THE STORE UNDER TEST. +pub(crate) struct TestStore { + store: LoadedStore, + url: String, + raw: Mutex>, +} + +impl std::ops::Deref for TestStore { + type Target = LoadedStore; + fn deref(&self) -> &LoadedStore { + &self.store + } +} + +fn op_refused(f: StoreFailure) -> OpRefused { + match f { + StoreFailure::Conflict => OpRefused::Conflict, + StoreFailure::Failed(t) | StoreFailure::Refused(t) | StoreFailure::Fault(t) => { + OpRefused::Failed(t) + } + other => OpRefused::Failed(other.to_string()), + } +} + +fn text(f: StoreFailure) -> String { + match f { + StoreFailure::Failed(t) | StoreFailure::Refused(t) | StoreFailure::Fault(t) => t, + other => other.to_string(), + } +} + +impl TestStore { + /// The store on the connection string `url`. + pub(crate) fn open(url: &str) -> Result { + let settings = serde_json::json!({ "url": url }).to_string(); + Ok(Self { + store: open_loaded(&settings)?, + url: url.to_owned(), + raw: Mutex::new(None), + }) + } + + /// `StoreSlots::open` on `settings` (the settings' judge), with no host. + pub(crate) fn open_slot(settings: &[u8]) -> Result<(), String> { + ::open(settings, None).map(drop) + } + + /// An INDEPENDENT driver connection to the same database, for raw SQL. + pub(crate) fn lock(&self) -> RawGuard<'_> { + let mut g = self.raw.lock().unwrap_or_else(PoisonError::into_inner); + if g.is_none() { + *g = Some(super::connect_client_with_retry(&self.url)); + } + RawGuard(g) + } + + pub(crate) fn add_usage_op( + &self, + op: OpId, + bucket: &str, + window_start: u64, + delta: &UsageDelta, + ) -> Result<(), OpRefused> { + let cells = [(bucket, window_start, delta.clone())]; + block_on(StoreCalls::add_usage_batch(&self.store, op, &cells)).map_err(op_refused) + } + + pub(crate) fn append_audit_op(&self, op: OpId, entry: &AuditRecord) -> Result<(), OpRefused> { + block_on(StoreCalls::append_audit_batch( + &self.store, + op, + std::slice::from_ref(entry), + )) + .map_err(op_refused) + } + + pub(crate) fn reserve<'c>( + &self, + op: OpId, + epoch: u64, + cells: impl Iterator>, + grants: &mut impl Extend, + ) -> Result<(), ReserveRefused> { + let cells: Vec> = cells.collect(); + match block_on(StoreCalls::reserve(&self.store, op, epoch, &cells)) { + Ok(g) => { + grants.extend(g); + Ok(()) + } + Err(StoreFailure::Reserve(r)) => Err(r), + Err(StoreFailure::Conflict) => Err(ReserveRefused::Conflict), + Err(_) => Err(ReserveRefused::Unavailable), + } + } + + pub(crate) fn slice_release( + &self, + op: OpId, + epoch: u64, + items: impl Iterator, + released: &mut impl Extend, + ) -> Result<(), OpRefused> { + let items: Vec<(u64, u64)> = items.collect(); + let back = block_on(StoreCalls::slice_release(&self.store, op, epoch, &items)) + .map_err(op_refused)?; + released.extend(back); + Ok(()) + } + + pub(crate) fn window_caps(&self, op: OpId, caps: &[Cap<'_>]) -> Result<(), CapsRefused> { + block_on(StoreCalls::window_caps(&self.store, op, caps)).map_err(|f| match f { + StoreFailure::CapConflict(index) => CapsRefused::CapConflict { index }, + StoreFailure::Conflict => CapsRefused::Conflict, + other => CapsRefused::Failed(text(other)), + }) + } + + pub(crate) fn append_batch( + &self, + op: OpId, + stream: &str, + records: &[RecordBytes], + ) -> Result { + block_on(StoreCalls::append_batch(&self.store, op, stream, records)).map_err(op_refused) + } + + pub(crate) fn heads(&self) -> Result, String> { + block_on(StoreCalls::heads(&self.store)).map_err(text) + } + + pub(crate) fn session_put( + &self, + session: u64, + node: &str, + principal: &str, + ) -> Result<(), String> { + block_on(StoreCalls::session_put( + &self.store, + session, + node, + principal, + )) + .map_err(text) + } + + pub(crate) fn session_remove(&self, session: u64) -> Result<(), String> { + block_on(StoreCalls::session_remove(&self.store, session)).map_err(text) + } + + pub(crate) fn sessions_for(&self, principal: &str) -> Result, String> { + block_on(StoreCalls::sessions_for(&self.store, principal)).map_err(text) + } + + pub(crate) fn record_put(&self, schema: &str, key: &[u8], value: &[u8]) -> Result<(), String> { + let value = RecordBytes::new(value.to_vec()).map_err(|n| format!("{n} bytes"))?; + block_on(StoreCalls::record_put(&self.store, schema, key, &value)).map_err(text) + } + + pub(crate) fn record_get( + &self, + schema: &str, + key: &[u8], + ) -> Result, String> { + block_on(StoreCalls::record_get(&self.store, schema, key)).map_err(text) + } + + pub(crate) fn record_scan( + &self, + schema: &str, + prefix: &[u8], + limit: u32, + ) -> Result, RecordBytes)>, String> { + block_on(StoreCalls::record_scan(&self.store, schema, prefix, limit)).map_err(text) + } +} + +/// [`TestStore::lock`]'s connection. +pub(crate) struct RawGuard<'a>(MutexGuard<'a, Option>); + +impl std::ops::Deref for RawGuard<'_> { + type Target = postgres::Client; + fn deref(&self) -> &postgres::Client { + self.0.as_ref().expect("connected") + } +} + +impl std::ops::DerefMut for RawGuard<'_> { + fn deref_mut(&mut self) -> &mut postgres::Client { + self.0.as_mut().expect("connected") + } +} diff --git a/store-postgres/src/tests/plane_records.rs b/store-postgres/src/tests/plane_records.rs index 496ba96..47264d6 100644 --- a/store-postgres/src/tests/plane_records.rs +++ b/store-postgres/src/tests/plane_records.rs @@ -118,7 +118,7 @@ fn demotion(server: &str, reason: &str, recorded_at: u64) -> PlaneRecord { } /// Live Postgres is SHARED across tests, so each test owns its own ids and clears them first. -fn reset(store: &PostgresStore, kind: &str, ids: &[&str]) { +fn reset(store: &TestStore, kind: &str, ids: &[&str]) { let mut c = store.lock(); for id in ids { c.execute( @@ -134,7 +134,7 @@ fn reset(store: &PostgresStore, kind: &str, ids: &[&str]) { } } -fn reset_tokens(store: &PostgresStore, kind: &str, tokens: &[&str]) { +fn reset_tokens(store: &TestStore, kind: &str, tokens: &[&str]) { let mut c = store.lock(); for t in tokens { let _ = c.execute( @@ -146,7 +146,7 @@ fn reset_tokens(store: &PostgresStore, kind: &str, tokens: &[&str]) { /// Own the whole low band of a kind: a previous run's leftovers would otherwise be counted by the /// exact-count assertions the purge tests make. -fn clear_purge_band(store: &PostgresStore) { +fn clear_purge_band(store: &TestStore) { let top = clamp(PURGE_BAND_TOP); let mut c = store.lock(); c.execute( @@ -167,7 +167,7 @@ fn clear_purge_band(store: &PostgresStore) { .expect("clear the purge band's chains"); } -fn chain(store: &PostgresStore, kind: &str, parent: &str) -> Vec { +fn chain(store: &TestStore, kind: &str, parent: &str) -> Vec { store .list_plane_records(kind, &PlaneSelector::Parent(parent.to_string().into())) .unwrap() @@ -176,14 +176,14 @@ fn chain(store: &PostgresStore, kind: &str, parent: &str) -> Vec Option { +fn task_state(store: &TestStore, id: &str) -> Option { store .get_plane_record("task", id) .unwrap() .map(|b| decode(&b)["state"].as_str().unwrap().to_string()) } -fn listed_task_ids(store: &PostgresStore) -> Vec { +fn listed_task_ids(store: &TestStore) -> Vec { store .list_plane_records("task", &PlaneSelector::All) .unwrap() @@ -864,7 +864,7 @@ fn trust_ns(tag: &str) -> String { const TRUST_NOW: u64 = 2_000_000_000; -fn demotions(store: &PostgresStore) -> Vec { +fn demotions(store: &TestStore) -> Vec { store .list_plane_records("demotion", &PlaneSelector::All) .unwrap() diff --git a/store-postgres/src/tests/tls.rs b/store-postgres/src/tests/tls.rs new file mode 100644 index 0000000..8845439 --- /dev/null +++ b/store-postgres/src/tests/tls.rs @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! TLS THROUGH THE HOST, and the KEPT connection, live (ARCHITECT rulings 2026-10-03: TLS, +//! STORE-KEEP). +//! +//! * `sslmode=verify-full` asks the server for TLS (`SSLRequest`), and on its `S` the store secures +//! the stream through the host (`Wire::upgrade_secure`); here the host is the loader's test table +//! trusting a CA minted for the run, and the server is an in-test TLS proxy in front of the live +//! server (it answers the `SSLRequest`, accepts TLS, and forwards the plaintext). A write and a +//! read round-trip; without the CA the load fails in the driver's words. +//! * The instance keeps ONE connection across its ops (1.5.5's one mutex-guarded connection): every +//! op of a store runs on the same server backend. + +use std::io::{ErrorKind, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::time::Duration; + +use busbar_contract::records::RecordStore; +use busbar_plugin_loader::tcp_conns::{SecureDial, SecuredSock, TcpConns}; + +use super::harness::open_loaded_over; +use super::live_url; +use crate::pgwire::Config; + +/// The host's TLS for the test table ([`TcpConns::with_tls`]): rustls trusting `ca_der` alone, +/// verifying the certificate and the name; the handshake runs to completion on the blocking socket +/// the table hands it. +struct Trusting(Arc); + +fn trusting(ca_der: &[u8]) -> Arc { + let mut roots = rustls::RootCertStore::empty(); + roots + .add(rustls_pki_types::CertificateDer::from(ca_der.to_vec())) + .expect("the test CA is a root certificate"); + let config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .expect("the default protocol versions") + .with_root_certificates(roots) + .with_no_client_auth(); + Arc::new(Trusting(Arc::new(config))) +} + +impl SecureDial for Trusting { + fn secure( + &self, + server: &str, + _verify_off: bool, + mut tcp: TcpStream, + ) -> std::io::Result> { + let name = rustls_pki_types::ServerName::try_from(server.to_owned()) + .map_err(|e| std::io::Error::new(ErrorKind::InvalidInput, e))?; + let mut tls = rustls::ClientConnection::new(Arc::clone(&self.0), name) + .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e))?; + while tls.is_handshaking() { + tls.complete_io(&mut tcp)?; + } + Ok(Box::new(Secured(rustls::StreamOwned::new(tls, tcp)))) + } +} + +/// A stream [`Trusting`] secured. +struct Secured(rustls::StreamOwned); + +impl Read for Secured { + fn read(&mut self, b: &mut [u8]) -> std::io::Result { + self.0.read(b) + } +} + +impl Write for Secured { + fn write(&mut self, b: &[u8]) -> std::io::Result { + self.0.write(b) + } + fn flush(&mut self) -> std::io::Result<()> { + self.0.flush() + } +} + +impl SecuredSock for Secured { + fn tcp(&self) -> &TcpStream { + self.0.get_ref() + } + fn close(&mut self) { + self.0.conn.send_close_notify(); + let _ = self.0.flush(); + let _ = self.0.sock.shutdown(std::net::Shutdown::Both); + } +} + +/// A CA and a `localhost` server config it signed: (ca_der, server config). +fn minted() -> (Vec, Arc) { + let ca_key = rcgen::KeyPair::generate().expect("ca key"); + let mut ca_params = rcgen::CertificateParams::new(Vec::::new()).expect("ca params"); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let ca = ca_params.self_signed(&ca_key).expect("ca"); + let issuer = rcgen::Issuer::from_params(&ca_params, ca_key); + let key = rcgen::KeyPair::generate().expect("key"); + let leaf = rcgen::CertificateParams::new(vec!["localhost".to_string()]) + .expect("params") + .signed_by(&key, &issuer) + .expect("leaf"); + let server = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .expect("versions") + .with_no_client_auth() + .with_single_cert( + vec![leaf.der().clone()], + rustls_pki_types::PrivateKeyDer::Pkcs8(key.serialize_der().into()), + ) + .expect("server config"); + (ca.der().to_vec(), Arc::new(server)) +} + +/// Pump bytes both ways between the TLS side and the server until either closes. +fn pump(mut tls: rustls::StreamOwned, mut up: TcpStream) { + if tls.sock.set_nonblocking(true).is_err() || up.set_nonblocking(true).is_err() { + return; + } + let mut buf = vec![0_u8; 16 * 1024]; + loop { + let mut moved = false; + match tls.read(&mut buf) { + Ok(0) => return, + Ok(n) => { + moved = true; + if write_all(&mut up, &buf[..n]).is_err() { + return; + } + } + Err(e) if e.kind() == ErrorKind::WouldBlock => {} + Err(_) => return, + } + match up.read(&mut buf) { + Ok(0) => return, + Ok(n) => { + moved = true; + if write_all(&mut tls, &buf[..n]).is_err() { + return; + } + } + Err(e) if e.kind() == ErrorKind::WouldBlock => {} + Err(_) => return, + } + if !moved { + std::thread::sleep(Duration::from_millis(1)); + } + } +} + +fn write_all(w: &mut impl Write, mut b: &[u8]) -> std::io::Result<()> { + while !b.is_empty() { + match w.write(b) { + Ok(0) => return Err(ErrorKind::WriteZero.into()), + Ok(n) => b = &b[n..], + Err(e) if e.kind() == ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(1)); + } + Err(e) => return Err(e), + } + } + loop { + match w.flush() { + Ok(()) => return Ok(()), + Err(e) if e.kind() == ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(1)); + } + Err(e) => return Err(e), + } + } +} + +/// A TLS proxy in front of `upstream` (`host:port`), speaking Postgres's `SSLRequest` opening: its +/// port. +fn tls_proxy(upstream: String, server: Arc) -> u16 { + let l = TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = l.local_addr().expect("addr").port(); + std::thread::spawn(move || { + for s in l.incoming() { + let Ok(mut s) = s else { return }; + let (server, upstream) = (server.clone(), upstream.clone()); + std::thread::spawn(move || { + // SSLRequest: length 8, code 80877103. + let mut req = [0_u8; 8]; + if s.read_exact(&mut req).is_err() || req != [0, 0, 0, 8, 4, 210, 22, 47] { + return; + } + if s.write_all(b"S").is_err() { + return; + } + let mut conn = rustls::ServerConnection::new(server).expect("tls conn"); + while conn.is_handshaking() { + if conn.complete_io(&mut s).is_err() { + return; + } + } + let Ok(up) = TcpStream::connect(&upstream) else { + return; + }; + pump(rustls::StreamOwned::new(conn, s), up); + }); + } + }); + port +} + +/// The live server's settings with the proxy in front: `postgres://...@localhost:/...?sslmode=verify-full`. +fn through_proxy(url: &str, port: u16) -> (String, String) { + let c = Config::parse(url).expect("the live url parses"); + let upstream = format!("{}:{}", c.host, c.port); + let tls = format!( + "postgres://{}:{}@localhost:{port}/{}?sslmode=verify-full", + c.user, + c.password.clone().unwrap_or_default(), + c.dbname.clone().unwrap_or_default() + ); + (upstream, tls) +} + +/// TLS: `sslmode=verify-full` through the host's TLS (the test table trusting the run's CA) carries +/// the connect step, a write and a read. +#[test] +fn verify_full_secures_the_connection_through_the_host() { + let Some(url) = live_url() else { return }; + let (ca_der, server) = minted(); + let (upstream, _) = through_proxy(&url, 0); + let port = tls_proxy(upstream, server); + let (_, tls_url) = through_proxy(&url, port); + let settings = serde_json::json!({ "url": tls_url }).to_string(); + let store = open_loaded_over(&settings, |d| { + Arc::new(TcpConns::with_tls(d.conn_waker(), trusting(&ca_der))) + }) + .expect("the store opens over TLS"); + let sub = format!("tls-{}", std::process::id()); + RecordStore::add_denylist(&store, &sub, "over tls").expect("a write over TLS"); + assert!( + RecordStore::list_denylist(&store) + .expect("a read over TLS") + .contains(&sub), + "the write is read back over TLS" + ); +} + +/// TLS: a host that does not trust the server's certificate refuses the handshake, and the load +/// fails in the driver's words. +#[test] +fn an_untrusted_server_certificate_fails_the_load_in_the_drivers_words() { + let Some(url) = live_url() else { return }; + let (_, server) = minted(); + let (upstream, _) = through_proxy(&url, 0); + let port = tls_proxy(upstream, server); + let (_, tls_url) = through_proxy(&url, port); + let settings = serde_json::json!({ "url": tls_url }).to_string(); + // A table with no trust: every upgrade refused. + let e = open_loaded_over(&settings, |d| Arc::new(TcpConns::new(d.conn_waker()))) + .expect_err("no TLS without trust"); + assert!( + e.contains("open failed: error performing TLS handshake"), + "the driver's words: {e}" + ); +} + +/// STORE-KEEP: every op of one store runs on the same server backend (one kept connection), and +/// the store holds one connection while idle. +#[test] +fn the_store_keeps_one_connection_across_its_ops() { + let Some(url) = live_url() else { return }; + let app = format!("bb_keep_{}", std::process::id()); + let sep = if url.contains('?') { '&' } else { '?' }; + let settings = + serde_json::json!({ "url": format!("{url}{sep}application_name={app}") }).to_string(); + let store = open_loaded_over(&settings, |d| Arc::new(TcpConns::new(d.conn_waker()))) + .expect("the store opens"); + let mut raw = super::connect_client_with_retry(&url); + let backends = |raw: &mut postgres::Client| -> Vec { + raw.query( + "SELECT pid FROM pg_stat_activity WHERE application_name = $1", + &[&app], + ) + .expect("pg_stat_activity") + .iter() + .map(|r| r.get(0)) + .collect() + }; + let after_open = backends(&mut raw); + assert_eq!(after_open.len(), 1, "the connect step's connection is kept"); + for i in 0..5 { + RecordStore::add_denylist(&store, &format!("{app}-{i}"), "keep").expect("a write"); + RecordStore::list_denylist(&store).expect("a read"); + } + assert_eq!( + backends(&mut raw), + after_open, + "every op ran on the one kept backend" + ); + // Dropping the store closes its instance, and its kept connection with it. + drop(store); + let gone = (0..50).any(|_| { + std::thread::sleep(Duration::from_millis(20)); + backends(&mut raw).is_empty() + }); + assert!(gone, "closing the instance closes its kept connection"); +} diff --git a/store-postgres/src/tests/v160_shapes.rs b/store-postgres/src/tests/v160_shapes.rs index d93dc32..1a3fb06 100644 --- a/store-postgres/src/tests/v160_shapes.rs +++ b/store-postgres/src/tests/v160_shapes.rs @@ -608,7 +608,10 @@ fn metering_splits_on_priced_from_ms_and_carries_open_classes() { let Some(url) = live_url() else { return }; let store = connect_store_with_retry(&url).expect("connect"); let key_id = format!("vk_meter_units_{}", unique_suffix()); - let bucket = 20_270_601u64; + // Not 20_270_601: `purge_windows_and_metering_delete_only_what_is_older_than_the_boundary` + // writes and purges that whole bucket in parallel, and a whole-bucket purge here would take its + // row (a test-isolation race the slower per-op connections exposed). + let bucket = 20_270_611u64; let delta = |priced_from_ms: u64, input: u64, classes: &[(&str, u64)]| MeteringDelta { key_id: key_id.clone(), bucket, diff --git a/store-postgres/src/tests/v3_slots.rs b/store-postgres/src/tests/v3_slots.rs index f98f0d1..53ca52e 100644 --- a/store-postgres/src/tests/v3_slots.rs +++ b/store-postgres/src/tests/v3_slots.rs @@ -8,7 +8,7 @@ use super::*; use busbar_contract::abi::sdk::store::{ - Cap, CapsRefused, Cell, CellKey, Dimension, Grant, OpRefused, ReserveRefused, StoreSlots, + Cap, CapsRefused, Cell, CellKey, Dimension, Grant, OpRefused, ReserveRefused, }; use busbar_contract::abi::store::OpId; use busbar_contract::kinds::RecordBytes; @@ -33,7 +33,7 @@ fn op() -> OpId { OpId::from_parts(node, N.fetch_add(1, Ordering::Relaxed)) } -fn store(url: &str) -> PostgresStore { +fn store(url: &str) -> TestStore { connect_store_with_retry(url).expect("connect") } @@ -46,13 +46,13 @@ fn requests(bucket: &str) -> CellKey<'_> { } } -fn reserve(s: &PostgresStore, op: OpId, cells: &[Cell<'_>]) -> Result, ReserveRefused> { +fn reserve(s: &TestStore, op: OpId, cells: &[Cell<'_>]) -> Result, ReserveRefused> { let mut grants = Vec::new(); s.reserve(op, 0, cells.iter().copied(), &mut grants) .map(|()| grants) } -fn release(s: &PostgresStore, op: OpId, items: &[(u64, u64)]) -> Result, OpRefused> { +fn release(s: &TestStore, op: OpId, items: &[(u64, u64)]) -> Result, OpRefused> { let mut back = Vec::new(); s.slice_release(op, 0, items.iter().copied(), &mut back) .map(|()| back) @@ -70,12 +70,12 @@ fn open_refuses_settings_without_a_url() { &br#"{"url": " "}"#[..], ] { assert_eq!( - PostgresStore::open(settings).err().as_deref(), + TestStore::open_slot(settings).err().as_deref(), Some(want), "{settings:?}" ); } - let e = PostgresStore::open(b"{ not json").err().unwrap(); + let e = TestStore::open_slot(b"{ not json").err().unwrap(); assert!(e.starts_with("invalid postgres plugin config:"), "{e}"); } diff --git a/store-postgres/src/v3.rs b/store-postgres/src/v3.rs index 2a87d2d..3a16886 100644 --- a/store-postgres/src/v3.rs +++ b/store-postgres/src/v3.rs @@ -21,25 +21,126 @@ //! never deadlock), tests each cell with 1.5.5's per-dimension rule (`abi::store::ReserveIn`) and //! applies all or nothing. A grant is valid until `u64::MAX`: nothing in the table expires a slice. //! +//! CONNECTIONS: every slot runs on the instance's ONE kept connection over the host's connector +//! (1.5.5's one mutex-guarded connection; STORE-KEEP), as straight-line async code by the store +//! SDK's `wire::drive_kept` (busbar THE DESIGN: every call Ready or Pending(wake), no +//! socket of the plugin's own; ARCHITECT rulings 2026-10-03 on Q-L14-1 and Q-L16-2). The door +//! declares one outbound `tcp` need (`NEEDS`, `operator-infrastructure`); `open` parses the +//! settings, and its connect step reaches the server and ensures the schema, so an unreachable or +//! refusing server still fails the load at open, in the driver's words. +//! //! Every `u64` the ABI hands this module is stored BIT-FOR-BIT in a BIGINT (`as i64` / `as u64`) //! and every comparison runs here, never in SQL, so no value is clamped or wrapped on the way back. use std::sync::atomic::Ordering; +use busbar_contract::abi::host::conn::connector::{ + Need, DIRECTION_OUTBOUND, EGRESS_OPERATOR_INFRASTRUCTURE, KEEP_NAMED, +}; +use busbar_contract::abi::mechanism::call::{AbiStr, Blob, BLOB_OCTETS}; +use busbar_contract::abi::sdk::conn::Host; +use busbar_contract::abi::sdk::door::abi_str; +use busbar_contract::abi::sdk::store::wire::{drive_kept, Body}; use busbar_contract::abi::sdk::store::{ - Cap, CapsRefused, Cell, CellKey, Dimension, Grant, OpRefused, OpResult, ReserveRefused, - StoreSlots, Tail, + Cap, CapsRefused, Cell, CellKey, Dimension, Grant, Op, OpRefused, OpResult, ReserveRefused, + Scanned, Step, StoreSlots, Tail, }; use busbar_contract::abi::store::{OpId, OP_ID_RETENTION_SECS}; use busbar_contract::kinds::{Head, RecordBytes}; use busbar_contract::records::{ - AuditRecord, MeteringDelta, PlaneRecordRef, RecordStoreError, UsageDelta, + AuditRecord, CredentialMeta, CredentialSecret, MeteringDelta, MeteringRow, PlaneRecordRef, + PlaneSelector, RecordStoreError, RecordStoreResult, UsageDelta, UsageLedger, VirtualKey, }; -use postgres::Transaction; -use crate::{now_secs, render_pg_error, PostgresStore, NAME}; +use crate::pgwire::Transaction; +use crate::{now_secs, render_pg_error, PostgresStore, Session, NAME}; + +const NO_TEXT: AbiStr = AbiStr { + ptr: std::ptr::null(), + len: 0, +}; -busbar_contract::store_door!(PostgresStore, NAME, env!("CARGO_PKG_VERSION"), 64); +/// THE STORE'S ONE NEED: the server, dialled over `tcp` at the DSN's `host:port` (the store names +/// the target per connection), in the `operator-infrastructure` egress class (private, loopback and +/// plaintext allowed; pinned; cloud metadata hosts refused). +pub const NEEDS: &[Need] = &[Need { + direction: DIRECTION_OUTBOUND, + egress_class: EGRESS_OPERATOR_INFRASTRUCTURE, + transport: abi_str("tcp"), + auth: NO_TEXT, + target_from: NO_TEXT, + trust_from: NO_TEXT, + details: Blob { + ptr: std::ptr::null(), + len: 0, + fmt: BLOB_OCTETS, + flags: 0, + }, + keep_response_headers: std::ptr::null(), + keep_response_headers_len: 0, + timeout_ms: 0, + keep_mode: KEEP_NAMED, + _reserved: 0, + deny_response_headers: std::ptr::null(), + deny_response_headers_len: 0, +}]; + +busbar_contract::store_door!( + PostgresStore, + NAME, + env!("CARGO_PKG_VERSION"), + 64, + needs: NEEDS +); + +/// RUN ONE OP on the instance's kept connection (a fresh one when none is kept): open the session +/// (a failure is `$fail` of the driver's words), run `$body` on it `$s`, and keep the connection if +/// the session is idle. Every argument the body names is owned (the body runs +/// across the op's entries). +macro_rules! on_conn { + ($self:ident, $cx:ident, $fail:expr, |$s:ident| $body:expr) => {{ + let shared = $self.shared(); + let pool = shared.pool.clone(); + drive_kept($cx, &pool, move |wire| -> Body<_> { + Box::pin(async move { + let mut $s = match Session::open(wire, shared).await { + Ok(s) => s, + Err(e) => return Err(($fail)(e)), + }; + let answer = $body; + $s.close().await; + answer + }) + }) + }}; +} + +/// One `op_id`-carrying write in ONE transaction with its dedupe row: a replay answers the +/// original, a conflict applies nothing, and a new op runs `$apply` (its answer numbers, or `$E`) +/// and is remembered only if it applied (an error rolls the row back with the effect). +macro_rules! deduped { + ($s:expr, $op:expr, $body:expr, $conflict:expr, $backend:expr, $E:ty, |$tx:ident| $apply:block) => {{ + let op: OpId = $op; + let (mut tx, seen) = $s.dedupe_begin(op, $body).await.map_err($backend)?; + match seen { + Seen::Replay(answer) => answer, + Seen::Conflict => return Err($conflict), + Seen::New => { + let answer = { + let $tx = &mut tx; + typed::<$E, _>(async { $apply }).await? + }; + dedupe_finish(tx, op, &answer).await.map_err($backend)?; + answer + } + } + }}; +} + +/// Pin an apply block's error type. +fn typed, E>>>(f: F) -> F { + f +} /// How often (seconds) one instance sweeps `store_ops` past its retention. const SWEEP_EVERY_SECS: u64 = 60; @@ -54,7 +155,7 @@ fn unbits(v: i64) -> u64 { v as u64 } -fn pg(e: postgres::Error) -> String { +fn pg(e: crate::pgwire::Error) -> String { render_pg_error(&e) } @@ -114,7 +215,7 @@ enum Seen { } /// Claim `op` for `body` inside `tx`: insert its row, or read the row a committed call left. -fn claim(tx: &mut Transaction<'_>, op: OpId, body: &str) -> Result { +async fn claim(tx: &mut Transaction<'_>, op: OpId, body: &str) -> Result { let id: &[u8] = &op.0; let inserted = tx .execute( @@ -122,6 +223,7 @@ fn claim(tx: &mut Transaction<'_>, op: OpId, body: &str) -> Result ON CONFLICT (op_id) DO NOTHING", &[&id, &body, &bits(now_secs())], ) + .await .map_err(pg)?; if inserted == 1 { return Ok(Seen::New); @@ -131,6 +233,7 @@ fn claim(tx: &mut Transaction<'_>, op: OpId, body: &str) -> Result "SELECT body, answer FROM store_ops WHERE op_id = $1", &[&id], ) + .await .map_err(pg)?; Ok(match row { Some(r) if r.get::<_, &str>(0) == body => match decode(r.get(1)) { @@ -144,9 +247,21 @@ fn claim(tx: &mut Transaction<'_>, op: OpId, body: &str) -> Result }) } -impl PostgresStore { +/// Record a new op's answer on its dedupe row and commit the op's transaction. +async fn dedupe_finish(mut tx: Transaction<'_>, op: OpId, answer: &[u64]) -> Result<(), String> { + let id: &[u8] = &op.0; + tx.execute( + "UPDATE store_ops SET answer = $2 WHERE op_id = $1", + &[&id, &encode(answer)], + ) + .await + .map_err(pg)?; + tx.commit().await.map_err(pg) +} + +impl Session { /// Forget every `op_id` past its retention, at most once per [`SWEEP_EVERY_SECS`]. - fn sweep_ops(&self) { + async fn sweep_ops(&mut self) { let now = now_secs(); let last = self.ops_swept_at.load(Ordering::Relaxed); if now.saturating_sub(last) < SWEEP_EVERY_SECS @@ -160,178 +275,170 @@ impl PostgresStore { let before = bits(now.saturating_sub(OP_ID_RETENTION_SECS)); // Best effort: a failed sweep leaves rows that the next sweep takes. let _ = self - .lock() - .execute("DELETE FROM store_ops WHERE recorded_at < $1", &[&before]); + .lock_client() + .execute("DELETE FROM store_ops WHERE recorded_at < $1", &[&before]) + .await; } - /// Run one `op_id`-carrying write in ONE transaction with its dedupe row: a replay answers the - /// original, a conflict applies nothing, and a new op runs `apply` and is remembered only if it - /// applied (an error rolls the row back with the effect). - fn deduped( - &self, + /// Begin an `op_id`-carrying write: sweep, open its transaction, claim the op. + async fn dedupe_begin( + &mut self, op: OpId, body: &str, - conflict: E, - backend: fn(String) -> E, - apply: impl FnOnce(&mut Transaction<'_>) -> Result, E>, - ) -> Result, E> { - self.sweep_ops(); - let mut client = self.lock(); - let mut tx = client.transaction().map_err(|e| backend(pg(e)))?; - match claim(&mut tx, op, body).map_err(backend)? { - Seen::Replay(answer) => Ok(answer), - Seen::Conflict => Err(conflict), - Seen::New => { - let answer = apply(&mut tx)?; - let id: &[u8] = &op.0; - tx.execute( - "UPDATE store_ops SET answer = $2 WHERE op_id = $1", - &[&id, &encode(&answer)], - ) - .map_err(|e| backend(pg(e)))?; - tx.commit().map_err(|e| backend(pg(e)))?; - Ok(answer) - } - } + ) -> Result<(Transaction<'_>, Seen), String> { + self.sweep_ops().await; + let mut tx = self.lock_client().transaction().await.map_err(pg)?; + let seen = claim(&mut tx, op, body).await?; + Ok((tx, seen)) } - fn op( - &self, - op: OpId, - body: &str, - apply: impl FnOnce(&mut Transaction<'_>) -> OpResult<()>, - ) -> OpResult<()> { - self.deduped(op, body, OpRefused::Conflict, OpRefused::Failed, |tx| { - apply(tx).map(|()| Vec::new()) - }) - .map(drop) - } -} - -/// An audit append inside an op's transaction: a vanished conflicting row is a failure here (the -/// transaction cannot be retried from inside), never a success. -fn audit_in(tx: &mut Transaction<'_>, entry: &AuditRecord) -> OpResult<()> { - match PostgresStore::append_audit_in(tx, entry).map_err(failed)? { - true => Ok(()), - false => Err(OpRefused::Failed(format!( - "append_audit: seq {} was freed between the insert and the read-back; the record was \ - NOT stored", - entry.seq - ))), - } -} - -impl StoreSlots for PostgresStore { - const TAIL: Tail = Tail { - ephemeral: false, - durable_plane: true, - fork_refusal: true, - }; - - /// Open from the store section's settings: - /// - /// ```json - /// { "url": "postgres://user:pass@host:5432/busbar" } - /// ``` - fn open(settings: &[u8]) -> Result { - let v: serde_json::Value = if settings.trim_ascii().is_empty() { - serde_json::Value::Object(Default::default()) - } else { - serde_json::from_slice(settings) - .map_err(|e| format!("invalid postgres plugin config: {e}"))? - }; - let url = v - .get("url") - .and_then(|x| x.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - "postgres plugin config requires a \"url\" (a libpq connection string)".to_string() - })?; - PostgresStore::connect(url).map_err(|e| e.0) - } - - fn add_usage_op( - &self, + async fn v3_add_usage( + &mut self, op: OpId, bucket: &str, window_start: u64, delta: &UsageDelta, ) -> OpResult<()> { let body = format!("add_usage:{bucket:?}:{window_start}:{delta:?}"); - self.op(op, &body, |tx| { - Self::add_usage_in(tx, bucket, window_start, delta).map_err(failed) - }) + deduped!( + self, + op, + &body, + OpRefused::Conflict, + OpRefused::Failed, + OpRefused, + |tx| { + Session::add_usage_in(tx, bucket, window_start, delta) + .await + .map_err(failed)?; + Ok(Vec::new()) + } + ); + Ok(()) } - fn add_metering_op(&self, op: OpId, delta: &MeteringDelta) -> OpResult<()> { + async fn v3_add_metering(&mut self, op: OpId, delta: &MeteringDelta) -> OpResult<()> { let body = format!("add_metering:{delta:?}"); - self.op(op, &body, |tx| { - Self::add_metering_in(tx, delta).map_err(failed) - }) + deduped!( + self, + op, + &body, + OpRefused::Conflict, + OpRefused::Failed, + OpRefused, + |tx| { + Session::add_metering_in(tx, delta).await.map_err(failed)?; + Ok(Vec::new()) + } + ); + Ok(()) } - fn append_audit_op(&self, op: OpId, entry: &AuditRecord) -> OpResult<()> { + async fn v3_append_audit(&mut self, op: OpId, entry: &AuditRecord) -> OpResult<()> { let body = format!("append_audit:{entry:?}"); - self.op(op, &body, |tx| audit_in(tx, entry)) + deduped!( + self, + op, + &body, + OpRefused::Conflict, + OpRefused::Failed, + OpRefused, + |tx| { + audit_in(tx, entry).await?; + Ok(Vec::new()) + } + ); + Ok(()) } - fn append_plane_record_op(&self, op: OpId, record: PlaneRecordRef<'_>) -> OpResult<()> { - let record = record.to_record(); + async fn v3_append_plane_record( + &mut self, + op: OpId, + record: &busbar_contract::records::PlaneRecord, + ) -> OpResult<()> { let body = format!("append_plane_record:{record:?}"); - self.op(op, &body, |tx| { - match Self::append_plane_record_in(tx, &record).map_err(failed)? { - true => Ok(()), - false => Err(OpRefused::Failed(format!( + deduped!( + self, + op, + &body, + OpRefused::Conflict, + OpRefused::Failed, + OpRefused, + |tx| { + match Session::append_plane_record_in(tx, record) + .await + .map_err(failed)? + { + true => Ok(Vec::new()), + false => Err(OpRefused::Failed(format!( "append_plane_record: kind '{}' seq {} was freed between the insert and the \ read-back; the record was NOT stored", record.kind, record.seq ))), + } } - }) + ); + Ok(()) } - fn append_batch(&self, op: OpId, stream: &str, records: &[RecordBytes]) -> OpResult { + async fn v3_append_batch( + &mut self, + op: OpId, + stream: &str, + records: &[RecordBytes], + ) -> OpResult { let body = format!("append_batch:{stream:?}:{records:?}"); - let answer = self.deduped(op, &body, OpRefused::Conflict, OpRefused::Failed, |tx| { - // One appender per stream at a time, fleet-wide: the head is read and extended under - // the stream's transaction-scoped advisory lock. - tx.execute( - "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", - &[&stream], - ) - .map_err(|e| OpRefused::Failed(pg(e)))?; - let head: i64 = tx - .query_one( - "SELECT COALESCE(MAX(seq), 0) FROM store_journal WHERE stream = $1", - &[&stream], - ) - .map_err(|e| OpRefused::Failed(pg(e)))? - .get(0); - let mut seq = head; - for r in records { - seq += 1; + let answer = deduped!( + self, + op, + &body, + OpRefused::Conflict, + OpRefused::Failed, + OpRefused, + |tx| { + // One appender per stream at a time, fleet-wide: the head is read and extended under + // the stream's transaction-scoped advisory lock. tx.execute( - "INSERT INTO store_journal (stream, seq, record) VALUES ($1, $2, $3)", - &[&stream, &seq, &r.as_slice()], + "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", + &[&stream], ) + .await .map_err(|e| OpRefused::Failed(pg(e)))?; + let head: i64 = tx + .query_one( + "SELECT COALESCE(MAX(seq), 0) FROM store_journal WHERE stream = $1", + &[&stream], + ) + .await + .map_err(|e| OpRefused::Failed(pg(e)))? + .get(0); + let mut seq = head; + for r in records { + seq += 1; + tx.execute( + "INSERT INTO store_journal (stream, seq, record) VALUES ($1, $2, $3)", + &[&stream, &seq, &r.as_slice()], + ) + .await + .map_err(|e| OpRefused::Failed(pg(e)))?; + } + Ok(vec![unbits(seq), 0]) } - Ok(vec![unbits(seq), 0]) - })?; + ); match answer[..] { [seq, epoch] => Ok(Head { seq, epoch }), _ => Err(OpRefused::Conflict), } } - fn heads(&self) -> Result, String> { + async fn v3_heads(&mut self) -> Result, String> { let rows = self - .lock() + .lock_client() .query( "SELECT stream, MAX(seq) FROM store_journal GROUP BY stream ORDER BY stream", &[], ) + .await .map_err(pg)?; Ok(rows .iter() @@ -347,35 +454,43 @@ impl StoreSlots for PostgresStore { .collect()) } - fn session_put(&self, session: u64, node: &str, principal: &str) -> Result<(), String> { - self.lock() + async fn v3_session_put( + &mut self, + session: u64, + node: &str, + principal: &str, + ) -> Result<(), String> { + self.lock_client() .execute( "INSERT INTO store_sessions (session, node, principal) VALUES ($1, $2, $3) ON CONFLICT (session) DO UPDATE SET node = EXCLUDED.node, principal = EXCLUDED.principal", &[&bits(session), &node, &principal], ) + .await .map(drop) .map_err(pg) } - fn session_remove(&self, session: u64) -> Result<(), String> { - self.lock() + async fn v3_session_remove(&mut self, session: u64) -> Result<(), String> { + self.lock_client() .execute( "DELETE FROM store_sessions WHERE session = $1", &[&bits(session)], ) + .await .map(drop) .map_err(pg) } - fn sessions_for(&self, principal: &str) -> Result, String> { + async fn v3_sessions_for(&mut self, principal: &str) -> Result, String> { let rows = self - .lock() + .lock_client() .query( "SELECT session, node FROM store_sessions WHERE principal = $1", &[&principal], ) + .await .map_err(pg)?; let mut sessions: Vec<(u64, String)> = rows.iter().map(|r| (unbits(r.get(0)), r.get(1))).collect(); @@ -383,40 +498,51 @@ impl StoreSlots for PostgresStore { Ok(sessions) } - fn record_put(&self, schema: &str, key: &[u8], value: &[u8]) -> Result<(), String> { - self.lock() + async fn v3_record_put( + &mut self, + schema: &str, + key: &[u8], + value: &[u8], + ) -> Result<(), String> { + self.lock_client() .execute( "INSERT INTO store_records (schema_id, key, value) VALUES ($1, $2, $3) ON CONFLICT (schema_id, key) DO UPDATE SET value = EXCLUDED.value", &[&schema, &key, &value], ) + .await .map(drop) .map_err(pg) } - fn record_get(&self, schema: &str, key: &[u8]) -> Result, String> { + async fn v3_record_get( + &mut self, + schema: &str, + key: &[u8], + ) -> Result, String> { let row = self - .lock() + .lock_client() .query_opt( "SELECT value FROM store_records WHERE schema_id = $1 AND key = $2", &[&schema, &key], ) + .await .map_err(pg)?; row.map(|r| record(r.get(0))).transpose() } - fn record_scan( - &self, + async fn v3_record_scan( + &mut self, schema: &str, prefix: &[u8], limit: u32, - ) -> Result, RecordBytes)>, String> { + ) -> Result { // `limit` 0 is nothing, never everything. if limit == 0 { return Ok(Vec::new()); } let rows = self - .lock() + .lock_client() .query( "SELECT key, value FROM store_records WHERE schema_id = $1 @@ -424,210 +550,293 @@ impl StoreSlots for PostgresStore { ORDER BY key LIMIT $3", &[&schema, &prefix, &i64::from(limit)], ) + .await .map_err(pg)?; rows.iter() .map(|r| Ok((r.get(0), record(r.get(1))?))) .collect() } - fn reserve<'c>( - &self, + /// `reserve` over the cells' slots (each cell's slot key, its dimension and its amount). + async fn v3_reserve( + &mut self, op: OpId, - epoch: u64, - cells: impl Iterator> + Clone, - grants: &mut impl Extend, - ) -> Result<(), ReserveRefused> { - let cells: Vec> = cells.collect(); - let body = format!("reserve:{epoch}:{cells:?}"); + body: &str, + cells: &[(String, Dimension<'static>, u64)], + ) -> Result, ReserveRefused> { let unavailable = |_: String| ReserveRefused::Unavailable; - let answer = self.deduped(op, &body, ReserveRefused::Conflict, unavailable, |tx| { - let slots: Vec = cells.iter().map(|c| slot(&c.key)).collect(); - let rows = tx - .query( - "SELECT slot, cap, used FROM money_slots WHERE slot = ANY($1) + let answer = deduped!( + self, + op, + body, + ReserveRefused::Conflict, + unavailable, + ReserveRefused, + |tx| { + let slots: Vec = cells.iter().map(|c| c.0.clone()).collect(); + let rows = tx + .query( + "SELECT slot, cap, used FROM money_slots WHERE slot = ANY($1) ORDER BY slot FOR UPDATE", - &[&slots], - ) - .map_err(|_| ReserveRefused::Unavailable)?; - let mut held: std::collections::HashMap = rows - .iter() - .map(|r| (r.get(0), (unbits(r.get(1)), unbits(r.get(2))))) - .collect(); - // The chain draw is all or nothing: each cell is tested against what the cells before - // it in THIS draw add, and nothing is written until every cell passes. - for (i, (c, s)) in cells.iter().zip(&slots).enumerate() { - let Some((cap, used)) = held.get_mut(s) else { - return Err(ReserveRefused::NoCap { cell: i as u32 }); - }; - if exhausted(&c.key.dimension, *used, c.amount, *cap) { - return Err(ReserveRefused::Exhausted { cell: i as u32 }); + &[&slots], + ) + .await + .map_err(|_| ReserveRefused::Unavailable)?; + let mut held: std::collections::HashMap = rows + .iter() + .map(|r| (r.get(0), (unbits(r.get(1)), unbits(r.get(2))))) + .collect(); + // The chain draw is all or nothing: each cell is tested against what the cells before + // it in THIS draw add, and nothing is written until every cell passes. + for (i, (s, dimension, amount)) in cells.iter().enumerate() { + let Some((cap, used)) = held.get_mut(s) else { + return Err(ReserveRefused::NoCap { cell: i as u32 }); + }; + if exhausted(dimension, *used, *amount, *cap) { + return Err(ReserveRefused::Exhausted { cell: i as u32 }); + } + *used = used.saturating_add(*amount); } - *used = used.saturating_add(c.amount); - } - let mut answer = Vec::with_capacity(cells.len() * 3); - for (c, s) in cells.iter().zip(&slots) { - tx.execute( - "UPDATE money_slots SET used = $2 WHERE slot = $1", - &[s, &bits(held[s].1)], - ) - .map_err(|_| ReserveRefused::Unavailable)?; - let id: i64 = tx - .query_one( - "INSERT INTO money_slices (slot, remaining) VALUES ($1, $2) - RETURNING slice_id", - &[s, &bits(c.amount)], + let mut answer = Vec::with_capacity(cells.len() * 3); + for (s, _, amount) in cells { + tx.execute( + "UPDATE money_slots SET used = $2 WHERE slot = $1", + &[s, &bits(held[s].1)], ) - .map_err(|_| ReserveRefused::Unavailable)? - .get(0); - answer.extend([unbits(id), c.amount, u64::MAX]); + .await + .map_err(|_| ReserveRefused::Unavailable)?; + let id: i64 = tx + .query_one( + "INSERT INTO money_slices (slot, remaining) VALUES ($1, $2) + RETURNING slice_id", + &[s, &bits(*amount)], + ) + .await + .map_err(|_| ReserveRefused::Unavailable)? + .get(0); + answer.extend([unbits(id), *amount, u64::MAX]); + } + Ok(answer) } - Ok(answer) - })?; + ); if answer.len() != cells.len() * 3 { return Err(ReserveRefused::Conflict); } - grants.extend(answer.as_chunks::<3>().0.iter().map(|g| Grant { - slice_id: g[0], - granted: g[1], - valid_until_ms: g[2], - })); - Ok(()) + Ok(answer + .as_chunks::<3>() + .0 + .iter() + .map(|g| Grant { + slice_id: g[0], + granted: g[1], + valid_until_ms: g[2], + }) + .collect()) } - fn slice_release( - &self, + async fn v3_slice_release( + &mut self, op: OpId, - epoch: u64, - items: impl Iterator + Clone, - released: &mut impl Extend, - ) -> OpResult<()> { - let items: Vec<(u64, u64)> = items.collect(); - let body = format!("slice_release:{epoch}:{items:?}"); - let answer = self.deduped(op, &body, OpRefused::Conflict, OpRefused::Failed, |tx| { - let mut back_all = Vec::with_capacity(items.len()); - let mut seen = std::collections::HashSet::new(); - for &(id, unspent) in &items { - let row = tx + body: &str, + items: &[(u64, u64)], + ) -> OpResult> { + let answer = deduped!( + self, + op, + body, + OpRefused::Conflict, + OpRefused::Failed, + OpRefused, + |tx| { + let mut back_all = Vec::with_capacity(items.len()); + let mut seen = std::collections::HashSet::new(); + for &(id, unspent) in items { + let row = tx .query_opt( "SELECT slot, remaining FROM money_slices WHERE slice_id = $1 FOR UPDATE", &[&bits(id)], ) + .await .map_err(|e| OpRefused::Failed(pg(e)))?; - let Some(row) = row else { - // An item naming a slice an EARLIER item of this call closed answers 0; any - // other unknown slice fails the whole release. - if seen.contains(&id) { - back_all.push(0); - continue; + let Some(row) = row else { + // An item naming a slice an EARLIER item of this call closed answers 0; any + // other unknown slice fails the whole release. + if seen.contains(&id) { + back_all.push(0); + continue; + } + return Err(OpRefused::Failed(format!( + "slice_release: slice {id} is not held" + ))); + }; + seen.insert(id); + let s: String = row.get(0); + let left = unbits(row.get(1)); + let back = unspent.min(left); + let closed = if left == back { + tx.execute("DELETE FROM money_slices WHERE slice_id = $1", &[&bits(id)]) + .await + } else { + tx.execute( + "UPDATE money_slices SET remaining = $2 WHERE slice_id = $1", + &[&bits(id), &bits(left - back)], + ) + .await + }; + closed.map_err(|e| OpRefused::Failed(pg(e)))?; + let used = tx + .query_opt( + "SELECT used FROM money_slots WHERE slot = $1 FOR UPDATE", + &[&s], + ) + .await + .map_err(|e| OpRefused::Failed(pg(e)))?; + if let Some(used) = used { + tx.execute( + "UPDATE money_slots SET used = $2 WHERE slot = $1", + &[&s, &bits(unbits(used.get(0)).saturating_sub(back))], + ) + .await + .map_err(|e| OpRefused::Failed(pg(e)))?; } - return Err(OpRefused::Failed(format!( - "slice_release: slice {id} is not held" - ))); - }; - seen.insert(id); - let s: String = row.get(0); - let left = unbits(row.get(1)); - let back = unspent.min(left); - let closed = if left == back { - tx.execute("DELETE FROM money_slices WHERE slice_id = $1", &[&bits(id)]) - } else { - tx.execute( - "UPDATE money_slices SET remaining = $2 WHERE slice_id = $1", - &[&bits(id), &bits(left - back)], - ) - }; - closed.map_err(|e| OpRefused::Failed(pg(e)))?; - let used = tx - .query_opt( - "SELECT used FROM money_slots WHERE slot = $1 FOR UPDATE", - &[&s], - ) - .map_err(|e| OpRefused::Failed(pg(e)))?; - if let Some(used) = used { - tx.execute( - "UPDATE money_slots SET used = $2 WHERE slot = $1", - &[&s, &bits(unbits(used.get(0)).saturating_sub(back))], - ) - .map_err(|e| OpRefused::Failed(pg(e)))?; + back_all.push(back); } - back_all.push(back); + Ok(back_all) } - Ok(back_all) - })?; + ); if answer.len() != items.len() { return Err(OpRefused::Conflict); } - released.extend(answer); - Ok(()) + Ok(answer) } - fn add_usage_batch(&self, op: OpId, cells: &[(&str, u64, UsageDelta)]) -> OpResult<()> { - let body = format!("add_usage_batch:{cells:?}"); - self.op(op, &body, |tx| { - for (bucket, window, delta) in cells { - Self::add_usage_in(tx, bucket, *window, delta).map_err(failed)?; + async fn v3_add_usage_batch( + &mut self, + op: OpId, + body: &str, + cells: &[(String, u64, UsageDelta)], + ) -> OpResult<()> { + deduped!( + self, + op, + body, + OpRefused::Conflict, + OpRefused::Failed, + OpRefused, + |tx| { + for (bucket, window, delta) in cells { + Session::add_usage_in(tx, bucket, *window, delta) + .await + .map_err(failed)?; + } + Ok(Vec::new()) } - Ok(()) - }) + ); + Ok(()) } - fn add_metering_batch(&self, op: OpId, deltas: &[MeteringDelta]) -> OpResult<()> { + async fn v3_add_metering_batch(&mut self, op: OpId, deltas: &[MeteringDelta]) -> OpResult<()> { let body = format!("add_metering_batch:{deltas:?}"); - self.op(op, &body, |tx| { - for d in deltas { - Self::add_metering_in(tx, d).map_err(failed)?; + deduped!( + self, + op, + &body, + OpRefused::Conflict, + OpRefused::Failed, + OpRefused, + |tx| { + for d in deltas { + Session::add_metering_in(tx, d).await.map_err(failed)?; + } + Ok(Vec::new()) } - Ok(()) - }) + ); + Ok(()) } - fn append_audit_batch(&self, op: OpId, entries: &[AuditRecord]) -> OpResult<()> { + async fn v3_append_audit_batch(&mut self, op: OpId, entries: &[AuditRecord]) -> OpResult<()> { let body = format!("append_audit_batch:{entries:?}"); // One transaction: a fork anywhere (against a stored seq or an earlier entry of this batch) // rolls the whole batch back. - self.op(op, &body, |tx| { - for e in entries { - audit_in(tx, e)?; + deduped!( + self, + op, + &body, + OpRefused::Conflict, + OpRefused::Failed, + OpRefused, + |tx| { + for e in entries { + audit_in(tx, e).await?; + } + Ok(Vec::new()) } - Ok(()) - }) + ); + Ok(()) } - fn window_caps(&self, op: OpId, caps: &[Cap<'_>]) -> Result<(), CapsRefused> { - let body = format!("window_caps:{caps:?}"); - self.deduped(op, &body, CapsRefused::Conflict, CapsRefused::Failed, |tx| { - let fail = |e: postgres::Error| CapsRefused::Failed(pg(e)); - // Atomic per push: every cap is checked (against the stored cap and the ones before it - // in this push) before the push's transaction commits; the first conflict rolls it all - // back. - for (index, c) in caps.iter().enumerate() { - let s = slot(&c.key); - let stored = tx - .query_opt( - "SELECT cap, config_gen FROM money_slots WHERE slot = $1 FOR UPDATE", - &[&s], - ) - .map_err(fail)? - .map(|r| (unbits(r.get(0)), unbits(r.get(1)))); - match stored { - Some((cap, gen)) if gen == c.config_gen && cap != c.cap => { - return Err(CapsRefused::CapConflict { index }); - } - Some((_, gen)) if gen >= c.config_gen => {} - _ => { - tx.execute( + /// `window_caps` over the caps' slots (each cap's slot key, cap and config generation). + async fn v3_window_caps( + &mut self, + op: OpId, + body: &str, + caps: &[(String, u64, u64)], + ) -> Result<(), CapsRefused> { + deduped!( + self, + op, + body, + CapsRefused::Conflict, + CapsRefused::Failed, + CapsRefused, + |tx| { + let fail = |e: crate::pgwire::Error| CapsRefused::Failed(pg(e)); + // Atomic per push: every cap is checked (against the stored cap and the ones before it + // in this push) before the push's transaction commits; the first conflict rolls it all + // back. + for (index, (s, cap_value, config_gen)) in caps.iter().enumerate() { + let stored = tx + .query_opt( + "SELECT cap, config_gen FROM money_slots WHERE slot = $1 FOR UPDATE", + &[s], + ) + .await + .map_err(fail)? + .map(|r| (unbits(r.get(0)), unbits(r.get(1)))); + match stored { + Some((cap, gen)) if gen == *config_gen && cap != *cap_value => { + return Err(CapsRefused::CapConflict { index }); + } + Some((_, gen)) if gen >= *config_gen => {} + _ => { + tx.execute( "INSERT INTO money_slots (slot, cap, config_gen, used) VALUES ($1, $2, $3, 0) ON CONFLICT (slot) DO UPDATE SET cap = EXCLUDED.cap, config_gen = EXCLUDED.config_gen", - &[&s, &bits(c.cap), &bits(c.config_gen)], + &[s, &bits(*cap_value), &bits(*config_gen)], ) + .await .map_err(fail)?; + } } } + Ok(Vec::new()) } - Ok(Vec::new()) - }) - .map(drop) + ); + Ok(()) + } +} + +/// An audit append inside an op's transaction: a vanished conflicting row is a failure here (the +/// transaction cannot be retried from inside), never a success. +async fn audit_in(tx: &mut Transaction<'_>, entry: &AuditRecord) -> OpResult<()> { + match Session::append_audit_in(tx, entry).await.map_err(failed)? { + true => Ok(()), + false => Err(OpRefused::Failed(format!( + "append_audit: seq {} was freed between the insert and the read-back; the record was \ + NOT stored", + entry.seq + ))), } } @@ -635,3 +844,578 @@ impl StoreSlots for PostgresStore { fn record(bytes: Vec) -> Result { RecordBytes::new(bytes).map_err(|n| format!("a stored record of {n} bytes is over the ceiling")) } + +/// The store section's settings: its `url`. Shared by `validate` and `open`, so their refusals are +/// the same words. +fn settings_url(settings: &[u8]) -> Result { + let v: serde_json::Value = if settings.trim_ascii().is_empty() { + serde_json::Value::Object(Default::default()) + } else { + serde_json::from_slice(settings) + .map_err(|e| format!("invalid postgres plugin config: {e}"))? + }; + v.get("url") + .and_then(|x| x.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + "postgres plugin config requires a \"url\" (a libpq connection string)".to_string() + }) +} + +/// A dimension, owned for an op that runs across entries (only its variant is read). +fn dimension_kind(d: &Dimension<'_>) -> Dimension<'static> { + match d { + Dimension::NanoUnits => Dimension::NanoUnits, + Dimension::Requests => Dimension::Requests, + Dimension::Concurrency => Dimension::Concurrency, + Dimension::Class(_) => Dimension::Class(""), + } +} + +impl StoreSlots for PostgresStore { + const TAIL: Tail = Tail { + ephemeral: false, + durable_plane: true, + fork_refusal: true, + }; + + fn validate(settings: &[u8]) -> Result<(), String> { + settings_url(settings).map(drop) + } + + /// Open from the store section's settings: + /// + /// ```json + /// { "url": "postgres://user:pass@host:5432/busbar" } + /// ``` + /// + /// The settings are parsed here; the server is reached by the connect step. + fn open(settings: &[u8], _host: Option) -> Result { + let url = settings_url(settings)?; + PostgresStore::new(&url).map_err(|e| e.0) + } + + /// Reach the server and ensure the schema (1.5.5's connect + migrate, at the same moment: the + /// load), in the driver's words when it cannot. + fn connect(&self, cx: &mut Op<'_>) -> Step> { + on_conn!(self, cx, |e| e, |s| s.migrate().await.map_err(|e| e.0)) + } + + fn add_usage_op( + &self, + cx: &mut Op<'_>, + op: OpId, + bucket: &str, + window_start: u64, + delta: &UsageDelta, + ) -> Step> { + let (bucket, delta) = (bucket.to_owned(), delta.clone()); + on_conn!(self, cx, OpRefused::Failed, |s| s + .v3_add_usage(op, &bucket, window_start, &delta) + .await) + } + + fn add_metering_op( + &self, + cx: &mut Op<'_>, + op: OpId, + delta: &MeteringDelta, + ) -> Step> { + let delta = delta.clone(); + on_conn!(self, cx, OpRefused::Failed, |s| s + .v3_add_metering(op, &delta) + .await) + } + + fn append_audit_op( + &self, + cx: &mut Op<'_>, + op: OpId, + entry: &AuditRecord, + ) -> Step> { + let entry = entry.clone(); + on_conn!(self, cx, OpRefused::Failed, |s| s + .v3_append_audit(op, &entry) + .await) + } + + fn append_plane_record_op( + &self, + cx: &mut Op<'_>, + op: OpId, + record: PlaneRecordRef<'_>, + ) -> Step> { + let record = record.to_record(); + on_conn!(self, cx, OpRefused::Failed, |s| s + .v3_append_plane_record(op, &record) + .await) + } + + fn append_batch( + &self, + cx: &mut Op<'_>, + op: OpId, + stream: &str, + records: &[RecordBytes], + ) -> Step> { + let (stream, records) = (stream.to_owned(), records.to_vec()); + on_conn!(self, cx, OpRefused::Failed, |s| s + .v3_append_batch(op, &stream, &records) + .await) + } + + fn heads(&self, cx: &mut Op<'_>) -> Step, String>> { + on_conn!(self, cx, |e| e, |s| s.v3_heads().await) + } + + fn session_put( + &self, + cx: &mut Op<'_>, + session: u64, + node: &str, + principal: &str, + ) -> Step> { + let (node, principal) = (node.to_owned(), principal.to_owned()); + on_conn!(self, cx, |e| e, |s| s + .v3_session_put(session, &node, &principal) + .await) + } + + fn session_remove(&self, cx: &mut Op<'_>, session: u64) -> Step> { + on_conn!(self, cx, |e| e, |s| s.v3_session_remove(session).await) + } + + fn sessions_for( + &self, + cx: &mut Op<'_>, + principal: &str, + ) -> Step, String>> { + let principal = principal.to_owned(); + on_conn!(self, cx, |e| e, |s| s.v3_sessions_for(&principal).await) + } + + fn record_put( + &self, + cx: &mut Op<'_>, + schema: &str, + key: &[u8], + value: &[u8], + ) -> Step> { + let (schema, key, value) = (schema.to_owned(), key.to_vec(), value.to_vec()); + on_conn!(self, cx, |e| e, |s| s + .v3_record_put(&schema, &key, &value) + .await) + } + + fn record_get( + &self, + cx: &mut Op<'_>, + schema: &str, + key: &[u8], + ) -> Step, String>> { + let (schema, key) = (schema.to_owned(), key.to_vec()); + on_conn!(self, cx, |e| e, |s| s.v3_record_get(&schema, &key).await) + } + + fn record_scan( + &self, + cx: &mut Op<'_>, + schema: &str, + prefix: &[u8], + limit: u32, + ) -> Step> { + let (schema, prefix) = (schema.to_owned(), prefix.to_vec()); + on_conn!(self, cx, |e| e, |s| s + .v3_record_scan(&schema, &prefix, limit) + .await) + } + + fn reserve<'c>( + &self, + cx: &mut Op<'_>, + op: OpId, + epoch: u64, + cells: impl Iterator> + Clone, + grants: &mut impl Extend, + ) -> Step> { + let cells: Vec> = cells.collect(); + let body = format!("reserve:{epoch}:{cells:?}"); + let owned: Vec<(String, Dimension<'static>, u64)> = cells + .iter() + .map(|c| (slot(&c.key), dimension_kind(&c.key.dimension), c.amount)) + .collect(); + let step = on_conn!(self, cx, |_: String| ReserveRefused::Unavailable, |s| s + .v3_reserve(op, &body, &owned) + .await); + match step { + Step::Ready(Ok(g)) => { + grants.extend(g); + Step::Ready(Ok(())) + } + Step::Ready(Err(e)) => Step::Ready(Err(e)), + Step::Pending { wake_at_ns } => Step::Pending { wake_at_ns }, + } + } + + fn slice_release( + &self, + cx: &mut Op<'_>, + op: OpId, + epoch: u64, + items: impl Iterator + Clone, + released: &mut impl Extend, + ) -> Step> { + let items: Vec<(u64, u64)> = items.collect(); + let body = format!("slice_release:{epoch}:{items:?}"); + let step = on_conn!(self, cx, OpRefused::Failed, |s| s + .v3_slice_release(op, &body, &items) + .await); + match step { + Step::Ready(Ok(back)) => { + released.extend(back); + Step::Ready(Ok(())) + } + Step::Ready(Err(e)) => Step::Ready(Err(e)), + Step::Pending { wake_at_ns } => Step::Pending { wake_at_ns }, + } + } + + fn add_usage_batch( + &self, + cx: &mut Op<'_>, + op: OpId, + cells: &[(&str, u64, UsageDelta)], + ) -> Step> { + let body = format!("add_usage_batch:{cells:?}"); + let cells: Vec<(String, u64, UsageDelta)> = cells + .iter() + .map(|(b, w, d)| ((*b).to_owned(), *w, d.clone())) + .collect(); + on_conn!(self, cx, OpRefused::Failed, |s| s + .v3_add_usage_batch(op, &body, &cells) + .await) + } + + fn add_metering_batch( + &self, + cx: &mut Op<'_>, + op: OpId, + deltas: &[MeteringDelta], + ) -> Step> { + let deltas = deltas.to_vec(); + on_conn!(self, cx, OpRefused::Failed, |s| s + .v3_add_metering_batch(op, &deltas) + .await) + } + + fn append_audit_batch( + &self, + cx: &mut Op<'_>, + op: OpId, + entries: &[AuditRecord], + ) -> Step> { + let entries = entries.to_vec(); + on_conn!(self, cx, OpRefused::Failed, |s| s + .v3_append_audit_batch(op, &entries) + .await) + } + + fn window_caps( + &self, + cx: &mut Op<'_>, + op: OpId, + caps: &[Cap<'_>], + ) -> Step> { + let body = format!("window_caps:{caps:?}"); + let caps: Vec<(String, u64, u64)> = caps + .iter() + .map(|c| (slot(&c.key), c.cap, c.config_gen)) + .collect(); + on_conn!(self, cx, CapsRefused::Failed, |s| s + .v3_window_caps(op, &body, &caps) + .await) + } + + // ── the 1.5.5 op set (slots 0-32): each the 1.5.5 body, on the op's connection ────────── + + fn put_key(&self, cx: &mut Op<'_>, key: &VirtualKey) -> Step> { + let key = key.clone(); + on_conn!(self, cx, RecordStoreError, |s| s.put_key(&key).await) + } + + fn get_key(&self, cx: &mut Op<'_>, id: &str) -> Step>> { + let id = id.to_owned(); + on_conn!(self, cx, RecordStoreError, |s| s.get_key(&id).await) + } + + fn list_keys(&self, cx: &mut Op<'_>) -> Step>> { + on_conn!(self, cx, RecordStoreError, |s| s.list_keys().await) + } + + fn delete_key(&self, cx: &mut Op<'_>, id: &str) -> Step> { + let id = id.to_owned(); + on_conn!(self, cx, RecordStoreError, |s| s.delete_key(&id).await) + } + + fn scrub_key(&self, cx: &mut Op<'_>, id: &str) -> Step> { + let id = id.to_owned(); + on_conn!(self, cx, RecordStoreError, |s| s.scrub_key(&id).await) + } + + fn list_keys_since( + &self, + cx: &mut Op<'_>, + since: u64, + ) -> Step>> { + on_conn!(self, cx, RecordStoreError, |s| s + .list_keys_since(since) + .await) + } + + fn get_usage( + &self, + cx: &mut Op<'_>, + bucket_id: &str, + window_start: u64, + ) -> Step> { + let bucket_id = bucket_id.to_owned(); + on_conn!(self, cx, RecordStoreError, |s| s + .get_usage(&bucket_id, window_start) + .await) + } + + fn put_usage( + &self, + cx: &mut Op<'_>, + bucket_id: &str, + window_start: u64, + ledger: &UsageLedger, + ) -> Step> { + let (bucket_id, ledger) = (bucket_id.to_owned(), ledger.clone()); + on_conn!(self, cx, RecordStoreError, |s| s + .put_usage(&bucket_id, window_start, &ledger) + .await) + } + + fn list_metering( + &self, + cx: &mut Op<'_>, + bucket: u64, + ) -> Step>> { + on_conn!(self, cx, RecordStoreError, |s| s + .list_metering(bucket) + .await) + } + + fn purge_windows_before(&self, cx: &mut Op<'_>, before: u64) -> Step> { + on_conn!(self, cx, RecordStoreError, |s| s + .purge_windows_before(before) + .await) + } + + fn purge_metering_before(&self, cx: &mut Op<'_>, bucket: &str) -> Step> { + let bucket = bucket.to_owned(); + on_conn!(self, cx, RecordStoreError, |s| s + .purge_metering_before(&bucket) + .await) + } + + fn put_credential( + &self, + cx: &mut Op<'_>, + secret: &CredentialSecret, + ) -> Step> { + let secret = secret.clone(); + on_conn!(self, cx, RecordStoreError, |s| s + .put_credential(&secret) + .await) + } + + fn put_key_with_credential( + &self, + cx: &mut Op<'_>, + key: &VirtualKey, + secret: &CredentialSecret, + ) -> Step> { + let (key, secret) = (key.clone(), secret.clone()); + on_conn!(self, cx, RecordStoreError, |s| s + .put_key_with_credential(&key, &secret) + .await) + } + + fn list_credentials( + &self, + cx: &mut Op<'_>, + key_id: &str, + ) -> Step>> { + let key_id = key_id.to_owned(); + on_conn!(self, cx, RecordStoreError, |s| s + .list_credentials(&key_id) + .await) + } + + fn lookup_credential_secret( + &self, + cx: &mut Op<'_>, + kind: &str, + public_id: &str, + ) -> Step>> { + let (kind, public_id) = (kind.to_owned(), public_id.to_owned()); + on_conn!(self, cx, RecordStoreError, |s| s + .lookup_credential_secret(&kind, &public_id) + .await) + } + + fn revoke_credential( + &self, + cx: &mut Op<'_>, + id: &str, + reason: &str, + ) -> Step> { + let (id, reason) = (id.to_owned(), reason.to_owned()); + on_conn!(self, cx, RecordStoreError, |s| s + .revoke_credential(&id, &reason) + .await) + } + + fn list_credentials_since( + &self, + cx: &mut Op<'_>, + since: u64, + ) -> Step>> { + on_conn!(self, cx, RecordStoreError, |s| s + .list_credentials_since(since) + .await) + } + + fn list_audit(&self, cx: &mut Op<'_>) -> Step>> { + on_conn!(self, cx, RecordStoreError, |s| s.list_audit().await) + } + + fn add_denylist( + &self, + cx: &mut Op<'_>, + sub: &str, + reason: &str, + ) -> Step> { + let (sub, reason) = (sub.to_owned(), reason.to_owned()); + on_conn!(self, cx, RecordStoreError, |s| s + .add_denylist(&sub, &reason) + .await) + } + + fn list_denylist(&self, cx: &mut Op<'_>) -> Step>> { + on_conn!(self, cx, RecordStoreError, |s| s.list_denylist().await) + } + + fn list_audit_tail( + &self, + cx: &mut Op<'_>, + limit: u64, + ) -> Step>> { + on_conn!(self, cx, RecordStoreError, |s| s + .list_audit_tail(limit) + .await) + } + + fn upsert_plane_record( + &self, + cx: &mut Op<'_>, + record: PlaneRecordRef<'_>, + ) -> Step> { + let record = record.to_record(); + on_conn!(self, cx, RecordStoreError, |s| s + .upsert_plane_record(record.view()) + .await) + } + + fn get_plane_record( + &self, + cx: &mut Op<'_>, + kind: &str, + id: &str, + ) -> Step>>> { + let (kind, id) = (kind.to_owned(), id.to_owned()); + on_conn!(self, cx, RecordStoreError, |s| s + .get_plane_record(&kind, &id) + .await) + } + + fn list_plane_records( + &self, + cx: &mut Op<'_>, + kind: &str, + selector: &PlaneSelector<'_>, + ) -> Step>>> { + let kind = kind.to_owned(); + let selector = selector.to_static(); + on_conn!(self, cx, RecordStoreError, |s| s + .list_plane_records(&kind, &selector) + .await) + } + + fn list_plane_record_parents( + &self, + cx: &mut Op<'_>, + kind: &str, + ) -> Step>> { + let kind = kind.to_owned(); + on_conn!(self, cx, RecordStoreError, |s| s + .list_plane_record_parents(&kind) + .await) + } + + fn purge_plane_records_before( + &self, + cx: &mut Op<'_>, + kind: &str, + before: u64, + ) -> Step> { + let kind = kind.to_owned(); + on_conn!(self, cx, RecordStoreError, |s| s + .purge_plane_records_before(&kind, before) + .await) + } + + fn delete_plane_record( + &self, + cx: &mut Op<'_>, + kind: &str, + id: &str, + ) -> Step> { + let (kind, id) = (kind.to_owned(), id.to_owned()); + on_conn!(self, cx, RecordStoreError, |s| s + .delete_plane_record(&kind, &id) + .await) + } + + fn redeem_plane_token( + &self, + cx: &mut Op<'_>, + kind: &str, + token: &str, + expires_at: u64, + now: u64, + ) -> Step> { + let (kind, token) = (kind.to_owned(), token.to_owned()); + on_conn!(self, cx, RecordStoreError, |s| s + .redeem_plane_token(&kind, &token, expires_at, now) + .await) + } + + fn plane_token_live( + &self, + cx: &mut Op<'_>, + kind: &str, + token: &str, + expires_at: u64, + now: u64, + ) -> Step> { + let (kind, token) = (kind.to_owned(), token.to_owned()); + on_conn!(self, cx, RecordStoreError, |s| s + .plane_token_live(&kind, &token, expires_at, now) + .await) + } +}