diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..f27aa58b --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[unstable] +min-publish-age = true + +[registry] +global-min-publish-age = "7 days" diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 00000000..13b52085 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,93 @@ +--- +name: release +description: Release new versions of changed packages +--- + +# /release + +Release new versions of changed packages. + +## How releases work + +This project uses [release-plz](https://release-plz.dev/) for automated releases. Version is determined by conventional commits (`fix:` → patch, `feat:` → minor, `feat!:` → major). + +Each package has its own version and changelog. release-plz opens a PR only for packages with changes since their last release. `s2-cli` and `s2-lite` are in the same `version_group` (released together). `s2-api`, `s2-common`, `s2-storage`, and `s2-resource-spec` are library packages for internal use (published to crates.io but no GitHub releases). + +Tags are per-package: `s2-cli-v{version}`, `s2-lite-v{version}`, `s2-api-v{version}`, `s2-common-v{version}`, `s2-storage-v{version}`, `s2-resource-spec-v{version}`. + +## Usage + +``` +/release +``` + +## Steps + +1. **Find the release PR** + ```bash + gh pr list --label release --state open + ``` + +2. **Identify which packages are being released** + - Check which `Cargo.toml` files are modified in the PR: + ```bash + gh pr diff --name-only + ``` + - Look for version bumps in `cli/Cargo.toml`, `lite/Cargo.toml`, `api/Cargo.toml`, `common/Cargo.toml`, `storage/Cargo.toml`, `resource-spec/Cargo.toml` + +3. **For each package being released, verify its changelog** + - Get the PR diff and review the changelog sections: + ```bash + gh pr diff + ``` + - Get commits since that package's last tag: + ```bash + git fetch --tags + # Example for s2-cli (substitute the package name as needed): + git log $(git tag -l 's2-cli-v*' --sort=-v:refname | head -1)..origin/main --oneline + ``` + - Compare the changelog entries with the commit list + - Conventional commits (`feat:`, `fix:`, `docs:`, etc.) should be included + - Commits prefixed with `chore:` may be excluded (expected) + +4. **If discrepancies found** + - First, check if `release-crates` is still running from a prior release. `release-plz` triggers on pushes to main, while `release-crates` triggers when a PR with the `release` label is merged. If a previous release PR was just merged, `release-crates` may still be running and hasn't created tags yet. If `release-plz` runs before those tags exist, the PR will have stale changelogs with duplicate entries from the previous release. + ```bash + gh run list --workflow=release-crates.yml --limit 1 + ``` + - If a run is in progress, wait for it to complete so tags are up to date: + ```bash + gh run watch --exit-status + ``` + - Then re-trigger release-plz and wait: + ```bash + gh workflow run release-plz.yml + gh run list --workflow=release-plz.yml --limit 1 + gh run watch --exit-status + ``` + - Re-verify the changelog from step 3 before proceeding. + +5. **Dry run before merging** (only for packages being released) + ```bash + # Run only for packages with version bumps in the PR, e.g.: + cargo publish -p --dry-run + ``` + +6. **If changelog is correct**: Merge the PR + ```bash + gh pr merge --squash + ``` + +## If no release PR exists + +```bash +gh workflow run release-plz.yml +``` +Wait for the PR to be created, then verify and merge. + +## Notes + +- Check workflow status: `gh run list --workflow=release-plz.yml` +- After merge, `release-crates.yml` publishes to crates.io and creates per-package git tags +- Tags like `s2-cli-v*` or `s2-lite-v*` trigger `release-cli.yml` (builds binaries, Docker images, updates Homebrew) +- To override version: edit the relevant package's `Cargo.toml` in the PR before merging diff --git a/.github/actions/rust-dependency-cooldown/.gitignore b/.github/actions/rust-dependency-cooldown/.gitignore new file mode 100644 index 00000000..c18dd8d8 --- /dev/null +++ b/.github/actions/rust-dependency-cooldown/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/.github/actions/rust-dependency-cooldown/README.md b/.github/actions/rust-dependency-cooldown/README.md new file mode 100644 index 00000000..c36831f3 --- /dev/null +++ b/.github/actions/rust-dependency-cooldown/README.md @@ -0,0 +1,51 @@ +# Rust dependency cooldown + +This action checks newly locked crates.io versions against the caller's +`registry.global-min-publish-age` setting. S2-owned crates listed in +`first-party-crates.txt` are exempt from the publication-age check. + +## Security exceptions + +`security-exceptions.toml` records reviewed exceptions for individual security +releases. Each entry requires an exact crate name and version, an advisory +identifier or URL, and a reason: + +```toml +[[exception]] +crate = "rustls" +version = "0.23.45" +advisory = "RUSTSEC-2026-0285" +reason = "Fixes TLS 1.3 handshake messages accepted at the wrong encryption level" +``` + +Review the advisory and confirm that the exact release fixes it before adding an +entry. Ranges, wildcards, duplicate entries, and malformed configuration are +rejected. The action prints the crate, version, advisory, and reason whenever it +uses an exception. The exception waives only publication age; other dependency +checks, including `cargo deny`, continue to apply. + +The exception file is distributed with this action. After merging an exception, +update consuming repositories' pinned action or reusable-workflow commit to pick +it up. Future releases of the same crate still have to satisfy the cooldown. + +Entries need no expiry field: an approved release follows the normal age check +once it is old enough. Old entries may be removed in a later cleanup. An empty +file or `exception = []` means there are no security exceptions. + +Cargo also enforces publication age during dependency resolution. To select an +approved release, override that resolver check for the targeted update command: + +```sh +CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow cargo +nightly update -p rustls --precise 0.23.45 +``` + +Run the cooldown action on the resulting lockfile to check every newly selected +version against the exception list and normal age requirement. + +## Tests + +Run from the repository root: + +```sh +python3 -m unittest discover -s .github/actions/rust-dependency-cooldown -p 'test_*.py' -v +``` diff --git a/.github/actions/rust-dependency-cooldown/action.yml b/.github/actions/rust-dependency-cooldown/action.yml new file mode 100644 index 00000000..72093d91 --- /dev/null +++ b/.github/actions/rust-dependency-cooldown/action.yml @@ -0,0 +1,25 @@ +name: Rust dependency cooldown gate +description: Reject new crates.io versions that are inside the publication cooldown + +inputs: + base-sha: + description: Pull request base commit + required: true + +runs: + using: composite + steps: + - name: Check dependency publish age + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + BASE_SHA: ${{ inputs.base-sha }} + run: | + env -i \ + HOME="$RUNNER_TEMP" \ + PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + python3 "$ACTION_PATH/check.py" \ + --repo-root "$GITHUB_WORKSPACE" \ + --config "$GITHUB_WORKSPACE/.cargo/config.toml" \ + --allowlist "$ACTION_PATH/first-party-crates.txt" \ + "$BASE_SHA" diff --git a/.github/actions/rust-dependency-cooldown/check.py b/.github/actions/rust-dependency-cooldown/check.py new file mode 100644 index 00000000..53f7d0b0 --- /dev/null +++ b/.github/actions/rust-dependency-cooldown/check.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""Reject newly locked crates.io versions that are too new.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import time +import tomllib +import urllib.error +import urllib.request +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +ACTION_ROOT = Path(__file__).resolve().parent +REPO_ROOT = Path.cwd() +CONFIG = REPO_ROOT / ".cargo" / "config.toml" +ALLOWLIST = ACTION_ROOT / "first-party-crates.txt" +SECURITY_EXCEPTIONS = ACTION_ROOT / "security-exceptions.toml" +CRATES_IO_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" +INDEX_BASE = "https://index.crates.io" +USER_AGENT = "s2 minimum-publish-age check (github.com/s2-streamstore/s2)" +RETRY_ATTEMPTS = 4 +RETRY_BASE_DELAY_SECONDS = 2 +UNIT_SECONDS = { + "second": 1, + "seconds": 1, + "minute": 60, + "minutes": 60, + "hour": 3600, + "hours": 3600, + "day": 86400, + "days": 86400, + "week": 604800, + "weeks": 604800, + "month": 2592000, + "months": 2592000, +} +RELEVANT_PATHS = ( + ":(glob)**/Cargo.lock", + ".cargo/config.toml", + ".github/actions/rust-dependency-cooldown", + ".github/workflows/rust-dependency-cooldown.yml", +) + + +def minimum_age() -> tuple[timedelta, str]: + with CONFIG.open("rb") as config_file: + raw = tomllib.load(config_file).get("registry", {}).get("global-min-publish-age") + if not isinstance(raw, str): + raise ValueError(f"registry.global-min-publish-age is not set in {CONFIG}") + value = raw.strip() + if value == "0": + return timedelta(0), value + match = re.fullmatch(r"(\d+)\s+(\w+)", value) + if match is None or match.group(2) not in UNIT_SECONDS: + raise ValueError(f"cannot parse global-min-publish-age = {value!r}") + return timedelta(seconds=int(match.group(1)) * UNIT_SECONDS[match.group(2)]), value + + +def allowed_crates() -> set[str]: + return { + name + for line in ALLOWLIST.read_text().splitlines() + if (name := line.split("#", 1)[0].strip()) + } + + +def security_exceptions() -> dict[tuple[str, str], dict[str, str]]: + with SECURITY_EXCEPTIONS.open("rb") as exceptions_file: + document = tomllib.load(exceptions_file) + if document.keys() - {"exception"}: + raise ValueError(f"unexpected fields in {SECURITY_EXCEPTIONS}") + entries = document.get("exception", []) + if not isinstance(entries, list): + raise ValueError(f"expected [[exception]] entries in {SECURITY_EXCEPTIONS}") + required_fields = {"crate", "version", "advisory", "reason"} + exceptions: dict[tuple[str, str], dict[str, str]] = {} + for index, entry in enumerate(entries, start=1): + if not isinstance(entry, dict) or entry.keys() != required_fields: + raise ValueError(f"security exception {index} must contain {sorted(required_fields)}") + for field, value in entry.items(): + if not isinstance(value, str) or not value or value != value.strip(): + raise ValueError(f"security exception {index} has invalid {field}") + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", entry["crate"]) is None: + raise ValueError(f"security exception {index} requires an exact crate name") + if re.fullmatch( + r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", + entry["version"], + ) is None: + raise ValueError(f"security exception {index} requires an exact version") + key = (entry["crate"], entry["version"]) + if key in exceptions: + raise ValueError(f"duplicate security exception for {key[0]} {key[1]}") + exceptions[key] = entry + return exceptions + + +def crates_io_versions(lock_text: str) -> set[tuple[str, str]]: + packages = tomllib.loads(lock_text).get("package", []) + return { + (package["name"], package["version"]) + for package in packages + if package.get("source") == CRATES_IO_SOURCE + } + + +def run_git(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def lock_at(revision: str, path: str) -> str | None: + result = run_git("show", f"{revision}:{path}") + return result.stdout if result.returncode == 0 else None + + +def has_relevant_changes(base_ref: str) -> bool: + result = run_git("diff", "--quiet", base_ref, "HEAD", "--", *RELEVANT_PATHS) + if result.returncode == 1: + return True + if result.returncode != 0: + raise RuntimeError( + f"cannot compare committed Rust dependency cooldown paths: {result.stderr.strip()}" + ) + + result = run_git("diff", "--quiet", "HEAD", "--", *RELEVANT_PATHS) + if result.returncode == 1: + return True + if result.returncode != 0: + raise RuntimeError( + f"cannot compare working Rust dependency cooldown paths: {result.stderr.strip()}" + ) + + result = run_git("ls-files", "--others", "--exclude-standard", "--", *RELEVANT_PATHS) + if result.returncode: + raise RuntimeError( + f"cannot list untracked Rust dependency cooldown paths: {result.stderr.strip()}" + ) + return bool(result.stdout.strip()) + + +def index_url(name: str) -> str: + normalized = name.lower() + if len(normalized) == 1: + path = f"1/{normalized}" + elif len(normalized) == 2: + path = f"2/{normalized}" + elif len(normalized) == 3: + path = f"3/{normalized[0]}/{normalized}" + else: + path = f"{normalized[:2]}/{normalized[2:4]}/{normalized}" + return f"{INDEX_BASE}/{path}" + + +def fetch_index(name: str) -> str: + request = urllib.request.Request(index_url(name), headers={"User-Agent": USER_AGENT}) + last_error: Exception | None = None + for attempt in range(1, RETRY_ATTEMPTS + 1): + if attempt > 1: + time.sleep(RETRY_BASE_DELAY_SECONDS * (attempt - 1)) + try: + with urllib.request.urlopen(request, timeout=30) as response: + raw = response.read() + except urllib.error.HTTPError as error: + if error.code < 500 and error.code != 429: + raise RuntimeError(f"cannot read the crates.io index for {name}: {error}") from error + last_error = error + continue + except (urllib.error.URLError, TimeoutError) as error: + last_error = error + continue + try: + return raw.decode() + except UnicodeDecodeError as error: + raise RuntimeError(f"cannot read the crates.io index for {name}: {error}") from error + raise RuntimeError( + f"cannot read the crates.io index for {name} after {RETRY_ATTEMPTS} attempts: {last_error}" + ) from last_error + + +def publication_times(name: str) -> dict[str, datetime]: + times: dict[str, datetime] = {} + for line in fetch_index(name).splitlines(): + if not line.strip(): + continue + entry = json.loads(line) + if pubtime := entry.get("pubtime"): + times[entry["vers"]] = datetime.fromisoformat(pubtime.replace("Z", "+00:00")) + return times + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repo-root", + type=Path, + default=Path.cwd(), + help="repository whose lock files are checked", + ) + parser.add_argument("--config", type=Path, help="Cargo configuration to read") + parser.add_argument("--allowlist", type=Path, help="exact first-party crate names") + parser.add_argument( + "base_ref", + nargs="?", + help="check only crates.io versions not present at this Git ref", + ) + return parser.parse_args() + + +def main() -> int: + global ALLOWLIST, CONFIG, REPO_ROOT + + args = parse_args() + REPO_ROOT = args.repo_root.resolve() + CONFIG = (args.config or REPO_ROOT / ".cargo" / "config.toml").resolve() + ALLOWLIST = (args.allowlist or ACTION_ROOT / "first-party-crates.txt").resolve() + try: + if args.base_ref and run_git("rev-parse", "--verify", "--quiet", args.base_ref).returncode: + raise ValueError(f"base ref {args.base_ref!r} is not available") + approved_exceptions = security_exceptions() + if args.base_ref and not has_relevant_changes(args.base_ref): + print("No Rust dependency cooldown files changed; check skipped.") + return 0 + + age_limit, age_text = minimum_age() + allowlist = allowed_crates() + + lockfile_result = run_git( + "ls-files", "--cached", "--others", "--exclude-standard", "*Cargo.lock" + ) + if lockfile_result.returncode: + raise RuntimeError(f"cannot list Cargo.lock files: {lockfile_result.stderr.strip()}") + lockfiles = lockfile_result.stdout.splitlines() + now = datetime.now(timezone.utc) + violations: list[str] = [] + checked = 0 + index_cache: dict[str, dict[str, datetime]] = {} + + for lockfile in sorted(lockfiles): + proposed = crates_io_versions((REPO_ROOT / lockfile).read_text()) + baseline: set[tuple[str, str]] = set() + if args.base_ref and (text := lock_at(args.base_ref, lockfile)) is not None: + baseline = crates_io_versions(text) + + for name, version in sorted(proposed - baseline): + if name in allowlist: + continue + if name not in index_cache: + index_cache[name] = publication_times(name) + pubtime = index_cache[name].get(version) + if pubtime is None: + raise RuntimeError(f"no publication time for {name} {version}") + checked += 1 + crate_age = now - pubtime + if crate_age < age_limit: + if exception := approved_exceptions.get((name, version)): + print( + f"Publication cooldown waived for {name} {version} ({lockfile}): " + f"{exception['advisory']} — {exception['reason']}" + ) + continue + violations.append( + f"{name} {version} ({lockfile}): published " + f"{crate_age.total_seconds() / 86400:.1f} days ago; " + f"minimum is {age_text}" + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: + print(f"minimum-publish-age error: {error}", file=sys.stderr) + return 2 + + if violations: + print("New crates.io versions are inside the publication cooldown:", file=sys.stderr) + for violation in violations: + print(f" - {violation}", file=sys.stderr) + return 1 + + print(f"Checked {checked} new crates.io version(s); publication cooldown policy satisfied.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/actions/rust-dependency-cooldown/first-party-crates.txt b/.github/actions/rust-dependency-cooldown/first-party-crates.txt new file mode 100644 index 00000000..f624704e --- /dev/null +++ b/.github/actions/rust-dependency-cooldown/first-party-crates.txt @@ -0,0 +1,10 @@ +# Crates that bypass only the minimum publish age. +# Use exact crate names. Only add crates that S2 publishes and controls. +s2-api +s2-cli +s2-common +s2-lite +s2-resource-spec +s2-sdk +s2-storage +s2-testcontainers diff --git a/.github/actions/rust-dependency-cooldown/security-exceptions.toml b/.github/actions/rust-dependency-cooldown/security-exceptions.toml new file mode 100644 index 00000000..c0e3a044 --- /dev/null +++ b/.github/actions/rust-dependency-cooldown/security-exceptions.toml @@ -0,0 +1,5 @@ +[[exception]] +crate = "rustls" +version = "0.23.45" +advisory = "RUSTSEC-2026-0285" +reason = "Fixes TLS 1.3 handshake messages accepted at the wrong encryption level" diff --git a/.github/actions/rust-dependency-cooldown/test_check.py b/.github/actions/rust-dependency-cooldown/test_check.py new file mode 100644 index 00000000..313e59a5 --- /dev/null +++ b/.github/actions/rust-dependency-cooldown/test_check.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +import importlib.util +import io +import json +import subprocess +import sys +import tempfile +import unittest +from contextlib import ExitStack, redirect_stderr, redirect_stdout +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch + + +SPEC = importlib.util.spec_from_file_location( + "cooldown_check", Path(__file__).with_name("check.py") +) +assert SPEC is not None and SPEC.loader is not None +check = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(check) + +NOW = datetime(2026, 9, 16, tzinfo=timezone.utc) +APPROVED_EXCEPTION = { + "crate": "rustls", + "version": "0.23.45", + "advisory": "https://rustsec.org/advisories/RUSTSEC-2026-0285.html", + "reason": "First release fixing TLS 1.3 handshake encryption-level validation.", +} + + +def exception_document(*entries: dict[str, object]) -> str: + return "\n".join( + "[[exception]]\n" + + "\n".join(f"{name} = {json.dumps(value)}" for name, value in entry.items()) + + "\n" + for entry in entries + ) + + +class CooldownSecurityExceptionTests(unittest.TestCase): + def setUp(self) -> None: + temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(temporary_directory.cleanup) + temporary_root = Path(temporary_directory.name) + self.repo = temporary_root / "consumer" + self.repo.mkdir() + self.action = temporary_root / "trusted-action" + self.action.mkdir() + self.exceptions = self.action / "security-exceptions.toml" + self.exceptions.write_text(exception_document(APPROVED_EXCEPTION)) + (self.action / "first-party-crates.txt").write_text("s2-api\n") + (self.repo / ".cargo").mkdir() + (self.repo / ".cargo" / "config.toml").write_text( + '[registry]\nglobal-min-publish-age = "7 days"\n' + ) + self.write_lock() + self.git("init", "--quiet") + self.commit() + + def git(self, *arguments: str) -> None: + subprocess.run( + [ + "git", + "-c", + "user.name=Cooldown Tests", + "-c", + "user.email=cooldown-tests@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.hooksPath=/dev/null", + *arguments, + ], + cwd=self.repo, + check=True, + capture_output=True, + text=True, + ) + + def commit(self) -> None: + self.git("add", ".") + self.git("commit", "--quiet", "-m", "test: record baseline") + + def write_lock(self, *packages: tuple[str, str, str]) -> None: + contents = "version = 3\n" + for name, version, source in packages: + contents += ( + "\n[[package]]\n" + f"name = {json.dumps(name)}\n" + f"version = {json.dumps(version)}\n" + f"source = {json.dumps(source)}\n" + ) + (self.repo / "Cargo.lock").write_text(contents) + + def run_gate( + self, + publication_times: dict[str, dict[str, datetime]] | None = None, + ) -> tuple[int, str, str, list[str]]: + published = publication_times or { + "rustls": {"0.23.45": NOW - timedelta(days=1)}, + } + output, errors = io.StringIO(), io.StringIO() + with ExitStack() as stack: + for name, value in { + "ACTION_ROOT": self.action, + "REPO_ROOT": self.repo, + "CONFIG": self.repo / ".cargo" / "config.toml", + "ALLOWLIST": self.action / "first-party-crates.txt", + "SECURITY_EXCEPTIONS": self.exceptions, + }.items(): + stack.enter_context(patch.object(check, name, value)) + stack.enter_context( + patch.object(sys, "argv", ["check.py", "--repo-root", str(self.repo), "HEAD"]) + ) + clock = stack.enter_context(patch.object(check, "datetime", wraps=datetime)) + clock.now.return_value = NOW + lookup = stack.enter_context( + patch.object(check, "publication_times", side_effect=published.__getitem__) + ) + stack.enter_context(redirect_stdout(output)) + stack.enter_context(redirect_stderr(errors)) + status = check.main() + lookups = [call.args[0] for call in lookup.call_args_list] + return status, output.getvalue(), errors.getvalue(), lookups + + def test_exact_security_exception_passes_and_reports_justification(self) -> None: + self.write_lock(("rustls", "0.23.45", check.CRATES_IO_SOURCE)) + status, output, errors, _ = self.run_gate() + self.assertEqual(status, 0, errors) + for field in APPROVED_EXCEPTION.values(): + self.assertIn(field, output) + + def test_exception_does_not_cover_another_version_or_crate(self) -> None: + for name, version in [("rustls", "0.23.46"), ("another-crate", "0.23.45")]: + with self.subTest(crate=name, version=version): + self.write_lock((name, version, check.CRATES_IO_SOURCE)) + status, _, errors, _ = self.run_gate( + {name: {version: NOW - timedelta(days=1)}} + ) + self.assertEqual(status, 1) + self.assertIn(f"{name} {version}", errors) + + def test_exception_does_not_hide_an_unrelated_young_dependency(self) -> None: + self.write_lock( + ("rustls", "0.23.45", check.CRATES_IO_SOURCE), + ("another-crate", "1.0.0", check.CRATES_IO_SOURCE), + ) + status, output, errors, _ = self.run_gate( + { + "rustls": {"0.23.45": NOW - timedelta(days=1)}, + "another-crate": {"1.0.0": NOW - timedelta(days=1)}, + } + ) + self.assertEqual(status, 1) + self.assertIn(APPROVED_EXCEPTION["advisory"], output) + self.assertIn("another-crate 1.0.0", errors) + self.assertNotIn("rustls 0.23.45", errors) + + def test_consumer_repository_cannot_supply_its_own_exception(self) -> None: + unapproved = {**APPROVED_EXCEPTION, "version": "0.23.46"} + consumer_action = self.repo / ".github" / "actions" / "rust-dependency-cooldown" + consumer_action.mkdir(parents=True) + for directory in [self.repo, self.repo / ".cargo", consumer_action]: + (directory / "security-exceptions.toml").write_text(exception_document(unapproved)) + self.write_lock(("rustls", "0.23.46", check.CRATES_IO_SOURCE)) + status, _, errors, _ = self.run_gate( + {"rustls": {"0.23.46": NOW - timedelta(days=1)}} + ) + self.assertEqual(status, 1) + self.assertIn("rustls 0.23.46", errors) + + def test_malformed_security_exceptions_fail_closed(self) -> None: + documents = { + "invalid TOML": "[[exception]", + "not an array": "exception = 1\n", + "not a table": "exception = [1]\n", + "unknown top-level field": "exceptions = []\n", + } + for field in APPROVED_EXCEPTION: + missing = {name: value for name, value in APPROVED_EXCEPTION.items() if name != field} + documents[f"missing {field}"] = exception_document(missing) + for value in ["", " ", 123, f" {APPROVED_EXCEPTION[field]}"]: + documents[f"invalid {field}: {value!r}"] = exception_document( + {**APPROVED_EXCEPTION, field: value} + ) + documents["unknown field"] = exception_document({**APPROVED_EXCEPTION, "extra": "value"}) + documents["duplicate pair"] = exception_document(APPROVED_EXCEPTION, APPROVED_EXCEPTION) + self.write_lock(("rustls", "0.23.45", check.CRATES_IO_SOURCE)) + for description, document in documents.items(): + with self.subTest(description=description): + self.exceptions.write_text(document) + status, _, errors, _ = self.run_gate() + self.assertEqual(status, 2) + self.assertIn("minimum-publish-age error", errors) + + def test_missing_action_exception_file_fails_closed(self) -> None: + self.exceptions.unlink() + self.write_lock(("rustls", "0.23.45", check.CRATES_IO_SOURCE)) + status, _, errors, _ = self.run_gate() + self.assertEqual(status, 2) + self.assertIn("minimum-publish-age error", errors) + + def test_malformed_exceptions_fail_even_when_no_lockfile_changed(self) -> None: + self.exceptions.write_text("[[exception]") + status, _, errors, _ = self.run_gate() + self.assertEqual(status, 2) + self.assertIn("minimum-publish-age error", errors) + + def test_empty_exception_lists_do_not_exempt_young_dependencies(self) -> None: + self.write_lock(("rustls", "0.23.45", check.CRATES_IO_SOURCE)) + for document in ["", "exception = []\n"]: + with self.subTest(document=document): + self.exceptions.write_text(document) + status, _, errors, _ = self.run_gate() + self.assertEqual(status, 1) + self.assertIn("rustls 0.23.45", errors) + + def test_exception_ranges_and_wildcards_are_rejected(self) -> None: + self.write_lock(("rustls", "0.23.45", check.CRATES_IO_SOURCE)) + for field, value in [ + ("crate", "rustls*"), + ("version", "*"), + ("version", "0.23.*"), + ("version", ">=0.23.45"), + ]: + with self.subTest(field=field, value=value): + self.exceptions.write_text( + exception_document({**APPROVED_EXCEPTION, field: value}) + ) + status, _, errors, _ = self.run_gate() + self.assertEqual(status, 2) + self.assertIn("minimum-publish-age error", errors) + + def test_approved_version_still_requires_a_publication_time(self) -> None: + self.write_lock(("rustls", "0.23.45", check.CRATES_IO_SOURCE)) + status, _, errors, _ = self.run_gate({"rustls": {}}) + self.assertEqual(status, 2) + self.assertIn("no publication time for rustls 0.23.45", errors) + + def test_mature_approved_version_uses_normal_age_check(self) -> None: + self.write_lock(("rustls", "0.23.45", check.CRATES_IO_SOURCE)) + status, output, errors, lookups = self.run_gate( + {"rustls": {"0.23.45": NOW - timedelta(days=7)}} + ) + self.assertEqual(status, 0, errors) + self.assertNotIn(APPROVED_EXCEPTION["advisory"], output) + self.assertEqual(lookups, ["rustls"]) + + def test_age_boundary_for_ordinary_dependencies_is_unchanged(self) -> None: + self.write_lock(("another-crate", "1.0.0", check.CRATES_IO_SOURCE)) + for age, expected_status in [(timedelta(days=7), 0), (timedelta(days=7, seconds=-1), 1)]: + with self.subTest(age=age): + status, _, errors, _ = self.run_gate({"another-crate": {"1.0.0": NOW - age}}) + self.assertEqual(status, expected_status, errors) + + def test_first_party_allowlist_still_exempts_new_versions(self) -> None: + self.write_lock(("s2-api", "99.0.0", check.CRATES_IO_SOURCE)) + status, _, errors, lookups = self.run_gate() + self.assertEqual(status, 0, errors) + self.assertEqual(lookups, []) + + def test_other_sources_are_not_processed_as_security_exceptions(self) -> None: + for source in [ + "registry+https://example.invalid/index", + "git+https://example.invalid/rustls", + ]: + with self.subTest(source=source): + self.write_lock(("rustls", "0.23.45", source)) + status, output, errors, lookups = self.run_gate() + self.assertEqual(status, 0, errors) + self.assertNotIn(APPROVED_EXCEPTION["advisory"], output) + self.assertEqual(lookups, []) + + def test_existing_locked_versions_are_not_rechecked(self) -> None: + self.write_lock(("already-locked", "1.0.0", check.CRATES_IO_SOURCE)) + self.commit() + self.write_lock( + ("already-locked", "1.0.0", check.CRATES_IO_SOURCE), + ("another-crate", "1.0.0", check.CRATES_IO_SOURCE), + ) + status, _, errors, lookups = self.run_gate( + {"another-crate": {"1.0.0": NOW - timedelta(days=8)}} + ) + self.assertEqual(status, 0, errors) + self.assertEqual(lookups, ["another-crate"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/build-s2-lite.yml b/.github/workflows/build-s2-lite.yml new file mode 100644 index 00000000..aedcb617 --- /dev/null +++ b/.github/workflows/build-s2-lite.yml @@ -0,0 +1,63 @@ +name: Build s2-lite + +on: + workflow_call: + inputs: + ref: + description: "Git ref to checkout" + required: false + type: string + default: "main" + artifact-name: + description: "Name for the uploaded artifact" + required: false + type: string + default: "server-binary" + binary-name: + description: "Name of the binary in the artifact" + required: false + type: string + default: "server" + retention-days: + description: "Number of days to retain the artifact" + required: false + type: number + default: 1 + runner: + description: "Runner to use" + required: false + type: string + default: "ubuntu-latest" + +jobs: + build: + name: Build s2-lite + runs-on: ${{ inputs.runner }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: s2-streamstore/s2 + ref: ${{ inputs.ref }} + submodules: true + + - name: Setup Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + + - name: Setup Protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build s2-lite + run: cargo build --locked --profile ci -p s2-lite + + - name: Upload binary + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ inputs.artifact-name }} + path: target/ci/${{ inputs.binary-name }} + retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/bump-chart-version.yml b/.github/workflows/bump-chart-version.yml new file mode 100644 index 00000000..c9f7e748 --- /dev/null +++ b/.github/workflows/bump-chart-version.yml @@ -0,0 +1,50 @@ +name: Bump Chart Version + +on: + workflow_dispatch: + +jobs: + bump_chart_version: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Generate GitHub token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + id: generate-token + with: + app-id: ${{ secrets.RELEASE_PLZ_APP_ID }} + private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + token: ${{ steps.generate-token.outputs.token }} + + - name: Bump chart patch version + run: | + CURRENT_VERSION=$(grep '^version:' charts/s2-lite-helm/Chart.yaml | awk '{print $2}') + if [ -z "$CURRENT_VERSION" ]; then + echo "ERROR: Could not read current chart version" + exit 1 + fi + echo "Current chart version: $CURRENT_VERSION" + + NEW_VERSION=$(echo "$CURRENT_VERSION" | awk -F. '{$NF = $NF + 1; print}' OFS=.) + echo "New chart version: $NEW_VERSION" + + sed -i "s/^version: .*/version: $NEW_VERSION/" charts/s2-lite-helm/Chart.yaml + + - name: Commit and push + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + git add charts/s2-lite-helm/Chart.yaml + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "Bump s2-lite-helm chart version to $(grep '^version:' charts/s2-lite-helm/Chart.yaml | awk '{print $2}')" + git pull --rebase + git push + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..2da7b71d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,412 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + rust-dependency-cooldown: + name: Rust dependency cooldown gate + permissions: + contents: read + uses: $/.github/workflows/rust-dependency-cooldown.yml + + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + sdk: ${{ steps.filter.outputs.sdk }} + apply_schema: ${{ steps.filter.outputs.apply_schema }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + sdk: + - 'sdk/**' + apply_schema: + - 'cli/src/cli.rs' + - 'cli/src/main.rs' + - 'lite/src/init.rs' + - 'cli/schema.json' + + cli-schema-drift: + name: CLI Schema Drift + needs: [changes, rust-dependency-cooldown] + if: needs.changes.outputs.apply_schema == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Generate apply schema + run: cargo run --locked -q -p s2-cli -- apply --schema > /tmp/apply.schema.json + - name: Check for schema drift + run: diff -u cli/schema.json /tmp/apply.schema.json + + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dtolnay/rust-toolchain@7c8d7d138f5c09cef361f8214cf96882cd029cdb # nightly + with: + components: rustfmt + - run: cargo +nightly fmt --all --check + - run: cargo +nightly fmt --manifest-path sim/Cargo.toml --check + + lockfile: + name: Lockfile + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - name: Verify Cargo.lock is up-to-date + run: cargo metadata --locked --format-version 1 >/dev/null + + sort: + name: Cargo Sort + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: taiki-e/install-action@84f765ae4b60a8aa21ae300a812fd0434d708108 # cargo-sort + - run: cargo sort --workspace --check + + deny: + name: Cargo Deny + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: taiki-e/install-action@994cebbc19c36971116a6d44f4408834ada6522e # cargo-deny + - run: cargo deny check + + clippy: + name: Clippy + needs: rust-dependency-cooldown + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: true + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + components: clippy + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Install Protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - run: cargo clippy --locked --workspace --all-features --all-targets -- -D warnings --allow deprecated + - name: Clippy (simulator) + env: + RUSTFLAGS: --cfg tokio_unstable + run: cargo clippy --manifest-path sim/Cargo.toml --all-targets -- -D warnings --allow deprecated + + test: + name: Tests + needs: rust-dependency-cooldown + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: true + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: taiki-e/install-action@d5f9268ff7620505a81ada10ddf18cdd72240185 # nextest + - run: cargo nextest run --locked --workspace --all-features --exclude s2-sdk --exclude s2-testcontainers -E 'not (package(s2-cli) & binary(integration))' + + testcontainers: + name: Testcontainers + needs: rust-dependency-cooldown + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: true + - name: Resolve source revision + id: source_revision + run: echo "value=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: Read image version + id: image-version + run: | + VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' testcontainers/Cargo.toml | head -n1) + echo "value=${VERSION}" >> "$GITHUB_OUTPUT" + - name: Build local S2 Docker image + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + with: + context: . + target: runtime + tags: ghcr.io/s2-streamstore/s2:${{ steps.image-version.outputs.value }} + load: true + build-args: | + S2_GIT_REV=${{ steps.source_revision.outputs.value }} + cache-from: type=gha,scope=s2-testcontainers + cache-to: type=gha,mode=max,scope=s2-testcontainers + - name: Verify image source revision + env: + S2_GIT_REV: ${{ steps.source_revision.outputs.value }} + run: | + docker run --rm ghcr.io/s2-streamstore/s2:${{ steps.image-version.outputs.value }} --version \ + | grep -F "rev $S2_GIT_REV" + - run: cargo test --locked -p s2-testcontainers + + simulation: + name: Simulation Tests + needs: rust-dependency-cooldown + # arm64, deliberately: on x86_64 Linux, fastant (via slatedb -> foyer) runs + # a pre-main TSC calibration loop that spins forever under mad-turmoil's + # interposed clock_gettime, hanging the simulator at startup. On aarch64 + # fastant does not take that code path. Revisit if mad-turmoil falls back + # to the real clock outside simulation context. + runs-on: ubuntu-24.04-arm + # Simulations that lose determinism or deadlock can hang; fail fast. + timeout-minutes: 30 + env: + # Required so turmoil can seed tokio's internal RNG for determinism. + RUSTFLAGS: --cfg tokio_unstable + # The Go linearizability checker must be built from the same rev as the + # s2-verification dependency pinned in sim/Cargo.toml. + S2_VERIFICATION_REV: b4af8c8ef4965d9b335101c422eadb33f3169004 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: true + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Build simulator + run: cargo build --manifest-path sim/Cargo.toml --profile sim + + # Determinism first: a broken meta test means seeds are not reproducible + # and any linearizability failure would not be debuggable. Children + # inherit RUST_LOG; trace level compares vastly more output (~50MB vs a + # dozen lines per run), catching nondeterminism that info level misses. + - name: Determinism (meta) tests + env: + RUST_LOG: trace + run: | + ./sim/target/sim/sim meta smoke --seed 1 + ./sim/target/sim/sim meta linearizable --seed 1 + ./sim/target/sim/sim meta linearizable --seed 2 --fail-rate 0.005 + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: stable + cache: false + - name: Build s2-porcupine checker + run: | + git clone https://github.com/s2-streamstore/s2-verification /tmp/s2-verification + git -C /tmp/s2-verification checkout "$S2_VERIFICATION_REV" + cd /tmp/s2-verification/golang/s2-porcupine + go build -o /tmp/s2-porcupine . + + - name: Linearizability tests + run: | + for seed in 1 2 3; do + echo "starting" + ./sim/target/sim/sim linearizable --seed "$seed" --clients 3 --ops-per-client 50 + /tmp/s2-porcupine -file="history.$seed.jsonl" + echo "finished" + done + # And under network chaos (message loss). + for seed in 4 5; do + echo "starting" + ./sim/target/sim/sim --fail-rate 0.005 linearizable --seed "$seed" --clients 3 --ops-per-client 50 + /tmp/s2-porcupine -file="history.$seed.jsonl" + echo "finished" + done + + - name: Upload histories on failure + if: failure() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: sim-histories + path: | + history.*.jsonl + porcupine-outputs/ + if-no-files-found: ignore + + helm-lint: + name: Helm Chart Lint & Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Helm + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 + with: + version: v3.14.0 + + - name: Lint Helm chart + run: helm lint charts/s2-lite-helm + + - name: Test template rendering (default) + run: helm template test-release charts/s2-lite-helm --dry-run > /dev/null + + - name: Test with TLS self-signed + run: helm template test-release charts/s2-lite-helm --set tls.enabled=true --set tls.selfSigned=true --dry-run > /dev/null + + - name: Test with TLS provided cert + run: helm template test-release charts/s2-lite-helm --set tls.enabled=true --set tls.cert=/etc/tls/tls.crt --set tls.key=/etc/tls/tls.key --dry-run > /dev/null + + - name: Test with S3 object storage + run: helm template test-release charts/s2-lite-helm --set objectStorage.enabled=true --set objectStorage.bucket=test-bucket --dry-run > /dev/null + + - name: Test with S3 and TLS + run: | + helm template test-release charts/s2-lite-helm \ + --set tls.enabled=true \ + --set tls.selfSigned=true \ + --set objectStorage.enabled=true \ + --set objectStorage.bucket=test-bucket \ + --set objectStorage.endpoint=https://s3.amazonaws.com \ + --set metrics.serviceMonitor.enabled=true \ + --dry-run > /dev/null + + - name: Test TLS without cert/key fails but selfSigned and provided cert work + run: | + if helm template test-release charts/s2-lite-helm \ + --set tls.enabled=true \ + --dry-run 2>&1; then + echo "Expected failure but got success" + exit 1 + fi + helm template test-release charts/s2-lite-helm \ + --set tls.enabled=true \ + --set tls.selfSigned=true \ + --dry-run > /dev/null + helm template test-release charts/s2-lite-helm \ + --set tls.enabled=true \ + --set tls.cert=/etc/tls/tls.crt \ + --set tls.key=/etc/tls/tls.key \ + --dry-run > /dev/null + + - name: Test objectStorage without bucket fails + run: | + if helm template test-release charts/s2-lite-helm \ + --set objectStorage.enabled=true \ + --dry-run 2>&1; then + echo "Expected failure but got success" + exit 1 + fi + + - name: Test with persistent volume and WAL storage + run: | + helm template test-release charts/s2-lite-helm \ + --set persistentVolume.enabled=true \ + --set walStorage.persistentVolume.enabled=true \ + --dry-run > /dev/null + helm template test-release charts/s2-lite-helm \ + --set objectStorage.enabled=true \ + --set objectStorage.bucket=test-bucket \ + --set walStorage.bucket=test-wal-bucket \ + --dry-run > /dev/null + + build-server: + name: Build s2-lite + needs: rust-dependency-cooldown + uses: ./.github/workflows/build-s2-lite.yml + with: + ref: ${{ github.sha }} + + sdk-integration-tests: + name: SDKs <> s2-lite Integration Tests + needs: [build-server, test, clippy] + uses: ./.github/workflows/sdk-tests.yml + with: + mode: local + server-binary: server + server-args: "--port 8080" + server-port: 8080 + sdks: | + [ + { + "name": "go", + "repo": "s2-streamstore/s2-sdk-go", + "ref": "main", + "lang": "go", + "go-version": "1.24", + "test_cmd": "go test -v -count=1 -skip 'WithScope|AccessToken|Metrics|Client_InvalidToken|Location' ./s2/..." + }, + { + "name": "typescript", + "repo": "s2-streamstore/s2-sdk-typescript", + "ref": "main", + "lang": "bun", + "bun-version": "latest", + "test_cmd": "S2_LITE=1 bun run vitest --run --exclude '**/account-basin*' --exclude '**/accessTokens*' --exclude '**/metrics*'" + }, + { + "name": "python", + "repo": "s2-streamstore/s2-sdk-python", + "ref": "main", + "lang": "python", + "uv-version": "0.11.6", + "test_cmd": "uv run pytest tests/ -v -s -m '(account or basin or stream) and not access_tokens and not locations'" + }, + { + "name": "rust", + "repo": "${{ github.repository }}", + "ref": "${{ github.sha }}", + "lang": "rust", + "test_cmd": "cargo test --locked -p s2-sdk --all-features -- --skip access_token --skip metrics" + } + ] + + rust-sdk: + name: Rust SDK + needs: [test, clippy, changes] + if: needs.changes.outputs.sdk == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Check docs + run: cargo doc --locked -p s2-sdk --all-features --no-deps + env: + RUSTDOCFLAGS: "-D warnings" + - name: Run tests + run: cargo test --locked -p s2-sdk --all-features + env: + S2_ACCESS_TOKEN: ${{ secrets.S2_ACCESS_TOKEN_FOR_RUST_SDK_TESTS }} + + cli-integration-tests: + name: CLI <> s2-lite Integration Tests + needs: [build-server, test, clippy] + uses: ./.github/workflows/sdk-tests.yml + with: + mode: local + server-binary: server + server-args: "--port 8080" + server-port: 8080 + sdks: | + [ + { + "name": "cli", + "repo": "${{ github.repository }}", + "ref": "${{ github.sha }}", + "lang": "rust", + "test_cmd": "cargo test --locked -p s2-cli --test integration -j 1" + } + ] diff --git a/.github/workflows/helm-release.yml b/.github/workflows/helm-release.yml new file mode 100644 index 00000000..6e291dcf --- /dev/null +++ b/.github/workflows/helm-release.yml @@ -0,0 +1,64 @@ +name: Release Helm Charts + +on: + push: + branches: + - main + paths: + - 'charts/**' + - '.github/workflows/helm-release.yml' + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write # Push to gh-pages and create releases + packages: write # Push to GHCR + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + + - name: Install Helm + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 + with: + version: v3.14.0 + + - name: Run chart-releaser + uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0 + with: + charts_dir: charts + skip_existing: true + env: + CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + CR_GENERATE_RELEASE_NOTES: "true" + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push charts to GHCR + run: | + shopt -s nullglob + packages=(.cr-release-packages/*.tgz) + if [ ${#packages[@]} -eq 0 ]; then + echo "No packages from chart-releaser, packaging charts directly..." + # NOTE: assumes a single chart (s2-lite-helm). If more charts are added, filter to only changed ones. + for chart in charts/*/; do + helm package "$chart" -d .cr-release-packages/ + done + packages=(.cr-release-packages/*.tgz) + fi + for pkg in "${packages[@]}"; do + echo "Pushing $pkg to GHCR" + helm push "${pkg}" oci://ghcr.io/${{ github.repository_owner }}/charts + done diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 00000000..f1c2e3f1 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,32 @@ +name: PR Title + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +jobs: + validate: + name: Validate PR Title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert + requireScope: false + subjectPattern: ^.+$ + subjectPatternError: | + The subject "{subject}" is not valid. + Please use a non-empty subject after the type/scope. diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 00000000..42fddca5 --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -0,0 +1,359 @@ +name: release-cli + +on: + push: + tags: + - 's2-cli-v*' + workflow_dispatch: + +permissions: + contents: write + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: s2-streamstore/s2 + +jobs: + update_helm_chart: + needs: [create_manifest] + runs-on: ubuntu-latest + steps: + - name: Generate GitHub token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + id: generate-token + with: + app-id: ${{ secrets.RELEASE_PLZ_APP_ID }} + private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + ref: main + token: ${{ steps.generate-token.outputs.token }} + + - name: Get version from tag + id: version + run: | + if [[ "$GITHUB_REF_NAME" == s2-cli-v* ]]; then + VERSION="${GITHUB_REF_NAME#s2-cli-v}" + else + VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' cli/Cargo.toml | head -n1) + fi + if [ -z "$VERSION" ]; then + echo "ERROR: Could not determine CLI version (GITHUB_REF_NAME=$GITHUB_REF_NAME)" + exit 1 + fi + echo "CLI version: $VERSION" + echo "value=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Update Helm chart version and appVersion + run: | + # Get current chart version + CURRENT_VERSION=$(grep '^version:' charts/s2-lite-helm/Chart.yaml | awk '{print $2}') + if [ -z "$CURRENT_VERSION" ]; then + echo "ERROR: Could not read current chart version" + exit 1 + fi + echo "Current chart version: $CURRENT_VERSION" + + # Increment patch version (e.g., 0.1.0 -> 0.1.1) + NEW_VERSION=$(echo "$CURRENT_VERSION" | awk -F. '{$NF = $NF + 1; print}' OFS=.) + echo "New chart version: $NEW_VERSION" + + # Update both version and appVersion in Chart.yaml + sed -i "s/^version: .*/version: $NEW_VERSION/" charts/s2-lite-helm/Chart.yaml + sed -i 's/^appVersion: ".*"$/appVersion: "${{ steps.version.outputs.value }}"/' charts/s2-lite-helm/Chart.yaml + + - name: Commit and push + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + git add charts/s2-lite-helm/Chart.yaml + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "Bump s2-lite-helm chart to appVersion ${{ steps.version.outputs.value }}" + git pull --rebase + git push + fi + + build_binaries: + name: ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + include: + - os: ubuntu-22.04 + target: aarch64-unknown-linux-gnu + deps: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + - os: ubuntu-22.04 + target: x86_64-unknown-linux-musl + builder: cross + - os: ubuntu-22.04 + target: aarch64-unknown-linux-musl + builder: cross + - os: macos-latest + target: x86_64-apple-darwin + - os: macos-latest + target: aarch64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-msvc + - os: windows-latest + target: aarch64-pc-windows-msvc + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: recursive + - name: Resolve source revision + id: source_revision + shell: bash + run: echo "value=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + with: + rustflags: "" + target: ${{ matrix.target }} + - name: Install dependencies + if: matrix.deps != '' + run: ${{ matrix.deps }} + shell: bash + - name: Set CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + - name: Install cross + if: matrix.builder == 'cross' + uses: taiki-e/install-action@f23382d582832e41d5eb4fff2bddb06bc5adf8d3 # v2.62.20 + with: + tool: cross@0.2.5 + - name: Build + env: + # Stamped into the binary (cli/src/update/channel.rs) to mark it as + # an official release artifact for install-channel detection. + S2_BUILD_CHANNEL: release + S2_GIT_REV: ${{ steps.source_revision.outputs.value }} + run: ${{ matrix.builder || 'cargo' }} build --locked --release --package s2-cli --target ${{ matrix.target }} + - name: Verify source revision stamp + shell: bash + env: + S2_GIT_REV: ${{ steps.source_revision.outputs.value }} + run: | + BINARY="target/${{ matrix.target }}/release/s2" + if [ "${{ matrix.os }}" = "windows-latest" ]; then + BINARY="${BINARY}.exe" + fi + grep -aFq "$S2_GIT_REV" "$BINARY" + - name: Create pem and certificate.der files + if: matrix.os == 'macos-latest' + run: | + echo "${{ secrets.MACOS_PEM }}" | base64 -d -o macos.pem + echo "${{ secrets.MACOS_CERTIFICATE_DER }}" | base64 -d -o certificate.der + - name: Sign macos binary + if: matrix.os == 'macos-latest' + uses: indygreg/apple-code-sign-action@44d0985b7f4363198e80b6fea63ac3e9dd3e9957 # v1.1 + with: + input_path: target/${{ matrix.target }}/release/s2 + pem_file: macos.pem + certificate_der_file: certificate.der + sign: true + sign_args: "--code-signature-flags=runtime" + - name: Prepare artifacts + shell: bash + run: | + cd target/${{ matrix.target }}/release + if [ "${{ matrix.os }}" = "windows-latest" ]; then + 7z a ../../../s2-${{ matrix.target }}.zip s2.exe + else + zip -r ../../../s2-${{ matrix.target }}.zip s2 + fi + - name: App store connect api key + if: matrix.os == 'macos-latest' + run: echo "${{ secrets.APP_STORE_CONNECT_API_KEY }}" | base64 -d -o app_store_connect_api_key.json + - name: Notarize macos binary + if: matrix.os == 'macos-latest' + uses: indygreg/apple-code-sign-action@44d0985b7f4363198e80b6fea63ac3e9dd3e9957 # v1.1 + with: + input_path: s2-${{ matrix.target }}.zip + sign: false + notarize: true + app_store_connect_api_key_json_file: app_store_connect_api_key.json + - name: Upload artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.target }} + path: | + *.zip + if-no-files-found: error + + build_images: + needs: build_binaries + name: Build ${{ matrix.arch }} Docker image + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: true + matrix: + include: + - arch: x86-64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: recursive + - name: Resolve source revision + id: source_revision + shell: bash + run: echo "value=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Log in to Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Get version + id: version + uses: SebRollen/toml-action@b1b3628f55fc3a28208d4203ada8b737e9687876 # v1.2.0 + with: + file: cli/Cargo.toml + field: package.version + + - name: Build and push image + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + with: + context: . + file: Dockerfile + target: runtime + push: true + build-args: | + S2_GIT_REV=${{ steps.source_revision.outputs.value }} + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.value }}-${{ matrix.arch }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }} + + create_manifest: + name: Create multi-arch Docker manifest + needs: build_images + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Log in to Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Get version + id: version + uses: SebRollen/toml-action@b1b3628f55fc3a28208d4203ada8b737e9687876 # v1.2.0 + with: + file: cli/Cargo.toml + field: package.version + + - name: Create and push versioned manifest + run: | + docker buildx imagetools create -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.value }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.value }}-x86-64 \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.value }}-arm64 + + - name: Create and push latest manifest + run: | + docker buildx imagetools create -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-x86-64 \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-arm64 + + upload_release_artifacts: + needs: [build_binaries, create_manifest] + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Get version + id: version + uses: SebRollen/toml-action@b1b3628f55fc3a28208d4203ada8b737e9687876 # v1.2.0 + with: + file: cli/Cargo.toml + field: package.version + - name: Download artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: '*-*-*' + - name: Generate checksums + # `s2 update` downloads this file to verify an artifact before + # replacing the binary in place. Names are bare (no directory) to match + # the uploaded asset names below. + run: | + find . -name 's2-*.zip' -print0 | while IFS= read -r -d '' f; do + printf '%s %s\n' "$(shasum -a 256 "$f" | cut -d' ' -f1)" "$(basename "$f")" + done | sort -k2 > SHA256SUMS + cat SHA256SUMS + - name: Upload to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="s2-cli-v${{ steps.version.outputs.value }}" + gh release upload "$TAG" */s2-*.zip SHA256SUMS --clobber || gh release create "$TAG" */s2-*.zip SHA256SUMS --title "$TAG" --notes "" + + update_homebrew: + needs: upload_release_artifacts + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Get version + id: version + uses: SebRollen/toml-action@b1b3628f55fc3a28208d4203ada8b737e9687876 # v1.2.0 + with: + file: cli/Cargo.toml + field: package.version + - name: Download artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: '*-*-*' + - name: Calculate checksums + run: | + LINUX_INTEL_SHA256=$(shasum -a 256 x86_64-unknown-linux-gnu/s2-x86_64-unknown-linux-gnu.zip | awk '{print $1}') + echo "LINUX_INTEL_SHA256=$LINUX_INTEL_SHA256" >> $GITHUB_ENV + LINUX_ARM_SHA256=$(shasum -a 256 aarch64-unknown-linux-gnu/s2-aarch64-unknown-linux-gnu.zip | awk '{print $1}') + echo "LINUX_ARM_SHA256=$LINUX_ARM_SHA256" >> $GITHUB_ENV + MAC_INTEL_SHA256=$(shasum -a 256 x86_64-apple-darwin/s2-x86_64-apple-darwin.zip | awk '{print $1}') + echo "MAC_INTEL_SHA256=$MAC_INTEL_SHA256" >> $GITHUB_ENV + MAC_ARM_SHA256=$(shasum -a 256 aarch64-apple-darwin/s2-aarch64-apple-darwin.zip | awk '{print $1}') + echo "MAC_ARM_SHA256=$MAC_ARM_SHA256" >> $GITHUB_ENV + - name: Checkout homebrew repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: 's2-streamstore/homebrew-s2' + token: ${{ secrets.HOMEBREW_PAT }} + - name: Update formula + run: | + sed -i.bak "s/^ version \".*\"$/ version \"${{ steps.version.outputs.value }}\"/" s2.rb + sed -z -i -e 's/[0-9a-f]\{64\}/${{ env.MAC_INTEL_SHA256 }}/1' s2.rb + sed -z -i -e 's/[0-9a-f]\{64\}/${{ env.MAC_ARM_SHA256 }}/2' s2.rb + sed -z -i -e 's/[0-9a-f]\{64\}/${{ env.LINUX_INTEL_SHA256 }}/3' s2.rb + sed -z -i -e 's/[0-9a-f]\{64\}/${{ env.LINUX_ARM_SHA256 }}/4' s2.rb + - name: Push formula + run: | + git config --global user.email "mehul@s2.dev" + git config --global user.name "Mehul Arora" + git add s2.rb + git commit -m "Update S2 to ${{ steps.version.outputs.value }}" + git push diff --git a/.github/workflows/release-crates.yml b/.github/workflows/release-crates.yml new file mode 100644 index 00000000..bd3972e8 --- /dev/null +++ b/.github/workflows/release-crates.yml @@ -0,0 +1,40 @@ +name: release-crates + +on: + pull_request: + types: [closed] + workflow_dispatch: + +permissions: + contents: write + +jobs: + release_plz: + if: | + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')) + runs-on: ubuntu-latest + steps: + - name: Generate GitHub token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + id: generate-token + with: + app-id: ${{ secrets.RELEASE_PLZ_APP_ID }} + private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + submodules: recursive + + - name: Install Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + + - name: Run release-plz release + uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 # v0.5.128 + with: + command: release + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml new file mode 100644 index 00000000..ef380af4 --- /dev/null +++ b/.github/workflows/release-plz.yml @@ -0,0 +1,39 @@ +name: release-plz + +on: + push: + branches: + - main + paths-ignore: + - 'charts/**' + workflow_dispatch: + +permissions: + pull-requests: write + contents: write + +jobs: + release-plz: + runs-on: ubuntu-latest + steps: + - name: Generate GitHub token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + id: generate-token + with: + app-id: ${{ secrets.RELEASE_PLZ_APP_ID }} + private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Install Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + + - name: Run release-plz + uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 # v0.5.128 + with: + command: release-pr + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/rust-dependency-cooldown-tests.yml b/.github/workflows/rust-dependency-cooldown-tests.yml new file mode 100644 index 00000000..76f56ecc --- /dev/null +++ b/.github/workflows/rust-dependency-cooldown-tests.yml @@ -0,0 +1,22 @@ +name: Rust dependency cooldown tests + +on: + pull_request: + paths: + - .github/actions/rust-dependency-cooldown/** + - .github/workflows/rust-dependency-cooldown-tests.yml + push: + branches: [main] + paths: + - .github/actions/rust-dependency-cooldown/** + - .github/workflows/rust-dependency-cooldown-tests.yml + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - run: python3 -m unittest discover -s .github/actions/rust-dependency-cooldown -p 'test_*.py' -v diff --git a/.github/workflows/rust-dependency-cooldown.yml b/.github/workflows/rust-dependency-cooldown.yml new file mode 100644 index 00000000..067b8edc --- /dev/null +++ b/.github/workflows/rust-dependency-cooldown.yml @@ -0,0 +1,25 @@ +name: Rust dependency cooldown gate + +on: + workflow_call: + +permissions: + contents: read + +jobs: + rust-dependency-cooldown: + name: Rust dependency cooldown gate + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + if: github.event_name == 'pull_request' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 2 + persist-credentials: false + - name: Check Rust dependency cooldown + if: github.event_name == 'pull_request' + uses: $/.github/actions/rust-dependency-cooldown + with: + base-sha: HEAD^1 diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml new file mode 100644 index 00000000..de82e045 --- /dev/null +++ b/.github/workflows/sdk-tests.yml @@ -0,0 +1,163 @@ +name: SDK Integration Tests + +on: + workflow_call: + inputs: + mode: + description: "local or remote" + required: false + type: string + default: "local" + + server-binary: + description: "Binary name in downloaded artifact" + required: false + type: string + default: "s2-mem" + server-args: + description: "Arguments to pass to server" + required: false + type: string + default: "" + server-port: + description: "Port the server listens on (local mode)" + required: false + type: number + default: 4243 + + account-endpoint: + description: "Account endpoint" + required: false + type: string + basin-endpoint: + description: "Basin endpoint" + required: false + type: string + + sdks: + description: "JSON array of SDK configs" + required: true + type: string + runner: + description: "Runner to use" + required: false + type: string + default: "ubuntu-latest" + + secrets: + GH_TOKEN: + required: false + S2_ACCESS_TOKEN: + required: false + +jobs: + test: + name: ${{ matrix.sdk.name }} + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + sdk: ${{ fromJson(inputs.sdks) }} + steps: + - name: Checkout SDK + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: ${{ matrix.sdk.repo }} + ref: ${{ matrix.sdk.ref }} + token: ${{ secrets.GH_TOKEN || github.token }} + path: sdk + + - name: Setup Go + if: matrix.sdk.lang == 'go' + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ matrix.sdk.go-version }} + cache-dependency-path: sdk/go.sum + + - name: Setup uv + if: matrix.sdk.lang == 'python' + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + version: ${{ matrix.sdk.uv-version }} + - name: Install dependencies (Python) + if: matrix.sdk.lang == 'python' + working-directory: sdk + run: uv sync --group test + + - name: Setup Node + if: matrix.sdk.lang == 'node' + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ matrix.sdk.node-version }} + cache: npm + cache-dependency-path: sdk/package-lock.json + + - name: Setup Bun + if: matrix.sdk.lang == 'bun' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: ${{ matrix.sdk.bun-version }} + - name: Install dependencies (Bun) + if: matrix.sdk.lang == 'bun' + working-directory: sdk + run: bun install + + - name: Setup Rust + if: matrix.sdk.lang == 'rust' + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: ${{ matrix.sdk.rust-version || 'stable' }} + + - name: Cache Cargo + if: matrix.sdk.lang == 'rust' + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: ${{ matrix.sdk.name }} + workspaces: sdk -> target + + - name: Download server binary + if: inputs.mode == 'local' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: server-binary + path: server + + - name: Start server + if: inputs.mode == 'local' + run: | + chmod +x server/${{ inputs.server-binary }} + server/${{ inputs.server-binary }} ${{ inputs.server-args }} > /tmp/server.log 2>&1 & + echo $! > /tmp/server.pid + for i in {1..30}; do + if curl -sf http://localhost:${{ inputs.server-port }}/health || curl -sf http://localhost:${{ inputs.server-port }}/ping; then + echo "Server is ready" + exit 0 + fi + echo "Waiting for server... ($i/30)" + sleep 1 + done + echo "Server failed to start" + cat /tmp/server.log + exit 1 + + - name: Extract token (local) + if: inputs.mode == 'local' + id: local-token + run: | + token=$(sed -n 's/.*S2_ACCESS_TOKEN="\([^"]*\)".*/\1/p' /tmp/server.log | head -1) + token="${token:-test}" + echo "::add-mask::$token" + echo "value=$token" >> "$GITHUB_OUTPUT" + + - name: Run tests + working-directory: sdk + env: + S2_ACCESS_TOKEN: ${{ inputs.mode == 'local' && steps.local-token.outputs.value || secrets.S2_ACCESS_TOKEN }} + S2_ACCOUNT_ENDPOINT: ${{ inputs.mode == 'local' && format('http://localhost:{0}', inputs.server-port) || inputs.account-endpoint }} + S2_BASIN_ENDPOINT: ${{ inputs.mode == 'local' && format('http://localhost:{0}', inputs.server-port) || inputs.basin-endpoint }} + run: ${{ matrix.sdk.test_cmd }} + + - name: Dump server logs + if: failure() && inputs.mode == 'local' + run: cat /tmp/server.log diff --git a/.github/workflows/sync-specs.yaml b/.github/workflows/sync-specs.yaml new file mode 100644 index 00000000..071955b8 --- /dev/null +++ b/.github/workflows/sync-specs.yaml @@ -0,0 +1,34 @@ +name: Sync specs submodule + +permissions: + contents: write + pull-requests: write + +on: + repository_dispatch: + types: [s2-specs-update] + +jobs: + sync-submodule: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: true + + - name: Update submodule + id: submodules + uses: sgoudham/update-git-submodules@85ea2b33c7a1e8ac4746d0726c74f26d01c46816 # v2.1.3 + with: + submodules: api/specs + + - name: Create pull request + if: ${{ steps.submodules.outputs['api/specs--updated'] }} + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + with: + committer: s2-helper[bot] <194906454+s2-helper[bot]@users.noreply.github.com> + author: s2-helper[bot] <194906454+s2-helper[bot]@users.noreply.github.com> + sign-commits: true + title: "chore: sync specs submodule" + branch: "specs/sync-${{ steps.submodules.outputs['api/specs--latestShortCommitSha'] }}" + body: ${{ steps.submodules.outputs.prBody }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..162cd62d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +.DS_Store +history.*.jsonl +porcupine-outputs/ +rustc-ice-*.txt diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..a2252d13 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "api/specs"] + path = api/specs + url = https://github.com/s2-streamstore/s2-specs.git diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 00000000..76f7efe3 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,8 @@ +max_width = 100 + +group_imports = "StdExternalCrate" +imports_granularity = "Crate" +imports_layout = "Mixed" + +comment_width = 100 +wrap_comments = true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e2ad9644 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +## Local Conventions + +- Formatting: run `just fmt` +- Tests: run `just test` +- PR title + description become the squashed commit message at merge time; use conventional commit format + +## Cargo Dependency Safety + +- Use `--locked` for Cargo commands that build, check, test, run, document, fetch, or read metadata. +- The simulator is temporarily exempt until its separate lockfile is regenerated. +- Use `cargo +nightly add`, `cargo +nightly update`, `cargo +nightly remove`, or `cargo +nightly generate-lockfile` for dependency changes. The repository Cargo configuration applies the publication cooldown. +- Do not use the stable forms of these dependency commands. +- Do not edit dependency declarations or `Cargo.lock` directly. +- Do not install Cargo tools as part of a coding task. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..3a5b6c33 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7586 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aegis" +version = "0.9.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58541132f980da31e9aa99f7bdee69bc84bf1e168b9b91ef2dbe8abb7b4ce5dd" +dependencies = [ + "cc", + "softaes", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ctutils", + "ghash", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[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", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "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.119", + "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.119", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + +[[package]] +name = "astral-tokio-tar" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" +dependencies = [ + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "rustix 1.1.4", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "asyncband" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d85fd3d291fabcc40c7232c92c280ec1754fd7b5d7ea769f143222143e179a" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-config" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d7b388a9fc3a6db15a5ec778c38b354eff1364882c94d08e0252f7a47dcaa4" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.5.0", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "aws-runtime" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef47857a1d4488b528f4a5d5715fa7c3300820897824152234d3fa22b1426657" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.5.0", + "http-body 1.1.0", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.109.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3cfe74df5d9ad2fedd691973ad3521ebf4f27a3c68c792556686aedb5519bab" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.111.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b0ec31ed6191bd11350aae4b2004198f2db21350cb0a20c57e0a92e55dd161" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef45745026107ec30c4ef86bd8ae4b002e7e5f6a86e4225240bdf6b06a0b944a" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.5.0", + "percent-encoding", + "sha2", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebfd138fac0337cee7516c352757ea73b9f2266e57d0bcb5bc70e9547e45aef1" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.5.0", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512346c7212ab7436df2d77a16d976a468ae44a418835511d2a69269810aaf62" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b82e438d30e02a825d363bd639a9efaed68a8089d86101054b0081e7e0d3e606" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "http-body 0.4.6", + "http-body 1.1.0", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c054752dd9e4dc73d0b75748c99ac2d0feafbf2f25c7b0516f03a3534161223" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.5.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.5.0", +] + +[[package]] +name = "aws-smithy-types" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f94d16e797ec62cd999fc9d5942b48fa7050c3093ddadff48e4d7528d16fcb9" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.5.0", + "http-body 0.4.6", + "http-body 1.1.0", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce84f71c72fee2cbbadde6e7d082f5fb466e3a84733855295fa7aafd1b31b7d8" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "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 1.5.0", + "http-body 1.1.0", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http 1.5.0", + "http-body 1.1.0", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[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 = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[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 = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bollard" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags 2.13.2", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http 1.5.0", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.10.2", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5c97450e79c7c565302dd92e86b08823b47550fcb4fc5ce910194d1b087a1a3" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", +] + +[[package]] +name = "bollard-stubs" +version = "1.53.1-rc.29.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost", + "serde", + "serde_json", + "serde_repr", + "time", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "bytesize" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "color-print" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aa954171903797d5623e047d9ab69d91b493657917bdfb8c2c80ecaf9cdb6f4" +dependencies = [ + "color-print-proc-macro", +] + +[[package]] +name = "color-print-proc-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692186b5ebe54007e45a59aea47ece9eb4108e141326c304cdc91699a7118a22" +dependencies = [ + "nom", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rkyv", + "serde", + "static_assertions", + "zmij", +] + +[[package]] +name = "compression-codecs" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8" +dependencies = [ + "compression-core", + "flate2", + "memchr", + "zstd 0.14.0", + "zstd-safe 8.0.0", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" +dependencies = [ + "async-trait", + "convert_case", + "json5", + "pathdiff", + "ron", + "rust-ini", + "serde-untagged", + "serde_core", + "serde_json", + "toml 1.1.6+spec-1.1.0", + "winnow 1.0.4", + "yaml-rust2", +] + +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_affinity" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342" +dependencies = [ + "libc", + "num_cpus", + "winapi", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "datasketches" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c4cf71a36b46dcfc00e5014c0c20ccad2b1b6a008304d7d57d2749b2d41b3d" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "duration-str" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f88959de2d447fd3eddcf1909d1f19fe084e27a056a6904203dc5d8b9e771c1e" +dependencies = [ + "rust_decimal", + "serde", + "thiserror 2.0.20", + "time", + "winnow 0.6.26", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumset" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "eyre" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" +dependencies = [ + "autocfg", + "indenter", + "once_cell", +] + +[[package]] +name = "fail-parallel" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c29b33a0187823f1fa88b36980227dc96c7504ede2288e7d2a77d9d6d88b260c" +dependencies = [ + "log", + "once_cell", + "rand 0.9.5", + "tokio", +] + +[[package]] +name = "fastant" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e825441bfb2d831c47c97d05821552db8832479f44c571b97fededbf0099c07" +dependencies = [ + "small_ctor", + "web-time", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ferroid" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" +dependencies = [ + "portable-atomic", + "rand 0.10.2", + "web-time", +] + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "serde_json", + "serde_yaml", + "toml 0.8.23", + "uncased", + "version_check", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags 2.13.2", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "foyer" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a59f42276891c0a4ce683fcda1aa1b4fd1065d68116c264e4bf49b9d318a0ed" +dependencies = [ + "anyhow", + "asyncband", + "equivalent", + "foyer-common", + "foyer-memory", + "foyer-storage", + "foyer-tokio", + "futures-util", + "mixtrics", + "pin-project", + "serde", + "tracing", +] + +[[package]] +name = "foyer-common" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643b47d510032a01e5af70dca288b0c5ec531646c1a8392e793fb5b0c13bef30" +dependencies = [ + "anyhow", + "bincode", + "bytes", + "cfg-if", + "foyer-tokio", + "mixtrics", + "parking_lot", + "pin-project", + "serde", + "twox-hash", +] + +[[package]] +name = "foyer-intrusive-collections" +version = "0.10.0-dev" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4fee46bea69e0596130e3210e65d3424e0ac1e6df3bde6636304bdf1ca4a3b" +dependencies = [ + "memoffset", +] + +[[package]] +name = "foyer-memory" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae51f5089f3d9025ae77f17ea66d2489ac0f82fbb1d91462807bea174d486d82" +dependencies = [ + "anyhow", + "asyncband", + "bitflags 2.13.2", + "datasketches", + "equivalent", + "foyer-common", + "foyer-intrusive-collections", + "foyer-tokio", + "futures-util", + "hashbrown 0.17.1", + "itertools 0.15.0", + "mixtrics", + "parking_lot", + "paste", + "pin-project", + "serde", + "tracing", +] + +[[package]] +name = "foyer-storage" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "118192f532ba013f0cdc607efc22324b9ed855baed185608af0daa986e835c6b" +dependencies = [ + "allocator-api2", + "anyhow", + "asyncband", + "bytes", + "core_affinity", + "equivalent", + "fastant", + "foyer-common", + "foyer-memory", + "foyer-tokio", + "fs4", + "futures-core", + "futures-util", + "hashbrown 0.17.1", + "io-uring", + "itertools 0.15.0", + "libc", + "lz4", + "parking_lot", + "pin-project", + "rand 0.10.2", + "serde", + "tracing", + "twox-hash", + "zstd 0.13.3", +] + +[[package]] +name = "foyer-tokio" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6741d1133dfaab64d3f8a9290bbfed3685084add28f8b6ee7a6264aa4dc26918" +dependencies = [ + "tokio", +] + +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", + "zeroize", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.5.0", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http 1.5.0", + "http-body 1.1.0", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.5.0", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width 0.2.2", + "unit-prefix", + "web-time", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[package]] +name = "io-uring" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "json_to_table" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec64c9908ffa97b6ef6044d96c9f56de1c81643d3b9fbc2823e8847ab11b467" +dependencies = [ + "serde_json", + "tabled", +] + +[[package]] +name = "keyring" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +dependencies = [ + "twox-hash", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mixtrics" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c46b5adfb7a3ae4996d327a5bdc90e78fec025806dd312bdbe6f07a755e0ec9" +dependencies = [ + "itertools 0.15.0", + "parking_lot", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.5", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[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.119", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[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 = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[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" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "object_store" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64 0.22.1", + "bytes", + "chrono", + "crc-fast", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body-util", + "humantime", + "hyper", + "itertools 0.15.0", + "md-5", + "nix", + "parking_lot", + "percent-encoding", + "quick-xml", + "rand 0.10.2", + "reqwest", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.20", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", + "windows-sys 0.61.2", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" + +[[package]] +name = "papergrid" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0984e668274d34691bc2b262ef0d115de5fa9973bcdee7ae32213f93099153e" +dependencies = [ + "bytecount", + "fnv", + "unicode-width 0.2.2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "parse-display" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78deb158fb1d73b29efb4b7e9b9860b78059c670de06bd28df8d0b458ded0eb" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e95a50d1084dab562913062c4c34bb204b68fc6ec38a1395909ff5aaaf4f10a" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.119", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" +dependencies = [ + "pest", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.2", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", + "zeroize", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.15+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + +[[package]] +name = "procfs" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" +dependencies = [ + "bitflags 2.13.2", + "hex", + "procfs-core", + "rustix 0.38.44", +] + +[[package]] +name = "procfs-core" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" +dependencies = [ + "bitflags 2.13.2", + "hex", +] + +[[package]] +name = "prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "libc", + "memchr", + "parking_lot", + "procfs", + "protobuf", + "thiserror 2.0.20", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec 0.8.0", + "bitflags 2.13.2", + "num-traits", + "rand 0.9.5", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "ptr_meta" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rancor" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.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.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[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 = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rend" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "h2", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "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 = [ + "bytecheck", + "bytes", + "hashbrown 0.17.1", + "indexmap 2.14.2", + "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.5", +] + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.2", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rstest" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948203e6d13b83e51a90d7cf236dd58a0065f23f4b902f00f306e7eb2bd09b9c" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d92eaf7b6e51e12471d71ddc72608e7151573de44099aacfbb1128177c0da4" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.119", + "unicode-ident", +] + +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "num-traits", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "s2-api" +version = "0.32.0" +dependencies = [ + "axum", + "base64ct", + "bytes", + "compact_str", + "flate2", + "futures", + "futures-core", + "futures-util", + "http 1.5.0", + "itertools 0.15.0", + "mime", + "proptest", + "prost", + "prost-build", + "rstest", + "s2-common", + "serde", + "serde_json", + "strum", + "thiserror 2.0.20", + "time", + "tokio-util", + "utoipa", + "zstd 0.14.0", +] + +[[package]] +name = "s2-cli" +version = "0.43.1" +dependencies = [ + "assert_cmd", + "async-stream", + "async-trait", + "axum", + "base64ct", + "bytes", + "clap", + "color-print", + "colored", + "compact_str", + "config", + "dirs", + "fs2", + "futures", + "http-body-util", + "humantime", + "hyper", + "hyper-util", + "indicatif", + "json_to_table", + "keyring", + "miette", + "predicates", + "proptest", + "rand 0.10.2", + "reqwest", + "rpassword", + "rstest", + "rustls", + "s2-api", + "s2-common", + "s2-lite", + "s2-resource-spec", + "s2-sdk", + "secrecy", + "self-replace", + "semver", + "serde", + "serde_json", + "serial_test", + "sha2", + "strum", + "tabled", + "tempfile", + "terminal_size", + "thiserror 2.0.20", + "tikv-jemallocator", + "tokio", + "tokio-stream", + "toml 1.1.6+spec-1.1.0", + "tracing", + "tracing-subscriber", + "uuid", + "xxhash-rust", + "zip", +] + +[[package]] +name = "s2-common" +version = "0.42.0" +dependencies = [ + "axum", + "base64ct", + "bytes", + "clap", + "compact_str", + "enumset", + "http 1.5.0", + "proptest", + "rand 0.10.2", + "rkyv", + "rstest", + "secrecy", + "serde", + "serde_json", + "strum", + "thiserror 2.0.20", + "time", + "utoipa", +] + +[[package]] +name = "s2-lite" +version = "0.43.1" +dependencies = [ + "async-stream", + "async-trait", + "aws-config", + "aws-credential-types", + "axum", + "axum-server", + "bytes", + "bytesize", + "clap", + "dashmap", + "eyre", + "futures", + "http 1.5.0", + "indexmap 2.14.2", + "itertools 0.15.0", + "parking_lot", + "prometheus", + "proptest", + "prost", + "rand 0.10.2", + "rcgen", + "rstest", + "rustls", + "s2-api", + "s2-common", + "s2-resource-spec", + "s2-storage", + "serde", + "serde_json", + "slatedb", + "strum", + "thiserror 2.0.20", + "tikv-jemallocator", + "time", + "tokio", + "tokio-util", + "tower", + "tower-http 0.7.1", + "tracing", + "tracing-subscriber", + "utoipa", + "uuid", +] + +[[package]] +name = "s2-resource-spec" +version = "0.3.0" +dependencies = [ + "compact_str", + "humantime", + "s2-common", + "schemars 1.2.2", + "serde", + "serde_json", +] + +[[package]] +name = "s2-sdk" +version = "0.35.1" +dependencies = [ + "assert_matches", + "async-compression", + "async-stream", + "async-trait", + "bytes", + "compact_str", + "futures-core", + "futures-util", + "h2", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "proptest", + "prost", + "rand 0.10.2", + "rstest", + "rustls", + "s2-api", + "s2-common", + "secrecy", + "serde", + "serde_json", + "serde_urlencoded", + "test-context", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-muxt", + "tokio-shared-rt", + "tokio-stream", + "tokio-util", + "tracing", + "urlencoding", + "uuid", +] + +[[package]] +name = "s2-storage" +version = "0.2.6" +dependencies = [ + "aegis", + "aes-gcm", + "blake3", + "bytes", + "proptest", + "rand 0.10.2", + "rstest", + "s2-common", + "secrecy", + "serde", + "thiserror 2.0.20", +] + +[[package]] +name = "s2-testcontainers" +version = "0.43.1" +dependencies = [ + "reqwest", + "s2-sdk", + "testcontainers", + "thiserror 2.0.20", + "tokio", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes", + "cbc", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap 2.14.2", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.2", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serial_test" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6df5ed973ad8d834e09f824f9e9f449af6b9a3745f78dec7cc752770bd3bf11" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slatedb" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb40332f41e231926df3a0ef742c05316b43368ba4b520433d2a1eba1ab00f0f" +dependencies = [ + "async-channel", + "async-trait", + "atomic", + "backon", + "bitflags 2.13.2", + "bytes", + "chrono", + "crc32fast", + "crossbeam-skiplist", + "dotenvy", + "duration-str", + "fail-parallel", + "figment", + "flatbuffers", + "foyer", + "futures", + "log", + "lru", + "lz4_flex", + "object_store", + "ouroboros", + "parking_lot", + "rand 0.9.5", + "serde", + "serde_json", + "siphasher", + "slatedb-common", + "slatedb-txn-obj", + "smallvec", + "sysinfo", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", + "ulid", + "url", + "uuid", + "walkdir", + "zstd 0.13.3", +] + +[[package]] +name = "slatedb-common" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b77f238f348e95e1653a5235f880ac703011b82582bd556f2aba405abf8180e" +dependencies = [ + "chrono", + "log", + "object_store", + "rand 0.9.5", + "rand_xoshiro", + "serde", + "thread_local", + "tokio", +] + +[[package]] +name = "slatedb-txn-obj" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad63b6c250219e26d71f497820f970274c301f896366daa56915f2a05df0e0a" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "log", + "object_store", + "parking_lot", + "slatedb-common", + "thiserror 1.0.69", +] + +[[package]] +name = "small_ctor" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88414a5ca1f85d82cc34471e975f0f74f6aa54c40f062efa42c0080e7f763f81" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softaes" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e14297decde697ddf377c25752aead0927d5cfc89c2684d2af96901a4ceeea" + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.119", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sysinfo" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3ffa3e4ff2b324a57f7aeb3c349656c7b127c3c189520251a648102a92496e" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tabled" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2596a104db1900b943f97d793a6c99addca918c4525c999f751a0f17d472eb" +dependencies = [ + "papergrid", + "tabled_derive", + "testing_table", +] + +[[package]] +name = "tabled_derive" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dca1937322a1e892b1a65f6a6736183bb0a29d3b4234d1d53bc436dff9beb76" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "test-context" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31a7a185e98df2b3ca0bce61e77f266ab6bf5b91dc3b62b5f1bcb42bf70c0de" +dependencies = [ + "futures", + "test-context-macros", +] + +[[package]] +name = "test-context-macros" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b736612c12990695ce435f17b904485a75f0db77edbaf4725590a240437b078" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "testcontainers" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2bbe381afaaa58ea610c5fc3ffb2184063a32b3e358a179f0b4865dd59934a" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera", + "ferroid", + "futures", + "http 1.5.0", + "itertools 0.14.0", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "testing_table" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" +dependencies = [ + "unicode-width 0.2.2", +] + +[[package]] +name = "textwrap" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b81c0cb5fce14f53e49c1d4da0c508334ff12040221bb8ab01b2dabd91d04b6e" +dependencies = [ + "icu_segmenter", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tikv-jemalloc-sys" +version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2825c78386b4ae0314074867860ba9577875de945f05992c38815cbec327f0" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249f09e49ab1609436f34c776e84231bead18d6a955f119f939bdc1d847561bd" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-muxt" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79eac432d68f5cc5ca99654adddb6ffc9501618bc267303d18a1f768ea01e7c" +dependencies = [ + "pin-project", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-shared-rt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6bb03ec682a0bb16ce93d19301abc5b98a0d7936477175a156a213dcc47d85" +dependencies = [ + "once_cell", + "tokio", + "tokio-shared-rt-macro", +] + +[[package]] +name = "tokio-shared-rt-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe49a94e3a984b0d0ab97343dc3dcd52baae1ee13f005bfad39faea47d051dc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "hashbrown 0.15.5", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 2.14.2", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "async-compression", + "bitflags 2.13.2", + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.5", + "serde", + "web-time", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "git+https://github.com/infiniteregrets/utoipa?rev=82bcb28a792ba9a0d29963827ec473823099fc94#82bcb28a792ba9a0d29963827ec473823099fc94" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "git+https://github.com/infiniteregrets/utoipa?rev=82bcb28a792ba9a0d29963827ec473823099fc94#82bcb28a792ba9a0d29963827ec473823099fc94" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "rand 0.10.2", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.6.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + +[[package]] +name = "yaml-rust2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b36710ce3a279cfce8465dbab826f161675a262950b922cb2c3663852dfe9eb0" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.2", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe 7.3.0", +] + +[[package]] +name = "zstd" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e" +dependencies = [ + "zstd-safe 8.0.0", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-safe" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..a76dfc09 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,111 @@ +[workspace] +resolver = "2" +members = [ + "api", + "cli", + "common", + "lite", + "resource-spec", + "sdk", + "storage", + "testcontainers", +] + +[workspace.package] +edition = "2024" +license = "MIT" +repository = "https://github.com/s2-streamstore/s2" +homepage = "https://s2.dev" + +[workspace.dependencies] +aegis = "0.9" +aes-gcm = "0.11" +async-stream = "0.3" +async-trait = "0.1" +aws-config = "1" +aws-credential-types = "1" +axum = "0.8" +axum-server = "0.8" +base64ct = "1.8" +blake3 = "1.8" +bytes = "1" +bytesize = "2.7" +clap = "4.6" +color-print = "0.3" +colored = "3.1" +compact_str = "0.10" +config = "0.15" +dashmap = "6.2" +dirs = "7.0" +enumset = "1.1" +eyre = "0.6" +flate2 = "1.1" +fs2 = "0.4" +futures = "0.3" +futures-core = "0.3" +futures-util = "0.3" +http = "1" +humantime = "2.4" +indexmap = "2.14" +indicatif = "0.18" +itertools = "0.15" +json_to_table = "0.14" +keyring = "4.2" +miette = "7.6" +mime = "0.3" +parking_lot = "0.12" +prometheus = "0.14" +proptest = "1.11" +prost = "0.14" +prost-build = "0.14" +rand = "0.10" +rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +rkyv = "0.8" +rpassword = "7.5" +rstest = "0.27" +rustls = { version = "0.23", default-features = false, features = ["logging", "std", "tls12"] } +s2-api = { path = "api", version = "0.32" } +s2-common = { path = "common", version = "0.42" } +s2-lite = { path = "lite", version = "0.43" } +s2-resource-spec = { path = "resource-spec", version = "0.3" } +s2-sdk = { path = "sdk", version = "0.35" } +s2-storage = { path = "storage", version = "0.2" } +schemars = "1.2" +secrecy = "0.10.3" +semver = "1.0" +serde = "1.0" +serde_json = "1.0" +slatedb = "0.16.0" +strum = "0.28" +tabled = "0.22" +tempfile = "3.27" +testcontainers = "0.28" +thiserror = "2.0" +tikv-jemallocator = { version = "0.7", features = ["unprefixed_malloc_on_supported_platforms"] } +time = "0.3" +tokio = "1.53" +tokio-stream = "0.1" +tokio-util = "0.7" +toml = "1.1" +tower-http = "0.7" +tracing = "0.1" +tracing-subscriber = "0.3" +utoipa = "=5.4" +uuid = "1.26" +xxhash-rust = "0.8" +zstd = "0.14" + +[patch.crates-io] +utoipa = { git = "https://github.com/infiniteregrets/utoipa", rev = "82bcb28a792ba9a0d29963827ec473823099fc94" } + +[profile.dev] +panic = "abort" + +[profile.release] +panic = "abort" +lto = true + +[profile.ci] +inherits = "release" +lto = false diff --git a/Cross.toml b/Cross.toml new file mode 100644 index 00000000..1292dbdf --- /dev/null +++ b/Cross.toml @@ -0,0 +1,8 @@ +[build.env] +passthrough = ["S2_BUILD_CHANNEL", "S2_GIT_REV"] + +[target.x86_64-unknown-linux-musl] +image = "ghcr.io/cross-rs/x86_64-unknown-linux-musl@sha256:b21940afb5d20705a9b0b9f95f033a50efb0480eb0cf19a0588a252e0ce46989" + +[target.aarch64-unknown-linux-musl] +image = "ghcr.io/cross-rs/aarch64-unknown-linux-musl@sha256:13c8c2839a4ca78706d6885add4f836f9d1d5756518b29954d1e35821b4ffea8" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..c218d428 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# Build stage +FROM reg.mini.dev/rust:latest AS builder + +USER root + +WORKDIR /build + +# Stamped into the binary (cli/src/update/channel.rs) so it knows it was +# installed via the Docker image. +ARG S2_BUILD_CHANNEL=docker +ARG S2_GIT_REV=unknown + +# Use Docker BuildKit cache mounts for faster builds +RUN --mount=type=bind,source=api,target=/build/api \ + --mount=type=bind,source=common,target=/build/common \ + --mount=type=bind,source=lite,target=/build/lite \ + --mount=type=bind,source=resource-spec,target=/build/resource-spec \ + --mount=type=bind,source=sdk,target=/build/sdk \ + --mount=type=bind,source=storage,target=/build/storage \ + --mount=type=bind,source=testcontainers,target=/build/testcontainers \ + --mount=type=bind,source=cli,target=/build/cli \ + --mount=type=bind,source=Cargo.toml,target=/build/Cargo.toml \ + --mount=type=bind,source=Cargo.lock,target=/build/Cargo.lock \ + --mount=type=cache,id=s2-rust,sharing=locked,target=/build/target \ + --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ + --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ + S2_BUILD_CHANNEL="${S2_BUILD_CHANNEL}" \ + S2_GIT_REV="${S2_GIT_REV}" \ + cargo build --locked --release --package s2-cli --bin s2 + +# Copy the binary from the cache volume +RUN --mount=type=cache,id=s2-rust,sharing=locked,target=/build/target \ + mkdir -p /output && \ + cp /build/target/release/s2 /output/s2 + +# Debug runtime - ubuntu with shell access +# Build with: docker build --target debug . +FROM ubuntu:latest AS debug + +RUN apt-get update && \ + apt-get install -y ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /output/s2 /app/s2 + +ENTRYPOINT ["./s2"] + +# Production runtime (default) - minimal distroless image running as non-root (UID 65532) +FROM gcr.io/distroless/cc-debian13:nonroot AS runtime + +LABEL org.opencontainers.image.source="https://github.com/s2-streamstore/s2" +LABEL org.opencontainers.image.documentation="https://github.com/s2-streamstore/s2/releases" + +WORKDIR /app + +COPY --from=builder /output/s2 /app/s2 + +ENTRYPOINT ["./s2"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..53b0c74c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bandar Systems Inc. and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..6e3acc97 --- /dev/null +++ b/README.md @@ -0,0 +1,277 @@ +
+

+ + + + + + + + +

+ +

S2, the durable streams API

+ +

+ + + + + + +

+
+ +[s2.dev](https://s2.dev) is a serverless datastore for real-time, streaming data. + +This repository contains: +- **[s2-cli](cli/)** - Command-line interface for S2 +- **[s2-lite](lite/)** - Open source, self-hostable server implementation of the [S2 API](https://s2.dev/docs/api) +- **[s2-sdk](sdk/)** - Rust SDK for S2 + +## Development + +Use the nightly Cargo dependency commands so that the repository publication cooldown applies: + +```bash +cargo +nightly add +cargo +nightly update +cargo +nightly update -p +cargo +nightly remove +cargo +nightly generate-lockfile +``` + +Use `--locked` with normal build, check, test, run, document, fetch, and metadata commands. The simulator is temporarily exempt until its separate lockfile is regenerated. The pull request dependency check verifies every proposed lock-file change before Rust build jobs start. + +Install the repository Cargo tools from Homebrew bottles: + +```bash +brew install cargo-deny cargo-nextest +``` + +## Installation + +### Homebrew (macOS/Linux) +```bash +brew install s2-streamstore/s2/s2 +``` + +### Cargo +```bash +cargo install --locked s2-cli +``` + +### Release Binaries (macOS/Linux) +```bash +curl -fsSL https://raw.githubusercontent.com/s2-streamstore/s2/main/install.sh | bash +``` +Or specify a version with `VERSION=x.y.z` before the command. See all [releases](https://github.com/s2-streamstore/s2/releases). + +### Docker +```bash +docker pull ghcr.io/s2-streamstore/s2 +``` + +## Authentication + +Store an access token in the OS credential store: + +```bash +s2 auth access-token set +``` + +Or pipe it in from a script or a secret manager: + +```bash +op read 'op://S2/CLI/access-token' | s2 auth access-token set --stdin +``` + +For CI and other ephemeral environments, set `S2_ACCESS_TOKEN` in the environment. On a headless host with no credential store, opt into a private plaintext file instead (mode `0600` on Unix): + +```bash +printf '%s' "$S2_ACCESS_TOKEN" | + s2 auth access-token set --stdin --insecure-storage +unset S2_ACCESS_TOKEN +``` + +Plaintext `access_token` values in `config.toml` are deprecated. +Migrate one with `s2 auth access-token migrate`, or re-store it with `--insecure-storage` on a +headless host that has no credential store. + +## s2-lite + +`s2-lite` is embedded as the `s2 lite` subcommand of the CLI. It's a self-hostable server implementation of the S2 API. + +It uses [SlateDB](https://slatedb.io) as its storage engine, which relies entirely on object storage for durability. + +It is easy to run `s2 lite` against object stores like AWS S3 and Tigris. It is a single-node binary with no other external dependencies. + +You can also simply not specify a `--bucket`, which makes it operate entirely in-memory (or use `--local-root` to persist to local disk instead). + +> [!TIP] +> When you point lite at a `--bucket`, data is **always durable** on object storage before being acknowledged or returned to readers — just like [s2.dev](https://s2.dev). +> +> The _optional_ in-memory mode (no `--bucket` or `--local-root` specified) just makes it an effective S2 emulator for integration tests. + +### Quickstart + +Here's how you can run in-memory without any external dependency: +```bash +# Using Docker +docker run -p 8080:80 ghcr.io/s2-streamstore/s2 lite + +# Or directly with the CLI +s2 lite --port 8080 +``` + +
+AWS S3 bucket example + +```bash +docker run -p 8080:80 \ + -e AWS_PROFILE=${AWS_PROFILE} \ + -v ~/.aws:/home/nonroot/.aws:ro \ + ghcr.io/s2-streamstore/s2 lite \ + --bucket ${S3_BUCKET} \ + --path s2lite +``` +
+ +
+Static credentials example (Tigris, R2 etc) + +```bash +docker run -p 8080:80 \ + -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \ + -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \ + -e AWS_ENDPOINT_URL_S3=${AWS_ENDPOINT_URL_S3} \ + ghcr.io/s2-streamstore/s2 lite \ + --bucket ${S3_BUCKET} \ + --path s2lite +``` +
+ +> [!NOTE] +> Point the [S2 CLI](https://s2.dev/docs/quickstart) or [SDKs](https://s2.dev/docs/sdk) at your lite instance like this: +> ```bash +> export S2_ACCOUNT_ENDPOINT="http://localhost:8080" +> export S2_BASIN_ENDPOINT="http://localhost:8080" +> export S2_ACCESS_TOKEN="ignored" +> ``` + +Let's make sure the server is ready: +```bash +while ! curl -sf ${S2_ACCOUNT_ENDPOINT}/health -o /dev/null; do echo Waiting...; sleep 2; done && echo Up! +``` + +Install the CLI (see [Installation](#installation) above) or upgrade if `s2 --version` is older than `0.26` + +Let's create a [basin](https://s2.dev/docs/concepts) with auto-creation of streams enabled: +```bash +s2 create-basin liteness --create-stream-on-append --create-stream-on-read +``` + +Test your performance: +```bash +s2 bench liteness --target-mibps 10 --duration 5s --catchup-delay 0s +``` + +![S2 Benchmark](./assets/bench.gif) + +Now let's try streaming sessions. In one or more new terminals (make sure you re-export the env vars noted above), +```bash +s2 read s2://liteness/starwars 2> /dev/null +``` + +Now back from your original terminal, let's write to the stream: +```bash +nc starwars.s2.dev 23 | s2 append s2://liteness/starwars +``` + +![S2 Star Wars Streaming](./assets/starwars.gif) + +### Kubernetes Deployment + +Deploy `s2-lite` to Kubernetes using Helm. See the [Helm chart documentation](charts/s2-lite-helm/README.md) for installation instructions and configuration options. + +### Storage + +Lite persists to an S3-compatible bucket or a local directory, or runs in-memory when neither is given. +The write-ahead log (WAL) shares the main store by default; it can be placed in a separate bucket or +directory to isolate WAL latency from flushes and compaction. `--path` applies to both stores. + +| Setting | Main store | WAL store | +| --- | --- | --- | +| S3 bucket | `--bucket` | `--wal-bucket` / `S2LITE_WAL_BUCKET` | +| Local directory | `--local-root` | `--wal-local-root` / `S2LITE_WAL_LOCAL_ROOT` | +| S3 endpoint | `AWS_ENDPOINT_URL_S3` | `S2LITE_WAL_AWS_ENDPOINT_URL_S3` | +| AWS region | `AWS_REGION` | `S2LITE_WAL_AWS_REGION` | +| Static credentials | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` | `S2LITE_WAL_AWS_ACCESS_KEY_ID` + `S2LITE_WAL_AWS_SECRET_ACCESS_KEY` | +| Session token | `AWS_SESSION_TOKEN` | `S2LITE_WAL_AWS_SESSION_TOKEN` | + +Without static credentials, the standard AWS credential chain (profile, instance role, etc.) is used. +The WAL bucket inherits the main store's S3 settings; each `S2LITE_WAL_AWS_*` variable overrides just +that setting, except that a WAL key pair replaces the main credentials (and session token) as a set. +A WAL store requires a persistent main store. + +```bash +# LSM in S3, WAL on local disk +s2 lite --bucket my-bucket --wal-local-root /data/wal + +# LSM and WAL in separate buckets +s2 lite --bucket my-bucket --wal-bucket my-wal-bucket +``` + +### Monitoring + +`/health` will return 200 on success for readiness and liveness checks + +`/metrics` returns Prometheus text format + +### Internals + +#### SlateDB settings + +[Settings reference](https://docs.rs/slatedb/latest/slatedb/config/struct.Settings.html#fields) + +Use `SL8_` prefixed environment variables, e.g.: + +```bash +# Defaults to 50ms for S3, 5ms otherwise; follows the WAL store when set +SL8_FLUSH_INTERVAL=10ms +``` + +#### Design + +[Concepts](https://s2.dev/docs/concepts) + +- HTTP serving is implemented using [axum](https://github.com/tokio-rs/axum) +- Each stream corresponds to a Tokio task called [`streamer`](lite/src/backend/streamer.rs) that owns the current `tail` position, serializes appends, and broadcasts acknowledged records to followers +- Appends are pipelined to improve performance against high-latency object storage +- [`lite::backend::kv::Key`](lite/src/backend/kv/mod.rs) documents the data modeling in SlateDB + +### Compatibility + +- [CLI](cli/) ✅ v0.26+ +- [TypeScript SDK](https://github.com/s2-streamstore/s2-sdk-typescript) ✅ v0.22+ +- [Go SDK](https://github.com/s2-streamstore/s2-sdk-go) ✅ v0.11+ +- [Rust SDK](sdk/) ✅ v0.22+ +- [Python SDK](https://github.com/s2-streamstore/s2-sdk-python) ✅ v0.1+ + +### API Coverage + +Complete [specs](https://github.com/s2-streamstore/s2-specs/tree/main/s2/v1) are available: +- [OpenAPI](https://s2.dev/docs/api) for the REST-ful core +- [Protobuf](https://buf.build/streamstore/s2/docs/main:s2.v1) definitions +- [S2S](https://s2.dev/docs/api/protocol#sessions), which is the streaming session protocol + +> [!IMPORTANT] +> Unlike the cloud service where the basin is implicit as a subdomain, `/streams/*` requests **must** specify the basin using the `S2-Basin` header. The SDKs take care of this automatically. + +| Endpoint | Support | +| --- | --- | +| `/basins` | Supported | +| `/streams` | Supported | +| `/streams/{stream}/records` | Supported | +| `/access-tokens` | Not supported https://github.com/s2-streamstore/s2/issues/28 | +| `/metrics` | Not supported | diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md new file mode 100644 index 00000000..55052994 --- /dev/null +++ b/api/CHANGELOG.md @@ -0,0 +1,465 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.32.0] - 2026-09-25 + +### Features + +- [**breaking**] Expose storage classes as strings and in location responses ([#775](https://github.com/s2-streamstore/s2/issues/775)) + +### Bug Fixes + +- Skip s2s response compression the client refused with q=0 ([#781](https://github.com/s2-streamstore/s2/issues/781)) + + + +## [0.31.5] - 2026-09-22 + +### Bug Fixes + +- Preserve SSE read budgets across reconnects ([#727](https://github.com/s2-streamstore/s2/issues/727)) + + + +## [0.31.4] - 2026-09-16 + +### Miscellaneous Tasks + +- Sync specs submodule ([#739](https://github.com/s2-streamstore/s2/issues/739)) + + + +## [0.31.3] - 2026-09-11 + +### Features + +- `s2-stream-config` header for auto-created streams ([#718](https://github.com/s2-streamstore/s2/issues/718)) + + + +## [0.31.2] - 2026-09-10 + +### Bug Fixes + +- Reject NUL bytes in stream names and access token IDs ([#728](https://github.com/s2-streamstore/s2/issues/728)) +- Switch JSON extractor from sonic-rs back to serde_json ([#729](https://github.com/s2-streamstore/s2/issues/729)) + + + +## [0.31.1] - 2026-08-13 + +### Features + +- S2s reconnect advice flag and server_draining error code ([#700](https://github.com/s2-streamstore/s2/issues/700)) + + + +## [0.31.0] - 2026-07-31 + +### Features + +- [**breaking**] Replace S2Error with surface-specific errors ([#653](https://github.com/s2-streamstore/s2/issues/653)) + + + +## [0.30.10] - 2026-07-22 + +### Miscellaneous Tasks + +- Sync specs submodule ([#647](https://github.com/s2-streamstore/s2/issues/647)) + + + +## [0.30.9] - 2026-07-17 + +### Features + +- Emit tail in read session SSE pings ([#643](https://github.com/s2-streamstore/s2/issues/643)) + + + +## [0.30.8] - 2026-07-16 + +### Bug Fixes + +- Make provision_stream exists-outcomes durably visible ([#641](https://github.com/s2-streamstore/s2/issues/641)) + + + +## [0.30.7] - 2026-07-07 + +### Miscellaneous Tasks + +- Sync specs submodule ([#626](https://github.com/s2-streamstore/s2/issues/626)) + + + +## [0.30.6] - 2026-07-02 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.30.5] - 2026-06-22 + +### Features + +- Make access token expiry semantics explicit ([#574](https://github.com/s2-streamstore/s2/issues/574)) + +### Miscellaneous Tasks + +- Remove unused BasinState, From, and From conversions ([#564](https://github.com/s2-streamstore/s2/issues/564)) + + + +## [0.30.4] - 2026-06-15 + +### Bug Fixes + +- Return not_implemented for lite stubs ([#546](https://github.com/s2-streamstore/s2/issues/546)) +- Narrow futures deps in sdk and api ([#548](https://github.com/s2-streamstore/s2/issues/548)) +- Reject zero retention in resource specs ([#544](https://github.com/s2-streamstore/s2/issues/544)) + + + +## [0.30.3] - 2026-06-13 + +### Miscellaneous Tasks + +- Sync specs submodule ([#539](https://github.com/s2-streamstore/s2/issues/539)) + + + +## [0.30.2] - 2026-06-12 + +### Refactor + +- Split storage internals from common ([#533](https://github.com/s2-streamstore/s2/issues/533)) + + + +## [0.30.1] - 2026-06-10 + +### Features + +- Allow list cursors before prefix ([#448](https://github.com/s2-streamstore/s2/issues/448)) + + + +## [0.30.0] - 2026-05-20 + +### Refactor + +- [**breaking**] Rename scope -> location, treat it as a string; add related RPCs ([#485](https://github.com/s2-streamstore/s2/issues/485)) + + + +## [0.29.4] - 2026-05-19 + +### Features + +- Expose `ensure_*` ops ([#471](https://github.com/s2-streamstore/s2/issues/471)) + + + +## [0.29.3] - 2026-05-19 + +### Miscellaneous Tasks + +- Unused deps ([#467](https://github.com/s2-streamstore/s2/issues/467)) +- Sync specs submodule ([#470](https://github.com/s2-streamstore/s2/issues/470)) + + + +## [0.29.2] - 2026-05-15 + +### Bug Fixes + +- Preserve explicit optional config values ([#459](https://github.com/s2-streamstore/s2/issues/459)) + +### Miscellaneous Tasks + +- Sync specs submodule ([#451](https://github.com/s2-streamstore/s2/issues/451)) + + + +## [0.29.1] - 2026-05-14 + +### Bug Fixes + +- Clarify PUT ensure semantics ([#450](https://github.com/s2-streamstore/s2/issues/450)) + + + +## [0.29.0] - 2026-05-10 + +### Features + +- [**breaking**] Mark s2_sdk::BasinScope as #[non_exhaustive] ([#433](https://github.com/s2-streamstore/s2/issues/433)) + +### Documentation + +- Clarify `create-basin` scope ([#436](https://github.com/s2-streamstore/s2/issues/436)) + +### Miscellaneous Tasks + +- Sync specs submodule ([#431](https://github.com/s2-streamstore/s2/issues/431)) + + + +## [0.28.5] - 2026-05-04 + +### Features + +- Add aws:us-west-2 and aws:eu-north-1 basin scopes ([#430](https://github.com/s2-streamstore/s2/issues/430)) + +### Miscellaneous Tasks + +- Sync specs submodule ([#427](https://github.com/s2-streamstore/s2/issues/427)) + + + +## [0.28.4] - 2026-04-27 + +### Refactor + +- Use `strum` instead of `enum_ordinalize` ([#426](https://github.com/s2-streamstore/s2/issues/426)) + + + +## [0.28.3] - 2026-04-27 + +### Features + +- Switch JSON deserialization to sonic-rs ([#383](https://github.com/s2-streamstore/s2/issues/383)) + + + +## [0.28.2] - 2026-04-24 + +### Miscellaneous Tasks + +- Include v1 `s2.proto` file in the package ([#423](https://github.com/s2-streamstore/s2/issues/423)) + + + +## [0.28.1] - 2026-04-22 + +### Documentation + +- Encryption key header ([#416](https://github.com/s2-streamstore/s2/issues/416)) + + + +## [0.28.0] - 2026-04-20 + +### Refactor + +- [**breaking**] Replace encryption modes with stream cipher metadata and key-only headers ([#403](https://github.com/s2-streamstore/s2/issues/403)) + + + +## [0.27.17] - 2026-04-17 + +### Bug Fixes + +- Dashed string repr for AEGIS-256 and AES-256-GCM modes ([#400](https://github.com/s2-streamstore/s2/issues/400)) + + + +## [0.27.16] - 2026-04-16 + +### Features + +- Add `DecryptionFailed` error code ([#396](https://github.com/s2-streamstore/s2/issues/396)) + + + +## [0.27.15] - 2026-04-15 + +### Refactor + +- Remove implicit optional config resolution ([#389](https://github.com/s2-streamstore/s2/issues/389)) + + + +## [0.27.14] - 2026-04-14 + +### Features + +- Request-time data encryption ([#349](https://github.com/s2-streamstore/s2/issues/349)) +- Enforce allowed encryption modes via stream config ([#376](https://github.com/s2-streamstore/s2/issues/376)) + +### Refactor + +- Clarify encryption spec, mode, and format semantics ([#375](https://github.com/s2-streamstore/s2/issues/375)) +- Decouple JSON extraction rejection from axum ([#348](https://github.com/s2-streamstore/s2/issues/348)) + +### Miscellaneous Tasks + +- Ignore basin state when deserializing BasinInfo ([#350](https://github.com/s2-streamstore/s2/issues/350)) + + + +## [0.27.13] - 2026-03-20 + +### Features + +- Align basin info with stream info ([#338](https://github.com/s2-streamstore/s2/issues/338)) + + + +## [0.27.12] - 2026-03-19 + +### Refactor + +- Remove basin creating state ([#333](https://github.com/s2-streamstore/s2/issues/333)) + +### Miscellaneous Tasks + +- Sync specs submodule ([#334](https://github.com/s2-streamstore/s2/issues/334)) + + + +## [0.27.11] - 2026-03-15 + +### Bug Fixes + +- Terminate framed stream after encoding errors ([#328](https://github.com/s2-streamstore/s2/issues/328)) + + + +## [0.27.10] - 2026-03-13 + +### Performance + +- Avoid intermediate SSE batch allocations ([#320](https://github.com/s2-streamstore/s2/issues/320)) + + + +## [0.27.9] - 2026-03-04 + +### Features + +- Add authn/authz error codes and surface token source in cli ([#286](https://github.com/s2-streamstore/s2/issues/286)) + +### Bug Fixes + +- Bound decompressed frame payload size ([#288](https://github.com/s2-streamstore/s2/issues/288)) + + + +## [0.27.8] - 2026-03-03 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.27.7] - 2026-02-24 + +### Features + +- Support creating resources from spec ([#239](https://github.com/s2-streamstore/s2/issues/239)) + +### Documentation + +- Display default values for `create_stream_on_*` config ([#241](https://github.com/s2-streamstore/s2/issues/241)) + + + +## [0.27.6] - 2026-02-15 + +### Miscellaneous Tasks + +- Add crate-level doc comment ([#213](https://github.com/s2-streamstore/s2/issues/213)) + + + +## [0.27.5] - 2026-02-15 + +### Miscellaneous Tasks + +- Updated the following local packages: s2-common + + + +## [0.27.4] - 2026-02-12 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.27.3] - 2026-02-05 + +### Miscellaneous Tasks + +- Rejig versioning and release workflow ([#163](https://github.com/s2-streamstore/s2/issues/163)) + + + +## [0.27.2] - 2026-02-05 + + + +## [0.27.1] - 2026-02-04 + + + +## [0.27.0] - 2026-02-03 + + + +## [0.26.9] - 2026-02-02 + + + +## [0.26.8] - 2026-01-30 + + + +## [0.26.7] - 2026-01-30 + + + +## [0.26.6] - 2026-01-30 + + + +## [0.26.5] - 2026-01-30 + + + +## [0.26.4] - 2026-01-29 + + + +## [0.26.3] - 2026-01-29 + + + +## [0.26.2] - 2026-01-29 + + + +## [0.26.1] - 2026-01-29 + + + +## [0.26.0] - 2026-01-28 + +### Bug Fixes + +- *(openapi)* Docs ([#96](https://github.com/s2-streamstore/s2/issues/96)) + +### Miscellaneous Tasks + +- Sync specs submodule ([#97](https://github.com/s2-streamstore/s2/issues/97)) + + diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 00000000..35cd9cee --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "s2-api" +version = "0.32.0" +description = "API types for S2, the durable streams API" +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords = ["s2", "durable", "streams", "log", "api"] +categories = ["api-bindings"] +include = [ + "src/**/*", + "build.rs", + "Cargo.toml", + "CHANGELOG.md", + "specs/s2/v1/s2.proto", +] + +[features] +axum = ["dep:axum", "s2-common/axum"] +utoipa = ["dep:utoipa"] +codegen = ["dep:prost-build"] + +[dependencies] +axum = { workspace = true, optional = true } +base64ct = { workspace = true, features = ["alloc"] } +bytes = { workspace = true } +compact_str = { workspace = true, features = ["serde"] } +flate2 = { workspace = true } +futures-core = { workspace = true } +futures-util = { workspace = true } +http = { workspace = true } +itertools = { workspace = true } +mime = { workspace = true } +prost = { workspace = true } +s2-common = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +strum = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +time = { workspace = true, features = ["serde", "formatting", "parsing"] } +tokio-util = { workspace = true, features = ["codec", "io"] } +utoipa = { workspace = true, optional = true, features = ["time"] } +zstd = { workspace = true } + +[build-dependencies] +prost-build = { workspace = true, optional = true } + +[dev-dependencies] +futures = { workspace = true } +proptest = { workspace = true } +rstest = { workspace = true } diff --git a/api/build.rs b/api/build.rs new file mode 100644 index 00000000..2f9d53c7 --- /dev/null +++ b/api/build.rs @@ -0,0 +1,10 @@ +fn main() -> Result<(), Box> { + #[cfg(feature = "codegen")] + { + prost_build::Config::new() + .bytes(["."]) + .out_dir("src/v1/stream/proto") + .compile_protos(&["specs/s2/v1/s2.proto"], &["specs/s2/v1"])?; + } + Ok(()) +} diff --git a/api/specs b/api/specs new file mode 160000 index 00000000..edaa1fbc --- /dev/null +++ b/api/specs @@ -0,0 +1 @@ +Subproject commit edaa1fbcb2507362d6c680c66cdc88660e73e036 diff --git a/api/src/data.rs b/api/src/data.rs new file mode 100644 index 00000000..9e53a92f --- /dev/null +++ b/api/src/data.rs @@ -0,0 +1,498 @@ +use std::str::FromStr; + +use base64ct::{Base64, Encoding as _}; +use bytes::Bytes; +use s2_common::ValidationError; + +#[derive(Debug)] +pub struct Json(pub T); + +#[cfg(feature = "axum")] +impl axum::response::IntoResponse for Json +where + T: serde::Serialize, +{ + fn into_response(self) -> axum::response::Response { + let Self(value) = self; + axum::Json(value).into_response() + } +} + +#[derive(Debug)] +pub struct Proto(pub T); + +#[cfg(feature = "axum")] +impl axum::response::IntoResponse for Proto +where + T: prost::Message, +{ + fn into_response(self) -> axum::response::Response { + let headers = [( + http::header::CONTENT_TYPE, + http::header::HeaderValue::from_static("application/protobuf"), + )]; + let body = self.0.encode_to_vec(); + (headers, body).into_response() + } +} + +#[rustfmt::skip] +#[derive(Debug, Default, Clone, Copy)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub enum Format { + #[default] + #[cfg_attr(feature = "utoipa", schema(rename = "raw"))] + Raw, + #[cfg_attr(feature = "utoipa", schema(rename = "base64"))] + Base64, +} + +impl s2_common::http::ParseableHeader for Format { + fn name() -> &'static http::HeaderName { + &FORMAT_HEADER + } +} + +impl Format { + pub fn encode(self, bytes: &[u8]) -> String { + match self { + Format::Raw => String::from_utf8_lossy(bytes).into_owned(), + Format::Base64 => Base64::encode_string(bytes), + } + } + + pub fn decode(self, s: String) -> Result { + Ok(match self { + Format::Raw => s.into_bytes().into(), + Format::Base64 => Base64::decode_vec(&s) + .map_err(|_| ValidationError("invalid Base64 encoding".to_owned()))? + .into(), + }) + } +} + +impl FromStr for Format { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + match s.trim() { + "raw" | "json" => Ok(Self::Raw), + "base64" | "json-binsafe" => Ok(Self::Base64), + _ => Err(ValidationError(s.to_string())), + } + } +} + +pub static FORMAT_HEADER: http::HeaderName = http::HeaderName::from_static("s2-format"); + +#[rustfmt::skip] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))] +pub struct S2FormatHeader { + /// Defines the interpretation of record data (header name, header value, and body) with the JSON content type. + /// Use `raw` (default) for efficient transmission and storage of Unicode data — storage will be in UTF-8. + /// Use `base64` for safe transmission with efficient storage of binary data. + #[cfg_attr(feature = "utoipa", param(required = false, rename = "s2-format"))] + pub s2_format: Format, +} + +#[rustfmt::skip] +#[derive(Debug)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))] +pub struct S2StreamConfigHeader { + /// JSON-encoded `StreamConfig` to apply if the stream is created on append or read. + /// Unset fields inherit the basin's default stream configuration. + /// Ignored if the stream already exists. + /// Compact JSON is preferred. + #[cfg_attr(feature = "utoipa", param( + required = false, + rename = "s2-stream-config", + content_type = "application/json", + value_type = crate::v1::config::StreamConfig, + example = json!({"retention_policy":{"age":3600},"delete_on_empty":{"min_age_secs":300}}), + ))] + pub s2_stream_config: String, +} + +#[rustfmt::skip] +#[derive(Debug)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))] +pub struct S2EncryptionKeyHeader { + /// Encryption key material for append and read operations. + /// Provide base64-encoded key when stream encryption is enabled. + #[cfg_attr(feature = "utoipa", param(required = false, rename = "s2-encryption-key", value_type = String))] + pub s2_encryption_key: String, +} + +#[cfg(feature = "axum")] +pub mod extract { + use std::borrow::Cow; + + use axum::{ + extract::{FromRequest, OptionalFromRequest, Request, rejection::BytesRejection}, + response::{IntoResponse, Response}, + }; + use bytes::Bytes; + use serde::de::DeserializeOwned; + + /// Rejection type for JSON extraction, owned by s2-api. + #[derive(Debug)] + #[non_exhaustive] + pub enum JsonExtractionRejection { + SyntaxError { + status: http::StatusCode, + message: Cow<'static, str>, + }, + DataError { + status: http::StatusCode, + message: Cow<'static, str>, + }, + MissingContentType, + Other { + status: http::StatusCode, + message: Cow<'static, str>, + }, + } + + const MISSING_CONTENT_TYPE_MSG: &str = "Expected request with `Content-Type: application/json`"; + + impl JsonExtractionRejection { + pub fn body_text(&self) -> &str { + match self { + Self::SyntaxError { message, .. } + | Self::DataError { message, .. } + | Self::Other { message, .. } => message, + Self::MissingContentType => MISSING_CONTENT_TYPE_MSG, + } + } + + pub fn status(&self) -> http::StatusCode { + match self { + Self::SyntaxError { status, .. } + | Self::DataError { status, .. } + | Self::Other { status, .. } => *status, + Self::MissingContentType => http::StatusCode::UNSUPPORTED_MEDIA_TYPE, + } + } + } + + impl std::fmt::Display for JsonExtractionRejection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.body_text()) + } + } + + impl std::error::Error for JsonExtractionRejection {} + + impl IntoResponse for JsonExtractionRejection { + fn into_response(self) -> Response { + let status = self.status(); + match self { + Self::SyntaxError { message, .. } + | Self::DataError { message, .. } + | Self::Other { message, .. } => match message { + Cow::Borrowed(s) => (status, s).into_response(), + Cow::Owned(s) => (status, s).into_response(), + }, + Self::MissingContentType => (status, MISSING_CONTENT_TYPE_MSG).into_response(), + } + } + } + + fn classify_json_error(err: serde_json::Error) -> JsonExtractionRejection { + use serde_json::error::Category; + match err.classify() { + Category::Data => JsonExtractionRejection::DataError { + status: http::StatusCode::UNPROCESSABLE_ENTITY, + message: err.to_string().into(), + }, + Category::Io => JsonExtractionRejection::Other { + status: http::StatusCode::INTERNAL_SERVER_ERROR, + message: err.to_string().into(), + }, + Category::Syntax | Category::Eof => JsonExtractionRejection::SyntaxError { + status: http::StatusCode::BAD_REQUEST, + message: err.to_string().into(), + }, + } + } + + impl FromRequest for super::Json + where + S: Send + Sync, + T: DeserializeOwned, + { + type Rejection = JsonExtractionRejection; + + async fn from_request(req: Request, state: &S) -> Result { + let Some(ctype) = req.headers().get(http::header::CONTENT_TYPE) else { + return Err(JsonExtractionRejection::MissingContentType); + }; + if !crate::mime::parse(ctype) + .as_ref() + .is_some_and(crate::mime::is_json) + { + return Err(JsonExtractionRejection::MissingContentType); + } + let bytes = Bytes::from_request(req, state).await.map_err(|e| { + JsonExtractionRejection::Other { + status: e.status(), + message: e.body_text().into(), + } + })?; + serde_json::from_slice(&bytes) + .map(Self) + .map_err(classify_json_error) + } + } + + impl OptionalFromRequest for super::Json + where + S: Send + Sync, + T: DeserializeOwned, + { + type Rejection = JsonExtractionRejection; + + async fn from_request(req: Request, state: &S) -> Result, Self::Rejection> { + let Some(ctype) = req.headers().get(http::header::CONTENT_TYPE) else { + return Ok(None); + }; + if !crate::mime::parse(ctype) + .as_ref() + .is_some_and(crate::mime::is_json) + { + return Err(JsonExtractionRejection::MissingContentType); + } + let bytes = Bytes::from_request(req, state).await.map_err(|e| { + JsonExtractionRejection::Other { + status: e.status(), + message: e.body_text().into(), + } + })?; + if bytes.is_empty() { + return Ok(None); + } + serde_json::from_slice(&bytes) + .map(|v| Some(Self(v))) + .map_err(classify_json_error) + } + } + + /// Workaround for https://github.com/tokio-rs/axum/issues/3623 + #[derive(Debug)] + pub struct JsonOpt(pub Option); + + impl FromRequest for JsonOpt + where + S: Send + Sync, + T: DeserializeOwned, + { + type Rejection = JsonExtractionRejection; + + async fn from_request(req: Request, state: &S) -> Result { + match as OptionalFromRequest>::from_request(req, state).await { + Ok(Some(super::Json(value))) => Ok(Self(Some(value))), + Ok(None) => Ok(Self(None)), + Err(e) => Err(e), + } + } + } + + #[derive(Debug, thiserror::Error)] + pub enum ProtoRejection { + #[error(transparent)] + BytesRejection(#[from] BytesRejection), + #[error(transparent)] + Decode(#[from] prost::DecodeError), + } + + impl IntoResponse for ProtoRejection { + fn into_response(self) -> Response { + match self { + ProtoRejection::BytesRejection(e) => e.into_response(), + ProtoRejection::Decode(e) => ( + http::StatusCode::BAD_REQUEST, + format!("Invalid protobuf body: {e}"), + ) + .into_response(), + } + } + } + + impl FromRequest for super::Proto + where + S: Send + Sync, + T: prost::Message + Default, + { + type Rejection = ProtoRejection; + + async fn from_request(req: Request, state: &S) -> Result { + let bytes = Bytes::from_request(req, state).await?; + Ok(super::Proto(T::decode(bytes)?)) + } + } + + #[cfg(test)] + mod tests { + use super::*; + use crate::v1::{ + config::{BasinReconfiguration, StreamReconfiguration}, + stream::{AppendInput, AppendRecord, Header}, + }; + + fn parse_json(json: &[u8]) -> Result { + serde_json::from_slice(json).map_err(classify_json_error) + } + + /// Verify that our rejection wrapper preserves axum's status code + /// classification for a variety of invalid JSON payloads. + #[test] + fn json_error_classification() { + let cases: &[(&[u8], http::StatusCode)] = &[ + // Syntax errors → 400 + (b"not json", http::StatusCode::BAD_REQUEST), + // `{}` is valid JSON but missing `records` — the data error is + // reported before checking trailing chars. + (b"{} trailing", http::StatusCode::UNPROCESSABLE_ENTITY), + (b"", http::StatusCode::BAD_REQUEST), + (b"{truncated", http::StatusCode::BAD_REQUEST), + // Data errors → 422 + (b"{}", http::StatusCode::UNPROCESSABLE_ENTITY), + ( + br#"{"records": "nope"}"#, + http::StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + br#"{"records": [{"body": 123}]}"#, + http::StatusCode::UNPROCESSABLE_ENTITY, + ), + ]; + + for (input, expected_status) in cases { + let err = parse_json::(input).expect_err(&format!( + "expected error for {:?}", + String::from_utf8_lossy(input) + )); + assert_eq!( + err.status(), + *expected_status, + "wrong status for {:?}: got {}, body: {}", + String::from_utf8_lossy(input), + err.status(), + err.body_text(), + ); + } + } + + #[test] + fn valid_json_parses_successfully() { + let input = br#"{"records": [], "match_seq_num": null}"#; + let result = parse_json::(input); + assert!(result.is_ok()); + } + + /// A deeply nested value must never overflow the stack, wherever it + /// appears in the document: a wrong-typed value is rejected before it + /// is descended into, an unknown field is skipped iteratively, and + /// nesting that is actually deserialized hits the recursion limit. + #[test] + fn deeply_nested_json_does_not_overflow_stack() { + const DEPTH: usize = 50_000; + let nested = format!("{}{}", "[".repeat(DEPTH), "]".repeat(DEPTH)); + let cases = [ + ( + format!(r#"{{"records":[{{"body":{nested}}}]}}"#), + Some(http::StatusCode::UNPROCESSABLE_ENTITY), + ), + (format!(r#"{{"records":[],"unknown":{nested}}}"#), None), + (nested.clone(), Some(http::StatusCode::UNPROCESSABLE_ENTITY)), + ]; + // Tokio's default worker stack size; unbounded recursion over + // 50k levels overflows it. + std::thread::Builder::new() + .stack_size(2 * 1024 * 1024) + .spawn(move || { + for (input, expected_status) in &cases { + let status = parse_json::(input.as_bytes()) + .err() + .map(|e| e.status()); + assert_eq!(status, *expected_status); + } + let err = parse_json::(nested.as_bytes()).unwrap_err(); + assert_eq!(err.status(), http::StatusCode::BAD_REQUEST); + assert!(err.body_text().contains("recursion limit exceeded")); + }) + .unwrap() + .join() + .unwrap(); + } + + /// Serialize with serde_json and deserialize again, asserting semantic + /// equality for shapes with custom (de)serialization. + #[test] + fn serde_json_roundtrip() { + fn assert_roundtrip(input: &T) + where + T: serde::Serialize + serde::de::DeserializeOwned + std::fmt::Debug, + { + let json = serde_json::to_vec(input).unwrap(); + let parsed: T = parse_json(&json).unwrap(); + assert_eq!( + format!("{input:?}"), + format!("{parsed:?}"), + "roundtrip mismatch for {}", + String::from_utf8_lossy(&json), + ); + } + + // AppendInput variants + assert_roundtrip(&AppendInput { + records: vec![], + match_seq_num: None, + fencing_token: None, + }); + assert_roundtrip(&AppendInput { + records: vec![AppendRecord { + timestamp: None, + headers: vec![Header("key".into(), "val".into())], + body: "hello world".into(), + }], + match_seq_num: Some(42), + fencing_token: Some("token".parse().unwrap()), + }); + + // StreamReconfiguration: exercises Maybe in all three states + use s2_common::maybe::Maybe; + + use crate::v1::config::{TimestampingMode, TimestampingReconfiguration}; + + // All fields unspecified (empty JSON object) + assert_roundtrip(&StreamReconfiguration { + storage_class: Maybe::Unspecified, + retention_policy: Maybe::Unspecified, + timestamping: Maybe::Unspecified, + delete_on_empty: Maybe::Unspecified, + }); + // Mix of specified-null and specified-value + assert_roundtrip(&StreamReconfiguration { + storage_class: Maybe::Specified(Some("express".into())), + retention_policy: Maybe::Specified(None), + timestamping: Maybe::Specified(Some(TimestampingReconfiguration { + mode: Maybe::Specified(Some(TimestampingMode::ClientRequire)), + uncapped: Maybe::Specified(Some(true)), + })), + delete_on_empty: Maybe::Unspecified, + }); + + // BasinReconfiguration: nested Maybe> + assert_roundtrip(&BasinReconfiguration { + default_stream_config: Maybe::Specified(None), + stream_cipher: Maybe::Unspecified, + create_stream_on_append: Maybe::Specified(true), + create_stream_on_read: Maybe::Unspecified, + }); + } + } +} diff --git a/api/src/lib.rs b/api/src/lib.rs new file mode 100644 index 00000000..f072524f --- /dev/null +++ b/api/src/lib.rs @@ -0,0 +1,5 @@ +//! S2 API types and protocol definitions. + +pub mod data; +pub mod mime; +pub mod v1; diff --git a/api/src/mime.rs b/api/src/mime.rs new file mode 100644 index 00000000..3305c204 --- /dev/null +++ b/api/src/mime.rs @@ -0,0 +1,82 @@ +use mime::Mime; + +#[derive(Debug, Clone, Copy)] +pub enum JsonOrProto { + Json, + Proto, +} + +impl JsonOrProto { + pub fn from_mime(mime: &Mime) -> Option { + if is_json(mime) { + Some(JsonOrProto::Json) + } else if is_protobuf(mime) { + Some(JsonOrProto::Proto) + } else { + None + } + } +} + +/// MIME type parsed from `Content-Type` header. +pub fn content_type(headers: &http::HeaderMap) -> Option { + headers.get(http::header::CONTENT_TYPE).and_then(parse) +} + +/// First MIME type present in the `Accept` header. +pub fn accept(headers: &http::HeaderMap) -> Option { + headers.get(http::header::ACCEPT).and_then(parse) +} + +/// Parse the **first** MIME type from a header value. +pub fn parse(header: &http::HeaderValue) -> Option { + header.to_str().ok()?.split(',').next()?.trim().parse().ok() +} + +pub fn is_json(mime: &mime::Mime) -> bool { + mime.type_() == mime::APPLICATION + && (mime.subtype() == mime::JSON || mime.suffix() == Some(mime::JSON)) +} + +pub fn is_protobuf(mime: &mime::Mime) -> bool { + mime.type_() == mime::APPLICATION && { + let s = mime.subtype().as_str(); + s.eq_ignore_ascii_case("protobuf") || s.eq_ignore_ascii_case("x-protobuf") + } +} + +pub fn is_s2s_proto(mime: &Mime) -> bool { + mime.type_().as_str().eq_ignore_ascii_case("s2s") + && mime.subtype().as_str().eq_ignore_ascii_case("proto") +} + +pub fn is_event_stream(mime: &mime::Mime) -> bool { + mime.type_() == mime::TEXT && mime.subtype() == mime::EVENT_STREAM +} + +#[cfg(test)] +mod tests { + use mime::Mime; + use rstest::rstest; + + #[rstest] + #[case("application/json", Some("application/json"))] + #[case(" application/json , application/protobuf", Some("application/json"))] + #[case( + "application/json; charset=utf-8", + Some("application/json;charset=utf-8") + )] + #[case("", None)] + #[case("not/a/mime, application/json", None)] + fn parse(#[case] header: &'static str, #[case] expected: Option<&'static str>) { + let header = http::HeaderValue::from_static(header); + let expected = expected.map(|s| s.parse::().unwrap()); + assert_eq!(super::parse(&header), expected); + } + + #[test] + fn parse_returns_none_for_non_utf8_header_values() { + let header = http::HeaderValue::from_bytes(b"\xFF\xFF").unwrap(); + assert_eq!(super::parse(&header), None); + } +} diff --git a/api/src/v1/access.rs b/api/src/v1/access.rs new file mode 100644 index 00000000..a5dc1cc1 --- /dev/null +++ b/api/src/v1/access.rs @@ -0,0 +1,503 @@ +use s2_common::{ + self, + access::{AccessTokenId, AccessTokenIdPrefix, AccessTokenIdStartAfter}, + basin::{BasinName, BasinNamePrefix}, + stream::{StreamName, StreamNamePrefix}, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub enum MaybeEmpty { + Empty, + NonEmpty(T), +} + +impl Serialize for MaybeEmpty { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::NonEmpty(v) => v.serialize(serializer), + Self::Empty => serializer.serialize_str(""), + } + } +} + +impl<'de, T> Deserialize<'de> for MaybeEmpty +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + if s.is_empty() { + Ok(MaybeEmpty::Empty) + } else { + T::deserialize(serde::de::value::StringDeserializer::new(s)).map(MaybeEmpty::NonEmpty) + } + } +} + +use time::OffsetDateTime; + +#[rustfmt::skip] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum Operation { + /// List basins. + ListBasins, + /// Create a basin. + CreateBasin, + /// Delete a basin. + DeleteBasin, + /// Reconfigure a basin. + ReconfigureBasin, + /// Get basin configuration. + GetBasinConfig, + /// Issue an access token. + IssueAccessToken, + /// Revoke an access token. + RevokeAccessToken, + /// List access tokens. + ListAccessTokens, + /// List streams. + ListStreams, + /// Create a stream. + CreateStream, + /// Delete a stream. + DeleteStream, + /// Get stream configuration. + GetStreamConfig, + /// Reconfigure a stream. + ReconfigureStream, + /// Check the tail of a stream. + CheckTail, + /// Append records to a stream. + Append, + /// Read records from a stream. + Read, + /// Trim records on a stream. + Trim, + /// Set the fencing token on a stream. + Fence, + /// Retrieve account-level metrics. + AccountMetrics, + /// Retrieve basin-level metrics. + BasinMetrics, + /// Retrieve stream-level metrics. + StreamMetrics, + /// List locations. + ListLocations, + /// Get the default location. + GetDefaultLocation, + /// Set the default location. + SetDefaultLocation, +} + +impl From for s2_common::access::Operation { + fn from(value: Operation) -> Self { + match value { + Operation::ListBasins => Self::ListBasins, + Operation::CreateBasin => Self::CreateBasin, + Operation::DeleteBasin => Self::DeleteBasin, + Operation::ReconfigureBasin => Self::ReconfigureBasin, + Operation::GetBasinConfig => Self::GetBasinConfig, + Operation::IssueAccessToken => Self::IssueAccessToken, + Operation::RevokeAccessToken => Self::RevokeAccessToken, + Operation::ListAccessTokens => Self::ListAccessTokens, + Operation::ListStreams => Self::ListStreams, + Operation::CreateStream => Self::CreateStream, + Operation::DeleteStream => Self::DeleteStream, + Operation::GetStreamConfig => Self::GetStreamConfig, + Operation::ReconfigureStream => Self::ReconfigureStream, + Operation::CheckTail => Self::CheckTail, + Operation::Append => Self::Append, + Operation::Read => Self::Read, + Operation::Trim => Self::Trim, + Operation::Fence => Self::Fence, + Operation::AccountMetrics => Self::AccountMetrics, + Operation::BasinMetrics => Self::BasinMetrics, + Operation::StreamMetrics => Self::StreamMetrics, + Operation::ListLocations => Self::ListLocations, + Operation::GetDefaultLocation => Self::GetDefaultLocation, + Operation::SetDefaultLocation => Self::SetDefaultLocation, + } + } +} + +impl From for Operation { + fn from(value: s2_common::access::Operation) -> Self { + use s2_common::access::Operation::*; + match value { + ListBasins => Self::ListBasins, + CreateBasin => Self::CreateBasin, + DeleteBasin => Self::DeleteBasin, + ReconfigureBasin => Self::ReconfigureBasin, + GetBasinConfig => Self::GetBasinConfig, + IssueAccessToken => Self::IssueAccessToken, + RevokeAccessToken => Self::RevokeAccessToken, + ListAccessTokens => Self::ListAccessTokens, + ListStreams => Self::ListStreams, + CreateStream => Self::CreateStream, + DeleteStream => Self::DeleteStream, + GetStreamConfig => Self::GetStreamConfig, + ReconfigureStream => Self::ReconfigureStream, + CheckTail => Self::CheckTail, + Append => Self::Append, + Read => Self::Read, + Trim => Self::Trim, + Fence => Self::Fence, + AccountMetrics => Self::AccountMetrics, + BasinMetrics => Self::BasinMetrics, + StreamMetrics => Self::StreamMetrics, + ListLocations => Self::ListLocations, + GetDefaultLocation => Self::GetDefaultLocation, + SetDefaultLocation => Self::SetDefaultLocation, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct AccessTokenInfo { + /// Access token ID. + pub id: AccessTokenId, + /// Expiration time in RFC 3339 format. + #[serde(default, with = "time::serde::rfc3339::option")] + pub expires_at: Option, + /// Namespace streams based on the configured stream-level scope. + pub auto_prefix_streams: bool, + /// Access token scope. + pub scope: AccessTokenScope, +} + +impl From for AccessTokenInfo { + fn from(value: s2_common::access::AccessTokenInfo) -> Self { + Self { + id: value.id, + expires_at: value.expires_at, + auto_prefix_streams: value.auto_prefix_streams, + scope: value.scope.into(), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct IssueAccessTokenRequest { + /// Access token ID. + /// It must be unique to the account and between 1 and 96 bytes in length, and must not + /// contain NUL bytes. + pub id: AccessTokenId, + /// Expiration time in RFC 3339 format. + /// If not set, the expiration will be set to that of the requestor's token. + #[serde(default, with = "time::serde::rfc3339::option")] + pub expires_at: Option, + /// Namespace streams based on the configured stream-level scope, which must be a prefix. + /// Stream name arguments will be automatically prefixed, and the prefix will be stripped when listing streams. + #[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))] + pub auto_prefix_streams: Option, + /// Access token scope. + pub scope: AccessTokenScope, +} + +impl TryFrom for s2_common::access::IssueAccessTokenRequest { + type Error = s2_common::ValidationError; + + fn try_from(value: IssueAccessTokenRequest) -> Result { + Ok(Self { + id: value.id, + expires_at: value.expires_at, + auto_prefix_streams: value.auto_prefix_streams.unwrap_or_default(), + scope: value.scope.try_into()?, + }) + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct AccessTokenScope { + /// Basin names allowed. + pub basins: Option, BasinNamePrefix>>, + /// Stream names allowed. + pub streams: Option, StreamNamePrefix>>, + /// Token IDs allowed. + pub access_tokens: Option, AccessTokenIdPrefix>>, + /// Access permissions at operation group level. + pub op_groups: Option, + /// Operations allowed for the token. + /// A union of allowed operations and groups is used as an effective set of allowed operations. + #[cfg_attr(feature = "utoipa", schema(required = false))] + pub ops: Option>, +} + +impl TryFrom for s2_common::access::AccessTokenScope { + type Error = s2_common::ValidationError; + + fn try_from(value: AccessTokenScope) -> Result { + let AccessTokenScope { + basins, + streams, + access_tokens, + op_groups, + ops, + } = value; + + Ok(Self { + basins: basins.map(Into::into).unwrap_or_default(), + streams: streams.map(Into::into).unwrap_or_default(), + access_tokens: access_tokens.map(Into::into).unwrap_or_default(), + op_groups: op_groups.map(Into::into).unwrap_or_default(), + ops: ops + .map(|o| { + o.into_iter() + .map(s2_common::access::Operation::from) + .collect() + }) + .unwrap_or_default(), + }) + } +} + +impl From for AccessTokenScope { + fn from(value: s2_common::access::AccessTokenScope) -> Self { + let s2_common::access::AccessTokenScope { + basins, + streams, + access_tokens, + op_groups, + ops, + } = value; + + Self { + basins: ResourceSet::to_opt(basins), + streams: ResourceSet::to_opt(streams), + access_tokens: ResourceSet::to_opt(access_tokens), + op_groups: Some(op_groups.into()), + ops: Some(ops.into_iter().map(Operation::from).collect()), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum ResourceSet { + /// Match only the resource with this exact name. + /// Use an empty string to match no resources. + #[cfg_attr(feature = "utoipa", schema(title = "exact", value_type = String))] + Exact(E), + /// Match all resources that start with this prefix. + /// Use an empty string to match all resource. + #[cfg_attr(feature = "utoipa", schema(title = "prefix", value_type = String))] + Prefix(P), +} + +impl ResourceSet, P> { + pub fn to_opt(rs: s2_common::access::ResourceSet) -> Option { + match rs { + s2_common::access::ResourceSet::None => None, + s2_common::access::ResourceSet::Exact(e) => { + Some(ResourceSet::Exact(MaybeEmpty::NonEmpty(e))) + } + s2_common::access::ResourceSet::Prefix(p) => Some(ResourceSet::Prefix(p)), + } + } +} + +impl From, P>> for s2_common::access::ResourceSet { + fn from(value: ResourceSet, P>) -> Self { + match value { + ResourceSet::Exact(MaybeEmpty::Empty) => Self::None, + ResourceSet::Exact(MaybeEmpty::NonEmpty(e)) => Self::Exact(e), + ResourceSet::Prefix(p) => Self::Prefix(p), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct PermittedOperationGroups { + /// Account-level access permissions. + pub account: Option, + /// Basin-level access permissions. + pub basin: Option, + /// Stream-level access permissions. + pub stream: Option, +} + +impl From for s2_common::access::PermittedOperationGroups { + fn from(value: PermittedOperationGroups) -> Self { + let PermittedOperationGroups { + account, + basin, + stream, + } = value; + + Self { + account: account.map(Into::into).unwrap_or_default(), + basin: basin.map(Into::into).unwrap_or_default(), + stream: stream.map(Into::into).unwrap_or_default(), + } + } +} + +impl From for PermittedOperationGroups { + fn from(value: s2_common::access::PermittedOperationGroups) -> Self { + let s2_common::access::PermittedOperationGroups { + account, + basin, + stream, + } = value; + + Self { + account: Some(account.into()), + basin: Some(basin.into()), + stream: Some(stream.into()), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct ReadWritePermissions { + /// Read permission. + #[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))] + pub read: Option, + /// Write permission. + #[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))] + pub write: Option, +} + +impl From for s2_common::access::ReadWritePermissions { + fn from(value: ReadWritePermissions) -> Self { + let ReadWritePermissions { read, write } = value; + + Self { + read: read.unwrap_or_default(), + write: write.unwrap_or_default(), + } + } +} + +impl From for ReadWritePermissions { + fn from(value: s2_common::access::ReadWritePermissions) -> Self { + let s2_common::access::ReadWritePermissions { read, write } = value; + + Self { + read: Some(read), + write: Some(write), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct ListAccessTokensRequest { + /// Filter to access tokens whose IDs begin with this prefix. + /// It must not contain NUL bytes. + #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))] + pub prefix: Option, + /// Filter to access tokens whose IDs lexicographically start after this string. + /// It must not contain NUL bytes. + #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))] + pub start_after: Option, + /// Number of results, up to a maximum of 1000. + #[cfg_attr(feature = "utoipa", param(value_type = usize, maximum = 1000, default = 1000, required = false))] + pub limit: Option, +} + +super::impl_list_request_conversions!( + ListAccessTokensRequest, + AccessTokenIdPrefix, + AccessTokenIdStartAfter +); + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct ListAccessTokensResponse { + /// Matching access tokens. + #[cfg_attr(feature = "utoipa", schema(max_items = 1000))] + pub access_tokens: Vec, + /// Indicates that there are more access tokens that match the criteria. + pub has_more: bool, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct IssueAccessTokenResponse { + /// Created access token. + pub access_token: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_exact_converts_to_resource_set_none() { + let json = serde_json::json!({ + "id": "test-token", + "scope": { + "streams": {"exact": ""}, + "basins": {"exact": ""}, + "access_tokens": {"exact": ""} + } + }); + + let parsed: IssueAccessTokenRequest = serde_json::from_value(json).unwrap(); + let internal: s2_common::access::IssueAccessTokenRequest = parsed.try_into().unwrap(); + + assert!(matches!( + internal.scope.streams, + s2_common::access::ResourceSet::None + )); + assert!(matches!( + internal.scope.basins, + s2_common::access::ResourceSet::None + )); + assert!(matches!( + internal.scope.access_tokens, + s2_common::access::ResourceSet::None + )); + } + + #[test] + fn missing_scope_fields_default_to_resource_set_none() { + let json = serde_json::json!({ + "id": "test-token", + "scope": {} + }); + + let parsed: IssueAccessTokenRequest = serde_json::from_value(json).unwrap(); + let internal: s2_common::access::IssueAccessTokenRequest = parsed.try_into().unwrap(); + + assert!(matches!( + internal.scope.streams, + s2_common::access::ResourceSet::None + )); + assert!(matches!( + internal.scope.basins, + s2_common::access::ResourceSet::None + )); + assert!(matches!( + internal.scope.access_tokens, + s2_common::access::ResourceSet::None + )); + } +} diff --git a/api/src/v1/basin.rs b/api/src/v1/basin.rs new file mode 100644 index 00000000..0598e8dc --- /dev/null +++ b/api/src/v1/basin.rs @@ -0,0 +1,156 @@ +use s2_common::{ + self, + basin::{BasinName, BasinNamePrefix, BasinNameStartAfter}, + location::LocationName, +}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +use super::config::BasinConfig; + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct ListBasinsRequest { + /// Filter to basins whose names begin with this prefix. + #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))] + pub prefix: Option, + /// Filter to basins whose names lexicographically start after this string. + #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))] + pub start_after: Option, + /// Number of results, up to a maximum of 1000. + #[cfg_attr(feature = "utoipa", param(value_type = usize, maximum = 1000, default = 1000, required = false))] + pub limit: Option, +} + +super::impl_list_request_conversions!(ListBasinsRequest, BasinNamePrefix, BasinNameStartAfter); + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct ListBasinsResponse { + /// Matching basins. + #[cfg_attr(feature = "utoipa", schema(max_items = 1000))] + pub basins: Vec, + /// Indicates that there are more basins that match the criteria. + pub has_more: bool, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct BasinInfo { + /// Basin name. + pub name: BasinName, + /// Basin location. + pub location: Option, + /// Creation time in RFC 3339 format. + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + /// Deletion time in RFC 3339 format, if the basin is being deleted. + #[serde(default, with = "time::serde::rfc3339::option")] + pub deleted_at: Option, + /// Deprecated basin state inferred from `deleted_at`. + #[cfg_attr(feature = "utoipa", schema(ignore))] + pub state: BasinState, +} + +impl From for BasinInfo { + fn from(value: s2_common::basin::BasinInfo) -> Self { + let s2_common::basin::BasinInfo { + name, + location, + created_at, + deleted_at, + } = value; + + Self { + name, + location, + created_at, + deleted_at, + state: basin_state_for_deleted_at(deleted_at.as_ref()), + } + } +} + +fn basin_state_for_deleted_at(deleted_at: Option<&OffsetDateTime>) -> BasinState { + if deleted_at.is_some() { + BasinState::Deleting + } else { + BasinState::Active + } +} + +#[derive(Deserialize)] +struct BasinInfoSerde { + name: BasinName, + location: Option, + #[serde(with = "time::serde::rfc3339")] + created_at: OffsetDateTime, + #[serde(default, with = "time::serde::rfc3339::option")] + deleted_at: Option, +} + +impl<'de> Deserialize<'de> for BasinInfo { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let BasinInfoSerde { + name, + location, + created_at, + deleted_at, + } = BasinInfoSerde::deserialize(deserializer)?; + + let state = basin_state_for_deleted_at(deleted_at.as_ref()); + + Ok(Self { + name, + location, + created_at, + deleted_at, + state, + }) + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum BasinState { + /// Basin is active. + Active, + /// Basin is being deleted. + Deleting, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct EnsureBasinRequest { + /// Basin configuration. + pub config: Option, + /// Basin location. + /// If omitted when creating, uses the default location for the account. + /// This cannot be changed. + pub location: Option, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct CreateBasinRequest { + /// Basin name which must be globally unique. + /// It can be between 8 and 48 bytes in length, and comprise lowercase letters, numbers and hyphens. + /// It cannot begin or end with a hyphen. + pub basin: BasinName, + /// Basin configuration. + pub config: Option, + /// Basin location. + /// If omitted when creating, uses the default location for the account. + pub location: Option, +} diff --git a/api/src/v1/config.rs b/api/src/v1/config.rs new file mode 100644 index 00000000..671c0cb8 --- /dev/null +++ b/api/src/v1/config.rs @@ -0,0 +1,1127 @@ +use std::{str::FromStr, time::Duration}; + +use compact_str::CompactString; +use http::{HeaderName, HeaderValue}; +use s2_common::{http::ParseableHeader, maybe::Maybe}; +use serde::{Deserialize, Serialize}; + +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum RetentionPolicy { + /// Age in seconds for automatic trimming of records older than this threshold. + /// This must be set to a value greater than 0 seconds. + Age(u64), + /// Retain records unless explicitly trimmed. + Infinite(InfiniteRetention) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub struct InfiniteRetention {} + +impl TryFrom for s2_common::config::RetentionPolicy { + type Error = s2_common::ValidationError; + + fn try_from(value: RetentionPolicy) -> Result { + let policy = match value { + RetentionPolicy::Age(age) => Self::Age(Duration::from_secs(age)), + RetentionPolicy::Infinite(_) => Self::Infinite(), + }; + policy.validate() + } +} + +impl From for RetentionPolicy { + fn from(value: s2_common::config::RetentionPolicy) -> Self { + match value { + s2_common::config::RetentionPolicy::Age(age) => Self::Age(age.as_secs()), + s2_common::config::RetentionPolicy::Infinite() => Self::Infinite(InfiniteRetention {}), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum TimestampingMode { + /// Prefer client-specified timestamp if present otherwise use arrival time. + #[default] + ClientPrefer, + /// Require a client-specified timestamp and reject the append if it is missing. + ClientRequire, + /// Use the arrival time and ignore any client-specified timestamp. + Arrival, +} + +impl From for s2_common::config::TimestampingMode { + fn from(value: TimestampingMode) -> Self { + match value { + TimestampingMode::ClientPrefer => Self::ClientPrefer, + TimestampingMode::ClientRequire => Self::ClientRequire, + TimestampingMode::Arrival => Self::Arrival, + } + } +} + +impl From for TimestampingMode { + fn from(value: s2_common::config::TimestampingMode) -> Self { + match value { + s2_common::config::TimestampingMode::ClientPrefer => Self::ClientPrefer, + s2_common::config::TimestampingMode::ClientRequire => Self::ClientRequire, + s2_common::config::TimestampingMode::Arrival => Self::Arrival, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct TimestampingConfig { + /// Timestamping mode for appends that influences how timestamps are handled. + pub mode: Option, + /// Allow client-specified timestamps to exceed the arrival time. + /// If this is `false` or not set, client timestamps will be capped at the arrival time. + pub uncapped: Option, +} + +impl TimestampingConfig { + pub fn to_opt(config: s2_common::config::OptionalTimestampingConfig) -> Option { + let config = TimestampingConfig { + mode: config.mode.map(Into::into), + uncapped: config.uncapped, + }; + if config == Self::default() { + None + } else { + Some(config) + } + } +} + +impl From for TimestampingConfig { + fn from(value: s2_common::config::TimestampingConfig) -> Self { + Self { + mode: Some(value.mode.into()), + uncapped: Some(value.uncapped), + } + } +} + +impl From for TimestampingConfig { + fn from(value: s2_common::config::OptionalTimestampingConfig) -> Self { + Self { + mode: value.mode.map(Into::into), + uncapped: value.uncapped, + } + } +} + +impl From for s2_common::config::OptionalTimestampingConfig { + fn from(value: TimestampingConfig) -> Self { + Self { + mode: value.mode.map(Into::into), + uncapped: value.uncapped, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct TimestampingReconfiguration { + /// Timestamping mode for appends that influences how timestamps are handled. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub mode: Maybe>, + /// Allow client-specified timestamps to exceed the arrival time. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub uncapped: Maybe>, +} + +impl From for s2_common::config::TimestampingReconfiguration { + fn from(value: TimestampingReconfiguration) -> Self { + Self { + mode: value.mode.map_opt(Into::into), + uncapped: value.uncapped, + } + } +} + +impl From for TimestampingReconfiguration { + fn from(value: s2_common::config::TimestampingReconfiguration) -> Self { + Self { + mode: value.mode.map_opt(Into::into), + uncapped: value.uncapped, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct DeleteOnEmptyConfig { + /// Minimum age in seconds before an empty stream can be deleted. + /// Set to 0 (default) to disable delete-on-empty (don't delete automatically). + #[serde(default)] + pub min_age_secs: u64, +} + +impl DeleteOnEmptyConfig { + pub fn to_opt(config: s2_common::config::OptionalDeleteOnEmptyConfig) -> Option { + config.min_age.map(|min_age| DeleteOnEmptyConfig { + min_age_secs: min_age.as_secs(), + }) + } +} + +impl From for DeleteOnEmptyConfig { + fn from(value: s2_common::config::DeleteOnEmptyConfig) -> Self { + Self { + min_age_secs: value.min_age.as_secs(), + } + } +} + +impl From for DeleteOnEmptyConfig { + fn from(value: s2_common::config::OptionalDeleteOnEmptyConfig) -> Self { + Self { + min_age_secs: value.min_age.unwrap_or_default().as_secs(), + } + } +} + +impl From for s2_common::config::DeleteOnEmptyConfig { + fn from(value: DeleteOnEmptyConfig) -> Self { + Self { + min_age: Duration::from_secs(value.min_age_secs), + } + } +} + +impl From for s2_common::config::OptionalDeleteOnEmptyConfig { + fn from(value: DeleteOnEmptyConfig) -> Self { + Self { + min_age: Some(Duration::from_secs(value.min_age_secs)), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct DeleteOnEmptyReconfiguration { + /// Minimum age in seconds before an empty stream can be deleted. + /// Set to 0 to disable delete-on-empty (don't delete automatically). + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub min_age_secs: Maybe>, +} + +impl From for s2_common::config::DeleteOnEmptyReconfiguration { + fn from(value: DeleteOnEmptyReconfiguration) -> Self { + Self { + min_age: value.min_age_secs.map_opt(Duration::from_secs), + } + } +} + +impl From for DeleteOnEmptyReconfiguration { + fn from(value: s2_common::config::DeleteOnEmptyReconfiguration) -> Self { + Self { + min_age_secs: value.min_age.map_opt(|d| d.as_secs()), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub enum EncryptionAlgorithm { + /// AEGIS-256 authenticated encryption. + #[serde(rename = "aegis-256")] + Aegis256, + /// AES-256-GCM authenticated encryption. + #[serde(rename = "aes-256-gcm")] + Aes256Gcm, +} + +impl From for s2_common::encryption::EncryptionAlgorithm { + fn from(value: EncryptionAlgorithm) -> Self { + match value { + EncryptionAlgorithm::Aegis256 => Self::Aegis256, + EncryptionAlgorithm::Aes256Gcm => Self::Aes256Gcm, + } + } +} + +impl From for EncryptionAlgorithm { + fn from(value: s2_common::encryption::EncryptionAlgorithm) -> Self { + match value { + s2_common::encryption::EncryptionAlgorithm::Aegis256 => Self::Aegis256, + s2_common::encryption::EncryptionAlgorithm::Aes256Gcm => Self::Aes256Gcm, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct StreamConfig { + /// [Storage class](https://s2.dev/docs/storage-classes) for recent writes. + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub storage_class: Option, + /// Retention policy for the stream. + /// If unspecified, the default is to retain records for 7 days. + pub retention_policy: Option, + /// Timestamping behavior. + pub timestamping: Option, + /// Delete-on-empty configuration. + #[serde(default)] + pub delete_on_empty: Option, +} + +impl StreamConfig { + pub fn to_opt(config: s2_common::config::OptionalStreamConfig) -> Option { + let s2_common::config::OptionalStreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = config; + + let config = StreamConfig { + storage_class, + retention_policy: retention_policy.map(Into::into), + timestamping: TimestampingConfig::to_opt(timestamping), + delete_on_empty: DeleteOnEmptyConfig::to_opt(delete_on_empty), + }; + if config == Self::default() { + None + } else { + Some(config) + } + } + + /// Encode as compact JSON for the `s2-stream-config` header. + pub fn to_header_value(&self) -> HeaderValue { + let json = serde_json::to_string(self).expect("StreamConfig serializes to JSON"); + HeaderValue::from_str(&json).expect("compact JSON of StreamConfig is a valid header value") + } +} + +impl From for StreamConfig { + fn from(value: s2_common::config::StreamConfig) -> Self { + let s2_common::config::StreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = value; + + Self { + storage_class, + retention_policy: Some(retention_policy.into()), + timestamping: Some(timestamping.into()), + delete_on_empty: Some(delete_on_empty.into()), + } + } +} + +pub static STREAM_CONFIG_HEADER: HeaderName = HeaderName::from_static("s2-stream-config"); + +/// Value of the `s2-stream-config` header: a JSON-encoded [`StreamConfig`] to apply over the +/// basin's default stream config if the stream is created on append or read. Ignored if the +/// stream already exists. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StreamConfigHeader(pub s2_common::config::OptionalStreamConfig); + +impl FromStr for StreamConfigHeader { + type Err = s2_common::ValidationError; + + fn from_str(s: &str) -> Result { + let config: StreamConfig = + serde_json::from_str(s).map_err(|e| format!("invalid JSON: {e}"))?; + Ok(Self(config.try_into()?)) + } +} + +impl ParseableHeader for StreamConfigHeader { + fn name() -> &'static HeaderName { + &STREAM_CONFIG_HEADER + } +} + +impl TryFrom for s2_common::config::OptionalStreamConfig { + type Error = s2_common::ValidationError; + + fn try_from(value: StreamConfig) -> Result { + let StreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = value; + + let retention_policy = match retention_policy { + None => None, + Some(policy) => Some(policy.try_into()?), + }; + + let config = Self { + storage_class, + retention_policy, + timestamping: timestamping.map(Into::into).unwrap_or_default(), + delete_on_empty: delete_on_empty.map(Into::into).unwrap_or_default(), + }; + config.validate()?; + Ok(config) + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct StreamReconfiguration { + /// [Storage class](https://s2.dev/docs/storage-classes) for recent writes. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub storage_class: Maybe>, + /// Retention policy for the stream. + /// If unspecified, the default is to retain records for 7 days. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub retention_policy: Maybe>, + /// Timestamping behavior. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub timestamping: Maybe>, + /// Delete-on-empty configuration. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub delete_on_empty: Maybe>, +} + +impl TryFrom for s2_common::config::StreamReconfiguration { + type Error = s2_common::ValidationError; + + fn try_from(value: StreamReconfiguration) -> Result { + let StreamReconfiguration { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = value; + + Ok(Self { + storage_class, + retention_policy: retention_policy.try_map_opt(TryInto::try_into)?, + timestamping: timestamping.map_opt(Into::into), + delete_on_empty: delete_on_empty.map_opt(Into::into), + }) + } +} + +impl From for StreamReconfiguration { + fn from(value: s2_common::config::StreamReconfiguration) -> Self { + let s2_common::config::StreamReconfiguration { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = value; + + Self { + storage_class, + retention_policy: retention_policy.map_opt(Into::into), + timestamping: timestamping.map_opt(Into::into), + delete_on_empty: delete_on_empty.map_opt(Into::into), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct BasinConfig { + /// Default stream configuration. + pub default_stream_config: Option, + /// Encryption algorithm to apply to newly created streams in the basin. + pub stream_cipher: Option, + /// Create stream on append if it doesn't exist, using the default stream configuration. + #[serde(default)] + #[cfg_attr(feature = "utoipa", schema(default = false))] + pub create_stream_on_append: bool, + /// Create stream on read if it doesn't exist, using the default stream configuration. + #[serde(default)] + #[cfg_attr(feature = "utoipa", schema(default = false))] + pub create_stream_on_read: bool, +} + +impl TryFrom for s2_common::config::BasinConfig { + type Error = s2_common::ValidationError; + + fn try_from(value: BasinConfig) -> Result { + let BasinConfig { + default_stream_config, + stream_cipher, + create_stream_on_append, + create_stream_on_read, + } = value; + + let config = Self { + default_stream_config: match default_stream_config { + Some(config) => config.try_into()?, + None => Default::default(), + }, + stream_cipher: stream_cipher.map(Into::into), + create_stream_on_append, + create_stream_on_read, + }; + config.validate()?; + Ok(config) + } +} + +impl From for BasinConfig { + fn from(value: s2_common::config::BasinConfig) -> Self { + let s2_common::config::BasinConfig { + default_stream_config, + stream_cipher, + create_stream_on_append, + create_stream_on_read, + } = value; + + Self { + default_stream_config: StreamConfig::to_opt(default_stream_config), + stream_cipher: stream_cipher.map(Into::into), + create_stream_on_append, + create_stream_on_read, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct BasinReconfiguration { + /// Basin configuration. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub default_stream_config: Maybe>, + /// Encryption algorithm to apply to newly created streams in the basin. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub stream_cipher: Maybe>, + /// Create a stream on append. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub create_stream_on_append: Maybe, + /// Create a stream on read. + #[serde(default, skip_serializing_if = "Maybe::is_unspecified")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub create_stream_on_read: Maybe, +} + +impl TryFrom for s2_common::config::BasinReconfiguration { + type Error = s2_common::ValidationError; + + fn try_from(value: BasinReconfiguration) -> Result { + let BasinReconfiguration { + default_stream_config, + stream_cipher, + create_stream_on_append, + create_stream_on_read, + } = value; + + Ok(Self { + default_stream_config: default_stream_config.try_map_opt(TryInto::try_into)?, + stream_cipher: stream_cipher.map_opt(Into::into), + create_stream_on_append: create_stream_on_append.map(Into::into), + create_stream_on_read: create_stream_on_read.map(Into::into), + }) + } +} + +impl From for BasinReconfiguration { + fn from(value: s2_common::config::BasinReconfiguration) -> Self { + let s2_common::config::BasinReconfiguration { + default_stream_config, + stream_cipher, + create_stream_on_append, + create_stream_on_read, + } = value; + + Self { + default_stream_config: default_stream_config.map_opt(Into::into), + stream_cipher: stream_cipher.map_opt(Into::into), + create_stream_on_append: create_stream_on_append.map(Into::into), + create_stream_on_read: create_stream_on_read.map(Into::into), + } + } +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + fn gen_storage_class() -> impl Strategy { + "[a-z][a-z0-9-]{0,30}".prop_map(CompactString::from) + } + + fn gen_timestamping_mode() -> impl Strategy { + prop_oneof![ + Just(TimestampingMode::ClientPrefer), + Just(TimestampingMode::ClientRequire), + Just(TimestampingMode::Arrival), + ] + } + + fn gen_retention_policy() -> impl Strategy { + prop_oneof![ + any::().prop_map(RetentionPolicy::Age), + Just(RetentionPolicy::Infinite(InfiniteRetention {})), + ] + } + + fn gen_timestamping_config() -> impl Strategy { + ( + proptest::option::of(gen_timestamping_mode()), + proptest::option::of(any::()), + ) + .prop_map(|(mode, uncapped)| TimestampingConfig { mode, uncapped }) + } + + fn gen_delete_on_empty_config() -> impl Strategy { + any::().prop_map(|min_age_secs| DeleteOnEmptyConfig { min_age_secs }) + } + + fn gen_encryption_algorithm() -> impl Strategy { + prop_oneof![ + Just(EncryptionAlgorithm::Aegis256), + Just(EncryptionAlgorithm::Aes256Gcm), + ] + } + + fn gen_stream_config() -> impl Strategy { + ( + proptest::option::of(gen_storage_class()), + proptest::option::of(gen_retention_policy()), + proptest::option::of(gen_timestamping_config()), + proptest::option::of(gen_delete_on_empty_config()), + ) + .prop_map( + |(storage_class, retention_policy, timestamping, delete_on_empty)| StreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + }, + ) + } + + fn gen_basin_config() -> impl Strategy { + ( + proptest::option::of(gen_stream_config()), + proptest::option::of(gen_encryption_algorithm()), + any::(), + any::(), + ) + .prop_map( + |( + default_stream_config, + stream_cipher, + create_stream_on_append, + create_stream_on_read, + )| { + BasinConfig { + default_stream_config, + stream_cipher, + create_stream_on_append, + create_stream_on_read, + } + }, + ) + } + + fn gen_maybe( + inner: impl Strategy, + ) -> impl Strategy>> { + prop_oneof![ + Just(Maybe::Unspecified), + Just(Maybe::Specified(None)), + inner.prop_map(|v| Maybe::Specified(Some(v))), + ] + } + + fn gen_stream_reconfiguration() -> impl Strategy { + ( + gen_maybe(gen_storage_class()), + gen_maybe(gen_retention_policy()), + gen_maybe(gen_timestamping_reconfiguration()), + gen_maybe(gen_delete_on_empty_reconfiguration()), + ) + .prop_map( + |(storage_class, retention_policy, timestamping, delete_on_empty)| { + StreamReconfiguration { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } + }, + ) + } + + fn gen_timestamping_reconfiguration() -> impl Strategy { + (gen_maybe(gen_timestamping_mode()), gen_maybe(any::())) + .prop_map(|(mode, uncapped)| TimestampingReconfiguration { mode, uncapped }) + } + + fn gen_delete_on_empty_reconfiguration() -> impl Strategy + { + gen_maybe(any::()) + .prop_map(|min_age_secs| DeleteOnEmptyReconfiguration { min_age_secs }) + } + + fn gen_basin_reconfiguration() -> impl Strategy { + ( + gen_maybe(gen_stream_reconfiguration()), + gen_maybe(gen_encryption_algorithm()), + prop_oneof![ + Just(Maybe::Unspecified), + any::().prop_map(Maybe::Specified), + ], + prop_oneof![ + Just(Maybe::Unspecified), + any::().prop_map(Maybe::Specified), + ], + ) + .prop_map( + |( + default_stream_config, + stream_cipher, + create_stream_on_append, + create_stream_on_read, + )| BasinReconfiguration { + default_stream_config, + stream_cipher, + create_stream_on_append, + create_stream_on_read, + }, + ) + } + + fn gen_internal_optional_stream_config() + -> impl Strategy { + ( + proptest::option::of(gen_storage_class()), + proptest::option::of(gen_retention_policy()), + proptest::option::of(gen_timestamping_mode()), + proptest::option::of(any::()), + proptest::option::of(any::()), + ) + .prop_map(|(sc, rp, ts_mode, ts_uncapped, doe)| { + s2_common::config::OptionalStreamConfig { + storage_class: sc, + retention_policy: rp.map(|rp| match rp { + RetentionPolicy::Age(secs) => { + s2_common::config::RetentionPolicy::Age(Duration::from_secs(secs)) + } + RetentionPolicy::Infinite(_) => { + s2_common::config::RetentionPolicy::Infinite() + } + }), + timestamping: s2_common::config::OptionalTimestampingConfig { + mode: ts_mode.map(Into::into), + uncapped: ts_uncapped, + }, + delete_on_empty: s2_common::config::OptionalDeleteOnEmptyConfig { + min_age: doe.map(Duration::from_secs), + }, + } + }) + } + + proptest! { + #[test] + fn stream_config_conversion_validates(config in gen_stream_config()) { + let has_zero_age = matches!(config.retention_policy, Some(RetentionPolicy::Age(0))); + let result: Result = config.try_into(); + + if has_zero_age { + prop_assert!(result.is_err()); + } else { + prop_assert!(result.is_ok()); + } + } + + #[test] + fn basin_config_conversion_validates(config in gen_basin_config()) { + let has_invalid_config = config.default_stream_config.as_ref().is_some_and(|sc| { + matches!(sc.retention_policy, Some(RetentionPolicy::Age(0))) + }); + + let result: Result = config.try_into(); + + if has_invalid_config { + prop_assert!(result.is_err()); + } else { + prop_assert!(result.is_ok()); + } + } + + #[test] + fn stream_reconfiguration_conversion_validates(reconfig in gen_stream_reconfiguration()) { + let has_zero_age = matches!( + reconfig.retention_policy, + Maybe::Specified(Some(RetentionPolicy::Age(0))) + ); + let result: Result = reconfig.try_into(); + + if has_zero_age { + prop_assert!(result.is_err()); + } else { + prop_assert!(result.is_ok()); + } + } + + #[test] + fn merge_stream_or_basin_or_default( + stream in gen_internal_optional_stream_config(), + basin in gen_internal_optional_stream_config(), + ) { + let merged = stream.clone().merge(basin.clone()); + + prop_assert_eq!( + merged.storage_class, + stream.storage_class.or(basin.storage_class) + ); + prop_assert_eq!( + merged.retention_policy, + stream.retention_policy.or(basin.retention_policy).unwrap_or_default() + ); + prop_assert_eq!( + merged.timestamping.mode, + stream.timestamping.mode.or(basin.timestamping.mode).unwrap_or_default() + ); + prop_assert_eq!( + merged.timestamping.uncapped, + stream.timestamping.uncapped.or(basin.timestamping.uncapped).unwrap_or_default() + ); + prop_assert_eq!( + merged.delete_on_empty.min_age, + stream.delete_on_empty.min_age.or(basin.delete_on_empty.min_age).unwrap_or_default() + ); + } + + #[test] + fn reconfigure_unspecified_preserves_base(base in gen_internal_optional_stream_config()) { + let reconfig = s2_common::config::StreamReconfiguration::default(); + let result = base.clone().reconfigure(reconfig); + + prop_assert_eq!(result.storage_class, base.storage_class); + prop_assert_eq!(result.retention_policy, base.retention_policy); + prop_assert_eq!(result.timestamping.mode, base.timestamping.mode); + prop_assert_eq!(result.timestamping.uncapped, base.timestamping.uncapped); + prop_assert_eq!(result.delete_on_empty.min_age, base.delete_on_empty.min_age); + } + + #[test] + fn reconfigure_specified_none_clears(base in gen_internal_optional_stream_config()) { + let reconfig = s2_common::config::StreamReconfiguration { + storage_class: Maybe::Specified(None), + retention_policy: Maybe::Specified(None), + timestamping: Maybe::Specified(None), + delete_on_empty: Maybe::Specified(None), + }; + let result = base.reconfigure(reconfig); + + prop_assert!(result.storage_class.is_none()); + prop_assert!(result.retention_policy.is_none()); + prop_assert!(result.timestamping.mode.is_none()); + prop_assert!(result.timestamping.uncapped.is_none()); + prop_assert!(result.delete_on_empty.min_age.is_none()); + } + + #[test] + fn reconfigure_specified_some_sets_value( + base in gen_internal_optional_stream_config(), + new_sc in gen_storage_class(), + new_rp_secs in 1u64..u64::MAX, + ) { + let reconfig = s2_common::config::StreamReconfiguration { + storage_class: Maybe::Specified(Some(new_sc.clone())), + retention_policy: Maybe::Specified(Some( + s2_common::config::RetentionPolicy::Age(Duration::from_secs(new_rp_secs)) + )), + ..Default::default() + }; + let result = base.reconfigure(reconfig); + + prop_assert_eq!(result.storage_class, Some(new_sc)); + prop_assert_eq!( + result.retention_policy, + Some(s2_common::config::RetentionPolicy::Age(Duration::from_secs(new_rp_secs))) + ); + } + + #[test] + fn to_opt_returns_some_for_non_defaults( + sc in gen_storage_class(), + doe_secs in 1u64..u64::MAX, + ts_mode in gen_timestamping_mode(), + ) { + // non-default storage class -> Some + let internal = s2_common::config::OptionalStreamConfig { + storage_class: Some(sc), + ..Default::default() + }; + prop_assert!(StreamConfig::to_opt(internal).is_some()); + + // non-zero delete_on_empty -> Some + let internal = s2_common::config::OptionalDeleteOnEmptyConfig { + min_age: Some(Duration::from_secs(doe_secs)), + }; + let api = DeleteOnEmptyConfig::to_opt(internal); + prop_assert!(api.is_some()); + prop_assert_eq!(api.unwrap().min_age_secs, doe_secs); + + // non-default timestamping -> Some + let internal = s2_common::config::OptionalTimestampingConfig { + mode: Some(ts_mode.into()), + uncapped: None, + }; + prop_assert!(TimestampingConfig::to_opt(internal).is_some()); + } + + #[test] + fn basin_reconfiguration_conversion_validates(reconfig in gen_basin_reconfiguration()) { + let has_zero_age = matches!( + &reconfig.default_stream_config, + Maybe::Specified(Some(sr)) if matches!( + sr.retention_policy, + Maybe::Specified(Some(RetentionPolicy::Age(0))) + ) + ); + let result: Result = reconfig.try_into(); + + if has_zero_age { + prop_assert!(result.is_err()); + } else { + prop_assert!(result.is_ok()); + } + } + + #[test] + fn reconfigure_basin_unspecified_preserves( + base_sc in proptest::option::of(gen_storage_class()), + base_algorithm in proptest::option::of(gen_encryption_algorithm()), + base_on_append in any::(), + base_on_read in any::(), + ) { + let base = s2_common::config::BasinConfig { + default_stream_config: s2_common::config::OptionalStreamConfig { + storage_class: base_sc, + ..Default::default() + }, + stream_cipher: base_algorithm.map(Into::into), + create_stream_on_append: base_on_append, + create_stream_on_read: base_on_read, + }; + + let reconfig = s2_common::config::BasinReconfiguration::default(); + let result = base.clone().reconfigure(reconfig); + + prop_assert_eq!(result.default_stream_config.storage_class, base.default_stream_config.storage_class); + prop_assert_eq!(result.stream_cipher, base.stream_cipher); + prop_assert_eq!(result.create_stream_on_append, base.create_stream_on_append); + prop_assert_eq!(result.create_stream_on_read, base.create_stream_on_read); + } + + #[test] + fn reconfigure_basin_specified_updates( + base_on_append in any::(), + new_on_append in any::(), + new_sc in gen_storage_class(), + new_algorithm in gen_encryption_algorithm(), + ) { + let base = s2_common::config::BasinConfig { + create_stream_on_append: base_on_append, + ..Default::default() + }; + + let reconfig = s2_common::config::BasinReconfiguration { + default_stream_config: Maybe::Specified(Some(s2_common::config::StreamReconfiguration { + storage_class: Maybe::Specified(Some(new_sc.clone())), + ..Default::default() + })), + stream_cipher: Maybe::Specified(Some(new_algorithm.into())), + create_stream_on_append: Maybe::Specified(new_on_append), + ..Default::default() + }; + let result = base.reconfigure(reconfig); + + prop_assert_eq!(result.default_stream_config.storage_class, Some(new_sc)); + prop_assert_eq!(result.stream_cipher, Some(new_algorithm.into())); + prop_assert_eq!(result.create_stream_on_append, new_on_append); + } + + #[test] + fn reconfigure_nested_partial_update( + base_mode in gen_timestamping_mode(), + base_uncapped in any::(), + new_mode in gen_timestamping_mode(), + ) { + let base = s2_common::config::OptionalStreamConfig { + timestamping: s2_common::config::OptionalTimestampingConfig { + mode: Some(base_mode.into()), + uncapped: Some(base_uncapped), + }, + ..Default::default() + }; + + let expected_mode: s2_common::config::TimestampingMode = new_mode.into(); + + let reconfig = s2_common::config::StreamReconfiguration { + timestamping: Maybe::Specified(Some(s2_common::config::TimestampingReconfiguration { + mode: Maybe::Specified(Some(expected_mode)), + uncapped: Maybe::Unspecified, + })), + ..Default::default() + }; + let result = base.reconfigure(reconfig); + + prop_assert_eq!(result.timestamping.mode, Some(expected_mode)); + prop_assert_eq!(result.timestamping.uncapped, Some(base_uncapped)); + } + } + + #[test] + fn to_opt_returns_none_for_defaults() { + // default stream config -> None + assert!(StreamConfig::to_opt(s2_common::config::OptionalStreamConfig::default()).is_none()); + + // delete_on_empty: None -> None + let doe_none = s2_common::config::OptionalDeleteOnEmptyConfig { min_age: None }; + assert!(DeleteOnEmptyConfig::to_opt(doe_none).is_none()); + + // default timestamping -> None + assert!( + TimestampingConfig::to_opt(s2_common::config::OptionalTimestampingConfig::default()) + .is_none() + ); + } + + #[test] + fn optional_stream_config_to_opt_preserves_explicit_zero_delete_on_empty() { + let api = StreamConfig::to_opt(s2_common::config::OptionalStreamConfig { + delete_on_empty: s2_common::config::OptionalDeleteOnEmptyConfig { + min_age: Some(Duration::ZERO), + }, + ..Default::default() + }) + .unwrap(); + + assert_eq!( + api.delete_on_empty, + Some(DeleteOnEmptyConfig { min_age_secs: 0 }) + ); + } + + #[test] + fn empty_json_converts_to_all_none() { + let json = serde_json::json!({}); + let parsed: StreamConfig = serde_json::from_value(json).unwrap(); + let internal: s2_common::config::OptionalStreamConfig = parsed.try_into().unwrap(); + + assert!( + internal.storage_class.is_none(), + "storage_class should be None" + ); + assert!( + internal.retention_policy.is_none(), + "retention_policy should be None" + ); + assert!( + internal.timestamping.mode.is_none(), + "timestamping.mode should be None" + ); + assert!( + internal.timestamping.uncapped.is_none(), + "timestamping.uncapped should be None" + ); + assert!( + internal.delete_on_empty.min_age.is_none(), + "delete_on_empty.min_age should be None" + ); + } + + #[test] + fn stream_config_header_parses_and_validates() { + let header: StreamConfigHeader = + r#"{"retention_policy":{"age":3600},"delete_on_empty":{"min_age_secs":300}}"# + .parse() + .unwrap(); + assert_eq!( + header.0, + s2_common::config::OptionalStreamConfig { + retention_policy: Some(s2_common::config::RetentionPolicy::Age( + Duration::from_secs(3600) + )), + delete_on_empty: s2_common::config::OptionalDeleteOnEmptyConfig { + min_age: Some(Duration::from_secs(300)), + }, + ..Default::default() + } + ); + + for spaced in [ + r#"{ "retention_policy": { "age": 3600 }, "delete_on_empty": { "min_age_secs": 300 } }"#, + "{\t\"delete_on_empty\":\t{\"min_age_secs\":\t300},\t\"retention_policy\":\t{\"age\":\t3600}\t}", + " {\"retention_policy\":{\"age\":3600},\"delete_on_empty\":{\"min_age_secs\":300}} ", + ] { + let parsed: StreamConfigHeader = spaced.parse().unwrap(); + assert_eq!(parsed, header, "{spaced:?}"); + } + + let empty: StreamConfigHeader = "{}".parse().unwrap(); + assert_eq!(empty.0, Default::default()); + + let invalid_json = "not json".parse::().unwrap_err(); + assert!(invalid_json.to_string().contains("invalid JSON")); + + let invalid_age = + r#"{"retention_policy":{"age":0}}"#.parse::().unwrap_err(); + assert!( + invalid_age + .to_string() + .contains("age must be greater than 0 seconds"), + "{invalid_age}" + ); + } + + #[test] + fn stream_config_header_value_roundtrips() { + let config = StreamConfig { + storage_class: Some("express".into()), + retention_policy: Some(RetentionPolicy::Infinite(InfiniteRetention {})), + timestamping: Some(TimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + uncapped: Some(true), + }), + delete_on_empty: Some(DeleteOnEmptyConfig { min_age_secs: 60 }), + }; + let value = config.to_header_value(); + let parsed: StreamConfigHeader = value.to_str().unwrap().parse().unwrap(); + assert_eq!( + parsed.0, + s2_common::config::OptionalStreamConfig::try_from(config).unwrap() + ); + } +} diff --git a/api/src/v1/error.rs b/api/src/v1/error.rs new file mode 100644 index 00000000..e8d7e602 --- /dev/null +++ b/api/src/v1/error.rs @@ -0,0 +1,212 @@ +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + strum::Display, + strum::EnumString, + strum::IntoStaticStr, +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +// Keep this alphabetized. +pub enum ErrorCode { + AccessTokenNotFound, + Authn, + BadFrame, + BadHeader, + BadJson, + BadPath, + BadProto, + BadQuery, + BasinDeletionPending, + BasinNotFound, + ClientHangup, + DecryptionFailed, + HotServer, + Invalid, + NotImplemented, + Other, + PermissionDenied, + QuotaExhausted, + RateLimited, + RequestTimeout, + ResourceAlreadyExists, + ServerDraining, + Storage, + StreamDeletionPending, + StreamNotFound, + TransactionConflict, + Unavailable, + UpstreamTimeout, +} + +impl ErrorCode { + /// Whether this code represents an authentication or authorization failure. + pub fn is_auth_error(self) -> bool { + matches!( + self, + Self::Authn | Self::PermissionDenied | Self::AccessTokenNotFound + ) + } + + /// HTTP status associated with this error code. + pub fn status(self) -> http::StatusCode { + match self { + Self::Authn => http::StatusCode::UNAUTHORIZED, + Self::DecryptionFailed + | Self::BadFrame + | Self::BadHeader + | Self::BadJson + | Self::BadPath + | Self::BadProto + | Self::BadQuery => http::StatusCode::BAD_REQUEST, + Self::PermissionDenied | Self::QuotaExhausted => http::StatusCode::FORBIDDEN, + Self::AccessTokenNotFound | Self::BasinNotFound | Self::StreamNotFound => { + http::StatusCode::NOT_FOUND + } + Self::RequestTimeout => http::StatusCode::REQUEST_TIMEOUT, + Self::BasinDeletionPending + | Self::ResourceAlreadyExists + | Self::StreamDeletionPending + | Self::TransactionConflict => http::StatusCode::CONFLICT, + Self::Invalid => http::StatusCode::UNPROCESSABLE_ENTITY, + Self::NotImplemented => http::StatusCode::NOT_IMPLEMENTED, + Self::RateLimited => http::StatusCode::TOO_MANY_REQUESTS, + Self::ClientHangup => http::StatusCode::from_u16(499).expect("valid status code"), + Self::Other | Self::Storage => http::StatusCode::INTERNAL_SERVER_ERROR, + Self::HotServer => http::StatusCode::BAD_GATEWAY, + Self::ServerDraining | Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE, + Self::UpstreamTimeout => http::StatusCode::GATEWAY_TIMEOUT, + } + } + + /// Whether retrying an operation that returned this code is sensible. + pub fn is_retryable(self) -> bool { + match self { + Self::RequestTimeout + | Self::TransactionConflict + | Self::RateLimited + | Self::Other + | Self::ServerDraining + | Self::Storage + | Self::HotServer + | Self::Unavailable + | Self::UpstreamTimeout => true, + Self::AccessTokenNotFound + | Self::Authn + | Self::BadFrame + | Self::BadHeader + | Self::BadJson + | Self::BadPath + | Self::BadProto + | Self::BadQuery + | Self::BasinDeletionPending + | Self::BasinNotFound + | Self::ClientHangup + | Self::DecryptionFailed + | Self::Invalid + | Self::NotImplemented + | Self::PermissionDenied + | Self::QuotaExhausted + | Self::ResourceAlreadyExists + | Self::StreamDeletionPending + | Self::StreamNotFound => false, + } + } + + /// Whether the server guarantees that an operation returning this code had no side effects. + pub fn has_no_side_effects(self) -> bool { + match self { + Self::AccessTokenNotFound + | Self::Authn + | Self::BadFrame + | Self::BadHeader + | Self::BadJson + | Self::BadPath + | Self::BadProto + | Self::BadQuery + | Self::BasinDeletionPending + | Self::BasinNotFound + | Self::DecryptionFailed + | Self::HotServer + | Self::Invalid + | Self::NotImplemented + | Self::PermissionDenied + | Self::QuotaExhausted + | Self::RateLimited + | Self::ResourceAlreadyExists + | Self::ServerDraining + | Self::StreamDeletionPending + | Self::StreamNotFound + | Self::TransactionConflict => true, + Self::ClientHangup + | Self::Other + | Self::RequestTimeout + | Self::Storage + | Self::Unavailable + | Self::UpstreamTimeout => false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct ErrorInfo { + pub code: &'static str, + pub message: String, +} + +#[derive(Debug, Clone)] +pub struct StandardError { + pub status: http::StatusCode, + pub info: ErrorInfo, +} + +#[derive(Debug, Clone)] +pub enum ErrorResponse { + AppendConditionFailed(super::stream::AppendConditionFailed), + Unwritten(super::stream::TailResponse), + Standard(StandardError), +} + +impl ErrorResponse { + pub fn to_parts(&self) -> (http::StatusCode, String) { + let (status, res) = match self { + ErrorResponse::AppendConditionFailed(payload) => ( + http::StatusCode::PRECONDITION_FAILED, + serde_json::to_string(&payload), + ), + ErrorResponse::Unwritten(payload) => ( + http::StatusCode::RANGE_NOT_SATISFIABLE, + serde_json::to_string(&payload), + ), + ErrorResponse::Standard(err) => (err.status, serde_json::to_string(&err.info)), + }; + (status, res.expect("basic json ser")) + } +} + +#[cfg(feature = "axum")] +impl axum::response::IntoResponse for ErrorResponse { + fn into_response(self) -> axum::response::Response { + let (status, json_str) = self.to_parts(); + let mut response = ( + [( + http::header::CONTENT_TYPE, + http::header::HeaderValue::from_static(mime::APPLICATION_JSON.as_ref()), + )], + json_str, + ) + .into_response(); + *response.status_mut() = status; + response + } +} diff --git a/api/src/v1/location.rs b/api/src/v1/location.rs new file mode 100644 index 00000000..aacb78a4 --- /dev/null +++ b/api/src/v1/location.rs @@ -0,0 +1,43 @@ +use compact_str::CompactString; +use s2_common::{self, location::LocationName}; +use serde::{Deserialize, Serialize}; + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct LocationInfo { + /// Location name. + pub name: LocationName, + /// Location represents a private placement, limited by account. + pub is_private: bool, + /// [Storage classes](https://s2.dev/docs/storage-classes) available to the account in this location. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option>))] + pub storage_classes: Option>, + /// Default [storage class](https://s2.dev/docs/storage-classes) for this location. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "utoipa", schema(value_type = Option))] + pub default_storage_class: Option, +} + +impl From for LocationInfo { + fn from(value: s2_common::location::LocationInfo) -> Self { + let s2_common::location::LocationInfo { + name, + is_private, + storage_classes, + default_storage_class, + } = value; + + Self { + name, + is_private, + storage_classes: Some(storage_classes), + default_storage_class: Some(default_storage_class), + } + } +} + +pub type GetDefaultLocationResponse = LocationInfo; + +pub type SetDefaultLocationRequest = LocationName; diff --git a/api/src/v1/metrics.rs b/api/src/v1/metrics.rs new file mode 100644 index 00000000..bd3f0ee3 --- /dev/null +++ b/api/src/v1/metrics.rs @@ -0,0 +1,325 @@ +use compact_str::CompactString; +use serde::{Deserialize, Serialize}; + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum TimeseriesInterval { + Minute, + Hour, + Day, +} + +impl From for s2_common::metrics::TimeseriesInterval { + fn from(value: TimeseriesInterval) -> Self { + match value { + TimeseriesInterval::Minute => s2_common::metrics::TimeseriesInterval::Minute, + TimeseriesInterval::Hour => s2_common::metrics::TimeseriesInterval::Hour, + TimeseriesInterval::Day => s2_common::metrics::TimeseriesInterval::Day, + } + } +} + +impl From for TimeseriesInterval { + fn from(value: s2_common::metrics::TimeseriesInterval) -> Self { + match value { + s2_common::metrics::TimeseriesInterval::Minute => Self::Minute, + s2_common::metrics::TimeseriesInterval::Hour => Self::Hour, + s2_common::metrics::TimeseriesInterval::Day => Self::Day, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct AccountMetricSetRequest { + /// Metric set to return. + pub set: AccountMetricSet, + /// Start timestamp as Unix epoch seconds, if applicable for the metric set. + pub start: Option, + /// End timestamp as Unix epoch seconds, if applicable for the metric set. + pub end: Option, + /// Interval to aggregate over for timeseries metric sets. + pub interval: Option, +} + +impl From for s2_common::metrics::AccountMetricsRequest { + fn from(value: AccountMetricSetRequest) -> Self { + Self { + set: match value.set { + AccountMetricSet::ActiveBasins => { + s2_common::metrics::AccountMetricSet::ActiveBasins + } + AccountMetricSet::AccountOps => s2_common::metrics::AccountMetricSet::AccountOps, + }, + start: value.start, + end: value.end, + interval: value.interval.map(Into::into), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum AccountMetricSet { + /// Set of all basins that had at least one stream during the specified period. + ActiveBasins, + /// Count of append RPC operations, per interval. + AccountOps, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct BasinMetricSetRequest { + /// Metric set to return. + pub set: BasinMetricSet, + /// Start timestamp as Unix epoch seconds, if applicable for the metric set. + pub start: Option, + /// End timestamp as Unix epoch seconds, if applicable for the metric set. + pub end: Option, + /// Interval to aggregate over for timeseries metric sets. + pub interval: Option, +} + +impl From for s2_common::metrics::BasinMetricsRequest { + fn from(value: BasinMetricSetRequest) -> Self { + Self { + set: match value.set { + BasinMetricSet::AppendOps => s2_common::metrics::BasinMetricSet::AppendOps, + BasinMetricSet::AppendThroughput => { + s2_common::metrics::BasinMetricSet::AppendThroughput + } + BasinMetricSet::BasinOps => s2_common::metrics::BasinMetricSet::BasinOps, + BasinMetricSet::ReadOps => s2_common::metrics::BasinMetricSet::ReadOps, + BasinMetricSet::ReadThroughput => { + s2_common::metrics::BasinMetricSet::ReadThroughput + } + BasinMetricSet::Storage => s2_common::metrics::BasinMetricSet::Storage, + }, + start: value.start, + end: value.end, + interval: value.interval.map(Into::into), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum BasinMetricSet { + /// Amount of stored data, per hour, aggregated over all streams in a basin. + Storage, + /// Append operations, per interval. + AppendOps, + /// Read operations, per interval. + ReadOps, + /// Read bytes, per interval. + ReadThroughput, + /// Appended bytes, per interval. + AppendThroughput, + /// Count of basin RPC operations, per interval. + BasinOps, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct StreamMetricSetRequest { + /// Metric set to return. + pub set: StreamMetricSet, + /// Start timestamp as Unix epoch seconds, if applicable for the metric set. + pub start: Option, + /// End timestamp as Unix epoch seconds, if applicable for metric set. + pub end: Option, + /// Interval to aggregate over for timeseries metric sets. + pub interval: Option, +} + +impl From for s2_common::metrics::StreamMetricsRequest { + fn from(value: StreamMetricSetRequest) -> Self { + Self { + set: match value.set { + StreamMetricSet::Storage => s2_common::metrics::StreamMetricSet::Storage, + }, + start: value.start, + end: value.end, + interval: value.interval.map(Into::into), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum StreamMetricSet { + /// Amount of stored data, per minute, for a specific stream. + Storage, +} + +#[rustfmt::skip] +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum MetricUnit { + Bytes, + Operations, +} + +impl From for MetricUnit { + fn from(value: s2_common::metrics::MetricUnit) -> Self { + match value { + s2_common::metrics::MetricUnit::Bytes => MetricUnit::Bytes, + s2_common::metrics::MetricUnit::Operations => MetricUnit::Operations, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct ScalarMetric { + /// Metric name. + #[cfg_attr(feature = "utoipa", schema(value_type = String))] + pub name: CompactString, + /// Unit of the metric. + pub unit: MetricUnit, + /// Metric value. + pub value: f64, +} + +impl From for ScalarMetric { + fn from(value: s2_common::metrics::ScalarMetric) -> Self { + Self { + name: value.name, + unit: value.unit.into(), + value: value.value, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct AccumulationMetric { + /// Timeseries name. + #[cfg_attr(feature = "utoipa", schema(value_type = String))] + pub name: CompactString, + /// Unit of the metric. + pub unit: MetricUnit, + /// The interval at which data points are accumulated. + pub interval: TimeseriesInterval, + /// Timeseries values. + /// Each element is a tuple of a timestamp in Unix epoch seconds and a data point. + /// The data point represents the accumulated value for the time period starting at the timestamp, spanning one `interval`. + pub values: Vec<(u32, f64)>, +} + +impl From for AccumulationMetric { + fn from(value: s2_common::metrics::AccumulationMetric) -> Self { + Self { + name: value.name, + unit: value.unit.into(), + interval: value.interval.into(), + values: value.values, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct GaugeMetric { + /// Timeseries name. + #[cfg_attr(feature = "utoipa", schema(value_type = String))] + pub name: CompactString, + /// Unit of the metric. + pub unit: MetricUnit, + /// Timeseries values. + /// Each element is a tuple of a timestamp in Unix epoch seconds and a data point. + /// The data point represents the value at the instant of the timestamp. + pub values: Vec<(u32, f64)>, +} + +impl From for GaugeMetric { + fn from(value: s2_common::metrics::GaugeMetric) -> Self { + Self { + name: value.name, + unit: value.unit.into(), + values: value.values, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct LabelMetric { + /// Label name. + #[cfg_attr(feature = "utoipa", schema(value_type = String))] + pub name: CompactString, + /// Label values. + pub values: Vec, +} + +impl From for LabelMetric { + fn from(value: s2_common::metrics::LabelMetric) -> Self { + Self { + name: value.name, + values: value.values, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum Metric { + /// Single named value. + Scalar(ScalarMetric), + /// Named series of `(timestamp, value)` points representing an accumulation over a specified interval. + Accumulation(AccumulationMetric), + /// Named series of `(timestamp, value)` points each representing an instantaneous value. + Gauge(GaugeMetric), + /// Set of string labels. + Label(LabelMetric), +} + +impl From for Metric { + fn from(value: s2_common::metrics::Metric) -> Self { + match value { + s2_common::metrics::Metric::Scalar(scalar) => Metric::Scalar(scalar.into()), + s2_common::metrics::Metric::Accumulation(timeseries) => { + Metric::Accumulation(timeseries.into()) + } + s2_common::metrics::Metric::Gauge(timeseries) => Metric::Gauge(timeseries.into()), + s2_common::metrics::Metric::Label(label) => Metric::Label(label.into()), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct MetricSetResponse { + /// Metrics comprising the set. + pub values: Vec, +} + +impl From for MetricSetResponse { + fn from(value: s2_common::metrics::MetricsResponse) -> Self { + Self { + values: value.values.into_iter().map(Into::into).collect(), + } + } +} diff --git a/api/src/v1/mod.rs b/api/src/v1/mod.rs new file mode 100644 index 00000000..c98989c8 --- /dev/null +++ b/api/src/v1/mod.rs @@ -0,0 +1,72 @@ +pub mod access; +pub mod basin; +pub mod config; +pub mod error; +pub mod location; +pub mod metrics; +pub mod stream; + +use s2_common::{ + access::AccessTokenId, basin::BasinName, resources::RequestToken, stream::StreamName, +}; + +#[rustfmt::skip] +#[derive(Debug)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Header))] +pub struct S2RequestTokenHeader { + /// Client-specified request token for idempotent retries. + #[cfg_attr(feature = "utoipa", param(required = false, rename = "s2-request-token"))] + pub s2_request_token: RequestToken, +} + +#[rustfmt::skip] +#[derive(Debug)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Path))] +pub struct AccessTokenIdPathSegment { + /// Access token ID. + pub id: AccessTokenId, +} + +#[rustfmt::skip] +#[derive(Debug)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Path))] +pub struct BasinNamePathSegment { + /// Basin name. + pub basin: BasinName, +} + +#[rustfmt::skip] +#[derive(Debug)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Path))] +pub struct StreamNamePathSegment { + /// Stream name. + pub stream: StreamName, +} + +macro_rules! impl_list_request_conversions { + ($name:ident, $prefix:ty, $start_after:ty) => { + impl TryFrom<$name> for s2_common::resources::ListItemsRequest<$prefix, $start_after> { + type Error = s2_common::ValidationError; + + fn try_from(value: $name) -> Result { + let $name { + prefix, + start_after, + limit, + } = value; + + Ok(Self { + prefix: prefix.unwrap_or_default(), + start_after: start_after.unwrap_or_default(), + limit: limit.map(Into::into).unwrap_or_default(), + }) + } + } + }; +} + +pub(crate) use impl_list_request_conversions; diff --git a/api/src/v1/stream/extract.rs b/api/src/v1/stream/extract.rs new file mode 100644 index 00000000..deae94fb --- /dev/null +++ b/api/src/v1/stream/extract.rs @@ -0,0 +1,192 @@ +use axum::{ + extract::{FromRequest, FromRequestParts, Request}, + response::{IntoResponse, Response}, +}; +use futures_util::StreamExt as _; +use http::{StatusCode, request::Parts}; +use s2_common::{ + encryption::EncryptionKey, + http::{ParseableHeader, extract::HeaderRejection}, +}; +use tokio_util::{codec::FramedRead, io::StreamReader}; + +use super::{AppendInput, AppendInputStreamError, AppendRequest, ReadRequest, proto, s2s}; +use crate::{ + data::{ + Format, Json, Proto, + extract::{JsonExtractionRejection, ProtoRejection}, + }, + mime::JsonOrProto, + v1::{config::StreamConfigHeader, stream::sse::LastEventId}, +}; + +#[derive(Debug, thiserror::Error)] +pub enum AppendRequestRejection { + #[error(transparent)] + HeaderRejection(#[from] HeaderRejection), + #[error(transparent)] + JsonRejection(#[from] JsonExtractionRejection), + #[error(transparent)] + ProtoRejection(#[from] ProtoRejection), + #[error(transparent)] + Validation(#[from] s2_common::ValidationError), +} + +impl IntoResponse for AppendRequestRejection { + fn into_response(self) -> Response { + match self { + AppendRequestRejection::HeaderRejection(e) => e.into_response(), + AppendRequestRejection::JsonRejection(e) => e.into_response(), + AppendRequestRejection::ProtoRejection(e) => e.into_response(), + AppendRequestRejection::Validation(e) => { + (StatusCode::UNPROCESSABLE_ENTITY, e.to_string()).into_response() + } + } + } +} + +impl FromRequest for AppendRequest +where + S: Send + Sync, +{ + type Rejection = AppendRequestRejection; + + async fn from_request(req: Request, state: &S) -> Result { + let content_type = crate::mime::content_type(req.headers()); + let encryption_key = parse_header_opt::(req.headers())?; + let create_stream_config_patch = parse_header_opt::(req.headers())? + .map(|header| header.0) + .unwrap_or_default(); + + if content_type.as_ref().is_some_and(crate::mime::is_s2s_proto) { + let response_compression = + s2s::CompressionAlgorithm::from_accept_encoding(req.headers()); + + let body_reader = StreamReader::new( + req.into_body() + .into_data_stream() + .map(|result| result.map_err(std::io::Error::other)), + ); + + let framed = FramedRead::new(body_reader, s2s::FrameDecoder); + + let inputs = futures_util::stream::try_unfold(framed, |mut framed| async move { + let Some(msg) = framed.next().await else { + return Ok(None); + }; + match msg? { + s2s::SessionMessage::Regular(data) => { + let input = data.try_into_proto::()?; + let input = s2_common::stream::AppendInput::try_from(input)?; + Ok(Some((input, framed))) + } + s2s::SessionMessage::Terminal(_) => { + Err(AppendInputStreamError::FrameDecode(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Unexpected terminal frame as input", + ))) + } + } + }); + + return Ok(Self::S2s { + encryption_key, + create_stream_config_patch, + inputs: Box::pin(inputs), + response_compression, + }); + } + + let request_mime = content_type + .as_ref() + .and_then(JsonOrProto::from_mime) + .unwrap_or(JsonOrProto::Json); + + let response_mime = crate::mime::accept(req.headers()) + .as_ref() + .and_then(JsonOrProto::from_mime) + .unwrap_or(JsonOrProto::Json); + + let input = match request_mime { + JsonOrProto::Proto => { + let Proto(input) = Proto::::from_request(req, state).await?; + input.try_into()? + } + JsonOrProto::Json => { + let format = parse_header_opt::(req.headers())?.unwrap_or_default(); + let Json(input) = Json::::from_request(req, state).await?; + input.decode(format)? + } + }; + + Ok(Self::Unary { + encryption_key, + create_stream_config_patch, + input, + response_mime, + }) + } +} + +impl FromRequestParts for ReadRequest +where + S: Send + Sync, +{ + type Rejection = HeaderRejection; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let content_type = crate::mime::content_type(&parts.headers); + let encryption_key = parse_header_opt::(&parts.headers)?; + let create_stream_config_patch = parse_header_opt::(&parts.headers)? + .map(|header| header.0) + .unwrap_or_default(); + + if content_type.as_ref().is_some_and(crate::mime::is_s2s_proto) { + let response_compression = + s2s::CompressionAlgorithm::from_accept_encoding(&parts.headers); + return Ok(Self::S2s { + encryption_key, + create_stream_config_patch, + response_compression, + }); + } + + let format = parse_header_opt::(&parts.headers)?.unwrap_or_default(); + + let accept = crate::mime::accept(&parts.headers); + + if accept.as_ref().is_some_and(crate::mime::is_event_stream) { + let last_event_id = parse_header_opt::(&parts.headers)?; + return Ok(Self::EventStream { + encryption_key, + create_stream_config_patch, + format, + last_event_id, + }); + } + + let response_mime = accept + .as_ref() + .and_then(JsonOrProto::from_mime) + .unwrap_or(JsonOrProto::Json); + + Ok(Self::Unary { + encryption_key, + create_stream_config_patch, + format, + response_mime, + }) + } +} + +fn parse_header_opt(headers: &http::HeaderMap) -> Result, HeaderRejection> +where + T: ParseableHeader, + T::Err: std::fmt::Display, +{ + match s2_common::http::extract::parse_header(headers) { + Ok(value) => Ok(Some(value)), + Err(HeaderRejection::MissingHeader(_)) => Ok(None), + Err(e) => Err(e)?, + } +} diff --git a/api/src/v1/stream/json.rs b/api/src/v1/stream/json.rs new file mode 100644 index 00000000..a2257533 --- /dev/null +++ b/api/src/v1/stream/json.rs @@ -0,0 +1,363 @@ +use base64ct::{Base64, Encoding as _}; +use s2_common::record; +use serde::{ + Serialize, + ser::{SerializeSeq, SerializeStruct, SerializeTuple}, +}; + +use crate::data::Format; + +pub fn serialize_read_batch( + format: Format, + batch: &s2_common::stream::ReadBatch, +) -> impl Serialize + '_ { + ReadBatchJson { format, batch } +} + +struct ReadBatchJson<'a> { + format: Format, + batch: &'a s2_common::stream::ReadBatch, +} + +impl Serialize for ReadBatchJson<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut state = + serializer.serialize_struct("ReadBatch", 1 + usize::from(self.batch.tail.is_some()))?; + state.serialize_field( + "records", + &RecordsJson { + format: self.format, + records: self.batch.records.as_slice(), + }, + )?; + if let Some(tail) = self.batch.tail { + state.serialize_field("tail", &StreamPositionJson(tail))?; + } + state.end() + } +} + +struct RecordsJson<'a> { + format: Format, + records: &'a [record::SequencedRecord], +} + +impl Serialize for RecordsJson<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut seq = serializer.serialize_seq(Some(self.records.len()))?; + for record in self.records { + seq.serialize_element(&RecordJson { + format: self.format, + record, + })?; + } + seq.end() + } +} + +struct RecordJson<'a> { + format: Format, + record: &'a record::SequencedRecord, +} + +impl Serialize for RecordJson<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // Some records omit `headers` and/or `body`, but `serde_json` does not rely on an exact + // field count here, so keep the fixed upper bound and avoid extra bookkeeping. + let mut state = serializer.serialize_struct("SequencedRecord", 4)?; + let position = self.record.position(); + state.serialize_field("seq_num", &position.seq_num)?; + state.serialize_field("timestamp", &position.timestamp)?; + match self.record.inner() { + record::Record::Command(command) => { + state.serialize_field( + "headers", + &CommandHeadersJson { + format: self.format, + command, + }, + )?; + match command { + record::CommandRecord::Fence(token) => { + if !token.is_empty() { + state.serialize_field( + "body", + &FormattedBytes { + format: self.format, + bytes: token.as_bytes(), + }, + )?; + } + } + record::CommandRecord::Trim(trim_point) => { + let bytes = trim_point.to_be_bytes(); + state.serialize_field( + "body", + &FormattedBytes { + format: self.format, + bytes: &bytes, + }, + )?; + } + } + } + record::Record::Envelope(envelope) => { + if !envelope.headers().is_empty() { + state.serialize_field( + "headers", + &HeadersJson { + format: self.format, + headers: envelope.headers(), + }, + )?; + } + if !envelope.body().is_empty() { + state.serialize_field( + "body", + &FormattedBytes { + format: self.format, + bytes: envelope.body().as_ref(), + }, + )?; + } + } + } + state.end() + } +} + +struct HeadersJson<'a> { + format: Format, + headers: &'a [record::Header], +} + +impl Serialize for HeadersJson<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut seq = serializer.serialize_seq(Some(self.headers.len()))?; + for header in self.headers { + seq.serialize_element(&HeaderJson { + format: self.format, + header, + })?; + } + seq.end() + } +} + +struct HeaderJson<'a> { + format: Format, + header: &'a record::Header, +} + +impl Serialize for HeaderJson<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut tuple = serializer.serialize_tuple(2)?; + tuple.serialize_element(&FormattedBytes { + format: self.format, + bytes: self.header.name.as_ref(), + })?; + tuple.serialize_element(&FormattedBytes { + format: self.format, + bytes: self.header.value.as_ref(), + })?; + tuple.end() + } +} + +struct CommandHeadersJson<'a> { + format: Format, + command: &'a record::CommandRecord, +} + +impl Serialize for CommandHeadersJson<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut seq = serializer.serialize_seq(Some(1))?; + seq.serialize_element(&CommandHeaderJson { + format: self.format, + command: self.command, + })?; + seq.end() + } +} + +struct CommandHeaderJson<'a> { + format: Format, + command: &'a record::CommandRecord, +} + +impl Serialize for CommandHeaderJson<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut tuple = serializer.serialize_tuple(2)?; + tuple.serialize_element(&FormattedBytes { + format: self.format, + bytes: b"", + })?; + tuple.serialize_element(&FormattedBytes { + format: self.format, + bytes: self.command.op().to_id(), + })?; + tuple.end() + } +} + +struct StreamPositionJson(record::StreamPosition); + +impl Serialize for StreamPositionJson { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("StreamPosition", 2)?; + state.serialize_field("seq_num", &self.0.seq_num)?; + state.serialize_field("timestamp", &self.0.timestamp)?; + state.end() + } +} + +struct FormattedBytes<'a> { + format: Format, + bytes: &'a [u8], +} + +impl Serialize for FormattedBytes<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self.format { + Format::Raw => serializer.collect_str(&LossyUtf8(self.bytes)), + Format::Base64 => serializer.collect_str(&Base64Display(self.bytes)), + } + } +} + +struct LossyUtf8<'a>(&'a [u8]); + +impl std::fmt::Display for LossyUtf8<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use std::fmt::Write as _; + + for chunk in self.0.utf8_chunks() { + f.write_str(chunk.valid())?; + if !chunk.invalid().is_empty() { + f.write_char(char::REPLACEMENT_CHARACTER)?; + } + } + Ok(()) + } +} + +struct Base64Display<'a>(&'a [u8]); + +impl std::fmt::Display for Base64Display<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + const INPUT_CHUNK: usize = 3 * 256; + const OUTPUT_CHUNK: usize = 4 * 256; + + let mut output = [0u8; OUTPUT_CHUNK]; + let (chunks, remainder) = self.0.as_chunks::(); + for chunk in chunks { + let encoded = Base64::encode(chunk, &mut output).map_err(|_| std::fmt::Error)?; + f.write_str(encoded)?; + } + + if !remainder.is_empty() { + let encoded_len = Base64::encoded_len(remainder); + let encoded = Base64::encode(remainder, &mut output[..encoded_len]) + .map_err(|_| std::fmt::Error)?; + f.write_str(encoded)?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use s2_common::record::MeteredExt; + + use super::*; + use crate::v1::stream::ReadBatch; + + fn fixture_batch() -> s2_common::stream::ReadBatch { + let envelope = record::Record::try_from_parts( + vec![record::Header { + name: Bytes::from_static(b"kind"), + value: Bytes::from(vec![b'a', 0xff, b'z']), + }], + Bytes::from(vec![0xf0, 0x28, 0x8c, 0xbc]), + ) + .expect("valid envelope"); + + let empty_fence = record::Record::Command(record::CommandRecord::Fence( + "".parse().expect("valid token"), + )); + + let non_empty_fence = record::Record::Command(record::CommandRecord::Fence( + "token-1".parse().expect("valid token"), + )); + + let trim = record::Record::Command(record::CommandRecord::Trim(42)); + + s2_common::stream::ReadBatch { + records: vec![ + envelope.metered().sequenced(record::StreamPosition { + seq_num: 7, + timestamp: 11, + }), + empty_fence.metered().sequenced(record::StreamPosition { + seq_num: 8, + timestamp: 12, + }), + non_empty_fence.metered().sequenced(record::StreamPosition { + seq_num: 9, + timestamp: 13, + }), + trim.metered().sequenced(record::StreamPosition { + seq_num: 10, + timestamp: 14, + }), + ] + .into_iter() + .collect(), + tail: Some(record::StreamPosition { + seq_num: 11, + timestamp: 15, + }), + } + } + + #[test] + fn serialized_batch_matches_existing_json_shape() { + let batch = fixture_batch(); + + for format in [Format::Raw, Format::Base64] { + let expected = + serde_json::to_value(ReadBatch::encode(format, batch.clone())).expect("json"); + let actual = serde_json::to_value(serialize_read_batch(format, &batch)).expect("json"); + assert_eq!(actual, expected); + } + } +} diff --git a/api/src/v1/stream/mod.rs b/api/src/v1/stream/mod.rs new file mode 100644 index 00000000..2d8d66e8 --- /dev/null +++ b/api/src/v1/stream/mod.rs @@ -0,0 +1,481 @@ +#[cfg(feature = "axum")] +pub mod extract; + +pub mod json; +pub mod proto; +pub mod s2s; +pub mod sse; + +use std::time::Duration; + +use futures_core::stream::BoxStream; +use itertools::Itertools as _; +use s2_common::{ + config::OptionalStreamConfig, + encryption::EncryptionKey, + record, + stream::{StreamName, StreamNamePrefix, StreamNameStartAfter}, +}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +use super::config::{EncryptionAlgorithm, StreamConfig}; +use crate::{data::Format, mime::JsonOrProto}; + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct StreamInfo { + /// Stream name. + pub name: StreamName, + /// Creation time in RFC 3339 format. + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + /// Deletion time in RFC 3339 format, if the stream is being deleted. + #[serde(with = "time::serde::rfc3339::option")] + pub deleted_at: Option, + /// Encryption algorithm for this stream, if encryption is enabled. + pub cipher: Option, +} + +impl From for StreamInfo { + fn from(value: s2_common::stream::StreamInfo) -> Self { + Self { + name: value.name, + created_at: value.created_at, + deleted_at: value.deleted_at, + cipher: value.cipher.map(Into::into), + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct ListStreamsRequest { + /// Filter to streams whose names begin with this prefix. + /// It must not contain NUL bytes. + #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))] + pub prefix: Option, + /// Filter to streams whose names lexicographically start after this string. + /// It must not contain NUL bytes. + #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))] + pub start_after: Option, + /// Number of results, up to a maximum of 1000. + #[cfg_attr(feature = "utoipa", param(value_type = usize, maximum = 1000, default = 1000, required = false))] + pub limit: Option, +} + +super::impl_list_request_conversions!(ListStreamsRequest, StreamNamePrefix, StreamNameStartAfter); + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct ListStreamsResponse { + /// Matching streams. + #[cfg_attr(feature = "utoipa", schema(max_items = 1000))] + pub streams: Vec, + /// Indicates that there are more results that match the criteria. + pub has_more: bool, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct CreateStreamRequest { + /// Stream name that is unique to the basin. + /// It can be between 1 and 512 bytes in length, and must not contain NUL bytes. + pub stream: StreamName, + /// Stream configuration. + pub config: Option, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +/// Position of a record in a stream. +pub struct StreamPosition { + /// Sequence number assigned by the service. + pub seq_num: record::SeqNum, + /// Timestamp, which may be client-specified or assigned by the service. + /// If it is assigned by the service, it will represent milliseconds since Unix epoch. + pub timestamp: record::Timestamp, +} + +impl From for StreamPosition { + fn from(pos: record::StreamPosition) -> Self { + Self { + seq_num: pos.seq_num, + timestamp: pos.timestamp, + } + } +} + +impl From for record::StreamPosition { + fn from(pos: StreamPosition) -> Self { + Self { + seq_num: pos.seq_num, + timestamp: pos.timestamp, + } + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct TailResponse { + /// Sequence number that will be assigned to the next record on the stream, and timestamp of the last record. + pub tail: StreamPosition, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct ReadStart { + /// Start from a sequence number. + #[cfg_attr(feature = "utoipa", param(value_type = record::SeqNum, required = false))] + pub seq_num: Option, + /// Start from a timestamp. + #[cfg_attr(feature = "utoipa", param(value_type = record::Timestamp, required = false))] + pub timestamp: Option, + /// Start from number of records before the next sequence number. + #[cfg_attr(feature = "utoipa", param(value_type = u64, required = false))] + pub tail_offset: Option, + /// Start reading from the tail if the requested position is beyond it. + /// Otherwise, a `416 Range Not Satisfiable` response is returned. + #[cfg_attr(feature = "utoipa", param(value_type = bool, required = false))] + pub clamp: Option, +} + +impl TryFrom for s2_common::stream::ReadStart { + type Error = s2_common::ValidationError; + + fn try_from(value: ReadStart) -> Result { + let from = match (value.seq_num, value.timestamp, value.tail_offset) { + (Some(seq_num), None, None) => s2_common::stream::ReadFrom::SeqNum(seq_num), + (None, Some(timestamp), None) => s2_common::stream::ReadFrom::Timestamp(timestamp), + (None, None, Some(tail_offset)) => s2_common::stream::ReadFrom::TailOffset(tail_offset), + (None, None, None) => s2_common::stream::ReadFrom::TailOffset(0), + _ => { + return Err(s2_common::ValidationError( + "only one of seq_num, timestamp, or tail_offset can be provided".to_owned(), + )); + } + }; + let clamp = value.clamp.unwrap_or(false); + Ok(Self { from, clamp }) + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))] +pub struct ReadEnd { + /// Record count limit. + /// Non-streaming reads are capped by the default limit of 1000 records. + #[cfg_attr(feature = "utoipa", param(value_type = u64, required = false))] + pub count: Option, + /// Metered bytes limit. + /// Non-streaming reads are capped by the default limit of 1 MiB. + #[cfg_attr(feature = "utoipa", param(value_type = usize, required = false))] + pub bytes: Option, + /// Exclusive timestamp to read until. + #[cfg_attr(feature = "utoipa", param(value_type = record::Timestamp, required = false))] + pub until: Option, + /// Duration in seconds to wait for new records. + /// The default duration is 0 if there is a bound on `count`, `bytes`, or `until`, and otherwise infinite. + /// Non-streaming reads are always bounded on `count` and `bytes`, so you can achieve long poll semantics by specifying a non-zero duration up to 60 seconds. + /// In the context of an SSE or S2S streaming read, the duration will bound how much time can elapse between records throughout the lifetime of the session. + #[cfg_attr(feature = "utoipa", param(value_type = u32, required = false))] + pub wait: Option, +} + +impl From for s2_common::stream::ReadEnd { + fn from(value: ReadEnd) -> Self { + Self { + limit: s2_common::read_extent::ReadLimit::from_count_and_bytes( + value.count, + value.bytes, + ), + until: value.until.into(), + wait: value.wait.map(|w| Duration::from_secs(w as u64)), + } + } +} + +#[derive(Debug, Clone)] +pub enum ReadRequest { + /// Unary + Unary { + encryption_key: Option, + /// Parsed `s2-stream-config` header; empty if absent. + create_stream_config_patch: OptionalStreamConfig, + format: Format, + response_mime: JsonOrProto, + }, + /// Server-Sent Events streaming response + EventStream { + encryption_key: Option, + /// Parsed `s2-stream-config` header; empty if absent. + create_stream_config_patch: OptionalStreamConfig, + format: Format, + last_event_id: Option, + }, + /// S2S streaming response + S2s { + encryption_key: Option, + /// Parsed `s2-stream-config` header; empty if absent. + create_stream_config_patch: OptionalStreamConfig, + response_compression: s2s::CompressionAlgorithm, + }, +} + +pub enum AppendRequest { + /// Unary + Unary { + encryption_key: Option, + /// Parsed `s2-stream-config` header; empty if absent. + create_stream_config_patch: OptionalStreamConfig, + input: s2_common::stream::AppendInput, + response_mime: JsonOrProto, + }, + /// S2S bi-directional streaming + S2s { + encryption_key: Option, + /// Parsed `s2-stream-config` header; empty if absent. + create_stream_config_patch: OptionalStreamConfig, + inputs: BoxStream<'static, Result>, + response_compression: s2s::CompressionAlgorithm, + }, +} + +impl std::fmt::Debug for AppendRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AppendRequest::Unary { + encryption_key, + create_stream_config_patch, + input, + response_mime: response, + } => f + .debug_struct("AppendRequest::Unary") + .field("encryption_key", encryption_key) + .field("create_stream_config_patch", create_stream_config_patch) + .field("input", input) + .field("response", response) + .finish(), + AppendRequest::S2s { + encryption_key, + create_stream_config_patch, + response_compression, + .. + } => f + .debug_struct("AppendRequest::S2s") + .field("encryption_key", encryption_key) + .field("create_stream_config_patch", create_stream_config_patch) + .field("response_compression", response_compression) + .finish(), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum AppendInputStreamError { + #[error("Failed to decode S2S frame: {0}")] + FrameDecode(#[from] std::io::Error), + #[error(transparent)] + Validation(#[from] s2_common::ValidationError), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct Header(pub String, pub String); + +#[rustfmt::skip] +/// Record that is durably sequenced on a stream. +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct SequencedRecord { + /// Sequence number assigned by the service. + pub seq_num: record::SeqNum, + /// Timestamp for this record. + pub timestamp: record::Timestamp, + /// Series of name-value pairs for this record. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[cfg_attr(feature = "utoipa", schema(required = false))] + pub headers: Vec
, + /// Body of the record. + #[serde(default, skip_serializing_if = "String::is_empty")] + #[cfg_attr(feature = "utoipa", schema(required = false))] + pub body: String, +} + +impl SequencedRecord { + pub fn encode(format: Format, record: record::SequencedRecord) -> Self { + let (record::StreamPosition { seq_num, timestamp }, record) = record.into_parts(); + let (headers, body) = record.into_parts(); + Self { + seq_num, + timestamp, + headers: headers + .into_iter() + .map(|h| Header(format.encode(&h.name), format.encode(&h.value))) + .collect(), + body: format.encode(&body), + } + } +} + +#[rustfmt::skip] +/// Record to be appended to a stream. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct AppendRecord { + /// Timestamp for this record. + /// The service will always ensure monotonicity by adjusting it up if necessary to the maximum observed timestamp. + /// Refer to stream timestamping configuration for the finer semantics around whether a client-specified timestamp is required, and whether it will be capped at the arrival time. + pub timestamp: Option, + /// Series of name-value pairs for this record. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[cfg_attr(feature = "utoipa", schema(required = false))] + pub headers: Vec
, + /// Body of the record. + #[serde(default, skip_serializing_if = "String::is_empty")] + #[cfg_attr(feature = "utoipa", schema(required = false))] + pub body: String, +} + +impl AppendRecord { + pub fn decode( + self, + format: Format, + ) -> Result { + let headers = self + .headers + .into_iter() + .map(|Header(name, value)| { + Ok::(record::Header { + name: format.decode(name)?, + value: format.decode(value)?, + }) + }) + .try_collect()?; + + let body = format.decode(self.body)?; + + let record = record::Record::try_from_parts(headers, body) + .map_err(|e| e.to_string())? + .into(); + + let parts = s2_common::stream::AppendRecordParts { + timestamp: self.timestamp, + record, + }; + + s2_common::stream::AppendRecord::try_from(parts) + .map_err(|e| s2_common::ValidationError(e.to_string())) + } +} + +#[rustfmt::skip] +/// Payload of an `append` request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct AppendInput { + /// Batch of records to append atomically, which must contain at least one record, and no more than 1000. + /// The total size of a batch of records may not exceed 1 MiB of metered bytes. + pub records: Vec, + /// Enforce that the sequence number assigned to the first record matches. + pub match_seq_num: Option, + /// Enforce a fencing token, which starts out as an empty string that can be overridden by a `fence` command record. + pub fencing_token: Option, +} + +impl AppendInput { + pub fn decode( + self, + format: Format, + ) -> Result { + let records: Vec = self + .records + .into_iter() + .map(|record| record.decode(format)) + .try_collect()?; + + Ok(s2_common::stream::AppendInput { + records: s2_common::stream::AppendRecordBatch::try_from(records)?, + match_seq_num: self.match_seq_num, + fencing_token: self.fencing_token, + }) + } +} + +#[rustfmt::skip] +/// Success response to an `append` request. +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct AppendAck { + /// Sequence number and timestamp of the first record that was appended. + pub start: StreamPosition, + /// Sequence number of the last record that was appended `+ 1`, and timestamp of the last record that was appended. + /// The difference between `end.seq_num` and `start.seq_num` will be the number of records appended. + pub end: StreamPosition, + /// Sequence number that will be assigned to the next record on the stream, and timestamp of the last record on the stream. + /// This can be greater than the `end` position in case of concurrent appends. + pub tail: StreamPosition, +} + +impl From for AppendAck { + fn from(ack: s2_common::stream::AppendAck) -> Self { + Self { + start: ack.start.into(), + end: ack.end.into(), + tail: ack.tail.into(), + } + } +} + +#[rustfmt::skip] +/// Aborted due to a failed condition. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum AppendConditionFailed { + /// Fencing token did not match. + /// The expected fencing token is returned. + #[cfg_attr(feature = "utoipa", schema(title = "fencing token"))] + FencingTokenMismatch(record::FencingToken), + /// Sequence number did not match the tail of the stream. + /// The expected next sequence number is returned. + #[cfg_attr(feature = "utoipa", schema(title = "seq num"))] + SeqNumMismatch(record::SeqNum), +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct ReadBatch { + /// Records that are durably sequenced on the stream, retrieved based on the requested criteria. + /// This can only be empty in response to a unary read (i.e. not SSE), if the request cannot be satisfied without violating an explicit bound (`count`, `bytes`, or `until`). + pub records: Vec, + /// Sequence number that will be assigned to the next record on the stream, and timestamp of the last record. + /// This will only be present when reading recent records. + #[serde(skip_serializing_if = "Option::is_none")] + pub tail: Option, +} + +impl ReadBatch { + pub fn encode(format: Format, batch: s2_common::stream::ReadBatch) -> Self { + Self { + records: batch + .records + .into_iter() + .map(|record| SequencedRecord::encode(format, record)) + .collect(), + tail: batch.tail.map(Into::into), + } + } +} diff --git a/api/src/v1/stream/proto/mod.rs b/api/src/v1/stream/proto/mod.rs new file mode 100644 index 00000000..177ad093 --- /dev/null +++ b/api/src/v1/stream/proto/mod.rs @@ -0,0 +1,104 @@ +use compact_str::ToCompactString; +use s2_common::record; + +include!("s2.v1.rs"); + +impl From for StreamPosition { + fn from(record::StreamPosition { seq_num, timestamp }: record::StreamPosition) -> Self { + Self { seq_num, timestamp } + } +} + +impl From
for record::Header { + fn from(Header { name, value }: Header) -> Self { + Self { name, value } + } +} + +impl From for Header { + fn from(record::Header { name, value }: record::Header) -> Self { + Self { name, value } + } +} + +impl TryFrom for s2_common::stream::AppendRecord { + type Error = s2_common::ValidationError; + + fn try_from( + AppendRecord { + timestamp, + headers, + body, + }: AppendRecord, + ) -> Result { + Ok(Self::try_from(s2_common::stream::AppendRecordParts { + timestamp, + record: record::Record::try_from_parts( + headers.into_iter().map(Into::into).collect(), + body, + ) + .map_err(|e| e.to_string())? + .into(), + })?) + } +} + +impl TryFrom for s2_common::stream::AppendInput { + type Error = s2_common::ValidationError; + + fn try_from( + AppendInput { + records, + match_seq_num, + fencing_token, + }: AppendInput, + ) -> Result { + let records = records + .into_iter() + .map(s2_common::stream::AppendRecord::try_from) + .collect::, _>>()?; + + Ok(Self { + records: s2_common::stream::AppendRecordBatch::try_from(records)?, + match_seq_num, + fencing_token: fencing_token + .as_deref() + .map(|s| s.to_compact_string().try_into()) + .transpose()?, + }) + } +} + +impl From for AppendAck { + fn from( + s2_common::stream::AppendAck { start, end, tail }: s2_common::stream::AppendAck, + ) -> Self { + Self { + start: Some(start.into()), + end: Some(end.into()), + tail: Some(tail.into()), + } + } +} + +impl From for SequencedRecord { + fn from(record: record::SequencedRecord) -> Self { + let (record::StreamPosition { seq_num, timestamp }, record) = record.into_parts(); + let (headers, body) = record.into_parts(); + Self { + seq_num, + timestamp, + headers: headers.into_iter().map(Into::into).collect(), + body, + } + } +} + +impl From for ReadBatch { + fn from(batch: s2_common::stream::ReadBatch) -> Self { + Self { + records: batch.records.into_iter().map(Into::into).collect(), + tail: batch.tail.map(Into::into), + } + } +} diff --git a/api/src/v1/stream/proto/s2.v1.rs b/api/src/v1/stream/proto/s2.v1.rs new file mode 100644 index 00000000..c45d6a11 --- /dev/null +++ b/api/src/v1/stream/proto/s2.v1.rs @@ -0,0 +1,95 @@ +// This file is @generated by prost-build. +/// Position of a record in a stream. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct StreamPosition { + /// Sequence number assigned by the service. + #[prost(uint64, tag = "1")] + pub seq_num: u64, + /// Timestamp, which may be user-specified or assigned by the service. + /// If it is assigned by the service, it will represent milliseconds since Unix epoch. + #[prost(uint64, tag = "2")] + pub timestamp: u64, +} +/// Headers add structured information to a record as name-value pairs. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Header { + /// Header name blob. + /// The name cannot be empty, with the exception of an S2 command record. + #[prost(bytes = "bytes", tag = "1")] + pub name: ::prost::bytes::Bytes, + /// Header value blob. + #[prost(bytes = "bytes", tag = "2")] + pub value: ::prost::bytes::Bytes, +} +/// Record to be appended to a stream. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AppendRecord { + /// Timestamp for this record. + /// Precise semantics depend on the stream's `timestamping` config. + #[prost(uint64, optional, tag = "1")] + pub timestamp: ::core::option::Option, + /// Series of name-value pairs for this record. + #[prost(message, repeated, tag = "2")] + pub headers: ::prost::alloc::vec::Vec
, + /// Body of this record. + #[prost(bytes = "bytes", tag = "3")] + pub body: ::prost::bytes::Bytes, +} +/// Payload of an Append request message. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AppendInput { + /// Batch of records to append atomically, which must contain at least one record, and no more than 1000. + /// The total size of a batch of records may not exceed 1MiB of metered bytes. + #[prost(message, repeated, tag = "1")] + pub records: ::prost::alloc::vec::Vec, + /// Enforce that the sequence number issued to the first record matches. + #[prost(uint64, optional, tag = "2")] + pub match_seq_num: ::core::option::Option, + /// Enforce a fencing token which must have been previously set by a `fence` command record. + #[prost(string, optional, tag = "3")] + pub fencing_token: ::core::option::Option<::prost::alloc::string::String>, +} +/// Success response message to an Append request. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AppendAck { + /// Sequence number and timestamp of the first record that was appended. + #[prost(message, optional, tag = "1")] + pub start: ::core::option::Option, + /// Sequence number of the last record that was appended + 1, and timestamp of the last record that was appended. + /// The difference between `end.seq_num` and `start.seq_num` will be the number of records appended. + #[prost(message, optional, tag = "2")] + pub end: ::core::option::Option, + /// Sequence number that will be assigned to the next record on the stream, and timestamp of the last record on the stream. + /// This can be greater than the `end` position in case of concurrent appends. + #[prost(message, optional, tag = "3")] + pub tail: ::core::option::Option, +} +/// Record that is durably sequenced on a stream. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SequencedRecord { + /// Sequence number assigned to this record. + #[prost(uint64, tag = "1")] + pub seq_num: u64, + /// Timestamp for this record. + #[prost(uint64, tag = "2")] + pub timestamp: u64, + /// Series of name-value pairs for this record. + #[prost(message, repeated, tag = "3")] + pub headers: ::prost::alloc::vec::Vec
, + /// Body of this record. + #[prost(bytes = "bytes", tag = "4")] + pub body: ::prost::bytes::Bytes, +} +/// Success response message to a Read request. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ReadBatch { + /// Records that are durably sequenced on the stream, retrieved based on the requested criteria. + /// This can only be empty in response to a unary read if the request cannot be satisfied without violating an explicit bound (`count`, `bytes`, or `until`). + /// In the context of a session, it can be empty as a heartbeat message. A heartbeat will be sent whenever a switch to following in real-time happens, and then at a randomized gap between 5 and 15 seconds if no records have become available. + #[prost(message, repeated, tag = "1")] + pub records: ::prost::alloc::vec::Vec, + /// Sequence number that will be assigned to the next record on the stream, and timestamp of the last record. + /// It will only be present when reading recent records. + #[prost(message, optional, tag = "2")] + pub tail: ::core::option::Option, +} diff --git a/api/src/v1/stream/s2s.rs b/api/src/v1/stream/s2s.rs new file mode 100644 index 00000000..ae6c6304 --- /dev/null +++ b/api/src/v1/stream/s2s.rs @@ -0,0 +1,961 @@ +use std::{ + io::{Read, Write}, + pin::Pin, + task::{Context, Poll}, +}; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use flate2::{Compression, read::GzDecoder, write::GzEncoder}; +use futures_core::Stream; +use strum::FromRepr; + +/* + REGULAR MESSAGE: + ┌─────────────┬────────────┬─────────────────────────────┐ + │ LENGTH │ FLAGS │ PAYLOAD DATA │ + │ (3 bytes) │ (1 byte) │ (variable length) │ + ├─────────────┼────────────┼─────────────────────────────┤ + │ 0x00 00 XX │ 0 CA RXXXX │ Compressed proto message │ + └─────────────┴────────────┴─────────────────────────────┘ + + TERMINAL MESSAGE: + ┌─────────────┬────────────┬─────────────┬───────────────┐ + │ LENGTH │ FLAGS │ STATUS CODE │ JSON BODY │ + │ (3 bytes) │ (1 byte) │ (2 bytes) │ (variable) │ + ├─────────────┼────────────┼─────────────┼───────────────┤ + │ 0x00 00 XX │ 1 CA RXXXX │ HTTP Code │ JSON data │ + └─────────────┴────────────┴─────────────┴───────────────┘ + + LENGTH = size of (FLAGS + PAYLOAD), does NOT include length header itself + Implemented limit: 2 MiB (smaller than 24-bit protocol maximum) +*/ + +const LENGTH_PREFIX_SIZE: usize = 3; +const STATUS_CODE_SIZE: usize = 2; +const COMPRESSION_THRESHOLD_BYTES: usize = 1024; // 1 KiB +const MAX_FRAME_BYTES: usize = 2 * 1024 * 1024; // 2 MiB + +/* +Flag byte layout: + ┌───┬───┬───┬───┬───┬───┬───┬───┐ + │ 7 │ 6 │ 5 │ 4 │ 3 │ 2 │ 1 │ 0 │ Bit positions + ├───┼───┴───┼───┼───┴───┴───┴───┤ + │ T │ C C │ R │ Reserved (0s) │ Purpose + └───┴───────┴───┴───────────────┘ + + T = Terminal flag (1 bit) + C = Compression (2 bits, encodes 0-3) + R = Reconnect advised (1 bit, set by a server that is about to terminate) +*/ + +const FLAG_TOTAL_SIZE: usize = 1; +// The frame length budget includes one flag byte, so payload bytes are capped at budget - flag. +const MAX_FRAME_PAYLOAD_BYTES: usize = MAX_FRAME_BYTES - FLAG_TOTAL_SIZE; +const MAX_DECOMPRESSED_PAYLOAD_BYTES: usize = MAX_FRAME_PAYLOAD_BYTES; +const FLAG_TERMINAL: u8 = 0b1000_0000; +const FLAG_COMPRESSION_MASK: u8 = 0b0110_0000; +const FLAG_COMPRESSION_SHIFT: u8 = 5; +const FLAG_RECONNECT_ADVISED: u8 = 0b0001_0000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, FromRepr)] +#[repr(u8)] +pub enum CompressionAlgorithm { + None = 0, + Zstd = 1, + Gzip = 2, +} + +impl CompressionAlgorithm { + pub fn from_accept_encoding(headers: &http::HeaderMap) -> Self { + let mut gzip = false; + for header_value in headers.get_all(http::header::ACCEPT_ENCODING) { + if let Ok(value) = header_value.to_str() { + for encoding in value.split(',') { + let mut parts = encoding.split(';'); + let encoding = parts.next().unwrap_or("").trim(); + if parts.any(is_zero_qvalue) { + continue; + } + if encoding.eq_ignore_ascii_case("zstd") { + return Self::Zstd; + } else if encoding.eq_ignore_ascii_case("gzip") { + gzip = true; + } + } + } + } + if gzip { Self::Gzip } else { Self::None } + } +} + +/// Whether an `Accept-Encoding` parameter is a `q=0` weight, which marks the coding as +/// "not acceptable" (RFC 9110 §12.4.2). +fn is_zero_qvalue(param: &str) -> bool { + let Some((name, value)) = param.split_once('=') else { + return false; + }; + name.trim().eq_ignore_ascii_case("q") && value.trim().parse::().is_ok_and(|q| q == 0.0) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompressedData { + compression: CompressionAlgorithm, + reconnect_advised: bool, + payload: Bytes, +} + +impl CompressedData { + pub fn for_proto( + compression: CompressionAlgorithm, + proto: &impl prost::Message, + ) -> std::io::Result { + Self::compress(compression, proto.encode_to_vec()) + } + + /// Whether this frame carried the reconnect-advised flag. + /// + /// A server sets the flag on responses when it is about to terminate, + /// signaling that the client should proactively reconnect. + pub fn reconnect_advised(&self) -> bool { + self.reconnect_advised + } + + fn compress(compression: CompressionAlgorithm, data: Vec) -> std::io::Result { + if data.len() > MAX_DECOMPRESSED_PAYLOAD_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "payload exceeds decompressed limit", + )); + } + + if compression == CompressionAlgorithm::None || data.len() < COMPRESSION_THRESHOLD_BYTES { + return Ok(Self { + compression: CompressionAlgorithm::None, + reconnect_advised: false, + payload: data.into(), + }); + } + let mut buf = Vec::with_capacity(data.len()); + match compression { + CompressionAlgorithm::Gzip => { + let mut encoder = GzEncoder::new(buf, Compression::default()); + encoder.write_all(data.as_slice())?; + buf = encoder.finish()?; + } + CompressionAlgorithm::Zstd => { + zstd::stream::copy_encode(data.as_slice(), &mut buf, 0)?; + } + CompressionAlgorithm::None => unreachable!("handled above"), + }; + let payload = Bytes::from(buf.into_boxed_slice()); + if payload.len() > MAX_FRAME_PAYLOAD_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "compressed payload exceeds frame limit", + )); + } + Ok(Self { + compression, + reconnect_advised: false, + payload, + }) + } + + fn decompressed(self) -> std::io::Result { + let initial_capacity = self + .payload + .len() + .saturating_mul(2) + .clamp(COMPRESSION_THRESHOLD_BYTES, MAX_DECOMPRESSED_PAYLOAD_BYTES); + + // Decode at most `MAX_DECOMPRESSED_PAYLOAD_BYTES + 1` bytes + fn read_to_end_limited( + mut reader: impl Read, + initial_capacity: usize, + ) -> std::io::Result { + let mut limited = reader + .by_ref() + .take((MAX_DECOMPRESSED_PAYLOAD_BYTES + 1) as u64); + let mut buf = Vec::with_capacity(initial_capacity); + limited.read_to_end(&mut buf)?; + if buf.len() > MAX_DECOMPRESSED_PAYLOAD_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "decompressed payload exceeds limit", + )); + } + Ok(Bytes::from(buf.into_boxed_slice())) + } + + match self.compression { + CompressionAlgorithm::None => { + if self.payload.len() > MAX_DECOMPRESSED_PAYLOAD_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "decompressed payload exceeds limit", + )); + } + Ok(self.payload) + } + CompressionAlgorithm::Gzip => { + let mut decoder = GzDecoder::new(&self.payload[..]); + read_to_end_limited(&mut decoder, initial_capacity) + } + CompressionAlgorithm::Zstd => { + let mut decoder = zstd::stream::Decoder::new(&self.payload[..])?; + read_to_end_limited(&mut decoder, initial_capacity) + } + } + } + + pub fn try_into_proto(self) -> std::io::Result

{ + let payload = self.decompressed()?; + P::decode(payload.as_ref()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalMessage { + pub status: u16, + pub body: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionMessage { + Regular(CompressedData), + Terminal(TerminalMessage), +} + +impl From for SessionMessage { + fn from(data: CompressedData) -> Self { + Self::Regular(data) + } +} + +impl From for SessionMessage { + fn from(msg: TerminalMessage) -> Self { + Self::Terminal(msg) + } +} + +impl SessionMessage { + pub fn regular( + compression: CompressionAlgorithm, + proto: &impl prost::Message, + ) -> std::io::Result { + Ok(Self::Regular(CompressedData::for_proto( + compression, + proto, + )?)) + } + + pub fn encode(&self) -> Bytes { + let encoded_size = FLAG_TOTAL_SIZE + self.payload_size(); + assert!( + encoded_size <= MAX_FRAME_BYTES, + "payload exceeds encoder limit" + ); + let mut buf = BytesMut::with_capacity(LENGTH_PREFIX_SIZE + encoded_size); + buf.put_uint(encoded_size as u64, 3); + match self { + Self::Regular(msg) => { + let mut flag = + ((msg.compression as u8) << FLAG_COMPRESSION_SHIFT) & FLAG_COMPRESSION_MASK; + if msg.reconnect_advised { + flag |= FLAG_RECONNECT_ADVISED; + } + buf.put_u8(flag); + buf.extend_from_slice(&msg.payload); + } + Self::Terminal(msg) => { + buf.put_u8(FLAG_TERMINAL); + buf.put_u16(msg.status); + buf.extend_from_slice(msg.body.as_bytes()); + } + } + buf.freeze() + } + + fn decode_message(mut buf: Bytes) -> std::io::Result { + if buf.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "empty frame payload", + )); + } + let flag = buf.get_u8(); + + let is_terminal = (flag & FLAG_TERMINAL) != 0; + if is_terminal { + if buf.len() < STATUS_CODE_SIZE { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "terminal message missing status code", + )); + } + let status = buf.get_u16(); + let body = String::from_utf8(buf.into()).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid utf-8") + })?; + return Ok(TerminalMessage { status, body }.into()); + } + + let compression_bits = (flag & FLAG_COMPRESSION_MASK) >> FLAG_COMPRESSION_SHIFT; + let Some(compression) = CompressionAlgorithm::from_repr(compression_bits) else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "unknown compression algorithm", + )); + }; + + Ok(CompressedData { + compression, + reconnect_advised: (flag & FLAG_RECONNECT_ADVISED) != 0, + payload: buf, + } + .into()) + } + + fn payload_size(&self) -> usize { + match self { + Self::Regular(msg) => msg.payload.len(), + Self::Terminal(msg) => STATUS_CODE_SIZE + msg.body.len(), + } + } +} + +/// Set the reconnect-advised flag on an already encoded frame. +/// +/// Terminal frames are returned unchanged. +pub fn advise_reconnect(frame: Bytes) -> Bytes { + if frame + .get(LENGTH_PREFIX_SIZE) + .is_none_or(|flag| flag & FLAG_TERMINAL != 0) + { + return frame; + } + + let mut frame = frame + .try_into_mut() + .unwrap_or_else(|frame| BytesMut::from(frame.as_ref())); + frame[LENGTH_PREFIX_SIZE] |= FLAG_RECONNECT_ADVISED; + frame.freeze() +} + +pub struct FramedMessageStream { + inner: S, + compression: CompressionAlgorithm, + terminated: bool, +} + +impl FramedMessageStream { + pub fn new(compression: CompressionAlgorithm, inner: S) -> Self { + Self { + inner, + compression, + terminated: false, + } + } +} + +impl Stream for FramedMessageStream +where + S: Stream> + Unpin, + P: prost::Message, + E: Into, +{ + type Item = std::io::Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.terminated { + return Poll::Ready(None); + } + + match Pin::new(&mut self.inner).poll_next(cx) { + Poll::Ready(Some(Ok(item))) => match SessionMessage::regular(self.compression, &item) { + Ok(msg) => Poll::Ready(Some(Ok(msg.encode()))), + Err(err) => { + self.terminated = true; + Poll::Ready(Some(Err(err))) + } + }, + Poll::Ready(Some(Err(e))) => { + self.terminated = true; + let bytes = SessionMessage::Terminal(e.into()).encode(); + Poll::Ready(Some(Ok(bytes))) + } + Poll::Ready(None) => { + self.terminated = true; + Poll::Ready(None) + } + Poll::Pending => Poll::Pending, + } + } +} + +pub struct FrameDecoder; + +impl tokio_util::codec::Decoder for FrameDecoder { + type Item = SessionMessage; + type Error = std::io::Error; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + if src.len() < LENGTH_PREFIX_SIZE { + return Ok(None); + } + + let length = ((src[0] as usize) << 16) | ((src[1] as usize) << 8) | (src[2] as usize); + + if length > MAX_FRAME_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "frame exceeds decode limit", + )); + } + + let total_size = LENGTH_PREFIX_SIZE + length; + if src.len() < total_size { + return Ok(None); + } + + src.advance(LENGTH_PREFIX_SIZE); + let frame_bytes = src.split_to(length).freeze(); + Ok(Some(SessionMessage::decode_message(frame_bytes)?)) + } +} + +#[cfg(test)] +mod test { + use std::{ + io, + pin::Pin, + task::{Context, Poll}, + }; + + use bytes::BytesMut; + use futures::StreamExt; + use http::HeaderValue; + use proptest::{collection::vec, prelude::*}; + use prost::Message; + use tokio_util::codec::Decoder; + + use super::*; + + #[derive(Clone, PartialEq, prost::Message)] + struct TestProto { + #[prost(bytes, tag = "1")] + payload: Vec, + } + + impl TestProto { + fn new(payload: Vec) -> Self { + Self { payload } + } + } + + #[derive(Debug, Clone)] + struct TestError { + status: u16, + body: &'static str, + } + + impl From for TerminalMessage { + fn from(val: TestError) -> Self { + TerminalMessage { + status: val.status, + body: val.body.to_string(), + } + } + } + + fn decode_once(bytes: &Bytes) -> io::Result { + let mut decoder = FrameDecoder; + let mut buf = BytesMut::from(bytes.as_ref()); + decoder + .decode(&mut buf)? + .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "frame incomplete")) + } + + fn compression_strategy() -> impl proptest::strategy::Strategy { + prop_oneof![ + Just(CompressionAlgorithm::None), + Just(CompressionAlgorithm::Gzip), + Just(CompressionAlgorithm::Zstd), + ] + } + + fn chunk_bytes(data: &Bytes, pattern: &[usize]) -> Vec { + let mut chunks = Vec::new(); + let mut offset = 0; + for &hint in pattern { + if offset >= data.len() { + break; + } + let remaining = data.len() - offset; + let take = (hint % remaining).saturating_add(1).min(remaining); + chunks.push(data.slice(offset..offset + take)); + offset += take; + } + if offset < data.len() { + chunks.push(data.slice(offset..)); + } + if chunks.is_empty() { + chunks.push(data.clone()); + } + chunks + } + + proptest! { + #[test] + fn regular_session_message_round_trips_proptest( + algo in compression_strategy(), + payload in vec(any::(), 0..=COMPRESSION_THRESHOLD_BYTES * 4) + ) { + let proto = TestProto::new(payload.clone()); + let msg = SessionMessage::regular(algo, &proto).unwrap(); + let encoded = msg.encode(); + let decoded = decode_once(&encoded).unwrap(); + + prop_assert!(matches!(decoded, SessionMessage::Regular(_))); + let SessionMessage::Regular(data) = decoded else { unreachable!() }; + + let expected_compression = if algo == CompressionAlgorithm::None || proto.encoded_len() < COMPRESSION_THRESHOLD_BYTES { + CompressionAlgorithm::None + } else { + algo + }; + let actual_compression = data.compression; + + let restored = data.try_into_proto::().unwrap(); + prop_assert_eq!(restored.payload, payload); + prop_assert_eq!(actual_compression, expected_compression); + } + + #[test] + fn frame_decoder_handles_chunked_frames( + algo in compression_strategy(), + payload in vec(any::(), 0..=COMPRESSION_THRESHOLD_BYTES * 4), + chunk_pattern in vec(0usize..=16, 0..=16) + ) { + let proto = TestProto::new(payload); + let msg = SessionMessage::regular(algo, &proto).unwrap(); + let encoded = msg.encode(); + let expected = decode_once(&encoded).unwrap(); + + let chunks = chunk_bytes(&encoded, &chunk_pattern); + prop_assert_eq!(chunks.iter().map(|c| c.len()).sum::(), encoded.len()); + + let mut decoder = FrameDecoder; + let mut buf = BytesMut::new(); + let mut decoded = None; + + for (idx, chunk) in chunks.iter().enumerate() { + buf.extend_from_slice(chunk.as_ref()); + let result = decoder.decode(&mut buf).expect("decode invocation failed"); + if idx < chunks.len() - 1 { + prop_assert!(result.is_none()); + } else { + let message = result.expect("final chunk should produce frame"); + prop_assert!(buf.is_empty()); + decoded = Some(message); + } + } + + let decoded = decoded.expect("decoder never emitted frame"); + prop_assert_eq!(decoded, expected); + } + } + + #[test] + fn from_accept_encoding_prefers_zstd() { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::ACCEPT_ENCODING, + HeaderValue::from_static("gzip, zstd, br"), + ); + + let algo = CompressionAlgorithm::from_accept_encoding(&headers); + assert_eq!(algo, CompressionAlgorithm::Zstd); + } + + #[test] + fn from_accept_encoding_falls_back_to_gzip() { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::ACCEPT_ENCODING, + HeaderValue::from_static("gzip;q=0.8, deflate"), + ); + + let algo = CompressionAlgorithm::from_accept_encoding(&headers); + assert_eq!(algo, CompressionAlgorithm::Gzip); + } + + #[rstest::rstest] + #[case("zstd;q=0, gzip", CompressionAlgorithm::Gzip)] + #[case("zstd; q=0.0, gzip;q=0.5", CompressionAlgorithm::Gzip)] + #[case("gzip;q=0", CompressionAlgorithm::None)] + #[case("gzip;Q=0.000, zstd;q=0", CompressionAlgorithm::None)] + #[case("zstd;q=0.001", CompressionAlgorithm::Zstd)] + fn from_accept_encoding_skips_refused_codings( + #[case] accept_encoding: &'static str, + #[case] expected: CompressionAlgorithm, + ) { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::ACCEPT_ENCODING, + HeaderValue::from_static(accept_encoding), + ); + + let algo = CompressionAlgorithm::from_accept_encoding(&headers); + assert_eq!(algo, expected); + } + + #[test] + fn from_accept_encoding_defaults_to_none() { + let headers = http::HeaderMap::new(); + let algo = CompressionAlgorithm::from_accept_encoding(&headers); + assert_eq!(algo, CompressionAlgorithm::None); + } + + #[test] + fn regular_session_message_round_trips() { + let proto = TestProto::new(vec![1, 2, 3, 4]); + let msg = SessionMessage::regular(CompressionAlgorithm::None, &proto).unwrap(); + let encoded = msg.encode(); + let decoded = decode_once(&encoded).unwrap(); + + match decoded { + SessionMessage::Regular(data) => { + assert_eq!(data.compression, CompressionAlgorithm::None); + let restored = data.try_into_proto::().unwrap(); + assert_eq!(restored, proto); + } + SessionMessage::Terminal(_) => panic!("expected regular message"), + } + } + + #[test] + fn terminal_session_message_round_trips() { + let terminal = TerminalMessage { + status: 418, + body: "short-circuit".to_string(), + }; + let msg = SessionMessage::from(terminal.clone()); + let encoded = msg.encode(); + let decoded = decode_once(&encoded).unwrap(); + + match decoded { + SessionMessage::Regular(_) => panic!("expected terminal message"), + SessionMessage::Terminal(decoded_terminal) => { + assert_eq!(decoded_terminal, terminal); + } + } + } + + #[test] + fn frame_decoder_waits_for_complete_frame() { + let proto = TestProto::new(vec![9, 9, 9]); + let msg = SessionMessage::regular(CompressionAlgorithm::None, &proto).unwrap(); + let encoded = msg.encode(); + let mut decoder = FrameDecoder; + + let split_idx = encoded.len() - 1; + let mut buf = BytesMut::from(&encoded[..split_idx]); + assert!(decoder.decode(&mut buf).unwrap().is_none()); + buf.extend_from_slice(&encoded[split_idx..]); + let decoded = decoder.decode(&mut buf).unwrap().unwrap(); + + match decoded { + SessionMessage::Regular(data) => { + let restored = data.try_into_proto::().unwrap(); + assert_eq!(restored, proto); + } + SessionMessage::Terminal(_) => panic!("expected regular message"), + } + assert!(buf.is_empty()); + } + + #[test] + fn frame_decoder_rejects_frames_exceeding_decode_limit() { + let length = MAX_FRAME_BYTES + 1; + let prefix = [ + ((length >> 16) & 0xFF) as u8, + ((length >> 8) & 0xFF) as u8, + (length & 0xFF) as u8, + ]; + let mut buf = BytesMut::from(prefix.as_slice()); + let mut decoder = FrameDecoder; + let err = decoder.decode(&mut buf).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } + + #[test] + #[should_panic(expected = "encoder limit")] + fn session_message_encode_rejects_frames_over_limit() { + let data = CompressedData { + compression: CompressionAlgorithm::None, + reconnect_advised: false, + payload: Bytes::from(vec![0u8; MAX_FRAME_BYTES]), + }; + let msg = SessionMessage::from(data); + let _ = msg.encode(); + } + + #[test] + fn frame_decoder_rejects_unknown_compression() { + let mut raw = vec![0, 0, 1]; + raw.push(0x60); + let mut decoder = FrameDecoder; + let mut buf = BytesMut::from(raw.as_slice()); + let err = decoder.decode(&mut buf).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn frame_decoder_rejects_terminal_without_status() { + let mut raw = vec![0, 0, 1]; + raw.push(FLAG_TERMINAL); + let mut decoder = FrameDecoder; + let mut buf = BytesMut::from(raw.as_slice()); + let err = decoder.decode(&mut buf).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn frame_decoder_handles_empty_payload() { + let raw = vec![0, 0, 0]; + let mut decoder = FrameDecoder; + let mut buf = BytesMut::from(raw.as_slice()); + let err = decoder.decode(&mut buf).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof); + } + + #[test] + fn compressed_data_round_trip_gzip() { + let payload = vec![42; 1_200_000]; + let proto = TestProto::new(payload.clone()); + let msg = SessionMessage::regular(CompressionAlgorithm::Gzip, &proto).unwrap(); + let encoded = msg.encode(); + let decoded = decode_once(&encoded).unwrap(); + + match decoded { + SessionMessage::Regular(data) => { + assert_eq!(data.compression, CompressionAlgorithm::Gzip); + assert!(data.payload.len() < proto.encode_to_vec().len()); + let restored = data.try_into_proto::().unwrap(); + assert_eq!(restored.payload, payload); + } + SessionMessage::Terminal(_) => panic!("expected regular message"), + } + } + + #[test] + fn compressed_data_round_trip_zstd() { + let payload = vec![7; 1_100_000]; + let proto = TestProto::new(payload.clone()); + let msg = SessionMessage::regular(CompressionAlgorithm::Zstd, &proto).unwrap(); + let encoded = msg.encode(); + let decoded = decode_once(&encoded).unwrap(); + + match decoded { + SessionMessage::Regular(data) => { + assert_eq!(data.compression, CompressionAlgorithm::Zstd); + assert!(data.payload.len() < proto.encode_to_vec().len()); + let restored = data.try_into_proto::().unwrap(); + assert_eq!(restored.payload, payload); + } + SessionMessage::Terminal(_) => panic!("expected regular message"), + } + } + + #[test] + fn decompression_rejects_payloads_exceeding_limit() { + let payload = vec![0; MAX_DECOMPRESSED_PAYLOAD_BYTES + 1]; + let proto = TestProto::new(payload); + let encoded = proto.encode_to_vec(); + + for algo in [CompressionAlgorithm::Gzip, CompressionAlgorithm::Zstd] { + let compressed = match algo { + CompressionAlgorithm::Gzip => { + let mut out = Vec::new(); + let mut encoder = GzEncoder::new(&mut out, Compression::default()); + encoder.write_all(encoded.as_slice()).unwrap(); + encoder.finish().unwrap(); + out + } + CompressionAlgorithm::Zstd => { + let mut out = Vec::new(); + zstd::stream::copy_encode(encoded.as_slice(), &mut out, 0).unwrap(); + out + } + CompressionAlgorithm::None => unreachable!("explicitly excluded in test"), + }; + + let data = CompressedData { + compression: algo, + reconnect_advised: false, + payload: Bytes::from(compressed), + }; + assert!(data.payload.len() <= MAX_FRAME_PAYLOAD_BYTES); + + let err = data.try_into_proto::().expect_err("should fail"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!( + err.to_string() + .contains("decompressed payload exceeds limit") + ); + } + } + + #[test] + fn compress_rejects_payloads_exceeding_decompressed_limit() { + let payload = vec![0; MAX_DECOMPRESSED_PAYLOAD_BYTES + 1]; + let proto = TestProto::new(payload); + + let err = CompressedData::compress(CompressionAlgorithm::Gzip, proto.encode_to_vec()) + .expect_err("should fail"); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("payload exceeds decompressed limit") + ); + } + + #[test] + fn compress_allows_payload_at_exact_limit_without_encode_panic() { + let payload = vec![0; MAX_DECOMPRESSED_PAYLOAD_BYTES]; + let data = CompressedData::compress(CompressionAlgorithm::None, payload).unwrap(); + let encoded = SessionMessage::from(data).encode(); + assert_eq!(encoded.len(), LENGTH_PREFIX_SIZE + MAX_FRAME_BYTES); + } + + #[test] + fn compress_rejects_incompressible_payload_that_exceeds_frame_limit_after_compression() { + let mut payload = vec![0u8; MAX_DECOMPRESSED_PAYLOAD_BYTES]; + let mut x = 0x1234_5678u32; + for byte in &mut payload { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *byte = (x & 0xFF) as u8; + } + + for algo in [CompressionAlgorithm::Gzip, CompressionAlgorithm::Zstd] { + let err = CompressedData::compress(algo, payload.clone()).expect_err("should fail"); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("compressed payload exceeds frame limit") + ); + } + } + + #[test] + fn framed_message_stream_yields_terminal_on_error() { + let proto = TestProto::new(vec![1, 2, 3]); + let items = vec![ + Ok(proto.clone()), + Err(TestError { + status: 500, + body: "boom", + }), + Ok(proto.clone()), + ]; + + let stream = futures::stream::iter(items); + let framed = FramedMessageStream::new(CompressionAlgorithm::None, stream); + let outputs = futures::executor::block_on(async { + framed.collect::>>().await + }); + + assert_eq!(outputs.len(), 2); + + let first = outputs[0].as_ref().expect("first frame ok"); + match decode_once(first).unwrap() { + SessionMessage::Regular(data) => { + let restored = data.try_into_proto::().unwrap(); + assert_eq!(restored, proto); + } + SessionMessage::Terminal(_) => panic!("expected regular message"), + } + + let second = outputs[1].as_ref().expect("second frame ok"); + match decode_once(second).unwrap() { + SessionMessage::Regular(_) => panic!("expected terminal message"), + SessionMessage::Terminal(term) => { + assert_eq!(term.status, 500); + assert_eq!(term.body, "boom"); + } + } + } + + #[test] + fn framed_message_stream_stops_after_termination() { + let mut stream = FramedMessageStream::new( + CompressionAlgorithm::None, + futures::stream::iter(vec![ + Ok(TestProto::new(vec![0])), + Err(TestError { + status: 400, + body: "bad", + }), + ]), + ); + + let mut cx = Context::from_waker(futures::task::noop_waker_ref()); + + match Pin::new(&mut stream).poll_next(&mut cx) { + Poll::Ready(Some(Ok(bytes))) => match decode_once(&bytes).unwrap() { + SessionMessage::Regular(_) => {} + SessionMessage::Terminal(_) => panic!("expected regular message"), + }, + other => panic!("unexpected poll result: {other:?}"), + } + + match Pin::new(&mut stream).poll_next(&mut cx) { + Poll::Ready(Some(Ok(bytes))) => match decode_once(&bytes).unwrap() { + SessionMessage::Terminal(term) => { + assert_eq!(term.status, 400); + assert_eq!(term.body, "bad"); + } + SessionMessage::Regular(_) => panic!("expected terminal message"), + }, + other => panic!("unexpected poll result: {other:?}"), + } + + match Pin::new(&mut stream).poll_next(&mut cx) { + Poll::Ready(None) => {} + other => panic!("expected stream to terminate, got {other:?}"), + } + } + + #[test] + fn framed_message_stream_terminates_after_encoding_error() { + let oversized = MAX_DECOMPRESSED_PAYLOAD_BYTES + 1; + let items: Vec> = vec![ + Ok(TestProto::new(vec![0u8; oversized])), + Ok(TestProto::new(vec![1u8; oversized])), + ]; + let mut stream = + FramedMessageStream::new(CompressionAlgorithm::None, futures::stream::iter(items)); + + let mut cx = Context::from_waker(futures::task::noop_waker_ref()); + + match Pin::new(&mut stream).poll_next(&mut cx) { + Poll::Ready(Some(Err(err))) => { + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("payload exceeds decompressed limit") + ); + } + other => panic!("expected encoding error, got {other:?}"), + } + + match Pin::new(&mut stream).poll_next(&mut cx) { + Poll::Ready(None) => {} + other => panic!("expected stream to terminate after encoding error, got {other:?}"), + } + } +} diff --git a/api/src/v1/stream/sse.rs b/api/src/v1/stream/sse.rs new file mode 100644 index 00000000..7fad5d0f --- /dev/null +++ b/api/src/v1/stream/sse.rs @@ -0,0 +1,194 @@ +use std::str::FromStr; + +use s2_common::http::ParseableHeader; +use serde::Serialize; + +use super::{ReadBatch, StreamPosition}; + +static LAST_EVENT_ID_HEADER: http::HeaderName = http::HeaderName::from_static("last-event-id"); + +#[derive(Debug, Clone, Copy)] +pub struct LastEventId { + /// Sequence number of the last delivered record. + pub seq_num: u64, + /// Total records delivered. + pub count: usize, + /// Total metered bytes delivered. + pub bytes: usize, +} + +impl ParseableHeader for LastEventId { + fn name() -> &'static http::HeaderName { + &LAST_EVENT_ID_HEADER + } +} + +impl Serialize for LastEventId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} + +impl std::fmt::Display for LastEventId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + seq_num, + count, + bytes, + } = self; + write!(f, "{seq_num},{count},{bytes}") + } +} + +impl FromStr for LastEventId { + type Err = s2_common::ValidationError; + + fn from_str(s: &str) -> Result { + let mut iter = s.splitn(3, ","); + + fn get_next( + iter: &mut std::str::SplitN<&str>, + field: &str, + ) -> Result + where + T: FromStr, + ::Err: std::fmt::Display, + { + let item = iter + .next() + .ok_or_else(|| format!("missing {field} in Last-Event-Id"))?; + item.parse() + .map_err(|e| format!("invalid {field} in Last-Event-ID: {e}").into()) + } + + let seq_num = get_next(&mut iter, "seq_num")?; + let count = get_next(&mut iter, "count")?; + let bytes = get_next(&mut iter, "bytes")?; + + Ok(Self { + seq_num, + count, + bytes, + }) + } +} + +macro_rules! event { + ($name:ident, $val:expr) => { + #[derive(Serialize)] + #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] + #[serde(rename_all = "snake_case")] + pub enum $name { + $name, + } + + impl AsRef for $name { + fn as_ref(&self) -> &str { + $val + } + } + }; +} + +event!(Batch, "batch"); +event!(Error, "error"); +event!(Ping, "ping"); + +#[derive(Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(untagged)] +pub enum ReadEvent { + #[cfg_attr(feature = "utoipa", schema(title = "batch"))] + Batch { + #[cfg_attr(feature = "utoipa", schema(inline))] + event: Batch, + data: ReadBatch, + #[cfg_attr(feature = "utoipa", schema(value_type = String, pattern = "^[0-9]+,[0-9]+,[0-9]+$"))] + id: LastEventId, + }, + #[cfg_attr(feature = "utoipa", schema(title = "error"))] + Error { + #[cfg_attr(feature = "utoipa", schema(inline))] + event: Error, + data: String, + }, + #[cfg_attr(feature = "utoipa", schema(title = "ping"))] + Ping { + #[cfg_attr(feature = "utoipa", schema(inline))] + event: Ping, + data: PingEventData, + }, + #[cfg_attr(feature = "utoipa", schema(title = "done"))] + #[serde(skip)] + Done { + #[cfg_attr(feature = "utoipa", schema(value_type = String, pattern = r"^\[DONE\]$"))] + data: DoneEventData, + }, +} + +#[cfg(feature = "axum")] +fn elapsed_since_epoch() -> std::time::Duration { + std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("healthy clock") +} + +#[cfg(feature = "axum")] +pub fn read_batch_event( + format: crate::data::Format, + batch: &s2_common::stream::ReadBatch, + id: LastEventId, +) -> Result { + axum::response::sse::Event::default() + .event(Batch::Batch) + .id(id.to_string()) + .json_data(super::json::serialize_read_batch(format, batch)) +} + +#[cfg(feature = "axum")] +pub fn error_event(data: String) -> Result { + Ok(axum::response::sse::Event::default() + .event(Error::Error) + .data(data)) +} + +#[cfg(feature = "axum")] +pub fn ping_event( + tail: s2_common::record::StreamPosition, +) -> Result { + axum::response::sse::Event::default() + .event(Ping::Ping) + .json_data(PingEventData { + timestamp: elapsed_since_epoch().as_millis() as u64, + tail: tail.into(), + }) +} + +#[cfg(feature = "axum")] +pub fn done_event() -> Result { + Ok(axum::response::sse::Event::default().data(DoneEventData)) +} + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename = "[DONE]")] +pub struct DoneEventData; + +impl AsRef for DoneEventData { + fn as_ref(&self) -> &str { + "[DONE]" + } +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct PingEventData { + /// Time the ping was emitted, as Unix epoch milliseconds. + pub timestamp: u64, + /// Sequence number that will be assigned to the next record on the stream, and timestamp of the last record. + pub tail: StreamPosition, +} diff --git a/assets/bench.gif b/assets/bench.gif new file mode 100644 index 00000000..afa7185c Binary files /dev/null and b/assets/bench.gif differ diff --git a/assets/s2-black.png b/assets/s2-black.png new file mode 100644 index 00000000..aab3f816 Binary files /dev/null and b/assets/s2-black.png differ diff --git a/assets/s2-white.png b/assets/s2-white.png new file mode 100644 index 00000000..01dd8be0 Binary files /dev/null and b/assets/s2-white.png differ diff --git a/assets/starwars.gif b/assets/starwars.gif new file mode 100644 index 00000000..4ddaf54c Binary files /dev/null and b/assets/starwars.gif differ diff --git a/charts/s2-lite-helm/.helmignore b/charts/s2-lite-helm/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/charts/s2-lite-helm/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/charts/s2-lite-helm/Chart.yaml b/charts/s2-lite-helm/Chart.yaml new file mode 100644 index 00000000..c243fb56 --- /dev/null +++ b/charts/s2-lite-helm/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v2 +name: s2-lite-helm +description: Self-hostable S2 streaming datastore using SlateDB on object storage +type: application +version: 0.1.72 +appVersion: "0.43.1" +keywords: + - s2 + - streaming + - datastore + - slatedb + - object-storage +home: https://s2.dev +sources: + - https://github.com/s2-streamstore/s2 +maintainers: + - name: S2 Team + email: hi@s2.dev diff --git a/charts/s2-lite-helm/README.md b/charts/s2-lite-helm/README.md new file mode 100644 index 00000000..ae02ba60 --- /dev/null +++ b/charts/s2-lite-helm/README.md @@ -0,0 +1,232 @@ +# s2-lite Helm Chart + +Deploy s2-lite to Kubernetes using the official Helm chart. + +## Quick Start + +### Install from Helm repository + +```bash +# Add the S2 Helm repository +helm repo add s2 https://s2-streamstore.github.io/s2 +helm repo update + +# Install with default settings (in-memory) +helm install my-s2-lite s2/s2-lite-helm + +# Or install with S3 storage +helm install my-s2-lite s2/s2-lite-helm \ + --set objectStorage.enabled=true \ + --set objectStorage.bucket=my-s3-bucket +``` + +### Install from OCI registry (GHCR) + +```bash +# Install directly from GitHub Container Registry +helm install my-s2-lite oci://ghcr.io/s2-streamstore/charts/s2-lite-helm + +# Or with custom values +helm install my-s2-lite oci://ghcr.io/s2-streamstore/charts/s2-lite-helm \ + --set objectStorage.enabled=true \ + --set objectStorage.bucket=my-s3-bucket \ + --set objectStorage.endpoint=https://s3.amazonaws.com +``` + +## Storage Options + +### In-memory (default) + +```bash +helm install my-s2-lite s2/s2-lite-helm +``` + +Great for development and testing. Data is lost when the pod restarts. + +### S3-compatible object storage + +```bash +helm install my-s2-lite s2/s2-lite-helm \ + --set objectStorage.enabled=true \ + --set objectStorage.bucket=my-bucket \ + --set objectStorage.endpoint=https://s3.amazonaws.com +``` + +Supports AWS S3, MinIO, Tigris, Cloudflare R2, and other S3-compatible services. + +### Persistent volume + +```bash +helm install my-s2-lite s2/s2-lite-helm \ + --set persistentVolume.enabled=true \ + --set persistentVolume.size=20Gi +``` + +Stores the database on a `PersistentVolumeClaim` instead of a bucket. Set +`persistentVolume.existingClaim` to use your own claim. + +### Separate WAL storage + +The write-ahead log can live in a separate bucket or on its own persistent +volume (see [Storage](../../README.md#storage)). Requires `objectStorage` or +`persistentVolume`. + +```bash +# WAL on a persistent volume +helm install my-s2-lite s2/s2-lite-helm \ + --set objectStorage.enabled=true \ + --set objectStorage.bucket=my-bucket \ + --set walStorage.persistentVolume.enabled=true + +# WAL in a separate bucket +helm install my-s2-lite s2/s2-lite-helm \ + --set objectStorage.enabled=true \ + --set objectStorage.bucket=my-bucket \ + --set walStorage.bucket=my-wal-bucket +``` + +Keep the WAL location the same across upgrades; changing it does not migrate +WAL data and the server starts without an error. + +### TLS Configuration + +**Self-signed certificate (for dev/testing):** +```bash +helm install my-s2-lite s2/s2-lite-helm \ + --set tls.enabled=true \ + --set tls.selfSigned=true + +# Configure CLI to trust self-signed certs +s2 config set ssl_no_verify true +``` + +**Provided certificate (from Kubernetes secret):** +```bash +# Create TLS secret +kubectl create secret tls s2-lite-tls --cert=tls.crt --key=tls.key + +# Install with provided certificate +helm install my-s2-lite s2/s2-lite-helm \ + --set tls.enabled=true \ + --set tls.cert=/etc/tls/tls.crt \ + --set tls.key=/etc/tls/tls.key \ + --set volumeMounts[0].name=tls-certs \ + --set volumeMounts[0].mountPath=/etc/tls \ + --set volumeMounts[0].readOnly=true \ + --set volumes[0].name=tls-certs \ + --set volumes[0].secret.secretName=s2-lite-tls +``` + +## Configuration + +For all configuration options, see the [values.yaml](values.yaml) file. + +Common configurations: + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `replicaCount` | Number of replicas | `1` | +| `image.repository` | Image repository | `ghcr.io/s2-streamstore/s2` | +| `image.tag` | Image tag (defaults to chart appVersion) | `""` | +| `service.type` | Service type | `ClusterIP` | +| `service.port` | Service port | `80` | +| `service.targetPort` | Container port | `8080` | +| `tls.enabled` | Enable TLS | `false` | +| `tls.selfSigned` | Use auto-generated self-signed certificate | `false` | +| `tls.cert` | Path to TLS certificate (when using provided cert) | `""` | +| `tls.key` | Path to TLS key (when using provided cert) | `""` | +| `objectStorage.enabled` | Enable S3-compatible storage | `false` | +| `objectStorage.bucket` | S3 bucket name | `""` | +| `objectStorage.path` | Path prefix within bucket | `""` | +| `persistentVolume.enabled` | Store the database on a persistent volume | `false` | +| `persistentVolume.size` | Volume size | `10Gi` | +| `walStorage.bucket` | Separate S3 bucket for the WAL | `""` | +| `walStorage.persistentVolume.enabled` | Store the WAL on a persistent volume | `false` | +| `walStorage.persistentVolume.size` | WAL volume size | `10Gi` | +| `metrics.serviceMonitor.enabled` | Enable Prometheus ServiceMonitor | `false` | + +## Examples + +### AWS S3 with IAM role (IRSA) + +```yaml +# values.yaml +objectStorage: + enabled: true + bucket: my-s3-bucket + +serviceAccount: + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/s2-lite-role +``` + +```bash +helm install my-s2-lite s2/s2-lite-helm -f values.yaml +``` + +### S3 with a local WAL volume + +```yaml +# values.yaml +objectStorage: + enabled: true + bucket: my-s3-bucket + +walStorage: + persistentVolume: + enabled: true + size: 20Gi + storageClass: gp3 +``` + +### Behind AWS Network Load Balancer + +```yaml +# values.yaml +service: + type: LoadBalancer + annotations: + service.beta.kubernetes.io/aws-load-balancer-type: "nlb" + external-dns.alpha.kubernetes.io/hostname: "s2.example.com" +``` + +### With Prometheus monitoring + +```yaml +# values.yaml +metrics: + serviceMonitor: + enabled: true + interval: 30s +``` + +## Upgrading + +```bash +# Update the repository +helm repo update + +# Upgrade to the latest version +helm upgrade my-s2-lite s2/s2-lite-helm + +# Or specify a version +helm upgrade my-s2-lite s2/s2-lite-helm --version 0.1.0 +``` + +## Uninstalling + +```bash +helm uninstall my-s2-lite +``` + +This deletes any `PersistentVolumeClaim` the chart created, and with it the data on the +volume. To keep the claim, annotate it before uninstalling: + +```yaml +persistentVolume: + annotations: + helm.sh/resource-policy: keep +``` + +Claims supplied via `existingClaim` are never deleted by the chart. + diff --git a/charts/s2-lite-helm/templates/NOTES.txt b/charts/s2-lite-helm/templates/NOTES.txt new file mode 100644 index 00000000..ed9e7eb8 --- /dev/null +++ b/charts/s2-lite-helm/templates/NOTES.txt @@ -0,0 +1,69 @@ +Thank you for installing {{ .Chart.Name }}! + +Your release is named {{ .Release.Name }}. + +To learn more about the release, try: + + $ helm status {{ .Release.Name }} --namespace {{ .Release.Namespace }} + $ helm get all {{ .Release.Name }} --namespace {{ .Release.Namespace }} + +s2-lite is now running with the following configuration: + + - Replicas: {{ .Values.replicaCount }} + - Image: {{ include "s2-lite.image" . }} +{{- $storageMode := include "s2-lite.storage.mode" . }} +{{- $walMode := include "s2-lite.walStorage.mode" . }} +{{- if eq $storageMode "bucket" }} + - Storage: S3-compatible ({{ .Values.objectStorage.bucket }}) + {{- if .Values.objectStorage.path }} + - Path: {{ .Values.objectStorage.path }} + {{- end }} + {{- if .Values.objectStorage.endpoint }} + - Endpoint: {{ .Values.objectStorage.endpoint }} + {{- end }} +{{- else if eq $storageMode "volume" }} + - Storage: Persistent volume ({{ include "s2-lite.claimName" (dict "root" . "name" "data" "volume" .Values.persistentVolume) }} at {{ .Values.persistentVolume.mountPath }}) +{{- else }} + - Storage: In-memory (data lost on pod restart) +{{- end }} +{{- if eq $walMode "bucket" }} + - WAL: S3-compatible ({{ .Values.walStorage.bucket }}) +{{- else if eq $walMode "volume" }} + - WAL: Persistent volume ({{ include "s2-lite.claimName" (dict "root" . "name" "wal" "volume" .Values.walStorage.persistentVolume) }} at {{ .Values.walStorage.persistentVolume.mountPath }}) +{{- end }} + +Get the application URL by running these commands: + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "s2-lite.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") +{{- if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "s2-lite.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo {{ if .Values.tls.enabled }}https{{ else }}http{{ end }}://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status by running: + kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "s2-lite.fullname" . }} + + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "s2-lite.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo {{ if .Values.tls.enabled }}https{{ else }}http{{ end }}://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit {{ if .Values.tls.enabled }}https{{ else }}http{{ end }}://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} + +Health check (via port-forward): + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME {{ .Values.service.targetPort }}:{{ .Values.service.targetPort }} & +{{- if .Values.tls.enabled }} + curl -k https://localhost:{{ .Values.service.targetPort }}/health +{{- else }} + curl http://localhost:{{ .Values.service.targetPort }}/health +{{- end }} + +Metrics endpoint: +{{- if .Values.tls.enabled }} + curl -k https://localhost:{{ .Values.service.targetPort }}/metrics +{{- else }} + curl http://localhost:{{ .Values.service.targetPort }}/metrics +{{- end }} + +For more information, visit https://s2.dev/docs diff --git a/charts/s2-lite-helm/templates/_helpers.tpl b/charts/s2-lite-helm/templates/_helpers.tpl new file mode 100644 index 00000000..faf28de6 --- /dev/null +++ b/charts/s2-lite-helm/templates/_helpers.tpl @@ -0,0 +1,117 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "s2-lite.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "s2-lite.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "s2-lite.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "s2-lite.labels" -}} +helm.sh/chart: {{ include "s2-lite.chart" . }} +{{ include "s2-lite.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "s2-lite.selectorLabels" -}} +app.kubernetes.io/name: {{ include "s2-lite.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "s2-lite.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "s2-lite.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Create the image name +*/}} +{{- define "s2-lite.image" -}} +{{- $tag := .Values.image.tag | default .Chart.AppVersion }} +{{- printf "%s:%s" .Values.image.repository $tag }} +{{- end }} + +{{/* +Main storage mode: "bucket", "volume", or "" for in-memory. +*/}} +{{- define "s2-lite.storage.mode" -}} +{{- if and .Values.objectStorage.enabled .Values.persistentVolume.enabled }} +{{- fail "objectStorage.enabled and persistentVolume.enabled are mutually exclusive" }} +{{- end }} +{{- if and .Values.objectStorage.enabled (not .Values.objectStorage.bucket) }} +{{- fail "objectStorage.enabled is true but objectStorage.bucket is not set" }} +{{- end }} +{{- if .Values.objectStorage.enabled }}bucket{{- else if .Values.persistentVolume.enabled }}volume{{- end }} +{{- end }} + +{{/* +WAL storage mode: "bucket", "volume", or "" when the WAL shares the main store. +*/}} +{{- define "s2-lite.walStorage.mode" -}} +{{- $bucket := .Values.walStorage.bucket }} +{{- $volume := .Values.walStorage.persistentVolume.enabled }} +{{- if and $bucket $volume }} +{{- fail "walStorage.bucket and walStorage.persistentVolume.enabled are mutually exclusive" }} +{{- end }} +{{- if and (or $bucket $volume) (not (include "s2-lite.storage.mode" .)) }} +{{- fail "walStorage requires objectStorage or persistentVolume" }} +{{- end }} +{{- if $bucket }}bucket{{- else if $volume }}volume{{- end }} +{{- end }} + +{{/* +Persistent volumes in use, keyed by volume name. Parse with fromYaml. +*/}} +{{- define "s2-lite.persistentVolumes" -}} +{{- $volumes := dict }} +{{- if eq (include "s2-lite.storage.mode" .) "volume" }} +{{- $_ := set $volumes "data" .Values.persistentVolume }} +{{- end }} +{{- if eq (include "s2-lite.walStorage.mode" .) "volume" }} +{{- $_ := set $volumes "wal" .Values.walStorage.persistentVolume }} +{{- end }} +{{- toYaml $volumes }} +{{- end }} + +{{/* +PersistentVolumeClaim name for a volume. Takes (dict "root" $ "name" "volume" ). +*/}} +{{- define "s2-lite.claimName" -}} +{{- .volume.existingClaim | default (printf "%s-%s" (include "s2-lite.fullname" .root) .name) }} +{{- end }} diff --git a/charts/s2-lite-helm/templates/deployment.yaml b/charts/s2-lite-helm/templates/deployment.yaml new file mode 100644 index 00000000..377dadaa --- /dev/null +++ b/charts/s2-lite-helm/templates/deployment.yaml @@ -0,0 +1,161 @@ +{{- $storageMode := include "s2-lite.storage.mode" . }} +{{- $walMode := include "s2-lite.walStorage.mode" . }} +{{- $volumes := include "s2-lite.persistentVolumes" . | fromYaml }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "s2-lite.fullname" . }} + labels: + {{- include "s2-lite.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: Recreate + selector: + matchLabels: + {{- include "s2-lite.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "s2-lite.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "s2-lite.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: s2-lite + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: {{ include "s2-lite.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - lite + - --port + - {{ .Values.service.targetPort | quote }} + {{- if .Values.tls.enabled }} + {{- if .Values.tls.selfSigned }} + - --tls-self + {{- else if and .Values.tls.cert .Values.tls.key }} + - --tls-cert + - {{ .Values.tls.cert | quote }} + - --tls-key + - {{ .Values.tls.key | quote }} + {{- else }} + {{- fail "tls.enabled is true but neither tls.cert/tls.key nor tls.selfSigned are configured" }} + {{- end }} + {{- end }} + {{- if eq $storageMode "bucket" }} + - --bucket + - {{ .Values.objectStorage.bucket | quote }} + {{- with .Values.objectStorage.path }} + - --path + - {{ . | quote }} + {{- end }} + {{- else if eq $storageMode "volume" }} + - --local-root + - {{ .Values.persistentVolume.mountPath | quote }} + {{- end }} + {{- if eq $walMode "bucket" }} + - --wal-bucket + - {{ .Values.walStorage.bucket | quote }} + {{- else if eq $walMode "volume" }} + - --wal-local-root + - {{ .Values.walStorage.persistentVolume.mountPath | quote }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + {{- if or (and .Values.objectStorage.enabled .Values.objectStorage.endpoint) .Values.walStorage.endpoint .Values.walStorage.region .Values.env }} + env: + {{- if and .Values.objectStorage.enabled .Values.objectStorage.endpoint }} + - name: AWS_ENDPOINT_URL_S3 + value: {{ .Values.objectStorage.endpoint | quote }} + {{- end }} + {{- with .Values.walStorage.endpoint }} + - name: S2LITE_WAL_AWS_ENDPOINT_URL_S3 + value: {{ . | quote }} + {{- end }} + {{- with .Values.walStorage.region }} + - name: S2LITE_WAL_AWS_REGION + value: {{ . | quote }} + {{- end }} + {{- with .Values.env }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} + livenessProbe: + {{- if and .Values.tls.enabled .Values.livenessProbe.httpGet }} + {{- $probe := omit .Values.livenessProbe "httpGet" }} + {{- $httpGet := merge (dict "scheme" "HTTPS") .Values.livenessProbe.httpGet }} + {{- $probe := merge (dict "httpGet" $httpGet) $probe }} + {{- toYaml $probe | nindent 10 }} + {{- else }} + {{- toYaml .Values.livenessProbe | nindent 10 }} + {{- end }} + readinessProbe: + {{- if and .Values.tls.enabled .Values.readinessProbe.httpGet }} + {{- $probe := omit .Values.readinessProbe "httpGet" }} + {{- $httpGet := merge (dict "scheme" "HTTPS") .Values.readinessProbe.httpGet }} + {{- $probe := merge (dict "httpGet" $httpGet) $probe }} + {{- toYaml $probe | nindent 10 }} + {{- else }} + {{- toYaml .Values.readinessProbe | nindent 10 }} + {{- end }} + {{- with .Values.startupProbe }} + startupProbe: + {{- if and $.Values.tls.enabled .httpGet }} + {{- $probe := omit . "httpGet" }} + {{- $httpGet := merge (dict "scheme" "HTTPS") .httpGet }} + {{- $probe := merge (dict "httpGet" $httpGet) $probe }} + {{- toYaml $probe | nindent 10 }} + {{- else }} + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 10 }} + {{- if or $volumes .Values.volumeMounts }} + volumeMounts: + {{- range $name, $volume := $volumes }} + - name: {{ $name }} + mountPath: {{ $volume.mountPath | quote }} + {{- end }} + {{- with .Values.volumeMounts }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} + {{- if or $volumes .Values.volumes }} + volumes: + {{- range $name, $volume := $volumes }} + - name: {{ $name }} + persistentVolumeClaim: + claimName: {{ include "s2-lite.claimName" (dict "root" $ "name" $name "volume" $volume) }} + {{- end }} + {{- with .Values.volumes }} + {{- toYaml . | nindent 6 }} + {{- end }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/s2-lite-helm/templates/ingress.yaml b/charts/s2-lite-helm/templates/ingress.yaml new file mode 100644 index 00000000..55e4c37a --- /dev/null +++ b/charts/s2-lite-helm/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "s2-lite.fullname" . }} + labels: + {{- include "s2-lite.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "s2-lite.fullname" $ }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/s2-lite-helm/templates/pdb.yaml b/charts/s2-lite-helm/templates/pdb.yaml new file mode 100644 index 00000000..353e5d0e --- /dev/null +++ b/charts/s2-lite-helm/templates/pdb.yaml @@ -0,0 +1,17 @@ +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "s2-lite.fullname" . }} + labels: + {{- include "s2-lite.labels" . | nindent 4 }} +spec: + {{- if hasKey .Values.podDisruptionBudget "minAvailable" }} + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + {{- else }} + maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }} + {{- end }} + selector: + matchLabels: + {{- include "s2-lite.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/s2-lite-helm/templates/pvc.yaml b/charts/s2-lite-helm/templates/pvc.yaml new file mode 100644 index 00000000..b19594c1 --- /dev/null +++ b/charts/s2-lite-helm/templates/pvc.yaml @@ -0,0 +1,24 @@ +{{- range $name, $volume := include "s2-lite.persistentVolumes" . | fromYaml }} +{{- if not $volume.existingClaim }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "s2-lite.claimName" (dict "root" $ "name" $name "volume" $volume) }} + labels: + {{- include "s2-lite.labels" $ | nindent 4 }} + {{- with $volume.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + - ReadWriteOnce + {{- with $volume.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ $volume.size | quote }} +{{- end }} +{{- end }} diff --git a/charts/s2-lite-helm/templates/service.yaml b/charts/s2-lite-helm/templates/service.yaml new file mode 100644 index 00000000..b130b92c --- /dev/null +++ b/charts/s2-lite-helm/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "s2-lite.fullname" . }} + labels: + {{- include "s2-lite.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "s2-lite.selectorLabels" . | nindent 4 }} diff --git a/charts/s2-lite-helm/templates/serviceaccount.yaml b/charts/s2-lite-helm/templates/serviceaccount.yaml new file mode 100644 index 00000000..e319d031 --- /dev/null +++ b/charts/s2-lite-helm/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "s2-lite.serviceAccountName" . }} + labels: + {{- include "s2-lite.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/charts/s2-lite-helm/templates/servicemonitor.yaml b/charts/s2-lite-helm/templates/servicemonitor.yaml new file mode 100644 index 00000000..abc0df55 --- /dev/null +++ b/charts/s2-lite-helm/templates/servicemonitor.yaml @@ -0,0 +1,29 @@ +{{- if and .Values.metrics.enabled .Values.metrics.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "s2-lite.fullname" . }} + labels: + {{- include "s2-lite.labels" . | nindent 4 }} + {{- with .Values.metrics.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + {{- include "s2-lite.selectorLabels" . | nindent 6 }} + endpoints: + - port: http + path: /metrics + interval: {{ .Values.metrics.serviceMonitor.interval }} + scrapeTimeout: {{ .Values.metrics.serviceMonitor.scrapeTimeout }} + {{- if .Values.tls.enabled }} + scheme: https + tlsConfig: + {{- if gt (len .Values.metrics.serviceMonitor.tlsConfig) 0 }} + {{- toYaml .Values.metrics.serviceMonitor.tlsConfig | nindent 6 }} + {{- else }} + insecureSkipVerify: true + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/s2-lite-helm/values.yaml b/charts/s2-lite-helm/values.yaml new file mode 100644 index 00000000..22336d60 --- /dev/null +++ b/charts/s2-lite-helm/values.yaml @@ -0,0 +1,257 @@ +# Default values for s2-lite +# This is a YAML-formatted file. +# +# By default, s2-lite runs in-memory mode (no persistent storage). +# This is suitable for development and testing environments. +# Enable objectStorage for production use with persistent data. + +replicaCount: 1 + +image: + repository: ghcr.io/s2-streamstore/s2 + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +imagePullSecrets: [] +nameOverride: "s2-lite" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Disabled by default - s2-lite doesn't need Kubernetes API access + automount: false + # Annotations to add to the service account (e.g., for IRSA) + # Example for AWS IRSA: + # eks.amazonaws.com/role-arn: "arn:aws:iam::xxx:role/s2-lite" + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +# Environment variables +env: [] + # - name: AWS_REGION + # value: "us-east-1" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 # nonroot user from distroless:nonroot + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + +# ------------------------------------------------------------------------------ +# Service Configuration +# ------------------------------------------------------------------------------ + +service: + type: ClusterIP # Use LoadBalancer for external access + port: 80 + targetPort: 8080 # Non-privileged port (container runs as non-root) + # External-DNS example (automatic DNS record creation): + # external-dns.alpha.kubernetes.io/hostname: "s2-lite.example.com" + annotations: {} + +# ------------------------------------------------------------------------------ +# TLS Configuration +# ------------------------------------------------------------------------------ + +tls: + enabled: false + # Provide certificate and key paths (--tls-cert and --tls-key) + # Mount certificates using volumes/volumeMounts (see examples at bottom of file) + cert: "" # e.g., /etc/tls/tls.crt + key: "" # e.g., /etc/tls/tls.key + # Or set selfSigned to true to use auto-generated self-signed certificate (--tls-self) + selfSigned: false + +# ------------------------------------------------------------------------------ +# Ingress Configuration (cloud-agnostic HTTP access) +# ------------------------------------------------------------------------------ + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # cert-manager.io/cluster-issuer: letsencrypt + hosts: + - host: s2-lite.local + paths: + - path: / + pathType: Prefix + tls: [] + # - secretName: s2-lite-tls + # hosts: + # - s2-lite.local + +# ------------------------------------------------------------------------------ +# Storage Configuration +# +# By default, s2-lite runs in-memory (great for dev/testing). +# For persistent data, enable objectStorage with an S3-compatible bucket or +# persistentVolume for a local disk. Enable at most one. +# ------------------------------------------------------------------------------ + +objectStorage: + enabled: false + # S3 bucket name + bucket: "" + # Path prefix within the bucket (optional) + path: "" + # S3-compatible endpoint URL (leave empty for AWS S3) + # Examples: + # MinIO: "http://minio:9000" + # Tigris: "https://fly.storage.tigris.dev" + # R2: "https://.r2.cloudflarestorage.com" + endpoint: "" + +# Persistent volume for the database (--local-root) +persistentVolume: + enabled: false + mountPath: /data + size: 10Gi + storageClass: "" + # Use an existing PersistentVolumeClaim instead of creating one + existingClaim: "" + # helm uninstall deletes the created claim; add helm.sh/resource-policy: keep to retain it + annotations: {} + +# ------------------------------------------------------------------------------ +# WAL Storage Configuration +# +# By default the write-ahead log (WAL) is stored with the database. Place it in +# a separate bucket or on its own persistent volume to isolate WAL latency from +# flushes and compaction. Requires objectStorage or persistentVolume; set at +# most one of bucket or persistentVolume.enabled. +# +# Keep the WAL location the same across upgrades: changing it does not migrate +# WAL data and the server starts without an error. +# ------------------------------------------------------------------------------ + +walStorage: + # S3 bucket for the WAL (--wal-bucket). Shares objectStorage's AWS + # configuration unless overridden below. For different credentials, set + # S2LITE_WAL_AWS_ACCESS_KEY_ID / S2LITE_WAL_AWS_SECRET_ACCESS_KEY via env. + bucket: "" + endpoint: "" + region: "" + # Persistent volume for the WAL (--wal-local-root) + persistentVolume: + enabled: false + mountPath: /wal + size: 10Gi + storageClass: "" + existingClaim: "" + annotations: {} + +# ------------------------------------------------------------------------------ +# Observability +# ------------------------------------------------------------------------------ + +metrics: + enabled: true + serviceMonitor: + enabled: false # Enable if using Prometheus Operator + interval: 30s + scrapeTimeout: 10s + # Additional labels for the ServiceMonitor + labels: {} + # TLS configuration for scraping metrics when tls.enabled=true + # By default uses insecureSkipVerify (suitable for tls.selfSigned=true) + # When using provided certificates, reference the same TLS secret: + # tlsConfig: + # ca: + # secret: + # name: s2-lite-tls # same secret used for tls.cert/tls.key + # key: tls.crt # or ca.crt if CA-signed + tlsConfig: {} + +# ------------------------------------------------------------------------------ +# Health Checks +# ------------------------------------------------------------------------------ + +livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + +startupProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 60 + +# ------------------------------------------------------------------------------ +# Resources +# ------------------------------------------------------------------------------ + +resources: {} + # Example: + # limits: + # cpu: 2000m + # memory: 2Gi + # requests: + # cpu: 500m + # memory: 512Mi + +# Pod Disruption Budget +podDisruptionBudget: + enabled: false + # minAvailable: 1 + maxUnavailable: 1 + +# ------------------------------------------------------------------------------ +# Scheduling +# ------------------------------------------------------------------------------ + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +# ------------------------------------------------------------------------------ +# Volumes (for mounting TLS certificates, etc.) +# ------------------------------------------------------------------------------ + +volumeMounts: [] + # Example: Mount TLS certificates from secret + # - name: tls-certs + # mountPath: /etc/tls + # readOnly: true + +volumes: [] + # Example: TLS certificates secret (use with --tls-cert and --tls-key) + # - name: tls-certs + # secret: + # secretName: s2-lite-tls diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md new file mode 100644 index 00000000..6d7e4fcb --- /dev/null +++ b/cli/CHANGELOG.md @@ -0,0 +1,1354 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.43.1] - 2026-09-28 + + + +## [0.43.0] - 2026-09-25 + +### Features + +- [**breaking**] Expose storage classes as strings and in location responses ([#775](https://github.com/s2-streamstore/s2/issues/775)) + +### Bug Fixes + +- Show match-none token scopes as no access, not as wildcards ([#782](https://github.com/s2-streamstore/s2/issues/782)) + + + +## [0.42.14] - 2026-09-24 + + + +## [0.42.13] - 2026-09-22 + +### Features + +- Support a separate WAL object store ([#766](https://github.com/s2-streamstore/s2/issues/766)) + + + +## [0.42.12] - 2026-09-16 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.42.11] - 2026-09-11 + +### Features + +- `s2-stream-config` header for auto-created streams ([#718](https://github.com/s2-streamstore/s2/issues/718)) + + + +## [0.42.10] - 2026-09-11 + + + +## [0.42.9] - 2026-09-10 + + + +## [0.42.8] - 2026-09-01 + +### Bug Fixes + +- Require http2 ([#710](https://github.com/s2-streamstore/s2/issues/710)) + + + +## [0.42.7] - 2026-08-18 + +### Bug Fixes + +- Reject symlinked credential directory on the write path ([#705](https://github.com/s2-streamstore/s2/issues/705)) +- Overwrite --output file instead of appending across runs ([#707](https://github.com/s2-streamstore/s2/issues/707)) +- Recognize all loopback addresses in browser-login host checks ([#708](https://github.com/s2-streamstore/s2/issues/708)) + + + +## [0.42.6] - 2026-08-13 + + + +## [0.42.5] - 2026-08-07 + +### Features + +- Improve authentication UX ([#689](https://github.com/s2-streamstore/s2/issues/689)) + + + +## [0.42.4] - 2026-08-07 + +### Features + +- Add browser login ([#687](https://github.com/s2-streamstore/s2/issues/687)) + + + +## [0.42.3] - 2026-08-05 + +### Bug Fixes + +- Clarify access token migration output ([#685](https://github.com/s2-streamstore/s2/issues/685)) + + + +## [0.42.2] - 2026-08-05 + +### Features + +- Securely store S2 access tokens ([#683](https://github.com/s2-streamstore/s2/issues/683)) + + + +## [0.42.1] - 2026-08-01 + +### Bug Fixes + +- Ignore empty HOMEBREW_CELLAR in install channel detection ([#675](https://github.com/s2-streamstore/s2/issues/675)) + + + +## [0.42.0] - 2026-07-31 + +### Features + +- [**breaking**] Replace S2Error with surface-specific errors ([#653](https://github.com/s2-streamstore/s2/issues/653)) +- [**breaking**] Support indefinite read session retries ([#658](https://github.com/s2-streamstore/s2/issues/658)) + + + +## [0.41.2] - 2026-07-28 + +### Bug Fixes + +- Make endpoint parse errors source-agnostic ([#664](https://github.com/s2-streamstore/s2/issues/664)) + +### Miscellaneous Tasks + +- Dep updates ([#667](https://github.com/s2-streamstore/s2/issues/667)) + + + +## [0.41.1] - 2026-07-24 + +### Features + +- Detect install channel for version output and upgrade hints ([#662](https://github.com/s2-streamstore/s2/issues/662)) + + + +## [0.41.0] - 2026-07-23 + +### Refactor + +- [**breaking**] Remove interactive TUI ([#660](https://github.com/s2-streamstore/s2/issues/660)) + + + +## [0.40.1] - 2026-07-23 + +### Features + +- Add resource diff command ([#649](https://github.com/s2-streamstore/s2/issues/649)) + + + +## [0.40.0] - 2026-07-22 + +### Features + +- [**breaking**] Expose caught-up-to-tail signal on read sessions ([#650](https://github.com/s2-streamstore/s2/issues/650)) + + + +## [0.39.3] - 2026-07-17 + + + +## [0.39.2] - 2026-07-16 + +### Bug Fixes + +- Make provision_stream exists-outcomes durably visible ([#641](https://github.com/s2-streamstore/s2/issues/641)) + + + +## [0.39.1] - 2026-07-07 + +### Features + +- Remind when a newer s2-cli release is available ([#619](https://github.com/s2-streamstore/s2/issues/619)) + + + +## [0.39.0] - 2026-07-06 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.38.0] - 2026-07-02 + +### Features + +- Explicit exact/prefix flags for issue-access-token scopes ([#611](https://github.com/s2-streamstore/s2/issues/611)) + +### Bug Fixes + +- Tui reconfigure can clear timestamping uncapped to inherit ([#605](https://github.com/s2-streamstore/s2/issues/605)) +- Allow re-editing a custom basin location typed before locations load ([#607](https://github.com/s2-streamstore/s2/issues/607)) +- Correlate benchmark events by run id to drop stale ones ([#608](https://github.com/s2-streamstore/s2/issues/608)) + +### Miscellaneous Tasks + +- Remove unnecessary #[allow(dead_code)] on RetentionPolicy::Age ([#592](https://github.com/s2-streamstore/s2/issues/592)) +- Remove unused From for CliError impl ([#612](https://github.com/s2-streamstore/s2/issues/612)) + + + +## [0.37.1] - 2026-06-22 + + + +## [0.37.0] - 2026-06-22 + +### Features + +- Live updating stats in `bench` ([#562](https://github.com/s2-streamstore/s2/issues/562)) +- Make access token expiry semantics explicit ([#574](https://github.com/s2-streamstore/s2/issues/574)) + +### Bug Fixes + +- Restrict cli config permissions ([#552](https://github.com/s2-streamstore/s2/issues/552)) + +### Miscellaneous Tasks + +- Remove unused render_sparkline_gradient function ([#565](https://github.com/s2-streamstore/s2/issues/565)) + + + +## [0.36.8] - 2026-06-15 + +### Bug Fixes + +- Return not_implemented for lite stubs ([#546](https://github.com/s2-streamstore/s2/issues/546)) + + + +## [0.36.7] - 2026-06-13 + + + +## [0.36.6] - 2026-06-12 + +### Bug Fixes + +- Only show token-source guidance for token-related SDK init failures ([#538](https://github.com/s2-streamstore/s2/issues/538)) + +### Refactor + +- Split storage internals from common ([#533](https://github.com/s2-streamstore/s2/issues/533)) + +### Testing + +- Add unit tests for low-coverage modules ([#515](https://github.com/s2-streamstore/s2/issues/515)) + + + +## [0.36.5] - 2026-06-11 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.36.4] - 2026-06-10 + + + +## [0.36.3] - 2026-06-10 + +### Features + +- Pills for known basin locations in create form ([#496](https://github.com/s2-streamstore/s2/issues/496)) + +### Bug Fixes + +- Check S2_ACCESS_TOKEN case-insensitively in token source detection ([#508](https://github.com/s2-streamstore/s2/issues/508)) +- Improve error handling and observability ([#506](https://github.com/s2-streamstore/s2/issues/506)) +- Restrict TUI location input to LocationName-valid characters ([#510](https://github.com/s2-streamstore/s2/issues/510)) +- Propagate config parse errors instead of silently overwriting ([#516](https://github.com/s2-streamstore/s2/issues/516)) +- Include access_token_not_found in auth error detection ([#511](https://github.com/s2-streamstore/s2/issues/511)) +- Compute token source before SDK init for better error messages ([#513](https://github.com/s2-streamstore/s2/issues/513)) + + + +## [0.36.2] - 2026-06-02 + + + +## [0.36.1] - 2026-05-29 + +### Bug Fixes + +- Incorrect propagation of unspecified `delete_on_empty.min_age` via `apply` ([#497](https://github.com/s2-streamstore/s2/issues/497)) + + + +## [0.36.0] - 2026-05-20 + +### Bug Fixes + +- [**breaking**] Reorder enum variants to preserve original discriminants ([#487](https://github.com/s2-streamstore/s2/issues/487)) + +### Refactor + +- [**breaking**] Rename scope -> location, treat it as a string; add related RPCs ([#485](https://github.com/s2-streamstore/s2/issues/485)) + + + +## [0.35.1] - 2026-05-20 + +### Bug Fixes + +- TUI panic safety and UTF-8 input handling ([#434](https://github.com/s2-streamstore/s2/issues/434)) + + + +## [0.35.0] - 2026-05-19 + +### Features + +- Expose `ensure_*` ops ([#471](https://github.com/s2-streamstore/s2/issues/471)) + + + +## [0.34.0] - 2026-05-19 + +### Bug Fixes + +- Switch global allocator to jemalloc ([#472](https://github.com/s2-streamstore/s2/issues/472)) + + + +## [0.33.0] - 2026-05-15 + +### Features + +- [**breaking**] Accept positional access token ids ([#458](https://github.com/s2-streamstore/s2/issues/458)) + +### Bug Fixes + +- Preserve explicit optional config values ([#459](https://github.com/s2-streamstore/s2/issues/459)) + +### Miscellaneous Tasks + +- Remove unused dependencies ([#452](https://github.com/s2-streamstore/s2/issues/452)) + + + +## [0.32.0] - 2026-05-14 + +### Bug Fixes + +- Clarify PUT ensure semantics ([#450](https://github.com/s2-streamstore/s2/issues/450)) + + + +## [0.31.0] - 2026-05-10 + +### Features + +- [**breaking**] Mark s2_sdk::BasinScope as #[non_exhaustive] ([#433](https://github.com/s2-streamstore/s2/issues/433)) + + + +## [0.30.6] - 2026-05-04 + +### Features + +- Add aws:us-west-2 and aws:eu-north-1 basin scopes ([#430](https://github.com/s2-streamstore/s2/issues/430)) + + + +## [0.30.5] - 2026-04-27 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.30.4] - 2026-04-24 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.30.3] - 2026-04-22 + + + +## [0.30.2] - 2026-04-21 + + + +## [0.30.1] - 2026-04-20 + +### Bug Fixes + +- Incorrect user agent when TUI loads existing config ([#410](https://github.com/s2-streamstore/s2/issues/410)) + + + +## [0.30.0] - 2026-04-20 + +### Refactor + +- [**breaking**] Replace encryption modes with stream cipher metadata and key-only headers ([#403](https://github.com/s2-streamstore/s2/issues/403)) + + + +## [0.29.32] - 2026-04-17 + +### Bug Fixes + +- Render Duration fields with humantime in config output ([#399](https://github.com/s2-streamstore/s2/issues/399)) + + + +## [0.29.31] - 2026-04-16 + +### Bug Fixes + +- Resolve clippy lints for Rust 1.95 ([#397](https://github.com/s2-streamstore/s2/issues/397)) + + + +## [0.29.30] - 2026-04-16 + +### Features + +- Include version in user agent and differentiate between CLI and TUI ([#395](https://github.com/s2-streamstore/s2/issues/395)) + + + +## [0.29.29] - 2026-04-15 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.29.28] - 2026-04-14 + +### Features + +- Request-time data encryption ([#349](https://github.com/s2-streamstore/s2/issues/349)) +- Enforce allowed encryption modes via stream config ([#376](https://github.com/s2-streamstore/s2/issues/376)) + +### Refactor + +- Clarify encryption spec, mode, and format semantics ([#375](https://github.com/s2-streamstore/s2/issues/375)) + +### Testing + +- Isolate CLI config tests and exclude live SDK tests ([#366](https://github.com/s2-streamstore/s2/issues/366)) + + + +## [0.29.27] - 2026-03-21 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.26] - 2026-03-20 + +### Features + +- Align basin info with stream info ([#338](https://github.com/s2-streamstore/s2/issues/338)) + +### Miscellaneous Tasks + +- Dep updates ([#340](https://github.com/s2-streamstore/s2/issues/340)) + + + +## [0.29.25] - 2026-03-19 + +### Refactor + +- Remove basin creating state ([#333](https://github.com/s2-streamstore/s2/issues/333)) + + + +## [0.29.24] - 2026-03-17 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.29.23] - 2026-03-15 + + + +## [0.29.22] - 2026-03-13 + + + +## [0.29.21] - 2026-03-06 + + + +## [0.29.20] - 2026-03-06 + +### Bug Fixes + +- Handle Ctrl+C during bench catchup ([#297](https://github.com/s2-streamstore/s2/issues/297)) + + + +## [0.29.19] - 2026-03-04 + +### Features + +- Add authn/authz error codes and surface token source in cli ([#286](https://github.com/s2-streamstore/s2/issues/286)) + + + +## [0.29.18] - 2026-03-03 + +### Bug Fixes + +- Enforce apply --schema flag conflicts ([#278](https://github.com/s2-streamstore/s2/issues/278)) + +### Miscellaneous Tasks + +- Upgrade sl8 to 0.11 ([#285](https://github.com/s2-streamstore/s2/issues/285)) +- Upgrade schemars dep to 1.2 ([#287](https://github.com/s2-streamstore/s2/issues/287)) + + + +## [0.29.17] - 2026-03-02 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.16] - 2026-02-28 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.15] - 2026-02-27 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.14] - 2026-02-26 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.13] - 2026-02-25 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.12] - 2026-02-25 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.11] - 2026-02-25 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.10] - 2026-02-25 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.9] - 2026-02-25 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.8] - 2026-02-24 + +### Features + +- Support creating resources from spec ([#239](https://github.com/s2-streamstore/s2/issues/239)) + + + +## [0.29.7] - 2026-02-23 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.6] - 2026-02-17 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.5] - 2026-02-17 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.4] - 2026-02-16 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.3] - 2026-02-16 + +### Miscellaneous Tasks + +- Move `s2-sdk` into `s2` monorepo ([#217](https://github.com/s2-streamstore/s2/issues/217)) + + + +## [0.29.2] - 2026-02-15 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.1] - 2026-02-15 + +### Miscellaneous Tasks + +- Add crate-level doc comment ([#213](https://github.com/s2-streamstore/s2/issues/213)) + + + +## [0.29.0] - 2026-02-15 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.28.4] - 2026-02-12 + +### Bug Fixes + +- Skip duplicate hash check for first record in bench verification ([#194](https://github.com/s2-streamstore/s2/issues/194)) +- Use FuturesOrdered for append ack stream ([#197](https://github.com/s2-streamstore/s2/issues/197)) + +### Miscellaneous Tasks + +- Introduce cargo-deny and justfile improvements ([#193](https://github.com/s2-streamstore/s2/issues/193)) + + + +## [0.28.3] - 2026-02-07 + +### Miscellaneous Tasks + +- Bump `s2-sdk` version ([#191](https://github.com/s2-streamstore/s2/issues/191)) + + + +## [0.28.2] - 2026-02-06 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.28.1] - 2026-02-06 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.28.0] - 2026-02-06 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.27.5] - 2026-02-05 + +### Bug Fixes + +- Skip extra newline for command records in tail text output ([#173](https://github.com/s2-streamstore/s2/issues/173)) + + + +## [0.27.4] - 2026-02-05 + +### Bug Fixes + +- Only set default_stream_config when stream config args provided ([#164](https://github.com/s2-streamstore/s2/issues/164)) + +### Miscellaneous Tasks + +- Install aws-lc-rs as default crypto provider for rustls ([#171](https://github.com/s2-streamstore/s2/issues/171)) + + + +## [0.27.3] - 2026-02-05 + +### Miscellaneous Tasks + +- Rejig versioning and release workflow ([#163](https://github.com/s2-streamstore/s2/issues/163)) +- Move `s2-sdk` dep from workspace into `cli` ([#166](https://github.com/s2-streamstore/s2/issues/166)) + + + +## [0.27.2] - 2026-02-05 + +### Miscellaneous Tasks + +- Release v0.27.1 ([#160](https://github.com/s2-streamstore/s2/issues/160)) + + + +## [0.27.1] - 2026-02-04 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.27.0] - 2026-02-03 + +### Miscellaneous Tasks + +- Release v0.26.9 ([#147](https://github.com/s2-streamstore/s2/issues/147)) + + + +## [0.26.9] - 2026-02-02 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.26.8] - 2026-01-30 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.26.7] - 2026-01-30 + +### Bug Fixes + +- *(tui)* Allow P key input on setup screen and cursors ([#141](https://github.com/s2-streamstore/s2/issues/141)) + + + +## [0.26.6] - 2026-01-30 + +### Bug Fixes + +- *(cli)* Show auth errors instead of empty list and improve benchmark catchup ([#139](https://github.com/s2-streamstore/s2/issues/139)) + + + +## [0.26.5] - 2026-01-30 + +### Bug Fixes + +- *(cli)* Prevent panic on small terminals ([#136](https://github.com/s2-streamstore/s2/issues/136)) + + + +## [0.26.4] - 2026-01-29 + +### Bug Fixes + +- *(cli)* Default to info log level for lite subcommand ([#121](https://github.com/s2-streamstore/s2/issues/121)) + + + +## [0.26.3] - 2026-01-29 + +### Features + +- *(cli)* Add interactive TUI mode ([#120](https://github.com/s2-streamstore/s2/issues/120)) + + + +## [0.26.2] - 2026-01-29 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.26.1] - 2026-01-29 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.26.0] - 2026-01-28 + +### Features + +- *(cli)* [**breaking**] Integrate s2-cli into workspace with lite subcommand ([#103](https://github.com/s2-streamstore/s2/issues/103)) + +### Miscellaneous Tasks + +- Fix cliff.toml preprocessor and import s2-cli changelog ([#105](https://github.com/s2-streamstore/s2/issues/105)) + + + +## [0.25.2] - 2026-01-27 + +### Bug Fixes + +- Add `S2_SSL_NO_VERIFY` config ([#210](https://github.com/s2-streamstore/s2-cli/issues/210)) + +### Miscellaneous Tasks + +- Release cmd +- Automate releases with release-plz +- Add dependabot for weekly dependency updates ([#208](https://github.com/s2-streamstore/s2-cli/issues/208)) +- Consolidate release workflows ([#211](https://github.com/s2-streamstore/s2-cli/issues/211)) + +## [0.25.1] - 2026-01-25 + +### Miscellaneous Tasks + +- Bump sdk version, opt-out of ignoring deleted resources in pagination ([#205](https://github.com/s2-streamstore/s2-cli/issues/205)) +- `bench` improvements ([#206](https://github.com/s2-streamstore/s2-cli/issues/206)) + +## [0.25.0] - 2026-01-20 + +### Features + +- [**breaking**] Replaced `ping` command which only tested latency. with a new `bench` command that measures throughput and latency. + +### Bug Fixes + +- Use cross instead of musl.cc for static bins ([#202](https://github.com/s2-streamstore/s2-cli/issues/202)) + +### Refactor + +- `--catchup-delay` arg for `bench` ([#203](https://github.com/s2-streamstore/s2-cli/issues/203)) + +## [0.24.2] - 2026-01-20 + +### Features + +- Add musl static binaries for linux ([#200](https://github.com/s2-streamstore/s2-cli/issues/200)) + +### Miscellaneous Tasks + +- Llm generated tests ([#197](https://github.com/s2-streamstore/s2-cli/issues/197)) + +## [0.24.1] - 2026-01-18 + +### Miscellaneous Tasks + +- Bump `s2-sdk` version ([#195](https://github.com/s2-streamstore/s2-cli/issues/195)) + +## [0.24.0] - 2026-01-18 + +### Refactor + +- [**breaking**] Improve CLI output formatting and help text ([#190](https://github.com/s2-streamstore/s2-cli/issues/190)) + +### Miscellaneous Tasks + +- Remove `protoc` installation step ([#191](https://github.com/s2-streamstore/s2-cli/issues/191)) +- Update dependencies ([#192](https://github.com/s2-streamstore/s2-cli/issues/192)) +- Bump `s2-sdk` version ([#193](https://github.com/s2-streamstore/s2-cli/issues/193)) + +## [0.23.2] - 2026-01-16 + +### Bug Fixes + +- Use ~/.config/s2/config.toml for config path ([#188](https://github.com/s2-streamstore/s2-cli/issues/188)) + +### Miscellaneous Tasks + +- Reduce append noise ([#187](https://github.com/s2-streamstore/s2-cli/issues/187)) + +## [0.23.1] - 2026-01-16 + +### Documentation + +- Explicitly avoid trailing periods for verbatim doc comments ([#185](https://github.com/s2-streamstore/s2-cli/issues/185)) + +## [0.23.0] - 2026-01-16 + +### Bug Fixes + +- Clippy new lint ([#173](https://github.com/s2-streamstore/s2-cli/issues/173)) + +### Refactor + +- [**breaking**] Migrate from `streamstore` to `s2-sdk` ([#183](https://github.com/s2-streamstore/s2-cli/issues/183)) + +### Documentation + +- Fix broken link + +### Miscellaneous Tasks + +- Change license to MIT ([#174](https://github.com/s2-streamstore/s2-cli/issues/174)) + +## [0.22.0] - 2025-09-03 + +### Features + +- Support `Infinite` retention ([#170](https://github.com/s2-streamstore/s2-cli/issues/170)) + +## [0.21.1] - 2025-07-28 + +### Bug Fixes + +- Make csoa/csor flags ([#167](https://github.com/s2-streamstore/s2-cli/issues/167)) + +## [0.21.0] - 2025-07-28 + +### Features + +- Delete-on-empty ([#163](https://github.com/s2-streamstore/s2-cli/issues/163)) + +### Bug Fixes + +- Specifying stream config args does not work ([#165](https://github.com/s2-streamstore/s2-cli/issues/165)) + +## [0.20.0] - 2025-07-22 + +### Features + +- Clamp ([#161](https://github.com/s2-streamstore/s2-cli/issues/161)) + +### Release + +- 0.19.2 ([#159](https://github.com/s2-streamstore/s2-cli/issues/159)) + +## [0.19.2] - 2025-07-15 + +### Bug Fixes + +- Reconfigure-* ([#158](https://github.com/s2-streamstore/s2-cli/issues/158)) + +## [0.19.1] - 2025-07-04 + +### Features + +- Add env var flag to disable tls ([#156](https://github.com/s2-streamstore/s2-cli/issues/156)) + +## [0.19.0] - 2025-06-13 + +### Bug Fixes + +- Error message for missing access token + +## [0.18.0] - 2025-06-13 + +### Miscellaneous Tasks + +- Update ubuntu version in release + +## [0.17.0] - 2025-06-06 + +### Features + +- Compress by default ([#153](https://github.com/s2-streamstore/s2-cli/issues/153)) +- Add `until` timestamp support + metrics ops ([#154](https://github.com/s2-streamstore/s2-cli/issues/154)) + +## [0.16.0] - 2025-05-25 + +### Features + +- Add linger opt for append ([#148](https://github.com/s2-streamstore/s2-cli/issues/148)) +- Fencing token as string rather than base64-encoded bytes ([#150](https://github.com/s2-streamstore/s2-cli/issues/150)) + +### Miscellaneous Tasks + +- Default `read` to tailing rather than reading from head of stream ([#149](https://github.com/s2-streamstore/s2-cli/issues/149)) +- Updated `--format` names ([#151](https://github.com/s2-streamstore/s2-cli/issues/151)) + +## [0.15.0] - 2025-05-10 + +### Miscellaneous Tasks + +- Bump SDK version ([#146](https://github.com/s2-streamstore/s2-cli/issues/146)) + +## [0.14.0] - 2025-05-08 + +### Features + +- Support timestamping configs ([#143](https://github.com/s2-streamstore/s2-cli/issues/143)) + +## [0.13.2] - 2025-05-02 + +### Miscellaneous Tasks + +- `CHANGELOG` update + +## [0.13.1] - 2025-05-02 + +### Miscellaneous Tasks + +- `Cargo.lock` update + +## [0.13.0] - 2025-05-02 + +### Features + +- `tail` command ([#140](https://github.com/s2-streamstore/s2-cli/issues/140)) + +### Miscellaneous Tasks + +- Reorder fields for json format + +## [0.12.0] - 2025-04-30 + +### Features + +- Support reading from timestamp or tail-offset ([#137](https://github.com/s2-streamstore/s2-cli/issues/137)) + +### Bug Fixes + +- Ping ([#138](https://github.com/s2-streamstore/s2-cli/issues/138)) +- `create_stream_on_read` for reconfigure basin ([#136](https://github.com/s2-streamstore/s2-cli/issues/136)) + +## [0.11.0] - 2025-04-15 + +### Features + +- Access token methods ([#133](https://github.com/s2-streamstore/s2-cli/issues/133)) + +### Miscellaneous Tasks + +- Release 0.11.0 +- Typed errors ([#135](https://github.com/s2-streamstore/s2-cli/issues/135)) + +## [0.10.0] - 2025-03-14 + +### Bug Fixes + +- `--create-stream-on-append` to accept explicit bool ([#131](https://github.com/s2-streamstore/s2-cli/issues/131)) + +## [0.9.0] - 2025-03-12 + +### Features + +- Auto-paginate for stream and basin list ([#128](https://github.com/s2-streamstore/s2-cli/issues/128)) + +### Bug Fixes + +- Ls to return fully qualified s2 uri ([#126](https://github.com/s2-streamstore/s2-cli/issues/126)) + +### Miscellaneous Tasks + +- Remove unused deps + bump sdk version ([#125](https://github.com/s2-streamstore/s2-cli/issues/125)) +- *(release)* Upgrade SDK ([#129](https://github.com/s2-streamstore/s2-cli/issues/129)) + +## [0.8.4] - 2025-02-05 + +### Bug Fixes + +- Improve output messages for command record appends ([#119](https://github.com/s2-streamstore/s2-cli/issues/119)) +- Metered bytes log ([#121](https://github.com/s2-streamstore/s2-cli/issues/121)) + +### Miscellaneous Tasks + +- Improve read cli command docs ([#117](https://github.com/s2-streamstore/s2-cli/issues/117)) +- Add uri args struct ([#120](https://github.com/s2-streamstore/s2-cli/issues/120)) + +## [0.8.3] - 2025-01-22 + +### Miscellaneous Tasks + +- Reflect the update to make list limit optional instead of a default of 0 ([#114](https://github.com/s2-streamstore/s2-cli/issues/114)) +- Minor upgrades + +## [0.8.2] - 2025-01-21 + +### Miscellaneous Tasks + +- Update SDK to `0.8.0` [#113](https://github.com/s2-streamstore/s2-cli/issues/113)) + +## [0.8.1] - 2025-01-16 + +### Miscellaneous Tasks + +- Update SDK to `0.7.0` ([#111](https://github.com/s2-streamstore/s2-cli/issues/111)) + +## [0.8.0] - 2025-01-13 + +### Features + +- Update fencing token to accept base64 instead of base16 ([#106](https://github.com/s2-streamstore/s2-cli/issues/106)) +- Support different formats for append ([#105](https://github.com/s2-streamstore/s2-cli/issues/105)) + +### Miscellaneous Tasks + +- Update clap CLI name ([#104](https://github.com/s2-streamstore/s2-cli/issues/104)) +- Update deps ([#108](https://github.com/s2-streamstore/s2-cli/issues/108)) + +## [0.7.0] - 2024-12-26 + +### Features + +- Only accept URIs in basin+stream args ([#100](https://github.com/s2-streamstore/s2-cli/issues/100)) +- `s2 ls` command to list basins or streams ([#102](https://github.com/s2-streamstore/s2-cli/issues/102)) + +### Miscellaneous Tasks + +- Inline path consts for consistency + +## [0.6.4] - 2024-12-23 + +### Bug Fixes + +- Error/help messages ([#95](https://github.com/s2-streamstore/s2-cli/issues/95)) + +### Documentation + +- Update README S2 doc link ([#92](https://github.com/s2-streamstore/s2-cli/issues/92)) + +## [0.6.3] - 2024-12-19 + +### Documentation + +- Update README API link ([#89](https://github.com/s2-streamstore/s2-cli/issues/89)) + +### Miscellaneous Tasks + +- Upgrade SDK to `0.5.0` ([#90](https://github.com/s2-streamstore/s2-cli/issues/90)) + +## [0.6.2] - 2024-12-18 + +### Bug Fixes + +- Update output for reconfigure basin and create basin results ([#86](https://github.com/s2-streamstore/s2-cli/issues/86)) + +### Miscellaneous Tasks + +- Add `README.md` ([#83](https://github.com/s2-streamstore/s2-cli/issues/83)) + +## [0.6.1] - 2024-12-17 + +### Miscellaneous Tasks + +- Update cargo binary name to `s2` ([#84](https://github.com/s2-streamstore/s2-cli/issues/84)) +- *(release)* Upgrade SDK to 0.4.0 ([#85](https://github.com/s2-streamstore/s2-cli/issues/85)) +- *(release)* Upgrade SDK to 0.4.1 ([#87](https://github.com/s2-streamstore/s2-cli/issues/87)) + +## [0.6.0] - 2024-12-14 + +### Features + +- Support `s2://` URIs ([#74](https://github.com/s2-streamstore/s2-cli/issues/74)) +- Better display for ping stats ([#81](https://github.com/s2-streamstore/s2-cli/issues/81)) + +### Bug Fixes + +- Disable noisy description in help ([#79](https://github.com/s2-streamstore/s2-cli/issues/79)) + +### Miscellaneous Tasks + +- Remove unnecessary dependencies from `Cargo.toml` ([#80](https://github.com/s2-streamstore/s2-cli/issues/80)) + +## [0.5.2] - 2024-12-13 + +### Miscellaneous Tasks + +- Rename binary to s2 when releasing ([#76](https://github.com/s2-streamstore/s2-cli/issues/76)) + +## [0.5.1] - 2024-12-13 + +### Features + +- Homebrew sync ([#71](https://github.com/s2-streamstore/s2-cli/issues/71)) + +## [0.5.0] - 2024-12-11 + +### Bug Fixes + +- Use a different `std::thread::Thread` for `Stdin` IO ([#69](https://github.com/s2-streamstore/s2-cli/issues/69)) + +### Miscellaneous Tasks + +- Release to crates.io ([#68](https://github.com/s2-streamstore/s2-cli/issues/68)) + +## [0.4.0] - 2024-12-11 + +### Features + +- Allow append concurrency control on `fence` and `trim` too ([#60](https://github.com/s2-streamstore/s2-cli/issues/60)) +- Ping ([#48](https://github.com/s2-streamstore/s2-cli/issues/48)) ([#63](https://github.com/s2-streamstore/s2-cli/issues/63)) + +### Bug Fixes + +- Usage example + +### Documentation + +- Clarify fencing token is in hex + +### Miscellaneous Tasks + +- Mandatory read `start_seq_num` ([#58](https://github.com/s2-streamstore/s2-cli/issues/58)) +- Make all short args explicit ([#29](https://github.com/s2-streamstore/s2-cli/issues/29)) ([#59](https://github.com/s2-streamstore/s2-cli/issues/59)) +- Upgrade deps ([#64](https://github.com/s2-streamstore/s2-cli/issues/64)) +- Update cargo.toml ([#65](https://github.com/s2-streamstore/s2-cli/issues/65)) +- Rename to streamstore-cli ([#66](https://github.com/s2-streamstore/s2-cli/issues/66)) +- Description - Cargo.toml +- Update README.md + +## [0.3.0] - 2024-12-05 + +### Features + +- Return reconfigured stream ([#53](https://github.com/s2-streamstore/s2-cli/issues/53)) +- Stderr `CommandRecord` when reading ([#45](https://github.com/s2-streamstore/s2-cli/issues/45)) ([#55](https://github.com/s2-streamstore/s2-cli/issues/55)) +- Sign and notarize apple binaries ([#54](https://github.com/s2-streamstore/s2-cli/issues/54)) +- Flatten commands ([#52](https://github.com/s2-streamstore/s2-cli/issues/52)) ([#56](https://github.com/s2-streamstore/s2-cli/issues/56)) + +## [0.2.0] - 2024-12-05 + +### Features + +- Load endpoints `from_env()` ([#16](https://github.com/s2-streamstore/s2-cli/issues/16)) +- Display throughput for read session ([#25](https://github.com/s2-streamstore/s2-cli/issues/25)) +- Exercise limits for read session ([#27](https://github.com/s2-streamstore/s2-cli/issues/27)) +- Better error reporting ([#30](https://github.com/s2-streamstore/s2-cli/issues/30)) +- Appends with `fencing_token` and `match_seq_num` ([#38](https://github.com/s2-streamstore/s2-cli/issues/38)) +- Stream `fence` and `trim` commands ([#46](https://github.com/s2-streamstore/s2-cli/issues/46)) + +### Bug Fixes + +- Config env var precedence +- Flush BufWriter ([#22](https://github.com/s2-streamstore/s2-cli/issues/22)) +- Handle common signals for streams ([#32](https://github.com/s2-streamstore/s2-cli/issues/32)) +- Optional `start_seq_num` in `StreamService/ReadSession` ([#42](https://github.com/s2-streamstore/s2-cli/issues/42)) +- Catch `ctrl-c` signal on windows ([#50](https://github.com/s2-streamstore/s2-cli/issues/50)) + +### Documentation + +- Consistency +- Nits ([#19](https://github.com/s2-streamstore/s2-cli/issues/19)) + +### Miscellaneous Tasks + +- Rm `S2ConfigError::PathError` ([#17](https://github.com/s2-streamstore/s2-cli/issues/17)) +- Only attempt to load config from file if it exists ([#18](https://github.com/s2-streamstore/s2-cli/issues/18)) +- Rename binary to s2 ([#21](https://github.com/s2-streamstore/s2-cli/issues/21)) +- Set user-agent to s2-cli ([#23](https://github.com/s2-streamstore/s2-cli/issues/23)) ([#24](https://github.com/s2-streamstore/s2-cli/issues/24)) +- Create LICENSE +- Update Cargo.toml with license +- Update SDK ([#26](https://github.com/s2-streamstore/s2-cli/issues/26)) +- Sdk update ([#31](https://github.com/s2-streamstore/s2-cli/issues/31)) +- Update CLI to latest sdk ([#37](https://github.com/s2-streamstore/s2-cli/issues/37)) +- Upgrade SDK ([#41](https://github.com/s2-streamstore/s2-cli/issues/41)) +- Upgrade sdk version ([#43](https://github.com/s2-streamstore/s2-cli/issues/43)) +- Update SDK ([#47](https://github.com/s2-streamstore/s2-cli/issues/47)) + +## [0.1.0] - 2024-11-05 + +### Features + +- Implement `AccountService` ([#1](https://github.com/s2-streamstore/s2-cli/issues/1)) +- Implement `BasinService` ([#2](https://github.com/s2-streamstore/s2-cli/issues/2)) +- Implement `StreamService` ([#3](https://github.com/s2-streamstore/s2-cli/issues/3)) + +### Bug Fixes + +- Try to fix release CI ([#9](https://github.com/s2-streamstore/s2-cli/issues/9)) +- Release CI ([#10](https://github.com/s2-streamstore/s2-cli/issues/10)) +- Release CI ([#11](https://github.com/s2-streamstore/s2-cli/issues/11)) +- Automatically add release notes ([#12](https://github.com/s2-streamstore/s2-cli/issues/12)) +- Changelog ([#13](https://github.com/s2-streamstore/s2-cli/issues/13)) +- Release CI ([#14](https://github.com/s2-streamstore/s2-cli/issues/14)) + +### Miscellaneous Tasks + +- Reflect renamed repo +- Upgrade deps +- Clippy, whitespace +- Add CI action ([#6](https://github.com/s2-streamstore/s2-cli/issues/6)) +- CODEOWNERS ([#7](https://github.com/s2-streamstore/s2-cli/issues/7)) +- Add release CI action ([#8](https://github.com/s2-streamstore/s2-cli/issues/8)) +- *(release)* Release 0.1.0 ([#15](https://github.com/s2-streamstore/s2-cli/issues/15)) + + diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 00000000..f3af2ba8 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,81 @@ +[package] +name = "s2-cli" +version = "0.43.1" +description = "CLI for S2" +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords = ["s2", "durable", "streams", "client", "cli"] + +[[bin]] +name = "s2" +path = "src/main.rs" + +[dependencies] +async-stream = { workspace = true } +async-trait = { workspace = true } +axum = { workspace = true } +base64ct = { workspace = true, features = ["alloc"] } +bytes = { workspace = true } +clap = { workspace = true, features = ["derive"] } +color-print = { workspace = true } +colored = { workspace = true } +compact_str = { workspace = true, features = ["serde"] } +config = { workspace = true } +dirs = { workspace = true } +fs2 = { workspace = true } +futures = { workspace = true } +humantime = { workspace = true } +indicatif = { workspace = true } +json_to_table = { workspace = true } +keyring = { workspace = true } +miette = { workspace = true, features = ["fancy"] } +rand = { workspace = true } +reqwest = { workspace = true, features = ["form"] } +rpassword = { workspace = true } +rustls = { workspace = true, features = ["aws-lc-rs"] } +s2-api = { workspace = true } +s2-common = { workspace = true } +s2-lite = { workspace = true } +s2-resource-spec = { workspace = true } +s2-sdk = { workspace = true, features = ["_hidden"] } +secrecy = { workspace = true } +semver = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["preserve_order"] } +sha2 = "0.11" +strum = { workspace = true, features = ["derive"] } +tabled = { workspace = true } +tempfile = { workspace = true } +terminal_size = "0.4" +thiserror = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tokio-stream = { workspace = true, features = ["io-util"] } +toml = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +uuid = { workspace = true, features = ["v4"] } +xxhash-rust = { workspace = true, features = ["xxh3"] } +zip = { version = "8", default-features = false, features = ["deflate"] } + +# tikv-jemallocator does not build on the MSVC toolchain; on Windows-MSVC +# Rust falls back to the system allocator (HeapAlloc). +[target.'cfg(not(target_env = "msvc"))'.dependencies] +tikv-jemallocator = { workspace = true } + +[target.'cfg(not(windows))'.dependencies] +self-replace = "1.5" + +[build-dependencies] +serde_json = { workspace = true } + +[dev-dependencies] +assert_cmd = "2.2" +http-body-util = "0.1" +hyper = { version = "1", features = ["http2", "server"] } +hyper-util = { version = "0.1", features = ["http2", "server", "tokio"] } +predicates = "3.1" +proptest = { workspace = true } +rstest = { workspace = true } +serial_test = "4.0" diff --git a/cli/build.rs b/cli/build.rs new file mode 100644 index 00000000..283aeac7 --- /dev/null +++ b/cli/build.rs @@ -0,0 +1,133 @@ +//! Records build provenance in the CLI binary: +//! - the target triple, so `s2 update` can pick the matching release artifact; +//! - the exact source commit, for `s2 --version`. + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::Command, +}; + +const REVISION_OVERRIDE: &str = "S2_GIT_REV"; +const EMBEDDED_REVISION: &str = "S2_GIT_COMMIT"; +const UNKNOWN_REVISION: &str = "unknown"; + +fn main() { + println!("cargo::rerun-if-env-changed={REVISION_OVERRIDE}"); + println!("cargo::rerun-if-env-changed=S2_BUILD_CHANNEL"); + + let target = env::var("TARGET").expect("cargo sets TARGET for build scripts"); + println!("cargo::rustc-env=S2_TARGET={target}"); + + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR for build scripts"), + ); + let revision = resolve_revision(&manifest_dir); + if revision == UNKNOWN_REVISION { + if env::var("S2_BUILD_CHANNEL").as_deref() == Ok("release") { + panic!("official release builds must set {REVISION_OVERRIDE} to the source commit"); + } + println!("cargo::warning=building s2-cli without source commit metadata"); + } + println!("cargo::rustc-env={EMBEDDED_REVISION}={revision}"); +} + +fn resolve_revision(manifest_dir: &Path) -> String { + if let Some(revision) = revision_override() { + return revision; + } + + let vcs_info = manifest_dir.join(".cargo_vcs_info.json"); + if let Some(revision) = packaged_revision(&vcs_info) { + println!("cargo::rerun-if-changed={}", vcs_info.display()); + return revision; + } + + if let Some(revision) = git_revision(manifest_dir) { + emit_git_rerun_hints(manifest_dir); + return revision; + } + + UNKNOWN_REVISION.to_string() +} + +fn revision_override() -> Option { + match env::var(REVISION_OVERRIDE) { + Ok(revision) => { + if revision != UNKNOWN_REVISION { + assert!( + is_full_git_hash(&revision), + "{REVISION_OVERRIDE} must be `unknown` or a full 40- or 64-character hexadecimal commit hash" + ); + } + Some(revision) + } + Err(env::VarError::NotPresent) => None, + Err(env::VarError::NotUnicode(_)) => { + panic!("{REVISION_OVERRIDE} must contain valid UTF-8") + } + } +} + +fn packaged_revision(path: &Path) -> Option { + let contents = fs::read_to_string(path).ok()?; + let document: serde_json::Value = serde_json::from_str(&contents).ok()?; + let revision = document.pointer("/git/sha1")?.as_str()?; + is_full_git_hash(revision).then(|| revision.to_string()) +} + +fn git_revision(manifest_dir: &Path) -> Option { + let output = git_output(manifest_dir, &["rev-parse", "--verify", "HEAD"])?; + is_full_git_hash(&output).then_some(output) +} + +fn emit_git_rerun_hints(manifest_dir: &Path) { + let Some(head) = git_path(manifest_dir, "HEAD") else { + return; + }; + println!("cargo::rerun-if-changed={}", head.display()); + + // The loose ref file may not exist (e.g. right after `git pack-refs`); cargo + // treats a registered-but-missing path as always dirty, which errs toward an + // extra rebuild rather than a stale embedded revision. + if let Ok(contents) = fs::read_to_string(&head) + && let Some(reference) = contents.strip_prefix("ref: ").map(str::trim) + { + for path in [reference, "packed-refs"] { + if let Some(path) = git_path(manifest_dir, path) { + println!("cargo::rerun-if-changed={}", path.display()); + } + } + } +} + +fn git_path(manifest_dir: &Path, path: &str) -> Option { + let path = PathBuf::from(git_output( + manifest_dir, + &["rev-parse", "--git-path", path], + )?); + Some(if path.is_absolute() { + path + } else { + manifest_dir.join(path) + }) +} + +fn git_output(manifest_dir: &Path, args: &[&str]) -> Option { + let output = Command::new("git") + .args(args) + .current_dir(manifest_dir) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|output| output.trim().to_string()) + .filter(|output| !output.is_empty()) +} + +fn is_full_git_hash(revision: &str) -> bool { + matches!(revision.len(), 40 | 64) && revision.bytes().all(|byte| byte.is_ascii_hexdigit()) +} diff --git a/cli/schema.json b/cli/schema.json new file mode 100644 index 00000000..c785bd21 --- /dev/null +++ b/cli/schema.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Resources", + "type": "object", + "properties": { + "basins": { + "type": "array", + "items": { + "$ref": "#/$defs/Basin" + } + } + }, + "$defs": { + "Basin": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "anyOf": [ + { + "$ref": "#/$defs/BasinConfig" + }, + { + "type": "null" + } + ] + }, + "streams": { + "type": "array", + "items": { + "$ref": "#/$defs/Stream" + } + } + }, + "additionalProperties": false, + "required": [ + "name" + ] + }, + "BasinConfig": { + "type": "object", + "properties": { + "default_stream_config": { + "anyOf": [ + { + "$ref": "#/$defs/StreamConfig" + }, + { + "type": "null" + } + ] + }, + "stream_cipher": { + "description": "Encryption algorithm to apply to newly created streams in the basin.", + "anyOf": [ + { + "$ref": "#/$defs/EncryptionAlgorithm" + }, + { + "type": "null" + } + ], + "default": null + }, + "create_stream_on_append": { + "description": "Create stream on append if it doesn't exist, using the default stream configuration.", + "type": [ + "boolean", + "null" + ], + "default": null + }, + "create_stream_on_read": { + "description": "Create stream on read if it doesn't exist, using the default stream configuration.", + "type": [ + "boolean", + "null" + ], + "default": null + } + }, + "additionalProperties": false + }, + "StreamConfig": { + "type": "object", + "properties": { + "storage_class": { + "description": "[Storage class](https://s2.dev/docs/storage-classes) for recent writes.", + "type": [ + "string", + "null" + ], + "default": null + }, + "retention_policy": { + "description": "Retention policy for the stream. If unspecified, the default is to retain records for 7\ndays.", + "anyOf": [ + { + "$ref": "#/$defs/RetentionPolicy" + }, + { + "type": "null" + } + ] + }, + "timestamping": { + "description": "Timestamping behavior.", + "anyOf": [ + { + "$ref": "#/$defs/Timestamping" + }, + { + "type": "null" + } + ] + }, + "delete_on_empty": { + "description": "Delete-on-empty configuration.", + "anyOf": [ + { + "$ref": "#/$defs/DeleteOnEmpty" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "RetentionPolicy": { + "description": "Retain records unless explicitly trimmed (\"infinite\"), or automatically trim records older than the given duration (e.g. \"7days\", \"1week\"). Age durations must be greater than 0 seconds.", + "type": "string", + "examples": [ + "infinite", + "7days", + "1week" + ] + }, + "Timestamping": { + "type": "object", + "properties": { + "mode": { + "description": "Timestamping mode for appends that influences how timestamps are handled.", + "anyOf": [ + { + "$ref": "#/$defs/TimestampingMode" + }, + { + "type": "null" + } + ], + "default": null + }, + "uncapped": { + "description": "Allow client-specified timestamps to exceed the arrival time.\nIf this is `false` or not set, client timestamps will be capped at the arrival time.", + "type": [ + "boolean", + "null" + ], + "default": null + } + }, + "additionalProperties": false + }, + "TimestampingMode": { + "description": "Timestamping mode for appends that influences how timestamps are handled.", + "type": "string", + "enum": [ + "client-prefer", + "client-require", + "arrival" + ] + }, + "DeleteOnEmpty": { + "type": "object", + "properties": { + "min_age": { + "description": "Minimum age before an empty stream can be deleted.\nSet to 0 (default) to disable delete-on-empty (don't delete automatically).", + "anyOf": [ + { + "$ref": "#/$defs/HumanDuration" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "HumanDuration": { + "description": "A duration string in humantime format, e.g. \"1day\", \"2h 30m\"", + "type": "string", + "examples": [ + "1day", + "2h 30m" + ] + }, + "EncryptionAlgorithm": { + "description": "Encryption algorithm to apply to newly created streams in the basin.", + "type": "string", + "enum": [ + "aegis-256", + "aes-256-gcm" + ] + }, + "Stream": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "anyOf": [ + { + "$ref": "#/$defs/StreamConfig" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "required": [ + "name" + ] + } + } +} diff --git a/cli/src/access_token.rs b/cli/src/access_token.rs new file mode 100644 index 00000000..4045a5ed --- /dev/null +++ b/cli/src/access_token.rs @@ -0,0 +1,535 @@ +use std::{ + io::{IsTerminal as _, Read as _}, + path::PathBuf, +}; + +use miette::Diagnostic; +use secrecy::{ExposeSecret as _, SecretBox, SecretString}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + cli::{AuthAccessTokenMigrateArgs, AuthAccessTokenSetArgs}, + config::{ + AuthMethod, CliConfig, CredentialStore, StoredCredentialReference, acquire_config_lock, + load_config_file, save_cli_config, + }, + credential_store::{self, CredentialKind, CredentialStoreError}, + error::CliConfigError, +}; + +const CREDENTIAL_KIND: &str = "s2_access_token"; +const CREDENTIAL_VERSION: u8 = 1; +const MAX_STDIN_BYTES: u64 = 1024 * 1024; + +#[derive(Debug, Error, Diagnostic)] +pub enum AccessTokenError { + #[error("Failed to read the access token from standard input")] + Stdin(#[source] std::io::Error), + + #[error("Failed to read the access token from the terminal")] + Prompt(#[source] std::io::Error), + + #[error("No access token was provided")] + #[diagnostic(help( + "Paste a token at the prompt, or pipe one into `s2 auth access-token set --stdin`." + ))] + EmptyInput, + + #[error("`--stdin` requires piped or redirected input")] + #[diagnostic(help( + "Pipe the token into this command, or omit `--stdin` to paste it securely at the prompt." + ))] + StdinIsTerminal, + + #[error("Cannot prompt for an access token without an interactive terminal")] + #[diagnostic(help( + "Pass `--stdin` when piping or redirecting an access token into this command." + ))] + PromptUnavailable, + + #[error("The access token is too large")] + #[diagnostic(help("Provide an access token no larger than 1 MiB."))] + InputTooLarge, + + #[error("The access token contains whitespace or control characters, or is not ASCII")] + #[diagnostic(help("Provide exactly one access token followed by an optional newline."))] + InvalidInput, + + #[error("Failed to serialize the stored access token")] + Serialize(#[source] serde_json::Error), + + #[error("The stored access token is invalid")] + Parse(#[source] serde_json::Error), + + #[error("The stored access token does not match its configuration")] + BindingMismatch, + + #[error("No access token is configured")] + #[diagnostic(help( + "Run `s2 auth access-token set`, or set `S2_ACCESS_TOKEN` for a non-persistent override." + ))] + NotConfigured, + + #[error("No legacy plaintext access token was found in the config file")] + #[diagnostic(help("Use `s2 auth access-token set` to store an access token."))] + NoLegacyToken, + + #[error("A securely stored access token is already configured")] + #[diagnostic(help( + "Use `s2 auth access-token set` to replace it. Migration only moves a legacy plaintext token." + ))] + AlreadyStored, + + #[error("The access token was deactivated, but its local credential could not be deleted")] + #[diagnostic(help( + "Retry `s2 auth access-token remove`; the credential remains queued for cleanup at {recovery}." + ))] + RemovalIncomplete { recovery: String }, + + #[error(transparent)] + #[diagnostic(transparent)] + Store(#[from] CredentialStoreError), + + #[error(transparent)] + #[diagnostic(transparent)] + Config(#[from] CliConfigError), +} + +impl AccessTokenError { + pub fn is_not_found(&self) -> bool { + matches!(self, Self::Store(CredentialStoreError::CredentialNotFound)) + } +} + +#[derive(Debug)] +pub struct TokenChange { + pub config_path: PathBuf, + pub credential_store: CredentialStore, + pub credential_path: Option, + pub replaced: bool, + pub cleanup_warning: Option, +} + +#[derive(Debug)] +pub struct TokenRemoval { + pub config_path: Option, + pub removed: bool, + pub cleanup_warning: Option, +} + +#[derive(Serialize)] +struct StoredCredential<'a> { + version: u8, + kind: &'static str, + credential_id: &'a str, + access_token: &'a str, +} + +#[derive(Deserialize)] +struct LoadedCredential { + version: u8, + kind: String, + credential_id: String, + access_token: String, +} + +#[derive(Clone, Copy)] +enum StoreIntent { + Set, + Migrate, +} + +pub async fn set(args: &AuthAccessTokenSetArgs) -> Result { + let token = if args.stdin { + read_from_stdin()? + } else { + read_from_terminal()? + }; + store_token(token, requested_store(args.insecure_storage)).await +} + +pub async fn set_from_argument(value: String) -> Result { + validate(&value)?; + store_token(value.into(), CredentialStore::Keyring).await +} + +pub async fn migrate(args: &AuthAccessTokenMigrateArgs) -> Result { + let _lock = acquire_config_lock().await?; + let config = load_config_file()?; + let token = config + .access_token + .as_ref() + .filter(|token| !token.is_empty()) + .cloned(); + let Some(token) = token else { + return Err(if config.stored_access_token.is_some() { + AccessTokenError::AlreadyStored + } else { + AccessTokenError::NoLegacyToken + }); + }; + + if let Some(reference) = config.stored_access_token.clone() { + // The stored reference is authoritative; only remove the stale plaintext copy. + load(&config)?; + let mut config = config; + config.access_token = None; + let config_path = save_cli_config(&config)?; + let credential_path = match reference.credential_store { + CredentialStore::Keyring => None, + CredentialStore::File => Some(credential_store::credential_file_path( + CredentialKind::AccessToken, + &reference.credential_id, + )?), + }; + return Ok(TokenChange { + config_path, + credential_store: reference.credential_store, + credential_path, + replaced: false, + cleanup_warning: None, + }); + } + + validate(&token)?; + store_with_config( + config, + token.into(), + requested_store(args.insecure_storage), + StoreIntent::Migrate, + ) +} + +pub async fn remove() -> Result { + let _lock = acquire_config_lock().await?; + let mut config = load_config_file()?; + let reference = config.stored_access_token.clone(); + let had_legacy = config + .access_token + .as_ref() + .is_some_and(|token| !token.is_empty()); + if reference.is_none() && !had_legacy && config.pending_access_token_cleanup.is_empty() { + return Ok(TokenRemoval { + config_path: None, + removed: false, + cleanup_warning: None, + }); + } + + config.stored_access_token = None; + config.access_token = None; + if config.auth_method == Some(AuthMethod::AccessToken) { + config.auth_method = config.oauth.as_ref().map(|_| AuthMethod::BrowserLogin); + } + // Deactivate durably before deletion so interruption leaves cleanup intent. + if let Some(reference) = reference.as_ref() { + queue_cleanup(&mut config, reference); + } + let config_path = save_cli_config(&config)?; + + let (cleanup_changed, mut cleanup_warning) = cleanup_pending_credentials(&mut config); + if cleanup_changed && let Err(error) = save_cli_config(&config) { + append_warning( + &mut cleanup_warning, + format!("could not update credential cleanup metadata: {error}"), + ); + } + if let Some(reference) = reference.as_ref() + && config.pending_access_token_cleanup.contains(reference) + { + return Err(AccessTokenError::RemovalIncomplete { + recovery: credential_store::credential_location( + CredentialKind::AccessToken, + &reference.credential_id, + reference.credential_store, + ), + }); + } + + Ok(TokenRemoval { + config_path: Some(config_path), + removed: reference.is_some() || had_legacy, + cleanup_warning, + }) +} + +pub fn load(config: &CliConfig) -> Result { + if let Some(reference) = config.stored_access_token.as_ref() { + let bytes = SecretBox::new(Box::new(credential_store::load( + CredentialKind::AccessToken, + &reference.credential_id, + reference.credential_store, + )?)); + return decode_stored_credential(reference, bytes.expose_secret()); + } + + let token = config + .access_token + .as_ref() + .filter(|token| !token.is_empty()) + .cloned() + .ok_or(AccessTokenError::NotConfigured)?; + validate(&token)?; + Ok(token.into()) +} + +fn decode_stored_credential( + reference: &StoredCredentialReference, + bytes: &[u8], +) -> Result { + let stored: LoadedCredential = + serde_json::from_slice(bytes).map_err(AccessTokenError::Parse)?; + if stored.version != CREDENTIAL_VERSION + || stored.kind != CREDENTIAL_KIND + || stored.credential_id != reference.credential_id + || stored.access_token.is_empty() + { + return Err(AccessTokenError::BindingMismatch); + } + validate(&stored.access_token)?; + Ok(stored.access_token.into()) +} + +fn read_from_stdin() -> Result { + let stdin = std::io::stdin(); + if stdin.is_terminal() { + return Err(AccessTokenError::StdinIsTerminal); + } + let mut bytes = Vec::new(); + stdin + .lock() + .take(MAX_STDIN_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(AccessTokenError::Stdin)?; + if bytes.len() as u64 > MAX_STDIN_BYTES { + return Err(AccessTokenError::InputTooLarge); + } + let mut token = String::from_utf8(bytes).map_err(|_| AccessTokenError::InvalidInput)?; + if token.ends_with("\r\n") { + token.truncate(token.len() - 2); + } else if token.ends_with('\n') { + token.truncate(token.len() - 1); + } + validate(&token)?; + Ok(token.into()) +} + +fn read_from_terminal() -> Result { + if !std::io::stdin().is_terminal() { + return Err(AccessTokenError::PromptUnavailable); + } + let token = rpassword::prompt_password("Enter your access token: ") + .map_err(AccessTokenError::Prompt)?; + if token.len() as u64 > MAX_STDIN_BYTES { + return Err(AccessTokenError::InputTooLarge); + } + validate(&token)?; + Ok(token.into()) +} + +pub(crate) fn validate(token: &str) -> Result<(), AccessTokenError> { + if token.is_empty() { + return Err(AccessTokenError::EmptyInput); + } + if !token.is_ascii() + || token.chars().any(char::is_whitespace) + || token.chars().any(char::is_control) + { + return Err(AccessTokenError::InvalidInput); + } + Ok(()) +} + +fn requested_store(insecure_storage: bool) -> CredentialStore { + if insecure_storage { + CredentialStore::File + } else { + CredentialStore::Keyring + } +} + +async fn store_token( + token: SecretString, + store: CredentialStore, +) -> Result { + let _lock = acquire_config_lock().await?; + let config = load_config_file()?; + store_with_config(config, token, store, StoreIntent::Set) +} + +fn store_with_config( + mut config: CliConfig, + token: SecretString, + store: CredentialStore, + intent: StoreIntent, +) -> Result { + let previous = config.stored_access_token.clone(); + let replaced = matches!(intent, StoreIntent::Set) + && (previous.is_some() + || config + .access_token + .as_ref() + .is_some_and(|token| !token.is_empty())); + let reference = credential_reference_for_write(&config, store); + let payload = StoredCredential { + version: CREDENTIAL_VERSION, + kind: CREDENTIAL_KIND, + credential_id: &reference.credential_id, + access_token: token.expose_secret(), + }; + let bytes = SecretBox::new(Box::new( + serde_json::to_vec(&payload).map_err(AccessTokenError::Serialize)?, + )); + + // Journal the new credential before writing it so a crash cannot orphan a secret. + queue_cleanup(&mut config, &reference); + save_cli_config(&config)?; + + credential_store::save( + CredentialKind::AccessToken, + &reference.credential_id, + reference.credential_store, + bytes.expose_secret(), + )?; + + config.stored_access_token = Some(reference.clone()); + config.access_token = None; + config + .pending_access_token_cleanup + .retain(|pending| pending != &reference); + if let Some(previous) = previous.as_ref() { + queue_cleanup(&mut config, previous); + } + if matches!(intent, StoreIntent::Set) { + config.auth_method = Some(AuthMethod::AccessToken); + } + let config_path = save_cli_config(&config)?; + + let (cleanup_changed, mut cleanup_warning) = cleanup_pending_credentials(&mut config); + if cleanup_changed && let Err(error) = save_cli_config(&config) { + append_warning( + &mut cleanup_warning, + format!("could not update credential cleanup metadata: {error}"), + ); + } + let credential_path = match store { + CredentialStore::Keyring => None, + CredentialStore::File => Some(credential_store::credential_file_path( + CredentialKind::AccessToken, + &reference.credential_id, + )?), + }; + + Ok(TokenChange { + config_path, + credential_store: store, + credential_path, + replaced, + cleanup_warning, + }) +} + +fn credential_reference_for_write( + config: &CliConfig, + store: CredentialStore, +) -> StoredCredentialReference { + // Reuse an inactive slot so repeated failed writes cannot grow the cleanup journal forever. + config + .pending_access_token_cleanup + .iter() + .find(|reference| reference.credential_store == store) + .cloned() + .unwrap_or_else(|| StoredCredentialReference { + credential_id: Uuid::new_v4().to_string(), + credential_store: store, + }) +} + +fn queue_cleanup(config: &mut CliConfig, reference: &StoredCredentialReference) { + if !config.pending_access_token_cleanup.contains(reference) { + config.pending_access_token_cleanup.push(reference.clone()); + } +} + +fn cleanup_pending_credentials(config: &mut CliConfig) -> (bool, Option) { + let pending = std::mem::take(&mut config.pending_access_token_cleanup); + let mut retained = Vec::new(); + let mut errors = Vec::new(); + let mut removed = false; + for reference in pending { + match credential_store::delete( + CredentialKind::AccessToken, + &reference.credential_id, + reference.credential_store, + ) { + Ok(()) => removed = true, + Err(error) => { + errors.push(format!("credential {}: {error}", reference.credential_id)); + retained.push(reference); + } + } + } + config.pending_access_token_cleanup = retained; + (removed, (!errors.is_empty()).then(|| errors.join("; "))) +} + +fn append_warning(warning: &mut Option, message: String) { + match warning { + Some(warning) => { + warning.push_str("; "); + warning.push_str(&message); + } + None => *warning = Some(message), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reference() -> StoredCredentialReference { + StoredCredentialReference { + credential_id: "credential-123".to_owned(), + credential_store: CredentialStore::File, + } + } + + #[test] + fn stored_credential_is_bound_to_kind_and_id() { + let valid = br#"{"version":1,"kind":"s2_access_token","credential_id":"credential-123","access_token":"secret"}"#; + assert_eq!( + decode_stored_credential(&reference(), valid) + .unwrap() + .expose_secret(), + "secret" + ); + + let wrong_kind = br#"{"version":1,"kind":"oauth","credential_id":"credential-123","access_token":"secret"}"#; + assert!(matches!( + decode_stored_credential(&reference(), wrong_kind), + Err(AccessTokenError::BindingMismatch) + )); + let wrong_id = br#"{"version":1,"kind":"s2_access_token","credential_id":"other","access_token":"secret"}"#; + assert!(matches!( + decode_stored_credential(&reference(), wrong_id), + Err(AccessTokenError::BindingMismatch) + )); + } + + #[test] + fn failed_write_retries_reuse_the_cleanup_entry() { + let mut config = CliConfig::default(); + let reference = credential_reference_for_write(&config, CredentialStore::Keyring); + queue_cleanup(&mut config, &reference); + + for _ in 0..3 { + let retry = credential_reference_for_write(&config, CredentialStore::Keyring); + assert_eq!(retry, reference); + queue_cleanup(&mut config, &retry); + } + + assert_eq!(config.pending_access_token_cleanup, [reference]); + } +} diff --git a/cli/src/apply.rs b/cli/src/apply.rs new file mode 100644 index 00000000..372b2d0e --- /dev/null +++ b/cli/src/apply.rs @@ -0,0 +1,672 @@ +//! Declarative basin/stream configuration via a JSON spec file. + +use std::path::Path; + +use colored::Colorize; +use miette::IntoDiagnostic; +use s2_common::{ + basin::BasinName, + config::{RetentionPolicy, StreamConfig, TimestampingMode}, + encryption::EncryptionAlgorithm, + stream::StreamName, +}; +use s2_sdk::{ + error::{ErrorCode, RequestError, StatusCode}, + types::BasinConfig, +}; + +use crate::types::resolve_stream_config; + +fn basin_config_to_sdk(config: s2_resource_spec::BasinConfig) -> s2_sdk::types::BasinConfig { + let mut sdk_config = s2_sdk::types::BasinConfig::new(); + if let Some(default_stream_config) = config.default_stream_config { + sdk_config = + sdk_config.with_default_stream_config(stream_config_to_sdk(default_stream_config)); + } + if let Some(stream_cipher) = config.stream_cipher { + sdk_config = sdk_config.with_stream_cipher(stream_cipher.into()); + } + if let Some(create_stream_on_append) = config.create_stream_on_append { + sdk_config = sdk_config.with_create_stream_on_append(create_stream_on_append); + } + if let Some(create_stream_on_read) = config.create_stream_on_read { + sdk_config = sdk_config.with_create_stream_on_read(create_stream_on_read); + } + sdk_config +} + +fn stream_config_to_sdk(config: s2_resource_spec::StreamConfig) -> s2_sdk::types::StreamConfig { + let mut sdk_config = s2_sdk::types::StreamConfig::new(); + if let Some(storage_class) = config.storage_class { + sdk_config = sdk_config.with_storage_class(storage_class); + } + if let Some(retention_policy) = config.retention_policy { + sdk_config = sdk_config.with_retention_policy(retention_policy_to_sdk(retention_policy)); + } + if let Some(timestamping) = config.timestamping { + sdk_config = sdk_config.with_timestamping(timestamping_to_sdk(timestamping)); + } + if let Some(delete_on_empty) = config.delete_on_empty.and_then(delete_on_empty_to_sdk) { + sdk_config = sdk_config.with_delete_on_empty(delete_on_empty); + } + sdk_config +} + +fn retention_policy_to_sdk( + retention_policy: s2_resource_spec::RetentionPolicy, +) -> s2_sdk::types::RetentionPolicy { + match retention_policy.0 { + RetentionPolicy::Age(duration) => s2_sdk::types::RetentionPolicy::Age(duration.as_secs()), + RetentionPolicy::Infinite() => s2_sdk::types::RetentionPolicy::Infinite, + } +} + +fn timestamping_to_sdk( + timestamping: s2_resource_spec::Timestamping, +) -> s2_sdk::types::TimestampingConfig { + let mut sdk_config = s2_sdk::types::TimestampingConfig::new(); + if let Some(mode) = timestamping.mode { + sdk_config = sdk_config.with_mode(timestamping_mode_to_sdk(mode)); + } + if let Some(uncapped) = timestamping.uncapped { + sdk_config = sdk_config.with_uncapped(uncapped); + } + sdk_config +} + +fn timestamping_mode_to_sdk( + mode: s2_resource_spec::TimestampingMode, +) -> s2_sdk::types::TimestampingMode { + match mode { + s2_resource_spec::TimestampingMode::ClientPrefer => { + s2_sdk::types::TimestampingMode::ClientPrefer + } + s2_resource_spec::TimestampingMode::ClientRequire => { + s2_sdk::types::TimestampingMode::ClientRequire + } + s2_resource_spec::TimestampingMode::Arrival => s2_sdk::types::TimestampingMode::Arrival, + } +} + +fn delete_on_empty_to_sdk( + delete_on_empty: s2_resource_spec::DeleteOnEmpty, +) -> Option { + delete_on_empty + .min_age + .map(|min_age| s2_sdk::types::DeleteOnEmptyConfig::new().with_min_age(min_age.0)) +} + +fn format_encryption_algorithm(algorithm: EncryptionAlgorithm) -> &'static str { + match algorithm { + EncryptionAlgorithm::Aegis256 => "aegis-256", + EncryptionAlgorithm::Aes256Gcm => "aes-256-gcm", + } +} + +pub fn validate(spec: &s2_resource_spec::Resources) -> miette::Result<()> { + s2_resource_spec::validate(spec).map_err(|e| miette::miette!("{}", e)) +} + +pub fn load(path: &Path) -> miette::Result { + let contents = std::fs::read_to_string(path) + .map_err(|e| miette::miette!("failed to read spec file {:?}: {}", path.display(), e))?; + let spec: s2_resource_spec::Resources = serde_json::from_str(&contents) + .map_err(|e| miette::miette!("failed to parse spec file {:?}: {}", path.display(), e))?; + Ok(spec) +} + +pub async fn apply(s2: &s2_sdk::S2, spec: s2_resource_spec::Resources) -> miette::Result<()> { + validate(&spec)?; + + for basin_spec in spec.basins { + apply_basin(s2, basin_spec.name.clone(), basin_spec.config).await?; + + for stream_spec in basin_spec.streams { + apply_stream( + s2, + basin_spec.name.clone(), + stream_spec.name, + stream_spec.config, + ) + .await?; + } + } + Ok(()) +} + +async fn apply_basin( + s2: &s2_sdk::S2, + basin: BasinName, + config: Option, +) -> miette::Result<()> { + let mut input = s2_sdk::types::EnsureBasinInput::new(basin.clone()); + if let Some(c) = config { + input = input.with_config(basin_config_to_sdk(c)); + } + match s2 + .ensure_basin(input) + .await + .map_err(|e| miette::miette!("failed to apply basin {:?}: {}", basin.as_ref(), e))? + { + s2_sdk::types::EnsureOutput::Created(_) => { + eprintln!("{}", format!(" basin {basin} (created)").green().bold()); + } + s2_sdk::types::EnsureOutput::ConfigUpdated(_) => { + eprintln!( + "{}", + format!(" basin {basin} (config updated)").yellow().bold() + ); + } + s2_sdk::types::EnsureOutput::ConfigUnchanged(_) => { + eprintln!("{}", format!(" basin {basin} (config unchanged)").dimmed()); + } + } + Ok(()) +} + +async fn apply_stream( + s2: &s2_sdk::S2, + basin: BasinName, + stream: StreamName, + config: Option, +) -> miette::Result<()> { + let basin_client = s2.basin(basin.clone()); + + let mut input = s2_sdk::types::EnsureStreamInput::new(stream.clone()); + if let Some(c) = config { + input = input.with_config(stream_config_to_sdk(c)); + } + match basin_client.ensure_stream(input).await.map_err(|e| { + miette::miette!( + "failed to apply stream {:?}/{:?}: {}", + basin.as_ref(), + stream.as_ref(), + e + ) + })? { + s2_sdk::types::EnsureOutput::Created(_) => { + eprintln!( + "{}", + format!(" stream {basin}/{stream} (created)") + .green() + .bold() + ); + } + s2_sdk::types::EnsureOutput::ConfigUpdated(_) => { + eprintln!( + "{}", + format!(" stream {basin}/{stream} (config updated)") + .yellow() + .bold() + ); + } + s2_sdk::types::EnsureOutput::ConfigUnchanged(_) => { + eprintln!( + "{}", + format!(" stream {basin}/{stream} (config unchanged)").dimmed() + ); + } + } + Ok(()) +} + +enum ResourceAction { + Create, + Ensure { + diffs: Vec, + storage_class_unresolved: bool, + }, + Unchanged, +} + +impl ResourceAction { + fn from_diff(diffs: Vec, storage_class_unresolved: bool) -> Self { + if diffs.is_empty() && !storage_class_unresolved { + Self::Unchanged + } else { + Self::Ensure { + diffs, + storage_class_unresolved, + } + } + } +} + +struct FieldDiff { + field: &'static str, + old: String, + new: String, +} + +fn is_not_found_error(error: &RequestError) -> bool { + error + .server_error() + .and_then(|error| error.known_code()) + .is_some_and(|code| matches!(code, ErrorCode::BasinNotFound | ErrorCode::StreamNotFound)) +} + +fn format_retention_policy(rp: RetentionPolicy) -> String { + match rp { + RetentionPolicy::Age(age) => humantime::format_duration(age).to_string(), + RetentionPolicy::Infinite() => "infinite".to_string(), + } +} + +fn format_timestamping_mode(m: TimestampingMode) -> &'static str { + match m { + TimestampingMode::ClientPrefer => "client-prefer", + TimestampingMode::ClientRequire => "client-require", + TimestampingMode::Arrival => "arrival", + } +} + +fn default_stream_config_field(field: &'static str) -> &'static str { + match field { + "storage_class" => "default_stream_config.storage_class", + "retention_policy" => "default_stream_config.retention_policy", + "timestamping.mode" => "default_stream_config.timestamping.mode", + "timestamping.uncapped" => "default_stream_config.timestamping.uncapped", + "delete_on_empty.min_age" => "default_stream_config.delete_on_empty.min_age", + _ => field, + } +} + +fn diff_basin_config( + existing: &BasinConfig, + desired: &BasinConfig, +) -> miette::Result> { + let mut diffs = Vec::new(); + + if existing.stream_cipher != desired.stream_cipher { + diffs.push(FieldDiff { + field: "stream_cipher", + old: existing + .stream_cipher + .map(format_encryption_algorithm) + .unwrap_or("none") + .to_string(), + new: desired + .stream_cipher + .map(format_encryption_algorithm) + .unwrap_or("none") + .to_string(), + }); + } + + if existing.create_stream_on_append != desired.create_stream_on_append { + diffs.push(FieldDiff { + field: "create_stream_on_append", + old: existing.create_stream_on_append.to_string(), + new: desired.create_stream_on_append.to_string(), + }); + } + + if existing.create_stream_on_read != desired.create_stream_on_read { + diffs.push(FieldDiff { + field: "create_stream_on_read", + old: existing.create_stream_on_read.to_string(), + new: desired.create_stream_on_read.to_string(), + }); + } + + let existing_dsc = resolve_stream_config( + existing + .default_stream_config + .clone() + .unwrap_or_default() + .into(), + Default::default(), + ) + .into_diagnostic()?; + let desired_dsc = resolve_stream_config( + desired + .default_stream_config + .clone() + .unwrap_or_default() + .into(), + Default::default(), + ) + .into_diagnostic()?; + for sd in diff_stream_configs(&existing_dsc, &desired_dsc) { + diffs.push(FieldDiff { + field: default_stream_config_field(sd.field), + old: sd.old, + new: sd.new, + }); + } + + Ok(diffs) +} + +fn diff_stream_configs(existing: &StreamConfig, desired: &StreamConfig) -> Vec { + let mut diffs = Vec::new(); + + if let Some(storage_class) = &desired.storage_class + && existing.storage_class.as_ref() != Some(storage_class) + { + diffs.push(FieldDiff { + field: "storage_class", + old: existing + .storage_class + .as_deref() + .unwrap_or("unspecified") + .to_owned(), + new: storage_class.to_string(), + }); + } + + if existing.retention_policy != desired.retention_policy { + diffs.push(FieldDiff { + field: "retention_policy", + old: format_retention_policy(existing.retention_policy), + new: format_retention_policy(desired.retention_policy), + }); + } + + if existing.timestamping.mode != desired.timestamping.mode { + diffs.push(FieldDiff { + field: "timestamping.mode", + old: format_timestamping_mode(existing.timestamping.mode).to_string(), + new: format_timestamping_mode(desired.timestamping.mode).to_string(), + }); + } + + if existing.timestamping.uncapped != desired.timestamping.uncapped { + diffs.push(FieldDiff { + field: "timestamping.uncapped", + old: existing.timestamping.uncapped.to_string(), + new: desired.timestamping.uncapped.to_string(), + }); + } + + if existing.delete_on_empty.min_age != desired.delete_on_empty.min_age { + diffs.push(FieldDiff { + field: "delete_on_empty.min_age", + old: humantime::format_duration(existing.delete_on_empty.min_age).to_string(), + new: humantime::format_duration(desired.delete_on_empty.min_age).to_string(), + }); + } + + diffs +} + +fn spec_basin_fields(spec: &s2_resource_spec::BasinConfig) -> Vec { + let mut fields = Vec::new(); + + if let Some(algorithm) = spec.stream_cipher.clone().map(EncryptionAlgorithm::from) { + fields.push(FieldDiff { + field: "stream_cipher", + old: String::new(), + new: format_encryption_algorithm(algorithm).to_string(), + }); + } + if let Some(v) = spec.create_stream_on_append { + fields.push(FieldDiff { + field: "create_stream_on_append", + old: String::new(), + new: v.to_string(), + }); + } + if let Some(v) = spec.create_stream_on_read { + fields.push(FieldDiff { + field: "create_stream_on_read", + old: String::new(), + new: v.to_string(), + }); + } + if let Some(ref dsc) = spec.default_stream_config { + for f in spec_stream_fields(dsc) { + fields.push(FieldDiff { + field: default_stream_config_field(f.field), + old: f.old, + new: f.new, + }); + } + } + + fields +} + +fn spec_stream_fields(spec: &s2_resource_spec::StreamConfig) -> Vec { + let mut fields = Vec::new(); + + if let Some(ref sc) = spec.storage_class { + fields.push(FieldDiff { + field: "storage_class", + old: String::new(), + new: sc.to_string(), + }); + } + if let Some(ref rp) = spec.retention_policy { + fields.push(FieldDiff { + field: "retention_policy", + old: String::new(), + new: format_retention_policy(rp.0), + }); + } + if let Some(ref ts) = spec.timestamping { + if let Some(ref mode) = ts.mode { + fields.push(FieldDiff { + field: "timestamping.mode", + old: String::new(), + new: format_timestamping_mode(mode.clone().into()).to_string(), + }); + } + if let Some(uncapped) = ts.uncapped { + fields.push(FieldDiff { + field: "timestamping.uncapped", + old: String::new(), + new: uncapped.to_string(), + }); + } + } + if let Some(ref doe) = spec.delete_on_empty + && let Some(ref min_age) = doe.min_age + { + fields.push(FieldDiff { + field: "delete_on_empty.min_age", + old: String::new(), + new: humantime::format_duration(min_age.0).to_string(), + }); + } + + fields +} + +fn print_basin_result(basin: &str, action: &ResourceAction) { + match action { + ResourceAction::Create => { + println!("{}", format!("+ basin {basin}").green().bold()); + } + ResourceAction::Ensure { + diffs, + storage_class_unresolved, + } => { + let marker = if diffs.is_empty() { "?" } else { "~" }; + println!("{}", format!("{marker} basin {basin}").yellow().bold()); + for diff in diffs { + println!(" {}: {} → {}", diff.field, diff.old.dimmed(), diff.new); + } + if *storage_class_unresolved { + println!(" default_stream_config.storage_class: resolved at apply"); + } + } + ResourceAction::Unchanged => { + println!("{}", format!("= basin {basin}").dimmed()); + } + } +} + +fn print_stream_result(basin: &str, stream: &str, action: &ResourceAction) { + match action { + ResourceAction::Create => { + println!("{}", format!(" + stream {basin}/{stream}").green().bold()); + } + ResourceAction::Ensure { + diffs, + storage_class_unresolved, + } => { + let marker = if diffs.is_empty() { "?" } else { "~" }; + println!( + "{}", + format!(" {marker} stream {basin}/{stream}") + .yellow() + .bold() + ); + for diff in diffs { + println!(" {}: {} → {}", diff.field, diff.old.dimmed(), diff.new); + } + if *storage_class_unresolved { + println!(" storage_class: resolved at apply"); + } + } + ResourceAction::Unchanged => { + println!("{}", format!(" = stream {basin}/{stream}").dimmed()); + } + } +} + +fn print_basin_create(basin: &str, spec: &Option) { + println!("{}", format!("+ basin {basin}").green().bold()); + if let Some(config) = spec { + for field in spec_basin_fields(config) { + println!(" {}: {}", field.field, field.new); + } + } +} + +fn print_stream_create(basin: &str, stream: &str, spec: &Option) { + println!("{}", format!(" + stream {basin}/{stream}").green().bold()); + if let Some(config) = spec { + for field in spec_stream_fields(config) { + println!(" {}: {}", field.field, field.new); + } + } +} + +pub async fn dry_run(s2: &s2_sdk::S2, spec: s2_resource_spec::Resources) -> miette::Result<()> { + validate(&spec)?; + + let needs_location_default = spec.basins.iter().any(|basin| { + basin + .config + .as_ref() + .and_then(|config| config.default_stream_config.as_ref()) + .and_then(|config| config.storage_class.as_ref()) + .is_none() + }); + let default_storage_class = if needs_location_default { + match s2.get_default_location().await { + Ok(location) => location.default_storage_class, + Err(error) + if error.server_error().is_some_and(|error| { + matches!( + error.known_code(), + Some(ErrorCode::NotImplemented | ErrorCode::PermissionDenied) + ) || error.status == StatusCode::NOT_FOUND + }) => + { + None + } + Err(error) => { + return Err(miette::miette!( + "failed to get default storage class: {error}" + )); + } + } + } else { + None + }; + + for basin_spec in spec.basins { + let mut desired_basin_config = basin_spec + .config + .clone() + .map(basin_config_to_sdk) + .unwrap_or_default(); + let desired_basin_defaults = desired_basin_config + .default_stream_config + .get_or_insert_default(); + if desired_basin_defaults.storage_class.is_none() { + desired_basin_defaults.storage_class = default_storage_class.clone(); + } + let desired_basin_defaults = desired_basin_defaults.clone(); + + let basin_action = match s2.get_basin_config(basin_spec.name.clone()).await { + Ok(existing) => { + let diffs = diff_basin_config(&existing, &desired_basin_config)?; + ResourceAction::from_diff(diffs, desired_basin_defaults.storage_class.is_none()) + } + Err(e) if is_not_found_error(&e) => ResourceAction::Create, + Err(e) => { + return Err(miette::miette!( + "failed to check basin {:?}: {}", + basin_spec.name.as_ref(), + e + )); + } + }; + + match &basin_action { + ResourceAction::Create => { + print_basin_create(basin_spec.name.as_ref(), &basin_spec.config); + } + action => { + print_basin_result(basin_spec.name.as_ref(), action); + } + } + + let basin_client = s2.basin(basin_spec.name.clone()); + + for stream_spec in basin_spec.streams { + let stream_action = match basin_client + .get_stream_config(stream_spec.name.clone()) + .await + { + Ok(existing) => { + let existing = resolve_stream_config(existing.into(), Default::default()) + .into_diagnostic()?; + let desired_stream_config = resolve_stream_config( + stream_spec + .config + .clone() + .map(stream_config_to_sdk) + .unwrap_or_default() + .into(), + desired_basin_defaults.clone().into(), + ) + .into_diagnostic()?; + let diffs = diff_stream_configs(&existing, &desired_stream_config); + ResourceAction::from_diff(diffs, desired_stream_config.storage_class.is_none()) + } + Err(e) if is_not_found_error(&e) => ResourceAction::Create, + Err(e) => { + return Err(miette::miette!( + "failed to check stream {:?}/{:?}: {}", + basin_spec.name.as_ref(), + stream_spec.name.as_ref(), + e + )); + } + }; + + match &stream_action { + ResourceAction::Create => { + print_stream_create( + basin_spec.name.as_ref(), + stream_spec.name.as_ref(), + &stream_spec.config, + ); + } + action => { + print_stream_result( + basin_spec.name.as_ref(), + stream_spec.name.as_ref(), + action, + ); + } + } + } + } + Ok(()) +} diff --git a/cli/src/auth.rs b/cli/src/auth.rs new file mode 100644 index 00000000..f1e2cc0b --- /dev/null +++ b/cli/src/auth.rs @@ -0,0 +1,254 @@ +use std::process::ExitCode; + +use colored::Colorize as _; +use s2_sdk::{S2, error::ErrorCode, types::ListBasinsInput}; + +use crate::{ + config::{CliConfig, CredentialStore, DEFAULT_ACCOUNT_ENDPOINT, sdk_config}, + error::{CliConfigError, CliError, TokenSource}, + login::{LoginError, effective_endpoints, resolve_access_token, uses_loopback_endpoints}, + update, +}; + +pub async fn status() -> Result { + let (config, credential) = match resolve_access_token().await { + Ok(resolved) => resolved, + Err(LoginError::Config(CliConfigError::MissingAccessToken)) => { + let config = crate::config::load_cli_config()?; + print_heading(&config); + eprintln!(" {}", "✗ Not logged in".red().bold()); + eprintln!(" - Run `s2 login`."); + print_configured_credentials(&config, None); + return Ok(ExitCode::FAILURE); + } + Err(error) => { + let config = crate::config::load_cli_config()?; + print_heading(&config); + print_unverified(&configured_credential_label(&config), &error); + print_configured_credentials(&config, configured_source(&config)); + return Ok(ExitCode::FAILURE); + } + }; + + print_heading(&config); + let label = credential_label(&config, credential.source()); + if let Err(error) = credential.validate_destination(&config) { + print_unverified(&label, &error); + print_configured_credentials(&config, Some(credential.source())); + return Ok(ExitCode::FAILURE); + } + + let sdk = match sdk_config(&config, credential.access_token(), update::user_agent()) { + Ok(sdk) => credential.configure_sdk(sdk), + Err(error) => { + print_unverified(&label, &error); + print_configured_credentials(&config, Some(credential.source())); + return Ok(ExitCode::FAILURE); + } + }; + let s2 = match S2::new(sdk) { + Ok(s2) => s2, + Err(error) => { + print_unverified(&label, &error); + print_configured_credentials(&config, Some(credential.source())); + return Ok(ExitCode::FAILURE); + } + }; + let result = s2.list_basins(ListBasinsInput::new().with_limit(1)).await; + + let exit_code = match result { + Ok(_) => { + if uses_loopback_endpoints(&config) { + print_local_connection(); + } else { + print_authenticated(&label); + } + ExitCode::SUCCESS + } + Err(error) + if error.server_error().and_then(|error| error.known_code()) + == Some(ErrorCode::PermissionDenied) => + { + // The server authenticated the credential before denying this probe. + print_authenticated(&label); + ExitCode::SUCCESS + } + Err(error) + if matches!( + error.server_error().and_then(|error| error.known_code()), + Some(ErrorCode::Authn | ErrorCode::AccessTokenNotFound) + ) => + { + eprintln!( + " {}", + format!("✗ Authentication failed for {label}").red().bold() + ); + eprintln!(" - {}", recovery_command(credential.source())); + ExitCode::FAILURE + } + Err(error) => { + print_unverified(&label, &error); + ExitCode::FAILURE + } + }; + + print_configured_credentials(&config, Some(credential.source())); + Ok(exit_code) +} + +fn print_authenticated(label: &str) { + eprintln!(" {}", format!("✓ Logged in with {label}").green().bold()); +} + +fn print_local_connection() { + eprintln!(" {}", "✓ Connected to local S2 endpoint".green().bold()); + eprintln!(" - Credentials are not verified for local endpoints."); +} + +fn print_unverified(label: &str, error: &impl std::fmt::Display) { + eprintln!( + " {}", + format!("! Could not verify {label}").yellow().bold() + ); + eprintln!(" - {error}"); +} + +fn print_heading(config: &CliConfig) { + let (account_endpoint, _) = effective_endpoints(config); + let heading = if account_endpoint == DEFAULT_ACCOUNT_ENDPOINT { + "s2.dev".to_owned() + } else { + reqwest::Url::parse(&account_endpoint) + .ok() + .and_then(|url| { + let host = url.host_str()?; + Some(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_owned(), + }) + }) + .unwrap_or_else(|| account_endpoint.clone()) + }; + eprintln!("{}", heading.bold()); +} + +fn configured_credential_label(config: &CliConfig) -> String { + configured_source(config).map_or_else( + || "authentication".to_owned(), + |source| credential_label(config, source), + ) +} + +fn configured_source(config: &CliConfig) -> Option { + if std::env::var_os("S2_ACCESS_TOKEN").is_some_and(|value| !value.is_empty()) { + return Some(TokenSource::Environment); + } + match config.auth_method { + Some(crate::config::AuthMethod::BrowserLogin) if config.oauth.is_some() => { + Some(TokenSource::BrowserLogin) + } + Some(crate::config::AuthMethod::AccessToken) if config.has_stored_access_token() => { + Some(stored_access_token_source(config)) + } + Some(_) => None, + None if config.has_stored_access_token() => Some(stored_access_token_source(config)), + None if config.oauth.is_some() => Some(TokenSource::BrowserLogin), + None => None, + } +} + +fn stored_access_token_source(config: &CliConfig) -> TokenSource { + if config.stored_access_token.is_some() { + TokenSource::StoredAccessToken + } else { + TokenSource::ConfigFile + } +} + +fn credential_label(config: &CliConfig, source: TokenSource) -> String { + match source { + TokenSource::Environment => "S2_ACCESS_TOKEN".to_owned(), + TokenSource::BrowserLogin => browser_login_label(config), + TokenSource::StoredAccessToken | TokenSource::ConfigFile => access_token_label(config), + } +} + +fn browser_login_label(config: &CliConfig) -> String { + let storage = config + .oauth + .as_ref() + .map(|session| storage_label(session.credential_store)); + storage.map_or_else( + || "browser login".to_owned(), + |storage| format!("browser login ({storage})"), + ) +} + +fn access_token_label(config: &CliConfig) -> String { + if let Some(reference) = config.stored_access_token.as_ref() { + format!( + "access token ({})", + storage_label(reference.credential_store) + ) + } else if config.has_legacy_access_token() { + "access token (config.toml)".to_owned() + } else { + "access token".to_owned() + } +} + +fn storage_label(store: CredentialStore) -> &'static str { + match store { + CredentialStore::Keyring => "keyring", + CredentialStore::File => "private file", + } +} + +fn print_configured_credentials(config: &CliConfig, active: Option) { + let browser_active = matches!(active, Some(TokenSource::BrowserLogin)); + let access_token_active = matches!( + active, + Some(TokenSource::StoredAccessToken | TokenSource::ConfigFile) + ); + + if !browser_active && config.oauth.is_some() { + eprintln!(" - Also configured: {}", browser_login_label(config)); + } + + if !access_token_active && config.has_stored_access_token() { + eprintln!(" - Also configured: {}", access_token_label(config)); + } + + if config.has_legacy_access_token() { + eprintln!( + "{}", + " ! The access token is stored in plaintext in config.toml.\n Run `s2 auth access-token migrate` to secure it." + .yellow() + ); + } + + let has_file_storage = config + .oauth + .as_ref() + .is_some_and(|session| session.credential_store == CredentialStore::File) + || config + .stored_access_token + .as_ref() + .is_some_and(|reference| reference.credential_store == CredentialStore::File); + if has_file_storage { + eprintln!( + "{}", + " ! Credentials are stored in a private plaintext file.".yellow() + ); + } +} + +fn recovery_command(source: TokenSource) -> &'static str { + match source { + TokenSource::BrowserLogin => "Run `s2 login` again.", + TokenSource::Environment => "Set S2_ACCESS_TOKEN to a valid access token.", + TokenSource::StoredAccessToken | TokenSource::ConfigFile => { + "Run `s2 auth access-token set` to replace it." + } + } +} diff --git a/cli/src/bench.rs b/cli/src/bench.rs new file mode 100644 index 00000000..7acc0eba --- /dev/null +++ b/cli/src/bench.rs @@ -0,0 +1,1135 @@ +use std::{ + collections::BTreeMap, + future::Future, + num::NonZeroU64, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::Duration, +}; + +use bytes::Bytes; +use colored::Colorize; +use futures::{Stream, StreamExt, stream::FuturesUnordered}; +use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; +use rand::{Rng, SeedableRng}; +use s2_sdk::{ + S2Stream, + error::ProducerError, + producer::{IndexedAppendAck, ProducerConfig}, + types::{ + AppendRecord, Header, MeteredBytes as _, RECORD_BATCH_MAX, ReadFrom, ReadInput, + ReadSessionConfig, ReadStart, ReadStop, SequencedRecord, + }, +}; +use tokio::{ + sync::mpsc, + time::{Instant, MissedTickBehavior}, +}; +use xxhash_rust::xxh3::Xxh3Default; + +use crate::{ + error::{CliError, OpKind}, + types::LatencyStats, +}; + +const HASH_HEADER_NAME: &[u8] = b"hash"; +const HEADER_VALUE_LEN: usize = 8; +const RECORD_OVERHEAD_BYTES: usize = 8 + 2 + HASH_HEADER_NAME.len() + HEADER_VALUE_LEN; +const WRITE_DONE_SENTINEL: u64 = u64::MAX; +const LIVE_UI_REFRESH_HZ: u8 = 20; +const LIVE_UI_REFRESH_MS: u64 = 1000 / LIVE_UI_REFRESH_HZ as u64; +const LATENCY_TABLE_COLUMN_WIDTH: usize = 44; +const LATENCY_TABLE_GAP: &str = " "; +const LATENCY_TABLE_SIDE_BY_SIDE_WIDTH: usize = + LATENCY_TABLE_COLUMN_WIDTH * 2 + LATENCY_TABLE_GAP.len(); + +type PendingAck = + Pin)> + Send>>; + +pub struct BenchWriteSample { + pub bytes: u64, + pub records: u64, + pub elapsed: Duration, + pub ack_latencies: Vec, + pub chain_hash: Option, +} + +pub struct BenchReadSample { + pub bytes: u64, + pub records: u64, + pub elapsed: Duration, + pub e2e_latencies: Vec, + pub chain_hash: Option, +} + +trait BenchSample { + fn bytes(&self) -> u64; + fn records(&self) -> u64; + fn elapsed(&self) -> Duration; + + fn mib_per_sec(&self) -> f64 { + let mib = self.bytes() as f64 / (1024.0 * 1024.0); + let secs = self.elapsed().as_secs_f64(); + if secs > 0.0 { mib / secs } else { 0.0 } + } + + fn records_per_sec(&self) -> f64 { + let secs = self.elapsed().as_secs_f64(); + if secs > 0.0 { + self.records() as f64 / secs + } else { + 0.0 + } + } +} + +impl BenchSample for BenchWriteSample { + fn bytes(&self) -> u64 { + self.bytes + } + fn records(&self) -> u64 { + self.records + } + fn elapsed(&self) -> Duration { + self.elapsed + } +} + +impl BenchSample for BenchReadSample { + fn bytes(&self) -> u64 { + self.bytes + } + fn records(&self) -> u64 { + self.records + } + fn elapsed(&self) -> Duration { + self.elapsed + } +} + +#[derive(Debug, Default)] +pub struct StreamingLatencyStats { + count: u64, + samples: BTreeMap, +} + +#[derive(Debug, Clone)] +pub struct LiveLatencySnapshot { + pub count: u64, + pub stats: LatencyStats, +} + +impl StreamingLatencyStats { + pub fn extend(&mut self, samples: impl IntoIterator) { + for sample in samples { + self.record(sample); + } + } + + fn record(&mut self, sample: Duration) { + self.count += 1; + *self.samples.entry(duration_nanos(sample)).or_default() += 1; + } + + pub fn snapshot(&self) -> Option { + if self.count == 0 { + return None; + } + + let min = Duration::from_nanos(*self.samples.keys().next()?); + let max = Duration::from_nanos(*self.samples.keys().next_back()?); + let p50_rank = Self::percentile_rank(self.count, 0.50); + let p90_rank = Self::percentile_rank(self.count, 0.90); + let p99_rank = Self::percentile_rank(self.count, 0.99); + + let mut seen = 0; + let mut p50 = None; + let mut p90 = None; + let mut p99 = None; + + for (nanos, sample_count) in &self.samples { + seen += *sample_count; + if p50.is_none() && seen >= p50_rank { + p50 = Some(Duration::from_nanos(*nanos)); + } + if p90.is_none() && seen >= p90_rank { + p90 = Some(Duration::from_nanos(*nanos)); + } + if p99.is_none() && seen >= p99_rank { + p99 = Some(Duration::from_nanos(*nanos)); + break; + } + } + + Some(LiveLatencySnapshot { + count: self.count, + stats: LatencyStats { + min, + p50: p50.unwrap_or(max), + p90: p90.unwrap_or(max), + p99: p99.unwrap_or(max), + max, + }, + }) + } + + fn percentile_rank(count: u64, percentile: f64) -> u64 { + ((count as f64) * percentile).ceil().max(1.0) as u64 + } +} + +fn duration_nanos(duration: Duration) -> u64 { + u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) +} + +fn format_latency_duration(duration: Duration) -> String { + let nanos = duration_nanos(duration); + if nanos < 1_000 { + format!("{nanos}ns") + } else if nanos < 1_000_000 { + format!("{:.1}us", nanos as f64 / 1_000.0) + } else if nanos < 1_000_000_000 { + format!("{:.2}ms", nanos as f64 / 1_000_000.0) + } else { + format!("{:.2}s", duration.as_secs_f64()) + } +} + +fn format_latency_stat_row(key: &str, value: Option, colored: bool) -> String { + match value { + Some(value) => { + let formatted = format_latency_duration(value); + if colored { + format!("{key:7}: {:>9}", formatted.green().bold()) + } else { + format!("{key:7}: {:>9}", formatted) + } + } + None => format!("{key:7}: {:>9}", "-"), + } +} + +#[derive(Debug, Clone, Copy)] +enum LatencyTableLayout { + SideBySide, + Stacked, +} + +fn latency_table_layout() -> LatencyTableLayout { + let width = terminal_size::terminal_size() + .map(|(terminal_size::Width(width), _)| width as usize) + .unwrap_or(usize::MAX); + + if width >= LATENCY_TABLE_SIDE_BY_SIDE_WIDTH { + LatencyTableLayout::SideBySide + } else { + LatencyTableLayout::Stacked + } +} + +fn latency_header(title: &str, snapshot: Option<&LiveLatencySnapshot>) -> String { + format!( + "{title} (n={})", + snapshot.map_or(0, |snapshot| snapshot.count) + ) +} + +fn latency_table_rows(snapshot: Option<&LiveLatencySnapshot>, colored: bool) -> Vec { + let stats = snapshot.map(|snapshot| &snapshot.stats); + + vec![ + format_latency_stat_row("min", stats.map(|stats| stats.min), colored), + format_latency_stat_row("p50", stats.map(|stats| stats.p50), colored), + format_latency_stat_row("p90", stats.map(|stats| stats.p90), colored), + format_latency_stat_row("p99", stats.map(|stats| stats.p99), colored), + format_latency_stat_row("max", stats.map(|stats| stats.max), colored), + ] +} + +fn format_latency_header(line: String) -> String { + line.yellow().bold().to_string() +} + +fn format_latency_header_cell(line: String) -> String { + format!("{line: String { + format!("{line:, + e2e: Option<&LiveLatencySnapshot>, + layout: LatencyTableLayout, + colored: bool, +) -> Vec { + // Side-by-side layout uses fixed-width padding that ANSI codes would break. + let color_values = colored && matches!(layout, LatencyTableLayout::Stacked); + let ack_header = latency_header("Ack Latency Statistics", ack); + let e2e_header = latency_header("End-to-End Latency Statistics", e2e); + let ack_rows = latency_table_rows(ack, color_values); + let e2e_rows = latency_table_rows(e2e, color_values); + + match layout { + LatencyTableLayout::SideBySide => { + let mut lines = Vec::with_capacity(6); + lines.push(format!( + "{}{LATENCY_TABLE_GAP}{}", + format_latency_header_cell(ack_header), + format_latency_header(e2e_header) + )); + + for (ack_row, e2e_row) in ack_rows.into_iter().zip(e2e_rows) { + lines.push(format!( + "{}{LATENCY_TABLE_GAP}{}", + format_latency_plain_cell(ack_row), + e2e_row + )); + } + + lines + } + LatencyTableLayout::Stacked => { + let mut lines = Vec::with_capacity(13); + lines.push(format_latency_header(ack_header)); + lines.extend(ack_rows); + lines.push(String::new()); + lines.push(format_latency_header(e2e_header)); + lines.extend(e2e_rows); + lines + } + } +} + +struct LiveLatencyTables { + layout: LatencyTableLayout, + lines: Vec, +} + +impl LiveLatencyTables { + fn new(multi: &MultiProgress, layout: LatencyTableLayout) -> Self { + let line_count = match layout { + LatencyTableLayout::SideBySide => 6, + LatencyTableLayout::Stacked => 13, + }; + let lines = (0..line_count) + .map(|_| { + multi.add( + ProgressBar::no_length().with_style( + ProgressStyle::default_bar() + .template("{msg}") + .expect("valid template"), + ), + ) + }) + .collect(); + + let tables = Self { layout, lines }; + tables.update(None, None); + tables + } + + fn update(&self, ack: Option<&LiveLatencySnapshot>, e2e: Option<&LiveLatencySnapshot>) { + for (bar, line) in + self.lines + .iter() + .zip(format_latency_tables(ack, e2e, self.layout, false)) + { + bar.set_message(line); + } + } + + fn finish_and_clear(&self) { + for line in &self.lines { + line.finish_and_clear(); + } + } +} + +fn body_size(record_size: usize) -> usize { + record_size.saturating_sub(RECORD_OVERHEAD_BYTES) +} + +fn record_body(record_size: usize, rng: &mut rand::rngs::StdRng) -> Bytes { + let mut body = vec![0u8; body_size(record_size)]; + rng.fill_bytes(&mut body); + Bytes::from(body) +} + +fn new_record(body: Bytes, timestamp: u64, hash: u64) -> AppendRecord { + AppendRecord::new(body) + .and_then(|record| { + record.with_headers([Header::new(HASH_HEADER_NAME, hash.to_be_bytes().to_vec())]) + }) + .expect("valid") + .with_timestamp(timestamp) +} + +fn record_hash(record: &SequencedRecord) -> Result { + let header = record + .headers + .iter() + .find(|h| h.name.as_ref() == HASH_HEADER_NAME) + .ok_or_else(|| "missing bench hash header".to_string())?; + let value = header.value.as_ref(); + if value.len() != HEADER_VALUE_LEN { + return Err(format!("invalid bench hash header length: {}", value.len())); + } + Ok(u64::from_be_bytes( + value.try_into().expect("length checked"), + )) +} + +fn chain_hash(prev_hash: u64, body: &[u8]) -> u64 { + let mut hasher = Xxh3Default::new(); + hasher.update(&prev_hash.to_be_bytes()); + hasher.update(body); + hasher.digest() +} + +pub fn bench_write( + stream: S2Stream, + record_size: usize, + target_mibps: NonZeroU64, + stop: Arc, + write_done_records: Arc, + bench_start: Instant, +) -> impl Stream> + Send { + let metered_size = + new_record(Bytes::from(vec![0u8; body_size(record_size)]), 0, 0).metered_bytes(); + assert_eq!(metered_size, record_size); + + let producer = stream.producer(ProducerConfig::default()); + + async_stream::stream! { + let target_bps = target_mibps.get() as f64 * 1024.0 * 1024.0; + + let mut total_bytes: u64 = 0; + let mut total_records: u64 = 0; + let throughput_start = Instant::now(); + let mut last_yield = Instant::now(); + let mut rng = rand::rngs::StdRng::seed_from_u64(0); + let mut prev_hash: u64 = 0; + let mut next_seq_num: u64 = 0; + + let mut pending_acks: FuturesUnordered = FuturesUnordered::new(); + let mut ack_latencies: Vec = Vec::new(); + + // Rate limiting state (time-based) + let mut bytes_submitted: usize = 0; + + let stopping = || stop.load(Ordering::Relaxed); + + loop { + if stopping() && pending_acks.is_empty() { + break; + } + + // Rate limiting: calculate delay needed based on bytes submitted vs time elapsed + let throttle_delay = { + if bytes_submitted == 0 { + None + } else { + let expected_elapsed = Duration::from_secs_f64(bytes_submitted as f64 / target_bps); + let actual_elapsed = throughput_start.elapsed(); + if expected_elapsed > actual_elapsed { + Some(expected_elapsed - actual_elapsed) + } else { + None + } + } + }; + + tokio::select! { + biased; + + Some((submit_time, res)) = pending_acks.next() => { + match res { + Ok(ack) => { + let latency = submit_time.elapsed(); + ack_latencies.push(latency); + total_bytes += record_size as u64; + total_records += 1; + next_seq_num = ack.seq_num + 1; + + if last_yield.elapsed() >= Duration::from_millis(100) { + last_yield = Instant::now(); + yield Ok(BenchWriteSample { + bytes: total_bytes, + records: total_records, + elapsed: throughput_start.elapsed(), + ack_latencies: std::mem::take(&mut ack_latencies), + chain_hash: None, + }); + } + } + Err(e) => { + yield Err(CliError::op(OpKind::Bench, e)); + return; + } + } + } + + _ = tokio::time::sleep(throttle_delay.unwrap_or(Duration::ZERO)), if throttle_delay.is_some() && !stopping() => { + // Rate limit delay + } + + permit = producer.reserve(record_size as u32), if !stopping() && throttle_delay.is_none() => { + match permit { + Ok(permit) => { + let submit_time = Instant::now(); + let timestamp = bench_start.elapsed().as_micros() as u64; + let body = record_body(record_size, &mut rng); + let hash = chain_hash(prev_hash, body.as_ref()); + prev_hash = hash; + let record = new_record(body, timestamp, hash); + pending_acks.push(Box::pin(async move { + let res = permit.submit(record).await; + (submit_time, res) + })); + bytes_submitted += record_size; + } + Err(e) => { + yield Err(CliError::op(OpKind::Bench, e)); + return; + } + } + } + } + } + + write_done_records.store(next_seq_num, Ordering::Release); + yield Ok(BenchWriteSample { + bytes: total_bytes, + records: total_records, + elapsed: throughput_start.elapsed(), + ack_latencies, + chain_hash: Some(prev_hash), + }); + } +} + +pub fn bench_read( + stream: S2Stream, + record_size: usize, + write_done_records: Arc, + bench_start: Instant, +) -> impl Stream> + Send { + bench_read_inner( + stream, + record_size, + ReadStop::new(), + write_done_records, + bench_start, + ) +} + +pub fn bench_read_catchup( + stream: S2Stream, + record_size: usize, + bench_start: Instant, +) -> impl Stream> + Send { + bench_read_inner( + stream, + record_size, + ReadStop::new().with_wait(0), + Arc::new(AtomicU64::new(WRITE_DONE_SENTINEL)), + bench_start, + ) +} + +fn bench_read_inner( + stream: S2Stream, + record_size: usize, + stop: ReadStop, + write_done_records: Arc, + bench_start: Instant, +) -> impl Stream> + Send { + async_stream::stream! { + let read_input = ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::SeqNum(0))) + .with_stop(stop); + let mut read_session = stream + .read_session(read_input, ReadSessionConfig::default()) + .await + .map_err(|e| CliError::op(OpKind::Bench, e))?; + + let mut total_bytes: u64 = 0; + let mut total_records: u64 = 0; + let throughput_start = Instant::now(); + let mut last_yield = Instant::now(); + let mut e2e_latencies: Vec = Vec::new(); + let mut prev_hash: u64 = 0; + + let mut poll_interval = tokio::time::interval(Duration::from_millis(250)); + + let done_records = || { + let value = write_done_records.load(Ordering::Acquire); + if value == WRITE_DONE_SENTINEL { + None + } else { + Some(value) + } + }; + + loop { + tokio::select! { + _ = poll_interval.tick() => { + if let Some(expected) = done_records() && total_records >= expected { + break; + } + } + batch_result = read_session.next() => { + match batch_result { + Some(Ok(batch)) => { + let now_micros = bench_start.elapsed().as_micros() as u64; + let batch_records = batch.records.len() as u64; + let mut batch_bytes: u64 = 0; + let expected_body_size = body_size(record_size); + for record in &batch.records { + if record.body.len() != expected_body_size { + yield Err(CliError::BenchVerification(format!( + "unexpected record body size at seq_num {}: expected {}, got {}", + record.seq_num, + expected_body_size, + record.body.len() + ))); + return; + } + + let header_hash = match record_hash(record) { + Ok(hash) => hash, + Err(err) => { + yield Err(CliError::BenchVerification(format!( + "invalid bench hash at seq_num {}: {err}", + record.seq_num + ))); + return; + } + }; + + if record.seq_num > 0 && header_hash == prev_hash { + yield Err(CliError::BenchVerification(format!( + "duplicate record hash at seq_num {}", + record.seq_num + ))); + return; + } + + let computed_hash = chain_hash(prev_hash, record.body.as_ref()); + if computed_hash != header_hash { + yield Err(CliError::BenchVerification(format!( + "unexpected record hash at seq_num {}", + record.seq_num + ))); + return; + } + prev_hash = computed_hash; + e2e_latencies.push(Duration::from_micros( + now_micros.saturating_sub(record.timestamp), + )); + batch_bytes += record_size as u64; + } + total_bytes += batch_bytes; + total_records += batch_records; + + if last_yield.elapsed() >= Duration::from_millis(100) { + last_yield = Instant::now(); + yield Ok(BenchReadSample { + bytes: total_bytes, + records: total_records, + elapsed: throughput_start.elapsed(), + e2e_latencies: std::mem::take(&mut e2e_latencies), + chain_hash: None, + }); + } + + if let Some(expected) = done_records() && total_records >= expected { + break; + } + } + Some(Err(e)) => { + yield Err(CliError::op(OpKind::Bench, e)); + return; + } + None => break, + } + } + } + } + + yield Ok(BenchReadSample { + bytes: total_bytes, + records: total_records, + elapsed: throughput_start.elapsed(), + e2e_latencies, + chain_hash: Some(prev_hash), + }); + } +} + +pub async fn run( + stream: S2Stream, + record_size: usize, + target_mibps: NonZeroU64, + duration: Duration, + catchup_delay: Duration, +) -> Result<(), CliError> { + assert!(record_size <= RECORD_BATCH_MAX.bytes); + + let bench_start = Instant::now(); + + let multi = + MultiProgress::with_draw_target(ProgressDrawTarget::stderr_with_hz(LIVE_UI_REFRESH_HZ)); + + let write_bar = multi.add( + ProgressBar::no_length().with_style( + ProgressStyle::default_bar() + .template("{msg}") + .expect("valid template"), + ), + ); + let read_bar = multi.add( + ProgressBar::no_length().with_style( + ProgressStyle::default_bar() + .template("{msg}") + .expect("valid template"), + ), + ); + fn blank_bar(multi: &MultiProgress) -> ProgressBar { + multi.add( + ProgressBar::no_length().with_style( + ProgressStyle::default_bar() + .template("{msg}") + .expect("valid template"), + ), + ) + } + + let latency_gap = blank_bar(&multi); + let latency_tables = LiveLatencyTables::new(&multi, latency_table_layout()); + + fn update_bench_bar( + bar: &ProgressBar, + label: impl std::fmt::Display, + sample: &T, + ) { + bar.set_message(format!( + "{label}: {:.2} MiB/s, {:.0} records/s ({} bytes, {} records in {:.2}s)", + sample.mib_per_sec(), + sample.records_per_sec(), + sample.bytes(), + sample.records(), + sample.elapsed().as_secs_f64(), + )); + } + + fn finish_live_bars(bars: &[&ProgressBar], latency_tables: &LiveLatencyTables) { + for bar in bars { + bar.finish_and_clear(); + } + latency_tables.finish_and_clear(); + } + + fn update_live_bars( + write_bar: &ProgressBar, + read_bar: &ProgressBar, + latency_tables: &LiveLatencyTables, + write_sample: Option<&BenchWriteSample>, + read_sample: Option<&BenchReadSample>, + ack_latency_stats: &StreamingLatencyStats, + e2e_latency_stats: &StreamingLatencyStats, + ) { + if let Some(sample) = write_sample { + update_bench_bar(write_bar, "Write".bold().blue(), sample); + } + if let Some(sample) = read_sample { + update_bench_bar(read_bar, "Read".bold().green(), sample); + } + let ack_snapshot = ack_latency_stats.snapshot(); + let e2e_snapshot = e2e_latency_stats.snapshot(); + latency_tables.update(ack_snapshot.as_ref(), e2e_snapshot.as_ref()); + } + + let mut write_sample: Option = None; + let mut read_sample: Option = None; + let mut ack_latency_stats = StreamingLatencyStats::default(); + let mut e2e_latency_stats = StreamingLatencyStats::default(); + let mut write_chain_hash: Option = None; + let mut read_chain_hash: Option = None; + + let stop = Arc::new(AtomicBool::new(false)); + let write_done_records = Arc::new(AtomicU64::new(WRITE_DONE_SENTINEL)); + let write_stream = bench_write( + stream.clone(), + record_size, + target_mibps, + stop.clone(), + write_done_records.clone(), + bench_start, + ); + let read_stream = bench_read( + stream.clone(), + record_size, + write_done_records.clone(), + bench_start, + ); + + enum BenchEvent { + Write(Result), + Read(Result), + WriteDone, + ReadDone, + } + + let (tx, mut rx) = mpsc::unbounded_channel(); + let write_tx = tx.clone(); + let write_handle = tokio::spawn(async move { + let mut write_stream = std::pin::pin!(write_stream); + while let Some(sample) = write_stream.next().await { + if write_tx.send(BenchEvent::Write(sample)).is_err() { + return; + } + } + let _ = write_tx.send(BenchEvent::WriteDone); + }); + let read_tx = tx.clone(); + let read_handle = tokio::spawn(async move { + let mut read_stream = std::pin::pin!(read_stream); + while let Some(sample) = read_stream.next().await { + if read_tx.send(BenchEvent::Read(sample)).is_err() { + return; + } + } + let _ = read_tx.send(BenchEvent::ReadDone); + }); + drop(tx); + + let deadline = bench_start + duration; + let mut write_done = false; + let mut read_done = false; + let mut interrupted = false; + let mut ui_tick = tokio::time::interval(Duration::from_millis(LIVE_UI_REFRESH_MS)); + ui_tick.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + if write_done && read_done { + break; + } + tokio::select! { + _ = ui_tick.tick() => { + update_live_bars( + &write_bar, + &read_bar, + &latency_tables, + write_sample.as_ref(), + read_sample.as_ref(), + &ack_latency_stats, + &e2e_latency_stats, + ); + } + _ = tokio::time::sleep_until(deadline), if !stop.load(Ordering::Relaxed) => { + stop.store(true, Ordering::Relaxed); + } + _ = tokio::signal::ctrl_c() => { + interrupted = true; + stop.store(true, Ordering::Relaxed); + write_handle.abort(); + read_handle.abort(); + break; + } + event = rx.recv() => { + match event { + Some(BenchEvent::Write(Ok(sample))) => { + ack_latency_stats.extend(sample.ack_latencies.iter().copied()); + if let Some(hash) = sample.chain_hash { + write_chain_hash = Some(hash); + } + write_sample = Some(sample); + } + Some(BenchEvent::Write(Err(e))) => { + finish_live_bars( + &[&write_bar, &read_bar, &latency_gap], + &latency_tables, + ); + stop.store(true, Ordering::Relaxed); + write_handle.abort(); + read_handle.abort(); + return Err(e); + } + Some(BenchEvent::WriteDone) => { + write_done = true; + } + Some(BenchEvent::Read(Ok(sample))) => { + e2e_latency_stats.extend(sample.e2e_latencies.iter().copied()); + if let Some(hash) = sample.chain_hash { + read_chain_hash = Some(hash); + } + read_sample = Some(sample); + } + Some(BenchEvent::Read(Err(e))) => { + finish_live_bars( + &[&write_bar, &read_bar, &latency_gap], + &latency_tables, + ); + stop.store(true, Ordering::Relaxed); + write_handle.abort(); + read_handle.abort(); + return Err(e); + } + Some(BenchEvent::ReadDone) => read_done = true, + None => { + write_done = true; + read_done = true; + } + } + } + } + } + + let _ = write_handle.await; + let _ = read_handle.await; + + finish_live_bars(&[&write_bar, &read_bar, &latency_gap], &latency_tables); + + if interrupted { + eprintln!(); + eprintln!( + "{}", + "Interrupted by Ctrl+C; showing partial results.".yellow() + ); + } + + eprintln!(); + if let Some(sample) = &write_sample { + eprintln!( + "{}: {:.2} MiB/s, {:.0} records/s ({} bytes, {} records in {:.2}s)", + "Write".bold().blue(), + sample.mib_per_sec(), + sample.records_per_sec(), + sample.bytes, + sample.records, + sample.elapsed.as_secs_f64() + ); + } + if let Some(sample) = &read_sample { + eprintln!( + "{}: {:.2} MiB/s, {:.0} records/s ({} bytes, {} records in {:.2}s)", + "Read".bold().green(), + sample.mib_per_sec(), + sample.records_per_sec(), + sample.bytes, + sample.records, + sample.elapsed.as_secs_f64() + ); + } + + let ack_latency_snapshot = ack_latency_stats.snapshot(); + let e2e_latency_snapshot = e2e_latency_stats.snapshot(); + if ack_latency_snapshot.is_some() || e2e_latency_snapshot.is_some() { + eprintln!(); + print_latency_stats( + ack_latency_snapshot.as_ref(), + e2e_latency_snapshot.as_ref(), + latency_tables.layout, + ); + } + + if interrupted { + return Ok(()); + } + + if let (Some(write_sample), Some(read_sample)) = (write_sample.as_ref(), read_sample.as_ref()) + && write_sample.records != read_sample.records + { + return Err(CliError::BenchVerification(format!( + "live read record count mismatch: expected {}, got {}", + write_sample.records, read_sample.records + ))); + } + + if let (Some(expected), Some(actual)) = (write_chain_hash, read_chain_hash) + && expected != actual + { + return Err(CliError::BenchVerification(format!( + "live read hash mismatch: expected {expected}, got {actual}" + ))); + } + + eprintln!(); + eprintln!("Waiting {:?} before catchup read...", catchup_delay); + tokio::select! { + _ = tokio::time::sleep(catchup_delay) => {} + _ = tokio::signal::ctrl_c() => return Ok(()), + } + + let catchup_bar = ProgressBar::no_length().with_style( + ProgressStyle::default_bar() + .template("{msg}") + .expect("valid template"), + ); + let mut catchup_sample: Option = None; + let mut catchup_chain_hash: Option = None; + let catchup_stream = bench_read_catchup(stream.clone(), record_size, bench_start); + let mut catchup_stream = std::pin::pin!(catchup_stream); + let catchup_timeout = Duration::from_secs(300); + let catchup_deadline = tokio::time::Instant::now() + catchup_timeout; + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + catchup_bar.finish_and_clear(); + return Ok(()); + } + next = tokio::time::timeout_at(catchup_deadline, catchup_stream.next()) => { + match next { + Ok(Some(Ok(sample))) => { + update_bench_bar(&catchup_bar, "Catchup".bold().cyan(), &sample); + if let Some(hash) = sample.chain_hash { + catchup_chain_hash = Some(hash); + } + catchup_sample = Some(sample); + } + Ok(Some(Err(e))) => { + catchup_bar.finish_and_clear(); + return Err(e); + } + Ok(None) => break, + Err(_) => { + catchup_bar.finish_and_clear(); + return Err(CliError::BenchVerification( + "catchup read timed out after 5 minutes".to_string(), + )); + } + } + } + } + } + + catchup_bar.finish_and_clear(); + if let Some(sample) = &catchup_sample { + eprintln!( + "{}: {:.2} MiB/s, {:.0} records/s ({} bytes, {} records in {:.2}s)", + "Catchup".bold().cyan(), + sample.mib_per_sec(), + sample.records_per_sec(), + sample.bytes, + sample.records, + sample.elapsed.as_secs_f64() + ); + } else { + eprintln!( + "{}: no records available for catchup read", + "Catchup".bold().cyan() + ); + } + + match (write_sample.as_ref(), catchup_sample.as_ref()) { + (Some(write_sample), Some(catchup_sample)) + if write_sample.records != catchup_sample.records => + { + return Err(CliError::BenchVerification(format!( + "catchup read record count mismatch: expected {}, got {}", + write_sample.records, catchup_sample.records + ))); + } + (Some(write_sample), None) if write_sample.records > 0 => { + return Err(CliError::BenchVerification(format!( + "catchup read returned no records but write produced {}", + write_sample.records + ))); + } + _ => {} + } + + if let (Some(expected), Some(actual)) = (write_chain_hash, catchup_chain_hash) + && expected != actual + { + return Err(CliError::BenchVerification(format!( + "catchup read hash mismatch: expected {expected}, got {actual}" + ))); + } + + Ok(()) +} + +fn print_latency_stats( + ack: Option<&LiveLatencySnapshot>, + e2e: Option<&LiveLatencySnapshot>, + layout: LatencyTableLayout, +) { + for line in format_latency_tables(ack, e2e, layout, true) { + eprintln!("{line}"); + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{LatencyTableLayout, StreamingLatencyStats, format_latency_tables}; + + #[test] + fn streaming_latency_stats_tracks_percentiles() { + let mut stats = StreamingLatencyStats::default(); + stats.extend((1..=10).map(Duration::from_millis)); + + let snapshot = stats.snapshot().expect("stats available"); + + assert_eq!(snapshot.count, 10); + assert_eq!(snapshot.stats.min, Duration::from_millis(1)); + assert_eq!(snapshot.stats.p50, Duration::from_millis(5)); + assert_eq!(snapshot.stats.p90, Duration::from_millis(9)); + assert_eq!(snapshot.stats.p99, Duration::from_millis(10)); + assert_eq!(snapshot.stats.max, Duration::from_millis(10)); + } + + #[test] + fn streaming_latency_stats_counts_duplicate_samples() { + let mut stats = StreamingLatencyStats::default(); + stats.extend([ + Duration::from_millis(1), + Duration::from_millis(5), + Duration::from_millis(5), + Duration::from_millis(10), + ]); + + let snapshot = stats.snapshot().expect("stats available"); + + assert_eq!(snapshot.count, 4); + assert_eq!(snapshot.stats.p50, Duration::from_millis(5)); + assert_eq!(snapshot.stats.p90, Duration::from_millis(10)); + assert_eq!(snapshot.stats.p99, Duration::from_millis(10)); + } + + #[test] + fn latency_tables_can_render_side_by_side() { + let mut ack_stats = StreamingLatencyStats::default(); + ack_stats.extend((1..=10).map(Duration::from_millis)); + let ack = ack_stats.snapshot().expect("ack stats available"); + + let mut e2e_stats = StreamingLatencyStats::default(); + e2e_stats.extend((11..=20).map(Duration::from_millis)); + let e2e = e2e_stats.snapshot().expect("e2e stats available"); + + let lines = format_latency_tables( + Some(&ack), + Some(&e2e), + LatencyTableLayout::SideBySide, + false, + ); + + assert_eq!(lines.len(), 6); + assert!(lines[0].contains("Ack Latency Statistics (n=10)")); + assert!(lines[0].contains("End-to-End Latency Statistics (n=10)")); + assert!(lines[1].contains("min")); + assert!(lines[1].contains("1.00ms")); + assert!(lines[1].contains("11.00ms")); + } +} diff --git a/cli/src/cli.rs b/cli/src/cli.rs new file mode 100644 index 00000000..30f2baf7 --- /dev/null +++ b/cli/src/cli.rs @@ -0,0 +1,1146 @@ +use std::{num::NonZeroU64, path::PathBuf, time::Duration}; + +use clap::{Args, Parser, Subcommand, ValueEnum, builder::styling}; +use compact_str::CompactString; +use s2_sdk::types::{ + AccessTokenId, AccessTokenIdPrefix, AccessTokenIdStartAfter, BasinName, BasinNamePrefix, + BasinNameStartAfter, EncryptionAlgorithm, EncryptionKey, FencingToken, StreamName, + StreamNamePrefix, StreamNameStartAfter, +}; + +use crate::{ + record_format::{ + RecordFormat, RecordsIn, RecordsOut, parse_records_input_source, + parse_records_output_source, + }, + types::{ + BasinConfig, Interval, LocationName, Operation, PermittedOperationGroups, + S2BasinAndMaybeStreamUri, S2BasinAndStreamUri, S2BasinUri, StreamConfig, + }, +}; + +const STYLES: styling::Styles = styling::Styles::styled() + .header(styling::AnsiColor::Green.on_default().bold()) + .usage(styling::AnsiColor::Green.on_default().bold()) + .literal(styling::AnsiColor::Blue.on_default().bold()) + .placeholder(styling::AnsiColor::Cyan.on_default()); + +const GENERAL_USAGE: &str = color_print::cstr!( + r#" + $ s2 login + $ s2 list-basins --prefix "foo" --limit 100 + "# +); + +#[derive(Parser, Debug)] +#[command(name = "s2", version = crate::update::long_version(), override_usage = GENERAL_USAGE, styles = STYLES)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Log in to S2 through your browser. + Login(LoginArgs), + + /// Log out of the browser-authenticated S2 session. + Logout(LogoutArgs), + + /// Manage CLI authentication. + #[command(subcommand)] + Auth(AuthCommand), + + /// Manage CLI configuration. + #[command(subcommand)] + Config(ConfigCommand), + + /// List basins or streams in a basin. + /// + /// List basins if basin name is not provided otherwise lists streams in + /// the basin. + Ls(LsArgs), + + /// List basins. + ListBasins(ListBasinsArgs), + + /// Create a basin. + CreateBasin(CreateBasinArgs), + + /// Delete a basin. + DeleteBasin { + /// Name of the basin to delete. + basin: S2BasinUri, + }, + + /// Get basin config. + GetBasinConfig { + /// Basin name to get config for. + basin: S2BasinUri, + }, + + /// Reconfigure a basin. + ReconfigureBasin(ReconfigureBasinArgs), + + /// List access tokens. + ListAccessTokens(ListAccessTokensArgs), + + /// Issue an access token. + IssueAccessToken(IssueAccessTokenArgs), + + /// Revoke an access token. + RevokeAccessToken { + /// ID of the access token to revoke. + id: AccessTokenId, + }, + + /// Compare two basins, streams, or access tokens. + Diff(DiffArgs), + + /// List locations. + ListLocations, + + /// Get the default location. + GetDefaultLocation, + + /// Set the default location. + SetDefaultLocation { + /// Location name to make the default. + location: LocationName, + }, + + /// Get account metrics. + GetAccountMetrics(GetAccountMetricsArgs), + + /// Get basin metrics. + GetBasinMetrics(GetBasinMetricsArgs), + + /// Get stream metrics. + GetStreamMetrics(GetStreamMetricsArgs), + + /// List streams. + ListStreams(ListStreamsArgs), + + /// Create a stream. + CreateStream(CreateStreamArgs), + + /// Delete a stream. + DeleteStream { + /// S2 URI of the format: s2://{basin}/{stream} + #[arg(value_name = "S2_URI")] + uri: S2BasinAndStreamUri, + }, + + /// Get stream config. + GetStreamConfig { + /// S2 URI of the format: s2://{basin}/{stream} + #[arg(value_name = "S2_URI")] + uri: S2BasinAndStreamUri, + }, + + /// Reconfigure a stream. + ReconfigureStream(ReconfigureStreamArgs), + + /// Check the tail position of a stream. + /// + /// Returns the sequence number that will be assigned to the next record, + /// and the timestamp of the last record. + CheckTail { + /// S2 URI of the format: s2://{basin}/{stream} + #[arg(value_name = "S2_URI")] + uri: S2BasinAndStreamUri, + }, + + /// Set a trim point for a stream. + /// + /// Trimming is eventually consistent, and trimmed records may be visible + /// for a brief period. + Trim(TrimArgs), + + /// Set a fencing token for a stream. + /// + /// Fencing is strongly consistent, and subsequent appends that specify a + /// token will be rejected if it does not match. + /// + /// Note that fencing is a cooperative mechanism, + /// and it is only enforced when a token is provided. + Fence(FenceArgs), + + /// Append records to a stream. + Append(AppendArgs), + + /// Read records from a stream. + /// + /// If a limit if specified, reading will stop when the limit is reached or there are no more + /// records on the stream. If a limit is not specified, the reader will keep tailing and + /// wait for new records. + Read(ReadArgs), + + /// Tail a stream, showing the last N records. + Tail(TailArgs), + + /// Benchmark a stream to measure throughput and latency. + Bench(BenchArgs), + + /// Apply a declarative spec file, ensuring basins and streams. + /// + /// Reads a JSON file and ensures the declared basins and streams exist with the + /// specified configuration. Defaults are applied before comparison; omitted fields are + /// defaulted, not preserved. + /// + /// Dry-run output legend: + /// `+` create + /// `~` ensure + /// `=` unchanged + /// `?` storage-class default resolved at apply + /// + /// For IDE validation/autocomplete, add `$schema` at the top of each spec file: + /// {"$schema":"https://raw.githubusercontent.com/s2-streamstore/s2/main/cli/schema.json","basins":[]} + /// + /// For local-only use, point to a local path/URI instead: + /// {"$schema":"./cli/schema.json","basins":[]} + /// + /// Example spec file: + /// {"$schema":"https://raw.githubusercontent.com/s2-streamstore/s2/main/cli/schema.json","basins":[{"name":"my-basin","streams":[{"name":"events"}]}]} + Apply(ApplyArgs), + + /// Run S2 Lite server backed by object storage. + /// + /// Starts a lightweight S2-compatible server that can be backed by + /// S3, local filesystem, or in-memory storage. + Lite(crate::lite::LiteArgs), + + /// Update the S2 CLI to the latest release. + /// + /// Detects how this binary was installed and upgrades it the right way: + /// - install script / manual download: downloads the matching release artifact, verifies its + /// checksum, and replaces the binary in place where the platform supports it; + /// - Homebrew / Cargo: shows (or, with --yes where supported, runs) the upgrade command for + /// that package manager; + /// - Docker / source build: prints how to update. + Update(UpdateArgs), +} + +#[derive(Args, Debug)] +pub struct UpdateArgs { + /// Report the installed and latest versions without upgrading. + #[arg(long, conflicts_with_all = ["skip", "yes"])] + pub check: bool, + + /// Silence update reminders for the current latest release without + /// upgrading. + #[arg(long, conflicts_with = "yes")] + pub skip: bool, + + /// Do not prompt; for Homebrew and Cargo installs, run the upgrade + /// command directly where the running executable can be replaced. + #[arg(long, short = 'y')] + pub yes: bool, +} + +#[derive(Subcommand, Debug)] +pub enum AuthCommand { + /// Log in to S2 through your browser. + Login(LoginArgs), + + /// Log out of the browser-authenticated S2 session. + Logout(LogoutArgs), + + /// Show the active authentication method and verify it with S2. + Status, + + /// Switch authentication methods without deleting either credential. + Use { + /// Authentication method to select. + method: crate::config::AuthMethod, + }, + + /// Manage a stored access token. + AccessToken { + #[command(subcommand)] + command: AuthAccessTokenCommand, + }, +} + +#[derive(Args, Debug)] +pub struct LoginArgs { + /// Print the login URL instead of opening a browser. + /// + /// The browser must still be able to reach this machine's loopback callback. + #[arg(long)] + pub no_open: bool, + + /// Maximum time to wait for browser authorization. + #[arg( + long, + default_value = "20m", + value_parser = humantime::parse_duration, + hide = true + )] + pub timeout: Duration, + + /// Override the OAuth issuer. + #[arg(long, value_name = "URL", hide = true)] + pub issuer: Option, + + /// Override the OAuth client ID. + #[arg(long, value_name = "CLIENT_ID", hide = true)] + pub client_id: Option, + + /// Store credentials in a private file instead of the OS credential store. + #[arg(long)] + pub insecure_storage: bool, +} + +#[derive(Args, Debug)] +pub struct LogoutArgs { + /// Remove local credentials without attempting server-side revocation. + #[arg(long)] + pub local_only: bool, +} + +#[derive(Subcommand, Debug)] +pub enum AuthAccessTokenCommand { + /// Store an access token. + Set(AuthAccessTokenSetArgs), + + /// Move a legacy plaintext access token out of the config file. + Migrate(AuthAccessTokenMigrateArgs), + + /// Remove the locally stored access token. + /// + /// This does not revoke the access token. + Remove, +} + +#[derive(Args, Debug)] +pub struct AuthAccessTokenSetArgs { + /// Read the access token from standard input instead of prompting. + #[arg(long)] + pub stdin: bool, + + /// Store the token in a private file instead of the OS credential store. + #[arg(long)] + pub insecure_storage: bool, +} + +#[derive(Args, Debug)] +pub struct AuthAccessTokenMigrateArgs { + /// Store the token in a private file instead of the OS credential store. + #[arg(long)] + pub insecure_storage: bool, +} + +#[derive(Subcommand)] +pub enum ConfigCommand { + /// List all configuration values. + List, + /// Get a configuration value. + Get { + /// Config key + key: crate::config::ConfigKey, + }, + /// Set a configuration value. + Set { + /// Config key + key: crate::config::ConfigKey, + /// Value to set + value: String, + }, + /// Unset a configuration value. + Unset { + /// Config key + key: crate::config::ConfigKey, + }, +} + +impl std::fmt::Debug for ConfigCommand { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::List => formatter.write_str("List"), + Self::Get { key } => formatter.debug_struct("Get").field("key", key).finish(), + Self::Set { key, value } => formatter + .debug_struct("Set") + .field("key", key) + .field( + "value", + if matches!(key, crate::config::ConfigKey::AccessToken) { + &"" as &dyn std::fmt::Debug + } else { + value as &dyn std::fmt::Debug + }, + ) + .finish(), + Self::Unset { key } => formatter.debug_struct("Unset").field("key", key).finish(), + } + } +} + +#[derive(Args, Debug)] +pub struct LsArgs { + /// Name of the basin to manage or S2 URI with basin and optionally prefix. + /// + /// S2 URI is of the format: s2://{basin}/{prefix} + #[arg(value_name = "BASIN|S2_URI")] + pub uri: Option, + + /// Filter to names that begin with this prefix. + #[arg(short = 'p', long)] + pub prefix: Option, + + /// Filter to names that lexicographically start after this name. + #[arg(short = 's', long)] + pub start_after: Option, + + /// Limit the number of items to return. Acts as page size (max 1000) when using + /// --no-auto-paginate. + #[arg(short = 'n', long)] + pub limit: Option, + + /// Returns only a single page of items instead of auto-paginating. + #[arg(long, default_value_t = false)] + pub no_auto_paginate: bool, +} + +#[derive(Args, Debug)] +pub struct ListBasinsArgs { + /// Filter to basin names that begin with this prefix. + #[arg(short = 'p', long)] + pub prefix: Option, + + /// Filter to basin names that lexicographically start after this name. + #[arg(short = 's', long)] + pub start_after: Option, + + /// Limit the number of basins to return. Acts as page size (max 1000) when using + /// --no-auto-paginate. + #[arg(short = 'n', long)] + pub limit: Option, + + /// Returns only a single page of basins instead of auto-paginating. + #[arg(long, default_value_t = false)] + pub no_auto_paginate: bool, +} + +#[derive(Args, Debug)] +pub struct CreateBasinArgs { + /// Name of the basin to create. + pub basin: S2BasinUri, + + /// Basin location. + #[arg(long)] + pub location: Option, + + #[command(flatten)] + pub config: BasinConfig, +} + +#[derive(Args, Debug)] +pub struct ReconfigureBasinArgs { + /// Name of the basin to reconfigure. + pub basin: S2BasinUri, + + /// Encryption algorithm to apply to newly created streams in this basin. + #[arg(long)] + pub stream_cipher: Option, + + /// Create stream on append with basin defaults if it doesn't exist. + #[arg(long)] + pub create_stream_on_append: Option, + + /// Create stream on read with basin defaults if it doesn't exist. + #[arg(long)] + pub create_stream_on_read: Option, + + #[clap(flatten)] + pub default_stream_config: StreamConfig, +} + +#[derive(Args, Debug)] +pub struct ListAccessTokensArgs { + /// List access tokens that begin with this prefix. + #[arg(short = 'p', long)] + pub prefix: Option, + + /// Only return access tokens that lexicographically start after this token ID. + #[arg(short = 's', long)] + pub start_after: Option, + + /// Limit the number of access tokens to return. Acts as page size (max 1000) when using + /// --no-auto-paginate. + #[arg(short = 'n', long)] + pub limit: Option, + + /// Returns only a single page of access tokens instead of auto-paginating. + #[arg(long, default_value_t = false)] + pub no_auto_paginate: bool, +} + +#[derive(Args, Debug)] +pub struct IssueAccessTokenArgs { + /// Access token ID. + pub id: AccessTokenId, + + /// Token validity duration (e.g., "30d", "1w", "24h"). Token expires after this duration from + /// now. + #[arg(long, conflicts_with = "expires_at")] + pub expires_in: Option, + + /// Absolute expiration time in RFC3339 format (e.g., "2024-12-31T23:59:59Z"). + #[arg(long, conflicts_with = "expires_in")] + pub expires_at: Option, + + /// Namespace streams based on the configured stream-level scope, which must be a prefix. + /// Stream name arguments will be automatically prefixed, and the prefix will be stripped + /// when listing streams. + #[arg(long, default_value_t = false)] + pub auto_prefix_streams: bool, + + /// Basin name prefix allowed. `""` matches all basins. + #[arg(long, conflicts_with = "basins_exact")] + pub basins_prefix: Option, + + /// Exact basin name allowed. + #[arg(long)] + pub basins_exact: Option, + + /// Stream name prefix allowed. `""` matches all streams. + #[arg(long, conflicts_with = "streams_exact")] + pub streams_prefix: Option, + + /// Exact stream name allowed. + #[arg(long)] + pub streams_exact: Option, + + /// Access token ID prefix allowed. `""` matches all tokens. + #[arg(long, conflicts_with = "access_tokens_exact")] + pub access_tokens_prefix: Option, + + /// Exact access token ID allowed. + #[arg(long)] + pub access_tokens_exact: Option, + + /// Access permissions at the operation group level. + /// The format is: "account=rw,basin=r,stream=w" + /// where 'r' indicates read permission and 'w' indicates write permission. + #[arg(long)] + pub op_group_perms: Option, + + /// Operations allowed for the token. + /// A union of allowed operations and groups is used as an effective set of allowed operations. + #[arg(long, value_delimiter = ',')] + pub ops: Vec, +} + +#[derive(Args, Debug)] +#[command( + override_usage = "s2 diff [OPTIONS] ", + after_help = "Examples:\n s2 diff s2://my-basin/left s2://my-basin/right\n s2 diff s2://my-basin s2://my-basin/my-stream\n s2 diff --resource basin basin-left basin-right\n s2 diff --resource access-token token-left token-right\n s2 diff s2://my-basin/left s2://my-basin/right --output json\n s2 diff s2://my-basin/left s2://my-basin/right --exit-code" +)] +pub struct DiffArgs { + /// First resource to compare. + pub left: String, + + /// Second resource to compare. + pub right: String, + + /// Resource type. Inferred when both resources are S2 URIs. + #[arg(short, long, value_enum)] + pub resource: Option, + + /// Output format. + #[arg(short, long, value_enum, default_value_t)] + pub output: DiffOutput, + + /// Exit with status 1 when differences are found. + #[arg(long)] + pub exit_code: bool, +} + +#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffResourceKind { + Basin, + Stream, + AccessToken, +} + +impl DiffResourceKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Basin => "basin", + Self::Stream => "stream", + Self::AccessToken => "access-token", + } + } +} + +#[derive(ValueEnum, Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum DiffOutput { + #[default] + Text, + Json, +} + +#[derive(Args, Debug)] +pub struct ListStreamsArgs { + /// Name of the basin to manage or S2 URI with basin and optionally prefix. + /// + /// S2 URI is of the format: s2://{basin}/{prefix} + #[arg(value_name = "BASIN|S2_URI")] + pub uri: S2BasinAndMaybeStreamUri, + + /// Filter to stream names that begin with this prefix. + #[arg(short = 'p', long)] + pub prefix: Option, + + /// Filter to stream names that lexicographically start after this name. + #[arg(short = 's', long)] + pub start_after: Option, + + /// Limit the number of streams to return. Acts as page size (max 1000) when using + /// --no-auto-paginate. + #[arg(short = 'n', long)] + pub limit: Option, + + /// Returns only a single page of streams instead of auto-paginating. + #[arg(long, default_value_t = false)] + pub no_auto_paginate: bool, +} + +#[derive(Args, Debug)] +pub struct CreateStreamArgs { + /// S2 URI of the format: s2://{basin}/{stream} + #[arg(value_name = "S2_URI")] + pub uri: S2BasinAndStreamUri, + + #[command(flatten)] + pub config: StreamConfig, +} + +#[derive(Args, Debug)] +pub struct ReconfigureStreamArgs { + /// S2 URI of the format: s2://{basin}/{stream} + #[arg(value_name = "S2_URI")] + pub uri: S2BasinAndStreamUri, + + #[clap(flatten)] + pub config: StreamConfig, +} + +#[derive(Args, Debug)] +pub struct TrimArgs { + /// S2 URI of the format: s2://{basin}/{stream} + #[arg(value_name = "S2_URI")] + pub uri: S2BasinAndStreamUri, + + /// Earliest sequence number that should be retained. + /// This sequence number is only allowed to advance, + /// and any regression will be ignored. + pub trim_point: u64, + + /// Enforce fencing token. + #[arg(short = 'f', long)] + pub fencing_token: Option, + + /// Enforce that the sequence number issued to the first record matches. + #[arg(short = 'm', long)] + pub match_seq_num: Option, +} + +#[derive(Args, Debug)] +pub struct FenceArgs { + /// S2 URI of the format: s2://{basin}/{stream} + #[arg(value_name = "S2_URI")] + pub uri: S2BasinAndStreamUri, + + /// New fencing token. + /// It may be upto 36 characters, and can be empty. + pub new_fencing_token: FencingToken, + + /// Enforce existing fencing token. + #[arg(short = 'f', long)] + pub fencing_token: Option, + + /// Enforce that the sequence number issued to this command matches. + #[arg(short = 'm', long)] + pub match_seq_num: Option, +} + +#[derive(Args, Debug)] +pub struct AppendArgs { + /// S2 URI of the format: s2://{basin}/{stream} + #[arg(value_name = "S2_URI")] + pub uri: S2BasinAndStreamUri, + + /// Enforce fencing token. + #[arg(short = 'f', long)] + pub fencing_token: Option, + + /// Enforce that the sequence number issued to the first record matches. + #[arg(short = 'm', long)] + pub match_seq_num: Option, + + /// Input format. + #[arg(long, value_enum, default_value_t)] + pub format: RecordFormat, + + /// Input newline delimited records to append from a file or stdin. + /// Use "-" to read from stdin. + #[arg(short = 'i', long, value_parser = parse_records_input_source, default_value = "-")] + pub input: RecordsIn, + + /// How long to wait for more records before flushing a batch. + #[arg(long, default_value = "5ms")] + pub linger: humantime::Duration, + + #[command(flatten)] + pub encryption_key: EncryptionKeyArgs, + + /// Stream configuration to apply if the stream is created on append. + /// Unset fields inherit the basin's default stream configuration. + /// Ignored if the stream already exists. + #[command( + flatten, + next_help_heading = "Stream configuration (applied only if the stream is created on append)" + )] + pub stream_config: StreamConfig, +} + +#[derive(Args, Debug, Clone, Default)] +pub struct EncryptionKeyArgs { + /// Base64-encoded encryption key material. + /// Alternatively, set `S2_ENCRYPTION_KEY`. + #[arg( + short = 'k', + long = "encryption-key", + env = "S2_ENCRYPTION_KEY", + hide_env_values = true, + value_name = "KEY", + group = "encryption_key_source" + )] + pub key: Option, + + /// Read base64-encoded encryption key material from file. + #[arg( + long = "encryption-key-file", + conflicts_with = "key", + value_name = "FILE", + group = "encryption_key_source" + )] + pub key_file: Option, +} + +#[derive(Args, Debug)] +pub struct ReadArgs { + /// S2 URI of the format: s2://{basin}/{stream} + #[arg(value_name = "S2_URI")] + pub uri: S2BasinAndStreamUri, + + /// Starting sequence number (inclusive). + #[arg(short = 's', long, group = "start")] + pub seq_num: Option, + + /// Starting timestamp in milliseconds since Unix epoch (inclusive). + #[arg(long, group = "start")] + pub timestamp: Option, + + /// Starting timestamp as a human-friendly delta from current time e.g. "1h", + /// which will be converted to milliseconds since Unix epoch. + #[arg(long, group = "start")] + pub ago: Option, + + /// Start from N records before the tail of the stream. + #[arg(long, group = "start")] + pub tail_offset: Option, + + /// Limit the number of records returned. + #[arg(short = 'n', long)] + pub count: Option, + + /// Limit the number of bytes returned. + #[arg(short = 'b', long)] + pub bytes: Option, + + /// Clamp the start position at the tail position. + #[arg(long, default_value_t = false)] + pub clamp: bool, + + /// Exclusive end-timestamp in milliseconds since Unix epoch. + /// If provided, results will be limited such that all records returned + /// will have a timestamp < the one provided via `until`. + #[arg(long)] + pub until: Option, + + /// Output format. + #[arg(long, value_enum, default_value_t)] + pub format: RecordFormat, + + /// Output records to a file or stdout. + /// Use "-" to write to stdout. + #[arg(short = 'o', long, value_parser = parse_records_output_source, default_value = "-")] + pub output: RecordsOut, + + #[command(flatten)] + pub encryption_key: EncryptionKeyArgs, + + /// Stream configuration to apply if the stream is created on read. + /// Unset fields inherit the basin's default stream configuration. + /// Ignored if the stream already exists. + #[command( + flatten, + next_help_heading = "Stream configuration (applied only if the stream is created on read)" + )] + pub stream_config: StreamConfig, +} + +#[derive(Args, Debug)] +pub struct TailArgs { + /// S2 URI of the format: s2://{basin}/{stream} + #[arg(value_name = "S2_URI")] + pub uri: S2BasinAndStreamUri, + + /// Output the last N records instead of the default (10). + #[arg(short = 'n', long = "lines", default_value_t = 10)] + pub lines: u64, + + /// Follow the stream, waiting for new records to be appended. + #[arg(short = 'f', long, default_value_t = false)] + pub follow: bool, + + /// Output format. + #[arg(long, value_enum, default_value_t)] + pub format: RecordFormat, + + /// Output records to a file or stdout. + /// Use "-" to write to stdout. + #[arg(short = 'o', long, value_parser = parse_records_output_source, default_value = "-")] + pub output: RecordsOut, + + #[command(flatten)] + pub encryption_key: EncryptionKeyArgs, +} + +#[derive(Args, Debug)] +pub struct ApplyArgs { + /// Path to a JSON spec file defining basins and streams to ensure. + #[arg( + short = 'f', + long, + value_name = "FILE", + required_unless_present = "schema" + )] + pub file: Option, + /// Preview changes without making any mutations. + /// + /// Dry-run output legend: + /// `+` create + /// `~` ensure + /// `=` unchanged + /// `?` storage-class default resolved at apply + #[arg(long)] + pub dry_run: bool, + /// Print the JSON Schema for the spec file format to stdout. + #[arg(long, conflicts_with_all = ["file", "dry_run"])] + pub schema: bool, +} + +#[derive(Args, Debug)] +pub struct BenchArgs { + /// Name of the basin to use for the test. + pub basin: S2BasinUri, + + /// Storage class for the test stream. Uses basin default if not specified. + #[arg(short = 'c', long)] + pub storage_class: Option, + + /// Total metered record size in bytes (includes headers and overhead). + #[arg( + short = 'b', + long, + default_value_t = 8*1024, + value_parser = clap::value_parser!(u32).range(128..1024*1024), + )] + pub record_size: u32, + + /// Target write throughput in MiB/s. + #[arg( + short = 't', + long, + value_parser = clap::value_parser!(NonZeroU64), + default_value_t = NonZeroU64::new(1).expect("non-zero") + )] + pub target_mibps: NonZeroU64, + + /// Run test for this duration. + #[arg(short = 'd', long, default_value = "60s")] + pub duration: humantime::Duration, + + /// Delay before starting the catchup read. + #[arg(short = 'w', long, default_value = "20s")] + pub catchup_delay: humantime::Duration, +} + +/// Time range args for gauge metrics (no interval). +#[derive(Args, Debug)] +#[command(group(clap::ArgGroup::new("start_time").required(true)))] +#[command(group(clap::ArgGroup::new("end_time").required(true)))] +pub struct TimeRangeArgs { + /// Start time in seconds since Unix epoch. + #[arg(long = "start-timestamp", group = "start_time")] + pub start_timestamp: Option, + + /// Start time as human-friendly delta from current time (e.g., "2h", "1d", "0s"). + #[arg(long, group = "start_time")] + pub start_ago: Option, + + /// End time in seconds since Unix epoch. + #[arg(long = "end-timestamp", group = "end_time")] + pub end_timestamp: Option, + + /// End time as human-friendly delta from current time (e.g., "2h", "1d", "0s"). + #[arg(long, group = "end_time")] + pub end_ago: Option, +} + +/// Time range args for accumulation metrics (with interval). +#[derive(Args, Debug)] +pub struct TimeRangeAndIntervalArgs { + #[command(flatten)] + pub time_range: TimeRangeArgs, + + /// Accumulation interval. + #[arg(long)] + pub interval: Option, +} + +/// Account metrics. +#[derive(Subcommand, Debug)] +#[command(disable_help_subcommand = true)] +pub enum AccountMetricCommand { + /// Basins with at least one stream in the time range. + ActiveBasins(TimeRangeArgs), + /// Account operations by type. + AccountOps(TimeRangeAndIntervalArgs), +} + +/// Basin metrics. +#[derive(Subcommand, Debug)] +#[command(disable_help_subcommand = true)] +pub enum BasinMetricCommand { + /// Total stored bytes across all streams (hourly). + Storage(TimeRangeArgs), + /// Append operations by storage class. + AppendOps(TimeRangeAndIntervalArgs), + /// Read operations by read type. + ReadOps(TimeRangeAndIntervalArgs), + /// Total bytes read across all streams. + ReadThroughput(TimeRangeAndIntervalArgs), + /// Total bytes appended across all streams. + AppendThroughput(TimeRangeAndIntervalArgs), + /// Basin operations by type. + BasinOps(TimeRangeAndIntervalArgs), +} + +/// Stream metrics. +#[derive(Subcommand, Debug)] +#[command(disable_help_subcommand = true)] +pub enum StreamMetricCommand { + /// Total stored bytes for the stream (minutely). + Storage(TimeRangeArgs), +} + +#[derive(Args, Debug)] +#[command(subcommand_value_name = "METRIC", subcommand_help_heading = "Metrics")] +pub struct GetAccountMetricsArgs { + #[command(subcommand)] + pub metric: AccountMetricCommand, +} + +#[derive(Args, Debug)] +#[command(subcommand_value_name = "METRIC", subcommand_help_heading = "Metrics")] +pub struct GetBasinMetricsArgs { + /// Basin name. + pub basin: S2BasinUri, + + #[command(subcommand)] + pub metric: BasinMetricCommand, +} + +#[derive(Args, Debug)] +#[command(subcommand_value_name = "METRIC", subcommand_help_heading = "Metrics")] +pub struct GetStreamMetricsArgs { + /// S2 URI of the format: s2://{basin}/{stream} + #[arg(value_name = "S2_URI")] + pub uri: S2BasinAndStreamUri, + + #[command(subcommand)] + pub metric: StreamMetricCommand, +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::{Cli, Command, DiffArgs, DiffOutput, DiffResourceKind, IssueAccessTokenArgs}; + + fn issue_access_token_args_from(args: I) -> IssueAccessTokenArgs + where + I: IntoIterator, + T: Into + Clone, + { + let cli = Cli::try_parse_from(args).expect("cli parses"); + match cli.command { + Some(Command::IssueAccessToken(args)) => args, + other => panic!("unexpected command: {other:?}"), + } + } + + fn diff_args_from(args: I) -> DiffArgs + where + I: IntoIterator, + T: Into + Clone, + { + let cli = Cli::try_parse_from(args).expect("cli parses"); + match cli.command { + Some(Command::Diff(args)) => args, + other => panic!("unexpected command: {other:?}"), + } + } + + #[test] + fn diff_parses_basins() { + let args = diff_args_from(["s2", "diff", "s2://left-basin", "s2://right-basin"]); + + assert_eq!(args.left, "s2://left-basin"); + assert_eq!(args.right, "s2://right-basin"); + assert!(args.resource.is_none()); + assert_eq!(args.output, DiffOutput::Text); + assert!(!args.exit_code); + } + + #[test] + fn diff_parses_streams() { + let args = diff_args_from([ + "s2", + "diff", + "s2://left-basin/left-stream", + "s2://right-basin/right-stream", + ]); + + assert_eq!(args.left, "s2://left-basin/left-stream"); + assert_eq!(args.right, "s2://right-basin/right-stream"); + assert!(args.resource.is_none()); + } + + #[test] + fn diff_parses_explicit_access_tokens() { + let args = diff_args_from(["s2", "diff", "--resource", "access-token", "left", "right"]); + + assert_eq!(args.left, "left"); + assert_eq!(args.right, "right"); + assert_eq!(args.resource, Some(DiffResourceKind::AccessToken)); + } + + #[test] + fn diff_parses_explicit_basins() { + let args = diff_args_from([ + "s2", + "diff", + "left-basin", + "right-basin", + "--resource", + "basin", + ]); + + assert_eq!(args.left, "left-basin"); + assert_eq!(args.right, "right-basin"); + assert_eq!(args.resource, Some(DiffResourceKind::Basin)); + } + + #[test] + fn diff_parses_explicit_streams() { + let args = diff_args_from([ + "s2", + "diff", + "--resource", + "stream", + "s2://left-basin/left-stream", + "s2://right-basin/right-stream", + ]); + + assert_eq!(args.resource, Some(DiffResourceKind::Stream)); + } + + #[test] + fn diff_parses_json_output_and_exit_code() { + let args = diff_args_from([ + "s2", + "diff", + "s2://left/stream", + "s2://right/stream", + "--output", + "json", + "--exit-code", + ]); + + assert_eq!(args.output, DiffOutput::Json); + assert!(args.exit_code); + } + + #[test] + fn issue_access_token_streams_prefix_empty_matches_all() { + let args = issue_access_token_args_from([ + "s2", + "issue-access-token", + "my-token", + "--streams-prefix", + "", + ]); + + assert_eq!(args.streams_prefix.unwrap().to_string(), ""); + assert!(args.streams_exact.is_none()); + } + + #[test] + fn issue_access_token_streams_prefix_accepts_leading_equals() { + let args = issue_access_token_args_from([ + "s2", + "issue-access-token", + "my-token", + "--streams-prefix", + "=tenant/", + ]); + + assert_eq!(args.streams_prefix.unwrap().to_string(), "=tenant/"); + assert!(args.streams_exact.is_none()); + } + + #[test] + fn issue_access_token_streams_exact_accepts_leading_equals() { + let args = issue_access_token_args_from([ + "s2", + "issue-access-token", + "my-token", + "--streams-exact", + "=stream", + ]); + + assert_eq!(args.streams_exact.unwrap().to_string(), "=stream"); + assert!(args.streams_prefix.is_none()); + } + + #[test] + fn issue_access_token_streams_flags_conflict() { + let err = Cli::try_parse_from([ + "s2", + "issue-access-token", + "my-token", + "--streams-prefix", + "x", + "--streams-exact", + "y", + ]); + + assert!(err.is_err()); + } +} diff --git a/cli/src/config.rs b/cli/src/config.rs new file mode 100644 index 00000000..c6e74e55 --- /dev/null +++ b/cli/src/config.rs @@ -0,0 +1,436 @@ +use std::{ + fs::{File, OpenOptions}, + path::{Path, PathBuf}, + time::Duration, +}; + +use config::{Config, FileFormat}; +use s2_sdk::{ + self as sdk, + types::{AccountEndpoint, BasinEndpoint, S2Config, S2Endpoints}, +}; +use serde::{Deserialize, Serialize}; + +use crate::error::{CliConfigError, CliError}; + +const CONFIG_LOCK_TIMEOUT: Duration = Duration::from_secs(60); +pub const DEFAULT_ACCOUNT_ENDPOINT: &str = "https://a.s2.dev"; +pub const DEFAULT_BASIN_ENDPOINT: &str = "https://{basin}.b.s2.dev"; + +pub struct ConfigLock { + file: File, +} + +impl Drop for ConfigLock { + fn drop(&mut self) { + let _ = fs2::FileExt::unlock(&self.file); + } +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display, strum::EnumString, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum Compression { + Gzip, + Zstd, +} + +impl From for sdk::types::Compression { + fn from(value: Compression) -> Self { + match value { + Compression::Gzip => sdk::types::Compression::Gzip, + Compression::Zstd => sdk::types::Compression::Zstd, + } + } +} + +#[derive(Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct CliConfig { + /// Legacy plaintext access token. New writes use `stored_access_token`. + pub access_token: Option, + pub stored_access_token: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub pending_access_token_cleanup: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub pending_oauth_cleanup: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub pending_oauth_revocation: Vec, + pub auth_method: Option, + pub oauth: Option, + pub account_endpoint: Option, + pub basin_endpoint: Option, + pub compression: Option, + pub ssl_no_verify: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)] +pub enum AuthMethod { + /// A stored S2 access token. + #[serde(rename = "access_token")] + #[value(name = "access-token")] + AccessToken, + /// Browser login managed by `s2 login`. + #[serde(rename = "oauth")] + #[value(name = "browser-login")] + BrowserLogin, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OAuthSession { + pub issuer: String, + pub client_id: String, + pub account_endpoint: String, + pub basin_endpoint: String, + pub credential_id: String, + pub credential_store: CredentialStore, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CredentialStore { + Keyring, + File, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoredCredentialReference { + pub credential_id: String, + pub credential_store: CredentialStore, +} + +impl OAuthSession { + pub fn credential_reference(&self) -> StoredCredentialReference { + StoredCredentialReference { + credential_id: self.credential_id.clone(), + credential_store: self.credential_store, + } + } +} + +#[cfg(target_os = "windows")] +pub fn config_path() -> Result { + let mut path = dirs::config_dir().ok_or(CliConfigError::DirNotFound)?; + path.push("s2"); + path.push("config.toml"); + Ok(path) +} + +#[cfg(not(target_os = "windows"))] +pub fn config_path() -> Result { + let mut path = dirs::home_dir().ok_or(CliConfigError::DirNotFound)?; + path.push(".config"); + path.push("s2"); + path.push("config.toml"); + Ok(path) +} + +/// Returns the config file path as a displayable string, falling back to a +/// placeholder if the home directory cannot be determined. Used in user-facing +/// error messages where panicking would be inappropriate. +pub fn config_path_string() -> String { + config_path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "".to_string()) +} + +pub fn load_config_file() -> Result { + let path = config_path()?; + if !path.exists() { + return Ok(CliConfig::default()); + } + let builder = Config::builder().add_source(config::File::new( + path.to_str().expect("config path is valid utf8"), + FileFormat::Toml, + )); + Ok(builder.build()?.try_deserialize::()?) +} + +pub fn load_cli_config() -> Result { + // Validate the secret without putting an environment value in serializable config state. + access_token_from_environment()?; + let mut config = load_config_file()?; + for (name, key) in [ + ("S2_ACCOUNT_ENDPOINT", ConfigKey::AccountEndpoint), + ("S2_BASIN_ENDPOINT", ConfigKey::BasinEndpoint), + ("S2_COMPRESSION", ConfigKey::Compression), + ("S2_SSL_NO_VERIFY", ConfigKey::SslNoVerify), + ] { + if let Some(value) = environment_value(name)? { + config.set(key, value)?; + } + } + Ok(config) +} + +fn environment_value(name: &'static str) -> Result, CliConfigError> { + let Some(value) = std::env::var_os(name) else { + return Ok(None); + }; + if value.is_empty() { + return Ok(None); + } + value + .into_string() + .map(Some) + .map_err(|_| CliConfigError::InvalidEnvironmentValue(name)) +} + +pub fn access_token_from_environment() -> Result, CliConfigError> { + let Some(value) = std::env::var_os("S2_ACCESS_TOKEN") else { + return Ok(None); + }; + if value.is_empty() { + return Ok(None); + } + value + .into_string() + .map(Some) + .map_err(|_| CliConfigError::InvalidAccessTokenEnvironment) +} + +pub async fn acquire_config_lock() -> Result { + let path = config_path()?; + let parent = path.parent().ok_or(CliConfigError::DirNotFound)?; + std::fs::create_dir_all(parent).map_err(CliConfigError::Lock)?; + secure_config_dir(parent).map_err(CliConfigError::Lock)?; + let lock_path = path.with_file_name("config.lock"); + let mut options = OpenOptions::new(); + options.create(true).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let file = options.open(lock_path).map_err(CliConfigError::Lock)?; + let deadline = tokio::time::Instant::now() + CONFIG_LOCK_TIMEOUT; + + loop { + match fs2::FileExt::try_lock_exclusive(&file) { + Ok(()) => return Ok(ConfigLock { file }), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if tokio::time::Instant::now() >= deadline { + return Err(CliConfigError::LockTimedOut); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(error) => return Err(CliConfigError::Lock(error)), + } + } +} + +#[derive( + Debug, Clone, Copy, clap::ValueEnum, strum::Display, strum::EnumString, strum::VariantNames, +)] +#[clap(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum ConfigKey { + #[value(hide = true)] + AccessToken, + AccountEndpoint, + BasinEndpoint, + Compression, + SslNoVerify, +} + +impl CliConfig { + pub fn has_legacy_access_token(&self) -> bool { + self.access_token + .as_ref() + .is_some_and(|token| !token.is_empty()) + } + + pub fn has_stored_access_token(&self) -> bool { + self.stored_access_token.is_some() || self.has_legacy_access_token() + } + + pub fn get(&self, key: ConfigKey) -> Option { + match key { + ConfigKey::AccessToken => self + .has_stored_access_token() + .then(|| "".to_owned()), + ConfigKey::AccountEndpoint => self.account_endpoint.clone(), + ConfigKey::BasinEndpoint => self.basin_endpoint.clone(), + ConfigKey::Compression => self.compression.map(|c| c.to_string()), + ConfigKey::SslNoVerify => self.ssl_no_verify.map(|v| v.to_string()), + } + } + + pub fn set(&mut self, key: ConfigKey, value: String) -> Result<(), CliConfigError> { + match key { + ConfigKey::AccessToken => return Err(CliConfigError::CredentialManagedSeparately), + ConfigKey::AccountEndpoint => self.account_endpoint = Some(value), + ConfigKey::BasinEndpoint => self.basin_endpoint = Some(value), + ConfigKey::Compression => { + self.compression = Some( + value + .parse() + .map_err(|_| CliConfigError::InvalidValue(key.to_string(), value))?, + ); + } + ConfigKey::SslNoVerify => { + self.ssl_no_verify = Some( + value + .parse() + .map_err(|_| CliConfigError::InvalidValue(key.to_string(), value))?, + ); + } + } + Ok(()) + } + + pub fn unset(&mut self, key: ConfigKey) -> Result<(), CliConfigError> { + match key { + ConfigKey::AccessToken => return Err(CliConfigError::CredentialManagedSeparately), + ConfigKey::AccountEndpoint => self.account_endpoint = None, + ConfigKey::BasinEndpoint => self.basin_endpoint = None, + ConfigKey::Compression => self.compression = None, + ConfigKey::SslNoVerify => self.ssl_no_verify = None, + } + Ok(()) + } +} + +pub fn save_cli_config(config: &CliConfig) -> Result { + let path = config_path()?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(CliConfigError::Write)?; + secure_config_dir(parent).map_err(CliConfigError::Write)?; + } + + let toml = toml::to_string(config).map_err(CliConfigError::Serialize)?; + write_config_file(&path, &toml).map_err(CliConfigError::Write)?; + + Ok(path) +} + +#[cfg(unix)] +fn secure_config_dir(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) +} + +#[cfg(not(unix))] +fn secure_config_dir(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +fn write_config_file(path: &Path, toml: &str) -> std::io::Result<()> { + use std::io::Write as _; + + let parent = path.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid config path") + })?; + let mut temp = tempfile::NamedTempFile::new_in(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + temp.as_file() + .set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + temp.write_all(toml.as_bytes())?; + temp.as_file_mut().sync_all()?; + temp.persist(path).map_err(|error| error.error)?; + // Rename committed the config; a directory-sync failure cannot be rolled back. + let _ = sync_directory(parent); + Ok(()) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> std::io::Result<()> { + std::fs::File::open(path)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_directory(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +pub async fn set_config_value(key: ConfigKey, value: String) -> Result { + if matches!(key, ConfigKey::AccessToken) { + return Err(CliConfigError::CredentialManagedSeparately); + } + let _lock = acquire_config_lock().await?; + let mut config = load_config_file()?; + config.set(key, value)?; + save_cli_config(&config) +} + +pub async fn unset_config_value(key: ConfigKey) -> Result { + if matches!(key, ConfigKey::AccessToken) { + return Err(CliConfigError::CredentialManagedSeparately); + } + let _lock = acquire_config_lock().await?; + let mut config = load_config_file()?; + config.unset(key)?; + save_cli_config(&config) +} + +pub async fn select_auth_method(method: AuthMethod) -> Result { + let _lock = acquire_config_lock().await?; + let mut config = load_config_file()?; + match method { + AuthMethod::AccessToken if !config.has_stored_access_token() => { + return Err(CliConfigError::StoredAccessTokenNotConfigured); + } + AuthMethod::BrowserLogin if config.oauth.is_none() => { + return Err(CliConfigError::BrowserLoginNotConfigured); + } + _ => {} + } + config.auth_method = Some(method); + save_cli_config(&config) +} + +pub fn sdk_config( + config: &CliConfig, + access_token: &str, + user_agent: &str, +) -> Result { + let compression: sdk::types::Compression = config + .compression + .map(Into::into) + .unwrap_or(sdk::types::Compression::None); + + let mut sdk_config = S2Config::new(access_token) + .with_user_agent(user_agent) + .expect("valid user agent") + .with_request_timeout(Duration::from_secs(30)) + .with_compression(compression); + + match (&config.account_endpoint, &config.basin_endpoint) { + (Some(account_endpoint_str), Some(basin_endpoint_str)) => { + let account_endpoint = AccountEndpoint::new(account_endpoint_str) + .map_err(|e| CliError::EndpointsInvalid(e.to_string()))?; + let basin_endpoint = BasinEndpoint::new(basin_endpoint_str) + .map_err(|e| CliError::EndpointsInvalid(e.to_string()))?; + let endpoints = S2Endpoints::new(account_endpoint, basin_endpoint) + .map_err(|e| CliError::EndpointsInvalid(e.to_string()))?; + sdk_config = sdk_config.with_endpoints(endpoints); + } + (Some(_), None) => { + eprintln!( + "Warning: account endpoint is set but basin endpoint is not. \ + Both must be set to use custom endpoints. Using default endpoints" + ); + } + (None, Some(_)) => { + eprintln!( + "Warning: basin endpoint is set but account endpoint is not. \ + Both must be set to use custom endpoints. Using default endpoints" + ); + } + (None, None) => {} + } + + if config.ssl_no_verify == Some(true) { + tracing::warn!("SSL certificate verification is disabled."); + sdk_config = sdk_config.with_insecure_skip_cert_verification(true); + } + + Ok(sdk_config) +} diff --git a/cli/src/credential_store.rs b/cli/src/credential_store.rs new file mode 100644 index 00000000..458cd180 --- /dev/null +++ b/cli/src/credential_store.rs @@ -0,0 +1,441 @@ +#[cfg(unix)] +use std::fs::File; +use std::{ + fs, + io::Write as _, + path::{Path, PathBuf}, +}; + +use base64ct::{Base64UrlUnpadded, Encoding as _}; +use miette::Diagnostic; +use sha2::{Digest as _, Sha256}; +use thiserror::Error; + +use crate::{ + config::{CredentialStore, config_path}, + error::CliConfigError, +}; + +const KEYRING_SERVICE: &str = "s2-cli"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CredentialKind { + AccessToken, + OAuth, +} + +impl CredentialKind { + fn storage_key_prefix(self) -> &'static str { + match self { + Self::AccessToken => "access-token", + Self::OAuth => "oauth", + } + } +} + +#[derive(Debug, Error, Diagnostic)] +pub enum CredentialStoreError { + #[error("The OS credential store is unavailable: {0}")] + #[diagnostic(help( + "Unlock or enable the OS credential store and retry. To explicitly use a private plaintext file, pass `--insecure-storage`." + ))] + SecureStorageUnavailable(String), + + #[error("Failed to access the OS credential store: {0}")] + CredentialStore(String), + + #[error("Stored credential was not found")] + #[diagnostic(help( + "Run `s2 login` again for browser authentication, or `s2 auth access-token set` for an access token." + ))] + CredentialNotFound, + + #[error("Failed to {action} the credentials file")] + CredentialFile { + action: &'static str, + #[source] + source: std::io::Error, + }, + + #[error("Invalid credential path")] + InvalidPath, + + #[cfg(unix)] + #[error("The private credential file is not safely protected: {0}")] + #[diagnostic(help( + "Restrict the credential directory to the current user and the file to mode 0600, or remove it and authenticate again." + ))] + UnsafeCredentialFile(&'static str), + + #[error(transparent)] + #[diagnostic(transparent)] + Config(#[from] CliConfigError), +} + +impl CredentialStoreError { + pub fn is_transient(&self) -> bool { + matches!( + self, + Self::SecureStorageUnavailable(_) + | Self::CredentialStore(_) + | Self::CredentialFile { .. } + ) + } +} + +pub fn save( + kind: CredentialKind, + credential_id: &str, + store: CredentialStore, + bytes: &[u8], +) -> Result<(), CredentialStoreError> { + match store { + CredentialStore::Keyring => { + let value = std::str::from_utf8(bytes) + .expect("credential JSON serialization always produces valid UTF-8"); + let entry = keyring_entry(kind, credential_id).map_err(|error| { + CredentialStoreError::SecureStorageUnavailable(error.to_string()) + })?; + entry + .set_password(value) + .map_err(|error| CredentialStoreError::SecureStorageUnavailable(error.to_string())) + } + CredentialStore::File => { + write_private_file(&credential_file_path(kind, credential_id)?, bytes) + } + } +} + +pub fn load( + kind: CredentialKind, + credential_id: &str, + store: CredentialStore, +) -> Result, CredentialStoreError> { + match store { + CredentialStore::Keyring => { + let entry = keyring_entry(kind, credential_id) + .map_err(|error| CredentialStoreError::CredentialStore(error.to_string()))?; + entry + .get_password() + .map(String::into_bytes) + .map_err(|error| match error { + keyring::Error::NoEntry => CredentialStoreError::CredentialNotFound, + error => CredentialStoreError::CredentialStore(error.to_string()), + }) + } + CredentialStore::File => { + let path = credential_file_path(kind, credential_id)?; + secure_private_file_for_read(&path)?; + fs::read(path).map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + CredentialStoreError::CredentialNotFound + } else { + CredentialStoreError::CredentialFile { + action: "read", + source, + } + } + }) + } + } +} + +pub fn delete( + kind: CredentialKind, + credential_id: &str, + store: CredentialStore, +) -> Result<(), CredentialStoreError> { + match store { + CredentialStore::Keyring => { + let entry = keyring_entry(kind, credential_id) + .map_err(|error| CredentialStoreError::CredentialStore(error.to_string()))?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(CredentialStoreError::CredentialStore(error.to_string())), + } + } + CredentialStore::File => { + let path = credential_file_path(kind, credential_id)?; + match fs::remove_file(&path) { + Ok(()) => { + let parent = path.parent().ok_or(CredentialStoreError::InvalidPath)?; + // Deletion already committed; a directory-sync failure is not recoverable. + let _ = sync_directory(parent); + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(CredentialStoreError::CredentialFile { + action: "delete", + source, + }), + } + } + } +} + +pub fn credential_file_path( + kind: CredentialKind, + credential_id: &str, +) -> Result { + let digest = Sha256::digest(credential_id.as_bytes()); + let filename = format!( + "{}-{}.json", + kind.storage_key_prefix(), + Base64UrlUnpadded::encode_string(digest.as_slice()) + ); + Ok(config_path()?.with_file_name(filename)) +} + +pub fn credential_location( + kind: CredentialKind, + credential_id: &str, + store: CredentialStore, +) -> String { + match store { + CredentialStore::Keyring => format!( + "OS credential store service `{KEYRING_SERVICE}`, account `{}`", + keyring_account(kind, credential_id) + ), + CredentialStore::File => credential_file_path(kind, credential_id) + .map(|path| path.display().to_string()) + .unwrap_or_else(|_| format!("credential ID `{credential_id}`")), + } +} + +fn keyring_entry( + kind: CredentialKind, + credential_id: &str, +) -> Result { + keyring::Entry::new(KEYRING_SERVICE, &keyring_account(kind, credential_id)) +} + +fn keyring_account(kind: CredentialKind, credential_id: &str) -> String { + format!("{}:{credential_id}", kind.storage_key_prefix()) +} + +fn write_private_file(path: &Path, bytes: &[u8]) -> Result<(), CredentialStoreError> { + let parent = path.parent().ok_or(CredentialStoreError::InvalidPath)?; + fs::create_dir_all(parent).map_err(|source| CredentialStoreError::CredentialFile { + action: "create the parent directory for", + source, + })?; + secure_directory(parent)?; + + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|source| { + CredentialStoreError::CredentialFile { + action: "create", + source, + } + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + temp.as_file() + .set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|source| CredentialStoreError::CredentialFile { + action: "secure", + source, + })?; + } + temp.write_all(bytes) + .and_then(|()| temp.as_file_mut().sync_all()) + .map_err(|source| CredentialStoreError::CredentialFile { + action: "write", + source, + })?; + temp.persist(path) + .map_err(|error| CredentialStoreError::CredentialFile { + action: "replace", + source: error.error, + })?; + // Rename committed the credential; a directory-sync failure cannot be rolled back. + let _ = sync_directory(parent); + Ok(()) +} + +#[cfg(unix)] +fn secure_private_file_for_read(path: &Path) -> Result<(), CredentialStoreError> { + use std::os::unix::fs::PermissionsExt as _; + + let parent = path.parent().ok_or(CredentialStoreError::InvalidPath)?; + let directory = fs::symlink_metadata(parent).map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + CredentialStoreError::CredentialNotFound + } else { + CredentialStoreError::CredentialFile { + action: "inspect the parent directory for", + source, + } + } + })?; + if !directory.file_type().is_dir() { + return Err(CredentialStoreError::UnsafeCredentialFile( + "the parent path is not a directory", + )); + } + if directory.permissions().mode() & 0o077 != 0 { + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).map_err(|source| { + CredentialStoreError::CredentialFile { + action: "secure the parent directory for", + source, + } + })?; + } + + let file = fs::symlink_metadata(path).map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + CredentialStoreError::CredentialNotFound + } else { + CredentialStoreError::CredentialFile { + action: "inspect", + source, + } + } + })?; + if !file.file_type().is_file() { + return Err(CredentialStoreError::UnsafeCredentialFile( + "the credential path is not a regular file", + )); + } + if file.permissions().mode() & 0o077 != 0 { + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|source| { + CredentialStoreError::CredentialFile { + action: "secure", + source, + } + })?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn secure_private_file_for_read(_path: &Path) -> Result<(), CredentialStoreError> { + Ok(()) +} + +#[cfg(unix)] +fn secure_directory(path: &Path) -> Result<(), CredentialStoreError> { + use std::os::unix::fs::PermissionsExt as _; + + // `symlink_metadata` does not follow symlinks, so a symlinked credential + // directory is rejected before anything is written through it. This + // mirrors the check in `secure_private_file_for_read`; the alternative of + // `fs::metadata` + `set_permissions` would silently operate on the + // symlink's target. + let directory = + fs::symlink_metadata(path).map_err(|source| CredentialStoreError::CredentialFile { + action: "inspect the parent directory for", + source, + })?; + if !directory.file_type().is_dir() { + return Err(CredentialStoreError::UnsafeCredentialFile( + "the parent path is not a directory", + )); + } + // The write path needs owner rwx in addition to no group/other access, so + // repair to exactly 0700 rather than only when group/other bits are set. + if directory.permissions().mode() & 0o777 != 0o700 { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|source| { + CredentialStoreError::CredentialFile { + action: "secure the parent directory for", + source, + } + })?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn secure_directory(_path: &Path) -> Result<(), CredentialStoreError> { + Ok(()) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> Result<(), CredentialStoreError> { + File::open(path) + .and_then(|directory| directory.sync_all()) + .map_err(|source| CredentialStoreError::CredentialFile { + action: "sync the parent directory for", + source, + }) +} + +#[cfg(not(unix))] +fn sync_directory(_path: &Path) -> Result<(), CredentialStoreError> { + Ok(()) +} + +#[cfg(all(test, unix))] +mod tests { + use std::os::unix::fs::{PermissionsExt as _, symlink}; + + use super::*; + + #[test] + fn private_file_is_atomically_replaced_with_user_only_permissions() { + let directory = tempfile::tempdir().unwrap(); + let credential_directory = directory.path().join("s2"); + let path = credential_directory.join("credential.json"); + + write_private_file(&path, b"first").unwrap(); + write_private_file(&path, b"second").unwrap(); + + assert_eq!(fs::read(&path).unwrap(), b"second"); + assert_eq!( + fs::metadata(&credential_directory) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(fs::read_dir(&credential_directory).unwrap().count(), 1); + } + + #[test] + fn private_file_permissions_are_repaired_before_reading() { + let directory = tempfile::tempdir().unwrap(); + let credential_directory = directory.path().join("s2"); + let path = credential_directory.join("credential.json"); + write_private_file(&path, b"secret").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + fs::set_permissions(&credential_directory, fs::Permissions::from_mode(0o755)).unwrap(); + + secure_private_file_for_read(&path).unwrap(); + + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&credential_directory) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + } + + #[test] + fn private_file_symlinks_are_rejected() { + let directory = tempfile::tempdir().unwrap(); + let credential_directory = directory.path().join("s2"); + fs::create_dir(&credential_directory).unwrap(); + fs::set_permissions(&credential_directory, fs::Permissions::from_mode(0o700)).unwrap(); + let target = credential_directory.join("target.json"); + fs::write(&target, b"secret").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + let link = credential_directory.join("credential.json"); + symlink(&target, &link).unwrap(); + + assert!(matches!( + secure_private_file_for_read(&link), + Err(CredentialStoreError::UnsafeCredentialFile(_)) + )); + } +} diff --git a/cli/src/diff.rs b/cli/src/diff.rs new file mode 100644 index 00000000..6ea1ffe3 --- /dev/null +++ b/cli/src/diff.rs @@ -0,0 +1,937 @@ +//! Diffing over typed presentation views of basins, streams, and access tokens. + +use colored::Colorize; +use compact_str::CompactString; +use s2_api::v1::access::AccessTokenInfo as ApiAccessTokenInfo; +use s2_common::{ + access::{PermittedOperationGroups, ReadWritePermissions, ResourceSet}, + config::StreamConfig, +}; +use s2_sdk::types::AccessTokenId; +use serde::Serialize; +use serde_json::{Map, Value}; + +use crate::{ + cli::{DiffArgs, DiffOutput, DiffResourceKind}, + error::CliError, + ops, + types::{DiffResource, S2BasinAndStreamUri, S2BasinUri, resolve_stream_config}, +}; + +#[derive(Debug, Serialize)] +struct FieldDiff { + path: String, + left: Option, + right: Option, +} + +struct ResolvedDiff { + comparison: DiffComparison, + left: DiffResource, + right: DiffResource, + left_label: String, + right_label: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DiffComparison { + Basin, + Stream, + AccessToken, + StreamVsBasinDefaults, +} + +impl DiffComparison { + fn as_str(self) -> &'static str { + match self { + Self::Basin => "basin", + Self::Stream => "stream", + Self::AccessToken => "access-token", + Self::StreamVsBasinDefaults => "stream-vs-basin-defaults", + } + } +} + +#[derive(Debug)] +pub struct DiffOutcome { + pub has_differences: bool, +} + +#[derive(Serialize)] +struct JsonDiff<'a> { + resource: &'static str, + left: &'a str, + right: &'a str, + differences: &'a [FieldDiff], +} + +pub fn validate_args(args: &DiffArgs) -> Result<(), CliError> { + resolve_args(args).map(|_| ()) +} + +pub async fn run(s2: &s2_sdk::S2, args: DiffArgs) -> Result { + let ResolvedDiff { + comparison, + left, + right, + left_label, + right_label, + } = resolve_args(&args)?; + + let (left_view, right_view) = match (left, right) { + (DiffResource::Basin(left), DiffResource::Basin(right)) => { + let (left_config, right_config) = tokio::try_join!( + ops::get_basin_config_api(s2, &left), + ops::get_basin_config_api(s2, &right), + )?; + (basin_view(left_config)?, basin_view(right_config)?) + } + (DiffResource::Stream(left), DiffResource::Stream(right)) => { + let (left_config, right_config) = tokio::try_join!( + ops::get_stream_config_api(s2, left), + ops::get_stream_config_api(s2, right), + )?; + (stream_view(left_config)?, stream_view(right_config)?) + } + (DiffResource::Basin(basin), DiffResource::Stream(stream)) => { + let (basin_config, stream_config) = tokio::try_join!( + ops::get_basin_config_api(s2, &basin), + ops::get_stream_config_api(s2, stream), + )?; + ( + basin_stream_defaults_view(basin_config)?, + stream_view(stream_config)?, + ) + } + (DiffResource::Stream(stream), DiffResource::Basin(basin)) => { + let (stream_config, basin_config) = tokio::try_join!( + ops::get_stream_config_api(s2, stream), + ops::get_basin_config_api(s2, &basin), + )?; + ( + stream_view(stream_config)?, + basin_stream_defaults_view(basin_config)?, + ) + } + (DiffResource::AccessToken(left), DiffResource::AccessToken(right)) => { + let (left_info, right_info) = tokio::try_join!( + ops::get_access_token_api(s2, left), + ops::get_access_token_api(s2, right), + )?; + ( + access_token_view(left_info)?, + access_token_view(right_info)?, + ) + } + _ => unreachable!("diff arguments are resolved to matching resource types"), + }; + + let differences = field_diffs(&left_view, &right_view); + let has_differences = !differences.is_empty(); + + match args.output { + DiffOutput::Text => print_text_diff(&left_label, &right_label, &differences), + DiffOutput::Json => println!( + "{}", + serde_json::to_string_pretty(&JsonDiff { + resource: comparison.as_str(), + left: &args.left, + right: &args.right, + differences: &differences, + })? + ), + } + + Ok(DiffOutcome { has_differences }) +} + +fn resolve_args(args: &DiffArgs) -> Result { + let (comparison, left, right) = match args.resource { + Some(kind) => { + let comparison = comparison_for_kind(kind); + ( + comparison, + parse_explicit_resource(kind, &args.left)?, + parse_explicit_resource(kind, &args.right)?, + ) + } + None => { + let left = infer_resource(&args.left)?; + let right = infer_resource(&args.right)?; + let left_kind = resource_kind(&left); + let right_kind = resource_kind(&right); + let comparison = if left_kind == right_kind { + comparison_for_kind(left_kind) + } else if stream_belongs_to_basin(&left, &right) { + DiffComparison::StreamVsBasinDefaults + } else if matches!( + (&left, &right), + (DiffResource::Basin(_), DiffResource::Stream(_)) + | (DiffResource::Stream(_), DiffResource::Basin(_)) + ) { + return Err(different_basin_error(&left, &right)); + } else { + return Err(CliError::InvalidArgs(miette::miette!( + help = "Both operands must identify the same kind of resource. A stream may also be compared with its own basin's defaults.", + "Cannot diff a {} against a {}", + left_kind.as_str(), + right_kind.as_str(), + ))); + }; + (comparison, left, right) + } + }; + + let mut left_label = args.left.clone(); + let mut right_label = args.right.clone(); + if comparison == DiffComparison::StreamVsBasinDefaults { + if matches!(&left, DiffResource::Basin(_)) { + left_label.push_str(" (stream defaults)"); + } else { + right_label.push_str(" (stream defaults)"); + } + } + + Ok(ResolvedDiff { + comparison, + left, + right, + left_label, + right_label, + }) +} + +fn comparison_for_kind(kind: DiffResourceKind) -> DiffComparison { + match kind { + DiffResourceKind::Basin => DiffComparison::Basin, + DiffResourceKind::Stream => DiffComparison::Stream, + DiffResourceKind::AccessToken => DiffComparison::AccessToken, + } +} + +fn stream_belongs_to_basin(left: &DiffResource, right: &DiffResource) -> bool { + match (left, right) { + (DiffResource::Basin(basin), DiffResource::Stream(stream)) + | (DiffResource::Stream(stream), DiffResource::Basin(basin)) => &stream.basin == basin, + _ => false, + } +} + +fn different_basin_error(left: &DiffResource, right: &DiffResource) -> CliError { + let (basin, stream) = match (left, right) { + (DiffResource::Basin(basin), DiffResource::Stream(stream)) + | (DiffResource::Stream(stream), DiffResource::Basin(basin)) => (basin, stream), + _ => unreachable!("called only for a basin and stream pair"), + }; + + CliError::InvalidArgs(miette::miette!( + help = format!( + "Use `s2://{}` to compare this stream with its basin defaults.", + stream.basin + ), + "Stream `s2://{}/{}` does not belong to basin `s2://{basin}`", + stream.basin, + stream.stream, + )) +} + +fn infer_resource(value: &str) -> Result { + if !value.contains("://") { + return Err(CliError::InvalidArgs(miette::miette!( + help = "Specify `--resource basin` for basin names or `--resource access-token` for access token IDs.", + "Cannot infer a resource type from bare name `{value}`" + ))); + } + + value.parse().map_err(|error: String| { + CliError::InvalidArgs(miette::miette!( + help = "Use an S2 URI such as `s2://my-basin` or `s2://my-basin/my-stream`.", + "Invalid S2 resource `{value}`: {error}" + )) + }) +} + +fn parse_explicit_resource(kind: DiffResourceKind, value: &str) -> Result { + let parsed = match kind { + DiffResourceKind::Basin => value + .parse::() + .map(|uri| DiffResource::Basin(uri.0)) + .map_err(|error| error.to_string()), + DiffResourceKind::Stream => value + .parse::() + .map(DiffResource::Stream) + .map_err(|error| error.to_string()), + DiffResourceKind::AccessToken => value + .parse::() + .map(DiffResource::AccessToken) + .map_err(|error| error.to_string()), + }; + + parsed.map_err(|error| { + CliError::InvalidArgs(miette::miette!( + help = explicit_resource_help(kind), + "Invalid {} `{value}`: {error}", + kind.as_str(), + )) + }) +} + +fn explicit_resource_help(kind: DiffResourceKind) -> &'static str { + match kind { + DiffResourceKind::Basin => { + "Use a basin name such as `my-basin` or an S2 URI such as `s2://my-basin`." + } + DiffResourceKind::Stream => "Use an S2 URI such as `s2://my-basin/my-stream`.", + DiffResourceKind::AccessToken => "Use an access token ID such as `production-reader`.", + } +} + +fn resource_kind(resource: &DiffResource) -> DiffResourceKind { + match resource { + DiffResource::Basin(_) => DiffResourceKind::Basin, + DiffResource::Stream(_) => DiffResourceKind::Stream, + DiffResource::AccessToken(_) => DiffResourceKind::AccessToken, + } +} + +/// Presentation of a stream configuration for comparison. +/// +/// - Field names and nesting mirror the API wire format so that `--output json` paths line up with +/// API responses. +/// - Durations render compact (`"7d"`, `"1h30m"`); retention renders as `"infinite"` or an age +/// duration. +#[derive(Serialize)] +struct StreamConfigView { + storage_class: Option, + retention_policy: String, + timestamping: TimestampingConfigView, + delete_on_empty: DeleteOnEmptyConfigView, +} + +#[derive(Serialize)] +struct TimestampingConfigView { + mode: String, + uncapped: bool, +} + +#[derive(Serialize)] +struct DeleteOnEmptyConfigView { + min_age: String, +} + +impl From for StreamConfigView { + fn from(config: StreamConfig) -> Self { + let StreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = config; + let s2_common::config::TimestampingConfig { mode, uncapped } = timestamping; + + Self { + storage_class, + retention_policy: match retention_policy { + s2_common::config::RetentionPolicy::Age(age) => compact_duration(age), + s2_common::config::RetentionPolicy::Infinite() => "infinite".to_owned(), + }, + timestamping: TimestampingConfigView { + mode: wire_name(s2_api::v1::config::TimestampingMode::from(mode)), + uncapped, + }, + delete_on_empty: DeleteOnEmptyConfigView { + min_age: compact_duration(delete_on_empty.min_age), + }, + } + } +} + +/// Presentation of an effective basin configuration, with stream defaults materialized. +#[derive(Serialize)] +struct BasinConfigView { + default_stream_config: StreamConfigView, + stream_cipher: String, + create_stream_on_append: bool, + create_stream_on_read: bool, +} + +impl TryFrom for BasinConfigView { + type Error = s2_common::ValidationError; + + fn try_from(config: s2_api::v1::config::BasinConfig) -> Result { + let s2_api::v1::config::BasinConfig { + default_stream_config, + stream_cipher, + create_stream_on_append, + create_stream_on_read, + } = config; + + Ok(Self { + default_stream_config: resolve_stream_config( + default_stream_config.unwrap_or_default(), + Default::default(), + )? + .into(), + stream_cipher: match stream_cipher { + None => "none".to_owned(), + Some(cipher) => wire_name(cipher), + }, + create_stream_on_append, + create_stream_on_read, + }) + } +} + +/// Presentation of an access token. +/// +/// - The token ID is identity, not configuration, so it is deliberately excluded. +/// - Operations render as a sorted list of wire-format names; sets and permissions render as +/// compact strings (`"prefix: \"prod-\""`, `"rw"`). +#[derive(Serialize)] +struct AccessTokenInfoView { + expires_at: String, + auto_prefix_streams: bool, + scope: AccessTokenScopeView, +} + +#[derive(Serialize)] +struct AccessTokenScopeView { + basins: String, + streams: String, + access_tokens: String, + op_groups: OperationGroupsView, + ops: Vec, +} + +#[derive(Serialize)] +struct OperationGroupsView { + account: String, + basin: String, + stream: String, +} + +impl From for AccessTokenScopeView { + fn from(scope: s2_common::access::AccessTokenScope) -> Self { + let s2_common::access::AccessTokenScope { + basins, + streams, + access_tokens, + op_groups, + ops, + } = scope; + let PermittedOperationGroups { + account, + basin, + stream, + } = op_groups; + + let mut ops = ops + .iter() + .map(|op| wire_name(s2_api::v1::access::Operation::from(op))) + .collect::>(); + ops.sort(); + + Self { + basins: resource_set_view(basins), + streams: resource_set_view(streams), + access_tokens: resource_set_view(access_tokens), + op_groups: OperationGroupsView { + account: permissions_view(account), + basin: permissions_view(basin), + stream: permissions_view(stream), + }, + ops, + } + } +} + +fn resource_set_view(set: ResourceSet) -> String { + match set { + ResourceSet::None => "none".to_owned(), + ResourceSet::Exact(exact) => format!("exact: \"{exact}\""), + ResourceSet::Prefix(prefix) => format!("prefix: \"{prefix}\""), + } +} + +fn permissions_view(permissions: ReadWritePermissions) -> String { + let ReadWritePermissions { read, write } = permissions; + match (read, write) { + (true, true) => "rw", + (true, false) => "r", + (false, true) => "w", + (false, false) => "none", + } + .to_owned() +} + +fn basin_view(config: s2_api::v1::config::BasinConfig) -> Result { + Ok(serde_json::to_value(BasinConfigView::try_from(config)?)?) +} + +/// Renders a basin's effective stream defaults for comparison against a concrete stream. +fn basin_stream_defaults_view(config: s2_api::v1::config::BasinConfig) -> Result { + stream_view(config.default_stream_config.unwrap_or_default()) +} + +fn stream_view(config: s2_api::v1::config::StreamConfig) -> Result { + let config = resolve_stream_config(config, Default::default())?; + Ok(serde_json::to_value(StreamConfigView::from(config))?) +} + +fn access_token_view(info: ApiAccessTokenInfo) -> Result { + let ApiAccessTokenInfo { + // Identity, not configuration. + id: _, + expires_at, + auto_prefix_streams, + scope, + } = info; + let scope: s2_common::access::AccessTokenScope = scope.try_into()?; + + Ok(serde_json::to_value(AccessTokenInfoView { + expires_at: match expires_at { + None => "never".to_owned(), + Some(at) => humantime::format_rfc3339_seconds(at.into()).to_string(), + }, + auto_prefix_streams, + scope: scope.into(), + })?) +} + +/// Wire-format name of a unit enum variant, taken from its serde serialization so that +/// presentation never drifts from the API's actual field vocabulary. +fn wire_name(value: impl Serialize) -> String { + match serde_json::to_value(value) { + Ok(Value::String(name)) => name, + _ => unreachable!("wire enums serialize to strings"), + } +} + +/// Compact, exact duration rendering. +/// +/// - Uses only units with a fixed number of seconds (`d`/`h`/`m`/`s`), so the rendering is exact +/// and round-trips with CLI inputs: a `60d` retention policy renders as `60d`, not humantime's +/// approximate `1month 29d 13h 26m 24s`. +/// - Distinct durations always render as distinct strings, which the diff relies on. +fn compact_duration(duration: std::time::Duration) -> String { + use std::fmt::Write; + + let mut seconds = duration.as_secs(); + if seconds == 0 { + return "0s".to_owned(); + } + + let mut rendered = String::new(); + for (unit_seconds, unit) in [(86_400, "d"), (3_600, "h"), (60, "m"), (1, "s")] { + let count = seconds / unit_seconds; + seconds %= unit_seconds; + if count > 0 { + if !rendered.is_empty() { + rendered.push(' '); + } + write!(rendered, "{count}{unit}").expect("string writes are infallible"); + } + } + rendered +} + +fn print_text_diff(left_label: &str, right_label: &str, differences: &[FieldDiff]) { + println!("{}", format!("--- {left_label}").red().bold()); + println!("{}", format!("+++ {right_label}").green().bold()); + + if differences.is_empty() { + println!(); + println!("{}", "✓ No differences".green().bold()); + return; + } + + for difference in differences { + println!(); + println!("{}", difference.path.bold()); + print_value("-", difference.left.as_ref(), true); + print_value("+", difference.right.as_ref(), false); + } +} + +fn print_value(prefix: &str, value: Option<&Value>, removed: bool) { + for line in format_value_lines(value) { + if removed { + println!("{} {}", prefix.red().bold(), line.red()); + } else { + println!("{} {}", prefix.green().bold(), line.green()); + } + } +} + +fn format_value_lines(value: Option<&Value>) -> Vec { + match value { + None => vec!["∅".to_owned()], + Some(Value::String(value)) => vec![value.clone()], + Some(value @ (Value::Array(_) | Value::Object(_))) => serde_json::to_string_pretty(value) + .expect("JSON values always serialize") + .lines() + .map(str::to_owned) + .collect(), + Some(value) => vec![value.to_string()], + } +} + +fn field_diffs(left: &Value, right: &Value) -> Vec { + let mut diffs = Vec::new(); + collect_field_diffs(None, Some(left), Some(right), &mut diffs); + diffs +} + +fn collect_field_diffs( + path: Option<&str>, + left: Option<&Value>, + right: Option<&Value>, + diffs: &mut Vec, +) { + match (left, right) { + (Some(Value::Object(left)), Some(Value::Object(right))) => { + collect_object_diffs(path, left, right, diffs); + } + (Some(left), Some(right)) if left == right => {} + _ => diffs.push(FieldDiff { + path: path.unwrap_or("value").to_owned(), + left: left.cloned(), + right: right.cloned(), + }), + } +} + +fn collect_object_diffs( + path: Option<&str>, + left: &Map, + right: &Map, + diffs: &mut Vec, +) { + for (field, left_value) in left { + let field_path = join_path(path, field); + collect_field_diffs(Some(&field_path), Some(left_value), right.get(field), diffs); + } + + for (field, right_value) in right { + if !left.contains_key(field) { + let field_path = join_path(path, field); + collect_field_diffs(Some(&field_path), None, Some(right_value), diffs); + } + } +} + +fn join_path(parent: Option<&str>, field: &str) -> String { + match parent { + Some(parent) => format!("{parent}.{field}"), + None => field.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{ + DiffComparison, access_token_view, basin_stream_defaults_view, basin_view, field_diffs, + format_value_lines, resolve_args, resource_kind, stream_view, + }; + use crate::cli::{DiffArgs, DiffOutput, DiffResourceKind}; + + fn args(left: &str, right: &str, resource: Option) -> DiffArgs { + DiffArgs { + left: left.to_owned(), + right: right.to_owned(), + resource, + output: DiffOutput::Text, + exit_code: false, + } + } + + #[test] + fn infers_streams_from_s2_uris() { + let resolved = resolve_args(&args( + "s2://left-basin/stream", + "s2://right-basin/stream", + None, + )) + .expect("resources resolve"); + + assert_eq!(resource_kind(&resolved.left), DiffResourceKind::Stream); + assert_eq!(resource_kind(&resolved.right), DiffResourceKind::Stream); + } + + #[test] + fn resolves_explicit_access_tokens() { + let resolved = resolve_args(&args( + "token-left", + "token-right", + Some(DiffResourceKind::AccessToken), + )) + .expect("resources resolve"); + + assert_eq!(resolved.comparison, DiffComparison::AccessToken); + } + + #[test] + fn resolves_stream_against_its_basin_defaults() { + let resolved = resolve_args(&args( + "s2://shared-basin", + "s2://shared-basin/my-stream", + None, + )) + .expect("resources resolve"); + + assert_eq!(resolved.comparison, DiffComparison::StreamVsBasinDefaults); + assert_eq!(resolved.left_label, "s2://shared-basin (stream defaults)"); + assert_eq!(resolved.right_label, "s2://shared-basin/my-stream"); + } + + #[test] + fn resolves_stream_against_basin_defaults_in_reverse() { + let resolved = resolve_args(&args( + "s2://shared-basin/my-stream", + "s2://shared-basin", + None, + )) + .expect("resources resolve"); + + assert_eq!(resolved.comparison, DiffComparison::StreamVsBasinDefaults); + assert_eq!(resolved.left_label, "s2://shared-basin/my-stream"); + assert_eq!(resolved.right_label, "s2://shared-basin (stream defaults)"); + } + + #[test] + fn rejects_stream_against_defaults_from_another_basin() { + let error = resolve_args(&args( + "s2://first-basin", + "s2://second-basin/my-stream", + None, + )) + .err() + .expect("different basins are rejected") + .to_string(); + + assert!(error.contains("does not belong to basin")); + } + + #[test] + fn rejects_bare_names_without_resource_type() { + let error = resolve_args(&args("token-left", "token-right", None)) + .err() + .expect("bare names are rejected") + .to_string(); + + assert!(error.contains("Cannot infer a resource type")); + } + + #[test] + fn stream_views_resolve_defaults_and_format_durations() { + let config = serde_json::from_value(json!({ + "retention_policy": {"age": 172800}, + "delete_on_empty": {"min_age_secs": 3600} + })) + .expect("stream config deserializes"); + + let view = stream_view(config).expect("stream view renders"); + + // Pins the full default-resolved view: a rename or format regression fails here. + assert_eq!( + view, + json!({ + "storage_class": null, + "retention_policy": "2d", + "timestamping": {"mode": "client-prefer", "uncapped": false}, + "delete_on_empty": {"min_age": "1h"} + }) + ); + } + + #[test] + fn durations_render_exactly_at_every_scale() { + for (seconds, rendered) in [ + (0, "0s"), + (1, "1s"), + (90 * 60, "1h 30m"), + (86_400, "1d"), + (36 * 3_600, "1d 12h"), + (60 * 86_400, "60d"), + (90 * 86_400, "90d"), + (365 * 86_400, "365d"), + (86_400 + 3_600 + 60 + 1, "1d 1h 1m 1s"), + ] { + assert_eq!( + super::compact_duration(std::time::Duration::from_secs(seconds)), + rendered, + ); + } + } + + #[test] + fn omitted_and_explicit_defaults_render_identically() { + let omitted = serde_json::from_value(json!({})).expect("empty config deserializes"); + let explicit = serde_json::from_value(json!({ + "storage_class": null, + "retention_policy": {"age": 604800}, + "timestamping": {"mode": "client-prefer", "uncapped": false}, + "delete_on_empty": {"min_age_secs": 0} + })) + .expect("explicit config deserializes"); + + assert_eq!( + stream_view(omitted).expect("omitted view renders"), + stream_view(explicit).expect("explicit view renders"), + ); + } + + #[test] + fn basin_views_materialize_effective_stream_defaults() { + let config = serde_json::from_value(json!({ + "default_stream_config": null, + "stream_cipher": null, + "create_stream_on_append": true, + "create_stream_on_read": false + })) + .expect("basin config deserializes"); + + let view = basin_view(config).expect("basin view renders"); + + assert_eq!( + view, + json!({ + "default_stream_config": { + "storage_class": null, + "retention_policy": "7d", + "timestamping": {"mode": "client-prefer", "uncapped": false}, + "delete_on_empty": {"min_age": "0s"} + }, + "stream_cipher": "none", + "create_stream_on_append": true, + "create_stream_on_read": false + }) + ); + } + + #[test] + fn basin_defaults_view_matches_equivalent_stream_view() { + let basin = serde_json::from_value(json!({ + "default_stream_config": {"retention_policy": {"age": 259200}}, + "stream_cipher": null, + "create_stream_on_append": false, + "create_stream_on_read": false + })) + .expect("basin config deserializes"); + let stream = serde_json::from_value(json!({ + "retention_policy": {"age": 259200} + })) + .expect("stream config deserializes"); + + assert_eq!( + basin_stream_defaults_view(basin).expect("defaults view renders"), + stream_view(stream).expect("stream view renders"), + ); + } + + #[test] + fn infinite_and_finite_retention_render_distinctly() { + let infinite = serde_json::from_value(json!({ + "retention_policy": {"infinite": {}} + })) + .expect("infinite config deserializes"); + let finite = serde_json::from_value(json!({ + "retention_policy": {"age": 604800} + })) + .expect("finite config deserializes"); + + let diffs = field_diffs( + &stream_view(infinite).expect("infinite view renders"), + &stream_view(finite).expect("finite view renders"), + ); + + assert_eq!(diffs.len(), 1); + assert_eq!(diffs[0].path, "retention_policy"); + assert_eq!(format_value_lines(diffs[0].left.as_ref()), ["infinite"]); + assert_eq!(format_value_lines(diffs[0].right.as_ref()), ["7d"]); + } + + #[test] + fn access_token_views_canonicalize_ops_and_drop_identity() { + let left = serde_json::from_value(json!({ + "id": "left-token", + "expires_at": null, + "auto_prefix_streams": false, + "scope": { + "basins": {"prefix": "prod-"}, + "streams": null, + "access_tokens": null, + "op_groups": {"account": {"read": true, "write": false}}, + "ops": ["read", "get-stream-config", "account-metrics"] + } + })) + .expect("left access token deserializes"); + let right = serde_json::from_value(json!({ + "id": "right-token", + "expires_at": null, + "auto_prefix_streams": false, + "scope": { + "basins": {"prefix": "prod-"}, + "streams": null, + "access_tokens": null, + "op_groups": {"account": {"read": true, "write": false}}, + "ops": ["account-metrics", "read", "get-stream-config"] + } + })) + .expect("right access token deserializes"); + + let left = access_token_view(left).expect("left view renders"); + let right = access_token_view(right).expect("right view renders"); + + // Identity and operation order do not affect the diff. + assert_eq!(left, right); + assert_eq!( + left["scope"]["ops"], + json!(["account-metrics", "get-stream-config", "read"]) + ); + assert_eq!(left["scope"]["basins"], json!("prefix: \"prod-\"")); + assert_eq!(left["scope"]["op_groups"]["account"], json!("r")); + assert_eq!(left["scope"]["op_groups"]["stream"], json!("none")); + assert_eq!(left["expires_at"], json!("never")); + assert!(left.get("id").is_none()); + } + + #[test] + fn reports_nested_field_differences() { + let diffs = field_diffs( + &json!({"unchanged": true, "config": {"storage_class": "standard"}}), + &json!({"unchanged": true, "config": {"storage_class": "express"}}), + ); + + assert_eq!(diffs.len(), 1); + assert_eq!(diffs[0].path, "config.storage_class"); + assert_eq!(format_value_lines(diffs[0].left.as_ref()), ["standard"]); + assert_eq!(format_value_lines(diffs[0].right.as_ref()), ["express"]); + } + + #[test] + fn reports_added_and_removed_fields() { + let diffs = field_diffs(&json!({"left": 1}), &json!({"right": 2})); + + assert_eq!(diffs.len(), 2); + assert_eq!(diffs[0].path, "left"); + assert_eq!(format_value_lines(diffs[0].right.as_ref()), ["∅"]); + assert_eq!(diffs[1].path, "right"); + assert_eq!(format_value_lines(diffs[1].left.as_ref()), ["∅"]); + } + + #[test] + fn formats_structured_values_across_lines() { + assert_eq!( + format_value_lines(Some(&json!(["read", "append"]))), + ["[", " \"read\",", " \"append\"", "]"] + ); + } + + #[test] + fn omits_unchanged_fields() { + assert!(field_diffs(&json!({"same": [1, 2]}), &json!({"same": [1, 2]})).is_empty()); + } +} diff --git a/cli/src/error.rs b/cli/src/error.rs new file mode 100644 index 00000000..4f50428c --- /dev/null +++ b/cli/src/error.rs @@ -0,0 +1,364 @@ +use miette::Diagnostic; +use s2_sdk::error::{ + AppendError, AppendSessionError, ErrorCode, ProducerError, ReadError, ReadSessionError, + RequestError, ServerError, +}; +use thiserror::Error; + +const HELP: &str = color_print::cstr!( + "\nNotice something wrong?\n\n\ + > Open an issue:\n\ + https://github.com/s2-streamstore/s2/issues\n\n\ + > Reach out to us:\n\ + hi@s2.dev" +); + +const BUG_HELP: &str = color_print::cstr!( + "\nLooks like you may have encountered a bug!\n\n\ + > Report this issue here: \n\ + https://github.com/s2-streamstore/s2/issues +" +); + +#[derive(Error, Debug)] +pub enum SdkError { + #[error(transparent)] + Request(#[from] RequestError), + #[error(transparent)] + Read(#[from] ReadError), + #[error(transparent)] + Append(#[from] AppendError), + #[error(transparent)] + AppendSession(#[from] AppendSessionError), + #[error(transparent)] + ReadSession(#[from] ReadSessionError), + #[error(transparent)] + Producer(#[from] ProducerError), +} + +impl SdkError { + fn request_error(&self) -> Option<&RequestError> { + match self { + Self::Request(error) => Some(error), + Self::Read(error) => error.request_error(), + Self::Append(error) => error.request_error(), + Self::AppendSession(error) => error.request_error(), + Self::ReadSession(error) => error.request_error(), + Self::Producer(error) => error.request_error(), + } + } + + fn server_error(&self) -> Option<&ServerError> { + self.request_error().and_then(RequestError::server_error) + } +} + +#[derive(Error, Debug, Diagnostic)] +pub enum CliError { + #[error(transparent)] + #[diagnostic(transparent)] + Config(#[from] CliConfigError), + + #[error("Invalid CLI arguments: {0}")] + #[diagnostic(transparent)] + InvalidArgs(miette::Report), + + #[error("Unable to parse S2 endpoints: {0}")] + #[diagnostic(help( + "Endpoints can be set in the config file ({}) or via the `S2_ACCOUNT_ENDPOINT` / \ + `S2_BASIN_ENDPOINT` environment variables. Make sure the values are valid URLs \ + (e.g., https://a.s2.dev).", + crate::config::config_path_string() + ))] + EndpointsInvalid(String), + + #[error("Failed to initialize S2 SDK")] + #[diagnostic(help("{}", HELP))] + SdkInit(#[source] SdkError), + + #[error("Failed to initialize S2 SDK")] + #[diagnostic(help( + "Token loaded from {1}. Verify it does not contain invalid characters.\n\ + Store one with `s2 auth access-token set`, or set `S2_ACCESS_TOKEN`.\n\n{}", + HELP + ))] + MalformedAccessToken(#[source] SdkError, TokenSource), + + #[error(transparent)] + #[diagnostic(help("{}", BUG_HELP))] + InvalidConfig(#[from] serde_json::Error), + + #[error("Failed to initialize a `Record Reader`! {0}")] + RecordReaderInit(String), + + #[error("Failed to write records: {0}")] + RecordWrite(String), + + #[error("Benchmark verification failed: {0}")] + #[diagnostic(help( + "Ensure no other writers are mutating the stream during bench and retry the test." + ))] + BenchVerification(String), + + #[error("{}: {}", .0, .1)] + #[diagnostic(help("{}", HELP))] + Operation(OpKind, #[source] SdkError), + + #[error("{}: {}", .0, .1)] + #[diagnostic(help( + "Verify the token loaded from {2} is valid and has permission for this operation, then retry.\n\ + Store one with `s2 auth access-token set`, or set `S2_ACCESS_TOKEN`." + ))] + UnauthorizedAccessToken(OpKind, #[source] SdkError, TokenSource), + + #[error("S2 Lite server error: {0}")] + #[diagnostic(help("{}", HELP))] + LiteServer(String), + + #[error(transparent)] + #[diagnostic(transparent)] + Login(#[from] crate::login::LoginError), + + #[error("Apply failed: {0}")] + #[diagnostic(help("{}", HELP))] + Apply(String), + + #[error("Access token '{0}' not found")] + AccessTokenNotFound(String), + + #[error(transparent)] + #[diagnostic(transparent)] + AccessToken(#[from] crate::access_token::AccessTokenError), + + #[error("Invalid configuration returned by S2: {0}")] + #[diagnostic(help("{}", BUG_HELP))] + InvalidApiConfig(#[from] s2_common::ValidationError), + + #[error("Update failed: {0}")] + #[diagnostic(help( + "Retry, or install the latest release manually:\n\ + https://s2.dev/docs/quickstart#get-started-with-the-cli" + ))] + Update(String), +} + +impl CliError { + pub fn op>(kind: OpKind, source: E) -> Self { + Self::Operation(kind, source.into()) + } + + pub fn with_token_source(self, token_source: Option) -> Self { + match (self, token_source) { + (CliError::Operation(kind, source), Some(token_source)) if is_auth_error(&source) => { + CliError::UnauthorizedAccessToken(kind, source, token_source) + } + (CliError::SdkInit(source), Some(token_source)) + if is_malformed_access_token(&source) => + { + CliError::MalformedAccessToken(source, token_source) + } + (err, _) => err, + } + } +} + +#[derive(Debug, Clone, Copy, strum::AsRefStr)] +#[strum(serialize_all = "title_case")] +pub enum OpKind { + ListBasins, + CreateBasin, + DeleteBasin, + GetBasinConfig, + ReconfigureBasin, + ListAccessTokens, + IssueAccessToken, + RevokeAccessToken, + ListLocations, + GetDefaultLocation, + SetDefaultLocation, + GetAccountMetrics, + GetBasinMetrics, + GetStreamMetrics, + ListStreams, + CreateStream, + DeleteStream, + GetStreamConfig, + ReconfigureStream, + CheckTail, + Trim, + #[strum(serialize = "set fencing token")] + Fence, + Append, + Read, + Tail, + Bench, +} + +impl std::fmt::Display for OpKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Failed to {}", self.as_ref().to_lowercase()) + } +} + +impl std::error::Error for OpKind {} + +#[derive(Debug, Error)] +pub enum S2UriParseError { + #[error("S2 URI must begin with `s2://`")] + MissingUriScheme, + #[error("Invalid S2 URI scheme `{0}://`. Must be `s2://`")] + InvalidUriScheme(String), + #[error("{0}")] + InvalidBasinName(String), + #[error("{0}")] + InvalidStreamName(String), + #[error("Only basin name expected but found both basin and stream names")] + UnexpectedStreamName, + #[error("Missing stream name in S2 URI")] + MissingStreamName, +} + +#[derive(Debug, Clone, Copy)] +pub enum TokenSource { + Environment, + BrowserLogin, + StoredAccessToken, + ConfigFile, +} + +impl std::fmt::Display for TokenSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TokenSource::Environment => write!(f, "environment (S2_ACCESS_TOKEN)"), + TokenSource::BrowserLogin => write!(f, "browser login"), + TokenSource::StoredAccessToken => write!(f, "stored access token"), + TokenSource::ConfigFile => write!(f, "config file"), + } + } +} + +fn is_auth_error(err: &SdkError) -> bool { + err.server_error() + .and_then(ServerError::known_code) + .is_some_and(ErrorCode::is_auth_error) +} + +fn is_malformed_access_token(err: &SdkError) -> bool { + matches!( + err.request_error(), + Some(RequestError::MalformedAccessToken(_)) + ) +} + +#[cfg(test)] +impl PartialEq for S2UriParseError { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::MissingUriScheme, Self::MissingUriScheme) => true, + (Self::InvalidUriScheme(s), Self::InvalidUriScheme(o)) if s.eq(o) => true, + (Self::InvalidBasinName(_), Self::InvalidBasinName(_)) => true, + (Self::InvalidStreamName(_), Self::InvalidStreamName(_)) => true, + (Self::MissingStreamName, Self::MissingStreamName) => true, + (Self::UnexpectedStreamName, Self::UnexpectedStreamName) => true, + _ => false, + } + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum OpGroupsParseError { + #[error("Invalid op_group format: '{value}'. Expected 'key=value'")] + InvalidFormat { value: String }, + + #[error("Invalid op_group key: '{key}'. Expected 'account', 'basin', or 'stream'")] + InvalidKey { key: String }, + + #[error("At least one permission ('r' or 'w') must be specified")] + MissingPermission, + + #[error("Invalid permission character: {0}")] + InvalidPermissionChar(char), +} + +#[derive(Debug, Error)] +pub enum RecordParseError { + #[error("Error reading: {0}")] + Io(#[from] std::io::Error), + #[error("Error parsing: {0}")] + Parse(String), +} + +impl From for RecordParseError { + fn from(s: String) -> Self { + RecordParseError::Parse(s) + } +} + +#[derive(Error, Debug, Diagnostic)] +pub enum CliConfigError { + #[error("Failed to find a home for config directory")] + DirNotFound, + + #[error("Failed to load config file")] + #[diagnostic(help( + "Run `s2 auth access-token set`, or set the `S2_ACCESS_TOKEN` environment variable." + ))] + Load, + + #[error("Failed to write config file")] + Write(#[source] std::io::Error), + + #[error("Failed to acquire the config lock")] + Lock(#[source] std::io::Error), + + #[error("Timed out waiting for another S2 process to update the config")] + LockTimedOut, + + #[error("Failed to serialize config")] + Serialize(#[source] toml::ser::Error), + + #[error("Invalid value '{1}' for config key '{0}'")] + InvalidValue(String, String), + + #[error("Access tokens are managed separately from ordinary configuration")] + #[diagnostic(help( + "Use `s2 auth access-token set` to store a token, or `s2 auth access-token remove` to forget it." + ))] + CredentialManagedSeparately, + + #[error("Stored access tokens cannot be read through `s2 config get`")] + #[diagnostic(help( + "Use the token's source of truth if it must be exported. The CLI intentionally does not print stored credentials." + ))] + CredentialNotReadable, + + #[error("Missing access token")] + #[diagnostic(help( + "Run `s2 login`, `s2 auth access-token set`, or set the `S2_ACCESS_TOKEN` environment variable." + ))] + MissingAccessToken, + + #[error("S2_ACCESS_TOKEN is not valid Unicode")] + #[diagnostic(help("Set S2_ACCESS_TOKEN to a valid access token and retry."))] + InvalidAccessTokenEnvironment, + + #[error("{0} is not valid Unicode")] + InvalidEnvironmentValue(&'static str), + + #[error("No access token is configured")] + #[diagnostic(help( + "Run `s2 auth access-token set` before selecting `s2 auth use access-token`." + ))] + StoredAccessTokenNotConfigured, + + #[error("No browser login is configured")] + #[diagnostic(help("Run `s2 login` before selecting `s2 auth use browser-login`."))] + BrowserLoginNotConfigured, +} + +impl From for CliConfigError { + fn from(_error: config::ConfigError) -> Self { + // Parser errors can include source excerpts containing a legacy plaintext token. + Self::Load + } +} diff --git a/cli/src/lite.rs b/cli/src/lite.rs new file mode 100644 index 00000000..0f22a268 --- /dev/null +++ b/cli/src/lite.rs @@ -0,0 +1,9 @@ +pub use s2_lite::server::LiteArgs; + +use crate::error::CliError; + +pub async fn run(args: LiteArgs) -> Result<(), CliError> { + s2_lite::server::run(args) + .await + .map_err(|e| CliError::LiteServer(e.to_string())) +} diff --git a/cli/src/login.rs b/cli/src/login.rs new file mode 100644 index 00000000..2fb0caa4 --- /dev/null +++ b/cli/src/login.rs @@ -0,0 +1,2051 @@ +use std::{ + collections::HashSet, + fs::{self, File, OpenOptions}, + path::{Path, PathBuf}, + process::Command, + sync::{Arc, Mutex}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use async_trait::async_trait; +use axum::{ + Router, + extract::{Query, State}, + http::{HeaderValue, StatusCode}, + response::{IntoResponse, Redirect, Response}, + routing::get, +}; +use base64ct::{Base64UrlUnpadded, Encoding as _}; +use colored::Colorize; +use miette::Diagnostic; +use rand::Rng as _; +use reqwest::Url; +use s2_sdk::types::{AccessTokenProvider, AccessTokenProviderError, S2Config}; +use secrecy::{ExposeSecret as _, SecretBox, SecretString}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use sha2::{Digest as _, Sha256}; +use thiserror::Error; +use tokio::{ + net::TcpListener, + sync::{Mutex as AsyncMutex, oneshot}, + task::JoinError, + time::timeout, +}; +use uuid::Uuid; + +use crate::{ + access_token::{self, AccessTokenError}, + cli::{LoginArgs, LogoutArgs}, + config::{ + AuthMethod, CliConfig, CredentialStore, OAuthSession, access_token_from_environment, + acquire_config_lock, config_path, load_cli_config, load_config_file, save_cli_config, + }, + credential_store::{self, CredentialKind, CredentialStoreError}, + error::{CliConfigError, TokenSource}, +}; + +mod destination; + +use destination::validate_endpoint_binding; +pub(crate) use destination::{effective_endpoints, uses_loopback_endpoints}; + +const DEFAULT_OAUTH_ISSUER: &str = "https://clerk.s2.dev"; +const DEFAULT_OAUTH_CLIENT_ID: &str = "9zTKDS3tHSmaWl33"; +const DEFAULT_OAUTH_COMPLETION_URL: &str = "https://s2.dev/cli/login"; +const OAUTH_SCOPES: &str = "offline_access user:org:read"; +const OAUTH_CREDENTIAL_KIND: &str = "s2_oauth"; +const CREDENTIAL_VERSION: u8 = 1; +const REFRESH_SKEW: Duration = Duration::from_secs(60); +const HTTP_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_OAUTH_RESPONSE_BYTES: usize = 64 * 1024; +const CALLBACK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); +const REFRESH_LOCK_TIMEOUT: Duration = Duration::from_secs(60); + +type CallbackOutcome = Result; +type CallbackSender = Arc>>>; + +#[derive(Debug, Error, Diagnostic)] +pub enum LoginError { + #[error("Invalid OAuth issuer: {0}")] + InvalidIssuer(String), + + #[error("Invalid OAuth completion URL: {0}")] + InvalidCompletionUrl(String), + + #[error("Failed to initialize the OAuth HTTP client")] + HttpClient(#[source] reqwest::Error), + + #[error("OAuth {operation} request failed")] + Request { + operation: &'static str, + #[source] + source: reqwest::Error, + }, + + #[error("OAuth {operation} was rejected ({status}): {message}")] + Rejected { + operation: &'static str, + status: StatusCode, + message: String, + }, + + #[error("OAuth {operation} returned an invalid response")] + InvalidResponse { + operation: &'static str, + #[source] + source: serde_json::Error, + }, + + #[error("OAuth {operation} response exceeded 64 KiB")] + ResponseTooLarge { operation: &'static str }, + + #[error("Invalid OAuth authorization-server metadata: {0}")] + InvalidMetadata(String), + + #[error("OAuth token response was incomplete: {0}")] + InvalidTokenResponse(String), + + #[error("Failed to listen for the browser callback")] + #[diagnostic(help( + "Check whether local applications may listen on 127.0.0.1, then retry `s2 login`." + ))] + Listen(#[source] std::io::Error), + + #[error("The browser callback server failed")] + CallbackServer(#[source] std::io::Error), + + #[error("The browser callback task failed")] + CallbackTask(#[source] JoinError), + + #[error("Timed out waiting for browser authorization")] + #[diagnostic(help("Run `s2 login` again and finish authorization in the browser."))] + TimedOut, + + #[error("Browser authorization ended before a code was received")] + CallbackClosed, + + #[error("S2 login was not authorized: {0}")] + AuthorizationRejected(String), + + #[error("Failed to serialize OAuth credentials")] + SerializeCredentials(#[source] serde_json::Error), + + #[error("Stored OAuth credentials are invalid")] + ParseCredentials(#[source] serde_json::Error), + + #[error("Stored OAuth credentials do not match the active login")] + CredentialBindingMismatch, + + #[error("Browser login changed while credentials were being refreshed")] + #[diagnostic(help("Retry the command to use the current authentication selection."))] + SessionChanged, + + #[error("Refusing to send browser-login credentials to an untrusted S2 endpoint")] + #[diagnostic(help( + "Use the matching HTTPS S2 endpoints. Development browser logins may use loopback endpoints; use an S2-issued access token for other custom endpoints." + ))] + UnsafeBrowserDestination, + + #[error("The browser login was revoked, but local configuration could not be updated")] + #[diagnostic(help("Run `s2 logout --local-only` to finish removing it locally."))] + RevokedButNotRemoved(#[source] CliConfigError), + + #[error("OAuth tokens were refreshed, but the rotated credential could not be saved")] + #[diagnostic(help("Run `s2 login` again before the current access token expires."))] + RefreshPersistence(#[source] Box), + + #[error("The browser login was deactivated, but its local credential could not be deleted")] + #[diagnostic(help( + "Retry `s2 logout --local-only`; the credential remains queued for cleanup at {recovery}." + ))] + LogoutIncomplete { recovery: String }, + + #[error("Could not finish cleaning up an older browser login: {details}")] + #[diagnostic(help( + "Restore access to the credential store and OAuth provider, then retry. To abandon server-side revocation, run `s2 logout --local-only`." + ))] + PendingLoginCleanup { details: String }, + + #[error( + "Failed to update the config ({config}) and clean up the newly issued OAuth credential ({cleanup}); cleanup remains queued at {recovery}" + )] + LoginRollback { + #[source] + config: Box, + cleanup: Box, + recovery: String, + }, + + #[error( + "Failed to store OAuth credentials ({write}) and clean up the newly issued grant ({cleanup}); cleanup remains queued at {recovery}" + )] + CredentialWriteRollback { + write: Box, + cleanup: Box, + recovery: String, + }, + + #[error( + "Failed to journal the newly issued OAuth credential ({config}) and revoke its grant ({revocation}); the provider may still retain the grant" + )] + UnjournaledGrant { + config: Box, + revocation: Box, + }, + + #[cfg(unix)] + #[error("Failed to {action} the OAuth credentials file")] + CredentialFile { + action: &'static str, + #[source] + source: std::io::Error, + }, + + #[error("Failed to acquire the OAuth refresh lock")] + RefreshLock(#[source] std::io::Error), + + #[error("Timed out waiting for another S2 process to refresh OAuth credentials")] + RefreshLockTimedOut, + + #[error(transparent)] + #[diagnostic(transparent)] + Config(#[from] CliConfigError), + + #[error(transparent)] + #[diagnostic(transparent)] + Credential(#[from] CredentialStoreError), + + #[error(transparent)] + #[diagnostic(transparent)] + StoredAccessToken(#[from] AccessTokenError), +} + +impl LoginError { + pub(crate) fn is_transient(&self) -> bool { + match self { + Self::Request { source, .. } => source.is_connect() || source.is_timeout(), + Self::Rejected { status, .. } => { + status.is_server_error() + || matches!( + *status, + StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_MANY_REQUESTS + ) + } + #[cfg(unix)] + Self::CredentialFile { source, .. } => matches!( + source.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock + ), + Self::RefreshLock(source) => matches!( + source.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock + ), + Self::Config(CliConfigError::LockTimedOut) => true, + Self::Config(CliConfigError::Lock(source)) => matches!( + source.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock + ), + Self::RefreshPersistence(error) => error.is_transient(), + Self::Credential(error) => error.is_transient(), + Self::RefreshLockTimedOut => true, + _ => false, + } + } + + fn is_credential_not_found(&self) -> bool { + matches!( + self, + Self::Credential(CredentialStoreError::CredentialNotFound) + ) || matches!(self, Self::StoredAccessToken(error) if error.is_not_found()) + } + + fn should_reload_auth_config(&self) -> bool { + self.is_credential_not_found() || matches!(self, Self::SessionChanged) + } +} + +pub struct ResolvedCredential { + access_token: SecretString, + source: TokenSource, + oauth_session: Option, + oauth_tokens: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CredentialChoice { + Environment, + BrowserLogin, + StoredAccessToken, +} + +impl ResolvedCredential { + pub fn access_token(&self) -> &str { + self.access_token.expose_secret() + } + + pub fn source(&self) -> TokenSource { + self.source + } + + pub fn validate_destination(&self, config: &CliConfig) -> Result<(), LoginError> { + let Some(session) = self.oauth_session.as_ref() else { + return Ok(()); + }; + let (account_endpoint, basin_endpoint) = effective_endpoints(config); + if account_endpoint != session.account_endpoint || basin_endpoint != session.basin_endpoint + { + return Err(LoginError::UnsafeBrowserDestination); + } + validate_endpoint_binding( + &session.issuer, + &account_endpoint, + &basin_endpoint, + config.ssl_no_verify == Some(true), + ) + } + + pub fn configure_sdk(&self, config: S2Config) -> S2Config { + match (self.oauth_session.as_ref(), self.oauth_tokens.as_ref()) { + (Some(session), Some(tokens)) => config.with_access_token_provider( + OAuthAccessTokenProvider::new(session.clone(), tokens.clone()), + ), + _ => config, + } + } +} + +struct OAuthAccessTokenProvider { + session: OAuthSession, + state: AsyncMutex, + rejected_access_tokens: Mutex>, +} + +struct ProviderTokenState { + tokens: TokenSet, + persist_pending: bool, +} + +impl OAuthAccessTokenProvider { + fn new(session: OAuthSession, tokens: TokenSet) -> Self { + Self { + session, + state: AsyncMutex::new(ProviderTokenState { + tokens, + persist_pending: false, + }), + rejected_access_tokens: Mutex::new(HashSet::new()), + } + } +} + +impl std::fmt::Debug for OAuthAccessTokenProvider { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OAuthAccessTokenProvider") + .field("session", &self.session) + .field("state", &"") + .field("rejected_access_tokens", &"") + .finish() + } +} + +#[async_trait] +impl AccessTokenProvider for OAuthAccessTokenProvider { + async fn access_token(&self) -> Result { + // Drain rejection signals only after serializing token selection to prevent replay. + let mut state = self.state.lock().await; + let rejected_access_tokens = { + let mut rejected = self + .rejected_access_tokens + .lock() + .expect("rejected access-token mutex poisoned"); + std::mem::take(&mut *rejected) + }; + match refresh_cached_tokens_if_needed(&self.session, &mut state, &rejected_access_tokens) + .await + { + Ok(()) => Ok(state.tokens.access_token.expose_secret().to_owned()), + Err(error) => { + if !rejected_access_tokens.is_empty() { + // Keep rejection signals until a refresh succeeds. Signals that + // arrive concurrently while refreshing are retained too. + self.rejected_access_tokens + .lock() + .expect("rejected access-token mutex poisoned") + .extend(rejected_access_tokens); + } + if error.is_transient() { + Err(AccessTokenProviderError::transient(error.to_string())) + } else { + Err(AccessTokenProviderError::permanent(error.to_string())) + } + } + } + } + + fn invalidate_access_token(&self, rejected_access_token: &str) { + self.rejected_access_tokens + .lock() + .expect("rejected access-token mutex poisoned") + .insert(rejected_access_token.to_owned()); + } +} + +#[derive(Clone)] +struct CallbackState { + expected_state: String, + sender: CallbackSender, + completion_url: Option, +} + +#[derive(Clone, Copy)] +enum CallbackStatus { + Authorized, + Denied, + Error, + AlreadyCompleted, +} + +impl CallbackStatus { + fn as_str(self) -> &'static str { + match self { + Self::Authorized => "authorized", + Self::Denied => "denied", + Self::Error => "error", + Self::AlreadyCompleted => "already-completed", + } + } +} + +#[derive(Deserialize)] +struct CallbackQuery { + code: Option, + error: Option, + error_description: Option, + state: Option, +} + +#[derive(Debug, Deserialize)] +struct AuthorizationServerMetadata { + issuer: String, + authorization_endpoint: String, + token_endpoint: String, + revocation_endpoint: Option, + #[serde(default)] + response_types_supported: Vec, + #[serde(default)] + grant_types_supported: Vec, + #[serde(default)] + token_endpoint_auth_methods_supported: Vec, + #[serde(default)] + scopes_supported: Vec, + #[serde(default)] + code_challenge_methods_supported: Vec, +} + +struct OAuthEndpoints { + issuer: Url, + authorization: Url, + token: Url, + revocation: Url, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: u64, + token_type: String, + scope: Option, +} + +#[derive(Deserialize)] +struct OAuthErrorResponse { + error: Option, +} + +#[derive(Deserialize, PartialEq, Eq)] +struct OAuthIdentity { + sub: String, + org_id: String, +} + +#[derive(Clone)] +struct TokenSet { + access_token: SecretString, + refresh_token: SecretString, + expires_at: u64, +} + +#[derive(Serialize, Deserialize)] +struct StoredTokenSet { + version: u8, + kind: String, + credential_id: String, + issuer: String, + client_id: String, + access_token: String, + refresh_token: String, + expires_at: u64, +} + +struct Pkce { + verifier: String, + challenge: String, +} + +struct RefreshLock { + file: File, +} + +impl Drop for RefreshLock { + fn drop(&mut self) { + let _ = fs2::FileExt::unlock(&self.file); + } +} + +pub async fn login(args: &LoginArgs) -> Result<(), LoginError> { + let issuer = oauth_issuer(args.issuer.as_deref())?; + let client_id = oauth_client_id(args.client_id.as_deref()); + let credential_store = if args.insecure_storage { + CredentialStore::File + } else { + CredentialStore::Keyring + }; + let destination_config = load_cli_config()?; + let (account_endpoint, basin_endpoint) = effective_endpoints(&destination_config); + validate_endpoint_binding( + issuer.as_str(), + &account_endpoint, + &basin_endpoint, + destination_config.ssl_no_verify == Some(true), + )?; + { + let _config_lock = acquire_config_lock().await?; + let mut config = load_config_file()?; + prepare_login_store(&mut config, credential_store).await?; + } + let completion_url = oauth_completion_url()?; + let http = oauth_http_client()?; + let endpoints = discover(&http, &issuer).await?; + let pkce = Pkce::random(); + let state = random_base64url(); + + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .map_err(LoginError::Listen)?; + let callback_url = format!( + "http://127.0.0.1:{}/callback", + listener.local_addr().map_err(LoginError::Listen)?.port() + ); + let authorization_url = authorization_url( + &endpoints.authorization, + &client_id, + &callback_url, + &state, + &pkce.challenge, + ); + + let (callback_tx, callback_rx) = oneshot::channel(); + let callback_state = CallbackState { + expected_state: state, + sender: Arc::new(Mutex::new(Some(callback_tx))), + completion_url, + }; + let app = Router::new() + .route("/callback", get(handle_callback)) + .with_state(callback_state); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let mut server = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + }); + + show_authorization_url(&authorization_url, args.no_open); + + let callback_result = timeout(args.timeout, callback_rx).await; + let _ = shutdown_tx.send(()); + match timeout(CALLBACK_SHUTDOWN_TIMEOUT, &mut server).await { + Ok(result) => result + .map_err(LoginError::CallbackTask)? + .map_err(LoginError::CallbackServer)?, + Err(_) => { + server.abort(); + let _ = server.await; + } + } + + let code = callback_result + .map_err(|_| LoginError::TimedOut)? + .map_err(|_| LoginError::CallbackClosed)? + .map_err(LoginError::AuthorizationRejected)?; + let _config_lock = acquire_config_lock().await?; + let mut config = load_config_file()?; + prepare_login_store(&mut config, credential_store).await?; + let current_destination_config = load_cli_config()?; + let (account_endpoint, basin_endpoint) = effective_endpoints(¤t_destination_config); + validate_endpoint_binding( + issuer.as_str(), + &account_endpoint, + &basin_endpoint, + current_destination_config.ssl_no_verify == Some(true), + )?; + let previous_session = config.oauth.clone(); + let token_set = exchange_code( + &http, + &endpoints, + &client_id, + &callback_url, + &code, + &pkce.verifier, + ) + .await?; + + let reference = crate::config::StoredCredentialReference { + credential_id: Uuid::new_v4().to_string(), + credential_store, + }; + let session = OAuthSession { + issuer: endpoints.issuer.to_string(), + client_id, + account_endpoint, + basin_endpoint, + credential_id: reference.credential_id.clone(), + credential_store, + }; + let grant_may_be_reused = previous_session.as_ref().is_some_and(|previous| { + let previous_tokens = load_credentials(previous).ok(); + oauth_grant_may_be_reused(previous, previous_tokens.as_ref(), &session, &token_set) + }); + + // Journal the new credential before writing it so a crash leaves enough + // information to either revoke it or delete it without revoking a reused grant. + let mut prepared_config = config.clone(); + if grant_may_be_reused { + prepared_config + .pending_oauth_cleanup + .push(reference.clone()); + } else { + prepared_config + .pending_oauth_revocation + .push(session.clone()); + } + if let Err(error) = save_cli_config(&prepared_config) { + if !grant_may_be_reused + && let Err(revocation) = revoke_token( + &http, + &endpoints, + &session.client_id, + &token_set.refresh_token, + ) + .await + { + return Err(LoginError::UnjournaledGrant { + config: Box::new(error), + revocation: Box::new(revocation), + }); + } + return Err(error.into()); + } + + if let Err(error) = save_credentials(&session, &token_set) { + if let Err(cleanup) = + cleanup_new_credential(&http, &endpoints, &session, &token_set, grant_may_be_reused) + .await + { + return Err(LoginError::CredentialWriteRollback { + write: Box::new(error), + cleanup: Box::new(cleanup), + recovery: credential_store::credential_location( + CredentialKind::OAuth, + &session.credential_id, + session.credential_store, + ), + }); + } + return Err(error); + } + + let mut config = prepared_config; + config + .pending_oauth_cleanup + .retain(|pending| pending != &reference); + config + .pending_oauth_revocation + .retain(|pending| pending != &session); + config.auth_method = Some(AuthMethod::BrowserLogin); + config.oauth = Some(session.clone()); + if let Some(previous) = previous_session.as_ref() { + queue_replaced_session(&mut config, previous, grant_may_be_reused); + } + let saved_path = match save_cli_config(&config) { + Ok(path) => path, + Err(error) => { + let cleanup = cleanup_new_credential( + &http, + &endpoints, + &session, + &token_set, + grant_may_be_reused, + ) + .await; + return Err(match cleanup { + Err(cleanup) => LoginError::LoginRollback { + config: Box::new(error), + cleanup: Box::new(cleanup), + recovery: credential_store::credential_location( + CredentialKind::OAuth, + &session.credential_id, + session.credential_store, + ), + }, + Ok(()) => error.into(), + }); + } + }; + + let replaced_existing_login = previous_session.is_some(); + let (cleanup_changed, mut cleanup_warning) = cleanup_pending_oauth(&mut config, true).await; + if cleanup_changed && let Err(error) = save_cli_config(&config) { + append_cleanup_warning( + &mut cleanup_warning, + format!("could not update OAuth credential cleanup metadata: {error}"), + ); + } + + eprintln!("{}", "✓ Logged in to S2".green().bold()); + eprintln!(" - Browser login selected."); + if replaced_existing_login { + eprintln!(" - Previous browser login replaced."); + } + match session.credential_store { + CredentialStore::Keyring => { + eprintln!(" - Credentials saved to: {}", "OS credential store".cyan()); + } + CredentialStore::File => { + #[cfg(unix)] + eprintln!( + "{}", + " - Warning: credentials are stored in a plaintext file with user-only permissions." + .yellow() + ); + #[cfg(not(unix))] + eprintln!( + "{}", + " - Warning: credentials are stored in a plaintext file using the platform's \ + default user-directory permissions." + .yellow() + ); + eprintln!( + " - Credentials saved to: {}", + credential_file_path(&session)?.display().to_string().cyan() + ); + } + } + eprintln!( + " - Configuration saved to: {}", + saved_path.display().to_string().cyan() + ); + if let Some(error) = cleanup_warning { + eprintln!( + "{}", + format!( + " - Warning: could not finish cleaning up an older browser login; it remains queued for cleanup: {error}" + ) + .yellow() + ); + } + + if has_access_token_environment_variable() { + eprintln!( + "{}", + " - Warning: S2_ACCESS_TOKEN is set and remains active. Unset it to use browser login." + .yellow() + ); + } + + Ok(()) +} + +async fn cleanup_new_credential( + http: &reqwest::Client, + endpoints: &OAuthEndpoints, + session: &OAuthSession, + token_set: &TokenSet, + grant_may_be_reused: bool, +) -> Result<(), LoginError> { + if !grant_may_be_reused { + revoke_token( + http, + endpoints, + &session.client_id, + &token_set.refresh_token, + ) + .await?; + } + credential_store::delete( + CredentialKind::OAuth, + &session.credential_id, + session.credential_store, + )?; + Ok(()) +} + +fn same_oauth_client(left: &OAuthSession, right: &OAuthSession) -> bool { + if left.client_id != right.client_id { + return false; + } + match (Url::parse(&left.issuer), Url::parse(&right.issuer)) { + (Ok(left), Ok(right)) => left == right, + _ => left.issuer == right.issuer, + } +} + +fn oauth_grant_may_be_reused( + previous_session: &OAuthSession, + previous_tokens: Option<&TokenSet>, + next_session: &OAuthSession, + next_tokens: &TokenSet, +) -> bool { + if !same_oauth_client(previous_session, next_session) { + return false; + } + + match ( + previous_tokens.and_then(|tokens| oauth_identity(&tokens.access_token)), + oauth_identity(&next_tokens.access_token), + ) { + (Some(previous), Some(next)) => previous == next, + // The token endpoint is trusted, but identity extraction is only a + // conservative grant-reuse heuristic. Unknown token formats must not + // risk revoking a grant the new login may share. + _ => true, + } +} + +fn oauth_identity(access_token: &SecretString) -> Option { + let mut parts = access_token.expose_secret().split('.'); + let _header = parts.next()?; + let payload = parts.next()?; + let signature = parts.next()?; + if signature.is_empty() || parts.next().is_some() { + return None; + } + let payload = Base64UrlUnpadded::decode_vec(payload).ok()?; + let identity: OAuthIdentity = serde_json::from_slice(&payload).ok()?; + (!identity.sub.is_empty() && !identity.org_id.is_empty()).then_some(identity) +} + +fn has_pending_oauth_in_store(config: &CliConfig, store: CredentialStore) -> bool { + config + .pending_oauth_cleanup + .iter() + .any(|reference| reference.credential_store == store) + || config + .pending_oauth_revocation + .iter() + .any(|session| session.credential_store == store) +} + +async fn prepare_login_store( + config: &mut CliConfig, + store: CredentialStore, +) -> Result<(), LoginError> { + let (cleanup_changed, cleanup_warning) = cleanup_pending_oauth(config, true).await; + if cleanup_changed { + save_cli_config(config)?; + } + if has_pending_oauth_in_store(config, store) { + return Err(LoginError::PendingLoginCleanup { + details: cleanup_warning + .unwrap_or_else(|| "the previous cleanup is still pending".to_owned()), + }); + } + Ok(()) +} + +fn queue_replaced_session( + config: &mut CliConfig, + previous: &OAuthSession, + grant_may_be_reused: bool, +) { + if grant_may_be_reused { + let reference = previous.credential_reference(); + if !config.pending_oauth_cleanup.contains(&reference) { + config.pending_oauth_cleanup.push(reference); + } + } else if !config.pending_oauth_revocation.contains(previous) { + config.pending_oauth_revocation.push(previous.clone()); + } +} + +pub async fn logout(args: &LogoutArgs) -> Result<(), LoginError> { + let _config_lock = acquire_config_lock().await?; + let mut config = load_config_file()?; + let Some(session) = config.oauth.clone() else { + let (cleanup_changed, cleanup_warning) = + cleanup_pending_oauth(&mut config, !args.local_only).await; + if cleanup_changed { + save_cli_config(&config)?; + } + eprintln!("{}", "✓ No browser login to remove".green().bold()); + if has_access_token_environment_variable() { + eprintln!( + "{}", + " - Warning: S2_ACCESS_TOKEN is set and remains active.".yellow() + ); + } else if config.has_stored_access_token() { + eprintln!(" - Access token remains configured."); + } + if let Some(error) = cleanup_warning { + eprintln!( + "{}", + format!( + " - Warning: could not finish cleaning up an older browser login; it remains queued for cleanup: {error}" + ) + .yellow() + ); + } + return Ok(()); + }; + + let refresh_lock = acquire_refresh_lock(&session).await?; + let mut remotely_revoked = false; + if !args.local_only { + let token_set = load_credentials(&session)?; + let issuer = oauth_issuer(Some(&session.issuer))?; + let http = oauth_http_client()?; + let endpoints = discover(&http, &issuer).await?; + revoke_token( + &http, + &endpoints, + &session.client_id, + &token_set.refresh_token, + ) + .await?; + remotely_revoked = true; + } + + let reference = session.credential_reference(); + config.oauth = None; + config.auth_method = config + .has_stored_access_token() + .then_some(AuthMethod::AccessToken); + if !config.pending_oauth_cleanup.contains(&reference) { + config.pending_oauth_cleanup.push(reference.clone()); + } + if let Err(error) = save_cli_config(&config) { + if remotely_revoked { + return Err(LoginError::RevokedButNotRemoved(error)); + } + return Err(error.into()); + } + + // The config no longer selects this credential. Release its refresh lock + // before the generic cleanup path takes the same lock. + drop(refresh_lock); + let (cleanup_changed, mut cleanup_warning) = + cleanup_pending_oauth(&mut config, !args.local_only).await; + if cleanup_changed && let Err(error) = save_cli_config(&config) { + append_cleanup_warning( + &mut cleanup_warning, + format!("could not update OAuth credential cleanup metadata: {error}"), + ); + } + if config.pending_oauth_cleanup.contains(&reference) { + return Err(LoginError::LogoutIncomplete { + recovery: credential_store::credential_location( + CredentialKind::OAuth, + &reference.credential_id, + reference.credential_store, + ), + }); + } + + eprintln!("{}", "✓ Browser login removed".green().bold()); + if has_access_token_environment_variable() { + eprintln!( + "{}", + " - Warning: S2_ACCESS_TOKEN is set and remains active.".yellow() + ); + } else if config.has_stored_access_token() { + eprintln!(" - Access token selected."); + } + if args.local_only { + eprintln!( + "{}", + " - Local credentials were removed without server-side revocation.".yellow() + ); + } + if let Some(error) = cleanup_warning { + eprintln!( + "{}", + format!( + " - Warning: could not finish cleaning up an older browser login; it remains queued for cleanup: {error}" + ) + .yellow() + ); + } + + Ok(()) +} + +pub async fn resolve_access_token() -> Result<(CliConfig, ResolvedCredential), LoginError> { + let mut snapshot = load_cli_config()?; + let environment_access_token = access_token_from_environment()?; + if let Some(access_token) = environment_access_token { + access_token::validate(&access_token)?; + return Ok(( + snapshot, + ResolvedCredential { + access_token: access_token.into(), + source: TokenSource::Environment, + oauth_session: None, + oauth_tokens: None, + }, + )); + } + + for attempt in 0..4 { + match resolve_persistent_access_token(&snapshot).await { + Err(error) if error.should_reload_auth_config() && attempt < 3 => { + let latest = load_cli_config()?; + if !authentication_config_changed(&snapshot, &latest) { + return Err(error); + } + snapshot = latest; + } + result => return result.map(|credential| (snapshot, credential)), + } + } + unreachable!("the final authentication attempt returns") +} + +async fn resolve_persistent_access_token( + config: &CliConfig, +) -> Result { + match credential_choice(config, false)? { + CredentialChoice::Environment => unreachable!("environment access is handled first"), + CredentialChoice::BrowserLogin => { + let session = config + .oauth + .as_ref() + .ok_or(CliConfigError::MissingAccessToken)?; + let rejected_access_tokens = HashSet::new(); + let token_set = load_fresh_tokens(session, &rejected_access_tokens).await?; + Ok(ResolvedCredential { + access_token: token_set.access_token.clone(), + source: TokenSource::BrowserLogin, + oauth_session: Some(session.clone()), + oauth_tokens: Some(token_set.clone()), + }) + } + CredentialChoice::StoredAccessToken => Ok(ResolvedCredential { + access_token: access_token::load(config)?, + source: if config.stored_access_token.is_some() { + TokenSource::StoredAccessToken + } else { + TokenSource::ConfigFile + }, + oauth_session: None, + oauth_tokens: None, + }), + } +} + +fn authentication_config_changed(previous: &CliConfig, latest: &CliConfig) -> bool { + previous.auth_method != latest.auth_method + || previous.oauth != latest.oauth + || previous.stored_access_token != latest.stored_access_token + || previous.access_token != latest.access_token +} + +fn credential_choice( + config: &CliConfig, + has_environment_token: bool, +) -> Result { + if has_environment_token { + return Ok(CredentialChoice::Environment); + } + + match config.auth_method { + Some(AuthMethod::BrowserLogin) if config.oauth.is_some() => { + Ok(CredentialChoice::BrowserLogin) + } + Some(AuthMethod::AccessToken) if config.has_stored_access_token() => { + Ok(CredentialChoice::StoredAccessToken) + } + Some(_) => Err(CliConfigError::MissingAccessToken), + None if config.has_stored_access_token() => Ok(CredentialChoice::StoredAccessToken), + None if config.oauth.is_some() => Ok(CredentialChoice::BrowserLogin), + None => Err(CliConfigError::MissingAccessToken), + } +} + +async fn load_fresh_tokens( + session: &OAuthSession, + rejected_access_tokens: &HashSet, +) -> Result { + let _config_lock = acquire_config_lock().await?; + let config = load_config_file()?; + if config.oauth.as_ref() != Some(session) { + return Err(LoginError::SessionChanged); + } + let _lock = acquire_refresh_lock(session).await?; + let token_set = load_credentials(session)?; + if !rejected_access_tokens.contains(token_set.access_token.expose_secret()) + && token_is_fresh(&token_set)? + { + // Another process may have refreshed while this process waited for the lock. + return Ok(token_set); + } + + let issuer = oauth_issuer(Some(&session.issuer))?; + let http = oauth_http_client()?; + let endpoints = discover(&http, &issuer).await?; + let refreshed = refresh_token( + &http, + &endpoints, + &session.client_id, + &token_set.refresh_token, + ) + .await?; + save_credentials(session, &refreshed) + .map_err(|error| LoginError::RefreshPersistence(Box::new(error)))?; + Ok(refreshed) +} + +async fn refresh_cached_tokens_if_needed( + session: &OAuthSession, + state: &mut ProviderTokenState, + rejected_access_tokens: &HashSet, +) -> Result<(), LoginError> { + let rejected = rejected_access_tokens.contains(state.tokens.access_token.expose_secret()); + if !state.persist_pending && !rejected && token_is_fresh(&state.tokens)? { + return Ok(()); + } + + // Keep config -> refresh-lock ordering consistent with login/logout cleanup. + let _config_lock = acquire_config_lock().await?; + let config = load_config_file()?; + if config.oauth.as_ref() != Some(session) { + return Err(LoginError::SessionChanged); + } + let _refresh_lock = acquire_refresh_lock(session).await?; + + if !state.persist_pending { + match load_credentials(session) { + Ok(tokens) => state.tokens = tokens, + Err(error) if error.is_credential_not_found() => {} + Err(error) => return Err(error), + } + } + + if state.persist_pending { + save_credentials(session, &state.tokens) + .map_err(|error| LoginError::RefreshPersistence(Box::new(error)))?; + state.persist_pending = false; + } + + let rejected = rejected_access_tokens.contains(state.tokens.access_token.expose_secret()); + if !rejected && token_is_fresh(&state.tokens)? { + return Ok(()); + } + + let issuer = oauth_issuer(Some(&session.issuer))?; + let http = oauth_http_client()?; + let endpoints = discover(&http, &issuer).await?; + let refreshed = refresh_token( + &http, + &endpoints, + &session.client_id, + &state.tokens.refresh_token, + ) + .await?; + state.tokens = refreshed; + if let Err(error) = save_credentials(session, &state.tokens) { + state.persist_pending = true; + return Err(LoginError::RefreshPersistence(Box::new(error))); + } + Ok(()) +} + +fn oauth_http_client() -> Result { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(HTTP_TIMEOUT) + .user_agent(concat!("s2-cli/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(LoginError::HttpClient) +} + +fn oauth_issuer(override_url: Option<&str>) -> Result { + let raw = override_url + .map(str::to_owned) + .or_else(|| std::env::var("S2_OAUTH_ISSUER").ok()) + .unwrap_or_else(|| DEFAULT_OAUTH_ISSUER.to_owned()); + let url = Url::parse(&raw).map_err(|error| LoginError::InvalidIssuer(error.to_string()))?; + + if url.cannot_be_a_base() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + return Err(LoginError::InvalidIssuer( + "expected an HTTP(S) origin without credentials, path, query, or fragment".to_owned(), + )); + } + + match url.scheme() { + "https" => {} + "http" if url.host_str().is_some_and(is_loopback_host) => {} + _ => { + return Err(LoginError::InvalidIssuer( + "HTTPS is required unless the issuer is on loopback".to_owned(), + )); + } + } + + Ok(url) +} + +fn oauth_client_id(override_id: Option<&str>) -> String { + override_id + .filter(|client_id| !client_id.trim().is_empty()) + .map(str::to_owned) + .or_else(|| { + std::env::var("S2_OAUTH_CLIENT_ID") + .ok() + .filter(|client_id| !client_id.trim().is_empty()) + }) + .unwrap_or_else(|| DEFAULT_OAUTH_CLIENT_ID.to_owned()) +} + +fn oauth_completion_url() -> Result, LoginError> { + let raw = std::env::var("S2_OAUTH_COMPLETION_URL") + .ok() + .filter(|url| !url.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_OAUTH_COMPLETION_URL.to_owned()); + let url = + Url::parse(&raw).map_err(|error| LoginError::InvalidCompletionUrl(error.to_string()))?; + + if url.cannot_be_a_base() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(LoginError::InvalidCompletionUrl( + "expected an HTTP(S) URL without credentials, query, or fragment".to_owned(), + )); + } + + match url.scheme() { + "https" => {} + "http" if url.host_str().is_some_and(is_loopback_host) => {} + _ => { + return Err(LoginError::InvalidCompletionUrl( + "HTTPS is required unless the website is on loopback".to_owned(), + )); + } + } + + Ok(Some(url)) +} + +async fn discover(http: &reqwest::Client, issuer: &Url) -> Result { + let metadata_url = issuer + .join("/.well-known/oauth-authorization-server") + .expect("validated issuer is a base URL"); + let response = http + .get(metadata_url) + .send() + .await + .map_err(|source| LoginError::Request { + operation: "discovery", + source, + })?; + let (status, bytes) = read_oauth_response(response, "discovery").await?; + let metadata: AuthorizationServerMetadata = parse_oauth_response("discovery", status, &bytes)?; + validate_metadata(issuer, metadata) +} + +fn validate_metadata( + expected_issuer: &Url, + metadata: AuthorizationServerMetadata, +) -> Result { + let issuer = Url::parse(&metadata.issuer) + .map_err(|error| LoginError::InvalidMetadata(format!("invalid issuer: {error}")))?; + if &issuer != expected_issuer { + return Err(LoginError::InvalidMetadata(format!( + "issuer mismatch: expected {expected_issuer}, received {issuer}" + ))); + } + if !metadata + .code_challenge_methods_supported + .iter() + .any(|method| method == "S256") + { + return Err(LoginError::InvalidMetadata( + "the provider does not support PKCE S256".to_owned(), + )); + } + if !metadata + .response_types_supported + .iter() + .any(|response_type| response_type == "code") + { + return Err(LoginError::InvalidMetadata( + "the provider does not support the code response type".to_owned(), + )); + } + if !metadata + .token_endpoint_auth_methods_supported + .iter() + .any(|method| method == "none") + { + return Err(LoginError::InvalidMetadata( + "the provider does not support public OAuth clients".to_owned(), + )); + } + for grant in ["authorization_code", "refresh_token"] { + if !metadata + .grant_types_supported + .iter() + .any(|supported| supported == grant) + { + return Err(LoginError::InvalidMetadata(format!( + "the provider does not support the {grant} grant" + ))); + } + } + for scope in OAUTH_SCOPES.split_ascii_whitespace() { + if !metadata + .scopes_supported + .iter() + .any(|supported| supported == scope) + { + return Err(LoginError::InvalidMetadata(format!( + "the provider does not support the {scope} scope" + ))); + } + } + + let revocation_endpoint = metadata.revocation_endpoint.as_deref().ok_or_else(|| { + LoginError::InvalidMetadata("the provider does not advertise token revocation".to_owned()) + })?; + + Ok(OAuthEndpoints { + authorization: trusted_endpoint( + &issuer, + &metadata.authorization_endpoint, + "authorization_endpoint", + )?, + token: trusted_endpoint(&issuer, &metadata.token_endpoint, "token_endpoint")?, + revocation: trusted_endpoint(&issuer, revocation_endpoint, "revocation_endpoint")?, + issuer, + }) +} + +fn trusted_endpoint(issuer: &Url, raw: &str, field: &str) -> Result { + let endpoint = Url::parse(raw) + .map_err(|error| LoginError::InvalidMetadata(format!("invalid {field}: {error}")))?; + if endpoint.origin() != issuer.origin() + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.fragment().is_some() + { + return Err(LoginError::InvalidMetadata(format!( + "{field} must use the OAuth issuer origin" + ))); + } + Ok(endpoint) +} + +fn authorization_url( + authorization_endpoint: &Url, + client_id: &str, + callback_url: &str, + state: &str, + code_challenge: &str, +) -> Url { + let mut url = authorization_endpoint.clone(); + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", client_id) + .append_pair("redirect_uri", callback_url) + .append_pair("scope", OAUTH_SCOPES) + .append_pair("state", state) + .append_pair("code_challenge", code_challenge) + .append_pair("code_challenge_method", "S256"); + url +} + +async fn exchange_code( + http: &reqwest::Client, + endpoints: &OAuthEndpoints, + client_id: &str, + callback_url: &str, + code: &str, + code_verifier: &str, +) -> Result { + let response = http + .post(endpoints.token.clone()) + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", client_id), + ("code", code), + ("redirect_uri", callback_url), + ("code_verifier", code_verifier), + ]) + .send() + .await + .map_err(|source| LoginError::Request { + operation: "token exchange", + source, + })?; + parse_token_http_response("token exchange", response, None).await +} + +async fn refresh_token( + http: &reqwest::Client, + endpoints: &OAuthEndpoints, + client_id: &str, + previous_refresh_token: &SecretString, +) -> Result { + let response = http + .post(endpoints.token.clone()) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", client_id), + ("refresh_token", previous_refresh_token.expose_secret()), + ]) + .send() + .await + .map_err(|source| LoginError::Request { + operation: "token refresh", + source, + })?; + parse_token_http_response( + "token refresh", + response, + Some(previous_refresh_token.expose_secret()), + ) + .await +} + +async fn revoke_token( + http: &reqwest::Client, + endpoints: &OAuthEndpoints, + client_id: &str, + refresh_token: &SecretString, +) -> Result<(), LoginError> { + let response = http + .post(endpoints.revocation.clone()) + .form(&[ + ("token", refresh_token.expose_secret()), + ("token_type_hint", "refresh_token"), + ("client_id", client_id), + ]) + .send() + .await + .map_err(|source| LoginError::Request { + operation: "token revocation", + source, + })?; + let status = response.status(); + if status.is_success() { + return Ok(()); + } + let (_, bytes) = read_oauth_response(response, "token revocation").await?; + Err(oauth_rejection("token revocation", status, &bytes)) +} + +async fn parse_token_http_response( + operation: &'static str, + response: reqwest::Response, + previous_refresh_token: Option<&str>, +) -> Result { + let (status, bytes) = read_oauth_response(response, operation).await?; + let response: TokenResponse = parse_oauth_response(operation, status, &bytes)?; + token_set(response, previous_refresh_token) +} + +async fn read_oauth_response( + mut response: reqwest::Response, + operation: &'static str, +) -> Result<(StatusCode, Vec), LoginError> { + let status = response.status(); + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|source| LoginError::Request { operation, source })? + { + if bytes.len().saturating_add(chunk.len()) > MAX_OAUTH_RESPONSE_BYTES { + return Err(LoginError::ResponseTooLarge { operation }); + } + bytes.extend_from_slice(&chunk); + } + Ok((status, bytes)) +} + +fn token_set( + response: TokenResponse, + previous_refresh_token: Option<&str>, +) -> Result { + if response.access_token.is_empty() { + return Err(LoginError::InvalidTokenResponse( + "access_token was empty".to_owned(), + )); + } + if !response.token_type.eq_ignore_ascii_case("bearer") { + return Err(LoginError::InvalidTokenResponse( + "token_type was not bearer".to_owned(), + )); + } + if response.expires_in == 0 { + return Err(LoginError::InvalidTokenResponse( + "expires_in was zero".to_owned(), + )); + } + if let Some(scope) = response.scope.as_deref() { + for required in OAUTH_SCOPES.split_ascii_whitespace() { + if !scope + .split_ascii_whitespace() + .any(|scope| scope == required) + { + return Err(LoginError::InvalidTokenResponse(format!( + "the {required} scope was not granted" + ))); + } + } + } + let refresh_token = response + .refresh_token + .as_deref() + .filter(|token| !token.is_empty()) + .or(previous_refresh_token) + .ok_or_else(|| LoginError::InvalidTokenResponse("refresh_token was missing".to_owned()))?; + let expires_at = now_unix_seconds()? + .checked_add(response.expires_in) + .ok_or_else(|| LoginError::InvalidTokenResponse("expires_in overflowed".to_owned()))?; + + Ok(TokenSet { + access_token: response.access_token.into(), + refresh_token: refresh_token.to_owned().into(), + expires_at, + }) +} + +fn parse_oauth_response( + operation: &'static str, + status: StatusCode, + bytes: &[u8], +) -> Result { + if !status.is_success() { + return Err(oauth_rejection(operation, status, bytes)); + } + serde_json::from_slice(bytes) + .map_err(|source| LoginError::InvalidResponse { operation, source }) +} + +fn oauth_rejection(operation: &'static str, status: StatusCode, bytes: &[u8]) -> LoginError { + let message = serde_json::from_slice::(bytes) + .ok() + .and_then(|body| body.error) + .and_then(|error| known_oauth_error(&error).map(str::to_owned)) + .unwrap_or_else(|| "unknown error".to_owned()); + LoginError::Rejected { + operation, + status, + message, + } +} + +fn known_oauth_error(error: &str) -> Option<&str> { + matches!( + error, + "access_denied" + | "invalid_client" + | "invalid_grant" + | "invalid_request" + | "invalid_scope" + | "invalid_token" + | "server_error" + | "temporarily_unavailable" + | "unauthorized_client" + | "unsupported_grant_type" + | "unsupported_response_type" + | "unsupported_token_type" + ) + .then_some(error) +} + +async fn handle_callback( + State(state): State, + Query(query): Query, +) -> Response { + if query.state.as_deref() != Some(&state.expected_state) { + return callback_response( + state.completion_url.as_ref(), + StatusCode::BAD_REQUEST, + CallbackStatus::Error, + ); + } + + let provider_denied = query.error.as_deref() == Some("access_denied") && query.code.is_none(); + let outcome = if query.error.is_some() || query.error_description.is_some() { + if query.code.is_some() { + Err("authorization callback contained both a code and an error".to_owned()) + } else { + Err(match (query.error, query.error_description) { + (Some(error), Some(description)) => { + format!( + "{}: {}", + sanitize_message(&error), + sanitize_message(&description) + ) + } + (Some(error), None) => sanitize_message(&error), + (None, Some(description)) => sanitize_message(&description), + (None, None) => unreachable!("checked for an OAuth error"), + }) + } + } else { + match query.code { + Some(code) if !code.is_empty() => Ok(code), + _ => Err("authorization code was missing".to_owned()), + } + }; + let sender = state + .sender + .lock() + .expect("callback sender mutex poisoned") + .take(); + let Some(sender) = sender else { + return callback_response( + state.completion_url.as_ref(), + StatusCode::CONFLICT, + CallbackStatus::AlreadyCompleted, + ); + }; + + let authorized = outcome.is_ok(); + let _ = sender.send(outcome); + if authorized { + callback_response( + state.completion_url.as_ref(), + StatusCode::OK, + CallbackStatus::Authorized, + ) + } else if provider_denied { + callback_response( + state.completion_url.as_ref(), + StatusCode::BAD_REQUEST, + CallbackStatus::Denied, + ) + } else { + callback_response( + state.completion_url.as_ref(), + StatusCode::BAD_REQUEST, + CallbackStatus::Error, + ) + } +} + +fn callback_response( + completion_url: Option<&Url>, + status: StatusCode, + callback_status: CallbackStatus, +) -> Response { + let mut response = match completion_url { + Some(completion_url) => { + let mut url = completion_url.clone(); + url.query_pairs_mut() + .append_pair("status", callback_status.as_str()); + Redirect::to(url.as_str()).into_response() + } + None => status.into_response(), + }; + let headers = response.headers_mut(); + headers.insert("cache-control", HeaderValue::from_static("no-store")); + headers.insert("referrer-policy", HeaderValue::from_static("no-referrer")); + response +} + +fn show_authorization_url(url: &Url, no_open: bool) { + if !no_open { + match open_browser(url.as_str()) { + Ok(()) => { + eprintln!("Opening your browser to finish logging in..."); + eprintln!("If it does not open, visit:\n{}", url.as_str().cyan()); + return; + } + Err(error) => { + eprintln!("Could not open a browser automatically: {error}"); + } + } + } + + eprintln!( + "Open this URL to finish logging in:\n{}", + url.as_str().cyan() + ); +} + +#[cfg(target_os = "macos")] +fn open_browser(url: &str) -> std::io::Result<()> { + ensure_command_succeeded(Command::new("open").arg(url).status()?) +} + +#[cfg(target_os = "windows")] +fn open_browser(url: &str) -> std::io::Result<()> { + ensure_command_succeeded( + Command::new("rundll32") + .args(["url.dll,FileProtocolHandler", url]) + .status()?, + ) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn open_browser(url: &str) -> std::io::Result<()> { + ensure_command_succeeded(Command::new("xdg-open").arg(url).status()?) +} + +fn ensure_command_succeeded(status: std::process::ExitStatus) -> std::io::Result<()> { + if status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!( + "browser command exited with {status}" + ))) + } +} + +fn save_credentials(session: &OAuthSession, token_set: &TokenSet) -> Result<(), LoginError> { + let stored = StoredTokenSet { + version: CREDENTIAL_VERSION, + kind: OAUTH_CREDENTIAL_KIND.to_owned(), + credential_id: session.credential_id.clone(), + issuer: session.issuer.clone(), + client_id: session.client_id.clone(), + access_token: token_set.access_token.expose_secret().to_owned(), + refresh_token: token_set.refresh_token.expose_secret().to_owned(), + expires_at: token_set.expires_at, + }; + let bytes = SecretBox::new(Box::new( + serde_json::to_vec(&stored).map_err(LoginError::SerializeCredentials)?, + )); + credential_store::save( + CredentialKind::OAuth, + &session.credential_id, + session.credential_store, + bytes.expose_secret(), + )?; + Ok(()) +} + +fn load_credentials(session: &OAuthSession) -> Result { + let bytes = SecretBox::new(Box::new(credential_store::load( + CredentialKind::OAuth, + &session.credential_id, + session.credential_store, + )?)); + decode_credentials(session, bytes.expose_secret()) +} + +fn decode_credentials(session: &OAuthSession, bytes: &[u8]) -> Result { + let stored: StoredTokenSet = + serde_json::from_slice(bytes).map_err(LoginError::ParseCredentials)?; + if stored.version != CREDENTIAL_VERSION + || stored.kind != OAUTH_CREDENTIAL_KIND + || stored.credential_id != session.credential_id + || stored.issuer != session.issuer + || stored.client_id != session.client_id + { + return Err(LoginError::CredentialBindingMismatch); + } + if stored.access_token.is_empty() || stored.refresh_token.is_empty() { + return Err(LoginError::CredentialBindingMismatch); + } + Ok(TokenSet { + access_token: stored.access_token.into(), + refresh_token: stored.refresh_token.into(), + expires_at: stored.expires_at, + }) +} + +async fn cleanup_pending_oauth( + config: &mut CliConfig, + revoke_pending: bool, +) -> (bool, Option) { + let (revocation_changed, mut warning) = if revoke_pending { + cleanup_pending_oauth_revocations(config).await + } else { + let pending = std::mem::take(&mut config.pending_oauth_revocation); + let changed = !pending.is_empty(); + for session in pending { + let reference = session.credential_reference(); + if !config.pending_oauth_cleanup.contains(&reference) { + config.pending_oauth_cleanup.push(reference); + } + } + (changed, None) + }; + let (credential_changed, credential_warning) = cleanup_pending_oauth_credentials(config).await; + if let Some(credential_warning) = credential_warning { + append_cleanup_warning(&mut warning, credential_warning); + } + (revocation_changed || credential_changed, warning) +} + +async fn cleanup_pending_oauth_revocations(config: &mut CliConfig) -> (bool, Option) { + let pending = std::mem::take(&mut config.pending_oauth_revocation); + let mut retained = Vec::new(); + let mut errors = Vec::new(); + let mut changed = false; + for session in pending { + if config.oauth.as_ref() == Some(&session) { + errors.push(format!( + "credential {} is still active", + session.credential_id + )); + retained.push(session); + continue; + } + let _refresh_lock = match acquire_refresh_lock(&session).await { + Ok(lock) => lock, + Err(error) => { + errors.push(format!("credential {}: {error}", session.credential_id)); + retained.push(session); + continue; + } + }; + let token_set = match load_credentials(&session) { + Ok(tokens) => tokens, + Err(error) if error.is_credential_not_found() => { + changed = true; + continue; + } + Err(error) => { + errors.push(format!("credential {}: {error}", session.credential_id)); + retained.push(session); + continue; + } + }; + let result = async { + let issuer = oauth_issuer(Some(&session.issuer))?; + let http = oauth_http_client()?; + let endpoints = discover(&http, &issuer).await?; + revoke_token( + &http, + &endpoints, + &session.client_id, + &token_set.refresh_token, + ) + .await?; + credential_store::delete( + CredentialKind::OAuth, + &session.credential_id, + session.credential_store, + )?; + Ok::<(), LoginError>(()) + } + .await; + match result { + Ok(()) => changed = true, + Err(error) => { + errors.push(format!("credential {}: {error}", session.credential_id)); + retained.push(session); + } + } + } + config.pending_oauth_revocation = retained; + (changed, (!errors.is_empty()).then(|| errors.join("; "))) +} + +async fn cleanup_pending_oauth_credentials(config: &mut CliConfig) -> (bool, Option) { + let pending = std::mem::take(&mut config.pending_oauth_cleanup); + let mut retained = Vec::new(); + let mut errors = Vec::new(); + let mut removed = false; + for reference in pending { + if config + .oauth + .as_ref() + .is_some_and(|active| active.credential_reference() == reference) + { + errors.push(format!( + "credential {} is still active", + reference.credential_id + )); + retained.push(reference); + continue; + } + let _refresh_lock = match acquire_refresh_lock_for_id(&reference.credential_id).await { + Ok(lock) => lock, + Err(error) => { + errors.push(format!("credential {}: {error}", reference.credential_id)); + retained.push(reference); + continue; + } + }; + match credential_store::delete( + CredentialKind::OAuth, + &reference.credential_id, + reference.credential_store, + ) { + Ok(()) => removed = true, + Err(error) => { + errors.push(format!("credential {}: {error}", reference.credential_id)); + retained.push(reference); + } + } + } + config.pending_oauth_cleanup = retained; + (removed, (!errors.is_empty()).then(|| errors.join("; "))) +} + +fn append_cleanup_warning(warning: &mut Option, message: String) { + match warning { + Some(warning) => { + warning.push_str("; "); + warning.push_str(&message); + } + None => *warning = Some(message), + } +} + +fn credential_file_path(session: &OAuthSession) -> Result { + Ok(credential_store::credential_file_path( + CredentialKind::OAuth, + &session.credential_id, + )?) +} + +fn refresh_lock_path(credential_id: &str) -> Result { + let digest = Sha256::digest(credential_id.as_bytes()); + let filename = format!( + "oauth-{}.lock", + Base64UrlUnpadded::encode_string(digest.as_slice()) + ); + let path = config_path()?; + Ok(path.with_file_name(filename)) +} + +async fn acquire_refresh_lock(session: &OAuthSession) -> Result { + acquire_refresh_lock_for_id(&session.credential_id).await +} + +async fn acquire_refresh_lock_for_id(credential_id: &str) -> Result { + let path = refresh_lock_path(credential_id)?; + let parent = path + .parent() + .ok_or_else(|| LoginError::InvalidTokenResponse("invalid lock path".to_owned()))?; + fs::create_dir_all(parent).map_err(LoginError::RefreshLock)?; + secure_directory(parent)?; + let mut options = OpenOptions::new(); + options.create(true).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let file = options.open(path).map_err(LoginError::RefreshLock)?; + let deadline = tokio::time::Instant::now() + REFRESH_LOCK_TIMEOUT; + loop { + match fs2::FileExt::try_lock_exclusive(&file) { + Ok(()) => return Ok(RefreshLock { file }), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if tokio::time::Instant::now() >= deadline { + return Err(LoginError::RefreshLockTimedOut); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(error) => return Err(LoginError::RefreshLock(error)), + } + } +} + +#[cfg(unix)] +fn secure_directory(path: &Path) -> Result<(), LoginError> { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|source| { + LoginError::CredentialFile { + action: "secure the parent directory for", + source, + } + }) +} + +#[cfg(not(unix))] +fn secure_directory(_path: &Path) -> Result<(), LoginError> { + Ok(()) +} + +impl Pkce { + fn random() -> Self { + Self::from_verifier(random_base64url()) + } + + fn from_verifier(verifier: String) -> Self { + let digest = Sha256::digest(verifier.as_bytes()); + Self { + verifier, + challenge: Base64UrlUnpadded::encode_string(digest.as_slice()), + } + } +} + +fn random_base64url() -> String { + let mut bytes = [0_u8; 32]; + rand::rng().fill_bytes(&mut bytes); + Base64UrlUnpadded::encode_string(&bytes) +} + +fn token_is_fresh(token_set: &TokenSet) -> Result { + Ok(token_set.expires_at > now_unix_seconds()?.saturating_add(REFRESH_SKEW.as_secs())) +} + +fn now_unix_seconds() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| LoginError::InvalidTokenResponse("system clock is before 1970".to_owned())) +} + +fn is_loopback_host(host: &str) -> bool { + if host == "localhost" { + return true; + } + // `Url::host_str()` serializes IPv6 hosts in brackets (e.g. `[::1]`), + // which `IpAddr` parsing does not accept. + let host = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host); + host.parse::() + .is_ok_and(|ip| ip.is_loopback()) +} + +pub(crate) fn has_access_token_environment_variable() -> bool { + access_token_from_environment().is_ok_and(|token| token.is_some()) +} + +fn sanitize_message(message: &str) -> String { + message + .chars() + .filter(|character| !character.is_control()) + .take(512) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/cli/src/login/destination.rs b/cli/src/login/destination.rs new file mode 100644 index 00000000..55105f2b --- /dev/null +++ b/cli/src/login/destination.rs @@ -0,0 +1,135 @@ +use reqwest::Url; + +use super::{DEFAULT_OAUTH_ISSUER, LoginError, is_loopback_host}; +use crate::config::{CliConfig, DEFAULT_ACCOUNT_ENDPOINT, DEFAULT_BASIN_ENDPOINT}; + +fn is_production_issuer(issuer: &str) -> bool { + match (Url::parse(issuer), Url::parse(DEFAULT_OAUTH_ISSUER)) { + (Ok(issuer), Ok(production)) => issuer == production, + _ => issuer == DEFAULT_OAUTH_ISSUER, + } +} + +pub(crate) fn effective_endpoints(config: &CliConfig) -> (String, String) { + match (&config.account_endpoint, &config.basin_endpoint) { + (Some(account), Some(basin)) => (normalize_endpoint(account), normalize_endpoint(basin)), + // The SDK ignores an incomplete custom-endpoint pair and uses its defaults. + _ => ( + DEFAULT_ACCOUNT_ENDPOINT.to_owned(), + DEFAULT_BASIN_ENDPOINT.to_owned(), + ), + } +} + +pub(crate) fn uses_loopback_endpoints(config: &CliConfig) -> bool { + let (account, basin) = effective_endpoints(config); + endpoint_environment(&account, &basin) == Some(EndpointEnvironment::Loopback) +} + +fn normalize_endpoint(endpoint: &str) -> String { + endpoint.trim_end_matches('/').to_owned() +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum EndpointEnvironment { + Production, + Staging, + Sandbox, + Loopback, +} + +pub(super) fn validate_endpoint_binding( + issuer: &str, + account_endpoint: &str, + basin_endpoint: &str, + tls_verification_disabled: bool, +) -> Result<(), LoginError> { + let environment = endpoint_environment(account_endpoint, basin_endpoint) + .ok_or(LoginError::UnsafeBrowserDestination)?; + if tls_verification_disabled && environment != EndpointEnvironment::Loopback { + return Err(LoginError::UnsafeBrowserDestination); + } + let issuer_matches = if is_production_issuer(issuer) { + environment == EndpointEnvironment::Production + } else { + matches!( + environment, + EndpointEnvironment::Staging + | EndpointEnvironment::Sandbox + | EndpointEnvironment::Loopback + ) + }; + issuer_matches + .then_some(()) + .ok_or(LoginError::UnsafeBrowserDestination) +} + +fn endpoint_environment(account: &str, basin: &str) -> Option { + if account == DEFAULT_ACCOUNT_ENDPOINT && basin == DEFAULT_BASIN_ENDPOINT { + return Some(EndpointEnvironment::Production); + } + + let account_url = trusted_endpoint_url(account, false)?; + let basin_url = trusted_endpoint_url(basin, true)?; + if is_loopback_host(account_url.host_str()?) && is_loopback_host(basin_url.host_str()?) { + return Some(EndpointEnvironment::Loopback); + } + if account_url.scheme() != "https" || basin_url.scheme() != "https" { + return None; + } + + let account_environment = account_endpoint_environment(account_url.host_str()?)?; + let basin_environment = basin_endpoint_environment(basin_url.host_str()?)?; + (account_environment == basin_environment).then_some(account_environment) +} + +fn trusted_endpoint_url(endpoint: &str, basin: bool) -> Option { + let basin_placeholders = endpoint.matches("{basin}.").count(); + if (!basin && basin_placeholders != 0) || basin_placeholders > 1 { + return None; + } + let raw = endpoint.replace("{basin}.", ""); + let url = Url::parse(&raw).ok()?; + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + { + return None; + } + let loopback = url.host_str().is_some_and(is_loopback_host); + if !matches!(url.scheme(), "http" | "https") + || (!loopback && url.scheme() != "https") + || (basin && !loopback && basin_placeholders != 1) + { + return None; + } + Some(url) +} + +fn account_endpoint_environment(host: &str) -> Option { + let labels = host.split('.').collect::>(); + match labels.as_slice() { + [_, "o-staging", "s2", "dev"] | [_, "o-staging", _, "s2", "dev"] => { + Some(EndpointEnvironment::Staging) + } + [_, "o-sandbox", "s2", "dev"] | [_, "o-sandbox", _, "s2", "dev"] => { + Some(EndpointEnvironment::Sandbox) + } + _ => None, + } +} + +fn basin_endpoint_environment(host: &str) -> Option { + let labels = host.split('.').collect::>(); + match labels.as_slice() { + ["b-staging", "s2", "dev"] | ["b-staging", _, "s2", "dev"] => { + Some(EndpointEnvironment::Staging) + } + ["b-sandbox", "s2", "dev"] | ["b-sandbox", _, "s2", "dev"] => { + Some(EndpointEnvironment::Sandbox) + } + _ => None, + } +} diff --git a/cli/src/login/tests.rs b/cli/src/login/tests.rs new file mode 100644 index 00000000..0763fa8a --- /dev/null +++ b/cli/src/login/tests.rs @@ -0,0 +1,534 @@ +use std::collections::HashMap; + +use axum::{Json, extract::Form, routing::post}; + +use super::*; +use crate::config::{DEFAULT_ACCOUNT_ENDPOINT, DEFAULT_BASIN_ENDPOINT}; + +#[derive(Clone)] +struct FakeOAuthState { + issuer: String, + requests: Arc>>>, +} + +async fn fake_metadata(State(state): State) -> Json { + Json(serde_json::json!({ + "issuer": state.issuer, + "authorization_endpoint": format!("{}/oauth/authorize", state.issuer), + "token_endpoint": format!("{}/oauth/token", state.issuer), + "revocation_endpoint": format!("{}/oauth/token/revoke", state.issuer), + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["none"], + "scopes_supported": ["offline_access", "user:org:read"], + "code_challenge_methods_supported": ["S256"] + })) +} + +async fn fake_token( + State(state): State, + Form(form): Form>, +) -> Json { + let grant_type = form.get("grant_type").cloned(); + state.requests.lock().unwrap().push(form); + match grant_type.as_deref() { + Some("authorization_code") => Json(serde_json::json!({ + "access_token": "first-access", + "refresh_token": "first-refresh", + "expires_in": 3600, + "token_type": "bearer", + "scope": OAUTH_SCOPES + })), + Some("refresh_token") => Json(serde_json::json!({ + "access_token": "second-access", + "expires_in": 3600, + "token_type": "bearer", + "scope": OAUTH_SCOPES + })), + _ => Json(serde_json::json!({ + "error": "unsupported_grant_type" + })), + } +} + +async fn fake_revoke( + State(state): State, + Form(form): Form>, +) -> StatusCode { + state.requests.lock().unwrap().push(form); + StatusCode::OK +} + +fn oauth_session() -> OAuthSession { + OAuthSession { + issuer: "https://clerk.s2.dev/".to_owned(), + client_id: "client_123".to_owned(), + account_endpoint: DEFAULT_ACCOUNT_ENDPOINT.to_owned(), + basin_endpoint: DEFAULT_BASIN_ENDPOINT.to_owned(), + credential_id: "credential_123".to_owned(), + credential_store: CredentialStore::File, + } +} + +fn test_token_set() -> TokenSet { + TokenSet { + access_token: "access-token".to_owned().into(), + refresh_token: "refresh-token".to_owned().into(), + expires_at: u64::MAX, + } +} + +fn test_jwt(sub: &str, org_id: &str) -> SecretString { + let payload = serde_json::to_vec(&serde_json::json!({ + "sub": sub, + "org_id": org_id, + })) + .unwrap(); + format!( + "header.{}.signature", + Base64UrlUnpadded::encode_string(&payload) + ) + .into() +} + +#[test] +fn replacement_grant_reuse_requires_the_same_known_identity() { + let previous_session = oauth_session(); + let next_session = oauth_session(); + let mut previous_tokens = test_token_set(); + previous_tokens.access_token = test_jwt("user_1", "org_1"); + let mut next_tokens = test_token_set(); + next_tokens.access_token = test_jwt("user_1", "org_1"); + + assert!(oauth_grant_may_be_reused( + &previous_session, + Some(&previous_tokens), + &next_session, + &next_tokens, + )); + + next_tokens.access_token = test_jwt("user_1", "org_2"); + assert!(!oauth_grant_may_be_reused( + &previous_session, + Some(&previous_tokens), + &next_session, + &next_tokens, + )); +} + +#[test] +fn production_browser_credentials_are_bound_to_production_transport() { + let mut credential = ResolvedCredential { + access_token: "access".to_owned().into(), + source: TokenSource::BrowserLogin, + oauth_session: Some(oauth_session()), + oauth_tokens: None, + }; + credential + .validate_destination(&CliConfig::default()) + .unwrap(); + + let custom = CliConfig { + account_endpoint: Some("https://attacker.example".to_owned()), + basin_endpoint: Some("https://{basin}.attacker.example".to_owned()), + ..CliConfig::default() + }; + assert!(matches!( + credential.validate_destination(&custom), + Err(LoginError::UnsafeBrowserDestination) + )); + + let insecure = CliConfig { + ssl_no_verify: Some(true), + ..CliConfig::default() + }; + assert!(matches!( + credential.validate_destination(&insecure), + Err(LoginError::UnsafeBrowserDestination) + )); + + let staging = CliConfig { + account_endpoint: Some("https://cell.o-staging.aws.s2.dev".to_owned()), + basin_endpoint: Some("https://{basin}.b-staging.aws.s2.dev".to_owned()), + ..CliConfig::default() + }; + let staging_session = OAuthSession { + issuer: "https://staging-clerk.example/".to_owned(), + account_endpoint: staging.account_endpoint.clone().unwrap(), + basin_endpoint: staging.basin_endpoint.clone().unwrap(), + ..oauth_session() + }; + credential.oauth_session = Some(staging_session); + credential.validate_destination(&staging).unwrap(); + assert!(matches!( + credential.validate_destination(&custom), + Err(LoginError::UnsafeBrowserDestination) + )); + + let loopback = CliConfig { + account_endpoint: Some("http://127.0.0.1:4243".to_owned()), + basin_endpoint: Some("http://localhost:4243".to_owned()), + ..CliConfig::default() + }; + credential.oauth_session = Some(OAuthSession { + issuer: "http://127.0.0.1:3000/".to_owned(), + account_endpoint: loopback.account_endpoint.clone().unwrap(), + basin_endpoint: loopback.basin_endpoint.clone().unwrap(), + ..oauth_session() + }); + credential.validate_destination(&loopback).unwrap(); + + assert!( + validate_endpoint_binding( + "https://staging-clerk.example", + "ftp://localhost:4243", + "ftp://localhost:4243", + false, + ) + .is_err() + ); +} + +fn metadata() -> AuthorizationServerMetadata { + serde_json::from_value(serde_json::json!({ + "issuer": "https://clerk.s2.dev", + "authorization_endpoint": "https://clerk.s2.dev/oauth/authorize", + "token_endpoint": "https://clerk.s2.dev/oauth/token", + "revocation_endpoint": "https://clerk.s2.dev/oauth/token/revoke", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["none"], + "scopes_supported": ["offline_access", "user:org:read"], + "code_challenge_methods_supported": ["S256"] + })) + .unwrap() +} + +#[test] +fn rfc_7636_pkce_vector() { + let pkce = Pkce::from_verifier("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk".to_owned()); + assert_eq!( + pkce.challenge, + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); +} + +#[test] +fn authorization_url_contains_oauth_and_pkce_parameters() { + let endpoint = Url::parse("https://clerk.s2.dev/oauth/authorize").unwrap(); + let url = authorization_url( + &endpoint, + "client_123", + "http://127.0.0.1:34567/callback", + "expected-state", + "challenge", + ); + let params = url + .query_pairs() + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + + assert_eq!( + params.get("response_type").map(String::as_str), + Some("code") + ); + assert_eq!( + params.get("client_id").map(String::as_str), + Some("client_123") + ); + assert_eq!( + params.get("redirect_uri").map(String::as_str), + Some("http://127.0.0.1:34567/callback") + ); + assert_eq!(params.get("scope").map(String::as_str), Some(OAUTH_SCOPES)); + assert_eq!( + params.get("state").map(String::as_str), + Some("expected-state") + ); + assert_eq!( + params.get("code_challenge").map(String::as_str), + Some("challenge") + ); + assert_eq!( + params.get("code_challenge_method").map(String::as_str), + Some("S256") + ); +} + +#[test] +fn validates_clerk_metadata_and_rejects_cross_origin_endpoints() { + let issuer = Url::parse("https://clerk.s2.dev").unwrap(); + let endpoints = validate_metadata(&issuer, metadata()).unwrap(); + assert_eq!( + endpoints.revocation.as_str(), + "https://clerk.s2.dev/oauth/token/revoke" + ); + + let mut malicious = metadata(); + malicious.token_endpoint = "https://attacker.example/token".to_owned(); + assert!(validate_metadata(&issuer, malicious).is_err()); + + let mut without_revocation = metadata(); + without_revocation.revocation_endpoint = None; + assert!(validate_metadata(&issuer, without_revocation).is_err()); +} + +#[tokio::test] +async fn exchanges_refreshes_and_revokes_with_standard_oauth_forms() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let issuer = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let app = Router::new() + .route( + "/.well-known/oauth-authorization-server", + get(fake_metadata), + ) + .route("/oauth/token", post(fake_token)) + .route("/oauth/token/revoke", post(fake_revoke)) + .with_state(FakeOAuthState { + issuer: issuer.clone(), + requests: requests.clone(), + }); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + + let http = oauth_http_client().unwrap(); + let issuer_url = oauth_issuer(Some(&issuer)).unwrap(); + let endpoints = discover(&http, &issuer_url).await.unwrap(); + let first = exchange_code( + &http, + &endpoints, + "client_123", + "http://127.0.0.1:34567/callback", + "authorization-code", + "pkce-verifier", + ) + .await + .unwrap(); + let second = refresh_token(&http, &endpoints, "client_123", &first.refresh_token) + .await + .unwrap(); + revoke_token(&http, &endpoints, "client_123", &second.refresh_token) + .await + .unwrap(); + + assert_eq!(first.access_token.expose_secret(), "first-access"); + assert_eq!(second.access_token.expose_secret(), "second-access"); + assert_eq!(second.refresh_token.expose_secret(), "first-refresh"); + { + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].get("grant_type").unwrap(), "authorization_code"); + assert_eq!(requests[0].get("code").unwrap(), "authorization-code"); + assert_eq!(requests[0].get("code_verifier").unwrap(), "pkce-verifier"); + assert_eq!( + requests[0].get("redirect_uri").unwrap(), + "http://127.0.0.1:34567/callback" + ); + assert_eq!(requests[1].get("grant_type").unwrap(), "refresh_token"); + assert_eq!(requests[1].get("refresh_token").unwrap(), "first-refresh"); + assert_eq!(requests[2].get("token").unwrap(), "first-refresh"); + assert_eq!(requests[2].get("token_type_hint").unwrap(), "refresh_token"); + } + + server.abort(); + let _ = server.await; +} + +#[test] +fn issuer_requires_https_except_on_loopback() { + assert!(oauth_issuer(Some("https://clerk.s2.dev")).is_ok()); + assert!(oauth_issuer(Some("http://127.0.0.1:3000")).is_ok()); + assert!(oauth_issuer(Some("http://localhost:3000")).is_ok()); + assert!(oauth_issuer(Some("http://clerk.s2.dev")).is_err()); + assert!(oauth_issuer(Some("https://user@clerk.s2.dev")).is_err()); + assert!(oauth_issuer(Some("https://clerk.s2.dev/path")).is_err()); +} + +#[test] +fn credential_precedence_is_environment_then_explicit_method_then_legacy_fallback() { + let mut config = CliConfig { + access_token: Some("static-token".to_owned()), + auth_method: Some(AuthMethod::BrowserLogin), + oauth: Some(oauth_session()), + ..CliConfig::default() + }; + + assert_eq!( + credential_choice(&config, true).unwrap(), + CredentialChoice::Environment + ); + assert_eq!( + credential_choice(&config, false).unwrap(), + CredentialChoice::BrowserLogin + ); + + config.auth_method = Some(AuthMethod::AccessToken); + assert_eq!( + credential_choice(&config, false).unwrap(), + CredentialChoice::StoredAccessToken + ); + + config.auth_method = None; + assert_eq!( + credential_choice(&config, false).unwrap(), + CredentialChoice::StoredAccessToken + ); + config.access_token = None; + assert_eq!( + credential_choice(&config, false).unwrap(), + CredentialChoice::BrowserLogin + ); +} + +#[test] +fn oauth_provider_tracks_rejected_tokens_without_exposing_them_in_debug_output() { + let provider = OAuthAccessTokenProvider::new(oauth_session(), test_token_set()); + provider.invalidate_access_token("rejected-secret-token"); + + assert!( + provider + .rejected_access_tokens + .lock() + .unwrap() + .contains("rejected-secret-token") + ); + assert!(!format!("{provider:?}").contains("rejected-secret-token")); +} + +#[tokio::test] +async fn callback_rejects_wrong_state_without_consuming_code_receiver() { + let (sender, receiver) = oneshot::channel(); + let state = CallbackState { + expected_state: "expected".to_owned(), + sender: Arc::new(Mutex::new(Some(sender))), + completion_url: None, + }; + + let rejected = handle_callback( + State(state.clone()), + Query(CallbackQuery { + code: Some("wrong-code".to_owned()), + error: None, + error_description: None, + state: Some("wrong".to_owned()), + }), + ) + .await; + assert_eq!(rejected.status(), StatusCode::BAD_REQUEST); + + let accepted = handle_callback( + State(state), + Query(CallbackQuery { + code: Some("right-code".to_owned()), + error: None, + error_description: None, + state: Some("expected".to_owned()), + }), + ) + .await; + assert_eq!(accepted.status(), StatusCode::OK); + assert_eq!(accepted.headers().get("cache-control").unwrap(), "no-store"); + assert_eq!( + accepted.headers().get("referrer-policy").unwrap(), + "no-referrer" + ); + assert_eq!(receiver.await.unwrap().unwrap(), "right-code"); +} + +#[tokio::test] +async fn callback_redirects_browser_to_website_without_exposing_code() { + let (sender, receiver) = oneshot::channel(); + let response = handle_callback( + State(CallbackState { + expected_state: "expected".to_owned(), + sender: Arc::new(Mutex::new(Some(sender))), + completion_url: Some(Url::parse("https://staging.s2.dev/cli/login").unwrap()), + }), + Query(CallbackQuery { + code: Some("secret-code".to_owned()), + error: None, + error_description: None, + state: Some("expected".to_owned()), + }), + ) + .await; + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let location = response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + assert_eq!( + location, + "https://staging.s2.dev/cli/login?status=authorized" + ); + assert!(!location.contains("secret-code")); + assert_eq!(receiver.await.unwrap().unwrap(), "secret-code"); +} + +#[tokio::test] +async fn callback_surfaces_provider_denial() { + let (sender, receiver) = oneshot::channel(); + let denied = handle_callback( + State(CallbackState { + expected_state: "expected".to_owned(), + sender: Arc::new(Mutex::new(Some(sender))), + completion_url: None, + }), + Query(CallbackQuery { + code: None, + error: Some("access_denied".to_owned()), + error_description: Some("The user denied access".to_owned()), + state: Some("expected".to_owned()), + }), + ) + .await; + assert_eq!(denied.status(), StatusCode::BAD_REQUEST); + assert_eq!( + receiver.await.unwrap().unwrap_err(), + "access_denied: The user denied access" + ); +} + +#[test] +fn refresh_token_rotation_is_optional() { + for (returned, expected) in [(None, "old-refresh"), (Some("new-refresh"), "new-refresh")] { + let response = TokenResponse { + access_token: "new-access".to_owned(), + refresh_token: returned.map(str::to_owned), + expires_in: 3_600, + token_type: "Bearer".to_owned(), + scope: Some(OAUTH_SCOPES.to_owned()), + }; + let tokens = token_set(response, Some("old-refresh")).unwrap(); + assert_eq!(tokens.refresh_token.expose_secret(), expected); + } +} + +#[test] +fn oauth_errors_do_not_echo_provider_payloads() { + for (body, expected) in [ + ( + br#"{"error":"invalid_grant","error_description":"super-secret"}"#.as_slice(), + "invalid_grant", + ), + ( + br#"{"error":"super-secret-refresh-token","error_description":"also-secret"}"# + .as_slice(), + "unknown error", + ), + ( + b"provider accidentally echoed super-secret-refresh-token".as_slice(), + "unknown error", + ), + ] { + let error = oauth_rejection("token refresh", StatusCode::BAD_REQUEST, body); + assert!(matches!( + error, + LoginError::Rejected { message, .. } + if message == expected && !message.contains("secret") + )); + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 00000000..7c23f771 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,1143 @@ +//! S2 command-line interface. + +mod access_token; +mod apply; +mod auth; +mod bench; +mod cli; +mod config; +mod credential_store; +mod diff; +mod error; +mod lite; +mod login; +mod ops; +mod record_format; +mod types; +mod update; + +#[cfg(not(target_env = "msvc"))] +#[global_allocator] +static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + +use std::{pin::Pin, process::ExitCode, time::Duration}; + +use clap::{CommandFactory, Parser}; +use cli::{ + ApplyArgs, AuthAccessTokenCommand, AuthCommand, Cli, Command, ConfigCommand, ListBasinsArgs, + ListStreamsArgs, +}; +use colored::Colorize; +use config::{ + AuthMethod, ConfigKey, CredentialStore, load_config_file, sdk_config, select_auth_method, + set_config_value, unset_config_value, +}; +use error::{CliError, OpKind}; +use futures::{Stream, StreamExt}; +use json_to_table::json_to_table; +use record_format::{ + JsonBase64Formatter, JsonFormatter, RecordFormat, RecordParser, RecordWriter, TextFormatter, +}; +use s2_sdk::{ + S2, + types::{ + AppendRetryPolicy, CreateStreamInput, DeleteOnEmptyConfig, DeleteStreamInput, + EncryptionKey, LocationInfo, MeteredBytes, Metric, RetentionPolicy, RetryConfig, + StreamConfig as SdkStreamConfig, StreamName, TimestampingConfig, TimestampingMode, + }, +}; +use strum::VariantNames; +use tabled::{Table, Tabled}; +use tokio::{io::AsyncWriteExt, select}; +use tracing_subscriber::{fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt}; +use types::{AccessTokenInfo, BasinConfig, S2BasinAndMaybeStreamUri, StreamConfig}; + +fn install_rustls_crypto_provider() { + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("failed to install aws-lc-rs as default rustls crypto provider"); +} + +#[tokio::main] +async fn main() -> miette::Result { + install_rustls_crypto_provider(); + miette::set_panic_hook(); + + let cli = parse_cli(); + let passive_update_check = allows_passive_update_check(cli.command.as_ref()); + let update_check = if passive_update_check { + update::spawn_check() + } else { + None + }; + + let result = run(cli).await; + if passive_update_check && result.is_ok() { + update::notify(update_check).await; + } + Ok(result?) +} + +fn parse_cli() -> Cli { + Cli::try_parse().unwrap_or_else(|e| { + // Customize error message for metric commands to say "metric" instead of "subcommand" + let msg = e.to_string(); + if msg.contains("requires a subcommand") && msg.contains("get-") && msg.contains("-metrics") + { + let msg = msg + .replace("requires a subcommand", "requires a metric") + .replace("[subcommands:", "[metrics:"); + eprintln!("{msg}"); + std::process::exit(2); + } + e.exit() + }) +} + +fn allows_passive_update_check(command: Option<&Command>) -> bool { + !matches!( + command, + Some( + Command::Lite(_) + | Command::Login(_) + | Command::Logout(_) + | Command::Auth( + AuthCommand::Login(_) | AuthCommand::Logout(_) | AuthCommand::Status, + ) + | Command::Update(_) + ) + ) +} + +fn print_legacy_access_token_deprecation(config: &config::CliConfig) { + if config.has_legacy_access_token() { + eprintln!( + "{}", + "! `access_token` in config.toml is deprecated.\n Run `s2 auth access-token migrate` to move it to secure storage." + .yellow() + ); + } +} + +fn print_token_change(message: &str, change: &access_token::TokenChange) { + eprintln!("{}", format!("✓ {message}").green().bold()); + if change.replaced { + eprintln!(" - Previous access token replaced."); + } + match change.credential_store { + CredentialStore::Keyring => { + eprintln!( + " - Access token saved to: {}", + "OS credential store".cyan() + ); + } + CredentialStore::File => { + #[cfg(unix)] + eprintln!( + "{}", + " - Warning: the access token is stored in a plaintext file with user-only permissions." + .yellow() + ); + #[cfg(not(unix))] + eprintln!( + "{}", + " - Warning: the access token is stored in a plaintext file using the platform's default user-directory permissions." + .yellow() + ); + if let Some(path) = change.credential_path.as_ref() { + eprintln!( + " - Access token saved to: {}", + path.display().to_string().cyan() + ); + } + } + } + eprintln!( + " - Configuration saved to: {}", + change.config_path.display().to_string().cyan() + ); + if let Some(error) = change.cleanup_warning.as_ref() { + eprintln!( + "{}", + format!(" - Warning: an older local credential remains queued for cleanup: {error}") + .yellow() + ); + } + if config::access_token_from_environment() + .ok() + .flatten() + .is_some() + { + eprintln!( + "{}", + " - Warning: S2_ACCESS_TOKEN is set and remains active. Unset it to use the selected credential." + .yellow() + ); + } +} + +fn print_token_removal(removal: access_token::TokenRemoval) { + if removal.removed { + eprintln!("{}", "✓ Access token removed".green().bold()); + eprintln!("{}", " - This does not revoke the access token.".yellow()); + } else { + eprintln!("{}", "✓ No access token to remove".green().bold()); + } + if let Some(path) = removal.config_path { + eprintln!( + " - Configuration saved to: {}", + path.display().to_string().cyan() + ); + } + if let Some(error) = removal.cleanup_warning { + eprintln!( + "{}", + format!(" - Warning: an older local credential remains queued for cleanup: {error}") + .yellow() + ); + } + if config::access_token_from_environment() + .ok() + .flatten() + .is_some() + { + eprintln!( + "{}", + " - Warning: S2_ACCESS_TOKEN remains set and is still active.".yellow() + ); + } +} + +async fn run(cli: Cli) -> Result { + let Some(command) = cli.command else { + Cli::command().print_help().ok(); + std::process::exit(0); + }; + + if let Command::Lite(args) = command { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + return lite::run(args).await.map(|()| ExitCode::SUCCESS); + } + + tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::layer() + .pretty() + .with_span_events(FmtSpan::NEW) + .compact() + .with_writer(std::io::stderr), + ) + .with(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + if let Command::Login(args) = &command { + login::login(args).await?; + return Ok(ExitCode::SUCCESS); + } + + if let Command::Logout(args) = &command { + login::logout(args).await?; + return Ok(ExitCode::SUCCESS); + } + + if let Command::Auth(auth_cmd) = &command { + match auth_cmd { + AuthCommand::Login(args) => login::login(args).await?, + AuthCommand::Logout(args) => login::logout(args).await?, + AuthCommand::Status => return auth::status().await, + AuthCommand::Use { method } => { + let saved_path = select_auth_method(*method).await?; + let message = match method { + AuthMethod::AccessToken => "✓ Access token selected", + AuthMethod::BrowserLogin => "✓ Browser login selected", + }; + eprintln!("{}", message.green().bold()); + eprintln!( + " - Configuration saved to: {}", + saved_path.display().to_string().cyan() + ); + if login::has_access_token_environment_variable() { + eprintln!( + "{}", + " - Warning: S2_ACCESS_TOKEN is set and remains active. Unset it to use the selected credential." + .yellow() + ); + } + } + AuthCommand::AccessToken { command } => match command { + AuthAccessTokenCommand::Set(args) => { + let change = access_token::set(args).await?; + print_token_change("Access token saved", &change); + } + AuthAccessTokenCommand::Migrate(args) => { + let change = access_token::migrate(args).await?; + print_token_change("Legacy access token migrated", &change); + } + AuthAccessTokenCommand::Remove => { + print_token_removal(access_token::remove().await?); + } + }, + } + return Ok(ExitCode::SUCCESS); + } + + if let Command::Config(config_cmd) = &command { + match config_cmd { + ConfigCommand::List => { + let config = load_config_file()?; + print_legacy_access_token_deprecation(&config); + for k in ConfigKey::VARIANTS { + if let Ok(key) = k.parse::() + && let Some(v) = config.get(key) + { + println!("{} = {}", k, v); + } + } + } + ConfigCommand::Get { key } => { + if matches!(key, ConfigKey::AccessToken) { + return Err(error::CliConfigError::CredentialNotReadable.into()); + } + let config = load_config_file()?; + if let Some(v) = config.get(*key) { + println!("{}", v); + } + } + ConfigCommand::Set { key, value } => { + if matches!(key, ConfigKey::AccessToken) { + eprintln!( + "{}", + "! `s2 config set access_token` is deprecated.\n Use `s2 auth access-token set` instead." + .yellow() + ); + let change = access_token::set_from_argument(value.clone()).await?; + print_token_change("Access token saved", &change); + } else { + let saved_path = set_config_value(*key, value.clone()).await?; + eprintln!("{}", format!("✓ {} set", key).green().bold()); + eprintln!( + " Configuration saved to: {}", + saved_path.display().to_string().cyan() + ); + } + } + ConfigCommand::Unset { key } => { + if matches!(key, ConfigKey::AccessToken) { + print_token_removal(access_token::remove().await?); + } else { + let saved_path = unset_config_value(*key).await?; + eprintln!("{}", format!("✓ {} unset", key).green().bold()); + eprintln!( + " Configuration saved to: {}", + saved_path.display().to_string().cyan() + ); + } + } + } + return Ok(ExitCode::SUCCESS); + } + + if let Command::Update(args) = &command { + update::apply::run(args) + .await + .map_err(|e| CliError::Update(e.to_string()))?; + return Ok(ExitCode::SUCCESS); + } + + if let Command::Apply(ApplyArgs { schema: true, .. }) = &command { + let schema = s2_resource_spec::json_schema(); + println!( + "{}", + serde_json::to_string_pretty(&schema).expect("valid schema") + ); + return Ok(ExitCode::SUCCESS); + } + + if let Command::Diff(args) = &command { + diff::validate_args(args)?; + } + + let (cli_config, credential) = login::resolve_access_token().await?; + print_legacy_access_token_deprecation(&cli_config); + credential.validate_destination(&cli_config)?; + let sdk_config = credential.configure_sdk(sdk_config( + &cli_config, + credential.access_token(), + update::user_agent(), + )?); + let token_source = Some(credential.source()); + let s2 = S2::new(sdk_config.clone()) + .map_err(|e| CliError::SdkInit(e.into()).with_token_source(token_source))?; + let mut exit_code = ExitCode::SUCCESS; + let result: Result<(), CliError> = (async { + match command { + Command::Login(..) + | Command::Logout(..) + | Command::Auth(..) + | Command::Config(..) + | Command::Lite(..) + | Command::Update(..) => { + unreachable!() + } + + Command::Ls(args) => { + if let Some(ref uri) = args.uri { + // List streams + let S2BasinAndMaybeStreamUri { + basin, + stream: uri_prefix, + } = uri.clone(); + + if uri_prefix.is_some() && args.prefix.is_some() { + return Err(CliError::InvalidArgs(miette::miette!( + help = "Make sure to provide the prefix once either using '--prefix' opt or in URI like 's2://basin-name/prefix'", + "Multiple prefixes provided" + ))); + } + + let list_streams_args = ListStreamsArgs { + uri: S2BasinAndMaybeStreamUri { + basin: basin.clone(), + stream: uri_prefix, + }, + prefix: args + .prefix + .clone() + .map(|s| s.parse()) + .transpose() + .map_err(|e| CliError::InvalidArgs(miette::miette!("{e}")))?, + start_after: args + .start_after + .clone() + .map(|s| s.parse()) + .transpose() + .map_err(|e| CliError::InvalidArgs(miette::miette!("{e}")))?, + limit: args.limit, + no_auto_paginate: args.no_auto_paginate, + }; + + let (streams, _) = ops::list_streams(&s2, list_streams_args).await?; + for stream_info in streams { + print_listing_with_created_at( + format!("s2://{}/{}", basin, stream_info.name), + stream_info.created_at.to_string(), + stream_info.deleted_at.is_some(), + ); + } + } else { + // List basins + let list_basins_args = ListBasinsArgs { + prefix: args + .prefix + .clone() + .map(|s| s.parse()) + .transpose() + .map_err(|e| CliError::InvalidArgs(miette::miette!("{e}")))?, + start_after: args + .start_after + .clone() + .map(|s| s.parse()) + .transpose() + .map_err(|e| CliError::InvalidArgs(miette::miette!("{e}")))?, + limit: args.limit, + no_auto_paginate: args.no_auto_paginate, + }; + + let (basins, _) = ops::list_basins(&s2, list_basins_args).await?; + for basin_info in basins { + print_basin_listing( + basin_info.name.to_string(), + basin_info.location.as_deref(), + basin_info.deleted_at.is_some(), + ); + } + } + } + + Command::ListBasins(args) => { + let (basins, _) = ops::list_basins(&s2, args).await?; + for basin_info in basins { + print_basin_listing( + basin_info.name.to_string(), + basin_info.location.as_deref(), + basin_info.deleted_at.is_some(), + ); + } + } + + Command::CreateBasin(args) => { + let _info = ops::create_basin(&s2, args).await?; + eprintln!("{}", "✓ Basin created".green().bold()); + } + + Command::DeleteBasin { basin } => { + ops::delete_basin(&s2, &basin.into()).await?; + eprintln!("{}", "✓ Basin deletion requested".green().bold()); + } + + Command::GetBasinConfig { basin } => { + let basin_config: BasinConfig = ops::get_basin_config(&s2, &basin.into()).await?.into(); + println!("{}", json_to_table(&serde_json::to_value(&basin_config)?)); + } + + Command::ReconfigureBasin(args) => { + let config = ops::reconfigure_basin(&s2, args).await?; + + eprintln!("{}", "✓ Basin reconfigured".green().bold()); + println!("{}", json_to_table(&serde_json::to_value(&config)?)); + } + + Command::ListAccessTokens(args) => { + let (tokens, _) = ops::list_access_tokens(&s2, args).await?; + let tokens: Vec = + tokens.into_iter().map(AccessTokenInfo::from).collect(); + let id_width = tokens.iter().map(|info| info.id.len()).max().unwrap_or(0); + let blocks: Vec = tokens + .iter() + .map(|info| info.summary_block(id_width)) + .collect(); + if !blocks.is_empty() { + println!("{}", blocks.join("\n\n")); + } + } + + Command::IssueAccessToken(args) => { + let token = ops::issue_access_token(&s2, args).await?; + println!("{}", token); + } + + Command::RevokeAccessToken { id } => { + ops::revoke_access_token(&s2, id.clone()).await?; + eprintln!( + "{}", + format!("✓ Access token '{}' revoked", id).green().bold() + ); + } + + Command::Diff(args) => { + let use_diff_exit_code = args.exit_code; + let outcome = diff::run(&s2, args).await?; + if use_diff_exit_code && outcome.has_differences { + exit_code = ExitCode::from(1); + } + } + + Command::ListLocations => { + let locations = ops::list_locations(&s2).await?; + for location_info in locations { + print_location_listing(&location_info); + } + } + + Command::GetDefaultLocation => { + let location = ops::get_default_location(&s2).await?; + print_location_listing(&location); + } + + Command::SetDefaultLocation { location } => { + let location_name = location.to_string(); + let location = ops::set_default_location(&s2, location).await?; + eprintln!( + "{}", + format!("✓ Default location set to '{}'", location_name) + .green() + .bold() + ); + print_location_listing(&location); + } + + Command::GetAccountMetrics(args) => { + let metrics = ops::get_account_metrics(&s2, args).await?; + print_metrics(&metrics); + } + + Command::GetBasinMetrics(args) => { + let metrics = ops::get_basin_metrics(&s2, args).await?; + print_metrics(&metrics); + } + + Command::GetStreamMetrics(args) => { + let metrics = ops::get_stream_metrics(&s2, args).await?; + print_metrics(&metrics); + } + + Command::ListStreams(args) => { + let basin_name = args.uri.basin.clone(); + let (streams, _) = ops::list_streams(&s2, args).await?; + for stream_info in streams { + print_listing_uri( + format!("s2://{}/{}", basin_name, stream_info.name), + stream_info.deleted_at.is_some(), + ); + } + } + + Command::CreateStream(args) => { + ops::create_stream(&s2, args).await?; + eprintln!("{}", "✓ Stream created".green().bold()); + } + + Command::DeleteStream { uri } => { + ops::delete_stream(&s2, uri).await?; + eprintln!("{}", "✓ Stream deletion requested".green().bold()); + } + + Command::GetStreamConfig { uri } => { + let stream_config = ops::get_stream_config(&s2, uri).await?; + let stream_config: StreamConfig = stream_config.into(); + println!("{}", json_to_table(&serde_json::to_value(&stream_config)?)); + } + + Command::ReconfigureStream(args) => { + let config = ops::reconfigure_stream(&s2, args).await?; + + eprintln!("{}", "✓ Stream reconfigured".green().bold()); + println!("{}", json_to_table(&serde_json::to_value(&config)?)); + } + + Command::CheckTail { uri } => { + let tail = ops::check_tail(&s2, uri).await?; + println!("{}", format_position(tail.seq_num, tail.timestamp)); + } + + Command::Trim(args) => { + let trim_point = args.trim_point; + let out = ops::trim(&s2, args).await?; + eprintln!( + "{}", + format!( + "✓ [APPENDED] trim to {} // tail: {}", + trim_point, + format_position(out.start.seq_num, out.start.timestamp) + ) + .green() + .bold() + ); + } + + Command::Fence(args) => { + let fencing_token = args.new_fencing_token.clone(); + let out = ops::fence(&s2, args).await?; + eprintln!( + "{}", + format!( + "✓ [APPENDED] new fencing token \"{}\" // tail: {}", + fencing_token, + format_position(out.start.seq_num, out.start.timestamp) + ) + .green() + .bold() + ); + } + + Command::Append(args) => { + let encryption_key = resolve_encryption_key(&args.encryption_key)?; + let records_in = args + .input + .reader() + .await + .map_err(|e| CliError::RecordReaderInit(e.to_string()))?; + + let record_stream: Pin + Send + Unpin>> = match args.format { + RecordFormat::Text => Box::pin(TextFormatter::parse_records(records_in)), + RecordFormat::Json => Box::pin(JsonFormatter::parse_records(records_in)), + RecordFormat::JsonBase64 => { + Box::pin(JsonBase64Formatter::parse_records(records_in)) + } + }; + + let acks = ops::append( + &s2, + record_stream, + args.uri, + encryption_key.as_ref(), + ops::AppendOptions { + fencing_token: args.fencing_token, + match_seq_num: args.match_seq_num, + linger: *args.linger, + stream_config: (!args.stream_config.is_empty()) + .then(|| args.stream_config.into()), + }, + ); + let mut acks = std::pin::pin!(acks); + let mut last_printed_batch_end: Option = None; + + loop { + select! { + ack = acks.next() => { + match ack { + Some(Ok(ack)) => { + if last_printed_batch_end.is_none_or(|end| end != ack.batch.end.seq_num) { + last_printed_batch_end = Some(ack.batch.end.seq_num); + eprintln!( + "{}", + format!( + "✓ [APPENDED] {}..{} // tail: {}", + ack.batch.start.seq_num, + ack.batch.end.seq_num, + format_position(ack.batch.tail.seq_num, ack.batch.tail.timestamp) + ) + .green() + .bold() + ); + } + } + Some(Err(e)) => { + return Err(e); + } + None => break, // Stream exhausted, all done + } + } + _ = tokio::signal::ctrl_c() => { + eprintln!("{}", "■ [ABORTED]".red().bold()); + break; + } + } + } + } + + Command::Read(args) => { + let encryption_key = resolve_encryption_key(&args.encryption_key)?; + let mut batches = ops::read(&s2, &args, encryption_key.as_ref()).await?; + let mut writer = args + .output + .writer() + .await + .map_err(|e| CliError::RecordWrite(e.to_string()))?; + + loop { + select! { + batch = batches.next() => { + match batch { + Some(Ok(batch)) => { + let num_records = batch.records.len(); + let batch_len: usize = batch.records.iter().map(|r| r.metered_bytes()).sum(); + + let seq_range = match (batch.records.first(), batch.records.last()) { + (Some(first), Some(last)) => first.seq_num..=last.seq_num, + _ => continue, + }; + + eprintln!( + "{}", + format!( + "⦿ {batch_len} bytes ({num_records} {} in range {seq_range:?})", + if num_records == 1 { "record" } else { "records" } + ) + .blue() + .bold() + ); + + for record in &batch.records { + write_record(record, &mut writer, args.format).await?; + let skip_newline = matches!(args.format, RecordFormat::Text) + && record.is_command_record(); + if !skip_newline { + writer + .write_all(b"\n") + .await + .map_err(|e| CliError::RecordWrite(e.to_string()))?; + } + } + + writer + .flush() + .await + .map_err(|e| CliError::RecordWrite(e.to_string()))?; + } + Some(Err(e)) => { + return Err(CliError::op(OpKind::Read, e)); + } + None => break, + } + } + _ = tokio::signal::ctrl_c() => { + eprintln!("{}", "■ [ABORTED]".red().bold()); + break; + } + } + } + } + + Command::Tail(args) => { + let encryption_key = resolve_encryption_key(&args.encryption_key)?; + let mut records = ops::tail(&s2, &args, encryption_key.as_ref()).await?; + let mut writer = args + .output + .writer() + .await + .map_err(|e| CliError::RecordWrite(e.to_string()))?; + + loop { + select! { + record = records.next() => { + match record { + Some(Ok(record)) => { + write_record(&record, &mut writer, args.format).await?; + let skip_newline = matches!(args.format, RecordFormat::Text) + && record.is_command_record(); + if !skip_newline { + writer + .write_all(b"\n") + .await + .map_err(|e| CliError::RecordWrite(e.to_string()))?; + } + writer + .flush() + .await + .map_err(|e| CliError::RecordWrite(e.to_string()))?; + } + Some(Err(e)) => { + return Err(e); + } + None => break, + } + } + _ = tokio::signal::ctrl_c() => { + eprintln!("{}", "■ [ABORTED]".red().bold()); + break; + } + } + } + } + + Command::Apply(ApplyArgs { + file, + dry_run, + schema: _, + }) => { + let file = file.expect("--file is required when --schema is not set"); + let spec = apply::load(&file).map_err(CliError::InvalidArgs)?; + if dry_run { + apply::dry_run(&s2, spec) + .await + .map_err(|e| CliError::Apply(e.to_string()))?; + } else { + apply::apply(&s2, spec) + .await + .map_err(|e| CliError::Apply(e.to_string()))?; + eprintln!("{}", "✓ Done".green().bold()); + } + } + + Command::Bench(args) => { + let basin_name = args.basin.0.clone(); + let stream_name: StreamName = format!("bench/{}", uuid::Uuid::new_v4()) + .parse() + .expect("valid stream name"); + + eprintln!( + "Creating temporary stream s2://{}/{} (storage class: {})", + basin_name, + stream_name, + args.storage_class + .as_ref() + .map(|sc| format!("{:?}", sc)) + .unwrap_or_else(|| "".to_owned()) + ); + + let mut stream_config = SdkStreamConfig::new() + .with_retention_policy(RetentionPolicy::Age(3600)) + .with_delete_on_empty( + DeleteOnEmptyConfig::new().with_min_age(Duration::from_secs(60)), + ) + .with_timestamping( + TimestampingConfig::new() + .with_mode(TimestampingMode::ClientRequire) + .with_uncapped(true), + ); + stream_config.storage_class = args.storage_class; + + let s2 = S2::new(sdk_config.clone().with_retry( + RetryConfig::new().with_append_retry_policy(AppendRetryPolicy::NoSideEffects), + )) + .map_err(|e| CliError::SdkInit(e.into()))?; + + let basin = s2.basin(basin_name); + basin + .create_stream( + CreateStreamInput::new(stream_name.clone()).with_config(stream_config), + ) + .await + .map_err(|e| CliError::op(OpKind::Bench, e))?; + + eprintln!( + "Running for {} targeting {} MiB/s with {} byte records, Ctrl+C to end early", + args.duration, args.target_mibps, args.record_size, + ); + + bench::run( + basin.stream(stream_name.clone()), + args.record_size as usize, + args.target_mibps, + *args.duration, + *args.catchup_delay, + ) + .await?; + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await + .map_err(|e| CliError::op(OpKind::Bench, e))?; + } + } + Ok(()) + }) + .await; + + result + .map(|()| exit_code) + .map_err(|err| err.with_token_source(token_source)) +} + +fn format_position(seq_num: u64, timestamp: u64) -> String { + format!("{seq_num} @ {timestamp}") +} + +fn print_listing_uri(uri: String, is_deleting: bool) { + let uri = format_listing_uri(uri, is_deleting); + if is_deleting { + println!("{} {}", uri, deletion_marker()); + } else { + println!("{uri}"); + } +} + +fn print_basin_listing(name: String, location: Option<&str>, is_deleting: bool) { + let name = format_listing_uri(name, is_deleting); + let location = + location.map(|location| format_listing_location(&format!("({location})"), is_deleting)); + match (location, is_deleting) { + (Some(location), true) => println!("{} {} {}", name, location, deletion_marker()), + (Some(location), false) => println!("{} {}", name, location), + (None, true) => println!("{} {}", name, deletion_marker()), + (None, false) => println!("{name}"), + } +} + +fn print_location_listing(location: &LocationInfo) { + let visibility = format_location_visibility(location.is_private); + println!("{} {visibility}", location.name); + if let Some(storage_classes) = &location.storage_classes { + let classes = if storage_classes.is_empty() { + "none".to_owned() + } else { + storage_classes.join(", ") + }; + println!(" storage classes: {classes}"); + } + if let Some(default_storage_class) = &location.default_storage_class { + println!(" default storage class: {default_storage_class}"); + } +} + +fn format_location_visibility(is_private: bool) -> colored::ColoredString { + if is_private { + "(private)".yellow() + } else { + "(public)".green() + } +} + +fn print_listing_with_created_at(uri: String, created_at: String, is_deleting: bool) { + let uri = format_listing_uri(uri, is_deleting); + let created_at = if is_deleting { + created_at.red() + } else { + created_at.green() + }; + + if is_deleting { + println!("{} {} {}", uri, created_at, deletion_marker()); + } else { + println!("{} {}", uri, created_at); + } +} + +fn format_listing_uri(uri: String, is_deleting: bool) -> colored::ColoredString { + if is_deleting { uri.red() } else { uri.normal() } +} + +fn format_listing_location(location: &str, is_deleting: bool) -> colored::ColoredString { + if is_deleting { + location.red() + } else { + location.yellow() + } +} + +fn deletion_marker() -> colored::ColoredString { + "[deleting]".red().bold() +} + +async fn write_record( + record: &s2_sdk::types::SequencedRecord, + writer: &mut (impl tokio::io::AsyncWrite + Unpin), + format: RecordFormat, +) -> Result<(), CliError> { + match format { + RecordFormat::Text => { + if record.is_command_record() { + if let Some(header) = record.headers.first() { + let cmd_type = &header.value; + let cmd_desc = if cmd_type.as_ref() == b"fence" { + let fencing_token = String::from_utf8_lossy(&record.body); + format!("new fencing token \"{}\"", fencing_token) + } else if cmd_type.as_ref() == b"trim" { + let trim_point = if record.body.len() >= 8 { + u64::from_be_bytes(record.body[..8].try_into().unwrap_or_default()) + } else { + 0 + }; + format!("trim to {}", trim_point) + } else { + "unknown command".to_string() + }; + eprintln!( + "{} // {}", + cmd_desc.bold(), + format_position(record.seq_num, record.timestamp) + ); + } + } else { + TextFormatter::write_record(record, writer) + .await + .map_err(|e| CliError::RecordWrite(e.to_string()))?; + } + } + RecordFormat::Json => { + JsonFormatter::write_record(record, writer) + .await + .map_err(|e| CliError::RecordWrite(e.to_string()))?; + } + RecordFormat::JsonBase64 => { + JsonBase64Formatter::write_record(record, writer) + .await + .map_err(|e| CliError::RecordWrite(e.to_string()))?; + } + } + Ok(()) +} + +fn format_timestamp(ts: u32) -> String { + use std::time::{Duration, UNIX_EPOCH}; + let time = UNIX_EPOCH + Duration::from_secs(ts as u64); + humantime::format_rfc3339_seconds(time).to_string() +} + +fn format_unit(unit: s2_sdk::types::MetricUnit) -> &'static str { + match unit { + s2_sdk::types::MetricUnit::Bytes => "bytes", + s2_sdk::types::MetricUnit::Operations => "operations", + } +} + +fn print_metrics(metrics: &[Metric]) { + #[derive(Tabled)] + struct AccumulationRow { + interval_start: String, + count: String, + } + + #[derive(Tabled)] + struct GaugeRow { + time: String, + value: String, + } + + for metric in metrics { + match metric { + Metric::Scalar(m) => { + println!("{}: {} {}", m.name, m.value, format_unit(m.unit)); + } + Metric::Accumulation(m) => { + let rows: Vec = m + .values + .iter() + .map(|(ts, value)| AccumulationRow { + interval_start: format_timestamp(*ts), + count: value.to_string(), + }) + .collect(); + + println!("{}", m.name); + + let mut table = Table::new(rows); + table.modify( + tabled::settings::object::Columns::last(), + tabled::settings::Alignment::right(), + ); + + let interval_col = "interval start time".to_string(); + let count_col = format_unit(m.unit).to_string(); + table.with( + tabled::settings::Modify::new(tabled::settings::object::Cell::new(0, 0)) + .with(tabled::settings::Format::content(|_| interval_col.clone())), + ); + table.with( + tabled::settings::Modify::new(tabled::settings::object::Cell::new(0, 1)) + .with(tabled::settings::Format::content(|_| count_col.clone())), + ); + + println!("{table}"); + println!(); + } + Metric::Gauge(m) => { + let rows: Vec = m + .values + .iter() + .map(|(ts, value)| GaugeRow { + time: format_timestamp(*ts), + value: value.to_string(), + }) + .collect(); + + let count_col = format_unit(m.unit).to_string(); + println!("{}\n", m.name); + + let mut table = Table::new(rows); + table.modify( + tabled::settings::object::Columns::last(), + tabled::settings::Alignment::right(), + ); + + table.with( + tabled::settings::Modify::new(tabled::settings::object::Cell::new(0, 1)) + .with(tabled::settings::Format::content(|_| count_col.clone())), + ); + + println!("{table}"); + println!(); + } + Metric::Label(m) => { + println!("{}:", m.name); + for label in &m.values { + println!(" {}", label); + } + } + } + } +} + +fn resolve_encryption_key( + args: &cli::EncryptionKeyArgs, +) -> Result, CliError> { + match (&args.key, &args.key_file) { + (Some(key), _) => Ok(Some(key.clone())), + (_, Some(path)) => { + let contents = std::fs::read_to_string(path).map_err(|e| { + CliError::InvalidArgs(miette::miette!("cannot read encryption key file: {e}")) + })?; + Ok(Some(contents.trim().parse::().map_err( + |e| CliError::InvalidArgs(miette::miette!("{e}")), + )?)) + } + _ => Ok(None), + } +} diff --git a/cli/src/ops.rs b/cli/src/ops.rs new file mode 100644 index 00000000..468d6ab0 --- /dev/null +++ b/cli/src/ops.rs @@ -0,0 +1,783 @@ +use std::{pin::Pin, time::Duration}; + +use futures::{Stream, StreamExt, TryStreamExt, stream, stream::FuturesOrdered}; +use s2_api::v1::{ + access::AccessTokenInfo as ApiAccessTokenInfo, + config::{BasinConfig as ApiBasinConfig, StreamConfig as ApiStreamConfig}, +}; +use s2_sdk::{ + self as sdk, S2, S2Stream, + batching::BatchingConfig, + producer::{IndexedAppendAck, ProducerConfig}, + read_session::ReadSession, + types::{ + AccessTokenId, AccessTokenInfo, AccessTokenScopeInput, AccountMetricSet, AppendAck, + AppendInput, AppendRecord, AppendRecordBatch, BasinInfo, BasinMetricSet, BasinName, + BasinReconfiguration, CommandRecord, CreateBasinInput, CreateStreamInput, DeleteBasinInput, + DeleteStreamInput, EncryptionKey, FencingToken, GetAccountMetricsInput, + GetBasinMetricsInput, GetStreamMetricsInput, IssueAccessTokenInput, ListAccessTokensInput, + ListAllAccessTokensInput, ListAllBasinsInput, ListAllStreamsInput, ListBasinsInput, + ListStreamsInput, LocationInfo, LocationName, MeteredBytes, Metric, ReadFrom, ReadInput, + ReadLimits, ReadSessionConfig, ReadStart, ReadStop, ReconfigureBasinInput, + ReconfigureStreamInput, S2DateTime, SequencedRecord, StreamInfo, StreamMetricSet, + StreamPosition, StreamReconfiguration, TimeRange, TimeRangeAndInterval, + }, +}; + +fn stream_with_encryption( + s2: &S2, + uri: S2BasinAndStreamUri, + encryption_key: Option<&EncryptionKey>, +) -> S2Stream { + let stream = s2.basin(uri.basin).stream(uri.stream); + match encryption_key { + Some(encryption_key) => stream.with_encryption_key(encryption_key.clone()), + None => stream, + } +} + +use crate::{ + cli::{ + CreateBasinArgs, CreateStreamArgs, FenceArgs, GetAccountMetricsArgs, GetBasinMetricsArgs, + GetStreamMetricsArgs, IssueAccessTokenArgs, ListAccessTokensArgs, ListBasinsArgs, + ListStreamsArgs, ReadArgs, ReconfigureBasinArgs, ReconfigureStreamArgs, TailArgs, + TimeRangeArgs, TrimArgs, + }, + error::{CliError, OpKind}, + types::{BasinConfig, Interval, S2BasinAndStreamUri, StreamConfig}, +}; + +/// List basins, returning items and whether there are more. +/// If `no_auto_paginate` is true, returns a single page. +/// If false, fetches all pages and returns (all_items, false). +pub async fn list_basins( + s2: &S2, + args: ListBasinsArgs, +) -> Result<(Vec, bool), CliError> { + let ListBasinsArgs { + prefix, + start_after, + limit, + no_auto_paginate, + } = args; + + if no_auto_paginate { + let mut input = ListBasinsInput::new(); + if let Some(p) = prefix { + input = input.with_prefix(p); + } + if let Some(s) = start_after { + input = input.with_start_after(s); + } + if let Some(l) = limit { + input = input.with_limit(l); + } + + let page = s2 + .list_basins(input) + .await + .map_err(|e| CliError::op(OpKind::ListBasins, e))?; + Ok((page.values, page.has_more)) + } else { + let mut input = ListAllBasinsInput::new().with_include_deleted(true); + if let Some(p) = prefix { + input = input.with_prefix(p); + } + if let Some(s) = start_after { + input = input.with_start_after(s); + } + + let items: Vec<_> = s2 + .list_all_basins(input) + .take(limit.unwrap_or(usize::MAX)) + .try_collect() + .await + .map_err(|e| CliError::op(OpKind::ListBasins, e))?; + + Ok((items, false)) + } +} + +pub async fn create_basin(s2: &S2, args: CreateBasinArgs) -> Result { + let mut input = CreateBasinInput::new(args.basin.into()).with_config(args.config.into()); + if let Some(location) = args.location { + input = input + .with_location(location) + .map_err(|e| CliError::InvalidArgs(miette::miette!("{e}")))?; + } + s2.create_basin(input) + .await + .map_err(|e| CliError::op(OpKind::CreateBasin, e)) +} + +pub async fn delete_basin(s2: &S2, basin: &BasinName) -> Result<(), CliError> { + s2.delete_basin(DeleteBasinInput::new(basin.clone())) + .await + .map_err(|e| CliError::op(OpKind::DeleteBasin, e)) +} + +pub async fn get_basin_config( + s2: &S2, + basin: &BasinName, +) -> Result { + s2.get_basin_config(basin.clone()) + .await + .map_err(|e| CliError::op(OpKind::GetBasinConfig, e)) +} + +pub async fn get_basin_config_api(s2: &S2, basin: &BasinName) -> Result { + s2.get_basin_config_api(basin.clone()) + .await + .map_err(|e| CliError::op(OpKind::GetBasinConfig, e)) +} + +pub async fn reconfigure_basin( + s2: &S2, + args: ReconfigureBasinArgs, +) -> Result { + let mut reconfig = BasinReconfiguration::new(); + if !args.default_stream_config.is_empty() { + reconfig = reconfig.with_default_stream_config(args.default_stream_config.into()); + } + if let Some(algorithm) = args.stream_cipher { + reconfig = reconfig.with_stream_cipher(algorithm); + } + if let Some(val) = args.create_stream_on_append { + reconfig = reconfig.with_create_stream_on_append(val); + } + if let Some(val) = args.create_stream_on_read { + reconfig = reconfig.with_create_stream_on_read(val); + } + + reconfigure_basin_with(s2, args.basin.into(), reconfig).await +} + +pub async fn reconfigure_basin_with( + s2: &S2, + basin: BasinName, + reconfig: BasinReconfiguration, +) -> Result { + let config = s2 + .reconfigure_basin(ReconfigureBasinInput::new(basin, reconfig)) + .await + .map_err(|e| CliError::op(OpKind::ReconfigureBasin, e))?; + + Ok(config.into()) +} + +/// List access tokens, returning items and whether there are more. +pub async fn list_access_tokens( + s2: &S2, + args: ListAccessTokensArgs, +) -> Result<(Vec, bool), CliError> { + let ListAccessTokensArgs { + prefix, + start_after, + limit, + no_auto_paginate, + } = args; + + if no_auto_paginate { + let mut input = ListAccessTokensInput::new(); + if let Some(p) = prefix { + input = input.with_prefix(p); + } + if let Some(s) = start_after { + input = input.with_start_after(s); + } + if let Some(l) = limit { + input = input.with_limit(l); + } + + let page = s2 + .list_access_tokens(input) + .await + .map_err(|e| CliError::op(OpKind::ListAccessTokens, e))?; + + Ok((page.values, page.has_more)) + } else { + let mut input = ListAllAccessTokensInput::new(); + if let Some(p) = prefix { + input = input.with_prefix(p); + } + if let Some(s) = start_after { + input = input.with_start_after(s); + } + + let items: Vec<_> = s2 + .list_all_access_tokens(input) + .take(limit.unwrap_or(usize::MAX)) + .try_collect() + .await + .map_err(|e| CliError::op(OpKind::ListAccessTokens, e))?; + + Ok((items, false)) + } +} + +pub async fn get_access_token_api( + s2: &S2, + id: AccessTokenId, +) -> Result { + let page = s2 + .list_access_tokens_api( + ListAccessTokensInput::new() + .with_prefix(id.clone().into()) + .with_limit(1), + ) + .await + .map_err(|e| CliError::op(OpKind::ListAccessTokens, e))?; + + page.access_tokens + .into_iter() + .find(|info| info.id == id) + .ok_or_else(|| CliError::AccessTokenNotFound(id.to_string())) +} + +pub async fn issue_access_token(s2: &S2, args: IssueAccessTokenArgs) -> Result { + let mut scope = AccessTokenScopeInput::from_ops(args.ops.into_iter().map(|op| op.into())); + if let Some(v) = args.basins_exact { + scope = scope.with_basins(sdk::types::BasinMatcher::Exact(v)); + } else if let Some(v) = args.basins_prefix { + scope = scope.with_basins(sdk::types::BasinMatcher::Prefix(v)); + } + if let Some(v) = args.streams_exact { + scope = scope.with_streams(sdk::types::StreamMatcher::Exact(v)); + } else if let Some(v) = args.streams_prefix { + scope = scope.with_streams(sdk::types::StreamMatcher::Prefix(v)); + } + if let Some(v) = args.access_tokens_exact { + scope = scope.with_access_tokens(sdk::types::AccessTokenMatcher::Exact(v)); + } else if let Some(v) = args.access_tokens_prefix { + scope = scope.with_access_tokens(sdk::types::AccessTokenMatcher::Prefix(v)); + } + if let Some(op_group_perms) = args.op_group_perms { + scope = scope.with_op_group_perms(op_group_perms.into()); + } + + let mut input = IssueAccessTokenInput::new(args.id, scope); + if let Some(expires_in) = args.expires_in { + let expiry_time = std::time::SystemTime::now() + *expires_in; + let rfc3339 = humantime::format_rfc3339(expiry_time).to_string(); + let dt: S2DateTime = rfc3339.parse().map_err(|e| { + CliError::InvalidArgs(miette::miette!("Invalid expiration time: {}", e)) + })?; + input = input.with_expires_at(dt); + } else if let Some(expires_at) = args.expires_at { + let dt: S2DateTime = expires_at.parse().map_err(|e| { + CliError::InvalidArgs(miette::miette!( + "Invalid expires_at (expected RFC3339 format, e.g., '2024-12-31T23:59:59Z'): {}", + e + )) + })?; + input = input.with_expires_at(dt); + } + if args.auto_prefix_streams { + input = input.with_auto_prefix_streams(true); + } + + s2.issue_access_token(input) + .await + .map_err(|e| CliError::op(OpKind::IssueAccessToken, e)) +} + +pub async fn revoke_access_token(s2: &S2, id: AccessTokenId) -> Result<(), CliError> { + s2.revoke_access_token(id) + .await + .map_err(|e| CliError::op(OpKind::RevokeAccessToken, e)) +} + +/// List locations. +pub async fn list_locations(s2: &S2) -> Result, CliError> { + s2.list_locations() + .await + .map_err(|e| CliError::op(OpKind::ListLocations, e)) +} + +pub async fn get_default_location(s2: &S2) -> Result { + s2.get_default_location() + .await + .map_err(|e| CliError::op(OpKind::GetDefaultLocation, e)) +} + +pub async fn set_default_location( + s2: &S2, + location: LocationName, +) -> Result { + s2.set_default_location(location) + .await + .map_err(|e| CliError::op(OpKind::SetDefaultLocation, e)) +} + +pub async fn get_account_metrics( + s2: &S2, + args: GetAccountMetricsArgs, +) -> Result, CliError> { + use crate::cli::AccountMetricCommand; + + let set = match args.metric { + AccountMetricCommand::ActiveBasins(t) => { + let (start, end) = resolve_time_range(&t); + AccountMetricSet::ActiveBasins(TimeRange::new(start, end)) + } + AccountMetricCommand::AccountOps(t) => { + let (start, end) = resolve_time_range(&t.time_range); + AccountMetricSet::AccountOps(time_range_and_interval(start, end, t.interval)) + } + }; + + let input = GetAccountMetricsInput::new(set); + s2.get_account_metrics(input) + .await + .map_err(|e| CliError::op(OpKind::GetAccountMetrics, e)) +} + +pub async fn get_basin_metrics( + s2: &S2, + args: GetBasinMetricsArgs, +) -> Result, CliError> { + use crate::cli::BasinMetricCommand; + + let set = match args.metric { + BasinMetricCommand::Storage(t) => { + let (start, end) = resolve_time_range(&t); + BasinMetricSet::Storage(TimeRange::new(start, end)) + } + BasinMetricCommand::AppendOps(t) => { + let (start, end) = resolve_time_range(&t.time_range); + BasinMetricSet::AppendOps(time_range_and_interval(start, end, t.interval)) + } + BasinMetricCommand::ReadOps(t) => { + let (start, end) = resolve_time_range(&t.time_range); + BasinMetricSet::ReadOps(time_range_and_interval(start, end, t.interval)) + } + BasinMetricCommand::ReadThroughput(t) => { + let (start, end) = resolve_time_range(&t.time_range); + BasinMetricSet::ReadThroughput(time_range_and_interval(start, end, t.interval)) + } + BasinMetricCommand::AppendThroughput(t) => { + let (start, end) = resolve_time_range(&t.time_range); + BasinMetricSet::AppendThroughput(time_range_and_interval(start, end, t.interval)) + } + BasinMetricCommand::BasinOps(t) => { + let (start, end) = resolve_time_range(&t.time_range); + BasinMetricSet::BasinOps(time_range_and_interval(start, end, t.interval)) + } + }; + + let input = GetBasinMetricsInput::new(args.basin.into(), set); + s2.get_basin_metrics(input) + .await + .map_err(|e| CliError::op(OpKind::GetBasinMetrics, e)) +} + +pub async fn get_stream_metrics( + s2: &S2, + args: GetStreamMetricsArgs, +) -> Result, CliError> { + use crate::cli::StreamMetricCommand; + + let set = match args.metric { + StreamMetricCommand::Storage(t) => { + let (start, end) = resolve_time_range(&t); + StreamMetricSet::Storage(TimeRange::new(start, end)) + } + }; + + let input = GetStreamMetricsInput::new(args.uri.basin, args.uri.stream, set); + s2.get_stream_metrics(input) + .await + .map_err(|e| CliError::op(OpKind::GetStreamMetrics, e)) +} + +/// List streams, returning items and whether there are more. +pub async fn list_streams( + s2: &S2, + args: ListStreamsArgs, +) -> Result<(Vec, bool), CliError> { + let prefix = args.uri.stream.or(args.prefix); + let basin = s2.basin(args.uri.basin); + + if args.no_auto_paginate { + let mut input = ListStreamsInput::new(); + if let Some(p) = prefix { + input = input.with_prefix(p); + } + if let Some(s) = args.start_after { + input = input.with_start_after(s); + } + if let Some(l) = args.limit { + input = input.with_limit(l); + } + + let page = basin + .list_streams(input) + .await + .map_err(|e| CliError::op(OpKind::ListStreams, e))?; + Ok((page.values, page.has_more)) + } else { + let mut input = ListAllStreamsInput::new().with_include_deleted(true); + if let Some(p) = prefix { + input = input.with_prefix(p); + } + if let Some(s) = args.start_after { + input = input.with_start_after(s); + } + + let items: Vec<_> = basin + .list_all_streams(input) + .take(args.limit.unwrap_or(usize::MAX)) + .try_collect() + .await + .map_err(|e| CliError::op(OpKind::ListStreams, e))?; + + Ok((items, false)) + } +} + +pub async fn create_stream(s2: &S2, args: CreateStreamArgs) -> Result { + let basin = s2.basin(args.uri.basin); + let input = CreateStreamInput::new(args.uri.stream).with_config(args.config.into()); + basin + .create_stream(input) + .await + .map_err(|e| CliError::op(OpKind::CreateStream, e)) +} + +pub async fn delete_stream(s2: &S2, uri: S2BasinAndStreamUri) -> Result<(), CliError> { + let basin = s2.basin(uri.basin); + basin + .delete_stream(DeleteStreamInput::new(uri.stream)) + .await + .map_err(|e| CliError::op(OpKind::DeleteStream, e)) +} + +pub async fn get_stream_config( + s2: &S2, + uri: S2BasinAndStreamUri, +) -> Result { + let basin = s2.basin(uri.basin); + basin + .get_stream_config(uri.stream) + .await + .map_err(|e| CliError::op(OpKind::GetStreamConfig, e)) +} + +pub async fn get_stream_config_api( + s2: &S2, + uri: S2BasinAndStreamUri, +) -> Result { + let basin = s2.basin(uri.basin); + basin + .get_stream_config_api(uri.stream) + .await + .map_err(|e| CliError::op(OpKind::GetStreamConfig, e)) +} + +pub async fn reconfigure_stream( + s2: &S2, + args: ReconfigureStreamArgs, +) -> Result { + let reconfig: StreamReconfiguration = args.config.into(); + reconfigure_stream_with(s2, args.uri, reconfig).await +} + +pub async fn reconfigure_stream_with( + s2: &S2, + uri: S2BasinAndStreamUri, + reconfig: StreamReconfiguration, +) -> Result { + let config = s2 + .basin(uri.basin) + .reconfigure_stream(ReconfigureStreamInput::new(uri.stream, reconfig)) + .await + .map_err(|e| CliError::op(OpKind::ReconfigureStream, e))?; + + Ok(config.into()) +} + +pub async fn check_tail(s2: &S2, uri: S2BasinAndStreamUri) -> Result { + let stream = s2.basin(uri.basin).stream(uri.stream); + stream + .check_tail() + .await + .map_err(|e| CliError::op(OpKind::CheckTail, e)) +} + +pub async fn trim(s2: &S2, args: TrimArgs) -> Result { + let stream = s2.basin(args.uri.basin).stream(args.uri.stream); + append_command( + &stream, + CommandRecord::trim(args.trim_point), + args.fencing_token, + args.match_seq_num, + OpKind::Trim, + ) + .await +} + +pub async fn fence(s2: &S2, args: FenceArgs) -> Result { + let stream = s2.basin(args.uri.basin).stream(args.uri.stream); + append_command( + &stream, + CommandRecord::fence(args.new_fencing_token), + args.fencing_token, + args.match_seq_num, + OpKind::Fence, + ) + .await +} + +pub async fn read( + s2: &S2, + args: &ReadArgs, + encryption_key: Option<&EncryptionKey>, +) -> Result { + use std::time::SystemTime; + + let stream = stream_with_encryption(s2, args.uri.clone(), encryption_key); + + let from = match (args.seq_num, args.timestamp, args.tail_offset, args.ago) { + (Some(seq), None, None, None) => ReadFrom::SeqNum(seq), + (None, Some(ts), None, None) => ReadFrom::Timestamp(ts), + (None, None, Some(offset), None) => ReadFrom::TailOffset(offset), + (None, None, None, Some(ago)) => { + let ts = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_millis() + .saturating_sub(ago.as_millis()) as u64; + ReadFrom::Timestamp(ts) + } + (None, None, None, None) => ReadFrom::TailOffset(0), + _ => unreachable!("clap ensures only one start option"), + }; + + let start = ReadStart::new() + .with_from(from) + .with_clamp_to_tail(args.clamp); + + let mut limits = ReadLimits::new(); + if let Some(count) = args.count { + limits = limits.with_count(count as usize); + } + if let Some(bytes) = args.bytes { + limits = limits.with_bytes(bytes as usize); + } + + let mut stop = ReadStop::new().with_limits(limits); + if let Some(until) = args.until { + stop = stop.with_until(..until); + } + + let mut input = ReadInput::new().with_start(start).with_stop(stop); + if !args.stream_config.is_empty() { + input = input.with_stream_config(args.stream_config.clone().into()); + } + + stream + .read_session(input, ReadSessionConfig::default()) + .await + .map_err(|e| CliError::op(OpKind::Read, e)) +} + +/// Options controlling how records are appended. +pub struct AppendOptions { + pub fencing_token: Option, + pub match_seq_num: Option, + pub linger: Duration, + /// Stream configuration to apply if the stream is created on append. + pub stream_config: Option, +} + +pub fn append<'a, S, E>( + s2: &'a S2, + records: S, + uri: S2BasinAndStreamUri, + encryption_key: Option<&'a EncryptionKey>, + options: AppendOptions, +) -> impl Stream> + Send + 'a +where + S: Stream> + Send + Unpin + 'a, + E: std::error::Error + Send + Sync + 'static, +{ + let stream = stream_with_encryption(s2, uri, encryption_key); + + let batching_config = BatchingConfig::new().with_linger(options.linger); + let mut producer_config = ProducerConfig::new().with_batching(batching_config); + if let Some(config) = options.stream_config { + producer_config = producer_config.with_stream_config(config); + } + if let Some(ft) = options.fencing_token { + producer_config = producer_config.with_fencing_token(ft); + } + if let Some(seq) = options.match_seq_num { + producer_config = producer_config.with_match_seq_num(seq); + } + + let producer = stream.producer(producer_config); + + async_stream::stream! { + let mut records = records; + let mut pending_acks = FuturesOrdered::new(); + let mut input_done = false; + let mut stashed_record: Option = None; + let mut stashed_bytes: u32 = 0; + + 'inner: loop { + tokio::select! { + permit = producer.reserve(stashed_bytes), if stashed_record.is_some() => { + match permit { + Ok(permit) => { + let record = stashed_record.take().unwrap(); + pending_acks.push_back(permit.submit(record)); + } + Err(e) => { + yield Err(CliError::op(OpKind::Append, e)); + break 'inner; + } + } + } + + res = records.next(), if stashed_record.is_none() && !input_done => { + match res { + Some(Ok(record)) => { + stashed_bytes = record.metered_bytes() as u32; + stashed_record = Some(record); + } + Some(Err(e)) => { + yield Err(CliError::RecordReaderInit(e.to_string())); + break 'inner; + } + None => { + input_done = true; + } + } + } + + Some(res) = pending_acks.next() => { + match res { + Ok(ack) => yield Ok(ack), + Err(e) => { + yield Err(CliError::op(OpKind::Append, e)); + break 'inner; + } + } + } + + else => { + if input_done && stashed_record.is_none() && pending_acks.is_empty() { + break; + } + } + } + } + + if let Err(e) = producer.close().await { + yield Err(CliError::op(OpKind::Append, e)); + return; + } + + while let Some(res) = pending_acks.next().await { + match res { + Ok(ack) => yield Ok(ack), + Err(e) => { + yield Err(CliError::op(OpKind::Append, e)); + return; + } + } + } + } +} + +pub async fn tail( + s2: &S2, + args: &TailArgs, + encryption_key: Option<&EncryptionKey>, +) -> Result> + Send>>, CliError> { + let stream = stream_with_encryption(s2, args.uri.clone(), encryption_key); + + // Use clamp_to_tail to handle empty streams gracefully - if we ask for + // TailOffset(10) but there are fewer records, clamp to the actual start + let start = ReadStart::new() + .with_from(ReadFrom::TailOffset(args.lines)) + .with_clamp_to_tail(true); + let stop = if args.follow { + ReadStop::new() + } else { + ReadStop::new().with_limits(ReadLimits::new().with_count(args.lines as usize)) + }; + + let batches = stream + .read_session( + ReadInput::new().with_start(start).with_stop(stop), + ReadSessionConfig::default(), + ) + .await + .map_err(|e| CliError::op(OpKind::Tail, e))?; + + Ok(Box::pin( + batches + .map_err(|e| CliError::op(OpKind::Tail, e)) + .flat_map(|batch_result| match batch_result { + Ok(batch) => stream::iter(batch.records.into_iter().map(Ok)).left_stream(), + Err(e) => stream::iter(std::iter::once(Err(e))).right_stream(), + }), + )) +} + +async fn append_command( + stream: &S2Stream, + command: CommandRecord, + fencing_token: Option, + match_seq_num: Option, + op_error: OpKind, +) -> Result { + let record: AppendRecord = command.into(); + let records = AppendRecordBatch::try_from_iter([record]) + .expect("single command record should always fit in a batch"); + let mut input = AppendInput::new(records); + if let Some(ft) = fencing_token { + input = input.with_fencing_token(ft); + } + if let Some(seq) = match_seq_num { + input = input.with_match_seq_num(seq); + } + stream + .append(input) + .await + .map_err(|e| CliError::op(op_error, e)) +} + +fn resolve_time(timestamp: Option, ago: Option) -> u32 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as u32; + + match (timestamp, ago) { + (Some(ts), None) => ts, + (None, Some(ago)) => now.saturating_sub(ago.as_secs() as u32), + (None, None) => unreachable!("clap group ensures one is specified"), + (Some(_), Some(_)) => unreachable!("clap group ensures only one is specified"), + } +} + +fn resolve_time_range(args: &TimeRangeArgs) -> (u32, u32) { + ( + resolve_time(args.start_timestamp, args.start_ago), + resolve_time(args.end_timestamp, args.end_ago), + ) +} + +fn time_range_and_interval( + start: u32, + end: u32, + interval: Option, +) -> TimeRangeAndInterval { + let mut range = TimeRangeAndInterval::new(start, end); + if let Some(interval) = interval { + range = range.with_interval(interval.into()); + } + range +} diff --git a/cli/src/record_format.rs b/cli/src/record_format.rs new file mode 100644 index 00000000..74f24c06 --- /dev/null +++ b/cli/src/record_format.rs @@ -0,0 +1,594 @@ +use std::{io, io::BufRead, path::PathBuf, pin::Pin}; + +use clap::ValueEnum; +use futures::Stream; +use s2_sdk::types::{AppendRecord, SequencedRecord}; +use tokio::{ + fs::{File, OpenOptions}, + io::{AsyncBufReadExt, AsyncWrite, BufWriter}, + sync::mpsc, +}; +use tokio_stream::wrappers::{LinesStream, ReceiverStream}; +use tracing::trace; + +use crate::error::RecordParseError; + +#[derive(Debug, Clone, Copy, Default, ValueEnum)] +pub enum RecordFormat { + /// Plaintext record body as UTF-8. + /// If the body is not valid UTF-8, this will be a lossy decoding. + /// Headers cannot be represented, so command records are sent to stderr when reading. + #[default] + #[clap(alias = "")] + Text, + /// JSON format with UTF-8 headers and body. + /// If the data is not valid UTF-8, this will be a lossy decoding. + #[clap(alias = "raw")] + Json, + /// JSON format with headers and body encoded as Base64. + #[clap(aliases = ["base64", "json-binsafe"])] + JsonBase64, +} + +#[derive(Debug, Clone)] +pub enum RecordsIn { + File(PathBuf), + Stdin, +} + +/// Sink for records in a read session. +#[derive(Debug, Clone)] +pub enum RecordsOut { + File(PathBuf), + Stdout, +} + +impl RecordsIn { + pub async fn reader( + &self, + ) -> io::Result> + Send>>> { + match self { + RecordsIn::File(path) => { + let file = File::open(path).await?; + let stream: Pin> + Send>> = + Box::pin(LinesStream::new(tokio::io::BufReader::new(file).lines())); + Ok(stream) + } + RecordsIn::Stdin => Ok(Box::pin(stdio_lines_stream(std::io::stdin()))), + } + } +} + +impl RecordsOut { + pub async fn writer(&self) -> io::Result> { + match self { + RecordsOut::File(path) => { + trace!(?path, "opening file writer"); + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) + .await?; + + Ok(Box::new(BufWriter::new(file))) + } + RecordsOut::Stdout => { + trace!("stdout writer"); + Ok(Box::new(BufWriter::new(tokio::io::stdout()))) + } + } + } +} + +fn stdio_lines_stream(f: F) -> ReceiverStream> +where + F: std::io::Read + Send + 'static, +{ + let lines = std::io::BufReader::new(f).lines(); + let (tx, rx) = mpsc::channel(s2_sdk::types::RECORD_BATCH_MAX.count); + let _handle = std::thread::spawn(move || { + for line in lines { + if tx.blocking_send(line).is_err() { + return; + } + } + }); + ReceiverStream::new(rx) +} + +pub fn parse_records_input_source(s: &str) -> Result { + match s { + "" | "-" => Ok(RecordsIn::Stdin), + _ => Ok(RecordsIn::File(PathBuf::from(s))), + } +} + +pub fn parse_records_output_source(s: &str) -> Result { + match s { + "" | "-" => Ok(RecordsOut::Stdout), + _ => Ok(RecordsOut::File(PathBuf::from(s))), + } +} + +pub trait RecordParser +where + I: Stream> + Send + Unpin, +{ + type RecordStream: Stream> + Send + Unpin; + + fn parse_records(lines: I) -> Self::RecordStream; +} + +pub trait RecordWriter { + async fn write_record( + record: &SequencedRecord, + writer: &mut (impl AsyncWrite + Unpin), + ) -> io::Result<()>; +} + +pub use body::TextFormatter; +pub type JsonFormatter = json::Formatter; +pub type JsonBase64Formatter = json::Formatter; + +mod body { + use std::{ + io, + pin::Pin, + task::{Context, Poll}, + }; + + use futures::{Stream, StreamExt}; + use s2_sdk::types::{AppendRecord, SequencedRecord}; + use tokio::io::{AsyncWrite, AsyncWriteExt}; + + use super::{RecordParseError, RecordParser, RecordWriter}; + + pub struct TextFormatter; + + impl RecordWriter for TextFormatter { + async fn write_record( + record: &SequencedRecord, + writer: &mut (impl AsyncWrite + Unpin), + ) -> io::Result<()> { + let s = String::from_utf8_lossy(&record.body); + writer.write_all(s.as_bytes()).await + } + } + + impl RecordParser for TextFormatter + where + I: Stream> + Send + Unpin, + { + type RecordStream = RecordStream; + + fn parse_records(lines: I) -> Self::RecordStream { + RecordStream(lines) + } + } + + pub struct RecordStream(S); + + impl Stream for RecordStream + where + S: Stream> + Send + Unpin, + { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.0.poll_next_unpin(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(None) => Poll::Ready(None), + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e.into()))), + Poll::Ready(Some(Ok(s))) => Poll::Ready(Some( + AppendRecord::new(s).map_err(|e| RecordParseError::Parse(e.to_string())), + )), + } + } + } +} + +mod json { + use std::{ + borrow::Cow, + io, + pin::Pin, + task::{Context, Poll}, + }; + + use base64ct::{Base64, Encoding}; + use bytes::Bytes; + use futures::{Stream, StreamExt}; + use s2_sdk::types::{AppendRecord, Header, SequencedRecord}; + use serde::{Deserialize, Serialize}; + use tokio::io::{AsyncWrite, AsyncWriteExt}; + + use super::{RecordParseError, RecordParser, RecordWriter}; + + #[derive(Debug, Clone, Default)] + struct CowStr<'a, const BIN_SAFE: bool>(Cow<'a, str>); + + impl CowStr<'_, BIN_SAFE> { + fn is_empty(&self) -> bool { + self.0.is_empty() + } + } + + type OwnedCowStr = CowStr<'static, BIN_SAFE>; + + impl<'a, const BIN_SAFE: bool> From<&'a [u8]> for CowStr<'a, BIN_SAFE> { + fn from(value: &'a [u8]) -> Self { + Self(if BIN_SAFE { + Base64::encode_string(value).into() + } else { + String::from_utf8_lossy(value) + }) + } + } + + impl TryFrom> for Bytes { + type Error = String; + + fn try_from(value: OwnedCowStr) -> Result { + let CowStr(s) = value; + + Ok(if BIN_SAFE { + Base64::decode_vec(&s).map_err(|_| format!("invalid base64: {s}"))? + } else { + s.into_owned().into_bytes() + } + .into()) + } + } + + impl Serialize for CowStr<'_, BIN_SAFE> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } + } + + impl<'de, const BIN_SAFE: bool> Deserialize<'de> for OwnedCowStr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer).map(|s| CowStr(s.into())) + } + } + + pub struct Formatter; + + #[derive(Debug, Clone, Serialize)] + struct SerializableSequencedRecord<'a, const BIN_SAFE: bool> { + seq_num: u64, + timestamp: u64, + #[serde(skip_serializing_if = "Vec::is_empty")] + headers: Vec<(CowStr<'a, BIN_SAFE>, CowStr<'a, BIN_SAFE>)>, + #[serde(skip_serializing_if = "CowStr::is_empty")] + body: CowStr<'a, BIN_SAFE>, + } + + impl<'a, const BIN_SAFE: bool> From<&'a SequencedRecord> + for SerializableSequencedRecord<'a, BIN_SAFE> + { + fn from(value: &'a SequencedRecord) -> Self { + let SequencedRecord { + timestamp, + seq_num, + headers, + body, + .. + } = value; + + let headers: Vec<(CowStr, CowStr)> = headers + .iter() + .map(|h| (h.name.as_ref().into(), h.value.as_ref().into())) + .collect(); + + let body: CowStr = body.as_ref().into(); + + SerializableSequencedRecord { + timestamp: *timestamp, + seq_num: *seq_num, + headers, + body, + } + } + } + + impl RecordWriter for Formatter { + async fn write_record( + record: &SequencedRecord, + writer: &mut (impl AsyncWrite + Unpin), + ) -> io::Result<()> { + let record: SerializableSequencedRecord = record.into(); + let s = serde_json::to_string(&record).map_err(io::Error::other)?; + writer.write_all(s.as_bytes()).await + } + } + + impl RecordParser for Formatter + where + I: Stream> + Send + Unpin, + { + type RecordStream = RecordStream; + + fn parse_records(lines: I) -> Self::RecordStream { + RecordStream(lines) + } + } + + #[derive(Debug, Clone, Deserialize)] + struct DeserializableAppendRecord { + timestamp: Option, + #[serde(default)] + headers: Vec<(OwnedCowStr, OwnedCowStr)>, + #[serde(default)] + body: OwnedCowStr, + } + + impl TryFrom> for AppendRecord { + type Error = String; + + fn try_from(value: DeserializableAppendRecord) -> Result { + let DeserializableAppendRecord { + timestamp, + headers, + body, + } = value; + + let body_bytes: Bytes = body.try_into()?; + let mut record = AppendRecord::new(body_bytes).map_err(|e| e.to_string())?; + + if !headers.is_empty() { + let parsed_headers: Vec

= headers + .into_iter() + .map(|(name, value)| { + let name_bytes: Bytes = name.try_into()?; + let value_bytes: Bytes = value.try_into()?; + Ok(Header::new(name_bytes, value_bytes)) + }) + .collect::, String>>()?; + record = record + .with_headers(parsed_headers) + .map_err(|e| e.to_string())?; + } + + if let Some(ts) = timestamp { + record = record.with_timestamp(ts); + } + + Ok(record) + } + } + + pub struct RecordStream(S); + + impl Stream for RecordStream + where + S: Stream> + Send + Unpin, + { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + fn parse_record( + s: String, + ) -> Result { + let append_record: DeserializableAppendRecord = + serde_json::from_str(&s).map_err(|e| RecordParseError::Parse(e.to_string()))?; + + Ok(append_record.try_into()?) + } + + match self.0.poll_next_unpin(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(None) => Poll::Ready(None), + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e.into()))), + Poll::Ready(Some(Ok(s))) => Poll::Ready(Some(parse_record::(s))), + } + } + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use futures::{StreamExt, executor::block_on}; + use proptest::{prelude::*, test_runner::TestCaseResult}; + use s2_sdk::types::Header; + + use super::*; + + fn ascii_string_strategy(max_len: usize) -> impl Strategy { + prop::collection::vec(0x20u8..=0x7e, 0..=max_len) + .prop_map(|bytes| String::from_utf8(bytes).unwrap()) + } + + fn string_strategy(max_len: usize) -> impl Strategy { + prop::collection::vec(any::(), 0..=max_len) + .prop_map(|chars| chars.into_iter().collect()) + } + + fn bytes_strategy(max_len: usize) -> impl Strategy> { + prop::collection::vec(any::(), 0..=max_len) + } + + fn sequenced_record( + seq_num: u64, + timestamp: u64, + headers: Vec
, + body: Bytes, + ) -> SequencedRecord { + SequencedRecord::from_parts(seq_num, timestamp, headers, body) + } + + async fn parse_text_line(line: String) -> AppendRecord { + let lines = futures::stream::iter(vec![Ok(line)]); + let mut stream = TextFormatter::parse_records(lines); + let record = stream.next().await.unwrap().unwrap(); + assert!(stream.next().await.is_none()); + record + } + + async fn parse_json_line(line: String) -> AppendRecord { + let lines = futures::stream::iter(vec![Ok(line)]); + let mut stream = + as RecordParser<_>>::parse_records(lines); + let record = stream.next().await.unwrap().unwrap(); + assert!(stream.next().await.is_none()); + record + } + + fn prop_assert_headers_eq(actual: &[Header], expected: &[(E, E)]) -> TestCaseResult + where + E: AsRef<[u8]>, + { + prop_assert_eq!(actual.len(), expected.len()); + for (actual, (expected_name, expected_value)) in actual.iter().zip(expected.iter()) { + prop_assert_eq!(actual.name.as_ref(), expected_name.as_ref()); + prop_assert_eq!(actual.value.as_ref(), expected_value.as_ref()); + } + Ok(()) + } + + // -- TextFormatter: parse_records -- + + #[tokio::test] + async fn text_parse_records() { + let lines = + futures::stream::iter(vec![Ok("line one".to_string()), Ok("line two".to_string())]); + let mut stream = TextFormatter::parse_records(lines); + let r1 = stream.next().await.unwrap().unwrap(); + assert_eq!(r1.body(), b"line one"); + let r2 = stream.next().await.unwrap().unwrap(); + assert_eq!(r2.body(), b"line two"); + assert!(stream.next().await.is_none()); + } + + // -- JsonFormatter: parse_records -- + + #[tokio::test] + async fn json_parse_records_invalid_json() { + let lines = futures::stream::iter(vec![Ok("not json".to_string())]); + let mut stream = >::parse_records(lines); + assert!(stream.next().await.unwrap().is_err()); + } + + #[tokio::test] + async fn json_parse_records_empty_body() { + let json_line = r#"{}"#.to_string(); + let lines = futures::stream::iter(vec![Ok(json_line)]); + let mut stream = >::parse_records(lines); + let r = stream.next().await.unwrap().unwrap(); + assert_eq!(r.body(), b""); + assert!(r.headers().is_empty()); + assert_eq!(r.timestamp(), None); + } + + // -- JsonBase64Formatter: parse_records -- + + #[tokio::test] + async fn json_base64_parse_records_invalid_base64() { + let json_line = r#"{"body":"not-valid-base64!!!"}"#.to_string(); + let lines = futures::stream::iter(vec![Ok(json_line)]); + let mut stream = >::parse_records(lines); + assert!(stream.next().await.unwrap().is_err()); + } + + // -- TextFormatter: parse IO error propagation -- + + #[tokio::test] + async fn text_parse_records_io_error() { + let lines = futures::stream::iter(vec![Err(io::Error::other("test error"))]); + let mut stream = TextFormatter::parse_records(lines); + let result = stream.next().await.unwrap(); + assert!(result.is_err()); + } + + // -- JsonFormatter: parse IO error propagation -- + + #[tokio::test] + async fn json_parse_records_io_error() { + let lines = futures::stream::iter(vec![Err(io::Error::other("io error"))]); + let mut stream = >::parse_records(lines); + assert!(stream.next().await.unwrap().is_err()); + } + + proptest! { + #[test] + fn text_formatter_write_then_parse_preserves_ascii_body(body in ascii_string_strategy(256)) { + let record = sequenced_record(0, 0, vec![], Bytes::from(body.clone())); + + let output = block_on(async { + let mut output = Vec::new(); + TextFormatter::write_record(&record, &mut output).await.unwrap(); + output + }); + prop_assert_eq!(output.as_slice(), body.as_bytes()); + + let parsed = block_on(parse_text_line(String::from_utf8(output).unwrap())); + prop_assert_eq!(parsed.body(), body.as_bytes()); + prop_assert!(parsed.headers().is_empty()); + prop_assert_eq!(parsed.timestamp(), None); + } + + #[test] + fn json_formatter_write_then_parse_preserves_utf8_fields( + body in string_strategy(256), + headers in prop::collection::vec((string_strategy(32), string_strategy(64)), 0..=8), + timestamp in any::(), + ) { + let record = sequenced_record( + 17, + timestamp, + headers + .iter() + .map(|(name, value)| Header::new(Bytes::from(name.clone()), Bytes::from(value.clone()))) + .collect(), + Bytes::from(body.clone()), + ); + + let output = block_on(async { + let mut output = Vec::new(); + JsonFormatter::write_record(&record, &mut output).await.unwrap(); + output + }); + + let parsed = block_on(parse_json_line::(String::from_utf8(output).unwrap())); + prop_assert_eq!(parsed.body(), body.as_bytes()); + prop_assert_eq!(parsed.timestamp(), Some(timestamp)); + prop_assert_headers_eq(parsed.headers(), &headers)?; + } + + #[test] + fn json_base64_formatter_write_then_parse_preserves_binary_fields( + body in bytes_strategy(256), + headers in prop::collection::vec((bytes_strategy(32), bytes_strategy(64)), 0..=8), + timestamp in any::(), + ) { + let record = sequenced_record( + 17, + timestamp, + headers + .iter() + .map(|(name, value)| Header::new(Bytes::from(name.clone()), Bytes::from(value.clone()))) + .collect(), + Bytes::from(body.clone()), + ); + + let output = block_on(async { + let mut output = Vec::new(); + JsonBase64Formatter::write_record(&record, &mut output).await.unwrap(); + output + }); + + let parsed = block_on(parse_json_line::(String::from_utf8(output).unwrap())); + prop_assert_eq!(parsed.body(), body.as_slice()); + prop_assert_eq!(parsed.timestamp(), Some(timestamp)); + prop_assert_headers_eq(parsed.headers(), &headers)?; + } + } +} diff --git a/cli/src/types.rs b/cli/src/types.rs new file mode 100644 index 00000000..d6187cbc --- /dev/null +++ b/cli/src/types.rs @@ -0,0 +1,1235 @@ +use std::{str::FromStr, time::Duration}; + +use clap::{Args, Parser, ValueEnum}; +use colored::Colorize; +use compact_str::CompactString; +use s2_sdk::{ + self as sdk, + types::{ + AccessTokenId, AccessTokenIdPrefix, BasinName, BasinNamePrefix, EncryptionAlgorithm, + StreamName, StreamNamePrefix, TimeseriesInterval, + }, +}; +use serde::Serialize; + +use crate::error::{OpGroupsParseError, S2UriParseError}; + +#[derive(Debug, Clone, PartialEq)] +struct S2Uri { + basin: BasinName, + stream: Option, +} + +impl FromStr for S2Uri { + type Err = S2UriParseError; + + fn from_str(s: &str) -> Result { + let (scheme, s) = s + .split_once("://") + .ok_or(S2UriParseError::MissingUriScheme)?; + if scheme != "s2" { + return Err(S2UriParseError::InvalidUriScheme(scheme.to_owned())); + } + + let (basin, stream) = match s.split_once("/") { + Some((basin, stream)) => (basin, (!stream.is_empty()).then(|| stream.to_owned())), + None => (s, None), + }; + + Ok(S2Uri { + basin: basin + .parse() + .map_err(|e| S2UriParseError::InvalidBasinName(format!("{e}")))?, + stream, + }) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct S2BasinUri(pub BasinName); + +impl From for BasinName { + fn from(value: S2BasinUri) -> Self { + value.0 + } +} + +impl FromStr for S2BasinUri { + type Err = S2UriParseError; + + fn from_str(s: &str) -> Result { + match S2Uri::from_str(s) { + Ok(S2Uri { + basin, + stream: None, + }) => Ok(Self(basin)), + Ok(S2Uri { + basin: _, + stream: Some(_), + }) => Err(S2UriParseError::UnexpectedStreamName), + Err(S2UriParseError::MissingUriScheme) => { + Ok(Self(s.parse().map_err(|e| { + S2UriParseError::InvalidBasinName(format!("{e}")) + })?)) + } + Err(other) => Err(other), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct S2BasinAndMaybeStreamUri { + pub basin: BasinName, + pub stream: Option, +} + +impl FromStr for S2BasinAndMaybeStreamUri { + type Err = S2UriParseError; + + fn from_str(s: &str) -> Result { + match S2Uri::from_str(s) { + Ok(S2Uri { basin, stream }) => { + let stream = stream + .map(|s| { + s.parse() + .map_err(|e| S2UriParseError::InvalidStreamName(format!("{e}"))) + }) + .transpose()?; + Ok(Self { basin, stream }) + } + Err(S2UriParseError::MissingUriScheme) => Ok(Self { + basin: s + .parse() + .map_err(|e| S2UriParseError::InvalidBasinName(format!("{e}")))?, + stream: None, + }), + Err(other) => Err(other), + } + } +} + +/// String Format: s2://{basin}/{stream} +#[derive(Debug, Clone, PartialEq)] +pub struct S2BasinAndStreamUri { + pub basin: BasinName, + pub stream: StreamName, +} + +impl FromStr for S2BasinAndStreamUri { + type Err = S2UriParseError; + + fn from_str(s: &str) -> Result { + let S2Uri { basin, stream } = s.parse()?; + let stream = stream.ok_or(S2UriParseError::MissingStreamName)?; + let stream: StreamName = stream + .parse() + .map_err(|e| S2UriParseError::InvalidStreamName(format!("{e}")))?; + Ok(Self { basin, stream }) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DiffResource { + Basin(BasinName), + Stream(S2BasinAndStreamUri), + AccessToken(AccessTokenId), +} + +impl FromStr for DiffResource { + type Err = String; + + fn from_str(s: &str) -> Result { + if !s.contains("://") { + return Err( + "resource type cannot be inferred from a bare name; use `--resource`".to_owned(), + ); + } + + let S2Uri { basin, stream } = s.parse().map_err(|e: S2UriParseError| e.to_string())?; + match stream { + Some(stream) => Ok(Self::Stream(S2BasinAndStreamUri { + basin, + stream: stream.parse().map_err(|e| format!("{e}"))?, + })), + None => Ok(Self::Basin(basin)), + } + } +} + +#[derive(Parser, Debug, Clone, Serialize)] +pub struct BasinConfig { + #[clap(flatten)] + pub default_stream_config: StreamConfig, + /// Encryption algorithm to apply to newly created streams in this basin. + #[arg(long)] + pub stream_cipher: Option, + /// Create stream on append with basin defaults if it doesn't exist. + #[arg(long, default_value_t = false)] + pub create_stream_on_append: bool, + /// Create stream on read with basin defaults if it doesn't exist. + #[arg(long, default_value_t = false)] + pub create_stream_on_read: bool, +} + +#[derive(Parser, Debug, Clone, Serialize, Default)] +pub struct StreamConfig { + #[arg(long)] + /// Storage class for a stream. + pub storage_class: Option, + #[arg(long, help("Example: 1d, 1w, 1y"))] + /// Retention policy for a stream. + pub retention_policy: Option, + #[clap(flatten)] + /// Timestamping configuration. + pub timestamping: Option, + #[clap(flatten)] + /// Delete-on-empty configuration. + pub delete_on_empty: Option, +} + +impl StreamConfig { + pub fn is_empty(&self) -> bool { + let Self { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = self; + storage_class.is_none() + && retention_policy.is_none() + && timestamping.is_none() + && delete_on_empty.is_none() + } +} + +pub fn resolve_stream_config( + config: s2_api::v1::config::StreamConfig, + basin_defaults: s2_api::v1::config::StreamConfig, +) -> Result { + let config: s2_common::config::OptionalStreamConfig = config.try_into()?; + Ok(config.merge(basin_defaults.try_into()?)) +} + +pub use sdk::types::LocationName; + +#[derive(ValueEnum, Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum TimestampingMode { + ClientPrefer, + ClientRequire, + Arrival, +} + +#[derive(Parser, Debug, Clone, Serialize)] +pub struct TimestampingConfig { + #[arg(long)] + /// Timestamping mode. + pub timestamping_mode: Option, + + #[arg(long)] + /// Uncapped timestamps. + pub timestamping_uncapped: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub enum RetentionPolicy { + Age(#[serde(serialize_with = "serialize_duration_humantime")] Duration), + Infinite, +} + +impl TryFrom<&str> for RetentionPolicy { + type Error = &'static str; + + fn try_from(value: &str) -> Result { + if value == "infinite" { + return Ok(RetentionPolicy::Infinite); + } else if let Ok(d) = humantime::parse_duration(value) { + return Ok(RetentionPolicy::Age(d)); + } + Err("invalid retention policy: expected a duration, or 'infinite'") + } +} + +impl FromStr for RetentionPolicy { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + RetentionPolicy::try_from(s) + } +} + +#[derive(Args, Clone, Debug, Serialize)] +pub struct DeleteOnEmptyConfig { + #[arg(long, value_parser = humantime::parse_duration, required = false)] + #[serde(serialize_with = "serialize_duration_humantime")] + /// Minimum age before an empty stream can be deleted. + /// Example: 1d, 1w, 1y + pub delete_on_empty_min_age: Duration, +} + +impl From for sdk::types::DeleteOnEmptyConfig { + fn from(value: DeleteOnEmptyConfig) -> Self { + sdk::types::DeleteOnEmptyConfig::new().with_min_age(value.delete_on_empty_min_age) + } +} + +impl From for sdk::types::DeleteOnEmptyReconfiguration { + fn from(value: DeleteOnEmptyConfig) -> Self { + sdk::types::DeleteOnEmptyReconfiguration::new().with_min_age(value.delete_on_empty_min_age) + } +} + +impl From for DeleteOnEmptyConfig { + fn from(value: sdk::types::DeleteOnEmptyConfig) -> Self { + Self { + delete_on_empty_min_age: Duration::from_secs(value.min_age_secs), + } + } +} + +impl From for sdk::types::BasinConfig { + fn from(config: BasinConfig) -> Self { + let mut basin_config = sdk::types::BasinConfig::new() + .with_default_stream_config(config.default_stream_config.into()); + if let Some(algorithm) = config.stream_cipher { + basin_config = basin_config.with_stream_cipher(algorithm); + } + basin_config + .with_create_stream_on_append(config.create_stream_on_append) + .with_create_stream_on_read(config.create_stream_on_read) + } +} + +impl From for sdk::types::StreamConfig { + fn from(config: StreamConfig) -> Self { + let mut stream_config = sdk::types::StreamConfig::new(); + if let Some(storage_class) = config.storage_class { + stream_config = stream_config.with_storage_class(storage_class); + } + if let Some(retention_policy) = config.retention_policy { + stream_config = stream_config.with_retention_policy(retention_policy.into()); + } + if let Some(timestamping) = config.timestamping { + stream_config = stream_config.with_timestamping(timestamping.into()); + } + if let Some(delete_on_empty) = config.delete_on_empty { + stream_config = stream_config.with_delete_on_empty(delete_on_empty.into()); + } + stream_config + } +} + +impl From for sdk::types::TimestampingMode { + fn from(mode: TimestampingMode) -> Self { + match mode { + TimestampingMode::ClientPrefer => sdk::types::TimestampingMode::ClientPrefer, + TimestampingMode::ClientRequire => sdk::types::TimestampingMode::ClientRequire, + TimestampingMode::Arrival => sdk::types::TimestampingMode::Arrival, + } + } +} + +impl From for TimestampingMode { + fn from(mode: sdk::types::TimestampingMode) -> Self { + match mode { + sdk::types::TimestampingMode::ClientPrefer => TimestampingMode::ClientPrefer, + sdk::types::TimestampingMode::ClientRequire => TimestampingMode::ClientRequire, + sdk::types::TimestampingMode::Arrival => TimestampingMode::Arrival, + } + } +} + +impl From for sdk::types::TimestampingConfig { + fn from(config: TimestampingConfig) -> Self { + let mut result = sdk::types::TimestampingConfig::new(); + if let Some(mode) = config.timestamping_mode { + result = result.with_mode(mode.into()); + } + if let Some(uncapped) = config.timestamping_uncapped { + result = result.with_uncapped(uncapped); + } + result + } +} + +impl From for TimestampingConfig { + fn from(config: sdk::types::TimestampingConfig) -> Self { + TimestampingConfig { + timestamping_mode: config.mode.map(Into::into), + timestamping_uncapped: config.uncapped, + } + } +} + +impl From for sdk::types::RetentionPolicy { + fn from(policy: RetentionPolicy) -> Self { + match policy { + RetentionPolicy::Age(d) => sdk::types::RetentionPolicy::Age(d.as_secs()), + RetentionPolicy::Infinite => sdk::types::RetentionPolicy::Infinite, + } + } +} + +impl From for RetentionPolicy { + fn from(policy: sdk::types::RetentionPolicy) -> Self { + match policy { + sdk::types::RetentionPolicy::Age(secs) => { + RetentionPolicy::Age(Duration::from_secs(secs)) + } + sdk::types::RetentionPolicy::Infinite => RetentionPolicy::Infinite, + } + } +} + +impl From for BasinConfig { + fn from(config: sdk::types::BasinConfig) -> Self { + BasinConfig { + default_stream_config: config + .default_stream_config + .map(Into::into) + .unwrap_or_default(), + stream_cipher: config.stream_cipher, + create_stream_on_append: config.create_stream_on_append, + create_stream_on_read: config.create_stream_on_read, + } + } +} + +impl From for StreamConfig { + fn from(config: sdk::types::StreamConfig) -> Self { + StreamConfig { + storage_class: config.storage_class, + retention_policy: config.retention_policy.map(Into::into), + timestamping: config.timestamping.map(Into::into), + delete_on_empty: config.delete_on_empty.map(Into::into), + } + } +} + +impl From for sdk::types::StreamReconfiguration { + fn from(config: StreamConfig) -> Self { + let mut reconfig = sdk::types::StreamReconfiguration::new(); + if let Some(storage_class) = config.storage_class { + reconfig = reconfig.with_storage_class(storage_class); + } + if let Some(retention_policy) = config.retention_policy { + reconfig = reconfig.with_retention_policy(retention_policy.into()); + } + if let Some(timestamping) = config.timestamping { + let ts_reconfig = sdk::types::TimestampingReconfiguration::from(timestamping); + reconfig = reconfig.with_timestamping(ts_reconfig); + } + if let Some(delete_on_empty) = config.delete_on_empty { + reconfig = reconfig.with_delete_on_empty(delete_on_empty.into()); + } + reconfig + } +} + +impl From for sdk::types::TimestampingReconfiguration { + fn from(config: TimestampingConfig) -> Self { + let mut result = sdk::types::TimestampingReconfiguration::new(); + if let Some(mode) = config.timestamping_mode { + result = result.with_mode(mode.into()); + } + if let Some(uncapped) = config.timestamping_uncapped { + result = result.with_uncapped(uncapped); + } + result + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BasinMatcher { + #[serde(serialize_with = "serialize_display")] + Exact(BasinName), + #[serde(serialize_with = "serialize_display")] + Prefix(BasinNamePrefix), +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StreamMatcher { + #[serde(serialize_with = "serialize_display")] + Exact(StreamName), + #[serde(serialize_with = "serialize_display")] + Prefix(StreamNamePrefix), +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AccessTokenMatcher { + #[serde(serialize_with = "serialize_display")] + Exact(AccessTokenId), + #[serde(serialize_with = "serialize_display")] + Prefix(AccessTokenIdPrefix), +} + +fn serialize_display(value: &T, serializer: S) -> Result +where + T: std::fmt::Display, + S: serde::Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +fn serialize_duration_humantime(value: &Duration, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&humantime::format_duration(*value).to_string()) +} + +impl BasinMatcher { + /// Converts an SDK matcher, returning `None` for a matcher that matches no resources. + fn from_sdk(matcher: sdk::types::BasinMatcher) -> Option { + match matcher { + sdk::types::BasinMatcher::Exact(v) => Some(BasinMatcher::Exact(v)), + sdk::types::BasinMatcher::Prefix(v) => Some(BasinMatcher::Prefix(v)), + sdk::types::BasinMatcher::None => None, + } + } +} + +impl StreamMatcher { + /// Converts an SDK matcher, returning `None` for a matcher that matches no resources. + fn from_sdk(matcher: sdk::types::StreamMatcher) -> Option { + match matcher { + sdk::types::StreamMatcher::Exact(v) => Some(StreamMatcher::Exact(v)), + sdk::types::StreamMatcher::Prefix(v) => Some(StreamMatcher::Prefix(v)), + sdk::types::StreamMatcher::None => None, + } + } +} + +impl AccessTokenMatcher { + /// Converts an SDK matcher, returning `None` for a matcher that matches no resources. + fn from_sdk(matcher: sdk::types::AccessTokenMatcher) -> Option { + match matcher { + sdk::types::AccessTokenMatcher::Exact(v) => Some(AccessTokenMatcher::Exact(v)), + sdk::types::AccessTokenMatcher::Prefix(v) => Some(AccessTokenMatcher::Prefix(v)), + sdk::types::AccessTokenMatcher::None => None, + } + } +} + +/// Compact human summary for a resource matcher, used in `s2 list-access-tokens`: +/// +/// - `*` matches any resource (an empty prefix). +/// - `*` matches names beginning with ``. +/// - `` matches exactly that name. +macro_rules! impl_matcher_summary { + ($($matcher:ty),+ $(,)?) => {$( + impl $matcher { + fn summary(&self) -> String { + match self { + Self::Exact(name) => name.to_string(), + Self::Prefix(prefix) => format!("{prefix}*"), + } + } + } + )+}; +} +impl_matcher_summary!(BasinMatcher, StreamMatcher, AccessTokenMatcher); + +/// Renders an optional matcher, using `∅` when the scope grants no access to that resource. +fn matcher_summary(matcher: Option) -> String { + matcher.unwrap_or_else(|| "∅".to_owned()) +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct PermittedOperationGroups { + pub account: Option, + pub basin: Option, + pub stream: Option, +} + +impl PermittedOperationGroups { + /// Compact grant summary like `a:rw b:r s:rw`, labelling account/basin/stream. Groups with + /// no permission are omitted; a token with no group permissions renders as `none`. + fn summary(&self) -> String { + let rendered: Vec = [ + ("a", self.account.as_ref()), + ("b", self.basin.as_ref()), + ("s", self.stream.as_ref()), + ] + .into_iter() + .filter_map(|(label, perm)| { + let perm = perm?; + let mut rw = String::new(); + if perm.read { + rw.push('r'); + } + if perm.write { + rw.push('w'); + } + (!rw.is_empty()).then(|| format!("{label}:{rw}")) + }) + .collect(); + + if rendered.is_empty() { + "none".to_owned() + } else { + rendered.join(" ") + } + } +} + +impl From for sdk::types::OperationGroupPermissions { + fn from(groups: PermittedOperationGroups) -> Self { + let mut result = sdk::types::OperationGroupPermissions::new(); + if let Some(account) = groups.account { + result = result.with_account(account.into()); + } + if let Some(basin) = groups.basin { + result = result.with_basin(basin.into()); + } + if let Some(stream) = groups.stream { + result = result.with_stream(stream.into()); + } + result + } +} + +impl From for PermittedOperationGroups { + fn from(groups: sdk::types::OperationGroupPermissions) -> Self { + PermittedOperationGroups { + account: groups.account.map(Into::into), + basin: groups.basin.map(Into::into), + stream: groups.stream.map(Into::into), + } + } +} + +impl FromStr for PermittedOperationGroups { + type Err = OpGroupsParseError; + + fn from_str(s: &str) -> Result { + let mut account = None; + let mut basin = None; + let mut stream = None; + + if s.is_empty() { + return Ok(PermittedOperationGroups { + account, + basin, + stream, + }); + } + + for part in s.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let (key, value) = + part.split_once('=') + .ok_or_else(|| OpGroupsParseError::InvalidFormat { + value: part.to_owned(), + })?; + let perms = value.parse::()?; + match key { + "account" => account = Some(perms), + "basin" => basin = Some(perms), + "stream" => stream = Some(perms), + _ => { + return Err(OpGroupsParseError::InvalidKey { + key: key.to_owned(), + }); + } + } + } + + Ok(PermittedOperationGroups { + account, + basin, + stream, + }) + } +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ReadWritePermissions { + pub read: bool, + pub write: bool, +} + +impl FromStr for ReadWritePermissions { + type Err = OpGroupsParseError; + + fn from_str(s: &str) -> Result { + let mut read = false; + let mut write = false; + for c in s.chars() { + match c { + 'r' => read = true, + 'w' => write = true, + _ => return Err(OpGroupsParseError::InvalidPermissionChar(c)), + } + } + if !read && !write { + return Err(OpGroupsParseError::MissingPermission); + } + Ok(ReadWritePermissions { read, write }) + } +} + +impl From for sdk::types::ReadWritePermissions { + fn from(permissions: ReadWritePermissions) -> Self { + match (permissions.read, permissions.write) { + (true, true) => sdk::types::ReadWritePermissions::read_write(), + (true, false) => sdk::types::ReadWritePermissions::read_only(), + (false, true) => sdk::types::ReadWritePermissions::write_only(), + (false, false) => sdk::types::ReadWritePermissions::new(), + } + } +} + +impl From for ReadWritePermissions { + fn from(permissions: sdk::types::ReadWritePermissions) -> Self { + ReadWritePermissions { + read: permissions.read, + write: permissions.write, + } + } +} + +#[derive(Debug, Serialize)] +pub struct AccessTokenInfo { + pub id: String, + pub expires_at: Option, + pub auto_prefix_streams: bool, + pub scope: AccessTokenScope, +} + +impl From for AccessTokenInfo { + fn from(info: sdk::types::AccessTokenInfo) -> Self { + AccessTokenInfo { + id: info.id.to_string(), + expires_at: info.expires_at.map(|expires_at| expires_at.to_string()), + auto_prefix_streams: info.auto_prefix_streams, + scope: info.scope.into(), + } + } +} + +impl AccessTokenInfo { + /// Two-line compact summary for `s2 list-access-tokens`. + /// + /// - Header: the token id (padded to `id_width` for column alignment across the listing) + /// followed by its expiry, or `never` when the token does not expire. + /// - Detail: a single indented line summarizing the scope — the basin/stream/token matchers, + /// the operation-group permissions, and the count of explicitly granted operations. + pub fn summary_block(&self, id_width: usize) -> String { + let expires = match &self.expires_at { + Some(at) => at.green().to_string(), + None => "never".dimmed().to_string(), + }; + let padding = " ".repeat(id_width.saturating_sub(self.id.len())); + let header = format!("{}{padding} expires {expires}", self.id.bold()); + + let scope = &self.scope; + let detail = format!( + " basins={} streams={} tokens={} perms={} ops={}", + matcher_summary(scope.basins.as_ref().map(BasinMatcher::summary)), + matcher_summary(scope.streams.as_ref().map(StreamMatcher::summary)), + matcher_summary( + scope + .access_tokens + .as_ref() + .map(AccessTokenMatcher::summary) + ), + scope + .op_group_perms + .as_ref() + .map_or_else(|| "none".to_owned(), PermittedOperationGroups::summary), + scope.ops.len(), + ); + + format!("{header}\n{detail}") + } +} + +#[derive(Debug, Serialize)] +pub struct AccessTokenScope { + pub basins: Option, + pub streams: Option, + pub access_tokens: Option, + pub op_group_perms: Option, + pub ops: Vec, +} + +impl From for AccessTokenScope { + fn from(scope: sdk::types::AccessTokenScope) -> Self { + AccessTokenScope { + basins: scope.basins.and_then(BasinMatcher::from_sdk), + streams: scope.streams.and_then(StreamMatcher::from_sdk), + access_tokens: scope.access_tokens.and_then(AccessTokenMatcher::from_sdk), + op_group_perms: scope.op_group_perms.map(Into::into), + ops: scope.ops.into_iter().map(Operation::from).collect(), + } + } +} + +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, clap::ValueEnum, strum::Display, strum::EnumString, +)] +#[serde(rename_all = "snake_case")] +#[clap(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum Operation { + ListBasins, + CreateBasin, + DeleteBasin, + GetBasinConfig, + ReconfigureBasin, + ListAccessTokens, + IssueAccessToken, + RevokeAccessToken, + GetAccountMetrics, + GetBasinMetrics, + GetStreamMetrics, + ListStreams, + CreateStream, + DeleteStream, + GetStreamConfig, + ReconfigureStream, + CheckTail, + Trim, + Fence, + Append, + Read, + ListLocations, + GetDefaultLocation, + SetDefaultLocation, +} + +impl From for sdk::types::Operation { + fn from(op: Operation) -> Self { + match op { + Operation::ListBasins => sdk::types::Operation::ListBasins, + Operation::CreateBasin => sdk::types::Operation::CreateBasin, + Operation::DeleteBasin => sdk::types::Operation::DeleteBasin, + Operation::GetBasinConfig => sdk::types::Operation::GetBasinConfig, + Operation::ReconfigureBasin => sdk::types::Operation::ReconfigureBasin, + Operation::ListAccessTokens => sdk::types::Operation::ListAccessTokens, + Operation::IssueAccessToken => sdk::types::Operation::IssueAccessToken, + Operation::RevokeAccessToken => sdk::types::Operation::RevokeAccessToken, + Operation::GetAccountMetrics => sdk::types::Operation::GetAccountMetrics, + Operation::GetBasinMetrics => sdk::types::Operation::GetBasinMetrics, + Operation::GetStreamMetrics => sdk::types::Operation::GetStreamMetrics, + Operation::ListLocations => sdk::types::Operation::ListLocations, + Operation::GetDefaultLocation => sdk::types::Operation::GetDefaultLocation, + Operation::SetDefaultLocation => sdk::types::Operation::SetDefaultLocation, + Operation::ListStreams => sdk::types::Operation::ListStreams, + Operation::CreateStream => sdk::types::Operation::CreateStream, + Operation::DeleteStream => sdk::types::Operation::DeleteStream, + Operation::GetStreamConfig => sdk::types::Operation::GetStreamConfig, + Operation::ReconfigureStream => sdk::types::Operation::ReconfigureStream, + Operation::CheckTail => sdk::types::Operation::CheckTail, + Operation::Trim => sdk::types::Operation::Trim, + Operation::Fence => sdk::types::Operation::Fence, + Operation::Append => sdk::types::Operation::Append, + Operation::Read => sdk::types::Operation::Read, + } + } +} + +impl From for Operation { + fn from(op: sdk::types::Operation) -> Self { + match op { + sdk::types::Operation::ListBasins => Operation::ListBasins, + sdk::types::Operation::CreateBasin => Operation::CreateBasin, + sdk::types::Operation::DeleteBasin => Operation::DeleteBasin, + sdk::types::Operation::GetBasinConfig => Operation::GetBasinConfig, + sdk::types::Operation::ReconfigureBasin => Operation::ReconfigureBasin, + sdk::types::Operation::ListAccessTokens => Operation::ListAccessTokens, + sdk::types::Operation::IssueAccessToken => Operation::IssueAccessToken, + sdk::types::Operation::RevokeAccessToken => Operation::RevokeAccessToken, + sdk::types::Operation::GetAccountMetrics => Operation::GetAccountMetrics, + sdk::types::Operation::GetBasinMetrics => Operation::GetBasinMetrics, + sdk::types::Operation::GetStreamMetrics => Operation::GetStreamMetrics, + sdk::types::Operation::ListLocations => Operation::ListLocations, + sdk::types::Operation::GetDefaultLocation => Operation::GetDefaultLocation, + sdk::types::Operation::SetDefaultLocation => Operation::SetDefaultLocation, + sdk::types::Operation::ListStreams => Operation::ListStreams, + sdk::types::Operation::CreateStream => Operation::CreateStream, + sdk::types::Operation::DeleteStream => Operation::DeleteStream, + sdk::types::Operation::GetStreamConfig => Operation::GetStreamConfig, + sdk::types::Operation::ReconfigureStream => Operation::ReconfigureStream, + sdk::types::Operation::CheckTail => Operation::CheckTail, + sdk::types::Operation::Trim => Operation::Trim, + sdk::types::Operation::Fence => Operation::Fence, + sdk::types::Operation::Append => Operation::Append, + sdk::types::Operation::Read => Operation::Read, + } + } +} + +#[derive(ValueEnum, Debug, Clone, Copy)] +pub enum Interval { + /// Per-minute intervals. + Minute, + /// Per-hour intervals. + Hour, + /// Per-day intervals. + Day, +} + +impl From for TimeseriesInterval { + fn from(value: Interval) -> Self { + match value { + Interval::Minute => TimeseriesInterval::Minute, + Interval::Hour => TimeseriesInterval::Hour, + Interval::Day => TimeseriesInterval::Day, + } + } +} + +#[derive(Debug, Clone)] +pub struct LatencyStats { + pub min: std::time::Duration, + pub p50: std::time::Duration, + pub p90: std::time::Duration, + pub p99: std::time::Duration, + pub max: std::time::Duration, +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::{ + AccessTokenInfo, AccessTokenMatcher, AccessTokenScope, BasinMatcher, OpGroupsParseError, + PermittedOperationGroups, ReadWritePermissions, S2BasinAndMaybeStreamUri, + S2BasinAndStreamUri, S2BasinUri, S2Uri, StreamMatcher, matcher_summary, + }; + use crate::error::S2UriParseError; + + fn rw(read: bool, write: bool) -> ReadWritePermissions { + ReadWritePermissions { read, write } + } + + #[test] + fn matcher_summaries_render_any_prefix_and_exact() { + assert_eq!( + BasinMatcher::Prefix("".parse().unwrap()).summary(), + "*", + "an empty prefix matches any resource" + ); + assert_eq!( + BasinMatcher::Prefix("ls-".parse().unwrap()).summary(), + "ls-*" + ); + assert_eq!( + BasinMatcher::Exact("exact-basin".parse().unwrap()).summary(), + "exact-basin" + ); + assert_eq!( + matcher_summary(None), + "∅", + "an unset matcher grants nothing" + ); + } + + #[test] + fn match_none_scope_matchers_render_as_no_access() { + colored::control::set_override(false); + + // On the wire, an empty exact name means "match no resources". + let scope: s2_api::v1::access::AccessTokenScope = + serde_json::from_value(serde_json::json!({ + "basins": { "exact": "" }, + "streams": { "exact": "" }, + "access_tokens": { "exact": "" }, + })) + .unwrap(); + let scope = s2_sdk::types::AccessTokenScope::from(scope); + let info = AccessTokenInfo { + id: "tok".to_owned(), + expires_at: None, + auto_prefix_streams: false, + scope: scope.into(), + }; + + assert_eq!( + info.summary_block(3), + "tok expires never\n\ + \x20 basins=∅ streams=∅ tokens=∅ perms=none ops=0" + ); + let json = serde_json::to_value(&info.scope).unwrap(); + assert!(json["basins"].is_null(), "{json}"); + assert!(json["streams"].is_null(), "{json}"); + assert!(json["access_tokens"].is_null(), "{json}"); + } + + #[test] + fn op_group_perms_summary_omits_empty_groups() { + assert_eq!( + PermittedOperationGroups { + account: Some(rw(true, true)), + basin: Some(rw(true, true)), + stream: Some(rw(true, true)), + } + .summary(), + "a:rw b:rw s:rw" + ); + assert_eq!( + PermittedOperationGroups { + account: None, + basin: Some(rw(true, false)), + stream: Some(rw(false, true)), + } + .summary(), + "b:r s:w" + ); + assert_eq!( + PermittedOperationGroups { + account: None, + basin: None, + stream: None, + } + .summary(), + "none" + ); + } + + #[test] + fn summary_block_renders_header_and_scope() { + colored::control::set_override(false); + + let info = AccessTokenInfo { + id: "tok".to_owned(), + expires_at: None, + auto_prefix_streams: false, + scope: AccessTokenScope { + basins: Some(BasinMatcher::Prefix("".parse().unwrap())), + streams: Some(StreamMatcher::Prefix("tenant/".parse().unwrap())), + access_tokens: None, + op_group_perms: Some(PermittedOperationGroups { + account: Some(rw(true, false)), + basin: None, + stream: None, + }), + ops: vec![], + }, + }; + + // id padded to width 8, unset expiry renders `never`, unset matcher renders `∅`. + assert_eq!( + info.summary_block(8), + "tok expires never\n\ + \x20 basins=* streams=tenant/* tokens=∅ perms=a:r ops=0" + ); + } + + #[test] + fn summary_block_renders_expiry_and_exact_token_matcher() { + colored::control::set_override(false); + + let info = AccessTokenInfo { + id: "prod".to_owned(), + expires_at: Some("2030-01-01T00:00:00Z".to_owned()), + auto_prefix_streams: false, + scope: AccessTokenScope { + basins: None, + streams: None, + access_tokens: Some(AccessTokenMatcher::Exact("root".parse().unwrap())), + op_group_perms: None, + ops: vec![super::Operation::Read, super::Operation::Append], + }, + }; + + assert_eq!( + info.summary_block(4), + "prod expires 2030-01-01T00:00:00Z\n\ + \x20 basins=∅ streams=∅ tokens=root perms=none ops=2" + ); + } + + #[rstest] + #[case("", Ok(PermittedOperationGroups { + account: None, + basin: None, + stream: None, + }))] + #[case("account=r", Ok(PermittedOperationGroups { + account: Some(ReadWritePermissions { + read: true, + write: false, + }), + basin: None, + stream: None, + }))] + #[case("account=w", Ok(PermittedOperationGroups { + account: Some(ReadWritePermissions { + read: false, + write: true, + }), + basin: None, + stream: None, + }))] + #[case("account=rw", Ok(PermittedOperationGroups { + account: Some(ReadWritePermissions { + read: true, + write: true, + }), + basin: None, + stream: None, + }))] + #[case("basin=r,stream=w", Ok(PermittedOperationGroups { + account: None, + basin: Some(ReadWritePermissions { + read: true, + write: false, + }), + stream: Some(ReadWritePermissions { + read: false, + write: true, + }), + }))] + #[case("account=rw,basin=rw,stream=rw", Ok(PermittedOperationGroups { + account: Some(ReadWritePermissions { + read: true, + write: true, + }), + basin: Some(ReadWritePermissions { + read: true, + write: true, + }), + stream: Some(ReadWritePermissions { + read: true, + write: true, + }), + }))] + #[case("invalid", Err(OpGroupsParseError::InvalidFormat { value: "invalid".to_owned() }))] + #[case("unknown=rw", Err(OpGroupsParseError::InvalidKey { key: "unknown".to_owned() }))] + #[case("account=", Err(OpGroupsParseError::MissingPermission))] + #[case("account=x", Err(OpGroupsParseError::InvalidPermissionChar('x')))] + fn test_parse_op_groups( + #[case] input: &str, + #[case] expected: Result, + ) { + assert_eq!( + input.parse::(), + expected, + "Testing input: {input}" + ); + } + + #[test] + fn test_s2_uri_parse() { + let test_cases = vec![ + ( + "valid-basin", + Err(S2UriParseError::MissingUriScheme), + Ok(S2BasinUri("valid-basin".parse().unwrap())), + Err(S2UriParseError::MissingUriScheme), + Ok(S2BasinAndMaybeStreamUri { + basin: "valid-basin".parse().unwrap(), + stream: None, + }), + ), + ( + "s2://valid-basin", + Ok(S2Uri { + basin: "valid-basin".parse().unwrap(), + stream: None, + }), + Ok(S2BasinUri("valid-basin".parse().unwrap())), + Err(S2UriParseError::MissingStreamName), + Ok(S2BasinAndMaybeStreamUri { + basin: "valid-basin".parse().unwrap(), + stream: None, + }), + ), + ( + "s2://valid-basin/", + Ok(S2Uri { + basin: "valid-basin".parse().unwrap(), + stream: None, + }), + Ok(S2BasinUri("valid-basin".parse().unwrap())), + Err(S2UriParseError::MissingStreamName), + Ok(S2BasinAndMaybeStreamUri { + basin: "valid-basin".parse().unwrap(), + stream: None, + }), + ), + ( + "s2://valid-basin/stream/name", + Ok(S2Uri { + basin: "valid-basin".parse().unwrap(), + stream: Some("stream/name".to_owned()), + }), + Err(S2UriParseError::UnexpectedStreamName), + Ok(S2BasinAndStreamUri { + basin: "valid-basin".parse().unwrap(), + stream: "stream/name".parse().unwrap(), + }), + Ok(S2BasinAndMaybeStreamUri { + basin: "valid-basin".parse().unwrap(), + stream: Some("stream/name".parse().unwrap()), + }), + ), + ( + "-invalid-basin", + Err(S2UriParseError::MissingUriScheme), + Err(S2UriParseError::InvalidBasinName("".to_owned())), + Err(S2UriParseError::MissingUriScheme), + Err(S2UriParseError::InvalidBasinName("".to_owned())), + ), + ( + "http://valid-basin", + Err(S2UriParseError::InvalidUriScheme("http".to_owned())), + Err(S2UriParseError::InvalidUriScheme("http".to_owned())), + Err(S2UriParseError::InvalidUriScheme("http".to_owned())), + Err(S2UriParseError::InvalidUriScheme("http".to_owned())), + ), + ( + "s2://-invalid-basin", + Err(S2UriParseError::InvalidBasinName("".to_owned())), + Err(S2UriParseError::InvalidBasinName("".to_owned())), + Err(S2UriParseError::InvalidBasinName("".to_owned())), + Err(S2UriParseError::InvalidBasinName("".to_owned())), + ), + ( + "s2:///stream/name", + Err(S2UriParseError::InvalidBasinName("".to_owned())), + Err(S2UriParseError::InvalidBasinName("".to_owned())), + Err(S2UriParseError::InvalidBasinName("".to_owned())), + Err(S2UriParseError::InvalidBasinName("".to_owned())), + ), + ( + "random:::string", + Err(S2UriParseError::MissingUriScheme), + Err(S2UriParseError::InvalidBasinName("".to_owned())), + Err(S2UriParseError::MissingUriScheme), + Err(S2UriParseError::InvalidBasinName("".to_owned())), + ), + ]; + + for ( + s, + expected_uri, + expected_basin_uri, + expected_basin_and_stream_uri, + expected_basin_and_maybe_stream_uri, + ) in test_cases + { + assert_eq!(s.parse(), expected_uri, "S2Uri: {s}"); + assert_eq!(s.parse(), expected_basin_uri, "S2BasinUri: {s}"); + assert_eq!( + s.parse(), + expected_basin_and_stream_uri, + "S2BasinAndStreamUri: {s}" + ); + assert_eq!( + s.parse(), + expected_basin_and_maybe_stream_uri, + "S2BasinAndMaybeStreamUri: {s}" + ); + } + } +} diff --git a/cli/src/update/apply.rs b/cli/src/update/apply.rs new file mode 100644 index 00000000..26cda204 --- /dev/null +++ b/cli/src/update/apply.rs @@ -0,0 +1,760 @@ +//! `s2 update`: bring the CLI up to date using the strategy that matches how +//! it was installed (see [`super::channel`]). +//! +//! - Install script or manual GitHub download (`InstallScript`, `GithubRelease`): on Unix, download +//! the release artifact for this exact target, verify its SHA-256 against the release's +//! `SHA256SUMS`, and atomically replace the running binary in place; on Windows, print exact +//! manual replacement instructions because a running executable cannot be replaced safely. +//! - Homebrew or Cargo: the package manager owns the binary, so print its upgrade command (or run +//! it with `--yes` where the running executable can be replaced). +//! - Docker or source build: nothing to replace; explain how to update. + +use std::{ + fmt, + io::{Cursor, IsTerminal, Read, Write}, + path::Path, + time::Duration, +}; + +use colored::Colorize; +use semver::Version; +use sha2::{Digest, Sha256}; + +use super::channel::{self, InstallChannel}; +use crate::cli::UpdateArgs; + +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Target triple this binary was built for, stamped by `build.rs`. +const TARGET: &str = env!("S2_TARGET"); +const CLI_TAG_PREFIX: &str = "s2-cli-v"; +const CHECKSUMS_ASSET: &str = "SHA256SUMS"; +const DOCS_URL: &str = "https://s2.dev/docs/quickstart#get-started-with-the-cli"; +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120); + +#[derive(Debug, thiserror::Error)] +pub enum UpdateError { + #[error("could not locate the running executable: {0}")] + CurrentExe(#[source] std::io::Error), + #[error("could not determine the latest release; is GitHub reachable?")] + LatestUnavailable, + #[error("failed to build HTTP client: {0}")] + Http(#[source] reqwest::Error), + #[error("release {0} does not publish {CHECKSUMS_ASSET}; install manually from {DOCS_URL}")] + ChecksumsUnavailable(Version), + #[error("{CHECKSUMS_ASSET} has no entry for {0}")] + ChecksumMissing(String), + #[error("failed to download {0}: {1}")] + Download(String, #[source] reqwest::Error), + #[error("checksum mismatch for {asset}: expected {expected}, computed {actual}")] + ChecksumMismatch { + asset: String, + expected: String, + actual: String, + }, + #[error("could not read the release archive: {0}")] + Archive(String), + #[error("release archive did not contain {0}")] + BinaryNotInArchive(String), + #[cfg(not(windows))] + #[error( + "could not replace {path}: {source}\n\ + (need write access to its directory; re-run with sufficient permissions)" + )] + Install { + path: String, + #[source] + source: std::io::Error, + }, + #[cfg(windows)] + #[error( + "automatic in-place replacement is disabled on Windows; \ + install the release manually after s2 exits" + )] + WindowsInPlaceUnsupported, + #[error("failed to run `{0}`: {1}")] + Spawn(String, #[source] std::io::Error), + #[error("`{0}` exited with a non-zero status")] + CommandFailed(String), +} + +/// Entry point for the `update` subcommand. +pub async fn run(args: &UpdateArgs) -> Result<(), UpdateError> { + let channel = channel::detect(); + let current = Version::parse(CURRENT_VERSION).expect("crate version is valid semver"); + + if args.check { + return report_status(channel, ¤t).await; + } + + let target = super::fetch_latest() + .await + .ok_or(UpdateError::LatestUnavailable)?; + + // Explicit --skip just records the marker and does nothing else. + if args.skip { + return do_skip(&target); + } + + if target <= current { + println!("s2-cli {current} is already up to date."); + return Ok(()); + } + + let action = match plan_update(channel, &target, cfg!(not(windows))) { + Plan::Mutate(action) => action, + Plan::Advise(advice) => { + print!("{}", advice.render(&target, &repo())); + return Ok(()); + } + }; + + // Describe what upgrading entails before asking. + println!("s2-cli {current} → {target} (installed via {channel})"); + if let Mutation::Run(command) = &action { + println!("This will run: {}", command.to_string().cyan()); + } + + // Confirm interactively, unless --yes was given. Never block on a prompt + // when there is no terminal to answer it (e.g. CI, piped input). + let choice = if args.yes { + Choice::Yes + } else if std::io::stdin().is_terminal() { + prompt_choice("Update now?") + } else { + match &action { + Mutation::InPlace => println!("Re-run with {} to update in place.", "--yes".cyan()), + Mutation::Run(command) => println!( + "Run {} yourself, or re-run with {}.", + command.to_string().cyan(), + "--yes".cyan() + ), + } + return Ok(()); + }; + + match choice { + Choice::Yes => match action { + Mutation::InPlace => in_place_update(channel, &target).await, + Mutation::Run(command) => run_command(&command), + }, + Choice::Skip => do_skip(&target), + Choice::No => { + println!("Cancelled; no changes made."); + Ok(()) + } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum Plan { + Mutate(Mutation), + Advise(Advice), +} + +/// A change this process can safely make. +#[derive(Debug, PartialEq, Eq)] +enum Mutation { + /// Download the release artifact and replace the binary in place. + InPlace, + /// Delegate to a package manager's upgrade command. + Run(CommandSpec), +} + +/// Guidance for channels this process must not mutate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Advice { + Docker, + SourceBuild, + WindowsCargo, + WindowsRelease, +} + +impl Advice { + fn render(self, target: &Version, repo: &str) -> String { + let tag = format!("{CLI_TAG_PREFIX}{target}"); + match self { + Self::Docker => format!( + "This is the Docker build of s2-cli. Pull the exact image on the host:\n\ + \x20 docker pull ghcr.io/s2-streamstore/s2:{target}\n\ + The running container was not changed.\n" + ), + Self::SourceBuild => format!( + "This s2-cli was built from source. Check out {tag} and rebuild,\n\ + or install the exact release from:\n\ + \x20 https://github.com/{repo}/releases/tag/{tag}\n\ + The running binary was not changed.\n" + ), + Self::WindowsCargo => format!( + "Cargo cannot replace the running s2.exe on Windows.\n\ + After s2 exits, run:\n\ + \x20 {}\n\ + The running binary was not changed.\n", + cargo_install_command(target), + ), + Self::WindowsRelease => format!( + "Automatic in-place replacement is disabled on Windows.\n\ + After s2 exits, download {asset} and {CHECKSUMS_ASSET} from:\n\ + \x20 https://github.com/{repo}/releases/tag/{tag}\n\ + The running binary was not changed.\n", + asset = asset_name(), + ), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +struct CommandSpec { + program: &'static str, + args: Vec, +} + +impl CommandSpec { + fn new(program: &'static str, args: impl IntoIterator>) -> Self { + Self { + program, + args: args.into_iter().map(Into::into).collect(), + } + } +} + +impl fmt::Display for CommandSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.program)?; + for arg in &self.args { + write!(f, " {arg}")?; + } + Ok(()) + } +} + +fn cargo_install_command(target: &Version) -> CommandSpec { + CommandSpec::new( + "cargo", + [ + "install".to_string(), + "--locked".to_string(), + "--force".to_string(), + "--version".to_string(), + target.to_string(), + "s2-cli".to_string(), + ], + ) +} + +fn plan_update( + channel: InstallChannel, + target: &Version, + running_executable_replaceable: bool, +) -> Plan { + match channel { + InstallChannel::InstallScript | InstallChannel::GithubRelease => { + if running_executable_replaceable { + Plan::Mutate(Mutation::InPlace) + } else { + Plan::Advise(Advice::WindowsRelease) + } + } + InstallChannel::Cargo if !running_executable_replaceable => { + Plan::Advise(Advice::WindowsCargo) + } + InstallChannel::Cargo => Plan::Mutate(Mutation::Run(cargo_install_command(target))), + InstallChannel::Homebrew => Plan::Mutate(Mutation::Run(CommandSpec::new( + "brew", + ["upgrade", "s2-streamstore/s2/s2"], + ))), + InstallChannel::Docker => Plan::Advise(Advice::Docker), + InstallChannel::SourceBuild => Plan::Advise(Advice::SourceBuild), + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Choice { + Yes, + No, + Skip, +} + +/// Map a raw answer to a choice; `None` means "unrecognized, ask again". +/// An empty line (bare Enter) takes the default, yes. +fn parse_choice(input: &str) -> Option { + match input.trim().to_ascii_lowercase().as_str() { + "" | "y" | "yes" => Some(Choice::Yes), + "n" | "no" => Some(Choice::No), + "s" | "skip" => Some(Choice::Skip), + _ => None, + } +} + +/// Ask the user to confirm: yes (default), no, or skip. Enter accepts the +/// default; EOF (Ctrl-D) cancels rather than accepting. +fn prompt_choice(question: &str) -> Choice { + loop { + print!("{question} [{}/n/s] ", "Y".bold()); + let _ = std::io::stdout().flush(); + let mut line = String::new(); + match std::io::stdin().read_line(&mut line) { + Ok(0) | Err(_) => return Choice::No, + Ok(_) => {} + } + match parse_choice(&line) { + Some(choice) => return choice, + None => println!( + "Please answer {} (yes), {} (no), or {} (skip).", + "y".cyan(), + "n".cyan(), + "s".cyan() + ), + } + } +} + +fn do_skip(target: &Version) -> Result<(), UpdateError> { + if super::skip_version(target) { + println!("Won't remind you about s2-cli {target} again."); + } + Ok(()) +} + +/// Run a package manager's upgrade command (Homebrew, Cargo). +fn run_command(command: &CommandSpec) -> Result<(), UpdateError> { + println!("Running: {}", command.to_string().cyan()); + let status = std::process::Command::new(command.program) + .args(&command.args) + .status() + .map_err(|e| UpdateError::Spawn(command.program.to_string(), e))?; + if !status.success() { + return Err(UpdateError::CommandFailed(command.to_string())); + } + Ok(()) +} + +async fn report_status(channel: InstallChannel, current: &Version) -> Result<(), UpdateError> { + let latest = super::fetch_latest() + .await + .ok_or(UpdateError::LatestUnavailable)?; + println!("Installed: {current} (via {channel})"); + println!("Latest: {latest}"); + if &latest > current { + println!("Run {} to upgrade.", "s2 update".cyan()); + } else { + println!("You are on the latest release."); + } + Ok(()) +} + +/// Download, verify, and swap the binary in place. +async fn in_place_update(channel: InstallChannel, target: &Version) -> Result<(), UpdateError> { + let exe = std::env::current_exe().map_err(UpdateError::CurrentExe)?; + let asset = asset_name(); + let base = format!( + "https://github.com/{repo}/releases/download/{CLI_TAG_PREFIX}{target}", + repo = repo(), + ); + let client = reqwest::Client::builder() + .user_agent(channel::user_agent()) + .timeout(DOWNLOAD_TIMEOUT) + .build() + .map_err(UpdateError::Http)?; + + println!("Downloading s2-cli {target} for {TARGET}..."); + + // Only a definitive 404 means the release lacks checksums; anything else + // (DNS failure, timeout, 5xx) is a download problem and must not steer the + // user toward a manual install. + let sums = get_text(&client, &format!("{base}/{CHECKSUMS_ASSET}")) + .await + .map_err(|e| { + if e.status() == Some(reqwest::StatusCode::NOT_FOUND) { + UpdateError::ChecksumsUnavailable(target.clone()) + } else { + UpdateError::Download(CHECKSUMS_ASSET.to_string(), e) + } + })?; + let expected = + checksum_for(&sums, &asset).ok_or_else(|| UpdateError::ChecksumMissing(asset.clone()))?; + + let archive = get_bytes(&client, &format!("{base}/{asset}")) + .await + .map_err(|e| UpdateError::Download(asset.clone(), e))?; + + let actual = sha256_hex(&archive); + if !actual.eq_ignore_ascii_case(expected) { + return Err(UpdateError::ChecksumMismatch { + asset, + expected: expected.to_string(), + actual, + }); + } + + let binary = extract_named(&archive, binary_name())?; + install_binary(&exe, &binary)?; + + // Keep the receipt's recorded version current so detection stays correct. + if channel == InstallChannel::InstallScript { + write_receipt(&exe, target); + } + + println!("{} s2-cli is now {target}.", "Updated:".green().bold()); + Ok(()) +} + +/// Write `binary` next to the running executable and atomically swap it into +/// place. Writing into the target directory first surfaces a permission error +/// cleanly and keeps the swap on one filesystem. +#[cfg(not(windows))] +fn install_binary(exe: &Path, binary: &[u8]) -> Result<(), UpdateError> { + let map_err = |source| UpdateError::Install { + path: exe.display().to_string(), + source, + }; + let dir = exe.parent().unwrap_or_else(|| Path::new(".")); + let staged = dir.join(format!(".s2-update-{}.tmp", uuid::Uuid::new_v4())); + + // A random name plus create_new prevents a pre-existing file or symlink + // from being followed and truncated with the updater's privileges. + let mut staged_file = open_new_staged_file(&staged).map_err(map_err)?; + if let Err(e) = staged_file.write_all(binary) { + drop(staged_file); + let _ = std::fs::remove_file(&staged); + return Err(map_err(e)); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Preserve the mode of the binary being replaced (a deliberate 0700 + // install stays 0700); fall back to 0755 if it cannot be read. + let mode = std::fs::metadata(exe) + .map(|m| m.permissions().mode() & 0o7777) + .unwrap_or(0o755); + if let Err(e) = staged_file.set_permissions(std::fs::Permissions::from_mode(mode)) { + drop(staged_file); + let _ = std::fs::remove_file(&staged); + return Err(map_err(e)); + } + } + drop(staged_file); + + let result = self_replace::self_replace(&staged).map_err(map_err); + let _ = std::fs::remove_file(&staged); + result +} + +#[cfg(not(windows))] +fn open_new_staged_file(path: &Path) -> std::io::Result { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) +} + +// TODO: the `self-replace` crate does support replacing the running executable +// on Windows (rename-aside + delete-pending); enable in-place updates there +// once that path has been validated on a Windows machine. +#[cfg(windows)] +fn install_binary(_exe: &Path, _binary: &[u8]) -> Result<(), UpdateError> { + Err(UpdateError::WindowsInPlaceUnsupported) +} + +fn write_receipt(exe: &Path, version: &Version) { + let Some(dir) = exe.parent() else { + return; + }; + // Serialize with serde_json rather than by hand: this file is the source + // of truth for channel detection, and a malformed write would permanently + // downgrade the detected channel. + let receipt = serde_json::json!({ + "channel": channel::INSTALL_SCRIPT_CHANNEL, + "version": version.to_string(), + "binary_path": exe.display().to_string(), + "installed_at": humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string(), + }); + let Ok(contents) = serde_json::to_string_pretty(&receipt) else { + return; + }; + let _ = std::fs::write(dir.join(channel::RECEIPT_FILE), contents + "\n"); +} + +async fn get_text(client: &reqwest::Client, url: &str) -> Result { + client + .get(url) + .send() + .await? + .error_for_status()? + .text() + .await +} + +async fn get_bytes(client: &reqwest::Client, url: &str) -> Result, reqwest::Error> { + Ok(client + .get(url) + .send() + .await? + .error_for_status()? + .bytes() + .await? + .to_vec()) +} + +/// Repository to pull releases from; overridable via `S2_REPO` to match +/// `install.sh` (useful for forks and testing). +fn repo() -> String { + std::env::var("S2_REPO").unwrap_or_else(|_| "s2-streamstore/s2".to_string()) +} + +fn asset_name() -> String { + format!("s2-{TARGET}.zip") +} + +fn binary_name() -> &'static str { + if TARGET.contains("windows") { + "s2.exe" + } else { + "s2" + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +/// Find the hash for `asset` in a `shasum`-style ` ` listing. +/// Tolerates the leading `*` that binary-mode checksums prepend to names. +fn checksum_for<'a>(sums: &'a str, asset: &str) -> Option<&'a str> { + sums.lines().find_map(|line| { + let mut fields = line.split_whitespace(); + let hash = fields.next()?; + let name = fields.next()?; + (name.trim_start_matches('*') == asset).then_some(hash) + }) +} + +/// Extract the entry whose file name is `want` from a zip archive in memory. +fn extract_named(archive_bytes: &[u8], want: &str) -> Result, UpdateError> { + let mut archive = zip::ZipArchive::new(Cursor::new(archive_bytes)) + .map_err(|e| UpdateError::Archive(e.to_string()))?; + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| UpdateError::Archive(e.to_string()))?; + let is_file = entry.is_file(); + let base = entry + .name() + .rsplit(['/', '\\']) + .next() + .unwrap_or("") + .to_string(); + if is_file && base == want { + // Cap the pre-allocation so a forged size field in the zip header + // cannot force a huge allocation; read_to_end grows as needed. + let mut buf = Vec::with_capacity(entry.size().min(64 << 20) as usize); + entry + .read_to_end(&mut buf) + .map_err(|e| UpdateError::Archive(e.to_string()))?; + return Ok(buf); + } + } + Err(UpdateError::BinaryNotInArchive(want.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_prompt_answers() { + assert_eq!(parse_choice(""), Some(Choice::Yes)); // bare Enter -> default + assert_eq!(parse_choice("y"), Some(Choice::Yes)); + assert_eq!(parse_choice(" YES\n"), Some(Choice::Yes)); + assert_eq!(parse_choice("n"), Some(Choice::No)); + assert_eq!(parse_choice("No"), Some(Choice::No)); + assert_eq!(parse_choice("s"), Some(Choice::Skip)); + assert_eq!(parse_choice("skip"), Some(Choice::Skip)); + assert_eq!(parse_choice("maybe"), None); + } + + #[test] + fn cargo_plan_pins_the_latest_target_as_one_argument() { + let target = Version::parse("1.2.3-rc.1").unwrap(); + let expected = Plan::Mutate(Mutation::Run(CommandSpec::new( + "cargo", + [ + "install", + "--locked", + "--force", + "--version", + "1.2.3-rc.1", + "s2-cli", + ], + ))); + + assert_eq!(plan_update(InstallChannel::Cargo, &target, true), expected); + assert_eq!( + plan_update(InstallChannel::Cargo, &target, false), + Plan::Advise(Advice::WindowsCargo) + ); + assert!( + Advice::WindowsCargo + .render(&target, "example/fork") + .contains("cargo install --locked --force --version 1.2.3-rc.1 s2-cli") + ); + } + + #[test] + fn homebrew_uses_its_upgrade_command() { + let latest = Version::new(0, 42, 0); + assert_eq!( + plan_update(InstallChannel::Homebrew, &latest, true), + Plan::Mutate(Mutation::Run(CommandSpec::new( + "brew", + ["upgrade", "s2-streamstore/s2/s2"], + ))) + ); + } + + #[test] + fn advisory_plans_name_the_exact_target_without_mutating() { + let target = Version::new(0, 40, 0); + let docker = plan_update(InstallChannel::Docker, &target, true); + let source = plan_update(InstallChannel::SourceBuild, &target, true); + let windows = plan_update(InstallChannel::GithubRelease, &target, false); + + assert_eq!(docker, Plan::Advise(Advice::Docker)); + assert!( + Advice::Docker + .render(&target, "example/fork") + .contains("s2:0.40.0") + ); + assert_eq!(source, Plan::Advise(Advice::SourceBuild)); + assert!( + Advice::SourceBuild + .render(&target, "example/fork") + .contains("example/fork/releases/tag/s2-cli-v0.40.0") + ); + assert_eq!(windows, Plan::Advise(Advice::WindowsRelease)); + assert!( + Advice::WindowsRelease + .render(&target, "example/fork") + .contains("example/fork/releases/tag/s2-cli-v0.40.0") + ); + } + + #[test] + fn release_channels_only_replace_in_place_where_supported() { + let target = Version::new(0, 42, 0); + for channel in [InstallChannel::InstallScript, InstallChannel::GithubRelease] { + assert_eq!( + plan_update(channel, &target, true), + Plan::Mutate(Mutation::InPlace) + ); + assert_eq!( + plan_update(channel, &target, false), + Plan::Advise(Advice::WindowsRelease) + ); + } + } + + #[cfg(unix)] + #[test] + fn staging_does_not_follow_an_existing_symlink() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let protected = dir.path().join("protected"); + let staged = dir.path().join(".s2-update-candidate.tmp"); + std::fs::write(&protected, b"do not overwrite").unwrap(); + symlink(&protected, &staged).unwrap(); + + let error = open_new_staged_file(&staged).unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(&protected).unwrap(), b"do not overwrite"); + } + + #[cfg(windows)] + #[test] + fn windows_install_refuses_without_touching_the_executable() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("s2.exe"); + std::fs::write(&exe, b"old binary").unwrap(); + + assert!(matches!( + install_binary(&exe, b"new binary"), + Err(UpdateError::WindowsInPlaceUnsupported) + )); + assert_eq!(std::fs::read(&exe).unwrap(), b"old binary"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } + + #[test] + fn receipt_written_after_update_is_valid_json() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("s2"); + write_receipt(&exe, &Version::new(0, 42, 0)); + + let contents = std::fs::read_to_string(dir.path().join(channel::RECEIPT_FILE)).unwrap(); + let receipt: serde_json::Value = serde_json::from_str(&contents).unwrap(); + assert_eq!(receipt["channel"], "install-script"); + assert_eq!(receipt["version"], "0.42.0"); + assert_eq!(receipt["binary_path"], exe.display().to_string()); + assert!( + receipt["installed_at"] + .as_str() + .is_some_and(|t| t.ends_with('Z')) + ); + } + + #[test] + fn checksum_lookup_by_asset_name() { + let sums = "\ +aaa11111111111111111111111111111111111111111111111111111111111aa s2-x86_64-apple-darwin.zip +bbb22222222222222222222222222222222222222222222222222222222222bb s2-aarch64-apple-darwin.zip +ccc33333333333333333333333333333333333333333333333333333333333cc *s2-x86_64-unknown-linux-gnu.zip +"; + assert_eq!( + checksum_for(sums, "s2-aarch64-apple-darwin.zip"), + Some("bbb22222222222222222222222222222222222222222222222222222222222bb") + ); + // Binary-mode `*` prefix on the filename is tolerated. + assert_eq!( + checksum_for(sums, "s2-x86_64-unknown-linux-gnu.zip"), + Some("ccc33333333333333333333333333333333333333333333333333333333333cc") + ); + assert_eq!(checksum_for(sums, "s2-nonexistent.zip"), None); + } + + fn zip_with(entry: &str, data: &[u8]) -> Vec { + let mut buf = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut buf); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + zip::ZipWriter::start_file(&mut writer, entry, opts).unwrap(); + std::io::Write::write_all(&mut writer, data).unwrap(); + writer.finish().unwrap(); + } + buf.into_inner() + } + + #[test] + fn extracts_named_entry_from_zip() { + let payload = b"\x7fELF fake binary contents"; + let archive = zip_with("s2", payload); + assert_eq!(extract_named(&archive, "s2").unwrap(), payload); + + // Entry nested under a directory is matched by base name. + let nested = zip_with("release/s2", payload); + assert_eq!(extract_named(&nested, "s2").unwrap(), payload); + + // Missing entry is a clean error, not a panic. + assert!(matches!( + extract_named(&archive, "s2.exe"), + Err(UpdateError::BinaryNotInArchive(_)) + )); + } +} diff --git a/cli/src/update/channel.rs b/cli/src/update/channel.rs new file mode 100644 index 00000000..d176e25e --- /dev/null +++ b/cli/src/update/channel.rs @@ -0,0 +1,358 @@ +//! Detection of the channel through which the running binary was installed. +//! +//! Sources of truth, in strict order of precedence (each layer only narrows, +//! never overrides an earlier one): +//! +//! 1. Install receipt: an `s2-receipt.json` written by `install.sh` next to the binary. Only +//! trusted when the binary path recorded in the receipt resolves to the running executable, so a +//! binary copied elsewhere does not inherit the receipt. +//! 2. Docker build stamp: `S2_BUILD_CHANNEL=docker`, baked in by the Dockerfile because a container +//! cannot be identified from the executable path. +//! 3. Environment facts (definitional, not heuristic): +//! - a resolved executable under a Homebrew cellar was installed by brew; +//! - one under `$CARGO_INSTALL_ROOT/bin`, `$CARGO_HOME/bin`, or `~/.cargo/bin` was installed by +//! `cargo install`. +//! 4. Generic `release` stamp from release CI: an official artifact whose installation method is +//! otherwise unknown, i.e. a manual download from GitHub releases. Weakest stamp on purpose: +//! today the brew tap repackages these same artifacts, so a Cellar path must win over it. +//! 5. Nothing matched: locally built from source. + +use std::{ + fmt, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +use serde::Deserialize; + +/// Build provenance baked into Docker and generic release binaries. +/// +/// Homebrew is intentionally inferred from the resolved executable path instead. +const BUILD_CHANNEL: Option<&str> = option_env!("S2_BUILD_CHANNEL"); + +/// Exact source commit stamped into the binary by `build.rs`. +const GIT_COMMIT: &str = env!("S2_GIT_COMMIT"); + +/// Name of the receipt file `install.sh` (and `s2 update` after an in-place +/// upgrade) writes next to the binary. +pub(super) const RECEIPT_FILE: &str = "s2-receipt.json"; + +/// Receipt `channel` value written by `install.sh`. +pub(super) const INSTALL_SCRIPT_CHANNEL: &str = "install-script"; + +/// How the running binary was installed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallChannel { + /// Installed by `install.sh` (a matching receipt is present). + InstallScript, + /// Installed via the Homebrew tap. + Homebrew, + /// Built and installed by `cargo install`. + Cargo, + /// Running from the official Docker image. + Docker, + /// Official release artifact, but not installed by `install.sh`: + /// a manual download from GitHub releases. + GithubRelease, + /// Locally built from source. + SourceBuild, +} + +impl fmt::Display for InstallChannel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::InstallScript => "install script", + Self::Homebrew => "homebrew", + Self::Cargo => "cargo", + Self::Docker => "docker", + Self::GithubRelease => "github release", + Self::SourceBuild => "source build", + }) + } +} + +impl InstallChannel { + /// Stable identifier for machine-readable metadata such as the HTTP user agent. + const fn id(self) -> &'static str { + match self { + Self::InstallScript => "install-script", + Self::Homebrew => "homebrew", + Self::Cargo => "cargo", + Self::Docker => "docker", + Self::GithubRelease => "github-release", + Self::SourceBuild => "source-build", + } + } +} + +/// Resolve the install channel of the running executable. +/// +/// Cached for the lifetime of the process since the answer cannot change. +pub fn detect() -> InstallChannel { + static CHANNEL: LazyLock = LazyLock::new(|| { + let exe = std::env::current_exe() + .ok() + .and_then(|p| std::fs::canonicalize(p).ok()); + resolve(exe.as_deref(), BUILD_CHANNEL) + }); + *CHANNEL +} + +/// Version string for `--version` that includes the source commit and detected +/// install channel. +pub fn long_version() -> &'static str { + static VERSION: LazyLock = + LazyLock::new(|| format_version(env!("CARGO_PKG_VERSION"), GIT_COMMIT, detect())); + &VERSION +} + +/// HTTP user agent shared by S2 API calls and updater requests. +pub fn user_agent() -> &'static str { + static USER_AGENT: LazyLock = + LazyLock::new(|| format_user_agent(env!("CARGO_PKG_VERSION"), detect())); + &USER_AGENT +} + +fn format_version(version: &str, revision: &str, channel: InstallChannel) -> String { + format!("{version} (rev {revision}, via {channel})") +} + +fn format_user_agent(version: &str, channel: InstallChannel) -> String { + format!("s2-cli/{version} (channel={})", channel.id()) +} + +/// Apply the precedence order documented at the module level. +/// +/// `exe` must already be symlink-resolved: Homebrew installs into a keg and +/// symlinks into `bin`, so the raw `current_exe` path would hide the cellar. +fn resolve(exe: Option<&Path>, stamp: Option<&str>) -> InstallChannel { + if let Some(exe) = exe + && receipt_matches(exe) + { + return InstallChannel::InstallScript; + } + if stamp == Some("docker") { + return InstallChannel::Docker; + } + if let Some(exe) = exe { + if is_homebrew(exe, std::env::var_os("HOMEBREW_CELLAR").map(PathBuf::from)) { + return InstallChannel::Homebrew; + } + if is_cargo(exe, cargo_bin_dirs()) { + return InstallChannel::Cargo; + } + } + if stamp == Some("release") { + return InstallChannel::GithubRelease; + } + InstallChannel::SourceBuild +} + +/// Subset of the receipt `install.sh` writes that detection relies on; +/// unknown fields are ignored so the receipt schema can grow. +#[derive(Deserialize)] +struct Receipt { + channel: String, + binary_path: PathBuf, +} + +/// Whether a receipt next to `exe` claims `exe` itself was installed by +/// `install.sh`. The recorded path must resolve to the running executable — +/// this is what keeps detection deterministic for copied binaries. +fn receipt_matches(exe: &Path) -> bool { + let Some(dir) = exe.parent() else { + return false; + }; + let Ok(contents) = std::fs::read_to_string(dir.join(RECEIPT_FILE)) else { + return false; + }; + let Ok(receipt) = serde_json::from_str::(&contents) else { + return false; + }; + receipt.channel == INSTALL_SCRIPT_CHANNEL + && std::fs::canonicalize(&receipt.binary_path).is_ok_and(|p| p == exe) +} + +/// Homebrew always installs into a keg under the cellar (`Cellar` in +/// standard prefixes, `$HOMEBREW_CELLAR` when relocated) and symlinks into +/// `bin`, so a resolved path under the cellar is brew by definition. +fn is_homebrew(exe: &Path, cellar: Option) -> bool { + // An empty `HOMEBREW_CELLAR` (e.g. `export HOMEBREW_CELLAR=""`) must not + // match every path: `Path::starts_with("")` is `true` for any `exe`, so + // treat an empty cellar the same as an absent one and fall through to the + // keg-shape heuristic below. + if cellar.is_some_and(|c| !c.as_os_str().is_empty() && exe.starts_with(c)) { + return true; + } + // Require the keg shape `Cellar///.../` rather than any + // component merely named "Cellar", so an unrelated directory of that name can't + // masquerade as a brew install. The binary must sit at least below formula/version. + let components: Vec<_> = exe.components().map(|c| c.as_os_str()).collect(); + components + .iter() + .position(|c| *c == "Cellar") + .is_some_and(|cellar| components.len() >= cellar + 4) +} + +/// Directories where `cargo install` places binaries, in cargo's own +/// resolution order: `$CARGO_INSTALL_ROOT/bin`, `$CARGO_HOME/bin`, +/// `~/.cargo/bin`. +fn cargo_bin_dirs() -> Vec { + [ + std::env::var_os("CARGO_INSTALL_ROOT").map(PathBuf::from), + std::env::var_os("CARGO_HOME").map(PathBuf::from), + dirs::home_dir().map(|home| home.join(".cargo")), + ] + .into_iter() + .flatten() + .map(|root| root.join("bin")) + .collect() +} + +/// Whether `exe` lives directly in one of the cargo bin directories. +/// Compares against the symlink-resolved directory as well, since `exe` is +/// canonicalized but `$CARGO_HOME` may be recorded through a symlink. +fn is_cargo(exe: &Path, bin_dirs: Vec) -> bool { + let Some(parent) = exe.parent() else { + return false; + }; + bin_dirs.into_iter().any(|dir| { + parent == dir || std::fs::canonicalize(&dir).is_ok_and(|resolved| parent == resolved) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_includes_full_revision_and_channel() { + assert_eq!( + format_version( + "0.41.0", + "0123456789abcdef0123456789abcdef01234567", + InstallChannel::Homebrew, + ), + "0.41.0 (rev 0123456789abcdef0123456789abcdef01234567, via homebrew)" + ); + } + + #[test] + fn stamp_resolution_without_receipt_or_known_paths() { + let exe = Path::new("/weird/place/s2"); + assert_eq!(resolve(Some(exe), Some("docker")), InstallChannel::Docker); + assert_eq!( + resolve(Some(exe), Some("brew")), + InstallChannel::SourceBuild, + "Homebrew is detected from its resolved Cellar path, not a build stamp" + ); + assert_eq!( + resolve(Some(exe), Some("release")), + InstallChannel::GithubRelease + ); + assert_eq!(resolve(Some(exe), None), InstallChannel::SourceBuild); + assert_eq!(resolve(None, None), InstallChannel::SourceBuild); + } + + #[test] + fn user_agent_includes_machine_readable_channel() { + for (channel, id) in [ + (InstallChannel::InstallScript, "install-script"), + (InstallChannel::Homebrew, "homebrew"), + (InstallChannel::Cargo, "cargo"), + (InstallChannel::Docker, "docker"), + (InstallChannel::GithubRelease, "github-release"), + (InstallChannel::SourceBuild, "source-build"), + ] { + assert_eq!( + format_user_agent("0.41.0", channel), + format!("s2-cli/0.41.0 (channel={id})") + ); + } + } + + #[test] + fn cellar_path_wins_over_release_stamp() { + // Brew currently repackages the release-stamped artifacts, so the + // environment fact must take precedence over the generic stamp. + let exe = Path::new("/opt/homebrew/Cellar/s2/0.40.1/bin/s2"); + assert_eq!( + resolve(Some(exe), Some("release")), + InstallChannel::Homebrew + ); + } + + #[test] + fn homebrew_detection() { + assert!(is_homebrew( + Path::new("/opt/homebrew/Cellar/s2/0.40.1/bin/s2"), + None + )); + assert!(is_homebrew( + Path::new("/home/linuxbrew/.linuxbrew/Cellar/s2/0.40.1/bin/s2"), + None + )); + assert!(is_homebrew( + Path::new("/custom/kegs/s2/0.40.1/bin/s2"), + Some(PathBuf::from("/custom/kegs")) + )); + assert!(!is_homebrew(Path::new("/Users/me/.s2/bin/s2"), None)); + assert!(!is_homebrew(Path::new("/usr/local/bin/s2"), None)); + // A directory merely named "Cellar" is not a keg without formula/version beneath it. + assert!(!is_homebrew(Path::new("/Users/me/Cellar/s2"), None)); + assert!(!is_homebrew(Path::new("/home/me/Cellar/projects/s2"), None)); + } + + #[test] + fn cargo_detection() { + let dirs = vec![PathBuf::from("/Users/me/.cargo/bin")]; + assert!(is_cargo(Path::new("/Users/me/.cargo/bin/s2"), dirs.clone())); + assert!(!is_cargo(Path::new("/Users/me/.s2/bin/s2"), dirs.clone())); + // Not directly in the bin dir. + assert!(!is_cargo(Path::new("/Users/me/.cargo/bin/sub/s2"), dirs)); + } + + #[test] + fn receipt_must_point_at_the_running_executable() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("s2"); + std::fs::write(&exe, "").unwrap(); + let exe = std::fs::canonicalize(&exe).unwrap(); + + // No receipt. + assert!(!receipt_matches(&exe)); + + // Matching receipt; extra fields are ignored. + std::fs::write( + dir.path().join(RECEIPT_FILE), + format!( + r#"{{"channel": "install-script", "version": "0.40.1", + "binary_path": {:?}, "installed_at": "2026-07-23T00:00:00Z"}}"#, + exe + ), + ) + .unwrap(); + assert!(receipt_matches(&exe)); + assert_eq!( + resolve(Some(&exe), Some("release")), + InstallChannel::InstallScript, + "receipt takes precedence over the stamp" + ); + + // Receipt pointing at some other binary must not match. + std::fs::write( + dir.path().join(RECEIPT_FILE), + r#"{"channel": "install-script", "binary_path": "/somewhere/else/s2"}"#, + ) + .unwrap(); + assert!(!receipt_matches(&exe)); + + // Receipt for an unknown channel must not match. + std::fs::write( + dir.path().join(RECEIPT_FILE), + format!(r#"{{"channel": "mystery", "binary_path": {exe:?}}}"#), + ) + .unwrap(); + assert!(!receipt_matches(&exe)); + } +} diff --git a/cli/src/update/mod.rs b/cli/src/update/mod.rs new file mode 100644 index 00000000..0d2520f5 --- /dev/null +++ b/cli/src/update/mod.rs @@ -0,0 +1,219 @@ +//! Once-daily reminder when a newer s2-cli release is available on GitHub, +//! and the `s2 update` command that acts on it (see [`apply`]). + +pub mod apply; +pub mod channel; + +use std::{ + io::IsTerminal, + path::PathBuf, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +pub use channel::{long_version, user_agent}; +use colored::Colorize; +use semver::Version; +use serde::{Deserialize, Serialize}; +use tokio::task::JoinHandle; + +// The largest page GitHub allows: every component in this workspace cuts a +// release per cycle, so a CLI tag must stay findable several cycles back. +const RELEASES_API_URL: &str = + "https://api.github.com/repos/s2-streamstore/s2/releases?per_page=100"; +const CLI_TAG_PREFIX: &str = "s2-cli-v"; +const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); +const FETCH_TIMEOUT: Duration = Duration::from_secs(3); + +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Timestamp and result of the last release lookup, so at most one lookup +/// (and one reminder) happens per [`CHECK_INTERVAL`]. +#[derive(Serialize, Deserialize)] +struct CheckState { + checked_at: u64, + latest_version: String, + /// Version the user asked to stop being reminded about, via + /// `s2 update --skip`. Reminders resume once a release newer than this + /// appears. + #[serde(default)] + skipped_version: Option, +} + +fn state_path() -> Option { + let mut path = dirs::cache_dir()?; + path.push("s2"); + path.push("cli-update-check.toml"); + Some(path) +} + +fn load_state(path: &PathBuf) -> Option { + toml::from_str(&std::fs::read_to_string(path).ok()?).ok() +} + +fn save_state(path: &PathBuf, state: &CheckState) { + let Ok(contents) = toml::to_string(state) else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(path, contents); +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default() +} + +/// Start the update check in the background, or return `None` when it should +/// not run: +/// +/// - opted out via `S2_NO_UPDATE_CHECK`, +/// - stderr is not a terminal, +/// - running from the Docker image, where the binary is immutable and image tags are pinned +/// deliberately. +pub fn spawn_check() -> Option>> { + if std::env::var_os("S2_NO_UPDATE_CHECK").is_some() + || !std::io::stderr().is_terminal() + || channel::detect() == channel::InstallChannel::Docker + { + return None; + } + Some(tokio::spawn(check())) +} + +/// Return the latest released version if it is newer than the current one, +/// has not been skipped, and no check has run within [`CHECK_INTERVAL`]. +async fn check() -> Option { + let path = state_path()?; + let now = unix_now(); + let state = load_state(&path); + if let Some(state) = &state + && now.saturating_sub(state.checked_at) < CHECK_INTERVAL.as_secs() + { + return None; + } + let skipped = state.as_ref().and_then(|s| s.skipped_version.clone()); + let fetched = fetch_latest().await; + // Record the attempt even on fetch failure, so an unreachable GitHub does + // not turn into a lookup on every invocation. Preserve any skip marker. + let latest_version = fetched + .as_ref() + .map(ToString::to_string) + .or_else(|| state.map(|s| s.latest_version)) + .unwrap_or_default(); + save_state( + &path, + &CheckState { + checked_at: now, + latest_version, + skipped_version: skipped.clone(), + }, + ); + let latest = fetched?; + // A skip only silences that exact version; a newer release resumes nagging. + if skipped.as_deref() == Some(latest.to_string().as_str()) { + return None; + } + let current = Version::parse(CURRENT_VERSION).ok()?; + (latest > current).then_some(latest) +} + +/// Persist a request (from `s2 update --skip`) to stop reminding about +/// `version`. Returns whether the marker was written. +pub fn skip_version(version: &Version) -> bool { + let Some(path) = state_path() else { + return false; + }; + let mut state = load_state(&path).unwrap_or(CheckState { + checked_at: 0, + latest_version: version.to_string(), + skipped_version: None, + }); + state.skipped_version = Some(version.to_string()); + save_state(&path, &state); + true +} + +pub(crate) async fn fetch_latest() -> Option { + #[derive(Deserialize)] + struct Release { + tag_name: String, + prerelease: bool, + draft: bool, + } + + let client = reqwest::Client::builder() + .user_agent(user_agent()) + .timeout(FETCH_TIMEOUT) + .build() + .ok()?; + let body = client + .get(RELEASES_API_URL) + .send() + .await + .ok()? + .error_for_status() + .ok()? + .text() + .await + .ok()?; + let releases: Vec = serde_json::from_str(&body).ok()?; + latest_cli_version( + releases + .iter() + .map(|r| (r.tag_name.as_str(), !r.prerelease && !r.draft)), + ) +} + +fn latest_cli_version<'a>(tags: impl IntoIterator) -> Option { + tags.into_iter() + .filter(|(_, released)| *released) + .filter_map(|(tag, _)| tag.strip_prefix(CLI_TAG_PREFIX)) + .filter_map(|v| Version::parse(v).ok()) + .max() +} + +/// Await the background check and print a reminder if a newer version exists. +pub async fn notify(check: Option>>) { + let Some(handle) = check else { + return; + }; + let Ok(Ok(Some(latest))) = tokio::time::timeout(FETCH_TIMEOUT, handle).await else { + return; + }; + eprintln!( + "\n{} {} {}", + "A new release of s2-cli is available:".yellow(), + CURRENT_VERSION.cyan(), + format!("→ {latest}").cyan(), + ); + // `s2 update` handles every install channel (in-place upgrade, delegating + // to a package manager, or explaining how), so it is the one hint to give. + eprintln!("{} {}", "To upgrade, run:".yellow(), "s2 update".cyan()); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn picks_latest_released_cli_tag() { + let tags = [ + ("s2-cli-v0.38.0", true), + ("s2-lite-v0.38.0", true), + ("s2-cli-v0.39.0", false), + ("s2-cli-v0.38.2", true), + ("s2-sdk-v0.31.8", true), + ("s2-cli-v0.38.10", true), + ]; + assert_eq!( + latest_cli_version(tags), + Some(Version::new(0, 38, 10)), + "highest semver among released s2-cli tags" + ); + assert_eq!(latest_cli_version([("s2-sdk-v0.31.8", true)]), None); + } +} diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs new file mode 100644 index 00000000..2baac327 --- /dev/null +++ b/cli/tests/cli.rs @@ -0,0 +1,616 @@ +use std::{ + convert::Infallible, + net::TcpListener, + sync::{Arc, Mutex}, + thread::JoinHandle, + time::Duration, +}; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +#[cfg(unix)] +fn mode(path: &std::path::Path) -> u32 { + use std::os::unix::fs::PermissionsExt; + + std::fs::metadata(path) + .expect("metadata") + .permissions() + .mode() + & 0o777 +} + +struct TestEnv { + home: TempDir, +} + +struct TestServer { + endpoint: String, + handle: JoinHandle, +} + +impl TestServer { + /// Serves one HTTP/2 request and returns the request line and headers it + /// saw. The client speaks h2 with prior knowledge over cleartext. + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let endpoint = format!("http://{}", listener.local_addr().expect("server address")); + let handle = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(serve_one_request(listener)) + }); + Self { endpoint, handle } + } + + fn finish(self) -> String { + self.handle.join().expect("test server") + } +} + +const TEST_SERVER_TIMEOUT: Duration = Duration::from_secs(10); + +async fn serve_one_request(listener: TcpListener) -> String { + listener + .set_nonblocking(true) + .expect("non-blocking listener"); + let listener = tokio::net::TcpListener::from_std(listener).expect("tokio listener"); + let (stream, _) = tokio::time::timeout(TEST_SERVER_TIMEOUT, listener.accept()) + .await + .expect("timed out waiting for a connection") + .expect("accept connection"); + + let observed = Arc::new(Mutex::new(None)); + let captured = observed.clone(); + let service = hyper::service::service_fn(move |req: hyper::Request| { + let captured = captured.clone(); + async move { + let mut rendered = format!("{} {}\r\n", req.method(), req.uri()); + for (name, value) in req.headers() { + rendered.push_str(name.as_str()); + rendered.push_str(": "); + rendered.push_str(value.to_str().unwrap_or_default()); + rendered.push_str("\r\n"); + } + *captured.lock().expect("capture request") = Some(rendered); + + let body = r#"{"basins":[],"has_more":false}"#; + Ok::<_, Infallible>( + hyper::Response::builder() + .header("content-type", "application/json") + .body(http_body_util::Full::new(bytes::Bytes::from(body))) + .expect("build response"), + ) + } + }); + + // A client that exits right after its response can reset the connection, + // so the served result only matters when no request came through. + let served = tokio::time::timeout( + TEST_SERVER_TIMEOUT, + hyper::server::conn::http2::Builder::new(hyper_util::rt::TokioExecutor::new()) + .serve_connection(hyper_util::rt::TokioIo::new(stream), service), + ) + .await; + + let observed = observed.lock().expect("read request").take(); + observed.unwrap_or_else(|| panic!("server received no HTTP/2 request: {served:?}")) +} + +impl TestEnv { + fn new() -> Self { + Self { + home: tempfile::tempdir().expect("temp home dir"), + } + } + + fn s2(&self) -> Command { + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("s2")); + cmd.env("HOME", self.home.path()); + cmd.env("XDG_CONFIG_HOME", self.home.path().join(".config")); + cmd.env("APPDATA", self.home.path()); + cmd.env("USERPROFILE", self.home.path()); + for key in [ + "S2_ACCESS_TOKEN", + "S2_ACCOUNT_ENDPOINT", + "S2_BASIN_ENDPOINT", + "S2_COMPRESSION", + "S2_SSL_NO_VERIFY", + ] { + cmd.env_remove(key); + } + cmd + } + + fn config_dir(&self) -> std::path::PathBuf { + #[cfg(windows)] + return self.home.path().join("s2"); + #[cfg(not(windows))] + return self.home.path().join(".config/s2"); + } + + fn remember_access_token(&self, token: &str) { + self.s2() + .args([ + "auth", + "access-token", + "set", + "--stdin", + "--insecure-storage", + ]) + .write_stdin(token) + .assert() + .success(); + } +} + +#[test] +fn invalid_uri_scheme() { + TestEnv::new() + .s2() + .args(["get-stream-config", "foo://invalid/stream"]) + .assert() + .failure() + .stderr(predicate::str::contains("s2://")); +} + +#[test] +fn missing_stream_in_uri() { + TestEnv::new() + .s2() + .args(["get-stream-config", "s2://basin-only"]) + .assert() + .failure(); +} + +#[test] +fn invalid_basin_name() { + TestEnv::new() + .s2() + .args(["create-basin", "-invalid-name"]) + .assert() + .failure(); +} + +#[test] +fn missing_access_token() { + let env = TestEnv::new(); + let mut cmd = env.s2(); + cmd.args(["list-basins"]) + .assert() + .failure() + .stderr(predicate::str::contains("access token")); +} + +#[test] +fn access_token_set_requires_stdin_flag_for_non_interactive_input() { + TestEnv::new() + .s2() + .args(["auth", "access-token", "set", "--insecure-storage"]) + .write_stdin("do-not-print-this-token") + .assert() + .failure() + .stderr( + predicate::str::contains("--stdin") + .and(predicate::str::contains("do-not-print-this-token").not()), + ); +} + +#[test] +fn private_file_access_token_authenticates_without_leaking_to_config() { + let env = TestEnv::new(); + env.remember_access_token("remembered-secret"); + + let config_dir = env.config_dir(); + let config = std::fs::read_to_string(config_dir.join("config.toml")).expect("read config"); + assert!(!config.contains("remembered-secret")); + assert!(config.contains("stored_access_token")); + + let server = TestServer::start(); + env.s2() + .env("S2_ACCOUNT_ENDPOINT", &server.endpoint) + .env("S2_BASIN_ENDPOINT", &server.endpoint) + .args(["list-basins", "--limit", "1"]) + .assert() + .success() + .stdout(predicate::str::contains("remembered-secret").not()) + .stderr(predicate::str::contains("remembered-secret").not()); + let request = server.finish().to_ascii_lowercase(); + assert!(request.contains("authorization: bearer remembered-secret")); +} + +#[test] +fn environment_access_token_overrides_a_stored_token() { + let env = TestEnv::new(); + env.remember_access_token("stored-secret"); + let server = TestServer::start(); + + env.s2() + .env("S2_ACCESS_TOKEN", "environment-secret") + .env("S2_ACCOUNT_ENDPOINT", &server.endpoint) + .env("S2_BASIN_ENDPOINT", &server.endpoint) + .args(["list-basins", "--limit", "1"]) + .assert() + .success(); + + let request = server.finish().to_ascii_lowercase(); + assert!(request.contains("authorization: bearer environment-secret")); + assert!(!request.contains("stored-secret")); +} + +#[test] +fn legacy_plaintext_access_token_can_be_migrated_to_a_private_file() { + let env = TestEnv::new(); + let config_dir = env.config_dir(); + std::fs::create_dir_all(&config_dir).expect("create config directory"); + let config_path = config_dir.join("config.toml"); + std::fs::write(&config_path, "access_token = \"legacy-secret\"\n").expect("write config"); + + env.s2() + .args(["auth", "access-token", "migrate", "--insecure-storage"]) + .assert() + .success() + .stderr( + predicate::str::contains("Legacy access token migrated") + .and(predicate::str::contains(" - Access token saved to:")) + .and(predicate::str::contains(" - Configuration saved to:")) + .and(predicate::str::contains("Previous access token replaced").not()) + .and(predicate::str::contains("legacy-secret").not()), + ); + + let config = std::fs::read_to_string(&config_path).expect("read migrated config"); + assert!(!config.contains("legacy-secret")); + assert!(!config.contains("access_token =")); + assert!(config.contains("stored_access_token")); + let credential = std::fs::read_dir(&config_dir) + .expect("list config directory") + .filter_map(Result::ok) + .find(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("access-token-") + }) + .expect("stored access-token file"); + assert!( + std::fs::read_to_string(credential.path()) + .expect("read credential") + .contains("legacy-secret") + ); +} + +#[test] +fn removing_a_stored_access_token_deletes_its_local_credential() { + let env = TestEnv::new(); + env.remember_access_token("removable-secret"); + let config_dir = env.config_dir(); + + env.s2() + .args(["auth", "access-token", "remove"]) + .assert() + .success() + .stderr( + predicate::str::contains("Access token removed") + .and(predicate::str::contains("removable-secret").not()) + .and(predicate::str::contains( + "This does not revoke the access token.", + )), + ); + + let config = std::fs::read_to_string(config_dir.join("config.toml")).expect("read config"); + assert!(!config.contains("stored_access_token")); + assert!( + std::fs::read_dir(config_dir) + .expect("list config directory") + .filter_map(Result::ok) + .all(|entry| !entry + .file_name() + .to_string_lossy() + .starts_with("access-token-")) + ); +} + +#[test] +fn config_commands_never_print_stored_or_legacy_tokens() { + let stored = TestEnv::new(); + stored.remember_access_token("never-print-stored"); + stored + .s2() + .args(["config", "list"]) + .assert() + .success() + .stdout( + predicate::str::contains("access_token = ") + .and(predicate::str::contains("never-print-stored").not()), + ); + stored + .s2() + .args(["config", "get", "access_token"]) + .assert() + .failure() + .stderr( + predicate::str::contains("cannot be read") + .and(predicate::str::contains("never-print-stored").not()), + ); + + let legacy = TestEnv::new(); + let config_dir = legacy.config_dir(); + std::fs::create_dir_all(&config_dir).expect("create config directory"); + std::fs::write( + config_dir.join("config.toml"), + "access_token = \"never-print-legacy\"\n", + ) + .expect("write config"); + legacy + .s2() + .args(["config", "list"]) + .assert() + .success() + .stdout( + predicate::str::contains("access_token = ") + .and(predicate::str::contains("never-print-legacy").not()), + ); +} + +#[test] +fn diff_bare_names_require_resource_before_authentication() { + TestEnv::new() + .s2() + .args(["diff", "token-left", "token-right"]) + .assert() + .failure() + .stderr( + predicate::str::contains("Cannot infer a resource type") + .and(predicate::str::contains("--resource")) + .and(predicate::str::contains("access token is required").not()), + ); +} + +#[test] +fn unknown_subcommand() { + TestEnv::new() + .s2() + .args(["unknown-command"]) + .assert() + .failure() + .stderr(predicate::str::contains("unrecognized subcommand")); +} + +#[test] +fn config_list() { + TestEnv::new() + .s2() + .args(["config", "list"]) + .assert() + .success(); +} + +#[test] +fn config_set_and_get() { + let env = TestEnv::new(); + env.s2() + .args(["config", "set", "compression", "zstd"]) + .assert() + .success(); + env.s2() + .args(["config", "get", "compression"]) + .assert() + .success() + .stdout(predicate::str::contains("zstd")); + env.s2() + .args(["config", "unset", "compression"]) + .assert() + .success(); +} + +#[cfg(unix)] +#[test] +fn config_set_writes_private_config() { + let env = TestEnv::new(); + env.s2() + .args(["config", "set", "compression", "zstd"]) + .assert() + .success(); + + let config_dir = env.config_dir(); + assert_eq!(mode(&config_dir), 0o700); + assert_eq!(mode(&config_dir.join("config.toml")), 0o600); +} + +#[test] +fn config_get_invalid_key() { + TestEnv::new() + .s2() + .args(["config", "get", "invalid_key"]) + .assert() + .failure(); +} + +#[test] +fn config_set_invalid_key() { + TestEnv::new() + .s2() + .args(["config", "set", "invalid_key", "value"]) + .assert() + .failure(); +} + +/// An invalid endpoint set via the config file should produce a source-agnostic +/// error message that points to both the config file and the environment +/// variables, without claiming the endpoints were loaded "from environment". +#[test] +fn invalid_endpoint_from_config_file() { + let env = TestEnv::new(); + + // Set up a token and malformed endpoints in the config file (realistic + // typo: "https//" instead of "https://"). + env.remember_access_token("test-token"); + env.s2() + .args(["config", "set", "account_endpoint", "https//a.s2.dev"]) + .assert() + .success(); + env.s2() + .args(["config", "set", "basin_endpoint", "https//b.s2.dev"]) + .assert() + .success(); + + let assert = env.s2().args(["list-basins"]).assert().failure(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + + // The error should not misattribute the source to environment variables. + assert!( + !stderr.contains("from environment"), + "stderr should not say 'from environment', got: {stderr}" + ); + // The error should be source-agnostic. + assert!( + stderr.contains("Unable to parse S2 endpoints"), + "stderr should say 'Unable to parse S2 endpoints', got: {stderr}" + ); + // Help text should mention the config file path that was actually used. + assert!( + stderr.contains("config.toml"), + "stderr should mention the config file path, got: {stderr}" + ); + // Help text should mention both environment variable names. + assert!( + stderr.contains("S2_ACCOUNT_ENDPOINT") && stderr.contains("S2_BASIN_ENDPOINT"), + "stderr should mention both S2_ACCOUNT_ENDPOINT and S2_BASIN_ENDPOINT, got: {stderr}" + ); + // The underlying parse failure detail should still be surfaced. + assert!( + stderr.contains("invalid account endpoint"), + "stderr should contain the underlying parse error, got: {stderr}" + ); +} + +/// An invalid endpoint set via environment variables should produce the same +/// source-agnostic error message. +#[test] +fn invalid_endpoint_from_env() { + let env = TestEnv::new(); + let mut cmd = env.s2(); + cmd.env("S2_ACCESS_TOKEN", "test-token"); + cmd.env("S2_ACCOUNT_ENDPOINT", "https//a.s2.dev"); + cmd.env("S2_BASIN_ENDPOINT", "https//b.s2.dev"); + + let assert = cmd.args(["list-basins"]).assert().failure(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + + assert!( + !stderr.contains("from environment"), + "stderr should not say 'from environment', got: {stderr}" + ); + assert!( + stderr.contains("Unable to parse S2 endpoints"), + "stderr should say 'Unable to parse S2 endpoints', got: {stderr}" + ); + assert!( + stderr.contains("config.toml"), + "stderr should mention the config file path, got: {stderr}" + ); + assert!( + stderr.contains("S2_ACCOUNT_ENDPOINT") && stderr.contains("S2_BASIN_ENDPOINT"), + "stderr should mention both S2_ACCOUNT_ENDPOINT and S2_BASIN_ENDPOINT, got: {stderr}" + ); +} + +/// When only the basin endpoint is malformed, the parse error for the basin +/// endpoint should still be surfaced with the source-agnostic message. +#[test] +fn invalid_basin_endpoint_from_config_file() { + let env = TestEnv::new(); + + env.remember_access_token("test-token"); + env.s2() + .args(["config", "set", "account_endpoint", "https://a.s2.dev"]) + .assert() + .success(); + env.s2() + .args(["config", "set", "basin_endpoint", "https//b.s2.dev"]) + .assert() + .success(); + + let assert = env.s2().args(["list-basins"]).assert().failure(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + + assert!( + !stderr.contains("from environment"), + "stderr should not say 'from environment', got: {stderr}" + ); + assert!( + stderr.contains("Unable to parse S2 endpoints"), + "stderr should say 'Unable to parse S2 endpoints', got: {stderr}" + ); + assert!( + stderr.contains("invalid basin endpoint"), + "stderr should contain the underlying basin parse error, got: {stderr}" + ); +} + +/// When both endpoints parse individually but have mismatched schemes, the +/// `S2Endpoints::new` mismatch error should also use the source-agnostic +/// message rather than blaming the environment. +#[test] +fn mismatched_endpoint_schemes_from_config_file() { + let env = TestEnv::new(); + + env.remember_access_token("test-token"); + env.s2() + .args(["config", "set", "account_endpoint", "https://a.s2.dev"]) + .assert() + .success(); + env.s2() + .args(["config", "set", "basin_endpoint", "http://{basin}.b.s2.dev"]) + .assert() + .success(); + + let assert = env.s2().args(["list-basins"]).assert().failure(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + + assert!( + !stderr.contains("from environment"), + "stderr should not say 'from environment', got: {stderr}" + ); + assert!( + stderr.contains("Unable to parse S2 endpoints"), + "stderr should say 'Unable to parse S2 endpoints', got: {stderr}" + ); + assert!( + stderr.contains("same scheme"), + "stderr should mention the scheme mismatch, got: {stderr}" + ); +} + +/// A command that sets only one endpoint should still warn (not error) and use +/// default endpoints, ensuring endpoint validation is not triggered in that +/// path. This guards against regressions in the partial-endpoint warnings. +#[test] +fn only_account_endpoint_set_warns_and_uses_defaults() { + let env = TestEnv::new(); + + env.remember_access_token("test-token"); + env.s2() + .args(["config", "set", "account_endpoint", "https://a.s2.dev"]) + .assert() + .success(); + + // Should not produce an endpoint parse error; it should warn about the + // missing basin endpoint and fall back to defaults. Without a reachable + // server the command will fail, but it must fail with a network/connection + // error, not an endpoint parse error. + let assert = env.s2().args(["list-basins"]).assert().failure(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + + assert!( + stderr.contains("account endpoint is set but basin endpoint is not"), + "stderr should warn about the partial endpoint config, got: {stderr}" + ); + assert!( + !stderr.contains("Unable to parse S2 endpoints"), + "stderr should not report an endpoint parse error, got: {stderr}" + ); +} diff --git a/cli/tests/integration.rs b/cli/tests/integration.rs new file mode 100644 index 00000000..7d0152f0 --- /dev/null +++ b/cli/tests/integration.rs @@ -0,0 +1,1333 @@ +use std::{ + io::Write, + time::{SystemTime, UNIX_EPOCH}, +}; + +use assert_cmd::Command; +use predicates::prelude::*; +use serial_test::serial; + +fn unique_name(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{prefix}-{nanos}") +} + +fn s2() -> Command { + Command::new(assert_cmd::cargo::cargo_bin!("s2")) +} + +fn wait_for_basin(basin: &str) { + for _ in 0..60 { + if s2() + .args(["get-basin-config", basin]) + .output() + .is_ok_and(|o| o.status.success()) + { + return; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } +} + +fn cleanup_basin(basin: &str) { + let _ = s2().args(["delete-basin", basin]).output(); +} + +fn cleanup_stream(basin: &str, stream: &str) { + let _ = s2() + .args(["delete-stream", &format!("s2://{basin}/{stream}")]) + .output(); +} + +fn ensure_test_basin(name: &str) -> String { + let _ = s2().args(["create-basin", name]).output(); + wait_for_basin(name); + name.to_string() +} + +#[test] +#[serial] +fn list_basins() { + s2().args(["list-basins", "--limit", "5"]) + .assert() + .success(); +} + +#[test] +#[serial] +fn list_basins_with_prefix() { + s2().args(["list-basins", "--prefix", "test-cli-", "--limit", "5"]) + .assert() + .success(); +} + +#[test] +#[serial] +fn create_get_delete_basin() { + let basin = unique_name("test-cli-basin"); + + s2().args(["create-basin", &basin]).assert().success(); + + wait_for_basin(&basin); + + s2().args(["get-basin-config", &basin]).assert().success(); + + s2().args(["delete-basin", &basin]).assert().success(); +} + +#[test] +#[serial] +fn create_basin_with_config() { + let basin = unique_name("test-cli-basin-cfg"); + + s2().args([ + "create-basin", + &basin, + "--retention-policy", + "1d", + "--create-stream-on-append", + ]) + .assert() + .success(); + + wait_for_basin(&basin); + + s2().args(["get-basin-config", &basin]) + .assert() + .success() + .stdout(predicate::str::contains("create_stream_on_append")); + + cleanup_basin(&basin); +} + +#[test] +#[serial] +fn diff_basins() { + let left = unique_name("test-cli-diff-basin-left"); + let right = unique_name("test-cli-diff-basin-right"); + + s2().args(["create-basin", &left, "--retention-policy", "infinite"]) + .assert() + .success(); + s2().args([ + "create-basin", + &right, + "--create-stream-on-append", + "--retention-policy", + "7d", + "--timestamping-mode", + "arrival", + ]) + .assert() + .success(); + wait_for_basin(&left); + wait_for_basin(&right); + + s2().args(["diff", &format!("s2://{left}"), &format!("s2://{right}")]) + .assert() + .success() + .stdout( + predicate::str::contains(format!("--- s2://{left}")) + .and(predicate::str::contains(format!("+++ s2://{right}"))) + .and(predicate::str::contains("create_stream_on_append")) + .and(predicate::str::contains("- false")) + .and(predicate::str::contains("+ true")) + .and(predicate::str::contains( + "default_stream_config.retention_policy\n- infinite\n+ 7d", + )) + .and(predicate::str::contains( + "default_stream_config.timestamping.mode\n- client-prefer\n+ arrival", + )), + ); + + s2().args([ + "diff", + "--resource", + "basin", + &left, + &right, + "--output", + "json", + ]) + .assert() + .success() + .stdout( + predicate::str::contains("\"resource\": \"basin\"") + .and(predicate::str::contains( + "\"path\": \"create_stream_on_append\"", + )) + .and(predicate::str::contains( + "\"path\": \"default_stream_config.retention_policy\"", + )) + .and(predicate::str::contains("\"left\": \"infinite\"")) + .and(predicate::str::contains("\"right\": \"7d\"")), + ); + + cleanup_basin(&left); + cleanup_basin(&right); +} + +#[test] +#[serial] +fn reconfigure_basin() { + let basin = unique_name("test-cli-basin-reconfig"); + + s2().args(["create-basin", &basin]).assert().success(); + wait_for_basin(&basin); + + s2().args([ + "reconfigure-basin", + &basin, + "--create-stream-on-append", + "true", + ]) + .assert() + .success(); + + s2().args(["get-basin-config", &basin]) + .assert() + .success() + .stdout(predicate::str::contains("create_stream_on_append")); + + cleanup_basin(&basin); +} + +#[test] +#[serial] +fn ls_basins() { + s2().args(["ls", "--limit", "5"]).assert().success(); +} + +#[test] +#[serial] +fn delete_nonexistent_basin() { + s2().args(["delete-basin", "nonexistent-basin-12345"]) + .assert() + .failure(); +} + +#[test] +#[serial] +fn get_config_nonexistent_basin() { + s2().args(["get-basin-config", "nonexistent-basin-12345"]) + .assert() + .failure(); +} + +#[test] +#[serial] +fn list_streams() { + let basin = ensure_test_basin("test-cli-streams"); + s2().args(["list-streams", &basin, "--limit", "5"]) + .assert() + .success(); +} + +#[test] +#[serial] +fn list_streams_with_uri() { + let basin = ensure_test_basin("test-cli-streams"); + s2().args(["list-streams", &format!("s2://{basin}/"), "--limit", "5"]) + .assert() + .success(); +} + +#[test] +#[serial] +fn create_get_delete_stream() { + let basin = ensure_test_basin("test-cli-streams"); + let stream = unique_name("test-stream"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + s2().args(["get-stream-config", &uri]).assert().success(); + + s2().args(["delete-stream", &uri]).assert().success(); +} + +#[test] +#[serial] +fn create_stream_with_config() { + let basin = ensure_test_basin("test-cli-streams"); + let stream = unique_name("test-stream-cfg"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri, "--retention-policy", "7d"]) + .assert() + .success(); + s2().args(["get-stream-config", &uri]).assert().success(); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn diff_streams() { + let basin = ensure_test_basin("test-cli-diff-streams"); + let left = unique_name("test-stream-diff-left"); + let right = unique_name("test-stream-diff-right"); + let left_uri = format!("s2://{basin}/{left}"); + let right_uri = format!("s2://{basin}/{right}"); + + s2().args(["create-stream", &left_uri]).assert().success(); + s2().args(["create-stream", &right_uri, "--retention-policy", "1d"]) + .assert() + .success(); + + s2().args(["diff", &left_uri, &right_uri]) + .assert() + .success() + .stdout( + predicate::str::contains(format!("--- {left_uri}")) + .and(predicate::str::contains(format!("+++ {right_uri}"))) + .and(predicate::str::contains("retention_policy")) + .and(predicate::str::contains("+ 1d")), + ); + + s2().args([ + "diff", + "--resource", + "stream", + &left_uri, + &right_uri, + "--output", + "json", + ]) + .assert() + .success() + .stdout( + predicate::str::contains("\"resource\": \"stream\"") + .and(predicate::str::contains("\"path\": \"retention_policy\"")), + ); + + s2().args(["diff", &left_uri, &right_uri, "--exit-code"]) + .assert() + .code(1); + + s2().args(["diff", &left_uri, &left_uri, "--exit-code"]) + .assert() + .success() + .stdout(predicate::str::contains("✓ No differences")); + + cleanup_stream(&basin, &left); + cleanup_stream(&basin, &right); +} + +#[test] +#[serial] +fn diff_stream_against_basin_defaults() { + let basin = unique_name("test-cli-diff-defaults"); + let stream = unique_name("test-stream-custom-config"); + let basin_uri = format!("s2://{basin}"); + let stream_uri = format!("{basin_uri}/{stream}"); + + s2().args([ + "create-basin", + &basin, + "--retention-policy", + "7d", + "--timestamping-mode", + "arrival", + ]) + .assert() + .success(); + wait_for_basin(&basin); + s2().args([ + "create-stream", + &stream_uri, + "--retention-policy", + "30d", + "--timestamping-mode", + "client-prefer", + ]) + .assert() + .success(); + + s2().args(["diff", &basin_uri, &stream_uri]) + .assert() + .success() + .stdout( + predicate::str::contains(format!("--- {basin_uri} (stream defaults)")) + .and(predicate::str::contains(format!("+++ {stream_uri}"))) + .and(predicate::str::contains("retention_policy\n- 7d\n+ 30d")) + .and(predicate::str::contains( + "timestamping.mode\n- arrival\n+ client-prefer", + )), + ); + + s2().args(["diff", &stream_uri, &basin_uri]) + .assert() + .success() + .stdout( + predicate::str::contains(format!("--- {stream_uri}")) + .and(predicate::str::contains(format!( + "+++ {basin_uri} (stream defaults)" + ))) + .and(predicate::str::contains("retention_policy\n- 30d\n+ 7d")), + ); + + s2().args(["diff", &basin_uri, &stream_uri, "--output", "json"]) + .assert() + .success() + .stdout( + predicate::str::contains("\"resource\": \"stream-vs-basin-defaults\"") + .and(predicate::str::contains(format!( + "\"left\": \"{basin_uri}\"" + ))) + .and(predicate::str::contains(format!( + "\"right\": \"{stream_uri}\"" + ))), + ); + + cleanup_stream(&basin, &stream); + cleanup_basin(&basin); +} + +#[test] +#[serial] +fn reconfigure_stream() { + let basin = ensure_test_basin("test-cli-streams"); + let stream = unique_name("test-stream-reconfig"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + s2().args(["reconfigure-stream", &uri, "--retention-policy", "14d"]) + .assert() + .success(); + + s2().args(["get-stream-config", &uri]) + .assert() + .success() + .stdout(predicate::str::contains("14d").or(predicate::str::contains("1209600"))); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn ls_streams() { + let basin = ensure_test_basin("test-cli-streams"); + s2().args(["ls", &basin, "--limit", "5"]).assert().success(); +} + +#[test] +#[serial] +fn check_tail() { + let basin = ensure_test_basin("test-cli-streams"); + let stream = unique_name("test-stream-tail"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + s2().args(["check-tail", &uri]) + .assert() + .success() + .stdout(predicate::str::contains("@")); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn delete_nonexistent_stream() { + let basin = ensure_test_basin("test-cli-streams"); + s2().args([ + "delete-stream", + &format!("s2://{basin}/nonexistent-stream-12345"), + ]) + .assert() + .failure(); +} + +#[test] +#[serial] +fn append_and_read_text() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-text"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + let temp = tempfile::TempDir::new().unwrap(); + let input = temp.path().join("input.txt"); + { + let mut f = std::fs::File::create(&input).unwrap(); + writeln!(f, "hello world").unwrap(); + writeln!(f, "line two").unwrap(); + } + + s2().args([ + "append", + &uri, + "--format", + "text", + "--input", + input.to_str().unwrap(), + ]) + .assert() + .success(); + + s2().args([ + "read", + &uri, + "--seq-num", + "0", + "--count", + "2", + "--format", + "text", + ]) + .assert() + .success() + .stdout(predicate::str::contains("hello world")); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn append_and_read_json() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-json"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + let temp = tempfile::TempDir::new().unwrap(); + let input = temp.path().join("input.json"); + { + let mut f = std::fs::File::create(&input).unwrap(); + writeln!(f, r#"{{"body": "record one"}}"#).unwrap(); + writeln!(f, r#"{{"body": "record two"}}"#).unwrap(); + } + + s2().args([ + "append", + &uri, + "--format", + "json", + "--input", + input.to_str().unwrap(), + ]) + .assert() + .success(); + + s2().args([ + "read", + &uri, + "--seq-num", + "0", + "--count", + "2", + "--format", + "json", + ]) + .assert() + .success() + .stdout(predicate::str::contains("record one")); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn append_from_stdin() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-stdin"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + s2().args(["append", &uri, "--format", "text", "--input", "-"]) + .write_stdin("stdin record\n") + .assert() + .success(); + + s2().args([ + "read", + &uri, + "--seq-num", + "0", + "--count", + "1", + "--format", + "text", + ]) + .assert() + .success() + .stdout(predicate::str::contains("stdin record")); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn append_with_stream_config() { + let basin = unique_name("test-cli-csoa-cfg"); + s2().args([ + "create-basin", + &basin, + "--retention-policy", + "7d", + "--create-stream-on-append", + ]) + .assert() + .success(); + wait_for_basin(&basin); + + let stream = unique_name("test-csoa-new"); + let uri = format!("s2://{basin}/{stream}"); + s2().args([ + "append", + &uri, + "--format", + "text", + "--input", + "-", + "--retention-policy", + "1h", + "--delete-on-empty-min-age", + "5m", + ]) + .write_stdin("first record\n") + .assert() + .success(); + + s2().args(["get-stream-config", &uri]) + .assert() + .success() + .stdout( + predicate::str::contains("1h") + .and(predicate::str::contains("5m")) + .and(predicate::str::contains("7days").not()), + ); + + s2().args([ + "append", + &uri, + "--format", + "text", + "--input", + "-", + "--retention-policy", + "2h", + ]) + .write_stdin("second record\n") + .assert() + .success(); + + s2().args(["get-stream-config", &uri]) + .assert() + .success() + .stdout(predicate::str::contains("1h").and(predicate::str::contains("2h").not())); + + cleanup_stream(&basin, &stream); + cleanup_basin(&basin); +} + +#[test] +#[serial] +fn read_with_stream_config() { + let basin = unique_name("test-cli-csor-cfg"); + s2().args(["create-basin", &basin, "--create-stream-on-read"]) + .assert() + .success(); + wait_for_basin(&basin); + + let stream = unique_name("test-csor-new"); + let uri = format!("s2://{basin}/{stream}"); + s2().args(["read", &uri, "--count", "1", "--retention-policy", "1h"]) + .assert() + .failure(); + + s2().args(["get-stream-config", &uri]) + .assert() + .success() + .stdout(predicate::str::contains("1h")); + + cleanup_stream(&basin, &stream); + cleanup_basin(&basin); +} + +#[test] +#[serial] +fn tail_stream() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-tail"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + let temp = tempfile::TempDir::new().unwrap(); + let input = temp.path().join("input.txt"); + { + let mut f = std::fs::File::create(&input).unwrap(); + for i in 1..=5 { + writeln!(f, "record {i}").unwrap(); + } + } + + s2().args([ + "append", + &uri, + "--format", + "text", + "--input", + input.to_str().unwrap(), + ]) + .assert() + .success(); + + s2().args(["tail", &uri, "-n", "3", "--format", "text"]) + .assert() + .success() + .stdout(predicate::str::contains("record 5")); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn read_with_tail_offset() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-offset"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + let temp = tempfile::TempDir::new().unwrap(); + let input = temp.path().join("input.txt"); + { + let mut f = std::fs::File::create(&input).unwrap(); + for i in 1..=10 { + writeln!(f, "record {i}").unwrap(); + } + } + + s2().args([ + "append", + &uri, + "--format", + "text", + "--input", + input.to_str().unwrap(), + ]) + .assert() + .success(); + + s2().args([ + "read", + &uri, + "--tail-offset", + "3", + "--count", + "3", + "--format", + "text", + ]) + .assert() + .success() + .stdout(predicate::str::contains("record 8")); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn trim_stream() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-trim"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + let temp = tempfile::TempDir::new().unwrap(); + let input = temp.path().join("input.txt"); + { + let mut f = std::fs::File::create(&input).unwrap(); + for i in 1..=5 { + writeln!(f, "record {i}").unwrap(); + } + } + + s2().args([ + "append", + &uri, + "--format", + "text", + "--input", + input.to_str().unwrap(), + ]) + .assert() + .success(); + + s2().args(["trim", &uri, "3"]) + .assert() + .success() + .stderr(predicate::str::contains("@")); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn fence_stream() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-fence"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + s2().args(["fence", &uri, "my-token"]) + .assert() + .success() + .stderr(predicate::str::contains("@")); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn append_with_fencing_token() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-fence-append"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + s2().args(["fence", &uri, "writer-1"]).assert().success(); + + s2().args([ + "append", + &uri, + "--format", + "text", + "--input", + "-", + "--fencing-token", + "writer-1", + ]) + .write_stdin("fenced record\n") + .assert() + .success(); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn list_basins_with_start_after() { + s2().args([ + "list-basins", + "--start-after", + "a", + "--limit", + "5", + "--no-auto-paginate", + ]) + .assert() + .success(); +} + +#[test] +#[serial] +fn list_streams_with_start_after() { + let basin = ensure_test_basin("test-cli-streams"); + s2().args([ + "list-streams", + &basin, + "--start-after", + "a", + "--limit", + "5", + "--no-auto-paginate", + ]) + .assert() + .success(); +} + +#[test] +#[serial] +fn create_basin_with_storage_class() { + let basin = unique_name("test-cli-basin-sc"); + + let output = s2() + .args(["create-basin", &basin, "--storage-class", "express"]) + .output() + .unwrap(); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("tier") || stderr.contains("unavailable") { + return; + } + panic!("create-basin failed: {stderr}"); + } + + wait_for_basin(&basin); + + s2().args(["get-basin-config", &basin]) + .assert() + .success() + .stdout(predicate::str::contains("express").or(predicate::str::contains("Express"))); + + cleanup_basin(&basin); +} + +#[test] +#[serial] +fn create_basin_with_timestamping() { + let basin = unique_name("test-cli-basin-ts"); + + s2().args([ + "create-basin", + &basin, + "--timestamping-mode", + "client-require", + ]) + .assert() + .success(); + + wait_for_basin(&basin); + cleanup_basin(&basin); +} + +#[test] +#[serial] +fn create_basin_with_create_stream_on_read() { + let basin = unique_name("test-cli-basin-csor"); + + s2().args(["create-basin", &basin, "--create-stream-on-read"]) + .assert() + .success(); + + wait_for_basin(&basin); + + s2().args(["get-basin-config", &basin]) + .assert() + .success() + .stdout(predicate::str::contains("create_stream_on_read")); + + cleanup_basin(&basin); +} + +#[test] +#[serial] +fn create_stream_with_storage_class() { + let basin = ensure_test_basin("test-cli-streams"); + let stream = unique_name("test-stream-sc"); + let uri = format!("s2://{basin}/{stream}"); + + let output = s2() + .args(["create-stream", &uri, "--storage-class", "express"]) + .output() + .unwrap(); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("tier") || stderr.contains("unavailable") { + return; + } + panic!("create-stream failed: {stderr}"); + } + + s2().args(["get-stream-config", &uri]) + .assert() + .success() + .stdout(predicate::str::contains("express").or(predicate::str::contains("Express"))); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn create_stream_with_timestamping() { + let basin = ensure_test_basin("test-cli-streams"); + let stream = unique_name("test-stream-ts"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args([ + "create-stream", + &uri, + "--timestamping-mode", + "client-prefer", + ]) + .assert() + .success(); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn append_with_match_seq_num() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-match"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + s2().args([ + "append", + &uri, + "--format", + "text", + "--input", + "-", + "--match-seq-num", + "0", + ]) + .write_stdin("first record\n") + .assert() + .success(); + + s2().args([ + "append", + &uri, + "--format", + "text", + "--input", + "-", + "--match-seq-num", + "0", + ]) + .write_stdin("should fail\n") + .assert() + .failure(); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn append_and_read_json_base64() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-b64"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + s2().args(["append", &uri, "--format", "json-base64", "--input", "-"]) + .write_stdin("{\"body\": \"aGVsbG8gd29ybGQ=\"}\n") + .assert() + .success(); + + s2().args([ + "read", + &uri, + "--seq-num", + "0", + "--count", + "1", + "--format", + "json-base64", + ]) + .assert() + .success() + .stdout(predicate::str::contains("aGVsbG8gd29ybGQ=")); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn read_with_bytes_limit() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-bytes"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + let temp = tempfile::TempDir::new().unwrap(); + let input = temp.path().join("input.txt"); + { + let mut f = std::fs::File::create(&input).unwrap(); + for i in 1..=100 { + writeln!(f, "record number {i}").unwrap(); + } + } + + s2().args([ + "append", + &uri, + "--format", + "text", + "--input", + input.to_str().unwrap(), + ]) + .assert() + .success(); + + s2().args([ + "read", + &uri, + "--seq-num", + "0", + "--bytes", + "50", + "--format", + "text", + ]) + .assert() + .success(); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn read_with_ago() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-ago"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + s2().args(["append", &uri, "--format", "text", "--input", "-"]) + .write_stdin("recent record\n") + .assert() + .success(); + + s2().args([ + "read", &uri, "--ago", "1h", "--count", "1", "--format", "text", + ]) + .assert() + .success() + .stdout(predicate::str::contains("recent record")); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn read_to_file() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-file"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + s2().args(["append", &uri, "--format", "text", "--input", "-"]) + .write_stdin("file output test\n") + .assert() + .success(); + + let temp = tempfile::TempDir::new().unwrap(); + let output = temp.path().join("output.txt"); + + s2().args([ + "read", + &uri, + "--seq-num", + "0", + "--count", + "1", + "--format", + "text", + "--output", + output.to_str().unwrap(), + ]) + .assert() + .success(); + + let content = std::fs::read_to_string(&output).unwrap(); + assert!(content.contains("file output test")); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn append_wrong_fencing_token_fails() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-wrong-fence"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + s2().args(["fence", &uri, "correct-token"]) + .assert() + .success(); + + s2().args([ + "append", + &uri, + "--format", + "text", + "--input", + "-", + "--fencing-token", + "wrong-token", + ]) + .write_stdin("should fail\n") + .assert() + .failure(); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn reconfigure_basin_storage_class() { + let basin = unique_name("test-cli-basin-reconfig-sc"); + + s2().args(["create-basin", &basin]).assert().success(); + wait_for_basin(&basin); + + let output = s2() + .args(["reconfigure-basin", &basin, "--storage-class", "express"]) + .output() + .unwrap(); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("tier") || stderr.contains("unavailable") { + cleanup_basin(&basin); + return; + } + cleanup_basin(&basin); + panic!("reconfigure-basin failed: {stderr}"); + } + + s2().args(["get-basin-config", &basin]) + .assert() + .success() + .stdout(predicate::str::contains("express").or(predicate::str::contains("Express"))); + + cleanup_basin(&basin); +} + +#[test] +#[serial] +fn reconfigure_stream_timestamping() { + let basin = ensure_test_basin("test-cli-streams"); + let stream = unique_name("test-stream-reconfig-ts"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + s2().args(["reconfigure-stream", &uri, "--timestamping-mode", "arrival"]) + .assert() + .success(); + + s2().args(["get-stream-config", &uri]) + .assert() + .success() + .stdout(predicate::str::contains("arrival").or(predicate::str::contains("Arrival"))); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn read_with_timestamp() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-ts-read"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + s2().args(["append", &uri, "--format", "text", "--input", "-"]) + .write_stdin("timestamp test record\n") + .assert() + .success(); + + s2().args([ + "read", + &uri, + "--timestamp", + "0", + "--count", + "1", + "--format", + "text", + ]) + .assert() + .success() + .stdout(predicate::str::contains("timestamp test record")); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn trim_with_fencing_token() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-trim-fence"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + let temp = tempfile::TempDir::new().unwrap(); + let input = temp.path().join("input.txt"); + { + let mut f = std::fs::File::create(&input).unwrap(); + for i in 1..=5 { + writeln!(f, "record {i}").unwrap(); + } + } + + s2().args([ + "append", + &uri, + "--format", + "text", + "--input", + input.to_str().unwrap(), + ]) + .assert() + .success(); + + s2().args(["fence", &uri, "trim-token"]).assert().success(); + + s2().args(["trim", &uri, "3", "--fencing-token", "trim-token"]) + .assert() + .success(); + + s2().args(["trim", &uri, "4", "--fencing-token", "wrong-token"]) + .assert() + .failure(); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn fence_with_existing_token() { + let basin = ensure_test_basin("test-cli-data"); + let stream = unique_name("test-data-fence-existing"); + let uri = format!("s2://{basin}/{stream}"); + + s2().args(["create-stream", &uri]).assert().success(); + + s2().args(["fence", &uri, "token-v1"]).assert().success(); + + s2().args(["fence", &uri, "token-v2", "--fencing-token", "token-v1"]) + .assert() + .success(); + + s2().args(["fence", &uri, "token-v3", "--fencing-token", "wrong-token"]) + .assert() + .failure(); + + cleanup_stream(&basin, &stream); +} + +#[test] +#[serial] +fn bench_stream() { + let basin = ensure_test_basin("test-cli-data"); + + s2().args([ + "bench", + &basin, + "--duration", + "1s", + "--target-mibps", + "1", + "--catchup-delay", + "0s", + ]) + .assert() + .success(); +} diff --git a/cli/tests/lite_wal.rs b/cli/tests/lite_wal.rs new file mode 100644 index 00000000..609ec24d --- /dev/null +++ b/cli/tests/lite_wal.rs @@ -0,0 +1,227 @@ +use std::{ + fs::{self, File}, + io::{Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + path::Path, + process::{Child, Command as ProcessCommand, Stdio}, + time::{Duration, Instant}, +}; + +use assert_cmd::Command; +use tempfile::TempDir; + +fn base_command(home: &Path) -> ProcessCommand { + let mut cmd = ProcessCommand::new(assert_cmd::cargo::cargo_bin!("s2")); + cmd.env_clear() + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("APPDATA", home) + .env("USERPROFILE", home) + .env("NO_COLOR", "1") + .env("RUST_LOG", "warn"); + cmd +} + +fn command(home: &Path) -> Command { + let mut cmd = Command::from_std(base_command(home)); + cmd.timeout(Duration::from_secs(30)); + cmd +} + +#[test] +fn wal_store_requires_a_persistent_main_store_and_one_backend() { + let home = tempfile::tempdir().unwrap(); + for args in [ + vec!["lite", "--wal-bucket", "wal"], + vec!["lite", "--wal-local-root", "wal"], + vec![ + "lite", + "--bucket", + "main", + "--wal-bucket", + "wal", + "--wal-local-root", + "wal", + ], + vec!["lite", "--bucket", "main", "--local-root", "main"], + ] { + command(home.path()).args(args).assert().failure().code(2); + } + command(home.path()) + .env("S2LITE_WAL_BUCKET", "wal") + .args(["lite"]) + .assert() + .failure() + .code(2); +} + +struct Server { + child: Child, + address: SocketAddr, +} + +impl Server { + fn start(root: &Path, attempt: &str) -> Self { + for bind_attempt in 0.. { + let log_path = root.join(format!("server-{attempt}-{bind_attempt}.log")); + match Self::spawn(root, &log_path) { + Ok(server) => return server, + Err(log) if bind_attempt < 5 && log.contains("Address already in use") => { + continue; + } + Err(log) => panic!("Server exited before becoming healthy: {log}"), + } + } + unreachable!() + } + + fn spawn(root: &Path, log_path: &Path) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + let log = File::create(log_path).unwrap(); + let mut cmd = base_command(root); + cmd.args([ + "lite", + "--path", + "db", + "--port", + &address.port().to_string(), + "--local-root", + ]) + .arg(root.join("main")) + .env("S2LITE_WAL_LOCAL_ROOT", root.join("wal")) + .env("SL8_FLUSH_INTERVAL", "1ms") + .env("SL8_L0_SST_SIZE_BYTES", "65536"); + let child = cmd + .stdin(Stdio::null()) + .stdout(log.try_clone().unwrap()) + .stderr(log) + .spawn() + .unwrap(); + let mut server = Self { child, address }; + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if server.child.try_wait().unwrap().is_some() { + return Err(fs::read_to_string(log_path).unwrap()); + } + if let Ok(mut stream) = TcpStream::connect_timeout(&address, Duration::from_millis(100)) + { + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(1))) + .unwrap(); + let mut response = [0; 256]; + if stream + .write_all( + b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ) + .is_ok() + && let Ok(n) = stream.read(&mut response) + && response[..n].starts_with(b"HTTP/1.1 200") + { + return Ok(server); + } + } + assert!( + Instant::now() < deadline, + "Server did not become healthy: {}", + fs::read_to_string(log_path).unwrap() + ); + std::thread::sleep(Duration::from_millis(25)); + } + } + + fn client(&self, root: &Path, args: &[&str], input: Option<&str>) -> String { + let endpoint = format!("http://{}", self.address); + let mut cmd = command(root); + cmd.env("S2_ACCOUNT_ENDPOINT", &endpoint) + .env("S2_BASIN_ENDPOINT", &endpoint) + .env("S2_ACCESS_TOKEN", "test") + .args(args); + if let Some(input) = input { + cmd.write_stdin(input); + } + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() + } + + fn crash(&mut self) { + self.child.kill().unwrap(); + self.child.wait().unwrap(); + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn has_sst(path: &Path) -> bool { + fs::read_dir(path).is_ok_and(|entries| { + entries + .flatten() + .any(|entry| entry.path().extension().is_some_and(|ext| ext == "sst")) + }) +} + +#[test] +fn separate_wal_routes_files_and_recovers_acknowledged_records_after_restart() { + let root: TempDir = tempfile::tempdir().unwrap(); + let mut server = Server::start(root.path(), "before"); + server.client(root.path(), &["create-basin", "wal-test"], None); + server.client( + root.path(), + &["create-stream", "s2://wal-test/recovery"], + None, + ); + let mut input = (0..64) + .map(|i| format!("record-{i}-{}\n", "x".repeat(4096))) + .collect::(); + server.client( + root.path(), + &["append", "s2://wal-test/recovery"], + Some(&input), + ); + let deadline = Instant::now() + Duration::from_secs(10); + while !has_sst(&root.path().join("main/db/compacted")) { + assert!(Instant::now() < deadline, "Main-store SST was not flushed"); + std::thread::sleep(Duration::from_millis(25)); + } + // Leave a final small write in the WAL after the earlier data reaches L0. + let last = "last-acknowledged-record\n"; + server.client( + root.path(), + &["append", "s2://wal-test/recovery"], + Some(last), + ); + input.push_str(last); + assert!(has_sst(&root.path().join("wal/db/wal"))); + assert!(!root.path().join("main/db/wal").exists()); + assert!(!root.path().join("wal/db/compacted").exists()); + assert!(!root.path().join("wal/db/manifest").exists()); + server.crash(); + let restarted = Server::start(root.path(), "after"); + let read = restarted.client( + root.path(), + &[ + "read", + "s2://wal-test/recovery", + "--seq-num", + "0", + "--count", + "65", + ], + None, + ); + assert_eq!(read, input); +} diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..694131b2 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,55 @@ +[changelog] +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +footer = """ + +""" +trim = true +postprocessors = [ + { pattern = '', replace = "https://github.com/s2-streamstore/s2" }, +] + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +commit_preprocessors = [ + # Fix malformed breaking change format: "feat!(scope):" -> "feat(scope)!:" + { pattern = '^(\w+)!\((\w+)\):', replace = "${1}(${2})!:" }, + { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, +] +commit_parsers = [ + { message = "^feat", group = " Features" }, + { message = "^fix", group = " Bug Fixes" }, + { message = "^doc", group = " Documentation" }, + { message = "^perf", group = " Performance" }, + { message = "^refactor", group = " Refactor" }, + { message = "^style", group = " Styling" }, + { message = "^test", group = " Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore|^ci", group = " Miscellaneous Tasks" }, + { body = ".*security", group = " Security" }, + { message = "^revert", group = " Revert" }, +] +filter_commits = false +topo_order = false +sort_commits = "oldest" diff --git a/common/CHANGELOG.md b/common/CHANGELOG.md new file mode 100644 index 00000000..8fa9a24a --- /dev/null +++ b/common/CHANGELOG.md @@ -0,0 +1,348 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.42.0] - 2026-09-25 + +### Features + +- [**breaking**] Expose storage classes as strings and in location responses ([#775](https://github.com/s2-streamstore/s2/issues/775)) + + + +## [0.41.3] - 2026-09-22 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.41.2] - 2026-09-10 + +### Bug Fixes + +- Reject NUL bytes in stream names and access token IDs ([#728](https://github.com/s2-streamstore/s2/issues/728)) + + + +## [0.41.1] - 2026-07-22 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.41.0] - 2026-07-07 + +### Miscellaneous Tasks + +- Remove unused From for BasinNamePrefix impl ([#622](https://github.com/s2-streamstore/s2/issues/622)) +- Remove unused Record::sequenced method ([#623](https://github.com/s2-streamstore/s2/issues/623)) + + + +## [0.40.1] - 2026-07-02 + +### Miscellaneous Tasks + +- Remove unused From for Option ([#594](https://github.com/s2-streamstore/s2/issues/594)) + + + +## [0.40.0] - 2026-06-22 + +### Features + +- Make access token expiry semantics explicit ([#574](https://github.com/s2-streamstore/s2/issues/574)) + +### Miscellaneous Tasks + +- Remove unused Metered::reserve method ([#571](https://github.com/s2-streamstore/s2/issues/571)) +- Remove COMMAND_ID_FENCE and COMMAND_ID_TRIM unused constants ([#570](https://github.com/s2-streamstore/s2/issues/570)) +- Remove unused `opt_or_default_mut` from `Maybe` ([#567](https://github.com/s2-streamstore/s2/issues/567)) +- Remove unused From-into-Reconfiguration impls in common config ([#566](https://github.com/s2-streamstore/s2/issues/566)) + + + +## [0.39.1] - 2026-06-15 + +### Bug Fixes + +- Reject zero retention in resource specs ([#544](https://github.com/s2-streamstore/s2/issues/544)) + + + +## [0.39.0] - 2026-06-12 + +### Refactor + +- Split storage internals from common ([#533](https://github.com/s2-streamstore/s2/issues/533)) + +### Testing + +- Add unit tests for low-coverage modules ([#515](https://github.com/s2-streamstore/s2/issues/515)) + + + +## [0.38.0] - 2026-06-10 + +### Features + +- Allow list cursors before prefix ([#448](https://github.com/s2-streamstore/s2/issues/448)) + + + +## [0.37.0] - 2026-05-20 + +### Refactor + +- [**breaking**] Rename scope -> location, treat it as a string; add related RPCs ([#485](https://github.com/s2-streamstore/s2/issues/485)) + + + +## [0.36.2] - 2026-05-19 + +### Features + +- Expose `ensure_*` ops ([#471](https://github.com/s2-streamstore/s2/issues/471)) + + + +## [0.36.1] - 2026-05-19 + +### Bug Fixes + +- Resolve lite stream config reconfigure defaults ([#465](https://github.com/s2-streamstore/s2/issues/465)) + + + +## [0.36.0] - 2026-05-14 + +### Bug Fixes + +- Reject empty envelope header names on decode ([#446](https://github.com/s2-streamstore/s2/issues/446)) +- Clarify PUT ensure semantics ([#450](https://github.com/s2-streamstore/s2/issues/450)) + + + +## [0.35.0] - 2026-05-10 + +### Refactor + +- Introduce resource-specific create intents ([#437](https://github.com/s2-streamstore/s2/issues/437)) + + + +## [0.34.0] - 2026-05-04 + +### Features + +- Add aws:us-west-2 and aws:eu-north-1 basin scopes ([#430](https://github.com/s2-streamstore/s2/issues/430)) + + + +## [0.33.1] - 2026-04-27 + +### Refactor + +- Use `strum` instead of `enum_ordinalize` ([#426](https://github.com/s2-streamstore/s2/issues/426)) + + + +## [0.33.0] - 2026-04-22 + +### Bug Fixes + +- Model record limits as max assignable sequence numbers ([#417](https://github.com/s2-streamstore/s2/issues/417)) + +### Documentation + +- Encryption key header ([#416](https://github.com/s2-streamstore/s2/issues/416)) + + + +## [0.32.1] - 2026-04-21 + +### Bug Fixes + +- Cap random-nonce encrypted stream messages ([#412](https://github.com/s2-streamstore/s2/issues/412)) + + + +## [0.32.0] - 2026-04-20 + +### Refactor + +- [**breaking**] Replace encryption modes with stream cipher metadata and key-only headers ([#403](https://github.com/s2-streamstore/s2/issues/403)) + + + +## [0.31.2] - 2026-04-17 + +### Bug Fixes + +- Dashed string repr for AEGIS-256 and AES-256-GCM modes ([#400](https://github.com/s2-streamstore/s2/issues/400)) + + + +## [0.31.1] - 2026-04-15 + +### Refactor + +- Remove implicit optional config resolution ([#389](https://github.com/s2-streamstore/s2/issues/389)) + + + +## [0.31.0] - 2026-04-14 + +### Features + +- Add EnumString and IntoStaticStr derives to BasinScope ([#344](https://github.com/s2-streamstore/s2/issues/344)) +- Request-time data encryption ([#349](https://github.com/s2-streamstore/s2/issues/349)) +- Enforce allowed encryption modes via stream config ([#376](https://github.com/s2-streamstore/s2/issues/376)) + +### Bug Fixes + +- Return error instead of panicking on truncated record bytes ([#362](https://github.com/s2-streamstore/s2/issues/362)) + +### Refactor + +- Clarify encryption spec, mode, and format semantics ([#375](https://github.com/s2-streamstore/s2/issues/375)) + +### Testing + +- Strengthen encrypted record test coverage ([#372](https://github.com/s2-streamstore/s2/issues/372)) +- Exercise encrypted append input coverage ([#373](https://github.com/s2-streamstore/s2/issues/373)) +- Simplify backend integration tests and tighten read coverage ([#374](https://github.com/s2-streamstore/s2/issues/374)) + + + +## [0.30.0] - 2026-03-20 + +### Features + +- Align basin info with stream info ([#338](https://github.com/s2-streamstore/s2/issues/338)) + + + +## [0.29.0] - 2026-03-19 + +### Refactor + +- Remove basin creating state ([#333](https://github.com/s2-streamstore/s2/issues/333)) + + + +## [0.28.3] - 2026-03-15 + +### Bug Fixes + +- Reject . and .. as access token and stream names ([#318](https://github.com/s2-streamstore/s2/issues/318)) + + + +## [0.28.2] - 2026-03-03 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.28.1] - 2026-02-15 + +### Miscellaneous Tasks + +- Add crate-level doc comment ([#213](https://github.com/s2-streamstore/s2/issues/213)) + + + +## [0.28.0] - 2026-02-15 + +### Bug Fixes + +- [**breaking**] Harden Bash hashing ([#207](https://github.com/s2-streamstore/s2/issues/207)) + + + +## [0.27.5] - 2026-02-12 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.27.4] - 2026-02-06 + +### Documentation + +- Document config type tiers ([#188](https://github.com/s2-streamstore/s2/issues/188)) + + + +## [0.27.3] - 2026-02-05 + +### Miscellaneous Tasks + +- Rejig versioning and release workflow ([#163](https://github.com/s2-streamstore/s2/issues/163)) + + + +## [0.27.2] - 2026-02-05 + + + +## [0.27.1] - 2026-02-04 + + + +## [0.27.0] - 2026-02-03 + +### Features + +- *(lite)* [**breaking**] Bgtasks for basin/stream deletion and stream trimming ([#148](https://github.com/s2-streamstore/s2/issues/148)) + + + +## [0.26.9] - 2026-02-02 + + + +## [0.26.8] - 2026-01-30 + +### Bug Fixes + +- Add hash impl for `StorageClass` ([#145](https://github.com/s2-streamstore/s2/issues/145)) + + + +## [0.26.7] - 2026-01-30 + + + +## [0.26.6] - 2026-01-30 + + + +## [0.26.5] - 2026-01-30 + + + +## [0.26.4] - 2026-01-29 + + + +## [0.26.3] - 2026-01-29 + + + +## [0.26.2] - 2026-01-29 + + + +## [0.26.1] - 2026-01-29 + + diff --git a/common/Cargo.toml b/common/Cargo.toml new file mode 100644 index 00000000..acdd4354 --- /dev/null +++ b/common/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "s2-common" +version = "0.42.0" +description = "Common stuff for client and servers for S2, the durable streams API" +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords = ["s2", "durable", "streams", "log", "api"] + +[features] +axum = ["dep:axum"] +clap = ["dep:clap"] +rkyv = ["dep:rkyv", "compact_str/rkyv"] +utoipa = ["dep:utoipa"] + +[dependencies] +axum = { workspace = true, optional = true } +base64ct = { workspace = true, features = ["alloc"] } +bytes = { workspace = true } +clap = { workspace = true, optional = true, features = ["derive"] } +compact_str = { workspace = true, features = ["serde"] } +enumset = { workspace = true } +http = { workspace = true } +rand = { workspace = true } +rkyv = { workspace = true, optional = true } +secrecy = { workspace = true } +serde = { workspace = true, features = ["derive"] } +strum = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +time = { workspace = true } +utoipa = { workspace = true, optional = true, features = ["time"] } + +[dev-dependencies] +proptest = { workspace = true } +rstest = { workspace = true } +serde_json = { workspace = true } diff --git a/common/src/access.rs b/common/src/access.rs new file mode 100644 index 00000000..5d67b948 --- /dev/null +++ b/common/src/access.rs @@ -0,0 +1,317 @@ +use std::{marker::PhantomData, ops::Deref, str::FromStr}; + +use compact_str::{CompactString, ToCompactString}; +use enumset::{EnumSet, EnumSetType}; + +use super::{ + ValidationError, + basin::{BasinName, BasinNamePrefix}, + stream::{StreamName, StreamNamePrefix}, + strings::{IdProps, PrefixProps, StartAfterProps, StrProps}, +}; +use crate::{caps, resources::ListItemsRequest}; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize) +)] +pub struct AccessTokenIdStr(CompactString, PhantomData); + +impl AccessTokenIdStr { + fn validate_str(id: &str) -> Result<(), ValidationError> { + if !T::IS_PREFIX && id.is_empty() { + return Err(format!("access token {} must not be empty", T::FIELD_NAME).into()); + } + + if !T::IS_PREFIX && (id == "." || id == "..") { + return Err( + format!("access token {} must not be \".\" or \"..\"", T::FIELD_NAME).into(), + ); + } + + if id.contains('\0') { + return Err( + format!("access token {} must not contain NUL bytes", T::FIELD_NAME).into(), + ); + } + + if id.len() > caps::MAX_ACCESS_TOKEN_ID_LEN { + return Err(format!( + "access token {} must not exceed {} bytes in length", + T::FIELD_NAME, + caps::MAX_ACCESS_TOKEN_ID_LEN + ) + .into()); + } + + Ok(()) + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::PartialSchema for AccessTokenIdStr +where + T: StrProps, +{ + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::Object::builder() + .schema_type(utoipa::openapi::Type::String) + .min_length((!T::IS_PREFIX).then_some(1)) + .max_length(Some(caps::MAX_ACCESS_TOKEN_ID_LEN)) + .into() + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::ToSchema for AccessTokenIdStr where T: StrProps {} + +impl serde::Serialize for AccessTokenIdStr { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de, T: StrProps> serde::Deserialize<'de> for AccessTokenIdStr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = CompactString::deserialize(deserializer)?; + s.try_into().map_err(serde::de::Error::custom) + } +} + +impl AsRef for AccessTokenIdStr { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for AccessTokenIdStr { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom for AccessTokenIdStr { + type Error = ValidationError; + + fn try_from(name: CompactString) -> Result { + Self::validate_str(&name)?; + Ok(Self(name, PhantomData)) + } +} + +impl FromStr for AccessTokenIdStr { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + Self::validate_str(s)?; + Ok(Self(s.to_compact_string(), PhantomData)) + } +} + +impl std::fmt::Debug for AccessTokenIdStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::fmt::Display for AccessTokenIdStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From> for CompactString { + fn from(value: AccessTokenIdStr) -> Self { + value.0 + } +} + +pub type AccessTokenId = AccessTokenIdStr; + +pub type AccessTokenIdPrefix = AccessTokenIdStr; + +impl Default for AccessTokenIdPrefix { + fn default() -> Self { + AccessTokenIdStr(CompactString::default(), PhantomData) + } +} + +impl From for AccessTokenIdPrefix { + fn from(value: AccessTokenId) -> Self { + Self(value.0, PhantomData) + } +} + +pub type AccessTokenIdStartAfter = AccessTokenIdStr; + +impl Default for AccessTokenIdStartAfter { + fn default() -> Self { + AccessTokenIdStr(CompactString::default(), PhantomData) + } +} + +impl From for AccessTokenIdStartAfter { + fn from(value: AccessTokenId) -> Self { + Self(value.0, PhantomData) + } +} + +#[derive(Debug, Hash, EnumSetType, strum::EnumCount)] +pub enum Operation { + ListBasins = 1, + CreateBasin = 2, + DeleteBasin = 3, + ReconfigureBasin = 4, + GetBasinConfig = 5, + IssueAccessToken = 6, + RevokeAccessToken = 7, + ListAccessTokens = 8, + ListStreams = 9, + CreateStream = 10, + DeleteStream = 11, + GetStreamConfig = 12, + ReconfigureStream = 13, + CheckTail = 14, + Append = 15, + Read = 16, + Trim = 17, + Fence = 18, + AccountMetrics = 19, + BasinMetrics = 20, + StreamMetrics = 21, + ListLocations = 22, + GetDefaultLocation = 23, + SetDefaultLocation = 24, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize) +)] +pub enum ResourceSet { + #[default] + None, + Exact(E), + Prefix(P), +} + +pub type BasinResourceSet = ResourceSet; +pub type StreamResourceSet = ResourceSet; +pub type AccessTokenResourceSet = ResourceSet; + +#[derive(Debug, Clone, Copy, Default)] +pub struct ReadWritePermissions { + pub read: bool, + pub write: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct PermittedOperationGroups { + pub account: ReadWritePermissions, + pub basin: ReadWritePermissions, + pub stream: ReadWritePermissions, +} + +#[derive(Debug, Clone, Default)] +pub struct AccessTokenScope { + pub basins: BasinResourceSet, + pub streams: StreamResourceSet, + pub access_tokens: AccessTokenResourceSet, + pub op_groups: PermittedOperationGroups, + pub ops: EnumSet, +} + +#[derive(Debug, Clone)] +pub struct AccessTokenInfo { + pub id: AccessTokenId, + pub expires_at: Option, + pub auto_prefix_streams: bool, + pub scope: AccessTokenScope, +} + +#[derive(Debug, Clone)] +pub struct IssueAccessTokenRequest { + pub id: AccessTokenId, + pub expires_at: Option, + pub auto_prefix_streams: bool, + pub scope: AccessTokenScope, +} + +pub type ListAccessTokensRequest = ListItemsRequest; + +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::{ + super::strings::{IdProps, PrefixProps, StartAfterProps}, + AccessTokenIdStr, + }; + + #[rstest] + #[case::normal("my-token".to_owned())] + #[case::max_len("a".repeat(crate::caps::MAX_ACCESS_TOKEN_ID_LEN))] + fn validate_id_ok(#[case] id: String) { + assert_eq!(AccessTokenIdStr::::validate_str(&id), Ok(())); + } + + #[rstest] + #[case::empty("".to_owned())] + #[case::dot(".".to_owned())] + #[case::dot_dot("..".to_owned())] + #[case::too_long("a".repeat(crate::caps::MAX_ACCESS_TOKEN_ID_LEN + 1))] + #[case::nul("a\0b".to_owned())] + fn validate_id_err(#[case] id: String) { + AccessTokenIdStr::::validate_str(&id).expect_err("expected validation error"); + } + + #[rstest] + #[case::empty("".to_owned())] + #[case::dot(".".to_owned())] + #[case::dot_dot("..".to_owned())] + #[case::max_len("a".repeat(crate::caps::MAX_ACCESS_TOKEN_ID_LEN))] + fn validate_prefix_ok(#[case] prefix: String) { + assert_eq!( + AccessTokenIdStr::::validate_str(&prefix), + Ok(()) + ); + } + + #[rstest] + #[case::too_long("a".repeat(crate::caps::MAX_ACCESS_TOKEN_ID_LEN + 1))] + #[case::nul("a\0b".to_owned())] + fn validate_prefix_err(#[case] prefix: String) { + AccessTokenIdStr::::validate_str(&prefix) + .expect_err("expected validation error"); + } + + #[rstest] + #[case::empty("".to_owned())] + #[case::dot(".".to_owned())] + #[case::dot_dot("..".to_owned())] + #[case::max_len("a".repeat(crate::caps::MAX_ACCESS_TOKEN_ID_LEN))] + fn validate_start_after_ok(#[case] start_after: String) { + assert_eq!( + AccessTokenIdStr::::validate_str(&start_after), + Ok(()) + ); + } + + #[rstest] + #[case::too_long("a".repeat(crate::caps::MAX_ACCESS_TOKEN_ID_LEN + 1))] + #[case::nul("a\0b".to_owned())] + fn validate_start_after_err(#[case] start_after: String) { + AccessTokenIdStr::::validate_str(&start_after) + .expect_err("expected validation error"); + } +} diff --git a/common/src/basin.rs b/common/src/basin.rs new file mode 100644 index 00000000..9538ce4f --- /dev/null +++ b/common/src/basin.rs @@ -0,0 +1,277 @@ +use std::{marker::PhantomData, ops::Deref, str::FromStr}; + +use compact_str::{CompactString, ToCompactString}; +use time::OffsetDateTime; + +use super::{ + ValidationError, + location::LocationName, + strings::{NameProps, PrefixProps, StartAfterProps, StrProps}, +}; +use crate::{caps, resources::ListItemsRequest}; + +pub static BASIN_HEADER: http::HeaderName = http::HeaderName::from_static("s2-basin"); + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize) +)] +pub struct BasinNameStr(CompactString, PhantomData); + +impl BasinNameStr { + fn validate_str(name: &str) -> Result<(), ValidationError> { + if name.len() > caps::MAX_BASIN_NAME_LEN { + return Err(format!( + "basin {} must not exceed {} bytes in length", + T::FIELD_NAME, + caps::MAX_BASIN_NAME_LEN + ) + .into()); + } + + if !T::IS_PREFIX && name.len() < caps::MIN_BASIN_NAME_LEN { + return Err(format!( + "basin {} should be at least {} bytes in length", + T::FIELD_NAME, + caps::MIN_BASIN_NAME_LEN + ) + .into()); + } + + let mut chars = name.chars(); + + let Some(first_char) = chars.next() else { + return Ok(()); + }; + + if !first_char.is_ascii_lowercase() && !first_char.is_ascii_digit() { + return Err(format!( + "basin {} must begin with a lowercase letter or number", + T::FIELD_NAME + ) + .into()); + } + + if !T::IS_PREFIX + && let Some(last_char) = chars.next_back() + && !last_char.is_ascii_lowercase() + && !last_char.is_ascii_digit() + { + return Err(format!( + "basin {} must end with a lowercase letter or number", + T::FIELD_NAME + ) + .into()); + } + + if chars.any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-') { + return Err(format!( + "basin {} must comprise lowercase letters, numbers, and hyphens", + T::FIELD_NAME + ) + .into()); + } + + Ok(()) + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::PartialSchema for BasinNameStr +where + T: StrProps, +{ + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::Object::builder() + .schema_type(utoipa::openapi::Type::String) + .min_length((!T::IS_PREFIX).then_some(caps::MIN_BASIN_NAME_LEN)) + .max_length(Some(caps::MAX_BASIN_NAME_LEN)) + .into() + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::ToSchema for BasinNameStr where T: StrProps {} + +impl serde::Serialize for BasinNameStr { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de, T: StrProps> serde::Deserialize<'de> for BasinNameStr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = CompactString::deserialize(deserializer)?; + s.try_into().map_err(serde::de::Error::custom) + } +} + +impl AsRef for BasinNameStr { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for BasinNameStr { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom for BasinNameStr { + type Error = ValidationError; + + fn try_from(name: CompactString) -> Result { + Self::validate_str(&name)?; + Ok(Self(name, PhantomData)) + } +} + +impl FromStr for BasinNameStr { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + Self::validate_str(s)?; + Ok(Self(s.to_compact_string(), PhantomData)) + } +} + +impl std::fmt::Debug for BasinNameStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::fmt::Display for BasinNameStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From> for CompactString { + fn from(value: BasinNameStr) -> Self { + value.0 + } +} + +pub type BasinName = BasinNameStr; + +pub type BasinNamePrefix = BasinNameStr; + +impl Default for BasinNamePrefix { + fn default() -> Self { + BasinNameStr(CompactString::default(), PhantomData) + } +} + +impl From for BasinNamePrefix { + fn from(value: BasinName) -> Self { + Self(value.0, PhantomData) + } +} + +pub type BasinNameStartAfter = BasinNameStr; + +impl Default for BasinNameStartAfter { + fn default() -> Self { + BasinNameStr(CompactString::default(), PhantomData) + } +} + +impl From for BasinNameStartAfter { + fn from(value: BasinName) -> Self { + Self(value.0, PhantomData) + } +} + +impl crate::http::ParseableHeader for BasinName { + fn name() -> &'static http::HeaderName { + &BASIN_HEADER + } +} + +pub type ListBasinsRequest = ListItemsRequest; + +#[derive(Debug, Clone)] +pub struct BasinInfo { + pub name: BasinName, + pub location: Option, + pub created_at: OffsetDateTime, + pub deleted_at: Option, +} + +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::{BasinNameStr, NameProps, PrefixProps, StartAfterProps}; + + #[rstest] + #[case::min_len("abcdefgh".to_owned())] + #[case::starts_with_digit("1abcdefg".to_owned())] + #[case::contains_hyphen("abcd-efg".to_owned())] + #[case::max_len("a".repeat(crate::caps::MAX_BASIN_NAME_LEN))] + fn validate_name_ok(#[case] name: String) { + assert_eq!(BasinNameStr::::validate_str(&name), Ok(())); + } + + #[rstest] + #[case::too_long("a".repeat(crate::caps::MAX_BASIN_NAME_LEN + 1))] + #[case::too_short("abcdefg".to_owned())] + #[case::empty("".to_owned())] + #[case::invalid_first_char("Abcdefgh".to_owned())] + #[case::invalid_last_char("abcdefg-".to_owned())] + #[case::invalid_characters("abcd_efg".to_owned())] + #[case::nul("abcd\0efg".to_owned())] + fn validate_name_err(#[case] name: String) { + BasinNameStr::::validate_str(&name).expect_err("expected validation error"); + } + + #[rstest] + #[case::empty("".to_owned())] + #[case::single_char("a".to_owned())] + #[case::trailing_hyphen("abcdefg-".to_owned())] + #[case::max_len("a".repeat(crate::caps::MAX_BASIN_NAME_LEN))] + fn validate_prefix_ok(#[case] prefix: String) { + assert_eq!(BasinNameStr::::validate_str(&prefix), Ok(())); + } + + #[rstest] + #[case::too_long("a".repeat(crate::caps::MAX_BASIN_NAME_LEN + 1))] + #[case::invalid_first_char("-abc".to_owned())] + #[case::invalid_characters("ab_cd".to_owned())] + #[case::nul("ab\0cd".to_owned())] + fn validate_prefix_err(#[case] prefix: String) { + BasinNameStr::::validate_str(&prefix).expect_err("expected validation error"); + } + + #[rstest] + #[case::empty("".to_owned())] + #[case::single_char("a".to_owned())] + #[case::trailing_hyphen("abcdefg-".to_owned())] + fn validate_start_after_ok(#[case] start_after: String) { + assert_eq!( + BasinNameStr::::validate_str(&start_after), + Ok(()) + ); + } + + #[rstest] + #[case::too_long("a".repeat(crate::caps::MAX_BASIN_NAME_LEN + 1))] + #[case::invalid_first_char("-abc".to_owned())] + #[case::invalid_characters("ab_cd".to_owned())] + #[case::nul("ab\0cd".to_owned())] + fn validate_start_after_err(#[case] start_after: String) { + BasinNameStr::::validate_str(&start_after) + .expect_err("expected validation error"); + } +} diff --git a/common/src/caps.rs b/common/src/caps.rs new file mode 100644 index 00000000..b7955c48 --- /dev/null +++ b/common/src/caps.rs @@ -0,0 +1,16 @@ +pub const MIN_BASIN_NAME_LEN: usize = 8; +pub const MAX_BASIN_NAME_LEN: usize = 48; +pub const MAX_LOCATION_NAME_LEN: usize = 64; + +pub const MIN_STREAM_NAME_LEN: usize = 1; +pub const MAX_STREAM_NAME_LEN: usize = 512; + +pub const MAX_ACCESS_TOKEN_ID_LEN: usize = 96; + +/// All record batches in the system are limited to 1000 records. +/// Batches are limited to a collective size of 1 MiB, which is also the maximum size of a single +/// record. +pub const RECORD_BATCH_MAX: crate::read_extent::CountOrBytes = crate::read_extent::CountOrBytes { + count: 1000, + bytes: 1024 * 1024, +}; diff --git a/common/src/config.rs b/common/src/config.rs new file mode 100644 index 00000000..8fc17e45 --- /dev/null +++ b/common/src/config.rs @@ -0,0 +1,345 @@ +//! Stream and basin configuration types. +//! +//! Stream configuration uses three representations: +//! +//! - Merged (`StreamConfig`, `TimestampingConfig`, `DeleteOnEmptyConfig`): values produced by +//! merging optional configs with defaults using `merge()`. Storage class remains unspecified when +//! neither the stream nor basin supplies one. +//! +//! - Optional (`OptionalStreamConfig`, `OptionalTimestampingConfig`, +//! `OptionalDeleteOnEmptyConfig`): partial configuration layers, where `None` means "not set at +//! this layer; fall back to defaults." +//! +//! - Reconfiguration (`StreamReconfiguration`, `TimestampingReconfiguration`, +//! `DeleteOnEmptyReconfiguration`): PATCH-style updates applied with `reconfigure()`. +//! +//! Reconfiguration of nested fields (e.g. `timestamping`, `delete_on_empty`, +//! `default_stream_config`) is applied recursively: `Specified(Some(inner_reconfig))` +//! applies the inner reconfiguration to the existing value, while `Specified(None)` +//! clears it to the default. +//! +//! `merge()` applies configuration layers with precedence: +//! stream-level → basin-level → field default. +//! +//! Basin config also carries basin-level knobs like `stream_cipher`, +//! `create_stream_on_append`, and `create_stream_on_read`. + +use std::time::Duration; + +use compact_str::CompactString; + +use crate::{ValidationError, encryption::EncryptionAlgorithm, maybe::Maybe}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetentionPolicy { + Age(Duration), + Infinite(), +} + +impl RetentionPolicy { + pub fn age(&self) -> Option { + match self { + Self::Age(duration) => Some(*duration), + Self::Infinite() => None, + } + } + + pub fn validate(self) -> Result { + match self { + Self::Age(duration) if duration.is_zero() => Err(ValidationError( + "age must be greater than 0 seconds".to_string(), + )), + policy => Ok(policy), + } + } +} + +impl Default for RetentionPolicy { + fn default() -> Self { + const ONE_WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60); + + Self::Age(ONE_WEEK) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum TimestampingMode { + #[default] + ClientPrefer, + ClientRequire, + Arrival, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TimestampingConfig { + pub mode: TimestampingMode, + pub uncapped: bool, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct DeleteOnEmptyConfig { + pub min_age: Duration, +} + +impl DeleteOnEmptyConfig { + pub fn min_age(&self) -> Option { + Some(self.min_age).filter(|age| !age.is_zero()) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StreamConfig { + pub storage_class: Option, + pub retention_policy: RetentionPolicy, + pub timestamping: TimestampingConfig, + pub delete_on_empty: DeleteOnEmptyConfig, +} + +#[derive(Debug, Clone, Default)] +pub struct TimestampingReconfiguration { + pub mode: Maybe>, + pub uncapped: Maybe>, +} + +#[derive(Debug, Clone, Default)] +pub struct DeleteOnEmptyReconfiguration { + pub min_age: Maybe>, +} + +#[derive(Debug, Clone, Default)] +pub struct StreamReconfiguration { + pub storage_class: Maybe>, + pub retention_policy: Maybe>, + pub timestamping: Maybe>, + pub delete_on_empty: Maybe>, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct OptionalTimestampingConfig { + pub mode: Option, + pub uncapped: Option, +} + +impl OptionalTimestampingConfig { + pub fn reconfigure(mut self, reconfiguration: TimestampingReconfiguration) -> Self { + if let Maybe::Specified(mode) = reconfiguration.mode { + self.mode = mode; + } + if let Maybe::Specified(uncapped) = reconfiguration.uncapped { + self.uncapped = uncapped; + } + self + } + + pub fn merge(self, basin_defaults: Self) -> TimestampingConfig { + let mode = self.mode.or(basin_defaults.mode).unwrap_or_default(); + let uncapped = self + .uncapped + .or(basin_defaults.uncapped) + .unwrap_or_default(); + TimestampingConfig { mode, uncapped } + } +} + +impl From for TimestampingConfig { + fn from(value: OptionalTimestampingConfig) -> Self { + Self { + mode: value.mode.unwrap_or_default(), + uncapped: value.uncapped.unwrap_or_default(), + } + } +} + +impl From for OptionalTimestampingConfig { + fn from(value: TimestampingConfig) -> Self { + Self { + mode: Some(value.mode), + uncapped: Some(value.uncapped), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OptionalDeleteOnEmptyConfig { + pub min_age: Option, +} + +impl OptionalDeleteOnEmptyConfig { + pub fn reconfigure(mut self, reconfiguration: DeleteOnEmptyReconfiguration) -> Self { + if let Maybe::Specified(min_age) = reconfiguration.min_age { + self.min_age = min_age; + } + self + } + + pub fn merge(self, basin_defaults: Self) -> DeleteOnEmptyConfig { + let min_age = self.min_age.or(basin_defaults.min_age).unwrap_or_default(); + DeleteOnEmptyConfig { min_age } + } +} + +impl From for DeleteOnEmptyConfig { + fn from(value: OptionalDeleteOnEmptyConfig) -> Self { + Self { + min_age: value.min_age.unwrap_or_default(), + } + } +} + +impl From for OptionalDeleteOnEmptyConfig { + fn from(value: DeleteOnEmptyConfig) -> Self { + Self { + min_age: Some(value.min_age), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OptionalStreamConfig { + pub storage_class: Option, + pub retention_policy: Option, + pub timestamping: OptionalTimestampingConfig, + pub delete_on_empty: OptionalDeleteOnEmptyConfig, +} + +impl OptionalStreamConfig { + pub fn validate(&self) -> Result<(), ValidationError> { + if let Some(retention_policy) = self.retention_policy { + retention_policy.validate()?; + } + Ok(()) + } + + pub fn reconfigure(mut self, reconfiguration: StreamReconfiguration) -> Self { + let StreamReconfiguration { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = reconfiguration; + if let Maybe::Specified(storage_class) = storage_class { + self.storage_class = storage_class; + } + if let Maybe::Specified(retention_policy) = retention_policy { + self.retention_policy = retention_policy; + } + if let Maybe::Specified(timestamping) = timestamping { + self.timestamping = timestamping + .map(|ts| self.timestamping.reconfigure(ts)) + .unwrap_or_default(); + } + if let Maybe::Specified(delete_on_empty_reconfig) = delete_on_empty { + self.delete_on_empty = delete_on_empty_reconfig + .map(|reconfig| self.delete_on_empty.reconfigure(reconfig)) + .unwrap_or_default(); + } + self + } + + pub fn merge(self, basin_defaults: Self) -> StreamConfig { + let storage_class = self.storage_class.or(basin_defaults.storage_class); + + let retention_policy = self + .retention_policy + .or(basin_defaults.retention_policy) + .unwrap_or_default(); + + let timestamping = self.timestamping.merge(basin_defaults.timestamping); + + let delete_on_empty = self.delete_on_empty.merge(basin_defaults.delete_on_empty); + + StreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } + } +} + +impl From for StreamConfig { + fn from(value: OptionalStreamConfig) -> Self { + let OptionalStreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = value; + + Self { + storage_class, + retention_policy: retention_policy.unwrap_or_default(), + timestamping: timestamping.into(), + delete_on_empty: delete_on_empty.into(), + } + } +} + +impl From for OptionalStreamConfig { + fn from(value: StreamConfig) -> Self { + let StreamConfig { + storage_class, + retention_policy, + timestamping, + delete_on_empty, + } = value; + + Self { + storage_class, + retention_policy: Some(retention_policy), + timestamping: timestamping.into(), + delete_on_empty: delete_on_empty.into(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BasinConfig { + pub default_stream_config: OptionalStreamConfig, + pub stream_cipher: Option, + pub create_stream_on_append: bool, + pub create_stream_on_read: bool, +} + +impl BasinConfig { + pub fn validate(&self) -> Result<(), ValidationError> { + self.default_stream_config.validate() + } + + pub fn reconfigure(mut self, reconfiguration: BasinReconfiguration) -> Self { + let BasinReconfiguration { + default_stream_config, + stream_cipher, + create_stream_on_append, + create_stream_on_read, + } = reconfiguration; + + if let Maybe::Specified(default_stream_config) = default_stream_config { + self.default_stream_config = default_stream_config + .map(|reconfig| self.default_stream_config.reconfigure(reconfig)) + .unwrap_or_default(); + } + + if let Maybe::Specified(stream_cipher) = stream_cipher { + self.stream_cipher = stream_cipher; + } + + if let Maybe::Specified(create_stream_on_append) = create_stream_on_append { + self.create_stream_on_append = create_stream_on_append; + } + + if let Maybe::Specified(create_stream_on_read) = create_stream_on_read { + self.create_stream_on_read = create_stream_on_read; + } + + self + } +} + +#[derive(Debug, Clone, Default)] +pub struct BasinReconfiguration { + pub default_stream_config: Maybe>, + pub stream_cipher: Maybe>, + pub create_stream_on_append: Maybe, + pub create_stream_on_read: Maybe, +} diff --git a/common/src/deep_size.rs b/common/src/deep_size.rs new file mode 100644 index 00000000..c2dc95ba --- /dev/null +++ b/common/src/deep_size.rs @@ -0,0 +1,155 @@ +pub trait DeepSize { + /// - size_of(primitive) + /// - length for chunks of data like strings and bytes (so not including the container overhead) + /// - deep size of all struct fields + /// - deep size of actual variant for enums + fn deep_size(&self) -> usize; +} + +impl DeepSize for (X, Y) { + fn deep_size(&self) -> usize { + self.0.deep_size() + self.1.deep_size() + } +} + +impl DeepSize for &[T] { + fn deep_size(&self) -> usize { + self.iter().map(DeepSize::deep_size).sum::() + } +} + +impl DeepSize for Vec { + fn deep_size(&self) -> usize { + self.iter().map(DeepSize::deep_size).sum::() + } +} + +impl DeepSize for Option { + fn deep_size(&self) -> usize { + match self { + Some(v) => v.deep_size(), + None => 1, + } + } +} + +impl DeepSize for String { + fn deep_size(&self) -> usize { + self.len() + } +} + +impl DeepSize for bytes::Bytes { + fn deep_size(&self) -> usize { + self.len() + } +} + +impl DeepSize for std::ops::Bound { + fn deep_size(&self) -> usize { + match self { + std::ops::Bound::Included(x) => x.deep_size(), + std::ops::Bound::Excluded(x) => x.deep_size(), + std::ops::Bound::Unbounded => 1, + } + } +} + +macro_rules! impl_deep_size_prim { + ($($t:ty),+) => { + $( + impl DeepSize for $t { + fn deep_size(&self) -> usize { + size_of_val(self) + } + } + )+ + }; +} + +impl_deep_size_prim!(bool, u64, usize, std::num::NonZeroU64); + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + fn string_strategy(max_chars: usize) -> impl Strategy { + prop::collection::vec(any::(), 0..=max_chars) + .prop_map(|chars| chars.into_iter().collect()) + } + + #[test] + fn primitives() { + assert_eq!(42u64.deep_size(), size_of::()); + assert_eq!(true.deep_size(), size_of::()); + assert_eq!(0usize.deep_size(), size_of::()); + let nz = std::num::NonZeroU64::new(1).unwrap(); + assert_eq!(nz.deep_size(), size_of::()); + } + + #[test] + fn option_some() { + let o: Option = Some(42); + assert_eq!(o.deep_size(), size_of::()); + } + + #[test] + fn option_none() { + let o: Option = None; + assert_eq!(o.deep_size(), 1); + } + + #[test] + fn tuple_deep_size() { + let t = (42u64, String::from("hi")); + assert_eq!(t.deep_size(), size_of::() + 2); + } + + #[test] + fn bound_included() { + let b = std::ops::Bound::Included(100u64); + assert_eq!(b.deep_size(), size_of::()); + } + + #[test] + fn bound_excluded() { + let b = std::ops::Bound::Excluded(100u64); + assert_eq!(b.deep_size(), size_of::()); + } + + #[test] + fn bound_unbounded() { + let b: std::ops::Bound = std::ops::Bound::Unbounded; + assert_eq!(b.deep_size(), 1); + } + + proptest! { + #[test] + fn string_deep_size_matches_byte_len(s in string_strategy(256)) { + prop_assert_eq!(s.deep_size(), s.len()); + } + + #[test] + fn bytes_deep_size_matches_len(bytes in prop::collection::vec(any::(), 0..=1024)) { + let bytes = bytes::Bytes::from(bytes); + prop_assert_eq!(bytes.deep_size(), bytes.len()); + } + + #[test] + fn vec_and_slice_deep_size_sum_elements(values in prop::collection::vec(any::(), 0..=256)) { + let expected = values.len() * size_of::(); + prop_assert_eq!(values.deep_size(), expected); + prop_assert_eq!(values.as_slice().deep_size(), expected); + } + + #[test] + fn nested_vec_of_strings_sums_string_lengths( + values in prop::collection::vec(string_strategy(64), 0..=32), + ) { + let expected = values.iter().map(String::len).sum::(); + prop_assert_eq!(values.deep_size(), expected); + } + } +} diff --git a/common/src/encryption.rs b/common/src/encryption.rs new file mode 100644 index 00000000..457889f6 --- /dev/null +++ b/common/src/encryption.rs @@ -0,0 +1,289 @@ +//! Encryption algorithm, key material parsing, and request header handling. + +use core::str::FromStr; +use std::sync::Arc; + +use base64ct::{Base64, Decoder, Encoding}; +use http::{HeaderName, HeaderValue}; +use secrecy::{ExposeSecret, SecretBox, SecretString, zeroize::Zeroize}; +use strum::{Display, EnumString}; + +use crate::http::ParseableHeader; + +pub static S2_ENCRYPTION_KEY_HEADER: HeaderName = HeaderName::from_static("s2-encryption-key"); + +// 32 bytes in Base 64 +const MAX_ENCRYPTION_KEY_HEADER_VALUE_LEN: usize = 44; + +type EncodedKeyMaterial = Arc; +type DecodedKey = Arc>; + +/// Encryption algorithm. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + serde::Serialize, + serde::Deserialize, + Display, + EnumString, +)] +#[strum(ascii_case_insensitive)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +pub enum EncryptionAlgorithm { + /// AEGIS-256 + #[strum(serialize = "aegis-256")] + #[serde(rename = "aegis-256")] + #[cfg_attr(feature = "clap", value(name = "aegis-256"))] + Aegis256, + /// AES-256-GCM + #[strum(serialize = "aes-256-gcm")] + #[serde(rename = "aes-256-gcm")] + #[cfg_attr(feature = "clap", value(name = "aes-256-gcm"))] + Aes256Gcm, +} + +/// Encryption key material for append/read operations. +#[derive(Debug, Clone)] +pub struct EncryptionKey(EncodedKeyMaterial); + +impl EncryptionKey { + pub fn new(key: [u8; N]) -> Self { + Self(Arc::new(Base64::encode_string(&key).into())) + } + + pub(crate) fn expose_secret(&self) -> &str { + self.0.expose_secret() + } + + pub fn to_header_value(&self) -> HeaderValue { + let mut value = HeaderValue::from_bytes(self.expose_secret().as_bytes()) + .expect("encryption key header value should be ASCII"); + value.set_sensitive(true); + value + } +} + +/// Decoded fixed-size encryption key material. +#[derive(Debug, Clone)] +pub struct DecodedEncryptionKey(DecodedKey); + +impl DecodedEncryptionKey { + pub fn new(key: [u8; N]) -> Self { + Self(Arc::new(SecretBox::new(Box::new(key)))) + } +} + +impl ExposeSecret<[u8; N]> for DecodedEncryptionKey { + fn expose_secret(&self) -> &[u8; N] { + self.0.expose_secret() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("invalid encryption key: key material length {0} is out of range")] +pub struct EncryptionKeyLengthError(usize); + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum EncryptionSpecResolutionError { + #[error("missing encryption key for stream cipher '{cipher}'")] + MissingKey { cipher: EncryptionAlgorithm }, + #[error("invalid encryption key for stream cipher '{cipher}': invalid base64")] + InvalidBase64 { cipher: EncryptionAlgorithm }, + #[error("invalid encryption key length for stream cipher '{cipher}': {length}")] + InvalidKeyLength { + cipher: EncryptionAlgorithm, + length: usize, + }, +} + +/// Resolved encryption spec after combining stream metadata with the encryption key material, if any. +#[rustfmt::skip] +#[derive(Debug, Clone, Default)] +pub enum EncryptionSpec { + #[default] + Plain, + Aegis256(DecodedEncryptionKey<32>), + Aes256Gcm(DecodedEncryptionKey<32>), +} + +impl EncryptionSpec { + pub fn resolve( + cipher: Option, + key: Option, + ) -> Result { + match (cipher, key) { + (None, _) => Ok(Self::Plain), + (Some(cipher @ EncryptionAlgorithm::Aegis256), Some(key)) => { + Ok(Self::Aegis256(resolve_key(cipher, key)?)) + } + (Some(cipher @ EncryptionAlgorithm::Aes256Gcm), Some(key)) => { + Ok(Self::Aes256Gcm(resolve_key(cipher, key)?)) + } + (Some(cipher), None) => Err(EncryptionSpecResolutionError::MissingKey { cipher }), + } + } + + pub fn aegis256(key: [u8; 32]) -> Self { + Self::Aegis256(DecodedEncryptionKey::new(key)) + } + + pub fn aes256_gcm(key: [u8; 32]) -> Self { + Self::Aes256Gcm(DecodedEncryptionKey::new(key)) + } +} + +impl FromStr for EncryptionKey { + type Err = EncryptionKeyLengthError; + + fn from_str(s: &str) -> Result { + let trimmed = s.trim(); + if (1..=MAX_ENCRYPTION_KEY_HEADER_VALUE_LEN).contains(&trimmed.len()) { + Ok(Self(Arc::new(trimmed.to_owned().into()))) + } else { + Err(EncryptionKeyLengthError(trimmed.len())) + } + } +} + +impl ParseableHeader for EncryptionKey { + fn name() -> &'static HeaderName { + &S2_ENCRYPTION_KEY_HEADER + } +} + +fn resolve_key( + cipher: EncryptionAlgorithm, + key: EncryptionKey, +) -> Result, EncryptionSpecResolutionError> { + let mut decoder = Decoder::::new(key.expose_secret().as_bytes()) + .map_err(|_| EncryptionSpecResolutionError::InvalidBase64 { cipher })?; + let mut key_material = Box::new([0u8; N]); + match decoder.decode(key_material.as_mut()) { + Ok(_) if decoder.is_finished() => { + Ok(DecodedEncryptionKey(Arc::new(SecretBox::new(key_material)))) + } + Ok(_) => { + let length = N + .checked_add(decoder.remaining_len()) + .expect("decoded key length should fit usize"); + key_material.as_mut().zeroize(); + Err(EncryptionSpecResolutionError::InvalidKeyLength { cipher, length }) + } + Err(base64ct::Error::InvalidEncoding) => { + key_material.as_mut().zeroize(); + Err(EncryptionSpecResolutionError::InvalidBase64 { cipher }) + } + Err(base64ct::Error::InvalidLength) => { + let length = decoder.remaining_len(); + key_material.as_mut().zeroize(); + Err(EncryptionSpecResolutionError::InvalidKeyLength { cipher, length }) + } + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + const KEY_B64: &str = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="; + const KEY_BYTES: [u8; 32] = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, + 26, 27, 28, 29, 30, 31, 32, + ]; + + fn resolve_encrypted( + cipher: EncryptionAlgorithm, + key: EncryptionKey, + ) -> Result { + EncryptionSpec::resolve(Some(cipher), Some(key)) + } + + #[test] + fn key_header_value_roundtrips_and_is_sensitive() { + let value = EncryptionKey::new(KEY_BYTES).to_header_value(); + assert_eq!(value.to_str().unwrap(), KEY_B64); + assert!(value.is_sensitive()); + + let parsed = value.to_str().unwrap().parse::().unwrap(); + assert_eq!(parsed.to_header_value().to_str().unwrap(), KEY_B64); + } + + #[test] + fn encryption_key_parsing_trims_and_enforces_bounds() { + let parsed = format!(" {KEY_B64}\n").parse::().unwrap(); + assert_eq!(parsed.to_header_value().to_str().unwrap(), KEY_B64); + + assert_eq!( + " ".parse::().unwrap_err(), + EncryptionKeyLengthError(0) + ); + + let too_long = "A".repeat(MAX_ENCRYPTION_KEY_HEADER_VALUE_LEN + 1); + assert_eq!( + too_long.parse::().unwrap_err(), + EncryptionKeyLengthError(MAX_ENCRYPTION_KEY_HEADER_VALUE_LEN + 1) + ); + } + + #[test] + fn resolve_plain_ignores_supplied_key() { + let encryption = EncryptionSpec::resolve(None, Some("!!!!".parse().unwrap())).unwrap(); + assert!(matches!(encryption, EncryptionSpec::Plain)); + } + + #[rstest] + #[case(EncryptionAlgorithm::Aegis256)] + #[case(EncryptionAlgorithm::Aes256Gcm)] + fn resolve_encrypted_requires_key(#[case] cipher: EncryptionAlgorithm) { + let err = EncryptionSpec::resolve(Some(cipher), None).unwrap_err(); + assert_eq!(err, EncryptionSpecResolutionError::MissingKey { cipher }); + } + + #[rstest] + #[case(EncryptionAlgorithm::Aegis256)] + #[case(EncryptionAlgorithm::Aes256Gcm)] + fn resolve_encrypted_decodes_key_for_each_algorithm(#[case] cipher: EncryptionAlgorithm) { + let encryption = resolve_encrypted(cipher, EncryptionKey::new(KEY_BYTES)).unwrap(); + + match (cipher, encryption) { + (EncryptionAlgorithm::Aegis256, EncryptionSpec::Aegis256(key)) => { + assert_eq!(key.expose_secret(), &KEY_BYTES); + } + (EncryptionAlgorithm::Aes256Gcm, EncryptionSpec::Aes256Gcm(key)) => { + assert_eq!(key.expose_secret(), &KEY_BYTES); + } + _ => panic!("resolved encryption spec did not match requested algorithm"), + } + } + + #[rstest] + #[case(EncryptionAlgorithm::Aegis256)] + #[case(EncryptionAlgorithm::Aes256Gcm)] + fn resolve_encrypted_rejects_invalid_base64(#[case] cipher: EncryptionAlgorithm) { + let err = resolve_encrypted(cipher, "!!!!".parse().unwrap()).unwrap_err(); + assert_eq!(err, EncryptionSpecResolutionError::InvalidBase64 { cipher }); + } + + #[test] + fn resolve_encrypted_rejects_non_32_byte_keys() { + let cipher = EncryptionAlgorithm::Aegis256; + + let short_err = resolve_encrypted(cipher, EncryptionKey::new([0x42; 4])).unwrap_err(); + assert_eq!( + short_err, + EncryptionSpecResolutionError::InvalidKeyLength { cipher, length: 4 } + ); + + let long_err = resolve_encrypted(cipher, EncryptionKey::new([0x42; 33])).unwrap_err(); + assert_eq!( + long_err, + EncryptionSpecResolutionError::InvalidKeyLength { cipher, length: 33 } + ); + } +} diff --git a/common/src/http.rs b/common/src/http.rs new file mode 100644 index 00000000..6dbab0b1 --- /dev/null +++ b/common/src/http.rs @@ -0,0 +1,111 @@ +/// An HTTP header that can be parsed from a UTF8 string value. +pub trait ParseableHeader: std::str::FromStr +where + Self::Err: std::fmt::Display, +{ + fn name() -> &'static http::HeaderName; +} + +#[cfg(feature = "axum")] +pub mod extract { + use axum::{ + extract::{FromRequestParts, OptionalFromRequestParts}, + response::{IntoResponse, Response}, + }; + + #[derive(Debug, thiserror::Error)] + pub enum HeaderRejection { + #[error("Missing header `{0}`")] + MissingHeader(&'static http::HeaderName), + #[error("Invalid header `{0}`: not UTF-8")] + InvalidUtf8(&'static http::HeaderName), + #[error("Invalid header `{0}`: {1}")] + InvalidHeaderValue(&'static http::HeaderName, String), + } + + impl IntoResponse for HeaderRejection { + fn into_response(self) -> Response { + (http::StatusCode::BAD_REQUEST, self.to_string()).into_response() + } + } + + pub fn parse_header(headers: &http::HeaderMap) -> Result + where + T: super::ParseableHeader, + T::Err: std::fmt::Display, + { + let name = T::name(); + let Some(value) = headers.get(name) else { + return Err(HeaderRejection::MissingHeader(name)); + }; + let value_str = value + .to_str() + .map_err(|_| HeaderRejection::InvalidUtf8(name))?; + let parsed = value_str + .parse::() + .map_err(|e| HeaderRejection::InvalidHeaderValue(name, e.to_string()))?; + Ok(parsed) + } + + #[derive(Debug, Clone)] + pub struct Header(pub T); + + impl FromRequestParts for Header + where + S: Send + Sync, + T: super::ParseableHeader, + T::Err: std::fmt::Display, + { + type Rejection = HeaderRejection; + + async fn from_request_parts( + parts: &mut http::request::Parts, + _state: &S, + ) -> Result { + parse_header(&parts.headers).map(Self) + } + } + + impl OptionalFromRequestParts for Header + where + S: Send + Sync, + T: super::ParseableHeader, + T::Err: std::fmt::Display, + { + type Rejection = HeaderRejection; + + async fn from_request_parts( + parts: &mut http::request::Parts, + _state: &S, + ) -> Result, Self::Rejection> { + match parse_header(&parts.headers) { + Ok(value) => Ok(Some(Header(value))), + Err(HeaderRejection::MissingHeader(_)) => Ok(None), + Err(e) => Err(e), + } + } + } + + /// Workaround for https://github.com/tokio-rs/axum/issues/3623 + pub struct HeaderOpt(pub Option); + + impl FromRequestParts for HeaderOpt + where + S: Send + Sync, + T: super::ParseableHeader, + T::Err: std::fmt::Display, + { + type Rejection = HeaderRejection; + + async fn from_request_parts( + parts: &mut http::request::Parts, + _state: &S, + ) -> Result { + match parse_header(&parts.headers) { + Ok(value) => Ok(Self(Some(value))), + Err(HeaderRejection::MissingHeader(_)) => Ok(Self(None)), + Err(e) => Err(e), + } + } + } +} diff --git a/common/src/lib.rs b/common/src/lib.rs new file mode 100644 index 00000000..0283e534 --- /dev/null +++ b/common/src/lib.rs @@ -0,0 +1,39 @@ +//! Common types and utilities shared across S2 crates. + +pub mod access; +pub mod basin; +pub mod caps; +pub mod config; +pub mod deep_size; +pub mod encryption; +pub mod http; +pub mod location; +pub mod maybe; +pub mod metrics; +pub mod read_extent; +pub mod record; +pub mod resources; +pub mod stream; +mod strings; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{0}")] +pub struct ValidationError(pub String); + +impl From for ValidationError { + fn from(value: String) -> Self { + ValidationError(value) + } +} + +impl From<&str> for ValidationError { + fn from(value: &str) -> Self { + ValidationError(value.to_owned()) + } +} + +impl From for ValidationError { + fn from(e: record::FencingTokenTooLongError) -> Self { + ValidationError(e.to_string()) + } +} diff --git a/common/src/location.rs b/common/src/location.rs new file mode 100644 index 00000000..86605447 --- /dev/null +++ b/common/src/location.rs @@ -0,0 +1,184 @@ +use std::{ops::Deref, str::FromStr}; + +use compact_str::{CompactString, ToCompactString}; + +use super::ValidationError; +use crate::caps; + +fn validate_location_str(field_name: &str, location: &str) -> Result<(), ValidationError> { + if location.chars().count() > caps::MAX_LOCATION_NAME_LEN { + return Err(format!( + "location {field_name} must be at most {} characters in length", + caps::MAX_LOCATION_NAME_LEN + ) + .into()); + } + + if location + .chars() + .any(|c| !c.is_ascii_alphanumeric() && c != ':' && c != '-' && c != '.') + { + return Err(format!( + "location {field_name} must comprise ASCII letters, numbers, colons, hyphens, and periods" + ) + .into()); + } + + Ok(()) +} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize) +)] +pub struct LocationName(CompactString); + +impl LocationName { + fn validate_str(location: &str) -> Result<(), ValidationError> { + if location.is_empty() { + return Err("location name must be at least 1 character in length".into()); + } + + validate_location_str("name", location) + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::PartialSchema for LocationName { + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::Object::builder() + .schema_type(utoipa::openapi::Type::String) + .min_length(Some(1)) + .max_length(Some(caps::MAX_LOCATION_NAME_LEN)) + .into() + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::ToSchema for LocationName {} + +impl serde::Serialize for LocationName { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for LocationName { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = CompactString::deserialize(deserializer)?; + s.try_into().map_err(serde::de::Error::custom) + } +} + +impl AsRef for LocationName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for LocationName { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom for LocationName { + type Error = ValidationError; + + fn try_from(location: CompactString) -> Result { + Self::validate_str(&location)?; + Ok(Self(location)) + } +} + +impl TryFrom for LocationName { + type Error = ValidationError; + + fn try_from(location: String) -> Result { + location.to_compact_string().try_into() + } +} + +impl TryFrom<&str> for LocationName { + type Error = ValidationError; + + fn try_from(location: &str) -> Result { + location.to_compact_string().try_into() + } +} + +impl FromStr for LocationName { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + s.try_into() + } +} + +impl std::fmt::Debug for LocationName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::fmt::Display for LocationName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From for CompactString { + fn from(value: LocationName) -> Self { + value.0 + } +} + +#[derive(Debug, Clone)] +pub struct LocationInfo { + pub name: LocationName, + pub is_private: bool, + pub storage_classes: Vec, + pub default_storage_class: CompactString, +} + +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::LocationName; + + #[rstest] + #[case::single_char("a".to_owned())] + #[case::aws_region("aws:us-east-1".to_owned())] + #[case::uppercase_and_period("cloud:US-West-2.edge".to_owned())] + #[case::max_len("a".repeat(crate::caps::MAX_LOCATION_NAME_LEN))] + fn validate_name_ok(#[case] location: String) { + assert_eq!( + location.parse::().as_deref(), + Ok(location.as_str()) + ); + } + + #[rstest] + #[case::empty("".to_owned())] + #[case::too_long("a".repeat(crate::caps::MAX_LOCATION_NAME_LEN + 1))] + #[case::underscore("aws:us_east-1".to_owned())] + #[case::slash("aws/us-east-1".to_owned())] + #[case::space("aws:us east-1".to_owned())] + #[case::multibyte("aws:é".to_owned())] + #[case::nul("aws:us\0east-1".to_owned())] + fn validate_name_err(#[case] location: String) { + location + .parse::() + .expect_err("expected validation error"); + } +} diff --git a/common/src/maybe.rs b/common/src/maybe.rs new file mode 100644 index 00000000..9b3d2027 --- /dev/null +++ b/common/src/maybe.rs @@ -0,0 +1,119 @@ +use serde::{Deserialize, Serialize}; + +/// The [`Maybe`] type represents an optional that might or might not be specified. +/// +/// An [`Option`] is deserialized as [`None`] if either the value is not specified or the value is +/// `null`. [`Maybe`] allows us to distinguish between the two. +/// +/// The [`Deserialize`] impl always produces `Specified` via the blanket `From` impl. +/// `Unspecified` is only produced by `Default::default()`, which serde invokes for +/// absent fields annotated with `#[serde(default)]`. +/// +/// # Examples +/// +/// ``` +/// use s2_common::maybe::Maybe; +/// +/// #[derive(Debug, PartialEq, Eq, serde::Deserialize)] +/// pub struct MyStruct { +/// #[serde(default)] +/// pub field: Maybe>, +/// } +/// +/// assert_eq!( +/// MyStruct { field: Maybe::Unspecified }, +/// serde_json::from_str("{}").unwrap(), +/// ); +/// +/// assert_eq!( +/// MyStruct { field: Maybe::Specified(None) }, +/// serde_json::from_str(r#"{ "field": null }"#).unwrap(), +/// ); +/// +/// assert_eq!( +/// MyStruct { field: Maybe::Specified(Some(10)) }, +/// serde_json::from_str(r#"{ "field": 10 }"#).unwrap(), +/// ); +/// ``` +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum Maybe { + #[default] + Unspecified, + Specified(T), +} + +impl Maybe { + pub fn is_unspecified(&self) -> bool { + matches!(self, Self::Unspecified) + } + + pub fn map(self, f: F) -> Maybe + where + F: FnOnce(T) -> U, + { + match self { + Self::Unspecified => Maybe::Unspecified, + Self::Specified(x) => Maybe::Specified(f(x)), + } + } + + pub fn unwrap_or_default(self) -> T + where + T: Default, + { + match self { + Self::Unspecified => T::default(), + Self::Specified(x) => x, + } + } +} + +impl Maybe> { + pub fn map_opt(self, f: F) -> Maybe> + where + F: FnOnce(T) -> U, + { + self.map(|opt| opt.map(f)) + } + + pub fn try_map_opt(self, f: F) -> Result>, E> + where + F: FnOnce(T) -> Result, + { + match self { + Maybe::Unspecified => Ok(Maybe::Unspecified), + Maybe::Specified(opt) => match opt { + Some(value) => f(value).map(|converted| Maybe::Specified(Some(converted))), + None => Ok(Maybe::Specified(None)), + }, + } + } +} + +impl From for Maybe { + fn from(value: T) -> Self { + Self::Specified(value) + } +} + +impl Serialize for Maybe { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Unspecified => serializer.serialize_none(), + Self::Specified(v) => v.serialize(serializer), + } + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for Maybe { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let v = T::deserialize(deserializer)?; + Ok(v.into()) + } +} diff --git a/common/src/metrics.rs b/common/src/metrics.rs new file mode 100644 index 00000000..9fd68b99 --- /dev/null +++ b/common/src/metrics.rs @@ -0,0 +1,97 @@ +use compact_str::CompactString; + +#[derive(Clone, Copy, Debug)] +pub enum MetricUnit { + Bytes, + Operations, +} + +#[derive(Clone, Copy, Debug, strum::IntoStaticStr)] +pub enum TimeseriesInterval { + #[strum(serialize = "minute")] + Minute, + #[strum(serialize = "hour")] + Hour, + #[strum(serialize = "day")] + Day, +} + +#[derive(Debug, Clone)] +pub struct ScalarMetric { + pub name: CompactString, + pub unit: MetricUnit, + pub value: f64, +} + +#[derive(Debug, Clone)] +pub struct AccumulationMetric { + pub name: CompactString, + pub unit: MetricUnit, + pub interval: TimeseriesInterval, + pub values: Vec<(u32, f64)>, +} + +#[derive(Debug, Clone)] +pub struct GaugeMetric { + pub name: CompactString, + pub unit: MetricUnit, + pub values: Vec<(u32, f64)>, +} + +#[derive(Debug, Clone)] +pub struct LabelMetric { + pub name: CompactString, + pub values: Vec, +} + +#[derive(Debug, Clone)] +pub enum Metric { + Scalar(ScalarMetric), + Accumulation(AccumulationMetric), + Gauge(GaugeMetric), + Label(LabelMetric), +} + +pub enum AccountMetricSet { + ActiveBasins, + AccountOps, +} + +pub struct AccountMetricsRequest { + pub set: AccountMetricSet, + pub start: Option, + pub end: Option, + pub interval: Option, +} + +pub enum BasinMetricSet { + Storage, + AppendOps, + BasinOps, + ReadOps, + ReadThroughput, + AppendThroughput, +} + +pub struct BasinMetricsRequest { + pub set: BasinMetricSet, + pub start: Option, + pub end: Option, + pub interval: Option, +} + +pub enum StreamMetricSet { + Storage, +} + +pub struct StreamMetricsRequest { + pub set: StreamMetricSet, + pub start: Option, + pub end: Option, + pub interval: Option, +} + +// Common response type +pub struct MetricsResponse { + pub values: Vec, +} diff --git a/common/src/read_extent.rs b/common/src/read_extent.rs new file mode 100644 index 00000000..40b6bb07 --- /dev/null +++ b/common/src/read_extent.rs @@ -0,0 +1,211 @@ +use crate::record::Timestamp; + +#[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)] +pub struct CountOrBytes { + pub count: usize, + pub bytes: usize, +} + +impl CountOrBytes { + pub const ZERO: CountOrBytes = CountOrBytes { count: 0, bytes: 0 }; + + pub const MAX: CountOrBytes = CountOrBytes { + count: usize::MAX, + bytes: usize::MAX, + }; +} + +#[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)] +pub enum ReadLimit { + #[default] + Unbounded, + Count(usize), + Bytes(usize), + CountOrBytes(CountOrBytes), +} + +#[derive(PartialEq, Debug)] +pub enum EvaluatedReadLimit { + Remaining(ReadLimit), + Exhausted, +} + +impl ReadLimit { + pub fn is_unbounded(&self) -> bool { + matches!(self, ReadLimit::Unbounded) + } + + pub fn is_bounded(&self) -> bool { + !matches!(self, ReadLimit::Unbounded) + } + + pub fn from_count_and_bytes(count: Option, bytes: Option) -> Self { + match (count, bytes) { + (None, None) => Self::Unbounded, + (Some(0), _) | (_, Some(0)) => Self::Count(0), + (Some(count), None) => Self::Count(count), + (None, Some(bytes)) => Self::Bytes(bytes), + (Some(count), Some(bytes)) => Self::CountOrBytes(CountOrBytes { count, bytes }), + } + } + + pub fn count(&self) -> Option { + match self { + ReadLimit::Unbounded => None, + ReadLimit::Count(count) => Some(*count), + ReadLimit::Bytes(_) => None, + ReadLimit::CountOrBytes(CountOrBytes { count, .. }) => Some(*count), + } + } + + pub fn bytes(&self) -> Option { + match self { + ReadLimit::Unbounded => None, + ReadLimit::Count(_) => None, + ReadLimit::Bytes(bytes) => Some(*bytes), + ReadLimit::CountOrBytes(CountOrBytes { bytes, .. }) => Some(*bytes), + } + } + + pub fn into_allowance(self, max: CountOrBytes) -> CountOrBytes { + match self { + ReadLimit::Unbounded => max, + ReadLimit::Count(count) => CountOrBytes { + count: count.min(max.count), + bytes: max.bytes, + }, + ReadLimit::Bytes(bytes) => CountOrBytes { + count: max.count, + bytes: bytes.min(max.bytes), + }, + ReadLimit::CountOrBytes(CountOrBytes { count, bytes }) => CountOrBytes { + count: count.min(max.count), + bytes: bytes.min(max.bytes), + }, + } + } + + pub fn allow(&self, additional_count: usize, additional_bytes: usize) -> bool { + match self { + ReadLimit::Unbounded => true, + ReadLimit::Count(count) => additional_count <= *count, + ReadLimit::Bytes(bytes) => additional_bytes <= *bytes, + ReadLimit::CountOrBytes(CountOrBytes { count, bytes }) => { + additional_count <= *count && additional_bytes <= *bytes + } + } + } + + pub fn deny(&self, additional_count: usize, additional_bytes: usize) -> bool { + match self { + ReadLimit::Unbounded => false, + ReadLimit::Count(count) => additional_count > *count, + ReadLimit::Bytes(bytes) => additional_bytes > *bytes, + ReadLimit::CountOrBytes(CountOrBytes { count, bytes }) => { + additional_count > *count || additional_bytes > *bytes + } + } + } + + /// Given the amount of records already consumed, generate a new `ReadLimit` representing + /// the remaining limit, or none if the limit has been met. + pub fn remaining(&self, consumed_count: usize, consumed_bytes: usize) -> EvaluatedReadLimit { + let remaining = match self { + ReadLimit::Unbounded => Some(ReadLimit::Unbounded), + ReadLimit::Count(count) => { + (consumed_count < *count).then(|| ReadLimit::Count(count - consumed_count)) + } + ReadLimit::Bytes(bytes) => { + (consumed_bytes < *bytes).then(|| ReadLimit::Bytes(bytes - consumed_bytes)) + } + ReadLimit::CountOrBytes(CountOrBytes { count, bytes }) => { + (consumed_count < *count && consumed_bytes < *bytes).then(|| { + ReadLimit::CountOrBytes(CountOrBytes { + count: count - consumed_count, + bytes: bytes - consumed_bytes, + }) + }) + } + }; + + match remaining { + Some(limit) => EvaluatedReadLimit::Remaining(limit), + None => EvaluatedReadLimit::Exhausted, + } + } +} + +#[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)] +pub enum ReadUntil { + #[default] + Unbounded, + Timestamp(Timestamp), +} + +impl From> for ReadUntil { + fn from(timestamp: Option) -> Self { + match timestamp { + Some(ts) => ReadUntil::Timestamp(ts), + None => ReadUntil::Unbounded, + } + } +} + +impl ReadUntil { + pub fn is_unbounded(&self) -> bool { + matches!(self, ReadUntil::Unbounded) + } + + pub fn is_timestamp(&self) -> bool { + matches!(self, ReadUntil::Timestamp(_)) + } + + pub fn allow(&self, timestamp: Timestamp) -> bool { + match self { + ReadUntil::Unbounded => true, + ReadUntil::Timestamp(t) => timestamp < *t, + } + } + + pub fn deny(&self, timestamp: Timestamp) -> bool { + match self { + ReadUntil::Unbounded => false, + ReadUntil::Timestamp(t) => timestamp >= *t, + } + } +} + +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::{CountOrBytes, EvaluatedReadLimit, ReadLimit}; + + #[rstest] + #[case( + ReadLimit::Count(100), + 10, + 100000, + EvaluatedReadLimit::Remaining(ReadLimit::Count(90)) + )] + #[case(ReadLimit::Count(100), 100, 100000, EvaluatedReadLimit::Exhausted)] + #[case( + ReadLimit::Bytes(100), + 1000, + 99, + EvaluatedReadLimit::Remaining(ReadLimit::Bytes(1)) + )] + #[case(ReadLimit::CountOrBytes(CountOrBytes{count: 50, bytes: 50}), 40, 45, EvaluatedReadLimit::Remaining(ReadLimit::CountOrBytes(CountOrBytes{count: 10, bytes: 5})))] + #[case(ReadLimit::CountOrBytes(CountOrBytes{count: 50, bytes: 50}), 51, 45, EvaluatedReadLimit::Exhausted)] + fn remaining( + #[case] old_limit: ReadLimit, + #[case] consumed_count: usize, + #[case] consumed_bytes: usize, + #[case] remaining_limit: EvaluatedReadLimit, + ) { + assert_eq!( + old_limit.remaining(consumed_count, consumed_bytes), + remaining_limit + ) + } +} diff --git a/common/src/record/command.rs b/common/src/record/command.rs new file mode 100644 index 00000000..327e9d97 --- /dev/null +++ b/common/src/record/command.rs @@ -0,0 +1,200 @@ +use std::{fmt, str::Utf8Error}; + +use bytes::Bytes; +use compact_str::CompactString; + +use super::{FencingTokenTooLongError, MeteredSize, fencing::FencingToken}; +use crate::{deep_size::DeepSize, record::SeqNum}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum CommandOp { + Fence, + Trim, +} + +impl CommandOp { + pub fn to_id(self) -> &'static [u8] { + match self { + Self::Fence => b"fence", + Self::Trim => b"trim", + } + } + + pub fn from_id(name: &[u8]) -> Option { + match name { + b"fence" => Some(Self::Fence), + b"trim" => Some(Self::Trim), + _ => None, + } + } +} + +impl fmt::Display for CommandOp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = std::str::from_utf8(self.to_id()).map_err(|_| fmt::Error)?; + f.write_str(name) + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum CommandRecord { + Fence(FencingToken), + Trim(SeqNum), +} + +impl DeepSize for CommandRecord { + fn deep_size(&self) -> usize { + match self { + Self::Fence(token) => token.deep_size(), + Self::Trim(seq_num) => seq_num.deep_size(), + } + } +} + +impl MeteredSize for CommandRecord { + fn metered_size(&self) -> usize { + 8 + 2 + + self.op().to_id().len() + + match self { + Self::Fence(token) => token.len(), + Self::Trim(trim_point) => size_of_val(trim_point), + } + } +} + +impl CommandRecord { + pub fn op(&self) -> CommandOp { + match self { + CommandRecord::Fence(_) => CommandOp::Fence, + CommandRecord::Trim(_) => CommandOp::Trim, + } + } + + pub fn payload(&self) -> Bytes { + match self { + Self::Fence(token) => Bytes::copy_from_slice(token.as_bytes()), + Self::Trim(trim_point) => Bytes::copy_from_slice(&trim_point.to_be_bytes()), + } + } + + pub fn try_from_parts(op: CommandOp, payload: &[u8]) -> Result { + match op { + CommandOp::Fence => { + let token = CompactString::from_utf8(payload) + .map_err(CommandPayloadError::InvalidUtf8)? + .try_into()?; + Ok(Self::Fence(token)) + } + CommandOp::Trim => { + let trim_point = SeqNum::from_be_bytes( + payload + .try_into() + .map_err(|_| CommandPayloadError::TrimPointSize(payload.len()))?, + ); + Ok(Self::Trim(trim_point)) + } + } + } +} + +#[derive(Debug, PartialEq, thiserror::Error)] +pub enum CommandPayloadError { + #[error("invalid UTF-8")] + InvalidUtf8(Utf8Error), + #[error(transparent)] + FencingTokenTooLong(#[from] FencingTokenTooLongError), + #[error("earliest sequence number to trim to was {0} bytes, must be 8")] + TrimPointSize(usize), +} + +#[cfg(test)] +mod tests { + use compact_str::ToCompactString; + use proptest::prelude::*; + use rstest::rstest; + + use super::*; + + #[test] + fn command_op_names() { + for cmd in [CommandOp::Fence, CommandOp::Trim] { + let name = cmd.to_id(); + assert_eq!(CommandOp::from_id(name), Some(cmd)); + } + assert_eq!(CommandOp::from_id(b""), None); + assert_eq!(CommandOp::from_id(b"invalid"), None); + } + + #[test] + fn fencing_token_invalid_utf8() { + assert!(matches!( + CommandRecord::try_from_parts(CommandOp::Fence, &[0xff]), + Err(CommandPayloadError::InvalidUtf8(_)) + )); + } + + #[test] + fn fencing_token_too_long() { + assert_eq!( + CommandRecord::try_from_parts( + CommandOp::Fence, + b"0123456789012345678901234567890123456789" + ), + Err(CommandPayloadError::FencingTokenTooLong( + FencingTokenTooLongError(40) + )) + ); + } + + #[rstest] + #[case::empty("")] + #[case::arbit("arbitrary")] + #[case::full("0123456789012345")] + fn fence_roundtrip(#[case] token: &str) { + let cmd = CommandRecord::Fence(FencingToken::try_from(token.to_compact_string()).unwrap()); + assert_eq!( + CommandRecord::try_from_parts(CommandOp::Fence, token.as_bytes()), + Ok(cmd.clone()) + ); + assert_eq!( + cmd.metered_size(), + 8 + 2 + CommandOp::Fence.to_id().len() + token.len() + ); + } + + #[rstest] + #[case::empty(b"")] + #[case::too_small(b"0123")] + #[case::too_big(b"0123456789")] + fn trim_point_size(#[case] payload: &[u8]) { + assert_eq!( + CommandRecord::try_from_parts(CommandOp::Trim, payload), + Err(CommandPayloadError::TrimPointSize(payload.len())) + ); + } + + #[test] + fn metered_size_is_computed_without_materializing_payload() { + let fence = + CommandRecord::Fence(FencingToken::try_from("fence-me".to_compact_string()).unwrap()); + assert_eq!( + fence.metered_size(), + 8 + 2 + CommandOp::Fence.to_id().len() + "fence-me".len() + ); + + let trim = CommandRecord::Trim(42); + assert_eq!( + trim.metered_size(), + 8 + 2 + CommandOp::Trim.to_id().len() + size_of_val(&42u64) + ); + } + + proptest! { + #[test] + fn trim_roundtrip(trim_point in any::()) { + let cmd = CommandRecord::Trim(trim_point); + assert_eq!(CommandRecord::try_from_parts(CommandOp::Trim, trim_point.to_be_bytes().as_slice()), Ok(cmd.clone())); + assert_eq!(cmd.metered_size(), 8 + 2 + CommandOp::Trim.to_id().len() + size_of::()); + } + } +} diff --git a/common/src/record/envelope.rs b/common/src/record/envelope.rs new file mode 100644 index 00000000..e31fb4c7 --- /dev/null +++ b/common/src/record/envelope.rs @@ -0,0 +1,345 @@ +use bytes::Bytes; + +use super::{Header, MeteredSize, RecordPartsError}; +use crate::deep_size::DeepSize; + +const MAX_HEADER_COUNT: usize = 0xFF_FFFF; +const MAX_HEADER_NAME_OR_VALUE_LEN: usize = u32::MAX as usize; + +#[derive(Debug, PartialEq, thiserror::Error)] +pub enum HeaderValidationError { + #[error("too many")] + TooMany, + #[error("too long")] + TooLong, + #[error("empty name")] + NameEmpty, +} + +#[derive(PartialEq, Eq, Clone)] +pub struct EnvelopeRecord { + headers: Vec
, + body: Bytes, + header_sizing: HeaderSizing, +} + +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +struct HeaderSizing(u64); + +impl HeaderSizing { + const TOTAL_BYTES_MASK: u64 = (1 << 60) - 1; + const NAME_LENGTH_WIDTH_SHIFT: u32 = 62; + const VALUE_LENGTH_WIDTH_SHIFT: u32 = 60; + + fn new(total_bytes: usize, name_length_width: u8, value_length_width: u8) -> Self { + debug_assert!(total_bytes as u64 <= Self::TOTAL_BYTES_MASK); + debug_assert!((1..=4).contains(&name_length_width)); + debug_assert!((1..=4).contains(&value_length_width)); + + Self( + total_bytes as u64 + | (u64::from(name_length_width - 1) << Self::NAME_LENGTH_WIDTH_SHIFT) + | (u64::from(value_length_width - 1) << Self::VALUE_LENGTH_WIDTH_SHIFT), + ) + } + + fn total_bytes(self) -> usize { + (self.0 & Self::TOTAL_BYTES_MASK) as usize + } + + fn name_length_width_bytes(self) -> usize { + (((self.0 >> Self::NAME_LENGTH_WIDTH_SHIFT) & 0b11) + 1) as usize + } + + fn value_length_width_bytes(self) -> usize { + (((self.0 >> Self::VALUE_LENGTH_WIDTH_SHIFT) & 0b11) + 1) as usize + } +} + +impl std::fmt::Debug for EnvelopeRecord { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EnvelopeRecord") + .field("headers.len", &self.headers.len()) + .field("body.len", &self.body.len()) + .finish() + } +} + +impl DeepSize for EnvelopeRecord { + fn deep_size(&self) -> usize { + self.headers.deep_size() + self.body.deep_size() + } +} + +impl MeteredSize for EnvelopeRecord { + fn metered_size(&self) -> usize { + 8 + (2 * self.headers.len()) + self.header_sizing.total_bytes() + self.body.len() + } +} + +impl EnvelopeRecord { + pub fn headers(&self) -> &[Header] { + &self.headers + } + + pub fn body(&self) -> &Bytes { + &self.body + } + + /// Total bytes across all header names and values. + pub fn headers_total_bytes(&self) -> usize { + self.header_sizing.total_bytes() + } + + #[doc(hidden)] + pub fn header_name_length_width_bytes(&self) -> usize { + self.header_sizing.name_length_width_bytes() + } + + #[doc(hidden)] + pub fn header_value_length_width_bytes(&self) -> usize { + self.header_sizing.value_length_width_bytes() + } + + pub fn into_parts(self) -> (Vec
, Bytes) { + (self.headers, self.body) + } + + pub fn try_from_parts(headers: Vec
, body: Bytes) -> Result { + let header_sizing = validate_headers(&headers)?; + Ok(Self { + headers, + body, + header_sizing, + }) + } +} + +fn validate_headers(headers: &[Header]) -> Result { + if headers.len() > MAX_HEADER_COUNT { + return Err(HeaderValidationError::TooMany); + } + + let mut total_bytes = 0usize; + let mut name_length_width_bytes = 1u8; + let mut value_length_width_bytes = 1u8; + + for Header { name, value } in headers { + if name.is_empty() { + return Err(HeaderValidationError::NameEmpty); + } + if name.len() > MAX_HEADER_NAME_OR_VALUE_LEN || value.len() > MAX_HEADER_NAME_OR_VALUE_LEN { + return Err(HeaderValidationError::TooLong); + } + + total_bytes = total_bytes + .checked_add(name.len()) + .and_then(|total| total.checked_add(value.len())) + .ok_or(HeaderValidationError::TooLong)?; + if total_bytes as u64 > HeaderSizing::TOTAL_BYTES_MASK { + return Err(HeaderValidationError::TooLong); + } + + name_length_width_bytes = name_length_width_bytes.max(length_width_bytes(name.len())?); + value_length_width_bytes = value_length_width_bytes.max(length_width_bytes(value.len())?); + } + + Ok(HeaderSizing::new( + total_bytes, + name_length_width_bytes, + value_length_width_bytes, + )) +} + +fn length_width_bytes(len: usize) -> Result { + if len == 0 { + return Ok(1); + } + + let width = 8 - len.leading_zeros() / 8; + if width <= 4 { + Ok(width as u8) + } else { + Err(HeaderValidationError::TooLong) + } +} + +#[cfg(test)] +mod test { + use bytes::Bytes; + use proptest::prelude::*; + + use super::{ + EnvelopeRecord, Header, HeaderSizing, HeaderValidationError, MeteredSize, RecordPartsError, + length_width_bytes, + }; + + fn assert_parts_preserved(headers: Vec
, body: Bytes) { + let record = EnvelopeRecord::try_from_parts(headers.clone(), body.clone()).unwrap(); + assert_eq!(record.headers(), headers); + assert_eq!(record.body(), &body); + } + + #[test] + fn preserves_headers() { + assert_parts_preserved( + vec![ + Header { + name: Bytes::from("key_1"), + value: Bytes::from("val_1"), + }, + Header { + name: Bytes::from("key_2"), + value: Bytes::from("val_2"), + }, + Header { + name: Bytes::from("key_3"), + value: Bytes::from("val_3"), + }, + Header { + name: Bytes::from("key_4"), + value: Bytes::from("val_4"), + }, + ], + Bytes::from("hello"), + ); + } + + #[test] + fn preserves_no_headers() { + assert_parts_preserved(vec![], Bytes::from("hello")); + } + + #[test] + fn rejects_empty_header_name() { + assert_eq!( + EnvelopeRecord::try_from_parts( + vec![Header { + name: Bytes::new(), + value: Bytes::from_static(b"value"), + }], + Bytes::from_static(b"body"), + ), + Err(RecordPartsError::Header(HeaderValidationError::NameEmpty)) + ); + } + + #[test] + fn preserves_duplicate_keys() { + // Duplicate keys preserved in original order. + assert_parts_preserved( + vec![ + Header { + name: Bytes::from("b"), + value: Bytes::from("val_1"), + }, + Header { + name: Bytes::from("b"), + value: Bytes::from("val_2"), + }, + Header { + name: Bytes::from("a"), + value: Bytes::from("val_3"), + }, + ], + Bytes::from("hello"), + ); + } + + #[test] + fn metered_size_uses_cached_header_bytes() { + let record = EnvelopeRecord::try_from_parts( + vec![ + Header { + name: Bytes::from("alpha"), + value: Bytes::from("1"), + }, + Header { + name: Bytes::from("beta"), + value: Bytes::from("two"), + }, + ], + Bytes::from("body"), + ) + .unwrap(); + + assert_eq!( + record.metered_size(), + 8 + (2 * record.headers().len()) + + ("alpha".len() + "1".len() + "beta".len() + "two".len()) + + "body".len() + ); + } + + #[test] + fn header_sizing_is_cached_from_validated_headers() { + let long_name = Bytes::from(vec![b'n'; 256]); + let long_value = Bytes::from(vec![b'v'; 65_536]); + let record = EnvelopeRecord::try_from_parts( + vec![ + Header { + name: Bytes::from_static(b"a"), + value: Bytes::from_static(b"value"), + }, + Header { + name: long_name.clone(), + value: long_value.clone(), + }, + ], + Bytes::from_static(b"body"), + ) + .unwrap(); + + assert_eq!( + record.headers_total_bytes(), + "a".len() + "value".len() + long_name.len() + long_value.len() + ); + assert_eq!(record.header_name_length_width_bytes(), 2); + assert_eq!(record.header_value_length_width_bytes(), 3); + } + + proptest! { + #[test] + fn header_sizing_pack_roundtrips( + total_bytes in 0usize..=HeaderSizing::TOTAL_BYTES_MASK as usize, + name_length_width in 1u8..=4, + value_length_width in 1u8..=4, + ) { + let summary = HeaderSizing::new( + total_bytes, + name_length_width, + value_length_width, + ); + + prop_assert_eq!(summary.total_bytes(), total_bytes); + prop_assert_eq!( + summary.name_length_width_bytes(), + name_length_width as usize, + ); + prop_assert_eq!( + summary.value_length_width_bytes(), + value_length_width as usize, + ); + } + } + + #[test] + fn length_width_bytes_covers_encoding_boundaries() { + assert_eq!(length_width_bytes(0), Ok(1)); + assert_eq!(length_width_bytes(1), Ok(1)); + assert_eq!(length_width_bytes(0xff), Ok(1)); + assert_eq!(length_width_bytes(0x100), Ok(2)); + assert_eq!(length_width_bytes(0xffff), Ok(2)); + assert_eq!(length_width_bytes(0x1_0000), Ok(3)); + assert_eq!(length_width_bytes(0xff_ffff), Ok(3)); + assert_eq!(length_width_bytes(0x100_0000), Ok(4)); + assert_eq!(length_width_bytes(u32::MAX as usize), Ok(4)); + + if let Some(too_long) = (u32::MAX as usize).checked_add(1) { + assert_eq!( + length_width_bytes(too_long), + Err(HeaderValidationError::TooLong) + ); + } + } +} diff --git a/common/src/record/fencing.rs b/common/src/record/fencing.rs new file mode 100644 index 00000000..62c5f72d --- /dev/null +++ b/common/src/record/fencing.rs @@ -0,0 +1,97 @@ +use std::{ops::Deref, str::FromStr}; + +use compact_str::{CompactString, ToCompactString}; + +use crate::deep_size::DeepSize; + +pub const MAX_FENCING_TOKEN_LENGTH: usize = 36; + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[error("fencing token must not exceed {MAX_FENCING_TOKEN_LENGTH} bytes in length")] +pub struct FencingTokenTooLongError(pub usize); + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct FencingToken(CompactString); + +#[cfg(feature = "utoipa")] +impl utoipa::PartialSchema for FencingToken { + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::Object::builder() + .schema_type(utoipa::openapi::Type::String) + .max_length(Some(MAX_FENCING_TOKEN_LENGTH)) + .into() + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::ToSchema for FencingToken {} + +impl serde::Serialize for FencingToken { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for FencingToken { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = CompactString::deserialize(deserializer)?; + FencingToken::try_from(s).map_err(serde::de::Error::custom) + } +} + +impl std::fmt::Display for FencingToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for FencingToken { + type Error = FencingTokenTooLongError; + + fn try_from(input: CompactString) -> Result { + if input.len() > MAX_FENCING_TOKEN_LENGTH { + return Err(FencingTokenTooLongError(input.len())); + } + Ok(FencingToken(input)) + } +} + +impl FromStr for FencingToken { + type Err = FencingTokenTooLongError; + + fn from_str(s: &str) -> Result { + s.to_compact_string().try_into() + } +} + +impl From for CompactString { + fn from(token: FencingToken) -> Self { + token.0 + } +} + +impl AsRef for FencingToken { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for FencingToken { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DeepSize for FencingToken { + fn deep_size(&self) -> usize { + self.0.len() + } +} diff --git a/common/src/record/metering.rs b/common/src/record/metering.rs new file mode 100644 index 00000000..423e6095 --- /dev/null +++ b/common/src/record/metering.rs @@ -0,0 +1,166 @@ +pub trait MeteredSize { + /// Return the metered size of a record or batch of records. + fn metered_size(&self) -> usize; +} + +impl MeteredSize for &T +where + T: MeteredSize, +{ + fn metered_size(&self) -> usize { + (**self).metered_size() + } +} + +impl MeteredSize for &[T] { + fn metered_size(&self) -> usize { + self.iter().fold(0, |acc, item| acc + item.metered_size()) + } +} + +impl MeteredSize for Vec { + fn metered_size(&self) -> usize { + self.as_slice().metered_size() + } +} + +pub trait MeteredExt: MeteredSize + Sized { + fn metered(self) -> Metered { + Metered::from(self) + } +} + +impl MeteredExt for T where T: MeteredSize {} + +pub struct Metered { + size: usize, + inner: T, +} + +impl Metered { + /// Construct a value with an already-known metered size. + /// + /// This is primarily for decoding persisted storage records, where the + /// encoded metered-size prefix is authoritative and must be preserved. + pub const fn with_size(size: usize, inner: T) -> Self { + Self { size, inner } + } + + pub fn into_inner(self) -> T { + self.inner + } + + pub const fn as_ref(&self) -> Metered<&T> { + Metered::with_size(self.size, &self.inner) + } +} + +impl std::fmt::Debug for Metered +where + T: std::fmt::Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Metered") + .field("size", &self.size) + .field("inner", &self.inner) + .finish() + } +} + +impl PartialEq for Metered +where + T: PartialEq, +{ + fn eq(&self, other: &Metered) -> bool { + self.size == other.size && self.inner == other.inner + } +} + +impl Eq for Metered where T: Eq {} + +impl std::ops::Deref for Metered { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl From for Metered +where + T: MeteredSize, +{ + fn from(inner: T) -> Self { + Self::with_size(inner.metered_size(), inner) + } +} + +impl Default for Metered +where + T: Default + MeteredSize, +{ + fn default() -> Self { + T::default().into() + } +} + +impl Clone for Metered +where + T: Clone, +{ + fn clone(&self) -> Self { + Self::with_size(self.size, self.inner.clone()) + } +} + +impl MeteredSize for Metered { + fn metered_size(&self) -> usize { + self.size + } +} + +impl Metered> +where + T: MeteredSize, +{ + pub fn with_capacity(capacity: usize) -> Self { + Self { + size: 0, + inner: Vec::with_capacity(capacity), + } + } + + pub fn push(&mut self, item: Metered) { + self.inner.push(item.inner); + self.size += item.size; + } + + pub fn append(&mut self, other: Self) { + self.inner.extend(other.inner); + self.size += other.size; + } +} + +impl FromIterator> for Metered> +where + T: MeteredSize, +{ + fn from_iter>>(iterable: I) -> Self { + let it = iterable.into_iter(); + let (cap_lower, cap_upper) = it.size_hint(); + let mut buf = Self::with_capacity(cap_upper.unwrap_or(cap_lower)); + for item in it { + buf.push(item); + } + buf + } +} + +impl IntoIterator for Metered> { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.inner.into_iter() + } +} diff --git a/common/src/record/mod.rs b/common/src/record/mod.rs new file mode 100644 index 00000000..e3fc18e6 --- /dev/null +++ b/common/src/record/mod.rs @@ -0,0 +1,304 @@ +mod command; +mod envelope; +mod fencing; +mod metering; + +use bytes::Bytes; +pub use command::{CommandOp, CommandPayloadError, CommandRecord}; +pub use envelope::{EnvelopeRecord, HeaderValidationError}; +pub use fencing::{FencingToken, FencingTokenTooLongError, MAX_FENCING_TOKEN_LENGTH}; +pub use metering::{Metered, MeteredExt, MeteredSize}; + +use crate::deep_size::DeepSize; + +pub type SeqNum = u64; +pub type NonZeroSeqNum = std::num::NonZeroU64; +pub type Timestamp = u64; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct StreamPosition { + pub seq_num: SeqNum, + pub timestamp: Timestamp, +} + +impl StreamPosition { + pub const MIN: StreamPosition = StreamPosition { + seq_num: SeqNum::MIN, + timestamp: Timestamp::MIN, + }; +} + +impl std::fmt::Display for StreamPosition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} @ {}", self.seq_num, self.timestamp) + } +} + +impl DeepSize for StreamPosition { + fn deep_size(&self) -> usize { + self.seq_num.deep_size() + self.timestamp.deep_size() + } +} + +#[derive(Debug, PartialEq, thiserror::Error)] +pub enum RecordPartsError { + #[error("unknown command")] + UnknownCommand, + #[error("invalid `{0}` command: {1}")] + CommandPayload(CommandOp, CommandPayloadError), + #[error("invalid header: {0}")] + Header(#[from] HeaderValidationError), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Header { + pub name: Bytes, + pub value: Bytes, +} + +impl DeepSize for Header { + fn deep_size(&self) -> usize { + self.name.len() + self.value.len() + } +} + +impl MeteredSize for Record { + fn metered_size(&self) -> usize { + match self { + Self::Command(command) => command.metered_size(), + Self::Envelope(envelope) => envelope.metered_size(), + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum Record { + Command(CommandRecord), + Envelope(EnvelopeRecord), +} + +impl DeepSize for Record { + fn deep_size(&self) -> usize { + match self { + Self::Command(c) => c.deep_size(), + Self::Envelope(e) => e.deep_size(), + } + } +} + +impl Record { + pub fn try_from_parts(headers: Vec
, body: Bytes) -> Result { + if headers.len() == 1 { + let header = &headers[0]; + if header.name.is_empty() { + let op = CommandOp::from_id(header.value.as_ref()) + .ok_or(RecordPartsError::UnknownCommand)?; + let command_record = CommandRecord::try_from_parts(op, body.as_ref()) + .map_err(|e| RecordPartsError::CommandPayload(op, e))?; + return Ok(Self::Command(command_record)); + } + } + let envelope = EnvelopeRecord::try_from_parts(headers, body)?; + Ok(Self::Envelope(envelope)) + } + + pub fn into_parts(self) -> (Vec
, Bytes) { + match self { + Record::Envelope(e) => e.into_parts(), + Record::Command(c) => { + let op = c.op(); + let header = Header { + name: Bytes::new(), + value: Bytes::from_static(op.to_id()), + }; + (vec![header], c.payload()) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Sequenced { + position: StreamPosition, + inner: T, +} + +impl Sequenced { + pub const fn new(position: StreamPosition, inner: T) -> Self { + Self { position, inner } + } + + pub const fn position(&self) -> &StreamPosition { + &self.position + } + + pub fn inner(&self) -> &T { + &self.inner + } + + pub fn as_ref(&self) -> Sequenced<&T> { + Sequenced::new(self.position, &self.inner) + } + + pub fn parts(&self) -> (StreamPosition, &T) { + (self.position, &self.inner) + } + + pub fn into_parts(self) -> (StreamPosition, T) { + (self.position, self.inner) + } +} + +pub type SequencedRecord = Sequenced; + +impl MeteredSize for Sequenced +where + T: MeteredSize, +{ + fn metered_size(&self) -> usize { + self.inner.metered_size() + } +} + +impl DeepSize for Sequenced +where + T: DeepSize, +{ + fn deep_size(&self) -> usize { + self.position.deep_size() + self.inner.deep_size() + } +} + +impl Metered +where + T: MeteredSize, +{ + pub fn sequenced(self, position: StreamPosition) -> Metered> { + Metered::with_size( + self.metered_size(), + Sequenced::new(position, self.into_inner()), + ) + } +} + +impl Metered> { + pub fn parts(&self) -> (StreamPosition, Metered<&T>) { + let size = self.metered_size(); + let (position, inner) = self.as_ref().into_inner().parts(); + (position, Metered::with_size(size, inner)) + } + + pub fn into_parts(self) -> (StreamPosition, Metered) { + let size = self.metered_size(); + let (position, inner) = self.into_inner().into_parts(); + (position, Metered::with_size(size, inner)) + } +} + +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::*; + + fn semantic_metered_size(record: &Record) -> usize { + let (headers, body) = record.clone().into_parts(); + 8 + (2 * headers.len()) + + headers + .iter() + .map(|header| header.name.len() + header.value.len()) + .sum::() + + body.len() + } + + #[test] + fn empty_header_name_solo() { + let headers = vec![Header { + name: Bytes::new(), + value: Bytes::from("hi"), + }]; + let body = Bytes::from("hello"); + assert_eq!( + Record::try_from_parts(headers, body), + Err(RecordPartsError::UnknownCommand) + ); + } + + #[test] + fn empty_header_name_among_others() { + let headers = vec![ + Header { + name: Bytes::from("boku"), + value: Bytes::from("hi"), + }, + Header { + name: Bytes::new(), + value: Bytes::from("hi"), + }, + ]; + let body = Bytes::from("hello"); + assert_eq!( + Record::try_from_parts(headers, body), + Err(RecordPartsError::Header(HeaderValidationError::NameEmpty)) + ); + } + + fn command_parts(op: &'static [u8], payload: &'static [u8]) -> (Vec
, Bytes) { + let headers = vec![Header { + name: Bytes::new(), + value: Bytes::from_static(op), + }]; + let body = Bytes::from_static(payload); + (headers, body) + } + + fn assert_valid_command_record(op: &'static [u8], payload: &'static [u8]) { + let (headers, body) = command_parts(op, payload); + let record = Record::try_from_parts(headers.clone(), body.clone()).unwrap(); + let record_metered = record.metered_size(); + match &record { + Record::Command(cmd) => { + assert_eq!(cmd.op().to_id(), op); + assert_eq!(cmd.payload().as_ref(), payload); + } + other => panic!("Command expected, got {other:?}"), + } + assert_eq!(record_metered, semantic_metered_size(&record)); + } + + #[rstest] + #[case::fence_empty(b"fence", b"")] + #[case::fence_uuid(b"fence", b"my-special-uuid")] + #[case::trim_0(b"trim", b"\x00\x00\x00\x00\x00\x00\x00\x00")] + fn valid_command_records(#[case] op: &'static [u8], #[case] payload: &'static [u8]) { + assert_valid_command_record(op, payload); + } + + #[rstest] + #[case::fence_too_long( + b"fence", + b"toolongtoolongtoolongtoolongtoolongtoolongtoolong", + RecordPartsError::CommandPayload( + CommandOp::Fence, + CommandPayloadError::FencingTokenTooLong(FencingTokenTooLongError(49)), + ) + )] + #[case::trim_empty( + b"trim", + b"", + RecordPartsError::CommandPayload(CommandOp::Trim, CommandPayloadError::TrimPointSize(0),) + )] + #[case::trim_overflow( + b"trim", + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00", + RecordPartsError::CommandPayload(CommandOp::Trim, CommandPayloadError::TrimPointSize(9),) + )] + fn invalid_command_records( + #[case] op: &'static [u8], + #[case] payload: &'static [u8], + #[case] expected: RecordPartsError, + ) { + let (headers, body) = command_parts(op, payload); + assert_eq!(Record::try_from_parts(headers, body), Err(expected)); + } +} diff --git a/common/src/resources.rs b/common/src/resources.rs new file mode 100644 index 00000000..b86fa0df --- /dev/null +++ b/common/src/resources.rs @@ -0,0 +1,234 @@ +use std::{fmt::Debug, num::NonZeroUsize, ops::Deref, str::FromStr}; + +use compact_str::{CompactString, ToCompactString}; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Page { + pub values: Vec, + pub has_more: bool, +} + +impl Page { + pub fn new_empty() -> Self { + Self { + values: Vec::new(), + has_more: false, + } + } + + pub fn new(values: impl Into>, has_more: bool) -> Self { + Self { + values: values.into(), + has_more, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ListLimit(NonZeroUsize); + +impl ListLimit { + pub const MAX: ListLimit = Self(NonZeroUsize::new(1000).unwrap()); + + pub fn get(&self) -> NonZeroUsize { + self.0 + } + + pub fn as_usize(&self) -> usize { + self.0.get() + } +} + +impl Default for ListLimit { + fn default() -> Self { + Self::MAX + } +} + +impl From for ListLimit { + fn from(value: usize) -> Self { + NonZeroUsize::new(value) + .and_then(|n| (n <= Self::MAX.0).then_some(Self(n))) + .unwrap_or_default() + } +} + +impl From for usize { + fn from(value: ListLimit) -> Self { + value.as_usize() + } +} + +#[derive(Debug, Clone, Default)] +pub struct ListItemsRequest { + pub prefix: P, + pub start_after: S, + pub limit: ListLimit, +} + +/// Mode for provisioning a resource. +/// +/// Provisioning either creates a new resource with create-only semantics, or ensures that +/// a resource exists with the requested config. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProvisionMode { + /// Create a new resource only. + /// + /// HTTP POST semantics: idempotent if a request token is provided and the resource was + /// previously created using the same token and config. + CreateOnly { + /// Optional request token used to make create retries idempotent. + request_token: Option, + }, + /// Ensure a resource exists with the requested config. + /// + /// HTTP PUT semantics: always idempotent. Defaults are applied before validation. When the + /// resource already exists, its stored config is set to the effective requested config unless + /// it already matches. + Ensure, +} + +/// Result of provisioning a resource. +/// +/// Indicates whether provisioning created, updated, or skipped writing a resource. +/// All variants hold the resource's current state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProvisionResult { + /// Resource was newly created. + Created(T), + /// Resource already existed and now matches the requested config. + Updated(T), + /// Resource already existed and no write was performed. + Noop(T), +} + +impl ProvisionResult { + /// Borrow the inner value regardless of variant. + pub fn inner(&self) -> &T { + match self { + Self::Created(t) | Self::Updated(t) | Self::Noop(t) => t, + } + } + + /// Unwrap the inner value regardless of variant. + pub fn into_inner(self) -> T { + match self { + Self::Created(t) | Self::Updated(t) | Self::Noop(t) => t, + } + } + + /// Map the inner value while preserving the provisioning outcome. + pub fn map(self, f: impl FnOnce(T) -> U) -> ProvisionResult { + match self { + Self::Created(t) => ProvisionResult::Created(f(t)), + Self::Updated(t) => ProvisionResult::Updated(f(t)), + Self::Noop(t) => ProvisionResult::Noop(f(t)), + } + } + + /// Fallibly map the inner value while preserving the provisioning outcome. + pub fn try_map(self, f: impl FnOnce(T) -> Result) -> Result, E> { + match self { + Self::Created(t) => Ok(ProvisionResult::Created(f(t)?)), + Self::Updated(t) => Ok(ProvisionResult::Updated(f(t)?)), + Self::Noop(t) => Ok(ProvisionResult::Noop(f(t)?)), + } + } +} +pub static REQUEST_TOKEN_HEADER: http::HeaderName = + http::HeaderName::from_static("s2-request-token"); + +pub static PROVISION_RESULT_HEADER: http::HeaderName = + http::HeaderName::from_static("s2-provision-result"); + +pub const MAX_REQUEST_TOKEN_LENGTH: usize = 36; + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[error("request token was longer than {MAX_REQUEST_TOKEN_LENGTH} bytes in length: {0}")] +pub struct RequestTokenTooLongError(pub usize); + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct RequestToken(CompactString); + +#[cfg(feature = "utoipa")] +impl utoipa::PartialSchema for RequestToken { + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::Object::builder() + .schema_type(utoipa::openapi::Type::String) + .max_length(Some(MAX_REQUEST_TOKEN_LENGTH)) + .into() + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::ToSchema for RequestToken {} + +impl serde::Serialize for RequestToken { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for RequestToken { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = CompactString::deserialize(deserializer)?; + RequestToken::try_from(s).map_err(serde::de::Error::custom) + } +} + +impl std::fmt::Display for RequestToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for RequestToken { + type Error = RequestTokenTooLongError; + + fn try_from(input: CompactString) -> Result { + if input.len() > MAX_REQUEST_TOKEN_LENGTH { + return Err(RequestTokenTooLongError(input.len())); + } + Ok(RequestToken(input)) + } +} + +impl FromStr for RequestToken { + type Err = RequestTokenTooLongError; + + fn from_str(s: &str) -> Result { + s.to_compact_string().try_into() + } +} + +impl From for CompactString { + fn from(token: RequestToken) -> Self { + token.0 + } +} + +impl AsRef for RequestToken { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for RequestToken { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl crate::http::ParseableHeader for RequestToken { + fn name() -> &'static http::HeaderName { + &REQUEST_TOKEN_HEADER + } +} diff --git a/common/src/stream.rs b/common/src/stream.rs new file mode 100644 index 00000000..8f6df6f8 --- /dev/null +++ b/common/src/stream.rs @@ -0,0 +1,457 @@ +use std::{marker::PhantomData, ops::Deref, str::FromStr, time::Duration}; + +use compact_str::{CompactString, ToCompactString}; +use time::OffsetDateTime; + +use super::{ + ValidationError, + strings::{NameProps, PrefixProps, StartAfterProps, StrProps}, +}; +use crate::{ + caps, + encryption::EncryptionAlgorithm, + read_extent::{ReadLimit, ReadUntil}, + record::{ + FencingToken, Metered, MeteredSize, Record, SeqNum, Sequenced, StreamPosition, Timestamp, + }, + resources::ListItemsRequest, +}; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + feature = "rkyv", + derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize) +)] +pub struct StreamNameStr(CompactString, PhantomData); + +impl StreamNameStr { + fn validate_str(name: &str) -> Result<(), ValidationError> { + if !T::IS_PREFIX && name.is_empty() { + return Err(format!("stream {} must not be empty", T::FIELD_NAME).into()); + } + + if !T::IS_PREFIX && (name == "." || name == "..") { + return Err(format!("stream {} must not be \".\" or \"..\"", T::FIELD_NAME).into()); + } + + if name.contains('\0') { + return Err(format!("stream {} must not contain NUL bytes", T::FIELD_NAME).into()); + } + + if name.len() > caps::MAX_STREAM_NAME_LEN { + return Err(format!( + "stream {} must not exceed {} bytes in length", + T::FIELD_NAME, + caps::MAX_STREAM_NAME_LEN + ) + .into()); + } + + Ok(()) + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::PartialSchema for StreamNameStr +where + T: StrProps, +{ + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::Object::builder() + .schema_type(utoipa::openapi::Type::String) + .min_length((!T::IS_PREFIX).then_some(caps::MIN_STREAM_NAME_LEN)) + .max_length(Some(caps::MAX_STREAM_NAME_LEN)) + .into() + } +} + +#[cfg(feature = "utoipa")] +impl utoipa::ToSchema for StreamNameStr where T: StrProps {} + +impl serde::Serialize for StreamNameStr { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de, T: StrProps> serde::Deserialize<'de> for StreamNameStr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = CompactString::deserialize(deserializer)?; + s.try_into().map_err(serde::de::Error::custom) + } +} + +impl AsRef for StreamNameStr { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for StreamNameStr { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom for StreamNameStr { + type Error = ValidationError; + + fn try_from(name: CompactString) -> Result { + Self::validate_str(&name)?; + Ok(Self(name, PhantomData)) + } +} + +impl FromStr for StreamNameStr { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + Self::validate_str(s)?; + Ok(Self(s.to_compact_string(), PhantomData)) + } +} + +impl std::fmt::Debug for StreamNameStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::fmt::Display for StreamNameStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From> for CompactString { + fn from(value: StreamNameStr) -> Self { + value.0 + } +} + +pub type StreamName = StreamNameStr; + +pub type StreamNamePrefix = StreamNameStr; + +impl Default for StreamNamePrefix { + fn default() -> Self { + StreamNameStr(CompactString::default(), PhantomData) + } +} + +impl From for StreamNamePrefix { + fn from(value: StreamName) -> Self { + Self(value.0, PhantomData) + } +} + +pub type StreamNameStartAfter = StreamNameStr; + +impl Default for StreamNameStartAfter { + fn default() -> Self { + StreamNameStr(CompactString::default(), PhantomData) + } +} + +impl From for StreamNameStartAfter { + fn from(value: StreamName) -> Self { + Self(value.0, PhantomData) + } +} + +#[derive(Debug, Clone)] +pub struct StreamInfo { + pub name: StreamName, + pub created_at: OffsetDateTime, + pub deleted_at: Option, + pub cipher: Option, +} + +#[derive(Debug, Clone)] +pub struct AppendRecord(AppendRecordParts); + +impl AppendRecord { + pub fn parts(&self) -> &AppendRecordParts { + let Self(parts) = self; + parts + } + + pub fn into_parts(self) -> AppendRecordParts { + let Self(parts) = self; + parts + } +} + +impl MeteredSize for AppendRecord { + fn metered_size(&self) -> usize { + self.0.record.metered_size() + } +} + +#[derive(Debug, Clone)] +pub struct AppendRecordParts { + pub timestamp: Option, + pub record: Metered, +} + +impl MeteredSize for AppendRecordParts { + fn metered_size(&self) -> usize { + self.record.metered_size() + } +} + +impl From> for AppendRecordParts { + fn from(record: AppendRecord) -> Self { + record.into_parts() + } +} + +impl TryFrom> for AppendRecord { + type Error = &'static str; + + fn try_from(parts: AppendRecordParts) -> Result { + if parts.metered_size() > caps::RECORD_BATCH_MAX.bytes { + Err("record must have metered size less than 1 MiB") + } else { + Ok(Self(parts)) + } + } +} + +#[derive(Clone)] +pub struct AppendRecordBatch(Metered>>); + +impl std::fmt::Debug for AppendRecordBatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AppendRecordBatch") + .field("num_records", &self.0.len()) + .field("metered_size", &self.0.metered_size()) + .finish() + } +} + +impl MeteredSize for AppendRecordBatch { + fn metered_size(&self) -> usize { + self.0.metered_size() + } +} + +impl std::ops::Deref for AppendRecordBatch { + type Target = [AppendRecord]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom>>> for AppendRecordBatch { + type Error = &'static str; + + fn try_from(records: Metered>>) -> Result { + if records.is_empty() { + return Err("record batch must not be empty"); + } + + if records.len() > caps::RECORD_BATCH_MAX.count { + return Err("record batch must not exceed 1000 records"); + } + + if records.metered_size() > caps::RECORD_BATCH_MAX.bytes { + return Err("record batch must not exceed a metered size of 1 MiB"); + } + + Ok(Self(records)) + } +} + +impl TryFrom>> for AppendRecordBatch { + type Error = &'static str; + + fn try_from(records: Vec>) -> Result { + let records = Metered::from(records); + Self::try_from(records) + } +} + +impl IntoIterator for AppendRecordBatch { + type Item = AppendRecord; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +#[derive(Debug, Clone)] +pub struct AppendInput { + pub records: AppendRecordBatch, + pub match_seq_num: Option, + pub fencing_token: Option, +} + +#[derive(Debug, Clone)] +pub struct AppendAck { + pub start: StreamPosition, + pub end: StreamPosition, + pub tail: StreamPosition, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadPosition { + SeqNum(SeqNum), + Timestamp(Timestamp), +} + +#[derive(Debug, Clone, Copy)] +pub enum ReadFrom { + SeqNum(SeqNum), + Timestamp(Timestamp), + TailOffset(u64), +} + +impl Default for ReadFrom { + fn default() -> Self { + Self::SeqNum(0) + } +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct ReadStart { + pub from: ReadFrom, + pub clamp: bool, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct ReadEnd { + pub limit: ReadLimit, + pub until: ReadUntil, + pub wait: Option, +} + +impl ReadEnd { + pub fn may_follow(&self) -> bool { + (self.limit.is_unbounded() && self.until.is_unbounded()) + || self.wait.is_some_and(|d| d > Duration::ZERO) + } +} + +#[derive(Clone)] +pub struct ReadBatch { + pub records: Metered>>, + pub tail: Option, +} + +impl Default for ReadBatch +where + T: MeteredSize, +{ + fn default() -> Self { + Self { + records: Metered::default(), + tail: None, + } + } +} + +impl std::fmt::Debug for ReadBatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReadBatch") + .field("num_records", &self.records.len()) + .field("metered_size", &self.records.metered_size()) + .field("tail", &self.tail) + .finish() + } +} + +#[derive(Debug, Clone)] +pub enum ReadSessionOutput { + Heartbeat(StreamPosition), + Batch(ReadBatch), +} + +pub type ListStreamsRequest = ListItemsRequest; + +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::{ + super::strings::{NameProps, PrefixProps, StartAfterProps}, + *, + }; + + #[rstest] + #[case::normal("my-stream".to_owned())] + #[case::control_chars("a\tb\nc\rd\x01e".to_owned())] + #[case::unicode("stream/名前 😀?#%20".to_owned())] + #[case::max_len("a".repeat(crate::caps::MAX_STREAM_NAME_LEN))] + fn validate_name_ok(#[case] name: String) { + assert_eq!(StreamNameStr::::validate_str(&name), Ok(())); + } + + #[rstest] + #[case::empty("".to_owned())] + #[case::dot(".".to_owned())] + #[case::dot_dot("..".to_owned())] + #[case::too_long("a".repeat(crate::caps::MAX_STREAM_NAME_LEN + 1))] + #[case::nul("a\0b".to_owned())] + #[case::leading_nul("\0a".to_owned())] + #[case::trailing_nul("a\0".to_owned())] + #[case::only_nul("\0".to_owned())] + fn validate_name_err(#[case] name: String) { + StreamNameStr::::validate_str(&name).expect_err("expected validation error"); + } + + #[rstest] + #[case::empty("".to_owned())] + #[case::dot(".".to_owned())] + #[case::dot_dot("..".to_owned())] + #[case::max_len("a".repeat(crate::caps::MAX_STREAM_NAME_LEN))] + fn validate_prefix_ok(#[case] prefix: String) { + assert_eq!(StreamNameStr::::validate_str(&prefix), Ok(())); + } + + #[rstest] + #[case::too_long("a".repeat(crate::caps::MAX_STREAM_NAME_LEN + 1))] + #[case::nul("a\0b".to_owned())] + #[case::only_nul("\0".to_owned())] + fn validate_prefix_err(#[case] prefix: String) { + StreamNameStr::::validate_str(&prefix).expect_err("expected validation error"); + } + + #[rstest] + #[case::empty("".to_owned())] + #[case::dot(".".to_owned())] + #[case::dot_dot("..".to_owned())] + #[case::max_len("a".repeat(crate::caps::MAX_STREAM_NAME_LEN))] + fn validate_start_after_ok(#[case] start_after: String) { + assert_eq!( + StreamNameStr::::validate_str(&start_after), + Ok(()) + ); + } + + #[rstest] + #[case::too_long("a".repeat(crate::caps::MAX_STREAM_NAME_LEN + 1))] + #[case::nul("a\0b".to_owned())] + #[case::only_nul("\0".to_owned())] + fn validate_start_after_err(#[case] start_after: String) { + StreamNameStr::::validate_str(&start_after) + .expect_err("expected validation error"); + } + + #[test] + fn append_record_batch_rejects_empty_batches() { + let empty_batch: Result = Vec::::new().try_into(); + + assert_eq!(empty_batch.unwrap_err(), "record batch must not be empty"); + } +} diff --git a/common/src/strings.rs b/common/src/strings.rs new file mode 100644 index 00000000..3bed2c44 --- /dev/null +++ b/common/src/strings.rs @@ -0,0 +1,36 @@ +pub trait StrProps: std::fmt::Debug + Clone { + const IS_PREFIX: bool; + const FIELD_NAME: &'static str; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct NameProps; + +impl StrProps for NameProps { + const IS_PREFIX: bool = false; + const FIELD_NAME: &'static str = "name"; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct IdProps; + +impl StrProps for IdProps { + const IS_PREFIX: bool = false; + const FIELD_NAME: &'static str = "id"; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PrefixProps; + +impl StrProps for PrefixProps { + const IS_PREFIX: bool = true; + const FIELD_NAME: &'static str = "prefix"; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct StartAfterProps; + +impl StrProps for StartAfterProps { + const IS_PREFIX: bool = true; + const FIELD_NAME: &'static str = "start-after"; +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..24aac6b4 --- /dev/null +++ b/deny.toml @@ -0,0 +1,45 @@ +[graph] +all-features = true + +[advisories] +ignore = [ + # Transitive deps via slatedb/foyer — not actionable. + "RUSTSEC-2024-0436", # paste unmaintained + "RUSTSEC-2025-0141", # bincode unmaintained + # Transitive dep via object_store (slatedb) — fix requires object_store >=0.13. + "RUSTSEC-2026-0194", # quick-xml quadratic attribute check + "RUSTSEC-2026-0195", # quick-xml unbounded namespace allocation +] + +[licenses] +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MPL-2.0", + "OpenSSL", + "Unicode-3.0", + "Zlib", +] + +[bans] +multiple-versions = "warn" +deny = [ + { crate = "openssl", use-instead = "rustls-tls" }, + { crate = "openssl-sys", use-instead = "rustls-tls" }, + # TODO { crate = "ring", use-instead = "aws-lc-rs" }, +] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-git = [ + "https://github.com/slatedb/slatedb", + "https://github.com/infiniteregrets/utoipa", +] diff --git a/docs/adr/csek.md b/docs/adr/csek.md new file mode 100644 index 00000000..244cbc61 --- /dev/null +++ b/docs/adr/csek.md @@ -0,0 +1,18 @@ +# Client-supplied Encryption Key + +Encryption algorithms supported: `aegis-256`, `aes-256-gcm`. + +At the basin level, users configure the encryption algorithm (or none, the default) to apply to newly created streams in that basin. + +New streams record this algorithm in their metadata when created. It is immutable for the lifetime of a stream and cannot be reconfigured. + +Data plane `append` and `read` operations look for the `s2-encryption-key` header, where it must be provided as a base64 string if encryption is enabled. + +Data plane operations treat the `s2-encryption-key` header as opaque base64-encoded key material. If we need wrapped or structured key material in future, that may be introduced as a format discriminator. + +The encryption key should stay consistent for a given stream, but this is not enforced by the service. +- If no key is provided when required, the `append` or `read` operation will fail. +- The same key used to encrypt when appending must be used when reading records. +- Appends will succeed even if a different key is provided than previously used. + +Encryption algorithm is one of the pieces of stream-level metadata returned when listing streams. diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100755 index 00000000..192d1c1c --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# LINK ME (from the root of the repo): ln -sf `pwd`/hooks/pre-commit .git/hooks/pre-commit + +function save_unstaged_changes() { + staged_changes_diff=$(mktemp -t format_patch.XXXXXX) + set +e + git diff --ignore-submodules --binary --exit-code --no-color >"${staged_changes_diff}" + if [ $? -eq 1 ]; then + echo "Found unstaged changes, storing in ${staged_changes_diff}" + echo "Clearing unstaged changes for formatting, will restore after." + git checkout -- "${root_dir}" + stored_unstaged_changes=true + fi +} + +function restore_unstaged_changes() { + echo "Restoring unstaged changes" + set +e + git apply "${staged_changes_diff}" + if [ $? -eq 1 ]; then + echo "Failed to re-apply unstaged changes with \`git apply\`." + echo "The patch is preserved at ${staged_changes_diff}" + fi +} + +function refine_rust() { + if ! cargo -V &>/dev/null; then + echo "cargo could not be found, please install it from https://rustup.rs/" + exit 1 + fi + if ! cargo +nightly -V &>/dev/null; then + echo "cargo +nightly could not be found, please install it with: rustup toolchain install nightly" + exit 1 + fi + + rust_staged_files=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.rs$') + if [ -z "$rust_staged_files" ]; then + return + fi + + echo "Executing: cargo fmt" + for file in $rust_staged_files; do + cargo +nightly fmt -- "$file" + git add "$file" + done +} + +stored_unstaged_changes=false +root_dir="$(git rev-parse --show-toplevel)" +if [ ! -d "${root_dir}" ]; then + echo "${root_dir} is not a directory" + exit 1 +fi + +echo "Running pre-commit hooks" + +save_unstaged_changes + +set -o pipefail + +refine_rust + +if ${stored_unstaged_changes}; then + restore_unstaged_changes +fi diff --git a/index.yaml b/index.yaml deleted file mode 100644 index 7884dd27..00000000 --- a/index.yaml +++ /dev/null @@ -1,1610 +0,0 @@ -apiVersion: v1 -entries: - s2-lite-helm: - - apiVersion: v2 - appVersion: 0.43.1 - created: "2026-09-28T19:58:48.100096487Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 0f6e00ac79de164c0a70435175aa2cf68e81370d756cb261144bb410c4370993 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.72/s2-lite-helm-0.1.72.tgz - version: 0.1.72 - - apiVersion: v2 - appVersion: 0.43.0 - created: "2026-09-25T23:55:37.206666326Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 2ab91671670ae5981fb1d52088f49df19d23eb9d6d1eb542ffc73520cd980301 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.71/s2-lite-helm-0.1.71.tgz - version: 0.1.71 - - apiVersion: v2 - appVersion: 0.42.14 - created: "2026-09-24T14:54:41.835377483Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 90e3813f4717aa2c799fe461e724a1b98526c7017b04af8bfb72642194bc11b5 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.70/s2-lite-helm-0.1.70.tgz - version: 0.1.70 - - apiVersion: v2 - appVersion: 0.42.13 - created: "2026-09-22T17:10:21.033906461Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: fb826d8df806df5d4250d19c5053e1aad3d147116338cbaf3363178a1b2ddd9c - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.69/s2-lite-helm-0.1.69.tgz - version: 0.1.69 - - apiVersion: v2 - appVersion: 0.42.12 - created: "2026-09-18T04:30:28.115638234Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: d56e72357718fe84c8c99e4f1970df4b14223d7b5866b8fc128693216a8089c2 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.68/s2-lite-helm-0.1.68.tgz - version: 0.1.68 - - apiVersion: v2 - appVersion: 0.42.11 - created: "2026-09-11T16:19:45.600879116Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 807da395e7ba8b6c46108341f40e5836f30dee8f08bb7a58362715e89b515daa - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.67/s2-lite-helm-0.1.67.tgz - version: 0.1.67 - - apiVersion: v2 - appVersion: 0.42.10 - created: "2026-09-11T00:56:19.049312899Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 567f3090a5a63e035dbf864989c88efd4e1910e28231831fe8e5691c8158d900 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.66/s2-lite-helm-0.1.66.tgz - version: 0.1.66 - - apiVersion: v2 - appVersion: 0.42.9 - created: "2026-09-10T01:12:16.134398203Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: c57118774bebf676dd50b1781b7d260d258dfe128f88662b87894c9e4c6e4dbe - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.65/s2-lite-helm-0.1.65.tgz - version: 0.1.65 - - apiVersion: v2 - appVersion: 0.42.8 - created: "2026-09-03T02:43:27.933173757Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 396043e932fd2274b0ef5c42515a4077ef2d7e17e640b7d61b20edfe161582ba - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.64/s2-lite-helm-0.1.64.tgz - version: 0.1.64 - - apiVersion: v2 - appVersion: 0.42.7 - created: "2026-08-18T20:30:43.979688201Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 3cad9ba5e9653de684a91988133f4c3ac3b715ac9171c01d9b05d8e2317ac7e4 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.63/s2-lite-helm-0.1.63.tgz - version: 0.1.63 - - apiVersion: v2 - appVersion: 0.42.6 - created: "2026-08-13T19:01:13.996570924Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: e8c8c669a8d76eb3ffa29145a4df881b5ecada66a2e110b7610217d8efdf8ad1 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.62/s2-lite-helm-0.1.62.tgz - version: 0.1.62 - - apiVersion: v2 - appVersion: 0.42.5 - created: "2026-08-07T03:43:41.474548645Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: e52048cda3b7bc819e9e60d5918950614a4717af65856c5b2f9c21b175ee8c98 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.61/s2-lite-helm-0.1.61.tgz - version: 0.1.61 - - apiVersion: v2 - appVersion: 0.42.4 - created: "2026-08-07T00:56:17.551438443Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: ef5cf59eafae852f1a449e835eeee4f4d975c58f59845e01546376e02a038cfb - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.60/s2-lite-helm-0.1.60.tgz - version: 0.1.60 - - apiVersion: v2 - appVersion: 0.42.3 - created: "2026-08-05T23:05:50.604436323Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: b4a4996e737cece1c717a52861b0babe4545ed59474a60862d7edd9fcc38f103 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.59/s2-lite-helm-0.1.59.tgz - version: 0.1.59 - - apiVersion: v2 - appVersion: 0.42.2 - created: "2026-08-05T21:46:22.848815855Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: dd80339ad26cd71e0e67feb3bbe82bac14d457913accaa4fb21726d0bd525457 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.58/s2-lite-helm-0.1.58.tgz - version: 0.1.58 - - apiVersion: v2 - appVersion: 0.42.1 - created: "2026-08-01T01:14:01.150263472Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 0a8983d3d232a63f0c7ae814073fed590d071a183349013fbaa62c98f45319fe - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.57/s2-lite-helm-0.1.57.tgz - version: 0.1.57 - - apiVersion: v2 - appVersion: 0.42.0 - created: "2026-07-31T19:18:11.609502392Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 825117ec33b48ac181ff4da276607c20881a675200e2754cd0bb8cbefa26c1b8 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.56/s2-lite-helm-0.1.56.tgz - version: 0.1.56 - - apiVersion: v2 - appVersion: 0.41.2 - created: "2026-07-28T15:33:58.098334188Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 47a60d9e10c8d5c9363faa6052f7d7ab35879e7ae78e9756dbff09d38112d5fc - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.55/s2-lite-helm-0.1.55.tgz - version: 0.1.55 - - apiVersion: v2 - appVersion: 0.41.1 - created: "2026-07-24T14:39:38.48060188Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: b339c1e1b61253d16ddecfbeeaa6cb09f3a55b83cf00b7a9eb4e667be3cf3e9d - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.54/s2-lite-helm-0.1.54.tgz - version: 0.1.54 - - apiVersion: v2 - appVersion: 0.41.0 - created: "2026-07-23T04:35:52.453415322Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 2d9cbb42b6c277cca696ff502a6978be2dc2ed3cf6803219623f0c7dc0d9e30a - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.53/s2-lite-helm-0.1.53.tgz - version: 0.1.53 - - apiVersion: v2 - appVersion: 0.40.1 - created: "2026-07-23T03:17:18.036094523Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 6ac01c8edcb074b3b0a2663e7351d8917e39ac9f0fb0b5dbea7376b857de42b6 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.52/s2-lite-helm-0.1.52.tgz - version: 0.1.52 - - apiVersion: v2 - appVersion: 0.40.0 - created: "2026-07-22T17:38:17.671721642Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: ed98ff8a2ac22a3a65a470dea8a8ee52c8acdf02000bdcba985c51e05ef2fd31 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.51/s2-lite-helm-0.1.51.tgz - version: 0.1.51 - - apiVersion: v2 - appVersion: 0.39.3 - created: "2026-07-17T03:19:06.516556926Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: de5aee94e093b1fb391e7581e5fbfbf5c390ef72be1507aafa6e96a50091124c - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.50/s2-lite-helm-0.1.50.tgz - version: 0.1.50 - - apiVersion: v2 - appVersion: 0.39.2 - created: "2026-07-16T20:11:52.562205138Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: de04a2d0ead8c67b0c84a89b3da910a00ebc929b04860836c13471d51dacb431 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.49/s2-lite-helm-0.1.49.tgz - version: 0.1.49 - - apiVersion: v2 - appVersion: 0.39.1 - created: "2026-07-07T23:06:32.053609799Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 5e6ac85d110f713fcb90facb351af0912a0783cd46db9862f4590ebfe02b5ba5 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.48/s2-lite-helm-0.1.48.tgz - version: 0.1.48 - - apiVersion: v2 - appVersion: 0.39.0 - created: "2026-07-06T03:23:03.35355706Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 0d62fcf90bb1169ed855e37785b90608a5f1e6b91d021c8bf15774dfa701cbf0 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.47/s2-lite-helm-0.1.47.tgz - version: 0.1.47 - - apiVersion: v2 - appVersion: 0.38.0 - created: "2026-07-02T18:16:24.122392338Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: e3601eacf123e613d6ae1324841b89be7252705aab6062d1a9ae60bc6b80d59f - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.46/s2-lite-helm-0.1.46.tgz - version: 0.1.46 - - apiVersion: v2 - appVersion: 0.37.1 - created: "2026-06-22T19:19:41.216166383Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: d2c8e40b3c0ba2d6152a7966be3a8134a5f5789224475ad7694d67cc97c252e3 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.45/s2-lite-helm-0.1.45.tgz - version: 0.1.45 - - apiVersion: v2 - appVersion: 0.37.0 - created: "2026-06-22T16:45:32.839613294Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 05f7222981cd309070a0b61408d5a390531a0675604953bc1dec1828248f0de6 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.44/s2-lite-helm-0.1.44.tgz - version: 0.1.44 - - apiVersion: v2 - appVersion: 0.36.8 - created: "2026-06-15T16:32:00.442006116Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 80b86940d13cc3f6b169b64184c0f28634e91c2b03aabcfd3379293ceb374296 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.43/s2-lite-helm-0.1.43.tgz - version: 0.1.43 - - apiVersion: v2 - appVersion: 0.36.7 - created: "2026-06-13T15:48:49.381450508Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 5b6e63cf2ba7aac53ea404eafe9bb13ee84475eb49ce58ff70bb6bc93c340b1d - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.42/s2-lite-helm-0.1.42.tgz - version: 0.1.42 - - apiVersion: v2 - appVersion: 0.36.6 - created: "2026-06-12T19:27:58.177317028Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: f4cbdad694df6c76673dab9de8eec1a673ece90612c4ba41cf1413c9a332de15 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.41/s2-lite-helm-0.1.41.tgz - version: 0.1.41 - - apiVersion: v2 - appVersion: 0.36.5 - created: "2026-06-11T00:44:24.732296308Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: db018100b5cc6526bc4522504d791b83b0c11977ea3ccb870fd323ac290493ca - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.40/s2-lite-helm-0.1.40.tgz - version: 0.1.40 - - apiVersion: v2 - appVersion: 0.36.4 - created: "2026-06-10T21:37:04.022679268Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: adc55c7ea2656c4caf69854ba4344bba153642ecb1d17488096c915fcf543360 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.39/s2-lite-helm-0.1.39.tgz - version: 0.1.39 - - apiVersion: v2 - appVersion: 0.36.3 - created: "2026-06-10T17:03:28.297451089Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 86ff395c03429f05aa514d7f68c0a4aee6afa9f5b5336cfa8f1a3c82c512fac9 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.38/s2-lite-helm-0.1.38.tgz - version: 0.1.38 - - apiVersion: v2 - appVersion: 0.36.2 - created: "2026-06-02T18:56:48.030544875Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: a470c14739de9103d5ecebe905160393351a612d6a8037a505455bb590849905 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.37/s2-lite-helm-0.1.37.tgz - version: 0.1.37 - - apiVersion: v2 - appVersion: 0.36.1 - created: "2026-05-29T20:12:16.395217023Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 6642e8454dd3dea7247ef88583a48ceda1dd15990d22a6792fcad877c43dac22 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.36/s2-lite-helm-0.1.36.tgz - version: 0.1.36 - - apiVersion: v2 - appVersion: 0.36.0 - created: "2026-05-20T23:53:07.121901891Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: b654f63bd041a939a95151e82f8e554f22c1f41ba2fe16112f2cca5cdbe84a94 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.35/s2-lite-helm-0.1.35.tgz - version: 0.1.35 - - apiVersion: v2 - appVersion: 0.35.1 - created: "2026-05-20T20:39:44.044583092Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 6bb673a3d6467abb5a931722d44fa037280d7f75e98e96f0632f09e06bda632e - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.34/s2-lite-helm-0.1.34.tgz - version: 0.1.34 - - apiVersion: v2 - appVersion: 0.35.0 - created: "2026-05-20T01:04:56.070285818Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 8b1fd287a1066b0b277d3cb5a8b7c8516c9104d3bb1bc0710e4c4e8960ce00f5 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.33/s2-lite-helm-0.1.33.tgz - version: 0.1.33 - - apiVersion: v2 - appVersion: 0.33.0 - created: "2026-05-15T23:21:00.587789535Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: c994db0c9a27efae56a8350c3deb87d4daa79dd6eb9f5e16b3521caf2f832963 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.32/s2-lite-helm-0.1.32.tgz - version: 0.1.32 - - apiVersion: v2 - appVersion: 0.32.0 - created: "2026-05-14T20:32:16.781258131Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: b6a7a131e7651be34fed62ecdb0a6908612fc9535721cf23cd1eec09cf058d7d - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.31/s2-lite-helm-0.1.31.tgz - version: 0.1.31 - - apiVersion: v2 - appVersion: 0.31.0 - created: "2026-05-10T20:55:43.424592868Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: bf86e3cf95da733da22dde016ad0659a6816a155cfb93a028fa7bdb481e1f28e - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.30/s2-lite-helm-0.1.30.tgz - version: 0.1.30 - - apiVersion: v2 - appVersion: 0.30.6 - created: "2026-05-04T15:14:43.93717658Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: ed45830ba1bb8d06b5519f8bdd2eead54e48d8d79bc73195cc36774ecf6c5580 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.29/s2-lite-helm-0.1.29.tgz - version: 0.1.29 - - apiVersion: v2 - appVersion: 0.30.5 - created: "2026-04-27T21:05:47.767213565Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 9b12edf01174b776474e145477f67bc413273f88844725fca7e9b887d9f93ae7 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.28/s2-lite-helm-0.1.28.tgz - version: 0.1.28 - - apiVersion: v2 - appVersion: 0.30.4 - created: "2026-04-24T19:51:30.131654818Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: c1996cfecdd9281143ab6b70556bb5addf35a90d19a3a4ee1b15b931c984dcef - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.27/s2-lite-helm-0.1.27.tgz - version: 0.1.27 - - apiVersion: v2 - appVersion: 0.30.3 - created: "2026-04-22T03:36:23.612149628Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 0fc381addb2ced1d1794b9b1dd8d571fe51b26493e2ebe8d85664f1f6886b6bb - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.26/s2-lite-helm-0.1.26.tgz - version: 0.1.26 - - apiVersion: v2 - appVersion: 0.30.2 - created: "2026-04-21T17:59:02.008037037Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 3a7c848c411cad7ddd4a8c71ed537f286f162e407eda4f69cd111f992c7901cb - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.25/s2-lite-helm-0.1.25.tgz - version: 0.1.25 - - apiVersion: v2 - appVersion: 0.30.1 - created: "2026-04-20T19:44:45.017296742Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: b258257d7cdbec2e9ba51d85e14e57cb5491bb2bb27003b1a182c00c25beac8d - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.24/s2-lite-helm-0.1.24.tgz - version: 0.1.24 - - apiVersion: v2 - appVersion: 0.30.0 - created: "2026-04-20T03:35:05.770605402Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: c6506a2ed84b3066e75b519c2442d7b61c3c936b04f8fc0335c4705df858c1d6 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.23/s2-lite-helm-0.1.23.tgz - version: 0.1.23 - - apiVersion: v2 - appVersion: 0.29.32 - created: "2026-04-17T01:58:18.884360734Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 5e65495f70c7eb6795c98d3a773ed1bcf154e1af1813524e2264327e057c1e2a - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.22/s2-lite-helm-0.1.22.tgz - version: 0.1.22 - - apiVersion: v2 - appVersion: 0.29.31 - created: "2026-04-16T18:57:09.864846521Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: a334ff55c0df6e8d170a79773a679cf668a65ab98e789830e181d3200c6b378a - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.21/s2-lite-helm-0.1.21.tgz - version: 0.1.21 - - apiVersion: v2 - appVersion: 0.29.28 - created: "2026-04-14T21:20:43.208752671Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 2c5adff251061041f95b4f0e86379b8932e9bbbdd27f95da0bc54294822e34f9 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.20/s2-lite-helm-0.1.20.tgz - version: 0.1.20 - - apiVersion: v2 - appVersion: 0.29.27 - created: "2026-03-27T16:51:19.949537694Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: bb82421f6d2095951a7d1ce6e4311a6954520127f56c3cbde85627f6221c0ad2 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.19/s2-lite-helm-0.1.19.tgz - version: 0.1.19 - - apiVersion: v2 - appVersion: 0.29.27 - created: "2026-03-21T16:20:02.438378775Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: eab27302e2e6dd4bf282742d615197b02f8d63ec9ab2902ea51c5e512a0ef794 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.18/s2-lite-helm-0.1.18.tgz - version: 0.1.18 - - apiVersion: v2 - appVersion: 0.29.26 - created: "2026-03-20T23:59:02.111445644Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 127389b79ec2179b777cfde5c48e2c922cf362550d1a7d68e191b85fe2da3b99 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.17/s2-lite-helm-0.1.17.tgz - version: 0.1.17 - - apiVersion: v2 - appVersion: 0.29.25 - created: "2026-03-19T19:23:12.320686199Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: b1054a2cf40f442b0a875d941bdd002152853dda8bc1fe16fb3ed2aae6491a65 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.16/s2-lite-helm-0.1.16.tgz - version: 0.1.16 - - apiVersion: v2 - appVersion: 0.29.24 - created: "2026-03-17T16:30:10.255053232Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: ba1acf30ad7eec316847fea0d93fdde250c6502a0f516b54fbb85010a1727d82 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.15/s2-lite-helm-0.1.15.tgz - version: 0.1.15 - - apiVersion: v2 - appVersion: 0.29.23 - created: "2026-03-15T06:30:37.431508234Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 87892a769c38f6f70aa95fc13b565c51fd4b6ce006e810f4299ef3bf07dc444d - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.14/s2-lite-helm-0.1.14.tgz - version: 0.1.14 - - apiVersion: v2 - appVersion: 0.29.22 - created: "2026-03-13T16:21:58.701020252Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 28c2f1bd9ced07c6a0953a3ca1998c44f9c38e12edc8e8133e82a5745e34ea7f - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.13/s2-lite-helm-0.1.13.tgz - version: 0.1.13 - - apiVersion: v2 - appVersion: 0.29.21 - created: "2026-03-06T23:33:03.714624399Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 12b73e548e34efffb3547f1f8dceab7811e84b6b3fdbb713c6590e63058b7629 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.12/s2-lite-helm-0.1.12.tgz - version: 0.1.12 - - apiVersion: v2 - appVersion: 0.29.20 - created: "2026-03-06T02:54:04.053160579Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 4fa3688b1eda54e14d165d505297ae273d41f039668ff84d4b4d02147a39211f - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.11/s2-lite-helm-0.1.11.tgz - version: 0.1.11 - - apiVersion: v2 - appVersion: 0.29.19 - created: "2026-03-04T03:12:14.228411721Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: c55a6ce9d4b3c1e0c97aa1148cbcbad14d9659fd01b6f75511c167832eecdcc5 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.10/s2-lite-helm-0.1.10.tgz - version: 0.1.10 - - apiVersion: v2 - appVersion: 0.29.18 - created: "2026-03-03T16:48:31.26117862Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 579093482d5e428d9cecb028810c209a15057db595b76a38bb3d7daf1788a1f2 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.9/s2-lite-helm-0.1.9.tgz - version: 0.1.9 - - apiVersion: v2 - appVersion: 0.29.17 - created: "2026-03-02T17:30:20.094502249Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 6120ca5e7488db41966fac25b6efcbda0b02b5ea7a712811f7921effccfaff9d - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.8/s2-lite-helm-0.1.8.tgz - version: 0.1.8 - - apiVersion: v2 - appVersion: 0.29.16 - created: "2026-02-28T12:37:58.764208194Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 4a70f6da77c5e194741e3a5cc28b39e64cd2dcd45caf7b8dac41d739a465f16e - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.7/s2-lite-helm-0.1.7.tgz - version: 0.1.7 - - apiVersion: v2 - appVersion: 0.29.15 - created: "2026-02-27T19:10:09.465344229Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 65d29d76066a22e5f8b80e3241b05239eee074aa928acc0f3f9819182a73356f - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.6/s2-lite-helm-0.1.6.tgz - version: 0.1.6 - - apiVersion: v2 - appVersion: 0.29.14 - created: "2026-02-26T16:15:40.945271055Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 7e8f30476ffd7f9a006dbd18beedc3b536a14f3552b4940741f79212a4cb7451 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.5/s2-lite-helm-0.1.5.tgz - version: 0.1.5 - - apiVersion: v2 - appVersion: 0.29.13 - created: "2026-02-25T23:33:14.084465489Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 1891fbb41f282f8e43206ed9f7a9b6526981815b2f17ac5dc483695fa33f0c62 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.4/s2-lite-helm-0.1.4.tgz - version: 0.1.4 - - apiVersion: v2 - appVersion: 0.29.12 - created: "2026-02-25T11:57:22.403725952Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: f3d3e4de4d3068a50a7f173275817df9fa4228c6aa48210aa0619ed664773ffa - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.3/s2-lite-helm-0.1.3.tgz - version: 0.1.3 - - apiVersion: v2 - appVersion: 0.29.11 - created: "2026-02-25T11:05:23.627846272Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: c799550332c580281e91290f7f7a294d35d6faf5d15393ac45835de8ad7d832a - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.2/s2-lite-helm-0.1.2.tgz - version: 0.1.2 - - apiVersion: v2 - appVersion: 0.29.10 - created: "2026-02-25T10:38:37.664116183Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: b0cc47bebc53e720062913fd559ac12b8cada4845fbce5220a508796a743e885 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.1/s2-lite-helm-0.1.1.tgz - version: 0.1.1 - - apiVersion: v2 - appVersion: 0.28.0 - created: "2026-02-25T08:17:26.037011473Z" - description: Self-hostable S2 streaming datastore using SlateDB on object storage - digest: 320afa69541598bb6025487a57f4306067865fd7db19717713806a542a66d1c0 - home: https://s2.dev - keywords: - - s2 - - streaming - - datastore - - slatedb - - object-storage - maintainers: - - email: hi@s2.dev - name: S2 Team - name: s2-lite-helm - sources: - - https://github.com/s2-streamstore/s2 - type: application - urls: - - https://github.com/s2-streamstore/s2/releases/download/s2-lite-helm-0.1.0/s2-lite-helm-0.1.0.tgz - version: 0.1.0 -generated: "2026-09-28T19:58:48.100385183Z" diff --git a/install.sh b/install.sh new file mode 100644 index 00000000..dddf5ddc --- /dev/null +++ b/install.sh @@ -0,0 +1,220 @@ +#!/bin/sh -e + +RESET="\\033[0m" +RED="\\033[31;1m" +GREEN="\\033[32;1m" +YELLOW="\\033[33;1m" +BLUE="\\033[34;1m" +WHITE="\\033[37;1m" + +echo_green() { + [ -z "${SILENT}" ] && printf "%b%s%b\\n" "${GREEN}" "$1" "${RESET}" + return 0 +} + +echo_red() { + printf "%b%s%b\\n" "${RED}" "$1" "${RESET}" +} + +echo_yellow() { + [ -z "${SILENT}" ] && printf "%b%s%b\\n" "${YELLOW}" "$1" "${RESET}" + return 0 +} + +echo_blue() { + [ -z "${SILENT}" ] && printf "%b%s%b\\n" "${BLUE}" "$1" "${RESET}" + return 0 +} + +echo_white() { + [ -z "${SILENT}" ] && printf "%b%s%b\\n" "${WHITE}" "$1" "${RESET}" + return 0 +} + +echo_em() { + [ -z "${SILENT}" ] && echo " $1" +} + +OS=$(uname -s) +ARCH=$(uname -m) + +# Check if we need musl (glibc < 2.38 or musl-based system like Alpine) +needs_musl() { + if ! command -v ldd >/dev/null 2>&1; then + return 1 + fi + # Check if it's a musl-based system (e.g., Alpine) + if ldd --version 2>&1 | grep -q musl; then + return 0 + fi + # Check glibc version + GLIBC_VERSION=$(ldd --version 2>/dev/null | sed -n '1s/.* \\([0-9][0-9]*\\.[0-9][0-9]*\\)$/\\1/p') + [ -n "${GLIBC_VERSION}" ] || GLIBC_VERSION="0.0" + GLIBC_MAJOR=$(echo "${GLIBC_VERSION}" | cut -d. -f1) + GLIBC_MINOR=$(echo "${GLIBC_VERSION}" | cut -d. -f2) + # Need musl if glibc < 2.38 + if [ "${GLIBC_MAJOR}" -lt 2 ] 2>/dev/null || \ + { [ "${GLIBC_MAJOR}" -eq 2 ] && [ "${GLIBC_MINOR}" -lt 38 ]; } 2>/dev/null; then + return 0 + fi + return 1 +} + +TARGET= + +if [ "${OS}" = "Linux" ] +then + if [ "${ARCH}" = "x86_64" -o "${ARCH}" = "amd64" ] + then + if needs_musl; then + TARGET="s2-x86_64-unknown-linux-musl.zip" + else + TARGET="s2-x86_64-unknown-linux-gnu.zip" + fi + elif [ "${ARCH}" = "aarch64" -o "${ARCH}" = "arm64" ] + then + if needs_musl; then + TARGET="s2-aarch64-unknown-linux-musl.zip" + else + TARGET="s2-aarch64-unknown-linux-gnu.zip" + fi + fi +elif [ "${OS}" = "Darwin" ] +then + if [ "${ARCH}" = "x86_64" -o "${ARCH}" = "amd64" ] + then + TARGET="s2-x86_64-apple-darwin.zip" + elif [ "${ARCH}" = "aarch64" -o "${ARCH}" = "arm64" ] + then + TARGET="s2-aarch64-apple-darwin.zip" + fi +elif echo "${OS}" | grep -qE '^(MINGW|MSYS|CYGWIN)' +then + echo_red "Platform not supported by install.sh." + echo_em "${OS} on ${ARCH}" + echo_white "Windows users: download a Windows zip from the GitHub releases +or install via Cargo (cargo install --locked s2-cli)." + exit 1 +fi + +if [ -z "${TARGET}" ] +then + echo_red "Platform not supported." + echo_em "${OS} on ${ARCH}" + echo_white "It looks like this platform is not supported. We're sorry about that. +Visit https://github.com/s2-streamstore/s2 to file an issue, and build +from source." + exit 1 +fi + +REPO="${S2_REPO:-s2-streamstore/s2}" +if [ -n "${VERSION}" ] +then + case "${VERSION}" in + s2-cli-v*) TAG="${VERSION}" ;; + v*) TAG="s2-cli-${VERSION}" ;; + *) TAG="s2-cli-v${VERSION}" ;; + esac +else + TAG=$(curl -sSL "https://github.com/${REPO}/releases?q=s2-cli&expanded=true" \ + | grep -o 'releases/tag/s2-cli-v[^"]*' \ + | head -1 \ + | grep -o 's2-cli-v[^"]*') + if [ -z "${TAG}" ]; then + echo_red "Failed to determine latest s2-cli release." + exit 1 + fi +fi +S2_VERSION="${TAG}" +DOWNLOAD_URI="download/${TAG}" + +BIN_PATH="${HOME}/.s2/bin" +test -d "${BIN_PATH}" || mkdir -p "${BIN_PATH}" + +DOWNLOAD_PATH=`mktemp` +PWD=`pwd` +URL="https://github.com/${REPO}/releases/${DOWNLOAD_URI}/${TARGET}" + +echo_blue "Installing S2 CLI" +echo_em "S2 Version: ${S2_VERSION}" + +curl --progress-bar -fSL "${URL}" -o "${DOWNLOAD_PATH}" \ + && unzip -o -d "${BIN_PATH}" "${DOWNLOAD_PATH}" >/dev/null \ + && chmod a+x "${BIN_PATH}/s2" \ + || exit 1 + +rm -f "${DOWNLOAD_PATH}" + +# Install receipt, read by the CLI (cli/src/update/channel.rs) to know it was +# installed by this script and can be upgraded in place. +# Escape backslashes then quotes so an unusual HOME can't produce invalid JSON. +BIN_PATH_JSON=$(printf '%s' "${BIN_PATH}/s2" | sed 's/\\/\\\\/g; s/"/\\"/g') +cat > "${BIN_PATH}/s2-receipt.json" </dev/null || true) +NEEDS_PATH_UPDATE= +if [ -n "${EXISTING_S2_PATH}" ] && [ "${EXISTING_S2_PATH}" != "${BIN_PATH}/s2" ] +then + NEEDS_PATH_UPDATE=1 + echo_yellow "WARNING: Found existing s2 at ${EXISTING_S2_PATH}" + echo_em "New binary is at ${BIN_PATH}/s2" +fi + +# Add the bin to $PATH if it doesn't exist or isn't taking precedence. +# Thanks to Pulumi install script for the inspiration. +if [ -z "${EXISTING_S2_PATH}" ] || [ -n "${NEEDS_PATH_UPDATE}" ]; then + SHELL_NAME=$(basename "${SHELL}") + PROFILE_FILE="" + + if [ "${SHELL_NAME}" = "bash" ] + then + if [ "${OS}" = "Darwin" ] + then + if [ -e "${HOME}/.bash_profile" ]; then + PROFILE_FILE="${HOME}/.bash_profile" + elif [ -e "${HOME}/.bashrc" ]; then + PROFILE_FILE="${HOME}/.bashrc" + fi + else + if [ -e "${HOME}/.bashrc" ]; then + PROFILE_FILE="${HOME}/.bashrc" + elif [ -e "${HOME}/.bash_profile" ]; then + PROFILE_FILE="${HOME}/.bash_profile" + fi + fi + elif [ "${SHELL_NAME}" = "zsh" ] + then + if [ -e "${ZDOTDIR:-$HOME}/.zshrc" ]; then + PROFILE_FILE="${ZDOTDIR:-$HOME}/.zshrc" + fi + fi + + if [ -n "${PROFILE_FILE}" ]; then + LINE_TO_ADD="export PATH=\"${BIN_PATH}:\\$PATH\"" + if ! grep -q "${BIN_PATH}" "${PROFILE_FILE}"; then + echo_white "Adding ${BIN_PATH} to \\$PATH in ${PROFILE_FILE}" + printf "\\n# add S2 to the PATH\\n%s\\n" "${LINE_TO_ADD}" >> "${PROFILE_FILE}" + else + echo_yellow "WARNING: ${BIN_PATH} is already in your PATH." + [ -n "${EXISTING_S2_PATH}" ] && echo_em "Ensure ${BIN_PATH} appears before ${EXISTING_S2_PATH}" + fi + + echo_yellow "WARNING: Please restart your shell or add ${BIN_PATH} to your \\$PATH" + else + echo_yellow "WARNING: Please add ${BIN_PATH} to your \\$PATH" + fi +fi + +echo_green "S2 CLI installed as" +echo_em "${BIN_PATH}/s2" +echo_green "Get started with S2:" +echo_em "https://s2.dev/docs/quickstart" diff --git a/justfile b/justfile new file mode 100644 index 00000000..7fbc7058 --- /dev/null +++ b/justfile @@ -0,0 +1,78 @@ +# List available commands +default: + @just --list + +# Sync git submodules +sync: + git submodule update --init --recursive + +# Build the s2 CLI binary (includes lite subcommand) +build *args: sync + cargo build --locked --release -p s2-cli {{args}} + +# Run clippy linter +clippy *args: sync + cargo clippy --locked --workspace --all-features --all-targets {{args}} -- -D warnings --allow deprecated + +# Run clippy on the simulator (separate workspace) +sim-clippy *args: + RUSTFLAGS="--cfg tokio_unstable" cargo clippy --manifest-path sim/Cargo.toml --all-targets {{args}} -- -D warnings --allow deprecated + +# Ensure cargo-deny is installed +_ensure-deny: + @cargo deny --version > /dev/null 2>&1 || (echo "cargo-deny is required; run: brew install cargo-deny" && exit 1) + +# Run cargo-deny checks +deny *args: _ensure-deny + cargo deny check {{args}} + +# Ensure nightly toolchain is installed +_ensure-nightly: + @rustup toolchain list | grep -q nightly || (echo "❌ Nightly toolchain required. Run: rustup toolchain install nightly" && exit 1) + +# Format code with rustfmt +fmt: _ensure-nightly + cargo +nightly fmt + cargo +nightly fmt --manifest-path sim/Cargo.toml + +# Ensure cargo-nextest is installed +_ensure-nextest: + @cargo nextest --version > /dev/null 2>&1 || (echo "cargo-nextest is required; run: brew install cargo-nextest" && exit 1) + +# Run tests with nextest (excludes Docker-backed and live integration tests) +test *args: sync _ensure-nextest + cargo nextest run --locked --workspace --all-features --exclude s2-testcontainers -E 'not ((package(s2-cli) & binary(integration)) or (package(s2-sdk) & (binary(account_ops) or binary(basin_ops) or binary(metrics_ops) or binary(stream_ops))))' {{args}} + +# Run CLI integration tests (requires s2 lite server running) +test-cli-integration: sync _ensure-nextest + S2_ACCESS_TOKEN=test S2_ACCOUNT_ENDPOINT=http://localhost S2_BASIN_ENDPOINT=http://localhost \ + cargo nextest run --locked -p s2-cli --test integration + +# Run SDK integration tests (requires S2_ACCESS_TOKEN and optional custom endpoints) +test-sdk-integration: sync _ensure-nextest + cargo nextest run --locked -p s2-sdk --test account_ops --test basin_ops --test metrics_ops --test stream_ops + +# Verify Cargo.lock is up-to-date +check-locked: + cargo metadata --locked --format-version 1 >/dev/null + +# Install git hooks from hooks/ +install-hooks: + ln -sf `pwd`/hooks/pre-commit .git/hooks/pre-commit + @echo "Git hooks installed" + +# Clean build artifacts +clean: + cargo clean + +# Run s2-lite +lite *args: + cargo run --locked --release -p s2-cli -- lite {{args}} + +# Run the s2-lite deterministic simulation (e.g. `just sim smoke --seed 42`, +# `just sim linearizable --seed 42 --clients 3 --ops-per-client 100`) +# The simulator lives in its own workspace (sim/) to isolate its patched +# dependencies (getrandom fork, etc.) from the main workspace. +# tokio_unstable is required so turmoil can seed tokio's internal RNG. +sim *args: + RUSTFLAGS="--cfg tokio_unstable" cargo run --manifest-path sim/Cargo.toml --profile sim -- {{args}} diff --git a/lite/CHANGELOG.md b/lite/CHANGELOG.md new file mode 100644 index 00000000..833fb00f --- /dev/null +++ b/lite/CHANGELOG.md @@ -0,0 +1,962 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.43.1] - 2026-09-28 + +### Bug Fixes + +- Resolve AWS region from profile chain for static credentials ([#780](https://github.com/s2-streamstore/s2/issues/780)) + + + +## [0.43.0] - 2026-09-25 + +### Features + +- [**breaking**] Expose storage classes as strings and in location responses ([#775](https://github.com/s2-streamstore/s2/issues/775)) + +### Bug Fixes + +- Gate basin deletion draining on per-basin progress ([#779](https://github.com/s2-streamstore/s2/issues/779)) + + + +## [0.42.14] - 2026-09-24 + +### Bug Fixes + +- Coalesce delete-on-empty scheduling ([#772](https://github.com/s2-streamstore/s2/issues/772)) + + + +## [0.42.13] - 2026-09-22 + +### Features + +- Support a separate WAL object store ([#766](https://github.com/s2-streamstore/s2/issues/766)) + +### Bug Fixes + +- Keep streamers alive until pending writes settle ([#757](https://github.com/s2-streamstore/s2/issues/757)) +- Await durable deletion markers on retries ([#756](https://github.com/s2-streamstore/s2/issues/756)) +- Prevent stale or missed stream config updates ([#759](https://github.com/s2-streamstore/s2/issues/759)) +- Apply the at-tail read guard after resolving a timestamp start ([#762](https://github.com/s2-streamstore/s2/issues/762)) +- Prevent deletion races when recreating streams ([#760](https://github.com/s2-streamstore/s2/issues/760)) +- Preserve SSE read budgets across reconnects ([#727](https://github.com/s2-streamstore/s2/issues/727)) + +### Performance + +- Avoid copying serialized append buffers ([#763](https://github.com/s2-streamstore/s2/issues/763)) +- Reuse acknowledgement queue capacity ([#764](https://github.com/s2-streamstore/s2/issues/764)) + + + +## [0.42.12] - 2026-09-16 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.42.11] - 2026-09-11 + +### Features + +- `s2-stream-config` header for auto-created streams ([#718](https://github.com/s2-streamstore/s2/issues/718)) + + + +## [0.42.10] - 2026-09-11 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.42.9] - 2026-09-10 + +### Bug Fixes + +- Close SlateDB on graceful shutdown +- Reject NUL bytes in stream names and access token IDs ([#728](https://github.com/s2-streamstore/s2/issues/728)) + + + +## [0.42.8] - 2026-09-01 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.42.7] - 2026-08-18 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.42.6] - 2026-08-13 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.42.5] - 2026-08-07 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.42.4] - 2026-08-07 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.42.3] - 2026-08-05 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.42.2] - 2026-08-05 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.42.1] - 2026-08-01 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.42.0] - 2026-07-31 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.41.2] - 2026-07-28 + +### Bug Fixes + +- Fsync local object store writes ([#670](https://github.com/s2-streamstore/s2/issues/670)) + + + +## [0.41.1] - 2026-07-24 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.41.0] - 2026-07-23 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.40.1] - 2026-07-23 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.40.0] - 2026-07-22 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.39.3] - 2026-07-17 + +### Features + +- Emit tail in read session SSE pings ([#643](https://github.com/s2-streamstore/s2/issues/643)) + + + +## [0.39.2] - 2026-07-16 + +### Bug Fixes + +- Make provision_stream exists-outcomes durably visible ([#641](https://github.com/s2-streamstore/s2/issues/641)) + + + +## [0.39.1] - 2026-07-07 + +### Features + +- Default account endpoint aws.s2.dev -> a.s2.dev (+ openapi servers URL) ([#620](https://github.com/s2-streamstore/s2/issues/620)) + + + +## [0.39.0] - 2026-07-06 + +### Miscellaneous Tasks + +- [**breaking**] Upgrade slatedb to 0.14 ([#618](https://github.com/s2-streamstore/s2/issues/618)) + + + +## [0.38.0] - 2026-07-02 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.37.1] - 2026-06-22 + +### Miscellaneous Tasks + +- Remove unused `From for [u8; StreamId::LEN]` impl ([#577](https://github.com/s2-streamstore/s2/issues/577)) + + + +## [0.37.0] - 2026-06-22 + +### Features + +- Make access token expiry semantics explicit ([#574](https://github.com/s2-streamstore/s2/issues/574)) + +### Miscellaneous Tasks + +- Remove unused DeserializationError::JsonSerialization variant ([#572](https://github.com/s2-streamstore/s2/issues/572)) +- Remove unused basins::METRICS path constant ([#573](https://github.com/s2-streamstore/s2/issues/573)) + + + +## [0.36.8] - 2026-06-15 + +### Bug Fixes + +- Return not_implemented for lite stubs ([#546](https://github.com/s2-streamstore/s2/issues/546)) + + + +## [0.36.7] - 2026-06-13 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.36.6] - 2026-06-12 + +### Refactor + +- Split storage internals from common ([#533](https://github.com/s2-streamstore/s2/issues/533)) + + + +## [0.36.5] - 2026-06-11 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.36.4] - 2026-06-10 + +### Features + +- Allow list cursors before prefix ([#448](https://github.com/s2-streamstore/s2/issues/448)) + +### Bug Fixes + +- Map encryption spec errors to bad header ([#422](https://github.com/s2-streamstore/s2/issues/422)) + + + +## [0.36.3] - 2026-06-10 + +### Bug Fixes + +- Add proxy-friendly SSE headers ([#500](https://github.com/s2-streamstore/s2/issues/500)) +- Add proxy-friendly S2S headers ([#503](https://github.com/s2-streamstore/s2/issues/503)) +- Read AWS_SESSION_TOKEN for static S3 credentials ([#507](https://github.com/s2-streamstore/s2/issues/507)) +- Improve error handling and observability ([#506](https://github.com/s2-streamstore/s2/issues/506)) +- Use saturating_add for seq_num in SSE resume to prevent overflow ([#527](https://github.com/s2-streamstore/s2/issues/527)) +- Apply S3 custom endpoint for AWS SDK/IAM credentials ([#512](https://github.com/s2-streamstore/s2/issues/512)) + +### Refactor + +- Extract shared KV key ser/deser helpers for StreamId and BasinName ([#505](https://github.com/s2-streamstore/s2/issues/505)) + + + +## [0.36.2] - 2026-06-02 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.36.1] - 2026-05-29 + +### Bug Fixes + +- Recheck DOE config before terminal trim ([#495](https://github.com/s2-streamstore/s2/issues/495)) + + + +## [0.36.0] - 2026-05-20 + +### Refactor + +- [**breaking**] Rename scope -> location, treat it as a string; add related RPCs ([#485](https://github.com/s2-streamstore/s2/issues/485)) + + + +## [0.35.1] - 2026-05-20 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.35.0] - 2026-05-19 + +### Features + +- Expose `ensure_*` ops ([#471](https://github.com/s2-streamstore/s2/issues/471)) + + + +## [0.34.0] - 2026-05-19 + +### Features + +- Enable lz4 and zstd SlateDB compression codecs ([#461](https://github.com/s2-streamstore/s2/issues/461)) + +### Bug Fixes + +- Resolve lite stream config reconfigure defaults ([#465](https://github.com/s2-streamstore/s2/issues/465)) +- Map control-plane transaction conflicts ([#466](https://github.com/s2-streamstore/s2/issues/466)) +- Reject appends during stream deletion ([#469](https://github.com/s2-streamstore/s2/issues/469)) +- Serialize delete-on-empty terminal trim ([#363](https://github.com/s2-streamstore/s2/issues/363)) +- Switch global allocator to jemalloc ([#472](https://github.com/s2-streamstore/s2/issues/472)) + +### Refactor + +- Use prefix scans for stream record checks ([#464](https://github.com/s2-streamstore/s2/issues/464)) + +### Miscellaneous Tasks + +- Upgrade SlateDB to 0.13.0 ([#463](https://github.com/s2-streamstore/s2/issues/463)) +- Unused deps ([#467](https://github.com/s2-streamstore/s2/issues/467)) + + + +## [0.33.0] - 2026-05-15 + +### Bug Fixes + +- Preserve explicit optional config values ([#459](https://github.com/s2-streamstore/s2/issues/459)) + + + +## [0.32.0] - 2026-05-14 + +### Bug Fixes + +- Clarify PUT ensure semantics ([#450](https://github.com/s2-streamstore/s2/issues/450)) + + + +## [0.31.0] - 2026-05-10 + +### Refactor + +- Introduce resource-specific create intents ([#437](https://github.com/s2-streamstore/s2/issues/437)) + +### Miscellaneous Tasks + +- Dep updates ([#438](https://github.com/s2-streamstore/s2/issues/438)) + + + +## [0.30.6] - 2026-05-04 + + + +## [0.30.5] - 2026-04-27 + +### Refactor + +- Use `strum` instead of `enum_ordinalize` ([#426](https://github.com/s2-streamstore/s2/issues/426)) + + + +## [0.30.4] - 2026-04-24 + +### Bug Fixes + +- Treat encryption algorithm mismatch as storage error ([#421](https://github.com/s2-streamstore/s2/issues/421)) + +### Miscellaneous Tasks + +- Error name and message ([#418](https://github.com/s2-streamstore/s2/issues/418)) + + + +## [0.30.3] - 2026-04-22 + +### Bug Fixes + +- Preserve offending seq num in limit error ([#414](https://github.com/s2-streamstore/s2/issues/414)) +- Model record limits as max assignable sequence numbers ([#417](https://github.com/s2-streamstore/s2/issues/417)) + + + +## [0.30.2] - 2026-04-21 + +### Bug Fixes + +- Cap random-nonce encrypted stream messages ([#412](https://github.com/s2-streamstore/s2/issues/412)) + + + +## [0.30.1] - 2026-04-20 + + + +## [0.30.0] - 2026-04-20 + +### Bug Fixes + +- Avoid `Streamer missing in action` errors ([#404](https://github.com/s2-streamstore/s2/issues/404)) + +### Refactor + +- [**breaking**] Replace encryption modes with stream cipher metadata and key-only headers ([#403](https://github.com/s2-streamstore/s2/issues/403)) + + + +## [0.29.32] - 2026-04-17 + +### Bug Fixes + +- Dashed string repr for AEGIS-256 and AES-256-GCM modes ([#400](https://github.com/s2-streamstore/s2/issues/400)) + + + +## [0.29.31] - 2026-04-16 + +### Features + +- Add `DecryptionFailed` error code ([#396](https://github.com/s2-streamstore/s2/issues/396)) + + + +## [0.29.30] - 2026-04-16 + +### Features + +- Add startup hint for s2 env vars ([#393](https://github.com/s2-streamstore/s2/issues/393)) + + + +## [0.29.29] - 2026-04-15 + +### Refactor + +- Remove implicit optional config resolution ([#389](https://github.com/s2-streamstore/s2/issues/389)) + +### Miscellaneous Tasks + +- Dep updates ([#391](https://github.com/s2-streamstore/s2/issues/391)) + + + +## [0.29.28] - 2026-04-14 + +### Features + +- Request-time data encryption ([#349](https://github.com/s2-streamstore/s2/issues/349)) +- Enforce allowed encryption modes via stream config ([#376](https://github.com/s2-streamstore/s2/issues/376)) + +### Refactor + +- Clarify encryption spec, mode, and format semantics ([#375](https://github.com/s2-streamstore/s2/issues/375)) +- Decouple JSON extraction rejection from axum ([#348](https://github.com/s2-streamstore/s2/issues/348)) + +### Testing + +- Stabilize follow-mode timing coverage ([#371](https://github.com/s2-streamstore/s2/issues/371)) +- Simplify backend integration tests and tighten read coverage ([#374](https://github.com/s2-streamstore/s2/issues/374)) + +### Miscellaneous Tasks + +- Update basin endpoint from b.aws.s2.dev to b.s2.dev ([#346](https://github.com/s2-streamstore/s2/issues/346)) + + + +## [0.29.27] - 2026-03-21 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.26] - 2026-03-20 + +### Features + +- Align basin info with stream info ([#338](https://github.com/s2-streamstore/s2/issues/338)) + + + +## [0.29.25] - 2026-03-19 + +### Refactor + +- Remove basin creating state ([#333](https://github.com/s2-streamstore/s2/issues/333)) + + + +## [0.29.24] - 2026-03-17 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.29.23] - 2026-03-15 + +### Bug Fixes + +- Guard full-delete trim finalization on trim-point state ([#330](https://github.com/s2-streamstore/s2/issues/330)) + + + +## [0.29.22] - 2026-03-13 + +### Bug Fixes + +- Make delete-on-empty armings prospective ([#314](https://github.com/s2-streamstore/s2/issues/314)) + +### Performance + +- Avoid intermediate SSE batch allocations ([#320](https://github.com/s2-streamstore/s2/issues/320)) + + + +## [0.29.21] - 2026-03-06 + +### Bug Fixes + +- Allow http endpoints for S3-compatible object stores ([#303](https://github.com/s2-streamstore/s2/issues/303)) +- Preserve follow wait budget after lagged recovery ([#311](https://github.com/s2-streamstore/s2/issues/311)) +- Initialize durability notifier from close status ([#312](https://github.com/s2-streamstore/s2/issues/312)) + + + +## [0.29.20] - 2026-03-06 + +### Features + +- Default append pipelining with durability-gated acks ([#289](https://github.com/s2-streamstore/s2/issues/289)) + +### Bug Fixes + +- Keep follow sessions alive across dormancy ([#301](https://github.com/s2-streamstore/s2/issues/301)) + +### Miscellaneous Tasks + +- Dep updates ([#299](https://github.com/s2-streamstore/s2/issues/299)) + + + +## [0.29.19] - 2026-03-04 + + + +## [0.29.18] - 2026-03-03 + +### Bug Fixes + +- Handle lagged bgtask triggers immediately ([#283](https://github.com/s2-streamstore/s2/issues/283)) + +### Miscellaneous Tasks + +- Upgrade sl8 to 0.11 ([#285](https://github.com/s2-streamstore/s2/issues/285)) +- Upgrade schemars dep to 1.2 ([#287](https://github.com/s2-streamstore/s2/issues/287)) + + + +## [0.29.17] - 2026-03-02 + +### Bug Fixes + +- Honor read wait budget across heartbeats ([#280](https://github.com/s2-streamstore/s2/issues/280)) + + + +## [0.29.16] - 2026-02-28 + +### Bug Fixes + +- Avoid caching dead streamer clients ([#270](https://github.com/s2-streamstore/s2/issues/270)) + + + +## [0.29.15] - 2026-02-27 + +### Bug Fixes + +- Enable GC by default ([#264](https://github.com/s2-streamstore/s2/issues/264)) + + + +## [0.29.14] - 2026-02-26 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.13] - 2026-02-25 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.12] - 2026-02-25 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.11] - 2026-02-25 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.10] - 2026-02-25 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.9] - 2026-02-25 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.8] - 2026-02-24 + +### Features + +- Support creating resources from spec ([#239](https://github.com/s2-streamstore/s2/issues/239)) + + + +## [0.29.7] - 2026-02-23 + +### Features + +- Add --no-cors flag to Lite ([#238](https://github.com/s2-streamstore/s2/issues/238)) + +### Refactor + +- Singleflight streamer lifecycle state ([#227](https://github.com/s2-streamstore/s2/issues/227)) + + + +## [0.29.6] - 2026-02-17 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.5] - 2026-02-17 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.4] - 2026-02-16 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.3] - 2026-02-16 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.2] - 2026-02-15 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.29.1] - 2026-02-15 + +### Miscellaneous Tasks + +- Add crate-level doc comment ([#213](https://github.com/s2-streamstore/s2/issues/213)) + + + +## [0.29.0] - 2026-02-15 + +### Bug Fixes + +- Correct command applied range check ([#209](https://github.com/s2-streamstore/s2/issues/209)) +- Avoid follow error on exact limit ([#208](https://github.com/s2-streamstore/s2/issues/208)) +- [**breaking**] Harden Bash hashing ([#207](https://github.com/s2-streamstore/s2/issues/207)) + + + +## [0.28.4] - 2026-02-12 + +### Bug Fixes + +- Return err for stream methods if stream is being deleted ([#196](https://github.com/s2-streamstore/s2/issues/196)) + +### Miscellaneous Tasks + +- Introduce cargo-deny and justfile improvements ([#193](https://github.com/s2-streamstore/s2/issues/193)) + + + +## [0.28.3] - 2026-02-07 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.28.2] - 2026-02-06 + +### Bug Fixes + +- Use memory durability for DOE scan ([#190](https://github.com/s2-streamstore/s2/issues/190)) + + + +## [0.28.1] - 2026-02-06 + +### Bug Fixes + +- Read session spinning if all records deleted ([#184](https://github.com/s2-streamstore/s2/issues/184)) + +### Miscellaneous Tasks + +- Improve integration test organization ([#186](https://github.com/s2-streamstore/s2/issues/186)) + + + +## [0.28.0] - 2026-02-06 + +### Features + +- [**breaking**] Delete-on-empty ([#158](https://github.com/s2-streamstore/s2/issues/158)) + +### Bug Fixes + +- Append session should also `create-stream-on-append` if configured ([#180](https://github.com/s2-streamstore/s2/issues/180)) + + + +## [0.27.5] - 2026-02-05 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.27.4] - 2026-02-05 + +### Miscellaneous Tasks + +- Install aws-lc-rs as default crypto provider for rustls ([#171](https://github.com/s2-streamstore/s2/issues/171)) + + + +## [0.27.3] - 2026-02-05 + +### Miscellaneous Tasks + +- Rejig versioning and release workflow ([#163](https://github.com/s2-streamstore/s2/issues/163)) + + + +## [0.27.2] - 2026-02-05 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.27.1] - 2026-02-04 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.27.0] - 2026-02-03 + +### Features + +- *(lite)* [**breaking**] Bgtasks for basin/stream deletion and stream trimming ([#148](https://github.com/s2-streamstore/s2/issues/148)) + +### Bug Fixes + +- *(lite)* Validate read timestamp >= until ([#153](https://github.com/s2-streamstore/s2/issues/153)) + + + +## [0.26.9] - 2026-02-02 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.26.8] - 2026-01-30 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.26.7] - 2026-01-30 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.26.6] - 2026-01-30 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.26.5] - 2026-01-30 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.26.4] - 2026-01-29 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.26.3] - 2026-01-29 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.26.2] - 2026-01-29 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.26.1] - 2026-01-29 + +### Miscellaneous Tasks + +- Update Cargo.lock dependencies + + + +## [0.26.0] - 2026-01-28 + +### Features + +- *(cli)* [**breaking**] Integrate s2-cli into workspace with lite subcommand ([#103](https://github.com/s2-streamstore/s2/issues/103)) + +### Miscellaneous Tasks + +- Improve health check to use newly-exposed sl8 status ([#100](https://github.com/s2-streamstore/s2/issues/100)) + + diff --git a/lite/Cargo.toml b/lite/Cargo.toml new file mode 100644 index 00000000..0600233d --- /dev/null +++ b/lite/Cargo.toml @@ -0,0 +1,71 @@ +[package] +name = "s2-lite" +version = "0.43.1" +description = "Lightweight server implementation of S2, the durable streams API, backed by object storage" +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords = ["s2", "durable", "streams", "object", "storage"] +categories = ["database-implementations"] + +[[bin]] +name = "openapi" +required-features = ["utoipa"] + +[[bin]] +name = "server" + +[features] +utoipa = ["dep:utoipa", "s2-api/utoipa", "s2-common/utoipa"] + +[dependencies] +async-stream = { workspace = true } +async-trait = { workspace = true } +aws-config = { workspace = true } +aws-credential-types = { workspace = true } +axum = { workspace = true, features = ["macros"] } +axum-server = { workspace = true, features = ["tls-rustls"] } +bytes = { workspace = true } +bytesize = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } +dashmap = { workspace = true } +eyre = { workspace = true } +futures = { workspace = true } +http = { workspace = true } +indexmap = { workspace = true } +itertools = { workspace = true } +parking_lot = { workspace = true, features = ["arc_lock"] } +prometheus = { workspace = true, features = ["process"] } +prost = { workspace = true } +rand = { workspace = true } +rcgen = { workspace = true } +rustls = { workspace = true, features = ["aws-lc-rs"] } +s2-api = { workspace = true, features = ["axum"] } +s2-common = { workspace = true, features = ["clap"] } +s2-resource-spec = { workspace = true } +s2-storage = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +slatedb = { workspace = true, features = ["lz4", "zstd"] } +strum = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +time = { workspace = true, features = ["serde", "formatting", "parsing"] } +tokio = { workspace = true, features = ["sync"] } +tokio-util = { workspace = true } +tower-http = { workspace = true, features = ["cors", "trace", "sensitive-headers", "compression-zstd", "compression-gzip", "decompression-zstd", "decompression-gzip"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +utoipa = { workspace = true, optional = true, features = ["time"] } + +# tikv-jemallocator does not build on the MSVC toolchain; on Windows-MSVC +# Rust falls back to the system allocator (HeapAlloc). +[target.'cfg(not(target_env = "msvc"))'.dependencies] +tikv-jemallocator = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +rstest = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "test-util", "time"] } +tower = "0.5" +uuid = { workspace = true, features = ["v4"] } diff --git a/lite/src/backend/append.rs b/lite/src/backend/append.rs new file mode 100644 index 00000000..aa83f752 --- /dev/null +++ b/lite/src/backend/append.rs @@ -0,0 +1,398 @@ +use std::{ + collections::VecDeque, + ops::{DerefMut as _, Range, RangeTo}, + sync::Arc, +}; + +use futures::{Stream, StreamExt as _, future::OptionFuture, stream::FuturesOrdered}; +use s2_common::{ + basin::BasinName, + config::OptionalStreamConfig, + encryption::{EncryptionKey, EncryptionSpec}, + record::{SeqNum, StreamPosition}, + stream::{AppendAck, AppendInput, StreamName}, +}; +use s2_storage::record::encrypt_append_input; +use tokio::sync::oneshot; + +use super::{Backend, StreamHandle, core::AutoCreateOn}; +use crate::backend::error::{AppendError, AppendErrorInternal, StorageError}; + +impl Backend { + /// Open a stream for an append or append session. + /// + /// `stream_config` is applied if the stream is created on append. Unset fields inherit the + /// basin's default stream configuration. Ignored if the stream already exists. + pub async fn open_for_append( + &self, + basin: &BasinName, + stream: &StreamName, + encryption_key: Option, + stream_config: OptionalStreamConfig, + ) -> Result { + self.stream_handle_with_auto_create::( + basin, + stream, + AutoCreateOn::Append, + stream_config, + |cipher| Ok(EncryptionSpec::resolve(cipher, encryption_key)?), + ) + .await + } +} + +impl StreamHandle { + pub async fn append(self, input: AppendInput) -> Result { + let input = + encrypt_append_input(input, &self.encryption, self.client.stream_id().as_bytes()); + let ack = self.client.append_permit(input).await?.submit().await?; + Ok(ack) + } + + pub fn append_session(self, inputs: S) -> impl Stream> + where + S: Stream, + { + let stream_id = self.client.stream_id(); + let StreamHandle { + client, encryption, .. + } = self; + let session = SessionHandle::new(); + async_stream::stream! { + tokio::pin!(inputs); + let mut permit_opt = None; + let mut append_futs = FuturesOrdered::new(); + loop { + tokio::select! { + Some(input) = inputs.next(), if permit_opt.is_none() => { + permit_opt = Some(Box::pin(client.append_permit( + encrypt_append_input(input, &encryption, stream_id.as_bytes()), + ))); + } + Some(res) = OptionFuture::from(permit_opt.as_mut()) => { + permit_opt = None; + match res { + Ok(permit) => append_futs.push_back(permit.submit_session(session.clone())), + Err(e) => { + yield Err(e.into()); + break; + } + } + } + Some(res) = append_futs.next(), if !append_futs.is_empty() => { + match res { + Ok(ack) => { + yield Ok(ack); + } + Err(e) => { + yield Err(e.into()); + break; + } + } + } + else => { + break; + } + } + } + } + } +} + +#[derive(Debug)] +struct SessionState { + last_ack_end: RangeTo, + poisoned: bool, +} + +#[derive(Debug, Clone)] +pub struct SessionHandle(Arc>); + +impl SessionHandle { + pub fn new() -> Self { + Self(Arc::new(parking_lot::Mutex::new(SessionState { + last_ack_end: ..SeqNum::MIN, + poisoned: false, + }))) + } +} + +#[must_use] +pub fn admit( + tx: oneshot::Sender>, + session: Option, +) -> Option { + if tx.is_closed() { + return None; + } + match session { + None => Some(Ticket { tx, session: None }), + Some(session) => { + let session = session.0.lock_arc(); + if session.poisoned { + None + } else { + Some(Ticket { + tx, + session: Some(session), + }) + } + } + } +} + +const MIN_PENDING_APPENDS_CAPACITY: usize = 16; + +#[derive(Debug, Default)] +pub struct PendingAppends { + queue: VecDeque, + next_ack_pos: Option, +} + +impl PendingAppends { + pub fn new() -> Self { + Self { + queue: VecDeque::new(), + next_ack_pos: None, + } + } + + pub fn next_ack_pos(&self) -> Option { + self.next_ack_pos + } + + pub fn accept(&mut self, ticket: Ticket, ack_range: Range) { + if let Some(prev_pos) = self.next_ack_pos.replace(StreamPosition { + seq_num: ack_range.end.seq_num, + timestamp: ack_range.end.timestamp, + }) { + assert_eq!(ack_range.start.seq_num, prev_pos.seq_num); + assert!(ack_range.start.timestamp >= prev_pos.timestamp); + } + let sender = ticket.accept(ack_range); + if let Some(prev) = self.queue.back() { + assert!(prev.durability_dependency.end < sender.durability_dependency.end); + } + self.queue.push_back(sender); + } + + pub fn reject(&mut self, ticket: Ticket, err: AppendErrorInternal, stable_pos: StreamPosition) { + if let Some(sender) = ticket.reject(err, stable_pos) { + let dd = sender.durability_dependency; + let insert_pos = self + .queue + .partition_point(|x| x.durability_dependency.end <= dd.end); + self.queue.insert(insert_pos, sender); + } + } + + pub fn on_stable(&mut self, stable_pos: StreamPosition) { + let completable = self + .queue + .iter() + .take_while(|sender| sender.durability_dependency.end <= stable_pos.seq_num) + .count(); + for sender in self.queue.drain(..completable) { + sender.unblock(Ok(stable_pos)); + } + // Lots of small appends could cause this, + // as we bound only on total bytes not num batches. + // Keep a small buffer to reuse across ordinary drain/refill cycles. + if self.queue.capacity() > MIN_PENDING_APPENDS_CAPACITY + && self.queue.capacity() >= 4 * self.queue.len() + { + self.queue + .shrink_to((self.queue.len() * 2).max(MIN_PENDING_APPENDS_CAPACITY)); + } + } + + pub fn on_durability_failed(self, err: slatedb::Error) { + let err = StorageError::from(err); + for sender in self.queue { + sender.unblock(Err(err.clone())); + } + } +} + +pub struct Ticket { + tx: oneshot::Sender>, + session: Option>, +} + +impl Ticket { + #[must_use] + fn accept(self, ack_range: Range) -> BlockedReplySender { + let durability_dependency = ..ack_range.end.seq_num; + if let Some(mut session) = self.session { + let session = session.deref_mut(); + assert!(!session.poisoned, "thanks to typestate"); + session.last_ack_end = durability_dependency; + } + BlockedReplySender { + reply: Ok(ack_range), + durability_dependency, + tx: self.tx, + } + } + + #[must_use] + fn reject( + self, + append_err: AppendErrorInternal, + stable_pos: StreamPosition, + ) -> Option { + let mut durability_dependency = append_err.durability_dependency(); + if let Some(mut session) = self.session { + let session = session.deref_mut(); + assert!(!session.poisoned, "thanks to typestate"); + session.poisoned = true; + durability_dependency = ..durability_dependency.end.max(session.last_ack_end.end); + } + if durability_dependency.end <= stable_pos.seq_num { + let _ = self.tx.send(Err(append_err)); + None + } else { + Some(BlockedReplySender { + reply: Err(append_err), + durability_dependency, + tx: self.tx, + }) + } + } +} + +#[derive(Debug)] +struct BlockedReplySender { + reply: Result, AppendErrorInternal>, + durability_dependency: RangeTo, + tx: oneshot::Sender>, +} + +impl BlockedReplySender { + fn unblock(self, stable_pos: Result) { + let reply = match stable_pos { + Ok(tail) => { + assert!(self.durability_dependency.end <= tail.seq_num); + self.reply.map(|ack| AppendAck { + start: ack.start, + end: ack.end, + tail, + }) + } + Err(e) => Err(e.into()), + }; + let _ = self.tx.send(reply); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn position(seq_num: SeqNum) -> StreamPosition { + StreamPosition { + seq_num, + timestamp: seq_num, + } + } + + fn accept( + pending: &mut PendingAppends, + seq_num: SeqNum, + ) -> oneshot::Receiver> { + let (tx, rx) = oneshot::channel(); + let ticket = admit(tx, None).expect("open receiver"); + pending.accept(ticket, position(seq_num)..position(seq_num + 1)); + rx + } + + #[rstest::rstest] + #[case(1)] + #[case(16)] + fn small_append_bursts_reuse_queue_capacity(#[case] burst_size: SeqNum) { + let mut pending = PendingAppends::new(); + let mut retained_capacity = None; + for burst in 0..4 { + let start = burst * burst_size; + let receivers: Vec<_> = (start..start + burst_size) + .map(|seq_num| accept(&mut pending, seq_num)) + .collect(); + let capacity = *retained_capacity.get_or_insert(pending.queue.capacity()); + assert_eq!(pending.queue.capacity(), capacity); + + // The streamer advances durability one append at a time, even when + // the whole burst became durable in the same WAL flush. + for (offset, mut rx) in receivers.into_iter().enumerate() { + let seq_num = start + offset as SeqNum; + assert!(matches!( + rx.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + pending.on_stable(position(seq_num + 1)); + let ack = rx + .try_recv() + .expect("ack delivered") + .expect("append accepted"); + assert_eq!(ack.start, position(seq_num)); + assert_eq!(ack.end, position(seq_num + 1)); + assert_eq!(ack.tail, position(seq_num + 1)); + assert_eq!(pending.queue.capacity(), capacity); + } + assert!(pending.queue.is_empty()); + } + } + + #[test] + fn unused_queue_stays_unallocated() { + let mut pending = PendingAppends::new(); + pending.on_stable(StreamPosition::MIN); + assert_eq!(pending.queue.capacity(), 0); + } + + #[test] + fn large_append_burst_releases_excess_capacity() { + let mut pending = PendingAppends::new(); + let mut receivers: Vec<_> = (0..128) + .map(|seq_num| accept(&mut pending, seq_num)) + .collect(); + let peak_capacity = pending.queue.capacity(); + + pending.on_stable(position(96)); + assert_eq!(pending.queue.len(), 32); + let reduced_capacity = pending.queue.capacity(); + assert!(reduced_capacity < peak_capacity); + for (seq_num, rx) in receivers.iter_mut().enumerate() { + if seq_num < 96 { + let ack = rx + .try_recv() + .expect("ack delivered") + .expect("append accepted"); + assert_eq!(ack.start, position(seq_num as SeqNum)); + assert_eq!(ack.end, position(seq_num as SeqNum + 1)); + assert_eq!(ack.tail, position(96)); + } else { + assert!(matches!( + rx.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + } + } + + // A small change in occupancy should not immediately shrink again. + pending.on_stable(position(97)); + assert_eq!(pending.queue.capacity(), reduced_capacity); + pending.on_stable(position(128)); + assert!(pending.queue.is_empty()); + assert!(pending.queue.capacity() > 0); + assert!(pending.queue.capacity() <= MIN_PENDING_APPENDS_CAPACITY); + for (seq_num, rx) in receivers.iter_mut().enumerate().skip(96) { + let ack = rx + .try_recv() + .expect("ack delivered") + .expect("append accepted"); + assert_eq!(ack.start, position(seq_num as SeqNum)); + assert_eq!(ack.end, position(seq_num as SeqNum + 1)); + assert_eq!(ack.tail, position(if seq_num == 96 { 97 } else { 128 })); + } + } +} diff --git a/lite/src/backend/basins.rs b/lite/src/backend/basins.rs new file mode 100644 index 00000000..73f1cb01 --- /dev/null +++ b/lite/src/backend/basins.rs @@ -0,0 +1,240 @@ +use s2_common::{ + basin::{BasinInfo, BasinName, ListBasinsRequest}, + config::{BasinConfig, BasinReconfiguration}, + resources::{Page, ProvisionMode, ProvisionResult, RequestToken}, + stream::StreamNameStartAfter, +}; +use s2_storage::bash::Bash; +use slatedb::{ + IsolationLevel, + config::{DurabilityLevel, ScanOptions}, +}; +use time::OffsetDateTime; + +use super::{ + Backend, + bgtasks::BgtaskTrigger, + store::{db_txn_commit_durable, db_txn_get, db_txn_get_with}, +}; +use crate::backend::{ + error::{ + BasinAlreadyExistsError, BasinDeletionPendingError, BasinNotFoundError, DeleteBasinError, + GetBasinConfigError, ListBasinsError, ProvisionBasinError, ReconfigureBasinError, + }, + kv, +}; + +impl Backend { + pub async fn list_basins( + &self, + request: ListBasinsRequest, + ) -> Result, ListBasinsError> { + let ListBasinsRequest { + prefix, + start_after, + limit, + } = request; + + let key_range = kv::basin_meta::ser_key_range(&prefix, &start_after); + if key_range.is_empty() { + return Ok(Page::new_empty()); + } + + let scan_opts = ScanOptions { + durability_filter: DurabilityLevel::Remote, + ..Default::default() + }; + let mut it = self.db.scan_with_options(key_range, &scan_opts).await?; + + let mut basins = Vec::with_capacity(limit.as_usize()); + let mut has_more = false; + while let Some(kv) = it.next().await? { + let basin = kv::basin_meta::deser_key(kv.key)?; + assert!(basin.as_ref() > start_after.as_ref()); + assert!(basin.as_ref() >= prefix.as_ref()); + if basins.len() == limit.as_usize() { + has_more = true; + break; + } + let meta = kv::basin_meta::deser_value(kv.value)?; + basins.push(BasinInfo { + name: basin, + location: None, + created_at: meta.created_at, + deleted_at: meta.deleted_at, + }); + } + Ok(Page::new(basins, has_more)) + } + + /// Any outcome asserting the basin exists — `Created`, `Updated`, `Noop`, + /// or `BasinAlreadyExists` — is readable at `DurabilityLevel::Remote`. + pub async fn provision_basin( + &self, + basin: BasinName, + config: BasinConfig, + mode: ProvisionMode, + ) -> Result, ProvisionBasinError> { + let meta_key = kv::basin_meta::ser_key(&basin); + + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + + // A transaction can see metadata that has not been flushed yet. + let (existing_meta, existing_seq) = db_txn_get_with(&txn, &meta_key, |entry| { + Ok((kv::basin_meta::deser_value(entry.value)?, entry.seq)) + }) + .await? + .unzip(); + if let Some(existing_meta) = &existing_meta + && existing_meta.deleted_at.is_some() + { + return Err(BasinDeletionPendingError { basin }.into()); + } + + let outcome = match (existing_meta, mode) { + (Some(existing), ProvisionMode::CreateOnly { request_token }) => { + let new_creation_idempotency_key = request_token + .as_ref() + .map(|req_token| creation_idempotency_key(req_token, &config)); + let result = if new_creation_idempotency_key.is_some() + && existing.creation_idempotency_key == new_creation_idempotency_key + { + Ok(ProvisionResult::Noop(BasinInfo { + name: basin, + location: None, + created_at: existing.created_at, + deleted_at: None, + })) + } else { + Err(BasinAlreadyExistsError { basin }.into()) + }; + drop(txn); + self.await_durable_seq(existing_seq.expect("existing meta was read")) + .await?; + return result; + } + (Some(existing), ProvisionMode::Ensure) => { + let meta = kv::basin_meta::BasinMeta { + config, + created_at: existing.created_at, + deleted_at: None, + creation_idempotency_key: existing.creation_idempotency_key, + }; + if existing.config == meta.config { + ProvisionResult::Noop(meta) + } else { + ProvisionResult::Updated(meta) + } + } + (None, ProvisionMode::CreateOnly { request_token }) => { + let new_creation_idempotency_key = request_token + .as_ref() + .map(|req_token| creation_idempotency_key(req_token, &config)); + ProvisionResult::Created(kv::basin_meta::BasinMeta { + config, + created_at: OffsetDateTime::now_utc(), + deleted_at: None, + creation_idempotency_key: new_creation_idempotency_key, + }) + } + (None, ProvisionMode::Ensure) => ProvisionResult::Created(kv::basin_meta::BasinMeta { + config, + created_at: OffsetDateTime::now_utc(), + deleted_at: None, + creation_idempotency_key: None, + }), + }; + + if matches!(&outcome, ProvisionResult::Noop(_)) { + drop(txn); + self.await_durable_seq(existing_seq.expect("noop implies existing meta")) + .await?; + } else { + let meta = outcome.inner(); + txn.put(&meta_key, kv::basin_meta::ser_value(meta))?; + + db_txn_commit_durable(txn).await?; + } + + Ok(outcome.map(|meta| BasinInfo { + name: basin, + location: None, + created_at: meta.created_at, + deleted_at: None, + })) + } + + pub async fn get_basin_config( + &self, + basin: BasinName, + ) -> Result { + let Some(meta) = self + .db_get(kv::basin_meta::ser_key(&basin), kv::basin_meta::deser_value) + .await? + else { + return Err(BasinNotFoundError { basin }.into()); + }; + Ok(meta.config) + } + + pub async fn reconfigure_basin( + &self, + basin: BasinName, + reconfig: BasinReconfiguration, + ) -> Result { + let meta_key = kv::basin_meta::ser_key(&basin); + + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + + let Some(mut meta) = db_txn_get(&txn, &meta_key, kv::basin_meta::deser_value).await? else { + return Err(BasinNotFoundError { basin }.into()); + }; + + if meta.deleted_at.is_some() { + return Err(BasinDeletionPendingError { basin }.into()); + } + + meta.config = meta.config.reconfigure(reconfig); + + txn.put(&meta_key, kv::basin_meta::ser_value(&meta))?; + + db_txn_commit_durable(txn).await?; + + Ok(meta.config) + } + + pub async fn delete_basin(&self, basin: BasinName) -> Result<(), DeleteBasinError> { + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + let meta_key = kv::basin_meta::ser_key(&basin); + let Some((mut meta, seq)) = db_txn_get_with(&txn, &meta_key, |entry| { + Ok((kv::basin_meta::deser_value(entry.value)?, entry.seq)) + }) + .await? + else { + return Err(BasinNotFoundError { basin }.into()); + }; + if meta.deleted_at.is_none() { + meta.deleted_at = Some(OffsetDateTime::now_utc()); + txn.put(&meta_key, kv::basin_meta::ser_value(&meta))?; + txn.put( + kv::basin_deletion_pending::ser_key(&basin), + kv::basin_deletion_pending::ser_value(&StreamNameStartAfter::default()), + )?; + db_txn_commit_durable(txn).await?; + } else { + // A retry may observe the marker before the first delete has flushed. + drop(txn); + self.await_durable_seq(seq).await?; + } + self.bgtask_trigger(BgtaskTrigger::BasinDeletion); + Ok(()) + } +} + +fn creation_idempotency_key(req_token: &RequestToken, config: &BasinConfig) -> Bash { + Bash::length_prefixed(&[ + req_token.as_bytes(), + &serde_json::to_vec(&s2_api::v1::config::BasinConfig::from(config.clone())) + .expect("serializable"), + ]) +} diff --git a/lite/src/backend/bgtasks/basin_deletion.rs b/lite/src/backend/bgtasks/basin_deletion.rs new file mode 100644 index 00000000..1dbb4344 --- /dev/null +++ b/lite/src/backend/bgtasks/basin_deletion.rs @@ -0,0 +1,530 @@ +use futures::{StreamExt, stream}; +use s2_common::{ + basin::BasinName, + resources::{ListLimit, Page}, + stream::{ListStreamsRequest, StreamNamePrefix, StreamNameStartAfter}, +}; +use slatedb::{ + WriteBatch, + config::{DurabilityLevel, ScanOptions}, +}; +use tracing::instrument; + +use super::{ItemProgress, PageProgress}; +use crate::backend::{ + Backend, + error::{BasinDeletionError, ListStreamsError, StorageError}, + kv, +}; + +const PENDING_LIST_LIMIT: usize = 32; +const CONCURRENCY: usize = 4; + +impl Backend { + pub(super) async fn tick_basin_deletion(self) -> Result { + let page = self.list_basin_deletion_pending().await?; + if page.values.is_empty() { + return Ok(page.has_more); + } + let mut progress = PageProgress::new(page.has_more); + let mut processed = stream::iter(page.values) + .map(|(basin, cursor)| { + let backend = self.clone(); + async move { backend.process_basin_deletion(basin, cursor).await } + }) + .buffer_unordered(CONCURRENCY); + while let Some(result) = processed.next().await { + progress.record(result?); + } + Ok(progress.should_continue()) + } + + async fn list_basin_deletion_pending( + &self, + ) -> Result, StorageError> { + let scan_opts = ScanOptions { + durability_filter: DurabilityLevel::Remote, + ..Default::default() + }; + let mut it = self + .db + .scan_with_options( + kv::key_type_range(kv::KeyType::BasinDeletionPending), + &scan_opts, + ) + .await?; + let mut pending = Vec::new(); + while let Some(kv) = it.next().await? { + let basin = kv::basin_deletion_pending::deser_key(kv.key)?; + let cursor = kv::basin_deletion_pending::deser_value(kv.value)?; + pending.push((basin, cursor)); + if pending.len() >= PENDING_LIST_LIMIT { + return Ok(Page::new(pending, true)); + } + } + Ok(Page::new(pending, false)) + } + + async fn process_basin_deletion( + &self, + basin: BasinName, + cursor: StreamNameStartAfter, + ) -> Result { + let request = ListStreamsRequest { + prefix: StreamNamePrefix::default(), + start_after: cursor.clone(), + limit: ListLimit::MAX, + }; + let page = self + .list_streams(basin.clone(), request) + .await + .map_err(|err| match err { + ListStreamsError::Storage(error) => error, + })?; + + let mut last_stream = None; + for info in page.values { + let stream = info.name; + last_stream = Some(StreamNameStartAfter::from(stream.clone())); + if info.deleted_at.is_some() { + continue; + } + self.delete_stream(basin.clone(), stream.clone()).await?; + } + + if page.has_more { + self.set_basin_deletion_cursor(&basin, &last_stream.expect("non-empty stream page")) + .await?; + Ok(ItemProgress::Advanced) + } else if last_stream.is_some() || !cursor.as_ref().is_empty() { + // Streams still pending deletion or cursor was advanced past + // earlier entries. Reset cursor so the next tick re-scans from + // the beginning. A reset is not progress: counting it would + // rescan a multi-page basin in a tight loop. + if !cursor.as_ref().is_empty() { + self.set_basin_deletion_cursor(&basin, &StreamNameStartAfter::default()) + .await?; + } + Ok(ItemProgress::Blocked) + } else { + // No streams from the very beginning — safe to complete. + self.complete_basin_deletion(&basin).await?; + Ok(ItemProgress::Completed) + } + } + + #[instrument(ret, err, skip(self))] + async fn set_basin_deletion_cursor( + &self, + basin: &BasinName, + cursor: &StreamNameStartAfter, + ) -> Result<(), StorageError> { + let mut batch = WriteBatch::new(); + batch.put( + kv::basin_deletion_pending::ser_key(basin), + kv::basin_deletion_pending::ser_value(cursor), + ); + self.db.write(batch).await?.await_durable().await?; + Ok(()) + } + + #[instrument(ret, err, skip(self))] + async fn complete_basin_deletion(&self, basin: &BasinName) -> Result<(), StorageError> { + let mut batch = WriteBatch::new(); + batch.delete(kv::basin_meta::ser_key(basin)); + batch.delete(kv::basin_deletion_pending::ser_key(basin)); + self.db.write(batch).await?.await_durable().await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use s2_common::{ + basin::BasinName, + config::{BasinConfig, StreamConfig}, + resources::ListLimit, + stream::{StreamName, StreamNameStartAfter}, + }; + use time::OffsetDateTime; + + use super::super::tests::test_backend; + use crate::backend::{Backend, kv, test_util::DbWriteTestExt as _}; + + fn basin_meta(deleted_at: Option) -> kv::basin_meta::BasinMeta { + kv::basin_meta::BasinMeta { + config: BasinConfig::default(), + created_at: OffsetDateTime::now_utc(), + deleted_at, + creation_idempotency_key: None, + } + } + + fn stream_meta(deleted_at: Option) -> kv::stream_meta::StreamMeta { + kv::stream_meta::StreamMeta { + config: StreamConfig::default(), + cipher: None, + created_at: OffsetDateTime::now_utc(), + deleted_at, + creation_idempotency_key: None, + } + } + + fn stream_name_for_index(index: usize) -> StreamName { + StreamName::from_str(&format!("stream-{index:04}")).unwrap() + } + + fn expected_page_cursor(limit: usize) -> StreamNameStartAfter { + StreamNameStartAfter::from(stream_name_for_index(limit.saturating_sub(1))) + } + + async fn seed_basin_for_deletion(backend: &Backend, basin: &BasinName) { + backend + .db + .put( + kv::basin_meta::ser_key(basin), + kv::basin_meta::ser_value(&basin_meta(Some(OffsetDateTime::now_utc()))), + ) + .assert_durable() + .await; + backend + .db + .put( + kv::basin_deletion_pending::ser_key(basin), + kv::basin_deletion_pending::ser_value(&StreamNameStartAfter::default()), + ) + .assert_durable() + .await; + } + + async fn seed_tombstoned_streams(backend: &Backend, basin: &BasinName, count: usize) { + let deleted_at = OffsetDateTime::from_unix_timestamp(1234567890).unwrap(); + let mut batch = slatedb::WriteBatch::new(); + for i in 0..count { + let stream = stream_name_for_index(i); + batch.put( + kv::stream_meta::ser_key(basin, &stream), + kv::stream_meta::ser_value(&stream_meta(Some(deleted_at))), + ); + } + backend.db.write(batch).assert_durable().await; + } + + #[tokio::test] + async fn basin_deletion_completes_empty_basin() { + let backend = test_backend().await; + let basin = BasinName::from_str("test-basin").unwrap(); + + backend + .db + .put( + kv::basin_meta::ser_key(&basin), + kv::basin_meta::ser_value(&basin_meta(Some(OffsetDateTime::now_utc()))), + ) + .assert_durable() + .await; + backend + .db + .put( + kv::basin_deletion_pending::ser_key(&basin), + kv::basin_deletion_pending::ser_value(&StreamNameStartAfter::default()), + ) + .assert_durable() + .await; + + let has_more = backend.clone().tick_basin_deletion().await.unwrap(); + assert!(!has_more); + + assert!( + backend + .db + .get(kv::basin_meta::ser_key(&basin)) + .await + .unwrap() + .is_none() + ); + assert!( + backend + .db + .get(kv::basin_deletion_pending::ser_key(&basin)) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn basin_deletion_tombstones_active_stream() { + let backend = test_backend().await; + let basin = BasinName::from_str("test-basin").unwrap(); + let stream = StreamName::from_str("live-stream").unwrap(); + + seed_basin_for_deletion(&backend, &basin).await; + backend + .db + .put( + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::ser_value(&stream_meta(None)), + ) + .assert_durable() + .await; + + backend + .db + .put( + kv::stream_id_mapping::ser_key(crate::stream_id::StreamId::new(&basin, &stream)), + kv::stream_id_mapping::ser_value(&basin, &stream), + ) + .assert_durable() + .await; + + let has_more = backend.clone().tick_basin_deletion().await.unwrap(); + assert!(!has_more); + + let meta = backend + .db + .get(kv::stream_meta::ser_key(&basin, &stream)) + .await + .unwrap() + .expect("stream meta present"); + let meta = kv::stream_meta::deser_value(meta).unwrap(); + assert!(meta.deleted_at.is_some()); + // Basin deletion is blocked until tombstoned stream metadata is cleaned + // up by stream_trim. + assert!( + backend + .db + .get(kv::basin_meta::ser_key(&basin)) + .await + .unwrap() + .is_some() + ); + assert!( + backend + .db + .get(kv::basin_deletion_pending::ser_key(&basin)) + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn basin_deletion_advances_cursor_when_page_has_more() { + let backend = test_backend().await; + let basin = BasinName::from_str("test-basin").unwrap(); + let limit = ListLimit::MAX.as_usize(); + + seed_basin_for_deletion(&backend, &basin).await; + seed_tombstoned_streams(&backend, &basin, limit + 1).await; + + let has_more = backend.clone().tick_basin_deletion().await.unwrap(); + assert!(has_more); + + let pending = backend + .db + .get(kv::basin_deletion_pending::ser_key(&basin)) + .await + .unwrap() + .expect("pending cursor still exists"); + let cursor = kv::basin_deletion_pending::deser_value(pending).unwrap(); + let expected_cursor = expected_page_cursor(limit); + assert_eq!(cursor.as_ref(), expected_cursor.as_ref()); + assert!( + backend + .db + .get(kv::basin_meta::ser_key(&basin)) + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn basin_deletion_aggregates_has_more_across_basins() { + let backend = test_backend().await; + let paged_basin = BasinName::from_str("paged-basin").unwrap(); + let empty_basin = BasinName::from_str("empty-basin").unwrap(); + let limit = ListLimit::MAX.as_usize(); + + seed_basin_for_deletion(&backend, &paged_basin).await; + seed_basin_for_deletion(&backend, &empty_basin).await; + seed_tombstoned_streams(&backend, &paged_basin, limit + 1).await; + + let has_more = backend.clone().tick_basin_deletion().await.unwrap(); + assert!(has_more); + + assert!( + backend + .db + .get(kv::basin_meta::ser_key(&empty_basin)) + .await + .unwrap() + .is_none() + ); + assert!( + backend + .db + .get(kv::basin_deletion_pending::ser_key(&empty_basin)) + .await + .unwrap() + .is_none() + ); + let pending = backend + .db + .get(kv::basin_deletion_pending::ser_key(&paged_basin)) + .await + .unwrap() + .expect("paged basin still pending"); + let cursor = kv::basin_deletion_pending::deser_value(pending).unwrap(); + let expected_cursor = expected_page_cursor(limit); + assert_eq!(cursor.as_ref(), expected_cursor.as_ref()); + } + + #[tokio::test] + async fn basin_deletion_completes_when_cursor_past_end() { + let backend = test_backend().await; + let basin = BasinName::from_str("test-basin").unwrap(); + let cursor = StreamNameStartAfter::from_str("zzz-stream").unwrap(); + + backend + .db + .put( + kv::basin_meta::ser_key(&basin), + kv::basin_meta::ser_value(&basin_meta(Some(OffsetDateTime::now_utc()))), + ) + .assert_durable() + .await; + backend + .db + .put( + kv::basin_deletion_pending::ser_key(&basin), + kv::basin_deletion_pending::ser_value(&cursor), + ) + .assert_durable() + .await; + + // First tick resets cursor from past-end back to the beginning. + let has_more = backend.clone().tick_basin_deletion().await.unwrap(); + assert!(!has_more); + + // Second tick scans from the beginning, finds no streams, completes. + let has_more = backend.clone().tick_basin_deletion().await.unwrap(); + assert!(!has_more); + + assert!( + backend + .db + .get(kv::basin_meta::ser_key(&basin)) + .await + .unwrap() + .is_none() + ); + assert!( + backend + .db + .get(kv::basin_deletion_pending::ser_key(&basin)) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn basin_deletion_blocked_when_only_tombstones_remain() { + let backend = test_backend().await; + let basin = BasinName::from_str("test-basin").unwrap(); + let stream = StreamName::from_str("tombstoned-stream").unwrap(); + let deleted_at = OffsetDateTime::from_unix_timestamp(1234567890).unwrap(); + + seed_basin_for_deletion(&backend, &basin).await; + backend + .db + .put( + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::ser_value(&stream_meta(Some(deleted_at))), + ) + .assert_durable() + .await; + + let has_more = backend.clone().tick_basin_deletion().await.unwrap(); + assert!(!has_more); + + // Basin deletion is blocked while tombstoned stream metadata exists. + assert!( + backend + .db + .get(kv::basin_meta::ser_key(&basin)) + .await + .unwrap() + .is_some() + ); + assert!( + backend + .db + .get(kv::basin_deletion_pending::ser_key(&basin)) + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn basin_deletion_completes_after_tombstones_cleaned() { + let backend = test_backend().await; + let basin = BasinName::from_str("test-basin").unwrap(); + let stream = StreamName::from_str("tombstoned-stream").unwrap(); + let deleted_at = OffsetDateTime::from_unix_timestamp(1234567890).unwrap(); + + seed_basin_for_deletion(&backend, &basin).await; + backend + .db + .put( + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::ser_value(&stream_meta(Some(deleted_at))), + ) + .assert_durable() + .await; + + // First tick: blocked by tombstoned stream. + let has_more = backend.clone().tick_basin_deletion().await.unwrap(); + assert!(!has_more); + assert!( + backend + .db + .get(kv::basin_meta::ser_key(&basin)) + .await + .unwrap() + .is_some() + ); + + // Simulate stream_trim cleaning up the tombstoned stream metadata. + backend + .db + .delete(kv::stream_meta::ser_key(&basin, &stream)) + .assert_durable() + .await; + + // Second tick: no streams remain, completes. + let has_more = backend.clone().tick_basin_deletion().await.unwrap(); + assert!(!has_more); + assert!( + backend + .db + .get(kv::basin_meta::ser_key(&basin)) + .await + .unwrap() + .is_none() + ); + assert!( + backend + .db + .get(kv::basin_deletion_pending::ser_key(&basin)) + .await + .unwrap() + .is_none() + ); + } +} diff --git a/lite/src/backend/bgtasks/mod.rs b/lite/src/backend/bgtasks/mod.rs new file mode 100644 index 00000000..ed219387 --- /dev/null +++ b/lite/src/backend/bgtasks/mod.rs @@ -0,0 +1,241 @@ +use std::{error::Error, future::Future, pin::Pin, time::Duration}; + +use tokio::{sync::broadcast, time::Instant}; +use tracing::{info, warn}; + +use crate::backend::Backend; + +mod basin_deletion; +mod stream_doe; +mod stream_trim; + +/// Keep draining the backlog while at least one item makes progress. A page where +/// every item is blocked waits for the next tick instead of retrying in a tight loop. +struct PageProgress { + has_more: bool, + any_progressed: bool, +} + +enum ItemProgress { + /// Made progress, and more work for this item can run immediately. + Advanced, + Completed, + /// Made no progress; the item waits for a later tick. + Blocked, +} + +impl PageProgress { + fn new(has_more: bool) -> Self { + Self { + has_more, + any_progressed: false, + } + } + + fn record(&mut self, item: ItemProgress) { + match item { + ItemProgress::Advanced => { + self.has_more = true; + self.any_progressed = true; + } + ItemProgress::Completed => self.any_progressed = true, + ItemProgress::Blocked => {} + } + } + + fn record_result( + &mut self, + result: Result, + is_conflict: fn(&E) -> bool, + ) -> Result, E> { + match result { + Ok(value) => { + self.record(ItemProgress::Completed); + Ok(Some(value)) + } + Err(err) if is_conflict(&err) => { + self.record(ItemProgress::Blocked); + Ok(None) + } + Err(err) => Err(err), + } + } + + fn should_continue(&self) -> bool { + self.has_more && self.any_progressed + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum BgtaskTrigger { + BasinDeletion, + StreamDeleteOnEmpty, + StreamTrim, +} + +pub fn spawn(backend: &Backend) { + spawn_bgtask( + "stream-trim", + Duration::from_secs(60), + &[BgtaskTrigger::StreamTrim], + backend.bgtask_trigger_subscribe(), + move |backend| backend.clone().tick_stream_trim(), + backend.clone(), + ); + spawn_bgtask( + "stream-delete-on-empty", + Duration::from_secs(60), + &[BgtaskTrigger::StreamDeleteOnEmpty], + backend.bgtask_trigger_subscribe(), + move |backend| backend.clone().tick_stream_doe(), + backend.clone(), + ); + spawn_bgtask( + "basin-deletion", + Duration::from_secs(60), + &[BgtaskTrigger::BasinDeletion], + backend.bgtask_trigger_subscribe(), + move |backend| backend.clone().tick_basin_deletion(), + backend.clone(), + ); +} + +fn spawn_bgtask( + name: &'static str, + interval: Duration, + triggers: &'static [BgtaskTrigger], + mut trigger_rx: broadcast::Receiver, + tick: Tick, + backend: Backend, +) where + Tick: Fn(&Backend) -> Fut + Send + Sync + 'static, + Fut: Future> + Send, + E: Error + Send + Sync + 'static, +{ + tokio::spawn(async move { + let sleep = tokio::time::sleep(jittered_delay(interval)); + tokio::pin!(sleep); + let reset_sleep = |sleep: &mut Pin<&mut tokio::time::Sleep>| { + sleep + .as_mut() + .reset(Instant::now() + jittered_delay(interval)); + }; + loop { + tokio::select! { + _ = &mut sleep => { + run_tick(name, &tick, &backend).await; + reset_sleep(&mut sleep); + } + res = trigger_rx.recv() => { + match res { + Ok(trigger) => { + if triggers.contains(&trigger) { + run_tick(name, &tick, &backend).await; + reset_sleep(&mut sleep); + } + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + warn!( + task = name, + skipped, + "bgtask trigger channel lagged, running tick immediately" + ); + run_tick(name, &tick, &backend).await; + reset_sleep(&mut sleep); + } + Err(broadcast::error::RecvError::Closed) => { + info!(task = name, "bgtask trigger channel closed, exiting"); + break; + } + } + } + } + } + }); +} + +fn jittered_delay(interval: Duration) -> Duration { + if interval.is_zero() { + return interval; + } + let max_jitter = interval / 10; + let max_ms = max_jitter.as_millis() as i64; + if max_ms == 0 { + return interval; + } + let jitter_ms = rand::random_range(-max_ms..=max_ms); + if jitter_ms >= 0 { + interval + Duration::from_millis(jitter_ms as u64) + } else { + interval - Duration::from_millis((-jitter_ms) as u64) + } +} + +async fn run_tick(task: &'static str, tick: &Tick, backend: &Backend) +where + Tick: Fn(&Backend) -> Fut + Send + Sync, + Fut: Future> + Send, + E: Error + Send + Sync, +{ + loop { + match tick(backend).await { + Ok(true) => continue, + Ok(false) => break, + Err(error) => { + warn!(task, %error, error_source = error.source().map(|s| s.to_string()), "bgtask tick failed"); + break; + } + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use bytesize::ByteSize; + use slatedb::object_store::memory::InMemory; + + use super::*; + + pub(super) async fn test_backend() -> Backend { + let object_store = Arc::new(InMemory::new()); + let db = slatedb::Db::builder("/test", object_store) + .build() + .await + .unwrap(); + Backend::new(db, ByteSize::mib(10)) + } + + #[tokio::test] + async fn run_tick_repeats_until_done() { + let backend = test_backend().await; + let calls = Arc::new(AtomicUsize::new(0)); + let tick = { + let calls = Arc::clone(&calls); + move |_backend: &Backend| { + let calls = Arc::clone(&calls); + async move { + let count = calls.fetch_add(1, Ordering::SeqCst); + Ok::(count < 2) + } + } + }; + + run_tick("test", &tick, &backend).await; + + assert_eq!(calls.load(Ordering::SeqCst), 3); + } + + #[test] + fn blocked_page_waits_for_next_tick() { + let mut progress = PageProgress::new(true); + progress.record(ItemProgress::Blocked); + assert!(!progress.should_continue()); + progress.record(ItemProgress::Completed); + assert!(progress.should_continue()); + } +} diff --git a/lite/src/backend/bgtasks/stream_doe.rs b/lite/src/backend/bgtasks/stream_doe.rs new file mode 100644 index 00000000..d5c3a19d --- /dev/null +++ b/lite/src/backend/bgtasks/stream_doe.rs @@ -0,0 +1,1244 @@ +use bytes::Bytes; +use futures::{StreamExt, stream}; +use indexmap::IndexMap; +use s2_common::{basin::BasinName, resources::Page, stream::StreamName}; +use slatedb::{ + IsolationLevel, WriteBatch, + config::{DurabilityLevel, ScanOptions}, +}; + +use super::PageProgress; +use crate::{ + backend::{ + Backend, doe, + error::{DeleteStreamError, StorageError, StreamDeleteOnEmptyError}, + kv, + store::{db_snapshot_get_with, db_txn_commit_durable, db_txn_get, db_txn_get_with}, + streamer::{TerminalTrimCondition, TerminalTrimOutcome}, + timestamp::TimestampSecs, + }, + stream_id::StreamId, +}; + +const PENDING_LIST_LIMIT: usize = 10_000; +const CONCURRENCY: usize = 4; + +#[derive(Clone, Copy, Debug)] +struct PendingCheck { + stream_id: StreamId, + check: kv::stream_doe_state::Check, +} + +struct CheckSnapshot { + revision: u64, + creation_seq: u64, + config_seq: u64, + basin: BasinName, + stream: StreamName, +} + +impl Backend { + pub(super) async fn tick_stream_doe(self) -> Result { + // Drain legacy keys regardless of their old deadlines. Their timestamps + // and values have no role in the new scheduler or deletion eligibility. + let mut progress = self.migrate_stream_doe().await?; + let page = self.list_pending_stream_doe(TimestampSecs::now()).await?; + progress.has_more |= page.has_more; + let mut processed = stream::iter(page.values) + .map(|pending| { + let backend = self.clone(); + async move { backend.process_stream_doe(pending).await } + }) + .buffer_unordered(CONCURRENCY); + while let Some(result) = processed.next().await { + progress.record_result(result, StreamDeleteOnEmptyError::is_transaction_conflict)?; + } + Ok(progress.should_continue()) + } + + async fn list_pending_stream_doe( + &self, + now: TimestampSecs, + ) -> Result, StorageError> { + let scan_opts = ScanOptions { + durability_filter: DurabilityLevel::Remote, + ..Default::default() + }; + let mut it = self + .db + .scan_with_options(kv::stream_doe_check::due_key_range(now), &scan_opts) + .await?; + let mut pending = Vec::new(); + while let Some(kv) = it.next().await? { + let (stream_id, check) = kv::stream_doe_check::deser_key(kv.key)?; + pending.push(PendingCheck { stream_id, check }); + if pending.len() == PENDING_LIST_LIMIT { + return Ok(Page::new(pending, true)); + } + } + Ok(Page::new(pending, false)) + } + + async fn process_stream_doe( + &self, + pending: PendingCheck, + ) -> Result<(), StreamDeleteOnEmptyError> { + let Some(snapshot) = self.observe_doe_check(pending).await? else { + return Ok(()); + }; + let outcome = match self + .delete_stream_with_condition( + snapshot.basin.clone(), + snapshot.stream.clone(), + TerminalTrimCondition::DeleteOnEmpty { + expected_stream_creation_seq: snapshot.creation_seq, + expected_config_seq: snapshot.config_seq, + }, + ) + .await + { + Ok(outcome) => outcome, + Err(DeleteStreamError::StreamNotFound(_)) => TerminalTrimOutcome::Obsolete, + Err(err) => return Err(err.into()), + }; + self.finish_doe_check(pending, snapshot, outcome).await?; + Ok(()) + } + + /// Capture the scheduler revision before the streamer's asynchronous scan. + async fn observe_doe_check( + &self, + pending: PendingCheck, + ) -> Result, StorageError> { + // Observation needs a consistent view, but no transaction read set. The + // completion transaction validates this revision after the actor's scan. + let snapshot = self.db.snapshot().await?; + let state = db_snapshot_get_with( + &snapshot, + kv::stream_doe_state::ser_key(pending.stream_id), + |entry| Ok((kv::stream_doe_state::deser_value(entry.value)?, entry.seq)), + ) + .await?; + let Some((kv::stream_doe_state::State::Scheduled(_), revision)) = state + .filter(|(state, _)| *state == kv::stream_doe_state::State::Scheduled(pending.check)) + else { + drop(snapshot); + self.discard_doe_check(pending, state).await?; + return Ok(None); + }; + let mapping = db_snapshot_get_with( + &snapshot, + kv::stream_id_mapping::ser_key(pending.stream_id), + |entry| Ok((kv::stream_id_mapping::deser_value(entry.value)?, entry.seq)), + ) + .await?; + if let Some(((basin, stream), creation_seq)) = mapping + && revision >= creation_seq + && let Some((meta, config_seq)) = db_snapshot_get_with( + &snapshot, + kv::stream_meta::ser_key(&basin, &stream), + |entry| Ok((kv::stream_meta::deser_value(entry.value)?, entry.seq)), + ) + .await? + && meta.deleted_at.is_none() + && meta.config.delete_on_empty.min_age().is_some() + { + return Ok(Some(CheckSnapshot { + revision, + creation_seq, + config_seq, + basin, + stream, + })); + } + drop(snapshot); + self.discard_doe_check(pending, state).await?; + Ok(None) + } + + /// Obsolete work needs a transaction only when it is actually discarded. + /// Revalidate the snapshot so cleanup cannot erase a concurrent wake. + async fn discard_doe_check( + &self, + pending: PendingCheck, + observed: Option<(kv::stream_doe_state::State, u64)>, + ) -> Result<(), StorageError> { + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + let current = db_txn_get_with( + &txn, + kv::stream_doe_state::ser_key(pending.stream_id), + |entry| Ok((kv::stream_doe_state::deser_value(entry.value)?, entry.seq)), + ) + .await?; + if current != observed { + return Ok(()); + } + match current { + Some((kv::stream_doe_state::State::Scheduled(check), _)) if check == pending.check => { + doe::replace( + &txn, + pending.stream_id, + Some(kv::stream_doe_state::State::Scheduled(check)), + None, + )?; + } + _ => { + txn.delete(kv::stream_doe_check::ser_key( + pending.stream_id, + pending.check, + ))?; + } + } + db_txn_commit_durable(txn).await?; + Ok(()) + } + + async fn finish_doe_check( + &self, + pending: PendingCheck, + snapshot: CheckSnapshot, + outcome: TerminalTrimOutcome, + ) -> Result<(), StorageError> { + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + let state = db_txn_get_with( + &txn, + kv::stream_doe_state::ser_key(pending.stream_id), + |entry| Ok((kv::stream_doe_state::deser_value(entry.value)?, entry.seq)), + ) + .await?; + if state + != Some(( + kv::stream_doe_state::State::Scheduled(pending.check), + snapshot.revision, + )) + { + // A trim/configuration event owns the next check, even if it kept the + // same ticket. Consuming or postponing it here would lose that wake. + return Ok(()); + } + // Successful deletion marks metadata and advances its sequence. Deferred + // outcomes must still belong to the configuration that was inspected. + if outcome != TerminalTrimOutcome::DeletionPending { + let config_seq = db_txn_get_with( + &txn, + kv::stream_meta::ser_key(&snapshot.basin, &snapshot.stream), + |entry| Ok(entry.seq), + ) + .await?; + if config_seq != Some(snapshot.config_seq) { + return Ok(()); + } + } + let next = match outcome { + TerminalTrimOutcome::RetryAt(at) => Some(kv::stream_doe_state::State::Scheduled( + kv::stream_doe_state::Check { + at, + id: rand::random(), + }, + )), + TerminalTrimOutcome::Parked => Some(kv::stream_doe_state::State::Parked), + TerminalTrimOutcome::DeletionPending | TerminalTrimOutcome::Obsolete => None, + }; + // Consuming the old check and installing its successor is one durable + // transaction, including when the caller disappears during commit. + doe::replace( + &txn, + pending.stream_id, + Some(kv::stream_doe_state::State::Scheduled(pending.check)), + next, + )?; + db_txn_commit_durable(txn).await?; + Ok(()) + } + + async fn migrate_stream_doe(&self) -> Result { + let scan_opts = ScanOptions { + durability_filter: DurabilityLevel::Remote, + ..Default::default() + }; + let mut it = self + .db + .scan_with_options( + kv::key_type_range(kv::KeyType::StreamDeleteOnEmptyDeadline), + &scan_opts, + ) + .await?; + let mut pending: IndexMap> = IndexMap::new(); + let mut count = 0; + while let Some(entry) = it.next().await? { + let (_, stream_id, _) = kv::stream_doe_deadline::deser_key(entry.key.clone())?; + pending.entry(stream_id).or_default().push(entry.key); + count += 1; + if count == PENDING_LIST_LIMIT { + break; + } + } + let mut progress = PageProgress::new(count == PENDING_LIST_LIMIT); + if pending.is_empty() { + return Ok(progress); + } + let snapshot = self.db.snapshot().await?; + let mut migrations = stream::iter(pending) + .map(|(stream_id, keys)| self.migrate_doe_deadlines(&snapshot, stream_id, keys)) + .buffer_unordered(CONCURRENCY); + let mut cleanup = WriteBatch::new(); + while let Some(result) = migrations.next().await { + if let Some(keys) = + progress.record_result(result, StorageError::is_transaction_conflict)? + { + for key in keys { + cleanup.delete(key); + } + } + } + if !cleanup.is_empty() { + // Later enablement/recreation installs its own schedule. Only + // legacy keys are removed; new state and its revision stay intact. + // Durability also covers any commits observed by the snapshot. + self.db.write(cleanup).await?.await_durable().await?; + } + Ok(progress) + } + + async fn migrate_doe_deadlines( + &self, + snapshot: &slatedb::DbSnapshot, + stream_id: StreamId, + keys: Vec, + ) -> Result, StorageError> { + if needs_doe_migration(snapshot, stream_id).await? { + // Initialization and removal remain atomic. Recheck all eligibility + // in the transaction after the snapshot. + self.initialize_doe_from_deadlines(stream_id, keys).await?; + Ok(Vec::new()) + } else { + Ok(keys) + } + } + + async fn initialize_doe_from_deadlines( + &self, + stream_id: StreamId, + keys: Vec, + ) -> Result<(), StorageError> { + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + let mapping = db_txn_get_with(&txn, kv::stream_id_mapping::ser_key(stream_id), |entry| { + Ok((kv::stream_id_mapping::deser_value(entry.value)?, entry.seq)) + }) + .await?; + if let Some(((basin, stream), creation_seq)) = mapping + && let Some(meta) = db_txn_get( + &txn, + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::deser_value, + ) + .await? + && meta.deleted_at.is_none() + && meta.config.delete_on_empty.min_age().is_some() + { + let state = db_txn_get_with(&txn, kv::stream_doe_state::ser_key(stream_id), |entry| { + Ok((kv::stream_doe_state::deser_value(entry.value)?, entry.seq)) + }) + .await?; + if state.is_none_or(|(_, seq)| seq < creation_seq) { + let next = kv::stream_doe_state::State::Scheduled(kv::stream_doe_state::Check { + at: TimestampSecs::now(), + id: rand::random(), + }); + doe::replace(&txn, stream_id, state.map(|(state, _)| state), Some(next))?; + } + // Existing state, including Parked, is deliberately untouched. + } + for key in keys { + txn.delete(key)?; + } + db_txn_commit_durable(txn).await?; + Ok(()) + } +} + +async fn needs_doe_migration( + snapshot: &slatedb::DbSnapshot, + stream_id: StreamId, +) -> Result { + let Some(((basin, stream), creation_seq)) = db_snapshot_get_with( + snapshot, + kv::stream_id_mapping::ser_key(stream_id), + |entry| Ok((kv::stream_id_mapping::deser_value(entry.value)?, entry.seq)), + ) + .await? + else { + return Ok(false); + }; + let Some(meta) = db_snapshot_get_with( + snapshot, + kv::stream_meta::ser_key(&basin, &stream), + |entry| kv::stream_meta::deser_value(entry.value), + ) + .await? + else { + return Ok(false); + }; + if meta.deleted_at.is_some() || meta.config.delete_on_empty.min_age().is_none() { + return Ok(false); + } + let state_seq = db_snapshot_get_with( + snapshot, + kv::stream_doe_state::ser_key(stream_id), + |entry| { + kv::stream_doe_state::deser_value(entry.value)?; + Ok(entry.seq) + }, + ) + .await?; + Ok(state_seq.is_none_or(|seq| seq < creation_seq)) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use s2_common::{ + config::{ + DeleteOnEmptyReconfiguration, OptionalStreamConfig, RetentionPolicy, + StreamReconfiguration, + }, + maybe::Maybe, + record::{CommandRecord, MeteredExt as _, Record}, + resources::ProvisionMode, + stream::{AppendInput, AppendRecord, AppendRecordParts}, + }; + + use super::*; + use crate::backend::{ + bgtasks::{run_tick, tests::test_backend}, + test_util::{DbWriteTestExt as _, create_stream}, + }; + + fn config(min_age: u64, retention: RetentionPolicy) -> OptionalStreamConfig { + let mut config = OptionalStreamConfig { + retention_policy: Some(retention), + ..Default::default() + }; + config.delete_on_empty.min_age = Some(Duration::from_secs(min_age)); + config + } + + async fn state( + backend: &Backend, + stream_id: StreamId, + ) -> Option<(kv::stream_doe_state::State, u64)> { + backend + .db_get_with(kv::stream_doe_state::ser_key(stream_id), |entry| { + Ok((kv::stream_doe_state::deser_value(entry.value)?, entry.seq)) + }) + .await + .unwrap() + } + + async fn scheduled(backend: &Backend, stream_id: StreamId) -> kv::stream_doe_state::Check { + let Some((kv::stream_doe_state::State::Scheduled(check), _)) = + state(backend, stream_id).await + else { + panic!("expected a scheduled check"); + }; + let checks = backend + .list_pending_stream_doe(TimestampSecs::MAX) + .await + .unwrap(); + assert_eq!(checks.values.len(), 1); + assert_eq!(checks.values[0].stream_id, stream_id); + assert_eq!(checks.values[0].check, check); + check + } + + async fn due(backend: &Backend, stream_id: StreamId) -> PendingCheck { + let txn = backend + .db + .begin(IsolationLevel::SerializableSnapshot) + .await + .unwrap(); + doe::schedule(&txn, stream_id, TimestampSecs::ZERO) + .await + .unwrap(); + db_txn_commit_durable(txn).await.unwrap(); + PendingCheck { + stream_id, + check: scheduled(backend, stream_id).await, + } + } + + async fn append(backend: &Backend, basin: &BasinName, stream: &StreamName, record: Record) { + let record: AppendRecord = AppendRecordParts { + timestamp: None, + record: record.metered(), + } + .try_into() + .unwrap(); + backend + .open_for_append(basin, stream, None, OptionalStreamConfig::default()) + .await + .unwrap() + .append(AppendInput { + records: vec![record].try_into().unwrap(), + match_seq_num: None, + fencing_token: None, + }) + .await + .unwrap(); + } + + fn record() -> Record { + Record::try_from_parts(vec![], Bytes::from_static(b"live")).unwrap() + } + + async fn configure_min_age( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + min_age: u64, + via_ensure: bool, + ) { + let min_age = Duration::from_secs(min_age); + if via_ensure { + let mut config = backend + .get_stream_config(basin.clone(), stream.clone()) + .await + .unwrap(); + config.delete_on_empty.min_age = min_age; + backend + .provision_stream( + basin.clone(), + stream.clone(), + config.into(), + ProvisionMode::Ensure, + ) + .await + .unwrap(); + } else { + backend + .reconfigure_stream( + basin.clone(), + stream.clone(), + StreamReconfiguration { + delete_on_empty: Maybe::from(Some(DeleteOnEmptyReconfiguration { + min_age: Maybe::from(Some(min_age)), + })), + ..Default::default() + }, + ) + .await + .unwrap(); + } + } + + async fn configure_retention( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + age: u64, + ) { + backend + .reconfigure_stream( + basin.clone(), + stream.clone(), + StreamReconfiguration { + retention_policy: Maybe::from(Some(RetentionPolicy::Age(Duration::from_secs( + age, + )))), + ..Default::default() + }, + ) + .await + .unwrap(); + } + + async fn legacy(backend: &Backend, stream_id: StreamId, id: Option) -> Bytes { + let key = kv::stream_doe_deadline::ser_key(TimestampSecs::MAX, stream_id, id); + // Migration must not interpret the old min_age, even for future keys. + backend.db.put(&key, [0xff]).assert_durable().await; + key + } + + #[tokio::test] + async fn creation_schedules_once_and_appends_do_not_write_schedules() { + let backend = test_backend().await; + let before = TimestampSecs::after(Duration::from_secs(60)); + let (basin, stream) = + create_stream(&backend, config(60, RetentionPolicy::Infinite())).await; + let stream_id = StreamId::new(&basin, &stream); + let check = scheduled(&backend, stream_id).await; + assert!(check.at >= before); + assert!(check.at <= TimestampSecs::after(Duration::from_secs(60))); + let original = state(&backend, stream_id).await; + append(&backend, &basin, &stream, record()).await; + assert_eq!(state(&backend, stream_id).await, original); + assert_eq!(scheduled(&backend, stream_id).await, check); + let mut old = backend + .db + .scan(kv::key_type_range(kv::KeyType::StreamDeleteOnEmptyDeadline)) + .await + .unwrap(); + assert!(old.next().await.unwrap().is_none()); + backend.close().await.unwrap(); + } + + #[rstest::rstest] + #[case::increase(3600, true)] + #[case::decrease(10, false)] + #[tokio::test] + async fn retry_uses_record_expiration_across_retention_changes( + #[case] new_age: u64, + #[case] append_after: bool, + ) { + let backend = test_backend().await; + let initial_age = if append_after { 10 } else { 3600 }; + let (basin, stream) = create_stream( + &backend, + config(60, RetentionPolicy::Age(Duration::from_secs(initial_age))), + ) + .await; + let stream_id = StreamId::new(&basin, &stream); + let lower_bound = TimestampSecs::after(Duration::from_secs(3600)); + append(&backend, &basin, &stream, record()).await; + let original = state(&backend, stream_id).await; + configure_retention(&backend, &basin, &stream, new_age).await; + if append_after { + append(&backend, &basin, &stream, record()).await; + } + assert_eq!(state(&backend, stream_id).await, original); + let pending = due(&backend, stream_id).await; + backend.process_stream_doe(pending).await.unwrap(); + let replacement = scheduled(&backend, stream_id).await; + assert_ne!(replacement.id, pending.check.id); + assert!( + replacement.at >= lower_bound, + "must preserve a check for the actual stored expiration" + ); + backend.close().await.unwrap(); + } + + #[rstest::rstest] + #[case::never_written(false)] + #[case::expired_records(true)] + #[tokio::test] + async fn empty_old_stream_is_deleted(#[case] written: bool) { + let backend = test_backend().await; + let (basin, stream) = create_stream( + &backend, + config(1, RetentionPolicy::Age(Duration::from_secs(1))), + ) + .await; + let stream_id = StreamId::new(&basin, &stream); + if written { + append(&backend, &basin, &stream, record()).await; + } + tokio::time::sleep(Duration::from_millis(1100)).await; + backend.clone().tick_stream_doe().await.unwrap(); + let meta = backend + .db_get( + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::deser_value, + ) + .await + .unwrap() + .unwrap(); + assert!(meta.deleted_at.is_some()); + assert!(state(&backend, stream_id).await.is_none()); + assert!( + backend + .list_pending_stream_doe(TimestampSecs::MAX) + .await + .unwrap() + .values + .is_empty() + ); + backend.clone().tick_stream_trim().await.unwrap(); + assert!( + backend + .db_get( + kv::stream_id_mapping::ser_key(stream_id), + kv::stream_id_mapping::deser_value + ) + .await + .unwrap() + .is_none() + ); + backend.close().await.unwrap(); + } + + #[rstest::rstest] + #[case::increase(3600)] + #[case::decrease(30)] + #[case::unchanged(60)] + #[case::disable(0)] + #[case::unrepresentable_cutoff(u64::MAX)] + #[tokio::test] + async fn configuration_coalesces_or_removes_the_schedule( + #[case] age: u64, + #[values(false, true)] via_ensure: bool, + ) { + let backend = test_backend().await; + let (basin, stream) = + create_stream(&backend, config(60, RetentionPolicy::Infinite())).await; + let stream_id = StreamId::new(&basin, &stream); + let original = state(&backend, stream_id).await; + configure_min_age(&backend, &basin, &stream, age, via_ensure).await; + if age == 0 { + assert!(state(&backend, stream_id).await.is_none()); + assert!( + backend + .list_pending_stream_doe(TimestampSecs::MAX) + .await + .unwrap() + .values + .is_empty() + ); + configure_min_age(&backend, &basin, &stream, 60, via_ensure).await; + assert!(scheduled(&backend, stream_id).await.at <= TimestampSecs::now()); + } else if age == 60 { + assert_eq!(state(&backend, stream_id).await, original); + } else { + assert!(scheduled(&backend, stream_id).await.at <= TimestampSecs::now()); + backend.clone().tick_stream_doe().await.unwrap(); + assert!( + scheduled(&backend, stream_id).await.at + >= TimestampSecs::now() + .saturating_add_duration(Duration::from_secs(age.saturating_sub(1))) + ); + assert!(backend.get_stream_config(basin, stream).await.is_ok()); + } + backend.close().await.unwrap(); + } + + #[tokio::test] + async fn partial_trim_wakes_parked_stream_with_finite_records_remaining() { + let backend = test_backend().await; + let (basin, stream) = + create_stream(&backend, config(60, RetentionPolicy::Infinite())).await; + let stream_id = StreamId::new(&basin, &stream); + append(&backend, &basin, &stream, record()).await; + backend + .process_stream_doe(due(&backend, stream_id).await) + .await + .unwrap(); + let parked = state(&backend, stream_id).await; + assert!(matches!( + parked, + Some((kv::stream_doe_state::State::Parked, _)) + )); + assert!( + backend + .list_pending_stream_doe(TimestampSecs::MAX) + .await + .unwrap() + .values + .is_empty() + ); + configure_retention(&backend, &basin, &stream, 3600).await; + append(&backend, &basin, &stream, record()).await; + assert_eq!(state(&backend, stream_id).await, parked); + append( + &backend, + &basin, + &stream, + Record::Command(CommandRecord::Trim(1)), + ) + .await; + backend.clone().tick_stream_trim().await.unwrap(); + assert!(scheduled(&backend, stream_id).await.at <= TimestampSecs::now()); + backend.clone().tick_stream_doe().await.unwrap(); + assert!( + scheduled(&backend, stream_id).await.at + > TimestampSecs::after(Duration::from_secs(3500)) + ); + backend.close().await.unwrap(); + } + + #[rstest::rstest] + #[case::disabled(0, false)] + #[case::disabled_before_cleanup(60, false)] + #[case::legacy_only(60, true)] + #[tokio::test] + async fn trim_initializes_missing_state_only_for_enabled_empty_streams( + #[case] min_age: u64, + #[case] legacy_only: bool, + #[values(false, true)] full_trim: bool, + ) { + let backend = test_backend().await; + let (basin, stream) = + create_stream(&backend, config(min_age, RetentionPolicy::Infinite())).await; + let stream_id = StreamId::new(&basin, &stream); + append(&backend, &basin, &stream, record()).await; + append( + &backend, + &basin, + &stream, + Record::Command(CommandRecord::Trim(if full_trim { u64::MAX } else { 1 })), + ) + .await; + if legacy_only { + // Model an enabled stream that has not migrated yet. + let txn = backend + .db + .begin(IsolationLevel::SerializableSnapshot) + .await + .unwrap(); + doe::clear(&txn, stream_id).await.unwrap(); + db_txn_commit_durable(txn).await.unwrap(); + legacy(&backend, stream_id, None).await; + } else if min_age != 0 { + configure_min_age(&backend, &basin, &stream, 0, false).await; + } + assert!(state(&backend, stream_id).await.is_none()); + + backend.clone().tick_stream_trim().await.unwrap(); + let after_trim = state(&backend, stream_id).await; + if legacy_only && full_trim { + assert!(scheduled(&backend, stream_id).await.at <= TimestampSecs::now()); + } else { + assert!(after_trim.is_none()); + assert!( + backend + .list_pending_stream_doe(TimestampSecs::MAX) + .await + .unwrap() + .values + .is_empty() + ); + } + + // Migration preserves a full trim's wake or initializes missing state. + if legacy_only { + backend.migrate_stream_doe().await.unwrap(); + if full_trim { + assert_eq!(state(&backend, stream_id).await, after_trim); + } + assert!(scheduled(&backend, stream_id).await.at <= TimestampSecs::now()); + } + backend.close().await.unwrap(); + } + + #[tokio::test] + async fn full_trim_restores_doe_without_any_legacy_deadline() { + let backend = test_backend().await; + let (basin, stream) = create_stream(&backend, config(1, RetentionPolicy::Infinite())).await; + let stream_id = StreamId::new(&basin, &stream); + append(&backend, &basin, &stream, record()).await; + // Before upgrade, an infinite-retention stream normally loses its last + // deadline when it fires while the stream is nonempty. + let txn = backend + .db + .begin(IsolationLevel::SerializableSnapshot) + .await + .unwrap(); + doe::clear(&txn, stream_id).await.unwrap(); + db_txn_commit_durable(txn).await.unwrap(); + backend.migrate_stream_doe().await.unwrap(); + assert!(state(&backend, stream_id).await.is_none()); + + append( + &backend, + &basin, + &stream, + Record::Command(CommandRecord::Trim(u64::MAX)), + ) + .await; + backend.clone().tick_stream_trim().await.unwrap(); + assert!(scheduled(&backend, stream_id).await.at <= TimestampSecs::now()); + tokio::time::sleep(Duration::from_millis(1100)).await; + backend.clone().tick_stream_doe().await.unwrap(); + let meta = backend + .db_get( + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::deser_value, + ) + .await + .unwrap() + .unwrap(); + assert!(meta.deleted_at.is_some()); + assert!(state(&backend, stream_id).await.is_none()); + backend.close().await.unwrap(); + } + + #[rstest::rstest] + #[tokio::test] + async fn trim_wake_conflicts_with_concurrent_disablement( + #[values(false, true)] missing_state: bool, + ) { + let backend = test_backend().await; + let (basin, stream) = + create_stream(&backend, config(60, RetentionPolicy::Infinite())).await; + let stream_id = StreamId::new(&basin, &stream); + if missing_state { + let txn = backend + .db + .begin(IsolationLevel::SerializableSnapshot) + .await + .unwrap(); + doe::clear(&txn, stream_id).await.unwrap(); + db_txn_commit_durable(txn).await.unwrap(); + } + let txn = backend + .db + .begin(IsolationLevel::SerializableSnapshot) + .await + .unwrap(); + doe::wake_after_trim(&txn, stream_id, false).await.unwrap(); + configure_min_age(&backend, &basin, &stream, 0, false).await; + let error = StorageError::from(txn.commit().await.unwrap_err()); + assert!(error.is_transaction_conflict()); + assert!(state(&backend, stream_id).await.is_none()); + assert!( + backend + .list_pending_stream_doe(TimestampSecs::MAX) + .await + .unwrap() + .values + .is_empty() + ); + backend.close().await.unwrap(); + } + + #[rstest::rstest] + #[case::parked(TerminalTrimOutcome::Parked)] + #[case::deferred(TerminalTrimOutcome::RetryAt(TimestampSecs::MAX))] + #[tokio::test] + async fn event_retaining_same_ticket_invalidates_inflight_result( + #[values(false, true)] trim: bool, + #[case] outcome: TerminalTrimOutcome, + ) { + let backend = test_backend().await; + let (basin, stream) = + create_stream(&backend, config(60, RetentionPolicy::Infinite())).await; + let stream_id = StreamId::new(&basin, &stream); + append(&backend, &basin, &stream, record()).await; + if trim { + configure_retention(&backend, &basin, &stream, 3600).await; + } + let pending = due(&backend, stream_id).await; + let snapshot = backend.observe_doe_check(pending).await.unwrap().unwrap(); + if trim { + // The worker may already have observed the infinite-retention + // record and decided to park. Subsequent appends do not invalidate + // that observation, but removing the blocker must invalidate it. + append(&backend, &basin, &stream, record()).await; + append( + &backend, + &basin, + &stream, + Record::Command(CommandRecord::Trim(1)), + ) + .await; + backend.clone().tick_stream_trim().await.unwrap(); + } else { + configure_min_age(&backend, &basin, &stream, 120, false).await; + } + assert_eq!(scheduled(&backend, stream_id).await, pending.check); + let woken = state(&backend, stream_id).await; + assert!(woken.unwrap().1 > snapshot.revision); + backend + .finish_doe_check(pending, snapshot, outcome) + .await + .unwrap(); + assert_eq!(state(&backend, stream_id).await, woken); + assert_eq!(scheduled(&backend, stream_id).await, pending.check); + backend.close().await.unwrap(); + } + + #[tokio::test] + async fn obsolete_check_cleanup_preserves_wake_after_snapshot() { + let backend = test_backend().await; + let (basin, stream) = + create_stream(&backend, config(60, RetentionPolicy::Infinite())).await; + let stream_id = StreamId::new(&basin, &stream); + let pending = due(&backend, stream_id).await; + let observed = state(&backend, stream_id).await; + configure_min_age(&backend, &basin, &stream, 120, false).await; + let woken = state(&backend, stream_id).await; + assert!(woken.unwrap().1 > observed.unwrap().1); + assert_eq!(scheduled(&backend, stream_id).await, pending.check); + backend.discard_doe_check(pending, observed).await.unwrap(); + assert_eq!(state(&backend, stream_id).await, woken); + assert_eq!(scheduled(&backend, stream_id).await, pending.check); + backend.close().await.unwrap(); + } + + #[tokio::test] + async fn legacy_deadlines_only_initialize_missing_state() { + let backend = test_backend().await; + let (basin, stream) = + create_stream(&backend, config(60, RetentionPolicy::Infinite())).await; + let stream_id = StreamId::new(&basin, &stream); + let txn = backend + .db + .begin(IsolationLevel::SerializableSnapshot) + .await + .unwrap(); + doe::clear(&txn, stream_id).await.unwrap(); + db_txn_commit_durable(txn).await.unwrap(); + let old = legacy(&backend, stream_id, None).await; + let unique = legacy(&backend, stream_id, Some(42)).await; + backend.migrate_stream_doe().await.unwrap(); + assert!(scheduled(&backend, stream_id).await.at <= TimestampSecs::now()); + assert!(backend.db.get(&old).await.unwrap().is_none()); + assert!(backend.db.get(&unique).await.unwrap().is_none()); + append(&backend, &basin, &stream, record()).await; + backend.clone().tick_stream_doe().await.unwrap(); + let parked = state(&backend, stream_id).await; + assert!(matches!( + parked, + Some((kv::stream_doe_state::State::Parked, _)) + )); + legacy(&backend, stream_id, Some(43)).await; + backend.migrate_stream_doe().await.unwrap(); + assert_eq!(state(&backend, stream_id).await, parked); + assert!( + backend + .list_pending_stream_doe(TimestampSecs::MAX) + .await + .unwrap() + .values + .is_empty() + ); + backend.close().await.unwrap(); + } + + #[tokio::test] + async fn migration_drains_pages_without_rewriting_schedules() { + let backend = test_backend().await; + let (basin, stream) = + create_stream(&backend, config(60, RetentionPolicy::Infinite())).await; + let stream_id = StreamId::new(&basin, &stream); + let before = state(&backend, stream_id).await; + let mut batch = slatedb::WriteBatch::new(); + for id in 0..=PENDING_LIST_LIMIT { + batch.put( + kv::stream_doe_deadline::ser_key(TimestampSecs::MAX, stream_id, Some(id as u128)), + [], + ); + } + backend.db.write(batch).assert_durable().await; + run_tick( + "stream-delete-on-empty", + &|backend: &Backend| backend.clone().tick_stream_doe(), + &backend, + ) + .await; + let mut remaining = backend + .db + .scan(kv::key_type_range(kv::KeyType::StreamDeleteOnEmptyDeadline)) + .await + .unwrap(); + assert!(remaining.next().await.unwrap().is_none()); + assert_eq!(state(&backend, stream_id).await, before); + scheduled(&backend, stream_id).await; + backend.close().await.unwrap(); + } + + #[tokio::test] + async fn migration_batches_cleanup_across_streams() { + let backend = test_backend().await; + let (basin, stream) = + create_stream(&backend, config(60, RetentionPolicy::Infinite())).await; + let scheduled_id = StreamId::new(&basin, &stream); + let before = state(&backend, scheduled_id).await; + let disabled: StreamName = "disabled".parse().unwrap(); + let deleted: StreamName = "deleted".parse().unwrap(); + for (stream, min_age) in [(&disabled, 0), (&deleted, 60)] { + backend + .provision_stream( + basin.clone(), + stream.clone(), + config(min_age, RetentionPolicy::Infinite()), + ProvisionMode::Ensure, + ) + .await + .unwrap(); + } + backend + .delete_stream(basin.clone(), deleted.clone()) + .await + .unwrap(); + let disabled_id = StreamId::new(&basin, &disabled); + let deleted_id = StreamId::new(&basin, &deleted); + let missing_id = StreamId::new(&basin, &"missing".parse().unwrap()); + let deleted_state = state(&backend, deleted_id).await; + let mut keys = Vec::new(); + for stream_id in [scheduled_id, disabled_id, deleted_id, missing_id] { + for id in 0..3 { + keys.push(legacy(&backend, stream_id, Some(id)).await); + } + } + let before_seq = backend.db.snapshot().await.unwrap().seq(); + backend.migrate_stream_doe().await.unwrap(); + assert_eq!( + backend.db.snapshot().await.unwrap().seq(), + before_seq + 1, + "all cleanup-only streams should share one durable batch" + ); + for key in keys { + assert!(backend.db.get(key).await.unwrap().is_none()); + } + assert_eq!(state(&backend, scheduled_id).await, before); + assert_eq!(state(&backend, deleted_id).await, deleted_state); + assert!(state(&backend, disabled_id).await.is_none()); + assert!(state(&backend, missing_id).await.is_none()); + backend.close().await.unwrap(); + } + + #[rstest::rstest] + #[tokio::test] + async fn migration_revalidates_configuration_after_snapshot(#[values(0, 120)] min_age: u64) { + let backend = test_backend().await; + let (basin, stream) = + create_stream(&backend, config(60, RetentionPolicy::Infinite())).await; + let stream_id = StreamId::new(&basin, &stream); + let txn = backend + .db + .begin(IsolationLevel::SerializableSnapshot) + .await + .unwrap(); + doe::clear(&txn, stream_id).await.unwrap(); + db_txn_commit_durable(txn).await.unwrap(); + let key = legacy(&backend, stream_id, None).await; + let snapshot = backend.db.snapshot().await.unwrap(); + configure_min_age(&backend, &basin, &stream, min_age, false).await; + let configured = state(&backend, stream_id).await; + backend + .migrate_doe_deadlines(&snapshot, stream_id, vec![key.clone()]) + .await + .unwrap(); + assert_eq!(state(&backend, stream_id).await, configured); + assert!(backend.db.get(key).await.unwrap().is_none()); + backend.close().await.unwrap(); + } + + #[tokio::test] + async fn concurrent_wake_conflicts_with_completion_transaction() { + let backend = test_backend().await; + let (basin, stream) = + create_stream(&backend, config(60, RetentionPolicy::Infinite())).await; + let stream_id = StreamId::new(&basin, &stream); + let pending = due(&backend, stream_id).await; + let txn = backend + .db + .begin(IsolationLevel::SerializableSnapshot) + .await + .unwrap(); + let previous = doe::state(&txn, stream_id).await.unwrap(); + doe::replace( + &txn, + stream_id, + previous, + Some(kv::stream_doe_state::State::Parked), + ) + .unwrap(); + // The same ticket is retained, but the scheduler revision changes. + let wake = backend + .db + .begin(IsolationLevel::SerializableSnapshot) + .await + .unwrap(); + doe::schedule(&wake, stream_id, TimestampSecs::now()) + .await + .unwrap(); + db_txn_commit_durable(wake).await.unwrap(); + let error = txn.commit().await.unwrap_err(); + assert_eq!(error.kind(), slatedb::ErrorKind::Transaction); + assert_eq!(scheduled(&backend, stream_id).await, pending.check); + backend.close().await.unwrap(); + } + + #[tokio::test] + async fn full_trim_wakes_parked_stream_and_waits_for_minimum_age() { + let backend = test_backend().await; + let (basin, stream) = + create_stream(&backend, config(60, RetentionPolicy::Infinite())).await; + let stream_id = StreamId::new(&basin, &stream); + append(&backend, &basin, &stream, record()).await; + backend + .process_stream_doe(due(&backend, stream_id).await) + .await + .unwrap(); + assert!(matches!( + state(&backend, stream_id).await, + Some((kv::stream_doe_state::State::Parked, _)) + )); + append( + &backend, + &basin, + &stream, + Record::Command(CommandRecord::Trim(u64::MAX)), + ) + .await; + backend.clone().tick_stream_trim().await.unwrap(); + assert!(scheduled(&backend, stream_id).await.at <= TimestampSecs::now()); + backend.clone().tick_stream_doe().await.unwrap(); + assert!(scheduled(&backend, stream_id).await.at > TimestampSecs::now()); + let meta = backend + .db_get( + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::deser_value, + ) + .await + .unwrap() + .unwrap(); + assert!(meta.deleted_at.is_none()); + backend.close().await.unwrap(); + } + + #[rstest::rstest] + #[case::deletion_pending(TerminalTrimOutcome::DeletionPending)] + #[case::parked(TerminalTrimOutcome::Parked)] + #[tokio::test] + async fn stale_work_cannot_delete_or_reschedule_recreated_stream( + #[case] outcome: TerminalTrimOutcome, + ) { + let backend = test_backend().await; + let configuration = config(60, RetentionPolicy::Infinite()); + let (basin, stream) = create_stream(&backend, configuration.clone()).await; + let stream_id = StreamId::new(&basin, &stream); + let pending = due(&backend, stream_id).await; + let snapshot = backend.observe_doe_check(pending).await.unwrap().unwrap(); + backend + .delete_stream(basin.clone(), stream.clone()) + .await + .unwrap(); + backend.clone().tick_stream_trim().await.unwrap(); + backend + .provision_stream( + basin.clone(), + stream.clone(), + configuration, + ProvisionMode::Ensure, + ) + .await + .unwrap(); + let recreated = state(&backend, stream_id).await; + let client = backend + .streamer_client_guarded(&basin, &stream) + .await + .unwrap(); + assert_eq!( + client + .terminal_trim(TerminalTrimCondition::DeleteOnEmpty { + expected_stream_creation_seq: snapshot.creation_seq, + expected_config_seq: snapshot.config_seq, + }) + .await + .unwrap(), + TerminalTrimOutcome::Obsolete + ); + backend + .finish_doe_check(pending, snapshot, outcome) + .await + .unwrap(); + backend.process_stream_doe(pending).await.unwrap(); + legacy(&backend, stream_id, None).await; + backend.migrate_stream_doe().await.unwrap(); + assert_eq!(state(&backend, stream_id).await, recreated); + scheduled(&backend, stream_id).await; + backend.close().await.unwrap(); + } +} diff --git a/lite/src/backend/bgtasks/stream_trim.rs b/lite/src/backend/bgtasks/stream_trim.rs new file mode 100644 index 00000000..db377d0c --- /dev/null +++ b/lite/src/backend/bgtasks/stream_trim.rs @@ -0,0 +1,704 @@ +use std::ops::RangeTo; + +use futures::{StreamExt, stream}; +use s2_common::{record::NonZeroSeqNum, resources::Page}; +use slatedb::{ + IsolationLevel, WriteBatch, + config::{DurabilityLevel, ScanOptions}, +}; +use tracing::instrument; + +use super::PageProgress; +use crate::{ + backend::{ + Backend, doe, + error::StorageError, + kv, + store::{db_txn_commit_durable, db_txn_get, db_txn_get_with}, + }, + stream_id::StreamId, +}; + +const PENDING_LIST_LIMIT: usize = 128; +const CONCURRENCY: usize = 4; +const DELETE_BATCH_SIZE: usize = 10_000; + +#[derive(Debug, Clone, Copy)] +struct PendingTrim { + stream_id: StreamId, + trim_point: RangeTo, + /// Commit sequence of the observed marker; bounds the records this job may delete. + marker_seq: u64, +} + +impl Backend { + pub(super) async fn tick_stream_trim(self) -> Result { + let page = self.list_stream_trim_pending().await?; + if page.values.is_empty() { + return Ok(page.has_more); + } + let mut progress = PageProgress::new(page.has_more); + let mut processed = stream::iter(page.values) + .map(|pending| { + let backend = self.clone(); + async move { backend.process_trim(pending).await } + }) + .buffer_unordered(CONCURRENCY); + while let Some(result) = processed.next().await { + progress.record_result(result, StorageError::is_transaction_conflict)?; + } + Ok(progress.should_continue()) + } + + async fn list_stream_trim_pending(&self) -> Result, StorageError> { + let scan_opts = ScanOptions { + durability_filter: DurabilityLevel::Remote, + ..Default::default() + }; + let mut it = self + .db + .scan_with_options(kv::key_type_range(kv::KeyType::StreamTrimPoint), &scan_opts) + .await?; + let mut pending = Vec::new(); + while let Some(kv) = it.next().await? { + let stream_id = kv::stream_trim_point::deser_key(kv.key)?; + let trim_point = kv::stream_trim_point::deser_value(kv.value)?; + pending.push(PendingTrim { + stream_id, + trim_point, + marker_seq: kv.seq, + }); + if pending.len() >= PENDING_LIST_LIMIT { + return Ok(Page::new(pending, true)); + } + } + Ok(Page::new(pending, false)) + } + + async fn process_trim(&self, pending: PendingTrim) -> Result<(), StorageError> { + let has_remaining_records = self.delete_records(pending).await?; + self.finalize_trim(pending, has_remaining_records).await + } + + /// Return whether records remain beyond the trim point. + #[instrument(ret, err, skip(self))] + async fn delete_records(&self, pending: PendingTrim) -> Result { + let prefix = kv::stream_record_timestamp::ser_key_prefix(pending.stream_id); + let scan_opts = ScanOptions { + durability_filter: DurabilityLevel::Remote, + ..Default::default() + }; + let mut it = self + .db + .scan_prefix_with_options(prefix, .., &scan_opts) + .await?; + let mut batch = WriteBatch::new(); + let mut batch_size = 0; + let mut has_remaining_records = false; + while let Some(kv) = it.next().await? { + let (deser_stream_id, pos) = kv::stream_record_timestamp::deser_key(kv.key.clone())?; + debug_assert_eq!(deser_stream_id, pending.stream_id); + // Name reuse requires all old records to be durably deleted first. + // A stale job may see records from the recreated stream; stop before + // deleting records committed after the trim marker it observed. + if pos.seq_num >= pending.trim_point.end.get() || kv.seq > pending.marker_seq { + has_remaining_records = true; + break; + } + batch.delete(kv.key); + batch.delete(kv::stream_record_data::ser_key(pending.stream_id, pos)); + batch_size += 1; + if batch_size >= DELETE_BATCH_SIZE { + self.db.write(batch).await?.await_durable().await?; + batch = WriteBatch::new(); + batch_size = 0; + } + } + if !batch.is_empty() { + self.db.write(batch).await?.await_durable().await?; + } + Ok(has_remaining_records) + } + + #[instrument(skip(self))] + async fn finalize_trim( + &self, + pending: PendingTrim, + has_remaining_records: bool, + ) -> Result<(), StorageError> { + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + let trim_point_key = kv::stream_trim_point::ser_key(pending.stream_id); + let current_seq = db_txn_get_with(&txn, &trim_point_key, |entry| Ok(entry.seq)).await?; + // A tick can exit on error while its submitted cleanup is still running. + // The next durable scan can capture the old marker before cleanup becomes + // durable. Cleanup removes the old marker and metadata before name reuse, + // but this scanned work can remain queued across recreation. The recreated + // stream's marker may have the same trim point, so compare commit sequences. + if current_seq != Some(pending.marker_seq) { + return Ok(()); + } + let is_terminal_trim = pending.trim_point == ..NonZeroSeqNum::MAX; + txn.delete(trim_point_key)?; + if is_terminal_trim { + let id_mapping_key = kv::stream_id_mapping::ser_key(pending.stream_id); + if let Some((basin, stream)) = + db_txn_get(&txn, &id_mapping_key, kv::stream_id_mapping::deser_value).await? + { + txn.delete(kv::stream_meta::ser_key(&basin, &stream))?; + txn.delete(id_mapping_key)?; + } + txn.delete(kv::stream_tail_position::ser_key(pending.stream_id))?; + txn.delete(kv::stream_fencing_token::ser_key(pending.stream_id))?; + doe::clear(&txn, pending.stream_id).await?; + } else { + // A partial trim may remove the infinite-retention record that + // parked DOE while leaving finite-retention records behind. + doe::wake_after_trim(&txn, pending.stream_id, has_remaining_records).await?; + } + db_txn_commit_durable(txn).await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::{ops::RangeTo, str::FromStr}; + + use bytes::Bytes; + use s2_common::{ + basin::BasinName, + config::StreamConfig, + record::{ + FencingToken, Metered, MeteredExt as _, NonZeroSeqNum, Record, SeqNum, StreamPosition, + }, + stream::StreamName, + }; + use s2_storage::record::StoredRecord; + use slatedb::WriteBatch; + use time::OffsetDateTime; + + use super::super::tests::test_backend; + use crate::{ + backend::{kv, test_util::DbWriteTestExt as _}, + stream_id::StreamId, + }; + + fn test_record() -> Metered { + let record = Record::try_from_parts(vec![], Bytes::from_static(b"trim-test")).unwrap(); + StoredRecord::from(record).metered() + } + + fn trim_point(seq_num: SeqNum) -> RangeTo { + ..NonZeroSeqNum::new(seq_num).expect("trim point must be non-zero") + } + + #[tokio::test] + async fn stream_trim_deletes_records_and_clears_trim_point() { + let backend = test_backend().await; + let stream_id: StreamId = [1u8; StreamId::LEN].into(); + let metered = test_record(); + + for seq in 0..5 { + let pos = StreamPosition { + seq_num: seq, + timestamp: 1000 + seq, + }; + backend + .db + .put( + kv::stream_record_data::ser_key(stream_id, pos), + kv::stream_record_data::ser_value(metered.as_ref()), + ) + .assert_durable() + .await; + backend + .db + .put( + kv::stream_record_timestamp::ser_key(stream_id, pos), + kv::stream_record_timestamp::ser_value(), + ) + .assert_durable() + .await; + } + + backend + .db + .put( + kv::stream_trim_point::ser_key(stream_id), + kv::stream_trim_point::ser_value(trim_point(3)), + ) + .assert_durable() + .await; + + backend.clone().tick_stream_trim().await.unwrap(); + + for seq in 0..5 { + let pos = StreamPosition { + seq_num: seq, + timestamp: 1000 + seq, + }; + let data = backend + .db + .get(kv::stream_record_data::ser_key(stream_id, pos)) + .await + .unwrap(); + let timestamp = backend + .db + .get(kv::stream_record_timestamp::ser_key(stream_id, pos)) + .await + .unwrap(); + if seq < 3 { + assert!(data.is_none()); + assert!(timestamp.is_none()); + } else { + assert!(data.is_some()); + assert!(timestamp.is_some()); + } + } + + let trim_point = backend + .db + .get(kv::stream_trim_point::ser_key(stream_id)) + .await + .unwrap(); + assert!(trim_point.is_none()); + } + + #[tokio::test] + async fn stream_trim_finalizes_full_delete() { + let backend = test_backend().await; + let basin = BasinName::from_str("test-basin").unwrap(); + let stream = StreamName::from_str("test-stream").unwrap(); + let stream_id = StreamId::new(&basin, &stream); + let metered = test_record(); + + let meta = kv::stream_meta::StreamMeta { + config: StreamConfig::default(), + cipher: None, + created_at: OffsetDateTime::now_utc(), + deleted_at: None, + creation_idempotency_key: None, + }; + + backend + .db + .put( + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::ser_value(&meta), + ) + .assert_durable() + .await; + backend + .db + .put( + kv::stream_id_mapping::ser_key(stream_id), + kv::stream_id_mapping::ser_value(&basin, &stream), + ) + .assert_durable() + .await; + backend + .db + .put( + kv::stream_tail_position::ser_key(stream_id), + kv::stream_tail_position::ser_value(StreamPosition { + seq_num: 10, + timestamp: 1234, + }), + ) + .assert_durable() + .await; + let token = FencingToken::from_str("token-1").unwrap(); + backend + .db + .put( + kv::stream_fencing_token::ser_key(stream_id), + kv::stream_fencing_token::ser_value(&token), + ) + .assert_durable() + .await; + + for seq in 0..3 { + let pos = StreamPosition { + seq_num: seq, + timestamp: 2000 + seq, + }; + backend + .db + .put( + kv::stream_record_data::ser_key(stream_id, pos), + kv::stream_record_data::ser_value(metered.as_ref()), + ) + .assert_durable() + .await; + backend + .db + .put( + kv::stream_record_timestamp::ser_key(stream_id, pos), + kv::stream_record_timestamp::ser_value(), + ) + .assert_durable() + .await; + } + + backend + .db + .put( + kv::stream_trim_point::ser_key(stream_id), + kv::stream_trim_point::ser_value(trim_point(SeqNum::MAX)), + ) + .assert_durable() + .await; + + backend.clone().tick_stream_trim().await.unwrap(); + + let meta_bytes = backend + .db + .get(kv::stream_meta::ser_key(&basin, &stream)) + .await + .unwrap(); + assert!(meta_bytes.is_none()); + let mapping_bytes = backend + .db + .get(kv::stream_id_mapping::ser_key(stream_id)) + .await + .unwrap(); + assert!(mapping_bytes.is_none()); + let tail_bytes = backend + .db + .get(kv::stream_tail_position::ser_key(stream_id)) + .await + .unwrap(); + assert!(tail_bytes.is_none()); + let fencing_bytes = backend + .db + .get(kv::stream_fencing_token::ser_key(stream_id)) + .await + .unwrap(); + assert!(fencing_bytes.is_none()); + let trim_bytes = backend + .db + .get(kv::stream_trim_point::ser_key(stream_id)) + .await + .unwrap(); + assert!(trim_bytes.is_none()); + + for seq in 0..3 { + let pos = StreamPosition { + seq_num: seq, + timestamp: 2000 + seq, + }; + let data = backend + .db + .get(kv::stream_record_data::ser_key(stream_id, pos)) + .await + .unwrap(); + let timestamp = backend + .db + .get(kv::stream_record_timestamp::ser_key(stream_id, pos)) + .await + .unwrap(); + assert!(data.is_none()); + assert!(timestamp.is_none()); + } + } + + #[tokio::test] + async fn stream_trim_paginates_pending_list() { + let backend = test_backend().await; + let total = super::PENDING_LIST_LIMIT + 1; + + let mut batch = WriteBatch::new(); + for idx in 0..total { + let mut stream_id_bytes = [0u8; StreamId::LEN]; + stream_id_bytes[0] = idx as u8; + let stream_id: StreamId = stream_id_bytes.into(); + batch.put( + kv::stream_trim_point::ser_key(stream_id), + kv::stream_trim_point::ser_value(trim_point(1)), + ); + } + backend.db.write(batch).assert_durable().await; + + let has_more = backend.clone().tick_stream_trim().await.unwrap(); + assert!(has_more); + + let has_more = backend.clone().tick_stream_trim().await.unwrap(); + assert!(!has_more); + + for idx in 0..total { + let mut stream_id_bytes = [0u8; StreamId::LEN]; + stream_id_bytes[0] = idx as u8; + let stream_id: StreamId = stream_id_bytes.into(); + let remaining = backend + .db + .get(kv::stream_trim_point::ser_key(stream_id)) + .await + .unwrap(); + assert!(remaining.is_none()); + } + } + + #[tokio::test] + async fn stream_trim_end_one_deletes_first_record() { + let backend = test_backend().await; + let stream_id: StreamId = [7u8; StreamId::LEN].into(); + let metered = test_record(); + let pos = StreamPosition { + seq_num: SeqNum::MIN, + timestamp: 5000, + }; + + backend + .db + .put( + kv::stream_record_data::ser_key(stream_id, pos), + kv::stream_record_data::ser_value(metered.as_ref()), + ) + .assert_durable() + .await; + backend + .db + .put( + kv::stream_record_timestamp::ser_key(stream_id, pos), + kv::stream_record_timestamp::ser_value(), + ) + .assert_durable() + .await; + backend + .db + .put( + kv::stream_trim_point::ser_key(stream_id), + kv::stream_trim_point::ser_value(trim_point(1)), + ) + .assert_durable() + .await; + + backend.clone().tick_stream_trim().await.unwrap(); + + let data = backend + .db + .get(kv::stream_record_data::ser_key(stream_id, pos)) + .await + .unwrap(); + let timestamp = backend + .db + .get(kv::stream_record_timestamp::ser_key(stream_id, pos)) + .await + .unwrap(); + assert!(data.is_none()); + assert!(timestamp.is_none()); + + let trim_point = backend + .db + .get(kv::stream_trim_point::ser_key(stream_id)) + .await + .unwrap(); + assert!(trim_point.is_none()); + } + + #[tokio::test] + async fn stream_trim_large_batch_flushes() { + let backend = test_backend().await; + let stream_id: StreamId = [3u8; StreamId::LEN].into(); + let metered = test_record(); + let total: SeqNum = (super::DELETE_BATCH_SIZE as SeqNum) + 5; + + let mut batch = WriteBatch::new(); + for seq in 0..total { + let pos = StreamPosition { + seq_num: seq, + timestamp: 4000 + seq, + }; + batch.put( + kv::stream_record_data::ser_key(stream_id, pos), + kv::stream_record_data::ser_value(metered.as_ref()), + ); + batch.put( + kv::stream_record_timestamp::ser_key(stream_id, pos), + kv::stream_record_timestamp::ser_value(), + ); + } + + batch.put( + kv::stream_trim_point::ser_key(stream_id), + kv::stream_trim_point::ser_value(trim_point(total)), + ); + backend.db.write(batch).assert_durable().await; + + backend.clone().tick_stream_trim().await.unwrap(); + + let samples: [SeqNum; 3] = [0, 9_999, total - 1]; + for seq in samples { + let pos = StreamPosition { + seq_num: seq, + timestamp: 4000 + seq, + }; + let data = backend + .db + .get(kv::stream_record_data::ser_key(stream_id, pos)) + .await + .unwrap(); + let timestamp = backend + .db + .get(kv::stream_record_timestamp::ser_key(stream_id, pos)) + .await + .unwrap(); + assert!(data.is_none()); + assert!(timestamp.is_none()); + } + + let trim_point = backend + .db + .get(kv::stream_trim_point::ser_key(stream_id)) + .await + .unwrap(); + assert!(trim_point.is_none()); + } + + #[tokio::test] + async fn stale_deletion_work_preserves_recreated_stream() { + use s2_common::{ + config::OptionalStreamConfig, + resources::ProvisionMode, + stream::{AppendInput, AppendRecord, AppendRecordParts}, + }; + + use crate::backend::error::{ProvisionStreamError, ReconfigureStreamError}; + let backend = test_backend().await; + let (basin, stream) = + crate::backend::test_util::create_stream(&backend, OptionalStreamConfig::default()) + .await; + let stream_id = StreamId::new(&basin, &stream); + backend + .open_for_check_tail(&basin, &stream) + .await + .unwrap() + .check_tail() + .await + .unwrap(); + + // Pause stream deletion after it submits terminal trim, before it marks metadata. + let mut deletion = Box::pin(backend.delete_stream(basin.clone(), stream.clone())); + assert!(futures::poll!(&mut deletion).is_pending()); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if backend + .db_get( + kv::stream_trim_point::ser_key(stream_id), + kv::stream_trim_point::deser_value, + ) + .await + .unwrap() + == Some(..NonZeroSeqNum::MAX) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + // A crash here leaves a durable terminal trim with unmarked metadata. + // Provisioning and reconfiguration must reject updates that the pending trim would erase. + assert!(matches!( + backend + .provision_stream( + basin.clone(), + stream.clone(), + OptionalStreamConfig::default(), + ProvisionMode::Ensure + ) + .await, + Err(ProvisionStreamError::StreamDeletionPending(_)) + )); + assert!(matches!( + backend + .reconfigure_stream(basin.clone(), stream.clone(), Default::default()) + .await, + Err(ReconfigureStreamError::StreamDeletionPending(_)) + )); + + let stale_trim = backend + .list_stream_trim_pending() + .await + .unwrap() + .values + .pop() + .unwrap(); + backend.clone().tick_stream_trim().await.unwrap(); + backend + .provision_stream( + basin.clone(), + stream.clone(), + OptionalStreamConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .unwrap(); + + deletion.await.unwrap(); + let meta = backend + .db_get( + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::deser_value, + ) + .await + .unwrap() + .unwrap(); + assert!( + meta.deleted_at.is_none(), + "the old deletion request must not mark the recreated stream as deleted" + ); + + let record: AppendRecord = AppendRecordParts { + timestamp: None, + record: Record::try_from_parts(vec![], Bytes::from_static(b"recreated stream")) + .unwrap() + .metered(), + } + .try_into() + .unwrap(); + let ack = backend + .open_for_append(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap() + .append(AppendInput { + records: vec![record].try_into().unwrap(), + match_seq_num: None, + fencing_token: None, + }) + .await + .unwrap(); + // A previous tick can have scanned the old marker before cleanup became durable. + backend.process_trim(stale_trim).await.unwrap(); + assert!( + backend + .db_get( + kv::stream_record_data::ser_key(stream_id, ack.start), + kv::stream_record_data::deser_value, + ) + .await + .unwrap() + .is_some(), + "old trim work must not delete records from the recreated stream" + ); + + // Even another terminal marker belongs to different work after recreation. + backend.delete_stream(basin, stream).await.unwrap(); + backend.process_trim(stale_trim).await.unwrap(); + assert_eq!( + backend + .db_get( + kv::stream_trim_point::ser_key(stream_id), + kv::stream_trim_point::deser_value, + ) + .await + .unwrap(), + Some(..NonZeroSeqNum::MAX), + "old trim work must not finalize the recreated stream's deletion" + ); + backend.close().await.unwrap(); + } +} diff --git a/lite/src/backend/core.rs b/lite/src/backend/core.rs new file mode 100644 index 00000000..25359c60 --- /dev/null +++ b/lite/src/backend/core.rs @@ -0,0 +1,747 @@ +use std::sync::Arc; + +use bytesize::ByteSize; +use dashmap::DashMap; +use futures::{ + FutureExt as _, + future::{BoxFuture, Shared}, +}; +use s2_common::{ + basin::BasinName, + config::{BasinConfig, OptionalStreamConfig, StreamConfig}, + encryption::{EncryptionAlgorithm, EncryptionSpec}, + record::{NonZeroSeqNum, SeqNum, StreamPosition}, + resources::ProvisionMode, + stream::StreamName, +}; +use tokio::sync::{Semaphore, broadcast}; + +use super::{ + StreamHandle, + durability_notifier::DurabilityNotifier, + error::{ + BasinDeletionPendingError, BasinNotFoundError, GetBasinConfigError, ProvisionStreamError, + StorageError, StreamDeletionPendingError, StreamNotFoundError, StreamerError, + StreamerMissingInActionError, TransactionConflictError, + }, + kv, + store::db_snapshot_get_with, + streamer::{GuardedStreamerClient, StreamerClient, StreamerGenerationId}, + timestamp::TimestampSecs, +}; +use crate::{backend::bgtasks::BgtaskTrigger, stream_id::StreamId}; + +type StreamerInitFuture = Shared>>; + +#[derive(Clone)] +enum StreamerClientSlot { + Initializing { + generation_id: StreamerGenerationId, + future: StreamerInitFuture, + pending_config: Option<(u64, StreamConfig)>, + }, + Ready { + client: StreamerClient, + }, +} + +#[derive(Clone)] +pub struct Backend { + pub(super) db: slatedb::Db, + streamer_slots: Arc>, + append_inflight_bytes_sema: Arc, + durability_notifier: DurabilityNotifier, + bgtask_trigger_tx: broadcast::Sender, +} + +impl Backend { + pub fn new(db: slatedb::Db, append_inflight_bytes: ByteSize) -> Self { + let (bgtask_trigger_tx, _) = broadcast::channel(16); + let append_inflight_bytes = Arc::new(Semaphore::new( + (append_inflight_bytes.as_u64() as usize).clamp( + s2_common::caps::RECORD_BATCH_MAX.bytes, + Semaphore::MAX_PERMITS, + ), + )); + let durability_notifier = DurabilityNotifier::spawn(&db); + Self { + db, + streamer_slots: Arc::new(DashMap::new()), + append_inflight_bytes_sema: append_inflight_bytes, + durability_notifier, + bgtask_trigger_tx, + } + } + + /// Flush memtables to L0 and close the database. + /// + /// Call after draining HTTP requests to reduce WAL replay on restart. + /// Dropping the backend does not close SlateDB. + pub async fn close(&self) -> Result<(), slatedb::Error> { + self.db.close().await + } + + pub(super) fn bgtask_trigger(&self, trigger: BgtaskTrigger) { + let _ = self.bgtask_trigger_tx.send(trigger); + } + + pub(super) fn bgtask_trigger_subscribe(&self) -> broadcast::Receiver { + self.bgtask_trigger_tx.subscribe() + } + + /// Wait until writes up to `db_seq` are durable, i.e. visible to reads at + /// `DurabilityLevel::Remote`. Resolves immediately if they already are. + pub(super) async fn await_durable_seq(&self, db_seq: u64) -> Result<(), StorageError> { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.durability_notifier.subscribe(db_seq, move |res| { + let _ = tx.send(res); + }); + let reason = match rx.await { + Ok(Ok(_)) => return Ok(()), + Ok(Err(reason)) => reason, + Err(_) => slatedb::CloseReason::Clean, + }; + Err(slatedb::Error::closed( + "database closed while waiting for durability".to_owned(), + reason, + ) + .into()) + } + + async fn start_streamer( + &self, + generation_id: StreamerGenerationId, + basin: BasinName, + stream: StreamName, + ) -> Result { + // Read one consistent, durable view of the stream. + let snapshot = self.db.snapshot().await.map_err(StorageError::from)?; + self.await_durable_seq(snapshot.seq()).await?; + let stream_id = StreamId::new(&basin, &stream); + + let (meta, persisted_tail, fencing_token, trim_point, stream_creation_seq) = tokio::try_join!( + db_snapshot_get_with( + &snapshot, + kv::stream_meta::ser_key(&basin, &stream), + |entry| { Ok((kv::stream_meta::deser_value(entry.value)?, entry.seq)) } + ), + db_snapshot_get_with( + &snapshot, + kv::stream_tail_position::ser_key(stream_id), + |entry| { + Ok(( + kv::stream_tail_position::deser_value(entry.value)?, + TimestampSecs::from_millis(entry.create_ts), + )) + } + ), + db_snapshot_get_with( + &snapshot, + kv::stream_fencing_token::ser_key(stream_id), + |entry| kv::stream_fencing_token::deser_value(entry.value), + ), + db_snapshot_get_with( + &snapshot, + kv::stream_trim_point::ser_key(stream_id), + |entry| kv::stream_trim_point::deser_value(entry.value), + ), + db_snapshot_get_with( + &snapshot, + kv::stream_id_mapping::ser_key(stream_id), + |entry| Ok(entry.seq) + ), + )?; + + let Some((meta, config_seq)) = meta else { + return Err(StreamNotFoundError { basin, stream }.into()); + }; + + let (tail_pos, last_tail_write_timestamp) = + persisted_tail.unwrap_or((StreamPosition::MIN, TimestampSecs::ZERO)); + + if meta.deleted_at.is_some() || trim_point == Some(..NonZeroSeqNum::MAX) { + return Err(StreamDeletionPendingError.into()); + } + + self.assert_no_records_following_tail(&snapshot, stream_id, &basin, &stream, tail_pos) + .await?; + + let fencing_token = fencing_token.unwrap_or_default(); + + let stream_creation_seq = stream_creation_seq.ok_or_else(|| { + StorageError::InvariantViolation(format!( + "live stream `{basin}/{stream}` has no ID mapping" + )) + })?; + + let streamer_slots = self.streamer_slots.clone(); + Ok(super::streamer::Spawner { + generation_id, + db: self.db.clone(), + stream_id, + stream_creation_seq, + config: meta.config, + config_seq, + cipher: meta.cipher, + tail_pos, + last_tail_write_timestamp, + fencing_token, + trim_point: ..trim_point.map_or(SeqNum::MIN, |tp| tp.end.get()), + append_inflight_bytes_sema: self.append_inflight_bytes_sema.clone(), + durability_notifier: self.durability_notifier.clone(), + bgtask_trigger_tx: self.bgtask_trigger_tx.clone(), + } + .spawn(move |client_id| { + streamer_slots.remove_if(&stream_id, |_, slot| { + matches!(slot, StreamerClientSlot::Ready { client } if client.generation_id() == client_id) + }); + })) + } + + async fn assert_no_records_following_tail( + &self, + snapshot: &slatedb::DbSnapshot, + stream_id: StreamId, + basin: &BasinName, + stream: &StreamName, + tail_pos: StreamPosition, + ) -> Result<(), StorageError> { + let prefix = kv::stream_record_data::ser_key_prefix(stream_id); + let start_suffix = kv::stream_record_data::ser_key_suffix(StreamPosition { + seq_num: tail_pos.seq_num, + timestamp: 0, + }); + let mut it = snapshot.scan_prefix(prefix, start_suffix..).await?; + let Some(kv) = it.next().await? else { + return Ok(()); + }; + let (deser_stream_id, pos) = kv::stream_record_data::deser_key(kv.key)?; + debug_assert_eq!(deser_stream_id, stream_id); + panic!( + "invariant violation: stream `{basin}/{stream}` tail_pos {tail_pos:?} but found record at {pos:?}" + ) + } + + fn streamer_client_slot(&self, basin: &BasinName, stream: &StreamName) -> StreamerClientSlot { + match self.streamer_slots.entry(StreamId::new(basin, stream)) { + dashmap::Entry::Occupied(mut oe) => { + if matches!(oe.get(), StreamerClientSlot::Ready { client } if client.is_dead()) { + let slot = self.clone().new_initializing_slot(basin, stream); + oe.insert(slot.clone()); + slot + } else { + oe.get().clone() + } + } + dashmap::Entry::Vacant(ve) => { + let slot = self.clone().new_initializing_slot(basin, stream); + ve.insert(slot.clone()); + slot + } + } + } + + fn new_initializing_slot(self, basin: &BasinName, stream: &StreamName) -> StreamerClientSlot { + let basin = basin.clone(); + let stream = stream.clone(); + let stream_id = StreamId::new(&basin, &stream); + let generation_id = StreamerGenerationId::next(); + // Drive initialization independently so cancelled callers cannot leave + // its snapshot pinned in a cached, unpolled future. + let future = tokio::spawn(async move { + let result = self.start_streamer(generation_id, basin, stream).await; + self.streamer_finish_initialization(stream_id, generation_id, &result); + result + }) + .map(|result| result.expect("streamer initialization task panicked")) + .boxed() + .shared(); + StreamerClientSlot::Initializing { + generation_id, + future, + pending_config: None, + } + } + + fn streamer_finish_initialization( + &self, + stream_id: StreamId, + generation_id: StreamerGenerationId, + result: &Result, + ) { + if let dashmap::Entry::Occupied(mut oe) = self.streamer_slots.entry(stream_id) { + let is_same_init = matches!( + oe.get(), + StreamerClientSlot::Initializing { + generation_id: state_generation_id, + .. + } if *state_generation_id == generation_id + ); + if is_same_init { + match result { + Ok(client) => { + debug_assert_eq!(client.generation_id(), generation_id); + if client.is_dead() { + oe.remove(); + } else { + if let StreamerClientSlot::Initializing { + pending_config: Some((seq, config)), + .. + } = oe.get() + { + client.advise_reconfig(*seq, config.clone()); + } + oe.insert(StreamerClientSlot::Ready { + client: client.clone(), + }); + } + } + Err(_) => { + oe.remove(); + } + } + } + } + } + + pub(super) async fn streamer_client( + &self, + basin: &BasinName, + stream: &StreamName, + ) -> Result { + match self.streamer_client_slot(basin, stream) { + StreamerClientSlot::Initializing { future, .. } => future.await, + StreamerClientSlot::Ready { client } => Ok(client), + } + } + + pub(super) fn advise_stream_config( + &self, + basin: &BasinName, + stream: &StreamName, + seq: u64, + config: StreamConfig, + ) { + let stream_id = StreamId::new(basin, stream); + let Some(mut slot) = self.streamer_slots.get_mut(&stream_id) else { + return; + }; + match slot.value_mut() { + StreamerClientSlot::Ready { client } => { + client.advise_reconfig(seq, config); + } + StreamerClientSlot::Initializing { pending_config, .. } => { + if pending_config + .as_ref() + .is_none_or(|(pending_seq, _)| seq > *pending_seq) + { + *pending_config = Some((seq, config)); + } + } + } + } + + pub(super) async fn streamer_client_guarded( + &self, + basin: &BasinName, + stream: &StreamName, + ) -> Result { + loop { + let client = self.streamer_client(basin, stream).await?; + match client.guard() { + Ok(client) => return Ok(client), + Err(StreamerMissingInActionError) => continue, + } + } + } + + /// Resolve a handle for `stream`, creating it on demand if the basin config allows. + /// + /// `stream_config` is applied over the basin's default stream configuration only if the + /// stream is being created. It must already be validated. + pub(super) async fn stream_handle_with_auto_create( + &self, + basin: &BasinName, + stream: &StreamName, + auto_create_on: AutoCreateOn, + stream_config: OptionalStreamConfig, + resolve_encryption: impl FnOnce(Option) -> Result, + ) -> Result + where + E: From + + From + + From + + From + + From + + From + + From, + { + let client = match self.streamer_client_guarded(basin, stream).await { + Ok(client) => client, + Err(StreamerError::StreamNotFound(e)) => { + let config = match self.get_basin_config(basin.clone()).await { + Ok(config) => config, + Err(GetBasinConfigError::Storage(e)) => Err(e)?, + Err(GetBasinConfigError::BasinNotFound(e)) => Err(e)?, + }; + if !auto_create_on.is_enabled(&config) { + return Err(e.into()); + } + if let Err(e) = self + .provision_stream( + basin.clone(), + stream.clone(), + stream_config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + { + match e { + ProvisionStreamError::Storage(e) => Err(e)?, + ProvisionStreamError::TransactionConflict(e) => Err(e)?, + ProvisionStreamError::BasinDeletionPending(e) => Err(e)?, + ProvisionStreamError::StreamDeletionPending(e) => Err(e)?, + ProvisionStreamError::BasinNotFound(e) => Err(e)?, + ProvisionStreamError::StreamAlreadyExists(_) => {} + ProvisionStreamError::Validation(e) => { + unreachable!("auto-create config is validated at the API boundary: {e}") + } + } + } + self.streamer_client_guarded(basin, stream).await? + } + Err(e) => return Err(e.into()), + }; + Ok(StreamHandle { + db: self.db.clone(), + encryption: resolve_encryption(client.cipher())?, + client, + }) + } +} + +/// Which basin setting governs creating a missing stream on demand. +#[derive(Debug, Clone, Copy)] +pub(super) enum AutoCreateOn { + /// `create_stream_on_append` + Append, + /// `create_stream_on_read` + Read, +} + +impl AutoCreateOn { + fn is_enabled(self, config: &BasinConfig) -> bool { + match self { + Self::Append => config.create_stream_on_append, + Self::Read => config.create_stream_on_read, + } + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr as _; + + use bytes::Bytes; + use s2_common::{ + config::{BasinConfig, OptionalStreamConfig, StreamConfig, TimestampingMode}, + record::{Metered, MeteredExt as _, Record, StreamPosition}, + resources::ProvisionMode, + stream::{AppendInput, AppendRecord, AppendRecordParts}, + }; + use s2_storage::record::StoredRecord; + use slatedb::{WriteBatch, object_store}; + use time::OffsetDateTime; + + use super::*; + use crate::backend::{error::AppendError, test_util::DbWriteTestExt as _}; + + async fn new_test_backend() -> Backend { + let object_store: Arc = + Arc::new(object_store::memory::InMemory::new()); + let db = slatedb::Db::builder("test", object_store) + .build() + .await + .unwrap(); + Backend::new(db, ByteSize::b(1)) + } + + fn append_input(body: &str) -> AppendInput { + let record = + Record::try_from_parts(vec![], Bytes::copy_from_slice(body.as_bytes())).unwrap(); + let record: AppendRecord = AppendRecordParts { + timestamp: None, + record: Metered::from(record), + } + .try_into() + .unwrap(); + AppendInput { + records: vec![record].try_into().unwrap(), + match_seq_num: None, + fencing_token: None, + } + } + + #[tokio::test] + #[should_panic(expected = "invariant violation: stream `testbasin1/stream1` tail_pos")] + async fn start_streamer_fails_if_records_exist_after_tail_pos() { + let backend = new_test_backend().await; + + let basin = BasinName::from_str("testbasin1").unwrap(); + let stream = StreamName::from_str("stream1").unwrap(); + let stream_id = StreamId::new(&basin, &stream); + + let meta = kv::stream_meta::StreamMeta { + config: StreamConfig::default(), + cipher: None, + created_at: OffsetDateTime::now_utc(), + deleted_at: None, + creation_idempotency_key: None, + }; + + let tail_pos = StreamPosition { + seq_num: 1, + timestamp: 123, + }; + let record_pos = StreamPosition { + seq_num: tail_pos.seq_num, + timestamp: tail_pos.timestamp, + }; + + let record = Record::try_from_parts(vec![], Bytes::from_static(b"hello")).unwrap(); + let metered_record: Metered = StoredRecord::from(record).metered(); + + let mut wb = WriteBatch::new(); + wb.put( + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::ser_value(&meta), + ); + wb.put( + kv::stream_tail_position::ser_key(stream_id), + kv::stream_tail_position::ser_value(tail_pos), + ); + wb.put( + kv::stream_record_data::ser_key(stream_id, record_pos), + kv::stream_record_data::ser_value(metered_record.as_ref()), + ); + backend.db.write(wb).assert_durable().await; + + backend + .start_streamer(StreamerGenerationId::next(), basin.clone(), stream.clone()) + .await + .unwrap(); + } + + #[tokio::test] + async fn streamer_initialization_is_shared_and_survives_cancellation() { + let db = slatedb::Db::builder("test", Arc::new(object_store::memory::InMemory::new())) + .with_settings(slatedb::config::Settings { + flush_interval: None, + ..Default::default() + }) + .build() + .await + .unwrap(); + let backend = Backend::new(db, ByteSize::b(1)); + backend.db.put(b"unflushed", b"value").await.unwrap(); + let basin = BasinName::from_str("testbasin2").unwrap(); + let stream = StreamName::from_str("stream2").unwrap(); + + let slot_1 = backend.streamer_client_slot(&basin, &stream); + let slot_2 = backend.streamer_client_slot(&basin, &stream); + + let (generation_id_1, generation_id_2, mut future_1, future_2) = match (slot_1, slot_2) { + ( + StreamerClientSlot::Initializing { + generation_id: generation_id_1, + future: future_1, + .. + }, + StreamerClientSlot::Initializing { + generation_id: generation_id_2, + future: future_2, + .. + }, + ) => (generation_id_1, generation_id_2, future_1, future_2), + _ => panic!("expected both slots to be Initializing"), + }; + assert_eq!(generation_id_1, generation_id_2); + assert_eq!(backend.streamer_slots.len(), 1); + + // Cancel every caller while initialization waits for its snapshot to be durable. + assert!(futures::poll!(&mut future_1).is_pending()); + tokio::task::yield_now().await; + drop((future_1, future_2)); + backend.db.flush().await.unwrap(); + + // The missing stream must still finish initialization and release its snapshot. + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while !backend.streamer_slots.is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + backend.close().await.unwrap(); + } + + #[tokio::test] + async fn config_notification_does_not_start_streamer() { + let backend = new_test_backend().await; + let basin = BasinName::from_str("testbasin3").unwrap(); + let stream = StreamName::from_str("stream3").unwrap(); + + backend.advise_stream_config(&basin, &stream, 1, StreamConfig::default()); + assert!(backend.streamer_slots.is_empty()); + backend.close().await.unwrap(); + } + + #[tokio::test] + async fn initializing_streamer_receives_latest_config() { + let backend = new_test_backend().await; + let basin = BasinName::from_str("config-init").unwrap(); + let stream = StreamName::from_str("stream").unwrap(); + let stream_id = StreamId::new(&basin, &stream); + backend + .provision_basin(basin.clone(), BasinConfig::default(), ProvisionMode::Ensure) + .await + .unwrap(); + backend + .provision_stream( + basin.clone(), + stream.clone(), + OptionalStreamConfig::default(), + ProvisionMode::Ensure, + ) + .await + .unwrap(); + + // Pause initialization after reading metadata, before publishing the client. + let generation_id = StreamerGenerationId::next(); + let client = backend + .start_streamer(generation_id, basin.clone(), stream.clone()) + .await + .unwrap(); + backend.streamer_slots.insert( + stream_id, + StreamerClientSlot::Initializing { + generation_id, + future: futures::future::pending().boxed().shared(), + pending_config: None, + }, + ); + let mut config = StreamConfig::default(); + config.timestamping.mode = TimestampingMode::ClientRequire; + backend.advise_stream_config(&basin, &stream, 20, config); + backend.advise_stream_config(&basin, &stream, 10, StreamConfig::default()); + backend.streamer_finish_initialization(stream_id, generation_id, &Ok(client)); + + let handle = backend + .open_for_append(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap(); + assert!(matches!( + handle.append(append_input("missing timestamp")).await, + Err(AppendError::TimestampMissing(_)) + )); + backend.close().await.unwrap(); + } + + #[tokio::test] + async fn streamer_client_failed_init_is_not_memoized() { + let backend = new_test_backend().await; + let basin = BasinName::from_str("testbasin4").unwrap(); + let stream = StreamName::from_str("stream4").unwrap(); + let stream_id = StreamId::new(&basin, &stream); + + for _ in 0..2 { + let err = backend.streamer_client(&basin, &stream).await; + assert!(matches!(err, Err(StreamerError::StreamNotFound(_)))); + assert!( + backend.streamer_slots.get(&stream_id).is_none(), + "failed init should not be cached" + ); + } + } + + #[tokio::test] + async fn streamer_finish_initialization_ignores_stale_generation_id() { + let backend = new_test_backend().await; + let basin = BasinName::from_str("testbasin5").unwrap(); + let stream = StreamName::from_str("stream5").unwrap(); + let stream_id = StreamId::new(&basin, &stream); + + let stale_generation_id = StreamerGenerationId::next(); + let current_generation_id = StreamerGenerationId::next(); + let future = futures::future::pending::>() + .boxed() + .shared(); + backend.streamer_slots.insert( + stream_id, + StreamerClientSlot::Initializing { + generation_id: current_generation_id, + future: future.clone(), + pending_config: None, + }, + ); + + let stale_result = Err(StreamNotFoundError { basin, stream }.into()); + backend.streamer_finish_initialization(stream_id, stale_generation_id, &stale_result); + + let Some(slot) = backend.streamer_slots.get(&stream_id) else { + panic!("stale init completion should not alter slot state"); + }; + match slot.value() { + StreamerClientSlot::Initializing { generation_id, .. } => { + assert_eq!(*generation_id, current_generation_id) + } + _ => panic!("expected initializing slot to remain unchanged"), + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn concurrent_appends_auto_create_stream_without_spurious_not_found() { + let backend = new_test_backend().await; + let basin = BasinName::from_str("autocreate").unwrap(); + backend + .provision_basin( + basin.clone(), + BasinConfig { + create_stream_on_append: true, + ..BasinConfig::default() + }, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .unwrap(); + + for round in 0..10 { + let stream = StreamName::from_str(&format!("fresh-{round}")).unwrap(); + let tasks: Vec<_> = (0..4) + .map(|i| { + let backend = backend.clone(); + let basin = basin.clone(); + let stream = stream.clone(); + tokio::spawn(async move { + let handle = backend + .open_for_append(&basin, &stream, None, OptionalStreamConfig::default()) + .await?; + handle.append(append_input(&format!("r{i}"))).await + }) + }) + .collect(); + for task in tasks { + match task.await.unwrap() { + Ok(_) => {} + // Conflict losers surface as retryable 409s to clients. + Err(AppendError::TransactionConflict(_)) => {} + Err(e) => panic!("concurrent auto-create append failed: {e:?}"), + } + } + } + } +} diff --git a/lite/src/backend/doe.rs b/lite/src/backend/doe.rs new file mode 100644 index 00000000..37d7ba7a --- /dev/null +++ b/lite/src/backend/doe.rs @@ -0,0 +1,124 @@ +//! Transactional scheduling shared by configuration changes, trims, and migration. +//! Appends only postpone eligibility and do not need to update the schedule. + +use std::time::Duration; + +use slatedb::DbTransaction; + +use super::{error::StorageError, kv, store::db_txn_get, timestamp::TimestampSecs}; +use crate::stream_id::StreamId; + +/// Bound polling on active streams, while allowing known expirations to defer +/// checks much farther into the future. +pub(super) const RETRY_INTERVAL: Duration = Duration::from_secs(600); + +pub(super) async fn state( + txn: &DbTransaction, + stream_id: StreamId, +) -> Result, StorageError> { + db_txn_get( + txn, + kv::stream_doe_state::ser_key(stream_id), + kv::stream_doe_state::deser_value, + ) + .await +} + +/// Request a check without adding a second ticket. Even when keeping an earlier +/// ticket, rewrite the state to advance its commit sequence: an in-flight worker +/// must not park or postpone the stream after a concurrent trim/configuration wake. +pub(super) async fn schedule( + txn: &DbTransaction, + stream_id: StreamId, + at: TimestampSecs, +) -> Result<(), StorageError> { + let previous = state(txn, stream_id).await?; + schedule_observed(txn, stream_id, previous, at)?; + Ok(()) +} + +/// Most trims need only the state row. A full trim also restores scheduling for +/// older enabled streams whose last legacy deadline was already consumed. +pub(super) async fn wake_after_trim( + txn: &DbTransaction, + stream_id: StreamId, + has_remaining_records: bool, +) -> Result<(), StorageError> { + let previous = state(txn, stream_id).await?; + if previous.is_none() { + if has_remaining_records { + return Ok(()); + } + let Some((basin, stream)) = db_txn_get( + txn, + kv::stream_id_mapping::ser_key(stream_id), + kv::stream_id_mapping::deser_value, + ) + .await? + else { + return Ok(()); + }; + let Some(meta) = db_txn_get( + txn, + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::deser_value, + ) + .await? + else { + return Ok(()); + }; + if meta.deleted_at.is_some() || meta.config.delete_on_empty.min_age().is_none() { + return Ok(()); + } + } + schedule_observed(txn, stream_id, previous, TimestampSecs::now())?; + Ok(()) +} + +fn schedule_observed( + txn: &DbTransaction, + stream_id: StreamId, + previous: Option, + at: TimestampSecs, +) -> Result<(), slatedb::Error> { + let next = match previous { + Some(kv::stream_doe_state::State::Scheduled(check)) if check.at <= at => { + kv::stream_doe_state::State::Scheduled(check) + } + _ => kv::stream_doe_state::State::Scheduled(kv::stream_doe_state::Check { + at, + id: rand::random(), + }), + }; + replace(txn, stream_id, previous, Some(next)) +} + +pub(super) async fn clear(txn: &DbTransaction, stream_id: StreamId) -> Result<(), StorageError> { + let previous = state(txn, stream_id).await?; + replace(txn, stream_id, previous, None)?; + Ok(()) +} + +/// The caller must have read the state in this serializable transaction. +pub(super) fn replace( + txn: &DbTransaction, + stream_id: StreamId, + previous: Option, + next: Option, +) -> Result<(), slatedb::Error> { + if previous != next { + if let Some(kv::stream_doe_state::State::Scheduled(check)) = previous { + txn.delete(kv::stream_doe_check::ser_key(stream_id, check))?; + } + if let Some(kv::stream_doe_state::State::Scheduled(check)) = next { + txn.put(kv::stream_doe_check::ser_key(stream_id, check), [])?; + } + } + let key = kv::stream_doe_state::ser_key(stream_id); + match next { + Some(next) => txn.put(key, kv::stream_doe_state::ser_value(next))?, + None if previous.is_some() => txn.delete(key)?, + None => (), + } + Ok(()) +} diff --git a/lite/src/backend/durability_notifier.rs b/lite/src/backend/durability_notifier.rs new file mode 100644 index 00000000..127876d2 --- /dev/null +++ b/lite/src/backend/durability_notifier.rs @@ -0,0 +1,314 @@ +use std::{collections::VecDeque, sync::Arc}; + +use parking_lot::Mutex; +use slatedb::{CloseReason, DbStatus}; +use tokio::sync::watch; + +type Callback = Box) + Send + 'static>; + +#[derive(Clone)] +pub(super) struct DurabilityNotifier { + state: Arc>, +} + +#[derive(Default)] +struct State { + closed_reason: Option, + last_durable_seq: u64, + waiters: VecDeque, +} + +struct Waiter { + durable_seq: u64, + callback: Callback, +} + +fn waiters_are_sorted(waiters: &VecDeque) -> bool { + let mut prev = None; + for waiter in waiters { + if let Some(prev_target) = prev + && prev_target > waiter.durable_seq + { + return false; + } + prev = Some(waiter.durable_seq); + } + true +} + +impl DurabilityNotifier { + pub fn spawn(db: &slatedb::Db) -> Self { + let status_rx = db.subscribe(); + let initial_status = status_rx.borrow().clone(); + let state = Arc::new(Mutex::new(State { + closed_reason: initial_status.close_reason, + last_durable_seq: initial_status.durable_seq, + waiters: VecDeque::new(), + })); + if initial_status.close_reason.is_none() { + tokio::spawn(run_notifier(status_rx, state.clone())); + } + Self { state } + } + + pub fn subscribe( + &self, + target_durable_seq: u64, + callback: impl FnOnce(Result) + Send + 'static, + ) { + let mut state = self.state.lock(); + if let Some(reason) = state.closed_reason { + drop(state); + callback(Err(reason)); + return; + } + if state.last_durable_seq >= target_durable_seq { + let durable_seq = state.last_durable_seq; + drop(state); + callback(Ok(durable_seq)); + return; + } + let waiter = Waiter { + durable_seq: target_durable_seq, + callback: Box::new(callback), + }; + let insert_pos = state + .waiters + .iter() + .rposition(|w| w.durable_seq <= target_durable_seq) + .map_or(0, |idx| idx + 1); + state.waiters.insert(insert_pos, waiter); + debug_assert!(waiters_are_sorted(&state.waiters)); + } +} + +async fn run_notifier(mut status_rx: watch::Receiver, state: Arc>) { + loop { + if status_rx.changed().await.is_err() { + close_with_reason(state.as_ref(), CloseReason::Clean); + return; + } + let status = status_rx.borrow().clone(); + notify_waiters(state.as_ref(), status.durable_seq); + if let Some(close_reason) = status.close_reason { + close_with_reason(state.as_ref(), close_reason); + return; + } + } +} + +fn notify_waiters(state: &Mutex, durable_seq: u64) { + let ready = { + let mut state = state.lock(); + if durable_seq <= state.last_durable_seq { + return; + } + state.last_durable_seq = durable_seq; + debug_assert!(waiters_are_sorted(&state.waiters)); + let split = state + .waiters + .partition_point(|w| w.durable_seq <= durable_seq); + state.waiters.drain(..split).collect::>() + }; + for waiter in ready { + (waiter.callback)(Ok(durable_seq)); + } +} + +fn close_with_reason(state: &Mutex, reason: CloseReason) { + let pending = { + let mut state = state.lock(); + let prev = state.closed_reason.replace(reason); + assert!(prev.is_none()); + std::mem::take(&mut state.waiters) + }; + for waiter in pending { + (waiter.callback)(Err(reason)); + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + mpsc::{self, TryRecvError}, + }, + time::Duration, + }; + + use slatedb::{Db, object_store::memory::InMemory}; + + use super::*; + + fn test_notifier(last_durable_seq: u64) -> DurabilityNotifier { + DurabilityNotifier { + state: Arc::new(Mutex::new(State { + closed_reason: None, + last_durable_seq, + waiters: VecDeque::new(), + })), + } + } + + #[test] + fn subscribe_immediate_when_target_already_durable() { + let notifier = test_notifier(42); + let (tx, rx) = mpsc::channel(); + notifier.subscribe(7, move |res| { + tx.send(res).expect("send callback result"); + }); + let res = rx + .recv_timeout(Duration::from_millis(100)) + .expect("callback should run"); + assert_eq!(res.expect("durable result"), 42); + assert!(notifier.state.lock().waiters.is_empty()); + } + + #[test] + fn notify_waiters_releases_only_ready_targets() { + let state = Arc::new(Mutex::new(State { + closed_reason: None, + last_durable_seq: 0, + waiters: VecDeque::new(), + })); + + let (tx5, rx5) = mpsc::channel(); + state.lock().waiters.push_back(Waiter { + durable_seq: 5, + callback: Box::new(move |res| { + tx5.send(res).expect("send callback result"); + }), + }); + + let (tx8, rx8) = mpsc::channel(); + state.lock().waiters.push_back(Waiter { + durable_seq: 8, + callback: Box::new(move |res| { + tx8.send(res).expect("send callback result"); + }), + }); + + notify_waiters(state.as_ref(), 5); + + let res5 = rx5 + .recv_timeout(Duration::from_millis(100)) + .expect("ready waiter should run"); + assert_eq!(res5.expect("durable result"), 5); + assert!(matches!(rx8.try_recv(), Err(TryRecvError::Empty))); + assert_eq!( + state + .lock() + .waiters + .iter() + .map(|w| w.durable_seq) + .collect::>(), + vec![8] + ); + } + + #[test] + fn waiters_are_kept_sorted_when_insertions_arrive_out_of_order() { + let notifier = test_notifier(0); + + notifier.subscribe(10, |_| {}); + notifier.subscribe(5, |_| {}); + notifier.subscribe(7, |_| {}); + + assert_eq!( + notifier + .state + .lock() + .waiters + .iter() + .map(|w| w.durable_seq) + .collect::>(), + vec![5, 7, 10] + ); + } + + #[test] + fn close_with_reason_fails_all_pending_waiters() { + let state = Arc::new(Mutex::new(State { + closed_reason: None, + last_durable_seq: 0, + waiters: VecDeque::new(), + })); + + let (tx5, rx5) = mpsc::channel(); + state.lock().waiters.push_back(Waiter { + durable_seq: 5, + callback: Box::new(move |res| { + tx5.send(res).expect("send callback result"); + }), + }); + + let (tx8, rx8) = mpsc::channel(); + state.lock().waiters.push_back(Waiter { + durable_seq: 8, + callback: Box::new(move |res| { + tx8.send(res).expect("send callback result"); + }), + }); + + close_with_reason(state.as_ref(), CloseReason::Clean); + + let err5 = rx5 + .recv_timeout(Duration::from_millis(100)) + .expect("callback should run") + .expect_err("close should fail waiter"); + let err8 = rx8 + .recv_timeout(Duration::from_millis(100)) + .expect("callback should run") + .expect_err("close should fail waiter"); + assert_eq!(err5, CloseReason::Clean); + assert_eq!(err8, CloseReason::Clean); + let state = state.lock(); + assert!(state.waiters.is_empty()); + assert_eq!(state.closed_reason, Some(CloseReason::Clean)); + } + + #[test] + fn subscribe_after_close_returns_error_immediately() { + let notifier = test_notifier(0); + close_with_reason(notifier.state.as_ref(), CloseReason::Clean); + + let (tx, rx) = mpsc::channel(); + notifier.subscribe(1, move |res| { + tx.send(res).expect("send callback result"); + }); + + let err = rx + .recv_timeout(Duration::from_millis(100)) + .expect("callback should run") + .expect_err("closed notifier should fail immediately"); + assert_eq!(err, CloseReason::Clean); + assert!(notifier.state.lock().waiters.is_empty()); + } + + #[tokio::test(flavor = "current_thread")] + async fn spawn_on_closed_db_fails_subscribers_immediately() { + let object_store: Arc = Arc::new(InMemory::new()); + let db = Db::builder("test", object_store) + .build() + .await + .expect("build test db"); + db.close().await.expect("close test db"); + + let notifier = DurabilityNotifier::spawn(&db); + let (tx, rx) = mpsc::channel(); + notifier.subscribe(0, move |res| { + tx.send(res).expect("send callback result"); + }); + + let err = rx + .recv_timeout(Duration::from_millis(100)) + .expect("callback should run") + .expect_err("closed db should fail immediately"); + assert_eq!(err, CloseReason::Clean); + assert_eq!( + notifier.state.lock().closed_reason, + Some(CloseReason::Clean) + ); + } +} diff --git a/lite/src/backend/error.rs b/lite/src/backend/error.rs new file mode 100644 index 00000000..62970c58 --- /dev/null +++ b/lite/src/backend/error.rs @@ -0,0 +1,550 @@ +use std::{ops::RangeTo, sync::Arc}; + +use s2_common::{ + basin::BasinName, + encryption::EncryptionSpecResolutionError, + record::{FencingToken, SeqNum, StreamPosition}, + stream::StreamName, +}; +use s2_storage::record::RecordDecryptionError; + +use crate::backend::kv; + +#[derive(Debug, Clone, thiserror::Error)] +pub enum StorageError { + #[error("invariant violation: {0}")] + InvariantViolation(String), + #[error("deserialization: {0}")] + Deserialization(#[from] kv::DeserializationError), + #[error("database: {0}")] + Database(Arc), +} + +impl StorageError { + pub(super) fn is_transaction_conflict(&self) -> bool { + matches!(self, Self::Database(err) if err.kind() == slatedb::ErrorKind::Transaction) + } +} + +impl From for StorageError { + fn from(error: slatedb::Error) -> Self { + StorageError::Database(Arc::new(error)) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("basin `{basin}` not found")] +pub struct BasinNotFoundError { + pub basin: BasinName, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("stream `{stream}` in basin `{basin}` not found")] +pub struct StreamNotFoundError { + pub basin: BasinName, + pub stream: StreamName, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("basin `{basin}` already exists")] +pub struct BasinAlreadyExistsError { + pub basin: BasinName, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("stream `{stream}` in basin `{basin}` already exists")] +pub struct StreamAlreadyExistsError { + pub basin: BasinName, + pub stream: StreamName, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("basin `{basin}` is being deleted")] +pub struct BasinDeletionPendingError { + pub basin: BasinName, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("stream deletion pending")] +pub struct StreamDeletionPendingError; + +#[derive(Debug, Clone, thiserror::Error)] +#[error("unwritten position: {0}")] +pub struct UnwrittenError(pub StreamPosition); + +#[derive(Debug, Clone, thiserror::Error)] +#[error("streamer missing in action")] +pub struct StreamerMissingInActionError; + +#[derive(Debug, Clone, thiserror::Error)] +#[error("request dropped")] +pub struct RequestDroppedError; + +#[derive(Debug, Clone, thiserror::Error)] +#[error("record timestamp was required but was missing")] +pub struct AppendTimestampRequiredError; + +#[derive(Debug, Clone, thiserror::Error)] +#[error("max assignable sequence number is {max_assignable_seq_num}; attempted {assigned_seq_num}")] +pub struct MaxSeqNumError { + pub first_seq_num: SeqNum, + pub assigned_seq_num: SeqNum, + pub max_assignable_seq_num: SeqNum, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("transaction conflict occurred – this is usually retriable")] +pub struct TransactionConflictError; + +#[derive(Debug, Clone, thiserror::Error)] +pub enum StreamerError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), + #[error(transparent)] + StreamDeletionPending(#[from] StreamDeletionPendingError), +} + +#[derive(Debug, Clone, thiserror::Error)] +pub(super) enum AppendErrorInternal { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + StreamerMissingInActionError(#[from] StreamerMissingInActionError), + #[error(transparent)] + RequestDroppedError(#[from] RequestDroppedError), + #[error("stream deletion pending")] + StreamDeletionPending { + durability_dependency: RangeTo, + }, + #[error(transparent)] + ConditionFailed(#[from] AppendConditionFailedError), + #[error(transparent)] + TimestampMissing(#[from] AppendTimestampRequiredError), + #[error(transparent)] + MaxSeqNum(#[from] MaxSeqNumError), +} + +impl AppendErrorInternal { + pub fn durability_dependency(&self) -> RangeTo { + match self { + Self::StreamDeletionPending { + durability_dependency, + } => *durability_dependency, + Self::ConditionFailed(e) => e.durability_dependency(), + Self::MaxSeqNum(e) => e.durability_dependency(), + _ => ..0, + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum CheckTailError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + StreamerMissingInActionError(#[from] StreamerMissingInActionError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), + #[error(transparent)] + BasinDeletionPending(#[from] BasinDeletionPendingError), + #[error(transparent)] + StreamDeletionPending(#[from] StreamDeletionPendingError), +} + +impl From for CheckTailError { + fn from(e: StreamerError) -> Self { + match e { + StreamerError::StreamNotFound(e) => Self::StreamNotFound(e), + StreamerError::Storage(e) => Self::Storage(e), + StreamerError::StreamDeletionPending(e) => Self::StreamDeletionPending(e), + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum AppendError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + EncryptionSpecResolution(#[from] EncryptionSpecResolutionError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + StreamerMissingInActionError(#[from] StreamerMissingInActionError), + #[error(transparent)] + RequestDroppedError(#[from] RequestDroppedError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), + #[error(transparent)] + BasinDeletionPending(#[from] BasinDeletionPendingError), + #[error(transparent)] + StreamDeletionPending(#[from] StreamDeletionPendingError), + #[error(transparent)] + ConditionFailed(#[from] AppendConditionFailedError), + #[error(transparent)] + TimestampMissing(#[from] AppendTimestampRequiredError), + #[error(transparent)] + MaxSeqNum(#[from] MaxSeqNumError), +} + +impl From for AppendError { + fn from(e: AppendErrorInternal) -> Self { + match e { + AppendErrorInternal::Storage(e) => AppendError::Storage(e), + AppendErrorInternal::StreamerMissingInActionError(e) => { + AppendError::StreamerMissingInActionError(e) + } + AppendErrorInternal::RequestDroppedError(e) => AppendError::RequestDroppedError(e), + AppendErrorInternal::StreamDeletionPending { .. } => { + AppendError::StreamDeletionPending(StreamDeletionPendingError) + } + AppendErrorInternal::ConditionFailed(e) => AppendError::ConditionFailed(e), + AppendErrorInternal::TimestampMissing(e) => AppendError::TimestampMissing(e), + AppendErrorInternal::MaxSeqNum(e) => AppendError::MaxSeqNum(e), + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum AppendConditionFailedError { + #[error("fencing token mismatch: expected `{expected}`, actual `{actual}`")] + FencingTokenMismatch { + expected: FencingToken, + actual: FencingToken, + applied_point: RangeTo, + }, + #[error("sequence number mismatch: expected {match_seq_num}, actual {assigned_seq_num}")] + SeqNumMismatch { + assigned_seq_num: SeqNum, + match_seq_num: SeqNum, + }, +} + +impl AppendConditionFailedError { + pub fn durability_dependency(&self) -> RangeTo { + use AppendConditionFailedError::*; + match self { + SeqNumMismatch { + assigned_seq_num, .. + } => ..*assigned_seq_num, + FencingTokenMismatch { applied_point, .. } => *applied_point, + } + } +} + +impl MaxSeqNumError { + pub fn durability_dependency(&self) -> RangeTo { + ..self.first_seq_num + } +} + +impl From for AppendError { + fn from(e: StreamerError) -> Self { + match e { + StreamerError::StreamNotFound(e) => Self::StreamNotFound(e), + StreamerError::Storage(e) => Self::Storage(e), + StreamerError::StreamDeletionPending(e) => Self::StreamDeletionPending(e), + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ReadError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + EncryptionSpecResolution(#[from] EncryptionSpecResolutionError), + #[error(transparent)] + RecordDecryption(#[from] RecordDecryptionError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + StreamerMissingInActionError(#[from] StreamerMissingInActionError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), + #[error(transparent)] + BasinDeletionPending(#[from] BasinDeletionPendingError), + #[error(transparent)] + StreamDeletionPending(#[from] StreamDeletionPendingError), + #[error(transparent)] + Unwritten(#[from] UnwrittenError), +} + +impl From for ReadError { + fn from(e: StreamerError) -> Self { + match e { + StreamerError::StreamNotFound(e) => Self::StreamNotFound(e), + StreamerError::Storage(e) => Self::Storage(e), + StreamerError::StreamDeletionPending(e) => Self::StreamDeletionPending(e), + } + } +} + +impl From for ReadError { + fn from(e: kv::DeserializationError) -> Self { + Self::Storage(e.into()) + } +} + +impl From for ReadError { + fn from(e: slatedb::Error) -> Self { + Self::Storage(e.into()) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ListStreamsError { + #[error(transparent)] + Storage(#[from] StorageError), +} + +impl From for ListStreamsError { + fn from(e: slatedb::Error) -> Self { + Self::Storage(e.into()) + } +} + +impl From for ListStreamsError { + fn from(e: kv::DeserializationError) -> Self { + Self::Storage(e.into()) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ProvisionStreamError { + #[error(transparent)] + Storage(StorageError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), + #[error(transparent)] + BasinDeletionPending(#[from] BasinDeletionPendingError), + #[error(transparent)] + StreamAlreadyExists(#[from] StreamAlreadyExistsError), + #[error(transparent)] + StreamDeletionPending(#[from] StreamDeletionPendingError), + #[error(transparent)] + Validation(#[from] s2_common::ValidationError), +} + +impl From for ProvisionStreamError { + fn from(err: slatedb::Error) -> Self { + Self::from(StorageError::from(err)) + } +} + +impl From for ProvisionStreamError { + fn from(err: StorageError) -> Self { + if err.is_transaction_conflict() { + Self::TransactionConflict(TransactionConflictError) + } else { + Self::Storage(err) + } + } +} + +impl From for ProvisionStreamError { + fn from(err: GetBasinConfigError) -> Self { + match err { + GetBasinConfigError::Storage(e) => Self::Storage(e), + GetBasinConfigError::BasinNotFound(e) => Self::BasinNotFound(e), + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum GetStreamConfigError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), + #[error(transparent)] + StreamDeletionPending(#[from] StreamDeletionPendingError), +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum DeleteStreamError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + StreamerMissingInActionError(#[from] StreamerMissingInActionError), + #[error(transparent)] + RequestDroppedError(#[from] RequestDroppedError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), +} + +impl From for DeleteStreamError { + fn from(err: slatedb::Error) -> Self { + if err.kind() == slatedb::ErrorKind::Transaction { + Self::TransactionConflict(TransactionConflictError) + } else { + Self::Storage(err.into()) + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum BasinDeletionError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + DeleteStream(#[from] DeleteStreamError), +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum StreamDeleteOnEmptyError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + DeleteStream(#[from] DeleteStreamError), +} + +impl StreamDeleteOnEmptyError { + pub(super) fn is_transaction_conflict(&self) -> bool { + match self { + Self::Storage(err) | Self::DeleteStream(DeleteStreamError::Storage(err)) => { + err.is_transaction_conflict() + } + Self::DeleteStream(DeleteStreamError::TransactionConflict(_)) => true, + _ => false, + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ListBasinsError { + #[error(transparent)] + Storage(#[from] StorageError), +} + +impl From for ListBasinsError { + fn from(err: slatedb::Error) -> Self { + Self::Storage(err.into()) + } +} + +impl From for ListBasinsError { + fn from(e: kv::DeserializationError) -> Self { + Self::Storage(e.into()) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ProvisionBasinError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + BasinAlreadyExists(#[from] BasinAlreadyExistsError), + #[error(transparent)] + BasinDeletionPending(#[from] BasinDeletionPendingError), +} + +impl From for ProvisionBasinError { + fn from(err: slatedb::Error) -> Self { + if err.kind() == slatedb::ErrorKind::Transaction { + Self::TransactionConflict(TransactionConflictError) + } else { + Self::Storage(err.into()) + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum GetBasinConfigError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ReconfigureBasinError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), + #[error(transparent)] + BasinDeletionPending(#[from] BasinDeletionPendingError), +} + +impl From for ReconfigureBasinError { + fn from(err: slatedb::Error) -> Self { + if err.kind() == slatedb::ErrorKind::Transaction { + Self::TransactionConflict(TransactionConflictError) + } else { + Self::Storage(err.into()) + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ReconfigureStreamError { + #[error(transparent)] + Storage(StorageError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), + #[error(transparent)] + BasinDeletionPending(#[from] BasinDeletionPendingError), + #[error(transparent)] + StreamNotFound(#[from] StreamNotFoundError), + #[error(transparent)] + StreamDeletionPending(#[from] StreamDeletionPendingError), + #[error(transparent)] + Validation(#[from] s2_common::ValidationError), +} + +impl From for ReconfigureStreamError { + fn from(err: slatedb::Error) -> Self { + Self::from(StorageError::from(err)) + } +} + +impl From for ReconfigureStreamError { + fn from(err: StorageError) -> Self { + if err.is_transaction_conflict() { + Self::TransactionConflict(TransactionConflictError) + } else { + Self::Storage(err) + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum DeleteBasinError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + TransactionConflict(#[from] TransactionConflictError), + #[error(transparent)] + BasinNotFound(#[from] BasinNotFoundError), +} + +impl From for DeleteBasinError { + fn from(err: slatedb::Error) -> Self { + if err.kind() == slatedb::ErrorKind::Transaction { + Self::TransactionConflict(TransactionConflictError) + } else { + Self::Storage(err.into()) + } + } +} diff --git a/lite/src/backend/kv/basin_deletion_pending.rs b/lite/src/backend/kv/basin_deletion_pending.rs new file mode 100644 index 00000000..c0b93e0e --- /dev/null +++ b/lite/src/backend/kv/basin_deletion_pending.rs @@ -0,0 +1,53 @@ +use std::str::FromStr; + +use bytes::{BufMut, Bytes, BytesMut}; +use s2_common::{basin::BasinName, stream::StreamNameStartAfter}; + +use super::{DeserializationError, KeyType, invalid_value_err}; + +pub fn ser_key(basin: &BasinName) -> Bytes { + super::ser_basin_name_key(KeyType::BasinDeletionPending, basin) +} + +pub fn deser_key(bytes: Bytes) -> Result { + super::deser_basin_name_key(KeyType::BasinDeletionPending, bytes) +} + +pub fn ser_value(cursor: &StreamNameStartAfter) -> Bytes { + let cursor_bytes = cursor.as_bytes(); + let capacity = cursor_bytes.len(); + let mut buf = BytesMut::with_capacity(capacity); + buf.put_slice(cursor_bytes); + debug_assert_eq!(buf.len(), capacity, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_value(bytes: Bytes) -> Result { + let cursor_str = std::str::from_utf8(&bytes).map_err(|e| invalid_value_err("cursor", e))?; + StreamNameStartAfter::from_str(cursor_str).map_err(|e| invalid_value_err("cursor", e)) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use s2_common::stream::StreamNameStartAfter; + + use crate::backend::kv::proptest_strategies::{basin_name_strategy, stream_name_strategy}; + + proptest! { + #[test] + fn roundtrip_basin_deletion_pending_key(basin in basin_name_strategy()) { + let bytes = super::ser_key(&basin); + let decoded = super::deser_key(bytes).unwrap(); + prop_assert_eq!(basin.as_ref(), decoded.as_ref()); + } + + #[test] + fn roundtrip_basin_deletion_pending_value(stream in stream_name_strategy(),) { + let cursor = StreamNameStartAfter::from(stream.clone()); + let bytes = super::ser_value(&cursor); + let decoded = super::deser_value(bytes).unwrap(); + prop_assert_eq!(cursor.as_ref(), decoded.as_ref()); + } + } +} diff --git a/lite/src/backend/kv/basin_meta.rs b/lite/src/backend/kv/basin_meta.rs new file mode 100644 index 00000000..d878411b --- /dev/null +++ b/lite/src/backend/kv/basin_meta.rs @@ -0,0 +1,436 @@ +use std::ops::Range; + +use bytes::{BufMut, Bytes, BytesMut}; +use s2_common::{ + basin::{BasinName, BasinNamePrefix, BasinNameStartAfter}, + config::BasinConfig, +}; +use s2_storage::bash::Bash; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +use super::{DeserializationError, KeyType, deser_json_value, increment_bytes, ser_json_value}; + +#[derive(Debug, Clone)] +pub struct BasinMeta { + pub config: BasinConfig, + pub created_at: OffsetDateTime, + pub deleted_at: Option, + pub creation_idempotency_key: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct BasinMetaSerde { + config: Option, + #[serde(with = "time::serde::rfc3339")] + created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339::option")] + deleted_at: Option, + creation_idempotency_key: Option, +} + +impl From for BasinMetaSerde { + fn from(meta: BasinMeta) -> Self { + Self { + config: Some(meta.config.into()), + created_at: meta.created_at, + deleted_at: meta.deleted_at, + creation_idempotency_key: meta.creation_idempotency_key, + } + } +} + +impl TryFrom for BasinMeta { + type Error = s2_common::ValidationError; + + fn try_from(serde: BasinMetaSerde) -> Result { + let config = match serde.config { + Some(api_config) => api_config.try_into()?, + None => BasinConfig::default(), + }; + + Ok(Self { + config, + created_at: serde.created_at, + deleted_at: serde.deleted_at, + creation_idempotency_key: serde.creation_idempotency_key, + }) + } +} + +fn ser_key_internal(basin: &[u8]) -> BytesMut { + let capacity = 1 + basin.len(); + let mut buf = BytesMut::with_capacity(capacity); + buf.put_u8(KeyType::BasinMeta as u8); + buf.put_slice(basin); + debug_assert_eq!(buf.len(), capacity, "serialized length mismatch"); + buf +} + +pub fn ser_key_prefix(prefix: &BasinNamePrefix) -> Bytes { + ser_key_internal(prefix.as_bytes()).freeze() +} + +pub fn ser_key_prefix_end(prefix: &BasinNamePrefix) -> Bytes { + increment_bytes(ser_key_internal(prefix.as_bytes())).expect("non-empty") +} + +pub fn ser_key_start_after(start_after: &BasinNameStartAfter) -> Bytes { + let start_after_bytes = start_after.as_bytes(); + let mut bytes = Vec::with_capacity(start_after_bytes.len() + 1); + bytes.extend_from_slice(start_after_bytes); + bytes.push(b'\0'); + ser_key_internal(&bytes).freeze() +} + +pub fn ser_key_range(prefix: &BasinNamePrefix, start_after: &BasinNameStartAfter) -> Range { + let prefix_start = ser_key_prefix(prefix); + let start = if !start_after.is_empty() { + let start_after_key = ser_key_start_after(start_after); + std::cmp::max(prefix_start, start_after_key) + } else { + prefix_start + }; + let end = ser_key_prefix_end(prefix); + start..end +} + +pub fn ser_key(basin: &BasinName) -> Bytes { + super::ser_basin_name_key(KeyType::BasinMeta, basin) +} + +pub fn deser_key(bytes: Bytes) -> Result { + super::deser_basin_name_key(KeyType::BasinMeta, bytes) +} + +pub fn ser_value(basin_meta: &BasinMeta) -> Bytes { + ser_json_value::(basin_meta, "BasinMeta") +} + +pub fn deser_value(bytes: Bytes) -> Result { + deser_json_value::(bytes, "basin_meta") +} + +#[cfg(test)] +mod tests { + use std::{str::FromStr, time::Duration}; + + use bytes::Bytes; + use proptest::prelude::*; + use s2_common::{ + basin::{BasinName, BasinNamePrefix, BasinNameStartAfter}, + config::{BasinConfig, OptionalDeleteOnEmptyConfig, OptionalStreamConfig}, + }; + use s2_storage::bash::Bash; + use time::OffsetDateTime; + + use crate::backend::kv::{ + DeserializationError, KeyType, proptest_strategies::basin_name_strategy, + }; + + fn basin(name: &str) -> BasinName { + BasinName::from_str(name).unwrap() + } + + fn basin_prefix(prefix: &str) -> BasinNamePrefix { + BasinNamePrefix::from_str(prefix).unwrap() + } + + fn basin_start_after(name: &str) -> BasinNameStartAfter { + BasinNameStartAfter::from_str(name).unwrap() + } + + #[test] + fn basin_meta_ser_key_prefix() { + let prefix = basin_prefix("test-prefix"); + let key = super::ser_key_prefix(&prefix); + + assert_eq!(key[0], (KeyType::BasinMeta as u8)); + assert_eq!(&key[1..], b"test-prefix"); + } + + #[test] + fn basin_meta_ser_key_prefix_empty() { + let prefix = BasinNamePrefix::default(); + let key = super::ser_key_prefix(&prefix); + + assert_eq!(key.len(), 1); + assert_eq!(key[0], (KeyType::BasinMeta as u8)); + } + + #[test] + fn basin_meta_ser_key_prefix_end_empty() { + let prefix = BasinNamePrefix::default(); + let end_key = super::ser_key_prefix_end(&prefix); + + assert_eq!(end_key.len(), 1); + assert_eq!(end_key[0], (KeyType::BasinMeta as u8) + 1); + } + + #[test] + fn basin_meta_ser_key_prefix_end_advances() { + for (input, expected_suffix) in [("test-a", &b"test-b"[..]), ("test-abc", &b"test-abd"[..])] + { + let end_key = super::ser_key_prefix_end(&basin_prefix(input)); + assert_eq!(end_key[0], (KeyType::BasinMeta as u8)); + assert_eq!(&end_key[1..], expected_suffix); + } + } + + #[test] + fn basin_meta_ser_key_start_after() { + let key = super::ser_key_start_after(&basin_start_after("my-basin")); + + assert_eq!(key[0], (KeyType::BasinMeta as u8)); + assert_eq!(&key[1..key.len() - 1], b"my-basin"); + assert_eq!( + key[key.len() - 1], + b'\0', + "should end with null byte for exclusion" + ); + } + + #[test] + fn basin_meta_key_range_handles_pagination() { + let prefix = basin_prefix("test-"); + let basin1 = basin("test-aaa"); + let basin2 = basin("test-bbb"); + let basin3 = basin("test-ccc"); + let outside = basin("staging-service1"); + + let page1 = super::ser_key_range(&prefix, &BasinNameStartAfter::default()); + assert_eq!(page1.start, super::ser_key_prefix(&prefix)); + assert_eq!(page1.end, super::ser_key_prefix_end(&prefix)); + + let key1 = super::ser_key(&basin1); + let key2 = super::ser_key(&basin2); + let key3 = super::ser_key(&basin3); + let key_outside = super::ser_key(&outside); + + assert!(key1 >= page1.start && key1 < page1.end); + assert!(key2 >= page1.start && key2 < page1.end); + assert!(key3 >= page1.start && key3 < page1.end); + assert!(key_outside < page1.start || key_outside >= page1.end); + + let start_after = BasinNameStartAfter::from(basin1.clone()); + let cursor_key = super::ser_key_start_after(&start_after); + assert!(cursor_key > key1); + assert!(cursor_key < key2); + + let page2 = super::ser_key_range(&prefix, &start_after); + assert_eq!(page2.start, super::ser_key_start_after(&start_after)); + assert_eq!(page2.end, super::ser_key_prefix_end(&prefix)); + + assert!(key1 < page2.start); + assert!(key2 >= page2.start && key2 < page2.end); + assert!(key3 >= page2.start && key3 < page2.end); + } + + #[test] + fn value_roundtrip_basin_meta() { + let config = BasinConfig { + create_stream_on_append: true, + default_stream_config: OptionalStreamConfig { + delete_on_empty: OptionalDeleteOnEmptyConfig { + min_age: Some(Duration::ZERO), + }, + ..Default::default() + }, + ..Default::default() + }; + let created_at = OffsetDateTime::from_unix_timestamp(1234567890) + .unwrap() + .replace_nanosecond(123456789) + .unwrap(); + let deleted_at = Some( + OffsetDateTime::from_unix_timestamp(1234567890) + .unwrap() + .replace_nanosecond(123456789) + .unwrap(), + ); + let basin_meta = super::BasinMeta { + config: config.clone(), + created_at, + deleted_at, + creation_idempotency_key: Some(Bash::length_prefixed(&[ + b"test-basin", + b"request-token-123", + ])), + }; + + let bytes = super::ser_value(&basin_meta); + let decoded = super::deser_value(bytes).unwrap(); + + assert_eq!( + basin_meta.config.create_stream_on_append, + decoded.config.create_stream_on_append + ); + assert_eq!( + basin_meta.config.create_stream_on_read, + decoded.config.create_stream_on_read + ); + assert_eq!( + basin_meta + .config + .default_stream_config + .delete_on_empty + .min_age, + decoded.config.default_stream_config.delete_on_empty.min_age + ); + assert_eq!(basin_meta.created_at, decoded.created_at); + assert_eq!(basin_meta.deleted_at, decoded.deleted_at); + } + + #[test] + fn basin_meta_deser_defaults_config_missing() { + let serde_value = super::BasinMetaSerde { + config: None, + created_at: OffsetDateTime::from_unix_timestamp(1_234_567).unwrap(), + deleted_at: None, + creation_idempotency_key: Some(Bash::length_prefixed(&[b"my-basin", b"req-789"])), + }; + let bytes = Bytes::from(serde_json::to_vec(&serde_value).unwrap()); + let decoded = super::deser_value(bytes).unwrap(); + let default_config = BasinConfig::default(); + + assert_eq!( + decoded.config.create_stream_on_append, + default_config.create_stream_on_append + ); + assert_eq!( + decoded.config.create_stream_on_read, + default_config.create_stream_on_read + ); + assert_eq!( + decoded.config.default_stream_config.storage_class, + default_config.default_stream_config.storage_class + ); + assert_eq!( + decoded.config.default_stream_config.retention_policy, + default_config.default_stream_config.retention_policy + ); + assert_eq!( + decoded.config.default_stream_config.timestamping.mode, + default_config.default_stream_config.timestamping.mode + ); + assert_eq!( + decoded.config.default_stream_config.timestamping.uncapped, + default_config.default_stream_config.timestamping.uncapped + ); + assert_eq!( + decoded.config.default_stream_config.delete_on_empty.min_age, + default_config.default_stream_config.delete_on_empty.min_age + ); + assert_eq!(decoded.created_at, serde_value.created_at); + assert_eq!(decoded.deleted_at, serde_value.deleted_at); + } + + #[test] + fn basin_meta_deser_invalid_json() { + let err = super::deser_value(Bytes::from_static(b"{")).unwrap_err(); + assert!(matches!(err, DeserializationError::JsonDeserialization(_))); + } + + fn basin_name_prefix_strategy() -> impl Strategy { + prop_oneof![ + Just(BasinNamePrefix::default()), + "[a-z][a-z0-9-]{0,46}".prop_map(|s| BasinNamePrefix::from_str(&s).unwrap()), + ] + } + + #[test] + fn basin_meta_range_start_after_before_prefix() { + let prefix = BasinNamePrefix::from_str("staging-").unwrap(); + let start_after = BasinNameStartAfter::from_str("prod-api").unwrap(); + + let range = super::ser_key_range(&prefix, &start_after); + + assert!( + range.start < range.end, + "range should be valid when start_after is before prefix range" + ); + + let staging_basin = BasinName::from_str("staging-api").unwrap(); + let staging_key = super::ser_key(&staging_basin); + assert!( + staging_key >= range.start && staging_key < range.end, + "basins matching prefix should be in range" + ); + + let prod_basin = BasinName::from_str("prod-service").unwrap(); + let prod_key = super::ser_key(&prod_basin); + assert!( + prod_key < range.start, + "basins before prefix should NOT be in range" + ); + } + + proptest! { + #[test] + fn roundtrip_basin_meta_key(basin in basin_name_strategy()) { + let bytes = super::ser_key(&basin); + let decoded = super::deser_key(bytes).unwrap(); + prop_assert_eq!(basin.as_ref(), decoded.as_ref()); + } + + #[test] + fn basin_meta_range_contains_prefixed_keys( + prefix in basin_name_prefix_strategy(), + basin in basin_name_strategy(), + ) { + let prefix_str = prefix.as_ref(); + let basin_str = basin.as_ref(); + let matches_prefix = prefix_str.is_empty() || basin_str.starts_with(prefix_str); + + let range = super::ser_key_range(&prefix, &BasinNameStartAfter::default()); + let key = super::ser_key(&basin); + + if matches_prefix { + prop_assert!(key >= range.start, "key {:?} should be >= range.start {:?}", key, range.start); + prop_assert!(key < range.end, "key {:?} should be < range.end {:?}", key, range.end); + } else { + prop_assert!(key < range.start || key >= range.end); + } + } + + #[test] + fn basin_meta_keys_preserve_ordering( + basin1 in basin_name_strategy(), + basin2 in basin_name_strategy(), + ) { + let key1 = super::ser_key(&basin1); + let key2 = super::ser_key(&basin2); + + let basin_cmp = basin1.as_ref().cmp(basin2.as_ref()); + let key_cmp = key1.cmp(&key2); + + prop_assert_eq!(basin_cmp, key_cmp, "ordering should be preserved"); + } + + #[test] + fn basin_meta_start_after_excludes_cursor( + prefix in basin_name_prefix_strategy(), + basin1 in basin_name_strategy(), + basin2 in basin_name_strategy(), + ) { + if basin1.as_ref() >= basin2.as_ref() { + return Ok(()); + } + + let start_after = BasinNameStartAfter::from(basin1.clone()); + let range = super::ser_key_range(&prefix, &start_after); + + let key1 = super::ser_key(&basin1); + let key2 = super::ser_key(&basin2); + + let prefix_str = prefix.as_ref(); + let basin1_matches = prefix_str.is_empty() || basin1.as_ref().starts_with(prefix_str); + let basin2_matches = prefix_str.is_empty() || basin2.as_ref().starts_with(prefix_str); + + prop_assert!(key1 < range.start, "cursor basin should be excluded (before range.start)"); + if basin2_matches && (basin1_matches || basin2.as_ref() > prefix_str) { + prop_assert!(key2 >= range.start, "later basin matching prefix should be included (at or after range.start)"); + } + } + } +} diff --git a/lite/src/backend/kv/mod.rs b/lite/src/backend/kv/mod.rs new file mode 100644 index 00000000..95f0e505 --- /dev/null +++ b/lite/src/backend/kv/mod.rs @@ -0,0 +1,357 @@ +pub mod basin_deletion_pending; +pub mod basin_meta; +pub mod stream_doe_check; +pub mod stream_doe_deadline; +pub mod stream_doe_state; +pub mod stream_fencing_token; +pub mod stream_id_mapping; +pub mod stream_meta; +pub mod stream_record_data; +pub mod stream_record_timestamp; +pub mod stream_tail_position; +pub mod stream_trim_point; + +use std::{ops::Range, str::FromStr}; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use s2_common::{ + basin::BasinName, caps::MIN_BASIN_NAME_LEN, record::StreamPosition, stream::StreamName, +}; +use strum::FromRepr; +use thiserror::Error; + +use crate::{backend::timestamp::TimestampSecs, stream_id::StreamId}; + +#[derive(Debug, Clone, Error)] +pub enum DeserializationError { + #[error("invalid ordinal: {0}")] + InvalidOrdinal(u8), + #[error("invalid size: expected {expected} bytes, got {actual}")] + InvalidSize { expected: usize, actual: usize }, + #[error("invalid value '{name}': {error}")] + InvalidValue { name: &'static str, error: String }, + #[error("missing field separator")] + MissingFieldSeparator, + #[error("json deserialization error: {0}")] + JsonDeserialization(String), +} + +// IDs persisted so must be kept stable. +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, FromRepr)] +pub enum KeyType { + BasinMeta = 1, + BasinDeletionPending = 8, + StreamMeta = 2, + StreamIdMapping = 9, + StreamTailPosition = 3, + StreamFencingToken = 4, + StreamTrimPoint = 5, + StreamRecordData = 6, + StreamRecordTimestamp = 7, + StreamDeleteOnEmptyDeadline = 10, + StreamDeleteOnEmptyState = 11, + StreamDeleteOnEmptyCheck = 12, +} + +#[derive(Debug, Clone)] +pub enum Key { + /// (BM) per-basin, updatable + /// Key: BasinName + /// Value: BasinMeta + BasinMeta(BasinName), + /// (BDP) per-basin, deletable, only present while basin deletion pending + /// Key: BasinName + /// Value: StreamNameStartAfter (cursor for resumable deletion) + BasinDeletionPending(BasinName), + /// (SM) per-stream, updatable + /// Key: BasinName \0 StreamName + /// Value: StreamMeta + StreamMeta(BasinName, StreamName), + /// (SIM) per-stream, immutable + /// Key: StreamID + /// Value: BasinName \0 StreamName + StreamIdMapping(StreamId), + /// (SP) per-stream, updatable + /// Key: StreamID + /// Value: SeqNum Timestamp + StreamTailPosition(StreamId), + /// (SFT) per-stream, updatable, optional, default empty + /// Key: StreamID + /// Value: FencingToken + StreamFencingToken(StreamId), + /// (STP) per-stream, updatable, optional; missing implies 0; only present while trim pending + /// Key: StreamID + /// Value: NonZeroSeqNum + StreamTrimPoint(StreamId), + /// (SRD) per-record, immutable + /// Key: StreamID StreamPosition + /// Value: EnvelopedRecord + StreamRecordData(StreamId, StreamPosition), + /// (SRT) per-record, immutable + /// Key: StreamID Timestamp SeqNum + /// Value: empty + StreamRecordTimestamp(StreamId, StreamPosition), + /// (SDOED) legacy schedule, consumed only to initialize the new DOE state + /// Key: TimestampSecs StreamID ScheduleID (u128, absent in legacy keys) + /// Value: MinAge seconds (u64) + StreamDeleteOnEmptyDeadline(TimestampSecs, StreamId, Option), + /// (SDOES) per-stream, updatable, optional + /// Key: StreamID + /// Value: Tag (u8: 0 = parked, 1 = scheduled). + /// Scheduled values append TimestampSecs (u32) CheckID (u128). + /// Uses the entry's SlateDB sequence as its revision. + /// State older than the current stream-ID mapping is stale. + StreamDeleteOnEmptyState(StreamId), + /// (SDOEC) per-check, immutable, deletable, time-ordered index of scheduled states + /// Key: TimestampSecs (u32) StreamID CheckID (u128) + /// Value: empty + StreamDeleteOnEmptyCheck(StreamId, stream_doe_state::Check), +} + +impl From for Bytes { + fn from(value: Key) -> Self { + match value { + Key::BasinMeta(basin) => basin_meta::ser_key(&basin), + Key::BasinDeletionPending(basin) => basin_deletion_pending::ser_key(&basin), + Key::StreamMeta(basin, stream) => stream_meta::ser_key(&basin, &stream), + Key::StreamIdMapping(stream_id) => stream_id_mapping::ser_key(stream_id), + Key::StreamTailPosition(stream_id) => stream_tail_position::ser_key(stream_id), + Key::StreamFencingToken(stream_id) => stream_fencing_token::ser_key(stream_id), + Key::StreamTrimPoint(stream_id) => stream_trim_point::ser_key(stream_id), + Key::StreamRecordData(stream_id, pos) => stream_record_data::ser_key(stream_id, pos), + Key::StreamRecordTimestamp(stream_id, pos) => { + stream_record_timestamp::ser_key(stream_id, pos) + } + Key::StreamDeleteOnEmptyDeadline(deadline, stream_id, schedule_id) => { + stream_doe_deadline::ser_key(deadline, stream_id, schedule_id) + } + Key::StreamDeleteOnEmptyState(stream_id) => stream_doe_state::ser_key(stream_id), + Key::StreamDeleteOnEmptyCheck(stream_id, check) => { + stream_doe_check::ser_key(stream_id, check) + } + } + } +} + +impl TryFrom for Key { + type Error = DeserializationError; + + fn try_from(bytes: Bytes) -> Result { + check_min_size(&bytes, 1)?; + let ordinal = KeyType::from_repr(bytes[0]) + .ok_or_else(|| DeserializationError::InvalidOrdinal(bytes[0]))?; + match ordinal { + KeyType::BasinMeta => basin_meta::deser_key(bytes).map(Key::BasinMeta), + KeyType::BasinDeletionPending => { + basin_deletion_pending::deser_key(bytes).map(Key::BasinDeletionPending) + } + KeyType::StreamMeta => { + stream_meta::deser_key(bytes).map(|(basin, stream)| Key::StreamMeta(basin, stream)) + } + KeyType::StreamIdMapping => { + stream_id_mapping::deser_key(bytes).map(Key::StreamIdMapping) + } + KeyType::StreamTailPosition => { + stream_tail_position::deser_key(bytes).map(Key::StreamTailPosition) + } + KeyType::StreamFencingToken => { + stream_fencing_token::deser_key(bytes).map(Key::StreamFencingToken) + } + KeyType::StreamTrimPoint => { + stream_trim_point::deser_key(bytes).map(Key::StreamTrimPoint) + } + KeyType::StreamRecordData => stream_record_data::deser_key(bytes) + .map(|(stream_id, pos)| Key::StreamRecordData(stream_id, pos)), + KeyType::StreamRecordTimestamp => stream_record_timestamp::deser_key(bytes) + .map(|(stream_id, pos)| Key::StreamRecordTimestamp(stream_id, pos)), + KeyType::StreamDeleteOnEmptyDeadline => { + stream_doe_deadline::deser_key(bytes).map(|(deadline, stream_id, schedule_id)| { + Key::StreamDeleteOnEmptyDeadline(deadline, stream_id, schedule_id) + }) + } + KeyType::StreamDeleteOnEmptyState => { + stream_doe_state::deser_key(bytes).map(Key::StreamDeleteOnEmptyState) + } + KeyType::StreamDeleteOnEmptyCheck => stream_doe_check::deser_key(bytes) + .map(|(stream_id, check)| Key::StreamDeleteOnEmptyCheck(stream_id, check)), + } + } +} + +/// Shared serializer for keys of the form `[KeyType][StreamId]`. +pub fn ser_stream_id_key(key_type: KeyType, stream_id: StreamId) -> Bytes { + let key_len = 1 + StreamId::LEN; + let mut buf = BytesMut::with_capacity(key_len); + buf.put_u8(key_type as u8); + buf.put_slice(stream_id.as_bytes()); + debug_assert_eq!(buf.len(), key_len, "serialized length mismatch"); + buf.freeze() +} + +/// Shared deserializer for keys of the form `[KeyType][StreamId]`. +pub fn deser_stream_id_key( + key_type: KeyType, + mut bytes: Bytes, +) -> Result { + let key_len = 1 + StreamId::LEN; + check_exact_size(&bytes, key_len)?; + let ordinal = bytes.get_u8(); + if ordinal != (key_type as u8) { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let mut stream_id_bytes = [0u8; StreamId::LEN]; + bytes.copy_to_slice(&mut stream_id_bytes); + Ok(stream_id_bytes.into()) +} + +/// Shared serializer for keys of the form `[KeyType][BasinName]`. +pub fn ser_basin_name_key(key_type: KeyType, basin: &BasinName) -> Bytes { + let basin_bytes = basin.as_bytes(); + let capacity = 1 + basin_bytes.len(); + let mut buf = BytesMut::with_capacity(capacity); + buf.put_u8(key_type as u8); + buf.put_slice(basin_bytes); + debug_assert_eq!(buf.len(), capacity, "serialized length mismatch"); + buf.freeze() +} + +/// Shared deserializer for keys of the form `[KeyType][BasinName]`. +pub fn deser_basin_name_key( + key_type: KeyType, + mut bytes: Bytes, +) -> Result { + check_min_size(&bytes, 1 + MIN_BASIN_NAME_LEN)?; + let ordinal = bytes.get_u8(); + if ordinal != (key_type as u8) { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let basin_str = std::str::from_utf8(&bytes).map_err(|e| invalid_value_err("basin", e))?; + BasinName::from_str(basin_str).map_err(|e| invalid_value_err("basin", e)) +} + +fn check_exact_size(bytes: &Bytes, expected: usize) -> Result<(), DeserializationError> { + if bytes.remaining() != expected { + return Err(DeserializationError::InvalidSize { + expected, + actual: bytes.remaining(), + }); + } + Ok(()) +} + +fn check_min_size(bytes: &Bytes, min: usize) -> Result<(), DeserializationError> { + if bytes.remaining() < min { + return Err(DeserializationError::InvalidSize { + expected: min, + actual: bytes.remaining(), + }); + } + Ok(()) +} + +pub fn key_type_range(key_type: KeyType) -> Range { + let ordinal = key_type as u8; + let start = Bytes::from(vec![ordinal]); + let end = Bytes::from(vec![ + ordinal.checked_add(1).expect("key type ordinal overflow"), + ]); + start..end +} + +fn increment_bytes(mut buf: BytesMut) -> Option { + for i in (0..buf.len()).rev() { + if buf[i] < 0xFF { + buf[i] += 1; + buf.truncate(i + 1); + return Some(buf.freeze()); + } + } + None +} + +fn invalid_value_err(name: &'static str, e: E) -> DeserializationError { + DeserializationError::InvalidValue { + name, + error: e.to_string(), + } +} + +fn ser_json_value(value: &T, type_name: &str) -> Bytes +where + T: Clone + Into, + S: serde::Serialize, +{ + let serde_value: S = value.clone().into(); + serde_json::to_vec(&serde_value) + .unwrap_or_else(|_| panic!("failed to serialize {}", type_name)) + .into() +} + +fn deser_json_value(bytes: Bytes, name: &'static str) -> Result +where + S: serde::de::DeserializeOwned, + T: TryFrom, + T::Error: std::fmt::Display, +{ + let serde_value: S = serde_json::from_slice(&bytes) + .map_err(|e| DeserializationError::JsonDeserialization(e.to_string()))?; + T::try_from(serde_value).map_err(|e| invalid_value_err(name, e)) +} + +#[cfg(test)] +mod proptest_strategies { + use std::str::FromStr; + + use proptest::prelude::*; + use s2_common::{basin::BasinName, stream::StreamName}; + + pub(super) fn basin_name_strategy() -> impl Strategy { + "[a-z][a-z0-9-]{6,46}[a-z0-9]".prop_map(|s| BasinName::from_str(&s).unwrap()) + } + + pub(super) fn stream_name_strategy() -> impl Strategy { + "[a-zA-Z0-9_-]{1,100}".prop_map(|s| StreamName::from_str(&s).unwrap()) + } +} + +#[cfg(test)] +mod tests { + use bytes::{BufMut, Bytes, BytesMut}; + + use super::{DeserializationError, Key, KeyType}; + + #[test] + fn error_on_invalid_ordinal() { + let bytes = Bytes::from(vec![255u8]); + let result = Key::try_from(bytes); + assert!(matches!( + result, + Err(DeserializationError::InvalidOrdinal(255)) + )); + } + + #[test] + fn error_on_insufficient_data() { + let bytes = Bytes::from(vec![KeyType::StreamTailPosition as u8, 1, 2, 3]); + let result = Key::try_from(bytes); + assert!(matches!( + result, + Err(DeserializationError::InvalidSize { .. }) + )); + } + + #[test] + fn error_on_missing_separator() { + let mut buf = BytesMut::new(); + buf.put_u8(KeyType::StreamMeta as u8); + buf.put_slice(b"basin-without-separator"); + let bytes = buf.freeze(); + + let result = Key::try_from(bytes); + assert!(matches!( + result, + Err(DeserializationError::MissingFieldSeparator) + )); + } +} diff --git a/lite/src/backend/kv/stream_doe_check.rs b/lite/src/backend/kv/stream_doe_check.rs new file mode 100644 index 00000000..2ec02c2c --- /dev/null +++ b/lite/src/backend/kv/stream_doe_check.rs @@ -0,0 +1,78 @@ +use std::ops::Range; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; + +use super::{DeserializationError, KeyType, check_exact_size, stream_doe_state::Check}; +use crate::{backend::timestamp::TimestampSecs, stream_id::StreamId}; + +const KEY_LEN: usize = 1 + 4 + StreamId::LEN + 16; + +pub fn ser_key(stream_id: StreamId, check: Check) -> Bytes { + let mut buf = BytesMut::with_capacity(KEY_LEN); + buf.put_u8(KeyType::StreamDeleteOnEmptyCheck as u8); + buf.put_u32(check.at.as_u32()); + buf.put_slice(stream_id.as_bytes()); + buf.put_u128(check.id); + buf.freeze() +} + +pub fn deser_key(mut bytes: Bytes) -> Result<(StreamId, Check), DeserializationError> { + check_exact_size(&bytes, KEY_LEN)?; + let ordinal = bytes.get_u8(); + if ordinal != KeyType::StreamDeleteOnEmptyCheck as u8 { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let at = TimestampSecs::from_secs(bytes.get_u32()); + let mut stream_id = [0; StreamId::LEN]; + bytes.copy_to_slice(&mut stream_id); + let id = bytes.get_u128(); + Ok((stream_id.into(), Check { at, id })) +} + +pub fn due_key_range(now: TimestampSecs) -> Range { + let start = Bytes::from_static(&[KeyType::StreamDeleteOnEmptyCheck as u8]); + let mut end = BytesMut::with_capacity(5); + end.put_u8(KeyType::StreamDeleteOnEmptyCheck as u8); + end.put_u32(now.as_u32()); + start..super::increment_bytes(end).expect("non-empty") +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + proptest! { + #[test] + fn roundtrip_check(at in any::(), id in any::(), stream in any::<[u8; StreamId::LEN]>()) { + let check = Check { at: TimestampSecs::from_secs(at), id }; + let stream_id = StreamId::from(stream); + let key = ser_key(stream_id, check); + prop_assert_eq!(deser_key(key.clone()).unwrap(), (stream_id, check)); + prop_assert_eq!(Bytes::from(super::super::Key::try_from(key.clone()).unwrap()), key); + } + } + + #[test] + fn due_range_includes_every_ticket_at_boundary() { + for seconds in [0, 100, u32::MAX] { + let at = TimestampSecs::from_secs(seconds); + let range = due_key_range(at); + for stream_id in [[0; StreamId::LEN], [u8::MAX; StreamId::LEN]].map(StreamId::from) { + for id in [0, u128::MAX] { + assert!(range.contains(&ser_key(stream_id, Check { at, id }))); + if let Some(next) = seconds.checked_add(1) { + assert!(!range.contains(&ser_key( + stream_id, + Check { + at: TimestampSecs::from_secs(next), + id + } + ))); + } + } + } + } + } +} diff --git a/lite/src/backend/kv/stream_doe_deadline.rs b/lite/src/backend/kv/stream_doe_deadline.rs new file mode 100644 index 00000000..8eef06a3 --- /dev/null +++ b/lite/src/backend/kv/stream_doe_deadline.rs @@ -0,0 +1,75 @@ +use bytes::{Buf, BufMut, Bytes, BytesMut}; + +use super::{DeserializationError, KeyType, check_exact_size}; +use crate::{backend::timestamp::TimestampSecs, stream_id::StreamId}; + +const LEGACY_KEY_LEN: usize = 1 + 4 + StreamId::LEN; +const KEY_LEN: usize = LEGACY_KEY_LEN + 16; +/// Legacy keys are retained only for decoding and migration. New schedules use +/// `stream_doe_state` and `stream_doe_check`. +pub fn ser_key(deadline: TimestampSecs, stream_id: StreamId, schedule_id: Option) -> Bytes { + let key_len = if schedule_id.is_some() { + KEY_LEN + } else { + LEGACY_KEY_LEN + }; + let mut buf = BytesMut::with_capacity(key_len); + buf.put_u8(KeyType::StreamDeleteOnEmptyDeadline as u8); + buf.put_u32(deadline.as_u32()); + buf.put_slice(stream_id.as_bytes()); + if let Some(schedule_id) = schedule_id { + buf.put_u128(schedule_id); + } + debug_assert_eq!(buf.len(), key_len, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_key( + mut bytes: Bytes, +) -> Result<(TimestampSecs, StreamId, Option), DeserializationError> { + if bytes.len() != LEGACY_KEY_LEN { + check_exact_size(&bytes, KEY_LEN)?; + } + let ordinal = bytes.get_u8(); + if ordinal != (KeyType::StreamDeleteOnEmptyDeadline as u8) { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let deadline_secs = bytes.get_u32(); + let mut stream_id_bytes = [0u8; StreamId::LEN]; + bytes.copy_to_slice(&mut stream_id_bytes); + let schedule_id = bytes.has_remaining().then(|| bytes.get_u128()); + Ok(( + TimestampSecs::from_secs(deadline_secs), + stream_id_bytes.into(), + schedule_id, + )) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use crate::{ + backend::{kv::stream_doe_deadline, timestamp::TimestampSecs}, + stream_id::StreamId, + }; + + proptest! { + #[test] + fn roundtrip_stream_doe_deadline_key( + deadline_secs in any::(), + stream_id_bytes in any::<[u8; StreamId::LEN]>(), + schedule_id in proptest::option::of(any::()), + ) { + let deadline = TimestampSecs::from_secs(deadline_secs); + let stream_id = StreamId::from(stream_id_bytes); + let bytes = stream_doe_deadline::ser_key(deadline, stream_id, schedule_id); + let (decoded_deadline, decoded_stream_id, decoded_schedule_id) = stream_doe_deadline::deser_key(bytes.clone()).unwrap(); + prop_assert_eq!(deadline, decoded_deadline); + prop_assert_eq!(stream_id, decoded_stream_id); + prop_assert_eq!(schedule_id, decoded_schedule_id); + let decoded = super::super::Key::try_from(bytes.clone()).unwrap(); + prop_assert_eq!(bytes, bytes::Bytes::from(decoded)); + } + } +} diff --git a/lite/src/backend/kv/stream_doe_state.rs b/lite/src/backend/kv/stream_doe_state.rs new file mode 100644 index 00000000..e1f07b49 --- /dev/null +++ b/lite/src/backend/kv/stream_doe_state.rs @@ -0,0 +1,82 @@ +use bytes::{Buf, BufMut, Bytes, BytesMut}; + +use super::{DeserializationError, KeyType, check_exact_size}; +use crate::{backend::timestamp::TimestampSecs, stream_id::StreamId}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Check { + pub at: TimestampSecs, + pub id: u128, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Scheduled(Check), + /// An observed record has no expiration. Only a trim can remove it. + Parked, +} + +pub fn ser_key(stream_id: StreamId) -> Bytes { + super::ser_stream_id_key(KeyType::StreamDeleteOnEmptyState, stream_id) +} + +pub fn deser_key(bytes: Bytes) -> Result { + super::deser_stream_id_key(KeyType::StreamDeleteOnEmptyState, bytes) +} + +pub fn ser_value(state: State) -> Bytes { + let mut buf = BytesMut::with_capacity(21); + match state { + State::Parked => buf.put_u8(0), + State::Scheduled(Check { at, id }) => { + buf.put_u8(1); + buf.put_u32(at.as_u32()); + buf.put_u128(id); + } + } + buf.freeze() +} + +pub fn deser_value(mut bytes: Bytes) -> Result { + super::check_min_size(&bytes, 1)?; + match bytes.get_u8() { + 0 => { + check_exact_size(&bytes, 0)?; + Ok(State::Parked) + } + 1 => { + check_exact_size(&bytes, 20)?; + Ok(State::Scheduled(Check { + at: TimestampSecs::from_secs(bytes.get_u32()), + id: bytes.get_u128(), + })) + } + ordinal => Err(DeserializationError::InvalidOrdinal(ordinal)), + } +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + proptest! { + #[test] + fn roundtrip_state(at in any::(), id in any::(), stream in any::<[u8; StreamId::LEN]>()) { + for state in [State::Parked, State::Scheduled(Check { at: TimestampSecs::from_secs(at), id })] { + prop_assert_eq!(deser_value(ser_value(state)).unwrap(), state); + } + let key = ser_key(stream.into()); + prop_assert_eq!(deser_key(key.clone()).unwrap(), StreamId::from(stream)); + prop_assert_eq!(Bytes::from(super::super::Key::try_from(key.clone()).unwrap()), key); + } + } + + #[test] + fn reject_truncated_or_unknown_state() { + for bytes in [&[][..], &[0, 1], &[1], &[2]] { + assert!(deser_value(Bytes::copy_from_slice(bytes)).is_err()); + } + } +} diff --git a/lite/src/backend/kv/stream_fencing_token.rs b/lite/src/backend/kv/stream_fencing_token.rs new file mode 100644 index 00000000..66b3d08c --- /dev/null +++ b/lite/src/backend/kv/stream_fencing_token.rs @@ -0,0 +1,71 @@ +use std::str::FromStr; + +use bytes::{BufMut, Bytes, BytesMut}; +use s2_common::record::FencingToken; + +use super::{DeserializationError, KeyType, invalid_value_err}; +use crate::stream_id::StreamId; + +pub fn ser_key(stream_id: StreamId) -> Bytes { + super::ser_stream_id_key(KeyType::StreamFencingToken, stream_id) +} + +pub fn deser_key(bytes: Bytes) -> Result { + super::deser_stream_id_key(KeyType::StreamFencingToken, bytes) +} + +pub fn ser_value(token: &FencingToken) -> Bytes { + let token_bytes = token.as_bytes(); + let capacity = token_bytes.len(); + let mut buf = BytesMut::with_capacity(capacity); + buf.put_slice(token_bytes); + debug_assert_eq!(buf.len(), capacity, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_value(bytes: Bytes) -> Result { + let token_str = + std::str::from_utf8(&bytes).map_err(|e| invalid_value_err("fencing_token", e))?; + FencingToken::from_str(token_str).map_err(|e| invalid_value_err("fencing_token", e)) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use bytes::Bytes; + use proptest::prelude::*; + use s2_common::record::FencingToken; + + use crate::{backend::kv::DeserializationError, stream_id::StreamId}; + + #[test] + fn stream_fencing_token_rejects_invalid_utf8() { + let err = super::deser_value(Bytes::from_static(&[0xFF])).unwrap_err(); + assert!(matches!( + err, + DeserializationError::InvalidValue { + name: "fencing_token", + .. + } + )); + } + + proptest! { + #[test] + fn roundtrip_stream_fencing_token_key(stream_id_bytes in any::<[u8; StreamId::LEN]>()) { + let stream_id = StreamId::from(stream_id_bytes); + let bytes = super::ser_key(stream_id); + let decoded = super::deser_key(bytes).unwrap(); + prop_assert_eq!(stream_id, decoded); + } + + #[test] + fn roundtrip_stream_fencing_token_value(token_str in "[a-zA-Z0-9_-]{0,36}") { + let token = FencingToken::from_str(&token_str).unwrap(); + let bytes = super::ser_value(&token); + let decoded = super::deser_value(bytes).unwrap(); + prop_assert_eq!(token.as_ref(), decoded.as_ref()); + } + } +} diff --git a/lite/src/backend/kv/stream_id_mapping.rs b/lite/src/backend/kv/stream_id_mapping.rs new file mode 100644 index 00000000..3519b793 --- /dev/null +++ b/lite/src/backend/kv/stream_id_mapping.rs @@ -0,0 +1,81 @@ +use std::str::FromStr; + +use bytes::{BufMut, Bytes, BytesMut}; +use s2_common::{ + basin::BasinName, + caps::{MIN_BASIN_NAME_LEN, MIN_STREAM_NAME_LEN}, + stream::StreamName, +}; + +use super::{DeserializationError, KeyType, check_min_size, invalid_value_err}; +use crate::stream_id::StreamId; + +const FIELD_SEPARATOR: u8 = b'\0'; + +pub fn ser_key(stream_id: StreamId) -> Bytes { + super::ser_stream_id_key(KeyType::StreamIdMapping, stream_id) +} + +pub fn deser_key(bytes: Bytes) -> Result { + super::deser_stream_id_key(KeyType::StreamIdMapping, bytes) +} + +pub fn ser_value(basin: &BasinName, stream: &StreamName) -> Bytes { + let basin_bytes = basin.as_bytes(); + let stream_bytes = stream.as_bytes(); + let capacity = basin_bytes.len() + 1 + stream_bytes.len(); + let mut buf = BytesMut::with_capacity(capacity); + buf.put_slice(basin_bytes); + buf.put_u8(FIELD_SEPARATOR); + buf.put_slice(stream_bytes); + debug_assert_eq!(buf.len(), capacity, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_value(bytes: Bytes) -> Result<(BasinName, StreamName), DeserializationError> { + check_min_size(&bytes, MIN_BASIN_NAME_LEN + 1 + MIN_STREAM_NAME_LEN)?; + let sep_pos = bytes + .iter() + .position(|&b| b == FIELD_SEPARATOR) + .ok_or(DeserializationError::MissingFieldSeparator)?; + + let basin_str = + std::str::from_utf8(&bytes[..sep_pos]).map_err(|e| invalid_value_err("basin", e))?; + let stream_str = + std::str::from_utf8(&bytes[sep_pos + 1..]).map_err(|e| invalid_value_err("stream", e))?; + + let basin = BasinName::from_str(basin_str).map_err(|e| invalid_value_err("basin", e))?; + let stream = StreamName::from_str(stream_str).map_err(|e| invalid_value_err("stream", e))?; + + Ok((basin, stream)) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use proptest::prelude::*; + use s2_common::{basin::BasinName, stream::StreamName}; + + use crate::stream_id::StreamId; + + #[test] + fn roundtrip_stream_id_mapping_value() { + let basin = BasinName::from_str("test-basin").unwrap(); + let stream = StreamName::from_str("test-stream").unwrap(); + let bytes = super::ser_value(&basin, &stream); + let (decoded_basin, decoded_stream) = super::deser_value(bytes).unwrap(); + assert_eq!(basin, decoded_basin); + assert_eq!(stream, decoded_stream); + } + + proptest! { + #[test] + fn roundtrip_stream_id_mapping_key(stream_id_bytes in any::<[u8; StreamId::LEN]>()) { + let stream_id = StreamId::from(stream_id_bytes); + let bytes = super::ser_key(stream_id); + let decoded = super::deser_key(bytes).unwrap(); + prop_assert_eq!(stream_id, decoded); + } + } +} diff --git a/lite/src/backend/kv/stream_meta.rs b/lite/src/backend/kv/stream_meta.rs new file mode 100644 index 00000000..f655aaa7 --- /dev/null +++ b/lite/src/backend/kv/stream_meta.rs @@ -0,0 +1,364 @@ +use std::{ops::Range, str::FromStr}; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use s2_common::{ + basin::BasinName, + caps::{MIN_BASIN_NAME_LEN, MIN_STREAM_NAME_LEN}, + config::{OptionalStreamConfig, StreamConfig}, + encryption::EncryptionAlgorithm, + stream::{StreamName, StreamNamePrefix, StreamNameStartAfter}, +}; +use s2_storage::bash::Bash; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +use super::{ + DeserializationError, KeyType, check_min_size, deser_json_value, increment_bytes, + invalid_value_err, ser_json_value, +}; +use crate::backend::resolve_stream_config; + +const FIELD_SEPARATOR: u8 = b'\0'; + +#[derive(Debug, Clone)] +pub struct StreamMeta { + pub config: StreamConfig, + pub cipher: Option, + pub created_at: OffsetDateTime, + pub deleted_at: Option, + pub creation_idempotency_key: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct StreamMetaSerde { + config: Option, + cipher: Option, + #[serde(with = "time::serde::rfc3339")] + created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339::option")] + deleted_at: Option, + creation_idempotency_key: Option, +} + +impl From for StreamMetaSerde { + fn from(meta: StreamMeta) -> Self { + Self { + config: Some(meta.config.into()), + cipher: meta.cipher, + created_at: meta.created_at, + deleted_at: meta.deleted_at, + creation_idempotency_key: meta.creation_idempotency_key, + } + } +} + +impl TryFrom for StreamMeta { + type Error = s2_common::ValidationError; + + fn try_from(serde: StreamMetaSerde) -> Result { + let config = match serde.config { + Some(api_config) => OptionalStreamConfig::try_from(api_config)?, + None => OptionalStreamConfig::default(), + }; + + Ok(Self { + config: resolve_stream_config(config, OptionalStreamConfig::default()), + cipher: serde.cipher, + created_at: serde.created_at, + deleted_at: serde.deleted_at, + creation_idempotency_key: serde.creation_idempotency_key, + }) + } +} + +pub fn ser_key_prefix(basin: &BasinName, prefix: &StreamNamePrefix) -> Bytes { + ser_key_internal(basin.as_bytes(), prefix.as_bytes()).freeze() +} + +pub fn ser_key_prefix_end(basin: &BasinName, prefix: &StreamNamePrefix) -> Bytes { + increment_bytes(ser_key_internal(basin.as_bytes(), prefix.as_bytes())).expect("non-empty") +} + +pub fn ser_key_start_after(basin: &BasinName, start_after: &StreamNameStartAfter) -> Bytes { + let start_after_bytes = start_after.as_bytes(); + let mut bytes = Vec::with_capacity(start_after_bytes.len() + 1); + bytes.extend_from_slice(start_after_bytes); + bytes.push(FIELD_SEPARATOR); + ser_key_internal(basin.as_bytes(), &bytes).freeze() +} + +pub fn ser_key_range( + basin: &BasinName, + prefix: &StreamNamePrefix, + start_after: &StreamNameStartAfter, +) -> Range { + let prefix_start = ser_key_prefix(basin, prefix); + let start = if !start_after.is_empty() { + let start_after_key = ser_key_start_after(basin, start_after); + std::cmp::max(prefix_start, start_after_key) + } else { + prefix_start + }; + let end = ser_key_prefix_end(basin, prefix); + start..end +} + +pub fn ser_key(basin: &BasinName, stream: &StreamName) -> Bytes { + ser_key_internal(basin.as_bytes(), stream.as_bytes()).freeze() +} + +fn ser_key_internal(basin_bytes: &[u8], stream_bytes: &[u8]) -> BytesMut { + let capacity = 1 + basin_bytes.len() + 1 + stream_bytes.len(); + let mut buf = BytesMut::with_capacity(capacity); + buf.put_u8(KeyType::StreamMeta as u8); + buf.put_slice(basin_bytes); + buf.put_u8(FIELD_SEPARATOR); + buf.put_slice(stream_bytes); + debug_assert_eq!(buf.len(), capacity, "serialized length mismatch"); + buf +} + +pub fn deser_key(mut bytes: Bytes) -> Result<(BasinName, StreamName), DeserializationError> { + check_min_size(&bytes, 1 + MIN_BASIN_NAME_LEN + 1 + MIN_STREAM_NAME_LEN)?; + let ordinal = bytes.get_u8(); + if ordinal != (KeyType::StreamMeta as u8) { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let sep_pos = bytes + .iter() + .position(|&b| b == FIELD_SEPARATOR) + .ok_or(DeserializationError::MissingFieldSeparator)?; + + let basin_str = + std::str::from_utf8(&bytes[..sep_pos]).map_err(|e| invalid_value_err("basin", e))?; + let stream_str = + std::str::from_utf8(&bytes[sep_pos + 1..]).map_err(|e| invalid_value_err("stream", e))?; + + let basin = BasinName::from_str(basin_str).map_err(|e| invalid_value_err("basin", e))?; + let stream = StreamName::from_str(stream_str).map_err(|e| invalid_value_err("stream", e))?; + + Ok((basin, stream)) +} + +pub fn ser_value(stream_meta: &StreamMeta) -> Bytes { + ser_json_value::(stream_meta, "StreamMeta") +} + +pub fn deser_value(bytes: Bytes) -> Result { + deser_json_value::(bytes, "stream_meta") +} + +#[cfg(test)] +mod tests { + use std::{str::FromStr, time::Duration}; + + use bytes::Bytes; + use proptest::prelude::*; + use s2_common::{ + basin::BasinName, + config::{OptionalDeleteOnEmptyConfig, OptionalStreamConfig, StreamConfig}, + encryption::EncryptionAlgorithm, + stream::{StreamName, StreamNamePrefix, StreamNameStartAfter}, + }; + use s2_storage::bash::Bash; + use time::OffsetDateTime; + + use crate::backend::kv::proptest_strategies::{basin_name_strategy, stream_name_strategy}; + + #[test] + fn value_roundtrip_stream_meta() { + let config = OptionalStreamConfig { + storage_class: Some("express".into()), + delete_on_empty: OptionalDeleteOnEmptyConfig { + min_age: Some(Duration::ZERO), + }, + ..Default::default() + }; + let created_at = OffsetDateTime::from_unix_timestamp(1234567890) + .unwrap() + .replace_nanosecond(123456789) + .unwrap(); + let deleted_at = Some( + OffsetDateTime::from_unix_timestamp(1234567890) + .unwrap() + .replace_nanosecond(123456789) + .unwrap(), + ); + let stream_meta = super::StreamMeta { + config: config.into(), + cipher: Some(EncryptionAlgorithm::Aegis256), + created_at, + deleted_at, + creation_idempotency_key: Some(Bash::length_prefixed(&[ + b"test-basin", + b"test-stream", + b"request-token-456", + ])), + }; + + let bytes = super::ser_value(&stream_meta); + let decoded = super::deser_value(bytes).unwrap(); + + assert_eq!( + stream_meta.config.storage_class, + decoded.config.storage_class + ); + assert_eq!( + stream_meta.config.delete_on_empty.min_age, + decoded.config.delete_on_empty.min_age + ); + assert_eq!(stream_meta.cipher, decoded.cipher); + assert_eq!(stream_meta.created_at, decoded.created_at); + assert_eq!(stream_meta.deleted_at, decoded.deleted_at); + } + + #[test] + fn stream_meta_deser_defaults_config_missing() { + let serde_value = super::StreamMetaSerde { + config: None, + cipher: Some(EncryptionAlgorithm::Aes256Gcm), + created_at: OffsetDateTime::from_unix_timestamp(2_345_678).unwrap(), + deleted_at: None, + creation_idempotency_key: Some(Bash::length_prefixed(&[ + b"my-basin", + b"my-stream", + b"req-abc", + ])), + }; + let bytes = Bytes::from(serde_json::to_vec(&serde_value).unwrap()); + let decoded = super::deser_value(bytes).unwrap(); + let default_config = StreamConfig::default(); + + assert_eq!(decoded.config.storage_class.as_deref(), Some("express")); + assert_eq!( + decoded.config.retention_policy, + default_config.retention_policy + ); + assert_eq!( + decoded.config.timestamping.mode, + default_config.timestamping.mode + ); + assert_eq!( + decoded.config.timestamping.uncapped, + default_config.timestamping.uncapped + ); + assert_eq!( + decoded.config.delete_on_empty.min_age, + default_config.delete_on_empty.min_age + ); + assert_eq!(decoded.created_at, serde_value.created_at); + assert_eq!(decoded.deleted_at, serde_value.deleted_at); + assert_eq!(decoded.cipher, serde_value.cipher); + } + + fn stream_name_prefix_strategy() -> impl Strategy { + prop_oneof![ + Just(StreamNamePrefix::default()), + "[a-zA-Z0-9_-]{0,100}".prop_map(|s| StreamNamePrefix::from_str(&s).unwrap()), + ] + } + + #[test] + fn stream_meta_range_start_after_before_prefix() { + let basin = BasinName::from_str("my-basin").unwrap(); + let prefix = StreamNamePrefix::from_str("staging-").unwrap(); + let start_after = StreamNameStartAfter::from_str("prod-api").unwrap(); + + let range = super::ser_key_range(&basin, &prefix, &start_after); + + assert!( + range.start < range.end, + "range should be valid when start_after is before prefix range" + ); + + let staging_stream = StreamName::from_str("staging-api").unwrap(); + let staging_key = super::ser_key(&basin, &staging_stream); + assert!( + staging_key >= range.start && staging_key < range.end, + "streams matching prefix should be in range" + ); + + let prod_stream = StreamName::from_str("prod-service").unwrap(); + let prod_key = super::ser_key(&basin, &prod_stream); + assert!( + prod_key < range.start, + "streams before prefix should NOT be in range" + ); + } + + proptest! { + #[test] + fn roundtrip_stream_meta_key( + basin in basin_name_strategy(), + stream in stream_name_strategy(), + ) { + let bytes = super::ser_key(&basin, &stream); + let (decoded_basin, decoded_stream) = super::deser_key(bytes).unwrap(); + prop_assert_eq!(basin.as_ref(), decoded_basin.as_ref()); + prop_assert_eq!(stream.as_ref(), decoded_stream.as_ref()); + } + + #[test] + fn stream_meta_range_contains_prefixed_keys( + basin in basin_name_strategy(), + prefix in stream_name_prefix_strategy(), + stream in stream_name_strategy(), + ) { + let prefix_str = prefix.as_ref(); + let stream_str = stream.as_ref(); + let matches_prefix = prefix_str.is_empty() || stream_str.starts_with(prefix_str); + + let range = super::ser_key_range(&basin, &prefix, &StreamNameStartAfter::default()); + let key = super::ser_key(&basin, &stream); + + if matches_prefix { + prop_assert!(key >= range.start, "key {:?} should be >= range.start {:?}", key, range.start); + prop_assert!(key < range.end, "key {:?} should be < range.end {:?}", key, range.end); + } else { + prop_assert!(key < range.start || key >= range.end); + } + } + + #[test] + fn stream_meta_keys_preserve_ordering( + basin in basin_name_strategy(), + stream1 in stream_name_strategy(), + stream2 in stream_name_strategy(), + ) { + let key1 = super::ser_key(&basin, &stream1); + let key2 = super::ser_key(&basin, &stream2); + + let stream_cmp = stream1.as_ref().cmp(stream2.as_ref()); + let key_cmp = key1.cmp(&key2); + + prop_assert_eq!(stream_cmp, key_cmp, "ordering should be preserved"); + } + + #[test] + fn stream_meta_start_after_excludes_cursor( + basin in basin_name_strategy(), + prefix in stream_name_prefix_strategy(), + stream1 in stream_name_strategy(), + stream2 in stream_name_strategy(), + ) { + if stream1.as_ref() >= stream2.as_ref() { + return Ok(()); + } + + let start_after = StreamNameStartAfter::from(stream1.clone()); + let range = super::ser_key_range(&basin, &prefix, &start_after); + + let key1 = super::ser_key(&basin, &stream1); + let key2 = super::ser_key(&basin, &stream2); + + let prefix_str = prefix.as_ref(); + let stream1_matches = prefix_str.is_empty() || stream1.as_ref().starts_with(prefix_str); + let stream2_matches = prefix_str.is_empty() || stream2.as_ref().starts_with(prefix_str); + + prop_assert!(key1 < range.start, "cursor stream should be excluded (before range.start)"); + if stream2_matches && (stream1_matches || stream2.as_ref() > prefix_str) { + prop_assert!(key2 >= range.start, "later stream matching prefix should be included (at or after range.start)"); + } + } + } +} diff --git a/lite/src/backend/kv/stream_record_data.rs b/lite/src/backend/kv/stream_record_data.rs new file mode 100644 index 00000000..345726bb --- /dev/null +++ b/lite/src/backend/kv/stream_record_data.rs @@ -0,0 +1,141 @@ +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use s2_common::record::{Metered, StreamPosition}; +use s2_storage::record::{StoredRecord, decode_stored_record, encode_stored_record}; + +use super::{DeserializationError, KeyType, check_exact_size, invalid_value_err}; +use crate::stream_id::StreamId; + +const KEY_LEN: usize = 1 + StreamId::LEN + 8 + 8; +const KEY_SUFFIX_LEN: usize = 8 + 8; + +pub fn ser_key_prefix(stream_id: StreamId) -> Bytes { + let mut buf = BytesMut::with_capacity(1 + StreamId::LEN); + buf.put_u8(KeyType::StreamRecordData as u8); + buf.put_slice(stream_id.as_bytes()); + debug_assert_eq!(buf.len(), 1 + StreamId::LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn ser_key_suffix(pos: StreamPosition) -> Bytes { + let mut buf = BytesMut::with_capacity(KEY_SUFFIX_LEN); + buf.put_u64(pos.seq_num); + buf.put_u64(pos.timestamp); + debug_assert_eq!(buf.len(), KEY_SUFFIX_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn ser_key(stream_id: StreamId, pos: StreamPosition) -> Bytes { + let mut buf = BytesMut::with_capacity(KEY_LEN); + buf.put_u8(KeyType::StreamRecordData as u8); + buf.put_slice(stream_id.as_bytes()); + buf.put_u64(pos.seq_num); + buf.put_u64(pos.timestamp); + debug_assert_eq!(buf.len(), KEY_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_key(mut bytes: Bytes) -> Result<(StreamId, StreamPosition), DeserializationError> { + check_exact_size(&bytes, KEY_LEN)?; + let ordinal = bytes.get_u8(); + if ordinal != (KeyType::StreamRecordData as u8) { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let mut stream_id_bytes = [0u8; StreamId::LEN]; + bytes.copy_to_slice(&mut stream_id_bytes); + let seq_num = bytes.get_u64(); + let timestamp = bytes.get_u64(); + Ok(( + stream_id_bytes.into(), + StreamPosition { seq_num, timestamp }, + )) +} + +pub fn ser_value(record: Metered<&StoredRecord>) -> Bytes { + encode_stored_record(record) +} + +pub fn deser_value(bytes: Bytes) -> Result, DeserializationError> { + decode_stored_record(bytes).map_err(|e| invalid_value_err("record", e)) +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use proptest::prelude::*; + use s2_common::record::{Metered, SeqNum, StreamPosition, Timestamp}; + + use crate::{backend::kv::DeserializationError, stream_id::StreamId}; + + #[test] + fn stream_record_data_rejects_invalid_payload() { + let err = super::deser_value(Bytes::from_static(&[0x00])).unwrap_err(); + assert!(matches!( + err, + DeserializationError::InvalidValue { name: "record", .. } + )); + } + + proptest! { + #[test] + fn roundtrip_stream_record_data_key( + stream_id_bytes in any::<[u8; StreamId::LEN]>(), + seq_num in any::(), + timestamp in any::(), + ) { + let stream_id = StreamId::from(stream_id_bytes); + let pos = StreamPosition { seq_num, timestamp }; + let key_bytes = super::ser_key(stream_id, pos); + let (decoded_stream_id, decoded_pos) = super::deser_key(key_bytes).unwrap(); + prop_assert_eq!(stream_id, decoded_stream_id); + prop_assert_eq!(pos, decoded_pos); + } + + #[test] + fn stream_record_data_key_prefix_and_suffix_match_record_keys( + stream_id_bytes in any::<[u8; StreamId::LEN]>(), + seq_num in any::(), + timestamp in any::(), + ) { + let stream_id = StreamId::from(stream_id_bytes); + let pos = StreamPosition { seq_num, timestamp }; + let prefix = super::ser_key_prefix(stream_id); + let suffix = super::ser_key_suffix(pos); + let key = super::ser_key(stream_id, pos); + let expected = [prefix.as_ref(), suffix.as_ref()].concat(); + prop_assert_eq!(key.as_ref(), expected.as_slice()); + } + + #[test] + fn roundtrip_stream_record_data_value( + header_name in prop::collection::vec(any::(), 1..20), + header_value in prop::collection::vec(any::(), 0..50), + body in prop::collection::vec(any::(), 0..200), + ) { + use s2_common::record::{Header, MeteredExt as _, MeteredSize, Record}; + use s2_storage::record::{StoredRecord, decode_record}; + + let header_name = Bytes::from(header_name); + let header_value = Bytes::from(header_value); + let headers = vec![Header { + name: header_name.clone(), + value: header_value.clone(), + }]; + let body = Bytes::from(body); + let expected_headers = headers.clone(); + let expected_body = body.clone(); + let record = Record::try_from_parts(headers.clone(), body).unwrap(); + let metered_record: Metered = record.into(); + let original_size = metered_record.metered_size(); + + let bytes = + super::ser_value(StoredRecord::from(metered_record.into_inner()).metered().as_ref()); + let decoded = super::deser_value(bytes).unwrap(); + let decoded = decode_record(super::ser_value(decoded.as_ref())).unwrap(); + + prop_assert_eq!(original_size, decoded.metered_size()); + let (decoded_headers, decoded_body) = decoded.into_inner().into_parts(); + prop_assert_eq!(decoded_headers, expected_headers); + prop_assert_eq!(decoded_body, expected_body); + } + } +} diff --git a/lite/src/backend/kv/stream_record_timestamp.rs b/lite/src/backend/kv/stream_record_timestamp.rs new file mode 100644 index 00000000..7f7e356f --- /dev/null +++ b/lite/src/backend/kv/stream_record_timestamp.rs @@ -0,0 +1,119 @@ +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use s2_common::record::StreamPosition; + +use super::{DeserializationError, KeyType, check_exact_size}; +use crate::stream_id::StreamId; + +const KEY_PREFIX_LEN: usize = 1 + StreamId::LEN; +const KEY_LEN: usize = 1 + StreamId::LEN + 8 + 8; +const KEY_SUFFIX_LEN: usize = 8 + 8; + +pub fn ser_key_prefix(stream_id: StreamId) -> Bytes { + let mut buf = BytesMut::with_capacity(KEY_PREFIX_LEN); + buf.put_u8(KeyType::StreamRecordTimestamp as u8); + buf.put_slice(stream_id.as_bytes()); + debug_assert_eq!(buf.len(), KEY_PREFIX_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn ser_key(stream_id: StreamId, pos: StreamPosition) -> Bytes { + let mut buf = BytesMut::with_capacity(KEY_LEN); + buf.put_u8(KeyType::StreamRecordTimestamp as u8); + buf.put_slice(stream_id.as_bytes()); + buf.put_u64(pos.timestamp); + buf.put_u64(pos.seq_num); + debug_assert_eq!(buf.len(), KEY_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn ser_key_suffix(pos: StreamPosition) -> Bytes { + let mut buf = BytesMut::with_capacity(KEY_SUFFIX_LEN); + buf.put_u64(pos.timestamp); + buf.put_u64(pos.seq_num); + debug_assert_eq!(buf.len(), KEY_SUFFIX_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_key(mut bytes: Bytes) -> Result<(StreamId, StreamPosition), DeserializationError> { + check_exact_size(&bytes, KEY_LEN)?; + let ordinal = bytes.get_u8(); + if ordinal != (KeyType::StreamRecordTimestamp as u8) { + return Err(DeserializationError::InvalidOrdinal(ordinal)); + } + let mut stream_id_bytes = [0u8; StreamId::LEN]; + bytes.copy_to_slice(&mut stream_id_bytes); + let timestamp = bytes.get_u64(); + let seq_num = bytes.get_u64(); + Ok(( + stream_id_bytes.into(), + StreamPosition { seq_num, timestamp }, + )) +} + +pub fn ser_value() -> Bytes { + Bytes::new() +} + +pub fn deser_value(bytes: Bytes) -> Result<(), DeserializationError> { + check_exact_size(&bytes, 0)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use s2_common::record::{SeqNum, StreamPosition, Timestamp}; + + use crate::stream_id::StreamId; + + #[test] + fn roundtrip_stream_record_timestamp_value() { + let bytes = super::ser_value(); + super::deser_value(bytes).unwrap(); + } + + proptest! { + #[test] + fn roundtrip_stream_record_timestamp_key( + stream_id_bytes in any::<[u8; StreamId::LEN]>(), + timestamp in any::(), + seq_num in any::(), + ) { + let stream_id = StreamId::from(stream_id_bytes); + let key_bytes = super::ser_key(stream_id, StreamPosition { seq_num, timestamp }); + let (decoded_stream_id, decoded_pos) = + super::deser_key(key_bytes).unwrap(); + prop_assert_eq!(stream_id, decoded_stream_id); + prop_assert_eq!(timestamp, decoded_pos.timestamp); + prop_assert_eq!(seq_num, decoded_pos.seq_num); + } + + #[test] + fn stream_record_timestamp_key_prefix_matches_record_keys( + stream_id_bytes in any::<[u8; StreamId::LEN]>(), + timestamp in any::(), + seq_num in any::(), + ) { + let stream_id = StreamId::from(stream_id_bytes); + let prefix = super::ser_key_prefix(stream_id); + let key_bytes = super::ser_key(stream_id, StreamPosition { seq_num, timestamp }); + prop_assert_eq!(prefix.len(), super::KEY_PREFIX_LEN); + prop_assert!(key_bytes.as_ref().starts_with(prefix.as_ref())); + } + + #[test] + fn stream_record_timestamp_key_prefix_and_suffix_match_record_keys( + stream_id_bytes in any::<[u8; StreamId::LEN]>(), + timestamp in any::(), + seq_num in any::(), + ) { + let stream_id = StreamId::from(stream_id_bytes); + let pos = StreamPosition { seq_num, timestamp }; + let prefix = super::ser_key_prefix(stream_id); + let suffix = super::ser_key_suffix(pos); + let key = super::ser_key(stream_id, pos); + let expected = [prefix.as_ref(), suffix.as_ref()].concat(); + prop_assert_eq!(key.as_ref(), expected.as_slice()); + } + } +} diff --git a/lite/src/backend/kv/stream_tail_position.rs b/lite/src/backend/kv/stream_tail_position.rs new file mode 100644 index 00000000..e6d708c1 --- /dev/null +++ b/lite/src/backend/kv/stream_tail_position.rs @@ -0,0 +1,81 @@ +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use s2_common::record::StreamPosition; + +use super::{DeserializationError, KeyType}; +use crate::stream_id::StreamId; + +const VALUE_LEN: usize = 8 + 8; +const LEGACY_VALUE_LEN: usize = 8 + 8 + 4; + +pub fn ser_key(stream_id: StreamId) -> Bytes { + super::ser_stream_id_key(KeyType::StreamTailPosition, stream_id) +} + +pub fn deser_key(bytes: Bytes) -> Result { + super::deser_stream_id_key(KeyType::StreamTailPosition, bytes) +} + +pub fn ser_value(tail: StreamPosition) -> Bytes { + let mut buf = BytesMut::with_capacity(VALUE_LEN); + buf.put_u64(tail.seq_num); + buf.put_u64(tail.timestamp); + debug_assert_eq!(buf.len(), VALUE_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_value(mut bytes: Bytes) -> Result { + match bytes.remaining() { + VALUE_LEN | LEGACY_VALUE_LEN => {} + actual => { + return Err(DeserializationError::InvalidSize { + expected: VALUE_LEN, + actual, + }); + } + } + let seq_num = bytes.get_u64(); + let timestamp = bytes.get_u64(); + Ok(StreamPosition { seq_num, timestamp }) +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use proptest::prelude::*; + use s2_common::record::{SeqNum, Timestamp}; + + use crate::{backend::kv::DeserializationError, stream_id::StreamId}; + + #[test] + fn stream_tail_position_value_rejects_unsupported_size() { + let err = super::deser_value(Bytes::from_static(&[0u8; 15])).unwrap_err(); + assert!(matches!( + err, + DeserializationError::InvalidSize { + expected: super::VALUE_LEN, + .. + } + )); + } + + proptest! { + #[test] + fn roundtrip_stream_tail_position_key(stream_id_bytes in any::<[u8; StreamId::LEN]>()) { + let stream_id = StreamId::from(stream_id_bytes); + let bytes = super::ser_key(stream_id); + let decoded = super::deser_key(bytes).unwrap(); + prop_assert_eq!(stream_id, decoded); + } + + #[test] + fn roundtrip_stream_tail_position_value( + seq_num in any::(), + timestamp in any::(), + ) { + let tail = s2_common::record::StreamPosition { seq_num, timestamp }; + let bytes = super::ser_value(tail); + let decoded = super::deser_value(bytes).unwrap(); + prop_assert_eq!(tail, decoded); + } + } +} diff --git a/lite/src/backend/kv/stream_trim_point.rs b/lite/src/backend/kv/stream_trim_point.rs new file mode 100644 index 00000000..8188ae5e --- /dev/null +++ b/lite/src/backend/kv/stream_trim_point.rs @@ -0,0 +1,56 @@ +use std::ops::RangeTo; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use s2_common::record::NonZeroSeqNum; + +use super::{DeserializationError, KeyType, check_exact_size, invalid_value_err}; +use crate::stream_id::StreamId; + +const VALUE_LEN: usize = 8; + +pub fn ser_key(stream_id: StreamId) -> Bytes { + super::ser_stream_id_key(KeyType::StreamTrimPoint, stream_id) +} + +pub fn deser_key(bytes: Bytes) -> Result { + super::deser_stream_id_key(KeyType::StreamTrimPoint, bytes) +} + +pub fn ser_value(trim_point: RangeTo) -> Bytes { + let mut buf = BytesMut::with_capacity(VALUE_LEN); + buf.put_u64(trim_point.end.get()); + debug_assert_eq!(buf.len(), VALUE_LEN, "serialized length mismatch"); + buf.freeze() +} + +pub fn deser_value(mut bytes: Bytes) -> Result, DeserializationError> { + check_exact_size(&bytes, VALUE_LEN)?; + let seq_num = NonZeroSeqNum::new(bytes.get_u64()) + .ok_or_else(|| invalid_value_err("trim_point", "must be non-zero"))?; + Ok(..seq_num) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use s2_common::record::NonZeroSeqNum; + + use crate::stream_id::StreamId; + + proptest! { + #[test] + fn roundtrip_stream_trim_point_key(stream_id_bytes in any::<[u8; StreamId::LEN]>()) { + let stream_id = StreamId::from(stream_id_bytes); + let bytes = super::ser_key(stream_id); + let decoded = super::deser_key(bytes).unwrap(); + prop_assert_eq!(stream_id, decoded); + } + + #[test] + fn roundtrip_stream_trim_point_value(seq_num in any::()) { + let bytes = super::ser_value(..seq_num); + let decoded = super::deser_value(bytes).unwrap(); + prop_assert_eq!(..seq_num, decoded); + } + } +} diff --git a/lite/src/backend/mod.rs b/lite/src/backend/mod.rs new file mode 100644 index 00000000..99aab0fb --- /dev/null +++ b/lite/src/backend/mod.rs @@ -0,0 +1,44 @@ +use s2_common::{ + config::{OptionalStreamConfig, StreamConfig}, + encryption::EncryptionSpec, +}; + +pub mod error; + +mod basins; +pub mod bgtasks; +mod core; +mod doe; +mod durability_notifier; +mod read; +mod store; +mod streamer; +mod streams; +mod timestamp; + +#[cfg(test)] +mod test_util; + +mod append; +mod kv; + +pub use core::Backend; + +pub use crate::stream_id::StreamId; + +pub struct StreamHandle { + db: slatedb::Db, + client: streamer::GuardedStreamerClient, + encryption: EncryptionSpec, +} + +pub const FOLLOWER_MAX_LAG: usize = 25; + +fn resolve_stream_config( + config: OptionalStreamConfig, + basin_defaults: OptionalStreamConfig, +) -> StreamConfig { + let mut config = config.merge(basin_defaults); + config.storage_class.get_or_insert_with(|| "express".into()); + config +} diff --git a/lite/src/backend/read.rs b/lite/src/backend/read.rs new file mode 100644 index 00000000..3ff6c5bc --- /dev/null +++ b/lite/src/backend/read.rs @@ -0,0 +1,1048 @@ +use std::time::Duration; + +use futures::{Stream, StreamExt as _}; +use s2_common::{ + basin::BasinName, + caps, + config::OptionalStreamConfig, + encryption::{EncryptionKey, EncryptionSpec}, + read_extent::{EvaluatedReadLimit, ReadLimit, ReadUntil}, + record::{Metered, MeteredSize as _, SeqNum, StreamPosition, Timestamp}, + stream::{ReadEnd, ReadPosition, ReadSessionOutput, ReadStart, StreamName}, +}; +use s2_storage::record::{ + StoredReadBatch, StoredReadSessionOutput, StoredSequencedRecord, decrypt_read_session_output, +}; +use slatedb::config::{DurabilityLevel, ScanOptions}; +use tokio::{sync::broadcast, time::Instant}; + +use super::{Backend, StreamHandle, core::AutoCreateOn}; +use crate::{ + backend::{ + error::{ + CheckTailError, ReadError, StorageError, StreamerMissingInActionError, UnwrittenError, + }, + kv, + streamer::GuardedStreamerClient, + }, + stream_id::StreamId, +}; + +impl Backend { + /// Open a stream for a check tail. + pub async fn open_for_check_tail( + &self, + basin: &BasinName, + stream: &StreamName, + ) -> Result { + self.stream_handle_with_auto_create::( + basin, + stream, + AutoCreateOn::Read, + OptionalStreamConfig::default(), + |_| Ok(EncryptionSpec::Plain), + ) + .await + } + + /// Open a stream for a read or read session. + /// + /// `stream_config` is applied if the stream is created on read. Unset fields inherit the + /// basin's default stream configuration. Ignored if the stream already exists. + pub async fn open_for_read( + &self, + basin: &BasinName, + stream: &StreamName, + encryption_key: Option, + stream_config: OptionalStreamConfig, + ) -> Result { + self.stream_handle_with_auto_create::( + basin, + stream, + AutoCreateOn::Read, + stream_config, + |cipher| Ok(EncryptionSpec::resolve(cipher, encryption_key)?), + ) + .await + } +} + +impl StreamHandle { + pub async fn check_tail(self) -> Result { + let tail = self.client.check_tail().await?; + Ok(tail) + } + + pub async fn read( + self, + start: ReadStart, + end: ReadEnd, + ) -> Result> + 'static, ReadError> { + let stream_id = self.client.stream_id(); + let session = read_session(self.db, self.client, start, end).await?; + Ok(async_stream::stream! { + tokio::pin!(session); + while let Some(output) = session.next().await { + let output = match output { + Ok(output) => { + decrypt_read_session_output(output, &self.encryption, stream_id.as_bytes()) + .map_err(ReadError::from) + } + Err(err) => Err(err), + }; + let should_stop = output.is_err(); + yield output; + if should_stop { + break; + } + } + }) + } +} + +async fn read_session( + db: slatedb::Db, + client: GuardedStreamerClient, + start: ReadStart, + end: ReadEnd, +) -> Result> + 'static, ReadError> { + // An exhausted limit completes even when the requested start is unwritten. + if end.limit.remaining(0, 0) == EvaluatedReadLimit::Exhausted { + return Ok(futures::stream::empty().left_stream()); + } + let stream_id = client.stream_id(); + let tail = client.check_tail().await?; + let mut state = ReadSessionState { + start_seq_num: read_start_seq_num(&db, stream_id, start, end, tail).await?, + limit: EvaluatedReadLimit::Remaining(end.limit), + until: end.until, + wait: end.wait, + wait_deadline: None, + tail, + }; + let session = async_stream::try_stream! { + 'session: while let EvaluatedReadLimit::Remaining(limit) = state.limit { + if state.start_seq_num < state.tail.seq_num { + let prefix = kv::stream_record_data::ser_key_prefix(stream_id); + let start_suffix = kv::stream_record_data::ser_key_suffix(StreamPosition { + seq_num: state.start_seq_num, + timestamp: 0, + }); + let end_suffix = kv::stream_record_data::ser_key_suffix(StreamPosition { + seq_num: state.tail.seq_num, + timestamp: 0, + }); + let scan_opts = ScanOptions { + durability_filter: DurabilityLevel::Remote, + read_ahead_bytes: 1024 * 1024, + cache_blocks: true, + max_fetch_tasks: 8, + ..Default::default() + }; + let mut it = db + .scan_prefix_with_options(prefix, start_suffix..end_suffix, &scan_opts) + .await?; + + let mut records = Metered::with_capacity( + limit.count() + .unwrap_or(usize::MAX) + .min(caps::RECORD_BATCH_MAX.count), + ); + + while let EvaluatedReadLimit::Remaining(limit) = state.limit { + let Some(kv) = it.next().await? else { + break; + }; + let (deser_stream_id, pos) = kv::stream_record_data::deser_key(kv.key)?; + assert_eq!(deser_stream_id, stream_id); + + let record = kv::stream_record_data::deser_value(kv.value)?.sequenced(pos); + + if end.until.deny(pos.timestamp) + || limit.deny(records.len() + 1, records.metered_size() + record.metered_size()) + { + if records.is_empty() { + break 'session; + } else { + break; + } + } + + if records.len() == caps::RECORD_BATCH_MAX.count + || records.metered_size() + record.metered_size() > caps::RECORD_BATCH_MAX.bytes + { + let new_records_buf = Metered::with_capacity( + limit.count() + .map_or(usize::MAX, |n| n.saturating_sub(records.len())) + .min(caps::RECORD_BATCH_MAX.count), + ); + yield state.on_batch(StoredReadBatch { + records: std::mem::replace(&mut records, new_records_buf), + tail: None, + }); + } + + records.push(record); + } + + if !records.is_empty() { + yield state.on_batch(StoredReadBatch { + records, + tail: None, + }); + } else { + state.start_seq_num = state.tail.seq_num; + } + } else { + assert_eq!(state.start_seq_num, state.tail.seq_num); + if !end.may_follow() { + break; + } + match client.follow(state.start_seq_num).await? { + Ok(mut follow_rx) => { + // Only a delivered batch should reset the absolute wait budget. + state.arm_wait_deadline_if_unset(); + if state.wait_deadline_expired() { + break; + } + yield StoredReadSessionOutput::Heartbeat(state.tail); + while let EvaluatedReadLimit::Remaining(limit) = state.limit { + tokio::select! { + biased; + msg = follow_rx.recv() => { + match msg { + Ok(mut records) => { + let count = records.len(); + let tail = super::streamer::next_pos(&records); + let allowed_count = count_allowed_records(limit, end.until, &records); + if allowed_count > 0 { + yield state.on_batch(StoredReadBatch { + records: records.drain(..allowed_count).collect(), + tail: Some(tail), + }); + } + if allowed_count < count { + break 'session; + } + Ok(()) + } + Err(broadcast::error::RecvError::Lagged(_)) => { + // Catch up using DB + continue 'session; + } + Err(broadcast::error::RecvError::Closed) => { + Err(StreamerMissingInActionError) + } + } + } + _ = new_heartbeat_sleep() => { + yield StoredReadSessionOutput::Heartbeat(state.tail); + Ok(()) + } + _ = wait_sleep_until(state.wait_deadline) => { + break 'session; + } + }?; + } + } + Err(tail) => { + assert!(state.tail.seq_num < tail.seq_num, "tail cannot regress"); + state.tail = tail; + } + } + } + } + }; + Ok(session.right_stream()) +} + +async fn read_start_seq_num( + db: &slatedb::Db, + stream_id: StreamId, + start: ReadStart, + end: ReadEnd, + tail: StreamPosition, +) -> Result { + let mut read_pos = match start.from { + s2_common::stream::ReadFrom::SeqNum(seq_num) => ReadPosition::SeqNum(seq_num), + s2_common::stream::ReadFrom::Timestamp(timestamp) => ReadPosition::Timestamp(timestamp), + s2_common::stream::ReadFrom::TailOffset(tail_offset) => { + ReadPosition::SeqNum(tail.seq_num.saturating_sub(tail_offset)) + } + }; + if match read_pos { + ReadPosition::SeqNum(start_seq_num) => start_seq_num > tail.seq_num, + ReadPosition::Timestamp(start_timestamp) => start_timestamp > tail.timestamp, + } { + if start.clamp { + read_pos = ReadPosition::SeqNum(tail.seq_num); + } else { + return Err(UnwrittenError(tail).into()); + } + } + // Resolve to a sequence number before deciding whether the read starts at the tail, so a + // timestamp start that resolves to the tail is treated like a sequence number start there. + let start_seq_num = match read_pos { + ReadPosition::SeqNum(start_seq_num) => start_seq_num, + ReadPosition::Timestamp(start_timestamp) => { + resolve_timestamp(db, stream_id, start_timestamp) + .await? + .unwrap_or(tail) + .seq_num + } + }; + if start_seq_num == tail.seq_num && !end.may_follow() { + return Err(UnwrittenError(tail).into()); + } + Ok(start_seq_num) +} + +async fn resolve_timestamp( + db: &slatedb::Db, + stream_id: StreamId, + timestamp: Timestamp, +) -> Result, StorageError> { + let prefix = kv::stream_record_timestamp::ser_key_prefix(stream_id); + let start_suffix = kv::stream_record_timestamp::ser_key_suffix(StreamPosition { + seq_num: SeqNum::MIN, + timestamp, + }); + let scan_opts = ScanOptions { + durability_filter: DurabilityLevel::Remote, + ..Default::default() + }; + let mut it = db + .scan_prefix_with_options(prefix, start_suffix.., &scan_opts) + .await?; + Ok(match it.next().await? { + Some(kv) => { + let (deser_stream_id, pos) = kv::stream_record_timestamp::deser_key(kv.key)?; + assert_eq!(deser_stream_id, stream_id); + assert!(pos.timestamp >= timestamp); + kv::stream_record_timestamp::deser_value(kv.value)?; + Some(StreamPosition { + seq_num: pos.seq_num, + timestamp: pos.timestamp, + }) + } + None => None, + }) +} + +struct ReadSessionState { + start_seq_num: u64, + limit: EvaluatedReadLimit, + until: ReadUntil, + wait: Option, + wait_deadline: Option, + tail: StreamPosition, +} + +impl ReadSessionState { + fn arm_wait_deadline_if_unset(&mut self) { + if self.wait_deadline.is_none() { + self.reset_wait_deadline(); + } + } + + fn reset_wait_deadline(&mut self) { + self.wait_deadline = self.wait.map(|wait| Instant::now() + wait); + } + + fn wait_deadline_expired(&self) -> bool { + self.wait_deadline + .is_some_and(|deadline| deadline <= Instant::now()) + } + + fn on_batch(&mut self, batch: StoredReadBatch) -> StoredReadSessionOutput { + if let Some(tail) = batch.tail { + self.tail = tail; + } + let last_record = batch.records.last().expect("non-empty"); + let EvaluatedReadLimit::Remaining(limit) = self.limit else { + panic!("batch after exhausted limit"); + }; + let count = batch.records.len(); + let bytes = batch.records.metered_size(); + let last_position = last_record.position(); + assert!(limit.allow(count, bytes)); + assert!(self.until.allow(last_position.timestamp)); + self.start_seq_num = last_position.seq_num + 1; + self.limit = limit.remaining(count, bytes); + self.reset_wait_deadline(); + StoredReadSessionOutput::Batch(batch) + } +} + +fn count_allowed_records( + limit: ReadLimit, + until: ReadUntil, + records: &[Metered], +) -> usize { + let mut acc_size = 0; + let mut acc_count = 0; + for record in records { + if limit.deny(acc_count + 1, acc_size + record.metered_size()) + || until.deny(record.position().timestamp) + { + break; + } + acc_count += 1; + acc_size += record.metered_size(); + } + acc_count +} + +#[cfg(not(test))] +fn new_heartbeat_sleep() -> tokio::time::Sleep { + tokio::time::sleep(Duration::from_millis(rand::random_range(5_000..15_000))) +} + +#[cfg(test)] +fn new_heartbeat_sleep() -> tokio::time::Sleep { + tokio::time::sleep(Duration::from_millis(rand::random_range(5..15))) +} + +async fn wait_sleep_until(deadline: Option) { + match deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => { + std::future::pending::<()>().await; + } + } +} + +#[cfg(test)] +mod tests { + use std::{sync::Arc, task::Poll}; + + use bytesize::ByteSize; + use futures::StreamExt; + use s2_common::{ + basin::BasinName, + config::{BasinConfig, OptionalStreamConfig}, + read_extent::{ReadLimit, ReadUntil}, + record::{Metered, Record}, + resources::ProvisionMode, + stream::{ + AppendInput, AppendRecord, AppendRecordBatch, AppendRecordParts, ReadEnd, ReadFrom, + ReadSessionOutput, ReadStart, StreamName, + }, + }; + use slatedb::{Db, WriteBatch, object_store::memory::InMemory}; + use tokio::time::Instant; + + use super::*; + use crate::{ + backend::{ + FOLLOWER_MAX_LAG, kv, streamer::DORMANT_TIMEOUT, test_util::DbWriteTestExt as _, + }, + stream_id::StreamId, + }; + + fn append_input(record: Record) -> AppendInput { + let record: AppendRecord = AppendRecordParts { + timestamp: None, + record: Metered::from(record), + } + .try_into() + .unwrap(); + let records: AppendRecordBatch = vec![record].try_into().unwrap(); + AppendInput { + records, + match_seq_num: None, + fencing_token: None, + } + } + + fn map_test_output( + output: Option>, + ) -> Option { + match output { + Some(Ok(output)) => Some(output), + Some(Err(e)) => panic!("Read error: {e:?}"), + None => None, + } + } + + async fn poll_next_after_advance( + session: &mut std::pin::Pin>, + advance_by: Duration, + ) -> Poll> + where + S: futures::Stream>, + { + let mut pinned_session = session.as_mut(); + let next = pinned_session.next(); + tokio::pin!(next); + + assert!( + matches!(futures::poll!(&mut next), Poll::Pending), + "session unexpectedly yielded before time advanced" + ); + + tokio::time::advance(advance_by).await; + tokio::task::yield_now().await; + + match futures::poll!(&mut next) { + Poll::Ready(output) => Poll::Ready(map_test_output(output)), + Poll::Pending => Poll::Pending, + } + } + + #[tokio::test] + async fn resolve_timestamp_bounded_to_stream() { + let object_store = Arc::new(InMemory::new()); + let db = Db::builder("/test", object_store).build().await.unwrap(); + let backend = Backend::new(db, ByteSize::mib(10)); + + let stream_a: StreamId = [0u8; 32].into(); + let stream_b: StreamId = [1u8; 32].into(); + + backend + .db + .put( + kv::stream_record_timestamp::ser_key( + stream_a, + StreamPosition { + seq_num: 0, + timestamp: 1000, + }, + ), + kv::stream_record_timestamp::ser_value(), + ) + .assert_durable() + .await; + backend + .db + .put( + kv::stream_record_timestamp::ser_key( + stream_b, + StreamPosition { + seq_num: 0, + timestamp: 2000, + }, + ), + kv::stream_record_timestamp::ser_value(), + ) + .assert_durable() + .await; + + // Should find record in stream_a + let result = resolve_timestamp(&backend.db, stream_a, 500).await.unwrap(); + assert_eq!( + result, + Some(StreamPosition { + seq_num: 0, + timestamp: 1000 + }) + ); + + // Should return None, not find stream_b's record + let result = resolve_timestamp(&backend.db, stream_a, 1500) + .await + .unwrap(); + assert_eq!(result, None); + } + + #[tokio::test] + async fn read_completes_when_all_records_deleted() { + let object_store = Arc::new(InMemory::new()); + let db = Db::builder("/test", object_store).build().await.unwrap(); + let backend = Backend::new(db, ByteSize::mib(10)); + + let basin: BasinName = "test-basin".parse().unwrap(); + backend + .provision_basin( + basin.clone(), + BasinConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .unwrap(); + let stream: StreamName = "test-stream".parse().unwrap(); + backend + .provision_stream( + basin.clone(), + stream.clone(), + OptionalStreamConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .unwrap(); + + let input = append_input(Record::try_from_parts(vec![], bytes::Bytes::from("x")).unwrap()); + let ack = backend + .open_for_append(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap() + .append(input) + .await + .unwrap(); + assert!(ack.end.seq_num > 0); + + let stream_id = StreamId::new(&basin, &stream); + let mut batch = WriteBatch::new(); + batch.delete(kv::stream_record_data::ser_key(stream_id, ack.start)); + backend.db.write(batch).assert_durable().await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Count(10), + until: ReadUntil::Unbounded, + wait: None, + }; + let session = backend + .open_for_read(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap() + .read(start, end) + .await + .unwrap(); + let records: Vec<_> = tokio::time::timeout( + Duration::from_secs(2), + futures::StreamExt::collect::>(session), + ) + .await + .expect("read should not spin forever"); + assert!(records.into_iter().all(|r| r.is_ok())); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn read_wait_is_not_extended_by_heartbeats() { + let object_store = Arc::new(InMemory::new()); + let db = Db::builder("/test", object_store).build().await.unwrap(); + let backend = Backend::new(db, ByteSize::mib(10)); + + let basin: BasinName = "test-basin".parse().unwrap(); + backend + .provision_basin( + basin.clone(), + BasinConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .unwrap(); + let stream: StreamName = "test-stream".parse().unwrap(); + backend + .provision_stream( + basin.clone(), + stream.clone(), + OptionalStreamConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .unwrap(); + + let wait = Duration::from_millis(30); + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(wait), + }; + + let session = backend + .open_for_read(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap() + .read(start, end) + .await + .unwrap(); + let mut session = Box::pin(session); + let probe_step = Duration::from_millis(1); + let first = session + .as_mut() + .next() + .await + .expect("session should enter follow mode") + .expect("session should not error"); + assert!(matches!(first, ReadSessionOutput::Heartbeat(_))); + + let started = Instant::now(); + let second = match poll_next_after_advance(&mut session, wait).await { + Poll::Ready(Some(output)) => output, + Poll::Ready(None) => panic!("session closed before emitting a follow heartbeat"), + Poll::Pending => panic!("expected a follow heartbeat before the wait budget expired"), + }; + assert!(matches!(second, ReadSessionOutput::Heartbeat(_))); + + tokio::task::yield_now().await; + let closed_at = loop { + match futures::poll!(session.as_mut().next()) { + Poll::Ready(Some(Ok(ReadSessionOutput::Heartbeat(_)))) => {} + Poll::Ready(Some(Ok(output))) => { + panic!("unexpected output after wait deadline: {output:?}"); + } + Poll::Ready(Some(Err(e))) => panic!("Read error: {e:?}"), + Poll::Ready(None) => break Instant::now(), + Poll::Pending => panic!("session should close once the wait budget expires"), + } + }; + + assert!(closed_at >= started + wait); + assert!(closed_at <= started + wait + probe_step); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn read_wait_is_reset_by_delivered_follow_batch() { + let object_store = Arc::new(InMemory::new()); + let db = Db::builder("/test", object_store).build().await.unwrap(); + let backend = Backend::new(db, ByteSize::mib(10)); + + let basin: BasinName = "test-basin".parse().unwrap(); + backend + .provision_basin( + basin.clone(), + BasinConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .unwrap(); + let stream: StreamName = "test-stream".parse().unwrap(); + backend + .provision_stream( + basin.clone(), + stream.clone(), + OptionalStreamConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .unwrap(); + + let initial_input = + append_input(Record::try_from_parts(vec![], bytes::Bytes::from("initial")).unwrap()); + backend + .open_for_append(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap() + .append(initial_input) + .await + .unwrap(); + + let wait = Duration::from_millis(30); + let probe_step = Duration::from_millis(1); + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(wait), + }; + + let session = backend + .open_for_read(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap() + .read(start, end) + .await + .unwrap(); + let mut session = Box::pin(session); + + let first = session + .as_mut() + .next() + .await + .expect("session should yield the initial batch") + .expect("session should not error"); + let ReadSessionOutput::Batch(batch) = first else { + panic!("expected initial batch"); + }; + let initial_record = batch + .records + .first() + .expect("batch should contain one record"); + let Record::Envelope(initial_envelope) = initial_record.inner() else { + panic!("expected plaintext envelope record"); + }; + assert_eq!(initial_envelope.body().as_ref(), b"initial"); + + let second = session + .as_mut() + .next() + .await + .expect("session should enter follow mode") + .expect("session should not error"); + assert!(matches!(second, ReadSessionOutput::Heartbeat(_))); + + tokio::time::advance(Duration::from_millis(20)).await; + tokio::task::yield_now().await; + + let follow_input = + append_input(Record::try_from_parts(vec![], bytes::Bytes::from("follow-1")).unwrap()); + backend + .open_for_append(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap() + .append(follow_input) + .await + .unwrap(); + + let follow = session + .as_mut() + .next() + .await + .expect("session should deliver the live batch") + .expect("session should not error"); + let reset_at = Instant::now(); + let ReadSessionOutput::Batch(batch) = follow else { + panic!("expected live batch after append"); + }; + let follow_record = batch + .records + .first() + .expect("batch should contain one record"); + let Record::Envelope(follow_envelope) = follow_record.inner() else { + panic!("expected plaintext envelope record"); + }; + assert_eq!(follow_envelope.body().as_ref(), b"follow-1"); + + tokio::time::advance(wait - probe_step).await; + tokio::task::yield_now().await; + + loop { + match futures::poll!(session.as_mut().next()) { + Poll::Ready(Some(Ok(ReadSessionOutput::Heartbeat(_)))) => {} + Poll::Ready(Some(Ok(output))) => { + panic!("unexpected output before the reset wait deadline: {output:?}"); + } + Poll::Ready(Some(Err(e))) => panic!("Read error: {e:?}"), + Poll::Ready(None) => { + panic!("session closed before the reset wait budget expired"); + } + Poll::Pending => break, + } + } + + tokio::time::advance(probe_step).await; + tokio::task::yield_now().await; + + let closed_at = loop { + match futures::poll!(session.as_mut().next()) { + Poll::Ready(Some(Ok(ReadSessionOutput::Heartbeat(_)))) => {} + Poll::Ready(Some(Ok(output))) => { + panic!("unexpected output after the reset wait deadline: {output:?}"); + } + Poll::Ready(Some(Err(e))) => panic!("Read error: {e:?}"), + Poll::Ready(None) => break Instant::now(), + Poll::Pending => { + panic!("session should close once the reset wait budget expires"); + } + } + }; + + assert!(closed_at >= reset_at + wait); + assert!(closed_at <= reset_at + wait + probe_step); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn read_wait_is_not_reset_after_follow_lag_without_catchup_records() { + let object_store = Arc::new(InMemory::new()); + let db = Db::builder("/test", object_store).build().await.unwrap(); + let backend = Backend::new(db, ByteSize::mib(10)); + + let basin: BasinName = "test-basin".parse().unwrap(); + backend + .provision_basin( + basin.clone(), + BasinConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .unwrap(); + let stream: StreamName = "test-stream".parse().unwrap(); + backend + .provision_stream( + basin.clone(), + stream.clone(), + OptionalStreamConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .unwrap(); + + let wait = Duration::from_secs(30); + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(wait), + }; + let session = backend + .open_for_read(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap() + .read(start, end) + .await + .unwrap(); + let mut session = Box::pin(session); + + let first = session + .as_mut() + .next() + .await + .expect("session should enter follow mode") + .expect("session should not error"); + assert!(matches!(first, ReadSessionOutput::Heartbeat(_))); + + let stream_id = StreamId::new(&basin, &stream); + let mut delete_batch = WriteBatch::new(); + let lagged_appends = FOLLOWER_MAX_LAG + 25; + + for i in 0..lagged_appends { + let input = append_input( + Record::try_from_parts(vec![], bytes::Bytes::from(format!("lagged-{i}"))).unwrap(), + ); + let ack = backend + .open_for_append(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap() + .append(input) + .await + .unwrap(); + delete_batch.delete(kv::stream_record_data::ser_key(stream_id, ack.start)); + } + + backend.db.write(delete_batch).assert_durable().await; + + tokio::time::advance(wait + Duration::from_secs(1)).await; + tokio::task::yield_now().await; + + let next = session.as_mut().next().await; + assert!( + next.is_none(), + "session should close immediately once the original wait budget has elapsed" + ); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn unbounded_follow_survives_streamer_dormancy() { + let object_store = Arc::new(InMemory::new()); + let db = Db::builder("/test", object_store).build().await.unwrap(); + let backend = Backend::new(db, ByteSize::mib(10)); + + let basin: BasinName = "test-basin".parse().unwrap(); + backend + .provision_basin( + basin.clone(), + BasinConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .unwrap(); + let stream: StreamName = "test-stream".parse().unwrap(); + backend + .provision_stream( + basin.clone(), + stream.clone(), + OptionalStreamConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .unwrap(); + + let initial_input = + append_input(Record::try_from_parts(vec![], bytes::Bytes::from("initial")).unwrap()); + backend + .open_for_append(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap() + .append(initial_input) + .await + .unwrap(); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: None, + }; + let session = backend + .open_for_read(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap() + .read(start, end) + .await + .unwrap(); + let mut session = Box::pin(session); + + let first = session + .as_mut() + .next() + .await + .expect("session should yield initial batch") + .expect("session should not error"); + assert!(matches!(first, ReadSessionOutput::Batch(_))); + + let second = session + .as_mut() + .next() + .await + .expect("session should enter follow mode") + .expect("session should not error"); + assert!(matches!(second, ReadSessionOutput::Heartbeat(_))); + + tokio::time::advance(DORMANT_TIMEOUT + Duration::from_secs(1)).await; + tokio::task::yield_now().await; + + let follow_input = + append_input(Record::try_from_parts(vec![], bytes::Bytes::from("follow-1")).unwrap()); + backend + .open_for_append(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap() + .append(follow_input) + .await + .unwrap(); + + let next = session + .as_mut() + .next() + .await + .expect("session should stay open after dormancy") + .expect("session should not error after dormancy"); + let ReadSessionOutput::Batch(batch) = next else { + panic!("expected new batch after append"); + }; + assert_eq!(batch.records.len(), 1); + let record = batch.records.first().expect("batch should have one record"); + let Record::Envelope(envelope) = record.inner() else { + panic!("expected envelope record"); + }; + assert_eq!(envelope.body().as_ref(), b"follow-1"); + } +} diff --git a/lite/src/backend/store.rs b/lite/src/backend/store.rs new file mode 100644 index 00000000..6b26bcdb --- /dev/null +++ b/lite/src/backend/store.rs @@ -0,0 +1,82 @@ +use bytes::Bytes; +use slatedb::{ + DbSnapshot, DbTransaction, KeyValue, + config::{DurabilityLevel, ReadOptions}, +}; + +use super::Backend; +use crate::backend::{error::StorageError, kv}; + +impl Backend { + pub fn db_status(&self) -> Result<(), slatedb::CloseReason> { + match self.db.status().close_reason { + None => Ok(()), + Some(reason) => Err(reason), + } + } + + pub(super) async fn db_get + Send, V>( + &self, + key: K, + deser: impl FnOnce(Bytes) -> Result, + ) -> Result, StorageError> { + self.db_get_with(key, |entry| deser(entry.value)).await + } + + /// Read a remotely durable row, including its sequence and timestamps. + pub(super) async fn db_get_with + Send, V>( + &self, + key: K, + deser: impl FnOnce(KeyValue) -> Result, + ) -> Result, StorageError> { + let read_opts = ReadOptions { + durability_filter: DurabilityLevel::Remote, + ..Default::default() + }; + let value = self + .db + .get_key_value_with_options(key, &read_opts) + .await? + .map(deser) + .transpose()?; + Ok(value) + } +} + +pub(super) async fn db_snapshot_get_with + Send, V>( + snapshot: &DbSnapshot, + key: K, + deser: impl FnOnce(KeyValue) -> Result, +) -> Result, StorageError> { + Ok(snapshot.get_key_value(key).await?.map(deser).transpose()?) +} + +pub(super) async fn db_txn_get + Send, V>( + txn: &DbTransaction, + key: K, + deser: impl FnOnce(Bytes) -> Result, +) -> Result, StorageError> { + db_txn_get_with(txn, key, |entry| deser(entry.value)).await +} + +/// Read a transaction row, which may not yet be durable. +pub(super) async fn db_txn_get_with + Send, V>( + txn: &DbTransaction, + key: K, + deser: impl FnOnce(KeyValue) -> Result, +) -> Result, StorageError> { + let value = txn.get_key_value(key).await?.map(deser).transpose()?; + Ok(value) +} + +/// Commit metadata changes and wait until remote reads can observe them. +/// Return the committed sequence number, or `None` if there were no writes. +pub(super) async fn db_txn_commit_durable( + txn: DbTransaction, +) -> Result, slatedb::Error> { + let Some(handle) = txn.commit().await? else { + return Ok(None); + }; + handle.await_durable().await?; + Ok(Some(handle.seqnum())) +} diff --git a/lite/src/backend/streamer.rs b/lite/src/backend/streamer.rs new file mode 100644 index 00000000..1dd567e8 --- /dev/null +++ b/lite/src/backend/streamer.rs @@ -0,0 +1,2093 @@ +use std::{ + collections::VecDeque, + ops::{Range, RangeTo}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use futures::{ + FutureExt as _, + future::{BoxFuture, OptionFuture}, +}; +use parking_lot::Mutex; +use s2_common::{ + config::{RetentionPolicy, StreamConfig, TimestampingConfig, TimestampingMode}, + encryption::EncryptionAlgorithm, + record::{ + CommandRecord, FencingToken, Metered, MeteredExt as _, MeteredSize, NonZeroSeqNum, Record, + SeqNum, StreamPosition, Timestamp, + }, + stream::AppendAck, +}; +use s2_storage::record::{ + StoredAppendInput, StoredAppendRecord, StoredAppendRecordBatch, StoredAppendRecordParts, + StoredRecord, StoredSequencedRecord, +}; +use slatedb::{ + IterationOrder, WriteBatch, + config::{PutOptions, ScanOptions, Ttl}, +}; +use tokio::{ + sync::{Semaphore, SemaphorePermit, broadcast, mpsc, oneshot}, + time::Instant, +}; +use tracing::debug; + +use crate::{ + backend::{ + append, + bgtasks::BgtaskTrigger, + doe, + durability_notifier::DurabilityNotifier, + error::{ + AppendConditionFailedError, AppendErrorInternal, AppendTimestampRequiredError, + DeleteStreamError, MaxSeqNumError, RequestDroppedError, StorageError, + StreamerMissingInActionError, + }, + kv, + timestamp::TimestampSecs, + }, + metrics, + stream_id::StreamId, +}; + +pub(super) const DORMANT_TIMEOUT: Duration = Duration::from_secs(60); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct StreamerGenerationId(u64); + +impl StreamerGenerationId { + pub(super) fn next() -> Self { + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + Self(NEXT_ID.fetch_add(1, Ordering::Relaxed)) + } +} + +#[derive(Debug)] +struct InFlightAppend { + db_seq: u64, + records: Vec>, +} + +struct DbSubmitAppendOptions { + retention: RetentionPolicy, + fencing_token: Option, + trim_point: Option>, +} + +#[derive(Debug, Default)] +struct LeaseState { + active: usize, + closed: bool, +} + +#[derive(Debug)] +struct StreamerLeaseState { + state: Arc>, +} + +impl StreamerLeaseState { + fn new() -> (Self, StreamerClientLeaseState) { + let state = Arc::new(Mutex::new(LeaseState::default())); + ( + Self { + state: state.clone(), + }, + StreamerClientLeaseState { state }, + ) + } + + fn close_if_idle(&self) -> bool { + let mut state = self.state.lock(); + if state.closed { + return true; + } + if state.active == 0 { + state.closed = true; + true + } else { + false + } + } +} + +impl Drop for StreamerLeaseState { + fn drop(&mut self) { + self.state.lock().closed = true; + } +} + +#[derive(Debug, Clone)] +struct StreamerClientLeaseState { + state: Arc>, +} + +pub(super) struct StreamerClientLeaseGuard { + state: Arc>, +} + +impl Drop for StreamerClientLeaseGuard { + fn drop(&mut self) { + let mut state = self.state.lock(); + assert!(state.active > 0, "lease count underflow"); + state.active -= 1; + } +} + +impl StreamerClientLeaseState { + fn try_acquire(&self) -> Result { + { + let mut state = self.state.lock(); + if state.closed { + return Err(StreamerMissingInActionError); + } + state.active += 1; + } + Ok(StreamerClientLeaseGuard { + state: self.state.clone(), + }) + } + + fn is_closed(&self) -> bool { + self.state.lock().closed + } +} + +pub(super) struct GuardedStreamerClient { + client: StreamerClient, + _guard: StreamerClientLeaseGuard, +} + +impl GuardedStreamerClient { + pub(super) fn stream_id(&self) -> StreamId { + self.client.stream_id + } + + pub(super) fn cipher(&self) -> Option { + self.client.cipher + } + + pub(super) async fn check_tail(&self) -> Result { + self.client.check_tail().await + } + + pub(super) async fn follow( + &self, + start_seq_num: SeqNum, + ) -> Result< + Result>>, StreamPosition>, + StreamerMissingInActionError, + > { + self.client.follow(start_seq_num).await + } + + pub(super) async fn append_permit( + &self, + input: StoredAppendInput, + ) -> Result, StreamerMissingInActionError> { + self.client.append_permit(input).await + } + + pub(super) async fn terminal_trim( + &self, + condition: TerminalTrimCondition, + ) -> Result { + self.client.terminal_trim(condition).await + } +} + +pub(super) struct Spawner { + pub generation_id: StreamerGenerationId, + pub db: slatedb::Db, + pub stream_id: StreamId, + /// Database commit sequence that created the stream's ID mapping. + /// Stable across streamer restarts; changes when the stream is recreated. + pub stream_creation_seq: u64, + pub config: StreamConfig, + pub config_seq: u64, + pub cipher: Option, + pub tail_pos: StreamPosition, + pub last_tail_write_timestamp: TimestampSecs, + pub fencing_token: FencingToken, + pub trim_point: RangeTo, + pub append_inflight_bytes_sema: Arc, + pub durability_notifier: DurabilityNotifier, + pub bgtask_trigger_tx: broadcast::Sender, +} + +impl Spawner { + pub fn spawn( + self, + on_exit: impl FnOnce(StreamerGenerationId) + Send + 'static, + ) -> StreamerClient { + let Self { + generation_id, + db, + stream_id, + stream_creation_seq, + config, + config_seq, + cipher, + tail_pos, + last_tail_write_timestamp, + fencing_token, + trim_point, + append_inflight_bytes_sema, + durability_notifier, + bgtask_trigger_tx, + } = self; + + let (msg_tx, msg_rx) = mpsc::unbounded_channel(); + let (streamer_lease_state, client_lease_state) = StreamerLeaseState::new(); + let streamer = Streamer { + db, + stream_id, + stream_creation_seq, + msg_tx: msg_tx.clone(), + config, + config_seq, + last_tail_write_timestamp, + fencing_token: CommandState { + state: fencing_token, + applied_point: ..tail_pos.seq_num, + }, + trim_point: CommandState { + state: trim_point, + applied_point: ..tail_pos.seq_num, + }, + db_writes_pending: VecDeque::new(), + db_durability_subscription: 0, + inflight_appends: VecDeque::new(), + pending_appends: append::PendingAppends::new(), + stable_pos: tail_pos, + follow_tx: broadcast::Sender::new(super::FOLLOWER_MAX_LAG), + lease_state: streamer_lease_state, + durability_notifier, + bgtask_trigger_tx, + }; + + tokio::spawn(async move { + streamer.run(msg_rx).await; + on_exit(generation_id); + }); + + StreamerClient { + generation_id, + stream_id, + cipher, + msg_tx, + append_inflight_bytes: append_inflight_bytes_sema, + lease_state: client_lease_state, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AppendType { + Regular, + Terminal, +} + +#[derive(Debug, Clone)] +struct CommandState { + applied_point: RangeTo, + state: T, +} + +impl CommandState { + fn is_applied_in(&self, seq_num_range: &Range) -> bool { + seq_num_range.start < self.applied_point.end && self.applied_point.end <= seq_num_range.end + } +} + +struct Streamer { + db: slatedb::Db, + stream_id: StreamId, + stream_creation_seq: u64, + msg_tx: mpsc::UnboundedSender, + config: StreamConfig, + config_seq: u64, + last_tail_write_timestamp: TimestampSecs, + fencing_token: CommandState, + trim_point: CommandState>, + db_writes_pending: VecDeque>>, + db_durability_subscription: u64, + inflight_appends: VecDeque, + pending_appends: append::PendingAppends, + stable_pos: StreamPosition, + follow_tx: broadcast::Sender>>, + lease_state: StreamerLeaseState, + durability_notifier: DurabilityNotifier, + bgtask_trigger_tx: broadcast::Sender, +} + +impl Streamer { + fn next_assignable_pos(&self) -> StreamPosition { + self.pending_appends + .next_ack_pos() + .unwrap_or(self.stable_pos) + } + + fn sequence_records( + &self, + StoredAppendInput { + records, + match_seq_num, + fencing_token, + }: StoredAppendInput, + ) -> Result>, AppendErrorInternal> { + if let Some(provided_token) = fencing_token + && provided_token != self.fencing_token.state + { + Err(AppendConditionFailedError::FencingTokenMismatch { + expected: provided_token, + actual: self.fencing_token.state.clone(), + applied_point: self.fencing_token.applied_point, + })?; + } + let next_assignable_pos = self.next_assignable_pos(); + let first_seq_num = next_assignable_pos.seq_num; + if let Some(match_seq_num) = match_seq_num + && match_seq_num != first_seq_num + { + Err(AppendConditionFailedError::SeqNumMismatch { + assigned_seq_num: first_seq_num, + match_seq_num, + })?; + } + sequenced_records( + records, + first_seq_num, + next_assignable_pos.timestamp, + &self.config.timestamping, + ) + } + + fn apply_command(&mut self, seq_num: SeqNum, cmd: &CommandRecord, append_type: AppendType) { + let new_applied_point = ..(seq_num + 1); + match cmd { + CommandRecord::Fence(token) => { + self.fencing_token = CommandState { + applied_point: new_applied_point, + state: token.clone(), + }; + } + CommandRecord::Trim(trim_point) => { + let trim_point = ..(*trim_point).min(match append_type { + AppendType::Regular => new_applied_point.end, + AppendType::Terminal => SeqNum::MAX, + }); + if self.trim_point.state.end < trim_point.end { + self.trim_point = CommandState { + applied_point: new_applied_point, + state: trim_point, + }; + } + } + } + } + + fn handle_append( + &mut self, + input: StoredAppendInput, + session: Option, + reply_tx: oneshot::Sender>, + append_type: AppendType, + ) { + let Some(ticket) = append::admit(reply_tx, session) else { + return; + }; + let sequenced_records = if self.trim_point.state.end == SeqNum::MAX { + Err(AppendErrorInternal::StreamDeletionPending { + // The terminal trim must be durable before reporting deletion pending. + durability_dependency: self.trim_point.applied_point, + }) + } else { + self.sequence_records(input) + }; + match sequenced_records { + Ok(sequenced_records) => { + if append_type == AppendType::Terminal { + assert_eq!(sequenced_records.len(), 1); + assert_eq!( + sequenced_records[0].inner(), + &StoredRecord::Plaintext(Record::Command(CommandRecord::Trim(SeqNum::MAX))) + ); + } + for sr in sequenced_records.iter() { + if let StoredRecord::Plaintext(Record::Command(cmd)) = sr.inner() { + self.apply_command(sr.position().seq_num, cmd, append_type); + } + } + let (first_pos, next_pos) = pos_span(&sequenced_records); + let seq_num_range = first_pos.seq_num..next_pos.seq_num; + let opts = DbSubmitAppendOptions { + retention: self.config.retention_policy, + fencing_token: self + .fencing_token + .is_applied_in(&seq_num_range) + .then(|| self.fencing_token.state.clone()), + trim_point: self + .trim_point + .is_applied_in(&seq_num_range) + .then_some(self.trim_point.state), + }; + self.db_writes_pending.push_back( + db_submit_append(self.db.clone(), self.stream_id, sequenced_records, opts) + .boxed(), + ); + self.pending_appends.accept(ticket, first_pos..next_pos); + self.last_tail_write_timestamp = TimestampSecs::now(); + } + Err(e) => { + self.pending_appends.reject(ticket, e, self.stable_pos); + } + } + } + + fn handle_terminal_trim( + &mut self, + condition: TerminalTrimCondition, + reply_tx: oneshot::Sender>, + ) { + match condition { + TerminalTrimCondition::Always => { + self.ensure_terminal_trim(reply_tx); + } + TerminalTrimCondition::DeleteOnEmpty { + expected_stream_creation_seq, + expected_config_seq, + } => { + if self.stream_creation_seq != expected_stream_creation_seq { + let _ = reply_tx.send(Ok(TerminalTrimOutcome::Obsolete)); + } else if self.trim_point.state.end == SeqNum::MAX { + self.ensure_terminal_trim(reply_tx); + } else if self.config_seq != expected_config_seq { + // The worker may have observed a configuration commit before + // its notification reached this actor (or vice versa). Do not + // defer using an age that may have just been decreased. + let _ = reply_tx.send(Ok(TerminalTrimOutcome::RetryAt(TimestampSecs::after( + doe::RETRY_INTERVAL, + )))); + } else if self.config.delete_on_empty.min_age().is_none() { + let _ = reply_tx.send(Ok(TerminalTrimOutcome::Obsolete)); + } else { + let db = self.db.clone(); + let stream_id = self.stream_id; + let stable_pos_snapshot = self.stable_pos; + let config_seq_snapshot = self.config_seq; + let msg_tx = self.msg_tx.clone(); + tokio::spawn(async move { + let records = stream_record_presence(&db, stream_id).await; + let _ = msg_tx.send(Message::DeleteOnEmptyCheckResult { + stable_pos_snapshot, + config_seq_snapshot, + records, + reply_tx, + }); + }); + } + } + } + } + + fn handle_doe_check_result( + &mut self, + stable_pos_snapshot: StreamPosition, + config_seq_snapshot: u64, + records: Result, + reply_tx: oneshot::Sender>, + ) { + let records = match records { + Ok(records) => records, + Err(err) => { + let _ = reply_tx.send(Err(err.into())); + return; + } + }; + if self.trim_point.state.end == SeqNum::MAX { + self.ensure_terminal_trim(reply_tx); + return; + } + if self.config_seq != config_seq_snapshot { + let _ = reply_tx.send(Ok(TerminalTrimOutcome::RetryAt(TimestampSecs::after( + doe::RETRY_INTERVAL, + )))); + return; + } + let Some(min_age) = self.config.delete_on_empty.min_age() else { + let _ = reply_tx.send(Ok(TerminalTrimOutcome::Obsolete)); + return; + }; + let outcome = match records { + // Appends cannot remove an observed record, so these bounds remain + // useful even when the tail advances. The scheduler's revision check + // protects against a trim removing the record before we finish. + RecordPresence::ExpiresAt(at) => self.doe_retry_at(at), + RecordPresence::Unbounded => TerminalTrimOutcome::Parked, + RecordPresence::Empty => { + let old_enough = TimestampSecs::now() + .checked_sub_duration(min_age) + .is_some_and(|cutoff| self.last_tail_write_timestamp <= cutoff); + if self.stable_pos == stable_pos_snapshot + && self.next_assignable_pos() == stable_pos_snapshot + && old_enough + { + self.ensure_terminal_trim(reply_tx); + return; + } + self.doe_retry_at(TimestampSecs::ZERO) + } + }; + let _ = reply_tx.send(Ok(outcome)); + } + + fn doe_retry_at(&self, earliest_empty: TimestampSecs) -> TerminalTrimOutcome { + let age_at = self + .last_tail_write_timestamp + .saturating_add_duration(self.config.delete_on_empty.min_age().unwrap_or_default()); + TerminalTrimOutcome::RetryAt( + TimestampSecs::after(doe::RETRY_INTERVAL) + .max(age_at) + .max(earliest_empty), + ) + } + + fn ensure_terminal_trim( + &mut self, + reply_tx: oneshot::Sender>, + ) { + let (append_reply_tx, append_reply_rx) = oneshot::channel(); + self.handle_append( + terminal_trim_input(), + None, + append_reply_tx, + AppendType::Terminal, + ); + tokio::spawn(async move { + let result = match append_reply_rx.await { + Ok(Ok(_)) => Ok(TerminalTrimOutcome::DeletionPending), + Ok(Err(AppendErrorInternal::StreamDeletionPending { .. })) => { + Ok(TerminalTrimOutcome::DeletionPending) + } + Ok(Err(AppendErrorInternal::Storage(e))) => Err(DeleteStreamError::Storage(e)), + Ok(Err(AppendErrorInternal::StreamerMissingInActionError(e))) => { + Err(DeleteStreamError::StreamerMissingInActionError(e)) + } + Ok(Err(AppendErrorInternal::RequestDroppedError(e))) => { + Err(DeleteStreamError::RequestDroppedError(e)) + } + Ok(Err(AppendErrorInternal::ConditionFailed(_))) => { + unreachable!("unconditional write") + } + Ok(Err(AppendErrorInternal::TimestampMissing(_))) => { + unreachable!("Timestamp::MAX used") + } + Ok(Err(AppendErrorInternal::MaxSeqNum(_))) => { + unreachable!("terminal append is plaintext command record") + } + Err(_) => Err(RequestDroppedError.into()), + }; + let _ = reply_tx.send(result); + }); + } + + fn subscribe_durability(&mut self) { + if let Some(inflight_append) = self + .inflight_appends + .front() + .filter(|pa| pa.db_seq > self.db_durability_subscription) + { + let msg_tx = self.msg_tx.clone(); + self.durability_notifier + .subscribe(inflight_append.db_seq, move |res| { + let _ = msg_tx.send(Message::DurabilityStatus(res)); + }); + self.db_durability_subscription = inflight_append.db_seq; + } + } + + fn on_db_durable_seq_advanced(&mut self, db_durable_seq: u64) { + while self + .inflight_appends + .front() + .is_some_and(|pa| pa.db_seq <= db_durable_seq) + { + let records = self + .inflight_appends + .pop_front() + .expect("non-empty") + .records; + let (first_pos, stable_pos) = pos_span(&records); + assert!(self.stable_pos.seq_num <= stable_pos.seq_num); + self.pending_appends.on_stable(stable_pos); + self.stable_pos = stable_pos; + if self + .trim_point + .is_applied_in(&(first_pos.seq_num..stable_pos.seq_num)) + { + let _ = self.bgtask_trigger_tx.send(BgtaskTrigger::StreamTrim); + } + if self.follow_tx.send(records).is_err() { + debug!(stream_id = %self.stream_id, "no active followers for durable records broadcast"); + } + } + } + + async fn run(mut self, mut msg_rx: mpsc::UnboundedReceiver) { + let dormancy = tokio::time::sleep(Duration::MAX); + tokio::pin!(dormancy); + loop { + if self.trim_point.state.end == SeqNum::MAX { + if self.trim_point.applied_point.end == self.stable_pos.seq_num { + // Terminal trim is durable. + break; + } else { + assert!(self.stable_pos.seq_num < self.trim_point.applied_point.end); + } + } + dormancy.as_mut().reset(Instant::now() + DORMANT_TIMEOUT); + tokio::select! { + biased; + Some(res) = OptionFuture::from(self.db_writes_pending.front_mut()) => { + drop(self.db_writes_pending.pop_front().expect("polled")); + match res { + Ok(submitted_append) => { + if let Some(prev) = self.inflight_appends.back() { + assert!(prev.db_seq < submitted_append.db_seq); + } + self.inflight_appends.push_back(submitted_append); + self.subscribe_durability(); + } + Err(db_err) => { + self.pending_appends.on_durability_failed(db_err); + break; + } + } + } + Some(msg) = msg_rx.recv() => { + match msg { + Message::Append { + input, + session, + reply_tx, + append_type, + } => { + self.handle_append(input, session, reply_tx, append_type); + } + Message::TerminalTrim { + condition, + reply_tx, + } => { + self.handle_terminal_trim(condition, reply_tx); + } + Message::DeleteOnEmptyCheckResult { + stable_pos_snapshot, + config_seq_snapshot, + records, + reply_tx, + } => { + self.handle_doe_check_result( + stable_pos_snapshot, + config_seq_snapshot, + records, + reply_tx, + ); + } + Message::Follow { + start_seq_num, + reply_tx, + } => { + let reply = if start_seq_num == self.stable_pos.seq_num { + Ok(self.follow_tx.subscribe()) + } else { + Err(self.stable_pos) + }; + let _ = reply_tx.send(reply); + } + Message::CheckTail { reply_tx } => { + let _ = reply_tx.send(self.stable_pos); + } + Message::Reconfigure { seq, config } => { + if seq > self.config_seq { + self.config = config; + self.config_seq = seq; + } + } + Message::DurabilityStatus(status) => { + match status { + Ok(durable_seq) => { + assert!(durable_seq >= self.db_durability_subscription); + self.on_db_durable_seq_advanced(durable_seq); + self.subscribe_durability(); + } + Err(reason) => { + self.pending_appends.on_durability_failed(slatedb::Error::closed( + "database closed while waiting for durability".to_owned(), + reason, + )); + break; + }, + } + } + } + } + _ = dormancy.as_mut() => { + // Cancelled requests can still have writes become durable. Keep + // their assigned positions until a new streamer can recover them. + if self.db_writes_pending.is_empty() + && self.inflight_appends.is_empty() + && self.lease_state.close_if_idle() + { + break; + } + } + } + } + } +} + +enum Message { + Append { + input: StoredAppendInput, + session: Option, + reply_tx: oneshot::Sender>, + append_type: AppendType, + }, + TerminalTrim { + condition: TerminalTrimCondition, + reply_tx: oneshot::Sender>, + }, + DeleteOnEmptyCheckResult { + stable_pos_snapshot: StreamPosition, + config_seq_snapshot: u64, + records: Result, + reply_tx: oneshot::Sender>, + }, + Follow { + start_seq_num: SeqNum, + reply_tx: oneshot::Sender< + Result>>, StreamPosition>, + >, + }, + CheckTail { + reply_tx: oneshot::Sender, + }, + Reconfigure { + seq: u64, + config: StreamConfig, + }, + DurabilityStatus(Result), +} + +pub(super) enum TerminalTrimCondition { + Always, + DeleteOnEmpty { + expected_stream_creation_seq: u64, + expected_config_seq: u64, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum TerminalTrimOutcome { + /// Deletion is durably pending. + DeletionPending, + RetryAt(TimestampSecs), + Parked, + /// DOE is disabled or this request belongs to an earlier incarnation. + Obsolete, +} + +#[derive(Debug, Clone)] +pub(super) struct StreamerClient { + generation_id: StreamerGenerationId, + stream_id: StreamId, + cipher: Option, + msg_tx: mpsc::UnboundedSender, + append_inflight_bytes: Arc, + lease_state: StreamerClientLeaseState, +} + +impl StreamerClient { + pub(super) fn generation_id(&self) -> StreamerGenerationId { + self.generation_id + } + + pub(super) fn is_dead(&self) -> bool { + self.lease_state.is_closed() + } + + pub(super) fn guard(self) -> Result { + let _guard = self.lease_state.try_acquire()?; + Ok(GuardedStreamerClient { + client: self, + _guard, + }) + } + + async fn check_tail(&self) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self.msg_tx + .send(Message::CheckTail { reply_tx }) + .map_err(|_| StreamerMissingInActionError)?; + reply_rx.await.map_err(|_| StreamerMissingInActionError) + } + + async fn follow( + &self, + start_seq_num: SeqNum, + ) -> Result< + Result>>, StreamPosition>, + StreamerMissingInActionError, + > { + let (reply_tx, reply_rx) = oneshot::channel(); + self.msg_tx + .send(Message::Follow { + start_seq_num, + reply_tx, + }) + .map_err(|_| StreamerMissingInActionError)?; + reply_rx.await.map_err(|_| StreamerMissingInActionError) + } + + async fn append_permit( + &self, + input: StoredAppendInput, + ) -> Result, StreamerMissingInActionError> { + let metered_size = input.records.metered_size(); + metrics::observe_append_batch_size(input.records.len(), metered_size); + let start = Instant::now(); + let num_permits = + u32::try_from(metered_size.max(1)).expect("append batch size fits in u32"); + let sema_permit = tokio::select! { + res = self.append_inflight_bytes.acquire_many(num_permits) => { + res.map_err(|_| StreamerMissingInActionError) + } + _ = self.msg_tx.closed() => { + Err(StreamerMissingInActionError) + } + }?; + metrics::observe_append_permit_latency(start.elapsed()); + Ok(AppendPermit { + sema_permit, + msg_tx: &self.msg_tx, + input, + }) + } + + pub(super) fn advise_reconfig(&self, seq: u64, config: StreamConfig) -> bool { + self.msg_tx + .send(Message::Reconfigure { seq, config }) + .is_ok() + } + + async fn terminal_trim( + &self, + condition: TerminalTrimCondition, + ) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self.msg_tx + .send(Message::TerminalTrim { + condition, + reply_tx, + }) + .map_err(|_| { + DeleteStreamError::StreamerMissingInActionError(StreamerMissingInActionError) + })?; + reply_rx.await.map_err(|_| RequestDroppedError)? + } +} + +fn timestamp_now() -> Timestamp { + std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("21st century") + .as_millis() + .try_into() + .expect("Milliseconds since Unix epoch fits into a u64") +} + +fn terminal_trim_input() -> StoredAppendInput { + let record: StoredAppendRecord = StoredAppendRecordParts { + timestamp: Some(Timestamp::MAX), + record: StoredRecord::from(Record::Command(CommandRecord::Trim(SeqNum::MAX))).metered(), + } + .try_into() + .expect("valid append record"); + StoredAppendInput { + records: vec![record].try_into().expect("valid append batch"), + match_seq_num: None, + fencing_token: None, + } +} + +#[derive(Debug)] +pub struct AppendPermit<'a> { + sema_permit: SemaphorePermit<'a>, + msg_tx: &'a mpsc::UnboundedSender, + input: StoredAppendInput, +} + +impl AppendPermit<'_> { + pub async fn submit(self) -> Result { + self.submit_internal(None, AppendType::Regular).await + } + + pub async fn submit_session( + self, + session: append::SessionHandle, + ) -> Result { + self.submit_internal(Some(session), AppendType::Regular) + .await + } + + async fn submit_internal( + self, + session: Option, + append_type: AppendType, + ) -> Result { + let start = Instant::now(); + let AppendPermit { + sema_permit, + msg_tx, + input, + } = self; + let (reply_tx, reply_rx) = oneshot::channel(); + msg_tx + .send(Message::Append { + input, + session, + reply_tx, + append_type, + }) + .map_err(|_| StreamerMissingInActionError)?; + let ack = reply_rx.await.map_err(|_| RequestDroppedError)??; + drop(sema_permit); + metrics::observe_append_ack_latency(start.elapsed()); + Ok(ack) + } +} + +fn pos_span(records: &[Metered]) -> (StreamPosition, StreamPosition) { + ( + *records.first().expect("non-empty").position(), + next_pos(records), + ) +} + +pub fn next_pos(records: &[Metered]) -> StreamPosition { + let last_pos = records.last().expect("non-empty").position(); + StreamPosition { + seq_num: last_pos.seq_num + 1, + timestamp: last_pos.timestamp, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RecordPresence { + Empty, + ExpiresAt(TimestampSecs), + Unbounded, +} + +async fn stream_record_presence( + db: &slatedb::Db, + stream_id: StreamId, +) -> Result { + let prefix = kv::stream_record_timestamp::ser_key_prefix(stream_id); + let scan_opts = ScanOptions::default().with_order(IterationOrder::Descending); + let mut it = db.scan_prefix_with_options(prefix, .., &scan_opts).await?; + let now_millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) + .unwrap_or(0); + while let Some(kv) = it.next().await? { + match kv.expire_ts { + None => return Ok(RecordPresence::Unbounded), + Some(expire_ts) if expire_ts > now_millis => { + // One live record bounds when the stream can become empty. + // Use its stored TTL, since retention changes are not retroactive. + // Round up so the check does not run just before expiration. + return Ok(RecordPresence::ExpiresAt(TimestampSecs::from_millis( + expire_ts.saturating_add(999), + ))); + } + _ => (), + } + } + Ok(RecordPresence::Empty) +} + +fn sequenced_records( + batch: StoredAppendRecordBatch, + first_seq_num: SeqNum, + prev_max_timestamp: Timestamp, + config: &TimestampingConfig, +) -> Result>, AppendErrorInternal> { + let mut sequenced_records = Vec::with_capacity(batch.len()); + let mut max_timestamp = prev_max_timestamp; + let now = timestamp_now(); + for (i, StoredAppendRecordParts { timestamp, record }) in batch + .into_iter() + .map(|record| record.into_parts()) + .enumerate() + { + let assigned_seq_num = first_seq_num + i as u64; + + let max_assignable_seq_num = record.as_ref().into_inner().max_assignable_seq_num(); + if assigned_seq_num > max_assignable_seq_num { + Err(MaxSeqNumError { + first_seq_num, + assigned_seq_num, + max_assignable_seq_num, + })?; + } + let mut timestamp = match config.mode { + TimestampingMode::ClientPrefer => timestamp.unwrap_or(now), + TimestampingMode::ClientRequire => timestamp.ok_or(AppendTimestampRequiredError)?, + TimestampingMode::Arrival => now, + }; + if !config.uncapped && timestamp > now { + timestamp = now; + } + if timestamp < max_timestamp { + timestamp = max_timestamp; + } else { + max_timestamp = timestamp; + } + + sequenced_records.push(record.sequenced(StreamPosition { + seq_num: assigned_seq_num, + timestamp, + })); + } + Ok(sequenced_records) +} + +async fn db_submit_append( + db: slatedb::Db, + stream_id: StreamId, + records: Vec>, + DbSubmitAppendOptions { + retention, + fencing_token, + trim_point, + }: DbSubmitAppendOptions, +) -> Result { + let ttl = match retention { + RetentionPolicy::Age(age) => Ttl::ExpireAfterMillis(age.as_millis() as u64), + RetentionPolicy::Infinite() => Ttl::NoExpiry, + }; + let ttl_put_opts = PutOptions { ttl }; + let mut wb = WriteBatch::new(); + for (position, record) in records.iter().map(|msr| msr.parts()) { + wb.put_bytes_with_options( + kv::stream_record_data::ser_key(stream_id, position), + kv::stream_record_data::ser_value(record), + &ttl_put_opts, + ); + wb.put_bytes_with_options( + kv::stream_record_timestamp::ser_key(stream_id, position), + kv::stream_record_timestamp::ser_value(), + &ttl_put_opts, + ); + } + if let Some(fencing_token) = fencing_token { + wb.put_bytes( + kv::stream_fencing_token::ser_key(stream_id), + kv::stream_fencing_token::ser_value(&fencing_token), + ); + } + if let Some(trim_point) = trim_point.and_then(|tp| NonZeroSeqNum::new(tp.end)) { + wb.put_bytes( + kv::stream_trim_point::ser_key(stream_id), + kv::stream_trim_point::ser_value(..trim_point), + ); + } + wb.put_bytes( + kv::stream_tail_position::ser_key(stream_id), + kv::stream_tail_position::ser_value(next_pos(&records)), + ); + // The durability notifier tracks this sequence and acknowledges the append after flush. + let write_handle = db.write(wb).await?; + Ok(InFlightAppend { + db_seq: write_handle.seqnum(), + records, + }) +} + +#[cfg(test)] +mod tests { + use std::{collections::VecDeque, sync::Arc}; + + use bytes::Bytes; + use s2_common::{ + encryption::EncryptionSpec, + record::{EnvelopeRecord, Record}, + }; + use s2_storage::record::{ + StoredAppendInput, StoredAppendRecord, StoredAppendRecordBatch, StoredAppendRecordParts, + StoredRecord, encrypt_record, + }; + use slatedb::object_store::memory::InMemory; + use tokio::sync::{broadcast, mpsc, oneshot}; + + use super::*; + + fn test_record(body: Bytes, timestamp: Option) -> StoredAppendRecord { + let envelope = EnvelopeRecord::try_from_parts(vec![], body).unwrap(); + let record = StoredRecord::from(Record::Envelope(envelope)).metered(); + let parts = StoredAppendRecordParts { timestamp, record }; + parts.try_into().unwrap() + } + + fn test_command_record( + command: CommandRecord, + timestamp: Option, + ) -> StoredAppendRecord { + let record = StoredRecord::from(Record::Command(command)).metered(); + let parts = StoredAppendRecordParts { timestamp, record }; + parts.try_into().unwrap() + } + + fn test_encrypted_record( + body: Bytes, + timestamp: Option, + encryption: &EncryptionSpec, + ) -> StoredAppendRecord { + let envelope = EnvelopeRecord::try_from_parts(vec![], body).unwrap(); + let record = encrypt_record( + Record::Envelope(envelope).metered(), + encryption, + b"test-streamer", + ); + let parts = StoredAppendRecordParts { timestamp, record }; + parts.try_into().unwrap() + } + + #[test] + fn sequenced_records_client_prefer_with_timestamps() { + let config = TimestampingConfig { + mode: TimestampingMode::ClientPrefer, + uncapped: false, + }; + + let records: StoredAppendRecordBatch = vec![ + test_record(vec![1, 2, 3].into(), Some(900)), + test_record(vec![4, 5, 6].into(), Some(950)), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].position().seq_num, 100); + assert_eq!(result[0].position().timestamp, 900); + assert_eq!(result[1].position().seq_num, 101); + assert_eq!(result[1].position().timestamp, 950); + } + + #[test] + fn sequenced_records_client_prefer_without_timestamps() { + let now = timestamp_now(); + let config = TimestampingConfig { + mode: TimestampingMode::ClientPrefer, + uncapped: false, + }; + + let records: StoredAppendRecordBatch = vec![ + test_record(vec![1, 2, 3].into(), None), + test_record(vec![4, 5, 6].into(), None), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].position().seq_num, 100); + assert!(result[0].position().timestamp >= now); + assert_eq!(result[1].position().seq_num, 101); + assert!(result[1].position().timestamp >= now); + } + + #[test] + fn sequenced_records_client_require_missing_timestamp() { + let config = TimestampingConfig { + mode: TimestampingMode::ClientRequire, + uncapped: false, + }; + + let records: StoredAppendRecordBatch = vec![test_record(vec![1, 2, 3].into(), None)] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config); + + assert!(matches!( + result, + Err(AppendErrorInternal::TimestampMissing(_)) + )); + } + + #[test] + fn sequenced_records_client_require_with_timestamps() { + let config = TimestampingConfig { + mode: TimestampingMode::ClientRequire, + uncapped: false, + }; + + let records: StoredAppendRecordBatch = vec![ + test_record(vec![1, 2, 3].into(), Some(900)), + test_record(vec![4, 5, 6].into(), Some(950)), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].position().timestamp, 900); + assert_eq!(result[1].position().timestamp, 950); + } + + #[test] + fn sequenced_records_arrival_mode() { + let now = timestamp_now(); + let config = TimestampingConfig { + mode: TimestampingMode::Arrival, + uncapped: false, + }; + + let records: StoredAppendRecordBatch = vec![ + test_record(vec![1, 2, 3].into(), Some(900)), + test_record(vec![4, 5, 6].into(), Some(950)), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 2); + assert!(result[0].position().timestamp >= now); + assert!(result[1].position().timestamp >= now); + } + + #[test] + fn sequenced_records_timestamp_monotonicity() { + let config = TimestampingConfig { + mode: TimestampingMode::ClientPrefer, + uncapped: false, + }; + + let records: StoredAppendRecordBatch = vec![ + test_record(vec![1, 2, 3].into(), Some(1000)), + test_record(vec![4, 5, 6].into(), Some(900)), + test_record(vec![7, 8, 9].into(), Some(1100)), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 3); + assert_eq!(result[0].position().timestamp, 1000); + assert_eq!(result[1].position().timestamp, 1000); + assert_eq!(result[2].position().timestamp, 1100); + } + + #[test] + fn sequenced_records_prev_max_timestamp_enforced() { + let config = TimestampingConfig { + mode: TimestampingMode::ClientPrefer, + uncapped: false, + }; + + let records: StoredAppendRecordBatch = vec![ + test_record(vec![1, 2, 3].into(), Some(500)), + test_record(vec![4, 5, 6].into(), Some(600)), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 1000, &config).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].position().timestamp, 1000); + assert_eq!(result[1].position().timestamp, 1000); + } + + #[test] + fn sequenced_records_future_timestamp_capped() { + let now = timestamp_now(); + let config = TimestampingConfig { + mode: TimestampingMode::ClientPrefer, + uncapped: false, + }; + + let future = now + 10_000; + let records: StoredAppendRecordBatch = + vec![test_record(vec![1, 2, 3].into(), Some(future))] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 1); + assert!(result[0].position().timestamp <= now + 100); + } + + #[test] + fn sequenced_records_future_timestamp_uncapped() { + let now = timestamp_now(); + let config = TimestampingConfig { + mode: TimestampingMode::ClientPrefer, + uncapped: true, + }; + + let future = now + 10_000; + let records: StoredAppendRecordBatch = + vec![test_record(vec![1, 2, 3].into(), Some(future))] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 100, 0, &config).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].position().timestamp, future); + } + + #[test] + fn sequenced_records_seq_num_assignment() { + let config = TimestampingConfig::default(); + + let records: StoredAppendRecordBatch = vec![ + test_record(vec![1].into(), None), + test_record(vec![2].into(), None), + test_record(vec![3].into(), None), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, 42, 0, &config).unwrap(); + + assert_eq!(result.len(), 3); + assert_eq!(result[0].position().seq_num, 42); + assert_eq!(result[1].position().seq_num, 43); + assert_eq!(result[2].position().seq_num, 44); + } + + #[test] + fn sequenced_records_reject_aes256gcm_records_past_random_nonce_limit() { + let config = TimestampingConfig::default(); + let first_record = test_encrypted_record( + vec![1, 2, 3].into(), + None, + &EncryptionSpec::aes256_gcm([0x24; 32]), + ); + let max_assignable_seq_num = first_record.parts().record.max_assignable_seq_num(); + let first_rejected_seq_num = max_assignable_seq_num + 1; + let records: StoredAppendRecordBatch = vec![ + first_record, + test_encrypted_record( + vec![4, 5, 6].into(), + None, + &EncryptionSpec::aes256_gcm([0x24; 32]), + ), + ] + .try_into() + .unwrap(); + + let result = sequenced_records(records, max_assignable_seq_num, 0, &config); + + assert!(matches!( + result, + Err(AppendErrorInternal::MaxSeqNum(error)) + if error.first_seq_num == max_assignable_seq_num + && error.assigned_seq_num == first_rejected_seq_num + && error.max_assignable_seq_num == max_assignable_seq_num + )); + } + + #[test] + fn sequenced_records_allow_aes256gcm_command_records_past_random_nonce_limit() { + let config = TimestampingConfig::default(); + let max_assignable_seq_num = test_encrypted_record( + vec![1, 2, 3].into(), + None, + &EncryptionSpec::aes256_gcm([0x24; 32]), + ) + .parts() + .record + .max_assignable_seq_num(); + + let records: StoredAppendRecordBatch = + vec![test_command_record(CommandRecord::Trim(42), None)] + .try_into() + .unwrap(); + + let first_command_seq_num = max_assignable_seq_num + 1; + let result = sequenced_records(records, first_command_seq_num, 0, &config).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].position().seq_num, first_command_seq_num); + } + + #[test] + fn command_state_is_applied_in_excludes_range_start() { + let state = CommandState { + applied_point: ..5, + state: (), + }; + + assert!(!state.is_applied_in(&(5..10))); + assert!(state.is_applied_in(&(4..10))); + assert!(state.is_applied_in(&(0..5))); + } + + fn append_input(body: &[u8]) -> StoredAppendInput { + StoredAppendInput { + records: vec![test_record(Bytes::copy_from_slice(body), None)] + .try_into() + .expect("valid batch"), + match_seq_num: None, + fencing_token: None, + } + } + + async fn make_pending_append_durable(streamer: &mut Streamer) { + let submitted = streamer + .db_writes_pending + .pop_front() + .unwrap() + .await + .unwrap(); + let seq = submitted.db_seq; + streamer.inflight_appends.push_back(submitted); + streamer.db.flush().await.unwrap(); + streamer.on_db_durable_seq_advanced(seq); + } + + async fn test_streamer() -> Streamer { + test_streamer_with_settings(Default::default()).await + } + + async fn test_streamer_with_settings(settings: slatedb::config::Settings) -> Streamer { + let object_store = Arc::new(InMemory::new()); + let db = slatedb::Db::builder("/test", object_store) + .with_settings(settings) + .build() + .await + .expect("db"); + let (msg_tx, _msg_rx) = mpsc::unbounded_channel(); + let (bgtask_trigger_tx, _) = broadcast::channel(16); + let (lease_state, _) = StreamerLeaseState::new(); + Streamer { + db: db.clone(), + stream_id: [3u8; StreamId::LEN].into(), + stream_creation_seq: 0, + msg_tx, + config: StreamConfig::default(), + config_seq: 0, + last_tail_write_timestamp: TimestampSecs::ZERO, + fencing_token: CommandState { + state: FencingToken::default(), + applied_point: ..SeqNum::MIN, + }, + trim_point: CommandState { + state: ..SeqNum::MIN, + applied_point: ..SeqNum::MIN, + }, + db_writes_pending: VecDeque::new(), + db_durability_subscription: 0, + inflight_appends: VecDeque::new(), + pending_appends: append::PendingAppends::new(), + stable_pos: StreamPosition::MIN, + follow_tx: broadcast::Sender::new(super::super::FOLLOWER_MAX_LAG), + lease_state, + durability_notifier: DurabilityNotifier::spawn(&db), + bgtask_trigger_tx, + } + } + + #[tokio::test] + async fn stale_config_notifications_cannot_restore_old_retention() { + let mut streamer = test_streamer().await; + let db = streamer.db.clone(); + let stream_id = streamer.stream_id; + let (msg_tx, msg_rx) = mpsc::unbounded_channel(); + streamer.msg_tx = msg_tx.clone(); + streamer.config.retention_policy = RetentionPolicy::Age(Duration::from_secs(1)); + let task = tokio::spawn(streamer.run(msg_rx)); + msg_tx + .send(Message::Reconfigure { + seq: 20, + config: StreamConfig { + retention_policy: RetentionPolicy::Infinite(), + ..Default::default() + }, + }) + .unwrap(); + let old = StreamConfig { + retention_policy: RetentionPolicy::Age(Duration::from_secs(1)), + ..Default::default() + }; + msg_tx + .send(Message::Reconfigure { + seq: 10, + config: old, + }) + .unwrap(); + let (reply_tx, reply_rx) = oneshot::channel(); + msg_tx + .send(Message::Append { + input: append_input(b"must not expire"), + session: None, + reply_tx, + append_type: AppendType::Regular, + }) + .unwrap(); + let ack = tokio::time::timeout(Duration::from_secs(5), reply_rx) + .await + .unwrap() + .unwrap() + .unwrap(); + let entry = db + .get_key_value(kv::stream_record_data::ser_key(stream_id, ack.start)) + .await + .unwrap() + .unwrap(); + assert!( + entry.expire_ts.is_none(), + "late config notification restored stale retention" + ); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + db.close().await.unwrap(); + } + + #[test] + fn lease_state_closes_when_idle_and_rejects_new_leases() { + let (streamer_lease_state, client_lease_state) = StreamerLeaseState::new(); + + let lease = client_lease_state + .try_acquire() + .expect("first lease should succeed"); + assert!( + !streamer_lease_state.close_if_idle(), + "an outstanding lease should keep the state open" + ); + + drop(lease); + + assert!( + streamer_lease_state.close_if_idle(), + "an idle state should close once dormancy wins" + ); + assert!(client_lease_state.is_closed()); + assert!(matches!( + client_lease_state.try_acquire(), + Err(StreamerMissingInActionError) + )); + } + + #[test] + fn streamer_lease_state_drop_blocks_new_leases_while_existing_guard_drops_cleanly() { + let (streamer_lease_state, client_lease_state) = StreamerLeaseState::new(); + + let lease = client_lease_state + .try_acquire() + .expect("first lease should succeed"); + drop(streamer_lease_state); + + assert!(matches!( + client_lease_state.try_acquire(), + Err(StreamerMissingInActionError) + )); + + drop(lease); + assert!(client_lease_state.is_closed()); + } + + #[tokio::test] + async fn terminal_trim_and_rejections_wait_for_durability() { + let mut streamer = test_streamer_with_settings(slatedb::config::Settings { + flush_interval: None, + ..Default::default() + }) + .await; + let mut replies = Vec::new(); + for _ in 0..2 { + let (tx, rx) = oneshot::channel(); + streamer.handle_terminal_trim(TerminalTrimCondition::Always, tx); + replies.push(rx); + } + // An empty-stream check can also finish after another deletion request starts. + let (tx, rx) = oneshot::channel(); + streamer.handle_doe_check_result( + StreamPosition::MIN, + streamer.config_seq, + Ok(RecordPresence::Empty), + tx, + ); + replies.push(rx); + + let (tx, mut append_reply) = oneshot::channel(); + streamer.handle_append(append_input(b"late"), None, tx, AppendType::Regular); + assert_eq!(streamer.db_writes_pending.len(), 1); + tokio::task::yield_now().await; + assert!(matches!( + append_reply.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + for reply in &mut replies { + assert!( + matches!(reply.try_recv(), Err(oneshot::error::TryRecvError::Empty)), + "stream deletion must wait even when the original trim has not been submitted" + ); + } + + let submitted = streamer + .db_writes_pending + .pop_front() + .unwrap() + .await + .unwrap(); + let db_seq = submitted.db_seq; + streamer.inflight_appends.push_back(submitted); + tokio::task::yield_now().await; + assert!(matches!( + append_reply.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + for reply in &mut replies { + assert!( + matches!(reply.try_recv(), Err(oneshot::error::TryRecvError::Empty)), + "a committed but unflushed trim must not acknowledge stream deletion" + ); + } + streamer.db.flush().await.unwrap(); + streamer.on_db_durable_seq_advanced(db_seq); + assert!(matches!( + append_reply.await.unwrap(), + Err(AppendErrorInternal::StreamDeletionPending { .. }) + )); + for reply in replies { + assert_eq!( + reply.await.unwrap().unwrap(), + TerminalTrimOutcome::DeletionPending + ); + } + streamer.db.close().await.unwrap(); + } + + #[rstest::rstest] + #[case::pending_before_scan(false, false)] + #[case::pending_after_scan(true, false)] + #[case::durable_after_scan(true, true)] + #[tokio::test] + async fn delete_on_empty_uses_nonempty_observation_despite_appends( + #[case] append_after_scan: bool, + #[case] durable: bool, + #[values(false, true)] infinite: bool, + ) { + let mut streamer = test_streamer().await; + streamer.config.delete_on_empty.min_age = Duration::from_secs(60); + streamer.config.retention_policy = if infinite { + RetentionPolicy::Infinite() + } else { + RetentionPolicy::Age(Duration::from_secs(3600)) + }; + let (seed_tx, seed_rx) = oneshot::channel(); + streamer.handle_append( + append_input(b"observed record"), + None, + seed_tx, + AppendType::Regular, + ); + make_pending_append_durable(&mut streamer).await; + seed_rx.await.unwrap().unwrap(); + + let (msg_tx, mut msg_rx) = mpsc::unbounded_channel(); + streamer.msg_tx = msg_tx; + let (append_tx, append_rx) = oneshot::channel(); + // Keep the sender until the selected point in the asynchronous check. + let mut append_tx = Some(append_tx); + if !append_after_scan { + streamer.handle_append( + append_input(b"pending before scan"), + None, + append_tx.take().unwrap(), + AppendType::Regular, + ); + } + let (reply_tx, reply_rx) = oneshot::channel(); + streamer.handle_terminal_trim( + TerminalTrimCondition::DeleteOnEmpty { + expected_stream_creation_seq: streamer.stream_creation_seq, + expected_config_seq: streamer.config_seq, + }, + reply_tx, + ); + let Message::DeleteOnEmptyCheckResult { + stable_pos_snapshot, + config_seq_snapshot, + records, + reply_tx, + } = msg_rx.recv().await.unwrap() + else { + panic!("expected record check even with a pending append"); + }; + if append_after_scan { + streamer.handle_append( + append_input(b"arrived after scan"), + None, + append_tx.take().unwrap(), + AppendType::Regular, + ); + } + if durable { + make_pending_append_durable(&mut streamer).await; + append_rx.await.unwrap().unwrap(); + } + streamer.handle_doe_check_result( + stable_pos_snapshot, + config_seq_snapshot, + records, + reply_tx, + ); + assert_eq!(streamer.db_writes_pending.len(), usize::from(!durable)); + assert_ne!(streamer.trim_point.state.end, SeqNum::MAX); + let outcome = reply_rx.await.unwrap().unwrap(); + if infinite { + assert_eq!(outcome, TerminalTrimOutcome::Parked); + } else { + assert!(matches!( + outcome, + TerminalTrimOutcome::RetryAt(at) + if at > TimestampSecs::after(Duration::from_secs(3500)) + )); + } + streamer.db.close().await.unwrap(); + } + + #[rstest::rstest] + #[case::pending_before_scan(false, false)] + #[case::pending_after_scan(true, false)] + #[case::durable_after_scan(true, true)] + #[tokio::test] + async fn delete_on_empty_rechecks_appends_after_empty_scan( + #[case] append_after_scan: bool, + #[case] durable: bool, + ) { + let mut streamer = test_streamer().await; + streamer.config.delete_on_empty.min_age = Duration::from_secs(1); + let (msg_tx, mut msg_rx) = mpsc::unbounded_channel(); + streamer.msg_tx = msg_tx; + let (append_tx, append_rx) = oneshot::channel(); + let mut append_tx = Some(append_tx); + if !append_after_scan { + streamer.handle_append( + append_input(b"pending before scan"), + None, + append_tx.take().unwrap(), + AppendType::Regular, + ); + } + let (reply_tx, reply_rx) = oneshot::channel(); + streamer.handle_terminal_trim( + TerminalTrimCondition::DeleteOnEmpty { + expected_stream_creation_seq: 0, + expected_config_seq: 0, + }, + reply_tx, + ); + let Message::DeleteOnEmptyCheckResult { + stable_pos_snapshot, + config_seq_snapshot, + records, + reply_tx, + } = msg_rx.recv().await.unwrap() + else { + panic!("expected empty-stream check result"); + }; + assert_eq!(records.as_ref().unwrap(), &RecordPresence::Empty); + if append_after_scan { + streamer.handle_append( + append_input(b"arrived during scan"), + None, + append_tx.take().unwrap(), + AppendType::Regular, + ); + } + if durable { + make_pending_append_durable(&mut streamer).await; + append_rx.await.unwrap().unwrap(); + } + // Let the minimum age elapse so it cannot mask a missing tail check. + tokio::time::sleep(Duration::from_millis(1100)).await; + streamer.handle_doe_check_result( + stable_pos_snapshot, + config_seq_snapshot, + records, + reply_tx, + ); + assert_eq!(streamer.db_writes_pending.len(), usize::from(!durable)); + assert_ne!(streamer.trim_point.state.end, SeqNum::MAX); + assert!(matches!( + reply_rx.await.unwrap().unwrap(), + TerminalTrimOutcome::RetryAt(_) + )); + streamer.db.close().await.unwrap(); + } + + #[rstest::rstest] + #[case::actor_behind(0, 1)] + #[case::worker_behind(1, 0)] + #[tokio::test] + async fn delete_on_empty_bounds_retry_for_stale_config( + #[case] actor_seq: u64, + #[case] worker_seq: u64, + ) { + let mut streamer = test_streamer().await; + streamer.config.delete_on_empty.min_age = Duration::from_secs(365 * 24 * 3600); + streamer.last_tail_write_timestamp = TimestampSecs::now(); + streamer.config_seq = actor_seq; + let earliest_retry = TimestampSecs::after(doe::RETRY_INTERVAL); + let (reply_tx, reply_rx) = oneshot::channel(); + streamer.handle_terminal_trim( + TerminalTrimCondition::DeleteOnEmpty { + expected_stream_creation_seq: 0, + expected_config_seq: worker_seq, + }, + reply_tx, + ); + let TerminalTrimOutcome::RetryAt(at) = reply_rx.await.unwrap().unwrap() else { + panic!("expected a bounded retry for mismatched configuration"); + }; + assert!(at >= earliest_retry && at <= TimestampSecs::after(doe::RETRY_INTERVAL)); + assert!(streamer.db_writes_pending.is_empty()); + streamer.db.close().await.unwrap(); + } + + #[tokio::test] + async fn delete_on_empty_bounds_retry_when_config_changes_during_scan() { + let mut streamer = test_streamer().await; + streamer.config.delete_on_empty.min_age = Duration::from_secs(365 * 24 * 3600); + streamer.last_tail_write_timestamp = TimestampSecs::now(); + let (msg_tx, mut msg_rx) = mpsc::unbounded_channel(); + streamer.msg_tx = msg_tx; + let (reply_tx, reply_rx) = oneshot::channel(); + streamer.handle_terminal_trim( + TerminalTrimCondition::DeleteOnEmpty { + expected_stream_creation_seq: 0, + expected_config_seq: 0, + }, + reply_tx, + ); + let Message::DeleteOnEmptyCheckResult { + stable_pos_snapshot, + config_seq_snapshot, + records, + reply_tx, + } = msg_rx.recv().await.unwrap() + else { + panic!("expected record check"); + }; + streamer.config_seq += 1; + let earliest_retry = TimestampSecs::after(doe::RETRY_INTERVAL); + streamer.handle_doe_check_result( + stable_pos_snapshot, + config_seq_snapshot, + records, + reply_tx, + ); + let TerminalTrimOutcome::RetryAt(at) = reply_rx.await.unwrap().unwrap() else { + panic!("expected a bounded retry for mismatched configuration"); + }; + assert!(at >= earliest_retry && at <= TimestampSecs::after(doe::RETRY_INTERVAL)); + assert!(streamer.db_writes_pending.is_empty()); + streamer.db.close().await.unwrap(); + } + + #[tokio::test] + async fn append_acks_release_only_after_durable_seq_and_in_order() { + let mut streamer = test_streamer().await; + let mut follow_rx = streamer.follow_tx.subscribe(); + + let (tx1, mut rx1) = oneshot::channel(); + streamer.handle_append(append_input(b"p0"), None, tx1, AppendType::Regular); + + let (tx2, mut rx2) = oneshot::channel(); + streamer.handle_append(append_input(b"p1"), None, tx2, AppendType::Regular); + + let (tx3, mut rx3) = oneshot::channel(); + streamer.handle_append(append_input(b"p2"), None, tx3, AppendType::Regular); + + let mut db_seqs = Vec::new(); + while let Some(fut) = streamer.db_writes_pending.pop_front() { + let submitted = fut.await.expect("db submit"); + db_seqs.push(submitted.db_seq); + streamer.inflight_appends.push_back(submitted); + } + assert_eq!(db_seqs.len(), 3); + assert!(db_seqs.windows(2).all(|w| w[0] < w[1])); + assert!(matches!( + rx1.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + assert!(matches!( + rx2.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + assert!(matches!( + rx3.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + + let first_seq = db_seqs[0]; + if first_seq > 0 { + streamer.on_db_durable_seq_advanced(first_seq - 1); + assert!(matches!( + rx1.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + } + + streamer.on_db_durable_seq_advanced(first_seq); + let ack1 = rx1.await.expect("ack 1").expect("append ack 1"); + assert_eq!(ack1.start.seq_num, 0); + assert_eq!(ack1.end.seq_num, 1); + assert_eq!(ack1.tail.seq_num, 1); + assert!(matches!( + rx2.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + assert!(matches!( + rx3.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + let batch1 = follow_rx.recv().await.expect("follow batch 1"); + assert_eq!(batch1.len(), 1); + let StoredRecord::Plaintext(Record::Envelope(env)) = batch1[0].inner() else { + panic!("expected envelope") + }; + assert_eq!(env.body().as_ref(), b"p0"); + + streamer.on_db_durable_seq_advanced(db_seqs[2]); + let ack2 = rx2.await.expect("ack 2").expect("append ack 2"); + let ack3 = rx3.await.expect("ack 3").expect("append ack 3"); + assert_eq!(ack2.start.seq_num, 1); + assert_eq!(ack2.end.seq_num, 2); + assert_eq!(ack3.start.seq_num, 2); + assert_eq!(ack3.end.seq_num, 3); + assert_eq!(streamer.stable_pos.seq_num, 3); + assert!(streamer.inflight_appends.is_empty()); + + let batch2 = follow_rx.recv().await.expect("follow batch 2"); + let batch3 = follow_rx.recv().await.expect("follow batch 3"); + let StoredRecord::Plaintext(Record::Envelope(env2)) = batch2[0].inner() else { + panic!("expected envelope") + }; + let StoredRecord::Plaintext(Record::Envelope(env3)) = batch3[0].inner() else { + panic!("expected envelope") + }; + assert_eq!(env2.body().as_ref(), b"p1"); + assert_eq!(env3.body().as_ref(), b"p2"); + } + + #[tokio::test] + async fn durable_seq_jump_releases_multiple_inflight_batches() { + let mut streamer = test_streamer().await; + let mut follow_rx = streamer.follow_tx.subscribe(); + let mut ack_rxs = Vec::new(); + + for i in 0..4 { + let (tx, rx) = oneshot::channel(); + ack_rxs.push(rx); + let payload = format!("jump-{i}"); + streamer.handle_append( + append_input(payload.as_bytes()), + None, + tx, + AppendType::Regular, + ); + } + + let mut db_seqs = Vec::new(); + while let Some(fut) = streamer.db_writes_pending.pop_front() { + let submitted = fut.await.expect("db submit"); + db_seqs.push(submitted.db_seq); + streamer.inflight_appends.push_back(submitted); + } + assert_eq!(db_seqs.len(), 4); + + streamer.on_db_durable_seq_advanced(*db_seqs.last().expect("non-empty")); + + for (i, rx) in ack_rxs.into_iter().enumerate() { + let ack = rx.await.expect("ack").expect("append ack"); + assert_eq!(ack.start.seq_num, i as u64); + assert_eq!(ack.end.seq_num, i as u64 + 1); + } + + for i in 0..4 { + let batch = follow_rx.recv().await.expect("follow batch"); + let StoredRecord::Plaintext(Record::Envelope(env)) = batch[0].inner() else { + panic!("expected envelope") + }; + assert_eq!(env.body(), format!("jump-{i}").as_bytes()); + } + assert_eq!(streamer.stable_pos.seq_num, 4); + assert!(streamer.inflight_appends.is_empty()); + } + + #[tokio::test(start_paused = true)] + async fn cancelled_append_delays_dormancy_until_writes_are_durable() { + let mut streamer = test_streamer_with_settings(slatedb::config::Settings { + flush_interval: None, + ..Default::default() + }) + .await; + let db = streamer.db.clone(); + let (msg_tx, msg_rx) = mpsc::unbounded_channel(); + streamer.msg_tx = msg_tx; + + let (reply_tx, reply_rx) = oneshot::channel(); + streamer.handle_append( + append_input(b"cancelled"), + None, + reply_tx, + AppendType::Regular, + ); + let task = tokio::spawn(streamer.run(msg_rx)); + tokio::time::timeout(Duration::from_secs(5), async { + while db.snapshot().await.unwrap().seq() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + drop(reply_rx); + tokio::time::sleep(DORMANT_TIMEOUT + Duration::from_secs(1)).await; + assert_eq!( + db.status().durable_seq, + 0, + "the write must still be unflushed" + ); + assert!( + !task.is_finished(), + "dormancy abandoned an unflushed append" + ); + + db.flush().await.unwrap(); + tokio::time::timeout(DORMANT_TIMEOUT + Duration::from_secs(1), task) + .await + .expect("durable writes should allow normal dormancy") + .unwrap(); + db.close().await.unwrap(); + } +} diff --git a/lite/src/backend/streams.rs b/lite/src/backend/streams.rs new file mode 100644 index 00000000..b74ca6f3 --- /dev/null +++ b/lite/src/backend/streams.rs @@ -0,0 +1,427 @@ +use s2_common::{ + basin::BasinName, + config::{DeleteOnEmptyConfig, OptionalStreamConfig, StreamConfig, StreamReconfiguration}, + record::{NonZeroSeqNum, StreamPosition}, + resources::{Page, ProvisionMode, ProvisionResult, RequestToken}, + stream::{ListStreamsRequest, StreamInfo, StreamName}, +}; +use s2_storage::bash::Bash; +use slatedb::{ + DbTransaction, IsolationLevel, + config::{DurabilityLevel, ScanOptions}, +}; +use time::OffsetDateTime; +use tracing::instrument; + +use super::{ + Backend, doe, resolve_stream_config, + store::{db_txn_commit_durable, db_txn_get, db_txn_get_with}, + streamer::{TerminalTrimCondition, TerminalTrimOutcome}, + timestamp::TimestampSecs, +}; +use crate::{ + backend::{ + error::{ + BasinDeletionPendingError, BasinNotFoundError, DeleteStreamError, GetStreamConfigError, + ListStreamsError, ProvisionStreamError, ReconfigureStreamError, StorageError, + StreamAlreadyExistsError, StreamDeletionPendingError, StreamNotFoundError, + StreamerError, + }, + kv, + }, + stream_id::StreamId, +}; + +impl Backend { + pub async fn list_streams( + &self, + basin: BasinName, + request: ListStreamsRequest, + ) -> Result, ListStreamsError> { + let ListStreamsRequest { + prefix, + start_after, + limit, + } = request; + + let key_range = kv::stream_meta::ser_key_range(&basin, &prefix, &start_after); + if key_range.is_empty() { + return Ok(Page::new_empty()); + } + + let scan_opts = ScanOptions { + durability_filter: DurabilityLevel::Remote, + ..Default::default() + }; + let mut it = self.db.scan_with_options(key_range, &scan_opts).await?; + + let mut streams = Vec::with_capacity(limit.as_usize()); + let mut has_more = false; + while let Some(kv) = it.next().await? { + let (deser_basin, stream) = kv::stream_meta::deser_key(kv.key)?; + assert_eq!(deser_basin.as_ref(), basin.as_ref()); + assert!(stream.as_ref() > start_after.as_ref()); + assert!(stream.as_ref() >= prefix.as_ref()); + if streams.len() == limit.as_usize() { + has_more = true; + break; + } + let meta = kv::stream_meta::deser_value(kv.value)?; + streams.push(StreamInfo { + name: stream, + created_at: meta.created_at, + deleted_at: meta.deleted_at, + cipher: meta.cipher, + }); + } + Ok(Page::new(streams, has_more)) + } + + /// Invariant: any outcome asserting the stream exists — `Created`, + /// `Updated`, `Noop`, or `StreamAlreadyExists` — is durably visible + /// (readable at `DurabilityLevel::Remote`) by the time this returns. + pub async fn provision_stream( + &self, + basin: BasinName, + stream: StreamName, + config: OptionalStreamConfig, + mode: ProvisionMode, + ) -> Result, ProvisionStreamError> { + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + + let Some(basin_meta) = db_txn_get( + &txn, + kv::basin_meta::ser_key(&basin), + kv::basin_meta::deser_value, + ) + .await? + else { + return Err(BasinNotFoundError { basin }.into()); + }; + + if basin_meta.deleted_at.is_some() { + return Err(BasinDeletionPendingError { basin }.into()); + } + + let stream_meta_key = kv::stream_meta::ser_key(&basin, &stream); + + // Existence is decided from a Memory-level read; capture the row's + // commit seq so exists-outcomes can await durability (see fn doc). + let (existing_meta, existing_seq) = db_txn_get_with(&txn, &stream_meta_key, |entry| { + Ok((kv::stream_meta::deser_value(entry.value)?, entry.seq)) + }) + .await? + .unzip(); + if existing_meta + .as_ref() + .is_some_and(|meta| meta.deleted_at.is_some()) + || has_terminal_trim(&txn, StreamId::new(&basin, &stream)).await? + { + return Err(ProvisionStreamError::StreamDeletionPending( + StreamDeletionPendingError, + )); + } + + let prior_doe = existing_meta + .as_ref() + .map(|meta| meta.config.delete_on_empty); + let basin_defaults = basin_meta.config.default_stream_config; + let outcome = match (existing_meta, mode) { + (Some(existing), ProvisionMode::CreateOnly { request_token }) => { + let new_creation_idempotency_key = request_token + .as_ref() + .map(|req_token| creation_idempotency_key(req_token, &config)); + let result = if new_creation_idempotency_key.is_some() + && existing.creation_idempotency_key == new_creation_idempotency_key + { + Ok(ProvisionResult::Noop(StreamInfo { + name: stream, + created_at: existing.created_at, + deleted_at: None, + cipher: existing.cipher, + })) + } else { + Err(StreamAlreadyExistsError { basin, stream }.into()) + }; + drop(txn); + self.await_durable_seq(existing_seq.expect("existing meta was read")) + .await?; + return result; + } + (Some(existing), ProvisionMode::Ensure) => { + let desired_config = resolve_stream_config(config, basin_defaults); + let config_unchanged = existing.config == desired_config; + let meta = kv::stream_meta::StreamMeta { + config: desired_config, + cipher: existing.cipher, + created_at: existing.created_at, + deleted_at: None, + creation_idempotency_key: existing.creation_idempotency_key, + }; + if config_unchanged { + ProvisionResult::Noop(meta) + } else { + ProvisionResult::Updated(meta) + } + } + (None, ProvisionMode::CreateOnly { request_token }) => { + let new_creation_idempotency_key = request_token + .as_ref() + .map(|req_token| creation_idempotency_key(req_token, &config)); + ProvisionResult::Created(kv::stream_meta::StreamMeta { + config: resolve_stream_config(config, basin_defaults), + cipher: basin_meta.config.stream_cipher, + created_at: OffsetDateTime::now_utc(), + deleted_at: None, + creation_idempotency_key: new_creation_idempotency_key, + }) + } + (None, ProvisionMode::Ensure) => { + ProvisionResult::Created(kv::stream_meta::StreamMeta { + config: resolve_stream_config(config, basin_defaults), + cipher: basin_meta.config.stream_cipher, + created_at: OffsetDateTime::now_utc(), + deleted_at: None, + creation_idempotency_key: None, + }) + } + }; + + if matches!(&outcome, ProvisionResult::Noop(_)) { + drop(txn); + self.await_durable_seq(existing_seq.expect("noop implies existing meta")) + .await?; + } else { + let meta = outcome.inner(); + + txn.put(&stream_meta_key, kv::stream_meta::ser_value(meta))?; + + let stream_id = StreamId::new(&basin, &stream); + + if matches!(&outcome, ProvisionResult::Created(_)) { + txn.put( + kv::stream_id_mapping::ser_key(stream_id), + kv::stream_id_mapping::ser_value(&basin, &stream), + )?; + txn.put( + kv::stream_tail_position::ser_key(stream_id), + kv::stream_tail_position::ser_value(StreamPosition::MIN), + )?; + } + + self.commit_stream_config( + txn, + basin.clone(), + stream.clone(), + meta.config.clone(), + prior_doe, + ) + .await?; + } + + Ok(outcome.map(|meta| StreamInfo { + name: stream, + created_at: meta.created_at, + deleted_at: None, + cipher: meta.cipher, + })) + } + + pub async fn get_stream_config( + &self, + basin: BasinName, + stream: StreamName, + ) -> Result { + let meta = self + .db_get( + kv::stream_meta::ser_key(&basin, &stream), + kv::stream_meta::deser_value, + ) + .await? + .ok_or_else(|| StreamNotFoundError { + basin: basin.clone(), + stream: stream.clone(), + })?; + if meta.deleted_at.is_some() { + return Err(StreamDeletionPendingError.into()); + } + Ok(meta.config) + } + + pub async fn reconfigure_stream( + &self, + basin: BasinName, + stream: StreamName, + reconfig: StreamReconfiguration, + ) -> Result { + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + + let meta_key = kv::stream_meta::ser_key(&basin, &stream); + let (basin_meta, meta) = tokio::try_join!( + db_txn_get( + &txn, + kv::basin_meta::ser_key(&basin), + kv::basin_meta::deser_value, + ), + db_txn_get(&txn, &meta_key, kv::stream_meta::deser_value), + )?; + + let basin_meta = basin_meta.ok_or_else(|| BasinNotFoundError { + basin: basin.clone(), + })?; + if basin_meta.deleted_at.is_some() { + return Err(BasinDeletionPendingError { basin }.into()); + } + + let mut meta = meta.ok_or_else(|| StreamNotFoundError { + basin: basin.clone(), + stream: stream.clone(), + })?; + + if meta.deleted_at.is_some() + || has_terminal_trim(&txn, StreamId::new(&basin, &stream)).await? + { + return Err(StreamDeletionPendingError.into()); + } + + let prior_doe = meta.config.delete_on_empty; + + meta.config = resolve_stream_config( + OptionalStreamConfig::from(meta.config).reconfigure(reconfig), + basin_meta.config.default_stream_config, + ); + + txn.put(&meta_key, kv::stream_meta::ser_value(&meta))?; + + self.commit_stream_config(txn, basin, stream, meta.config.clone(), Some(prior_doe)) + .await?; + + Ok(meta.config) + } + + async fn commit_stream_config( + &self, + txn: DbTransaction, + basin: BasinName, + stream: StreamName, + config: StreamConfig, + prior_doe: Option, + ) -> Result<(), StorageError> { + let stream_id = StreamId::new(&basin, &stream); + match (prior_doe, config.delete_on_empty.min_age()) { + (None, Some(min_age)) => { + doe::schedule(&txn, stream_id, TimestampSecs::after(min_age)).await?; + } + (Some(prior), Some(min_age)) if prior.min_age != min_age => { + doe::schedule(&txn, stream_id, TimestampSecs::now()).await?; + } + (Some(prior), None) if prior.min_age().is_some() => { + doe::clear(&txn, stream_id).await?; + } + _ => (), + } + + let backend = self.clone(); + // Once a commit starts, cancellation must not discard its notification. + tokio::spawn(async move { + let seq = db_txn_commit_durable(txn) + .await? + .expect("stream metadata was written"); + backend.advise_stream_config(&basin, &stream, seq, config); + Ok::<_, StorageError>(()) + }) + .await + .expect("stream config commit task panicked") + } + + #[instrument(ret, err, skip(self))] + pub async fn delete_stream( + &self, + basin: BasinName, + stream: StreamName, + ) -> Result<(), DeleteStreamError> { + self.delete_stream_with_condition(basin, stream, TerminalTrimCondition::Always) + .await + .map(|_| ()) + } + + pub(super) async fn delete_stream_with_condition( + &self, + basin: BasinName, + stream: StreamName, + condition: TerminalTrimCondition, + ) -> Result { + let outcome = match self.streamer_client_guarded(&basin, &stream).await { + Ok(client) => client.terminal_trim(condition).await?, + Err(StreamerError::Storage(e)) => { + return Err(DeleteStreamError::Storage(e)); + } + Err(StreamerError::StreamNotFound(e)) => { + return Err(DeleteStreamError::StreamNotFound(e)); + } + Err(StreamerError::StreamDeletionPending(_)) => TerminalTrimOutcome::DeletionPending, + }; + if outcome == TerminalTrimOutcome::DeletionPending { + self.mark_stream_deleted(basin, stream).await?; + } + Ok(outcome) + } + + async fn mark_stream_deleted( + &self, + basin: BasinName, + stream: StreamName, + ) -> Result<(), DeleteStreamError> { + let txn = self.db.begin(IsolationLevel::SerializableSnapshot).await?; + // A delayed deletion request may resume after the trim worker has deleted the old + // stream and the name has been reused. Only mark metadata when this + // transaction also sees a terminal trim marker. + if !has_terminal_trim(&txn, StreamId::new(&basin, &stream)).await? { + let read_seq = txn.seqnum(); + drop(txn); + // The trim worker may have removed the marker without flushing yet. + // Wait for that removal to become durable before acknowledging deletion. + self.await_durable_seq(read_seq).await?; + return Ok(()); + } + let meta_key = kv::stream_meta::ser_key(&basin, &stream); + let (mut meta, seq) = db_txn_get_with(&txn, &meta_key, |entry| { + Ok((kv::stream_meta::deser_value(entry.value)?, entry.seq)) + }) + .await? + .ok_or_else(|| StreamNotFoundError { + basin, + stream: stream.clone(), + })?; + if meta.deleted_at.is_none() { + meta.deleted_at = Some(OffsetDateTime::now_utc()); + txn.put(&meta_key, kv::stream_meta::ser_value(&meta))?; + db_txn_commit_durable(txn).await?; + } else { + // The terminal trim is durable, but the metadata marker may not be yet. + drop(txn); + self.await_durable_seq(seq).await?; + } + Ok(()) + } +} + +async fn has_terminal_trim(txn: &DbTransaction, stream_id: StreamId) -> Result { + Ok(db_txn_get( + txn, + kv::stream_trim_point::ser_key(stream_id), + kv::stream_trim_point::deser_value, + ) + .await? + == Some(..NonZeroSeqNum::MAX)) +} + +fn creation_idempotency_key(req_token: &RequestToken, config: &OptionalStreamConfig) -> Bash { + Bash::length_prefixed(&[ + req_token.as_bytes(), + &s2_api::v1::config::StreamConfig::to_opt(config.clone()) + .as_ref() + .map(|v| serde_json::to_vec(v).expect("serializable")) + .unwrap_or_default(), + ]) +} diff --git a/lite/src/backend/test_util.rs b/lite/src/backend/test_util.rs new file mode 100644 index 00000000..3c45f38a --- /dev/null +++ b/lite/src/backend/test_util.rs @@ -0,0 +1,44 @@ +use std::future::Future; + +use slatedb::{Error, WriteHandle}; + +/// Finish a fixture write and assert that remote reads can observe it. +pub(super) trait DbWriteTestExt: + Future> + Sized +{ + async fn assert_durable(self) { + self.await + .expect("fixture write should succeed") + .await_durable() + .await + .expect("fixture write should become durable"); + } +} + +impl>> DbWriteTestExt for F {} + +/// Provision a real stream for lifecycle tests, including its ID mapping and tail. +pub(super) async fn create_stream( + backend: &super::Backend, + config: s2_common::config::OptionalStreamConfig, +) -> (s2_common::basin::BasinName, s2_common::stream::StreamName) { + use s2_common::{config::BasinConfig, resources::ProvisionMode}; + let basin: s2_common::basin::BasinName = "test-basin".parse().unwrap(); + let stream: s2_common::stream::StreamName = "test-stream".parse().unwrap(); + backend + .provision_basin(basin.clone(), BasinConfig::default(), ProvisionMode::Ensure) + .await + .unwrap(); + backend + .provision_stream( + basin.clone(), + stream.clone(), + config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .unwrap(); + (basin, stream) +} diff --git a/lite/src/backend/timestamp.rs b/lite/src/backend/timestamp.rs new file mode 100644 index 00000000..1455cac7 --- /dev/null +++ b/lite/src/backend/timestamp.rs @@ -0,0 +1,96 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct TimestampSecs(u32); + +impl TimestampSecs { + pub const ZERO: Self = Self(0); + pub const MAX: Self = Self(u32::MAX); + + pub fn now() -> Self { + Self::from_system_time(SystemTime::now()) + } + + pub fn after(dur: Duration) -> Self { + match SystemTime::now().checked_add(dur) { + Some(deadline) => Self::from_system_time(deadline), + None => Self(u32::MAX), + } + } + + pub fn from_secs(secs: u32) -> Self { + Self(secs) + } + + pub fn from_millis(millis: i64) -> Self { + if millis <= 0 { + return Self::ZERO; + } + let secs = (millis as u64) / 1000; + if secs >= u64::from(Self::MAX.0) { + Self::MAX + } else { + Self(secs as u32) + } + } + + pub fn as_u32(self) -> u32 { + self.0 + } + + pub fn checked_sub_duration(self, dur: Duration) -> Option { + u64::from(self.0) + .checked_sub(dur.as_secs()) + .map(|secs| Self(secs as u32)) + } + + pub fn saturating_add_duration(self, dur: Duration) -> Self { + Self( + u64::from(self.0) + .saturating_add(dur.as_secs()) + .min(u64::from(u32::MAX)) as u32, + ) + } + + fn from_system_time(time: SystemTime) -> Self { + match time.duration_since(UNIX_EPOCH) { + Ok(duration) => { + let secs = duration.as_secs(); + if secs >= u64::from(Self::MAX.0) { + Self::MAX + } else { + Self(secs as u32) + } + } + Err(_) => Self::ZERO, + } + } +} + +#[cfg(test)] +mod tests { + use super::TimestampSecs; + + #[test] + fn from_millis_converts_to_seconds() { + assert_eq!(TimestampSecs::from_millis(-1), TimestampSecs::ZERO); + assert_eq!(TimestampSecs::from_millis(0), TimestampSecs::ZERO); + assert_eq!( + TimestampSecs::from_millis(1_999), + TimestampSecs::from_secs(1) + ); + assert_eq!(TimestampSecs::from_millis(i64::MAX), TimestampSecs::MAX); + } + + #[test] + fn checked_sub_duration_subtracts_seconds() { + assert_eq!( + TimestampSecs::from_secs(10).checked_sub_duration(std::time::Duration::from_secs(3)), + Some(TimestampSecs::from_secs(7)) + ); + assert_eq!( + TimestampSecs::from_secs(3).checked_sub_duration(std::time::Duration::from_secs(10)), + None + ); + } +} diff --git a/lite/src/bin/openapi.rs b/lite/src/bin/openapi.rs new file mode 100644 index 00000000..10696305 --- /dev/null +++ b/lite/src/bin/openapi.rs @@ -0,0 +1,162 @@ +use s2_api::{ + data::Format, + v1::metrics::{AccountMetricSet, BasinMetricSet, StreamMetricSet}, +}; +use s2_common::resources::RequestToken; +use s2_lite::handlers::v1::{ + access_tokens::{ + __path_issue_access_token, __path_list_access_tokens, __path_revoke_access_token, + }, + basins::{ + __path_create_basin, __path_delete_basin, __path_ensure_basin, __path_get_basin_config, + __path_list_basins, __path_reconfigure_basin, + }, + locations::{__path_get_default_location, __path_list_locations, __path_set_default_location}, + metrics::{__path_account_metrics, __path_basin_metrics, __path_stream_metrics}, + paths::{self, cloud_endpoints}, + records::{__path_append, __path_check_tail, __path_read}, + streams::{ + __path_create_stream, __path_delete_stream, __path_ensure_stream, __path_get_stream_config, + __path_list_streams, __path_reconfigure_stream, + }, +}; +use utoipa::{ + Modify, OpenApi, + openapi::{ + path::Operation, + security::{Http, HttpAuthScheme, SecurityScheme}, + }, +}; + +#[derive(OpenApi)] +#[openapi( + info( + title = "S2, the durable streams API", + description = "Streams as a cloud storage primitive.", + version = "1.0.0", + license(name = "MIT"), + terms_of_service = "https://s2.dev/terms", + contact(email = "support@s2.dev") + ), + servers( + (url = cloud_endpoints::ACCOUNT) + ), + modifiers(&SecurityAddon, &PathLevelServersAddon), + security(("access_token" = [])), + tags( + (name = paths::metrics::TAG, description = paths::metrics::DESCRIPTION), + (name = paths::basins::TAG, description = paths::basins::DESCRIPTION), + (name = paths::access_tokens::TAG, description = paths::access_tokens::DESCRIPTION), + (name = paths::locations::TAG, description = paths::locations::DESCRIPTION), + (name = paths::streams::TAG, description = paths::streams::DESCRIPTION), + (name = paths::streams::records::TAG, description = paths::streams::records::DESCRIPTION), + ), + paths( + // Record ops + append, + read, + check_tail, + // Stream ops + list_streams, + create_stream, + get_stream_config, + ensure_stream, + delete_stream, + reconfigure_stream, + // Basin ops + list_basins, + create_basin, + get_basin_config, + ensure_basin, + delete_basin, + reconfigure_basin, + // Access token ops + list_access_tokens, + issue_access_token, + revoke_access_token, + // Location ops + list_locations, + get_default_location, + set_default_location, + // Metrics ops + account_metrics, + basin_metrics, + stream_metrics, + ), + components(schemas(Format, RequestToken, AccountMetricSet, BasinMetricSet, StreamMetricSet)) +)] +pub struct ApiDoc; + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "access_token", + SecurityScheme::Http( + Http::builder() + .scheme(HttpAuthScheme::Bearer) + .description(Some(concat!( + "Bearer authentication header of the form `Bearer `, ", + "where `` is your access token." + ))) + .build(), + ), + ) + } + } +} + +struct PathLevelServersAddon; + +impl PathLevelServersAddon { + fn get_operations_mut(path_item: &mut utoipa::openapi::PathItem) -> Vec<&mut Operation> { + [ + path_item.get.as_mut(), + path_item.put.as_mut(), + path_item.post.as_mut(), + path_item.delete.as_mut(), + path_item.options.as_mut(), + path_item.head.as_mut(), + path_item.patch.as_mut(), + path_item.trace.as_mut(), + ] + .into_iter() + .flatten() + .collect() + } +} + +impl Modify for PathLevelServersAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + for path_item in openapi.paths.paths.values_mut() { + let operations = Self::get_operations_mut(path_item); + + if operations.is_empty() { + continue; + } + + let all_servers: Vec<_> = operations.iter().map(|op| op.servers.as_ref()).collect(); + + let first_servers = all_servers.first().copied().flatten(); + let all_same = all_servers + .iter() + .all(|s| s.as_ref() == first_servers.as_ref()); + + if all_same && let Some(servers) = first_servers.cloned() { + path_item.servers = Some(servers); + + for op in Self::get_operations_mut(path_item) { + op.servers = None; + } + } + } + } +} + +fn main() -> eyre::Result<()> { + let json = ApiDoc::openapi().to_pretty_json()?; + println!("{json}"); + Ok(()) +} diff --git a/lite/src/bin/server.rs b/lite/src/bin/server.rs new file mode 100644 index 00000000..7f10ea47 --- /dev/null +++ b/lite/src/bin/server.rs @@ -0,0 +1,33 @@ +#[cfg(not(target_env = "msvc"))] +#[global_allocator] +static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + +use clap::Parser; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +fn install_rustls_crypto_provider() { + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("failed to install aws-lc-rs as default rustls crypto provider"); +} + +#[derive(Parser, Debug)] +#[command(author, version, about = "S2 Lite")] +struct Args { + #[command(flatten)] + lite: s2_lite::server::LiteArgs, +} + +#[tokio::main] +async fn main() -> eyre::Result<()> { + install_rustls_crypto_provider(); + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let args = Args::parse(); + s2_lite::server::run(args.lite).await +} diff --git a/lite/src/handlers/mod.rs b/lite/src/handlers/mod.rs new file mode 100644 index 00000000..13711cdf --- /dev/null +++ b/lite/src/handlers/mod.rs @@ -0,0 +1,38 @@ +pub mod v1; + +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::backend::Backend; + +pub fn router() -> axum::Router { + axum::Router::new() + .route(/* bw compat */ "/ping", axum::routing::get(health)) + .route("/health", axum::routing::get(health)) + .route("/metrics", axum::routing::get(metrics)) + .nest("/v1", v1::router()) +} + +async fn health(State(backend): State) -> Response { + match backend.db_status() { + Ok(()) => "OK".into_response(), + Err(err) => (StatusCode::SERVICE_UNAVAILABLE, format!("{err:?}")).into_response(), + } +} + +async fn metrics(State(_backend): State) -> Response { + match crate::metrics::gather() { + Ok(body) => ( + [( + axum::http::header::CONTENT_TYPE, + "text/plain; version=0.0.4", + )], + body, + ) + .into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/lite/src/handlers/v1/access_tokens.rs b/lite/src/handlers/v1/access_tokens.rs new file mode 100644 index 00000000..1b57fa27 --- /dev/null +++ b/lite/src/handlers/v1/access_tokens.rs @@ -0,0 +1,99 @@ +use axum::extract::{FromRequest, Path, Query, State}; +use http::StatusCode; +use s2_api::{data::Json, v1 as v1t}; +use s2_common::access::AccessTokenId; + +use crate::{backend::Backend, handlers::v1::error::ServiceError}; + +pub fn router() -> axum::Router { + use axum::routing::{delete, get, post}; + axum::Router::new() + .route(super::paths::access_tokens::LIST, get(list_access_tokens)) + .route(super::paths::access_tokens::ISSUE, post(issue_access_token)) + .route( + super::paths::access_tokens::REVOKE, + delete(revoke_access_token), + ) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct ListArgs { + #[from_request(via(Query))] + _request: v1t::access::ListAccessTokensRequest, +} + +/// List access tokens. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::access_tokens::LIST, + tag = super::paths::access_tokens::TAG, + responses( + (status = StatusCode::OK, body = v1t::access::ListAccessTokensResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::access::ListAccessTokensRequest), +))] +pub async fn list_access_tokens( + State(_backend): State, + ListArgs { .. }: ListArgs, +) -> Result, ServiceError> { + Err(ServiceError::NotImplemented) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct IssueArgs { + #[from_request(via(Json))] + _request: v1t::access::IssueAccessTokenRequest, +} + +/// Issue a new access token. +#[cfg_attr(feature = "utoipa", utoipa::path( + post, + path = super::paths::access_tokens::ISSUE, + tag = super::paths::access_tokens::TAG, + request_body = v1t::access::IssueAccessTokenRequest, + responses( + (status = StatusCode::CREATED, body = v1t::access::IssueAccessTokenResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + ), +))] +pub async fn issue_access_token( + State(_backend): State, + IssueArgs { .. }: IssueArgs, +) -> Result<(StatusCode, Json), ServiceError> { + Err(ServiceError::NotImplemented) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct RevokeArgs { + #[from_request(via(Path))] + _id: AccessTokenId, +} + +/// Revoke an access token. +#[cfg_attr(feature = "utoipa", utoipa::path( + delete, + path = super::paths::access_tokens::REVOKE, + tag = super::paths::access_tokens::TAG, + responses( + (status = StatusCode::NO_CONTENT), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::AccessTokenIdPathSegment), +))] +pub async fn revoke_access_token( + State(_backend): State, + RevokeArgs { .. }: RevokeArgs, +) -> Result { + Err(ServiceError::NotImplemented) +} diff --git a/lite/src/handlers/v1/basins.rs b/lite/src/handlers/v1/basins.rs new file mode 100644 index 00000000..1062d5e6 --- /dev/null +++ b/lite/src/handlers/v1/basins.rs @@ -0,0 +1,274 @@ +use axum::extract::{FromRequest, Path, Query, State}; +use http::StatusCode; +use s2_api::{ + data::{Json, extract::JsonOpt}, + v1 as v1t, +}; +use s2_common::{ + basin::{BasinName, ListBasinsRequest}, + config::{BasinConfig, BasinReconfiguration}, + http::extract::HeaderOpt, + resources::{PROVISION_RESULT_HEADER, Page, ProvisionMode, ProvisionResult, RequestToken}, +}; + +use crate::{backend::Backend, handlers::v1::error::ServiceError}; + +pub fn router() -> axum::Router { + use axum::routing::{delete, get, patch, post, put}; + axum::Router::new() + .route(super::paths::basins::LIST, get(list_basins)) + .route(super::paths::basins::CREATE, post(create_basin)) + .route(super::paths::basins::GET_CONFIG, get(get_basin_config)) + .route(super::paths::basins::ENSURE, put(ensure_basin)) + .route(super::paths::basins::DELETE, delete(delete_basin)) + .route(super::paths::basins::RECONFIGURE, patch(reconfigure_basin)) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct ListArgs { + #[from_request(via(Query))] + request: v1t::basin::ListBasinsRequest, +} + +/// List basins. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::basins::LIST, + tag = super::paths::basins::TAG, + responses( + (status = StatusCode::OK, body = v1t::basin::ListBasinsResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::basin::ListBasinsRequest), +))] +pub async fn list_basins( + State(backend): State, + ListArgs { request }: ListArgs, +) -> Result, ServiceError> { + let request: ListBasinsRequest = request.try_into()?; + let Page { values, has_more } = backend.list_basins(request).await?; + Ok(Json(v1t::basin::ListBasinsResponse { + basins: values.into_iter().map(Into::into).collect(), + has_more, + })) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct CreateArgs { + request_token: HeaderOpt, + #[from_request(via(Json))] + request: v1t::basin::CreateBasinRequest, +} + +/// Create a basin. +#[cfg_attr(feature = "utoipa", utoipa::path( + post, + path = super::paths::basins::CREATE, + tag = super::paths::basins::TAG, + params(v1t::S2RequestTokenHeader), + request_body = v1t::basin::CreateBasinRequest, + responses( + (status = StatusCode::OK, body = v1t::basin::BasinInfo), + (status = StatusCode::CREATED, body = v1t::basin::BasinInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), +))] +pub async fn create_basin( + State(backend): State, + CreateArgs { + request_token: HeaderOpt(request_token), + request, + }: CreateArgs, +) -> Result< + ( + StatusCode, + [(http::HeaderName, &'static str); 1], + Json, + ), + ServiceError, +> { + let config: BasinConfig = request + .config + .map(TryInto::try_into) + .transpose()? + .unwrap_or_default(); + let info = backend + .provision_basin( + request.basin, + config, + ProvisionMode::CreateOnly { request_token }, + ) + .await? + .map(Into::into); + let (outcome, info) = match info { + ProvisionResult::Created(info) => ("created", info), + ProvisionResult::Noop(info) => ("noop", info), + ProvisionResult::Updated(_) => unreachable!("CreateOnly mode never produces Updated"), + }; + Ok(( + StatusCode::CREATED, + [(PROVISION_RESULT_HEADER.clone(), outcome)], + Json(info), + )) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct GetConfigArgs { + #[from_request(via(Path))] + basin: BasinName, +} + +/// Get basin configuration. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::basins::GET_CONFIG, + tag = super::paths::basins::TAG, + responses( + (status = StatusCode::OK, body = v1t::config::BasinConfig), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::BasinNamePathSegment), +))] +pub async fn get_basin_config( + State(backend): State, + GetConfigArgs { basin }: GetConfigArgs, +) -> Result, ServiceError> { + let config = backend.get_basin_config(basin).await?; + Ok(Json(config.into())) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct EnsureArgs { + #[from_request(via(Path))] + basin: BasinName, + request: JsonOpt, +} + +/// Ensure a basin. +#[cfg_attr(feature = "utoipa", utoipa::path( + put, + path = super::paths::basins::ENSURE, + tag = super::paths::basins::TAG, + request_body = Option, + params(v1t::BasinNamePathSegment), + responses( + (status = StatusCode::OK, body = v1t::basin::BasinInfo), + (status = StatusCode::CREATED, body = v1t::basin::BasinInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), +))] +pub async fn ensure_basin( + State(backend): State, + EnsureArgs { + basin, + request: JsonOpt(request), + }: EnsureArgs, +) -> Result< + ( + StatusCode, + [(http::HeaderName, &'static str); 1], + Json, + ), + ServiceError, +> { + let config: BasinConfig = request + .and_then(|req| req.config) + .map(TryInto::try_into) + .transpose()? + .unwrap_or_default(); + let info = backend + .provision_basin(basin, config, ProvisionMode::Ensure) + .await? + .map(Into::into); + let (status, outcome, info) = match info { + ProvisionResult::Created(info) => (StatusCode::CREATED, "created", info), + ProvisionResult::Updated(info) => (StatusCode::OK, "updated", info), + ProvisionResult::Noop(info) => (StatusCode::OK, "noop", info), + }; + Ok(( + status, + [(PROVISION_RESULT_HEADER.clone(), outcome)], + Json(info), + )) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct DeleteArgs { + #[from_request(via(Path))] + basin: BasinName, +} + +/// Delete a basin. +#[cfg_attr(feature = "utoipa", utoipa::path( + delete, + path = super::paths::basins::DELETE, + tag = super::paths::basins::TAG, + responses( + (status = StatusCode::ACCEPTED), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::BasinNamePathSegment), +))] +pub async fn delete_basin( + State(backend): State, + DeleteArgs { basin }: DeleteArgs, +) -> Result { + backend.delete_basin(basin).await?; + Ok(StatusCode::ACCEPTED) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct ReconfigureArgs { + #[from_request(via(Path))] + basin: BasinName, + #[from_request(via(Json))] + reconfiguration: v1t::config::BasinReconfiguration, +} + +/// Reconfigure a basin. +#[cfg_attr(feature = "utoipa", utoipa::path( + patch, + path = super::paths::basins::RECONFIGURE, + tag = super::paths::basins::TAG, + request_body = v1t::config::BasinReconfiguration, + responses( + (status = StatusCode::OK, body = v1t::config::BasinConfig), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::BasinNamePathSegment), +))] +pub async fn reconfigure_basin( + State(backend): State, + ReconfigureArgs { + basin, + reconfiguration, + }: ReconfigureArgs, +) -> Result, ServiceError> { + let reconfiguration: BasinReconfiguration = reconfiguration.try_into()?; + let config = backend.reconfigure_basin(basin, reconfiguration).await?; + Ok(Json(config.into())) +} diff --git a/lite/src/handlers/v1/error.rs b/lite/src/handlers/v1/error.rs new file mode 100644 index 00000000..e589edc2 --- /dev/null +++ b/lite/src/handlers/v1/error.rs @@ -0,0 +1,331 @@ +use axum::{ + extract::rejection::{PathRejection, QueryRejection}, + response::{IntoResponse, Response}, +}; +use s2_api::{ + data::extract::{JsonExtractionRejection, ProtoRejection}, + v1::{ + self as v1t, + error::{ErrorCode, ErrorInfo, ErrorResponse, StandardError}, + stream::{AppendInputStreamError, extract::AppendRequestRejection, s2s}, + }, +}; +use s2_common::{ValidationError, http::extract::HeaderRejection}; +use s2_storage::record::RecordDecryptionError; + +use crate::backend::error::{ + AppendConditionFailedError, AppendError, CheckTailError, DeleteBasinError, DeleteStreamError, + GetBasinConfigError, GetStreamConfigError, ListBasinsError, ListStreamsError, + ProvisionBasinError, ProvisionStreamError, ReadError, ReconfigureBasinError, + ReconfigureStreamError, +}; + +#[derive(Debug, thiserror::Error)] +pub enum ServiceError { + #[error(transparent)] + HeaderRejection(#[from] HeaderRejection), + #[error(transparent)] + PathRejection(#[from] PathRejection), + #[error(transparent)] + QueryRejection(#[from] QueryRejection), + #[error(transparent)] + JsonRejection(#[from] JsonExtractionRejection), + #[error(transparent)] + ProtoRejection(#[from] ProtoRejection), + #[error(transparent)] + AppendInputStream(#[from] AppendInputStreamError), + #[error(transparent)] + Validation(#[from] ValidationError), + #[error(transparent)] + ListBasins(#[from] ListBasinsError), + #[error(transparent)] + ProvisionBasin(#[from] ProvisionBasinError), + #[error(transparent)] + GetBasinConfig(#[from] GetBasinConfigError), + #[error(transparent)] + DeleteBasin(#[from] DeleteBasinError), + #[error(transparent)] + ReconfigureBasin(#[from] ReconfigureBasinError), + #[error(transparent)] + ListStreams(#[from] ListStreamsError), + #[error(transparent)] + ProvisionStream(#[from] ProvisionStreamError), + #[error(transparent)] + GetStreamConfig(#[from] GetStreamConfigError), + #[error(transparent)] + DeleteStream(#[from] DeleteStreamError), + #[error(transparent)] + ReconfigureStream(#[from] ReconfigureStreamError), + #[error(transparent)] + CheckTail(#[from] CheckTailError), + #[error(transparent)] + Append(#[from] AppendError), + #[error(transparent)] + Read(#[from] ReadError), + #[error("Not implemented")] + NotImplemented, +} + +impl From for ServiceError { + fn from(value: AppendRequestRejection) -> Self { + match value { + AppendRequestRejection::HeaderRejection(e) => ServiceError::from(e), + AppendRequestRejection::JsonRejection(e) => ServiceError::from(e), + AppendRequestRejection::ProtoRejection(e) => ServiceError::from(e), + AppendRequestRejection::Validation(e) => ServiceError::Validation(e), + } + } +} + +impl ServiceError { + pub fn to_response(&self) -> ErrorResponse { + match self { + ServiceError::HeaderRejection(e) => standard(ErrorCode::BadHeader, e.to_string()), + ServiceError::PathRejection(e) => standard(ErrorCode::BadPath, e.body_text()), + ServiceError::QueryRejection(e) => standard(ErrorCode::BadQuery, e.body_text()), + ServiceError::JsonRejection(e) => standard(ErrorCode::BadJson, e.body_text()), + ServiceError::ProtoRejection(e) => standard(ErrorCode::BadProto, e.to_string()), + ServiceError::AppendInputStream(e) => match e { + AppendInputStreamError::FrameDecode(e) => { + standard(ErrorCode::BadFrame, e.to_string()) + } + AppendInputStreamError::Validation(e) => { + standard(ErrorCode::Invalid, e.to_string()) + } + }, + ServiceError::Validation(e) => standard(ErrorCode::Invalid, e.to_string()), + ServiceError::ListBasins(e) => match e { + ListBasinsError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + }, + ServiceError::ProvisionBasin(e) => match e { + ProvisionBasinError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + ProvisionBasinError::TransactionConflict(e) => { + standard(ErrorCode::TransactionConflict, e.to_string()) + } + ProvisionBasinError::BasinAlreadyExists(e) => { + standard(ErrorCode::ResourceAlreadyExists, e.to_string()) + } + ProvisionBasinError::BasinDeletionPending(e) => { + standard(ErrorCode::BasinDeletionPending, e.to_string()) + } + }, + ServiceError::GetBasinConfig(e) => match e { + GetBasinConfigError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + GetBasinConfigError::BasinNotFound(e) => { + standard(ErrorCode::BasinNotFound, e.to_string()) + } + }, + ServiceError::DeleteBasin(e) => match e { + DeleteBasinError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + DeleteBasinError::TransactionConflict(e) => { + standard(ErrorCode::TransactionConflict, e.to_string()) + } + DeleteBasinError::BasinNotFound(e) => { + standard(ErrorCode::BasinNotFound, e.to_string()) + } + }, + ServiceError::ReconfigureBasin(e) => match e { + ReconfigureBasinError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + ReconfigureBasinError::TransactionConflict(e) => { + standard(ErrorCode::TransactionConflict, e.to_string()) + } + ReconfigureBasinError::BasinNotFound(e) => { + standard(ErrorCode::BasinNotFound, e.to_string()) + } + ReconfigureBasinError::BasinDeletionPending(e) => { + standard(ErrorCode::BasinDeletionPending, e.to_string()) + } + }, + ServiceError::ListStreams(e) => match e { + ListStreamsError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + }, + ServiceError::ProvisionStream(e) => match e { + ProvisionStreamError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + ProvisionStreamError::TransactionConflict(e) => { + standard(ErrorCode::TransactionConflict, e.to_string()) + } + ProvisionStreamError::BasinNotFound(e) => { + standard(ErrorCode::BasinNotFound, e.to_string()) + } + ProvisionStreamError::BasinDeletionPending(e) => { + standard(ErrorCode::BasinDeletionPending, e.to_string()) + } + ProvisionStreamError::StreamAlreadyExists(e) => { + standard(ErrorCode::ResourceAlreadyExists, e.to_string()) + } + ProvisionStreamError::StreamDeletionPending(e) => { + standard(ErrorCode::StreamDeletionPending, e.to_string()) + } + ProvisionStreamError::Validation(e) => standard(ErrorCode::Invalid, e.to_string()), + }, + ServiceError::GetStreamConfig(e) => match e { + GetStreamConfigError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + GetStreamConfigError::StreamNotFound(e) => { + standard(ErrorCode::StreamNotFound, e.to_string()) + } + GetStreamConfigError::StreamDeletionPending(e) => { + standard(ErrorCode::StreamDeletionPending, e.to_string()) + } + }, + ServiceError::DeleteStream(e) => match e { + DeleteStreamError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + DeleteStreamError::TransactionConflict(e) => { + standard(ErrorCode::TransactionConflict, e.to_string()) + } + DeleteStreamError::StreamerMissingInActionError(e) => { + standard(ErrorCode::Unavailable, e.to_string()) + } + DeleteStreamError::RequestDroppedError(e) => { + // Unavailable error code promised to be side-effect free, + // The terminal trim marker may have become durable prior to drop. + standard(ErrorCode::Other, e.to_string()) + } + DeleteStreamError::StreamNotFound(e) => { + standard(ErrorCode::StreamNotFound, e.to_string()) + } + }, + ServiceError::ReconfigureStream(e) => match e { + ReconfigureStreamError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + ReconfigureStreamError::TransactionConflict(e) => { + standard(ErrorCode::TransactionConflict, e.to_string()) + } + ReconfigureStreamError::BasinNotFound(e) => { + standard(ErrorCode::BasinNotFound, e.to_string()) + } + ReconfigureStreamError::BasinDeletionPending(e) => { + standard(ErrorCode::BasinDeletionPending, e.to_string()) + } + ReconfigureStreamError::StreamNotFound(e) => { + standard(ErrorCode::StreamNotFound, e.to_string()) + } + ReconfigureStreamError::StreamDeletionPending(e) => { + standard(ErrorCode::StreamDeletionPending, e.to_string()) + } + ReconfigureStreamError::Validation(e) => { + standard(ErrorCode::Invalid, e.to_string()) + } + }, + ServiceError::CheckTail(e) => match e { + CheckTailError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + CheckTailError::TransactionConflict(e) => { + standard(ErrorCode::TransactionConflict, e.to_string()) + } + CheckTailError::StreamerMissingInActionError(_) => { + standard(ErrorCode::Unavailable, e.to_string()) + } + CheckTailError::BasinNotFound(e) => { + standard(ErrorCode::BasinNotFound, e.to_string()) + } + CheckTailError::StreamNotFound(e) => { + standard(ErrorCode::StreamNotFound, e.to_string()) + } + CheckTailError::BasinDeletionPending(e) => { + standard(ErrorCode::BasinDeletionPending, e.to_string()) + } + CheckTailError::StreamDeletionPending(e) => { + standard(ErrorCode::StreamDeletionPending, e.to_string()) + } + }, + ServiceError::Append(e) => match e { + AppendError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + AppendError::EncryptionSpecResolution(e) => { + standard(ErrorCode::BadHeader, e.to_string()) + } + AppendError::TransactionConflict(e) => { + standard(ErrorCode::TransactionConflict, e.to_string()) + } + AppendError::StreamerMissingInActionError(e) => { + standard(ErrorCode::Unavailable, e.to_string()) + } + AppendError::RequestDroppedError(e) => { + // Unavailable error code promised to be side-effect free, + // The append may have become durable prior to drop. + standard(ErrorCode::Other, e.to_string()) + } + AppendError::BasinNotFound(e) => standard(ErrorCode::BasinNotFound, e.to_string()), + AppendError::StreamNotFound(e) => { + standard(ErrorCode::StreamNotFound, e.to_string()) + } + AppendError::BasinDeletionPending(e) => { + standard(ErrorCode::BasinDeletionPending, e.to_string()) + } + AppendError::StreamDeletionPending(e) => { + standard(ErrorCode::StreamDeletionPending, e.to_string()) + } + AppendError::ConditionFailed(e) => ErrorResponse::AppendConditionFailed(match e { + AppendConditionFailedError::FencingTokenMismatch { actual, .. } => { + v1t::stream::AppendConditionFailed::FencingTokenMismatch(actual.clone()) + } + AppendConditionFailedError::SeqNumMismatch { + assigned_seq_num, .. + } => v1t::stream::AppendConditionFailed::SeqNumMismatch(*assigned_seq_num), + }), + AppendError::TimestampMissing(e) => standard(ErrorCode::Invalid, e.to_string()), + AppendError::MaxSeqNum(e) => standard(ErrorCode::Invalid, e.to_string()), + }, + ServiceError::Read(e) => match e { + ReadError::Storage(e) => standard(ErrorCode::Storage, e.to_string()), + ReadError::EncryptionSpecResolution(e) => { + standard(ErrorCode::BadHeader, e.to_string()) + } + ReadError::RecordDecryption(e) => match e { + RecordDecryptionError::AuthenticationFailed => { + standard(ErrorCode::DecryptionFailed, e.to_string()) + } + RecordDecryptionError::AlgorithmMismatch { .. } + | RecordDecryptionError::MalformedEncryptedRecord + | RecordDecryptionError::MeteredSizeMismatch { .. } + | RecordDecryptionError::MalformedDecryptedRecord(_) => { + standard(ErrorCode::Storage, e.to_string()) + } + }, + ReadError::TransactionConflict(e) => { + standard(ErrorCode::TransactionConflict, e.to_string()) + } + ReadError::StreamerMissingInActionError(_) => { + standard(ErrorCode::Unavailable, e.to_string()) + } + ReadError::BasinNotFound(e) => standard(ErrorCode::BasinNotFound, e.to_string()), + ReadError::StreamNotFound(e) => standard(ErrorCode::StreamNotFound, e.to_string()), + ReadError::BasinDeletionPending(e) => { + standard(ErrorCode::BasinDeletionPending, e.to_string()) + } + ReadError::StreamDeletionPending(e) => { + standard(ErrorCode::StreamDeletionPending, e.to_string()) + } + ReadError::Unwritten(tail) => ErrorResponse::Unwritten(v1t::stream::TailResponse { + tail: tail.0.into(), + }), + }, + ServiceError::NotImplemented => { + standard(ErrorCode::NotImplemented, "Not implemented".to_string()) + } + } + } +} + +impl IntoResponse for ServiceError { + fn into_response(self) -> Response { + self.to_response().into_response() + } +} + +impl From for s2s::TerminalMessage { + fn from(value: ServiceError) -> Self { + let (status, body) = value.to_response().to_parts(); + s2s::TerminalMessage { + status: status.as_u16(), + body, + } + } +} + +fn standard(code: ErrorCode, message: impl Into) -> ErrorResponse { + ErrorResponse::Standard(StandardError { + status: code.status(), + info: ErrorInfo { + code: code.into(), + message: message.into(), + }, + }) +} diff --git a/lite/src/handlers/v1/locations.rs b/lite/src/handlers/v1/locations.rs new file mode 100644 index 00000000..913c4f85 --- /dev/null +++ b/lite/src/handlers/v1/locations.rs @@ -0,0 +1,74 @@ +use axum::extract::{FromRequest, State}; +use s2_api::{data::Json, v1 as v1t}; + +use crate::{backend::Backend, handlers::v1::error::ServiceError}; + +pub fn router() -> axum::Router { + use axum::routing::{get, put}; + axum::Router::new() + .route(super::paths::locations::LIST, get(list_locations)) + .route(super::paths::locations::DEFAULT, get(get_default_location)) + .route(super::paths::locations::DEFAULT, put(set_default_location)) +} + +/// List locations. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::locations::LIST, + tag = super::paths::locations::TAG, + responses( + (status = 200, body = Vec), + (status = 400, body = v1t::error::ErrorInfo), + (status = 403, body = v1t::error::ErrorInfo), + (status = 408, body = v1t::error::ErrorInfo), + ), +))] +pub async fn list_locations( + State(_backend): State, +) -> Result>, ServiceError> { + Err(ServiceError::NotImplemented) +} + +/// Get the default location. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::locations::DEFAULT, + tag = super::paths::locations::TAG, + responses( + (status = 200, body = v1t::location::GetDefaultLocationResponse), + (status = 403, body = v1t::error::ErrorInfo), + (status = 408, body = v1t::error::ErrorInfo), + ), +))] +pub async fn get_default_location( + State(_backend): State, +) -> Result, ServiceError> { + Err(ServiceError::NotImplemented) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct SetDefaultArgs { + #[from_request(via(Json))] + _request: v1t::location::SetDefaultLocationRequest, +} + +/// Set the default location. +#[cfg_attr(feature = "utoipa", utoipa::path( + put, + path = super::paths::locations::DEFAULT, + tag = super::paths::locations::TAG, + request_body = v1t::location::SetDefaultLocationRequest, + responses( + (status = 200, body = v1t::location::GetDefaultLocationResponse), + (status = 400, body = v1t::error::ErrorInfo), + (status = 403, body = v1t::error::ErrorInfo), + (status = 408, body = v1t::error::ErrorInfo), + ), +))] +pub async fn set_default_location( + State(_backend): State, + SetDefaultArgs { .. }: SetDefaultArgs, +) -> Result, ServiceError> { + Err(ServiceError::NotImplemented) +} diff --git a/lite/src/handlers/v1/metrics.rs b/lite/src/handlers/v1/metrics.rs new file mode 100644 index 00000000..98018bb3 --- /dev/null +++ b/lite/src/handlers/v1/metrics.rs @@ -0,0 +1,98 @@ +use axum::extract::{FromRequest, Path, Query, State}; +use s2_api::{data::Json, v1 as v1t}; +use s2_common::{basin::BasinName, stream::StreamName}; + +use crate::{backend::Backend, handlers::v1::error::ServiceError}; + +pub fn router() -> axum::Router { + use axum::routing::get; + axum::Router::new() + .route(super::paths::metrics::ACCOUNT, get(account_metrics)) + .route(super::paths::metrics::BASIN, get(basin_metrics)) + .route(super::paths::metrics::STREAM, get(stream_metrics)) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct AccountArgs { + #[from_request(via(Query))] + _request: v1t::metrics::AccountMetricSetRequest, +} + +/// Account-level metrics. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::metrics::ACCOUNT, + tag = super::paths::metrics::TAG, + responses( + (status = StatusCode::OK, body = v1t::metrics::MetricSetResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::metrics::AccountMetricSetRequest) +))] +pub async fn account_metrics( + State(_backend): State, + AccountArgs { .. }: AccountArgs, +) -> Result, ServiceError> { + Err(ServiceError::NotImplemented) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct BasinArgs { + #[from_request(via(Path))] + _basin: BasinName, + #[from_request(via(Query))] + _request: v1t::metrics::BasinMetricSetRequest, +} + +/// Basin-level metrics. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::metrics::BASIN, + tag = super::paths::metrics::TAG, + responses( + (status = StatusCode::OK, body = v1t::metrics::MetricSetResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::metrics::BasinMetricSetRequest, v1t::BasinNamePathSegment), +))] +pub async fn basin_metrics( + State(_backend): State, + BasinArgs { .. }: BasinArgs, +) -> Result, ServiceError> { + Err(ServiceError::NotImplemented) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct StreamArgs { + #[from_request(via(Path))] + _basin_and_stream: (BasinName, StreamName), + #[from_request(via(Query))] + _request: v1t::metrics::StreamMetricSetRequest, +} + +/// Stream-level metrics. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::metrics::STREAM, + tag = super::paths::metrics::TAG, + responses( + (status = StatusCode::OK, body = v1t::metrics::MetricSetResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::metrics::StreamMetricSetRequest, v1t::BasinNamePathSegment, v1t::StreamNamePathSegment), +))] +pub async fn stream_metrics( + State(_backend): State, + StreamArgs { .. }: StreamArgs, +) -> Result, ServiceError> { + Err(ServiceError::NotImplemented) +} diff --git a/lite/src/handlers/v1/mod.rs b/lite/src/handlers/v1/mod.rs new file mode 100644 index 00000000..9f220fdb --- /dev/null +++ b/lite/src/handlers/v1/mod.rs @@ -0,0 +1,35 @@ +use tower_http::{compression::CompressionLayer, decompression::RequestDecompressionLayer}; + +use crate::backend::Backend; + +pub mod access_tokens; +pub mod basins; +mod error; +pub mod locations; +pub mod metrics; +pub mod paths; +pub mod records; +pub mod streams; + +const MAX_UNARY_READ_WAIT: std::time::Duration = std::time::Duration::from_secs(60); + +pub fn router() -> axum::Router { + let compress_when = { + use tower_http::compression::predicate::{NotForContentType, Predicate, SizeAbove}; + SizeAbove::new(1024) + .and(NotForContentType::SSE) + .and(NotForContentType::const_new("s2s/proto")) + }; + + axum::Router::new() + .merge(basins::router()) + .merge(streams::router()) + .merge(records::router()) + .merge(locations::router()) + .merge(access_tokens::router()) + .merge(metrics::router()) + .route_layer(( + CompressionLayer::new().compress_when(compress_when), + RequestDecompressionLayer::new(), + )) +} diff --git a/lite/src/handlers/v1/paths.rs b/lite/src/handlers/v1/paths.rs new file mode 100644 index 00000000..995bce3c --- /dev/null +++ b/lite/src/handlers/v1/paths.rs @@ -0,0 +1,63 @@ +pub mod basins { + pub const TAG: &str = "basins"; + pub const DESCRIPTION: &str = "Manage basins"; + + pub const LIST: &str = "/basins"; + pub const CREATE: &str = "/basins"; + pub const ENSURE: &str = "/basins/{basin}"; + pub const DELETE: &str = "/basins/{basin}"; + pub const GET_CONFIG: &str = "/basins/{basin}"; + pub const RECONFIGURE: &str = "/basins/{basin}"; +} + +pub mod metrics { + pub const TAG: &str = "metrics"; + pub const DESCRIPTION: &str = "Usage metrics and data."; + + pub const ACCOUNT: &str = "/metrics"; + pub const BASIN: &str = "/metrics/{basin}"; + pub const STREAM: &str = "/metrics/{basin}/{stream}"; +} + +pub mod access_tokens { + pub const TAG: &str = "access-tokens"; + pub const DESCRIPTION: &str = "Manage access tokens"; + + pub const LIST: &str = "/access-tokens"; + pub const ISSUE: &str = "/access-tokens"; + pub const REVOKE: &str = "/access-tokens/{id}"; +} + +pub mod locations { + pub const TAG: &str = "locations"; + pub const DESCRIPTION: &str = "Manage locations"; + + pub const LIST: &str = "/locations"; + pub const DEFAULT: &str = "/locations/default"; +} + +pub mod streams { + pub const TAG: &str = "streams"; + pub const DESCRIPTION: &str = "Manage streams"; + + pub const LIST: &str = "/streams"; + pub const CREATE: &str = "/streams"; + pub const ENSURE: &str = "/streams/{stream}"; + pub const DELETE: &str = "/streams/{stream}"; + pub const GET_CONFIG: &str = "/streams/{stream}"; + pub const RECONFIGURE: &str = "/streams/{stream}"; + + pub mod records { + pub const TAG: &str = "records"; + pub const DESCRIPTION: &str = "Manage records"; + + pub const CHECK_TAIL: &str = "/streams/{stream}/records/tail"; + pub const READ: &str = "/streams/{stream}/records"; + pub const APPEND: &str = "/streams/{stream}/records"; + } +} + +pub mod cloud_endpoints { + pub const ACCOUNT: &str = "https://a.s2.dev/v1"; + pub const BASIN: &str = "https://{basin}.b.s2.dev/v1"; +} diff --git a/lite/src/handlers/v1/records.rs b/lite/src/handlers/v1/records.rs new file mode 100644 index 00000000..10fe2907 --- /dev/null +++ b/lite/src/handlers/v1/records.rs @@ -0,0 +1,1353 @@ +use std::time::Duration; + +use axum::{ + body::Body, + extract::{FromRequest, Path, Query, State}, + response::{IntoResponse, Response}, +}; +use futures::{Stream, StreamExt, TryStreamExt}; +use http::{HeaderValue, StatusCode, header}; +use s2_api::{ + data::{Json, Proto}, + mime::JsonOrProto, + v1::{self as v1t, stream::s2s}, +}; +use s2_common::{ + ValidationError, + basin::BasinName, + caps::RECORD_BATCH_MAX, + http::extract::Header, + read_extent::{CountOrBytes, ReadLimit}, + record::{Metered, MeteredSize as _}, + stream::{ReadBatch, ReadEnd, ReadFrom, ReadSessionOutput, ReadStart, StreamName}, +}; + +use crate::{ + backend::{Backend, error::ReadError}, + handlers::v1::error::ServiceError, +}; + +pub fn router() -> axum::Router { + use axum::routing::{get, post}; + axum::Router::new() + .route(super::paths::streams::records::CHECK_TAIL, get(check_tail)) + .route(super::paths::streams::records::READ, get(read)) + .route(super::paths::streams::records::APPEND, post(append)) +} + +fn validate_read_until(start: ReadStart, end: ReadEnd) -> Result<(), ServiceError> { + if let ReadFrom::Timestamp(ts) = start.from + && end.until.deny(ts) + { + return Err(ServiceError::Validation(ValidationError( + "start `timestamp` exceeds or equal to `until`".to_owned(), + ))); + } + Ok(()) +} + +/// Adjusts the read bounds to resume after `last_event_id`, and returns the +/// count/bytes already delivered so emitted event ids stay cumulative across +/// reconnects. +fn apply_last_event_id( + mut start: ReadStart, + mut end: v1t::stream::ReadEnd, + last_event_id: Option, +) -> (ReadStart, v1t::stream::ReadEnd, CountOrBytes) { + let mut delivered = CountOrBytes::ZERO; + if let Some(v1t::stream::sse::LastEventId { + seq_num, + count, + bytes, + }) = last_event_id + { + start.from = ReadFrom::SeqNum(seq_num.saturating_add(1)); + end.count = end.count.map(|c| c.saturating_sub(count)); + end.bytes = end.bytes.map(|c| c.saturating_sub(bytes)); + delivered = CountOrBytes { count, bytes }; + } + (start, end, delivered) +} + +enum ReadMode { + Unary, + Streaming, +} + +fn prepare_read( + start: ReadStart, + end: v1t::stream::ReadEnd, + mode: ReadMode, +) -> Result<(ReadStart, ReadEnd), ServiceError> { + let mut end: ReadEnd = end.into(); + if matches!(mode, ReadMode::Unary) { + end.limit = ReadLimit::CountOrBytes(end.limit.into_allowance(RECORD_BATCH_MAX)); + end.wait = end.wait.map(|d| d.min(super::MAX_UNARY_READ_WAIT)); + } + validate_read_until(start, end)?; + Ok((start, end)) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct CheckTailArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, +} + +/// Check the tail. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::streams::records::CHECK_TAIL, + tag = super::paths::streams::records::TAG, + responses( + (status = StatusCode::OK, body = v1t::stream::TailResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::StreamNamePathSegment), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn check_tail( + State(backend): State, + CheckTailArgs { basin, stream }: CheckTailArgs, +) -> Result, ServiceError> { + let tail = backend + .open_for_check_tail(&basin, &stream) + .await? + .check_tail() + .await?; + Ok(Json(v1t::stream::TailResponse { tail: tail.into() })) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct ReadArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, + #[from_request(via(Query))] + start: v1t::stream::ReadStart, + #[from_request(via(Query))] + end: v1t::stream::ReadEnd, + request: v1t::stream::ReadRequest, +} + +/// Read records. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::streams::records::READ, + tag = super::paths::streams::records::TAG, + responses( + (status = StatusCode::OK, content( + (v1t::stream::ReadBatch = "application/json"), + (v1t::stream::sse::ReadEvent = "text/event-stream"), + )), + (status = StatusCode::RANGE_NOT_SATISFIABLE, body = v1t::stream::TailResponse), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params( + v1t::StreamNamePathSegment, + s2_api::data::S2FormatHeader, + s2_api::data::S2EncryptionKeyHeader, + s2_api::data::S2StreamConfigHeader, + v1t::stream::ReadStart, + v1t::stream::ReadEnd, + ), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn read( + State(backend): State, + ReadArgs { + basin, + stream, + start, + end, + request, + }: ReadArgs, +) -> Result { + let start: ReadStart = start.try_into()?; + match request { + v1t::stream::ReadRequest::Unary { + encryption_key, + create_stream_config_patch, + format, + response_mime, + } => { + let (start, end) = prepare_read(start, end, ReadMode::Unary)?; + let session = backend + .open_for_read(&basin, &stream, encryption_key, create_stream_config_patch) + .await? + .read(start, end) + .await?; + let batch = merge_read_session(session, end.wait).await?; + match response_mime { + JsonOrProto::Json => Ok(Json(v1t::stream::json::serialize_read_batch( + format, &batch, + )) + .into_response()), + JsonOrProto::Proto => { + let batch: v1t::stream::proto::ReadBatch = batch.into(); + Ok(Proto(batch).into_response()) + } + } + } + v1t::stream::ReadRequest::EventStream { + encryption_key, + create_stream_config_patch, + format, + last_event_id, + } => { + let (start, end, delivered) = apply_last_event_id(start, end, last_event_id); + let (start, end) = prepare_read(start, end, ReadMode::Streaming)?; + let session = backend + .open_for_read(&basin, &stream, encryption_key, create_stream_config_patch) + .await? + .read(start, end) + .await?; + let events = async_stream::stream! { + let mut processed = delivered; + tokio::pin!(session); + let mut errored = false; + while let Some(output) = session.next().await { + match output { + Ok(ReadSessionOutput::Heartbeat(tail)) => { + yield v1t::stream::sse::ping_event(tail); + }, + Ok(ReadSessionOutput::Batch(batch)) => { + let Some(last_record) = batch.records.last() else { + continue; + }; + processed.count = processed.count.saturating_add(batch.records.len()); + processed.bytes = processed.bytes.saturating_add(batch.records.metered_size()); + let id = v1t::stream::sse::LastEventId { + seq_num: last_record.position().seq_num, + count: processed.count, + bytes: processed.bytes, + }; + yield v1t::stream::sse::read_batch_event(format, &batch, id); + }, + Err(err) => { + let (_, body) = ServiceError::from(err).to_response().to_parts(); + yield v1t::stream::sse::error_event(body); + errored = true; + } + } + } + if !errored { + yield v1t::stream::sse::done_event(); + } + }; + + let mut response = axum::response::Sse::new(events).into_response(); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache, no-transform"), + ); + response + .headers_mut() + .insert("x-accel-buffering", HeaderValue::from_static("no")); + Ok(response) + } + v1t::stream::ReadRequest::S2s { + encryption_key, + create_stream_config_patch, + response_compression, + } => { + let (start, end) = prepare_read(start, end, ReadMode::Streaming)?; + let s2s_stream = backend + .open_for_read(&basin, &stream, encryption_key, create_stream_config_patch) + .await? + .read(start, end) + .await? + .map_ok(|msg| match msg { + ReadSessionOutput::Heartbeat(tail) => v1t::stream::proto::ReadBatch { + records: vec![], + tail: Some(tail.into()), + }, + ReadSessionOutput::Batch(batch) => v1t::stream::proto::ReadBatch::from(batch), + }) + .map_err(ServiceError::from); + let response_stream = + s2s::FramedMessageStream::<_>::new(response_compression, Box::pin(s2s_stream)); + Ok(Response::builder() + .status(StatusCode::OK) + .header(http::header::CONTENT_TYPE, "s2s/proto") + .header(http::header::CACHE_CONTROL, "no-cache, no-transform") + .header("x-accel-buffering", "no") + .body(Body::from_stream(response_stream)) + .expect("valid response builder")) + } + } +} + +async fn merge_read_session( + session: impl Stream>, + wait: Option, +) -> Result { + let mut acc = ReadBatch { + records: Metered::with_capacity(RECORD_BATCH_MAX.count), + tail: None, + }; + let mut wait_mode = false; + tokio::pin!(session); + while let Some(output) = session.next().await { + match output? { + ReadSessionOutput::Batch(batch) => { + assert!(!batch.records.is_empty(), "unexpected empty batch"); + assert!( + (acc.records.metered_size() + batch.records.metered_size()) + <= RECORD_BATCH_MAX.bytes + && acc.records.len() + batch.records.len() <= RECORD_BATCH_MAX.count, + "cannot accumulate more than limit" + ); + acc.records.append(batch.records); + acc.tail = batch.tail; + if wait_mode { + break; + } + } + ReadSessionOutput::Heartbeat(pos) => { + assert!( + wait.is_some_and(|d| d > Duration::ZERO), + "heartbeat {pos} only if non-zero wait" + ); + if !acc.records.is_empty() { + break; + } + wait_mode = true; + } + } + } + Ok(acc) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct AppendArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, + request: v1t::stream::AppendRequest, +} + +/// Append records. +#[cfg_attr(feature = "utoipa", utoipa::path( + post, + path = super::paths::streams::records::APPEND, + tag = super::paths::streams::records::TAG, + request_body(content = v1t::stream::AppendInput, content_type = "application/json"), + responses( + (status = StatusCode::OK, body = v1t::stream::AppendAck), + (status = StatusCode::PRECONDITION_FAILED, body = v1t::stream::AppendConditionFailed), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params( + v1t::StreamNamePathSegment, + s2_api::data::S2FormatHeader, + s2_api::data::S2EncryptionKeyHeader, + s2_api::data::S2StreamConfigHeader, + ), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn append( + State(backend): State, + AppendArgs { + basin, + stream, + request, + }: AppendArgs, +) -> Result { + match request { + v1t::stream::AppendRequest::Unary { + encryption_key, + create_stream_config_patch, + input, + response_mime, + } => { + let handle = backend + .open_for_append(&basin, &stream, encryption_key, create_stream_config_patch) + .await?; + let ack = handle.append(input).await?; + match response_mime { + JsonOrProto::Json => { + let ack: v1t::stream::AppendAck = ack.into(); + Ok(Json(ack).into_response()) + } + JsonOrProto::Proto => { + let ack: v1t::stream::proto::AppendAck = ack.into(); + Ok(Proto(ack).into_response()) + } + } + } + v1t::stream::AppendRequest::S2s { + encryption_key, + create_stream_config_patch, + inputs, + response_compression, + } => { + let handle = backend + .open_for_append(&basin, &stream, encryption_key, create_stream_config_patch) + .await?; + let (err_tx, err_rx) = tokio::sync::oneshot::channel(); + + let inputs = async_stream::stream! { + tokio::pin!(inputs); + let mut err_tx = Some(err_tx); + while let Some(input) = inputs.next().await { + match input { + Ok(input) => yield input, + Err(e) => { + if let Some(tx) = err_tx.take() { + let _ = tx.send(e); + } + break; + } + } + } + }; + + let ack_stream = handle.append_session(inputs).map(|res| { + res.map(v1t::stream::proto::AppendAck::from) + .map_err(ServiceError::from) + }); + + let input_err_stream = futures::stream::once(err_rx).filter_map(|res| async move { + match res { + Ok(err) => Some(Err(err.into())), + Err(_) => None, + } + }); + + let response_stream = s2s::FramedMessageStream::<_>::new( + response_compression, + Box::pin(ack_stream.chain(input_err_stream)), + ); + + Ok(Response::builder() + .status(StatusCode::OK) + .header(http::header::CONTENT_TYPE, "s2s/proto") + .header(http::header::CACHE_CONTROL, "no-cache, no-transform") + .header("x-accel-buffering", "no") + .body(Body::from_stream(response_stream)) + .expect("valid response builder")) + } + } +} + +#[cfg(test)] +mod tests { + use std::{sync::Arc, time::Duration}; + + use axum::{ + body::{self, Body}, + http::{Request, StatusCode, header}, + response::Response, + }; + use bytes::{Bytes, BytesMut}; + use bytesize::ByteSize; + use futures::TryStreamExt as _; + use prost::Message as _; + use s2_api::v1::{ + config::STREAM_CONFIG_HEADER, + stream::{ + proto, + s2s::{self, FrameDecoder, SessionMessage}, + sse::LastEventId, + }, + }; + use s2_common::{ + basin::{BASIN_HEADER, BasinName}, + config::{ + BasinConfig, DeleteOnEmptyConfig, OptionalStreamConfig, RetentionPolicy, StreamConfig, + }, + encryption::{EncryptionAlgorithm, EncryptionKey, S2_ENCRYPTION_KEY_HEADER}, + read_extent::{ReadLimit, ReadUntil}, + record::{EnvelopeRecord, Metered, MeteredSize as _, Record}, + resources::ProvisionMode, + stream::{ + AppendInput, AppendRecord, AppendRecordBatch, AppendRecordParts, ListStreamsRequest, + ReadEnd, ReadFrom, ReadSessionOutput, ReadStart, StreamName, + }, + }; + use slatedb::{Db, config::Settings, object_store::memory::InMemory}; + use tokio_util::codec::Decoder as _; + use tower::ServiceExt as _; + use uuid::Uuid; + + use crate::{backend::Backend, handlers}; + + fn basin_config_with_stream_cipher(stream_cipher: EncryptionAlgorithm) -> BasinConfig { + BasinConfig { + default_stream_config: OptionalStreamConfig::default(), + stream_cipher: Some(stream_cipher), + ..Default::default() + } + } + + fn aegis_key(byte: u8) -> EncryptionKey { + EncryptionKey::new([byte; 32]) + } + + async fn create_backend() -> Backend { + let object_store = Arc::new(InMemory::new()); + let db_path = format!("/tmp/records-handler-test-{}", Uuid::new_v4()); + let db = Db::builder(db_path, object_store) + .with_settings(Settings { + flush_interval: Some(Duration::from_millis(5)), + ..Default::default() + }) + .build() + .await + .expect("create in-memory db"); + Backend::new(db, ByteSize::mib(10)) + } + + async fn setup_app_with_config( + test_suffix: &str, + basin_config: BasinConfig, + stream_config: OptionalStreamConfig, + ) -> (axum::Router, Backend, BasinName, StreamName) { + let backend = create_backend().await; + let basin: BasinName = format!("test-basin-{test_suffix}").parse().unwrap(); + backend + .provision_basin( + basin.clone(), + basin_config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("create basin"); + let stream: StreamName = format!("test-stream-{test_suffix}").parse().unwrap(); + backend + .provision_stream( + basin.clone(), + stream.clone(), + stream_config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("create stream"); + let app = handlers::router().with_state(backend.clone()); + (app, backend, basin, stream) + } + + async fn setup_app_without_stream( + test_suffix: &str, + basin_config: BasinConfig, + ) -> (axum::Router, Backend, BasinName, StreamName) { + let backend = create_backend().await; + let basin: BasinName = format!("test-basin-{test_suffix}").parse().unwrap(); + backend + .provision_basin( + basin.clone(), + basin_config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("create basin"); + let stream: StreamName = format!("test-stream-{test_suffix}").parse().unwrap(); + let app = handlers::router().with_state(backend.clone()); + (app, backend, basin, stream) + } + + fn append_input(body: &'static [u8]) -> AppendInput { + let record = Metered::from(Record::Envelope( + EnvelopeRecord::try_from_parts(vec![], Bytes::from_static(body)).unwrap(), + )); + let record = AppendRecord::try_from(AppendRecordParts { + timestamp: None, + record, + }) + .unwrap(); + let records = AppendRecordBatch::try_from(vec![record]).unwrap(); + AppendInput { + records, + match_seq_num: None, + fencing_token: None, + } + } + + async fn append_encrypted_payload( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + body: &'static [u8], + encryption_key: EncryptionKey, + ) { + backend + .open_for_append( + basin, + stream, + Some(encryption_key), + OptionalStreamConfig::default(), + ) + .await + .expect("open append handle") + .append(append_input(body)) + .await + .expect("append encrypted payload"); + } + + async fn append_payload( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + body: &'static [u8], + ) { + backend + .open_for_append(basin, stream, None, OptionalStreamConfig::default()) + .await + .expect("open append handle") + .append(append_input(body)) + .await + .expect("append payload"); + } + + struct SseBatches { + seq_nums: Vec, + last_id: Option, + } + + async fn sse_read( + app: &axum::Router, + basin: &BasinName, + stream: &StreamName, + bounds: &str, + last_event_id: Option, + ) -> SseBatches { + let mut request = request_builder( + "GET", + format!("/v1/streams/{stream}/records?seq_num=0&wait=0&{bounds}"), + basin, + ) + .header(header::ACCEPT, "text/event-stream"); + if let Some(id) = last_event_id { + request = request.header("last-event-id", id.to_string()); + } + let response = send(app, request.body(Body::empty()).unwrap()).await; + assert_eq!(response.status(), StatusCode::OK); + let body = + tokio::time::timeout(Duration::from_secs(5), response_bytes(response, "sse body")) + .await + .expect("SSE read should terminate"); + let body = std::str::from_utf8(&body).expect("utf8 sse body"); + + let mut batches = SseBatches { + seq_nums: Vec::new(), + last_id: None, + }; + let mut done = false; + for event in body.split("\n\n").filter(|e| !e.trim().is_empty()) { + let field = |name: &str| { + event + .lines() + .find_map(|line| line.strip_prefix(name)) + .map(str::trim) + }; + match field("event:") { + Some("batch") => { + let id: LastEventId = field("id:") + .expect("batch event id") + .parse() + .expect("parse batch event id"); + let data: serde_json::Value = + serde_json::from_str(field("data:").expect("batch event data")) + .expect("batch event json"); + let records = data["records"].as_array().expect("records array"); + batches.seq_nums.extend( + records + .iter() + .map(|r| r["seq_num"].as_u64().expect("seq_num")), + ); + assert_eq!( + id.seq_num, + *batches.seq_nums.last().expect("non-empty batch") + ); + batches.last_id = Some(id); + } + Some("error") => panic!("unexpected sse error event: {event}"), + Some(other) => panic!("unexpected sse event `{other}`: {event}"), + None => { + assert_eq!(field("data:"), Some("[DONE]"), "unexpected event: {event}"); + done = true; + } + } + } + assert!(done, "SSE read should end with [DONE]"); + batches + } + + fn read_uri(stream: &StreamName) -> String { + format!("/v1/streams/{stream}/records?seq_num=0&wait=0") + } + + fn request_builder( + method: &str, + uri: impl Into, + basin: &BasinName, + ) -> axum::http::request::Builder { + Request::builder() + .method(method) + .uri(uri.into()) + .header(BASIN_HEADER.as_str(), basin.as_ref()) + } + + async fn send(app: &axum::Router, request: Request) -> Response { + app.clone() + .oneshot(request) + .await + .expect("request should complete") + } + + async fn response_bytes(response: Response, context: &str) -> Bytes { + body::to_bytes(response.into_body(), usize::MAX) + .await + .expect(context) + } + + async fn response_json(response: Response, context: &str) -> serde_json::Value { + let body = response_bytes(response, context).await; + serde_json::from_slice(&body).expect("json body") + } + + fn decode_single_frame(body: Bytes, context: &str) -> SessionMessage { + let mut decoder = FrameDecoder; + let mut buf = BytesMut::from(body.as_ref()); + let frame = decoder + .decode(&mut buf) + .expect("frame decode") + .expect(context); + assert!(buf.is_empty(), "expected a single frame"); + frame + } + + async fn assert_no_streams(backend: &Backend, basin: &BasinName) { + let stream_list = backend + .list_streams(basin.clone(), ListStreamsRequest::default()) + .await + .expect("list streams"); + assert!(stream_list.values.is_empty()); + } + + #[tokio::test] + async fn unary_append_with_encryption_header_persists_encrypted_record() { + let encryption_key = aegis_key(0x42); + let (app, backend, basin, stream) = setup_app_with_config( + "append-unary-encrypted", + basin_config_with_stream_cipher(EncryptionAlgorithm::Aegis256), + OptionalStreamConfig::default(), + ) + .await; + + let input = proto::AppendInput { + records: vec![proto::AppendRecord { + timestamp: None, + headers: vec![], + body: Bytes::from_static(b"secret"), + }], + match_seq_num: None, + fencing_token: None, + }; + + let response = send( + &app, + request_builder("POST", format!("/v1/streams/{stream}/records"), &basin) + .header(header::CONTENT_TYPE, "application/protobuf") + .header(header::ACCEPT, "application/protobuf") + .header( + S2_ENCRYPTION_KEY_HEADER.as_str(), + encryption_key.to_header_value(), + ) + .body(Body::from(input.encode_to_vec())) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let body = response_bytes(response, "append ack body").await; + let ack = proto::AppendAck::decode(body).expect("append ack"); + assert_eq!(ack.end.as_ref().map(|pos| pos.seq_num), Some(1)); + + let records = backend + .open_for_read( + &basin, + &stream, + Some(encryption_key.clone()), + OptionalStreamConfig::default(), + ) + .await + .expect("open read handle") + .read( + ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }, + ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }, + ) + .await + .expect("create read session") + .try_filter_map(|output| async move { + match output { + ReadSessionOutput::Batch(batch) => Ok(Some(batch)), + ReadSessionOutput::Heartbeat(_) => Ok(None), + } + }) + .try_collect::>() + .await + .expect("read encrypted record"); + let batch = records.into_iter().next().expect("batch"); + assert_eq!(batch.records.len(), 1); + let record = batch.records.first().expect("record"); + let Record::Envelope(record) = record.inner() else { + panic!("expected envelope record"); + }; + assert_eq!(record.body().as_ref(), b"secret"); + } + + fn basin_config_with_create_stream_on_append() -> BasinConfig { + BasinConfig { + create_stream_on_append: true, + default_stream_config: OptionalStreamConfig { + storage_class: Some("standard".into()), + ..Default::default() + }, + ..Default::default() + } + } + + fn expected_auto_created_config() -> StreamConfig { + StreamConfig { + storage_class: Some("standard".into()), + retention_policy: RetentionPolicy::Age(Duration::from_secs(3600)), + timestamping: Default::default(), + delete_on_empty: DeleteOnEmptyConfig { + min_age: Duration::from_secs(300), + }, + } + } + + const STREAM_CONFIG_HEADER_VALUE: &str = + r#"{"retention_policy":{"age":3600},"delete_on_empty":{"min_age_secs":300}}"#; + + fn append_record_input(body: &'static [u8]) -> proto::AppendInput { + proto::AppendInput { + records: vec![proto::AppendRecord { + timestamp: None, + headers: vec![], + body: Bytes::from_static(body), + }], + match_seq_num: None, + fencing_token: None, + } + } + + #[tokio::test] + async fn json_append_auto_creates_stream_with_stream_config_header() { + let (app, backend, basin, stream) = setup_app_without_stream( + "append-json-create-config", + basin_config_with_create_stream_on_append(), + ) + .await; + + let body = serde_json::json!({"records": [{"body": "hello"}]}); + let response = send( + &app, + request_builder("POST", format!("/v1/streams/{stream}/records"), &basin) + .header(header::CONTENT_TYPE, "application/json") + .header(STREAM_CONFIG_HEADER.as_str(), STREAM_CONFIG_HEADER_VALUE) + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let ack = response_json(response, "append ack body").await; + assert_eq!(ack["end"]["seq_num"], 1); + let config = backend + .get_stream_config(basin, stream) + .await + .expect("get stream config"); + assert_eq!(config, expected_auto_created_config()); + } + + #[tokio::test] + async fn append_with_invalid_stream_config_header_is_rejected_without_creating() { + let (app, backend, basin, stream) = setup_app_without_stream( + "append-create-config-invalid", + basin_config_with_create_stream_on_append(), + ) + .await; + + for (value, expected_message) in [ + ( + r#"{"retention_policy":{"age":0}}"#, + "age must be greater than 0 seconds", + ), + ("not json", "invalid JSON"), + ] { + let body = serde_json::json!({"records": [{"body": "hello"}]}); + let response = send( + &app, + request_builder("POST", format!("/v1/streams/{stream}/records"), &basin) + .header(header::CONTENT_TYPE, "application/json") + .header(STREAM_CONFIG_HEADER.as_str(), value) + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let info = response_json(response, "append error body").await; + assert_eq!(info["code"], "bad_header"); + let message = info["message"].as_str().expect("error message string"); + assert!( + message.contains("s2-stream-config") && message.contains(expected_message), + "{message}" + ); + } + assert_no_streams(&backend, &basin).await; + } + + #[tokio::test] + async fn s2s_append_session_auto_creates_stream_with_stream_config_header() { + let (app, backend, basin, stream) = setup_app_without_stream( + "append-s2s-create-config", + basin_config_with_create_stream_on_append(), + ) + .await; + + let frame = |body: &'static [u8]| { + SessionMessage::regular(s2s::CompressionAlgorithm::None, &append_record_input(body)) + .expect("encode frame") + .encode() + }; + let mut body = BytesMut::new(); + body.extend_from_slice(&frame(b"first")); + body.extend_from_slice(&frame(b"second")); + + let response = send( + &app, + request_builder("POST", format!("/v1/streams/{stream}/records"), &basin) + .header(header::CONTENT_TYPE, "s2s/proto") + .header(STREAM_CONFIG_HEADER.as_str(), STREAM_CONFIG_HEADER_VALUE) + .body(Body::from(body.freeze())) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let body = response_bytes(response, "s2s body").await; + let mut decoder = FrameDecoder; + let mut buf = BytesMut::from(body.as_ref()); + let mut acks = Vec::new(); + while let Some(frame) = decoder.decode(&mut buf).expect("frame decode") { + let SessionMessage::Regular(ack) = frame else { + panic!("expected regular frame"); + }; + acks.push( + ack.try_into_proto::() + .expect("decode append ack"), + ); + } + assert_eq!(acks.len(), 2); + assert_eq!(acks[1].end.as_ref().map(|pos| pos.seq_num), Some(2)); + + let config = backend + .get_stream_config(basin, stream) + .await + .expect("get stream config"); + assert_eq!(config, expected_auto_created_config()); + } + + #[rstest::rstest] + #[case::count("count=0")] + #[case::bytes("bytes=0")] + #[tokio::test] + async fn unary_read_with_zero_limit_returns_empty( + #[case] bound: &str, + #[values( + "timestamp=0", + "seq_num=0", + "tail_offset=0", + "timestamp=1", + "seq_num=1" + )] + start: &str, + #[values(0, 60)] wait: u32, + ) { + let (app, _backend, basin, stream) = setup_app_with_config( + "read-zero-limit", + BasinConfig::default(), + OptionalStreamConfig::default(), + ) + .await; + + let response = tokio::time::timeout( + Duration::from_secs(2), + send( + &app, + request_builder( + "GET", + format!("/v1/streams/{stream}/records?{start}&{bound}&wait={wait}"), + &basin, + ) + .body(Body::empty()) + .unwrap(), + ), + ) + .await + .expect("a zero limit should complete without waiting for records"); + + let status = response.status(); + let body = response_json(response, "read zero-limit response").await; + assert_eq!( + status, + StatusCode::OK, + "{start}&{bound}&wait={wait}: {body}" + ); + assert_eq!(body["records"], serde_json::json!([])); + } + + #[tokio::test] + async fn read_auto_creates_stream_with_stream_config_header() { + let basin_config = BasinConfig { + create_stream_on_append: false, + create_stream_on_read: true, + ..basin_config_with_create_stream_on_append() + }; + let (app, backend, basin, stream) = + setup_app_without_stream("read-create-config", basin_config).await; + + let response = send( + &app, + request_builder( + "GET", + format!("/v1/streams/{stream}/records?seq_num=0"), + &basin, + ) + .header(STREAM_CONFIG_HEADER.as_str(), STREAM_CONFIG_HEADER_VALUE) + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + let config = backend + .get_stream_config(basin, stream) + .await + .expect("get stream config"); + assert_eq!(config, expected_auto_created_config()); + } + + #[tokio::test] + async fn invalid_read_bounds_do_not_auto_create_stream() { + let basin_config = BasinConfig { + create_stream_on_read: true, + ..Default::default() + }; + let (app, backend, basin, stream) = + setup_app_without_stream("read-invalid-bounds-no-create", basin_config).await; + + let response = send( + &app, + request_builder( + "GET", + format!("/v1/streams/{stream}/records?timestamp=5&until=5"), + &basin, + ) + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + let info = response_json(response, "read error body").await; + assert_eq!(info["code"], "invalid"); + assert!( + info["message"] + .as_str() + .expect("error message string") + .contains("start `timestamp` exceeds or equal to `until`") + ); + assert_no_streams(&backend, &basin).await; + } + + #[tokio::test] + async fn unary_read_with_wrong_key_returns_decryption_failed_error() { + let encryption_key = aegis_key(0x42); + let wrong_key = aegis_key(0x24); + let (app, backend, basin, stream) = setup_app_with_config( + "read-unary-bad-key", + basin_config_with_stream_cipher(EncryptionAlgorithm::Aegis256), + OptionalStreamConfig::default(), + ) + .await; + append_encrypted_payload(&backend, &basin, &stream, b"secret", encryption_key).await; + + let response = send( + &app, + request_builder("GET", read_uri(&stream), &basin) + .header( + S2_ENCRYPTION_KEY_HEADER.as_str(), + wrong_key.to_header_value(), + ) + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let info = response_json(response, "read error body").await; + assert_eq!(info["code"], "decryption_failed"); + assert!( + info["message"] + .as_str() + .expect("error message string") + .contains("record decryption failed") + ); + } + + #[tokio::test] + async fn sse_read_without_key_header_is_rejected_before_stream_starts() { + let encryption_key = aegis_key(0x42); + let (app, backend, basin, stream) = setup_app_with_config( + "read-sse-plain", + basin_config_with_stream_cipher(EncryptionAlgorithm::Aegis256), + OptionalStreamConfig::default(), + ) + .await; + append_encrypted_payload(&backend, &basin, &stream, b"secret", encryption_key).await; + + let response = send( + &app, + request_builder( + "GET", + format!("/v1/streams/{stream}/records?seq_num=0"), + &basin, + ) + .header(header::ACCEPT, "text/event-stream") + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let info = response_json(response, "sse read error body").await; + assert_eq!(info["code"], "bad_header"); + assert!( + info["message"] + .as_str() + .expect("error message string") + .contains("missing encryption key") + ); + } + + #[rstest::rstest] + #[case::count(Some(10), None)] + #[case::bytes(None, Some(10))] + #[case::count_first(Some(10), Some(15))] + #[case::bytes_first(Some(15), Some(10))] + #[tokio::test] + async fn sse_resume_emits_cumulative_ids_and_honors_bounds( + #[case] count: Option, + #[case] bytes_in_records: Option, + ) { + let (app, backend, basin, stream) = setup_app_with_config( + "read-sse-resume", + BasinConfig::default(), + OptionalStreamConfig::default(), + ) + .await; + let per_record = append_input(b"payload").records.metered_size(); + let mut bounds = String::new(); + if let Some(count) = count { + bounds.push_str(&format!("count={count}&")); + } + if let Some(records) = bytes_in_records { + bounds.push_str(&format!("bytes={}&", records * per_record)); + } + + // End two connections at the current tail, then resume with exactly + // the same bounds and the actual id emitted by the previous connection. + let mut last_id = None; + let mut delivered = Vec::new(); + for (appended, expected_total) in [(4, 4), (3, 7), (13, 10)] { + for _ in 0..appended { + append_payload(&backend, &basin, &stream, b"payload").await; + } + let resumed = sse_read(&app, &basin, &stream, &bounds, last_id).await; + delivered.extend(resumed.seq_nums); + assert_eq!(delivered, (0..expected_total as u64).collect::>()); + let id = resumed.last_id.expect("last id"); + assert_eq!(id.seq_num, expected_total as u64 - 1); + assert_eq!(id.count, expected_total); + assert_eq!(id.bytes, expected_total * per_record); + last_id = Some(id); + } + + // The same read without reconnects produces the same records and id. + let full = sse_read(&app, &basin, &stream, &bounds, None).await; + assert_eq!(full.seq_nums, delivered); + assert_eq!( + full.last_id.expect("full id").to_string(), + last_id.expect("resumed id").to_string(), + ); + + let exhausted = sse_read(&app, &basin, &stream, &bounds, last_id).await; + assert!(exhausted.seq_nums.is_empty(), "{:?}", exhausted.seq_nums); + assert!(exhausted.last_id.is_none()); + } + + #[rstest::rstest] + #[case::unbounded("", (usize::MAX - 1, usize::MAX - 1), (usize::MAX, usize::MAX), 3)] + #[case::count_bound("count=2", (0, usize::MAX), (2, usize::MAX), 2)] + #[case::bytes_bound("bytes=30", (usize::MAX, 0), (usize::MAX, 30), 2)] + #[tokio::test] + async fn sse_resume_saturates_client_supplied_counters( + #[case] bounds: &str, + #[case] (count, bytes): (usize, usize), + #[case] expected_totals: (usize, usize), + #[case] expected_records: usize, + ) { + let (app, backend, basin, stream) = setup_app_with_config( + "read-sse-saturation", + BasinConfig::default(), + OptionalStreamConfig::default(), + ) + .await; + // Each "payload" record occupies 15 metered bytes. + for _ in 0..4 { + append_payload(&backend, &basin, &stream, b"payload").await; + } + let resumed = sse_read( + &app, + &basin, + &stream, + bounds, + Some(LastEventId { + seq_num: 0, + count, + bytes, + }), + ) + .await; + assert_eq!( + resumed.seq_nums, + (1..=expected_records as u64).collect::>() + ); + let id = resumed.last_id.expect("last id"); + assert_eq!((id.count, id.bytes), expected_totals); + } + + #[tokio::test] + async fn s2s_read_without_key_header_is_rejected_before_stream_starts() { + let encryption_key = aegis_key(0x42); + let (app, backend, basin, stream) = setup_app_with_config( + "read-s2s-plain", + basin_config_with_stream_cipher(EncryptionAlgorithm::Aegis256), + OptionalStreamConfig::default(), + ) + .await; + append_encrypted_payload(&backend, &basin, &stream, b"secret", encryption_key).await; + + let response = send( + &app, + request_builder("GET", read_uri(&stream), &basin) + .header(header::CONTENT_TYPE, "s2s/proto") + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let info = response_json(response, "s2s read error body").await; + assert_eq!(info["code"], "bad_header"); + assert!( + info["message"] + .as_str() + .expect("error message string") + .contains("missing encryption key") + ); + } + + #[tokio::test] + async fn s2s_read_with_correct_encryption_returns_batch_frame() { + let encryption_key = aegis_key(0x42); + let (app, backend, basin, stream) = setup_app_with_config( + "read-s2s-ok", + basin_config_with_stream_cipher(EncryptionAlgorithm::Aegis256), + OptionalStreamConfig::default(), + ) + .await; + append_encrypted_payload(&backend, &basin, &stream, b"secret", encryption_key.clone()) + .await; + + let response = send( + &app, + request_builder("GET", read_uri(&stream), &basin) + .header(header::CONTENT_TYPE, "s2s/proto") + .header( + S2_ENCRYPTION_KEY_HEADER.as_str(), + encryption_key.to_header_value(), + ) + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let body = response_bytes(response, "s2s body").await; + let frame = decode_single_frame(body, "batch frame"); + let SessionMessage::Regular(batch) = frame else { + panic!("expected regular frame"); + }; + let batch = batch + .try_into_proto::() + .expect("decode read batch proto"); + assert_eq!(batch.records.len(), 1); + assert_eq!(batch.records[0].body.as_ref(), b"secret"); + } +} diff --git a/lite/src/handlers/v1/streams.rs b/lite/src/handlers/v1/streams.rs new file mode 100644 index 00000000..1792da12 --- /dev/null +++ b/lite/src/handlers/v1/streams.rs @@ -0,0 +1,494 @@ +use axum::extract::{FromRequest, Path, Query, State}; +use http::StatusCode; +use s2_api::{ + data::{Json, extract::JsonOpt}, + v1 as v1t, +}; +use s2_common::{ + basin::BasinName, + config::{OptionalStreamConfig, StreamReconfiguration}, + http::extract::{Header, HeaderOpt}, + resources::{PROVISION_RESULT_HEADER, Page, ProvisionMode, ProvisionResult, RequestToken}, + stream::{ListStreamsRequest, StreamName}, +}; + +use crate::{backend::Backend, handlers::v1::error::ServiceError}; + +pub fn router() -> axum::Router { + use axum::routing::{delete, get, patch, post, put}; + axum::Router::new() + .route(super::paths::streams::LIST, get(list_streams)) + .route(super::paths::streams::CREATE, post(create_stream)) + .route(super::paths::streams::GET_CONFIG, get(get_stream_config)) + .route(super::paths::streams::ENSURE, put(ensure_stream)) + .route(super::paths::streams::DELETE, delete(delete_stream)) + .route( + super::paths::streams::RECONFIGURE, + patch(reconfigure_stream), + ) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct ListArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Query))] + request: v1t::stream::ListStreamsRequest, +} + +/// List streams. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::streams::LIST, + tag = super::paths::streams::TAG, + responses( + (status = StatusCode::OK, body = v1t::stream::ListStreamsResponse), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::stream::ListStreamsRequest), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn list_streams( + State(backend): State, + ListArgs { basin, request }: ListArgs, +) -> Result, ServiceError> { + let request: ListStreamsRequest = request.try_into()?; + let Page { values, has_more } = backend.list_streams(basin, request).await?; + Ok(Json(v1t::stream::ListStreamsResponse { + streams: values.into_iter().map(Into::into).collect(), + has_more, + })) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct CreateArgs { + request_token: HeaderOpt, + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Json))] + request: v1t::stream::CreateStreamRequest, +} + +/// Create a stream. +#[cfg_attr(feature = "utoipa", utoipa::path( + post, + path = super::paths::streams::CREATE, + tag = super::paths::streams::TAG, + params(v1t::S2RequestTokenHeader), + request_body = v1t::stream::CreateStreamRequest, + responses( + (status = StatusCode::CREATED, body = v1t::stream::StreamInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn create_stream( + State(backend): State, + CreateArgs { + request_token: HeaderOpt(request_token), + basin, + request, + }: CreateArgs, +) -> Result< + ( + StatusCode, + [(http::HeaderName, &'static str); 1], + Json, + ), + ServiceError, +> { + let config: OptionalStreamConfig = request + .config + .map(TryInto::try_into) + .transpose()? + .unwrap_or_default(); + let info = backend + .provision_stream( + basin, + request.stream, + config, + ProvisionMode::CreateOnly { request_token }, + ) + .await? + .map(Into::into); + let (outcome, info) = match info { + ProvisionResult::Created(info) => ("created", info), + ProvisionResult::Noop(info) => ("noop", info), + ProvisionResult::Updated(_) => unreachable!("CreateOnly mode never produces Updated"), + }; + Ok(( + StatusCode::CREATED, + [(PROVISION_RESULT_HEADER.clone(), outcome)], + Json(info), + )) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct GetConfigArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, +} + +/// Get stream configuration. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = super::paths::streams::GET_CONFIG, + tag = super::paths::streams::TAG, + responses( + (status = StatusCode::OK, body = v1t::config::StreamConfig), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::StreamNamePathSegment), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn get_stream_config( + State(backend): State, + GetConfigArgs { basin, stream }: GetConfigArgs, +) -> Result, ServiceError> { + Ok(Json(backend.get_stream_config(basin, stream).await?.into())) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct EnsureArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, + config: JsonOpt, +} + +/// Ensure a stream. +#[cfg_attr(feature = "utoipa", utoipa::path( + put, + path = super::paths::streams::ENSURE, + tag = super::paths::streams::TAG, + request_body = Option, + params(v1t::StreamNamePathSegment), + responses( + (status = StatusCode::OK, body = v1t::stream::StreamInfo), + (status = StatusCode::CREATED, body = v1t::stream::StreamInfo), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn ensure_stream( + State(backend): State, + EnsureArgs { + basin, + stream, + config: JsonOpt(config), + }: EnsureArgs, +) -> Result< + ( + StatusCode, + [(http::HeaderName, &'static str); 1], + Json, + ), + ServiceError, +> { + let config: OptionalStreamConfig = config + .map(TryInto::try_into) + .transpose()? + .unwrap_or_default(); + let info = backend + .provision_stream(basin, stream, config, ProvisionMode::Ensure) + .await? + .map(Into::into); + let (status, outcome, info) = match info { + ProvisionResult::Created(info) => (StatusCode::CREATED, "created", info), + ProvisionResult::Updated(info) => (StatusCode::OK, "updated", info), + ProvisionResult::Noop(info) => (StatusCode::OK, "noop", info), + }; + Ok(( + status, + [(PROVISION_RESULT_HEADER.clone(), outcome)], + Json(info), + )) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct DeleteArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, +} + +/// Delete a stream. +#[cfg_attr(feature = "utoipa", utoipa::path( + delete, + path = super::paths::streams::DELETE, + tag = super::paths::streams::TAG, + responses( + (status = StatusCode::ACCEPTED), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::StreamNamePathSegment), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn delete_stream( + State(backend): State, + DeleteArgs { basin, stream }: DeleteArgs, +) -> Result { + backend.delete_stream(basin, stream).await?; + Ok(StatusCode::ACCEPTED) +} + +#[derive(FromRequest)] +#[from_request(rejection(ServiceError))] +pub struct ReconfigureArgs { + #[from_request(via(Header))] + basin: BasinName, + #[from_request(via(Path))] + stream: StreamName, + #[from_request(via(Json))] + reconfiguration: v1t::config::StreamReconfiguration, +} + +/// Reconfigure a stream. +#[cfg_attr(feature = "utoipa", utoipa::path( + patch, + path = super::paths::streams::RECONFIGURE, + tag = super::paths::streams::TAG, + request_body = v1t::config::StreamReconfiguration, + responses( + (status = StatusCode::OK, body = v1t::config::StreamConfig), + (status = StatusCode::NOT_FOUND, body = v1t::error::ErrorInfo), + (status = StatusCode::BAD_REQUEST, body = v1t::error::ErrorInfo), + (status = StatusCode::FORBIDDEN, body = v1t::error::ErrorInfo), + (status = StatusCode::CONFLICT, body = v1t::error::ErrorInfo), + (status = StatusCode::REQUEST_TIMEOUT, body = v1t::error::ErrorInfo), + ), + params(v1t::StreamNamePathSegment), + servers( + (url = super::paths::cloud_endpoints::BASIN, variables( + ("basin" = ( + description = "Basin name", + )) + ), description = "Endpoint for the basin"), + ) +))] +pub async fn reconfigure_stream( + State(backend): State, + ReconfigureArgs { + basin, + stream, + reconfiguration, + }: ReconfigureArgs, +) -> Result, ServiceError> { + let reconfiguration: StreamReconfiguration = reconfiguration.try_into()?; + let config = backend + .reconfigure_stream(basin, stream, reconfiguration) + .await?; + Ok(Json(config.into())) +} + +#[cfg(test)] +mod test { + use std::{sync::Arc, time::Duration}; + + use axum::{ + body::{self, Body}, + http::{Request, StatusCode, header}, + response::Response, + }; + use bytesize::ByteSize; + use rstest::rstest; + use s2_common::{ + basin::{BASIN_HEADER, BasinName}, + config::BasinConfig, + resources::ProvisionMode, + }; + use slatedb::{Db, config::Settings, object_store::memory::InMemory}; + use tower::ServiceExt as _; + use uuid::Uuid; + + use crate::{backend::Backend, handlers}; + + async fn setup_app(test_suffix: &str) -> (axum::Router, Backend, BasinName) { + let object_store = Arc::new(InMemory::new()); + let db_path = format!("/tmp/streams-handler-test-{}", Uuid::new_v4()); + let db = Db::builder(db_path, object_store) + .with_settings(Settings { + flush_interval: Some(Duration::from_millis(5)), + ..Default::default() + }) + .build() + .await + .expect("create in-memory db"); + let backend = Backend::new(db, ByteSize::mib(10)); + let basin: BasinName = format!("test-basin-{test_suffix}").parse().unwrap(); + backend + .provision_basin( + basin.clone(), + BasinConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("create basin"); + let app = handlers::router().with_state(backend.clone()); + (app, backend, basin) + } + + async fn send(app: &axum::Router, request: Request) -> Response { + app.clone() + .oneshot(request) + .await + .expect("request should complete") + } + + async fn response_json(response: Response) -> serde_json::Value { + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"); + serde_json::from_slice(&body).expect("json body") + } + + async fn create_stream(app: &axum::Router, basin: &BasinName, name: &str) -> Response { + let payload = serde_json::json!({ "stream": name }).to_string(); + send( + app, + Request::builder() + .method("POST") + .uri("/v1/streams") + .header(BASIN_HEADER.as_str(), basin.as_ref()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(payload)) + .unwrap(), + ) + .await + } + + async fn list_streams(app: &axum::Router, basin: &BasinName, query: &str) -> Response { + send( + app, + Request::builder() + .method("GET") + .uri(format!("/v1/streams?{query}")) + .header(BASIN_HEADER.as_str(), basin.as_ref()) + .body(Body::empty()) + .unwrap(), + ) + .await + } + + #[tokio::test] + async fn create_stream_with_nul_byte_is_bad_json() { + let (app, backend, basin) = setup_app("create-nul").await; + + let response = create_stream(&app, &basin, "a\0b").await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let error = response_json(response).await; + assert_eq!(error["code"], "bad_json"); + assert!( + error["message"] + .as_str() + .unwrap() + .contains("stream name must not contain NUL bytes"), + "unexpected message: {error}" + ); + + backend.close().await.expect("close backend"); + } + + #[rstest] + #[case::control_chars("a\tb\nc\rd\x01e")] + #[case::unicode("stream/名前 😀?#%20")] + #[tokio::test] + async fn create_stream_with_other_chars_is_created(#[case] name: &str) { + let (app, backend, basin) = setup_app("create-ok").await; + + let response = create_stream(&app, &basin, name).await; + + assert_eq!(response.status(), StatusCode::CREATED); + let info = response_json(response).await; + assert_eq!(info["name"], name); + + backend.close().await.expect("close backend"); + } + + #[rstest] + #[case::prefix("prefix=a%00b", "stream prefix must not contain NUL bytes")] + #[case::start_after("start_after=a%00b", "stream start-after must not contain NUL bytes")] + #[tokio::test] + async fn list_streams_with_nul_byte_is_bad_query( + #[case] query: &str, + #[case] expected_message: &str, + ) { + let (app, backend, basin) = setup_app("list-nul").await; + + let response = list_streams(&app, &basin, query).await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let error = response_json(response).await; + assert_eq!(error["code"], "bad_query"); + assert!( + error["message"] + .as_str() + .unwrap() + .contains(expected_message), + "unexpected message: {error}" + ); + + backend.close().await.expect("close backend"); + } +} diff --git a/lite/src/init.rs b/lite/src/init.rs new file mode 100644 index 00000000..68ea1e2c --- /dev/null +++ b/lite/src/init.rs @@ -0,0 +1,73 @@ +//! Declarative basin/stream initialization from a JSON spec file. +//! +//! Loaded at startup when `--init-file` / `S2LITE_INIT_FILE` is set. + +use std::path::Path; + +use s2_common::{ + config::{BasinConfig, OptionalStreamConfig}, + resources::ProvisionMode, +}; +use tracing::info; + +use crate::backend::Backend; + +pub fn load(path: &Path) -> eyre::Result { + let contents = std::fs::read_to_string(path) + .map_err(|e| eyre::eyre!("failed to read init file {:?}: {}", path, e))?; + let spec: s2_resource_spec::Resources = serde_json::from_str(&contents) + .map_err(|e| eyre::eyre!("failed to parse init file {:?}: {}", path, e))?; + Ok(spec) +} + +pub async fn apply(backend: &Backend, spec: s2_resource_spec::Resources) -> eyre::Result<()> { + s2_resource_spec::validate(&spec).map_err(|e| eyre::eyre!(e))?; + + for basin_spec in spec.basins { + let config = basin_spec.config.map(BasinConfig::from).unwrap_or_default(); + + backend + .provision_basin(basin_spec.name.clone(), config, ProvisionMode::Ensure) + .await + .map_err(|e| { + eyre::eyre!( + "failed to apply basin {:?}: {}", + basin_spec.name.as_ref(), + e + ) + })?; + + info!(basin = basin_spec.name.as_ref(), "basin applied"); + + for stream_spec in basin_spec.streams { + let config = stream_spec + .config + .map(OptionalStreamConfig::from) + .unwrap_or_default(); + + backend + .provision_stream( + basin_spec.name.clone(), + stream_spec.name.clone(), + config, + ProvisionMode::Ensure, + ) + .await + .map_err(|e| { + eyre::eyre!( + "failed to apply stream {:?}/{:?}: {}", + basin_spec.name.as_ref(), + stream_spec.name.as_ref(), + e + ) + })?; + + info!( + basin = basin_spec.name.as_ref(), + stream = stream_spec.name.as_ref(), + "stream applied" + ); + } + } + Ok(()) +} diff --git a/lite/src/lib.rs b/lite/src/lib.rs new file mode 100644 index 00000000..8d57a5cb --- /dev/null +++ b/lite/src/lib.rs @@ -0,0 +1,8 @@ +//! S2 Lite server implementation. + +pub mod backend; +pub mod handlers; +pub mod init; +pub mod metrics; +pub mod server; +pub mod stream_id; diff --git a/lite/src/metrics.rs b/lite/src/metrics.rs new file mode 100644 index 00000000..f049fcce --- /dev/null +++ b/lite/src/metrics.rs @@ -0,0 +1,73 @@ +use std::{sync::LazyLock, time::Duration}; + +use bytes::{BufMut, Bytes, BytesMut}; +use prometheus::{Encoder, Histogram, TextEncoder, register_histogram}; + +pub fn observe_append_permit_latency(latency: Duration) { + static HISTOGRAM: LazyLock = LazyLock::new(|| { + register_histogram!( + "s2_append_permit_latency_seconds", + "Append permit latency in seconds", + vec![ + 0.005, 0.010, 0.025, 0.050, 0.100, 0.250, 0.500, 1.000, 2.500 + ] + ) + .unwrap() + }); + HISTOGRAM.observe(latency.as_secs_f64()); +} + +pub fn observe_append_ack_latency(latency: Duration) { + static HISTOGRAM: LazyLock = LazyLock::new(|| { + register_histogram!( + "s2_append_ack_latency_seconds", + "Append ack latency in seconds", + vec![ + 0.005, 0.010, 0.025, 0.050, 0.100, 0.250, 0.500, 1.000, 2.500 + ] + ) + .unwrap() + }); + HISTOGRAM.observe(latency.as_secs_f64()); +} + +pub fn observe_append_batch_size(count: usize, bytes: usize) { + static RECORDS: LazyLock = LazyLock::new(|| { + register_histogram!( + "s2_append_batch_records", + "Append batch size in number of records", + vec![1.0, 10.0, 50.0, 100.0, 250.0, 500.0, 1000.0] + ) + .unwrap() + }); + RECORDS.observe(count as f64); + + static BYTES: LazyLock = LazyLock::new(|| { + register_histogram!( + "s2_append_batch_bytes", + "Append batch size in bytes", + vec![ + 512.0, + 1024.0, + (4 * 1024) as f64, + (16 * 1024) as f64, + (64 * 1024) as f64, + (256 * 1024) as f64, + (512 * 1024) as f64, + (1024 * 1024) as f64, + ] + ) + .unwrap() + }); + BYTES.observe(bytes as f64); +} + +pub fn gather() -> Result { + let encoder = TextEncoder::new(); + let metric_families = prometheus::gather(); + let mut buffer = BytesMut::new().writer(); + encoder + .encode(&metric_families, &mut buffer) + .map_err(|_| std::fmt::Error)?; + Ok(buffer.into_inner().freeze()) +} diff --git a/lite/src/server.rs b/lite/src/server.rs new file mode 100644 index 00000000..d78f71f7 --- /dev/null +++ b/lite/src/server.rs @@ -0,0 +1,660 @@ +use std::{ + net::SocketAddr, + path::PathBuf, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use axum_server::tls_rustls::RustlsConfig; +use bytesize::ByteSize; +use http::header::AUTHORIZATION; +use s2_common::encryption::S2_ENCRYPTION_KEY_HEADER; +use slatedb::object_store; +use tokio::time::Instant; +use tower_http::{ + cors::CorsLayer, + sensitive_headers::SetSensitiveRequestHeadersLayer, + trace::{DefaultMakeSpan, DefaultOnRequest, DefaultOnResponse, TraceLayer}, +}; +use tracing::info; + +use crate::{backend::Backend, handlers, init}; + +#[derive(clap::Args, Debug, Clone)] +pub struct TlsConfig { + /// Use a self-signed certificate for TLS + #[arg(long, conflicts_with_all = ["tls_cert", "tls_key"])] + pub tls_self: bool, + + /// Path to the TLS certificate file (e.g., cert.pem) + /// Must be used together with --tls-key + #[arg(long, requires = "tls_key")] + pub tls_cert: Option, + + /// Path to the private key file (e.g., key.pem) + /// Must be used together with --tls-cert + #[arg(long, requires = "tls_cert")] + pub tls_key: Option, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct LiteArgs { + /// Name of the S3 bucket to back the database. + /// + /// If not specified, in-memory storage is used unless --local-root is set. + /// Uses the standard AWS configuration for the endpoint, region and credentials. + #[arg(long, group = "main_store")] + pub bucket: Option, + + /// Root directory to back the database on the local filesystem. + /// + /// Conflicts with --bucket. + #[arg( + long, + value_name = "DIR", + conflicts_with = "bucket", + group = "main_store" + )] + pub local_root: Option, + + /// Name of the S3 bucket to back the write-ahead log (WAL). + /// + /// If not specified, the main store is used unless --wal-local-root is set. + /// Uses the same AWS configuration as --bucket, with optional + /// S2LITE_WAL_AWS_* overrides for the endpoint, region and credentials. + /// + /// Requires --bucket or --local-root. Conflicts with --wal-local-root. + #[arg( + long, + env = "S2LITE_WAL_BUCKET", + requires = "main_store", + conflicts_with = "wal_local_root" + )] + pub wal_bucket: Option, + + /// Root directory to back the write-ahead log (WAL) on the local filesystem. + /// + /// Requires --bucket or --local-root. Conflicts with --wal-bucket. + #[arg( + long, + env = "S2LITE_WAL_LOCAL_ROOT", + value_name = "DIR", + requires = "main_store" + )] + pub wal_local_root: Option, + + /// Base path on object storage. + #[arg(long, default_value = "")] + pub path: String, + + /// TLS configuration (defaults to plain HTTP if not specified). + #[command(flatten)] + pub tls: TlsConfig, + + /// Port to listen on [default: 443 if HTTPS configured, otherwise 80 for HTTP] + #[arg(long)] + pub port: Option, + + /// Disable permissive CORS headers. + /// + /// By default, Lite sends CORS headers that allow browser-based clients + /// on any origin to connect (e.g. the S2 console). Pass this flag to + /// suppress those headers for stricter deployments where browser access + /// should be denied at the HTTP layer. + #[arg(long)] + pub no_cors: bool, + + /// Path to a JSON file defining basins and streams to create at startup. + /// + /// Creates missing resources and updates existing configs to match the file, + /// so it is safe to run on repeated restarts. Can also be set via + /// S2LITE_INIT_FILE environment variable. + #[arg(long, env = "S2LITE_INIT_FILE")] + pub init_file: Option, + + /// Maximum in-flight append metered bytes across all streams before admission blocks. + #[arg(long, default_value = "128MiB")] + pub append_inflight_bytes: ByteSize, +} + +#[derive(Debug, Clone)] +enum StoreType { + S3Bucket(String), + LocalFileSystem(PathBuf), + InMemory, +} + +impl StoreType { + fn default_flush_interval(&self) -> Duration { + Duration::from_millis(match self { + StoreType::S3Bucket(_) => 50, + StoreType::LocalFileSystem(_) | StoreType::InMemory => 5, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ServerProtocol { + Http, + Https { self_signed: bool }, +} + +impl ServerProtocol { + fn from_args(args: &LiteArgs) -> Self { + if args.tls.tls_self { + Self::Https { self_signed: true } + } else if args.tls.tls_cert.is_some() { + Self::Https { self_signed: false } + } else { + Self::Http + } + } + + fn scheme(self) -> &'static str { + match self { + Self::Http => "http", + Self::Https { .. } => "https", + } + } + + fn default_port(self) -> u16 { + match self { + Self::Http => 80, + Self::Https { .. } => 443, + } + } + + fn requires_ssl_no_verify(self) -> bool { + matches!(self, Self::Https { self_signed: true }) + } +} + +fn cli_endpoint(protocol: ServerProtocol, port: u16) -> String { + format!("{}://localhost:{port}", protocol.scheme()) +} + +fn cli_env_hint(protocol: ServerProtocol, port: u16) -> String { + let endpoint = cli_endpoint(protocol, port); + let mut lines = vec![ + "copy/paste into a new terminal to point the S2 CLI at this server:".to_string(), + format!("export S2_ACCOUNT_ENDPOINT={endpoint}"), + format!("export S2_BASIN_ENDPOINT={endpoint}"), + "export S2_ACCESS_TOKEN=ignored".to_string(), + ]; + + if protocol.requires_ssl_no_verify() { + lines.push("export S2_SSL_NO_VERIFY=1".to_string()); + } + + lines.join("\n") +} + +pub async fn run(args: LiteArgs) -> eyre::Result<()> { + info!(?args); + + let protocol = ServerProtocol::from_args(&args); + let port = args.port.unwrap_or_else(|| protocol.default_port()); + let addr = format!("0.0.0.0:{port}"); + let cli_hint = cli_env_hint(protocol, port); + + let store_type = if let Some(bucket) = args.bucket { + StoreType::S3Bucket(bucket) + } else if let Some(local_root) = args.local_root { + StoreType::LocalFileSystem(local_root) + } else { + StoreType::InMemory + }; + + let object_store = init_object_store(&store_type, None).await?; + let wal_store_type = if let Some(bucket) = args.wal_bucket { + Some(StoreType::S3Bucket(bucket)) + } else { + args.wal_local_root.map(StoreType::LocalFileSystem) + }; + let wal_object_store = match &wal_store_type { + Some(wal_store_type) => { + let s3_overrides = match wal_store_type { + StoreType::S3Bucket(_) => Some(WalS3Overrides::from_env()?), + StoreType::LocalFileSystem(_) | StoreType::InMemory => None, + }; + Some(init_object_store(wal_store_type, s3_overrides).await?) + } + None => None, + }; + + let db_settings = slatedb::Settings::from_env_with_default( + "SL8_", + slatedb::Settings { + flush_interval: Some( + wal_store_type + .as_ref() + .unwrap_or(&store_type) + .default_flush_interval(), + ), + ..Default::default() + }, + )?; + + let manifest_poll_interval = db_settings.manifest_poll_interval; + + let mut builder = slatedb::Db::builder(args.path, object_store).with_settings(db_settings); + if let Some(wal_object_store) = wal_object_store { + info!(store = ?wal_store_type, "using dedicated WAL object store"); + builder = builder.with_wal_object_store(wal_object_store); + } + let db = builder.build().await?; + + info!( + ?manifest_poll_interval, + "sleeping to ensure prior instance fenced out" + ); + + tokio::time::sleep(manifest_poll_interval).await; + + info!(%args.append_inflight_bytes, "starting backend"); + let backend = Backend::new(db, args.append_inflight_bytes); + let shutdown_backend = backend.clone(); + crate::backend::bgtasks::spawn(&backend); + + if let Some(init_file) = &args.init_file { + let spec = init::load(init_file)?; + init::apply(&backend, spec).await?; + } + + let mut app = handlers::router() + .with_state(backend) + .layer( + TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO)) + .on_request(DefaultOnRequest::new().level(tracing::Level::DEBUG)) + .on_response(DefaultOnResponse::new().level(tracing::Level::INFO)), + ) + .layer(SetSensitiveRequestHeadersLayer::new([ + AUTHORIZATION, + S2_ENCRYPTION_KEY_HEADER.clone(), + ])); + + if !args.no_cors { + app = app.layer(CorsLayer::very_permissive()); + } + + let server_handle = axum_server::Handle::new(); + tokio::spawn(shutdown_signal(server_handle.clone())); + match ( + args.tls.tls_self, + args.tls.tls_cert.clone(), + args.tls.tls_key.clone(), + ) { + (false, Some(cert_path), Some(key_path)) => { + info!( + addr, + ?cert_path, + "starting https server with provided certificate" + ); + let rustls_config = RustlsConfig::from_pem_file(cert_path, key_path).await?; + info!("{}", cli_hint); + axum_server::bind_rustls(addr.parse()?, rustls_config) + .handle(server_handle) + .serve(app.into_make_service()) + .await?; + } + (true, None, None) => { + info!( + addr, + "starting https server with self-signed certificate, clients will need to use --insecure" + ); + let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed([ + "localhost".to_string(), + "127.0.0.1".to_string(), + "::1".to_string(), + ])?; + let rustls_config = RustlsConfig::from_pem( + cert.pem().into_bytes(), + signing_key.serialize_pem().into_bytes(), + ) + .await?; + info!("{}", cli_hint); + axum_server::bind_rustls(addr.parse()?, rustls_config) + .handle(server_handle) + .serve(app.into_make_service()) + .await?; + } + (false, None, None) => { + info!(addr, "starting plain http server"); + info!("{}", cli_hint); + axum_server::bind(addr.parse()?) + .handle(server_handle) + .serve(app.into_make_service()) + .await?; + } + _ => { + // This shouldn't happen due to clap validation... + return Err(eyre::eyre!("Invalid TLS configuration")); + } + } + + info!("http server stopped; closing SlateDB"); + let close_started = Instant::now(); + shutdown_backend + .close() + .await + .map_err(|error| eyre::eyre!("SlateDB close: {error}"))?; + info!( + elapsed_ms = close_started.elapsed().as_millis(), + "SlateDB closed" + ); + + Ok(()) +} + +async fn init_object_store( + store_type: &StoreType, + s3_overrides: Option, +) -> eyre::Result> { + let store: Arc = match store_type { + StoreType::S3Bucket(bucket) => { + info!(bucket, "using s3 object store"); + let mut builder = s3_builder().await; + if let Some(overrides) = s3_overrides { + builder = overrides.apply(builder); + } + Arc::new(builder.with_bucket_name(bucket).build()?) + } + StoreType::LocalFileSystem(local_root) => { + std::fs::create_dir_all(local_root)?; + info!( + root = %local_root.display(), + "using local filesystem object store" + ); + Arc::new( + // Match the durability contract of remote object stores: an + // acknowledged SlateDB write must survive a host crash. + object_store::local::LocalFileSystem::new_with_prefix(local_root)?.with_fsync(true), + ) + } + StoreType::InMemory => { + info!("using in-memory object store"); + Arc::new(object_store::memory::InMemory::new()) + } + }; + Ok(store) +} + +// Both buckets start with the same AWS configuration and credential chain. +async fn s3_builder() -> object_store::aws::AmazonS3Builder { + let mut builder = object_store::aws::AmazonS3Builder::from_env(); + + if let Some(endpoint) = + std::env::var_os("AWS_ENDPOINT_URL_S3").and_then(|s| s.into_string().ok()) + { + if endpoint.starts_with("http://") { + builder = builder.with_allow_http(true); + } + builder = builder.with_endpoint(endpoint); + } + + match ( + std::env::var_os("AWS_ACCESS_KEY_ID").and_then(|s| s.into_string().ok()), + std::env::var_os("AWS_SECRET_ACCESS_KEY").and_then(|s| s.into_string().ok()), + ) { + (Some(key_id), Some(secret_key)) => { + info!(key_id, "using static credentials from env vars"); + + let aws_config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + if let Some(region) = aws_config.region() { + info!(region = region.as_ref()); + builder = builder.with_region(region.to_string()); + } + + let token = std::env::var_os("AWS_SESSION_TOKEN").and_then(|s| s.into_string().ok()); + builder = builder.with_credentials(Arc::new( + object_store::StaticCredentialProvider::new(object_store::aws::AwsCredential { + key_id, + secret_key, + token, + }), + )); + } + _ => { + let aws_config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + if let Some(region) = aws_config.region() { + info!(region = region.as_ref()); + builder = builder.with_region(region.to_string()); + } + if let Some(credentials_provider) = aws_config.credentials_provider() { + info!("using aws-config credentials provider"); + builder = builder.with_credentials(Arc::new(S3CredentialProvider { + aws: credentials_provider.clone(), + cache: tokio::sync::Mutex::new(None), + })); + } + } + } + builder +} + +// Keep credentials out of LiteArgs and its startup Debug log. Only supplied +// fields override the shared AWS configuration; credentials are replaced as a set. +struct WalS3Overrides { + endpoint: Option, + region: Option, + access_key_id: Option, + secret_access_key: Option, + session_token: Option, +} + +impl WalS3Overrides { + fn from_env() -> eyre::Result { + Self::from_getter(|name| match std::env::var(name) { + Ok(value) => Ok(Some(value)), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => { + Err(eyre::eyre!("{name} must contain valid UTF-8")) + } + }) + } + + fn from_getter(get: impl Fn(&str) -> eyre::Result>) -> eyre::Result { + let config = Self { + endpoint: get("S2LITE_WAL_AWS_ENDPOINT_URL_S3")?, + region: get("S2LITE_WAL_AWS_REGION")?, + access_key_id: get("S2LITE_WAL_AWS_ACCESS_KEY_ID")?, + secret_access_key: get("S2LITE_WAL_AWS_SECRET_ACCESS_KEY")?, + session_token: get("S2LITE_WAL_AWS_SESSION_TOKEN")?, + }; + eyre::ensure!( + config.access_key_id.is_some() == config.secret_access_key.is_some(), + "S2LITE_WAL_AWS_ACCESS_KEY_ID and S2LITE_WAL_AWS_SECRET_ACCESS_KEY must be set together" + ); + eyre::ensure!( + config.session_token.is_none() || config.access_key_id.is_some(), + "S2LITE_WAL_AWS_SESSION_TOKEN requires the WAL access key and secret key" + ); + Ok(config) + } + + fn apply( + self, + mut builder: object_store::aws::AmazonS3Builder, + ) -> object_store::aws::AmazonS3Builder { + if let Some(endpoint) = &self.endpoint { + // Override the S3-specific endpoint inherited from the main store. + builder = builder + .with_allow_http(endpoint.starts_with("http://")) + .with_config(object_store::aws::AmazonS3ConfigKey::S3Endpoint, endpoint); + } + if let Some(region) = self.region { + builder = builder.with_region(region); + } + if let (Some(key_id), Some(secret_key)) = (self.access_key_id, self.secret_access_key) { + builder = builder.with_credentials(Arc::new( + object_store::StaticCredentialProvider::new(object_store::aws::AwsCredential { + key_id, + secret_key, + token: self.session_token, + }), + )); + } + builder + } +} + +async fn shutdown_signal(handle: axum_server::Handle) { + let ctrl_c = async { + tokio::signal::ctrl_c().await.expect("ctrl-c"); + }; + + #[cfg(unix)] + let term = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("SIGTERM") + .recv() + .await; + }; + + #[cfg(not(unix))] + let term = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => { + info!("received Ctrl+C, starting graceful shutdown"); + }, + _ = term => { + info!("received SIGTERM, starting graceful shutdown"); + }, + } + + handle.graceful_shutdown(Some(Duration::from_secs(10))); +} + +#[derive(Debug)] +struct CachedCredential { + credential: Arc, + expiry: Option, +} + +impl CachedCredential { + fn is_valid(&self) -> bool { + self.expiry + .is_none_or(|exp| exp > SystemTime::now() + Duration::from_secs(60)) + } +} + +#[derive(Debug)] +struct S3CredentialProvider { + aws: aws_credential_types::provider::SharedCredentialsProvider, + cache: tokio::sync::Mutex>, +} + +#[async_trait::async_trait] +impl object_store::CredentialProvider for S3CredentialProvider { + type Credential = object_store::aws::AwsCredential; + + async fn get_credential(&self) -> object_store::Result> { + let mut cached = self.cache.lock().await; + if let Some(cached) = cached.as_ref().filter(|c| c.is_valid()) { + return Ok(cached.credential.clone()); + } + + use aws_credential_types::provider::ProvideCredentials as _; + + let start = Instant::now(); + let creds = + self.aws + .provide_credentials() + .await + .map_err(|e| object_store::Error::Generic { + store: "S3", + source: Box::new(e), + })?; + info!( + key_id = creds.access_key_id(), + expiry_s = creds + .expiry() + .and_then(|t| t.duration_since(SystemTime::now()).ok()) + .map(|d| d.as_secs()), + elapsed_ms = start.elapsed().as_millis(), + "fetched credentials" + ); + let credential = Arc::new(object_store::aws::AwsCredential { + key_id: creds.access_key_id().to_owned(), + secret_key: creds.secret_access_key().to_owned(), + token: creds.session_token().map(|s| s.to_owned()), + }); + *cached = Some(CachedCredential { + credential: credential.clone(), + expiry: creds.expiry(), + }); + Ok(credential) + } +} + +#[cfg(test)] +mod tests { + use super::{ServerProtocol, WalS3Overrides, cli_endpoint, cli_env_hint}; + + fn wal_config(values: &[(&str, &str)]) -> eyre::Result { + WalS3Overrides::from_getter(|name| { + Ok(values + .iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| (*value).to_owned())) + }) + } + + #[test] + fn wal_static_credentials_must_be_complete() { + for values in [ + vec![("S2LITE_WAL_AWS_ACCESS_KEY_ID", "test-key")], + vec![("S2LITE_WAL_AWS_SECRET_ACCESS_KEY", "do-not-log-this-secret")], + vec![("S2LITE_WAL_AWS_SESSION_TOKEN", "do-not-log-this-token")], + ] { + let error = wal_config(&values) + .err() + .expect("invalid credentials") + .to_string(); + assert!(error.contains("S2LITE_WAL_AWS_")); + assert!(!error.contains("do-not-log-this")); + } + assert!(wal_config(&[]).is_ok()); + } + + #[test] + fn cli_endpoint_uses_localhost_with_explicit_port() { + assert_eq!( + cli_endpoint(ServerProtocol::Http, 80), + "http://localhost:80" + ); + assert_eq!( + cli_endpoint(ServerProtocol::Https { self_signed: false }, 443), + "https://localhost:443" + ); + } + + #[test] + fn cli_env_hint_includes_exports_for_http() { + assert_eq!( + cli_env_hint(ServerProtocol::Http, 8080), + concat!( + "copy/paste into a new terminal to point the S2 CLI at this server:\n", + "export S2_ACCOUNT_ENDPOINT=http://localhost:8080\n", + "export S2_BASIN_ENDPOINT=http://localhost:8080\n", + "export S2_ACCESS_TOKEN=ignored", + ) + ); + } + + #[test] + fn cli_env_hint_includes_ssl_no_verify_for_self_signed_tls() { + assert_eq!( + cli_env_hint(ServerProtocol::Https { self_signed: true }, 8443), + concat!( + "copy/paste into a new terminal to point the S2 CLI at this server:\n", + "export S2_ACCOUNT_ENDPOINT=https://localhost:8443\n", + "export S2_BASIN_ENDPOINT=https://localhost:8443\n", + "export S2_ACCESS_TOKEN=ignored\n", + "export S2_SSL_NO_VERIFY=1", + ) + ); + } +} diff --git a/lite/src/stream_id.rs b/lite/src/stream_id.rs new file mode 100644 index 00000000..b450b024 --- /dev/null +++ b/lite/src/stream_id.rs @@ -0,0 +1,40 @@ +use s2_common::{basin::BasinName, stream::StreamName}; +use s2_storage::bash::Bash; + +/// Unique identifier for a stream scoped by its basin. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct StreamId(Bash); + +impl std::fmt::Display for StreamId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl std::fmt::Debug for StreamId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "StreamId({})", self.0) + } +} + +impl StreamId { + pub const LEN: usize = 32; + const SEPARATOR: u8 = 0; + + pub fn new(basin: &BasinName, stream: &StreamName) -> Self { + Self(Bash::delimited( + &[basin.as_bytes(), stream.as_bytes()], + Self::SEPARATOR, + )) + } + + pub fn as_bytes(&self) -> &[u8; Self::LEN] { + self.0.as_bytes() + } +} + +impl From<[u8; StreamId::LEN]> for StreamId { + fn from(bytes: [u8; StreamId::LEN]) -> Self { + Self(bytes.into()) + } +} diff --git a/lite/tests/backend/common/mod.rs b/lite/tests/backend/common/mod.rs new file mode 100644 index 00000000..a4bea287 --- /dev/null +++ b/lite/tests/backend/common/mod.rs @@ -0,0 +1,73 @@ +use std::pin::Pin; + +use futures::Stream; +use s2_common::{ + basin::BasinName, + config::OptionalStreamConfig, + encryption::EncryptionSpec, + record::StreamPosition, + stream::{AppendAck, AppendInput, StreamName}, +}; +use s2_lite::backend::{ + Backend, + error::{AppendError, CheckTailError}, +}; + +mod read; +mod setup; + +pub use read::*; +pub use setup::*; + +pub async fn append( + backend: &Backend, + basin: BasinName, + stream: StreamName, + input: AppendInput, + encryption: Option<&EncryptionSpec>, +) -> Result { + backend + .open_for_append( + &basin, + &stream, + encryption.and_then(encryption_key_for_spec), + OptionalStreamConfig::default(), + ) + .await? + .append(input) + .await +} + +pub async fn append_session( + backend: &Backend, + basin: BasinName, + stream: StreamName, + encryption: Option<&EncryptionSpec>, + inputs: S, +) -> Result>>>, AppendError> +where + S: Stream + 'static, +{ + let session = backend + .open_for_append( + &basin, + &stream, + encryption.and_then(encryption_key_for_spec), + OptionalStreamConfig::default(), + ) + .await? + .append_session(inputs); + Ok(Box::pin(session)) +} + +pub async fn check_tail( + backend: &Backend, + basin: BasinName, + stream: StreamName, +) -> Result { + backend + .open_for_check_tail(&basin, &stream) + .await? + .check_tail() + .await +} diff --git a/lite/tests/backend/common/read.rs b/lite/tests/backend/common/read.rs new file mode 100644 index 00000000..6e3405ba --- /dev/null +++ b/lite/tests/backend/common/read.rs @@ -0,0 +1,355 @@ +use std::{pin::Pin, task::Poll, time::Duration}; + +use futures::StreamExt; +use s2_common::{ + basin::BasinName, + config::OptionalStreamConfig, + encryption::EncryptionSpec, + read_extent::{ReadLimit, ReadUntil}, + record::{Record, SequencedRecord}, + stream::{ReadEnd, ReadFrom, ReadSessionOutput, ReadStart, StreamName}, +}; +use s2_lite::backend::{Backend, error::ReadError}; + +use super::encryption_key_for_spec; + +pub fn read_all_bounds() -> (ReadStart, ReadEnd) { + ( + ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }, + ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }, + ) +} + +pub async fn open_read_session( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + start: ReadStart, + end: ReadEnd, +) -> Pin> + use<>>> { + open_read_session_with_encryption(backend, basin, stream, start, end, &EncryptionSpec::Plain) + .await +} + +pub async fn open_read_session_with_encryption( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + start: ReadStart, + end: ReadEnd, + encryption: &EncryptionSpec, +) -> Pin> + use<>>> { + try_open_read_session_with_encryption(backend, basin, stream, start, end, encryption) + .await + .expect("Failed to create read session") +} + +pub async fn try_open_read_session( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + start: ReadStart, + end: ReadEnd, +) -> Result< + Pin> + use<>>>, + ReadError, +> { + try_open_read_session_with_encryption( + backend, + basin, + stream, + start, + end, + &EncryptionSpec::Plain, + ) + .await +} + +pub async fn try_open_read_session_with_encryption( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + start: ReadStart, + end: ReadEnd, + encryption: &EncryptionSpec, +) -> Result< + Pin> + use<>>>, + ReadError, +> { + let read_session = backend + .open_for_read( + basin, + stream, + encryption_key_for_spec(encryption), + OptionalStreamConfig::default(), + ) + .await? + .read(start, end) + .await?; + Ok(Box::pin(read_session)) +} + +pub async fn advance_time(by: Duration) { + tokio::time::advance(by).await; + tokio::task::yield_now().await; +} + +pub enum SessionPoll { + Output(ReadSessionOutput), + Closed, + TimedOut, +} + +pub struct ClosedSessionOutputs { + pub outputs: Vec, + pub closed_at: tokio::time::Instant, +} + +fn map_session_output(output: Option>) -> SessionPoll { + match output { + Some(Ok(output)) => SessionPoll::Output(output), + Some(Err(e)) => panic!("Read error: {:?}", e), + None => SessionPoll::Closed, + } +} + +pub async fn poll_session_with_deadline( + session: &mut Pin>, + deadline: tokio::time::Instant, + advance_step: Option, +) -> SessionPoll +where + S: futures::Stream>, +{ + if let Some(step) = advance_step { + let mut pinned_session = session.as_mut(); + let next = pinned_session.next(); + tokio::pin!(next); + + loop { + let now = tokio::time::Instant::now(); + let Some(remaining) = deadline.checked_duration_since(now) else { + return SessionPoll::TimedOut; + }; + + if remaining.is_zero() { + return match futures::poll!(&mut next) { + Poll::Ready(output) => map_session_output(output), + Poll::Pending => SessionPoll::TimedOut, + }; + } + + tokio::select! { + biased; + output = &mut next => return map_session_output(output), + () = tokio::time::advance(step.min(remaining)) => { + tokio::task::yield_now().await; + } + } + } + } + + loop { + let now = tokio::time::Instant::now(); + let Some(remaining) = deadline.checked_duration_since(now) else { + return SessionPoll::TimedOut; + }; + + match tokio::time::timeout( + remaining.min(Duration::from_millis(500)), + session.as_mut().next(), + ) + .await + { + Ok(output) => return map_session_output(output), + Err(_) => continue, + } + } +} + +async fn collect_records_inner( + session: &mut Pin>, + timeout: Option, + target_count: Option, + advance_step: Option, +) -> Vec +where + S: futures::Stream>, +{ + let deadline = timeout.map(|timeout| tokio::time::Instant::now() + timeout); + let mut records = Vec::new(); + + loop { + if let Some(target_count) = target_count + && records.len() >= target_count + { + break; + } + + let polled = if let Some(deadline) = deadline { + poll_session_with_deadline(session, deadline, advance_step).await + } else { + match session.as_mut().next().await { + Some(Ok(output)) => SessionPoll::Output(output), + Some(Err(e)) => panic!("Read error: {:?}", e), + None => SessionPoll::Closed, + } + }; + + match polled { + SessionPoll::Output(ReadSessionOutput::Batch(batch)) => { + if let Some(target_count) = target_count { + let remaining = target_count.saturating_sub(records.len()); + records.extend(batch.records.iter().take(remaining).cloned()); + if batch.records.len() >= remaining { + break; + } + } else { + records.extend(batch.records.iter().cloned()); + } + } + SessionPoll::Output(ReadSessionOutput::Heartbeat(_)) => {} + SessionPoll::Closed | SessionPoll::TimedOut => break, + } + } + + records +} + +pub async fn collect_records(session: &mut Pin>) -> Vec +where + S: futures::Stream>, +{ + collect_records_inner(session, None, None, None).await +} + +pub async fn collect_records_until_advanced( + session: &mut Pin>, + timeout: Duration, + target_count: usize, + advance_step: Duration, +) -> Vec +where + S: futures::Stream>, +{ + collect_records_inner( + session, + Some(timeout), + Some(target_count), + Some(advance_step), + ) + .await +} + +pub async fn expect_heartbeat_advanced( + session: &mut Pin>, + timeout: Duration, + advance_step: Duration, +) where + S: futures::Stream>, +{ + let deadline = tokio::time::Instant::now() + timeout; + let output = match poll_session_with_deadline(session, deadline, Some(advance_step)).await { + SessionPoll::Output(output) => output, + SessionPoll::Closed => panic!("Read session ended unexpectedly"), + SessionPoll::TimedOut => panic!("Timed out waiting for heartbeat"), + }; + + assert!( + matches!(output, ReadSessionOutput::Heartbeat(_)), + "Unexpected first output: {output:?}" + ); +} + +pub async fn collect_outputs_until_closed_advanced( + session: &mut Pin>, + timeout: Duration, + advance_step: Duration, +) -> ClosedSessionOutputs +where + S: futures::Stream>, +{ + let deadline = tokio::time::Instant::now() + timeout; + let mut outputs = Vec::new(); + + loop { + match poll_session_with_deadline(session, deadline, Some(advance_step)).await { + SessionPoll::Output(output) => outputs.push(output), + SessionPoll::Closed => { + return ClosedSessionOutputs { + outputs, + closed_at: tokio::time::Instant::now(), + }; + } + SessionPoll::TimedOut => panic!("Timed out waiting for read session to close"), + } + } +} + +pub async fn collect_records_until_closed_advanced( + session: &mut Pin>, + timeout: Duration, + advance_step: Duration, +) -> Vec +where + S: futures::Stream>, +{ + let deadline = tokio::time::Instant::now() + timeout; + let mut records = Vec::new(); + + loop { + match poll_session_with_deadline(session, deadline, Some(advance_step)).await { + SessionPoll::Output(ReadSessionOutput::Batch(batch)) => { + records.extend(batch.records.iter().cloned()); + } + SessionPoll::Output(ReadSessionOutput::Heartbeat(_)) => {} + SessionPoll::Closed => break, + SessionPoll::TimedOut => panic!("Timed out waiting for read session to close"), + } + } + + records +} + +pub async fn read_records( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + start: ReadStart, + end: ReadEnd, +) -> Vec { + let read_session = open_read_session(backend, basin, stream, start, end).await; + let mut read_session = Box::pin(read_session); + collect_records(&mut read_session).await +} + +pub async fn read_records_with_encryption( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + start: ReadStart, + end: ReadEnd, + encryption: &EncryptionSpec, +) -> Vec { + let read_session = + open_read_session_with_encryption(backend, basin, stream, start, end, encryption).await; + let mut read_session = Box::pin(read_session); + collect_records(&mut read_session).await +} + +pub fn envelope_bodies(records: &[SequencedRecord]) -> Vec> { + records + .iter() + .map(|record| match record.inner() { + Record::Envelope(envelope) => envelope.body().to_vec(), + other => panic!("Unexpected record type: {:?}", other), + }) + .collect() +} diff --git a/lite/tests/backend/common/setup.rs b/lite/tests/backend/common/setup.rs new file mode 100644 index 00000000..597f8023 --- /dev/null +++ b/lite/tests/backend/common/setup.rs @@ -0,0 +1,331 @@ +use std::{fmt::Debug, future::Future, sync::Arc, time::Duration}; + +use bytes::Bytes; +use bytesize::ByteSize; +use s2_common::{ + basin::BasinName, + config::{BasinConfig, OptionalStreamConfig}, + encryption::{EncryptionAlgorithm, EncryptionKey, EncryptionSpec}, + record::{CommandRecord, FencingToken, Metered, Record, Timestamp}, + resources::ProvisionMode, + stream::{ + AppendAck, AppendInput, AppendRecord, AppendRecordBatch, AppendRecordParts, StreamName, + }, +}; +use s2_lite::backend::Backend; +use slatedb::{Db, config::Settings, object_store::memory::InMemory}; +use uuid::Uuid; + +const TEST_AEGIS256_KEY: [u8; 32] = [0x42; 32]; +const TEST_AES256_GCM_KEY: [u8; 32] = [0x24; 32]; + +pub async fn create_in_memory_db() -> Db { + create_in_memory_db_with_flush_interval(Some(Duration::from_millis(5))).await +} + +async fn create_in_memory_db_with_flush_interval(flush_interval: Option) -> Db { + let object_store = Arc::new(InMemory::new()); + let db_path = format!("/tmp/test_{}", Uuid::new_v4()); + + Db::builder(db_path, object_store) + .with_settings(Settings { + flush_interval, + ..Default::default() + }) + .build() + .await + .expect("Failed to create in-memory database") +} + +pub async fn create_backend() -> Backend { + let db = create_in_memory_db().await; + Backend::new(db, ByteSize::mib(10)) +} + +pub async fn create_backend_without_auto_flush() -> (Backend, Db) { + let db = create_in_memory_db_with_flush_interval(None).await; + (Backend::new(db.clone(), ByteSize::mib(10)), db) +} + +/// Poll an operation until the database has a committed, unflushed write. +/// The database must have automatic flushing disabled and no unrelated writers. +pub async fn assert_pending_until_committed( + db: &Db, + operation: &mut (impl Future + Unpin), +) -> u64 { + tokio::time::timeout(Duration::from_secs(5), async { + tokio::select! { + biased; + result = operation => panic!("metadata acknowledged before flush: {result:?}"), + seq = async { + loop { + let seq = db.snapshot().await.unwrap().seq(); + if seq > db.status().durable_seq { + return seq; + } + tokio::task::yield_now().await; + } + } => seq, + } + }) + .await + .expect("metadata should be committed in memory before flushing") +} + +pub async fn assert_waits_for_flush(db: &Db, operation: impl Future) -> T { + tokio::pin!(operation); + tokio::time::timeout(Duration::from_secs(5), async { + let seq = assert_pending_until_committed(db, &mut operation).await; + assert!(futures::poll!(&mut operation).is_pending()); + db.flush().await.unwrap(); + let result = operation.await; + assert!(db.status().durable_seq >= seq); + result + }) + .await + .expect("metadata write should finish after an explicit flush") +} + +pub fn test_basin_name(suffix: &str) -> BasinName { + format!("test-basin-{}", suffix).parse().unwrap() +} + +pub fn test_stream_name(suffix: &str) -> StreamName { + format!("test-stream-{}", suffix).parse().unwrap() +} + +pub fn basin_config_with_stream_cipher(stream_cipher: EncryptionAlgorithm) -> BasinConfig { + BasinConfig { + default_stream_config: OptionalStreamConfig::default(), + stream_cipher: Some(stream_cipher), + ..Default::default() + } +} + +pub fn aegis256_encryption_spec() -> EncryptionSpec { + EncryptionSpec::aegis256(TEST_AEGIS256_KEY) +} + +pub fn aegis256_encryption_key() -> EncryptionKey { + EncryptionKey::new(TEST_AEGIS256_KEY) +} + +pub fn aes256_gcm_encryption_key() -> EncryptionKey { + EncryptionKey::new(TEST_AES256_GCM_KEY) +} + +pub fn encryption_key_for_spec(encryption: &EncryptionSpec) -> Option { + match encryption { + EncryptionSpec::Plain => None, + // Test helpers use fixed key material for encrypted cases. + EncryptionSpec::Aegis256(_) => Some(aegis256_encryption_key()), + EncryptionSpec::Aes256Gcm(_) => Some(aes256_gcm_encryption_key()), + } +} + +pub async fn setup_backend_for_encryption_spec( + basin_suffix: &str, + stream_suffix: &str, + encryption: &EncryptionSpec, +) -> (Backend, BasinName, StreamName) { + match encryption { + EncryptionSpec::Plain => { + setup_backend_with_stream(basin_suffix, stream_suffix, OptionalStreamConfig::default()) + .await + } + EncryptionSpec::Aegis256(_) => { + setup_backend_with_basin_and_stream( + basin_suffix, + stream_suffix, + basin_config_with_stream_cipher(EncryptionAlgorithm::Aegis256), + OptionalStreamConfig::default(), + ) + .await + } + EncryptionSpec::Aes256Gcm(_) => { + setup_backend_with_basin_and_stream( + basin_suffix, + stream_suffix, + basin_config_with_stream_cipher(EncryptionAlgorithm::Aes256Gcm), + OptionalStreamConfig::default(), + ) + .await + } + } +} + +pub fn create_test_record(body: Bytes) -> AppendRecord { + create_test_record_with_optional_timestamp(body, None) +} + +pub fn create_test_record_with_optional_timestamp( + body: Bytes, + timestamp: Option, +) -> AppendRecord { + let envelope = s2_common::record::EnvelopeRecord::try_from_parts(vec![], body).unwrap(); + let record = Metered::from(Record::Envelope(envelope)); + let parts = AppendRecordParts { timestamp, record }; + parts.try_into().unwrap() +} + +pub fn create_test_record_with_timestamp(body: Bytes, timestamp: Timestamp) -> AppendRecord { + create_test_record_with_optional_timestamp(body, Some(timestamp)) +} + +pub fn create_fencing_command_record(token: FencingToken) -> AppendRecord { + let record = Metered::from(Record::Command(CommandRecord::Fence(token))); + let parts = AppendRecordParts { + timestamp: None, + record, + }; + parts.try_into().unwrap() +} + +pub fn create_test_record_batch(bodies: Vec) -> AppendRecordBatch { + let records: Vec = bodies.into_iter().map(create_test_record).collect(); + records.try_into().unwrap() +} + +pub fn create_test_record_batch_with_timestamps( + items: Vec<(Bytes, Timestamp)>, +) -> AppendRecordBatch { + let records: Vec = items + .into_iter() + .map(|(body, timestamp)| create_test_record_with_timestamp(body, timestamp)) + .collect(); + records.try_into().unwrap() +} + +pub async fn create_test_basin(backend: &Backend, suffix: &str, config: BasinConfig) -> BasinName { + let basin_name = test_basin_name(suffix); + backend + .provision_basin( + basin_name.clone(), + config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create basin"); + basin_name +} + +pub async fn create_test_stream( + backend: &Backend, + basin: &BasinName, + suffix: &str, + config: OptionalStreamConfig, +) -> StreamName { + let stream_name = test_stream_name(suffix); + backend + .provision_stream( + basin.clone(), + stream_name.clone(), + config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create stream"); + stream_name +} + +pub async fn setup_backend_with_stream( + basin_suffix: &str, + stream_suffix: &str, + stream_config: OptionalStreamConfig, +) -> (Backend, BasinName, StreamName) { + setup_backend_with_basin_and_stream( + basin_suffix, + stream_suffix, + BasinConfig::default(), + stream_config, + ) + .await +} + +pub async fn setup_backend_with_basin_and_stream( + basin_suffix: &str, + stream_suffix: &str, + basin_config: BasinConfig, + stream_config: OptionalStreamConfig, +) -> (Backend, BasinName, StreamName) { + let backend = create_backend().await; + let basin_name = create_test_basin(&backend, basin_suffix, basin_config).await; + let stream_name = create_test_stream(&backend, &basin_name, stream_suffix, stream_config).await; + (backend, basin_name, stream_name) +} + +pub async fn append_payloads( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + payloads: &[&[u8]], +) -> AppendAck { + let encryption = EncryptionSpec::Plain; + append_payloads_with_encryption(backend, basin, stream, payloads, &encryption).await +} + +pub async fn append_payloads_with_encryption( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + payloads: &[&[u8]], + encryption: &EncryptionSpec, +) -> AppendAck { + let bodies = payloads + .iter() + .map(|bytes| Bytes::copy_from_slice(bytes)) + .collect(); + let input = AppendInput { + records: create_test_record_batch(bodies), + match_seq_num: None, + fencing_token: None, + }; + backend + .open_for_append( + basin, + stream, + encryption_key_for_spec(encryption), + OptionalStreamConfig::default(), + ) + .await + .expect("Failed to open append handle") + .append(input) + .await + .expect("Failed to append payloads") +} + +pub async fn append_timestamped_payloads( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + payloads: Vec<(Bytes, Timestamp)>, +) -> AppendAck { + let input = AppendInput { + records: create_test_record_batch_with_timestamps(payloads), + match_seq_num: None, + fencing_token: None, + }; + backend + .open_for_append(basin, stream, None, OptionalStreamConfig::default()) + .await + .expect("Failed to open append handle") + .append(input) + .await + .expect("Failed to append timestamped payloads") +} + +pub async fn append_repeat( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + payload: &[u8], + count: usize, +) { + for _ in 0..count { + append_payloads(backend, basin, stream, &[payload]).await; + } +} diff --git a/lite/tests/backend/control_plane/basin.rs b/lite/tests/backend/control_plane/basin.rs new file mode 100644 index 00000000..67e1037d --- /dev/null +++ b/lite/tests/backend/control_plane/basin.rs @@ -0,0 +1,767 @@ +use std::time::Duration; + +use s2_common::{ + basin::{BasinNamePrefix, BasinNameStartAfter, ListBasinsRequest}, + config::{ + BasinConfig, BasinReconfiguration, OptionalDeleteOnEmptyConfig, OptionalStreamConfig, + RetentionPolicy, StreamReconfiguration, TimestampingMode, TimestampingReconfiguration, + }, + maybe::Maybe, + resources::{ProvisionMode, ProvisionResult, RequestToken}, +}; +use s2_lite::backend::error::{ + DeleteBasinError, GetBasinConfigError, ProvisionBasinError, ReconfigureBasinError, +}; + +use super::common::*; + +#[tokio::test] +async fn test_provision_basin_acknowledges_only_durable_metadata() { + let (backend, db) = create_backend_without_auto_flush().await; + let basin = test_basin_name("durable"); + let result = assert_waits_for_flush( + &db, + backend.provision_basin(basin.clone(), BasinConfig::default(), ProvisionMode::Ensure), + ) + .await + .unwrap(); + assert!(matches!(result, ProvisionResult::Created(_))); + assert_eq!( + backend.get_basin_config(basin).await.unwrap(), + BasinConfig::default() + ); + backend.close().await.unwrap(); +} + +#[rstest::rstest] +#[case::ensure(ProvisionMode::Ensure, false)] +#[case::idempotent_create(ProvisionMode::CreateOnly { + request_token: Some("original".parse().unwrap()), +}, false)] +#[case::create_without_token(ProvisionMode::CreateOnly { + request_token: None, +}, true)] +#[case::create_with_different_token(ProvisionMode::CreateOnly { + request_token: Some("different".parse().unwrap()), +}, true)] +#[tokio::test] +async fn test_basin_retries_acknowledge_only_durable_metadata( + #[case] retry_mode: ProvisionMode, + #[case] expect_already_exists: bool, +) { + let (backend, db) = create_backend_without_auto_flush().await; + let basin = test_basin_name("durable-retry"); + let creation = backend.provision_basin( + basin.clone(), + BasinConfig::default(), + ProvisionMode::CreateOnly { + request_token: Some("original".parse().unwrap()), + }, + ); + tokio::pin!(creation); + assert_pending_until_committed(&db, &mut creation).await; + assert!(matches!( + backend.get_basin_config(basin.clone()).await, + Err(GetBasinConfigError::BasinNotFound(_)) + )); + + let retry = assert_waits_for_flush( + &db, + backend.provision_basin(basin.clone(), BasinConfig::default(), retry_mode), + ) + .await; + if expect_already_exists { + assert!(matches!( + retry, + Err(ProvisionBasinError::BasinAlreadyExists(_)) + )); + } else { + assert!(matches!(retry, Ok(ProvisionResult::Noop(_)))); + } + assert!(matches!( + creation.await.unwrap(), + ProvisionResult::Created(_) + )); + assert_eq!( + backend.get_basin_config(basin).await.unwrap(), + BasinConfig::default() + ); + backend.close().await.unwrap(); +} + +#[tokio::test] +async fn test_create_basin_idempotency_respects_request_token() { + let backend = create_backend().await; + let basin_name = test_basin_name("basin-idempotency"); + let config = BasinConfig { + create_stream_on_append: true, + ..Default::default() + }; + + let token1: RequestToken = "token-1".parse().unwrap(); + + let created = backend + .provision_basin( + basin_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: Some(token1.clone()), + }, + ) + .await + .expect("Failed to create basin"); + assert!(matches!( + created, + ProvisionResult::Created(ref info) if info.deleted_at.is_none() + && info.created_at <= time::OffsetDateTime::now_utc() + )); + + let idempotent = backend + .provision_basin( + basin_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: Some(token1.clone()), + }, + ) + .await + .expect("Idempotent create should succeed with same request token"); + assert!(matches!( + idempotent, + ProvisionResult::Noop(ref info) if info.deleted_at.is_none() + && info.created_at <= time::OffsetDateTime::now_utc() + )); + + let different_token: RequestToken = "token-2".parse().unwrap(); + let different_token_result = backend + .provision_basin( + basin_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: Some(different_token), + }, + ) + .await; + assert!(matches!( + different_token_result, + Err(ProvisionBasinError::BasinAlreadyExists(_)) + )); + + let mut different_config = config.clone(); + different_config.create_stream_on_append = false; + let different_config_result = backend + .provision_basin( + basin_name, + different_config, + ProvisionMode::CreateOnly { + request_token: Some(token1), + }, + ) + .await; + assert!(matches!( + different_config_result, + Err(ProvisionBasinError::BasinAlreadyExists(_)) + )); +} + +#[tokio::test] +async fn test_ensure_preserves_idempotency_key() { + let backend = create_backend().await; + let basin_name = test_basin_name("idempotency-key-preserve"); + let config = BasinConfig { + create_stream_on_append: true, + ..Default::default() + }; + + let token: RequestToken = "my-request-token".parse().unwrap(); + + backend + .provision_basin( + basin_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: Some(token.clone()), + }, + ) + .await + .expect("Failed to create basin"); + + backend + .provision_basin( + basin_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: Some(token.clone()), + }, + ) + .await + .expect("Idempotency should work before Ensure"); + + let mut updated_config = config.clone(); + updated_config.create_stream_on_read = true; + backend + .provision_basin(basin_name.clone(), updated_config, ProvisionMode::Ensure) + .await + .expect("Ensure should succeed"); + + backend + .provision_basin( + basin_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: Some(token.clone()), + }, + ) + .await + .expect("Idempotency should still work after Ensure"); +} + +#[tokio::test] +async fn test_provision_basin_ensure_updates_config() { + let backend = create_backend().await; + let basin_name = test_basin_name("basin-recreate"); + let initial_config = BasinConfig { + create_stream_on_append: false, + create_stream_on_read: false, + ..Default::default() + }; + + backend + .provision_basin( + basin_name.clone(), + initial_config.clone(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create basin"); + + let mut updated_config = initial_config.clone(); + updated_config.create_stream_on_append = true; + updated_config.create_stream_on_read = true; + updated_config.default_stream_config.storage_class = Some("standard".into()); + + backend + .provision_basin( + basin_name.clone(), + updated_config.clone(), + ProvisionMode::Ensure, + ) + .await + .expect("Ensure should update basin config"); + + let stored_config = backend + .get_basin_config(basin_name.clone()) + .await + .expect("Failed to fetch basin config"); + assert!(stored_config.create_stream_on_append); + assert!(stored_config.create_stream_on_read); + assert_eq!( + stored_config.default_stream_config.storage_class, + Some("standard".into()) + ); + + backend + .provision_basin( + basin_name.clone(), + updated_config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect_err("CreateOnly without request token should not be idempotent"); +} + +#[tokio::test] +async fn test_provision_basin_ensure_resets_unspecified_config() { + let backend = create_backend().await; + let basin_name = test_basin_name("basin-ensure-reset"); + let initial_config = BasinConfig { + create_stream_on_append: true, + create_stream_on_read: false, + default_stream_config: OptionalStreamConfig { + storage_class: Some("standard".into()), + retention_policy: Some(RetentionPolicy::Infinite()), + ..Default::default() + }, + ..Default::default() + }; + + backend + .provision_basin( + basin_name.clone(), + initial_config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create basin"); + + backend + .provision_basin( + basin_name.clone(), + BasinConfig { + create_stream_on_read: true, + ..Default::default() + }, + ProvisionMode::Ensure, + ) + .await + .expect("Ensure should reset unspecified fields to defaults"); + + let stored_config = backend + .get_basin_config(basin_name) + .await + .expect("Failed to fetch basin config"); + assert!(!stored_config.create_stream_on_append); + assert!(stored_config.create_stream_on_read); + assert_eq!(stored_config.default_stream_config.storage_class, None); + assert_eq!(stored_config.default_stream_config.retention_policy, None); +} + +#[tokio::test] +async fn test_provision_basin_ensure_noops_with_explicit_zero_delete_on_empty() { + let backend = create_backend().await; + let basin_name = test_basin_name("basin-zero-doe-noop"); + let config = BasinConfig { + default_stream_config: OptionalStreamConfig { + delete_on_empty: OptionalDeleteOnEmptyConfig { + min_age: Some(Duration::ZERO), + }, + ..Default::default() + }, + ..Default::default() + }; + + backend + .provision_basin( + basin_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create basin"); + + let ensured = backend + .provision_basin(basin_name.clone(), config, ProvisionMode::Ensure) + .await + .expect("Ensure should succeed"); + + assert!(matches!(ensured, ProvisionResult::Noop(_))); + + let stored_config = backend + .get_basin_config(basin_name) + .await + .expect("Failed to fetch basin config"); + assert_eq!( + stored_config.default_stream_config.delete_on_empty.min_age, + Some(Duration::ZERO) + ); +} + +#[tokio::test] +async fn test_reconfigure_basin_updates_nested_defaults() { + let backend = create_backend().await; + let basin_name = test_basin_name("basin-reconfigure"); + let mut initial_config = BasinConfig::default(); + initial_config.default_stream_config.storage_class = Some("standard".into()); + + backend + .provision_basin( + basin_name.clone(), + initial_config.clone(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create basin"); + + let timestamping_reconfig = TimestampingReconfiguration { + mode: Maybe::from(Some(TimestampingMode::Arrival)), + ..Default::default() + }; + let mut stream_reconfig = StreamReconfiguration { + storage_class: Maybe::from(Some("express".into())), + retention_policy: Maybe::from(Some(RetentionPolicy::Infinite())), + ..Default::default() + }; + stream_reconfig.timestamping = Maybe::from(Some(timestamping_reconfig)); + + let reconfig = BasinReconfiguration { + default_stream_config: Maybe::from(Some(stream_reconfig)), + stream_cipher: Maybe::default(), + create_stream_on_append: Maybe::from(true), + create_stream_on_read: Maybe::from(true), + }; + + let updated = backend + .reconfigure_basin(basin_name.clone(), reconfig) + .await + .expect("Failed to reconfigure basin"); + + assert!(updated.create_stream_on_append); + assert!(updated.create_stream_on_read); + assert_eq!( + updated.default_stream_config.storage_class, + Some("express".into()) + ); + assert_eq!( + updated.default_stream_config.retention_policy, + Some(RetentionPolicy::Infinite()) + ); + assert_eq!( + updated.default_stream_config.timestamping.mode, + Some(TimestampingMode::Arrival) + ); + + let fetched = backend + .get_basin_config(basin_name) + .await + .expect("Failed to fetch basin config after reconfigure"); + assert_eq!( + fetched.default_stream_config.storage_class, + Some("express".into()) + ); + assert_eq!( + fetched.default_stream_config.retention_policy, + Some(RetentionPolicy::Infinite()) + ); + assert_eq!( + fetched.default_stream_config.timestamping.mode, + Some(TimestampingMode::Arrival) + ); + assert!(fetched.create_stream_on_append); + assert!(fetched.create_stream_on_read); +} + +#[tokio::test(start_paused = true)] +async fn test_delete_basin_retry_waits_for_durable_metadata() { + let (backend, db) = create_backend_without_auto_flush().await; + let basin = assert_waits_for_flush( + &db, + create_test_basin(&backend, "durable-delete", BasinConfig::default()), + ) + .await; + + let mut first = Box::pin(backend.delete_basin(basin.clone())); + assert_pending_until_committed(&db, &mut first).await; + drop(first); + + let retry = backend.delete_basin(basin); + tokio::pin!(retry); + assert!( + tokio::time::timeout(Duration::from_secs(1), &mut retry) + .await + .is_err() + ); + assert_waits_for_flush(&db, retry).await.unwrap(); + backend.close().await.unwrap(); +} + +#[tokio::test] +async fn test_delete_basin_marks_deleting_and_blocks_create() { + let backend = create_backend().await; + let basin_name = test_basin_name("basin-delete"); + + backend + .provision_basin( + basin_name.clone(), + BasinConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create basin"); + + backend + .delete_basin(basin_name.clone()) + .await + .expect("Failed to delete basin"); + + let page = backend + .list_basins(ListBasinsRequest::default()) + .await + .expect("Failed to list basins"); + let info = page + .values + .iter() + .find(|info| info.name == basin_name) + .expect("Deleted basin should appear in listing"); + assert!(info.deleted_at.is_some()); + + let reconfigure_result = backend + .reconfigure_basin(basin_name.clone(), BasinReconfiguration::default()) + .await; + assert!(matches!( + reconfigure_result, + Err(ReconfigureBasinError::BasinDeletionPending(_)) + )); + + let recreate_result = backend + .provision_basin( + basin_name.clone(), + BasinConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await; + assert!(matches!( + recreate_result, + Err(ProvisionBasinError::BasinDeletionPending(_)) + )); + + backend + .delete_basin(basin_name) + .await + .expect("Second delete should be idempotent"); +} + +#[tokio::test] +async fn test_get_nonexistent_basin_config() { + let backend = create_backend().await; + let basin_name = test_basin_name("nonexistent-basin"); + + let result = backend.get_basin_config(basin_name).await; + + assert!(matches!(result, Err(GetBasinConfigError::BasinNotFound(_)))); +} + +#[tokio::test] +async fn test_delete_nonexistent_basin_returns_not_found() { + let backend = create_backend().await; + let basin_name = test_basin_name("delete-missing-basin"); + + let result = backend.delete_basin(basin_name).await; + + assert!(matches!(result, Err(DeleteBasinError::BasinNotFound(_)))); +} + +#[tokio::test] +async fn test_list_basins_empty() { + let backend = create_backend().await; + + let page = backend + .list_basins(ListBasinsRequest::default()) + .await + .expect("Failed to list basins"); + + assert!(page.values.is_empty()); + assert!(!page.has_more); +} + +#[tokio::test] +async fn test_list_basins_multiple() { + let backend = create_backend().await; + + for i in 0..5 { + create_test_basin(&backend, &format!("list-{}", i), BasinConfig::default()).await; + } + + let page = backend + .list_basins(ListBasinsRequest::default()) + .await + .expect("Failed to list basins"); + + let names: Vec<_> = page.values.iter().map(|info| info.name.as_ref()).collect(); + assert_eq!( + names, + vec![ + "test-basin-list-0", + "test-basin-list-1", + "test-basin-list-2", + "test-basin-list-3", + "test-basin-list-4", + ] + ); + assert!(!page.has_more); +} + +#[tokio::test] +async fn test_list_basins_pagination() { + let backend = create_backend().await; + + for i in 0..15 { + create_test_basin( + &backend, + &format!("paginated-{:02}", i), + BasinConfig::default(), + ) + .await; + } + + let page1 = backend + .list_basins(ListBasinsRequest { + prefix: BasinNamePrefix::default(), + start_after: BasinNameStartAfter::default(), + limit: 5.into(), + }) + .await + .expect("Failed to list basins page 1"); + + assert!(page1.has_more); + let page1_names: Vec<_> = page1.values.iter().map(|info| info.name.as_ref()).collect(); + assert_eq!( + page1_names, + vec![ + "test-basin-paginated-00", + "test-basin-paginated-01", + "test-basin-paginated-02", + "test-basin-paginated-03", + "test-basin-paginated-04", + ] + ); + + let page2 = backend + .list_basins(ListBasinsRequest { + prefix: BasinNamePrefix::default(), + start_after: page1.values.last().unwrap().name.clone().into(), + limit: 5.into(), + }) + .await + .expect("Failed to list basins page 2"); + + assert!(page2.has_more); + let page2_names: Vec<_> = page2.values.iter().map(|info| info.name.as_ref()).collect(); + assert_eq!( + page2_names, + vec![ + "test-basin-paginated-05", + "test-basin-paginated-06", + "test-basin-paginated-07", + "test-basin-paginated-08", + "test-basin-paginated-09", + ] + ); + + let page3 = backend + .list_basins(ListBasinsRequest { + prefix: BasinNamePrefix::default(), + start_after: page2.values.last().unwrap().name.clone().into(), + limit: 5.into(), + }) + .await + .expect("Failed to list basins page 3"); + + assert!(!page3.has_more); + let page3_names: Vec<_> = page3.values.iter().map(|info| info.name.as_ref()).collect(); + assert_eq!( + page3_names, + vec![ + "test-basin-paginated-10", + "test-basin-paginated-11", + "test-basin-paginated-12", + "test-basin-paginated-13", + "test-basin-paginated-14", + ] + ); +} + +#[tokio::test] +async fn test_list_basins_prefix_filter() { + let backend = create_backend().await; + + create_test_basin(&backend, "prod-app-1", BasinConfig::default()).await; + create_test_basin(&backend, "prod-app-2", BasinConfig::default()).await; + create_test_basin(&backend, "dev-app-1", BasinConfig::default()).await; + create_test_basin(&backend, "staging-app-1", BasinConfig::default()).await; + + let prod_basins = backend + .list_basins(ListBasinsRequest { + prefix: "test-basin-prod-".parse().unwrap(), + start_after: BasinNameStartAfter::default(), + limit: Default::default(), + }) + .await + .expect("Failed to list basins with prefix"); + + let prod_names: Vec<_> = prod_basins + .values + .iter() + .map(|info| info.name.as_ref()) + .collect(); + assert_eq!( + prod_names, + vec!["test-basin-prod-app-1", "test-basin-prod-app-2"] + ); +} + +#[tokio::test] +async fn test_list_basins_prefix_with_pagination() { + let backend = create_backend().await; + + for i in 0..10 { + create_test_basin( + &backend, + &format!("prefixed-{:02}", i), + BasinConfig::default(), + ) + .await; + } + create_test_basin(&backend, "other-basin", BasinConfig::default()).await; + + let page1 = backend + .list_basins(ListBasinsRequest { + prefix: "test-basin-prefixed-".parse().unwrap(), + start_after: BasinNameStartAfter::default(), + limit: 4.into(), + }) + .await + .expect("Failed to list basins"); + + assert!(page1.has_more); + let page1_names: Vec<_> = page1.values.iter().map(|info| info.name.as_ref()).collect(); + assert_eq!( + page1_names, + vec![ + "test-basin-prefixed-00", + "test-basin-prefixed-01", + "test-basin-prefixed-02", + "test-basin-prefixed-03", + ] + ); + + let page2 = backend + .list_basins(ListBasinsRequest { + prefix: "test-basin-prefixed-".parse().unwrap(), + start_after: page1.values.last().unwrap().name.clone().into(), + limit: 4.into(), + }) + .await + .expect("Failed to list basins"); + + assert!(page2.has_more); + let page2_names: Vec<_> = page2.values.iter().map(|info| info.name.as_ref()).collect(); + assert_eq!( + page2_names, + vec![ + "test-basin-prefixed-04", + "test-basin-prefixed-05", + "test-basin-prefixed-06", + "test-basin-prefixed-07", + ] + ); + + let page3 = backend + .list_basins(ListBasinsRequest { + prefix: "test-basin-prefixed-".parse().unwrap(), + start_after: page2.values.last().unwrap().name.clone().into(), + limit: 4.into(), + }) + .await + .expect("Failed to list basins"); + + assert!(!page3.has_more); + let page3_names: Vec<_> = page3.values.iter().map(|info| info.name.as_ref()).collect(); + assert_eq!( + page3_names, + vec!["test-basin-prefixed-08", "test-basin-prefixed-09"] + ); +} diff --git a/lite/tests/backend/control_plane/mod.rs b/lite/tests/backend/control_plane/mod.rs new file mode 100644 index 00000000..2eec5394 --- /dev/null +++ b/lite/tests/backend/control_plane/mod.rs @@ -0,0 +1,4 @@ +use super::common; + +mod basin; +mod stream; diff --git a/lite/tests/backend/control_plane/stream.rs b/lite/tests/backend/control_plane/stream.rs new file mode 100644 index 00000000..6ded3547 --- /dev/null +++ b/lite/tests/backend/control_plane/stream.rs @@ -0,0 +1,1247 @@ +use std::time::Duration; + +use bytes::Bytes; +use s2_common::{ + config::{ + BasinConfig, BasinReconfiguration, DeleteOnEmptyReconfiguration, + OptionalDeleteOnEmptyConfig, OptionalStreamConfig, OptionalTimestampingConfig, + RetentionPolicy, StreamReconfiguration, TimestampingMode, TimestampingReconfiguration, + }, + encryption::EncryptionAlgorithm, + maybe::Maybe, + resources::{ProvisionMode, ProvisionResult, RequestToken}, + stream::{ + AppendInput, ListStreamsRequest, ReadEnd, ReadFrom, ReadStart, StreamNamePrefix, + StreamNameStartAfter, + }, +}; +use s2_lite::backend::error::{ + AppendError, CheckTailError, DeleteStreamError, GetStreamConfigError, ProvisionStreamError, + ReadError, ReconfigureStreamError, +}; + +use super::common::*; + +#[tokio::test] +async fn test_provision_stream_acknowledges_only_durable_metadata() { + let (backend, db) = create_backend_without_auto_flush().await; + let basin = test_basin_name("durable-stream"); + let stream = test_stream_name("durable"); + assert_waits_for_flush( + &db, + backend.provision_basin(basin.clone(), BasinConfig::default(), ProvisionMode::Ensure), + ) + .await + .unwrap(); + + let result = assert_waits_for_flush( + &db, + backend.provision_stream( + basin.clone(), + stream.clone(), + OptionalStreamConfig::default(), + ProvisionMode::Ensure, + ), + ) + .await + .unwrap(); + assert!(matches!(result, ProvisionResult::Created(_))); + backend.get_stream_config(basin, stream).await.unwrap(); + backend.close().await.unwrap(); +} + +#[tokio::test] +async fn test_create_stream_honors_basin_defaults() { + let backend = create_backend().await; + let basin_name = test_basin_name("stream-defaults"); + + let basin_config = BasinConfig { + default_stream_config: OptionalStreamConfig { + storage_class: Some("standard".into()), + retention_policy: Some(RetentionPolicy::Infinite()), + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + backend + .provision_basin( + basin_name.clone(), + basin_config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create basin"); + + let stream_name = test_stream_name("stream-defaults"); + + backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + OptionalStreamConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create stream"); + + let config = backend + .get_stream_config(basin_name, stream_name) + .await + .expect("Failed to fetch stream config"); + assert_eq!(config.storage_class.as_deref(), Some("standard")); + assert_eq!(config.retention_policy, RetentionPolicy::Infinite()); + assert_eq!(config.timestamping.mode, TimestampingMode::ClientRequire); +} + +#[tokio::test] +async fn test_create_stream_defaults_to_no_encryption_algorithm() { + let backend = create_backend().await; + let basin_name = + create_test_basin(&backend, "stream-default-enc", BasinConfig::default()).await; + let stream_name = create_test_stream( + &backend, + &basin_name, + "stream-default-enc", + OptionalStreamConfig::default(), + ) + .await; + + let page = backend + .list_streams(basin_name, ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + let info = page + .values + .iter() + .find(|info| info.name == stream_name) + .expect("stream info should be present"); + assert_eq!(info.cipher, None); +} + +#[tokio::test] +async fn test_create_stream_uses_basin_cipher() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "stream-cipher", + BasinConfig { + stream_cipher: Some(EncryptionAlgorithm::Aegis256), + ..Default::default() + }, + ) + .await; + let stream_name = create_test_stream( + &backend, + &basin_name, + "stream-cipher", + OptionalStreamConfig::default(), + ) + .await; + + let page = backend + .list_streams(basin_name, ListStreamsRequest::default()) + .await; + let page = page.expect("Failed to list streams"); + let info = page + .values + .iter() + .find(|info| info.name == stream_name) + .expect("stream info should be present"); + assert_eq!(info.cipher, Some(EncryptionAlgorithm::Aegis256)); +} + +#[tokio::test] +async fn test_existing_stream_keeps_cipher_after_basin_reconfigure() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "stream-basin-cipher-reconfigure", + BasinConfig { + stream_cipher: Some(EncryptionAlgorithm::Aegis256), + ..Default::default() + }, + ) + .await; + let stream_name = create_test_stream( + &backend, + &basin_name, + "stream-basin-cipher-reconfigure", + OptionalStreamConfig::default(), + ) + .await; + + backend + .reconfigure_basin( + basin_name.clone(), + BasinReconfiguration { + stream_cipher: Maybe::Specified(Some(EncryptionAlgorithm::Aes256Gcm)), + ..Default::default() + }, + ) + .await + .expect("Failed to reconfigure basin"); + + let next_stream = create_test_stream( + &backend, + &basin_name, + "stream-basin-cipher-reconfigure-next", + OptionalStreamConfig::default(), + ) + .await; + + let page = backend + .list_streams(basin_name, ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + let original = page + .values + .iter() + .find(|info| info.name == stream_name) + .expect("original stream info should be present"); + let next = page + .values + .iter() + .find(|info| info.name == next_stream) + .expect("new stream info should be present"); + assert_eq!(original.cipher, Some(EncryptionAlgorithm::Aegis256)); + assert_eq!(next.cipher, Some(EncryptionAlgorithm::Aes256Gcm)); +} + +#[tokio::test] +async fn test_get_nonexistent_stream_config() { + let backend = create_backend().await; + let basin_name = + create_test_basin(&backend, "basin-for-missing-stream", BasinConfig::default()).await; + let stream_name = test_stream_name("nonexistent-stream"); + + let result = backend.get_stream_config(basin_name, stream_name).await; + + assert!(matches!( + result, + Err(GetStreamConfigError::StreamNotFound(_)) + )); +} + +#[tokio::test] +async fn test_create_stream_idempotency_and_request_token() { + let backend = create_backend().await; + let basin_name = + create_test_basin(&backend, "stream-idempotency", BasinConfig::default()).await; + let stream_name = test_stream_name("stream-idempotency"); + + let config = OptionalStreamConfig { + storage_class: Some("express".into()), + ..Default::default() + }; + + let token1: RequestToken = "stream-token-1".parse().unwrap(); + + backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: Some(token1.clone()), + }, + ) + .await + .expect("Failed to create stream"); + + let stored_config = backend + .get_stream_config(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to fetch stored stream config"); + assert_eq!(stored_config.storage_class.as_deref(), Some("express")); + + let idempotent = backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: Some(token1.clone()), + }, + ) + .await + .expect("Idempotent create should succeed with same request token"); + assert!(matches!( + idempotent, + ProvisionResult::Noop(ref info) if info.deleted_at.is_none() + && info.created_at <= time::OffsetDateTime::now_utc() + )); + + let different_token_result = backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: Some("stream-token-2".parse().unwrap()), + }, + ) + .await; + assert!(matches!( + different_token_result, + Err(ProvisionStreamError::StreamAlreadyExists(_)) + )); + + let mut different_config = config.clone(); + different_config.timestamping.mode = Some(TimestampingMode::Arrival); + let different_config_result = backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + different_config, + ProvisionMode::CreateOnly { + request_token: Some(token1), + }, + ) + .await; + assert!(matches!( + different_config_result, + Err(ProvisionStreamError::StreamAlreadyExists(_)) + )); +} + +#[tokio::test] +async fn test_provision_stream_ensure_preserves_idempotency_key() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "stream-idempotency-key-preserve", + BasinConfig::default(), + ) + .await; + let stream_name = test_stream_name("stream-idempotency-key-preserve"); + + let config = OptionalStreamConfig { + storage_class: Some("standard".into()), + ..Default::default() + }; + let token: RequestToken = "stream-token-preserve".parse().unwrap(); + + backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: Some(token.clone()), + }, + ) + .await + .expect("Failed to create stream"); + + backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: Some(token.clone()), + }, + ) + .await + .expect("Idempotency should work before Ensure"); + + backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + OptionalStreamConfig { + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::Arrival), + ..Default::default() + }, + ..Default::default() + }, + ProvisionMode::Ensure, + ) + .await + .expect("Ensure should succeed"); + + let stored_config = backend + .get_stream_config(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to fetch stream config"); + assert_eq!(stored_config.storage_class.as_deref(), Some("express")); + assert_eq!(stored_config.timestamping.mode, TimestampingMode::Arrival); + + backend + .provision_stream( + basin_name, + stream_name, + config, + ProvisionMode::CreateOnly { + request_token: Some(token), + }, + ) + .await + .expect("Idempotency should still work after Ensure"); +} + +#[tokio::test] +async fn test_provision_stream_ensure_noops_when_effective_config_matches() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "stream-ensure-effective-noop", + BasinConfig { + default_stream_config: OptionalStreamConfig { + storage_class: Some("express".into()), + retention_policy: Some(RetentionPolicy::Age(Duration::from_secs( + 10 * 24 * 60 * 60, + ))), + ..Default::default() + }, + ..Default::default() + }, + ) + .await; + let stream_name = test_stream_name("stream-ensure-effective-noop"); + let config = OptionalStreamConfig { + storage_class: Some("standard".into()), + retention_policy: Some(RetentionPolicy::Infinite()), + ..Default::default() + }; + + backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create stream"); + + let ensured = backend + .provision_stream(basin_name, stream_name, config, ProvisionMode::Ensure) + .await + .expect("Ensure should succeed"); + + assert!(matches!(ensured, ProvisionResult::Noop(_))); +} + +#[tokio::test] +async fn test_provision_stream_preserves_explicit_zero_delete_on_empty() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "stream-zero-doe", + BasinConfig { + default_stream_config: OptionalStreamConfig { + delete_on_empty: OptionalDeleteOnEmptyConfig { + min_age: Some(Duration::from_secs(60)), + }, + ..Default::default() + }, + ..Default::default() + }, + ) + .await; + let stream_name = test_stream_name("stream-zero-doe"); + let config = OptionalStreamConfig { + delete_on_empty: OptionalDeleteOnEmptyConfig { + min_age: Some(Duration::ZERO), + }, + ..Default::default() + }; + + backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create stream"); + + let stored_config = backend + .get_stream_config(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to fetch stream config"); + assert_eq!(stored_config.delete_on_empty.min_age, Duration::ZERO); + + let ensured = backend + .provision_stream(basin_name, stream_name, config, ProvisionMode::Ensure) + .await + .expect("Ensure should succeed"); + + assert!(matches!(ensured, ProvisionResult::Noop(_))); +} + +#[tokio::test] +async fn test_provision_stream_idempotency_ignores_changed_basin_defaults() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "stream-idempotency-defaults", + BasinConfig::default(), + ) + .await; + let stream_name = test_stream_name("stream-idempotency-defaults"); + let config = OptionalStreamConfig::default(); + let token: RequestToken = "stream-token-defaults".parse().unwrap(); + + backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + config.clone(), + ProvisionMode::CreateOnly { + request_token: Some(token.clone()), + }, + ) + .await + .expect("Failed to create stream"); + + backend + .reconfigure_basin( + basin_name.clone(), + BasinReconfiguration { + default_stream_config: Maybe::from(Some(StreamReconfiguration { + storage_class: Maybe::from(Some("standard".into())), + ..Default::default() + })), + ..Default::default() + }, + ) + .await + .expect("Failed to reconfigure basin defaults"); + + backend + .provision_stream( + basin_name, + stream_name, + config, + ProvisionMode::CreateOnly { + request_token: Some(token), + }, + ) + .await + .expect("Idempotency key should be based on the raw create config"); +} + +#[tokio::test] +async fn test_reconfigure_stream_updates_selected_fields() { + let backend = create_backend().await; + let basin_name = test_basin_name("stream-reconfigure"); + + let mut basin_config = BasinConfig::default(); + basin_config.default_stream_config.storage_class = Some("standard".into()); + + backend + .provision_basin( + basin_name.clone(), + basin_config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create basin"); + + let stream_name = test_stream_name("stream-reconfigure"); + let initial_config = OptionalStreamConfig { + retention_policy: Some(RetentionPolicy::Age(Duration::from_secs(60))), + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + ..Default::default() + }, + ..Default::default() + }; + + backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + initial_config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create stream"); + + let ts_reconfig = TimestampingReconfiguration { + mode: Maybe::from(Some(TimestampingMode::Arrival)), + uncapped: Maybe::from(Some(true)), + }; + let mut stream_reconfig = StreamReconfiguration { + storage_class: Maybe::from(Some("express".into())), + retention_policy: Maybe::from(Some(RetentionPolicy::Infinite())), + ..Default::default() + }; + stream_reconfig.timestamping = Maybe::from(Some(ts_reconfig)); + + let updated = backend + .reconfigure_stream(basin_name.clone(), stream_name.clone(), stream_reconfig) + .await + .expect("Failed to reconfigure stream"); + + assert_eq!(updated.storage_class.as_deref(), Some("express")); + assert_eq!(updated.retention_policy, RetentionPolicy::Infinite()); + assert_eq!(updated.timestamping.mode, TimestampingMode::Arrival); + assert!(updated.timestamping.uncapped); + + let fetched = backend + .get_stream_config(basin_name, stream_name) + .await + .expect("Failed to fetch stream config after reconfigure"); + assert_eq!(fetched.storage_class.as_deref(), Some("express")); + assert_eq!(fetched.retention_policy, RetentionPolicy::Infinite()); + assert_eq!(fetched.timestamping.mode, TimestampingMode::Arrival); + assert!(fetched.timestamping.uncapped); +} + +#[tokio::test] +async fn test_reconfigure_stream_clears_fields_to_basin_defaults() { + let backend = create_backend().await; + let basin_name = test_basin_name("stream-reconfigure-clear-defaults"); + + let basin_config = BasinConfig { + default_stream_config: OptionalStreamConfig { + storage_class: Some("standard".into()), + retention_policy: Some(RetentionPolicy::Infinite()), + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::Arrival), + uncapped: Some(true), + }, + delete_on_empty: OptionalDeleteOnEmptyConfig { + min_age: Some(Duration::from_secs(300)), + }, + }, + ..Default::default() + }; + + backend + .provision_basin( + basin_name.clone(), + basin_config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create basin"); + + let stream_name = test_stream_name("stream-reconfigure-clear-defaults"); + let stream_config = OptionalStreamConfig { + storage_class: Some("express".into()), + retention_policy: Some(RetentionPolicy::Age(Duration::from_secs(60))), + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + uncapped: Some(false), + }, + delete_on_empty: OptionalDeleteOnEmptyConfig { + min_age: Some(Duration::ZERO), + }, + }; + + backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + stream_config, + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await + .expect("Failed to create stream"); + + let reconfig = StreamReconfiguration { + storage_class: Maybe::from(None), + retention_policy: Maybe::from(None), + timestamping: Maybe::from(Some(TimestampingReconfiguration { + mode: Maybe::from(None), + uncapped: Maybe::from(None), + })), + delete_on_empty: Maybe::from(Some(DeleteOnEmptyReconfiguration { + min_age: Maybe::from(None), + })), + }; + + let updated = backend + .reconfigure_stream(basin_name.clone(), stream_name.clone(), reconfig) + .await + .expect("Failed to reconfigure stream"); + + assert_eq!(updated.storage_class.as_deref(), Some("standard")); + assert_eq!(updated.retention_policy, RetentionPolicy::Infinite()); + assert_eq!(updated.timestamping.mode, TimestampingMode::Arrival); + assert!(updated.timestamping.uncapped); + assert_eq!(updated.delete_on_empty.min_age, Duration::from_secs(300)); + + let fetched = backend + .get_stream_config(basin_name, stream_name) + .await + .expect("Failed to fetch stream config after reconfigure"); + + assert_eq!(fetched, updated); +} + +#[rstest::rstest] +#[case::patch(false, false)] +#[case::ensure(true, false)] +#[case::cancelled_patch(false, true)] +#[case::cancelled_ensure(true, true)] +#[tokio::test] +async fn test_stream_config_updates_active_streamer(#[case] ensure: bool, #[case] cancel: bool) { + let (backend, db) = create_backend_without_auto_flush().await; + let basin = assert_waits_for_flush( + &db, + create_test_basin(&backend, "config-delivery", BasinConfig::default()), + ) + .await; + let stream = assert_waits_for_flush( + &db, + create_test_stream( + &backend, + &basin, + "stream", + OptionalStreamConfig { + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + ..Default::default() + }, + ..Default::default() + }, + ), + ) + .await; + let _active_streamer = backend + .open_for_append(&basin, &stream, None, OptionalStreamConfig::default()) + .await + .unwrap(); + + let mut update = Box::pin(async { + if ensure { + backend + .provision_stream( + basin.clone(), + stream.clone(), + OptionalStreamConfig { + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::Arrival), + ..Default::default() + }, + ..Default::default() + }, + ProvisionMode::Ensure, + ) + .await + .unwrap(); + } else { + backend + .reconfigure_stream( + basin.clone(), + stream.clone(), + StreamReconfiguration { + timestamping: Maybe::from(Some(TimestampingReconfiguration { + mode: Maybe::from(Some(TimestampingMode::Arrival)), + ..Default::default() + })), + ..Default::default() + }, + ) + .await + .unwrap(); + } + }); + assert_pending_until_committed(&db, &mut update).await; + if cancel { + drop(update); + db.flush().await.unwrap(); + } else { + db.flush().await.unwrap(); + update.await; + } + + // Application is asynchronous; retry until the active streamer accepts arrival timestamps. + let append_after_update = async { + loop { + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"no timestamp")]), + match_seq_num: None, + fencing_token: None, + }; + match append(&backend, basin.clone(), stream.clone(), input, None).await { + Err(AppendError::TimestampMissing(_)) => tokio::task::yield_now().await, + result => break result, + } + } + }; + assert_waits_for_flush(&db, append_after_update) + .await + .unwrap(); + backend.close().await.unwrap(); +} + +#[tokio::test] +async fn test_create_stream_fails_when_basin_deleting() { + let backend = create_backend().await; + let basin_name = + create_test_basin(&backend, "stream-basin-deleting", BasinConfig::default()).await; + + backend + .delete_basin(basin_name.clone()) + .await + .expect("Failed to delete basin"); + + let stream_name = test_stream_name("blocked"); + let result = backend + .provision_stream( + basin_name, + stream_name, + OptionalStreamConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await; + + assert!(matches!( + result, + Err(ProvisionStreamError::BasinDeletionPending(_)) + )); +} + +#[tokio::test(start_paused = true)] +async fn test_delete_stream_retry_waits_for_durable_metadata() { + let (backend, db) = create_backend_without_auto_flush().await; + let basin = assert_waits_for_flush( + &db, + create_test_basin(&backend, "durable-delete", BasinConfig::default()), + ) + .await; + let stream = assert_waits_for_flush( + &db, + create_test_stream(&backend, &basin, "durable-delete", Default::default()), + ) + .await; + + let mut first = Box::pin(backend.delete_stream(basin.clone(), stream.clone())); + // Stream deletion first persists the terminal trim, then the metadata marker. + assert_pending_until_committed(&db, &mut first).await; + db.flush().await.unwrap(); + assert_pending_until_committed(&db, &mut first).await; + drop(first); + + let retry = backend.delete_stream(basin, stream); + tokio::pin!(retry); + assert!( + tokio::time::timeout(Duration::from_secs(1), &mut retry) + .await + .is_err() + ); + assert_waits_for_flush(&db, retry).await.unwrap(); + backend.close().await.unwrap(); +} + +#[tokio::test] +async fn test_delete_stream_marks_deleted_and_blocks_recreation() { + let backend = create_backend().await; + let basin_name = create_test_basin(&backend, "stream-delete", BasinConfig::default()).await; + let stream_name = create_test_stream( + &backend, + &basin_name, + "stream-delete", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"seed data"]).await; + + backend + .delete_stream(basin_name.clone(), stream_name.clone()) + .await + .unwrap(); + + let page = backend + .list_streams(basin_name.clone(), ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + let info = page + .values + .iter() + .find(|info| info.name == stream_name) + .expect("Deleted stream should appear in listing"); + assert!(info.deleted_at.is_some()); + + let recreate_result = backend + .provision_stream( + basin_name.clone(), + stream_name.clone(), + OptionalStreamConfig::default(), + ProvisionMode::CreateOnly { + request_token: None, + }, + ) + .await; + assert!(matches!( + recreate_result, + Err(ProvisionStreamError::StreamDeletionPending(_)) + )); + + let reconfigure_result = backend + .reconfigure_stream( + basin_name.clone(), + stream_name.clone(), + StreamReconfiguration::default(), + ) + .await; + assert!(matches!( + reconfigure_result, + Err(ReconfigureStreamError::StreamDeletionPending(_)) + )); + + backend + .delete_stream(basin_name.clone(), stream_name.clone()) + .await + .expect("Second delete should be idempotent"); +} + +#[tokio::test] +async fn test_delete_stream_allows_plaintext_command_records_on_encrypted_only_stream() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "stream-delete-encrypted-only", + basin_config_with_stream_cipher(EncryptionAlgorithm::Aegis256), + ) + .await; + let stream_name = create_test_stream( + &backend, + &basin_name, + "stream-delete-encrypted-only", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads_with_encryption( + &backend, + &basin_name, + &stream_name, + &[b"secret"], + &aegis256_encryption_spec(), + ) + .await; + + backend + .delete_stream(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to delete encrypted-only stream"); + + let page = backend + .list_streams(basin_name, ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + let info = page + .values + .iter() + .find(|info| info.name == stream_name) + .expect("Deleted stream should appear in listing"); + assert!(info.deleted_at.is_some()); +} + +#[tokio::test] +async fn test_delete_stream_blocks_data_operations() { + let backend = create_backend().await; + + let basin_name = + create_test_basin(&backend, "stream-delete-blocks", BasinConfig::default()).await; + let stream_name = create_test_stream( + &backend, + &basin_name, + "stream-delete-blocks", + OptionalStreamConfig::default(), + ) + .await; + + backend + .delete_stream(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to delete stream"); + + let tail = check_tail(&backend, basin_name.clone(), stream_name.clone()).await; + assert!(matches!( + tail, + Err(CheckTailError::StreamDeletionPending(_)) + ),); + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"should fail")]), + match_seq_num: None, + fencing_token: None, + }; + let append_result = append( + &backend, + basin_name.clone(), + stream_name.clone(), + input, + None, + ) + .await; + assert!(matches!( + append_result, + Err(AppendError::StreamDeletionPending(_)) + )); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd::default(); + let read_result = try_open_read_session(&backend, &basin_name, &stream_name, start, end).await; + assert!(matches!( + read_result, + Err(ReadError::StreamDeletionPending(_)) + )); +} + +#[tokio::test] +async fn test_get_stream_config_for_deleting_stream_returns_pending() { + let backend = create_backend().await; + let basin_name = + create_test_basin(&backend, "stream-delete-config", BasinConfig::default()).await; + let stream_name = create_test_stream( + &backend, + &basin_name, + "stream-delete-config", + OptionalStreamConfig::default(), + ) + .await; + + backend + .delete_stream(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to delete stream"); + + let result = backend.get_stream_config(basin_name, stream_name).await; + assert!(matches!( + result, + Err(GetStreamConfigError::StreamDeletionPending(_)) + )); +} + +#[tokio::test] +async fn test_delete_stream_nonexistent_returns_not_found() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "stream-delete-nonexistent", + BasinConfig::default(), + ) + .await; + let stream_name = test_stream_name("missing"); + + let result = backend.delete_stream(basin_name, stream_name).await; + assert!(matches!(result, Err(DeleteStreamError::StreamNotFound(_)))); +} + +#[tokio::test] +async fn test_list_streams_empty() { + let backend = create_backend().await; + let basin_name = create_test_basin(&backend, "empty-streams", BasinConfig::default()).await; + + let page = backend + .list_streams(basin_name.clone(), ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + + assert!(page.values.is_empty()); + assert!(!page.has_more); +} + +#[tokio::test] +async fn test_list_streams_multiple() { + let backend = create_backend().await; + let basin_name = create_test_basin(&backend, "list-streams", BasinConfig::default()).await; + + for i in 0..5 { + create_test_stream( + &backend, + &basin_name, + &format!("list-{}", i), + OptionalStreamConfig::default(), + ) + .await; + } + + let page = backend + .list_streams(basin_name.clone(), ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + + let names: Vec<_> = page.values.iter().map(|info| info.name.as_ref()).collect(); + assert_eq!( + names, + vec![ + "test-stream-list-0", + "test-stream-list-1", + "test-stream-list-2", + "test-stream-list-3", + "test-stream-list-4", + ] + ); + assert!(!page.has_more); +} + +#[tokio::test] +async fn test_list_streams_pagination() { + let backend = create_backend().await; + let basin_name = create_test_basin(&backend, "stream-pagination", BasinConfig::default()).await; + + for i in 0..12 { + create_test_stream( + &backend, + &basin_name, + &format!("stream-{:02}", i), + OptionalStreamConfig::default(), + ) + .await; + } + + let page1 = backend + .list_streams( + basin_name.clone(), + ListStreamsRequest { + prefix: StreamNamePrefix::default(), + start_after: StreamNameStartAfter::default(), + limit: 5.into(), + }, + ) + .await + .expect("Failed to list streams page 1"); + + assert!(page1.has_more); + let page1_names: Vec<_> = page1.values.iter().map(|info| info.name.as_ref()).collect(); + assert_eq!( + page1_names, + vec![ + "test-stream-stream-00", + "test-stream-stream-01", + "test-stream-stream-02", + "test-stream-stream-03", + "test-stream-stream-04", + ] + ); + + let page2 = backend + .list_streams( + basin_name.clone(), + ListStreamsRequest { + prefix: StreamNamePrefix::default(), + start_after: page1.values.last().unwrap().name.clone().into(), + limit: 5.into(), + }, + ) + .await + .expect("Failed to list streams page 2"); + + assert!(page2.has_more); + let page2_names: Vec<_> = page2.values.iter().map(|info| info.name.as_ref()).collect(); + assert_eq!( + page2_names, + vec![ + "test-stream-stream-05", + "test-stream-stream-06", + "test-stream-stream-07", + "test-stream-stream-08", + "test-stream-stream-09", + ] + ); + + let page3 = backend + .list_streams( + basin_name.clone(), + ListStreamsRequest { + prefix: StreamNamePrefix::default(), + start_after: page2.values.last().unwrap().name.clone().into(), + limit: 5.into(), + }, + ) + .await + .expect("Failed to list streams page 3"); + + assert!(!page3.has_more); + let page3_names: Vec<_> = page3.values.iter().map(|info| info.name.as_ref()).collect(); + assert_eq!( + page3_names, + vec!["test-stream-stream-10", "test-stream-stream-11"] + ); +} + +#[tokio::test] +async fn test_list_streams_prefix_filter() { + let backend = create_backend().await; + let basin_name = create_test_basin(&backend, "stream-prefix", BasinConfig::default()).await; + + create_test_stream( + &backend, + &basin_name, + "metrics-cpu", + OptionalStreamConfig::default(), + ) + .await; + create_test_stream( + &backend, + &basin_name, + "metrics-memory", + OptionalStreamConfig::default(), + ) + .await; + create_test_stream( + &backend, + &basin_name, + "logs-app", + OptionalStreamConfig::default(), + ) + .await; + create_test_stream( + &backend, + &basin_name, + "traces-span", + OptionalStreamConfig::default(), + ) + .await; + + let metrics_streams = backend + .list_streams( + basin_name.clone(), + ListStreamsRequest { + prefix: "test-stream-metrics-".parse().unwrap(), + start_after: StreamNameStartAfter::default(), + limit: Default::default(), + }, + ) + .await + .expect("Failed to list streams with prefix"); + + let metric_names: Vec<_> = metrics_streams + .values + .iter() + .map(|info| info.name.as_ref()) + .collect(); + assert_eq!( + metric_names, + vec!["test-stream-metrics-cpu", "test-stream-metrics-memory"] + ); +} diff --git a/lite/tests/backend/data_plane/append.rs b/lite/tests/backend/data_plane/append.rs new file mode 100644 index 00000000..19ee93bb --- /dev/null +++ b/lite/tests/backend/data_plane/append.rs @@ -0,0 +1,872 @@ +use std::time::Duration; + +use bytes::Bytes; +use futures::StreamExt; +use rstest::rstest; +use s2_common::{ + basin::BasinName, + config::{OptionalStreamConfig, OptionalTimestampingConfig, TimestampingMode}, + encryption::EncryptionSpec, + record::FencingToken, + stream::{AppendAck, AppendInput, AppendRecordBatch, StreamName}, +}; +use s2_lite::backend::{ + Backend, + error::{AppendConditionFailedError, AppendError}, +}; + +use super::common::*; + +async fn assert_append_session_roundtrip(test_suffix: &str, encryption: &EncryptionSpec) { + let (backend, basin_name, stream_name) = + setup_backend_for_encryption_spec(test_suffix, "stream", encryption).await; + + let expected_bodies = vec![ + b"batch 1".to_vec(), + b"batch 2".to_vec(), + b"batch 3".to_vec(), + ]; + let inputs = futures::stream::iter( + expected_bodies + .iter() + .map(|body| AppendInput { + records: create_test_record_batch(vec![Bytes::copy_from_slice(body)]), + match_seq_num: None, + fencing_token: None, + }) + .collect::>(), + ); + + let session = append_session( + &backend, + basin_name.clone(), + stream_name.clone(), + Some(encryption), + inputs, + ) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let mut acks = Vec::new(); + while let Some(result) = session.next().await { + acks.push(result.expect("Append should succeed")); + } + + assert_eq!(acks.len(), expected_bodies.len()); + for (index, ack) in acks.iter().enumerate() { + let index = index as u64; + assert_eq!(ack.start.seq_num, index); + assert_eq!(ack.end.seq_num, index + 1); + } + + let tail = check_tail(&backend, basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, expected_bodies.len() as u64); + + let (start, end) = read_all_bounds(); + let records = + read_records_with_encryption(&backend, &basin_name, &stream_name, start, end, encryption) + .await; + assert_eq!(envelope_bodies(&records), expected_bodies); +} + +async fn append_with_optional_encryption( + backend: &Backend, + basin: &BasinName, + stream: &StreamName, + input: AppendInput, + encryption: Option<&EncryptionSpec>, +) -> Result { + append(backend, basin.clone(), stream.clone(), input, encryption).await +} + +#[derive(Clone, Copy)] +enum FencingBootstrap { + SeedWithData, + CommandFirst, +} + +async fn issue_fencing_command( + backend: &Backend, + basin_name: &BasinName, + stream_name: &StreamName, + matching_token: &FencingToken, + new_token: &FencingToken, + encryption: Option<&EncryptionSpec>, + bootstrap: FencingBootstrap, +) -> AppendAck { + let command_match_seq_num = match bootstrap { + FencingBootstrap::SeedWithData => { + let matching_input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"matched token")]), + match_seq_num: None, + fencing_token: Some(matching_token.clone()), + }; + + let ack = append_with_optional_encryption( + backend, + basin_name, + stream_name, + matching_input, + encryption, + ) + .await + .expect("append should succeed with matching fencing token"); + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + Some(ack.end.seq_num) + } + FencingBootstrap::CommandFirst => None, + }; + + let command_batch: AppendRecordBatch = vec![create_fencing_command_record(new_token.clone())] + .try_into() + .unwrap(); + let command_input = AppendInput { + records: command_batch, + match_seq_num: command_match_seq_num, + fencing_token: Some(matching_token.clone()), + }; + + let command_ack = append_with_optional_encryption( + backend, + basin_name, + stream_name, + command_input, + encryption, + ) + .await + .expect("fencing command should succeed"); + + let expected_start = command_match_seq_num.unwrap_or(0); + assert_eq!(command_ack.start.seq_num, expected_start); + assert_eq!(command_ack.end.seq_num, expected_start + 1); + command_ack +} + +async fn assert_fencing_command_controls_stream_state( + test_suffix: &str, + encryption: Option, + bootstrap: FencingBootstrap, +) { + let (backend, basin_name, stream_name) = match encryption.as_ref() { + Some(encryption) => { + setup_backend_for_encryption_spec(test_suffix, "stream", encryption).await + } + None => { + setup_backend_with_stream(test_suffix, "stream", OptionalStreamConfig::default()).await + } + }; + + let encryption = encryption.as_ref(); + let matching_token = FencingToken::default(); + let new_token: FencingToken = "updated-token".parse().unwrap(); + + let command_ack = issue_fencing_command( + &backend, + &basin_name, + &stream_name, + &matching_token, + &new_token, + encryption, + bootstrap, + ) + .await; + + let mismatched_input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"mismatched token")]), + match_seq_num: Some(command_ack.end.seq_num), + fencing_token: Some(matching_token.clone()), + }; + + let result = append_with_optional_encryption( + &backend, + &basin_name, + &stream_name, + mismatched_input, + encryption, + ) + .await; + + let Err(AppendError::ConditionFailed(AppendConditionFailedError::FencingTokenMismatch { + expected, + actual, + .. + })) = result + else { + panic!("expected fencing token mismatch"); + }; + assert_eq!(expected, matching_token); + assert_eq!(actual, new_token); + + let refreshed_input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"updated token accepted")]), + match_seq_num: Some(command_ack.end.seq_num), + fencing_token: Some(new_token.clone()), + }; + + let refreshed_ack = append_with_optional_encryption( + &backend, + &basin_name, + &stream_name, + refreshed_input, + encryption, + ) + .await + .expect("append should succeed with refreshed fencing token"); + + assert_eq!(refreshed_ack.start.seq_num, command_ack.end.seq_num); + assert_eq!(refreshed_ack.end.seq_num, command_ack.end.seq_num + 1); +} + +#[tokio::test] +async fn test_append_multiple_records() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-multiple", + "multiple", + OptionalStreamConfig::default(), + ) + .await; + + let ack = append_payloads( + &backend, + &basin_name, + &stream_name, + &[b"record 1", b"record 2", b"record 3"], + ) + .await; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 3); +} + +#[rstest] +#[case::plaintext_seeded("append-fencing", None, FencingBootstrap::SeedWithData)] +#[case::encrypted_seeded( + "append-fencing-encrypted", + Some(aegis256_encryption_spec()), + FencingBootstrap::SeedWithData +)] +#[case::encrypted_command_first( + "fence-enc-first", + Some(aegis256_encryption_spec()), + FencingBootstrap::CommandFirst +)] +#[tokio::test] +async fn test_fencing_command_controls_stream_state( + #[case] test_suffix: &str, + #[case] encryption: Option, + #[case] bootstrap: FencingBootstrap, +) { + assert_fencing_command_controls_stream_state(test_suffix, encryption, bootstrap).await; +} + +#[tokio::test] +async fn test_append_requires_timestamp() { + let stream_config = OptionalStreamConfig { + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + ..Default::default() + }, + ..Default::default() + }; + + let (backend, basin_name, stream_name) = + setup_backend_with_stream("append-timestamp", "require", stream_config).await; + + let missing_timestamp = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"missing timestamp")]), + match_seq_num: None, + fencing_token: None, + }; + + let result = append( + &backend, + basin_name.clone(), + stream_name.clone(), + missing_timestamp, + None, + ) + .await; + + assert!(matches!(result, Err(AppendError::TimestampMissing(_)))); + + let with_timestamp = AppendInput { + records: create_test_record_batch_with_timestamps(vec![( + Bytes::from_static(b"with timestamp"), + 123, + )]), + match_seq_num: None, + fencing_token: None, + }; + + let ack = append(&backend, basin_name, stream_name, with_timestamp, None) + .await + .expect("Expected append to succeed when timestamp is provided"); + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); +} + +#[tokio::test] +async fn test_append_with_seq_num_match() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("seq-num-match", "match", OptionalStreamConfig::default()).await; + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"first record")]), + match_seq_num: Some(0), + fencing_token: None, + }; + + let ack = append( + &backend, + basin_name.clone(), + stream_name.clone(), + input, + None, + ) + .await + .expect("Failed to append with matching seq_num"); + + assert_eq!(ack.start.seq_num, 0); + + let input2 = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"second record")]), + match_seq_num: Some(1), + fencing_token: None, + }; + + let ack2 = append( + &backend, + basin_name.clone(), + stream_name.clone(), + input2, + None, + ) + .await + .expect("Failed to append with matching seq_num"); + + assert_eq!(ack2.start.seq_num, 1); +} + +#[tokio::test] +async fn test_append_with_seq_num_mismatch() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "seq-num-mismatch", + "mismatch", + OptionalStreamConfig::default(), + ) + .await; + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"first record")]), + match_seq_num: Some(0), + fencing_token: None, + }; + + append( + &backend, + basin_name.clone(), + stream_name.clone(), + input, + None, + ) + .await + .expect("Failed to append first record"); + + let input2 = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"second record")]), + match_seq_num: Some(0), + fencing_token: None, + }; + + let result = append( + &backend, + basin_name.clone(), + stream_name.clone(), + input2, + None, + ) + .await; + + assert!(matches!( + result, + Err(AppendError::ConditionFailed( + AppendConditionFailedError::SeqNumMismatch { .. } + )) + )); +} + +#[rstest] +#[case::plaintext("append-session-basic", EncryptionSpec::Plain)] +#[case::encrypted("appsess-enc", aegis256_encryption_spec())] +#[tokio::test] +async fn test_append_session_roundtrip( + #[case] test_suffix: &str, + #[case] encryption: EncryptionSpec, +) { + assert_append_session_roundtrip(test_suffix, &encryption).await; +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_append_session_survives_streamer_dormancy_between_inputs() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-dormancy", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + tx.send(AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"first")]), + match_seq_num: None, + fencing_token: None, + }) + .expect("first input should send"); + tokio::time::sleep(Duration::from_secs(61)).await; + tx.send(AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"second")]), + match_seq_num: None, + fencing_token: None, + }) + .expect("second input should send"); + }); + let inputs = async_stream::stream! { + while let Some(input) = rx.recv().await { + yield input; + } + }; + + let session = append_session(&backend, basin_name, stream_name, None, inputs) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let first_ack = session + .next() + .await + .expect("session should yield first ack") + .expect("first append should succeed"); + assert_eq!(first_ack.start.seq_num, 0); + assert_eq!(first_ack.end.seq_num, 1); + + tokio::time::advance(Duration::from_secs(61)).await; + tokio::task::yield_now().await; + + let second_ack = session + .next() + .await + .expect("session should yield second ack") + .expect("append session should survive dormancy between inputs"); + assert_eq!(second_ack.start.seq_num, 1); + assert_eq!(second_ack.end.seq_num, 2); +} + +#[tokio::test] +async fn test_append_session_empty() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-empty", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let inputs = futures::stream::iter(Vec::::new()); + + let session = append_session( + &backend, + basin_name.clone(), + stream_name.clone(), + None, + inputs, + ) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let ack = session.next().await; + assert!(ack.is_none()); + + let tail = check_tail(&backend, basin_name, stream_name) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, 0); +} + +#[tokio::test] +async fn test_append_session_multiple_records_per_batch() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-multi", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let inputs = futures::stream::iter(vec![ + AppendInput { + records: create_test_record_batch(vec![ + Bytes::from_static(b"record 1"), + Bytes::from_static(b"record 2"), + ]), + match_seq_num: None, + fencing_token: None, + }, + AppendInput { + records: create_test_record_batch(vec![ + Bytes::from_static(b"record 3"), + Bytes::from_static(b"record 4"), + Bytes::from_static(b"record 5"), + ]), + match_seq_num: None, + fencing_token: None, + }, + ]); + + let session = append_session( + &backend, + basin_name.clone(), + stream_name.clone(), + None, + inputs, + ) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let ack1 = session + .next() + .await + .expect("Should have first ack") + .expect("First append should succeed"); + assert_eq!(ack1.start.seq_num, 0); + assert_eq!(ack1.end.seq_num, 2); + + let ack2 = session + .next() + .await + .expect("Should have second ack") + .expect("Second append should succeed"); + assert_eq!(ack2.start.seq_num, 2); + assert_eq!(ack2.end.seq_num, 5); + + let tail = check_tail(&backend, basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, 5); + + let (start, end) = read_all_bounds(); + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + + assert_eq!( + envelope_bodies(&records), + vec![ + b"record 1".to_vec(), + b"record 2".to_vec(), + b"record 3".to_vec(), + b"record 4".to_vec(), + b"record 5".to_vec(), + ] + ); +} + +#[tokio::test] +async fn test_append_session_with_seq_num_conditions() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-seqnum", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let inputs = futures::stream::iter(vec![ + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"batch 1")]), + match_seq_num: Some(0), + fencing_token: None, + }, + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"batch 2")]), + match_seq_num: Some(1), + fencing_token: None, + }, + ]); + + let session = append_session( + &backend, + basin_name.clone(), + stream_name.clone(), + None, + inputs, + ) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let ack1 = session + .next() + .await + .expect("Should have first ack") + .expect("First append should succeed"); + assert_eq!(ack1.start.seq_num, 0); + + let ack2 = session + .next() + .await + .expect("Should have second ack") + .expect("Second append should succeed"); + assert_eq!(ack2.start.seq_num, 1); +} + +#[tokio::test] +async fn test_append_session_seq_num_mismatch() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-mismatch", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"existing data"]).await; + + let inputs = futures::stream::iter(vec![AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"batch 1")]), + match_seq_num: Some(0), + fencing_token: None, + }]); + + let session = append_session(&backend, basin_name, stream_name, None, inputs) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let result = session.next().await.expect("Should have result"); + assert!(matches!(result, Err(AppendError::ConditionFailed(_)))); +} + +#[tokio::test] +async fn test_append_session_stops_after_condition_failure() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-stop-after-error", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let inputs = futures::stream::iter(vec![ + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"first")]), + match_seq_num: Some(0), + fencing_token: None, + }, + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"bad")]), + match_seq_num: Some(0), + fencing_token: None, + }, + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"after-error")]), + match_seq_num: Some(1), + fencing_token: None, + }, + ]); + + let session = append_session( + &backend, + basin_name.clone(), + stream_name.clone(), + None, + inputs, + ) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let ack = session + .next() + .await + .expect("Should have first ack") + .expect("First append should succeed"); + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let result = session + .next() + .await + .expect("Should have a condition failure"); + assert!(matches!( + result, + Err(AppendError::ConditionFailed( + AppendConditionFailedError::SeqNumMismatch { .. } + )) + )); + assert!(session.next().await.is_none()); + + let tail = check_tail(&backend, basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, 1); + + let (start, end) = read_all_bounds(); + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + assert_eq!(envelope_bodies(&records), vec![b"first".to_vec()]); +} + +#[tokio::test] +async fn test_append_session_with_fencing_token() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-fence", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let token = FencingToken::default(); + + let inputs = futures::stream::iter(vec![ + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"batch 1")]), + match_seq_num: None, + fencing_token: Some(token.clone()), + }, + AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"batch 2")]), + match_seq_num: None, + fencing_token: Some(token.clone()), + }, + ]); + + let session = append_session(&backend, basin_name, stream_name, None, inputs) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let ack1 = session + .next() + .await + .expect("Should have first ack") + .expect("First append should succeed"); + assert_eq!(ack1.start.seq_num, 0); + + let ack2 = session + .next() + .await + .expect("Should have second ack") + .expect("Second append should succeed"); + assert_eq!(ack2.start.seq_num, 1); +} + +#[tokio::test] +async fn test_append_session_large_batches() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-large", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let large_record = vec![0u8; 100_000]; + let batch_count = 50; + + let inputs = futures::stream::iter((0..batch_count).map({ + let large_record = large_record.clone(); + move |_| AppendInput { + records: create_test_record_batch(vec![Bytes::from(large_record.clone())]), + match_seq_num: None, + fencing_token: None, + } + })); + + let session = append_session( + &backend, + basin_name.clone(), + stream_name.clone(), + None, + inputs, + ) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let mut ack_count = 0; + while let Some(result) = session.next().await { + result.expect("Append should succeed"); + ack_count += 1; + } + + assert_eq!(ack_count, batch_count); + + let tail = check_tail(&backend, basin_name, stream_name) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, batch_count); +} + +#[tokio::test] +async fn test_append_session_pipeline_preserves_ack_tail_and_read_order() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "append-session-pipeline-order", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let expected_bodies: Vec<_> = (0..32) + .map(|i| format!("msg-{i:02}").into_bytes()) + .collect(); + let inputs: Vec<_> = expected_bodies + .iter() + .map(|body| AppendInput { + records: create_test_record_batch(vec![Bytes::copy_from_slice(body)]), + match_seq_num: None, + fencing_token: None, + }) + .collect(); + let inputs = futures::stream::iter(inputs); + + let session = append_session( + &backend, + basin_name.clone(), + stream_name.clone(), + None, + inputs, + ) + .await + .expect("Failed to create append session"); + tokio::pin!(session); + + let mut acks = Vec::new(); + while let Some(result) = session.next().await { + acks.push(result.expect("append should succeed")); + } + + assert_eq!(acks.len(), expected_bodies.len()); + for (i, ack) in acks.iter().enumerate() { + assert_eq!(ack.start.seq_num, i as u64); + assert_eq!(ack.end.seq_num, i as u64 + 1); + assert!( + ack.tail.seq_num >= ack.end.seq_num, + "tail must include acknowledged append" + ); + if let Some(prev) = i.checked_sub(1).and_then(|idx| acks.get(idx)) { + assert!( + ack.tail.seq_num >= prev.tail.seq_num, + "tail seq must be monotonic" + ); + } + } + + let tail = check_tail(&backend, basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, expected_bodies.len() as u64); + + let (start, end) = read_all_bounds(); + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + assert_eq!(envelope_bodies(&records), expected_bodies); +} diff --git a/lite/tests/backend/data_plane/auto_create.rs b/lite/tests/backend/data_plane/auto_create.rs new file mode 100644 index 00000000..491a3eb7 --- /dev/null +++ b/lite/tests/backend/data_plane/auto_create.rs @@ -0,0 +1,540 @@ +use std::time::Duration; + +use bytes::Bytes; +use s2_common::{ + basin::BasinName, + config::{ + BasinConfig, DeleteOnEmptyConfig, OptionalDeleteOnEmptyConfig, OptionalStreamConfig, + RetentionPolicy, StreamConfig, + }, + encryption::EncryptionAlgorithm, + read_extent::{ReadLimit, ReadUntil}, + record::StreamPosition, + stream::{AppendInput, ListStreamsRequest, ReadEnd, ReadFrom, ReadStart, StreamName}, +}; +use s2_lite::backend::error::{AppendError, CheckTailError, ReadError}; + +use super::common::*; + +const MAX_AUTO_CREATE_ATTEMPTS: usize = 50; + +async fn assert_stream_count( + backend: &s2_lite::backend::Backend, + basin_name: &BasinName, + expected: usize, +) { + let stream_list = backend + .list_streams(basin_name.clone(), ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + assert_eq!(stream_list.values.len(), expected); +} + +async fn assert_stream_cipher( + backend: &s2_lite::backend::Backend, + basin_name: &BasinName, + stream_name: &StreamName, + expected: Option, +) { + let stream_list = backend + .list_streams(basin_name.clone(), ListStreamsRequest::default()) + .await + .expect("Failed to list streams"); + assert_eq!(stream_list.values.len(), 1); + assert_eq!(stream_list.values[0].name.as_ref(), stream_name.as_ref()); + assert_eq!(stream_list.values[0].cipher, expected); +} + +#[tokio::test] +async fn test_backend_append_auto_creates_stream() { + let backend = create_backend().await; + let basin_config = BasinConfig { + create_stream_on_append: true, + ..Default::default() + }; + let basin_name = create_test_basin(&backend, "backend-auto-create-append", basin_config).await; + let stream_name = test_stream_name("missing"); + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"should fail")]), + match_seq_num: None, + fencing_token: None, + }; + + let ack = append( + &backend, + basin_name.clone(), + stream_name.clone(), + input, + None, + ) + .await + .expect("Failed to append to auto-created stream"); + + assert_eq!(ack.end.seq_num, 1); + assert_stream_count(&backend, &basin_name, 1).await; + let tail = check_tail(&backend, basin_name, stream_name) + .await + .expect("Failed to check tail on auto-created stream"); + assert_eq!(tail.seq_num, 1); +} + +#[tokio::test] +async fn test_backend_append_auto_creates_stream_with_basin_cipher() { + let backend = create_backend().await; + let mut basin_config = basin_config_with_stream_cipher(EncryptionAlgorithm::Aegis256); + basin_config.create_stream_on_append = true; + let basin_name = create_test_basin( + &backend, + "backend-auto-create-append-encrypted", + basin_config, + ) + .await; + let stream_name = test_stream_name("missing"); + let encryption = aegis256_encryption_spec(); + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"secret")]), + match_seq_num: None, + fencing_token: None, + }; + + let ack = append( + &backend, + basin_name.clone(), + stream_name.clone(), + input, + Some(&encryption), + ) + .await + .expect("Failed to append to auto-created encrypted stream"); + + assert_eq!(ack.end.seq_num, 1); + assert_stream_count(&backend, &basin_name, 1).await; + assert_stream_cipher( + &backend, + &basin_name, + &stream_name, + Some(EncryptionAlgorithm::Aegis256), + ) + .await; + + let (start, end) = read_all_bounds(); + let records = + read_records_with_encryption(&backend, &basin_name, &stream_name, start, end, &encryption) + .await; + assert_eq!(envelope_bodies(&records), vec![b"secret".to_vec()]); +} + +fn basin_config_with_defaults() -> BasinConfig { + BasinConfig { + create_stream_on_append: true, + default_stream_config: OptionalStreamConfig { + storage_class: Some("standard".into()), + retention_policy: Some(RetentionPolicy::Age(Duration::from_secs(7 * 24 * 60 * 60))), + ..Default::default() + }, + ..Default::default() + } +} + +fn requested_stream_config() -> OptionalStreamConfig { + OptionalStreamConfig { + retention_policy: Some(RetentionPolicy::Age(Duration::from_secs(3600))), + delete_on_empty: OptionalDeleteOnEmptyConfig { + min_age: Some(Duration::from_secs(300)), + }, + ..Default::default() + } +} + +fn expected_merged_stream_config() -> StreamConfig { + StreamConfig { + storage_class: Some("standard".into()), + retention_policy: RetentionPolicy::Age(Duration::from_secs(3600)), + timestamping: Default::default(), + delete_on_empty: DeleteOnEmptyConfig { + min_age: Duration::from_secs(300), + }, + } +} + +#[tokio::test] +async fn test_backend_append_auto_create_applies_stream_config() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "backend-auto-create-config", + basin_config_with_defaults(), + ) + .await; + let stream_name = test_stream_name("missing"); + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"hello")]), + match_seq_num: None, + fencing_token: None, + }; + let ack = backend + .open_for_append(&basin_name, &stream_name, None, requested_stream_config()) + .await + .expect("Failed to open append handle") + .append(input) + .await + .expect("Failed to append to auto-created stream"); + assert_eq!(ack.end.seq_num, 1); + + let config = backend + .get_stream_config(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to get stream config"); + assert_eq!(config, expected_merged_stream_config()); +} + +#[tokio::test] +async fn test_backend_append_ignores_stream_config_for_existing_stream() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "backend-auto-create-config-existing", + basin_config_with_defaults(), + ) + .await; + let stream_name = create_test_stream( + &backend, + &basin_name, + "existing", + OptionalStreamConfig::default(), + ) + .await; + let before = backend + .get_stream_config(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to get stream config"); + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"hello")]), + match_seq_num: None, + fencing_token: None, + }; + backend + .open_for_append(&basin_name, &stream_name, None, requested_stream_config()) + .await + .expect("Failed to open append handle") + .append(input) + .await + .expect("Failed to append to existing stream"); + + let after = backend + .get_stream_config(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to get stream config"); + assert_eq!(after, before); + assert_ne!(after, expected_merged_stream_config()); +} + +#[tokio::test] +async fn test_backend_read_auto_create_applies_stream_config() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "backend-auto-create-read-config", + BasinConfig { + create_stream_on_append: false, + create_stream_on_read: true, + ..basin_config_with_defaults() + }, + ) + .await; + let stream_name = test_stream_name("missing"); + + backend + .open_for_read(&basin_name, &stream_name, None, requested_stream_config()) + .await + .expect("Failed to open read handle on auto-created stream"); + + let config = backend + .get_stream_config(basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to get stream config"); + assert_eq!(config, expected_merged_stream_config()); +} + +#[tokio::test] +async fn test_backend_append_without_auto_create_returns_not_found() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "backend-no-auto-create-append", + BasinConfig::default(), + ) + .await; + let stream_name = test_stream_name("missing"); + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"should fail")]), + match_seq_num: None, + fencing_token: None, + }; + + let result = append(&backend, basin_name.clone(), stream_name, input, None).await; + + assert!(matches!(result, Err(AppendError::StreamNotFound(_)))); + assert_stream_count(&backend, &basin_name, 0).await; +} + +#[tokio::test] +async fn test_backend_read_auto_creates_stream() { + let backend = create_backend().await; + let basin_config = BasinConfig { + create_stream_on_read: true, + ..Default::default() + }; + let basin_name = create_test_basin(&backend, "backend-auto-create-read", basin_config).await; + let stream_name = test_stream_name("missing"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let _session = open_read_session( + &backend, + &basin_name, + &stream_name, + start, + ReadEnd::default(), + ) + .await; + assert_stream_count(&backend, &basin_name, 1).await; + let tail = check_tail(&backend, basin_name, stream_name) + .await + .expect("Failed to check tail on auto-created read stream"); + assert_eq!(tail.seq_num, 0); +} + +#[tokio::test] +async fn test_backend_read_without_auto_create_returns_not_found() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "backend-no-auto-create-read", + BasinConfig::default(), + ) + .await; + let stream_name = test_stream_name("missing"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let result = try_open_read_session( + &backend, + &basin_name, + &stream_name, + start, + ReadEnd::default(), + ) + .await; + + assert!(matches!(result, Err(ReadError::StreamNotFound(_)))); + assert_stream_count(&backend, &basin_name, 0).await; +} + +#[tokio::test] +async fn test_backend_check_tail_auto_creates_stream() { + let backend = create_backend().await; + let basin_config = BasinConfig { + create_stream_on_read: true, + ..Default::default() + }; + let basin_name = create_test_basin(&backend, "backend-auto-create-tail", basin_config).await; + let stream_name = test_stream_name("missing"); + + let tail = check_tail(&backend, basin_name.clone(), stream_name) + .await + .expect("Failed to check tail on auto-created stream"); + + assert_eq!(tail.seq_num, 0); + assert_stream_count(&backend, &basin_name, 1).await; +} + +#[tokio::test] +async fn test_backend_check_tail_auto_creates_stream_with_basin_cipher() { + let backend = create_backend().await; + let mut basin_config = basin_config_with_stream_cipher(EncryptionAlgorithm::Aegis256); + basin_config.create_stream_on_read = true; + let basin_name = + create_test_basin(&backend, "backend-auto-create-tail-encrypted", basin_config).await; + let stream_name = test_stream_name("missing"); + + let tail = check_tail(&backend, basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail on auto-created encrypted stream"); + + assert_eq!(tail, StreamPosition::MIN); + assert_stream_count(&backend, &basin_name, 1).await; + assert_stream_cipher( + &backend, + &basin_name, + &stream_name, + Some(EncryptionAlgorithm::Aegis256), + ) + .await; +} + +#[tokio::test] +async fn test_backend_check_tail_without_auto_create_returns_not_found() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "backend-no-auto-create-tail", + BasinConfig::default(), + ) + .await; + let stream_name = test_stream_name("missing"); + + let result = check_tail(&backend, basin_name.clone(), stream_name).await; + + assert!(matches!(result, Err(CheckTailError::StreamNotFound(_)))); + assert_stream_count(&backend, &basin_name, 0).await; +} + +#[tokio::test] +async fn test_backend_append_auto_create_is_race_safe() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "backend-auto-create-append-race", + BasinConfig { + create_stream_on_append: true, + ..Default::default() + }, + ) + .await; + let stream_name = test_stream_name("missing"); + let expected_bodies: Vec<_> = (0..10).map(|i| format!("racer-{i}").into_bytes()).collect(); + let mut handles = Vec::new(); + + for body in &expected_bodies { + let backend = backend.clone(); + let basin_name = basin_name.clone(); + let stream_name = stream_name.clone(); + let body = body.clone(); + handles.push(tokio::spawn(async move { + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from(body)]), + match_seq_num: None, + fencing_token: None, + }; + for _ in 0..MAX_AUTO_CREATE_ATTEMPTS { + match append( + &backend, + basin_name.clone(), + stream_name.clone(), + input.clone(), + None, + ) + .await + { + Ok(ack) => return Ok(ack), + Err(AppendError::TransactionConflict(_)) + | Err(AppendError::StreamNotFound(_)) => { + tokio::task::yield_now().await; + } + Err(err) => return Err(err), + } + } + append(&backend, basin_name, stream_name, input, None).await + })); + } + + for handle in handles { + handle + .await + .unwrap() + .expect("auto-create append racer should succeed"); + } + + let tail = check_tail(&backend, basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, 10); + assert_stream_count(&backend, &basin_name, 1).await; + + let (start, end) = read_all_bounds(); + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + let mut actual_bodies = envelope_bodies(&records); + let mut expected_bodies = expected_bodies; + actual_bodies.sort(); + expected_bodies.sort(); + assert_eq!(actual_bodies, expected_bodies); +} + +#[tokio::test] +async fn test_backend_read_auto_create_is_race_safe() { + let backend = create_backend().await; + let basin_name = create_test_basin( + &backend, + "backend-auto-create-read-race", + BasinConfig { + create_stream_on_read: true, + ..Default::default() + }, + ) + .await; + let stream_name = test_stream_name("missing"); + let mut handles = Vec::new(); + + for _ in 0..10 { + let backend = backend.clone(); + let basin_name = basin_name.clone(); + let stream_name = stream_name.clone(); + handles.push(tokio::spawn(async move { + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + for _ in 0..MAX_AUTO_CREATE_ATTEMPTS { + match try_open_read_session(&backend, &basin_name, &stream_name, start, end).await { + Ok(session) => { + drop(session); + return Ok::<(), ReadError>(()); + } + Err(ReadError::TransactionConflict(_)) | Err(ReadError::StreamNotFound(_)) => { + tokio::task::yield_now().await; + } + Err(err) => return Err(err), + } + } + match try_open_read_session(&backend, &basin_name, &stream_name, start, end).await { + Ok(session) => { + drop(session); + Ok::<(), ReadError>(()) + } + Err(err) => Err(err), + } + })); + } + + for handle in handles { + handle + .await + .unwrap() + .expect("auto-create read racer should succeed"); + } + + let tail = check_tail(&backend, basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail after auto-create reads"); + assert_eq!(tail, StreamPosition::MIN); + assert_stream_count(&backend, &basin_name, 1).await; + + let (start, end) = read_all_bounds(); + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + assert!(records.is_empty()); +} diff --git a/lite/tests/backend/data_plane/mixed.rs b/lite/tests/backend/data_plane/mixed.rs new file mode 100644 index 00000000..7bc8c40c --- /dev/null +++ b/lite/tests/backend/data_plane/mixed.rs @@ -0,0 +1,229 @@ +use std::{sync::Arc, time::Duration}; + +use bytes::Bytes; +use s2_common::{ + config::{OptionalStreamConfig, RetentionPolicy, StreamReconfiguration}, + read_extent::{ReadLimit, ReadUntil}, + stream::{AppendInput, ReadEnd, ReadFrom, ReadStart}, +}; +use s2_lite::backend::error::{AppendError, CheckTailError, ReadError}; +use tokio::sync::Notify; + +use super::common::*; + +#[tokio::test] +async fn test_operations_on_nonexistent_basin() { + let backend = create_backend().await; + let basin_name = test_basin_name("nonexistent"); + let stream_name = test_stream_name("nonexistent"); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: None, + }; + + let read_result = try_open_read_session(&backend, &basin_name, &stream_name, start, end).await; + assert!(matches!(read_result, Err(ReadError::BasinNotFound(_)))); + + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from_static(b"test data")]), + match_seq_num: None, + fencing_token: None, + }; + let append_result = append( + &backend, + basin_name.clone(), + stream_name.clone(), + input, + None, + ) + .await; + assert!(matches!(append_result, Err(AppendError::BasinNotFound(_)))); + + let check_tail_result = check_tail(&backend, basin_name, stream_name).await; + assert!(matches!( + check_tail_result, + Err(CheckTailError::BasinNotFound(_)) + )); +} + +#[tokio::test] +async fn test_concurrent_appends_to_same_stream() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "concurrent-append", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let expected_bodies: Vec<_> = (0..20) + .map(|i| format!("concurrent-{i}").into_bytes()) + .collect(); + let mut handles = vec![]; + for body in &expected_bodies { + let backend = backend.clone(); + let basin_name = basin_name.clone(); + let stream_name = stream_name.clone(); + let body = body.clone(); + let handle = tokio::spawn(async move { + let input = AppendInput { + records: create_test_record_batch(vec![Bytes::from(body)]), + match_seq_num: None, + fencing_token: None, + }; + append(&backend, basin_name, stream_name, input, None).await + }); + handles.push(handle); + } + + for handle in handles { + handle + .await + .unwrap() + .expect("Concurrent append should succeed"); + } + + let tail = check_tail(&backend, basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, 20); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + + let session = open_read_session(&backend, &basin_name, &stream_name, start, end).await; + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + let mut actual_bodies = envelope_bodies(&records); + let mut expected_bodies = expected_bodies; + actual_bodies.sort(); + expected_bodies.sort(); + assert_eq!(actual_bodies, expected_bodies); +} + +#[tokio::test] +async fn test_concurrent_reconfigure_during_append() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "concurrent-reconfig", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let backend_append = backend.clone(); + let basin_append = basin_name.clone(); + let stream_append = stream_name.clone(); + let ready = Arc::new(Notify::new()); + + let ready_clone = ready.clone(); + let append_handle = tokio::spawn(async move { + for i in 0..10 { + append_payloads( + &backend_append, + &basin_append, + &stream_append, + &[format!("data-{}", i).as_bytes()], + ) + .await; + if i == 0 { + ready_clone.notify_one(); + } + tokio::task::yield_now().await; + } + }); + + ready.notified().await; + + let reconfig = StreamReconfiguration { + storage_class: s2_common::maybe::Maybe::from(Some("express".into())), + retention_policy: s2_common::maybe::Maybe::from(Some(RetentionPolicy::Infinite())), + timestamping: s2_common::maybe::Maybe::default(), + delete_on_empty: s2_common::maybe::Maybe::default(), + }; + + let updated_config = backend + .reconfigure_stream(basin_name.clone(), stream_name.clone(), reconfig) + .await + .expect("Failed to reconfigure stream during appends"); + assert_eq!(updated_config.storage_class.as_deref(), Some("express")); + + append_handle.await.unwrap(); + + let tail = check_tail(&backend, basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail"); + assert_eq!(tail.seq_num, 10); + + let (start, end) = read_all_bounds(); + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + assert_eq!( + envelope_bodies(&records), + (0..10) + .map(|i| format!("data-{i}").into_bytes()) + .collect::>() + ); +} + +#[tokio::test] +async fn test_concurrent_reads_same_stream() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "concurrent-reads", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + for i in 0..20 { + append_payloads( + &backend, + &basin_name, + &stream_name, + &[format!("record-{}", i).as_bytes()], + ) + .await; + } + + let mut handles = vec![]; + for _ in 0..10 { + let backend = backend.clone(); + let basin_name = basin_name.clone(); + let stream_name = stream_name.clone(); + let handle = tokio::spawn(async move { + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + let session = + try_open_read_session(&backend, &basin_name, &stream_name, start, end).await?; + let mut session = Box::pin(session); + let records = collect_records(&mut session).await; + Ok::>, ReadError>(envelope_bodies(&records)) + }); + handles.push(handle); + } + + let expected_bodies: Vec<_> = (0..20) + .map(|i| format!("record-{i}").into_bytes()) + .collect(); + for handle in handles { + let bodies = handle.await.unwrap().expect("Read should succeed"); + assert_eq!(bodies, expected_bodies); + } +} diff --git a/lite/tests/backend/data_plane/mod.rs b/lite/tests/backend/data_plane/mod.rs new file mode 100644 index 00000000..8e9689a0 --- /dev/null +++ b/lite/tests/backend/data_plane/mod.rs @@ -0,0 +1,7 @@ +use super::common; + +mod append; +mod auto_create; +mod mixed; +mod read; +mod read_follow; diff --git a/lite/tests/backend/data_plane/read.rs b/lite/tests/backend/data_plane/read.rs new file mode 100644 index 00000000..511ae50c --- /dev/null +++ b/lite/tests/backend/data_plane/read.rs @@ -0,0 +1,825 @@ +use std::time::Duration; + +use bytes::Bytes; +use futures::StreamExt; +use rstest::rstest; +use s2_common::{ + basin::BasinName, + config::{OptionalStreamConfig, OptionalTimestampingConfig, TimestampingMode}, + encryption::EncryptionAlgorithm, + read_extent::{ReadLimit, ReadUntil}, + record::{MeteredSize, StreamPosition}, + stream::{ReadEnd, ReadFrom, ReadSessionOutput, ReadStart, StreamName}, +}; +use s2_lite::backend::{ + Backend, + error::{CheckTailError, ReadError, UnwrittenError}, +}; + +use super::common::*; + +#[derive(Clone, Copy, Debug)] +enum TailStartCase { + TailOffset, + SeqNumAtEnd, + TimestampAfterEnd, +} + +#[derive(Clone, Copy, Debug)] +enum TailEndCase { + CountNoWait, + CountZeroWait, + TimestampMax, +} + +fn tail_read_from(case: TailStartCase, tail: &StreamPosition) -> ReadFrom { + match case { + TailStartCase::TailOffset => ReadFrom::TailOffset(0), + TailStartCase::SeqNumAtEnd => ReadFrom::SeqNum(tail.seq_num), + TailStartCase::TimestampAfterEnd => ReadFrom::Timestamp(tail.timestamp + 1), + } +} + +fn tail_read_end(case: TailEndCase) -> ReadEnd { + match case { + TailEndCase::CountNoWait => ReadEnd { + limit: ReadLimit::Count(10), + until: ReadUntil::Unbounded, + wait: None, + }, + TailEndCase::CountZeroWait => ReadEnd { + limit: ReadLimit::Count(10), + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }, + TailEndCase::TimestampMax => ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Timestamp(u64::MAX), + wait: None, + }, + } +} + +fn body_vecs(bodies: &[&[u8]]) -> Vec> { + bodies.iter().map(|body| body.to_vec()).collect() +} + +fn timestamped_payloads(records: &[(&[u8], u64)]) -> Vec<(Bytes, u64)> { + records + .iter() + .map(|(body, timestamp)| (Bytes::copy_from_slice(body), *timestamp)) + .collect() +} + +fn client_timestamp_stream_config() -> OptionalStreamConfig { + OptionalStreamConfig { + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + ..Default::default() + }, + ..Default::default() + } +} + +async fn seed_timestamped_stream( + basin_suffix: &str, + stream_suffix: &str, + stream_config: OptionalStreamConfig, + records: &[(&[u8], u64)], +) -> (Backend, BasinName, StreamName) { + let (backend, basin_name, stream_name) = + setup_backend_with_stream(basin_suffix, stream_suffix, stream_config).await; + append_timestamped_payloads( + &backend, + &basin_name, + &stream_name, + timestamped_payloads(records), + ) + .await; + (backend, basin_name, stream_name) +} + +#[tokio::test] +async fn test_check_tail_scenarios() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("check-tail", "stream", OptionalStreamConfig::default()).await; + + let empty_tail = check_tail(&backend, basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail on empty stream"); + assert_eq!(empty_tail, StreamPosition::MIN); + + let ack = append_payloads(&backend, &basin_name, &stream_name, &[b"test data"]).await; + + let tail_after_append = check_tail(&backend, basin_name.clone(), stream_name.clone()) + .await + .expect("Failed to check tail after append"); + assert_eq!(tail_after_append, ack.end); + + let missing_backend = create_backend().await; + let missing_result = check_tail( + &missing_backend, + test_basin_name("check-tail-missing"), + test_stream_name("missing"), + ) + .await; + + assert!(matches!( + missing_result, + Err(CheckTailError::BasinNotFound(_)) + )); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_check_tail_handle_survives_streamer_dormancy_before_call() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "check-tail-dormancy", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let ack = append_payloads(&backend, &basin_name, &stream_name, &[b"seed"]).await; + let handle = backend + .open_for_check_tail(&basin_name, &stream_name) + .await + .expect("Failed to open check-tail handle"); + + tokio::time::advance(Duration::from_secs(61)).await; + tokio::task::yield_now().await; + + let tail = handle + .check_tail() + .await + .expect("check-tail handle should survive dormancy before use"); + assert_eq!(tail, ack.end); +} + +#[tokio::test] +async fn test_read_from_beginning() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-from-beginning", + "read", + OptionalStreamConfig::default(), + ) + .await; + + append_repeat(&backend, &basin_name, &stream_name, b"test data", 5).await; + + let (start, end) = read_all_bounds(); + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + + assert_eq!(envelope_bodies(&records), vec![b"test data".to_vec(); 5]); +} + +#[tokio::test] +async fn test_read_encrypted_roundtrip() { + let encryption = aegis256_encryption_spec(); + let (backend, basin_name, stream_name) = setup_backend_with_basin_and_stream( + "read-enc", + "stream", + basin_config_with_stream_cipher(EncryptionAlgorithm::Aegis256), + OptionalStreamConfig::default(), + ) + .await; + + append_payloads_with_encryption( + &backend, + &basin_name, + &stream_name, + &[b"secret-1", b"secret-2"], + &encryption, + ) + .await; + + let (start, end) = read_all_bounds(); + let records = + read_records_with_encryption(&backend, &basin_name, &stream_name, start, end, &encryption) + .await; + assert_eq!( + envelope_bodies(&records), + vec![b"secret-1".to_vec(), b"secret-2".to_vec()] + ); +} + +#[tokio::test] +async fn test_read_with_limit() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("read-with-limit", "limit", OptionalStreamConfig::default()) + .await; + + let expected_bodies: Vec<_> = (0..10) + .map(|i| format!("record-{i}").into_bytes()) + .collect(); + for body in &expected_bodies { + append_payloads(&backend, &basin_name, &stream_name, &[body.as_slice()]).await; + } + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Count(5), + until: ReadUntil::Unbounded, + wait: None, + }; + + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + + assert_eq!( + envelope_bodies(&records), + expected_bodies.into_iter().take(5).collect::>() + ); +} + +#[tokio::test] +async fn test_read_unwritten_clamp_behavior() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-unwritten-clamp", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"record"]).await; + + // Without clamp: returns Unwritten error + let start = ReadStart { + from: ReadFrom::SeqNum(100), + clamp: false, + }; + let result = try_open_read_session( + &backend, + &basin_name, + &stream_name, + start, + ReadEnd::default(), + ) + .await; + assert!(matches!(result, Err(ReadError::Unwritten(_)))); + + // With clamp: succeeds with empty result + let start = ReadStart { + from: ReadFrom::SeqNum(100), + clamp: true, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + assert!(records.is_empty()); +} + +#[rstest] +#[case::tail_offset_no_wait(TailStartCase::TailOffset, TailEndCase::CountNoWait, false)] +#[case::tail_seq_num_zero_wait(TailStartCase::SeqNumAtEnd, TailEndCase::CountZeroWait, false)] +#[case::tail_timestamp_max(TailStartCase::TimestampAfterEnd, TailEndCase::TimestampMax, false)] +#[case::timestamp_after_end_with_clamp( + TailStartCase::TimestampAfterEnd, + TailEndCase::CountNoWait, + true +)] +#[tokio::test] +async fn test_read_at_tail_without_follow_returns_unwritten( + #[case] start_case: TailStartCase, + #[case] end_case: TailEndCase, + #[case] clamp: bool, +) { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-at-tail-no-follow", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let ack = append_timestamped_payloads( + &backend, + &basin_name, + &stream_name, + vec![ + (Bytes::from_static(b"record 1"), 1000), + (Bytes::from_static(b"record 2"), 2000), + ], + ) + .await; + + let start = ReadStart { + from: tail_read_from(start_case, &ack.end), + clamp, + }; + let end = tail_read_end(end_case); + let result = try_open_read_session(&backend, &basin_name, &stream_name, start, end).await; + + match result { + Err(ReadError::Unwritten(UnwrittenError(tail))) => { + assert_eq!(tail, ack.end); + } + Ok(_) => panic!( + "Expected Unwritten error for {start_case:?} / clamp={clamp} / {end_case:?}, got Ok" + ), + Err(e) => panic!( + "Expected Unwritten error for {start_case:?} / clamp={clamp} / {end_case:?}, got: {e:?}" + ), + } +} + +#[rstest] +#[case::seq_num(ReadFrom::SeqNum(0))] +#[case::timestamp(ReadFrom::Timestamp(0))] +#[tokio::test] +async fn test_read_at_tail_of_empty_stream_returns_unwritten(#[case] from: ReadFrom) { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-empty-at-tail", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let start = ReadStart { from, clamp: false }; + let end = tail_read_end(TailEndCase::CountNoWait); + let result = try_open_read_session(&backend, &basin_name, &stream_name, start, end).await; + + match result { + Err(ReadError::Unwritten(UnwrittenError(tail))) => { + assert_eq!(tail, StreamPosition::MIN); + } + Ok(_) => panic!("Expected Unwritten error for {from:?} on an empty stream, got Ok"), + Err(e) => panic!("Expected Unwritten error for {from:?} on an empty stream, got: {e:?}"), + } +} + +#[tokio::test] +async fn test_read_from_tail_offset() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-tail-offset", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + for payload in ["record 1", "record 2", "record 3", "record 4", "record 5"] { + append_payloads(&backend, &basin_name, &stream_name, &[payload.as_bytes()]).await; + } + + let start = ReadStart { + from: ReadFrom::TailOffset(2), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + let bodies = envelope_bodies(&records); + + assert_eq!(bodies, vec![b"record 4".to_vec(), b"record 5".to_vec()]); +} + +#[tokio::test] +async fn test_read_from_timestamp_includes_duplicate_timestamps() { + let timestamp = 1000; + let (backend, basin_name, stream_name) = seed_timestamped_stream( + "read-dupe-timestamp", + "stream", + client_timestamp_stream_config(), + &[ + (b"dup-1", timestamp), + (b"dup-2", timestamp), + (b"dup-3", timestamp), + ], + ) + .await; + + let start = ReadStart { + from: ReadFrom::Timestamp(timestamp), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::ZERO), + }; + + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + + assert_eq!( + envelope_bodies(&records), + body_vecs(&[b"dup-1", b"dup-2", b"dup-3"]) + ); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_read_from_tail_times_out_without_new_data() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("read-tail-wait", "idle", OptionalStreamConfig::default()).await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"seed data"]).await; + + let start = ReadStart { + from: ReadFrom::TailOffset(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::from_millis(100)), + }; + + let mut session = open_read_session(&backend, &basin_name, &stream_name, start, end).await; + let probe_step = Duration::from_millis(1); + + let started = tokio::time::Instant::now(); + let outputs = + collect_outputs_until_closed_advanced(&mut session, Duration::from_secs(1), probe_step) + .await; + + assert!(!outputs.outputs.is_empty()); + assert!( + outputs + .outputs + .iter() + .all(|output| matches!(output, ReadSessionOutput::Heartbeat(_))) + ); + let wait = Duration::from_millis(100); + assert!(outputs.closed_at >= started + wait); + assert!(outputs.closed_at <= started + wait + probe_step); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_read_from_tail_wait_is_reset_by_new_data() { + let (backend, basin_name, stream_name) = + setup_backend_with_stream("read-tail-reset", "stream", OptionalStreamConfig::default()) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"seed data"]).await; + + let wait = Duration::from_millis(100); + let follow_delay = Duration::from_millis(40); + let probe_step = Duration::from_millis(1); + let start = ReadStart { + from: ReadFrom::TailOffset(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(wait), + }; + + let mut session = open_read_session(&backend, &basin_name, &stream_name, start, end).await; + + let first = session + .as_mut() + .next() + .await + .expect("session should enter follow mode") + .expect("session should not error"); + assert!(matches!(first, ReadSessionOutput::Heartbeat(_))); + + advance_time(follow_delay).await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"follow data"]).await; + + let follow = session + .as_mut() + .next() + .await + .expect("session should yield the live tail batch") + .expect("session should not error"); + let reset_at = tokio::time::Instant::now(); + let ReadSessionOutput::Batch(batch) = follow else { + panic!("expected a batch after appending past tail"); + }; + assert_eq!( + envelope_bodies(&batch.records), + vec![b"follow data".to_vec()] + ); + + let outputs = collect_outputs_until_closed_advanced( + &mut session, + wait + Duration::from_secs(1), + probe_step, + ) + .await; + + assert!(outputs.closed_at >= reset_at + wait); + assert!(outputs.closed_at <= reset_at + wait + probe_step); +} + +#[tokio::test] +async fn test_read_with_bytes_limit_exact_fit() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-bytes-limit", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads( + &backend, + &basin_name, + &stream_name, + &[b"record-1", b"record-2", b"record-3"], + ) + .await; + + let expected_batch = create_test_record_batch(vec![ + Bytes::from_static(b"record-1"), + Bytes::from_static(b"record-2"), + ]); + let exact_limit = expected_batch[0].metered_size() + expected_batch[1].metered_size(); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Bytes(exact_limit), + until: ReadUntil::Unbounded, + wait: None, + }; + + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + assert_eq!( + envelope_bodies(&records), + vec![b"record-1".to_vec(), b"record-2".to_vec()] + ); +} + +#[tokio::test] +async fn test_read_with_bytes_limit_smaller_than_first_record_returns_empty() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-bytes-too-small", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"oversized"]).await; + + let first_size = + create_test_record_batch(vec![Bytes::from_static(b"oversized")])[0].metered_size(); + assert!(first_size > 0); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Bytes(first_size - 1), + until: ReadUntil::Unbounded, + wait: None, + }; + + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + assert!(records.is_empty()); +} + +#[tokio::test] +async fn test_read_with_count_or_bytes_limit_count_wins() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-count-or-bytes-count", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let expected_bodies: Vec<_> = (0..20).map(|i| format!("count-{i}").into_bytes()).collect(); + for body in &expected_bodies { + append_payloads(&backend, &basin_name, &stream_name, &[body.as_slice()]).await; + } + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::from_count_and_bytes(Some(5), Some(1_000_000)), + until: ReadUntil::Unbounded, + wait: None, + }; + + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + + assert_eq!( + envelope_bodies(&records), + expected_bodies.into_iter().take(5).collect::>() + ); +} + +#[tokio::test] +async fn test_read_with_count_or_bytes_limit_bytes_wins() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "read-count-or-bytes-bytes", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads( + &backend, + &basin_name, + &stream_name, + &[b"slot-0", b"slot-1", b"slot-2", b"slot-3", b"slot-4"], + ) + .await; + + let per_record_bytes = + create_test_record_batch(vec![Bytes::from_static(b"slot-0")])[0].metered_size(); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::from_count_and_bytes(Some(100), Some(per_record_bytes * 3)), + until: ReadUntil::Unbounded, + wait: None, + }; + + let records = read_records(&backend, &basin_name, &stream_name, start, end).await; + assert_eq!( + envelope_bodies(&records), + vec![b"slot-0".to_vec(), b"slot-1".to_vec(), b"slot-2".to_vec()] + ); +} + +#[rstest] +#[case::before("read-until-before", 500, vec![])] +#[case::exact_duplicate_boundary( + "read-until-exact-duplicate-boundary", + 2000, + vec![b"ts-1000".to_vec()] +)] +#[case::after( + "read-until-after", + 5000, + vec![ + b"ts-1000".to_vec(), + b"ts-2000-a".to_vec(), + b"ts-2000-b".to_vec(), + b"ts-3000".to_vec(), + ] +)] +#[tokio::test] +async fn test_read_until_timestamp_boundaries( + #[case] suffix: &str, + #[case] cutoff: u64, + #[case] expected: Vec>, +) { + let boundary_records = [ + (b"ts-1000".as_ref(), 1000), + (b"ts-2000-a".as_ref(), 2000), + (b"ts-2000-b".as_ref(), 2000), + (b"ts-3000".as_ref(), 3000), + ]; + + let (backend, basin_name, stream_name) = seed_timestamped_stream( + suffix, + "boundary", + client_timestamp_stream_config(), + &boundary_records, + ) + .await; + + let records = read_records( + &backend, + &basin_name, + &stream_name, + ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }, + ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Timestamp(cutoff), + wait: None, + }, + ) + .await; + + assert_eq!(envelope_bodies(&records), expected, "case {suffix}"); + assert!( + records + .iter() + .all(|record| record.position().timestamp < cutoff), + "case {suffix}" + ); +} + +#[tokio::test] +async fn test_read_until_with_additional_limits() { + let timestamped_records = [ + (b"ts-1000".as_ref(), 1000), + (b"ts-2000".as_ref(), 2000), + (b"ts-3000".as_ref(), 3000), + (b"ts-4000".as_ref(), 4000), + (b"ts-5000".as_ref(), 5000), + ]; + let (backend, basin_name, stream_name) = seed_timestamped_stream( + "read-until-limits", + "stream", + client_timestamp_stream_config(), + ×tamped_records, + ) + .await; + + let per_record_bytes = + create_test_record_batch(vec![Bytes::from_static(b"ts-1000")])[0].metered_size(); + let cases = vec![ + ( + "count wins", + ReadLimit::Count(2), + 5_000, + body_vecs(&[b"ts-1000", b"ts-2000"]), + ), + ( + "timestamp beats count", + ReadLimit::Count(10), + 3_500, + body_vecs(&[b"ts-1000", b"ts-2000", b"ts-3000"]), + ), + ( + "bytes win", + ReadLimit::Bytes(per_record_bytes * 2), + 5_000, + body_vecs(&[b"ts-1000", b"ts-2000"]), + ), + ( + "timestamp beats bytes", + ReadLimit::Bytes(per_record_bytes * 100), + 3_500, + body_vecs(&[b"ts-1000", b"ts-2000", b"ts-3000"]), + ), + ]; + + for (label, limit, cutoff, expected) in cases { + let records = read_records( + &backend, + &basin_name, + &stream_name, + ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }, + ReadEnd { + limit, + until: ReadUntil::Timestamp(cutoff), + wait: None, + }, + ) + .await; + + assert_eq!(envelope_bodies(&records), expected, "{label}"); + } +} + +#[tokio::test] +async fn test_read_timestamp_range_with_from_and_until() { + let timestamped_records = [ + (b"ts-500".as_ref(), 500), + (b"ts-2000-a".as_ref(), 2000), + (b"ts-2000-b".as_ref(), 2000), + (b"ts-2500".as_ref(), 2500), + (b"ts-3500".as_ref(), 3500), + (b"ts-4500".as_ref(), 4500), + (b"ts-5500".as_ref(), 5500), + ]; + let (backend, basin_name, stream_name) = seed_timestamped_stream( + "read-timestamp-range", + "from-until", + client_timestamp_stream_config(), + ×tamped_records, + ) + .await; + + let records = read_records( + &backend, + &basin_name, + &stream_name, + ReadStart { + from: ReadFrom::Timestamp(2000), + clamp: false, + }, + ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Timestamp(4500), + wait: None, + }, + ) + .await; + + assert_eq!( + envelope_bodies(&records), + body_vecs(&[b"ts-2000-a", b"ts-2000-b", b"ts-2500", b"ts-3500"]) + ); + assert!(records.iter().all(|record| { + let position = record.position(); + position.timestamp >= 2000 && position.timestamp < 4500 + })); +} diff --git a/lite/tests/backend/data_plane/read_follow.rs b/lite/tests/backend/data_plane/read_follow.rs new file mode 100644 index 00000000..e725f1ff --- /dev/null +++ b/lite/tests/backend/data_plane/read_follow.rs @@ -0,0 +1,933 @@ +use std::{task::Poll, time::Duration}; + +use bytes::Bytes; +use futures::StreamExt; +use rstest::rstest; +use s2_common::{ + config::{OptionalStreamConfig, OptionalTimestampingConfig, TimestampingMode}, + encryption::EncryptionSpec, + read_extent::{ReadLimit, ReadUntil}, + record::MeteredSize, + stream::{AppendInput, ReadEnd, ReadFrom, ReadSessionOutput, ReadStart}, +}; +use s2_lite::backend::FOLLOWER_MAX_LAG; + +use super::common::*; + +const VIRTUAL_TIME_STEP: Duration = Duration::from_millis(50); + +async fn run_follow_mode_receives_new_data_case(test_suffix: &str, encryption: &EncryptionSpec) { + let (backend, basin_name, stream_name) = + setup_backend_for_encryption_spec(test_suffix, "stream", encryption).await; + + append_payloads_with_encryption( + &backend, + &basin_name, + &stream_name, + &[b"initial"], + encryption, + ) + .await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let wait_duration = Duration::from_millis(200); + let first_follow_delay = Duration::from_millis(100); + let second_follow_delay = Duration::from_millis(50); + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(wait_duration), + }; + + let session = open_read_session_with_encryption( + &backend, + &basin_name, + &stream_name, + start, + end, + encryption, + ) + .await; + let mut session = Box::pin(session); + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + let encryption_clone = encryption.clone(); + + let append_handle = tokio::spawn(async move { + tokio::time::sleep(first_follow_delay).await; + append_payloads_with_encryption( + &backend_clone, + &basin_clone, + &stream_clone, + &[b"follow-1"], + &encryption_clone, + ) + .await; + tokio::time::sleep(second_follow_delay).await; + append_payloads_with_encryption( + &backend_clone, + &basin_clone, + &stream_clone, + &[b"follow-2"], + &encryption_clone, + ) + .await; + }); + + let probe_step = Duration::from_millis(1); + let deadline = tokio::time::Instant::now() + + wait_duration + + first_follow_delay + + second_follow_delay + + Duration::from_secs(1); + let mut outputs = Vec::new(); + let mut final_delivery_at = None; + let closed_at = loop { + match poll_session_with_deadline(&mut session, deadline, Some(probe_step)).await { + SessionPoll::Output(output) => { + if matches!(output, ReadSessionOutput::Batch(_)) { + final_delivery_at = Some(tokio::time::Instant::now()); + } + outputs.push(output); + } + SessionPoll::Closed => break tokio::time::Instant::now(), + SessionPoll::TimedOut => panic!("Timed out waiting for read session to close"), + } + }; + + append_handle.await.unwrap(); + + let all_records = outputs + .into_iter() + .filter_map(|output| match output { + ReadSessionOutput::Batch(batch) => Some(batch), + ReadSessionOutput::Heartbeat(_) => None, + }) + .flat_map(|batch| batch.records.into_iter()) + .collect::>(); + let bodies = envelope_bodies(&all_records); + assert_eq!( + bodies, + vec![ + b"initial".to_vec(), + b"follow-1".to_vec(), + b"follow-2".to_vec() + ] + ); + let final_delivery_at = + final_delivery_at.expect("read session should deliver the initial and follow batches"); + assert!(closed_at >= final_delivery_at + wait_duration); + assert!(closed_at <= final_delivery_at + wait_duration + probe_step); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_follow_mode_broadcast_lag_resets_wait_after_db_catchup() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-broadcast-lag", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let message_count = FOLLOWER_MAX_LAG + 25; + let wait_duration = Duration::from_millis(200); + let pre_lag_delay = Duration::from_millis(100); + let probe_step = Duration::from_millis(1); + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(wait_duration), + }; + + let session = open_read_session(&backend, &basin_name, &stream_name, start, end).await; + let mut session = Box::pin(session); + + expect_heartbeat_advanced(&mut session, Duration::from_secs(1), VIRTUAL_TIME_STEP).await; + advance_time(pre_lag_delay).await; + + let mut expected = Vec::with_capacity(message_count); + for i in 0..message_count { + let payload = format!("msg-{}", i); + expected.push(payload.as_bytes().to_vec()); + append_payloads(&backend, &basin_name, &stream_name, &[payload.as_bytes()]).await; + } + + let follow = session + .as_mut() + .next() + .await + .expect("session should deliver the lagged catchup batch") + .expect("session should not error"); + let reset_at = tokio::time::Instant::now(); + let ReadSessionOutput::Batch(batch) = follow else { + panic!("expected catchup batch after lagged follow"); + }; + assert_eq!(envelope_bodies(&batch.records), expected); + + let outputs = collect_outputs_until_closed_advanced( + &mut session, + wait_duration + Duration::from_secs(1), + probe_step, + ) + .await; + + assert!(outputs.closed_at >= reset_at + wait_duration); + assert!(outputs.closed_at <= reset_at + wait_duration + probe_step); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_follow_mode_broadcast_lag_respects_count_limit() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-broadcast-lag-count-limit", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let message_count = FOLLOWER_MAX_LAG + 25; + let count_limit = 3; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Count(count_limit), + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(3)), + }; + + let session = open_read_session(&backend, &basin_name, &stream_name, start, end).await; + let mut session = Box::pin(session); + + expect_heartbeat_advanced(&mut session, Duration::from_secs(1), VIRTUAL_TIME_STEP).await; + advance_time(Duration::from_millis(100)).await; + + let mut expected = Vec::with_capacity(message_count); + for i in 0..message_count { + let payload = format!("msg-{}", i); + expected.push(payload.as_bytes().to_vec()); + append_payloads(&backend, &basin_name, &stream_name, &[payload.as_bytes()]).await; + } + + let records = collect_records_until_closed_advanced( + &mut session, + Duration::from_secs(2), + VIRTUAL_TIME_STEP, + ) + .await; + assert_eq!( + envelope_bodies(&records), + expected.into_iter().take(count_limit).collect::>() + ); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_follow_mode_broadcast_lag_respects_bytes_limit() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-broadcast-lag-bytes-limit", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"item-00"]).await; + + let per_record_bytes = + create_test_record_batch(vec![Bytes::from_static(b"item-00")])[0].metered_size(); + let message_count = FOLLOWER_MAX_LAG + 25; + let bytes_limit = per_record_bytes * 3; + + let session = open_read_session( + &backend, + &basin_name, + &stream_name, + ReadStart { + from: ReadFrom::TailOffset(0), + clamp: false, + }, + ReadEnd { + limit: ReadLimit::Bytes(bytes_limit), + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(3)), + }, + ) + .await; + let mut session = Box::pin(session); + + expect_heartbeat_advanced(&mut session, Duration::from_secs(1), VIRTUAL_TIME_STEP).await; + advance_time(Duration::from_millis(100)).await; + + let expected: Vec<_> = (1..=message_count) + .map(|i| format!("item-{i:02}").into_bytes()) + .collect(); + for body in &expected { + append_payloads(&backend, &basin_name, &stream_name, &[body.as_slice()]).await; + } + + let records = collect_records_until_closed_advanced( + &mut session, + Duration::from_secs(2), + VIRTUAL_TIME_STEP, + ) + .await; + + assert_eq!( + envelope_bodies(&records), + expected.into_iter().take(3).collect::>() + ); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_follow_mode_broadcast_lag_respects_timestamp_until() { + let stream_config = OptionalStreamConfig { + timestamping: OptionalTimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + ..Default::default() + }, + ..Default::default() + }; + let (backend, basin_name, stream_name) = + setup_backend_with_stream("follow-broadcast-lag-until", "stream", stream_config).await; + + let message_count = FOLLOWER_MAX_LAG + 25; + let cutoff = 4_000; + + let session = open_read_session( + &backend, + &basin_name, + &stream_name, + ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }, + ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Timestamp(cutoff), + wait: Some(Duration::from_secs(3)), + }, + ) + .await; + let mut session = Box::pin(session); + + expect_heartbeat_advanced(&mut session, Duration::from_secs(1), VIRTUAL_TIME_STEP).await; + advance_time(Duration::from_millis(100)).await; + + let expected: Vec<_> = (1..=message_count) + .map(|i| (format!("ts-{i:03}").into_bytes(), i as u64 * 1_000)) + .collect(); + for (body, timestamp) in &expected { + append_timestamped_payloads( + &backend, + &basin_name, + &stream_name, + vec![(Bytes::from(body.clone()), *timestamp)], + ) + .await; + } + + let catchup = session + .as_mut() + .next() + .await + .expect("session should deliver the lagged catchup batch") + .expect("session should not error"); + let ReadSessionOutput::Batch(batch) = catchup else { + panic!("expected lagged catchup batch"); + }; + assert_eq!( + envelope_bodies(&batch.records), + expected + .iter() + .take_while(|(_, timestamp)| *timestamp < cutoff) + .map(|(body, _)| body.clone()) + .collect::>() + ); + + tokio::task::yield_now().await; + match futures::poll!(session.as_mut().next()) { + Poll::Ready(None) => {} + Poll::Ready(Some(Ok(output))) => { + panic!("unexpected output after timestamp cutoff catchup: {output:?}"); + } + Poll::Ready(Some(Err(e))) => panic!("Read error: {e:?}"), + Poll::Pending => { + panic!( + "session should close immediately once lagged catchup crosses the timestamp cutoff" + ); + } + } +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_follow_mode_broadcast_lag_resumes_live_follow_after_catchup() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-broadcast-lag-live", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let message_count = FOLLOWER_MAX_LAG + 25; + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::from_millis(300)), + }; + + let session = open_read_session(&backend, &basin_name, &stream_name, start, end).await; + let mut session = Box::pin(session); + + expect_heartbeat_advanced(&mut session, Duration::from_secs(1), VIRTUAL_TIME_STEP).await; + advance_time(Duration::from_millis(100)).await; + + let mut expected_catchup = Vec::with_capacity(message_count); + for i in 0..message_count { + let payload = format!("lag-{}", i); + expected_catchup.push(payload.as_bytes().to_vec()); + append_payloads(&backend, &basin_name, &stream_name, &[payload.as_bytes()]).await; + } + + let catchup = session + .as_mut() + .next() + .await + .expect("session should deliver the lagged catchup batch") + .expect("session should not error"); + let ReadSessionOutput::Batch(batch) = catchup else { + panic!("expected catchup batch after lagged follow"); + }; + assert_eq!(envelope_bodies(&batch.records), expected_catchup); + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + let append_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + append_payloads( + &backend_clone, + &basin_clone, + &stream_clone, + &[b"live-after-lag"], + ) + .await; + }); + + let live_records = + collect_records_until_advanced(&mut session, Duration::from_secs(1), 1, VIRTUAL_TIME_STEP) + .await; + + append_handle.await.unwrap(); + + assert_eq!( + envelope_bodies(&live_records), + vec![b"live-after-lag".to_vec()] + ); +} + +#[rstest] +#[case::plaintext("follow-new-data", EncryptionSpec::Plain)] +#[case::encrypted("follow-enc", aegis256_encryption_spec())] +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_follow_mode_receives_new_data( + #[case] test_suffix: &str, + #[case] encryption: EncryptionSpec, +) { + run_follow_mode_receives_new_data_case(test_suffix, &encryption).await; +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_transition_from_catchup_to_follow() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "catchup-to-follow", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads( + &backend, + &basin_name, + &stream_name, + &[b"record-0", b"record-1", b"record-2"], + ) + .await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(3)), + }; + + let session = open_read_session(&backend, &basin_name, &stream_name, start, end).await; + let mut session = Box::pin(session); + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + + let append_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(800)).await; + append_payloads(&backend_clone, &basin_clone, &stream_clone, &[b"live-1"]).await; + tokio::time::sleep(Duration::from_millis(200)).await; + append_payloads(&backend_clone, &basin_clone, &stream_clone, &[b"live-2"]).await; + }); + + let all_records = + collect_records_until_advanced(&mut session, Duration::from_secs(4), 5, VIRTUAL_TIME_STEP) + .await; + + append_handle.await.unwrap(); + + let bodies = envelope_bodies(&all_records); + assert_eq!( + bodies, + vec![ + b"record-0".to_vec(), + b"record-1".to_vec(), + b"record-2".to_vec(), + b"live-1".to_vec(), + b"live-2".to_vec(), + ] + ); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_follow_mode_survives_streamer_dormancy_after_catchup_batch() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-dormancy-after-catchup", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"initial"]).await; + + let session = open_read_session( + &backend, + &basin_name, + &stream_name, + ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }, + ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: None, + }, + ) + .await; + let mut session = Box::pin(session); + + let initial = session + .as_mut() + .next() + .await + .expect("session should yield the catchup batch") + .expect("session should not error"); + let ReadSessionOutput::Batch(batch) = initial else { + panic!("expected initial catchup batch"); + }; + assert_eq!(envelope_bodies(&batch.records), vec![b"initial".to_vec()]); + + tokio::task::yield_now().await; + advance_time(Duration::from_secs(61)).await; + + let heartbeat = session + .as_mut() + .next() + .await + .expect("session should re-enter follow mode after dormancy") + .expect("session should not error after dormancy"); + assert!(matches!(heartbeat, ReadSessionOutput::Heartbeat(_))); + + append_payloads(&backend, &basin_name, &stream_name, &[b"follow-1"]).await; + + let next = session + .as_mut() + .next() + .await + .expect("session should deliver live data after dormancy") + .expect("session should not error after dormancy"); + let ReadSessionOutput::Batch(batch) = next else { + panic!("expected live batch after dormancy"); + }; + assert_eq!(envelope_bodies(&batch.records), vec![b"follow-1".to_vec()]); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_follow_mode_with_count_limit() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-count-limit", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"initial"]).await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Count(3), + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(3)), + }; + + let session = open_read_session(&backend, &basin_name, &stream_name, start, end).await; + let mut session = Box::pin(session); + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + + let append_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(300)).await; + append_payloads( + &backend_clone, + &basin_clone, + &stream_clone, + &[b"follow-1", b"follow-2", b"follow-3"], + ) + .await; + }); + + let all_records = collect_records_until_closed_advanced( + &mut session, + Duration::from_secs(4), + VIRTUAL_TIME_STEP, + ) + .await; + + append_handle.await.unwrap(); + + let bodies = envelope_bodies(&all_records); + assert_eq!( + bodies, + vec![ + b"initial".to_vec(), + b"follow-1".to_vec(), + b"follow-2".to_vec() + ] + ); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_follow_mode_with_exact_count_limit() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-exact-count-limit", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Count(2), + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(2)), + }; + + let session = open_read_session(&backend, &basin_name, &stream_name, start, end).await; + let mut session = Box::pin(session); + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + + let append_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + append_payloads( + &backend_clone, + &basin_clone, + &stream_clone, + &[b"follow-1", b"follow-2"], + ) + .await; + }); + + let all_records = collect_records_until_closed_advanced( + &mut session, + Duration::from_secs(3), + VIRTUAL_TIME_STEP, + ) + .await; + + append_handle.await.unwrap(); + + let bodies = envelope_bodies(&all_records); + assert_eq!(bodies, vec![b"follow-1".to_vec(), b"follow-2".to_vec()]); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_collect_records_until_advanced_stops_at_target_count_with_multi_record_batch() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-target-count", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"seed"]).await; + + let session = open_read_session( + &backend, + &basin_name, + &stream_name, + ReadStart { + from: ReadFrom::TailOffset(0), + clamp: false, + }, + ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(2)), + }, + ) + .await; + let mut session = Box::pin(session); + + expect_heartbeat_advanced(&mut session, Duration::from_secs(1), VIRTUAL_TIME_STEP).await; + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + let append_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + append_payloads( + &backend_clone, + &basin_clone, + &stream_clone, + &[b"follow-1", b"follow-2", b"follow-3"], + ) + .await; + }); + + let records = + collect_records_until_advanced(&mut session, Duration::from_secs(1), 2, VIRTUAL_TIME_STEP) + .await; + + append_handle.await.unwrap(); + + assert_eq!( + envelope_bodies(&records), + vec![b"follow-1".to_vec(), b"follow-2".to_vec()] + ); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_follow_mode_with_bytes_limit_truncates_live_batch() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-bytes-limit", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"item-00"]).await; + + let per_record_bytes = + create_test_record_batch(vec![Bytes::from_static(b"item-00")])[0].metered_size(); + + let session = open_read_session( + &backend, + &basin_name, + &stream_name, + ReadStart { + from: ReadFrom::TailOffset(0), + clamp: false, + }, + ReadEnd { + limit: ReadLimit::Bytes(per_record_bytes * 2), + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(2)), + }, + ) + .await; + let mut session = Box::pin(session); + + expect_heartbeat_advanced(&mut session, Duration::from_secs(1), VIRTUAL_TIME_STEP).await; + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + let append_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + let input = AppendInput { + records: create_test_record_batch(vec![ + Bytes::from_static(b"item-01"), + Bytes::from_static(b"item-02"), + Bytes::from_static(b"item-03"), + ]), + match_seq_num: None, + fencing_token: None, + }; + append(&backend_clone, basin_clone, stream_clone, input, None) + .await + .expect("live append should succeed"); + }); + + let records = collect_records_until_closed_advanced( + &mut session, + Duration::from_secs(3), + VIRTUAL_TIME_STEP, + ) + .await; + + append_handle.await.unwrap(); + + assert_eq!( + envelope_bodies(&records), + vec![b"item-01".to_vec(), b"item-02".to_vec()] + ); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_follow_mode_with_bytes_limit_smaller_than_first_live_record_closes_without_batch() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-bytes-too-small", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_payloads(&backend, &basin_name, &stream_name, &[b"item-00"]).await; + + let per_record_bytes = + create_test_record_batch(vec![Bytes::from_static(b"item-00")])[0].metered_size(); + + let session = open_read_session( + &backend, + &basin_name, + &stream_name, + ReadStart { + from: ReadFrom::TailOffset(0), + clamp: false, + }, + ReadEnd { + limit: ReadLimit::Bytes(per_record_bytes - 1), + until: ReadUntil::Unbounded, + wait: Some(Duration::from_secs(2)), + }, + ) + .await; + let mut session = Box::pin(session); + + expect_heartbeat_advanced(&mut session, Duration::from_secs(1), VIRTUAL_TIME_STEP).await; + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + let append_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + append_payloads(&backend_clone, &basin_clone, &stream_clone, &[b"item-01"]).await; + }); + + let outputs = collect_outputs_until_closed_advanced( + &mut session, + Duration::from_secs(1), + VIRTUAL_TIME_STEP, + ) + .await; + + append_handle.await.unwrap(); + + assert!( + outputs + .outputs + .iter() + .all(|output| matches!(output, ReadSessionOutput::Heartbeat(_))), + "live oversize record should close the session without yielding a batch" + ); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn test_follow_mode_with_timestamp_until() { + let (backend, basin_name, stream_name) = setup_backend_with_stream( + "follow-timestamp-until", + "stream", + OptionalStreamConfig::default(), + ) + .await; + + append_timestamped_payloads( + &backend, + &basin_name, + &stream_name, + vec![(Bytes::from_static(b"initial"), 1000)], + ) + .await; + + let start = ReadStart { + from: ReadFrom::SeqNum(0), + clamp: false, + }; + let end = ReadEnd { + limit: ReadLimit::Unbounded, + until: ReadUntil::Timestamp(2500), + wait: Some(Duration::from_secs(2)), + }; + + let session = open_read_session(&backend, &basin_name, &stream_name, start, end).await; + let mut session = Box::pin(session); + + let backend_clone = backend.clone(); + let basin_clone = basin_name.clone(); + let stream_clone = stream_name.clone(); + + let append_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(300)).await; + append_timestamped_payloads( + &backend_clone, + &basin_clone, + &stream_clone, + vec![(Bytes::from_static(b"before-cutoff"), 2000)], + ) + .await; + + tokio::time::sleep(Duration::from_millis(200)).await; + append_timestamped_payloads( + &backend_clone, + &basin_clone, + &stream_clone, + vec![(Bytes::from_static(b"after-cutoff"), 3000)], + ) + .await; + }); + + let all_records = collect_records_until_closed_advanced( + &mut session, + Duration::from_secs(3), + VIRTUAL_TIME_STEP, + ) + .await; + + append_handle.await.unwrap(); + + let bodies = envelope_bodies(&all_records); + assert_eq!(bodies, vec![b"initial".to_vec(), b"before-cutoff".to_vec()]); +} diff --git a/lite/tests/backend/mod.rs b/lite/tests/backend/mod.rs new file mode 100644 index 00000000..eee8411f --- /dev/null +++ b/lite/tests/backend/mod.rs @@ -0,0 +1,3 @@ +mod common; +mod control_plane; +mod data_plane; diff --git a/lite/tests/backend_tests.rs b/lite/tests/backend_tests.rs new file mode 100644 index 00000000..cdf5dd7d --- /dev/null +++ b/lite/tests/backend_tests.rs @@ -0,0 +1 @@ +mod backend; diff --git a/release-plz.toml b/release-plz.toml new file mode 100644 index 00000000..b042fcf3 --- /dev/null +++ b/release-plz.toml @@ -0,0 +1,34 @@ +[workspace] +changelog_config = "cliff.toml" +git_tag_enable = true +git_tag_name = "{{ package }}-v{{ version }}" +git_release_enable = true +pr_labels = ["release"] + +[[package]] +name = "s2-api" +git_release_enable = false + +[[package]] +name = "s2-common" +git_release_enable = false + +[[package]] +name = "s2-resource-spec" +git_release_enable = false + +[[package]] +name = "s2-storage" +git_release_enable = false + +[[package]] +name = "s2-cli" +version_group = "s2" + +[[package]] +name = "s2-lite" +version_group = "s2" + +[[package]] +name = "s2-testcontainers" +version_group = "s2" diff --git a/resource-spec/CHANGELOG.md b/resource-spec/CHANGELOG.md new file mode 100644 index 00000000..d5368b6d --- /dev/null +++ b/resource-spec/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.3.0] - 2026-09-25 + +### Features + +- [**breaking**] Expose storage classes as strings and in location responses ([#775](https://github.com/s2-streamstore/s2/issues/775)) + + + +## [0.2.2] - 2026-07-07 + +### Miscellaneous Tasks + +- Updated the following local packages: s2-common + + + +## [0.2.1] - 2026-07-06 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.2.0] - 2026-07-02 + +### Miscellaneous Tasks + +- Remove unused RetentionPolicy::age_secs method ([#597](https://github.com/s2-streamstore/s2/issues/597)) + + + +## [0.1.2] - 2026-06-22 + +### Miscellaneous Tasks + +- Updated the following local packages: s2-common + + + +## [0.1.1] - 2026-06-15 + +### Bug Fixes + +- Reject zero retention in resource specs ([#544](https://github.com/s2-streamstore/s2/issues/544)) + + + +## [0.1.0] - 2026-06-12 + +### Refactor + +- Split storage internals from common ([#533](https://github.com/s2-streamstore/s2/issues/533)) + + +# Changelog + +All notable changes to this project will be documented in this file. diff --git a/resource-spec/Cargo.toml b/resource-spec/Cargo.toml new file mode 100644 index 00000000..4c5816c3 --- /dev/null +++ b/resource-spec/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "s2-resource-spec" +version = "0.3.0" +description = "Declarative resource specifications for S2" +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords = ["s2", "durable", "streams", "config", "schema"] + +[dependencies] +compact_str = { workspace = true, features = ["serde"] } +humantime = { workspace = true } +s2-common = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + diff --git a/resource-spec/src/lib.rs b/resource-spec/src/lib.rs new file mode 100644 index 00000000..042edd6c --- /dev/null +++ b/resource-spec/src/lib.rs @@ -0,0 +1,602 @@ +//! Declarative basin/stream resource spec shared by CLI apply and lite init files. + +use std::{borrow::Cow, time::Duration}; + +use compact_str::CompactString; +use s2_common::{basin::BasinName, stream::StreamName}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Default, schemars::JsonSchema)] +pub struct Resources { + #[serde(default)] + pub basins: Vec, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Basin { + #[schemars(with = "String")] + pub name: BasinName, + #[serde(default)] + pub config: Option, + #[serde(default)] + pub streams: Vec, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Stream { + #[schemars(with = "String")] + pub name: StreamName, + #[serde(default)] + pub config: Option, +} + +#[derive(Debug, Clone, Deserialize, Default, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct BasinConfig { + #[serde(default)] + pub default_stream_config: Option, + /// Encryption algorithm to apply to newly created streams in the basin. + #[serde(default)] + pub stream_cipher: Option, + /// Create stream on append if it doesn't exist, using the default stream configuration. + #[serde(default)] + pub create_stream_on_append: Option, + /// Create stream on read if it doesn't exist, using the default stream configuration. + #[serde(default)] + pub create_stream_on_read: Option, +} + +#[derive(Debug, Clone, Deserialize, Default, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct StreamConfig { + /// [Storage class](https://s2.dev/docs/storage-classes) for recent writes. + #[serde(default)] + #[schemars(with = "Option")] + pub storage_class: Option, + /// Retention policy for the stream. If unspecified, the default is to retain records for 7 + /// days. + #[serde(default)] + pub retention_policy: Option, + /// Timestamping behavior. + #[serde(default)] + pub timestamping: Option, + /// Delete-on-empty configuration. + #[serde(default)] + pub delete_on_empty: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub enum EncryptionAlgorithm { + #[serde(rename = "aegis-256")] + Aegis256, + #[serde(rename = "aes-256-gcm")] + Aes256Gcm, +} + +impl schemars::JsonSchema for EncryptionAlgorithm { + fn schema_name() -> Cow<'static, str> { + "EncryptionAlgorithm".into() + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "description": "Encryption algorithm to apply to newly created streams in the basin.", + "enum": ["aegis-256", "aes-256-gcm"] + }) + } +} + +impl From for s2_common::encryption::EncryptionAlgorithm { + fn from(m: EncryptionAlgorithm) -> Self { + match m { + EncryptionAlgorithm::Aegis256 => Self::Aegis256, + EncryptionAlgorithm::Aes256Gcm => Self::Aes256Gcm, + } + } +} + +/// Accepts `"infinite"` or a humantime duration string such as `"7d"`, `"1w"`. +#[derive(Debug, Clone, Copy)] +pub struct RetentionPolicy(pub s2_common::config::RetentionPolicy); + +impl TryFrom for RetentionPolicy { + type Error = String; + + fn try_from(s: String) -> Result { + if s.eq_ignore_ascii_case("infinite") { + return Ok(RetentionPolicy( + s2_common::config::RetentionPolicy::Infinite(), + )); + } + let d = humantime::parse_duration(&s) + .map_err(|e| format!("invalid retention_policy {:?}: {}", s, e))?; + Ok(RetentionPolicy(s2_common::config::RetentionPolicy::Age(d))) + } +} + +impl<'de> Deserialize<'de> for RetentionPolicy { + fn deserialize>(d: D) -> Result { + let s = String::deserialize(d)?; + RetentionPolicy::try_from(s).map_err(serde::de::Error::custom) + } +} + +impl schemars::JsonSchema for RetentionPolicy { + fn schema_name() -> Cow<'static, str> { + "RetentionPolicy".into() + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "description": "Retain records unless explicitly trimmed (\"infinite\"), or automatically \ + trim records older than the given duration (e.g. \"7days\", \"1week\"). \ + Age durations must be greater than 0 seconds.", + "examples": ["infinite", "7days", "1week"] + }) + } +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Timestamping { + /// Timestamping mode for appends that influences how timestamps are handled. + #[serde(default)] + pub mode: Option, + /// Allow client-specified timestamps to exceed the arrival time. + /// If this is `false` or not set, client timestamps will be capped at the arrival time. + #[serde(default)] + pub uncapped: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum TimestampingMode { + ClientPrefer, + ClientRequire, + Arrival, +} + +impl schemars::JsonSchema for TimestampingMode { + fn schema_name() -> Cow<'static, str> { + "TimestampingMode".into() + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "description": "Timestamping mode for appends that influences how timestamps are handled.", + "enum": ["client-prefer", "client-require", "arrival"] + }) + } +} + +impl From for s2_common::config::TimestampingMode { + fn from(m: TimestampingMode) -> Self { + match m { + TimestampingMode::ClientPrefer => Self::ClientPrefer, + TimestampingMode::ClientRequire => Self::ClientRequire, + TimestampingMode::Arrival => Self::Arrival, + } + } +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DeleteOnEmpty { + /// Minimum age before an empty stream can be deleted. + /// Set to 0 (default) to disable delete-on-empty (don't delete automatically). + #[serde(default)] + pub min_age: Option, +} + +/// A `std::time::Duration` deserialized from a humantime string (e.g. `"1d"`, `"2h 30m"`). +#[derive(Debug, Clone, Copy)] +pub struct HumanDuration(pub Duration); + +impl TryFrom for HumanDuration { + type Error = String; + + fn try_from(s: String) -> Result { + humantime::parse_duration(&s) + .map(HumanDuration) + .map_err(|e| format!("invalid duration {:?}: {}", s, e)) + } +} + +impl<'de> Deserialize<'de> for HumanDuration { + fn deserialize>(d: D) -> Result { + let s = String::deserialize(d)?; + HumanDuration::try_from(s).map_err(serde::de::Error::custom) + } +} + +impl schemars::JsonSchema for HumanDuration { + fn schema_name() -> Cow<'static, str> { + "HumanDuration".into() + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "description": "A duration string in humantime format, e.g. \"1day\", \"2h 30m\"", + "examples": ["1day", "2h 30m"] + }) + } +} + +impl From for s2_common::config::BasinConfig { + fn from(s: BasinConfig) -> Self { + Self { + default_stream_config: s.default_stream_config.map(Into::into).unwrap_or_default(), + stream_cipher: s.stream_cipher.map(Into::into), + create_stream_on_append: s.create_stream_on_append.unwrap_or_default(), + create_stream_on_read: s.create_stream_on_read.unwrap_or_default(), + } + } +} + +impl From for s2_common::config::OptionalTimestampingConfig { + fn from(s: Timestamping) -> Self { + Self { + mode: s.mode.map(Into::into), + uncapped: s.uncapped, + } + } +} + +impl From for s2_common::config::OptionalDeleteOnEmptyConfig { + fn from(s: DeleteOnEmpty) -> Self { + Self { + min_age: s.min_age.map(|h| h.0), + } + } +} + +impl From for s2_common::config::OptionalStreamConfig { + fn from(s: StreamConfig) -> Self { + Self { + storage_class: s.storage_class, + retention_policy: s.retention_policy.map(|rp| rp.0), + timestamping: s.timestamping.map(Into::into).unwrap_or_default(), + delete_on_empty: s.delete_on_empty.map(Into::into).unwrap_or_default(), + } + } +} + +pub fn json_schema() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(Resources)).unwrap() +} + +pub fn validate(spec: &Resources) -> Result<(), String> { + let mut errors = Vec::new(); + let mut seen_basins = std::collections::HashSet::new(); + + for basin_spec in &spec.basins { + if !seen_basins.insert(basin_spec.name.clone()) { + errors.push(format!("duplicate basin name {:?}", basin_spec.name)); + } + + if let Some(default_stream_config) = basin_spec + .config + .as_ref() + .and_then(|config| config.default_stream_config.as_ref()) + { + validate_stream_config( + default_stream_config, + &format!("basin {:?} default_stream_config", basin_spec.name.as_ref()), + &mut errors, + ); + } + + let mut seen_streams = std::collections::HashSet::new(); + for stream_spec in &basin_spec.streams { + if !seen_streams.insert(stream_spec.name.clone()) { + errors.push(format!( + "duplicate stream name {:?} in basin {:?}", + stream_spec.name, basin_spec.name + )); + } + + if let Some(config) = stream_spec.config.as_ref() { + validate_stream_config( + config, + &format!( + "stream {:?} in basin {:?}", + stream_spec.name.as_ref(), + basin_spec.name.as_ref() + ), + &mut errors, + ); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("\n")) + } +} + +fn validate_stream_config(config: &StreamConfig, context: &str, errors: &mut Vec) { + if let Some(retention_policy) = config.retention_policy + && let Err(err) = retention_policy.0.validate() + { + errors.push(format!("{context}: {err}")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_spec(json: &str) -> Resources { + serde_json::from_str(json).expect("valid JSON") + } + + fn parse_spec_err(json: &str) -> serde_json::Error { + serde_json::from_str::(json).expect_err("invalid resource spec") + } + + #[test] + fn empty_spec() { + let spec = parse_spec("{}"); + assert!(spec.basins.is_empty()); + } + + #[test] + fn basin_no_config() { + let spec = parse_spec(r#"{"basins":[{"name":"my-basin"}]}"#); + assert_eq!(spec.basins.len(), 1); + assert_eq!(spec.basins[0].name.as_ref(), "my-basin"); + assert!(spec.basins[0].config.is_none()); + assert!(spec.basins[0].streams.is_empty()); + } + + #[test] + fn retention_policy_infinite() { + let rp: RetentionPolicy = serde_json::from_str(r#""infinite""#).expect("deserialize"); + assert!(matches!( + rp.0, + s2_common::config::RetentionPolicy::Infinite() + )); + } + + #[test] + fn retention_policy_duration() { + let rp: RetentionPolicy = serde_json::from_str(r#""7days""#).expect("deserialize"); + assert!(matches!(rp.0, s2_common::config::RetentionPolicy::Age(_))); + if let s2_common::config::RetentionPolicy::Age(d) = rp.0 { + assert_eq!(d, Duration::from_secs(7 * 24 * 3600)); + } + } + + #[test] + fn retention_policy_invalid() { + let err = serde_json::from_str::(r#""not-a-duration""#); + assert!(err.is_err()); + } + + #[test] + fn human_duration() { + let hd: HumanDuration = serde_json::from_str(r#""1day""#).expect("deserialize"); + assert_eq!(hd.0, Duration::from_secs(86400)); + } + + #[test] + fn full_spec_roundtrip() { + let json = r#" + { + "basins": [ + { + "name": "my-basin", + "config": { + "create_stream_on_append": true, + "create_stream_on_read": false, + "default_stream_config": { + "storage_class": "express", + "retention_policy": "7days", + "timestamping": { + "mode": "client-prefer", + "uncapped": false + }, + "delete_on_empty": { + "min_age": "1day" + } + } + }, + "streams": [ + { + "name": "events", + "config": { + "storage_class": "standard", + "retention_policy": "infinite" + } + } + ] + } + ] + }"#; + + let spec = parse_spec(json); + assert_eq!(spec.basins.len(), 1); + let basin = &spec.basins[0]; + assert_eq!(basin.name.as_ref(), "my-basin"); + + let config = basin.config.as_ref().unwrap(); + assert_eq!(config.create_stream_on_append, Some(true)); + assert_eq!(config.create_stream_on_read, Some(false)); + + let dsc = config.default_stream_config.as_ref().unwrap(); + assert_eq!(dsc.storage_class.as_deref(), Some("express")); + assert!(matches!( + dsc.retention_policy.as_ref().map(|r| &r.0), + Some(s2_common::config::RetentionPolicy::Age(_)) + )); + + let ts = dsc.timestamping.as_ref().unwrap(); + assert!(matches!(ts.mode, Some(TimestampingMode::ClientPrefer))); + assert_eq!(ts.uncapped, Some(false)); + + let doe = dsc.delete_on_empty.as_ref().unwrap(); + assert_eq!( + doe.min_age.as_ref().map(|h| h.0), + Some(Duration::from_secs(86400)) + ); + + assert_eq!(basin.streams.len(), 1); + let stream = &basin.streams[0]; + assert_eq!(stream.name.as_ref(), "events"); + let sc = stream.config.as_ref().unwrap(); + assert_eq!(sc.storage_class.as_deref(), Some("standard")); + assert!(matches!( + sc.retention_policy.as_ref().map(|r| &r.0), + Some(s2_common::config::RetentionPolicy::Infinite()) + )); + } + + #[test] + fn basin_config_conversion() { + let spec = BasinConfig { + default_stream_config: None, + stream_cipher: None, + create_stream_on_append: Some(true), + create_stream_on_read: None, + }; + let config = s2_common::config::BasinConfig::from(spec); + assert!(config.create_stream_on_append); + assert!(!config.create_stream_on_read); + assert_eq!( + config.default_stream_config, + s2_common::config::OptionalStreamConfig::default() + ); + } + + #[test] + fn validate_valid_spec() { + let spec = parse_spec( + r#"{"basins":[{"name":"my-basin","streams":[{"name":"events"},{"name":"logs"}]}]}"#, + ); + assert!(validate(&spec).is_ok()); + } + + #[test] + fn validate_rejects_zero_retention_policy_in_basin_default_stream_config() { + let spec = parse_spec( + r#"{"basins":[{"name":"my-basin","config":{"default_stream_config":{"retention_policy":"0s"}}}]}"#, + ); + let err = validate(&spec).unwrap_err(); + assert!(err.contains("basin \"my-basin\" default_stream_config")); + assert!(err.contains("age must be greater than 0 seconds")); + } + + #[test] + fn validate_rejects_zero_retention_policy_in_stream_config() { + let spec = parse_spec( + r#"{"basins":[{"name":"my-basin","streams":[{"name":"events","config":{"retention_policy":"0s"}}]}]}"#, + ); + let err = validate(&spec).unwrap_err(); + assert!(err.contains("stream \"events\" in basin \"my-basin\"")); + assert!(err.contains("age must be greater than 0 seconds")); + } + + #[test] + fn deserialize_invalid_basin_name() { + let err = parse_spec_err(r#"{"basins":[{"name":"INVALID_BASIN"}]}"#); + assert!(err.to_string().contains("basin name")); + } + + #[test] + fn deserialize_invalid_stream_name() { + let err = parse_spec_err(r#"{"basins":[{"name":"my-basin","streams":[{"name":""}]}]}"#); + assert!(err.to_string().contains("stream name")); + } + + #[test] + fn validate_duplicate_basin_names() { + let spec = parse_spec(r#"{"basins":[{"name":"my-basin"},{"name":"my-basin"}]}"#); + let err = validate(&spec).unwrap_err(); + assert!(err.to_string().contains("duplicate basin name")); + } + + #[test] + fn validate_duplicate_stream_names() { + let spec = parse_spec( + r#"{"basins":[{"name":"my-basin","streams":[{"name":"events"},{"name":"events"}]}]}"#, + ); + let err = validate(&spec).unwrap_err(); + assert!(err.to_string().contains("duplicate stream name")); + } + + #[test] + fn validate_multiple_errors() { + let spec = parse_spec( + r#"{"basins":[{"name":"my-basin","streams":[{"name":"events"},{"name":"events"}]},{"name":"my-basin"}]}"#, + ); + let err = validate(&spec).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("duplicate basin name")); + assert!(msg.contains("duplicate stream name")); + } + + #[test] + fn json_schema_is_valid() { + let schema = json_schema(); + assert!(schema.is_object()); + let schema_obj = schema.as_object().unwrap(); + + // using the default generated + assert_eq!( + schema_obj.get("$schema"), + Some(&serde_json::Value::String( + "https://json-schema.org/draft/2020-12/schema".to_string() + )) + ); + + assert!( + schema_obj.contains_key("properties"), + "schema should have root properties" + ); + + assert!( + schema_obj.contains_key("$defs"), + "schema should have $defs for reusable definitions" + ); + + let properties = schema_obj.get("properties").unwrap().as_object().unwrap(); + assert!( + properties.contains_key("basins"), + "schema should include the `basins` property" + ); + } + + #[test] + fn stream_config_conversion() { + let spec = StreamConfig { + storage_class: Some("standard".into()), + retention_policy: Some(RetentionPolicy( + s2_common::config::RetentionPolicy::Infinite(), + )), + timestamping: None, + delete_on_empty: None, + }; + let config = s2_common::config::OptionalStreamConfig::from(spec); + assert_eq!(config.storage_class, Some("standard".into())); + assert_eq!( + config.retention_policy, + Some(s2_common::config::RetentionPolicy::Infinite()) + ); + assert_eq!( + config.timestamping, + s2_common::config::OptionalTimestampingConfig::default() + ); + assert_eq!( + config.delete_on_empty, + s2_common::config::OptionalDeleteOnEmptyConfig::default() + ); + } +} diff --git a/sdk/CHANGELOG.md b/sdk/CHANGELOG.md new file mode 100644 index 00000000..2268ccd6 --- /dev/null +++ b/sdk/CHANGELOG.md @@ -0,0 +1,973 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.35.1] - 2026-09-28 + +### Features + +- Surface http2 knobs relevant to flow control ([#776](https://github.com/s2-streamstore/s2/issues/776)) + + + +## [0.35.0] - 2026-09-25 + +### Features + +- [**breaking**] Expose storage classes as strings and in location responses ([#775](https://github.com/s2-streamstore/s2/issues/775)) + + + +## [0.34.10] - 2026-09-24 + +### Bug Fixes + +- Preserve uncertainty across append retries ([#767](https://github.com/s2-streamstore/s2/issues/767)) + + + +## [0.34.9] - 2026-09-22 + + + +## [0.34.8] - 2026-09-16 + +### Bug Fixes + +- Reject Content-Type in default headers ([#740](https://github.com/s2-streamstore/s2/issues/740)) + + + +## [0.34.7] - 2026-09-11 + +### Features + +- `s2-stream-config` header for auto-created streams ([#718](https://github.com/s2-streamstore/s2/issues/718)) + + + +## [0.34.6] - 2026-09-11 + +### Features + +- Set default request headers (`_hidden` only) ([#731](https://github.com/s2-streamstore/s2/issues/731)) + + + +## [0.34.5] - 2026-09-10 + +### Bug Fixes + +- Reject NUL bytes in stream names and access token IDs ([#728](https://github.com/s2-streamstore/s2/issues/728)) + + + +## [0.34.4] - 2026-09-01 + +### Features + +- Act on s2s reconnect advice in append and read sessions ([#703](https://github.com/s2-streamstore/s2/issues/703)) + +### Bug Fixes + +- Require http2 ([#710](https://github.com/s2-streamstore/s2/issues/710)) +- Classify h2 REFUSED_STREAM as retryable ([#716](https://github.com/s2-streamstore/s2/issues/716)) + + + +## [0.34.3] - 2026-08-13 + +### Features + +- Add durable producer flush ([#698](https://github.com/s2-streamstore/s2/issues/698)) + + + +## [0.34.2] - 2026-08-05 + +### Features + +- Support refreshable access token providers ([#682](https://github.com/s2-streamstore/s2/issues/682)) + + + +## [0.34.1] - 2026-08-01 + +### Features + +- Expose fixture constructors ([#681](https://github.com/s2-streamstore/s2/issues/681)) +- Expose read session resume sequence number ([#680](https://github.com/s2-streamstore/s2/issues/680)) + + + +## [0.34.0] - 2026-07-31 + +### Features + +- [**breaking**] Replace S2Error with surface-specific errors ([#653](https://github.com/s2-streamstore/s2/issues/653)) +- [**breaking**] Support indefinite read session retries ([#658](https://github.com/s2-streamstore/s2/issues/658)) + + + +## [0.33.0] - 2026-07-28 + +### Features + +- Ergonomic endpoint constructors ([#654](https://github.com/s2-streamstore/s2/issues/654)) +- [**breaking**] Eager batching via AppendRecordBatches::from_iter, canonical BatchLimits ([#655](https://github.com/s2-streamstore/s2/issues/655)) + + + +## [0.32.1] - 2026-07-23 + +### Features + +- Add resource diff command ([#649](https://github.com/s2-streamstore/s2/issues/649)) + + + +## [0.32.0] - 2026-07-22 + +### Features + +- [**breaking**] Expose caught-up-to-tail signal on read sessions ([#650](https://github.com/s2-streamstore/s2/issues/650)) + +### Miscellaneous Tasks + +- Upgrade dependencies ([#656](https://github.com/s2-streamstore/s2/issues/656)) + + + +## [0.31.10] - 2026-07-16 + +### Bug Fixes + +- Make provision_stream exists-outcomes durably visible ([#641](https://github.com/s2-streamstore/s2/issues/641)) + + + +## [0.31.9] - 2026-07-07 + +### Features + +- Default account endpoint aws.s2.dev -> a.s2.dev (+ openapi servers URL) ([#620](https://github.com/s2-streamstore/s2/issues/620)) + +### Performance + +- Enable `TCP_NODELAY` to reduce ack latency for small unary appends ([#630](https://github.com/s2-streamstore/s2/issues/630)) + + + +## [0.31.8] - 2026-07-02 + +### Documentation + +- Add encryption examples for central docs site ([#590](https://github.com/s2-streamstore/s2/issues/590)) +- Add S2_ENCRYPTION_KEY to SDK examples env vars ([#600](https://github.com/s2-streamstore/s2/issues/600)) + +### Miscellaneous Tasks + +- Dep upgrades ([#582](https://github.com/s2-streamstore/s2/issues/582)) +- Remove unused Error::is_connect method from sdk client ([#606](https://github.com/s2-streamstore/s2/issues/606)) + + + +## [0.31.7] - 2026-06-22 + +### Features + +- Make access token expiry semantics explicit ([#574](https://github.com/s2-streamstore/s2/issues/574)) + + + +## [0.31.6] - 2026-06-15 + +### Features + +- Add s2-testcontainers crate ([#551](https://github.com/s2-streamstore/s2/issues/551)) + +### Bug Fixes + +- Narrow futures deps in sdk and api ([#548](https://github.com/s2-streamstore/s2/issues/548)) +- Remove sdk url dependency ([#549](https://github.com/s2-streamstore/s2/issues/549)) + + + +## [0.31.5] - 2026-06-12 + +### Bug Fixes + +- Only show token-source guidance for token-related SDK init failures ([#538](https://github.com/s2-streamstore/s2/issues/538)) + +### Refactor + +- Split storage internals from common ([#533](https://github.com/s2-streamstore/s2/issues/533)) + +### Testing + +- Add unit tests for low-coverage modules ([#515](https://github.com/s2-streamstore/s2/issues/515)) + + + +## [0.31.4] - 2026-06-11 + +### Bug Fixes + +- Roll back hyper to 1.9.0 ([#531](https://github.com/s2-streamstore/s2/issues/531)) + + + +## [0.31.3] - 2026-06-10 + +### Features + +- Allow list cursors before prefix ([#448](https://github.com/s2-streamstore/s2/issues/448)) + +### Testing + +- Remove start_after < prefix tests from Rust SDK ([#529](https://github.com/s2-streamstore/s2/issues/529)) + + + +## [0.31.2] - 2026-06-02 + +### Bug Fixes + +- Inconsistent ack deadline semantics in append session ([#498](https://github.com/s2-streamstore/s2/issues/498)) + + + +## [0.31.1] - 2026-05-29 + +### Documentation + +- Remove redundant doc comment for `Encryption*` re-exports ([#489](https://github.com/s2-streamstore/s2/issues/489)) + + + +## [0.31.0] - 2026-05-20 + +### Bug Fixes + +- Producer returns terminal err without draining ready acks ([#483](https://github.com/s2-streamstore/s2/issues/483)) +- [**breaking**] Reorder enum variants to preserve original discriminants ([#487](https://github.com/s2-streamstore/s2/issues/487)) + +### Refactor + +- [**breaking**] Rename scope -> location, treat it as a string; add related RPCs ([#485](https://github.com/s2-streamstore/s2/issues/485)) + + + +## [0.30.0] - 2026-05-20 + +### Features + +- Support configurable rustls crypto provider ([#481](https://github.com/s2-streamstore/s2/issues/481)) + + + +## [0.29.3] - 2026-05-19 + +### Features + +- Expose `ensure_*` ops ([#471](https://github.com/s2-streamstore/s2/issues/471)) + +### Bug Fixes + +- Panic on invariant checks involving acks from server ([#473](https://github.com/s2-streamstore/s2/issues/473)) + + + +## [0.29.2] - 2026-05-15 + +### Bug Fixes + +- Preserve explicit optional config values ([#459](https://github.com/s2-streamstore/s2/issues/459)) + + + +## [0.29.1] - 2026-05-14 + +### Bug Fixes + +- Clarify PUT ensure semantics ([#450](https://github.com/s2-streamstore/s2/issues/450)) + + + +## [0.29.0] - 2026-05-10 + +### Features + +- [**breaking**] Mark s2_sdk::BasinScope as #[non_exhaustive] ([#433](https://github.com/s2-streamstore/s2/issues/433)) + + + +## [0.28.0] - 2026-05-04 + +### Features + +- Add aws:us-west-2 and aws:eu-north-1 basin scopes ([#430](https://github.com/s2-streamstore/s2/issues/430)) + + + +## [0.27.1] - 2026-04-22 + +### Miscellaneous Tasks + +- Updated the following local packages: s2-common + + + +## [0.27.0] - 2026-04-20 + +### Refactor + +- [**breaking**] Replace encryption modes with stream cipher metadata and key-only headers ([#403](https://github.com/s2-streamstore/s2/issues/403)) + + + +## [0.26.2] - 2026-04-15 + +### Miscellaneous Tasks + +- Dep updates ([#391](https://github.com/s2-streamstore/s2/issues/391)) + + + +## [0.26.1] - 2026-04-14 + +### Features + +- Request-time data encryption ([#349](https://github.com/s2-streamstore/s2/issues/349)) +- Enforce allowed encryption modes via stream config ([#376](https://github.com/s2-streamstore/s2/issues/376)) + +### Bug Fixes + +- Missing validation for empty buffer in S2S EOS case ([#384](https://github.com/s2-streamstore/s2/issues/384)) +- Incorrect tracking of limits after client-side filtering ([#387](https://github.com/s2-streamstore/s2/issues/387)) +- Last seen tail not updated when heartbeat batches are received ([#388](https://github.com/s2-streamstore/s2/issues/388)) + +### Refactor + +- Clarify encryption spec, mode, and format semantics ([#375](https://github.com/s2-streamstore/s2/issues/375)) + +### Documentation + +- Update examples for centralized SDK docs ([#368](https://github.com/s2-streamstore/s2/issues/368)) + +### Miscellaneous Tasks + +- Update basin endpoint from b.aws.s2.dev to b.s2.dev ([#346](https://github.com/s2-streamstore/s2/issues/346)) + + + +## [0.26.0] - 2026-03-20 + +### Features + +- Align basin info with stream info ([#338](https://github.com/s2-streamstore/s2/issues/338)) + + + +## [0.25.0] - 2026-03-19 + +### Refactor + +- Remove basin creating state ([#333](https://github.com/s2-streamstore/s2/issues/333)) + + + +## [0.24.8] - 2026-03-06 + +### Bug Fixes + +- Compression and FrameSignal ordering ([#313](https://github.com/s2-streamstore/s2/issues/313)) + + + +## [0.24.7] - 2026-03-06 + +### Miscellaneous Tasks + +- Dep updates ([#299](https://github.com/s2-streamstore/s2/issues/299)) + + + +## [0.24.6] - 2026-03-04 + +### Bug Fixes + +- New decision tree for append retry safety ([#226](https://github.com/s2-streamstore/s2/issues/226)) + + + +## [0.24.5] - 2026-03-03 + +### Bug Fixes + +- Surface DNS resolution details in connect errors ([#290](https://github.com/s2-streamstore/s2/issues/290)) + + + +## [0.24.4] - 2026-02-25 + +### Bug Fixes + +- Premature unary read long polling timeout ([#256](https://github.com/s2-streamstore/s2/issues/256)) +- Panic in retry delay calculation ([#257](https://github.com/s2-streamstore/s2/issues/257)) +- Unnecessary batching linger in `Producer::close` ([#259](https://github.com/s2-streamstore/s2/issues/259)) +- Unlikely panic in append session resend flow ([#260](https://github.com/s2-streamstore/s2/issues/260)) + + + +## [0.24.3] - 2026-02-24 + +### Features + +- Support creating resources from spec ([#239](https://github.com/s2-streamstore/s2/issues/239)) + + + +## [0.24.2] - 2026-02-16 + +### Documentation + +- Use abs links in README for crates.io page ([#219](https://github.com/s2-streamstore/s2/issues/219)) + + + +## [0.24.1] - 2026-02-16 + +### Miscellaneous Tasks + +- Move `s2-sdk` into `s2` monorepo ([#217](https://github.com/s2-streamstore/s2/issues/217)) + + + +## [0.24.0] - 2026-02-15 + +### Features + +- Add accessors for `AppendRecord` ([#305](https://github.com/s2-streamstore/s2-sdk-rust/issues/305)) +- [**breaking**] Add lower bounds for `max_batch_bytes` and `max_batch_records` ([#309](https://github.com/s2-streamstore/s2-sdk-rust/issues/309)) +- [**breaking**] Reduce default `max_unacked_bytes` to `5MiB` ([#311](https://github.com/s2-streamstore/s2-sdk-rust/issues/311)) + +### Refactor + +- Replace `reqwest` with `hyper-util` and add client pooling ([#298](https://github.com/s2-streamstore/s2-sdk-rust/issues/298)) +- [**breaking**] Make `idempotency_token` private ([#306](https://github.com/s2-streamstore/s2-sdk-rust/issues/306)) +- [**breaking**] Remove unnecessary `Result` from `with_max_unacked_batches` ([#307](https://github.com/s2-streamstore/s2-sdk-rust/issues/307)) +- Remove unnecessary compression for GET and DELETE requests ([#308](https://github.com/s2-streamstore/s2-sdk-rust/issues/308)) +- Rename fields, methods, and vars related to `RetryBackoff` ([#310](https://github.com/s2-streamstore/s2-sdk-rust/issues/310)) +- [**breaking**] Make `S2DateTime` conversion from `time::OffsetDateTime` fallible ([#312](https://github.com/s2-streamstore/s2-sdk-rust/issues/312)) + +### Testing + +- Metrics ([#297](https://github.com/s2-streamstore/s2-sdk-rust/issues/297)) +- Basin & stream api ([#300](https://github.com/s2-streamstore/s2-sdk-rust/issues/300)) + +### Miscellaneous Tasks + +- Bump dependencies ([#296](https://github.com/s2-streamstore/s2-sdk-rust/issues/296)) +- Dep updates ([#314](https://github.com/s2-streamstore/s2-sdk-rust/issues/314)) + + + +## [0.23.8] - 2026-02-07 + +### Bug Fixes + +- Deplete read session wait budget on retry ([#293](https://github.com/s2-streamstore/s2-sdk-rust/issues/293)) +- Io errors nested in hyper request errors not classified as retryable ([#295](https://github.com/s2-streamstore/s2-sdk-rust/issues/295)) + + + +## [0.23.7] - 2026-02-05 + +### Miscellaneous Tasks + +- Relax version constraint for `s2-api` and `s2-common` ([#291](https://github.com/s2-streamstore/s2-sdk-rust/issues/291)) + + + +## [0.23.6] - 2026-02-04 + +### Bug Fixes + +- Incorrect condition for retry on 409 ([#288](https://github.com/s2-streamstore/s2-sdk-rust/issues/288)) + +### Miscellaneous Tasks + +- Update dependencies ([#290](https://github.com/s2-streamstore/s2-sdk-rust/issues/290)) + + + +## [0.23.5] - 2026-02-02 + +### Bug Fixes + +- Violation of oneof constraint in `ReadStart` during read session retry ([#286](https://github.com/s2-streamstore/s2-sdk-rust/issues/286)) + + + +## [0.23.4] - 2026-01-31 + +### Miscellaneous Tasks + +- Release workflow naming ([#279](https://github.com/s2-streamstore/s2-sdk-rust/issues/279)) +- Dependency upgrades ([#285](https://github.com/s2-streamstore/s2-sdk-rust/issues/285)) + + + +## [0.23.3] - 2026-01-27 + +### Bug Fixes + +- Explicitly enable git tagging and releases in release-plz ([#276](https://github.com/s2-streamstore/s2-sdk-rust/issues/276)) +- Rename S2_INSECURE to S2_SSL_NO_VERIFY ([#278](https://github.com/s2-streamstore/s2-sdk-rust/issues/278)) + + + +## [0.23.2] - 2026-01-26 + +### Bug Fixes + +- Have release-plz publish to crates.io directly ([#273](https://github.com/s2-streamstore/s2-sdk-rust/issues/273)) + + + +## [0.23.1] - 2026-01-26 + +### Features + +- SSL cert insecure mode ([#272](https://github.com/s2-streamstore/s2-sdk-rust/issues/272)) + +### Miscellaneous Tasks + +- Automate releases with release-plz ([#268](https://github.com/s2-streamstore/s2-sdk-rust/issues/268)) +- Add dependabot for weekly dependency updates ([#269](https://github.com/s2-streamstore/s2-sdk-rust/issues/269)) + + + +## [0.23.0] - 2026-01-21 + +### Refactor + +- [**breaking**] Make `S2Endpoints::new` accept `AccountEndpoint` and `BasinEndpoint` ([#264](https://github.com/s2-streamstore/s2-sdk-rust/issues/264)) +- [**breaking**] Rename `max_inflight_*` to `max_unacked_*` in `AppendSessionConfig` ([#265](https://github.com/s2-streamstore/s2-sdk-rust/issues/265)) +- [**breaking**] Rename `ignore_pending_deletions` to `include_deleted` in `ListAll*Input`s ([#266](https://github.com/s2-streamstore/s2-sdk-rust/issues/266)) + +## [0.22.5] - 2026-01-20 + +### Miscellaneous Tasks + +- SDK documentation examples + change to pagination defaults ([#262](https://github.com/s2-streamstore/s2-sdk-rust/issues/262)) + +## [0.22.4] - 2026-01-18 + +### Bug Fixes + +- Terminal error propagation in append session and producer ([#259](https://github.com/s2-streamstore/s2-sdk-rust/issues/259)) + +### Miscellaneous Tasks + +- Bump `s2-api` and `s2-common` versions ([#260](https://github.com/s2-streamstore/s2-sdk-rust/issues/260)) + +## [0.22.3] - 2026-01-18 + +### Bug Fixes + +- Racy error propagation in append session ([#257](https://github.com/s2-streamstore/s2-sdk-rust/issues/257)) + +### Miscellaneous Tasks + +- Run tests against s2-lite ([#249](https://github.com/s2-streamstore/s2-sdk-rust/issues/249)) +- Update s2-lite integration action ([#256](https://github.com/s2-streamstore/s2-sdk-rust/issues/256)) + +## [0.22.2] - 2026-01-15 + +### Bug Fixes + +- Missing `CONTENT_ENCODING` header for unary requests ([#247](https://github.com/s2-streamstore/s2-sdk-rust/issues/247)) + +## [0.22.1] - 2026-01-14 + +### Miscellaneous Tasks + +- Add `parse_from` to `S2Endpoints` for internal use ([#245](https://github.com/s2-streamstore/s2-sdk-rust/issues/245)) + +## [0.22.0] - 2026-01-14 + +### Features + +- [**breaking**] Migrate from `gRPC` to `REST` API ([#220](https://github.com/s2-streamstore/s2-sdk-rust/issues/220)) +- [**breaking**] Add validation for `AppendSessionConfig` ([#225](https://github.com/s2-streamstore/s2-sdk-rust/issues/225)) +- [**breaking**] Add `list_all_*` methods for automatic pagination ([#235](https://github.com/s2-streamstore/s2-sdk-rust/issues/235)) +- Support `start_after` in `list_all_*` ops ([#237](https://github.com/s2-streamstore/s2-sdk-rust/issues/237)) +- [**breaking**] Expose `reserve` in `AppendSession`, support `reserve` in `Producer` ([#229](https://github.com/s2-streamstore/s2-sdk-rust/issues/229)) +- Expose `RECORD_BATCH_MAX` ([#242](https://github.com/s2-streamstore/s2-sdk-rust/issues/242)) + +### Bug Fixes + +- Incorrect lexicographical order in tests ([#228](https://github.com/s2-streamstore/s2-sdk-rust/issues/228)) +- Unhandled error variants in session ops ([#230](https://github.com/s2-streamstore/s2-sdk-rust/issues/230)) +- Misclassification of 409 as non-retryable ([#236](https://github.com/s2-streamstore/s2-sdk-rust/issues/236)) + +### Refactor + +- Make `RetryConfig::max_attempts` field a `NonZeroU32` as well ([#223](https://github.com/s2-streamstore/s2-sdk-rust/issues/223)) +- Remove irrelevant backoff reset in unary request context ([#224](https://github.com/s2-streamstore/s2-sdk-rust/issues/224)) +- [**breaking**] Restrict public items for misuse safety ([#227](https://github.com/s2-streamstore/s2-sdk-rust/issues/227)) +- [**breaking**] Make tests runnable against non-prod envs ([#231](https://github.com/s2-streamstore/s2-sdk-rust/issues/231)) +- [**breaking**] Rename `ReadBeyondTail` as `ReadUnwritten`, change `read_session` return type ([#234](https://github.com/s2-streamstore/s2-sdk-rust/issues/234)) +- [**breaking**] Remove `non_exhaustive` marker for enums, make `IssueAccessTokenInput` and `AccessTokenInfo` symmetrical ([#238](https://github.com/s2-streamstore/s2-sdk-rust/issues/238)) +- Consume backoffs lazily ([#239](https://github.com/s2-streamstore/s2-sdk-rust/issues/239)) +- [**breaking**] Rename `streamstore` crate to `s2-sdk` ([#221](https://github.com/s2-streamstore/s2-sdk-rust/issues/221)) +- Include package version in `User-Agent` header ([#243](https://github.com/s2-streamstore/s2-sdk-rust/issues/243)) + +### Testing + +- Fix basin/stream collisions ([#241](https://github.com/s2-streamstore/s2-sdk-rust/issues/241)) + +### Miscellaneous Tasks + +- Update license to MIT ([#222](https://github.com/s2-streamstore/s2-sdk-rust/issues/222)) +- Crates.io only allows 5 keywords ([#232](https://github.com/s2-streamstore/s2-sdk-rust/issues/232)) +- Add categories to Cargo manifest ([#233](https://github.com/s2-streamstore/s2-sdk-rust/issues/233)) +- Bump `s2-api` and `s2-common` versions ([#240](https://github.com/s2-streamstore/s2-sdk-rust/issues/240)) + +## [0.21.0] - 2025-09-11 + +### Miscellaneous Tasks + +- Upgrade to tonic & prost 0.14 ([#212](https://github.com/s2-streamstore/s2-sdk-rust/issues/212)) + +## [0.20.0] - 2025-09-03 + +### Features + +- Support `Infinite` retention policy ([#209](https://github.com/s2-streamstore/s2-sdk-rust/issues/209)) + +## [0.19.2] - 2025-08-13 + +### Bug Fixes + +- Append session assertion failure ([#206](https://github.com/s2-streamstore/s2-sdk-rust/issues/206)) + +## [0.19.1] - 2025-07-28 + +### Miscellaneous Tasks + +- Renaming DeleteOnEmpty -> DeleteOnEmptyConfig ([#202](https://github.com/s2-streamstore/s2-sdk-rust/issues/202)) + +## [0.19.0] - 2025-07-24 + +### Features + +- Delete-on-empty ([#199](https://github.com/s2-streamstore/s2-sdk-rust/issues/199)) + +### Miscellaneous Tasks + +- Fixes to delete-on-empty impl ([#200](https://github.com/s2-streamstore/s2-sdk-rust/issues/200)) + +### Release + +- 0.18.0 ([#198](https://github.com/s2-streamstore/s2-sdk-rust/issues/198)) + +## [0.18.0] - 2025-07-22 + +### Features + +- Clamp ([#197](https://github.com/s2-streamstore/s2-sdk-rust/issues/197)) + +## [0.17.0] - 2025-06-06 + +### Features + +- `until` timestamp in reads + new metrics op types ([#193](https://github.com/s2-streamstore/s2-sdk-rust/issues/193)) + +### Miscellaneous Tasks + +- Derive `PartialEq`, `Eq`, `Hash` on stringy newtypes ([#191](https://github.com/s2-streamstore/s2-sdk-rust/issues/191)) + +## [0.16.2] - 2025-05-25 + +### Features + +- Impl `FromStr` / `Display` for `FencingToken` ([#189](https://github.com/s2-streamstore/s2-sdk-rust/issues/189)) + +## [0.16.1] - 2025-05-25 + +## [0.16.0] - 2025-05-25 + +### Features + +- [**breaking**] Fencing token is now a string ([#185](https://github.com/s2-streamstore/s2-sdk-rust/issues/185)) + +## [0.15.0] - 2025-05-10 + +### Features + +- Retry on `ResourceExhausted` and use `retry-after` if present ([#177](https://github.com/s2-streamstore/s2-sdk-rust/issues/177)) + +## [0.14.0] - 2025-05-08 + +### Features + +- Timestamping config and refactoring of type conversion ([#179](https://github.com/s2-streamstore/s2-sdk-rust/issues/179)) + +### Miscellaneous Tasks + +- Dependency upgrades ([#180](https://github.com/s2-streamstore/s2-sdk-rust/issues/180)) + +## [0.13.0] - 2025-04-30 + +### Features + +- [**breaking**] Updated read APIs ([#174](https://github.com/s2-streamstore/s2-sdk-rust/issues/174)) + +## [0.12.0] - 2025-04-18 + +### Features + +- [**breaking**] Remove `ReadOutput::FirstSeqNum` ([#169](https://github.com/s2-streamstore/s2-sdk-rust/issues/169)) + +### Bug Fixes + +- Access token example ([#168](https://github.com/s2-streamstore/s2-sdk-rust/issues/168)) + +### Miscellaneous Tasks + +- Add missing builders ([#170](https://github.com/s2-streamstore/s2-sdk-rust/issues/170)) +- Dependency upgrades ([#171](https://github.com/s2-streamstore/s2-sdk-rust/issues/171)) + +## [0.11.0] - 2025-04-15 + +### Features + +- Access token methods [S2-758] ([#163](https://github.com/s2-streamstore/s2-sdk-rust/issues/163)) + +## [0.10.0] - 2025-03-19 + +### Features + +- [**breaking**] Timestamped records ([#157](https://github.com/s2-streamstore/s2-sdk-rust/issues/157)) + +## [0.9.0] - 2025-03-12 + +### Features + +- Configurable option for compression ([#151](https://github.com/s2-streamstore/s2-sdk-rust/issues/151)) + +### Miscellaneous Tasks + +- Upgrade proto ([#153](https://github.com/s2-streamstore/s2-sdk-rust/issues/153)) +- Proto update ([#154](https://github.com/s2-streamstore/s2-sdk-rust/issues/154)) + +## [0.8.2] - 2025-02-07 + +### Bug Fixes + +- Retry CANCELLED gRPC status code ([#149](https://github.com/s2-streamstore/s2-sdk-rust/issues/149)) + +## [0.8.1] - 2025-02-05 + +### Features + +- Enable compression ([#147](https://github.com/s2-streamstore/s2-sdk-rust/issues/147)) + +## [0.8.0] - 2025-01-21 + +### Bug Fixes + +- Respect limits with read session resumption ([#139](https://github.com/s2-streamstore/s2-sdk-rust/issues/139)) + +### Miscellaneous Tasks + +- Make `with_limit()` take option ([#145](https://github.com/s2-streamstore/s2-sdk-rust/issues/145)) + +## [0.7.0] - 2025-01-16 + +### Miscellaneous Tasks + +- Update proto ([#135](https://github.com/s2-streamstore/s2-sdk-rust/issues/135)) + +## [0.6.0] - 2025-01-13 + +### Documentation + +- Update README link for docs.rs ([#128](https://github.com/s2-streamstore/s2-sdk-rust/issues/128)) + +### Miscellaneous Tasks + +- Update proto ([#129](https://github.com/s2-streamstore/s2-sdk-rust/issues/129)) +- Default impl for AppendInput ([#130](https://github.com/s2-streamstore/s2-sdk-rust/issues/130)) +- Update protos ([#133](https://github.com/s2-streamstore/s2-sdk-rust/issues/133)) + +## [0.5.1] - 2024-12-20 + +### Documentation + +- Update S2 doc links ([#126](https://github.com/s2-streamstore/s2-sdk-rust/issues/126)) + +## [0.5.0] - 2024-12-19 + +### Documentation + +- `batching` module Rust docs ([#119](https://github.com/s2-streamstore/s2-sdk-rust/issues/119)) +- Update basin and stream names ([#122](https://github.com/s2-streamstore/s2-sdk-rust/issues/122)) +- Update README API link ([#123](https://github.com/s2-streamstore/s2-sdk-rust/issues/123)) +- `s2::client` ([#121](https://github.com/s2-streamstore/s2-sdk-rust/issues/121)) +- Crate level documentation ([#124](https://github.com/s2-streamstore/s2-sdk-rust/issues/124)) + +### Miscellaneous Tasks + +- Rename `[lib]` to `s2` ([#120](https://github.com/s2-streamstore/s2-sdk-rust/issues/120)) +- *(release)* 0.5.0 + +## [0.4.1] - 2024-12-17 + +### Bug Fixes + +- Deadlock potential due to `await`s in append_session's `select!` loop ([#115](https://github.com/s2-streamstore/s2-sdk-rust/issues/115)) + +## [0.4.0] - 2024-12-16 + +### Features + +- Add `FencingToken::generate` method ([#110](https://github.com/s2-streamstore/s2-sdk-rust/issues/110)) +- Return `StreamInfo` from `BasinClient::create_stream` ([#114](https://github.com/s2-streamstore/s2-sdk-rust/issues/114)) + +### Miscellaneous Tasks + +- Remove `GH_TOKEN` use to clone submodule in CI ([#109](https://github.com/s2-streamstore/s2-sdk-rust/issues/109)) +- Proto up-to-date check ([#112](https://github.com/s2-streamstore/s2-sdk-rust/issues/112)) +- Upgrade proto ([#111](https://github.com/s2-streamstore/s2-sdk-rust/issues/111)) +- Add examples for API ([#113](https://github.com/s2-streamstore/s2-sdk-rust/issues/113)) +- Add `README.md` ([#116](https://github.com/s2-streamstore/s2-sdk-rust/issues/116)) + +## [0.3.1] - 2024-12-12 + +### Miscellaneous Tasks + +- Switch on `missing_docs` ([#106](https://github.com/s2-streamstore/s2-sdk-rust/issues/106)) + +## [0.3.0] - 2024-12-11 + +### Features + +- Return reconfigured stream ([#95](https://github.com/s2-streamstore/s2-sdk-rust/issues/95)) +- Implement `SequencedRecord::as_command_record` ([#96](https://github.com/s2-streamstore/s2-sdk-rust/issues/96)) +- Make protoc requirement optional ([#103](https://github.com/s2-streamstore/s2-sdk-rust/issues/103)) + +### Bug Fixes + +- Tonic-side-effect version ([#102](https://github.com/s2-streamstore/s2-sdk-rust/issues/102)) + +### Miscellaneous Tasks + +- Update proto and associated types for non-optional `start_seq_num` ([#97](https://github.com/s2-streamstore/s2-sdk-rust/issues/97)) +- `CommandRecord::Fence` requires `FencingToken` even if empty ([#98](https://github.com/s2-streamstore/s2-sdk-rust/issues/98)) +- Rm serde ([#99](https://github.com/s2-streamstore/s2-sdk-rust/issues/99)) +- Lower `max_append_inflight_bytes` default +- Move sync_docs to separate repository ([#101](https://github.com/s2-streamstore/s2-sdk-rust/issues/101)) + +## [0.2.0] - 2024-12-04 + +### Features + +- Redo endpoint logic ([#39](https://github.com/s2-streamstore/s2-sdk-rust/issues/39)) ([#40](https://github.com/s2-streamstore/s2-sdk-rust/issues/40)) +- Metered_size impl ([#51](https://github.com/s2-streamstore/s2-sdk-rust/issues/51)) +- Allow custom tonic connectors & expose more errors ([#54](https://github.com/s2-streamstore/s2-sdk-rust/issues/54)) +- Implement lingering for append record stream ([#55](https://github.com/s2-streamstore/s2-sdk-rust/issues/55)) +- Read session resumption ([#64](https://github.com/s2-streamstore/s2-sdk-rust/issues/64)) +- Pre-validate append record batch ([#72](https://github.com/s2-streamstore/s2-sdk-rust/issues/72)) +- Retryable `append_session` + side-effect logic +- Validate fencing token length ([#87](https://github.com/s2-streamstore/s2-sdk-rust/issues/87)) +- S2_request_token header (exercise idempotence) ([#86](https://github.com/s2-streamstore/s2-sdk-rust/issues/86)) + +### Bug Fixes + +- Only connect lazily (remove option to connect eagerly) ([#49](https://github.com/s2-streamstore/s2-sdk-rust/issues/49)) +- Update `HostEndpoints::from_env` with new spec ([#58](https://github.com/s2-streamstore/s2-sdk-rust/issues/58)) +- Add Send bound to Streaming wrapper ([#63](https://github.com/s2-streamstore/s2-sdk-rust/issues/63)) +- Validate append input when converting from sdk type to api ([#65](https://github.com/s2-streamstore/s2-sdk-rust/issues/65)) +- Limit retries when read resumes but stream keeps erroring ([#66](https://github.com/s2-streamstore/s2-sdk-rust/issues/66)) +- Retry on deadline exceeded ([#67](https://github.com/s2-streamstore/s2-sdk-rust/issues/67)) +- Remove `ConnectionError` in favour of pre-processing ([#68](https://github.com/s2-streamstore/s2-sdk-rust/issues/68)) +- Rename 'max_retries' to 'max_attempts' +- Validate `types::AppendRecord` for metered size ([#79](https://github.com/s2-streamstore/s2-sdk-rust/issues/79)) +- Adapt to recent gRPC interface updates ([#84](https://github.com/s2-streamstore/s2-sdk-rust/issues/84)) +- Use `if_exists` for delete basin/stream ([#85](https://github.com/s2-streamstore/s2-sdk-rust/issues/85)) +- `append_session` inner loop while condition ([#91](https://github.com/s2-streamstore/s2-sdk-rust/issues/91)) + +### Documentation + +- ConnectError + +### Testing + +- `fencing_token` and `match_seq_num` for `AppendRecordsBatchStream` ([#77](https://github.com/s2-streamstore/s2-sdk-rust/issues/77)) + +### Miscellaneous Tasks + +- Rename `ClientError` to `ConnectError` ([#47](https://github.com/s2-streamstore/s2-sdk-rust/issues/47)) +- Make `ReadLimit` fields pub +- Add clippy to CI ([#50](https://github.com/s2-streamstore/s2-sdk-rust/issues/50)) +- Expose Aborted as an error variant ([#52](https://github.com/s2-streamstore/s2-sdk-rust/issues/52)) +- Expose tonic Internal error message ([#53](https://github.com/s2-streamstore/s2-sdk-rust/issues/53)) +- Refactor errors to return tonic::Status ([#57](https://github.com/s2-streamstore/s2-sdk-rust/issues/57)) +- Conversion from `HostCloud` for `HostEndpoints` ([#59](https://github.com/s2-streamstore/s2-sdk-rust/issues/59)) +- Rm unneeded async ([#62](https://github.com/s2-streamstore/s2-sdk-rust/issues/62)) +- Create LICENSE +- Update Cargo.toml with license +- Update license for sync_docs +- Add expect messages instead of unwraps ([#69](https://github.com/s2-streamstore/s2-sdk-rust/issues/69)) +- Make `ClientConfig` fields private + revise docs ([#73](https://github.com/s2-streamstore/s2-sdk-rust/issues/73)) +- Whoops, max_attempts -> with_max_attempts +- Endpoints re-rejig ([#70](https://github.com/s2-streamstore/s2-sdk-rust/issues/70)) +- Add back `S2Endpoints::from_env()` ([#74](https://github.com/s2-streamstore/s2-sdk-rust/issues/74)) +- Example from_env +- Assertions instead of errors for batch capacity & size ([#75](https://github.com/s2-streamstore/s2-sdk-rust/issues/75)) +- Simplify s2_request_token creation +- Remove `bytesize` dependency ([#89](https://github.com/s2-streamstore/s2-sdk-rust/issues/89)) +- Update proto ([#93](https://github.com/s2-streamstore/s2-sdk-rust/issues/93)) + +## [0.1.0] - 2024-11-06 + +### Features + +- Implement `BasinService/{ListStreams, GetBasinConfig}` ([#3](https://github.com/s2-streamstore/s2-sdk-rust/issues/3)) +- Implement `BasinService/{CreateStream, GetStreamConfig}` ([#8](https://github.com/s2-streamstore/s2-sdk-rust/issues/8)) +- Implement `AccountService/{ListBasins, DeleteBasin}` ([#10](https://github.com/s2-streamstore/s2-sdk-rust/issues/10)) +- Implement `BasinService` ([#12](https://github.com/s2-streamstore/s2-sdk-rust/issues/12)) +- Add request timeout ([#14](https://github.com/s2-streamstore/s2-sdk-rust/issues/14)) +- Display impl for types::BasinState ([#18](https://github.com/s2-streamstore/s2-sdk-rust/issues/18)) +- Implement `StreamService` (complete) ([#16](https://github.com/s2-streamstore/s2-sdk-rust/issues/16)) +- Implement `AppendRecordStream` with batching support ([#24](https://github.com/s2-streamstore/s2-sdk-rust/issues/24)) +- Enable tls config and make connection uri depend on env ([#25](https://github.com/s2-streamstore/s2-sdk-rust/issues/25)) +- Doc reuse ([#32](https://github.com/s2-streamstore/s2-sdk-rust/issues/32)) +- Support for overriding user-agent ([#33](https://github.com/s2-streamstore/s2-sdk-rust/issues/33)) +- Sync rpc - sdk wrapper docs ([#34](https://github.com/s2-streamstore/s2-sdk-rust/issues/34)) + +### Bug Fixes + +- Use usize for ListBasins ([#15](https://github.com/s2-streamstore/s2-sdk-rust/issues/15)) +- Make all errors public ([#20](https://github.com/s2-streamstore/s2-sdk-rust/issues/20)) + +### Miscellaneous Tasks + +- Update proto submodule ([#7](https://github.com/s2-streamstore/s2-sdk-rust/issues/7)) +- Replace url with http::uri::Uri ([#6](https://github.com/s2-streamstore/s2-sdk-rust/issues/6)) +- Update `HAS_NO_SIDE_EFFECTS` to `IDEMPOTENCY_LEVEL` ([#11](https://github.com/s2-streamstore/s2-sdk-rust/issues/11)) +- FromStr impl to convert str to StorageClass enum ([#13](https://github.com/s2-streamstore/s2-sdk-rust/issues/13)) +- Move `get_basin_config`, `reconfigure_basin` to `AccountService` ([#17](https://github.com/s2-streamstore/s2-sdk-rust/issues/17)) +- Remove usage of deprecated `tonic_build` method ([#21](https://github.com/s2-streamstore/s2-sdk-rust/issues/21)) +- Add+feature-gate serde Serialize/Deserialize derives ([#22](https://github.com/s2-streamstore/s2-sdk-rust/issues/22)) +- Deps +- Updated repo for proto submodule ([#38](https://github.com/s2-streamstore/s2-sdk-rust/issues/38)) +- Http2 adaptive window [S2-412] ([#41](https://github.com/s2-streamstore/s2-sdk-rust/issues/41)) +- Add CI action ([#44](https://github.com/s2-streamstore/s2-sdk-rust/issues/44)) +- Add release action ([#45](https://github.com/s2-streamstore/s2-sdk-rust/issues/45)) + + diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml new file mode 100644 index 00000000..194465ff --- /dev/null +++ b/sdk/Cargo.toml @@ -0,0 +1,69 @@ +[package] +name = "s2-sdk" +description = "Rust SDK for S2" +version = "0.35.1" +edition.workspace = true +license.workspace = true +repository = "https://github.com/s2-streamstore/s2/tree/main/sdk" +homepage.workspace = true +documentation = "https://docs.rs/s2-sdk/latest/s2_sdk/" +keywords = ["s2", "durable", "streams", "client", "sdk"] +categories = ["api-bindings", "database"] + +[package.metadata.docs.rs] +cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] +features = ["rustls-aws-lc-rs", "rustls-ring"] + +[features] +default = ["rustls-aws-lc-rs"] +_hidden = [] +rustls-aws-lc-rs = ["hyper-rustls/aws-lc-rs", "rustls/aws-lc-rs"] +rustls-ring = ["hyper-rustls/ring", "rustls/ring"] + +[dependencies] +async-compression = { version = "0.4", features = ["tokio", "gzip", "zstd"] } +async-stream = { workspace = true } +async-trait = { workspace = true } +bytes = { workspace = true } +compact_str = { workspace = true, features = ["serde"] } +futures-core = { workspace = true } +futures-util = { workspace = true } +h2 = "0.4" +http = { workspace = true } +http-body = "1" +http-body-util = "0.1" +hyper = "1" +hyper-rustls = { version = "0.27", default-features = false, features = ["http2", "native-tokio", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "tokio", "http1", "http2"] } +pin-project-lite = "0.2" +prost = { workspace = true } +rand = { workspace = true } +rustls = { workspace = true } +s2-api = { workspace = true } +s2-common = { workspace = true } +secrecy = "0.10" +serde = { workspace = true } +serde_json = { workspace = true } +serde_urlencoded = "0.7" +thiserror = { workspace = true } +time = { workspace = true } +tokio = { version = "1.53", features = ["time", "macros", "io-util", "sync"] } +tokio-muxt = "0.7" +tokio-stream = { workspace = true } +tokio-util = { workspace = true, features = ["rt", "codec"] } +tracing = { workspace = true } +urlencoding = "2" +uuid = { version = "1", features = ["v4", "fast-rng"] } + +[dev-dependencies] +assert_matches = "1.5" +proptest = { workspace = true } +rstest = { workspace = true } +test-context = { version = "0.5" } +tokio = { workspace = true, features = ["full", "test-util"] } +tokio-shared-rt = "0.1" + +[[example]] +# `doc-scrape-examples` requires *any* one example to specify the option. +name = "create_basin" +doc-scrape-examples = true diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 00000000..d6132a80 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,105 @@ +
+

+ + + + + + + + +

+ +

Rust SDK for S2

+ +

+ + + + + + + + + + +

+
+ +The Rust SDK provides ergonomic interface and utilities to interact with the +[S2 API](https://s2.dev/docs/rest/records/overview). + +## Getting started + +1. Ensure you have added [tokio](https://crates.io/crates/tokio) and [futures](https://crates.io/crates/futures) as dependencies. + ```bash + cargo add tokio --features full + cargo add futures + ``` + +1. Add the `s2-sdk` dependency to your project: + ```bash + cargo add s2-sdk + ``` + +1. Generate an access token by logging into the web console at + [s2.dev](https://s2.dev/dashboard). + +1. Perform an operation. + ```rust + use s2_sdk::{ + S2, + types::{ListBasinsInput, S2Config}, + }; + + #[tokio::main] + async fn main() -> Result<(), Box> { + let s2 = S2::new(S2Config::new(""))?; + let page = s2.list_basins(ListBasinsInput::new()).await?; + println!("My basins: {:?}", page.values); + Ok(()) + } + ``` + +## Examples + +The [`examples`](./examples) directory in this repository contains a variety of +example use cases demonstrating how to use the SDK effectively. + +You might have to set one or more of these env vars based on the example you run. + +```bash +export S2_ACCESS_TOKEN="" +export S2_BASIN="" +export S2_STREAM="" +export S2_ENCRYPTION_KEY="" +cargo run --example +``` + +## SDK Docs and Reference + +Head over to [docs.rs](https://docs.rs/s2-sdk/latest/s2_sdk/) for +detailed documentation and crate reference. + +## Feedback + +We use [Github Issues](https://github.com/s2-streamstore/s2/issues) to +track feature requests and issues with the SDK. If you wish to provide feedback, +report a bug or request a feature, feel free to open a Github issue. + +## Contributing + +Developers are welcome to submit Pull Requests on the repository. If there is +no tracking issue for the bug or feature request corresponding to the PR, we +encourage you to open one for discussion before submitting the PR. + +## Reach out to us + +Join our [Discord](https://discord.gg/vTCs7kMkAf) server. We would love to hear +from you. + +You can also email us at [hi@s2.dev](mailto:hi@s2.dev). + +## License + +This project is licensed under the [MIT License](https://github.com/s2-streamstore/s2/blob/main/LICENSE). diff --git a/sdk/examples/caught_up.rs b/sdk/examples/caught_up.rs new file mode 100644 index 00000000..23a4cc35 --- /dev/null +++ b/sdk/examples/caught_up.rs @@ -0,0 +1,101 @@ +use futures_util::StreamExt; +use s2_sdk::{ + S2, + types::{ + AppendInput, AppendRecord, AppendRecordBatch, BasinName, CreateBasinInput, + CreateStreamInput, DeleteBasinInput, DeleteStreamInput, ReadBatch, ReadFrom, ReadInput, + ReadSessionConfig, ReadStart, S2Config, S2Endpoints, StreamName, + }, +}; + +fn print_batch(label: &str, batch: &ReadBatch) { + let seq_nums = batch + .records + .iter() + .map(|record| record.seq_num) + .collect::>(); + println!("{label}: {seq_nums:?}"); +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + let mut config = S2Config::new(access_token); + if std::env::var_os("S2_ACCOUNT_ENDPOINT").is_some() + || std::env::var_os("S2_BASIN_ENDPOINT").is_some() + { + config = config.with_endpoints(S2Endpoints::from_env()?); + } + + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; + let basin_name: BasinName = format!("caught-up-{suffix}").parse()?; + let stream_name: StreamName = "example".parse()?; + let s2 = S2::new(config)?; + let basin = s2.basin(basin_name.clone()); + + s2.create_basin(CreateBasinInput::new(basin_name.clone())) + .await?; + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + let stream = basin.stream(stream_name.clone()); + + stream + .append(AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("first")?, + AppendRecord::new("second")?, + ])?)) + .await?; + + let mut session = stream + .read_session( + ReadInput::new().with_start(ReadStart::new().with_from(ReadFrom::TailOffset(2))), + ReadSessionConfig::default(), + ) + .await?; + let mut caught_up = session.caught_up(); + + loop { + tokio::select! { + tail = &mut caught_up => { + println!("Caught up through sequence number {}", tail?.seq_num); + break; + } + Some(batch) = session.next() => { + print_batch("Read before catching up", &batch?); + } + } + } + + let ack = stream + .append(AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("third")?, + ])?)) + .await?; + println!( + "Appended another record at sequence number {}", + ack.start.seq_num + ); + + while let Some(batch) = session.next().await { + let batch = batch?; + print_batch("Read after catching up", &batch); + if batch + .records + .iter() + .any(|record| record.seq_num == ack.start.seq_num) + { + break; + } + } + println!("Session is caught up again: {}", session.is_caught_up()); + + drop(session); + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + s2.delete_basin(DeleteBasinInput::new(basin_name)).await?; + + Ok(()) +} diff --git a/sdk/examples/consumer.rs b/sdk/examples/consumer.rs new file mode 100644 index 00000000..1cc89538 --- /dev/null +++ b/sdk/examples/consumer.rs @@ -0,0 +1,33 @@ +use futures_util::StreamExt; +use s2_sdk::{ + S2, + types::{BasinName, ReadInput, ReadSessionConfig, S2Config, StreamName}, +}; +use tokio::select; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = std::env::var("S2_ACCESS_TOKEN")?; + let basin_name: BasinName = std::env::var("S2_BASIN")?.parse()?; + let stream_name: StreamName = std::env::var("S2_STREAM")?.parse()?; + + let s2 = S2::new(S2Config::new(access_token))?; + let stream = s2.basin(basin_name).stream(stream_name); + + let input = ReadInput::new(); + let mut batches = stream + .read_session(input, ReadSessionConfig::default()) + .await?; + loop { + select! { + batch = batches.next() => { + let Some(batch) = batch else { break }; + let batch = batch?; + println!("{batch:?}"); + } + _ = tokio::signal::ctrl_c() => break, + } + } + + Ok(()) +} diff --git a/sdk/examples/create_basin.rs b/sdk/examples/create_basin.rs new file mode 100644 index 00000000..286d55d2 --- /dev/null +++ b/sdk/examples/create_basin.rs @@ -0,0 +1,29 @@ +use s2_sdk::{ + S2, + types::{BasinConfig, BasinName, CreateBasinInput, RetentionPolicy, S2Config, StreamConfig}, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + let basin_name: BasinName = std::env::var("S2_BASIN") + .map_err(|_| "S2_BASIN env var not set")? + .parse()?; + + let config = S2Config::new(access_token); + let s2 = S2::new(config)?; + + let input = CreateBasinInput::new(basin_name.clone()).with_config( + BasinConfig::new().with_default_stream_config( + StreamConfig::new().with_retention_policy(RetentionPolicy::Age(10 * 24 * 60 * 60)), + ), + ); + let basin_info = s2.create_basin(input).await?; + println!("{basin_info:#?}"); + + let basin_config = s2.get_basin_config(basin_name).await?; + println!("{basin_config:#?}"); + + Ok(()) +} diff --git a/sdk/examples/create_stream.rs b/sdk/examples/create_stream.rs new file mode 100644 index 00000000..e856d630 --- /dev/null +++ b/sdk/examples/create_stream.rs @@ -0,0 +1,35 @@ +use s2_sdk::{ + S2, + types::{ + BasinName, CreateStreamInput, S2Config, StreamConfig, StreamName, TimestampingConfig, + TimestampingMode, + }, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + let basin_name: BasinName = std::env::var("S2_BASIN") + .map_err(|_| "S2_BASIN env var not set")? + .parse()?; + let stream_name: StreamName = std::env::var("S2_STREAM") + .map_err(|_| "S2_STREAM env var not set")? + .parse()?; + + let s2 = S2::new(S2Config::new(access_token))?; + let basin = s2.basin(basin_name); + + let input = CreateStreamInput::new(stream_name.clone()).with_config( + StreamConfig::new().with_timestamping( + TimestampingConfig::new().with_mode(TimestampingMode::ClientRequire), + ), + ); + let stream_info = basin.create_stream(input).await?; + println!("{stream_info:#?}"); + + let stream_config = basin.get_stream_config(stream_name).await?; + println!("{stream_config:#?}"); + + Ok(()) +} diff --git a/sdk/examples/delete_basin.rs b/sdk/examples/delete_basin.rs new file mode 100644 index 00000000..f77b4255 --- /dev/null +++ b/sdk/examples/delete_basin.rs @@ -0,0 +1,22 @@ +use s2_sdk::{ + S2, + types::{BasinName, DeleteBasinInput, S2Config}, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + let basin_name: BasinName = std::env::var("S2_BASIN") + .map_err(|_| "S2_BASIN env var not set")? + .parse()?; + + let config = S2Config::new(access_token); + let s2 = S2::new(config)?; + + let input = DeleteBasinInput::new(basin_name).with_ignore_not_found(true); + s2.delete_basin(input).await?; + println!("Deletion requested"); + + Ok(()) +} diff --git a/sdk/examples/delete_stream.rs b/sdk/examples/delete_stream.rs new file mode 100644 index 00000000..211b0556 --- /dev/null +++ b/sdk/examples/delete_stream.rs @@ -0,0 +1,25 @@ +use s2_sdk::{ + S2, + types::{BasinName, DeleteStreamInput, S2Config, StreamName}, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + let basin_name: BasinName = std::env::var("S2_BASIN") + .map_err(|_| "S2_BASIN env var not set")? + .parse()?; + let stream_name: StreamName = std::env::var("S2_STREAM") + .map_err(|_| "S2_STREAM env var not set")? + .parse()?; + + let s2 = S2::new(S2Config::new(access_token))?; + let basin = s2.basin(basin_name); + + let input = DeleteStreamInput::new(stream_name); + basin.delete_stream(input).await?; + println!("Deletion requested"); + + Ok(()) +} diff --git a/sdk/examples/docs_account_and_basins.rs b/sdk/examples/docs_account_and_basins.rs new file mode 100644 index 00000000..cd7be46d --- /dev/null +++ b/sdk/examples/docs_account_and_basins.rs @@ -0,0 +1,138 @@ +//! Documentation examples for Account and Basins page. +//! +//! Run with: cargo run --example docs_account_and_basins + +use futures_util::StreamExt; +use s2_sdk::{ + S2, + types::{ + AccessTokenScopeInput, BasinMatcher, BasinName, CreateBasinInput, CreateStreamInput, + DeleteBasinInput, DeleteStreamInput, IssueAccessTokenInput, ListAllStreamsInput, + ListBasinsInput, ListStreamsInput, OperationGroupPermissions, ReadWritePermissions, + S2Config, StreamMatcher, + }, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = std::env::var("S2_ACCESS_TOKEN")?; + let basin_name: BasinName = std::env::var("S2_BASIN")?.parse()?; + + let client = S2::new(S2Config::new(access_token))?; + + // ANCHOR: basin-operations + // List basins + let basins = client.list_basins(ListBasinsInput::new()).await?; + + // Create a basin + client + .create_basin(CreateBasinInput::new("my-events".parse()?)) + .await?; + + // Get configuration + let config = client.get_basin_config("my-events".parse()?).await?; + + // Delete + client + .delete_basin(DeleteBasinInput::new("my-events".parse()?)) + .await?; + // ANCHOR_END: basin-operations + println!("Basins: {:?}, config: {:?}", basins, config); + + let basin = client.basin(basin_name); + + // ANCHOR: stream-operations + // List streams + let streams = basin + .list_streams(ListStreamsInput::new().with_prefix("user-".parse()?)) + .await?; + + // Create a stream + // Optionally, pass `.with_config(StreamConfig { .. })` to CreateStreamInput. + basin + .create_stream(CreateStreamInput::new("user-actions".parse()?)) + .await?; + + // Get configuration + let config = basin.get_stream_config("user-actions".parse()?).await?; + + // Delete + basin + .delete_stream(DeleteStreamInput::new("user-actions".parse()?)) + .await?; + // ANCHOR_END: stream-operations + println!("Streams: {:?}, config: {:?}", streams, config); + + // ANCHOR: access-token-basic + // List tokens (returns metadata, not the secret) + let tokens = client.list_access_tokens(Default::default()).await?; + + // Issue a token scoped to streams under "users/1234/" + let result = client + .issue_access_token( + IssueAccessTokenInput::new( + "user-1234-rw-token".parse()?, + AccessTokenScopeInput::from_op_group_perms( + OperationGroupPermissions::new() + .with_stream(ReadWritePermissions::read_write()), + ) + .with_basins(BasinMatcher::Prefix("".parse()?)) // all basins + .with_streams(StreamMatcher::Prefix("users/1234/".parse()?)), + ) + .with_expires_at("2027-01-01T00:00:00Z".parse()?), + ) + .await?; + + // Revoke a token + client + .revoke_access_token("user-1234-rw-token".parse()?) + .await?; + // ANCHOR_END: access-token-basic + println!("Tokens: {:?}, issued: {:?}", tokens, result); + + // ANCHOR: access-token-restricted + client + .issue_access_token(IssueAccessTokenInput::new( + "restricted-token".parse()?, + AccessTokenScopeInput::from_op_group_perms( + OperationGroupPermissions::new().with_stream(ReadWritePermissions::read_only()), + ) + .with_basins(BasinMatcher::Exact("production".parse()?)) + .with_streams(StreamMatcher::Prefix("logs/".parse()?)), + )) + .await?; + // ANCHOR_END: access-token-restricted + + // Pagination examples - not executed by default + if false { + // ANCHOR: pagination + // Iterate through all streams with automatic pagination + let mut stream = basin.list_all_streams(ListAllStreamsInput::new()); + while let Some(info) = stream.next().await { + let info = info?; + println!("{}", info.name); + } + // ANCHOR_END: pagination + + // ANCHOR: pagination-filtering + // List streams with a prefix filter + let input = ListAllStreamsInput::new().with_prefix("events/".parse()?); + let mut stream = basin.list_all_streams(input); + while let Some(info) = stream.next().await { + println!("{}", info?.name); + } + // ANCHOR_END: pagination-filtering + + // ANCHOR: pagination-deleted + // Include streams that are being deleted + let input = ListAllStreamsInput::new().with_include_deleted(true); + let mut stream = basin.list_all_streams(input); + while let Some(info) = stream.next().await { + let info = info?; + println!("{} {:?}", info.name, info.deleted_at); + } + // ANCHOR_END: pagination-deleted + } + + Ok(()) +} diff --git a/sdk/examples/docs_configuration.rs b/sdk/examples/docs_configuration.rs new file mode 100644 index 00000000..ad5dbd11 --- /dev/null +++ b/sdk/examples/docs_configuration.rs @@ -0,0 +1,56 @@ +//! Documentation examples for Configuration page. +//! +//! Run with: cargo run --example docs_configuration + +use std::{num::NonZeroU32, time::Duration}; + +use s2_sdk::{ + S2, + types::{AccountEndpoint, BasinEndpoint, RetryConfig, S2Config, S2Endpoints}, +}; + +fn main() -> Result<(), Box> { + // Example: Custom endpoints (e.g., for s2-lite local dev) + { + // ANCHOR: custom-endpoints + let client = S2::new( + S2Config::new("local-token").with_endpoints(S2Endpoints::new( + AccountEndpoint::new("http://localhost:8080")?, + BasinEndpoint::new("http://localhost:8080")?, + )?), + )?; + // ANCHOR_END: custom-endpoints + println!("Created client with custom endpoints: {:?}", client); + } + + // Example: Custom retry configuration + { + let access_token = std::env::var("S2_ACCESS_TOKEN").unwrap_or_else(|_| "demo".into()); + // ANCHOR: retry-config + let client = S2::new( + S2Config::new(access_token).with_retry( + RetryConfig::new() + .with_max_attempts(NonZeroU32::new(5).unwrap()) + .with_min_base_delay(Duration::from_millis(100)) + .with_max_base_delay(Duration::from_secs(2)), + ), + )?; + // ANCHOR_END: retry-config + println!("Created client with retry config: {:?}", client); + } + + // Example: Custom timeout configuration + { + let access_token = std::env::var("S2_ACCESS_TOKEN").unwrap_or_else(|_| "demo".into()); + // ANCHOR: timeout-config + let client = S2::new( + S2Config::new(access_token) + .with_connection_timeout(Duration::from_secs(5)) + .with_request_timeout(Duration::from_secs(10)), + )?; + // ANCHOR_END: timeout-config + println!("Created client with timeout config: {:?}", client); + } + + Ok(()) +} diff --git a/sdk/examples/docs_encryption.rs b/sdk/examples/docs_encryption.rs new file mode 100644 index 00000000..23a51c6d --- /dev/null +++ b/sdk/examples/docs_encryption.rs @@ -0,0 +1,76 @@ +//! Documentation examples for Encryption page. +//! +//! Run with: cargo run --example docs_encryption + +use s2_sdk::{ + S2, + types::{ + AppendInput, AppendRecord, AppendRecordBatch, BasinConfig, BasinName, BasinReconfiguration, + CreateBasinInput, CreateStreamInput, DeleteStreamInput, EncryptionAlgorithm, ReadFrom, + ReadInput, ReadLimits, ReadStart, ReadStop, ReconfigureBasinInput, S2Config, StreamName, + }, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = std::env::var("S2_ACCESS_TOKEN")?; + let basin_name: BasinName = std::env::var("S2_BASIN")?.parse()?; + let stream_name: StreamName = format!( + "docs-encryption-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() + ) + .parse()?; + + let client = S2::new(S2Config::new(access_token))?; + + // ANCHOR: basin-cipher + client + .create_basin( + CreateBasinInput::new(basin_name.clone()) + .with_config(BasinConfig::new().with_stream_cipher(EncryptionAlgorithm::Aegis256)), + ) + .await?; + + client + .reconfigure_basin(ReconfigureBasinInput::new( + basin_name.clone(), + BasinReconfiguration::new().with_stream_cipher(EncryptionAlgorithm::Aes256Gcm), + )) + .await?; + // ANCHOR_END: basin-cipher + + let basin = client.basin(basin_name.clone()); + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + // ANCHOR: append-read + let stream = basin + .stream(stream_name.clone()) + .with_encryption_key(std::env::var("S2_ENCRYPTION_KEY")?.parse()?); + + stream + .append(AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("top secret")?, + ])?)) + .await?; + + let batch = stream + .read( + ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::SeqNum(0))) + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_count(10))), + ) + .await?; + // ANCHOR_END: append-read + + println!("Read {} encrypted record(s)", batch.records.len()); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} diff --git a/sdk/examples/docs_metrics.rs b/sdk/examples/docs_metrics.rs new file mode 100644 index 00000000..90cffd43 --- /dev/null +++ b/sdk/examples/docs_metrics.rs @@ -0,0 +1,57 @@ +//! Documentation examples for Metrics page. +//! +//! Run with: cargo run --example docs_metrics + +use s2_sdk::{ + S2, + types::{ + AccountMetricSet, BasinMetricSet, GetAccountMetricsInput, GetBasinMetricsInput, + GetStreamMetricsInput, S2Config, StreamMetricSet, TimeRange, + }, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = std::env::var("S2_ACCESS_TOKEN")?; + let client = S2::new(S2Config::new(access_token))?; + + // ANCHOR: metrics + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() as u32; + let thirty_days_ago = now - 30 * 24 * 3600; + let six_hours_ago = now - 6 * 3600; + let hour_ago = now - 3600; + + // Account-level: active basins over the last 30 days + let account_metrics = client + .get_account_metrics(GetAccountMetricsInput::new(AccountMetricSet::ActiveBasins( + TimeRange::new(thirty_days_ago, now), + ))) + .await?; + + // Basin-level: storage usage with hourly resolution + let basin_metrics = client + .get_basin_metrics(GetBasinMetricsInput::new( + "events".parse()?, + BasinMetricSet::Storage(TimeRange::new(six_hours_ago, now)), + )) + .await?; + + // Stream-level: storage for a specific stream + let stream_metrics = client + .get_stream_metrics(GetStreamMetricsInput::new( + "events".parse()?, + "user-actions".parse()?, + StreamMetricSet::Storage(TimeRange::new(hour_ago, now)), + )) + .await?; + // ANCHOR_END: metrics + + println!( + "{:?} {:?} {:?}", + account_metrics, basin_metrics, stream_metrics + ); + + Ok(()) +} diff --git a/sdk/examples/docs_overview.rs b/sdk/examples/docs_overview.rs new file mode 100644 index 00000000..ec047303 --- /dev/null +++ b/sdk/examples/docs_overview.rs @@ -0,0 +1,17 @@ +//! Documentation examples for SDK Overview page. +//! +//! Run with: cargo run --example docs_overview + +fn main() -> Result<(), Box> { + // ANCHOR: create-client + use s2_sdk::{S2, types::S2Config}; + + let client = S2::new(S2Config::new(std::env::var("S2_ACCESS_TOKEN")?))?; + + let basin = client.basin("my-basin".parse()?); + let stream = basin.stream("my-stream".parse()?); + // ANCHOR_END: create-client + + println!("Created client for stream: {:?}", stream); + Ok(()) +} diff --git a/sdk/examples/docs_streams.rs b/sdk/examples/docs_streams.rs new file mode 100644 index 00000000..40fb556f --- /dev/null +++ b/sdk/examples/docs_streams.rs @@ -0,0 +1,224 @@ +//! Documentation examples for Streams page. +//! +//! Run with: cargo run --example docs_streams + +use std::time::Duration; + +use futures_util::StreamExt; +use s2_sdk::{ + S2, + append_session::AppendSessionConfig, + batching::BatchingConfig, + producer::ProducerConfig, + types::{ + AppendInput, AppendRecord, AppendRecordBatch, BasinName, ReadFrom, ReadInput, ReadLimits, + ReadSessionConfig, ReadStart, ReadStop, S2Config, StreamName, + }, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = std::env::var("S2_ACCESS_TOKEN")?; + let basin_name: BasinName = std::env::var("S2_BASIN")?.parse()?; + + let client = S2::new(S2Config::new(access_token))?; + let basin = client.basin(basin_name); + + // Create a temporary stream for examples + let stream_name: StreamName = format!( + "docs-streams-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() + ) + .parse()?; + basin + .create_stream(s2_sdk::types::CreateStreamInput::new(stream_name.clone())) + .await?; + + // ANCHOR: simple-append + let stream = basin.stream(stream_name.clone()); + + let ack = stream + .append(AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("first event")?, + AppendRecord::new("second event")?, + ])?)) + .await?; + + // ack tells us where the records landed + println!( + "Wrote records {} through {}", + ack.start.seq_num, + ack.end.seq_num - 1 + ); + // ANCHOR_END: simple-append + + // ANCHOR: simple-read + let batch = stream + .read( + ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::SeqNum(0))) + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_count(100))), + ) + .await?; + + for record in batch.records { + println!("[{}] {:?}", record.seq_num, record.body); + } + // ANCHOR_END: simple-read + + // ANCHOR: append-session + let session = stream.append_session(AppendSessionConfig::new()); + + // Submit a batch - this enqueues it and returns a ticket + let records = AppendRecordBatch::try_from_iter([ + AppendRecord::new("event-1")?, + AppendRecord::new("event-2")?, + ])?; + let ticket = session.submit(AppendInput::new(records)).await?; + + // Wait for durability + let ack = ticket.await?; + println!("Durable at seqNum {}", ack.start.seq_num); + + session.close().await?; + // ANCHOR_END: append-session + + // ANCHOR: producer + let producer = stream.producer( + ProducerConfig::new() + .with_batching(BatchingConfig::new().with_linger(Duration::from_millis(5))), + ); + + // Submit individual records + let ticket = producer.submit(AppendRecord::new("my event")?).await?; + + // Force the partial batch and wait for durability without closing the producer + producer.flush().await?; + + // Get the exact sequence number + let ack = ticket.await?; + println!("Record durable at seqNum {}", ack.seq_num); + + producer.close().await?; + // ANCHOR_END: producer + + // ANCHOR: check-tail + let tail = stream.check_tail().await?; + println!("Stream has {} records", tail.seq_num); + // ANCHOR_END: check-tail + + // Cleanup + basin + .delete_stream(s2_sdk::types::DeleteStreamInput::new(stream_name)) + .await?; + + println!("Streams examples completed"); + + // The following read session examples are for documentation snippets only. + // They are not executed because they would block waiting for new records. + if std::env::var("RUN_READ_SESSIONS").is_err() { + return Ok(()); + } + + // ANCHOR: read-session + let mut session = stream + .read_session( + ReadInput::new().with_start(ReadStart::new().with_from(ReadFrom::SeqNum(0))), + ReadSessionConfig::default(), + ) + .await?; + + while let Some(batch) = session.next().await { + let batch = batch?; + for record in batch.records { + println!("[{}] {:?}", record.seq_num, record.body); + } + } + // ANCHOR_END: read-session + + // ANCHOR: read-session-tail-offset + // Start reading from 10 records before the current tail + let mut session = stream + .read_session( + ReadInput::new().with_start(ReadStart::new().with_from(ReadFrom::TailOffset(10))), + ReadSessionConfig::default(), + ) + .await?; + + while let Some(batch) = session.next().await { + let batch = batch?; + for record in batch.records { + println!("[{}] {:?}", record.seq_num, record.body); + } + } + // ANCHOR_END: read-session-tail-offset + + // ANCHOR: read-session-timestamp + // Start reading from a specific timestamp + let one_hour_ago = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64 + - 3600 * 1000; + let mut session = stream + .read_session( + ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::Timestamp(one_hour_ago))), + ReadSessionConfig::default(), + ) + .await?; + + while let Some(batch) = session.next().await { + let batch = batch?; + for record in batch.records { + println!("[{}] {:?}", record.seq_num, record.body); + } + } + // ANCHOR_END: read-session-timestamp + + // ANCHOR: read-session-until + // Read records until a specific timestamp + let one_hour_ago = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64 + - 3600 * 1000; + let mut session = stream + .read_session( + ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::SeqNum(0))) + .with_stop(ReadStop::new().with_until(..one_hour_ago)), + ReadSessionConfig::default(), + ) + .await?; + + while let Some(batch) = session.next().await { + let batch = batch?; + for record in batch.records { + println!("[{}] {:?}", record.seq_num, record.body); + } + } + // ANCHOR_END: read-session-until + + // ANCHOR: read-session-wait + // Read all available records, and once reaching the current tail, wait an additional 30 seconds + // for new ones + let mut session = stream + .read_session( + ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::SeqNum(0))) + .with_stop(ReadStop::new().with_wait(30)), + ReadSessionConfig::default(), + ) + .await?; + + while let Some(batch) = session.next().await { + let batch = batch?; + for record in batch.records { + println!("[{}] {:?}", record.seq_num, record.body); + } + } + // ANCHOR_END: read-session-wait + + Ok(()) +} diff --git a/sdk/examples/explicit_trim.rs b/sdk/examples/explicit_trim.rs new file mode 100644 index 00000000..c85b2b4b --- /dev/null +++ b/sdk/examples/explicit_trim.rs @@ -0,0 +1,34 @@ +use s2_sdk::{ + S2, + types::{AppendInput, AppendRecordBatch, BasinName, CommandRecord, S2Config, StreamName}, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + let basin_name: BasinName = std::env::var("S2_BASIN") + .map_err(|_| "S2_BASIN env var not set")? + .parse()?; + let stream_name: StreamName = std::env::var("S2_STREAM") + .map_err(|_| "S2_STREAM env var not set")? + .parse()?; + + let s2 = S2::new(S2Config::new(access_token))?; + let stream = s2.basin(basin_name).stream(stream_name); + + let tail = stream.check_tail().await?; + if tail.seq_num == 0 { + println!("Empty stream"); + return Ok(()); + } + + let input = AppendInput::new(AppendRecordBatch::try_from_iter([CommandRecord::trim( + tail.seq_num - 1, + ) + .into()])?); + stream.append(input).await?; + println!("Trim requested"); + + Ok(()) +} diff --git a/sdk/examples/get_latest_record.rs b/sdk/examples/get_latest_record.rs new file mode 100644 index 00000000..aaae3386 --- /dev/null +++ b/sdk/examples/get_latest_record.rs @@ -0,0 +1,29 @@ +use s2_sdk::{ + S2, + types::{ + BasinName, ReadFrom, ReadInput, ReadLimits, ReadStart, ReadStop, S2Config, StreamName, + }, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + let basin_name: BasinName = std::env::var("S2_BASIN") + .map_err(|_| "S2_BASIN env var not set")? + .parse()?; + let stream_name: StreamName = std::env::var("S2_STREAM") + .map_err(|_| "S2_STREAM env var not set")? + .parse()?; + + let s2 = S2::new(S2Config::new(access_token))?; + let stream = s2.basin(basin_name).stream(stream_name); + + let input = ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::TailOffset(1))) + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_count(1))); + let batch = stream.read(input).await?; + println!("{batch:#?}"); + + Ok(()) +} diff --git a/sdk/examples/issue_access_token.rs b/sdk/examples/issue_access_token.rs new file mode 100644 index 00000000..09e63d29 --- /dev/null +++ b/sdk/examples/issue_access_token.rs @@ -0,0 +1,33 @@ +use s2_sdk::{ + S2, + types::{ + AccessTokenScopeInput, BasinMatcher, BasinName, IssueAccessTokenInput, Operation, + OperationGroupPermissions, ReadWritePermissions, S2Config, StreamMatcher, + }, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + let basin_name: BasinName = std::env::var("S2_BASIN") + .map_err(|_| "S2_BASIN env var not set")? + .parse()?; + + let config = S2Config::new(access_token); + let s2 = S2::new(config)?; + + let input = IssueAccessTokenInput::new( + "ro-token".parse()?, + AccessTokenScopeInput::from_op_group_perms( + OperationGroupPermissions::new().with_account(ReadWritePermissions::read_only()), + ) + .with_ops([Operation::CreateStream]) + .with_streams(StreamMatcher::Prefix("audit".parse()?)) + .with_basins(BasinMatcher::Exact(basin_name)), + ); + let issued_token = s2.issue_access_token(input).await?; + println!("Issued access token: {issued_token}"); + + Ok(()) +} diff --git a/sdk/examples/list_all_basins.rs b/sdk/examples/list_all_basins.rs new file mode 100644 index 00000000..599e7d34 --- /dev/null +++ b/sdk/examples/list_all_basins.rs @@ -0,0 +1,22 @@ +use futures_util::{StreamExt, TryStreamExt}; +use s2_sdk::{ + S2, + types::{ListAllBasinsInput, S2Config}, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + + let config = S2Config::new(access_token); + let s2 = S2::new(config)?; + + let input = ListAllBasinsInput::new(); + + let basins: Vec<_> = s2.list_all_basins(input).take(10).try_collect().await?; + + println!("{basins:#?}"); + + Ok(()) +} diff --git a/sdk/examples/list_streams.rs b/sdk/examples/list_streams.rs new file mode 100644 index 00000000..86c35541 --- /dev/null +++ b/sdk/examples/list_streams.rs @@ -0,0 +1,22 @@ +use s2_sdk::{ + S2, + types::{BasinName, ListStreamsInput, S2Config}, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + let basin_name: BasinName = std::env::var("S2_BASIN") + .map_err(|_| "S2_BASIN env var not set")? + .parse()?; + + let s2 = S2::new(S2Config::new(access_token))?; + let basin = s2.basin(basin_name); + + let input = ListStreamsInput::new().with_prefix("my-".parse()?); + let page = basin.list_streams(input).await?; + println!("{page:#?}"); + + Ok(()) +} diff --git a/sdk/examples/producer.rs b/sdk/examples/producer.rs new file mode 100644 index 00000000..5b82998f --- /dev/null +++ b/sdk/examples/producer.rs @@ -0,0 +1,36 @@ +use s2_sdk::{ + S2, + producer::ProducerConfig, + types::{AppendRecord, BasinName, S2Config, StreamName}, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + let basin_name: BasinName = std::env::var("S2_BASIN") + .map_err(|_| "S2_BASIN env var not set")? + .parse()?; + let stream_name: StreamName = std::env::var("S2_STREAM") + .map_err(|_| "S2_STREAM env var not set")? + .parse()?; + + let s2 = S2::new(S2Config::new(access_token))?; + let stream = s2.basin(basin_name).stream(stream_name); + + let producer = stream.producer(ProducerConfig::new()); + + let ticket1 = producer.submit(AppendRecord::new("lorem")?).await?; + let ticket2 = producer.submit(AppendRecord::new("ipsum")?).await?; + + producer.flush().await?; + + let ack1 = ticket1.await?; + let ack2 = ticket2.await?; + println!("Record 1 seq_num: {}", ack1.seq_num); + println!("Record 2 seq_num: {}", ack2.seq_num); + + producer.close().await?; + + Ok(()) +} diff --git a/sdk/examples/reconfigure_basin.rs b/sdk/examples/reconfigure_basin.rs new file mode 100644 index 00000000..a1946d28 --- /dev/null +++ b/sdk/examples/reconfigure_basin.rs @@ -0,0 +1,34 @@ +use s2_sdk::{ + S2, + types::{ + BasinName, BasinReconfiguration, ReconfigureBasinInput, S2Config, StreamReconfiguration, + TimestampingReconfiguration, + }, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + let basin_name: BasinName = std::env::var("S2_BASIN") + .map_err(|_| "S2_BASIN env var not set")? + .parse()?; + + let config = S2Config::new(access_token); + let s2 = S2::new(config)?; + + let input = ReconfigureBasinInput::new( + basin_name, + BasinReconfiguration::new() + .with_default_stream_config( + StreamReconfiguration::new() + .with_storage_class("standard") + .with_timestamping(TimestampingReconfiguration::new().with_uncapped(true)), + ) + .with_create_stream_on_read(true), + ); + let config = s2.reconfigure_basin(input).await?; + println!("{config:#?}"); + + Ok(()) +} diff --git a/sdk/examples/reconfigure_stream.rs b/sdk/examples/reconfigure_stream.rs new file mode 100644 index 00000000..bc38dfb0 --- /dev/null +++ b/sdk/examples/reconfigure_stream.rs @@ -0,0 +1,31 @@ +use s2_sdk::{ + S2, + types::{ + BasinName, ReconfigureStreamInput, RetentionPolicy, S2Config, StreamName, + StreamReconfiguration, + }, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + let basin_name: BasinName = std::env::var("S2_BASIN") + .map_err(|_| "S2_BASIN env var not set")? + .parse()?; + let stream_name: StreamName = std::env::var("S2_STREAM") + .map_err(|_| "S2_STREAM env var not set")? + .parse()?; + + let s2 = S2::new(S2Config::new(access_token))?; + let basin = s2.basin(basin_name); + + let input = ReconfigureStreamInput::new( + stream_name, + StreamReconfiguration::new().with_retention_policy(RetentionPolicy::Age(10 * 24 * 60 * 60)), + ); + let config = basin.reconfigure_stream(input).await?; + println!("{config:#?}"); + + Ok(()) +} diff --git a/sdk/src/api.rs b/sdk/src/api.rs new file mode 100644 index 00000000..92d442f7 --- /dev/null +++ b/sdk/src/api.rs @@ -0,0 +1,1908 @@ +use std::{ops::Deref, pin::Pin, sync::Arc, time::Duration}; + +use async_stream::try_stream; +use async_trait::async_trait; +use bytes::BytesMut; +use futures_core::Stream; +use futures_util::StreamExt; +use http::{ + HeaderMap, HeaderValue, StatusCode, Uri, + header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, +}; +use prost::{self, Message}; +use s2_api::v1::{ + access::{ + IssueAccessTokenRequest, IssueAccessTokenResponse, ListAccessTokensRequest, + ListAccessTokensResponse, + }, + basin::{ + BasinInfo, CreateBasinRequest, EnsureBasinRequest, ListBasinsRequest, ListBasinsResponse, + }, + config::{ + BasinConfig, BasinReconfiguration, STREAM_CONFIG_HEADER, StreamConfig, + StreamReconfiguration, + }, + location::LocationInfo, + metrics::{ + AccountMetricSetRequest, BasinMetricSetRequest, MetricSetResponse, StreamMetricSetRequest, + }, + stream::{ + AppendConditionFailed, CreateStreamRequest, ListStreamsRequest, ListStreamsResponse, + ReadEnd, ReadStart, StreamInfo, TailResponse, + proto::{AppendAck, AppendInput, ReadBatch}, + s2s::{self, FrameDecoder, SessionMessage, TerminalMessage}, + }, +}; +use s2_common::{ + encryption::S2_ENCRYPTION_KEY_HEADER, + resources::{PROVISION_RESULT_HEADER, ProvisionResult}, +}; +use secrecy::ExposeSecret; +use tokio_util::codec::Decoder; +use tracing::{debug, warn}; + +use crate::{ + client::{self, StreamingResponse, UnaryResponse}, + error::{ClientError, server_error_has_no_side_effects, server_error_is_retryable}, + frame_signal::FrameSignal, + reconnect::ReconnectAdvice, + retry::{AppendRetryError, RetryBackoff, RetryBackoffBuilder}, + types::{ + AccessToken, AccessTokenId, AccessTokenMode, AppendRetryPolicy, BasinAuthority, BasinName, + Compression, EncryptionKey, LocationName, RetryConfig, S2Config, S2Endpoints, StreamName, + }, +}; +const CONTENT_TYPE_S2S: &str = "s2s/proto"; +const CONTENT_TYPE_PROTO: &str = "application/protobuf"; +const ACCEPT_PROTO: &str = "application/protobuf"; +const S2_REQUEST_TOKEN: &str = "s2-request-token"; +const S2_BASIN: &str = "s2-basin"; +const RETRY_AFTER_MS_HEADER: &str = "retry-after-ms"; + +#[derive(Debug, Clone)] +pub struct AccountClient { + pub client: BaseClient, + pub config: Arc, + pub base_url: Uri, +} + +impl AccountClient { + pub fn init(config: S2Config, client: BaseClient) -> Self { + let base_url = base_url(&config.endpoints, ClientKind::Account); + Self { + client, + config: Arc::new(config), + base_url, + } + } + + pub fn basin_client(&self, name: BasinName) -> BasinClient { + BasinClient::init(name, self.config.clone(), self.client.clone()) + } + + fn uri(&self, path: impl AsRef) -> Uri { + client::uri_with_path(&self.base_url, path) + } + + pub async fn list_access_tokens( + &self, + request: ListAccessTokensRequest, + ) -> Result { + let url = self.uri("v1/access-tokens"); + let request = self.get(url).query(&request).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn issue_access_token( + &self, + request: IssueAccessTokenRequest, + ) -> Result { + let url = self.uri("v1/access-tokens"); + let request = self.post(url).json(&request).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn revoke_access_token(&self, id: AccessTokenId) -> Result<(), ApiError> { + let url = self.uri(format!("v1/access-tokens/{}", urlencoding::encode(&id))); + let request = self.delete(url).build()?; + let _response = self.request(request).send().await?; + Ok(()) + } + + pub async fn list_locations(&self) -> Result, ApiError> { + let url = self.uri("v1/locations"); + let request = self.get(url).build()?; + let response = self.request(request).send().await?; + Ok(response.json::>()?) + } + + pub async fn get_default_location(&self) -> Result { + let url = self.uri("v1/locations/default"); + let request = self.get(url).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn set_default_location( + &self, + location: LocationName, + ) -> Result { + let url = self.uri("v1/locations/default"); + let request = self.put(url).json(&location).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn list_basins( + &self, + request: ListBasinsRequest, + ) -> Result { + let url = self.uri("v1/basins"); + let request = self.get(url).query(&request).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn create_basin( + &self, + request: CreateBasinRequest, + idempotency_token: String, + ) -> Result { + let url = self.uri("v1/basins"); + let request = self + .post(url) + .header(S2_REQUEST_TOKEN, idempotency_token) + .json(&request) + .build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn get_basin_config(&self, name: BasinName) -> Result { + let url = self.uri(format!("v1/basins/{name}")); + let request = self.get(url).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn reconfigure_basin( + &self, + name: BasinName, + config: BasinReconfiguration, + ) -> Result { + let url = self.uri(format!("v1/basins/{name}")); + let request = self.patch(url).json(&config).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn ensure_basin( + &self, + name: BasinName, + request: Option, + ) -> Result, ApiError> { + let url = self.uri(format!("v1/basins/{name}")); + let request = match request { + Some(body) => self.put(url).json(&body).build()?, + None => self.put(url).build()?, + }; + let response = self.request(request).send().await?; + let status = response.status(); + let provision_result_header_value = provision_result_header_value(&response); + let info = response.json::()?; + Ok(provision_result_from_parts( + status, + provision_result_header_value.as_deref(), + info, + )) + } + + pub async fn delete_basin( + &self, + name: BasinName, + ignore_not_found: bool, + ) -> Result<(), ApiError> { + let url = self.uri(format!("v1/basins/{name}")); + let request = self.delete(url).build()?; + self.request(request) + .send() + .await + .ignore_not_found(ignore_not_found)?; + Ok(()) + } + + pub async fn get_account_metrics( + &self, + request: AccountMetricSetRequest, + ) -> Result { + let url = self.uri("v1/metrics"); + let request = self.get(url).query(&request).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn get_basin_metrics( + &self, + name: BasinName, + request: BasinMetricSetRequest, + ) -> Result { + let url = self.uri(format!("v1/metrics/{name}")); + let request = self.get(url).query(&request).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn get_stream_metrics( + &self, + basin_name: BasinName, + stream_name: StreamName, + request: StreamMetricSetRequest, + ) -> Result { + let url = self.uri(format!( + "v1/metrics/{basin_name}/{}", + urlencoding::encode(&stream_name) + )); + let request = self.get(url).query(&request).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } +} + +impl Deref for AccountClient { + type Target = BaseClient; + + fn deref(&self) -> &Self::Target { + &self.client + } +} + +#[derive(Debug, Clone)] +pub struct BasinClient { + pub name: BasinName, + pub client: BaseClient, + pub config: Arc, + pub base_url: Uri, +} + +impl BasinClient { + pub fn init(name: BasinName, config: Arc, client: BaseClient) -> Self { + let base_url = base_url(&config.endpoints, ClientKind::Basin(name.clone())); + Self { + name, + client, + config, + base_url, + } + } + + fn uri(&self, path: impl AsRef) -> Uri { + client::uri_with_path(&self.base_url, path) + } + + fn request(&self, mut request: client::Request) -> RequestBuilder<'_> { + if matches!( + self.config.endpoints.basin_authority, + BasinAuthority::Direct(_) + ) { + request.headers_mut().insert( + S2_BASIN, + HeaderValue::from_str(&self.name).expect("valid header value"), + ); + } + self.client.request(request) + } + + pub async fn list_streams( + &self, + request: ListStreamsRequest, + ) -> Result { + let url = self.uri("v1/streams"); + let request = self.get(url).query(&request).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn create_stream( + &self, + request: CreateStreamRequest, + idempotency_token: String, + ) -> Result { + let url = self.uri("v1/streams"); + let request = self + .post(url) + .header(S2_REQUEST_TOKEN, idempotency_token) + .json(&request) + .build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn get_stream_config(&self, name: StreamName) -> Result { + let url = self.uri(format!("v1/streams/{}", urlencoding::encode(&name))); + let request = self.get(url).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn reconfigure_stream( + &self, + name: StreamName, + config: StreamReconfiguration, + ) -> Result { + let url = self.uri(format!("v1/streams/{}", urlencoding::encode(&name))); + let request = self.patch(url).json(&config).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn ensure_stream( + &self, + name: StreamName, + config: Option, + ) -> Result, ApiError> { + let url = self.uri(format!("v1/streams/{}", urlencoding::encode(&name))); + let request = match config { + Some(body) => self.put(url).json(&body).build()?, + None => self.put(url).build()?, + }; + let response = self.request(request).send().await?; + let status = response.status(); + let provision_result_header_value = provision_result_header_value(&response); + let info = response.json::()?; + Ok(provision_result_from_parts( + status, + provision_result_header_value.as_deref(), + info, + )) + } + + pub async fn delete_stream( + &self, + name: StreamName, + ignore_not_found: bool, + ) -> Result<(), ApiError> { + let url = self.uri(format!("v1/streams/{}", urlencoding::encode(&name))); + let request = self.delete(url).build()?; + self.request(request) + .send() + .await + .ignore_not_found(ignore_not_found)?; + Ok(()) + } + + pub async fn check_tail(&self, name: &StreamName) -> Result { + let url = self.uri(format!( + "v1/streams/{}/records/tail", + urlencoding::encode(name) + )); + let request = self.get(url).build()?; + let response = self.request(request).send().await?; + Ok(response.json::()?) + } + + pub async fn append( + &self, + name: &StreamName, + input: AppendInput, + encryption: Option<&EncryptionKey>, + stream_config: Option<&StreamConfig>, + append_retry_policy: AppendRetryPolicy, + ) -> Result { + let url = self.uri(format!("v1/streams/{}/records", urlencoding::encode(name))); + let mut request = self + .post(url) + .header(CONTENT_TYPE, CONTENT_TYPE_PROTO) + .header(ACCEPT, ACCEPT_PROTO) + .body(input.encode_to_vec()) + .build()?; + set_encryption_header(&mut request, encryption); + set_stream_config_header(&mut request, stream_config); + let response = self + .request(request) + .with_append_retry_policy(append_retry_policy) + .error_handler(|status, response| { + if status == StatusCode::PRECONDITION_FAILED { + Err(ApiError::AppendConditionFailed( + response.json::()?, + )) + } else { + Err(ApiError::Server( + status, + response.json::()?, + )) + } + }) + .send() + .await?; + Ok(AppendAck::decode(response.into_bytes())?) + } + + pub async fn read( + &self, + name: &StreamName, + start: ReadStart, + end: ReadEnd, + encryption: Option<&EncryptionKey>, + stream_config: Option<&StreamConfig>, + ) -> Result { + let url = self.uri(format!("v1/streams/{}/records", urlencoding::encode(name))); + let mut builder = self + .get(url) + .header(ACCEPT, ACCEPT_PROTO) + .query(&start) + .query(&end); + if let Some(wait) = end.wait { + builder = + builder.timeout(self.client.request_timeout + Duration::from_secs(wait.into())); + } + let mut request = builder.build()?; + set_encryption_header(&mut request, encryption); + set_stream_config_header(&mut request, stream_config); + let response = self + .request(request) + .error_handler(read_response_error_handler) + .send() + .await?; + Ok(ReadBatch::decode(response.into_bytes())?) + } + + pub async fn append_session( + &self, + name: &StreamName, + inputs: I, + encryption: Option<&EncryptionKey>, + stream_config: Option<&StreamConfig>, + frame_signal: Option, + reconnect: ReconnectAdvice, + ) -> Result, ApiError> + where + I: Stream + Send + 'static, + { + let url = self.uri(format!("v1/streams/{}/records", urlencoding::encode(name))); + + let compression = self.config.compression.into(); + + let encoded_stream = inputs.map(move |input| { + s2s::SessionMessage::regular(compression, &input).map(|msg| msg.encode()) + }); + + let body = client::Body::wrap_stream(encoded_stream); + let body = match frame_signal { + Some(signal) => body.monitored(signal), + None => body, + }; + + let mut request_builder = self + .client + .post(url) + .header(CONTENT_TYPE, CONTENT_TYPE_S2S) + .body(body) + .timeout(self.client.request_timeout); + request_builder = + add_basin_header_if_required(request_builder, &self.config.endpoints, &self.name); + let mut request = request_builder.build()?; + set_encryption_header(&mut request, encryption); + set_stream_config_header(&mut request, stream_config); + let (response, access_token) = self.client.init_streaming_authorized(request).await?; + let response = match response.into_result().await { + Ok(response) => response, + Err(error) => { + self.client + .invalidate_access_token_if_rejected(&error, access_token.as_deref()); + return Err(error); + } + }; + let (mut bytes_stream, poison_handle) = response.into_stream(); + let auth_client = self.client.clone(); + + let mut buffer = BytesMut::new(); + let mut decoder = FrameDecoder; + + Ok(Box::pin(try_stream! { + let mut advice_seen = false; + + while let Some(chunk) = bytes_stream.next().await { + let chunk = chunk?; + buffer.extend_from_slice(&chunk); + + loop { + match decoder.decode(&mut buffer) { + Ok(Some(SessionMessage::Regular(msg))) => { + if !advice_seen && msg.reconnect_advised() { + advice_seen = true; + poison_handle.poison(); + reconnect.advise(); + } + yield msg.try_into_proto()?; + } + Ok(Some(SessionMessage::Terminal(msg))) => { + let error: ApiError = msg.into(); + if error.is_server_draining() { + poison_handle.poison(); + } + auth_client.invalidate_access_token_if_rejected( + &error, + access_token.as_deref(), + ); + Err::<(), ApiError>(error)?; + } + Ok(None) => break, + Err(err) => Err(err)?, + } + } + } + if !buffer.is_empty() { + Err(ClientError::UnexpectedEof( + format!("not all bytes were consumed from the buffer, {} remaining", buffer.len()), + ))?; + } + })) + } + + pub async fn read_session( + &self, + name: &StreamName, + start: ReadStart, + end: ReadEnd, + encryption: Option<&EncryptionKey>, + stream_config: Option<&StreamConfig>, + reconnect: ReconnectAdvice, + ) -> Result, ApiError> { + let url = self.uri(format!("v1/streams/{}/records", urlencoding::encode(name))); + + let mut request_builder = self + .client + .get(url) + .header(CONTENT_TYPE, CONTENT_TYPE_S2S) + .query(&start) + .query(&end) + .timeout(self.client.request_timeout); + request_builder = + add_basin_header_if_required(request_builder, &self.config.endpoints, &self.name); + let mut request = request_builder.build()?; + set_encryption_header(&mut request, encryption); + set_stream_config_header(&mut request, stream_config); + let (response, access_token) = self.client.init_streaming_authorized(request).await?; + let response = match response.into_result().await { + Ok(response) => response, + Err(error) => { + self.client + .invalidate_access_token_if_rejected(&error, access_token.as_deref()); + return Err(error); + } + }; + let (mut bytes_stream, poison_handle) = response.into_stream(); + let auth_client = self.client.clone(); + + let mut buffer = BytesMut::new(); + let mut decoder = FrameDecoder; + + Ok(Box::pin(try_stream! { + let mut advice_seen = false; + + while let Some(chunk) = bytes_stream.next().await { + let chunk = chunk?; + buffer.extend_from_slice(&chunk); + + loop { + match decoder.decode(&mut buffer) { + Ok(Some(SessionMessage::Regular(msg))) => { + if !advice_seen && msg.reconnect_advised() { + advice_seen = true; + poison_handle.poison(); + reconnect.advise(); + } + yield msg.try_into_proto()?; + } + Ok(Some(SessionMessage::Terminal(msg))) => { + let error: ApiError = msg.into(); + if error.is_server_draining() { + poison_handle.poison(); + } + auth_client.invalidate_access_token_if_rejected( + &error, + access_token.as_deref(), + ); + Err::<(), ApiError>(error)?; + } + Ok(None) => break, + Err(err) => Err(err)?, + } + } + } + if !buffer.is_empty() { + Err(ClientError::UnexpectedEof( + format!("not all bytes were consumed from the buffer, {} remaining", buffer.len()), + ))?; + } + })) + } +} + +fn read_response_error_handler( + status: StatusCode, + response: UnaryResponse, +) -> Result { + if status == StatusCode::RANGE_NOT_SATISFIABLE { + Err(ApiError::ReadUnwritten(response.json::()?)) + } else { + Err(ApiError::Server( + status, + response.json::()?, + )) + } +} + +impl Deref for BasinClient { + type Target = BaseClient; + + fn deref(&self) -> &Self::Target { + &self.client + } +} + +#[derive(Debug, thiserror::Error, serde::Deserialize)] +#[error("{code}: {message}")] +pub(crate) struct ServerErrorBody { + pub code: String, + pub message: String, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ApiError { + #[error(transparent)] + Client(#[from] ClientError), + #[error(transparent)] + ProtoDecode(#[from] prost::DecodeError), + #[error(transparent)] + TerminalDecode(#[from] TerminalDecodeError), + #[error("malformed access token: {0}")] + MalformedAccessToken(String), + #[cfg(feature = "_hidden")] + #[error("access token provider failed: {0}")] + AccessTokenProvider(crate::types::AccessTokenProviderError), + #[error(transparent)] + Compression(#[from] std::io::Error), + #[error("append condition check failed")] + AppendConditionFailed(AppendConditionFailed), + #[error( + "append may have taken effect in an earlier attempt; final attempt failed: {final_attempt_error}" + )] + IndefiniteFailure { + #[source] + final_attempt_error: Box, + }, + #[error("read from an unwritten position")] + ReadUnwritten(TailResponse), + #[error("{1}")] + Server(StatusCode, ServerErrorBody), +} + +impl ApiError { + pub fn is_retryable(&self) -> bool { + match self { + Self::Server(status, err_resp) => server_error_is_retryable(*status, &err_resp.code), + Self::Client(err) => err.is_retryable(), + Self::IndefiniteFailure { + final_attempt_error, + } => final_attempt_error.is_retryable(), + #[cfg(feature = "_hidden")] + Self::AccessTokenProvider(error) => error.is_retryable(), + _ => false, + } + } + + pub(crate) fn is_server_draining(&self) -> bool { + matches!( + self, + Self::Server(StatusCode::SERVICE_UNAVAILABLE, response) + if response.code == "server_draining" + ) + } + + pub(crate) fn is_authentication_error(&self) -> bool { + matches!( + self, + Self::Server(StatusCode::UNAUTHORIZED, response) if response.code == "authn" + ) + } +} + +impl AppendRetryError for ApiError { + fn has_no_side_effects(&self) -> bool { + match self { + Self::Server(status, err_resp) => { + server_error_has_no_side_effects(*status, &err_resp.code) + } + Self::Client(err) => err.has_no_side_effects(), + Self::AppendConditionFailed(_) | Self::MalformedAccessToken(_) => true, + #[cfg(feature = "_hidden")] + Self::AccessTokenProvider(_) => true, + _ => false, + } + } + + fn into_indefinite_failure(self) -> Self { + Self::IndefiniteFailure { + final_attempt_error: Box::new(self), + } + } +} + +impl From for ApiError { + fn from(err: client::HttpError) -> Self { + ClientError::from(err).into() + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum TerminalDecodeError { + #[error("invalid status code: {0}")] + InvalidStatusCode(#[from] http::status::InvalidStatusCode), + #[error("failed to parse error response: {0}")] + JsonDecode(#[from] serde_json::Error), +} + +impl From for ApiError { + fn from(msg: TerminalMessage) -> Self { + let status = match StatusCode::from_u16(msg.status) { + Ok(status) => status, + Err(err) => return ApiError::TerminalDecode(err.into()), + }; + if status == StatusCode::PRECONDITION_FAILED { + let condition_failed = match serde_json::from_str::(&msg.body) { + Ok(condition_failed) => condition_failed, + Err(err) => { + return ApiError::TerminalDecode(err.into()); + } + }; + ApiError::AppendConditionFailed(condition_failed) + } else if status == StatusCode::RANGE_NOT_SATISFIABLE { + let tail = match serde_json::from_str::(&msg.body) { + Ok(tail) => tail, + Err(err) => { + return ApiError::TerminalDecode(err.into()); + } + }; + ApiError::ReadUnwritten(tail) + } else { + let response = match serde_json::from_str::(&msg.body) { + Ok(response) => response, + Err(err) => { + return ApiError::TerminalDecode(err.into()); + } + }; + ApiError::Server(status, response) + } + } +} + +pub type Streaming = Pin>>>; + +fn authorization_header(access_token: &str) -> Result { + let mut header = HeaderValue::try_from(format!("Bearer {access_token}")) + .map_err(|error| ApiError::MalformedAccessToken(error.to_string()))?; + header.set_sensitive(true); + Ok(header) +} + +#[derive(Clone)] +pub struct BaseClient { + client: Arc, + default_headers: HeaderMap, + access_token_mode: AccessTokenMode, + #[cfg(feature = "_hidden")] + access_token_provider: Option>, + request_timeout: Duration, + retry_builder: RetryBackoffBuilder, + compression: Compression, +} + +impl std::fmt::Debug for BaseClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BaseClient").finish_non_exhaustive() + } +} + +impl BaseClient { + pub fn init(config: &S2Config) -> Result { + let connector = client::default_connector( + Some(config.connection_timeout), + config.insecure_skip_cert_verification, + config.rustls_crypto_provider.clone(), + ) + .map_err(|e| { + ClientError::Configuration(format!("failed to initialize TLS connector: {e}")) + })?; + Self::init_with_connector(config, connector) + } + + pub fn init_with_connector(config: &S2Config, connector: C) -> Result + where + C: client::Connect + Clone + Send + Sync + 'static, + { + let access_token_mode = config.access_token.mode(); + let mut default_headers = config.default_headers.clone(); + // Authorization belongs to the configured token, including when a + // refreshable provider supplies it immediately before each attempt. + default_headers.remove(AUTHORIZATION); + #[cfg(feature = "_hidden")] + let mut access_token_provider = None; + match &config.access_token { + AccessToken::Static(access_token) => { + default_headers.insert( + AUTHORIZATION, + authorization_header(access_token.expose_secret())?, + ); + } + #[cfg(feature = "_hidden")] + AccessToken::Provider(provider) => { + access_token_provider = Some(provider.clone()); + } + } + default_headers.insert(http::header::USER_AGENT, config.user_agent.clone()); + match config.compression { + Compression::Gzip => { + default_headers.insert( + http::header::ACCEPT_ENCODING, + HeaderValue::from_static("gzip"), + ); + } + Compression::Zstd => { + default_headers.insert( + http::header::ACCEPT_ENCODING, + HeaderValue::from_static("zstd"), + ); + } + Compression::None => {} + } + + let client = client::Pool::new(connector, config.http2); + + Ok(Self { + client: Arc::new(client), + default_headers, + access_token_mode, + #[cfg(feature = "_hidden")] + access_token_provider, + request_timeout: config.request_timeout, + retry_builder: retry_builder(&config.retry), + compression: config.compression, + }) + } + + pub fn get(&self, uri: Uri) -> client::RequestBuilder { + client::RequestBuilder::get(uri) + .timeout(self.request_timeout) + .headers(&self.default_headers) + } + + pub fn post(&self, uri: Uri) -> client::RequestBuilder { + client::RequestBuilder::post(uri) + .timeout(self.request_timeout) + .headers(&self.default_headers) + .compression(self.compression) + } + + pub fn patch(&self, uri: Uri) -> client::RequestBuilder { + client::RequestBuilder::patch(uri) + .timeout(self.request_timeout) + .headers(&self.default_headers) + .compression(self.compression) + } + + pub fn put(&self, uri: Uri) -> client::RequestBuilder { + client::RequestBuilder::put(uri) + .timeout(self.request_timeout) + .headers(&self.default_headers) + .compression(self.compression) + } + + pub fn delete(&self, uri: Uri) -> client::RequestBuilder { + client::RequestBuilder::delete(uri) + .timeout(self.request_timeout) + .headers(&self.default_headers) + } + + pub async fn init_streaming( + &self, + request: client::Request, + ) -> Result { + self.client.init_streaming(request).await + } + + #[cfg(not(feature = "_hidden"))] + async fn init_streaming_authorized( + &self, + request: client::Request, + ) -> Result<(StreamingResponse, Option), ApiError> { + self.init_streaming(request) + .await + .map(|response| (response, None)) + .map_err(ApiError::from) + } + + #[cfg(feature = "_hidden")] + async fn init_streaming_authorized( + &self, + mut request: client::Request, + ) -> Result<(StreamingResponse, Option), ApiError> { + let access_token = self.authorize(&mut request).await?; + let response = self.init_streaming(request).await.map_err(ApiError::from)?; + Ok((response, access_token)) + } + + async fn execute_unary( + &self, + mut request: client::Request, + ) -> Result<(UnaryResponse, Option), ApiError> { + let access_token = self.authorize(&mut request).await?; + let response = self + .client + .execute_unary(request) + .await + .map_err(ApiError::from)?; + Ok((response, access_token)) + } + + async fn authorize(&self, _request: &mut client::Request) -> Result, ApiError> { + #[cfg(feature = "_hidden")] + if let Some(provider) = self.access_token_provider.as_ref() { + let access_token = provider + .access_token() + .await + .map_err(ApiError::AccessTokenProvider)?; + _request + .headers_mut() + .insert(AUTHORIZATION, authorization_header(&access_token)?); + return Ok(Some(access_token)); + } + Ok(None) + } + + fn invalidate_access_token(&self, _access_token: Option<&str>) { + #[cfg(feature = "_hidden")] + if let (Some(provider), Some(access_token)) = + (self.access_token_provider.as_ref(), _access_token) + { + provider.invalidate_access_token(access_token); + } + } + + fn invalidate_access_token_if_rejected(&self, error: &ApiError, access_token: Option<&str>) { + if error.is_authentication_error() { + self.invalidate_access_token(access_token); + } + } + + fn request(&self, request: client::Request) -> RequestBuilder<'_> { + RequestBuilder { + client: self, + request, + retry_enabled: true, + append_retry_policy: None, + frame_signal: None, + error_handler: None, + } + } +} + +fn set_encryption_header(request: &mut client::Request, encryption: Option<&EncryptionKey>) { + if let Some(encryption) = encryption { + request.headers_mut().insert( + S2_ENCRYPTION_KEY_HEADER.clone(), + encryption.to_header_value(), + ); + } +} + +fn set_stream_config_header(request: &mut client::Request, stream_config: Option<&StreamConfig>) { + if let Some(config) = stream_config { + request + .headers_mut() + .insert(STREAM_CONFIG_HEADER.clone(), config.to_header_value()); + } +} + +pub fn retry_builder(config: &RetryConfig) -> RetryBackoffBuilder { + RetryBackoffBuilder::default() + .with_min_base_delay(config.min_base_delay) + .with_max_base_delay(config.max_base_delay) + .with_max_retries(config.max_retries()) +} + +type ErrorHandlerFn = + Box Result + Send + Sync>; + +struct RequestBuilder<'a> { + client: &'a BaseClient, + request: client::Request, + retry_enabled: bool, + append_retry_policy: Option, + frame_signal: Option, + error_handler: Option, +} + +impl<'a> RequestBuilder<'a> { + fn with_append_retry_policy(self, policy: AppendRetryPolicy) -> Self { + let frame_signal = match policy { + AppendRetryPolicy::NoSideEffects => Some(FrameSignal::new()), + AppendRetryPolicy::All => None, + }; + Self { + append_retry_policy: Some(policy), + frame_signal, + ..self + } + } + + fn error_handler(self, handler: F) -> Self + where + F: Fn(StatusCode, UnaryResponse) -> Result + Send + Sync + 'static, + { + Self { + error_handler: Some(Box::new(handler)), + ..self + } + } + + async fn send(self) -> Result { + let request = self.request; + + let mut retry_backoff: Option = self + .retry_enabled + .then(|| self.client.retry_builder.build()); + let mut prior_uncertainty = false; + + loop { + if let Some(ref signal) = self.frame_signal { + signal.reset(); + } + + let attempt_request = { + let mut r = request.try_clone().expect("body should not be a stream"); + if let Some(ref signal) = self.frame_signal { + r = r.compress().await.map_err(ApiError::from)?; + r = r.with_monitored_body(signal.clone()); + } + r + }; + + let response = self.client.execute_unary(attempt_request).await; + + let (err, retry_after, access_token) = match response { + Ok((resp, access_token)) => { + let retry_after: Option = resp + .headers() + .get(RETRY_AFTER_MS_HEADER) + .and_then(|v| match v.to_str() { + Ok(s) => Some(s), + Err(e) => { + warn!( + ?e, + "failed to parse {RETRY_AFTER_MS_HEADER} header as string" + ); + None + } + }) + .and_then(|v| match v.parse::() { + Ok(ms) => Some(ms), + Err(e) => { + warn!(?e, "failed to parse {RETRY_AFTER_MS_HEADER} header as u64"); + None + } + }) + .map(Duration::from_millis); + + let result = if let Some(ref handler) = self.error_handler { + resp.into_result_with_handler(handler) + } else { + resp.into_result() + }; + + match result { + Ok(resp) => { + return Ok(resp); + } + Err(err) => (err, retry_after, access_token), + } + } + Err(err) => (err, None, None), + }; + + let refreshable_authentication_error = + self.client.access_token_mode.is_refreshable() && err.is_authentication_error(); + if refreshable_authentication_error { + self.client.invalidate_access_token(access_token.as_deref()); + } + + if is_safe_to_retry( + &err, + self.append_retry_policy, + self.frame_signal.as_ref(), + self.client.access_token_mode, + ) && let Some(backoff) = retry_backoff.as_mut().and_then(|b| b.next()) + { + let backoff = retry_after.map_or(backoff, |ra| ra.max(backoff)); + debug!( + %err, + ?backoff, + num_retries_remaining = retry_backoff.as_ref().map(|b| b.remaining()).unwrap_or(0), + "retrying request" + ); + if self.append_retry_policy.is_some() + && err.attempt_may_have_side_effects(self.frame_signal.as_ref()) + { + prior_uncertainty = true; + } + tokio::time::sleep(backoff).await; + } else { + debug!( + %err, + is_retryable = err.is_retryable() || refreshable_authentication_error, + retry_enabled = self.retry_enabled, + retries_exhausted = retry_backoff.as_ref().is_none_or(|b| b.is_exhausted()), + "not retrying request" + ); + return Err(err.with_prior_uncertainty(prior_uncertainty)); + } + } + } +} + +fn is_safe_to_retry( + err: &ApiError, + policy: Option, + frame_signal: Option<&FrameSignal>, + access_token_mode: AccessTokenMode, +) -> bool { + let policy_compliant = match policy { + None | Some(AppendRetryPolicy::All) => true, + Some(AppendRetryPolicy::NoSideEffects) => !err.attempt_may_have_side_effects(frame_signal), + }; + policy_compliant + && (err.is_retryable() + || (access_token_mode.is_refreshable() && err.is_authentication_error())) +} + +fn add_basin_header_if_required( + request: client::RequestBuilder, + endpoints: &S2Endpoints, + name: &BasinName, +) -> client::RequestBuilder { + if matches!(endpoints.basin_authority, BasinAuthority::Direct(_)) { + return request.header( + S2_BASIN, + HeaderValue::from_str(name).expect("valid header value"), + ); + } + request +} + +#[derive(Debug, Clone)] +enum ClientKind { + Account, + Basin(BasinName), +} + +fn base_url(endpoints: &S2Endpoints, kind: ClientKind) -> Uri { + let authority = match kind { + ClientKind::Account => endpoints.account_authority.clone(), + ClientKind::Basin(basin) => match &endpoints.basin_authority { + BasinAuthority::ParentZone(zone) => format!("{basin}.{zone}") + .try_into() + .expect("valid authority as basin pre-validated"), + BasinAuthority::Direct(endpoint) => endpoint.clone(), + }, + }; + let scheme = &endpoints.scheme; + format!("{scheme}://{authority}") + .parse() + .expect("valid URI") +} + +trait UnaryResult { + fn into_result(self) -> Result; + fn into_result_with_handler(self, handler: F) -> Result + where + F: FnOnce(StatusCode, UnaryResponse) -> Result; +} + +impl UnaryResult for UnaryResponse { + fn into_result(self) -> Result { + let status = self.status(); + if status.is_success() { + Ok(self) + } else { + Err(ApiError::Server(status, self.json::()?)) + } + } + + fn into_result_with_handler(self, handler: F) -> Result + where + F: FnOnce(StatusCode, UnaryResponse) -> Result, + { + let status = self.status(); + if status.is_success() { + Ok(self) + } else { + handler(status, self) + } + } +} + +#[async_trait] +trait StreamingResult { + async fn into_result(self) -> Result; +} + +#[async_trait] +impl StreamingResult for StreamingResponse { + async fn into_result(self) -> Result { + if self.status().is_success() { + return Ok(self); + } + + let status = self.status(); + let (bytes, poison_handle) = self.into_bytes_with_poison_handle().await?; + if status == StatusCode::RANGE_NOT_SATISFIABLE + && let Ok(tail) = serde_json::from_slice::(&bytes) + { + return Err(ApiError::ReadUnwritten(tail)); + } + match serde_json::from_slice::(&bytes) { + Ok(response) => { + let error = ApiError::Server(status, response); + if error.is_server_draining() { + poison_handle.poison(); + } + Err(error) + } + Err(error) => Err(ApiError::Client(ClientError::ResponseDecode(format!( + "could not decode server error {status}: {error}; body: {}", + String::from_utf8_lossy(&bytes), + )))), + } + } +} + +trait IgnoreNotFound { + fn ignore_not_found(self, enabled: bool) -> Result<(), ApiError>; +} + +impl IgnoreNotFound for Result { + fn ignore_not_found(self, enabled: bool) -> Result<(), ApiError> { + match self { + Ok(_) => Ok(()), + Err(ApiError::Server(StatusCode::NOT_FOUND, _)) if enabled => Ok(()), + Err(err) => Err(err), + } + } +} + +fn provision_result_header_value(response: &UnaryResponse) -> Option { + response + .headers() + .get(&PROVISION_RESULT_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) +} + +fn provision_result_from_parts( + status: StatusCode, + header_value: Option<&str>, + info: T, +) -> ProvisionResult { + match header_value { + Some("created") => ProvisionResult::Created(info), + Some("noop") => ProvisionResult::Noop(info), + Some("updated") => ProvisionResult::Updated(info), + _ if status == StatusCode::CREATED => ProvisionResult::Created(info), + _ => ProvisionResult::Updated(info), + } +} + +#[cfg(test)] +mod tests { + use std::error::Error; + #[cfg(feature = "_hidden")] + use std::sync::Mutex; + #[cfg(feature = "_hidden")] + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + #[cfg(feature = "_hidden")] + use async_trait::async_trait; + #[cfg(feature = "_hidden")] + use hyper_util::client::legacy::connect::HttpConnector; + + use super::*; + use crate::error::AppendError; + + #[cfg(feature = "_hidden")] + #[derive(Default)] + struct HeaderCapture { + unary: Mutex>, + streaming: Mutex>, + } + + #[cfg(feature = "_hidden")] + #[async_trait] + impl client::RequestExecutor for HeaderCapture { + async fn execute_unary( + &self, + mut request: client::Request, + ) -> Result { + let mut headers = self.unary.lock().unwrap(); + headers.push(request.headers_mut().clone()); + // Exercise a real retry through RequestBuilder::send. + Ok(if headers.len() == 1 { + UnaryResponse::new_for_test( + StatusCode::SERVICE_UNAVAILABLE, + br#"{"code":"unavailable","message":"retry"}"#.to_vec(), + ) + } else { + UnaryResponse::new_for_test( + StatusCode::OK, + br#"{"basins":[],"streams":[],"has_more":false}"#.to_vec(), + ) + }) + } + + async fn init_streaming( + &self, + mut request: client::Request, + ) -> Result { + self.streaming + .lock() + .unwrap() + .push(request.headers_mut().clone()); + // Capturing initiation is sufficient: do not construct a response body. + Err(client::HttpError::Timeout) + } + } + + #[cfg(feature = "_hidden")] + #[tokio::test] + async fn default_headers_reach_account_basin_streaming_and_retry_requests() { + let mut session = HeaderValue::from_static("session-1"); + session.set_sensitive(true); + let headers = HeaderMap::from_iter([ + ( + http::header::HeaderName::from_static("x-origin-session"), + session, + ), + ( + AUTHORIZATION, + HeaderValue::from_static("Bearer wrong-token"), + ), + ( + http::header::USER_AGENT, + HeaderValue::from_static("wrong-agent"), + ), + ( + http::header::ACCEPT_ENCODING, + HeaderValue::from_static("wrong-encoding"), + ), + ( + http::header::HeaderName::from_static(S2_BASIN), + HeaderValue::from_static("wrong-basin"), + ), + ]); + let config = S2Config::new("actual-token") + .with_endpoints(S2Endpoints::for_endpoint("http://example.test").unwrap()) + .with_compression(Compression::Gzip) + .with_default_headers(headers) + .unwrap(); + let executor = Arc::new(HeaderCapture::default()); + let mut base = BaseClient::init_with_connector(&config, HttpConnector::new()).unwrap(); + base.client = executor.clone(); + base.retry_builder = RetryBackoffBuilder::default() + .with_min_base_delay(Duration::ZERO) + .with_max_base_delay(Duration::ZERO) + .with_max_retries(1); + let account = AccountClient::init(config.clone(), base); + account + .list_basins(ListBasinsRequest { + prefix: None, + start_after: None, + limit: None, + }) + .await + .unwrap(); + let basin = account.basin_client("test-basin".parse().unwrap()); + basin + .list_streams(ListStreamsRequest { + prefix: None, + start_after: None, + limit: None, + }) + .await + .unwrap(); + + let stream = "test-stream".parse().unwrap(); + assert!( + basin + .read_session( + &stream, + ReadStart { + seq_num: None, + timestamp: None, + tail_offset: None, + clamp: None + }, + ReadEnd { + count: None, + bytes: None, + until: None, + wait: None + }, + None, + None, + ReconnectAdvice::default(), + ) + .await + .is_err() + ); + assert!( + basin + .append_session( + &stream, + futures_util::stream::empty(), + None, + None, + None, + ReconnectAdvice::default(), + ) + .await + .is_err() + ); + + let unary = executor.unary.lock().unwrap(); + let streaming = executor.streaming.lock().unwrap(); + assert_eq!(unary.len(), 3); // account, account retry, basin + assert_eq!(streaming.len(), 2); // read and append + for headers in unary.iter().chain(streaming.iter()) { + assert_eq!(headers["x-origin-session"], "session-1"); + assert!(headers["x-origin-session"].is_sensitive()); + assert_eq!(headers[AUTHORIZATION], "Bearer actual-token"); + assert!(headers[AUTHORIZATION].is_sensitive()); + assert_eq!(headers[http::header::USER_AGENT], config.user_agent); + assert_eq!(headers[http::header::ACCEPT_ENCODING], "gzip"); + assert!(!headers.contains_key(http::header::CONTENT_ENCODING)); + } + assert_eq!(unary[2][S2_BASIN], "test-basin"); + for headers in streaming.iter() { + assert_eq!(headers[S2_BASIN], "test-basin"); + assert_eq!(headers[CONTENT_TYPE], CONTENT_TYPE_S2S); + } + } + + #[cfg(feature = "_hidden")] + #[rstest::rstest] + #[case::none(Compression::None, None, "gzip, zstd")] + #[case::gzip(Compression::Gzip, Some("gzip"), "gzip")] + #[case::zstd(Compression::Zstd, Some("zstd"), "zstd")] + #[tokio::test] + async fn default_accept_encoding_respects_configured_compression( + #[case] compression: Compression, + #[case] content_encoding: Option<&str>, + #[case] accept_encoding: &str, + ) { + let config = S2Config::new("token") + .with_compression(compression) + .with_default_headers(HeaderMap::from_iter([( + http::header::ACCEPT_ENCODING, + HeaderValue::from_static("gzip, zstd"), + )])) + .unwrap(); + let client = BaseClient::init_with_connector(&config, HttpConnector::new()).unwrap(); + let mut request = client + .post("http://example.test/v1/basins".parse().unwrap()) + .json(&serde_json::json!({"name": "test-basin"})) + .build() + .unwrap() + .compress() + .await + .unwrap(); + let headers = request.headers_mut(); + assert_eq!( + headers + .get(http::header::CONTENT_ENCODING) + .map(|value| value.to_str().unwrap()), + content_encoding + ); + assert_eq!(headers[http::header::ACCEPT_ENCODING], accept_encoding); + } + + #[cfg(feature = "_hidden")] + #[tokio::test] + async fn default_headers_are_replaced_and_isolated_between_clients() { + let first_headers = HeaderMap::from_iter([ + ( + http::header::HeaderName::from_static("x-origin-session"), + HeaderValue::from_static("first"), + ), + ( + http::header::HeaderName::from_static("x-first-only"), + HeaderValue::from_static("present"), + ), + ]); + let first = S2Config::new("token") + .with_default_headers(first_headers) + .unwrap(); + let second = first + .clone() + .with_default_headers(HeaderMap::from_iter([( + http::header::HeaderName::from_static("x-origin-session"), + HeaderValue::from_static("second"), + )])) + .unwrap(); + for (config, session) in [(&first, "first"), (&second, "second")] { + let client = BaseClient::init_with_connector(config, HttpConnector::new()).unwrap(); + let mut request = client + .get("http://example.test".parse().unwrap()) + .build() + .unwrap(); + assert_eq!(request.headers_mut()["x-origin-session"], session); + assert_eq!( + request.headers_mut().contains_key("x-first-only"), + session == "first" + ); + } + let cleared = second.with_default_headers(HeaderMap::new()).unwrap(); + assert!(cleared.default_headers.is_empty()); + assert!(S2Config::new("token").default_headers.is_empty()); + } + + #[cfg(feature = "_hidden")] + #[derive(Debug)] + struct RotatingTokenProvider { + generation: AtomicUsize, + } + + #[cfg(feature = "_hidden")] + #[async_trait] + impl crate::types::AccessTokenProvider for RotatingTokenProvider { + async fn access_token(&self) -> Result { + let generation = self.generation.fetch_add(1, Ordering::Relaxed); + Ok(format!("token-{generation}")) + } + } + + #[cfg(feature = "_hidden")] + #[derive(Debug)] + struct RejectAwareTokenProvider { + invalidated: AtomicBool, + rejected: Mutex>, + } + + #[cfg(feature = "_hidden")] + #[async_trait] + impl crate::types::AccessTokenProvider for RejectAwareTokenProvider { + async fn access_token(&self) -> Result { + Ok(if self.invalidated.load(Ordering::Acquire) { + "new-token" + } else { + "old-token" + } + .to_owned()) + } + + fn invalidate_access_token(&self, rejected_access_token: &str) { + self.rejected + .lock() + .expect("rejected token mutex poisoned") + .push(rejected_access_token.to_owned()); + self.invalidated.store(true, Ordering::Release); + } + } + + #[cfg(feature = "_hidden")] + #[derive(Debug, Default)] + struct RejectOldTokenExecutor { + authorization_headers: Mutex>, + } + + #[cfg(feature = "_hidden")] + #[async_trait] + impl client::RequestExecutor for RejectOldTokenExecutor { + async fn execute_unary( + &self, + mut request: client::Request, + ) -> Result { + let authorization = request + .headers_mut() + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned(); + self.authorization_headers + .lock() + .expect("authorization header mutex poisoned") + .push(authorization.clone()); + + if authorization == "Bearer old-token" { + Ok(UnaryResponse::new_for_test( + StatusCode::UNAUTHORIZED, + br#"{"code":"authn","message":"rejected"}"#.to_vec(), + )) + } else { + Ok(UnaryResponse::new_for_test(StatusCode::OK, "ok")) + } + } + + async fn init_streaming( + &self, + _request: client::Request, + ) -> Result { + unreachable!("unary retry test does not initialize a stream") + } + } + + #[rstest::rstest] + #[case(StatusCode::FORBIDDEN, "permission_denied", false, false)] + #[case(StatusCode::FORBIDDEN, "permission_denied", true, true)] + #[case(StatusCode::SERVICE_UNAVAILABLE, "unavailable", false, false)] + #[case(StatusCode::SERVICE_UNAVAILABLE, "unavailable", true, false)] + #[case(StatusCode::TOO_MANY_REQUESTS, "rate_limited", true, true)] + #[test] + fn unary_append_failure_preserves_uncertainty_and_final_error( + #[case] status: StatusCode, + #[case] code: &str, + #[case] prior_uncertainty: bool, + #[case] wrapped: bool, + ) { + let error = + AppendError::from(server_error(status, code).with_prior_uncertainty(prior_uncertainty)); + assert_eq!( + matches!(error, AppendError::IndefiniteFailure { .. }), + wrapped + ); + let server = error.request_error().unwrap().server_error().unwrap(); + assert_eq!((server.status, server.code.as_str()), (status, code)); + assert_eq!(server.message, "test"); + assert_eq!(error.is_retryable(), server.is_retryable()); + assert_eq!( + error.has_no_side_effects(), + !wrapped && server.has_no_side_effects() + ); + if wrapped { + let latest = error + .source() + .unwrap() + .downcast_ref::>() + .unwrap(); + assert!(latest.has_no_side_effects()); + assert_eq!( + latest.request_error().unwrap().server_error().unwrap().code, + code + ); + assert_eq!( + error.to_string(), + format!( + "append may have taken effect in an earlier attempt; final attempt failed: {latest}" + ) + ); + } + } + + #[rstest::rstest] + #[case::condition_failed( + ApiError::AppendConditionFailed(AppendConditionFailed::SeqNumMismatch(42)), + "sequence number mismatch, expected: 42" + )] + #[case::malformed_access_token( + ApiError::MalformedAccessToken("invalid header".to_owned()), + "malformed access token: invalid header" + )] + #[test] + fn unary_append_wraps_definite_terminal_errors(#[case] error: ApiError, #[case] message: &str) { + assert!(error.has_no_side_effects()); + let error = AppendError::from(error.with_prior_uncertainty(true)); + assert!(matches!(error, AppendError::IndefiniteFailure { .. })); + assert!(!error.has_no_side_effects()); + assert!(!error.is_retryable()); + let latest = error + .source() + .unwrap() + .downcast_ref::>() + .unwrap(); + assert!(latest.has_no_side_effects()); + assert_eq!(latest.to_string(), message); + } + + fn server_error(status: StatusCode, code: &str) -> ApiError { + ApiError::Server( + status, + ServerErrorBody { + code: code.to_owned(), + message: "test".to_owned(), + }, + ) + } + + #[test] + fn safe_to_retry_unary_no_policy() { + let retryable = server_error(StatusCode::INTERNAL_SERVER_ERROR, "internal"); + let non_retryable = server_error(StatusCode::BAD_REQUEST, "bad_request"); + let mode = AccessTokenMode::Static; + + // Non-append requests (no policy) — retry if retryable. + assert!(is_safe_to_retry(&retryable, None, None, mode)); + assert!(!is_safe_to_retry(&non_retryable, None, None, mode)); + } + + #[test] + fn safe_to_retry_unary_all_policy() { + let retryable = server_error(StatusCode::INTERNAL_SERVER_ERROR, "internal"); + let non_retryable = server_error(StatusCode::BAD_REQUEST, "bad_request"); + let policy = Some(AppendRetryPolicy::All); + let mode = AccessTokenMode::Static; + + // All policy — retry if retryable, no frame signal checks. + assert!(is_safe_to_retry(&retryable, policy, None, mode)); + assert!(!is_safe_to_retry(&non_retryable, policy, None, mode)); + } + + #[test] + fn safe_to_retry_unary_no_side_effects_policy() { + let retryable = server_error(StatusCode::INTERNAL_SERVER_ERROR, "internal"); + let non_retryable = server_error(StatusCode::BAD_REQUEST, "bad_request"); + let no_side_effect = server_error(StatusCode::TOO_MANY_REQUESTS, "rate_limited"); + let transaction_conflict = server_error(StatusCode::CONFLICT, "transaction_conflict"); + let policy = Some(AppendRetryPolicy::NoSideEffects); + let signal = FrameSignal::new(); + let mode = AccessTokenMode::Static; + + // Signal not set — safe to retry. + assert!(is_safe_to_retry(&retryable, policy, Some(&signal), mode)); + + // Signal set + error with possible side effects — not safe. + signal.signal(); + assert!(!is_safe_to_retry(&retryable, policy, Some(&signal), mode)); + + // Signal set + no-side-effect error — safe. + assert!(is_safe_to_retry( + &no_side_effect, + policy, + Some(&signal), + mode, + )); + assert!(is_safe_to_retry( + &transaction_conflict, + policy, + Some(&signal), + mode, + )); + + // Signal set + non-retryable — never safe. + assert!(!is_safe_to_retry( + &non_retryable, + policy, + Some(&signal), + mode, + )); + } + + #[cfg(feature = "_hidden")] + #[tokio::test] + async fn dynamic_access_token_is_loaded_for_each_attempt_and_marked_sensitive() { + let config = S2Config::new("unused") + .with_default_headers(HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_static("Bearer wrong-token"), + )])) + .unwrap() + .with_access_token_provider(RotatingTokenProvider { + generation: AtomicUsize::new(1), + }); + let client = BaseClient::init_with_connector(&config, HttpConnector::new()).unwrap(); + let uri = "http://example.test/v1/basins".parse().unwrap(); + let mut request = client.get(uri).build().unwrap(); + + assert!(request.headers_mut().get(AUTHORIZATION).is_none()); + + client.authorize(&mut request).await.unwrap(); + let first = request.headers_mut().get(AUTHORIZATION).unwrap(); + assert_eq!(first, "Bearer token-1"); + assert!(first.is_sensitive()); + + client.authorize(&mut request).await.unwrap(); + let second = request.headers_mut().get(AUTHORIZATION).unwrap(); + assert_eq!(second, "Bearer token-2"); + assert!(second.is_sensitive()); + } + + #[cfg(feature = "_hidden")] + #[tokio::test] + async fn rejected_token_is_invalidated_and_replaced_on_unary_retry() { + let provider = Arc::new(RejectAwareTokenProvider { + invalidated: AtomicBool::new(false), + rejected: Mutex::new(Vec::new()), + }); + let executor = Arc::new(RejectOldTokenExecutor::default()); + let client = BaseClient { + client: executor.clone(), + default_headers: HeaderMap::new(), + access_token_mode: AccessTokenMode::Refreshable, + access_token_provider: Some(provider.clone()), + request_timeout: Duration::from_secs(1), + retry_builder: RetryBackoffBuilder::default() + .with_min_base_delay(Duration::ZERO) + .with_max_base_delay(Duration::ZERO) + .with_max_retries(1), + compression: Compression::None, + }; + let request = client + .get("http://example.test/v1/basins".parse().unwrap()) + .build() + .unwrap(); + + let response = client.request(request).send().await.unwrap(); + + assert_eq!(response.into_bytes(), bytes::Bytes::from_static(b"ok")); + assert_eq!( + executor + .authorization_headers + .lock() + .expect("authorization header mutex poisoned") + .as_slice(), + ["Bearer old-token", "Bearer new-token"] + ); + assert_eq!( + provider + .rejected + .lock() + .expect("rejected token mutex poisoned") + .as_slice(), + ["old-token"] + ); + } + + #[cfg(feature = "_hidden")] + #[test] + fn transient_provider_failures_are_retryable_without_side_effects() { + let transient = ApiError::AccessTokenProvider( + crate::types::AccessTokenProviderError::transient("temporarily unavailable"), + ); + assert!(transient.is_retryable()); + assert!(transient.has_no_side_effects()); + + let permanent = ApiError::AccessTokenProvider( + crate::types::AccessTokenProviderError::permanent("login required"), + ); + assert!(!permanent.is_retryable()); + assert!(permanent.has_no_side_effects()); + } + + #[cfg(any(feature = "rustls-aws-lc-rs", feature = "rustls-ring"))] + #[tokio::test] + async fn dns_errors_are_classified_as_connect() { + let config = crate::types::S2Config::new("test-token".to_owned()) + .with_endpoints( + crate::types::S2Endpoints::new( + "https://no-such-basin.invalid".parse().unwrap(), + "https://no-such-basin.invalid".parse().unwrap(), + ) + .unwrap(), + ) + // Skip native root CA loading so the test works in sandboxed + // CI environments without keychain access. + .with_insecure_skip_cert_verification(true); + let client = BaseClient::init(&config).expect("client init"); + let url = "https://no-such-basin.invalid/v1/streams" + .parse::() + .unwrap(); + let request = client.get(url).build().unwrap(); + let err: ApiError = match client.request(request).send().await { + Err(e) => e, + Ok(_) => panic!("should fail with DNS error"), + }; + assert!( + matches!(&err, ApiError::Client(ClientError::Connect(_))), + "expected a connect error, got: {err}" + ); + } +} diff --git a/sdk/src/batching.rs b/sdk/src/batching.rs new file mode 100644 index 00000000..eac2c5ab --- /dev/null +++ b/sdk/src/batching.rs @@ -0,0 +1,523 @@ +//! Utilities for batching [AppendRecord]s. + +use std::{ + pin::Pin, + task::{Context, Poll}, + time::Duration, +}; + +use futures_core::Stream; +use futures_util::{StreamExt, stream}; +use s2_common::{ + caps::RECORD_BATCH_MAX, + read_extent::CountOrBytes, + record::{Metered, MeteredSize}, +}; +use tokio::time::Instant; + +use crate::types::{AppendInput, AppendRecord, AppendRecordBatch, FencingToken, ValidationError}; + +const RECORD_BATCH_MIN: CountOrBytes = CountOrBytes { count: 1, bytes: 8 }; + +#[derive(Debug, Clone)] +/// Limits for batching [`AppendRecord`]s. +pub struct BatchLimits { + max_batch_bytes: usize, + max_batch_records: usize, +} + +impl Default for BatchLimits { + fn default() -> Self { + Self { + max_batch_bytes: RECORD_BATCH_MAX.bytes, + max_batch_records: RECORD_BATCH_MAX.count, + } + } +} + +impl BatchLimits { + /// Create new [`BatchLimits`] with default settings. + pub fn new() -> Self { + Self::default() + } + + /// Set the maximum metered bytes per batch. + /// + /// **Note:** It must be at least `8B` and must not exceed `1MiB`. + /// + /// Defaults to `1MiB`. + pub fn with_max_batch_bytes(self, max_batch_bytes: usize) -> Result { + if max_batch_bytes < RECORD_BATCH_MIN.bytes { + return Err(ValidationError(format!( + "max_batch_bytes ({max_batch_bytes}) must be at least {}", + RECORD_BATCH_MIN.bytes + ))); + } + if max_batch_bytes > RECORD_BATCH_MAX.bytes { + return Err(ValidationError(format!( + "max_batch_bytes ({max_batch_bytes}) must not exceed {}", + RECORD_BATCH_MAX.bytes + ))); + } + Ok(Self { + max_batch_bytes, + ..self + }) + } + + /// Set the maximum number of records per batch. + /// + /// **Note:** It must be at least `1` and must not exceed `1000`. + /// + /// Defaults to `1000`. + pub fn with_max_batch_records(self, max_batch_records: usize) -> Result { + if max_batch_records < RECORD_BATCH_MIN.count { + return Err(ValidationError(format!( + "max_batch_records ({max_batch_records}) must be at least {}", + RECORD_BATCH_MIN.count + ))); + } + if max_batch_records > RECORD_BATCH_MAX.count { + return Err(ValidationError(format!( + "max_batch_records ({max_batch_records}) must not exceed {}", + RECORD_BATCH_MAX.count + ))); + } + Ok(Self { + max_batch_records, + ..self + }) + } +} + +#[derive(Debug, Clone)] +/// Configuration for batching [`AppendRecord`]s. +pub struct BatchingConfig { + linger: Duration, + limits: BatchLimits, +} + +impl Default for BatchingConfig { + fn default() -> Self { + Self { + linger: Duration::from_millis(5), + limits: BatchLimits::default(), + } + } +} + +impl BatchingConfig { + /// Create a new [`BatchingConfig`] with default settings. + pub fn new() -> Self { + Self::default() + } + + /// Set the duration for how long to wait for more records before flushing a batch. + /// + /// Defaults to `5ms`. + pub fn with_linger(self, linger: Duration) -> Self { + Self { linger, ..self } + } + + /// Set the batch limits. + pub fn with_limits(self, limits: BatchLimits) -> Self { + Self { limits, ..self } + } +} + +/// A [`Stream`] that batches [`AppendRecord`]s into [`AppendInput`]s. +pub struct AppendInputs { + pub(crate) batches: AppendRecordBatches, + pub(crate) fencing_token: Option, + pub(crate) match_seq_num: Option, +} + +impl AppendInputs { + /// Create a new [`AppendInputs`] from pre-batched records. + pub fn new(batches: AppendRecordBatches) -> Self { + Self { + batches, + fencing_token: None, + match_seq_num: None, + } + } + + /// Set the fencing token for all [`AppendInput`]s. + pub fn with_fencing_token(self, fencing_token: FencingToken) -> Self { + Self { + fencing_token: Some(fencing_token), + ..self + } + } + + /// Set the match sequence number for the initial [`AppendInput`]. It will be auto-incremented + /// for the subsequent ones. + pub fn with_match_seq_num(self, seq_num: u64) -> Self { + Self { + match_seq_num: Some(seq_num), + ..self + } + } +} + +impl Stream for AppendInputs { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.batches.poll_next_unpin(cx) { + Poll::Ready(Some(Ok(batch))) => { + let match_seq_num = self.match_seq_num; + if let Some(seq_num) = self.match_seq_num.as_mut() { + *seq_num += batch.len() as u64; + } + Poll::Ready(Some(Ok(AppendInput { + records: batch, + match_seq_num, + fencing_token: self.fencing_token.clone(), + stream_config: None, + }))) + } + Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +/// A [`Stream`] that batches [`AppendRecord`]s into [`AppendRecordBatch`]es. +pub struct AppendRecordBatches { + inner: Pin> + Send>>, +} + +impl AppendRecordBatches { + /// Create a new [`AppendRecordBatches`] from a record stream and config. + pub fn from_stream( + records: impl Stream + Send> + Send + Unpin + 'static, + config: BatchingConfig, + ) -> Self { + Self { + inner: Box::pin(append_record_batches(records, config)), + } + } + + /// Eagerly batch in-memory records with the given limits. + /// + /// The returned value is a stream of validated batches and can be collected + /// into a `Vec` when eager results are needed. + /// + /// ```rust + /// # use s2_sdk::batching::{AppendRecordBatches, BatchLimits}; + /// # use s2_sdk::types::AppendRecord; + /// let records = [AppendRecord::new("one")?, AppendRecord::new("two")?]; + /// let batches = AppendRecordBatches::from_iter(records, BatchLimits::new())?; + /// # let _ = batches; + /// # Ok::<(), s2_sdk::types::ValidationError>(()) + /// ``` + pub fn from_iter( + records: impl IntoIterator>, + limits: BatchLimits, + ) -> Result { + let mut batches = Vec::new(); + let mut batch = Metered::with_capacity(limits.max_batch_records); + + for item in records { + let record = Metered::from(item.into()); + if record.metered_size() > limits.max_batch_bytes { + return Err(ValidationError(format!( + "record size in metered bytes ({}) exceeds max_batch_bytes ({})", + record.metered_size(), + limits.max_batch_bytes + ))); + } + + if !batch.is_empty() && would_overflow_batch(&limits, &batch, &record) { + batches.push(AppendRecordBatch::from(std::mem::replace( + &mut batch, + Metered::with_capacity(limits.max_batch_records), + ))); + } + + batch.push(record); + if is_batch_full(&limits, &batch) { + batches.push(AppendRecordBatch::from(std::mem::replace( + &mut batch, + Metered::with_capacity(limits.max_batch_records), + ))); + } + } + + if !batch.is_empty() { + batches.push(batch.into()); + } + + Ok(Self { + inner: Box::pin(stream::iter(batches.into_iter().map(Ok))), + }) + } +} + +impl Stream for AppendRecordBatches { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.inner.as_mut().poll_next(cx) + } +} + +fn is_batch_full(limits: &BatchLimits, batch: &Metered>) -> bool { + batch.len() >= limits.max_batch_records || batch.metered_size() >= limits.max_batch_bytes +} + +fn would_overflow_batch( + limits: &BatchLimits, + batch: &Metered>, + record: &Metered, +) -> bool { + batch.len() + 1 > limits.max_batch_records + || batch.metered_size() + record.metered_size() > limits.max_batch_bytes +} + +fn append_record_batches( + mut records: impl Stream + Send> + Send + Unpin + 'static, + config: BatchingConfig, +) -> impl Stream> + Send + 'static { + async_stream::try_stream! { + let mut batch = Metered::with_capacity(config.limits.max_batch_records); + let mut overflowed_record: Option> = None; + + let linger_deadline = tokio::time::sleep(config.linger); + tokio::pin!(linger_deadline); + + 'outer: loop { + let first_record = match overflowed_record.take() { + Some(pair) => pair, + None => match records.next().await { + Some(item) => Metered::from(item.into()), + None => break, + }, + }; + + if first_record.metered_size() > config.limits.max_batch_bytes { + Err(ValidationError(format!( + "record size in metered bytes ({}) exceeds max_batch_bytes ({})", + first_record.metered_size(), + config.limits.max_batch_bytes + )))?; + } + batch.push(first_record); + + while !is_batch_full(&config.limits, &batch) && overflowed_record.is_none() { + if batch.len() == 1 { + linger_deadline + .as_mut() + .reset(Instant::now() + config.linger); + } + + tokio::select! { + next_record = records.next() => { + match next_record { + Some(record) => { + let record = Metered::from(record.into()); + if would_overflow_batch(&config.limits, &batch, &record) { + overflowed_record = Some(record); + } else { + batch.push(record); + } + } + None => { + yield AppendRecordBatch::from(std::mem::replace( + &mut batch, + Metered::with_capacity(config.limits.max_batch_records), + )); + break 'outer; + } + } + }, + _ = &mut linger_deadline, if !batch.is_empty() => { + break; + } + }; + } + + yield AppendRecordBatch::from(std::mem::replace( + &mut batch, + Metered::with_capacity(config.limits.max_batch_records), + )); + } + } +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use futures_util::TryStreamExt; + + use super::*; + use crate::types::MeteredBytes as _; + + #[tokio::test] + async fn batches_should_be_empty_when_record_stream_is_empty() { + let batches: Vec<_> = AppendRecordBatches::from_stream( + futures_util::stream::iter::>(vec![]), + BatchingConfig::default(), + ) + .collect() + .await; + assert_eq!(batches.len(), 0); + } + + #[tokio::test] + async fn batches_respect_count_limit() -> Result<(), ValidationError> { + let records: Vec<_> = (0..10) + .map(|i| AppendRecord::new(format!("record{i}"))) + .collect::>()?; + let config = BatchingConfig::default() + .with_limits(BatchLimits::default().with_max_batch_records(3)?); + let batches: Vec<_> = + AppendRecordBatches::from_stream(futures_util::stream::iter(records), config) + .try_collect() + .await?; + + assert_eq!(batches.len(), 4); + assert_eq!(batches[0].len(), 3); + assert_eq!(batches[1].len(), 3); + assert_eq!(batches[2].len(), 3); + assert_eq!(batches[3].len(), 1); + + Ok(()) + } + + #[tokio::test] + async fn batches_respect_bytes_limit() -> Result<(), ValidationError> { + let records: Vec<_> = (0..10) + .map(|i| AppendRecord::new(format!("record{i}"))) + .collect::>()?; + let single_record_bytes = records[0].metered_bytes(); + let max_batch_bytes = single_record_bytes * 3; + + let config = BatchingConfig::default() + .with_limits(BatchLimits::default().with_max_batch_bytes(max_batch_bytes)?); + let batches: Vec<_> = + AppendRecordBatches::from_stream(futures_util::stream::iter(records), config) + .try_collect() + .await?; + + assert_eq!(batches.len(), 4); + assert_eq!(batches[0].metered_bytes(), max_batch_bytes); + assert_eq!(batches[1].metered_bytes(), max_batch_bytes); + assert_eq!(batches[2].metered_bytes(), max_batch_bytes); + assert_eq!(batches[3].metered_bytes(), single_record_bytes); + + Ok(()) + } + + #[tokio::test(start_paused = true)] + async fn batches_flush_after_linger_when_stream_remains_open() -> Result<(), ValidationError> { + let records: Vec<_> = (0..2) + .map(|i| AppendRecord::new(format!("record{i}"))) + .collect::>()?; + let records = futures_util::stream::iter(records).chain(futures_util::stream::pending()); + let config = BatchingConfig::default().with_linger(Duration::from_millis(5)); + let mut batches = AppendRecordBatches::from_stream(records, config); + let next_batch = tokio::spawn(async move { batches.next().await }); + + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_millis(6)).await; + + let batch = next_batch.await.unwrap().unwrap()?; + assert_eq!(batch.len(), 2); + Ok(()) + } + + #[tokio::test] + async fn batching_should_error_when_it_sees_oversized_record() -> Result<(), ValidationError> { + let record = AppendRecord::new("hello-world")?; + let record_bytes = record.metered_bytes(); + let max_batch_bytes = 10; + + let config = BatchingConfig::default() + .with_limits(BatchLimits::default().with_max_batch_bytes(max_batch_bytes)?); + let results: Vec<_> = + AppendRecordBatches::from_stream(futures_util::stream::iter(vec![record]), config) + .collect() + .await; + + assert_eq!(results.len(), 1); + assert_matches!(&results[0], Err(err) => { + assert_eq!( + err.to_string(), + format!("record size in metered bytes ({record_bytes}) exceeds max_batch_bytes ({max_batch_bytes})") + ); + }); + + Ok(()) + } + + #[tokio::test] + async fn eager_batches_should_be_empty_when_record_iter_is_empty() { + let batches = + AppendRecordBatches::from_iter(std::iter::empty::(), BatchLimits::new()) + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert!(batches.is_empty()); + } + + #[tokio::test] + async fn eager_batches_respect_count_limit() -> Result<(), ValidationError> { + let records: Vec<_> = (0..10) + .map(|i| AppendRecord::new(format!("record{i}"))) + .collect::>()?; + let limits = BatchLimits::new().with_max_batch_records(3)?; + let batches = AppendRecordBatches::from_iter(records, limits)? + .try_collect::>() + .await?; + + assert_eq!(batches.len(), 4); + assert_eq!(batches[0].len(), 3); + assert_eq!(batches[1].len(), 3); + assert_eq!(batches[2].len(), 3); + assert_eq!(batches[3].len(), 1); + Ok(()) + } + + #[tokio::test] + async fn eager_batches_respect_bytes_limit() -> Result<(), ValidationError> { + let records: Vec<_> = (0..10) + .map(|i| AppendRecord::new(format!("record{i}"))) + .collect::>()?; + let single_record_bytes = records[0].metered_bytes(); + let max_batch_bytes = single_record_bytes * 3; + let limits = BatchLimits::new().with_max_batch_bytes(max_batch_bytes)?; + let batches = AppendRecordBatches::from_iter(records, limits)? + .try_collect::>() + .await?; + + assert_eq!(batches.len(), 4); + assert_eq!(batches[0].metered_bytes(), max_batch_bytes); + assert_eq!(batches[1].metered_bytes(), max_batch_bytes); + assert_eq!(batches[2].metered_bytes(), max_batch_bytes); + assert_eq!(batches[3].metered_bytes(), single_record_bytes); + Ok(()) + } + + #[tokio::test] + async fn eager_batching_should_error_when_record_is_oversized() -> Result<(), ValidationError> { + let record = AppendRecord::new("hello-world")?; + let record_bytes = record.metered_bytes(); + let max_batch_bytes = 10; + let limits = BatchLimits::new().with_max_batch_bytes(max_batch_bytes)?; + let err = AppendRecordBatches::from_iter([record], limits) + .err() + .unwrap(); + + assert_eq!( + err.to_string(), + format!( + "record size in metered bytes ({record_bytes}) exceeds max_batch_bytes ({max_batch_bytes})" + ) + ); + Ok(()) + } +} diff --git a/sdk/src/client.rs b/sdk/src/client.rs new file mode 100644 index 00000000..781844a2 --- /dev/null +++ b/sdk/src/client.rs @@ -0,0 +1,1268 @@ +use std::{ + collections::HashMap, + convert::Infallible, + sync::{ + Arc, Mutex, RwLock as StdRwLock, + atomic::{AtomicU64, AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; + +use async_compression::{ + Level, + tokio::{ + bufread::{GzipDecoder, ZstdDecoder}, + write::{GzipEncoder, ZstdEncoder}, + }, +}; +use async_trait::async_trait; +use bytes::Bytes; +use futures_core::Stream; +use futures_util::StreamExt; +use http::{ + HeaderMap, Method, StatusCode, Uri, + header::{CONTENT_ENCODING, CONTENT_TYPE, HeaderName, HeaderValue}, +}; +use http_body_util::{BodyExt, Empty, Full, StreamBody, combinators::UnsyncBoxBody}; +use hyper::body::{Frame, Incoming}; +use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; +pub use hyper_util::client::legacy::connect::Connect; +use hyper_util::{ + client::legacy::{Client as HyperClient, connect::HttpConnector}, + rt::{TokioExecutor, TokioTimer}, +}; +use serde::{Serialize, de::DeserializeOwned}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + sync::RwLock, + time::timeout, +}; +use tokio_util::task::AbortOnDropHandle; + +use crate::{ + frame_signal::{FrameSignal, RequestFrameMonitorBody}, + types::Http2Config, +}; + +const APPLICATION_JSON: HeaderValue = HeaderValue::from_static("application/json"); +const MAX_CONCURRENT_REQUESTS_PER_CLIENT: usize = 90; +const IDLE_TIMEOUT: Duration = Duration::from_secs(90); +const REAPER_INTERVAL: Duration = Duration::from_secs(30); + +type BoxError = Box; +type BoxBody = UnsyncBoxBody; + +#[derive(Debug, Clone, Copy, Default)] +pub enum Compression { + #[default] + None, + Gzip, + Zstd, +} + +impl From for Compression { + fn from(c: crate::types::Compression) -> Self { + match c { + crate::types::Compression::None => Compression::None, + crate::types::Compression::Gzip => Compression::Gzip, + crate::types::Compression::Zstd => Compression::Zstd, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum HttpError { + #[error("send error: {0}")] + Send(#[from] hyper_util::client::legacy::Error), + #[error("receive error: {0}")] + Receive(#[from] hyper::Error), + #[error("request build error: {0}")] + RequestBuild(String), + #[error("response decode error: {0}")] + ResponseDecode(#[source] serde_json::Error), + #[error("timeout")] + Timeout, + #[error("request compression error: {0}")] + RequestCompression(String), + #[error("response compression error: {0}")] + ResponseCompression(String), +} + +enum BodyInner { + Empty, + Full(Bytes), + Streaming(BoxBody), +} + +pub struct Body(BodyInner); + +impl Body { + fn empty() -> Self { + Self(BodyInner::Empty) + } + + pub fn wrap_stream(stream: S) -> Self + where + S: Stream> + Send + 'static, + E: Into + 'static, + { + let stream_body = StreamBody::new(stream.map(|r| r.map(Frame::data).map_err(Into::into))); + Self(BodyInner::Streaming(BoxBody::new(stream_body))) + } + + pub(crate) fn monitored(self, signal: FrameSignal) -> Self { + Self(BodyInner::Streaming(BoxBody::new( + RequestFrameMonitorBody::new(self.into_http_body(), signal), + ))) + } + + fn as_bytes(&self) -> Option<&[u8]> { + match &self.0 { + BodyInner::Empty => Some(&[]), + BodyInner::Full(bytes) => Some(bytes), + BodyInner::Streaming(_) => None, + } + } + + fn into_http_body(self) -> BoxBody { + match self.0 { + BodyInner::Empty => BoxBody::new(Empty::new().map_err(|e: Infallible| match e {})), + BodyInner::Full(bytes) => { + BoxBody::new(Full::new(bytes).map_err(|e: Infallible| match e {})) + } + BodyInner::Streaming(stream) => stream, + } + } +} + +impl Default for Body { + fn default() -> Self { + Self::empty() + } +} + +impl From> for Body { + fn from(data: Vec) -> Self { + Self(BodyInner::Full(Bytes::from(data))) + } +} + +pub struct Request { + method: Method, + uri: Uri, + headers: HeaderMap, + body: Body, + timeout: Option, + compression: Compression, +} + +impl Request { + pub fn headers_mut(&mut self) -> &mut HeaderMap { + &mut self.headers + } + + pub fn with_monitored_body(self, signal: FrameSignal) -> Self { + Self { + body: self.body.monitored(signal), + ..self + } + } + + pub async fn compress(self) -> Result { + let (body, content_encoding) = compress_body(self.body, self.compression).await?; + let mut headers = self.headers; + if let Some(encoding) = content_encoding { + headers.insert(CONTENT_ENCODING, encoding); + } + Ok(Self { + body, + headers, + compression: Compression::None, + ..self + }) + } + + pub fn authority(&self) -> &str { + self.uri.authority().map(|a| a.as_str()).unwrap_or("") + } + + pub fn try_clone(&self) -> Option { + let body = match &self.body.0 { + BodyInner::Empty => Body::empty(), + BodyInner::Full(bytes) => Body(BodyInner::Full(bytes.clone())), + BodyInner::Streaming(_) => return None, + }; + + Some(Self { + method: self.method.clone(), + uri: self.uri.clone(), + headers: self.headers.clone(), + body, + timeout: self.timeout, + compression: self.compression, + }) + } +} + +pub struct RequestBuilder { + method: Method, + uri: Uri, + headers: HeaderMap, + body: Option, + timeout: Option, + compression: Compression, + error: Option, +} + +impl RequestBuilder { + pub fn new(method: Method, uri: Uri) -> Self { + Self { + method, + uri, + headers: HeaderMap::new(), + body: None, + timeout: None, + compression: Compression::None, + error: None, + } + } + + pub fn get(uri: Uri) -> Self { + Self::new(Method::GET, uri) + } + + pub fn post(uri: Uri) -> Self { + Self::new(Method::POST, uri) + } + + pub fn patch(uri: Uri) -> Self { + Self::new(Method::PATCH, uri) + } + + pub fn put(uri: Uri) -> Self { + Self::new(Method::PUT, uri) + } + + pub fn delete(uri: Uri) -> Self { + Self::new(Method::DELETE, uri) + } + + pub fn query(mut self, query: &T) -> Self { + if self.error.is_some() { + return self; + } + + match serde_urlencoded::to_string(query) { + Ok(query_string) => { + if !query_string.is_empty() { + self.uri = match uri_with_query(&self.uri, &query_string) { + Ok(uri) => uri, + Err(e) => { + self.error = Some(HttpError::RequestBuild(e.to_string())); + return self; + } + }; + } + } + Err(e) => self.error = Some(HttpError::RequestBuild(e.to_string())), + } + self + } + + pub fn json(mut self, json: &T) -> Self { + if self.error.is_some() { + return self; + } + + match serde_json::to_vec(json) { + Ok(data) => { + self.headers.insert(CONTENT_TYPE, APPLICATION_JSON); + self.body = Some(Body::from(data)); + } + Err(e) => self.error = Some(HttpError::RequestBuild(e.to_string())), + } + self + } + + pub fn body>(mut self, body: B) -> Self { + self.body = Some(body.into()); + self + } + + pub fn header(mut self, key: K, value: V) -> Self + where + K: TryInto, + K::Error: Into, + V: TryInto, + V::Error: Into, + { + match (key.try_into(), value.try_into()) { + (Ok(name), Ok(value)) => { + self.headers.insert(name, value); + } + (Err(e), _) => self.error = Some(HttpError::RequestBuild(e.into().to_string())), + (_, Err(e)) => self.error = Some(HttpError::RequestBuild(e.into().to_string())), + } + self + } + + pub fn headers(mut self, headers: &HeaderMap) -> Self { + // An owned HeaderMap replaces each key's existing values while preserving duplicates. + self.headers.extend(headers.clone()); + self + } + + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + pub fn compression(mut self, compression: impl Into) -> Self { + self.compression = compression.into(); + self + } + + pub fn build(self) -> Result { + if let Some(e) = self.error { + return Err(e); + } + + Ok(Request { + method: self.method, + uri: self.uri, + headers: self.headers, + body: self.body.unwrap_or_default(), + timeout: self.timeout, + compression: self.compression, + }) + } +} + +pub struct UnaryResponse { + status: StatusCode, + headers: HeaderMap, + bytes: Bytes, +} + +impl UnaryResponse { + #[cfg(all(test, feature = "_hidden"))] + pub(crate) fn new_for_test(status: StatusCode, bytes: impl Into) -> Self { + Self { + status, + headers: HeaderMap::new(), + bytes: bytes.into(), + } + } + + pub fn status(&self) -> StatusCode { + self.status + } + + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + pub fn into_bytes(self) -> Bytes { + self.bytes + } + + pub fn json(self) -> Result { + serde_json::from_slice(&self.bytes).map_err(HttpError::ResponseDecode) + } +} + +/// Identifies a pooled connection within its host pool, so poisoning drops +/// just the connection reconnect advice arrived on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ConnectionId(u64); + +impl ConnectionId { + fn next() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + Self(NEXT.fetch_add(1, Ordering::Relaxed)) + } +} + +/// Grants its holder the ability to poison the pooled connection a streaming +/// response was served on, dropping it from the pool so no new request reuses +/// it. Requests already in flight keep the connection. +/// +/// Poisoning is idempotent: the connection is identified by its +/// [`ConnectionId`], so poisoning it again — including from another session +/// sharing the connection — is a no-op. +pub struct PoisonHandle { + poison: Box, +} + +impl PoisonHandle { + fn new(poison: impl Fn() + Send + Sync + 'static) -> Self { + Self { + poison: Box::new(poison), + } + } + + pub(crate) fn poison(&self) { + (self.poison)(); + } +} + +pub struct StreamingResponse { + status: StatusCode, + headers: HeaderMap, + body: Incoming, + permit: RequestPermit, + poison_handle: PoisonHandle, +} + +impl StreamingResponse { + fn new( + status: StatusCode, + headers: HeaderMap, + body: Incoming, + permit: RequestPermit, + poison_handle: PoisonHandle, + ) -> Self { + Self { + status, + headers, + body, + permit, + poison_handle, + } + } + + pub fn status(&self) -> StatusCode { + self.status + } + + pub(crate) async fn into_bytes_with_poison_handle( + self, + ) -> Result<(Bytes, PoisonHandle), HttpError> { + let Self { + headers, + body, + permit, + poison_handle, + .. + } = self; + let bytes = body.collect().await?.to_bytes(); + let bytes = decompress_body(&headers, bytes).await?; + drop(permit); + Ok((bytes, poison_handle)) + } + + pub fn into_stream(self) -> (impl Stream>, PoisonHandle) { + let Self { + body, + permit, + poison_handle, + .. + } = self; + let stream = http_body_util::BodyStream::new(body).filter_map(move |result| { + let _ = &permit; + std::future::ready(match result { + Ok(frame) => frame.into_data().ok().map(Ok), + Err(e) => Some(Err(HttpError::Receive(e))), + }) + }); + (stream, poison_handle) + } +} + +#[async_trait] +pub trait RequestExecutor: Send + Sync { + async fn execute_unary(&self, request: Request) -> Result; + async fn init_streaming(&self, request: Request) -> Result; +} + +pub fn default_connector( + connect_timeout: Option, + insecure_skip_cert_verification: bool, + rustls_crypto_provider: Option>, +) -> Result, std::io::Error> { + let mut connector = HttpConnector::new(); + connector.enforce_http(false); + connector.set_nodelay(true); + if let Some(timeout) = connect_timeout { + connector.set_connect_timeout(Some(timeout)); + } + let rustls_crypto_provider = resolve_rustls_crypto_provider(rustls_crypto_provider)?; + + let builder = if insecure_skip_cert_verification { + HttpsConnectorBuilder::new().with_tls_config( + rustls::ClientConfig::builder_with_provider(rustls_crypto_provider.clone()) + .with_safe_default_protocol_versions() + .map_err(std::io::Error::other)? + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerifier { + rustls_crypto_provider, + })) + .with_no_client_auth(), + ) + } else { + HttpsConnectorBuilder::new().with_provider_and_native_roots(rustls_crypto_provider)? + }; + + Ok(builder + .https_or_http() + .enable_http2() + .wrap_connector(connector)) +} + +fn resolve_rustls_crypto_provider( + provider: Option>, +) -> Result, std::io::Error> { + provider + .or_else(|| rustls::crypto::CryptoProvider::get_default().cloned()) + .ok_or_else(|| { + std::io::Error::other( + "no rustls crypto provider configured; configure \ + S2Config::with_rustls_crypto_provider(...) or install a \ + process-global provider with \ + rustls::crypto::CryptoProvider::install_default()", + ) + }) +} + +#[derive(Debug)] +struct NoVerifier { + rustls_crypto_provider: Arc, +} + +impl rustls::client::danger::ServerCertVerifier for NoVerifier { + fn verify_server_cert( + &self, + _: &rustls::pki_types::CertificateDer<'_>, + _: &[rustls::pki_types::CertificateDer<'_>], + _: &rustls::pki_types::ServerName<'_>, + _: &[u8], + _: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _: &[u8], + _: &rustls::pki_types::CertificateDer<'_>, + _: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _: &[u8], + _: &rustls::pki_types::CertificateDer<'_>, + _: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + self.rustls_crypto_provider + .signature_verification_algorithms + .supported_schemes() + } +} + +fn build_http_request( + method: Method, + uri: &Uri, + headers: HeaderMap, + body: BoxBody, + content_encoding: Option, +) -> Result, HttpError> { + let mut builder = http::Request::builder().method(method).uri(uri.clone()); + + if let Some(req_headers) = builder.headers_mut() { + *req_headers = headers; + if let Some(encoding) = content_encoding { + req_headers.insert(CONTENT_ENCODING, encoding); + } + } + + builder + .body(body) + .map_err(|error| HttpError::RequestBuild(error.to_string())) +} + +async fn execute_unary_with( + client: &HyperClient, + request: Request, +) -> Result +where + C: Connect + Clone + Send + Sync + 'static, +{ + let request_timeout = request.timeout; + + let (body, content_encoding) = compress_body(request.body, request.compression).await?; + + let http_request = build_http_request( + request.method, + &request.uri, + request.headers, + body.into_http_body(), + content_encoding, + )?; + + let operation = async { + let response = client.request(http_request).await?; + let (parts, body) = response.into_parts(); + let bytes = body.collect().await?.to_bytes(); + + Ok::<_, HttpError>((parts.status, parts.headers, bytes)) + }; + + let (status, headers, bytes) = if let Some(timeout_duration) = request_timeout { + timeout(timeout_duration, operation) + .await + .map_err(|_| HttpError::Timeout)?? + } else { + operation.await? + }; + + let bytes = decompress_body(&headers, bytes).await?; + + Ok(UnaryResponse { + status, + headers, + bytes, + }) +} + +async fn init_streaming_with( + client: &HyperClient, + request: Request, + permit: RequestPermit, + poison_handle: PoisonHandle, +) -> Result +where + C: Connect + Clone + Send + Sync + 'static, +{ + let request_timeout = request.timeout; + + let http_request = build_http_request( + request.method, + &request.uri, + request.headers, + request.body.into_http_body(), + None, + )?; + + let operation = async { + let response = client.request(http_request).await?; + let (parts, body) = response.into_parts(); + + Ok::<_, HttpError>(StreamingResponse::new( + parts.status, + parts.headers, + body, + permit, + poison_handle, + )) + }; + + if let Some(duration) = request_timeout { + timeout(duration, operation) + .await + .map_err(|_| HttpError::Timeout)? + } else { + operation.await + } +} + +/// Builds a URI from `base`'s scheme and authority, replacing any path/query. +pub(crate) fn uri_with_path(base: &Uri, path: impl AsRef) -> Uri { + let path = path.as_ref().trim_start_matches('/'); + uri_with_path_and_query(base, &format!("/{path}")).expect("SDK-generated path is a valid URI") +} + +fn uri_with_query(uri: &Uri, query: &str) -> Result { + let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/"); + let (path, existing_query) = path_and_query + .split_once('?') + .map_or((path_and_query, ""), |(path, query)| (path, query)); + let path_and_query = if existing_query.is_empty() { + format!("{path}?{query}") + } else { + format!("{path}?{existing_query}&{query}") + }; + uri_with_path_and_query(uri, &path_and_query) +} + +fn uri_with_path_and_query(uri: &Uri, path_and_query: &str) -> Result { + let scheme = uri.scheme_str().unwrap_or("https"); + let authority = uri.authority().map(|a| a.as_str()).unwrap_or(""); + format!("{scheme}://{authority}{path_and_query}").parse() +} + +async fn compress_body( + body: Body, + compression: Compression, +) -> Result<(Body, Option), HttpError> { + match compression { + Compression::None => Ok((body, None)), + Compression::Gzip => { + let Some(data) = body.as_bytes() else { + return Err(HttpError::RequestCompression( + "streaming request bodies cannot be compressed".into(), + )); + }; + let mut encoder = GzipEncoder::with_quality(Vec::new(), Level::Fastest); + encoder + .write_all(data) + .await + .map_err(|e| HttpError::RequestCompression(e.to_string()))?; + encoder + .shutdown() + .await + .map_err(|e| HttpError::RequestCompression(e.to_string()))?; + let compressed = encoder.into_inner(); + Ok(( + Body::from(compressed), + Some(HeaderValue::from_static("gzip")), + )) + } + Compression::Zstd => { + let Some(data) = body.as_bytes() else { + return Err(HttpError::RequestCompression( + "streaming request bodies cannot be compressed".into(), + )); + }; + let mut encoder = ZstdEncoder::with_quality(Vec::new(), Level::Fastest); + encoder + .write_all(data) + .await + .map_err(|e| HttpError::RequestCompression(e.to_string()))?; + encoder + .shutdown() + .await + .map_err(|e| HttpError::RequestCompression(e.to_string()))?; + let compressed = encoder.into_inner(); + Ok(( + Body::from(compressed), + Some(HeaderValue::from_static("zstd")), + )) + } + } +} + +async fn decompress_body(headers: &HeaderMap, bytes: Bytes) -> Result { + let content_encoding = headers.get(CONTENT_ENCODING).and_then(|v| v.to_str().ok()); + + match content_encoding { + Some("gzip") => { + let mut decoder = GzipDecoder::new(bytes.as_ref()); + let mut decompressed = Vec::new(); + decoder + .read_to_end(&mut decompressed) + .await + .map_err(|e| HttpError::ResponseCompression(e.to_string()))?; + Ok(Bytes::from(decompressed)) + } + Some("zstd") => { + let mut decoder = ZstdDecoder::new(bytes.as_ref()); + let mut decompressed = Vec::new(); + decoder + .read_to_end(&mut decompressed) + .await + .map_err(|e| HttpError::ResponseCompression(e.to_string()))?; + Ok(Bytes::from(decompressed)) + } + _ => Ok(bytes), + } +} + +struct RequestPermit { + active_requests: Arc, + idle_since: Arc>>, +} + +impl Drop for RequestPermit { + fn drop(&mut self) { + let prev = self.active_requests.fetch_sub(1, Ordering::Relaxed); + if prev == 1 { + *self.idle_since.lock().unwrap() = Some(Instant::now()); + } + } +} + +struct PooledClient { + max_concurrent_requests: usize, + id: ConnectionId, + client: Arc>, + active_requests: Arc, + idle_since: Arc>>, +} + +impl PooledClient { + fn new(client: HyperClient, max_concurrent_requests: usize) -> Self { + Self { + max_concurrent_requests, + id: ConnectionId::next(), + client: Arc::new(client), + active_requests: Arc::new(AtomicUsize::new(0)), + idle_since: Arc::new(Mutex::new(Some(Instant::now()))), + } + } + + fn request_permit(&self) -> Option { + self.active_requests + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |ar| { + (ar < self.max_concurrent_requests).then_some(ar + 1) + }) + .ok()?; + *self.idle_since.lock().unwrap() = None; + Some(RequestPermit { + active_requests: self.active_requests.clone(), + idle_since: self.idle_since.clone(), + }) + } + + fn should_reap(&self, idle_timeout: Duration) -> bool { + if self.active_requests.load(Ordering::Relaxed) != 0 { + return false; + } + if let Some(idle_since) = *self.idle_since.lock().unwrap() { + return idle_since.elapsed() > idle_timeout; + } + false + } +} + +struct HostPool { + http2: Http2Config, + clients: StdRwLock>>, + connector: C, +} + +impl HostPool +where + C: Connect + Clone + Send + Sync + 'static, +{ + fn new(connector: C, http2: Http2Config) -> Self { + Self { + http2, + clients: StdRwLock::new(Vec::new()), + connector, + } + } + + fn create_client(&self) -> PooledClient { + let mut builder = HyperClient::builder(TokioExecutor::new()); + builder + .timer(TokioTimer::new()) + .http2_only(true) + .http2_keep_alive_interval(Duration::from_secs(20)) + .http2_keep_alive_timeout(Duration::from_secs(10)) + .http2_initial_stream_window_size(self.http2.stream_receive_window) + .http2_initial_connection_window_size(self.http2.connection_receive_window); + let max_concurrent_requests = self + .http2 + .max_concurrent_requests + .unwrap_or(MAX_CONCURRENT_REQUESTS_PER_CLIENT); + PooledClient::new( + builder.build(self.connector.clone()), + max_concurrent_requests, + ) + } + + fn checkout(&self) -> (Arc>, RequestPermit, ConnectionId) { + { + let clients = self.clients.read().unwrap(); + for pooled in clients.iter() { + if let Some(permit) = pooled.request_permit() { + return (pooled.client.clone(), permit, pooled.id); + } + } + } + let mut clients = self.clients.write().unwrap(); + for pooled in clients.iter() { + if let Some(permit) = pooled.request_permit() { + return (pooled.client.clone(), permit, pooled.id); + } + } + let new_client = self.create_client(); + let permit = new_client + .request_permit() + .expect("new client must have a permit"); + let client = new_client.client.clone(); + let id = new_client.id; + clients.push(new_client); + (client, permit, id) + } + + /// Drop the pooled client identified by `id` so no new request reuses it. + /// Clients pinned to servers that are not going away stay pooled, requests + /// already in flight keep their connection, and poisoning the same + /// connection again is a no-op. + fn poison(&self, host: &str, id: ConnectionId) { + let mut clients = self.clients.write().unwrap(); + let pooled = clients.len(); + clients.retain(|pooled| pooled.id != id); + let removed = pooled - clients.len(); + tracing::debug!(host, connection = ?id, removed, "poisoned pooled connections"); + } + + fn reap_idle_clients(&self) { + self.clients + .write() + .unwrap() + .retain(|pooled| !pooled.should_reap(IDLE_TIMEOUT)); + } + + fn is_empty(&self) -> bool { + self.clients + .try_read() + .map(|clients| clients.is_empty()) + .unwrap_or(false) + } +} + +pub struct Pool { + http2: Http2Config, + hosts: Arc>>>>, + connector: C, + _reaper: AbortOnDropHandle<()>, +} + +impl Pool +where + C: Connect + Clone + Send + Sync + 'static, +{ + pub fn new(connector: C, http2: Http2Config) -> Self { + let hosts = Arc::new(RwLock::new(HashMap::new())); + + let _reaper = AbortOnDropHandle::new(tokio::spawn({ + let hosts = hosts.clone(); + async move { + let mut interval = tokio::time::interval(REAPER_INTERVAL); + loop { + interval.tick().await; + reap_idle_clients(&hosts).await; + } + } + })); + + Self { + http2, + hosts, + connector, + _reaper, + } + } + + async fn get_or_create_host_pool(&self, host: &str) -> Arc> { + { + let hosts = self.hosts.read().await; + if let Some(pool) = hosts.get(host) { + return pool.clone(); + } + } + let mut hosts = self.hosts.write().await; + hosts + .entry(host.to_owned()) + .or_insert_with(|| Arc::new(HostPool::new(self.connector.clone(), self.http2))) + .clone() + } + + async fn checkout( + &self, + host: &str, + ) -> (Arc>, RequestPermit, ConnectionId) { + self.get_or_create_host_pool(host).await.checkout() + } +} + +async fn reap_idle_clients( + hosts: &RwLock>>>, +) { + let pools: Vec>> = { + let hosts = hosts.read().await; + hosts.values().cloned().collect() + }; + + for pool in &pools { + pool.reap_idle_clients(); + } + + hosts.write().await.retain(|_, pool| !pool.is_empty()); +} + +#[async_trait] +impl RequestExecutor for Pool +where + C: Connect + Clone + Send + Sync + 'static, +{ + async fn execute_unary(&self, request: Request) -> Result { + let (client, _permit, _) = self.checkout(request.authority()).await; + execute_unary_with(&client, request).await + } + + async fn init_streaming(&self, request: Request) -> Result { + let host = request.authority().to_owned(); + let pool = self.get_or_create_host_pool(&host).await; + let (client, permit, id) = pool.checkout(); + // Weak: the handle can outlive the pool (a session holds it for the + // connection's lifetime) and must not keep a reaped pool alive. + let weak = Arc::downgrade(&pool); + let poison_handle = PoisonHandle::new(move || { + if let Some(pool) = weak.upgrade() { + pool.poison(&host, id); + } + }); + init_streaming_with(&client, request, permit, poison_handle).await + } +} + +#[cfg(test)] +mod tests { + #[cfg(any(feature = "rustls-aws-lc-rs", feature = "rustls-ring"))] + use rustls::client::danger::ServerCertVerifier; + + use super::*; + + const TEST_HOST: &str = "localhost:8080"; + + fn test_pool() -> Pool { + Pool::new(HttpConnector::new(), Http2Config::new()) + } + + #[test] + fn default_headers_preserve_multiple_values_and_sensitive_flags_on_wire_request() { + let mut headers = HeaderMap::new(); + headers.append("x-tag", HeaderValue::from_static("first")); + let mut second = HeaderValue::from_static("second"); + second.set_sensitive(true); + headers.append("x-tag", second); + headers.insert(CONTENT_ENCODING, HeaderValue::from_static("wrong-encoding")); + + let request = RequestBuilder::get("http://example.test".parse().unwrap()) + .header("x-tag", "replaced") + .headers(&headers) + .build() + .unwrap(); + let cloned = request.try_clone().unwrap(); + let http = build_http_request( + cloned.method, + &cloned.uri, + cloned.headers, + cloned.body.into_http_body(), + Some(HeaderValue::from_static("gzip")), + ) + .unwrap(); + let values = http.headers().get_all("x-tag").iter().collect::>(); + assert_eq!(values.len(), 2); + assert_eq!(values[0], "first"); + assert_eq!(values[1], "second"); + assert!(values[1].is_sensitive()); + assert_eq!(http.headers()[CONTENT_ENCODING], "gzip"); + } + + #[test] + fn uri_with_path_percent_encoded_segment() { + let base: Uri = "https://example.com".parse().unwrap(); + + let uri = uri_with_path( + &base, + format!("v1/access-tokens/{}", urlencoding::encode("a/b?c d")), + ); + + assert_eq!(uri, "https://example.com/v1/access-tokens/a%2Fb%3Fc%20d"); + } + + #[test] + fn query_appends_to_existing_query() { + #[derive(serde::Serialize)] + struct Params<'a> { + prefix: &'a str, + } + + let uri: Uri = "https://example.com/v1/streams?limit=1".parse().unwrap(); + let request = RequestBuilder::get(uri) + .query(&Params { prefix: "a/b c" }) + .build() + .unwrap(); + + assert_eq!( + request.uri, + "https://example.com/v1/streams?limit=1&prefix=a%2Fb+c" + ); + } + + async fn host_client_count(pool: &Pool, host: &str) -> usize { + let hosts = pool.hosts.read().await; + match hosts.get(host) { + Some(pool) => pool.clients.read().unwrap().len(), + None => 0, + } + } + + #[cfg(feature = "rustls-aws-lc-rs")] + fn test_rustls_crypto_provider() -> Arc { + Arc::new(rustls::crypto::aws_lc_rs::default_provider()) + } + + #[cfg(all(not(feature = "rustls-aws-lc-rs"), feature = "rustls-ring"))] + fn test_rustls_crypto_provider() -> Arc { + Arc::new(rustls::crypto::ring::default_provider()) + } + + #[cfg(any(feature = "rustls-aws-lc-rs", feature = "rustls-ring"))] + #[test] + fn default_connector_accepts_explicit_crypto_provider_for_insecure_tls() { + let connector = default_connector(None, true, Some(test_rustls_crypto_provider())); + assert!(connector.is_ok()); + } + + #[cfg(any(feature = "rustls-aws-lc-rs", feature = "rustls-ring"))] + #[test] + fn no_verifier_uses_configured_provider_signature_schemes() { + let provider = test_rustls_crypto_provider(); + let verifier = NoVerifier { + rustls_crypto_provider: provider.clone(), + }; + + assert_eq!( + verifier.supported_verify_schemes(), + provider + .signature_verification_algorithms + .supported_schemes() + ); + } + + #[tokio::test] + async fn checkout_within_capacity() { + let pool = test_pool(); + let mut permits = Vec::new(); + for _ in 0..MAX_CONCURRENT_REQUESTS_PER_CLIENT { + let (_client, permit, _) = pool.checkout(TEST_HOST).await; + permits.push(permit); + } + assert_eq!(host_client_count(&pool, TEST_HOST).await, 1); + } + + #[tokio::test] + async fn overflow_creates_new_client() { + let pool = test_pool(); + let mut permits = Vec::new(); + for _ in 0..MAX_CONCURRENT_REQUESTS_PER_CLIENT { + let (_client, permit, _) = pool.checkout(TEST_HOST).await; + permits.push(permit); + } + assert_eq!(host_client_count(&pool, TEST_HOST).await, 1); + + let (_client, permit, _) = pool.checkout(TEST_HOST).await; + permits.push(permit); + assert_eq!(host_client_count(&pool, TEST_HOST).await, 2); + } + + #[tokio::test] + async fn custom_request_cap_bounds_client() { + let http2 = Http2Config::new().with_max_concurrent_requests(2).unwrap(); + let pool = Pool::new(HttpConnector::new(), http2); + let mut permits = Vec::new(); + for _ in 0..2 { + let (_client, permit, _) = pool.checkout(TEST_HOST).await; + permits.push(permit); + } + assert_eq!(host_client_count(&pool, TEST_HOST).await, 1); + + let (_client, permit, _) = pool.checkout(TEST_HOST).await; + permits.push(permit); + assert_eq!(host_client_count(&pool, TEST_HOST).await, 2); + } + + #[tokio::test] + async fn permit_drop_frees_capacity() { + let pool = test_pool(); + let mut permits = Vec::new(); + for _ in 0..MAX_CONCURRENT_REQUESTS_PER_CLIENT { + let (_client, permit, _) = pool.checkout(TEST_HOST).await; + permits.push(permit); + } + permits.pop(); + + let (_client, permit, _) = pool.checkout(TEST_HOST).await; + permits.push(permit); + assert_eq!(host_client_count(&pool, TEST_HOST).await, 1); + } + + #[tokio::test] + async fn reaper_removes_idle_clients() { + let pool = test_pool(); + let mut permits = Vec::new(); + for _ in 0..MAX_CONCURRENT_REQUESTS_PER_CLIENT { + let (_client, permit, _) = pool.checkout(TEST_HOST).await; + permits.push(permit); + } + let (_client, permit, _) = pool.checkout(TEST_HOST).await; + permits.push(permit); + assert_eq!(host_client_count(&pool, TEST_HOST).await, 2); + + permits.clear(); + { + let hosts = pool.hosts.read().await; + let pool = hosts.get(TEST_HOST).unwrap(); + let clients = pool.clients.read().unwrap(); + for pooled in clients.iter() { + *pooled.idle_since.lock().unwrap() = + Some(Instant::now() - IDLE_TIMEOUT - Duration::from_secs(1)); + } + } + + reap_idle_clients(&pool.hosts).await; + assert_eq!(host_client_count(&pool, TEST_HOST).await, 0); + assert!(pool.hosts.read().await.get(TEST_HOST).is_none()); + } + + #[tokio::test] + async fn different_hosts_get_independent_pools() { + let pool = test_pool(); + let host_a = "host-a:443"; + let host_b = "host-b:443"; + + let mut permits_a = Vec::new(); + for _ in 0..MAX_CONCURRENT_REQUESTS_PER_CLIENT { + let (_client, permit, _) = pool.checkout(host_a).await; + permits_a.push(permit); + } + assert_eq!(host_client_count(&pool, host_a).await, 1); + + let (_client, permit_b, _) = pool.checkout(host_b).await; + assert_eq!(host_client_count(&pool, host_b).await, 1); + assert_eq!(host_client_count(&pool, host_a).await, 1); + + drop(permit_b); + drop(permits_a); + } + + #[tokio::test] + async fn reaper_removes_empty_host_entries() { + let pool = test_pool(); + + let (_client, permit_a, _) = pool.checkout("host-a:443").await; + let (_client, permit_b, _) = pool.checkout("host-b:443").await; + assert_eq!(pool.hosts.read().await.len(), 2); + + drop(permit_a); + { + let hosts = pool.hosts.read().await; + let pool_a = hosts.get("host-a:443").unwrap(); + let clients = pool_a.clients.read().unwrap(); + for pooled in clients.iter() { + *pooled.idle_since.lock().unwrap() = + Some(Instant::now() - IDLE_TIMEOUT - Duration::from_secs(1)); + } + } + + reap_idle_clients(&pool.hosts).await; + + let hosts = pool.hosts.read().await; + assert!(hosts.get("host-a:443").is_none()); + assert!(hosts.get("host-b:443").is_some()); + + drop(permit_b); + } +} diff --git a/sdk/src/error.rs b/sdk/src/error.rs new file mode 100644 index 00000000..bdeb5e53 --- /dev/null +++ b/sdk/src/error.rs @@ -0,0 +1,588 @@ +//! Errors returned by the SDK. +//! +//! Operations return the narrowest error type for their surface. Errors expose classification and +//! accessors relevant to that surface, so callers do not need to inspect display strings or unwrap +//! the complete error hierarchy. + +pub use http::StatusCode; +use s2_api::v1 as api; +pub use s2_api::v1::error::ErrorCode; + +pub use crate::session::{ + append::AppendSessionError, + read::{CaughtUpError, ReadSessionError}, +}; +use crate::{ + api::{ApiError, ServerErrorBody}, + client, + types::{FencingToken, StreamPosition, ValidationError}, +}; + +/// A classified client-side error. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum ClientError { + /// Failed to establish a connection. + #[error("connect: {0}")] + Connect(String), + /// The request timed out. + #[error("timeout")] + Timeout, + /// The connection closed before the response was complete. + #[error("connection closed early: {0}")] + ConnectionClosedEarly(String), + /// The request was canceled. + #[error("request canceled: {0}")] + RequestCanceled(String), + /// The connection ended unexpectedly. + #[error("unexpected eof: {0}")] + UnexpectedEof(String), + /// The connection was reset. + #[error("connection reset: {0}")] + ConnectionReset(String), + /// The connection was aborted. + #[error("connection aborted: {0}")] + ConnectionAborted(String), + /// The connection was refused. + #[error("connection refused: {0}")] + ConnectionRefused(String), + /// Client configuration prevented a request from being attempted. + #[error("configuration: {0}")] + Configuration(String), + /// The request could not be built or encoded. + #[error("request build: {0}")] + RequestBuild(String), + /// The request body could not be compressed. + #[error("request compression: {0}")] + RequestCompression(String), + /// The response body could not be decompressed. + #[error("response compression: {0}")] + ResponseCompression(String), + /// The response body could not be decoded. + #[error("response decode: {0}")] + ResponseDecode(String), + /// A streaming protocol message could not be decoded. + #[error("session protocol: {0}")] + SessionProtocol(String), + /// An otherwise-unclassified client error. + #[error("{0}")] + Other(String), +} + +impl ClientError { + /// Whether retrying the request is safe or sensible. + pub fn is_retryable(&self) -> bool { + matches!( + self, + Self::Connect(_) + | Self::Timeout + | Self::ConnectionClosedEarly(_) + | Self::RequestCanceled(_) + | Self::UnexpectedEof(_) + | Self::ConnectionReset(_) + | Self::ConnectionAborted(_) + | Self::ConnectionRefused(_) + ) + } + + /// Whether retrying the request cannot duplicate a mutation. + pub fn has_no_side_effects(&self) -> bool { + matches!( + self, + Self::Connect(_) + | Self::ConnectionRefused(_) + | Self::Configuration(_) + | Self::RequestBuild(_) + | Self::RequestCompression(_) + ) + } +} + +impl From for ClientError { + fn from(err: client::HttpError) -> Self { + let err_msg = err.to_string(); + match err { + client::HttpError::Send(ref send_err) if send_err.is_connect() => { + classify_io_source(&err, &err_msg).unwrap_or(Self::Connect(err_msg)) + } + client::HttpError::Send(_) | client::HttpError::Receive(_) => { + classify_hyper_source(&err, &err_msg) + .or_else(|| classify_io_source(&err, &err_msg)) + .unwrap_or(Self::Other(err_msg)) + } + client::HttpError::RequestBuild(message) => Self::RequestBuild(message), + client::HttpError::RequestCompression(message) => Self::RequestCompression(message), + client::HttpError::ResponseCompression(message) => Self::ResponseCompression(message), + client::HttpError::ResponseDecode(error) => Self::ResponseDecode(error.to_string()), + client::HttpError::Timeout => Self::Timeout, + } + } +} + +fn classify_hyper_source(err: &client::HttpError, err_msg: &str) -> Option { + let hyper_err = source_err::(err)?; + let err_msg = format!("{hyper_err} -> {err_msg}"); + if hyper_err.is_timeout() { + // The h2 keep-alive timing out fails requests sent on the dead connection. + Some(ClientError::Timeout) + } else if hyper_err.is_incomplete_message() || hyper_err.is_closed() { + // `is_closed` covers a request dispatched onto a pooled connection + // that the server had already shut down. + Some(ClientError::ConnectionClosedEarly(err_msg)) + } else if hyper_err.is_canceled() { + Some(ClientError::RequestCanceled(err_msg)) + } else if source_err::(err).is_some_and(|e| { + e.is_io() || e.is_go_away() || e.reason() == Some(h2::Reason::REFUSED_STREAM) + }) { + // An I/O failure ends streaming bodies without tripping any hyper marker above. + // A remote GOAWAY ends streams dispatched onto a connection the server is + // gracefully shutting down. + Some(ClientError::ConnectionClosedEarly(err_msg)) + } else { + None + } +} + +fn classify_io_source(err: &client::HttpError, err_msg: &str) -> Option { + let io_err = source_err::(err)?; + let err_msg = format!("{io_err} -> {err_msg}"); + Some(match io_err.kind() { + std::io::ErrorKind::UnexpectedEof => ClientError::UnexpectedEof(err_msg), + // h2 surfaces a stream cut short by connection shutdown as a broken pipe. + std::io::ErrorKind::BrokenPipe => ClientError::ConnectionClosedEarly(err_msg), + std::io::ErrorKind::ConnectionReset => ClientError::ConnectionReset(err_msg), + std::io::ErrorKind::ConnectionAborted => ClientError::ConnectionAborted(err_msg), + std::io::ErrorKind::ConnectionRefused => ClientError::ConnectionRefused(err_msg), + _ => return None, + }) +} + +fn source_err(err: &dyn std::error::Error) -> Option<&T> { + let mut source = err.source(); + while let Some(err) = source { + if let Some(err) = err.downcast_ref::() { + return Some(err); + } + source = err.source(); + } + None +} + +/// Why an append condition check failed. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum AppendConditionFailed { + /// Fencing token did not match. Contains the expected fencing token. + #[error("fencing token mismatch, expected: {0}")] + FencingTokenMismatch(FencingToken), + /// Sequence number did not match. Contains the expected sequence number. + #[error("sequence number mismatch, expected: {0}")] + SeqNumMismatch(u64), +} + +impl From for AppendConditionFailed { + fn from(value: api::stream::AppendConditionFailed) -> Self { + match value { + api::stream::AppendConditionFailed::FencingTokenMismatch(token) => { + Self::FencingTokenMismatch(FencingToken::from_server(token.to_string())) + } + api::stream::AppendConditionFailed::SeqNumMismatch(seq) => Self::SeqNumMismatch(seq), + } + } +} + +/// Errors that can be returned by any network request. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum RequestError { + /// A client-side error. + #[error(transparent)] + Client(#[from] ClientError), + /// An error returned by the server. + #[error(transparent)] + Server(#[from] ServerError), + /// The access token could not be used as an HTTP header value. + #[error("malformed access token: {0}")] + MalformedAccessToken(String), + #[cfg(feature = "_hidden")] + #[doc(hidden)] + #[error("access token provider failed: {0}")] + AccessTokenProvider(crate::types::AccessTokenProviderError), + /// Input validation failed. + #[error(transparent)] + Validation(#[from] ValidationError), +} + +impl RequestError { + /// Whether retrying the operation is safe or sensible. + pub fn is_retryable(&self) -> bool { + match self { + Self::Client(error) => error.is_retryable(), + Self::Server(error) => error.is_retryable(), + #[cfg(feature = "_hidden")] + Self::AccessTokenProvider(error) => error.is_retryable(), + Self::MalformedAccessToken(_) | Self::Validation(_) => false, + } + } + + /// Whether retrying the operation cannot duplicate a mutation. + pub fn has_no_side_effects(&self) -> bool { + match self { + Self::Client(error) => error.has_no_side_effects(), + Self::Server(error) => error.has_no_side_effects(), + #[cfg(feature = "_hidden")] + Self::AccessTokenProvider(_) => true, + Self::MalformedAccessToken(_) | Self::Validation(_) => true, + } + } + + /// Return the server error, if present. + pub fn server_error(&self) -> Option<&ServerError> { + match self { + Self::Server(error) => Some(error), + _ => None, + } + } + + pub(crate) fn is_authentication_error(&self) -> bool { + matches!( + self, + Self::Server(error) + if error.status == StatusCode::UNAUTHORIZED && error.code == "authn" + ) + } + + pub(crate) fn is_server_draining(&self) -> bool { + matches!( + self, + Self::Server(error) + if error.status == StatusCode::SERVICE_UNAVAILABLE + && error.code == "server_draining" + ) + } +} + +impl From for RequestError { + fn from(error: ApiError) -> Self { + match error { + ApiError::Client(error) => Self::Client(error), + ApiError::ProtoDecode(error) => { + Self::Client(ClientError::ResponseDecode(error.to_string())) + } + ApiError::TerminalDecode(error) => { + Self::Client(ClientError::SessionProtocol(error.to_string())) + } + ApiError::MalformedAccessToken(error) => Self::MalformedAccessToken(error), + #[cfg(feature = "_hidden")] + ApiError::AccessTokenProvider(error) => Self::AccessTokenProvider(error), + ApiError::Compression(error) => { + Self::Client(ClientError::ResponseCompression(error.to_string())) + } + ApiError::Server(status, response) => { + Self::Server(ServerError::from_api(status, response)) + } + other => Self::Client(ClientError::Other(other.to_string())), + } + } +} + +/// Errors returned by unary read operations. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum ReadError { + /// A network request error. + #[error(transparent)] + Request(#[from] RequestError), + /// The requested position has not been written. + #[error("read from an unwritten position. current tail: {0}")] + ReadUnwritten(StreamPosition), +} + +impl ReadError { + /// Whether retrying the operation is safe or sensible. + pub fn is_retryable(&self) -> bool { + matches!(self, Self::Request(error) if error.is_retryable()) + } + + /// Return the underlying request error, if present. + pub fn request_error(&self) -> Option<&RequestError> { + match self { + Self::Request(error) => Some(error), + Self::ReadUnwritten(_) => None, + } + } +} + +impl From for ReadError { + fn from(error: ApiError) -> Self { + match error { + ApiError::ReadUnwritten(tail) => Self::ReadUnwritten(tail.tail.into()), + other => Self::Request(other.into()), + } + } +} + +/// Errors returned by unary append operations. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum AppendError { + /// A network request error. + #[error(transparent)] + Request(#[from] RequestError), + /// The append condition did not match. + #[error(transparent)] + ConditionFailed(#[from] AppendConditionFailed), + /// The final attempt failed definitively, but an earlier attempt may have taken effect, + /// so the entire append operation is indeterminate. + #[error( + "append may have taken effect in an earlier attempt; final attempt failed: {final_attempt_error}" + )] + IndefiniteFailure { + /// The definite error returned by the final attempt. + #[source] + final_attempt_error: Box, + }, +} + +impl AppendError { + /// Whether retrying the operation is safe or sensible. + pub fn is_retryable(&self) -> bool { + match self { + Self::Request(error) => error.is_retryable(), + Self::ConditionFailed(_) => false, + Self::IndefiniteFailure { + final_attempt_error, + } => final_attempt_error.is_retryable(), + } + } + + /// Whether retrying the operation cannot duplicate a mutation. + pub fn has_no_side_effects(&self) -> bool { + match self { + Self::Request(error) => error.has_no_side_effects(), + Self::ConditionFailed(_) => true, + Self::IndefiniteFailure { .. } => false, + } + } + + /// Return the underlying request error, if present. + pub fn request_error(&self) -> Option<&RequestError> { + match self { + Self::Request(error) => Some(error), + Self::ConditionFailed(_) => None, + Self::IndefiniteFailure { + final_attempt_error, + } => final_attempt_error.request_error(), + } + } +} + +impl From for AppendError { + fn from(error: ApiError) -> Self { + match error { + ApiError::AppendConditionFailed(condition) => Self::ConditionFailed(condition.into()), + ApiError::IndefiniteFailure { + final_attempt_error, + } => Self::IndefiniteFailure { + final_attempt_error: Box::new((*final_attempt_error).into()), + }, + other => Self::Request(other.into()), + } + } +} + +/// Errors from producer operations. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum ProducerError { + /// An append-session error encountered while producing records. + #[error(transparent)] + Append(#[from] AppendSessionError), + /// Producer input validation failed before an append was attempted. + #[error(transparent)] + Validation(#[from] ValidationError), + /// The producer was already closed. + #[error("producer already closed")] + ProducerClosed, + /// The producer is closing. + #[error("producer is closing")] + ProducerClosing, + /// The producer was dropped without being closed. + #[error("producer dropped without calling close")] + ProducerDropped, +} + +impl ProducerError { + /// Whether retrying the operation is safe or sensible. + pub fn is_retryable(&self) -> bool { + match self { + Self::Append(error) => error.is_retryable(), + Self::Validation(_) + | Self::ProducerClosed + | Self::ProducerClosing + | Self::ProducerDropped => false, + } + } + + /// Whether retrying the operation cannot duplicate a mutation. + pub fn has_no_side_effects(&self) -> bool { + match self { + Self::Append(error) => error.has_no_side_effects(), + Self::Validation(_) | Self::ProducerClosed | Self::ProducerClosing => true, + Self::ProducerDropped => false, + } + } + + /// Return the underlying request error, if present. + pub fn request_error(&self) -> Option<&RequestError> { + match self { + Self::Append(error) => error.request_error(), + Self::Validation(_) + | Self::ProducerClosed + | Self::ProducerClosing + | Self::ProducerDropped => None, + } + } +} + +/// An error returned by an S2 server. +#[derive(Debug, Clone, thiserror::Error)] +#[error("{code}: {message}")] +#[non_exhaustive] +pub struct ServerError { + /// HTTP status returned by the server. + pub status: StatusCode, + /// Error code. + pub code: String, + /// Error message. + pub message: String, +} + +impl ServerError { + pub(crate) fn from_api(status: StatusCode, response: ServerErrorBody) -> Self { + Self { + status, + code: response.code, + message: response.message, + } + } + + /// Return the server error code when it is recognized by this SDK version. + /// + /// The raw [`code`](Self::code) remains available so callers can preserve and report codes + /// introduced by newer servers. + pub fn known_code(&self) -> Option { + self.code.parse().ok() + } + + /// Whether retrying the request is safe or sensible for this server error. + pub fn is_retryable(&self) -> bool { + server_error_is_retryable(self.status, &self.code) + } + + /// Whether retrying the request cannot duplicate a mutation. + pub fn has_no_side_effects(&self) -> bool { + server_error_has_no_side_effects(self.status, &self.code) + } +} + +pub(crate) fn server_error_is_retryable(status: StatusCode, code: &str) -> bool { + match code.parse::() { + Ok(code) if code.status() == status => code.is_retryable(), + Ok(_) => false, + Err(_) => matches!( + status, + StatusCode::REQUEST_TIMEOUT + | StatusCode::TOO_MANY_REQUESTS + | StatusCode::INTERNAL_SERVER_ERROR + | StatusCode::BAD_GATEWAY + | StatusCode::SERVICE_UNAVAILABLE + | StatusCode::GATEWAY_TIMEOUT + ), + } +} + +pub(crate) fn server_error_has_no_side_effects(status: StatusCode, code: &str) -> bool { + code.parse::() + .is_ok_and(|code| code.status() == status && code.has_no_side_effects()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn response(status: StatusCode, code: &str) -> ServerError { + ServerError::from_api( + status, + ServerErrorBody { + code: code.to_owned(), + message: "test".to_owned(), + }, + ) + } + + #[test] + fn error_response_preserves_raw_and_known_codes() { + let known = response(StatusCode::NOT_FOUND, "basin_not_found"); + assert_eq!(known.code, "basin_not_found"); + assert_eq!(known.message, "test"); + assert_eq!(known.known_code(), Some(ErrorCode::BasinNotFound)); + assert!(known.to_string().contains("basin_not_found")); + + let unknown = response(StatusCode::BAD_REQUEST, "introduced_by_a_newer_server"); + assert_eq!(unknown.known_code(), None); + assert_eq!(unknown.code, "introduced_by_a_newer_server"); + } + + #[test] + fn server_classification_fails_closed_on_status_mismatch() { + let mismatch = response(StatusCode::INTERNAL_SERVER_ERROR, "rate_limited"); + assert!(!mismatch.is_retryable()); + assert!(!mismatch.has_no_side_effects()); + } + + #[test] + fn unknown_codes_retain_retryable_status_fallback() { + let unknown = response(StatusCode::SERVICE_UNAVAILABLE, "future_server_error"); + assert!(unknown.is_retryable()); + assert!(!unknown.has_no_side_effects()); + } + + #[test] + fn internal_client_errors_preserve_the_failure_stage() { + assert!(matches!( + ClientError::from(client::HttpError::RequestBuild("bad request".to_owned())), + ClientError::RequestBuild(message) if message == "bad request" + )); + assert!(matches!( + ClientError::from(client::HttpError::RequestCompression("encode".to_owned())), + ClientError::RequestCompression(message) if message == "encode" + )); + assert!(matches!( + ClientError::from(client::HttpError::ResponseCompression("decode".to_owned())), + ClientError::ResponseCompression(message) if message == "decode" + )); + + let json_error = serde_json::from_slice::(b"{") + .expect_err("invalid JSON should fail"); + assert!(matches!( + ClientError::from(client::HttpError::ResponseDecode(json_error)), + ClientError::ResponseDecode(_) + )); + } + + #[test] + fn nested_errors_expose_request_and_server_errors() { + let append = AppendError::Request(RequestError::Server(response( + StatusCode::CONFLICT, + "transaction_conflict", + ))); + + assert!(append.is_retryable()); + assert!(append.has_no_side_effects()); + let request = append.request_error().expect("request error"); + assert!(matches!(request, RequestError::Server(_))); + let server = request.server_error().expect("server error"); + assert_eq!(server.known_code(), Some(ErrorCode::TransactionConflict)); + } +} diff --git a/sdk/src/frame_signal.rs b/sdk/src/frame_signal.rs new file mode 100644 index 00000000..fc3b4a0c --- /dev/null +++ b/sdk/src/frame_signal.rs @@ -0,0 +1,159 @@ +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +use http_body::{Body, Frame}; +use pin_project_lite::pin_project; + +/// An atomic flag that tracks whether any body frame has been yielded. +/// +/// When used with [`RequestFrameMonitorBody`], the signal is set to `true` +/// the first time `poll_frame()` produces a frame. At error time, an unset +/// signal proves the request data never left the process, making retry safe. +#[derive(Debug, Clone)] +pub struct FrameSignal(Arc); + +impl FrameSignal { + /// Create a new unsignalled [`FrameSignal`]. + pub fn new() -> Self { + Self(Arc::new(AtomicBool::new(false))) + } + + /// Returns `true` if the signal has been set. + pub fn is_signalled(&self) -> bool { + self.0.load(Ordering::Acquire) + } + + /// Reset the signal to unsignalled. + pub fn reset(&self) { + self.0.store(false, Ordering::Release); + } + + /// Set the signal to signalled. + pub fn signal(&self) { + self.0.store(true, Ordering::Release); + } +} + +impl Default for FrameSignal { + fn default() -> Self { + Self::new() + } +} + +pin_project! { + /// A body wrapper that signals a [`FrameSignal`] when a frame is yielded. + /// + /// Wraps any `B: http_body::Body` and delegates all operations to the inner + /// body. On each successful `poll_frame()` that yields a frame, the + /// associated [`FrameSignal`] is set to signalled. + pub struct RequestFrameMonitorBody { + #[pin] + inner: B, + signal: FrameSignal, + } +} + +impl RequestFrameMonitorBody { + /// Wrap an inner body with frame monitoring. + pub fn new(inner: B, signal: FrameSignal) -> Self { + Self { inner, signal } + } +} + +impl Body for RequestFrameMonitorBody +where + B: Body, +{ + type Data = B::Data; + type Error = B::Error; + + fn poll_frame( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, Self::Error>>> { + let this = self.project(); + let poll = this.inner.poll_frame(cx); + if let std::task::Poll::Ready(Some(Ok(_))) = &poll { + this.signal.signal(); + } + poll + } + + fn is_end_stream(&self) -> bool { + self.inner.is_end_stream() + } + + fn size_hint(&self) -> http_body::SizeHint { + self.inner.size_hint() + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use http_body_util::{BodyExt, Empty, Full}; + + use super::*; + + #[test] + fn signal_starts_unsignalled() { + let signal = FrameSignal::new(); + assert!(!signal.is_signalled()); + } + + #[test] + fn signal_round_trip() { + let signal = FrameSignal::new(); + signal.signal(); + assert!(signal.is_signalled()); + signal.reset(); + assert!(!signal.is_signalled()); + } + + #[test] + fn clone_shares_state() { + let a = FrameSignal::new(); + let b = a.clone(); + a.signal(); + assert!(b.is_signalled()); + } + + #[tokio::test] + async fn empty_body_does_not_signal() { + let signal = FrameSignal::new(); + let body = RequestFrameMonitorBody::new(Empty::::new(), signal.clone()); + let collected = body.collect().await.unwrap(); + assert!(collected.to_bytes().is_empty()); + assert!(!signal.is_signalled()); + } + + #[tokio::test] + async fn full_body_signals_on_frame() { + let signal = FrameSignal::new(); + let body = RequestFrameMonitorBody::new(Full::new(Bytes::from("hello")), signal.clone()); + let collected = body.collect().await.unwrap(); + assert_eq!(collected.to_bytes().as_ref(), b"hello"); + assert!(signal.is_signalled()); + } + + #[tokio::test] + async fn reset_between_attempts() { + let signal = FrameSignal::new(); + + // First attempt: signal gets set. + let body = RequestFrameMonitorBody::new(Full::new(Bytes::from("data")), signal.clone()); + body.collect().await.unwrap(); + assert!(signal.is_signalled()); + + // Reset before retry. + signal.reset(); + assert!(!signal.is_signalled()); + + // Second attempt with empty body: stays unsignalled. + let body = RequestFrameMonitorBody::new(Empty::::new(), signal.clone()); + body.collect().await.unwrap(); + assert!(!signal.is_signalled()); + } +} diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs new file mode 100644 index 00000000..5699fea9 --- /dev/null +++ b/sdk/src/lib.rs @@ -0,0 +1,117 @@ +/*! +Rust SDK for [S2](https://s2.dev/). + +The Rust SDK provides ergonomic wrappers and utilities to interact with the +[S2 API](https://s2.dev/docs/rest/records/overview). + +# Getting started + +1. Ensure you have added [tokio](https://crates.io/crates/tokio) and [futures](https://crates.io/crates/futures) as dependencies. + ```bash + cargo add tokio --features full + cargo add futures + ``` + +1. Add the `s2-sdk` dependency to your project: + + ```bash + cargo add s2-sdk + ``` + +1. Generate an access token by logging into the web console at [s2.dev](https://s2.dev/dashboard). + +1. Perform an operation. + + ```no_run + use s2_sdk::{ + S2, + types::{ListBasinsInput, S2Config}, + }; + + #[tokio::main] + async fn main() -> Result<(), Box> { + let s2 = S2::new(S2Config::new(""))?; + let page = s2.list_basins(ListBasinsInput::new()).await?; + println!("My basins: {:?}", page.values); + Ok(()) + } + ``` + +See [`S2`] for account-level operations, [`S2Basin`] for basin-level operations, +and [`S2Stream`] for stream-level operations. + +# Examples + +We have curated a bunch of examples in the +[repository](https://github.com/s2-streamstore/s2/tree/main/sdk/examples) +demonstrating how to use the SDK effectively: + +* [List all basins](https://github.com/s2-streamstore/s2/blob/main/sdk/examples/list_all_basins.rs) +* [Explicit stream trimming](https://github.com/s2-streamstore/s2/blob/main/sdk/examples/explicit_trim.rs) +* [Producer](https://github.com/s2-streamstore/s2/blob/main/sdk/examples/producer.rs) +* [Consumer](https://github.com/s2-streamstore/s2/blob/main/sdk/examples/consumer.rs) +* [Caught-up read session](https://github.com/s2-streamstore/s2/blob/main/sdk/examples/caught_up.rs) +* and many more... + +This documentation is generated using +[`rustdoc-scrape-examples`](https://doc.rust-lang.org/rustdoc/scraped-examples.html), +so you will be able to see snippets from examples right here in the +documentation. + +# Integration tests with s2-lite + +Use [`s2-testcontainers`](https://docs.rs/s2-testcontainers/latest/s2_testcontainers/) +to start `s2-lite` from the S2 Docker image and get an SDK client/config without +manual Docker port allocation, endpoint plumbing, or health polling. See the +[`s2_lite` example](https://github.com/s2-streamstore/s2/blob/main/testcontainers/examples/s2_lite.rs) +for the canonical setup. + +# Feedback + +We use [Github Issues](https://github.com/s2-streamstore/s2/issues) +to track feature requests and issues with the SDK. If you wish to provide +feedback, report a bug or request a feature, feel free to open a Github +issue. + +# Quick Links + +* [S2 Website](https://s2.dev) +* [S2 Documentation](https://s2.dev/docs) +* [CHANGELOG](https://github.com/s2-streamstore/s2/blob/main/sdk/CHANGELOG.md) +*/ + +#![doc( + html_favicon_url = "https://raw.githubusercontent.com/s2-streamstore/s2/main/assets/s2-black.png" +)] +#![doc( + html_logo_url = "https://raw.githubusercontent.com/s2-streamstore/s2/main/assets/s2-black.png" +)] +#![warn(missing_docs)] + +#[rustfmt::skip] +mod api; +mod client; +mod frame_signal; +mod reconnect; +mod session; + +pub mod batching; +pub mod error; +mod ops; +pub mod producer; +mod retry; +pub mod types; + +pub use ops::{S2, S2Basin, S2Stream}; +/// Append session for pipelining multiple appends with backpressure control. +/// +/// See [`AppendSession`](append_session::AppendSession). +pub mod append_session { + pub use crate::session::append::{ + AppendSession, AppendSessionConfig, BatchSubmitPermit, BatchSubmitTicket, + }; +} +/// Continuous read sessions. +pub mod read_session { + pub use crate::session::read::ReadSession; +} diff --git a/sdk/src/ops.rs b/sdk/src/ops.rs new file mode 100644 index 00000000..db339709 --- /dev/null +++ b/sdk/src/ops.rs @@ -0,0 +1,531 @@ +#[cfg(feature = "_hidden")] +use crate::client::Connect; +use crate::{ + api::{AccountClient, BaseClient, BasinClient}, + error::{AppendError, ReadError, RequestError}, + producer::{Producer, ProducerConfig}, + session::{ + self, AppendSession, AppendSessionConfig, ReadSession, ReadSessionError, StreamHeaders, + }, + types::{ + AccessTokenId, AccessTokenInfo, AppendAck, AppendInput, BasinConfig, BasinInfo, BasinName, + CreateBasinInput, CreateStreamInput, DeleteBasinInput, DeleteStreamInput, EncryptionKey, + EnsureBasinInput, EnsureOutput, EnsureStreamInput, GetAccountMetricsInput, + GetBasinMetricsInput, GetStreamMetricsInput, IssueAccessTokenInput, ListAccessTokensInput, + ListAllAccessTokensInput, ListAllBasinsInput, ListAllStreamsInput, ListBasinsInput, + ListStreamsInput, LocationInfo, LocationName, Metric, Page, ReadBatch, ReadInput, + ReadSessionConfig, ReconfigureBasinInput, ReconfigureStreamInput, S2Config, StreamConfig, + StreamInfo, StreamName, StreamPosition, Streaming, + }, +}; + +#[derive(Debug, Clone)] +/// An S2 account. +pub struct S2 { + client: AccountClient, +} + +impl S2 { + /// Create a new [`S2`]. + pub fn new(config: S2Config) -> Result { + let base_client = BaseClient::init(&config)?; + Ok(Self { + client: AccountClient::init(config, base_client), + }) + } + + #[doc(hidden)] + #[cfg(feature = "_hidden")] + pub fn new_with_connector(config: S2Config, connector: C) -> Result + where + C: Connect + Clone + Send + Sync + 'static, + { + let base_client = BaseClient::init_with_connector(&config, connector)?; + Ok(Self { + client: AccountClient::init(config, base_client), + }) + } + + /// Get an [`S2Basin`]. + pub fn basin(&self, name: BasinName) -> S2Basin { + S2Basin { + client: self.client.basin_client(name), + } + } + + /// List a page of basins. + /// + /// See [`list_all_basins`](crate::S2::list_all_basins) for automatic pagination. + pub async fn list_basins( + &self, + input: ListBasinsInput, + ) -> Result, RequestError> { + let response = self.client.list_basins(input.into()).await?; + Ok(Page::new( + response + .basins + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?, + response.has_more, + )) + } + + /// List all basins, paginating automatically. + pub fn list_all_basins(&self, input: ListAllBasinsInput) -> Streaming { + let s2 = self.clone(); + let prefix = input.prefix; + let start_after = input.start_after; + let include_deleted = input.include_deleted; + let mut input = ListBasinsInput::new() + .with_prefix(prefix) + .with_start_after(start_after); + Box::pin(async_stream::try_stream! { + loop { + let page = s2.list_basins(input.clone()).await?; + let start_after = page.values.last().map(|info| info.name.clone().into()); + + for info in page.values { + if !include_deleted && info.deleted_at.is_some() { + continue; + } + yield info; + } + + if page.has_more && let Some(start_after) = start_after { + input = input.with_start_after(start_after); + } else { + break; + } + } + }) + } + + /// Create a basin. + pub async fn create_basin(&self, input: CreateBasinInput) -> Result { + let (request, idempotency_token) = input.into(); + let info = self.client.create_basin(request, idempotency_token).await?; + Ok(info.try_into()?) + } + + /// Ensure a basin. + /// + /// If the basin doesn't exist, creates the basin with specified configuration. + /// + /// If the basin already exists: + /// - Its configuration is updated to the specified configuration, if different. + /// - Its configuration is unchanged, if the specified configuration is same. + pub async fn ensure_basin( + &self, + input: EnsureBasinInput, + ) -> Result, RequestError> { + let (name, request) = input.into(); + Ok(self + .client + .ensure_basin(name, request) + .await? + .try_map(BasinInfo::try_from)? + .into()) + } + + /// Get basin configuration. + pub async fn get_basin_config(&self, name: BasinName) -> Result { + let config = self.client.get_basin_config(name).await?; + Ok(config.into()) + } + + #[doc(hidden)] + #[cfg(feature = "_hidden")] + pub async fn get_basin_config_api( + &self, + name: BasinName, + ) -> Result { + Ok(self.client.get_basin_config(name).await?) + } + + /// Delete a basin. + pub async fn delete_basin(&self, input: DeleteBasinInput) -> Result<(), RequestError> { + Ok(self + .client + .delete_basin(input.name, input.ignore_not_found) + .await?) + } + + /// Reconfigure a basin. + pub async fn reconfigure_basin( + &self, + input: ReconfigureBasinInput, + ) -> Result { + let config = self + .client + .reconfigure_basin(input.name, input.config.into()) + .await?; + Ok(config.into()) + } + + /// List a page of access tokens. + /// + /// See [`list_all_access_tokens`](crate::S2::list_all_access_tokens) for automatic pagination. + pub async fn list_access_tokens( + &self, + input: ListAccessTokensInput, + ) -> Result, RequestError> { + let response = self.client.list_access_tokens(input.into()).await?; + Ok(Page::new( + response + .access_tokens + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?, + response.has_more, + )) + } + + #[doc(hidden)] + #[cfg(feature = "_hidden")] + pub async fn list_access_tokens_api( + &self, + input: ListAccessTokensInput, + ) -> Result { + Ok(self.client.list_access_tokens(input.into()).await?) + } + + /// List all access tokens, paginating automatically. + pub fn list_all_access_tokens( + &self, + input: ListAllAccessTokensInput, + ) -> Streaming { + let s2 = self.clone(); + let prefix = input.prefix; + let start_after = input.start_after; + let mut input = ListAccessTokensInput::new() + .with_prefix(prefix) + .with_start_after(start_after); + Box::pin(async_stream::try_stream! { + loop { + let page = s2.list_access_tokens(input.clone()).await?; + + let start_after = page.values.last().map(|info| info.id.clone().into()); + for info in page.values { + yield info; + } + + if page.has_more && let Some(start_after) = start_after { + input = input.with_start_after(start_after); + } else { + break; + } + } + }) + } + + /// Issue an access token. + pub async fn issue_access_token( + &self, + input: IssueAccessTokenInput, + ) -> Result { + let response = self.client.issue_access_token(input.into()).await?; + Ok(response.access_token) + } + + /// Revoke an access token. + pub async fn revoke_access_token(&self, id: AccessTokenId) -> Result<(), RequestError> { + Ok(self.client.revoke_access_token(id).await?) + } + + /// List locations. + pub async fn list_locations(&self) -> Result, RequestError> { + let response = self.client.list_locations().await?; + Ok(response.into_iter().map(Into::into).collect()) + } + + /// Get the default location. + pub async fn get_default_location(&self) -> Result { + Ok(self.client.get_default_location().await?.into()) + } + + /// Set the default location. + pub async fn set_default_location( + &self, + location: LocationName, + ) -> Result { + Ok(self.client.set_default_location(location).await?.into()) + } + + /// Get account metrics. + pub async fn get_account_metrics( + &self, + input: GetAccountMetricsInput, + ) -> Result, RequestError> { + let response = self.client.get_account_metrics(input.into()).await?; + Ok(response.values.into_iter().map(Into::into).collect()) + } + + /// Get basin metrics. + pub async fn get_basin_metrics( + &self, + input: GetBasinMetricsInput, + ) -> Result, RequestError> { + let (name, request) = input.into(); + let response = self.client.get_basin_metrics(name, request).await?; + Ok(response.values.into_iter().map(Into::into).collect()) + } + + /// Get stream metrics. + pub async fn get_stream_metrics( + &self, + input: GetStreamMetricsInput, + ) -> Result, RequestError> { + let (basin_name, stream_name, request) = input.into(); + let response = self + .client + .get_stream_metrics(basin_name, stream_name, request) + .await?; + Ok(response.values.into_iter().map(Into::into).collect()) + } +} + +#[derive(Debug, Clone)] +/// A basin in an S2 account. +/// +/// See [`S2::basin`]. +pub struct S2Basin { + client: BasinClient, +} + +impl S2Basin { + /// Get an [`S2Stream`]. + pub fn stream(&self, name: StreamName) -> S2Stream { + S2Stream { + client: self.client.clone(), + name, + encryption: None, + } + } + + /// List a page of streams. + /// + /// See [`list_all_streams`](crate::S2Basin::list_all_streams) for automatic pagination. + pub async fn list_streams( + &self, + input: ListStreamsInput, + ) -> Result, RequestError> { + let response = self.client.list_streams(input.into()).await?; + Ok(Page::new( + response + .streams + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?, + response.has_more, + )) + } + + /// List all streams, paginating automatically. + pub fn list_all_streams(&self, input: ListAllStreamsInput) -> Streaming { + let basin = self.clone(); + let prefix = input.prefix; + let start_after = input.start_after; + let include_deleted = input.include_deleted; + let mut input = ListStreamsInput::new() + .with_prefix(prefix) + .with_start_after(start_after); + Box::pin(async_stream::try_stream! { + loop { + let page = basin.list_streams(input.clone()).await?; + let start_after = page.values.last().map(|info| info.name.clone().into()); + + for info in page.values { + if !include_deleted && info.deleted_at.is_some() { + continue; + } + yield info; + } + + if page.has_more && let Some(start_after) = start_after { + input = input.with_start_after(start_after); + } else { + break; + } + } + }) + } + + /// Create a stream. + pub async fn create_stream( + &self, + input: CreateStreamInput, + ) -> Result { + let (request, idempotency_token) = input.into(); + let info = self + .client + .create_stream(request, idempotency_token) + .await?; + Ok(info.try_into()?) + } + + /// Ensure a stream. + /// + /// If the stream doesn't exist, creates the stream with specified configuration. + /// + /// If the stream already exists: + /// - Its configuration is updated to the specified configuration, if different. + /// - Its configuration is unchanged, if the specified configuration is same. + pub async fn ensure_stream( + &self, + input: EnsureStreamInput, + ) -> Result, RequestError> { + let (name, config) = input.into(); + Ok(self + .client + .ensure_stream(name, config) + .await? + .try_map(StreamInfo::try_from)? + .into()) + } + + /// Get stream configuration. + pub async fn get_stream_config(&self, name: StreamName) -> Result { + let config = self.client.get_stream_config(name).await?; + Ok(config.into()) + } + + #[doc(hidden)] + #[cfg(feature = "_hidden")] + pub async fn get_stream_config_api( + &self, + name: StreamName, + ) -> Result { + Ok(self.client.get_stream_config(name).await?) + } + + /// Delete a stream. + pub async fn delete_stream(&self, input: DeleteStreamInput) -> Result<(), RequestError> { + Ok(self + .client + .delete_stream(input.name, input.ignore_not_found) + .await?) + } + + /// Reconfigure a stream. + pub async fn reconfigure_stream( + &self, + input: ReconfigureStreamInput, + ) -> Result { + let config = self + .client + .reconfigure_stream(input.name, input.config.into()) + .await?; + Ok(config.into()) + } +} + +#[derive(Debug, Clone)] +/// A stream in an S2 basin. +/// +/// See [`S2Basin::stream`]. +pub struct S2Stream { + client: BasinClient, + name: StreamName, + encryption: Option, +} + +impl S2Stream { + /// Set the encryption key for this stream handle. + pub fn with_encryption_key(self, encryption: EncryptionKey) -> Self { + Self { + encryption: Some(encryption), + ..self + } + } + + fn headers(&self, stream_config: Option<&StreamConfig>) -> StreamHeaders { + StreamHeaders { + encryption: self.encryption.clone(), + stream_config: stream_config.cloned().map(Into::into), + } + } + + /// Check tail position. + pub async fn check_tail(&self) -> Result { + let response = self.client.check_tail(&self.name).await?; + Ok(response.tail.into()) + } + + /// Append records. + pub async fn append(&self, mut input: AppendInput) -> Result { + let stream_config = input + .stream_config + .take() + .map(s2_api::v1::config::StreamConfig::from); + let ack = self + .client + .append( + &self.name, + input.into(), + self.encryption.as_ref(), + stream_config.as_ref(), + self.client.config.retry.append_retry_policy, + ) + .await?; + Ok(ack.into()) + } + + /// Read records. + pub async fn read(&self, input: ReadInput) -> Result { + let stream_config = input + .stream_config + .map(s2_api::v1::config::StreamConfig::from); + let batch = self + .client + .read( + &self.name, + input.start.into(), + input.stop.into(), + self.encryption.as_ref(), + stream_config.as_ref(), + ) + .await?; + let mut batch = ReadBatch::from_api(batch); + if input.ignore_command_records { + batch.records.retain(|r| !r.is_command_record()); + } + Ok(batch) + } + + /// Create an append session for submitting [`AppendInput`]s. + pub fn append_session(&self, config: AppendSessionConfig) -> AppendSession { + AppendSession::new( + self.client.clone(), + self.name.clone(), + self.headers(config.stream_config()), + config, + ) + } + + /// Create a producer for submitting individual [`AppendRecord`](crate::types::AppendRecord)s. + pub fn producer(&self, config: ProducerConfig) -> Producer { + Producer::new( + self.client.clone(), + self.name.clone(), + self.headers(config.stream_config()), + config, + ) + } + + /// Create a read session. + pub async fn read_session( + &self, + input: ReadInput, + config: ReadSessionConfig, + ) -> Result { + session::read_session( + self.client.clone(), + self.name.clone(), + self.headers(input.stream_config.as_ref()), + input, + config, + ) + .await + } +} diff --git a/sdk/src/producer.rs b/sdk/src/producer.rs new file mode 100644 index 00000000..293f774a --- /dev/null +++ b/sdk/src/producer.rs @@ -0,0 +1,639 @@ +//! High-level producer for appending records to streams. +//! +//! See [`Producer`]. + +use std::{ + collections::VecDeque, + future::Future, + pin::Pin, + sync::{Arc, OnceLock}, + task::{Context, Poll}, +}; + +use futures_util::{FutureExt, StreamExt, TryFutureExt, stream::FuturesUnordered}; +use s2_common::caps::RECORD_BATCH_MAX; +use tokio::sync::{mpsc, oneshot}; +use tokio_stream::wrappers::ReceiverStream; +use tokio_util::task::AbortOnDropHandle; + +use crate::{ + api::BasinClient, + batching::{AppendInputs, AppendRecordBatches, BatchingConfig}, + error::ProducerError, + session::{ + AppendPermit, AppendPermits, AppendSessionInternal, BatchSubmitTicket, StreamHeaders, + }, + types::{ + AppendAck, AppendRecord, FencingToken, MeteredBytes, ONE_MIB, StreamConfig, StreamName, + ValidationError, + }, +}; + +/// A [`Future`] that resolves to an acknowledgement once the record is appended. +pub struct RecordSubmitTicket { + rx: oneshot::Receiver>, + terminal_err: Arc>, +} + +impl Future for RecordSubmitTicket { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match Pin::new(&mut self.rx).poll(cx) { + Poll::Ready(Ok(res)) => Poll::Ready(res), + Poll::Ready(Err(_)) => Poll::Ready(Err(self + .terminal_err + .get() + .cloned() + .unwrap_or(ProducerError::ProducerDropped))), + Poll::Pending => Poll::Pending, + } + } +} + +/// Acknowledgement for an appended record. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct IndexedAppendAck { + /// Sequence number assigned to the record. + pub seq_num: u64, + /// Acknowledgement for the containing batch. + pub batch: AppendAck, +} + +impl IndexedAppendAck { + /// Construct an acknowledgement for an appended record. + /// + /// This is intended for building fixtures in downstream tests. + pub fn new(seq_num: u64, batch: AppendAck) -> Self { + Self { seq_num, batch } + } +} + +/// Configuration for a [`Producer`]. +#[derive(Debug, Clone)] +pub struct ProducerConfig { + max_unacked_bytes: u32, + batching: BatchingConfig, + fencing_token: Option, + match_seq_num: Option, + stream_config: Option, +} + +impl Default for ProducerConfig { + fn default() -> Self { + Self { + max_unacked_bytes: 5 * ONE_MIB, + batching: BatchingConfig::default(), + fencing_token: None, + match_seq_num: None, + stream_config: None, + } + } +} + +impl ProducerConfig { + /// Create a new [`ProducerConfig`] with default settings. + pub fn new() -> Self { + Self::default() + } + + /// Set the limit on total metered bytes of unacknowledged [`AppendRecord`]s held in memory. + /// + /// **Note:** It must be at least `1MiB`. + /// + /// Defaults to `5MiB`. + pub fn with_max_unacked_bytes(self, max_unacked_bytes: u32) -> Result { + if max_unacked_bytes < ONE_MIB { + return Err(format!("max_unacked_bytes must be at least {ONE_MIB}").into()); + } + Ok(Self { + max_unacked_bytes, + ..self + }) + } + + /// Set the configuration for batching records into [`AppendInput`](crate::types::AppendInput)s + /// before appending. + /// + /// See [`BatchingConfig`] for defaults. + pub fn with_batching(self, batching: BatchingConfig) -> Self { + Self { batching, ..self } + } + + /// Set the fencing token for all [`AppendInput`](crate::types::AppendInput)s. + /// + /// Defaults to `None`. + pub fn with_fencing_token(self, fencing_token: FencingToken) -> Self { + Self { + fencing_token: Some(fencing_token), + ..self + } + } + + /// Set the match sequence number for the initial [`AppendInput`](crate::types::AppendInput). It + /// will be auto-incremented for subsequent ones. + /// + /// Defaults to `None`. + pub fn with_match_seq_num(self, match_seq_num: u64) -> Self { + Self { + match_seq_num: Some(match_seq_num), + ..self + } + } + + /// Set the stream configuration to apply if the stream is created on append. + /// + /// Unset fields inherit the basin's default stream configuration. Ignored if the stream + /// already exists. + /// + /// Defaults to `None`. + pub fn with_stream_config(self, stream_config: StreamConfig) -> Self { + Self { + stream_config: Some(stream_config), + ..self + } + } + + pub(crate) fn stream_config(&self) -> Option<&StreamConfig> { + self.stream_config.as_ref() + } +} + +/// High-level interface for submitting individual [`AppendRecord`]s. +/// +/// Handles batching of records into [`AppendInput`](crate::types::AppendInput)s automatically based +/// on the provided [`configuration`](ProducerConfig), and uses an append session internally. +pub struct Producer { + cmd_tx: mpsc::Sender, + permits: AppendPermits, + terminal_err: Arc>, + _handle: AbortOnDropHandle<()>, +} + +impl Producer { + pub(crate) fn new( + client: BasinClient, + stream: StreamName, + headers: StreamHeaders, + config: ProducerConfig, + ) -> Self { + let (cmd_tx, cmd_rx) = mpsc::channel::(RECORD_BATCH_MAX.count); + let permits = AppendPermits::new(None, config.max_unacked_bytes); + let session = AppendSessionInternal::new(client, stream, headers); + let terminal_err = Arc::new(OnceLock::new()); + let _handle = AbortOnDropHandle::new(tokio::spawn(Self::run( + session, + config, + cmd_rx, + terminal_err.clone(), + ))); + Self { + cmd_tx, + permits, + terminal_err, + _handle, + } + } + + /// Submit a record for appending. + /// + /// Internally, it waits on [`reserve`](Self::reserve), then submits using the permit. + /// This provides backpressure when the unacknowledged bytes limit is reached. + /// For explicit control, use [`reserve`](Self::reserve) followed by + /// [`RecordSubmitPermit::submit`]. + /// + /// Use [`flush`](Self::flush) to establish a non-terminal durability boundary, and call + /// [`close`](Self::close) when finished to flush remaining records and release resources. + pub async fn submit(&self, record: AppendRecord) -> Result { + let permit = self.reserve(record.metered_bytes() as u32).await?; + Ok(permit.submit(record)) + } + + /// Reserve capacity for a record to be submitted. Useful in [`select!`](tokio::select) loops + /// where you want to interleave submission with other async work. See [`submit`](Self::submit) + /// for a simpler API. + /// + /// Waits when the unacknowledged bytes limit is reached, providing explicit backpressure + /// control. The returned permit must be used to submit the record. + /// + /// Reserving capacity does not order a record relative to [`flush`](Self::flush); the record is + /// ordered when [`RecordSubmitPermit::submit`] is called. + /// + /// # Cancel safety + /// + /// This method is cancel safe. Internally, it only awaits + /// [`Semaphore::acquire_many_owned`](tokio::sync::Semaphore::acquire_many_owned) and + /// [`Sender::reserve_owned`](tokio::sync::mpsc::Sender::reserve_owned), both of which are + /// cancel safe. + pub async fn reserve(&self, bytes: u32) -> Result { + let append_permit = self.permits.acquire(bytes).await; + let cmd_tx_permit = self + .cmd_tx + .clone() + .reserve_owned() + .await + .map_err(|_| self.terminal_err())?; + Ok(RecordSubmitPermit { + append_permit, + cmd_tx_permit, + terminal_err: self.terminal_err.clone(), + }) + } + + /// Flush all records ordered before this call and wait for them to become durable. + /// + /// This immediately emits the current partial batch without waiting for the configured linger + /// duration. The producer remains open and can be used for subsequent submissions and flushes. + /// If there is no preceding work, this completes without submitting an empty batch. + /// + /// A record whose [`submit`](Self::submit) call completed before this method began is covered + /// by the flush. Submissions concurrent with the flush may be ordered on either side of the + /// boundary; records ordered after it are not included in the durability wait. + pub async fn flush(&self) -> Result<(), ProducerError> { + let (done_tx, done_rx) = oneshot::channel(); + self.cmd_tx + .send(Command::Flush { done_tx }) + .await + .map_err(|_| self.terminal_err())?; + done_rx.await.map_err(|_| self.terminal_err())? + } + + /// Close the producer and wait for all submitted records to be appended. + pub async fn close(self) -> Result<(), ProducerError> { + let (done_tx, done_rx) = oneshot::channel(); + self.cmd_tx + .send(Command::Close { done_tx }) + .await + .map_err(|_| self.terminal_err())?; + done_rx.await.map_err(|_| self.terminal_err())? + } + + fn terminal_err(&self) -> ProducerError { + self.terminal_err + .get() + .cloned() + .unwrap_or(ProducerError::ProducerClosed) + } + + async fn run( + session: AppendSessionInternal, + config: ProducerConfig, + mut cmd_rx: mpsc::Receiver, + terminal_err: Arc>, + ) { + let (record_tx, mut inputs) = Self::batcher(&config, config.match_seq_num); + let mut record_tx = Some(record_tx); + + let mut pending_batch_acks = FuturesUnordered::new(); + let mut pending_record_acks = VecDeque::new(); + let mut control = PendingControl::default(); + let mut stashed_submission: Option = None; + let mut submit_fut: Option = None; + let mut submit_batch_len: Option = None; + let mut inputs_exhausted = false; + + loop { + tokio::select! { + record_tx_permit = async { + record_tx + .as_ref() + .expect("record_tx should not be None") + .reserve() + .await + }, if stashed_submission.is_some() => { + let submission = stashed_submission + .take() + .expect("stashed_submission should not be None"); + pending_record_acks.push_back(PendingRecordAck { + ack_tx: submission.ack_tx, + _permit: submission.permit, + }); + record_tx_permit + .expect("record_rx should not be closed") + .send(submission.record); + } + + cmd = cmd_rx.recv(), if stashed_submission.is_none() && control.flush_tx.is_none() => { + match cmd { + Some(Command::Submit { record, ack_tx, permit }) => { + if control.close_tx.is_some() { + let _ = ack_tx.send( + Err(ProducerError::ProducerClosing) + ); + } else { + stashed_submission = Some(StashedSubmission { record, ack_tx, permit }); + } + } + Some(Command::Close { done_tx }) => { + control.close_tx = Some(done_tx); + } + Some(Command::Flush { done_tx }) => { + if control.close_tx.is_some() { + let _ = done_tx.send(Err(ProducerError::ProducerClosing)); + } else { + control.flush_tx = Some(done_tx); + } + } + None => { + terminate_producer( + ProducerError::ProducerDropped, + &terminal_err, + &mut pending_batch_acks, + &mut pending_record_acks, + &mut stashed_submission, + &mut control, + &mut cmd_rx, + ) + .await; + return; + } + } + } + + input = inputs.next(), if submit_fut.is_none() && !inputs_exhausted => { + match input { + Some(Ok(input)) => { + submit_batch_len = Some(input.records.len()); + submit_fut = Some(Box::pin(session.submit(input).map_err(Into::into))); + } + Some(Err(err)) => { + terminate_producer( + err.into(), + &terminal_err, + &mut pending_batch_acks, + &mut pending_record_acks, + &mut stashed_submission, + &mut control, + &mut cmd_rx, + ) + .await; + return; + } + None => { + inputs_exhausted = true; + } + } + } + + ticket = async { + submit_fut + .as_mut() + .expect("submit_fut should not be None") + .await + }, if submit_fut.is_some() => { + submit_fut = None; + match ticket { + Ok(ticket) => { + let batch_len = submit_batch_len + .take() + .expect("submit_batch_len should not be None"); + pending_batch_acks.push(PendingBatchAck { + ticket, + pending_record_acks: Some( + pending_record_acks.drain(..batch_len).collect::>(), + ), + }); + } + Err(err) => { + terminate_producer( + err, + &terminal_err, + &mut pending_batch_acks, + &mut pending_record_acks, + &mut stashed_submission, + &mut control, + &mut cmd_rx, + ) + .await; + return; + } + } + } + + Some((batch_ack, batch_record_acks)) = pending_batch_acks.next() => { + let terminal_batch_err = batch_ack.as_ref().err().cloned(); + dispatch_acks(batch_ack, batch_record_acks); + if let Some(err) = terminal_batch_err { + terminate_producer( + err, + &terminal_err, + &mut pending_batch_acks, + &mut pending_record_acks, + &mut stashed_submission, + &mut control, + &mut cmd_rx, + ) + .await; + return; + } + } + } + + if (control.flush_tx.is_some() || control.close_tx.is_some()) && record_tx.is_some() { + record_tx = None; + } + + if control.flush_tx.is_some() + && inputs_exhausted + && pending_record_acks.is_empty() + && pending_batch_acks.is_empty() + && stashed_submission.is_none() + && submit_fut.is_none() + { + let next_match_seq_num = inputs.match_seq_num; + let (next_record_tx, next_inputs) = Self::batcher(&config, next_match_seq_num); + record_tx = Some(next_record_tx); + inputs = next_inputs; + inputs_exhausted = false; + + if let Some(done_tx) = control.flush_tx.take() { + let _ = done_tx.send(Ok(())); + } + } + + if control.close_tx.is_some() + && control.flush_tx.is_none() + && pending_record_acks.is_empty() + && pending_batch_acks.is_empty() + && stashed_submission.is_none() + && submit_fut.is_none() + { + break; + } + } + + let session_close_res = session.close().await; + + if let Some(done_tx) = control.close_tx.take() { + let _ = done_tx.send(session_close_res.map_err(Into::into)); + } + } + + fn batcher( + config: &ProducerConfig, + match_seq_num: Option, + ) -> (mpsc::Sender, AppendInputs) { + let (record_tx, record_rx) = mpsc::channel(RECORD_BATCH_MAX.count); + let mut inputs = AppendInputs::new(AppendRecordBatches::from_stream( + ReceiverStream::new(record_rx), + config.batching.clone(), + )); + if let Some(fencing_token) = config.fencing_token.as_ref() { + inputs = inputs.with_fencing_token(fencing_token.clone()); + } + if let Some(seq_num) = match_seq_num { + inputs = inputs.with_match_seq_num(seq_num); + } + (record_tx, inputs) + } +} + +/// A permit to submit a record after reserving capacity. +pub struct RecordSubmitPermit { + append_permit: AppendPermit, + cmd_tx_permit: mpsc::OwnedPermit, + terminal_err: Arc>, +} + +impl RecordSubmitPermit { + /// Submit the record using this permit. + pub fn submit(self, record: AppendRecord) -> RecordSubmitTicket { + let (ack_tx, ack_rx) = oneshot::channel(); + self.cmd_tx_permit.send(Command::Submit { + record, + ack_tx, + permit: self.append_permit, + }); + RecordSubmitTicket { + rx: ack_rx, + terminal_err: self.terminal_err, + } + } +} + +type SubmitFuture = Pin> + Send>>; + +enum Command { + Submit { + record: AppendRecord, + ack_tx: oneshot::Sender>, + permit: AppendPermit, + }, + Flush { + done_tx: oneshot::Sender>, + }, + Close { + done_tx: oneshot::Sender>, + }, +} + +impl Command { + fn reject(self, err: ProducerError) { + match self { + Command::Submit { ack_tx, .. } => { + let _ = ack_tx.send(Err(err)); + } + Command::Flush { done_tx } => { + let _ = done_tx.send(Err(err)); + } + Command::Close { done_tx } => { + let _ = done_tx.send(Err(err)); + } + } + } +} + +struct StashedSubmission { + record: AppendRecord, + ack_tx: oneshot::Sender>, + permit: AppendPermit, +} + +#[derive(Default)] +struct PendingControl { + flush_tx: Option>>, + close_tx: Option>>, +} + +struct PendingRecordAck { + ack_tx: oneshot::Sender>, + _permit: AppendPermit, +} + +struct PendingBatchAck { + ticket: BatchSubmitTicket, + pending_record_acks: Option>, +} + +impl Future for PendingBatchAck { + type Output = (Result, Vec); + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match Pin::new(&mut self.ticket).poll(cx) { + Poll::Ready(batch_ack) => Poll::Ready(( + batch_ack.map_err(Into::into), + self.pending_record_acks + .take() + .expect("pending_record_acks should not be None"), + )), + Poll::Pending => Poll::Pending, + } + } +} + +fn dispatch_acks( + batch_ack: Result, + pending_record_acks: Vec, +) { + match batch_ack { + Ok(batch_ack) => { + for (offset, pending) in pending_record_acks.into_iter().enumerate() { + let seq_num = batch_ack.start.seq_num + offset as u64; + let _ = pending.ack_tx.send(Ok(IndexedAppendAck { + seq_num, + batch: batch_ack.clone(), + })); + } + } + Err(err) => { + for pending in pending_record_acks { + let _ = pending.ack_tx.send(Err(err.clone())); + } + } + } +} + +async fn terminate_producer( + err: ProducerError, + terminal_err: &OnceLock, + pending_batch_acks: &mut FuturesUnordered, + pending_record_acks: &mut VecDeque, + stashed_submission: &mut Option, + control: &mut PendingControl, + cmd_rx: &mut mpsc::Receiver, +) { + while let Some((batch_ack, pending_record_acks)) = + pending_batch_acks.next().now_or_never().flatten() + { + dispatch_acks(batch_ack, pending_record_acks); + } + + let _ = terminal_err.set(err.clone()); + for pending in pending_record_acks.drain(..) { + let _ = pending.ack_tx.send(Err(err.clone())); + } + if let Some(submission) = stashed_submission.take() { + let _ = submission.ack_tx.send(Err(err.clone())); + } + if let Some(done_tx) = control.flush_tx.take() { + let _ = done_tx.send(Err(err.clone())); + } + if let Some(done_tx) = control.close_tx.take() { + let _ = done_tx.send(Err(err.clone())); + } + cmd_rx.close(); + while let Some(cmd) = cmd_rx.recv().await { + cmd.reject(err.clone()); + } +} diff --git a/sdk/src/reconnect.rs b/sdk/src/reconnect.rs new file mode 100644 index 00000000..9df29362 --- /dev/null +++ b/sdk/src/reconnect.rs @@ -0,0 +1,62 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use tokio::time::Instant; + +/// Advised reconnects to attempt before staying on. +pub(crate) const MAX_ADVISED_RECONNECTS: usize = 1; + +/// Gap after which the attempt count resets. +pub(crate) const ADVISED_RECONNECT_IDLE: Duration = Duration::from_secs(60); + +/// Advised reconnects attempted lately. +#[derive(Clone, Copy, Default)] +pub(crate) struct AdvisedReconnects { + count: usize, + last: Option, +} + +impl AdvisedReconnects { + pub(crate) fn record(&mut self) { + if !self.is_recent() { + self.count = 0; + } + self.last = Some(Instant::now()); + self.count += 1; + } + + /// Whether to act on advice, or stay until the server ends the connection. + pub(crate) fn should_reconnect(&self) -> bool { + !self.is_recent() || self.count < MAX_ADVISED_RECONNECTS + } + + pub(crate) fn count(&self) -> usize { + self.count + } + + fn is_recent(&self) -> bool { + self.last + .is_some_and(|at| at.elapsed() <= ADVISED_RECONNECT_IDLE) + } +} + +/// An atomic flag tracking whether the server has advised reconnecting on this +/// connection. Set by the response decoder when a frame carries the +/// reconnect-advised bit, checked by the session loops. +#[derive(Clone, Default)] +pub(crate) struct ReconnectAdvice(Arc); + +impl ReconnectAdvice { + pub(crate) fn is_advised(&self) -> bool { + self.0.load(Ordering::Acquire) + } + + pub(crate) fn advise(&self) { + self.0.store(true, Ordering::Release); + } +} diff --git a/sdk/src/retry.rs b/sdk/src/retry.rs new file mode 100644 index 00000000..8eddd738 --- /dev/null +++ b/sdk/src/retry.rs @@ -0,0 +1,218 @@ +use std::time::Duration; + +use rand::{RngExt, rng}; + +use crate::frame_signal::FrameSignal; + +pub(crate) trait AppendRetryError: Sized { + /// Whether the failure represented by this error guarantees that no mutation occurred. + fn has_no_side_effects(&self) -> bool; + + /// Mark the whole append as indefinite, retaining this final attempt's definite error. + /// The caller must establish that an earlier attempt may have taken effect. + fn into_indefinite_failure(self) -> Self; + + /// Wrap a definite final error if an earlier attempt may have taken effect. + /// Already indefinite errors are returned unchanged. + fn with_prior_uncertainty(self, prior_uncertainty: bool) -> Self { + if prior_uncertainty && self.has_no_side_effects() { + self.into_indefinite_failure() + } else { + self + } + } + + /// Whether this attempt may have taken effect, accounting for unsent request data. + /// Without a frame signal, assume the request may have been sent. + fn attempt_may_have_side_effects(&self, frame_signal: Option<&FrameSignal>) -> bool { + !self.has_no_side_effects() && frame_signal.is_none_or(|s| s.is_signalled()) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct RetryBackoffBuilder { + pub min_base_delay: Duration, + pub max_base_delay: Duration, + pub max_retries: u32, +} + +impl Default for RetryBackoffBuilder { + fn default() -> Self { + Self { + min_base_delay: Duration::from_millis(100), + max_base_delay: Duration::from_secs(1), + max_retries: 3, + } + } +} + +impl RetryBackoffBuilder { + pub fn with_min_base_delay(self, min_base_delay: Duration) -> Self { + Self { + min_base_delay, + ..self + } + } + + pub fn with_max_base_delay(self, max_base_delay: Duration) -> Self { + Self { + max_base_delay, + ..self + } + } + + pub fn with_max_retries(self, max_retries: u32) -> Self { + Self { + max_retries, + ..self + } + } + + pub fn build(self) -> RetryBackoff { + RetryBackoff { + min_base_delay: self.min_base_delay, + max_base_delay: self.max_base_delay, + max_retries: self.max_retries, + cur_retry: 0, + } + } +} + +pub struct RetryBackoff { + min_base_delay: Duration, + max_base_delay: Duration, + max_retries: u32, + cur_retry: u32, +} + +impl RetryBackoff { + /// Return the next delay, continuing at the jittered maximum base delay after exhaustion. + pub fn next_or_max(&mut self) -> Duration { + self.next() + .unwrap_or_else(|| jittered_delay(self.max_base_delay)) + } + + pub fn remaining(&self) -> u32 { + self.max_retries.saturating_sub(self.cur_retry) + } + + pub fn is_exhausted(&self) -> bool { + self.cur_retry >= self.max_retries + } + + pub fn reset(&mut self) { + self.cur_retry = 0; + } + + pub fn used(&self) -> u32 { + self.cur_retry + } +} + +impl Iterator for RetryBackoff { + type Item = Duration; + + fn next(&mut self) -> Option { + if self.cur_retry >= self.max_retries { + return None; + } + let base_delay = (self + .min_base_delay + .saturating_mul(2u32.saturating_pow(self.cur_retry))) + .min(self.max_base_delay); + self.cur_retry += 1; + Some(jittered_delay(base_delay)) + } +} + +fn jittered_delay(base_delay: Duration) -> Duration { + let jitter = + Duration::try_from_secs_f64(base_delay.as_secs_f64() * rng().random_range(0.0..=1.0)) + .unwrap_or(Duration::MAX); + base_delay.saturating_add(jitter) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backoffs() { + let backoffs: Vec<_> = RetryBackoffBuilder::default() + .with_max_retries(6) + .build() + .collect(); + + assert_eq!(backoffs.len(), 6); + assert!(backoffs[0] >= Duration::from_millis(100)); + assert!(backoffs[0] <= Duration::from_millis(200)); + + assert!(backoffs[1] >= Duration::from_millis(200)); + assert!(backoffs[1] <= Duration::from_millis(400)); + + assert!(backoffs[2] >= Duration::from_millis(400)); + assert!(backoffs[2] <= Duration::from_millis(800)); + + assert!(backoffs[3] >= Duration::from_millis(800)); + assert!(backoffs[3] <= Duration::from_millis(1600)); + + assert!(backoffs[4] >= Duration::from_millis(1000)); + assert!(backoffs[4] <= Duration::from_millis(2000)); + + assert!(backoffs[5] >= Duration::from_millis(1000)); + assert!(backoffs[5] <= Duration::from_millis(2000)); + } + + #[test] + fn backoff_with_reset() { + let mut backoff = RetryBackoffBuilder::default().with_max_retries(3).build(); + + assert_eq!(backoff.used(), 0); + assert_eq!(backoff.remaining(), 3); + assert!(!backoff.is_exhausted()); + + assert!(backoff.next().is_some()); + assert_eq!(backoff.used(), 1); + assert_eq!(backoff.remaining(), 2); + assert!(!backoff.is_exhausted()); + + backoff.reset(); + + assert_eq!(backoff.used(), 0); + assert_eq!(backoff.remaining(), 3); + assert!(!backoff.is_exhausted()); + + assert!(backoff.next().is_some()); + assert_eq!(backoff.used(), 1); + assert_eq!(backoff.remaining(), 2); + + assert!(backoff.next().is_some()); + assert_eq!(backoff.used(), 2); + assert_eq!(backoff.remaining(), 1); + + assert!(backoff.next().is_some()); + assert_eq!(backoff.used(), 3); + assert_eq!(backoff.remaining(), 0); + assert!(backoff.is_exhausted()); + + assert!(backoff.next().is_none()); + } + + #[test] + fn next_or_max_continues_with_capped_jitter() { + let delay = Duration::from_secs(7); + let mut backoff = RetryBackoffBuilder::default() + .with_max_base_delay(delay) + .with_max_retries(1) + .build(); + assert!(backoff.next().is_some()); + assert!(backoff.next().is_none()); + + for _ in 0..10 { + let next = backoff.next_or_max(); + assert!(next >= delay); + assert!(next <= delay * 2); + } + assert!(backoff.is_exhausted()); + } +} diff --git a/sdk/src/session/append.rs b/sdk/src/session/append.rs new file mode 100644 index 00000000..43989a7b --- /dev/null +++ b/sdk/src/session/append.rs @@ -0,0 +1,1299 @@ +use std::{ + collections::VecDeque, + future::Future, + num::NonZeroU32, + pin::Pin, + sync::{Arc, OnceLock}, + task::{Context, Poll}, + time::Duration, +}; + +use futures_util::StreamExt; +use tokio::{ + sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot}, + time::Instant, +}; +use tokio_muxt::{CoalesceMode, MuxTimer}; +use tokio_stream::wrappers::ReceiverStream; +use tokio_util::task::AbortOnDropHandle; +use tracing::debug; + +use crate::{ + api::{ApiError, BasinClient, Streaming, retry_builder}, + error::{AppendError, RequestError}, + frame_signal::FrameSignal, + reconnect::{AdvisedReconnects, ReconnectAdvice}, + retry::{AppendRetryError, RetryBackoffBuilder}, + session::StreamHeaders, + types::{ + AccessTokenMode, AppendAck, AppendInput, AppendRetryPolicy, MeteredBytes, ONE_MIB, + StreamConfig, StreamName, StreamPosition, ValidationError, + }, +}; + +/// Errors returned by an append session. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum AppendSessionError { + /// An error with the append request underlying the session. + #[error(transparent)] + Append(#[from] AppendError), + /// An append acknowledgement timed out. + #[error("append acknowledgement timed out")] + AckTimeout, + /// The server disconnected during the session. + #[error("server disconnected")] + ServerDisconnected, + /// The response stream closed while appends were in flight. + #[error("response stream closed early while appends in flight")] + StreamClosedEarly, + /// The session was already closed. + #[error("session already closed")] + SessionClosed, + /// The session is closing. + #[error("session is closing")] + SessionClosing, + /// The session was dropped without being closed. + #[error("session dropped without calling close")] + SessionDropped, + /// The server returned an invalid append acknowledgement. + #[error("invalid append acknowledgement: {0}")] + InvalidAck(String), + /// The final attempt failed definitively, but an earlier attempt may have taken effect, + /// so the entire append operation is indeterminate. + #[error( + "append may have taken effect in an earlier attempt; final attempt failed: {final_attempt_error}" + )] + IndefiniteFailure { + /// The definite error returned by the final attempt. + #[source] + final_attempt_error: Box, + }, +} + +impl AppendSessionError { + /// Whether retrying the operation is safe or sensible. + pub fn is_retryable(&self) -> bool { + match self { + Self::Append(error) => error.is_retryable(), + Self::IndefiniteFailure { + final_attempt_error, + } => final_attempt_error.is_retryable(), + Self::AckTimeout | Self::ServerDisconnected => true, + Self::StreamClosedEarly + | Self::SessionClosed + | Self::SessionClosing + | Self::SessionDropped + | Self::InvalidAck(_) => false, + } + } + + /// Whether retrying the operation cannot duplicate a mutation. + pub fn has_no_side_effects(&self) -> bool { + match self { + Self::Append(error) => error.has_no_side_effects(), + Self::IndefiniteFailure { .. } => false, + Self::SessionClosed | Self::SessionClosing => true, + Self::AckTimeout + | Self::ServerDisconnected + | Self::StreamClosedEarly + | Self::SessionDropped + | Self::InvalidAck(_) => false, + } + } + + /// Return the underlying request error, if present. + pub fn request_error(&self) -> Option<&RequestError> { + match self { + Self::Append(error) => error.request_error(), + Self::IndefiniteFailure { + final_attempt_error, + } => final_attempt_error.request_error(), + Self::AckTimeout + | Self::ServerDisconnected + | Self::StreamClosedEarly + | Self::SessionClosed + | Self::SessionClosing + | Self::SessionDropped + | Self::InvalidAck(_) => None, + } + } + + fn is_authentication_error(&self) -> bool { + matches!( + self, + Self::Append(AppendError::Request(error)) if error.is_authentication_error() + ) + } + + fn is_server_draining(&self) -> bool { + matches!( + self, + Self::Append(AppendError::Request(error)) if error.is_server_draining() + ) + } +} + +impl AppendRetryError for AppendSessionError { + fn has_no_side_effects(&self) -> bool { + Self::has_no_side_effects(self) + } + + fn into_indefinite_failure(self) -> Self { + Self::IndefiniteFailure { + final_attempt_error: Box::new(self), + } + } +} + +impl From for AppendSessionError { + fn from(error: ApiError) -> Self { + Self::Append(error.into()) + } +} + +/// A [`Future`] that resolves to an acknowledgement once the batch of records is appended. +pub struct BatchSubmitTicket { + rx: oneshot::Receiver>, + terminal_err: Arc>, +} + +impl Future for BatchSubmitTicket { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match Pin::new(&mut self.rx).poll(cx) { + Poll::Ready(Ok(res)) => Poll::Ready(res), + Poll::Ready(Err(_)) => Poll::Ready(Err(self + .terminal_err + .get() + .cloned() + .unwrap_or(AppendSessionError::SessionDropped))), + Poll::Pending => Poll::Pending, + } + } +} + +#[derive(Debug, Clone)] +/// Configuration for an [`AppendSession`]. +pub struct AppendSessionConfig { + max_unacked_bytes: u32, + max_unacked_batches: Option, + stream_config: Option, +} + +impl Default for AppendSessionConfig { + fn default() -> Self { + Self { + max_unacked_bytes: 5 * ONE_MIB, + max_unacked_batches: None, + stream_config: None, + } + } +} + +impl AppendSessionConfig { + /// Create a new [`AppendSessionConfig`] with default settings. + pub fn new() -> Self { + Self::default() + } + + /// Set the limit on total metered bytes of unacknowledged [`AppendInput`]s held in memory. + /// + /// **Note:** It must be at least `1MiB`. + /// + /// Defaults to `5MiB`. + pub fn with_max_unacked_bytes(self, max_unacked_bytes: u32) -> Result { + if max_unacked_bytes < ONE_MIB { + return Err(format!("max_unacked_bytes must be at least {ONE_MIB}").into()); + } + Ok(Self { + max_unacked_bytes, + ..self + }) + } + + /// Set the limit on number of unacknowledged [`AppendInput`]s held in memory. + /// + /// Defaults to no limit. + pub fn with_max_unacked_batches(self, max_unacked_batches: NonZeroU32) -> Self { + Self { + max_unacked_batches: Some(max_unacked_batches.get()), + ..self + } + } + + /// Set the stream configuration to apply if the stream is created on append. + /// + /// Unset fields inherit the basin's default stream configuration. Ignored if the stream + /// already exists. + /// + /// Defaults to `None`. + pub fn with_stream_config(self, stream_config: StreamConfig) -> Self { + Self { + stream_config: Some(stream_config), + ..self + } + } + + pub(crate) fn stream_config(&self) -> Option<&StreamConfig> { + self.stream_config.as_ref() + } +} + +struct SessionState { + cmd_rx: mpsc::Receiver, + inflight_appends: VecDeque, + inflight_bytes: usize, + close_tx: Option>>, + total_records: usize, + total_acked_records: usize, + prev_ack_end: Option, + stashed_submission: Option, +} + +impl SessionState { + fn is_close_complete(&self) -> bool { + self.close_tx.is_some() + && self.inflight_appends.is_empty() + && self.stashed_submission.is_none() + } +} + +/// A session for high-throughput appending with backpressure control. It can be created from +/// [`append_session`](crate::S2Stream::append_session). +/// +/// Supports pipelining multiple [`AppendInput`]s while preserving submission order. +pub struct AppendSession { + cmd_tx: mpsc::Sender, + permits: AppendPermits, + terminal_err: Arc>, + _handle: AbortOnDropHandle<()>, +} + +impl AppendSession { + pub(crate) fn new( + client: BasinClient, + stream: StreamName, + headers: StreamHeaders, + config: AppendSessionConfig, + ) -> Self { + let buffer_size = config + .max_unacked_batches + .map(|mib| mib as usize) + .unwrap_or(DEFAULT_CHANNEL_BUFFER_SIZE); + let (cmd_tx, cmd_rx) = mpsc::channel(buffer_size); + let permits = AppendPermits::new(config.max_unacked_batches, config.max_unacked_bytes); + let retry_builder = retry_builder(&client.config.retry); + let terminal_err = Arc::new(OnceLock::new()); + let handle = AbortOnDropHandle::new(tokio::spawn(run_session_with_retry( + client, + stream, + headers, + cmd_rx, + retry_builder, + buffer_size, + terminal_err.clone(), + ))); + Self { + cmd_tx, + permits, + terminal_err, + _handle: handle, + } + } + + /// Submit a batch of records for appending. + /// + /// Internally, it waits on [`reserve`](Self::reserve), then submits using the permit. + /// This provides backpressure when inflight limits are reached. + /// For explicit control, use [`reserve`](Self::reserve) followed by + /// [`BatchSubmitPermit::submit`]. + /// + /// **Note**: After all submits, you must call [`close`](Self::close) to ensure all batches are + /// appended. + pub async fn submit( + &self, + input: AppendInput, + ) -> Result { + let permit = self.reserve(input.records.metered_bytes() as u32).await?; + Ok(permit.submit(input)) + } + + /// Reserve capacity for a batch to be submitted. Useful in [`select!`](tokio::select) loops + /// where you want to interleave submission with other async work. See [`submit`](Self::submit) + /// for a simpler API. + /// + /// Waits when inflight limits are reached, providing explicit backpressure control. + /// The returned permit must be used to submit the batch. + /// + /// **Note**: After all submits, you must call [`close`](Self::close) to ensure all batches are + /// appended. + /// + /// # Cancel safety + /// + /// This method is cancel safe. Internally, it only awaits + /// [`Semaphore::acquire_many_owned`](tokio::sync::Semaphore::acquire_many_owned) and + /// [`Sender::reserve_owned`](tokio::sync::mpsc::Sender::reserve), both of which are cancel + /// safe. + pub async fn reserve(&self, bytes: u32) -> Result { + let append_permit = self.permits.acquire(bytes).await; + let cmd_tx_permit = self + .cmd_tx + .clone() + .reserve_owned() + .await + .map_err(|_| self.terminal_err())?; + Ok(BatchSubmitPermit { + append_permit, + cmd_tx_permit, + terminal_err: self.terminal_err.clone(), + }) + } + + /// Close the session and wait for all submitted batch of records to be appended. + pub async fn close(self) -> Result<(), AppendSessionError> { + let (done_tx, done_rx) = oneshot::channel(); + self.cmd_tx + .send(Command::Close { done_tx }) + .await + .map_err(|_| self.terminal_err())?; + done_rx.await.map_err(|_| self.terminal_err())??; + Ok(()) + } + + fn terminal_err(&self) -> AppendSessionError { + self.terminal_err + .get() + .cloned() + .unwrap_or(AppendSessionError::SessionClosed) + } +} + +/// A permit to submit a batch after reserving capacity. +pub struct BatchSubmitPermit { + append_permit: AppendPermit, + cmd_tx_permit: mpsc::OwnedPermit, + terminal_err: Arc>, +} + +impl BatchSubmitPermit { + /// Submit the batch using this permit. + pub fn submit(self, input: AppendInput) -> BatchSubmitTicket { + let (ack_tx, ack_rx) = oneshot::channel(); + self.cmd_tx_permit.send(Command::Submit { + input, + ack_tx, + permit: Some(self.append_permit), + }); + BatchSubmitTicket { + rx: ack_rx, + terminal_err: self.terminal_err, + } + } +} + +pub(crate) struct AppendSessionInternal { + cmd_tx: mpsc::Sender, + terminal_err: Arc>, + _handle: AbortOnDropHandle<()>, +} + +impl AppendSessionInternal { + pub(crate) fn new(client: BasinClient, stream: StreamName, headers: StreamHeaders) -> Self { + let buffer_size = DEFAULT_CHANNEL_BUFFER_SIZE; + let (cmd_tx, cmd_rx) = mpsc::channel(buffer_size); + let retry_builder = retry_builder(&client.config.retry); + let terminal_err = Arc::new(OnceLock::new()); + let handle = AbortOnDropHandle::new(tokio::spawn(run_session_with_retry( + client, + stream, + headers, + cmd_rx, + retry_builder, + buffer_size, + terminal_err.clone(), + ))); + Self { + cmd_tx, + terminal_err, + _handle: handle, + } + } + + pub(crate) fn submit( + &self, + input: AppendInput, + ) -> impl Future> + Send + 'static { + let cmd_tx = self.cmd_tx.clone(); + let terminal_err = self.terminal_err.clone(); + async move { + let (ack_tx, ack_rx) = oneshot::channel(); + cmd_tx + .send(Command::Submit { + input, + ack_tx, + permit: None, + }) + .await + .map_err(|_| { + terminal_err + .get() + .cloned() + .unwrap_or(AppendSessionError::SessionClosed) + })?; + Ok(BatchSubmitTicket { + rx: ack_rx, + terminal_err, + }) + } + } + + pub(crate) async fn close(self) -> Result<(), AppendSessionError> { + let (done_tx, done_rx) = oneshot::channel(); + self.cmd_tx + .send(Command::Close { done_tx }) + .await + .map_err(|_| self.terminal_err())?; + done_rx.await.map_err(|_| self.terminal_err())??; + Ok(()) + } + + fn terminal_err(&self) -> AppendSessionError { + self.terminal_err + .get() + .cloned() + .unwrap_or(AppendSessionError::SessionClosed) + } +} + +#[derive(Debug)] +pub(crate) struct AppendPermit { + _count: Option, + _bytes: OwnedSemaphorePermit, +} + +#[derive(Clone)] +pub(crate) struct AppendPermits { + count: Option>, + bytes: Arc, +} + +impl AppendPermits { + pub(crate) fn new(count_permits: Option, bytes_permits: u32) -> Self { + Self { + count: count_permits.map(|permits| Arc::new(Semaphore::new(permits as usize))), + bytes: Arc::new(Semaphore::new(bytes_permits as usize)), + } + } + + pub(crate) async fn acquire(&self, bytes: u32) -> AppendPermit { + AppendPermit { + _count: if let Some(count) = self.count.as_ref() { + Some( + count + .clone() + .acquire_many_owned(1) + .await + .expect("semaphore should not be closed"), + ) + } else { + None + }, + _bytes: self + .bytes + .clone() + .acquire_many_owned(bytes) + .await + .expect("semaphore should not be closed"), + } + } +} + +async fn run_session_with_retry( + client: BasinClient, + stream: StreamName, + headers: StreamHeaders, + cmd_rx: mpsc::Receiver, + retry_builder: RetryBackoffBuilder, + buffer_size: usize, + terminal_err: Arc>, +) { + let access_token_mode = client.config.access_token.mode(); + let frame_signal = match client.config.retry.append_retry_policy { + AppendRetryPolicy::NoSideEffects => Some(FrameSignal::new()), + AppendRetryPolicy::All => None, + }; + + let mut state = SessionState { + cmd_rx, + inflight_appends: VecDeque::new(), + inflight_bytes: 0, + close_tx: None, + total_records: 0, + total_acked_records: 0, + prev_ack_end: None, + stashed_submission: None, + }; + let mut prev_total_acked_records = 0; + let mut retry_backoff = retry_builder.build(); + let mut advised_reconnects = AdvisedReconnects::default(); + + loop { + let result = run_session( + &client, + &stream, + &headers, + &mut state, + buffer_size, + &frame_signal, + advised_reconnects, + ) + .await; + + match result { + Ok(SessionOutcome::Closed) => { + break; + } + Ok(SessionOutcome::ReconnectAdvised) => { + // The advised connection was already poisoned when the advice + // was first decoded, so reconnecting dials a fresh one. + advised_reconnects.record(); + debug!( + inflight_appends_len = state.inflight_appends.len(), + advised_reconnects = advised_reconnects.count(), + "reconnecting append session on server advice" + ); + } + Err(err) if err.is_server_draining() && state.is_close_complete() => break, + Err(err) if err.is_server_draining() => { + advised_reconnects.record(); + debug!( + inflight_appends_len = state.inflight_appends.len(), + advised_reconnects = advised_reconnects.count(), + "reconnecting append session while server drains" + ); + } + Err(err) => { + if prev_total_acked_records < state.total_acked_records { + prev_total_acked_records = state.total_acked_records; + retry_backoff.reset(); + } + + if is_safe_to_retry( + &err, + client.config.retry.append_retry_policy, + !state.inflight_appends.is_empty(), + frame_signal.as_ref(), + access_token_mode, + ) && let Some(backoff) = retry_backoff.next() + { + if err.attempt_may_have_side_effects(frame_signal.as_ref()) { + for append in &mut state.inflight_appends { + append.prior_uncertainty = true; + } + } + debug!( + %err, + ?backoff, + num_retries_remaining = retry_backoff.remaining(), + "retrying append session" + ); + tokio::time::sleep(backoff).await; + } else { + debug!( + %err, + retries_exhausted = retry_backoff.is_exhausted(), + "not retrying append session" + ); + + let session_err = err.clone().with_prior_uncertainty( + state.inflight_appends.iter().any(|a| a.prior_uncertainty), + ); + let _ = terminal_err.set(session_err.clone()); + + for inflight_append in state.inflight_appends.drain(..) { + let error = err + .clone() + .with_prior_uncertainty(inflight_append.prior_uncertainty); + let _ = inflight_append.ack_tx.send(Err(error)); + } + + if let Some(stashed) = state.stashed_submission.take() { + let _ = stashed.ack_tx.send(Err(err.clone())); + } + + if let Some(done_tx) = state.close_tx.take() { + let _ = done_tx.send(Err(session_err.clone())); + } + + state.cmd_rx.close(); + while let Some(cmd) = state.cmd_rx.recv().await { + let error = match &cmd { + Command::Submit { .. } => &err, + Command::Close { .. } => &session_err, + }; + cmd.reject(error.clone()); + } + break; + } + } + } + } + + if let Some(done_tx) = state.close_tx.take() { + let _ = done_tx.send(Ok(())); + } +} + +/// How a connection attempt ended without failing. +enum SessionOutcome { + /// Everything submitted was acknowledged and the caller closed the session. + Closed, + /// The server advised reconnecting and this connection drained cleanly. + ReconnectAdvised, +} + +async fn run_session( + client: &BasinClient, + stream: &StreamName, + headers: &StreamHeaders, + state: &mut SessionState, + buffer_size: usize, + frame_signal: &Option, + advised_reconnects: AdvisedReconnects, +) -> Result { + if let Some(s) = frame_signal { + s.reset(); + } + + let reconnect = ReconnectAdvice::default(); + let (input_tx, mut acks) = connect( + client, + stream, + headers, + buffer_size, + frame_signal.clone(), + reconnect.clone(), + ) + .await?; + let ack_timeout = client.config.request_timeout; + + if !state.inflight_appends.is_empty() { + resend(state, &input_tx, &mut acks, ack_timeout).await?; + + if let Some(s) = frame_signal { + s.reset(); + } + + assert!(state.inflight_appends.is_empty()); + assert_eq!(state.inflight_bytes, 0); + } + + if state.is_close_complete() { + return Ok(SessionOutcome::Closed); + } + + let timer = MuxTimer::::default(); + tokio::pin!(timer); + + let mut declined_advice = false; + + loop { + if reconnect.is_advised() && state.close_tx.is_none() && !declined_advice { + if advised_reconnects.should_reconnect() { + drain_for_reconnect(input_tx, acks, state, timer.as_mut(), ack_timeout).await?; + return Ok(SessionOutcome::ReconnectAdvised); + } + declined_advice = true; + } + + tokio::select! { + (event_ord, _deadline) = &mut timer, if timer.is_armed() => { + match TimerEvent::from(event_ord) { + TimerEvent::AckDeadline => { + return Err(AppendSessionError::AckTimeout); + } + } + } + + input_tx_permit = input_tx.reserve(), if state.stashed_submission.is_some() => { + let input_tx_permit = input_tx_permit + .map_err(|_| AppendSessionError::ServerDisconnected)?; + let submission = state.stashed_submission + .take() + .expect("stashed_submission should not be None"); + + let ack_deadline = Instant::now() + ack_timeout; + input_tx_permit.send(submission.input.clone()); + + state.total_records += submission.input.records.len(); + state.inflight_bytes += submission.input_metered_bytes; + + timer.as_mut().fire_at( + TimerEvent::AckDeadline, + ack_deadline, + CoalesceMode::Earliest, + ); + state.inflight_appends.push_back(InflightAppend { + input: submission.input, + input_metered_bytes: submission.input_metered_bytes, + ack_tx: submission.ack_tx, + ack_deadline, + _permit: submission.permit, + prior_uncertainty: false, + }); + } + + cmd = state.cmd_rx.recv(), if state.stashed_submission.is_none() => { + match cmd { + Some(Command::Submit { input, ack_tx, permit }) => { + if state.close_tx.is_some() { + let _ = ack_tx.send( + Err(AppendSessionError::SessionClosing) + ); + } else { + let input_metered_bytes = input.records.metered_bytes(); + state.stashed_submission = Some(StashedSubmission { + input, + input_metered_bytes, + ack_tx, + permit, + }); + } + } + Some(Command::Close { done_tx }) => { + state.close_tx = Some(done_tx); + } + None => { + return Err(AppendSessionError::SessionDropped); + } + } + } + + ack = acks.next() => { + match ack { + Some(Ok(ack)) => { + process_ack( + ack, + state, + timer.as_mut(), + )?; + } + Some(Err(err)) => { + return Err(err.into()); + } + None => { + if !state.inflight_appends.is_empty() || state.stashed_submission.is_some() { + return Err(AppendSessionError::StreamClosedEarly); + } + break; + } + } + } + } + + if state.is_close_complete() { + break; + } + } + + assert!(state.inflight_appends.is_empty()); + assert_eq!(state.inflight_bytes, 0); + assert!(state.stashed_submission.is_none()); + + Ok(SessionOutcome::Closed) +} + +async fn resend( + state: &mut SessionState, + input_tx: &mpsc::Sender, + acks: &mut Streaming, + ack_timeout: Duration, +) -> Result<(), AppendSessionError> { + debug!( + inflight_appends_len = state.inflight_appends.len(), + inflight_bytes = state.inflight_bytes, + "resending inflight appends" + ); + + let mut resend_index = 0; + let mut resend_finished = false; + + let timer = MuxTimer::::default(); + tokio::pin!(timer); + + while !state.inflight_appends.is_empty() { + tokio::select! { + (event_ord, _deadline) = &mut timer, if timer.is_armed() => { + match TimerEvent::from(event_ord) { + TimerEvent::AckDeadline => { + return Err(AppendSessionError::AckTimeout); + } + } + } + + input_tx_permit = input_tx.reserve(), if !resend_finished => { + let input_tx_permit = input_tx_permit + .map_err(|_| AppendSessionError::ServerDisconnected)?; + + if let Some(inflight_append) = state.inflight_appends.get_mut(resend_index) { + inflight_append.ack_deadline = Instant::now() + ack_timeout; + timer.as_mut().fire_at( + TimerEvent::AckDeadline, + inflight_append.ack_deadline, + CoalesceMode::Latest, + ); + input_tx_permit.send(inflight_append.input.clone()); + resend_index += 1; + } else { + resend_finished = true; + } + } + + ack = acks.next() => { + match ack { + Some(Ok(ack)) => { + process_ack( + ack, + state, + timer.as_mut(), + )?; + resend_index = resend_index.checked_sub(1).ok_or_else(|| { + AppendSessionError::InvalidAck( + "received ack without a corresponding resent append in flight".to_string(), + ) + })?; + } + Some(Err(err)) => { + return Err(err.into()); + } + None => { + return Err(AppendSessionError::StreamClosedEarly); + } + } + } + } + } + + assert_eq!( + resend_index, 0, + "resend_index should be 0 after resend completes" + ); + debug!("finished resending inflight appends"); + Ok(()) +} + +/// Half-close so the server acknowledges everything it accepted and then ends +/// the response cleanly. Every input reaches the server ahead of the request's +/// end, so a clean end with appends still unacknowledged is a truncated +/// response, and nothing is resent. +async fn drain_for_reconnect( + input_tx: mpsc::Sender, + mut acks: Streaming, + state: &mut SessionState, + mut timer: Pin<&mut MuxTimer>, + ack_timeout: Duration, +) -> Result<(), AppendSessionError> { + drop(input_tx); + loop { + // Bound the wait for the server's end of stream, which is otherwise + // unbounded once nothing is in flight. + if !timer.is_armed() { + timer.as_mut().fire_at( + TimerEvent::AckDeadline, + Instant::now() + ack_timeout, + CoalesceMode::Earliest, + ); + } + + tokio::select! { + (event_ord, _deadline) = &mut timer, if timer.is_armed() => { + match TimerEvent::from(event_ord) { + TimerEvent::AckDeadline => { + return Err(AppendSessionError::AckTimeout); + } + } + } + + ack = acks.next() => { + match ack { + Some(Ok(ack)) => { + process_ack(ack, state, timer.as_mut())?; + } + Some(Err(err)) if err.is_server_draining() => { + return Ok(()); + } + Some(Err(err)) => { + return Err(err.into()); + } + None => { + if !state.inflight_appends.is_empty() { + return Err(AppendSessionError::StreamClosedEarly); + } + return Ok(()); + } + } + } + } + } +} + +async fn connect( + client: &BasinClient, + stream: &StreamName, + headers: &StreamHeaders, + buffer_size: usize, + frame_signal: Option, + reconnect: ReconnectAdvice, +) -> Result<(mpsc::Sender, Streaming), AppendSessionError> { + let (input_tx, input_rx) = mpsc::channel::(buffer_size); + let ack_stream = Box::pin( + client + .append_session( + stream, + ReceiverStream::new(input_rx).map(|i| i.into()), + headers.encryption.as_ref(), + headers.stream_config.as_ref(), + frame_signal, + reconnect, + ) + .await? + .map(|ack| match ack { + Ok(ack) => Ok(ack.into()), + Err(err) => Err(err), + }), + ); + Ok((input_tx, ack_stream)) +} + +fn process_ack( + ack: AppendAck, + state: &mut SessionState, + timer: Pin<&mut MuxTimer>, +) -> Result<(), AppendSessionError> { + let corresponding_append = state.inflight_appends.pop_front().ok_or_else(|| { + AppendSessionError::InvalidAck( + "received ack without a corresponding append in flight".to_string(), + ) + })?; + + if ack.end.seq_num < ack.start.seq_num { + return Err(AppendSessionError::InvalidAck( + "ack end seq_num should be greater than or equal to start seq_num".to_string(), + )); + } + + if state + .prev_ack_end + .is_some_and(|end| ack.end.seq_num <= end.seq_num) + { + return Err(AppendSessionError::InvalidAck( + "ack end seq_num should be greater than previous ack end".to_string(), + )); + } + + let num_acked_records = (ack.end.seq_num - ack.start.seq_num) as usize; + let expected_records = corresponding_append.input.records.len(); + if num_acked_records != expected_records { + return Err(AppendSessionError::InvalidAck(format!( + "acked record count {num_acked_records} does not match submitted batch size {expected_records}" + ))); + } + + state.total_acked_records += num_acked_records; + state.inflight_bytes -= corresponding_append.input_metered_bytes; + state.prev_ack_end = Some(ack.end); + + let _ = corresponding_append.ack_tx.send(Ok(ack)); + + if let Some(oldest_append) = state.inflight_appends.front() { + timer.fire_at( + TimerEvent::AckDeadline, + oldest_append.ack_deadline, + CoalesceMode::Latest, + ); + } else { + timer.cancel(TimerEvent::AckDeadline); + assert_eq!( + state.total_records, state.total_acked_records, + "all records should be acked when inflight is empty" + ); + } + + Ok(()) +} + +struct StashedSubmission { + input: AppendInput, + input_metered_bytes: usize, + ack_tx: oneshot::Sender>, + permit: Option, +} + +struct InflightAppend { + input: AppendInput, + input_metered_bytes: usize, + ack_tx: oneshot::Sender>, + ack_deadline: Instant, + _permit: Option, + prior_uncertainty: bool, +} + +enum Command { + Submit { + input: AppendInput, + ack_tx: oneshot::Sender>, + permit: Option, + }, + Close { + done_tx: oneshot::Sender>, + }, +} + +impl Command { + fn reject(self, err: AppendSessionError) { + match self { + Command::Submit { ack_tx, .. } => { + let _ = ack_tx.send(Err(err)); + } + Command::Close { done_tx } => { + let _ = done_tx.send(Err(err)); + } + } + } +} + +fn is_safe_to_retry( + err: &AppendSessionError, + policy: AppendRetryPolicy, + has_inflight: bool, + frame_signal: Option<&FrameSignal>, + access_token_mode: AccessTokenMode, +) -> bool { + let policy_compliant = match policy { + AppendRetryPolicy::All => true, + AppendRetryPolicy::NoSideEffects => { + !has_inflight || !err.attempt_may_have_side_effects(frame_signal) + } + }; + policy_compliant + && (err.is_retryable() + || (access_token_mode.is_refreshable() && err.is_authentication_error())) +} + +const DEFAULT_CHANNEL_BUFFER_SIZE: usize = 100; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TimerEvent { + AckDeadline, +} + +const N_TIMER_VARIANTS: usize = 1; + +impl From for usize { + fn from(event: TimerEvent) -> Self { + match event { + TimerEvent::AckDeadline => 0, + } + } +} + +impl From for TimerEvent { + fn from(value: usize) -> Self { + match value { + 0 => TimerEvent::AckDeadline, + _ => panic!("invalid ordinal"), + } + } +} + +#[cfg(test)] +mod tests { + use std::error::Error; + + use http::StatusCode; + + use super::{AppendSessionError, is_safe_to_retry}; + use crate::{ + api::{ApiError, ServerErrorBody}, + error::{AppendError, ProducerError, RequestError}, + frame_signal::FrameSignal, + retry::AppendRetryError, + types::{AccessTokenMode, AppendRetryPolicy}, + }; + + fn server_error(status: StatusCode, code: &str) -> AppendSessionError { + AppendSessionError::Append(AppendError::Request(RequestError::from(ApiError::Server( + status, + ServerErrorBody { + code: code.to_owned(), + message: "test".to_owned(), + }, + )))) + } + + #[rstest::rstest] + #[case(StatusCode::FORBIDDEN, "permission_denied", false, false)] + #[case(StatusCode::FORBIDDEN, "permission_denied", true, true)] + #[case(StatusCode::SERVICE_UNAVAILABLE, "unavailable", false, false)] + #[case(StatusCode::SERVICE_UNAVAILABLE, "unavailable", true, false)] + #[case(StatusCode::TOO_MANY_REQUESTS, "rate_limited", true, true)] + #[test] + fn session_failure_preserves_uncertainty_and_latest_error( + #[case] status: StatusCode, + #[case] code: &str, + #[case] prior_uncertainty: bool, + #[case] wrapped: bool, + ) { + let latest = server_error(status, code); + let error = latest.clone().with_prior_uncertainty(prior_uncertainty); + assert_eq!( + matches!(error, AppendSessionError::IndefiniteFailure { .. }), + wrapped + ); + assert_eq!(error.is_retryable(), latest.is_retryable()); + assert_eq!( + error.has_no_side_effects(), + !wrapped && latest.has_no_side_effects() + ); + assert_eq!( + error.request_error().unwrap().server_error().unwrap().code, + code + ); + if wrapped { + let source = error + .source() + .unwrap() + .downcast_ref::>() + .unwrap(); + assert_eq!(source.to_string(), latest.to_string()); + assert!(source.has_no_side_effects()); + } + + let producer_error = ProducerError::from(error.clone()); + assert_eq!(producer_error.is_retryable(), error.is_retryable()); + assert_eq!( + producer_error.has_no_side_effects(), + error.has_no_side_effects() + ); + assert_eq!( + producer_error + .request_error() + .unwrap() + .server_error() + .unwrap() + .code, + code + ); + } + + #[test] + fn safe_to_retry_session_all_policy() { + let retryable = server_error(StatusCode::INTERNAL_SERVER_ERROR, "internal"); + let non_retryable = server_error(StatusCode::BAD_REQUEST, "bad_request"); + let policy = AppendRetryPolicy::All; + let static_mode = AccessTokenMode::Static; + + // All policy — always policy-compliant, just needs retryable. + assert!(is_safe_to_retry( + &retryable, + policy, + true, + None, + static_mode + )); + assert!(!is_safe_to_retry( + &non_retryable, + policy, + true, + None, + static_mode, + )); + + let unauthorized = server_error(StatusCode::UNAUTHORIZED, "authn"); + #[cfg(feature = "_hidden")] + assert!(is_safe_to_retry( + &unauthorized, + policy, + true, + None, + AccessTokenMode::Refreshable, + )); + assert!(!is_safe_to_retry( + &unauthorized, + policy, + true, + None, + static_mode, + )); + + #[cfg(feature = "_hidden")] + let unrelated_unauthorized = server_error(StatusCode::UNAUTHORIZED, "other"); + #[cfg(feature = "_hidden")] + assert!(!is_safe_to_retry( + &unrelated_unauthorized, + policy, + true, + None, + AccessTokenMode::Refreshable, + )); + } + + #[test] + fn safe_to_retry_session_no_side_effects_policy() { + let retryable = server_error(StatusCode::INTERNAL_SERVER_ERROR, "internal"); + let no_side_effect = server_error(StatusCode::TOO_MANY_REQUESTS, "rate_limited"); + let policy = AppendRetryPolicy::NoSideEffects; + let signal = FrameSignal::new(); + let mode = AccessTokenMode::Static; + + // No inflight — always safe. + signal.signal(); + assert!(is_safe_to_retry( + &retryable, + policy, + false, + Some(&signal), + mode, + )); + + // Inflight + signal not set — safe (no data sent this attempt). + signal.reset(); + assert!(is_safe_to_retry( + &retryable, + policy, + true, + Some(&signal), + mode, + )); + + // Inflight + signal set + error with possible side effects — not safe. + signal.signal(); + assert!(!is_safe_to_retry( + &retryable, + policy, + true, + Some(&signal), + mode, + )); + + // Inflight + signal set + no-side-effect error — safe. + assert!(is_safe_to_retry( + &no_side_effect, + policy, + true, + Some(&signal), + mode, + )); + + // AckTimeout — retryable but has possible side effects. + assert!(!is_safe_to_retry( + &AppendSessionError::AckTimeout, + policy, + true, + Some(&signal), + mode, + )); + } +} diff --git a/sdk/src/session/mod.rs b/sdk/src/session/mod.rs new file mode 100644 index 00000000..7868ed29 --- /dev/null +++ b/sdk/src/session/mod.rs @@ -0,0 +1,16 @@ +pub mod append; +pub mod read; + +pub(crate) use append::{AppendPermit, AppendPermits, AppendSessionInternal, BatchSubmitTicket}; +pub use append::{AppendSession, AppendSessionConfig}; +pub(crate) use read::read_session; +pub use read::{ReadSession, ReadSessionError}; + +/// Per-stream options sent as request headers on every (re)connect of a session. +#[derive(Debug, Clone, Default)] +pub(crate) struct StreamHeaders { + /// `s2-encryption-key` + pub encryption: Option, + /// `s2-stream-config` + pub stream_config: Option, +} diff --git a/sdk/src/session/read.rs b/sdk/src/session/read.rs new file mode 100644 index 00000000..83f848f4 --- /dev/null +++ b/sdk/src/session/read.rs @@ -0,0 +1,980 @@ +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll}, + time::Duration, +}; + +use async_stream::{stream, try_stream}; +use futures_util::{ + StreamExt, + future::{FutureExt, Shared}, +}; +use s2_api::v1::stream::{ReadEnd, ReadStart}; +use tokio::{ + sync::oneshot, + time::{Instant, timeout}, +}; +use tracing::debug; + +use crate::{ + api::{ApiError, BasinClient, retry_builder}, + error::{ReadError, RequestError}, + reconnect::{AdvisedReconnects, ReconnectAdvice}, + retry::RetryBackoff, + session::StreamHeaders, + types::{ + AccessTokenMode, MeteredBytes, ReadBatch, ReadInput, ReadSessionConfig, + ReadSessionRetryPolicy, StreamName, StreamPosition, + }, +}; + +#[derive(Debug, thiserror::Error)] +enum ReadSessionFailure { + #[error(transparent)] + Api(#[from] ApiError), + #[error("heartbeat timeout")] + HeartbeatTimeout, +} + +impl ReadSessionFailure { + pub fn is_retryable(&self) -> bool { + match self { + Self::Api(err) => err.is_retryable(), + Self::HeartbeatTimeout => true, + } + } + + fn is_authentication_error(&self) -> bool { + matches!(self, Self::Api(error) if error.is_authentication_error()) + } + + fn is_server_draining(&self) -> bool { + matches!(self, Self::Api(error) if error.is_server_draining()) + } +} + +/// Errors returned by a read session. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum ReadSessionError { + /// An error with the read request underlying the session. + #[error(transparent)] + Read(#[from] ReadError), + /// The session heartbeat timed out. + #[error("heartbeat timeout")] + HeartbeatTimeout, +} + +impl ReadSessionError { + /// Whether retrying the operation is safe or sensible. + pub fn is_retryable(&self) -> bool { + match self { + Self::Read(error) => error.is_retryable(), + Self::HeartbeatTimeout => true, + } + } + + /// Return the underlying request error, if present. + pub fn request_error(&self) -> Option<&RequestError> { + match self { + Self::Read(error) => error.request_error(), + Self::HeartbeatTimeout => None, + } + } +} + +impl From for ReadSessionError { + fn from(error: ReadSessionFailure) -> Self { + match error { + ReadSessionFailure::Api(error) => Self::Read(error.into()), + ReadSessionFailure::HeartbeatTimeout => Self::HeartbeatTimeout, + } + } +} + +/// The server heartbeats a tailing read session at a randomized gap of at most +/// 15 seconds (), plus some buffer. +const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(20); + +type InternalStreaming = + Pin>>>; + +/// An item from a single read connection. +enum ReadItem { + Batch(ReadBatch), + /// The server advised reconnecting and the response ended cleanly. + /// + /// Always the last item of a connection, emitted after the batch it rode + /// in on, so the resume position already accounts for that batch. + ReconnectAdvised, +} + +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +/// Error returned while waiting for a read session to catch up. +pub enum CaughtUpError { + #[error("read session ended before catching up")] + /// The session ended before reaching a reported tail. + SessionClosed, + #[error(transparent)] + /// The read failed. + Read(#[from] ReadSessionError), +} + +impl CaughtUpError { + /// Whether retrying the operation is safe or sensible. + pub fn is_retryable(&self) -> bool { + match self { + Self::SessionClosed => false, + Self::Read(error) => error.is_retryable(), + } + } + + /// Return the underlying request error, if present. + pub fn request_error(&self) -> Option<&RequestError> { + match self { + Self::SessionClosed => None, + Self::Read(error) => error.request_error(), + } + } +} + +type CaughtUpResult = Result; + +#[derive(Clone)] +enum CaughtUpFuture { + Pending(Shared>), + Ready(CaughtUpResult), +} + +impl Future for CaughtUpFuture { + type Output = CaughtUpResult; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match &mut *self { + Self::Pending(future) => match Pin::new(future).poll(cx) { + Poll::Ready(Ok(result)) => Poll::Ready(result), + Poll::Ready(Err(_)) => Poll::Ready(Err(CaughtUpError::SessionClosed)), + Poll::Pending => Poll::Pending, + }, + Self::Ready(result) => Poll::Ready(result.clone()), + } + } +} + +struct CaughtUpState { + /// Latest reported tail we've fully delivered, if currently caught up. + tail: Option, + /// Once set, the session has ended. + terminal: bool, + /// Fires the current caught-up future. + tx: Option>, + /// The future handed out by `caught_up()`. + future: CaughtUpFuture, +} + +impl CaughtUpState { + fn new() -> Self { + let (tx, future) = pending_catch_up(); + Self { + tail: None, + terminal: false, + tx: Some(tx), + future, + } + } + + fn is_caught_up(&self) -> bool { + self.tail.is_some() + } + + fn future(&self) -> CaughtUpFuture { + self.future.clone() + } + + fn set_behind(&mut self) { + if self.terminal || self.tail.take().is_none() { + return; + } + let (tx, future) = pending_catch_up(); + self.tx = Some(tx); + self.future = future; + } + + fn set_caught_up(&mut self, tail: StreamPosition) { + if self.terminal || self.tail == Some(tail) { + return; + } + self.tail = Some(tail); + self.complete(Ok(tail)); + } + + fn end(&mut self, error: Option) { + if self.terminal { + return; + } + self.terminal = true; + if let Some(error) = error { + self.tail = None; + self.complete(Err(CaughtUpError::Read(error))); + } else if self.tail.is_none() { + self.complete(Err(CaughtUpError::SessionClosed)); + } + } + + fn complete(&mut self, result: CaughtUpResult) { + if let Some(tx) = self.tx.take() { + let _ = tx.send(result); + } else { + self.future = CaughtUpFuture::Ready(result); + } + } +} + +fn pending_catch_up() -> (oneshot::Sender, CaughtUpFuture) { + let (tx, rx) = oneshot::channel(); + (tx, CaughtUpFuture::Pending(rx.shared())) +} + +struct ReadUpdate { + batch: Option, + caught_up_tail: Option, + resume_seq_num: Option, +} + +impl ReadUpdate { + fn behind() -> Self { + Self { + batch: None, + caught_up_tail: None, + resume_seq_num: None, + } + } + + fn from_batch(mut batch: ReadBatch, ignore_command_records: bool) -> Self { + let resume_seq_num = resume_seq_num_after_batch(&batch); + let caught_up_tail = batch.tail.filter(|tail| { + batch.records.is_empty() + || batch + .records + .last() + .is_some_and(|record| record.seq_num.checked_add(1) == Some(tail.seq_num)) + }); + + if ignore_command_records { + batch.records.retain(|record| !record.is_command_record()); + } + + Self { + batch: (!batch.records.is_empty()).then_some(batch), + caught_up_tail, + resume_seq_num, + } + } +} + +/// A continuous stream of read batches. +pub struct ReadSession { + updates: InternalStreaming, + state: CaughtUpState, + resume_seq_num: Option, +} + +impl ReadSession { + fn new(updates: InternalStreaming, resume_seq_num: Option) -> Self { + Self { + updates, + state: CaughtUpState::new(), + resume_seq_num, + } + } + + /// Return the absolute sequence number from which the session would resume after a retry. + /// + /// An unclamped absolute starting sequence number is available immediately. A timestamp, + /// tail-relative, or clamped start returns `None` until the session receives a record or a + /// reported tail. The returned value is the sequence number of the next record the session + /// expects. It advances as the session is polled, including across records hidden by + /// [`ReadInput::ignore_command_records`](crate::types::ReadInput::ignore_command_records). + pub fn resume_seq_num(&self) -> Option { + self.resume_seq_num + } + + /// Return whether all records through the latest reported tail were delivered. + /// + /// A later batch that does not reach a reported tail or a reconnect resets it. + /// Ignored command records count toward progress. Use + /// [`S2Stream::check_tail`](crate::S2Stream::check_tail) for the current tail. + pub fn is_caught_up(&self) -> bool { + self.state.is_caught_up() + } + + /// Return a future for the current or next caught-up tail. + /// + /// Continue polling the read session while awaiting this future; the future does not drive + /// reads itself. It is ready immediately when the session is already caught up and remains + /// pending across retries. Once it resolves, its returned tail never changes. If the session + /// later falls behind, call `caught_up()` again to wait for the next catch-up. The future + /// returns [`CaughtUpError`] if the session fails or closes before catching up. + pub fn caught_up( + &self, + ) -> impl Future> + + Clone + + Send + + Sync + + Unpin + + 'static { + self.state.future() + } +} + +impl futures_core::Stream for ReadSession { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + loop { + match self.updates.as_mut().poll_next(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Some(Ok(update))) => { + if let Some(resume_seq_num) = update.resume_seq_num { + self.resume_seq_num = Some(resume_seq_num); + } + if let Some(tail) = update.caught_up_tail { + self.state.set_caught_up(tail); + } else { + self.state.set_behind(); + } + if let Some(batch) = update.batch { + return Poll::Ready(Some(Ok(batch))); + } + } + Poll::Ready(Some(Err(error))) => { + let error = ReadSessionError::from(error); + self.state.end(Some(error.clone())); + return Poll::Ready(Some(Err(error))); + } + Poll::Ready(None) => { + self.state.end(None); + return Poll::Ready(None); + } + } + } + } +} + +impl Drop for ReadSession { + fn drop(&mut self) { + self.state.end(None); + } +} + +pub async fn read_session( + client: BasinClient, + name: StreamName, + headers: StreamHeaders, + input: ReadInput, + config: ReadSessionConfig, +) -> Result { + let ReadInput { + start, + stop, + ignore_command_records, + stream_config: _, + } = input; + let mut start: ReadStart = start.into(); + let mut end: ReadEnd = stop.into(); + let retry_policy = config.retry_policy; + let mut retry_backoff = retry_builder(&client.config.retry).build(); + let access_token_mode = client.config.access_token.mode(); + let baseline_wait = end.wait; + let mut last_tail_at: Option = None; + let mut advised_reconnects = AdvisedReconnects::default(); + let initial_resume_seq_num = if start.clamp == Some(true) { + None + } else { + start.seq_num + }; + + let batches = loop { + end.wait = remaining_wait(baseline_wait, last_tail_at); + match session_inner( + client.clone(), + name.clone(), + headers.clone(), + start.clone(), + end.clone(), + ReconnectAdvice::default(), + advised_reconnects, + ) + .await + { + Ok(batches) => { + retry_backoff.reset(); + break batches; + } + Err(err) => { + if take_server_draining_reconnect(&err, &mut advised_reconnects) { + debug!( + advised_reconnects = advised_reconnects.count(), + "reconnecting initial read session while server drains" + ); + continue; + } + if let Some(backoff) = + retry_delay(&err, &mut retry_backoff, retry_policy, access_token_mode) + { + tokio::time::sleep(backoff).await; + continue; + } + return Err(err.into()); + } + } + }; + + let updates = Box::pin(stream! { + let mut batches: Option> = Some(batches); + + loop { + if batches.is_none() { + end.wait = remaining_wait(baseline_wait, last_tail_at); + match session_inner( + client.clone(), + name.clone(), + headers.clone(), + start.clone(), + end.clone(), + ReconnectAdvice::default(), + advised_reconnects, + ).await { + Ok(b) => batches = Some(b), + Err(err) => { + if take_server_draining_reconnect(&err, &mut advised_reconnects) { + debug!( + resume_seq_num = ?start.seq_num, + advised_reconnects = advised_reconnects.count(), + "reconnecting read session while server drains" + ); + continue; + } + if let Some(backoff) = + retry_delay( + &err, + &mut retry_backoff, + retry_policy, + access_token_mode, + ) + { + tokio::time::sleep(backoff).await; + continue; + } + yield Err(err); + break; + } + } + } + + match batches + .as_mut() + .expect("batches should not be None") + .next() + .await + { + Some(Ok(ReadItem::ReconnectAdvised)) => { + batches = None; + // The advised connection was already poisoned when the + // advice was first decoded; reconnecting dials a fresh + // one. Avoid a useless reconnect for a read that was + // already satisfied when the advice arrived. + if read_limits_exhausted(&end) { + break; + } + advised_reconnects.record(); + debug!( + resume_seq_num = ?start.seq_num, + advised_reconnects = advised_reconnects.count(), + "reconnecting read session on server advice" + ); + yield Ok(ReadUpdate::behind()); + continue; + } + Some(Ok(ReadItem::Batch(batch))) => { + if retry_backoff.used() > 0 { + retry_backoff.reset(); + } + + if batch.tail.is_some() { + last_tail_at = Some(Instant::now()); + } + + update_resume_start(&mut start, &batch); + if let Some(count) = end.count.as_mut() { + *count = count.saturating_sub(batch.records.len()) + } + if let Some(bytes) = end.bytes.as_mut() { + *bytes = bytes.saturating_sub( + batch.records.iter().map(|r| r.metered_bytes()).sum() + ) + } + + yield Ok(ReadUpdate::from_batch(batch, ignore_command_records)); + } + Some(Err(err)) => { + batches = None; + if err.is_server_draining() && read_limits_exhausted(&end) { + break; + } + if take_server_draining_reconnect(&err, &mut advised_reconnects) { + debug!( + resume_seq_num = ?start.seq_num, + advised_reconnects = advised_reconnects.count(), + "reconnecting read session while server drains" + ); + yield Ok(ReadUpdate::behind()); + continue; + } + if let Some(backoff) = + retry_delay( + &err, + &mut retry_backoff, + retry_policy, + access_token_mode, + ) + { + yield Ok(ReadUpdate::behind()); + tokio::time::sleep(backoff).await; + continue; + } + yield Err(err); + break; + } + None => break, + } + } + }); + Ok(ReadSession::new(updates, initial_resume_seq_num)) +} + +fn resume_seq_num_after_batch(batch: &ReadBatch) -> Option { + batch + .records + .last() + .map(|record| record.seq_num + 1) + .or_else(|| batch.tail.as_ref().map(|tail| tail.seq_num)) +} + +/// Advance the absolute start used when reconnecting the read session. +/// +/// An empty batch with a reported tail still resolves a relative or timestamp start. Anchoring it +/// prevents a reconnect from evaluating the original start against a newer tail. +fn update_resume_start(start: &mut ReadStart, batch: &ReadBatch) { + if let Some(seq_num) = resume_seq_num_after_batch(batch) { + *start = ReadStart { + seq_num: Some(seq_num), + timestamp: None, + tail_offset: None, + clamp: start.clamp, + }; + } +} + +async fn session_inner( + client: BasinClient, + name: StreamName, + headers: StreamHeaders, + start: ReadStart, + end: ReadEnd, + reconnect: ReconnectAdvice, + advised_reconnects: AdvisedReconnects, +) -> Result, ReadSessionFailure> { + let mut batches = client + .read_session( + &name, + start, + end, + headers.encryption.as_ref(), + headers.stream_config.as_ref(), + reconnect.clone(), + ) + .await?; + + let mut declined_advice = false; + Ok(Box::pin(try_stream! { + loop { + match timeout(HEARTBEAT_TIMEOUT, batches.next()).await { + Ok(Some(batch)) => { + yield ReadItem::Batch(ReadBatch::from_api(batch?)); + if reconnect.is_advised() && !declined_advice { + if advised_reconnects.should_reconnect() { + yield ReadItem::ReconnectAdvised; + break; + } + declined_advice = true; + } + } + Ok(None) => break, + Err(_) => Err(ReadSessionFailure::HeartbeatTimeout)?, + } + } + })) +} + +/// Whether the read's `count` or `bytes` limit has been used up. +fn read_limits_exhausted(end: &ReadEnd) -> bool { + end.count == Some(0) || end.bytes == Some(0) +} + +/// Compute the remaining wait budget for a retry. +/// +/// During catchup (tail not yet observed), the full wait is sent. +/// Once tailing, the wait budget is depleted based on time since +/// the last batch with tail info, which approximates how long the +/// server has been in its long polling state. +fn remaining_wait(baseline_wait: Option, last_tail_at: Option) -> Option { + baseline_wait.map(|w| match last_tail_at { + Some(since) => w.saturating_sub(since.elapsed().as_secs() as u32), + None => w, + }) +} + +fn retry_delay( + err: &ReadSessionFailure, + backoffs: &mut RetryBackoff, + retry_policy: ReadSessionRetryPolicy, + access_token_mode: AccessTokenMode, +) -> Option { + let is_retryable = + err.is_retryable() || (access_token_mode.is_refreshable() && err.is_authentication_error()); + if !is_retryable { + debug!( + %err, + is_retryable = false, + retries_exhausted = backoffs.is_exhausted(), + "not retrying read session" + ); + return None; + } + + let backoff = match retry_policy { + ReadSessionRetryPolicy::Budgeted => backoffs.next(), + ReadSessionRetryPolicy::Indefinite => Some(backoffs.next_or_max()), + }; + if let Some(backoff) = backoff { + debug!( + %err, + ?backoff, + ?retry_policy, + num_retries_remaining = backoffs.remaining(), + "retrying read session" + ); + Some(backoff) + } else { + debug!( + %err, + is_retryable, + retries_exhausted = backoffs.is_exhausted(), + "not retrying read session" + ); + None + } +} + +fn take_server_draining_reconnect( + err: &ReadSessionFailure, + advised_reconnects: &mut AdvisedReconnects, +) -> bool { + if err.is_server_draining() { + advised_reconnects.record(); + true + } else { + false + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use futures_util::{StreamExt, poll, stream}; + use tokio::sync::mpsc; + use tokio_stream::wrappers::UnboundedReceiverStream; + + use super::*; + use crate::types::{Header, SequencedRecord}; + + fn position(seq_num: u64) -> StreamPosition { + StreamPosition { + seq_num, + timestamp: seq_num, + } + } + + fn record(seq_num: u64, command: bool) -> SequencedRecord { + SequencedRecord { + seq_num, + timestamp: seq_num, + body: Bytes::new(), + headers: if command { + vec![Header::new("", "fence")] + } else { + Vec::new() + }, + } + } + + fn batch(records: Vec, tail: Option) -> ReadBatch { + ReadBatch { records, tail } + } + + #[test] + fn empty_tail_anchors_relative_resume_start() { + let mut start = ReadStart { + seq_num: None, + timestamp: None, + tail_offset: Some(0), + clamp: Some(true), + }; + + update_resume_start(&mut start, &batch(Vec::new(), Some(position(42)))); + + assert_eq!(start.seq_num, Some(42)); + assert_eq!(start.timestamp, None); + assert_eq!(start.tail_offset, None); + assert_eq!(start.clamp, Some(true)); + } + + fn test_session( + updates: impl futures_core::Stream> + + Send + + 'static, + ) -> ReadSession { + ReadSession::new(Box::pin(updates), None) + } + + #[tokio::test] + async fn empty_tail_exposes_absolute_resume_seq_num() { + let (tx, rx) = mpsc::unbounded_channel(); + let mut session = test_session(UnboundedReceiverStream::new(rx)); + + assert_eq!(session.resume_seq_num(), None); + tx.send(Ok(ReadUpdate::from_batch( + batch(Vec::new(), Some(position(42))), + false, + ))) + .unwrap(); + + let mut next = Box::pin(session.next()); + assert!(poll!(next.as_mut()).is_pending()); + drop(next); + + assert_eq!(session.resume_seq_num(), Some(42)); + } + + #[tokio::test] + async fn caught_up_follows_delivery_and_pins_tail() { + let tail = position(2); + let mut session = test_session(stream::iter([ + Ok(ReadUpdate::from_batch( + batch(vec![record(0, false), record(1, false)], Some(tail)), + false, + )), + Ok(ReadUpdate::from_batch( + batch(vec![record(2, false)], Some(position(5))), + false, + )), + ])); + let caught_up = session.caught_up(); + let mut pending = Box::pin(caught_up.clone()); + + assert!(poll!(pending.as_mut()).is_pending()); + assert!(!session.is_caught_up()); + + let first = session.next().await.unwrap().unwrap(); + assert_eq!(first.records.len(), 2); + assert!(session.is_caught_up()); + assert_eq!(session.resume_seq_num(), Some(2)); + let caught_up_while_caught = session.caught_up(); + + session.next().await.unwrap().unwrap(); + assert!(!session.is_caught_up()); + assert_eq!(session.resume_seq_num(), Some(3)); + assert_eq!(caught_up.await.unwrap(), tail); + assert_eq!(caught_up_while_caught.await.unwrap(), tail); + } + + #[tokio::test] + async fn heartbeat_waits_for_visible_batch() { + let tail = position(2); + let (tx, rx) = mpsc::unbounded_channel(); + let mut session = test_session(UnboundedReceiverStream::new(rx)); + let caught_up = session.caught_up(); + + tx.send(Ok(ReadUpdate::from_batch( + batch(vec![record(0, false), record(1, false)], None), + false, + ))) + .unwrap(); + tx.send(Ok(ReadUpdate::from_batch( + batch(Vec::new(), Some(tail)), + false, + ))) + .unwrap(); + + assert_eq!(session.next().await.unwrap().unwrap().records.len(), 2); + assert!(!session.is_caught_up()); + + let mut next = Box::pin(session.next()); + assert!(poll!(next.as_mut()).is_pending()); + drop(next); + assert!(session.is_caught_up()); + assert_eq!(caught_up.await.unwrap(), tail); + } + + #[tokio::test] + async fn unchanged_heartbeat_reuses_caught_up_future() { + let tail = position(1); + let (tx, rx) = mpsc::unbounded_channel(); + let mut session = test_session(UnboundedReceiverStream::new(rx)); + + tx.send(Ok(ReadUpdate::from_batch( + batch(vec![record(0, false)], Some(tail)), + false, + ))) + .unwrap(); + session.next().await.unwrap().unwrap(); + let caught_up = session.state.future(); + + tx.send(Ok(ReadUpdate::from_batch( + batch(Vec::new(), Some(tail)), + false, + ))) + .unwrap(); + let mut next = Box::pin(session.next()); + assert!(poll!(next.as_mut()).is_pending()); + drop(next); + + let CaughtUpFuture::Pending(caught_up) = caught_up else { + panic!("initial caught-up future should use the pending epoch"); + }; + let CaughtUpFuture::Pending(current) = session.state.future() else { + panic!("unchanged heartbeat should preserve the pending epoch"); + }; + assert!(caught_up.ptr_eq(¤t)); + } + + #[tokio::test] + async fn filtered_command_counts_toward_caught_up() { + let tail = position(2); + let mut session = test_session(stream::iter([ + Ok(ReadUpdate::from_batch( + batch(vec![record(0, false)], None), + true, + )), + Ok(ReadUpdate::from_batch( + batch(vec![record(1, true)], Some(tail)), + true, + )), + ])); + let caught_up = session.caught_up(); + + let delivered = session.next().await.unwrap().unwrap(); + assert_eq!(delivered.records.len(), 1); + assert_eq!(delivered.records[0].seq_num, 0); + assert!(!session.is_caught_up()); + + assert!(session.next().await.is_none()); + assert!(session.is_caught_up()); + assert_eq!(session.resume_seq_num(), Some(2)); + assert_eq!(caught_up.await.unwrap(), tail); + } + + #[tokio::test] + async fn caught_up_wait_survives_retry() { + let first_tail = position(1); + let tail = position(3); + let (tx, rx) = mpsc::unbounded_channel(); + let mut session = test_session(UnboundedReceiverStream::new(rx)); + + tx.send(Ok(ReadUpdate::from_batch( + batch(Vec::new(), Some(first_tail)), + false, + ))) + .unwrap(); + let mut next = Box::pin(session.next()); + assert!(poll!(next.as_mut()).is_pending()); + drop(next); + assert!(session.is_caught_up()); + + tx.send(Ok(ReadUpdate::behind())).unwrap(); + let mut next = Box::pin(session.next()); + assert!(poll!(next.as_mut()).is_pending()); + drop(next); + assert!(!session.is_caught_up()); + let caught_up = session.caught_up(); + + tx.send(Ok(ReadUpdate::behind())).unwrap(); + tx.send(Ok(ReadUpdate::from_batch( + batch(Vec::new(), Some(tail)), + false, + ))) + .unwrap(); + drop(tx); + assert!(session.next().await.is_none()); + assert_eq!(caught_up.await.unwrap(), tail); + } + + #[tokio::test] + async fn clean_end_rejects_wait() { + let mut session = test_session(stream::empty()); + let caught_up = session.caught_up(); + + assert!(session.next().await.is_none()); + assert!(matches!(caught_up.await, Err(CaughtUpError::SessionClosed))); + } + + #[tokio::test] + async fn read_error_rejects_wait() { + let mut session = test_session(stream::iter([Err(ReadSessionFailure::HeartbeatTimeout)])); + let caught_up = session.caught_up(); + + let error = session.next().await.unwrap().unwrap_err(); + assert_eq!(error.to_string(), "heartbeat timeout"); + assert!(matches!( + caught_up.await, + Err(CaughtUpError::Read(ReadSessionError::HeartbeatTimeout)) + )); + } + + #[tokio::test] + async fn read_error_after_caught_up_preserves_resolved_future() { + let tail = position(1); + let mut session = test_session(stream::iter([ + Ok(ReadUpdate::from_batch( + batch(vec![record(0, false)], Some(tail)), + false, + )), + Err(ReadSessionFailure::HeartbeatTimeout), + ])); + + session.next().await.unwrap().unwrap(); + assert!(session.is_caught_up()); + let caught_up = session.caught_up(); + + session.next().await.unwrap().unwrap_err(); + assert!(!session.is_caught_up()); + assert_eq!(caught_up.await.unwrap(), tail); + assert!(matches!( + session.caught_up().await, + Err(CaughtUpError::Read(ReadSessionError::HeartbeatTimeout)) + )); + } + + #[tokio::test] + async fn dropping_session_rejects_wait() { + let caught_up = { + let session = test_session(stream::pending()); + session.caught_up() + }; + + assert!(matches!(caught_up.await, Err(CaughtUpError::SessionClosed))); + } +} diff --git a/sdk/src/types.rs b/sdk/src/types.rs new file mode 100644 index 00000000..a5f22cc7 --- /dev/null +++ b/sdk/src/types.rs @@ -0,0 +1,4721 @@ +//! Types relevant to [`S2`](crate::S2), [`S2Basin`](crate::S2Basin), and +//! [`S2Stream`](crate::S2Stream). +use std::{ + collections::HashSet, + env::VarError, + fmt, + num::NonZeroU32, + ops::{Deref, RangeTo}, + pin::Pin, + str::FromStr, + sync::Arc, + time::Duration, +}; + +#[cfg(feature = "_hidden")] +use async_trait::async_trait; +use bytes::Bytes; +use compact_str::CompactString; +use http::{ + HeaderMap, + header::HeaderValue, + uri::{Authority, Scheme}, +}; +use rand::RngExt; +use s2_api::{v1 as api, v1::stream::s2s::CompressionAlgorithm}; +/// Validation error. +pub use s2_common::ValidationError; +/// Access token ID. +/// +/// **Note:** It must be unique to the account and between 1 and 96 bytes in length, and must +/// not contain NUL bytes. +pub use s2_common::access::AccessTokenId; +/// See [`ListAccessTokensInput::prefix`]. It must not contain NUL bytes. +pub use s2_common::access::AccessTokenIdPrefix; +/// See [`ListAccessTokensInput::start_after`]. It must not contain NUL bytes. +pub use s2_common::access::AccessTokenIdStartAfter; +/// Basin name. +/// +/// **Note:** It must be globally unique and between 8 and 48 bytes in length. It can only +/// comprise lowercase letters, numbers, and hyphens. It cannot begin or end with a hyphen. +pub use s2_common::basin::BasinName; +/// See [`ListBasinsInput::prefix`]. +pub use s2_common::basin::BasinNamePrefix; +/// See [`ListBasinsInput::start_after`]. +pub use s2_common::basin::BasinNameStartAfter; +/// Location name. +/// +/// **Note:** It must be between 1 and 64 characters in length and can only comprise ASCII +/// letters, numbers, colons, hyphens, and periods. +pub use s2_common::location::LocationName; +/// Stream name. +/// +/// **Note:** It must be unique to the basin and between 1 and 512 bytes in length, and must +/// not contain NUL bytes. +pub use s2_common::stream::StreamName; +/// See [`ListStreamsInput::prefix`]. It must not contain NUL bytes. +pub use s2_common::stream::StreamNamePrefix; +/// See [`ListStreamsInput::start_after`]. It must not contain NUL bytes. +pub use s2_common::stream::StreamNameStartAfter; +pub use s2_common::{ + caps::RECORD_BATCH_MAX, + encryption::{EncryptionAlgorithm, EncryptionKey}, +}; + +pub(crate) const ONE_MIB: u32 = 1024 * 1024; + +use s2_common::{ + maybe::Maybe, + record::{MAX_FENCING_TOKEN_LENGTH, Metered, MeteredSize}, + resources::ProvisionResult, +}; +use secrecy::SecretString; + +use crate::error::RequestError; + +#[cfg(feature = "_hidden")] +#[derive(Debug, Clone, thiserror::Error)] +#[error("{message}")] +#[doc(hidden)] +pub struct AccessTokenProviderError { + message: String, + retryable: bool, +} + +#[cfg(feature = "_hidden")] +impl AccessTokenProviderError { + /// Create a provider error that should be retried with normal SDK backoff. + pub fn transient(message: impl Into) -> Self { + Self { + message: message.into(), + retryable: true, + } + } + + /// Create a provider error that should be returned immediately. + pub fn permanent(message: impl Into) -> Self { + Self { + message: message.into(), + retryable: false, + } + } + + pub(crate) fn is_retryable(&self) -> bool { + self.retryable + } +} + +#[cfg(feature = "_hidden")] +#[async_trait] +#[doc(hidden)] +pub trait AccessTokenProvider: fmt::Debug + Send + Sync { + /// Return an access token for the next request attempt. + async fn access_token(&self) -> Result; + + /// Notify the provider that S2 rejected an access token. + fn invalidate_access_token(&self, _rejected_access_token: &str) {} +} + +#[derive(Clone)] +pub(crate) enum AccessToken { + Static(SecretString), + #[cfg(feature = "_hidden")] + Provider(Arc), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccessTokenMode { + Static, + #[cfg(feature = "_hidden")] + Refreshable, +} + +impl AccessTokenMode { + pub(crate) fn is_refreshable(self) -> bool { + match self { + Self::Static => false, + #[cfg(feature = "_hidden")] + Self::Refreshable => true, + } + } +} + +impl AccessToken { + pub(crate) fn mode(&self) -> AccessTokenMode { + match self { + Self::Static(_) => AccessTokenMode::Static, + #[cfg(feature = "_hidden")] + Self::Provider(_) => AccessTokenMode::Refreshable, + } + } +} + +impl fmt::Debug for AccessToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Static(_) => formatter.write_str("Static()"), + #[cfg(feature = "_hidden")] + Self::Provider(_) => formatter.write_str("Provider()"), + } + } +} + +/// An RFC 3339 datetime. +/// +/// It can be created in either of the following ways: +/// - Parse an RFC 3339 datetime string using [`FromStr`] or [`str::parse`]. +/// - Convert from [`time::OffsetDateTime`] using [`TryFrom`]/[`TryInto`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct S2DateTime(time::OffsetDateTime); + +impl TryFrom for S2DateTime { + type Error = ValidationError; + + fn try_from(dt: time::OffsetDateTime) -> Result { + dt.format(&time::format_description::well_known::Rfc3339) + .map_err(|e| ValidationError(format!("not a valid RFC 3339 datetime: {e}")))?; + Ok(Self(dt)) + } +} + +impl From for time::OffsetDateTime { + fn from(dt: S2DateTime) -> Self { + dt.0 + } +} + +impl FromStr for S2DateTime { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339) + .map(Self) + .map_err(|e| ValidationError(format!("not a valid RFC 3339 datetime: {e}"))) + } +} + +impl fmt::Display for S2DateTime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + self.0 + .format(&time::format_description::well_known::Rfc3339) + .expect("RFC3339 formatting should not fail for S2DateTime") + ) + } +} + +/// Authority for connecting to an S2 basin. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum BasinAuthority { + /// Parent zone for basins. DNS is used to route to the correct cell for the basin. + ParentZone(Authority), + /// Direct cell authority. Basin is expected to be hosted by this cell. + Direct(Authority), +} + +/// Account endpoint. +#[derive(Debug, Clone)] +pub struct AccountEndpoint { + scheme: Scheme, + authority: Authority, +} + +impl AccountEndpoint { + /// Create a new [`AccountEndpoint`] with the given endpoint. + pub fn new(endpoint: &str) -> Result { + endpoint.parse() + } +} + +impl FromStr for AccountEndpoint { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + let (scheme, authority) = match s.find("://") { + Some(idx) => { + let scheme: Scheme = s[..idx] + .parse() + .map_err(|_| "invalid account endpoint scheme".to_string())?; + (scheme, &s[idx + 3..]) + } + None => (Scheme::HTTPS, s), + }; + Ok(Self { + scheme, + authority: authority + .parse() + .map_err(|e| format!("invalid account endpoint authority: {e}"))?, + }) + } +} + +/// Basin endpoint. +#[derive(Debug, Clone)] +pub struct BasinEndpoint { + scheme: Scheme, + authority: BasinAuthority, +} + +impl BasinEndpoint { + /// Create a new [`BasinEndpoint`] with the given endpoint. + pub fn new(endpoint: &str) -> Result { + endpoint.parse() + } +} + +impl FromStr for BasinEndpoint { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + let (scheme, authority) = match s.find("://") { + Some(idx) => { + let scheme: Scheme = s[..idx] + .parse() + .map_err(|_| "invalid basin endpoint scheme".to_string())?; + (scheme, &s[idx + 3..]) + } + None => (Scheme::HTTPS, s), + }; + let authority = if let Some(authority) = authority.strip_prefix("{basin}.") { + BasinAuthority::ParentZone( + authority + .parse() + .map_err(|e| format!("invalid basin endpoint authority: {e}"))?, + ) + } else { + BasinAuthority::Direct( + authority + .parse() + .map_err(|e| format!("invalid basin endpoint authority: {e}"))?, + ) + }; + Ok(Self { scheme, authority }) + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Endpoints for the S2 environment. +pub struct S2Endpoints { + pub(crate) scheme: Scheme, + pub(crate) account_authority: Authority, + pub(crate) basin_authority: BasinAuthority, +} + +impl S2Endpoints { + /// Create a new [`S2Endpoints`] with the given account and basin endpoints. + pub fn new( + account_endpoint: AccountEndpoint, + basin_endpoint: BasinEndpoint, + ) -> Result { + if account_endpoint.scheme != basin_endpoint.scheme { + return Err("account and basin endpoints must have the same scheme".into()); + } + Ok(Self { + scheme: account_endpoint.scheme, + account_authority: account_endpoint.authority, + basin_authority: basin_endpoint.authority, + }) + } + + /// Create endpoints for a single account and basin endpoint. + /// + /// This is useful for S2-compatible services that expose both APIs at one endpoint. + pub fn for_endpoint(endpoint: &str) -> Result { + Self::new( + AccountEndpoint::new(endpoint)?, + BasinEndpoint::new(endpoint)?, + ) + } + + /// Create a new [`S2Endpoints`] from environment variables. + /// + /// The following environment variables are expected to be set: + /// - `S2_ACCOUNT_ENDPOINT` - Account-level endpoint. + /// - `S2_BASIN_ENDPOINT` - Basin-level endpoint. + pub fn from_env() -> Result { + let account_endpoint: AccountEndpoint = match std::env::var("S2_ACCOUNT_ENDPOINT") { + Ok(endpoint) => endpoint.parse()?, + Err(VarError::NotPresent) => return Err("S2_ACCOUNT_ENDPOINT env var not set".into()), + Err(VarError::NotUnicode(_)) => { + return Err("S2_ACCOUNT_ENDPOINT is not valid unicode".into()); + } + }; + + let basin_endpoint: BasinEndpoint = match std::env::var("S2_BASIN_ENDPOINT") { + Ok(endpoint) => endpoint.parse()?, + Err(VarError::NotPresent) => return Err("S2_BASIN_ENDPOINT env var not set".into()), + Err(VarError::NotUnicode(_)) => { + return Err("S2_BASIN_ENDPOINT is not valid unicode".into()); + } + }; + + if account_endpoint.scheme != basin_endpoint.scheme { + return Err( + "S2_ACCOUNT_ENDPOINT and S2_BASIN_ENDPOINT must have the same scheme".into(), + ); + } + + Ok(Self { + scheme: account_endpoint.scheme, + account_authority: account_endpoint.authority, + basin_authority: basin_endpoint.authority, + }) + } + + /// Return the default S2 Cloud endpoints. + pub fn for_cloud() -> Self { + Self { + scheme: Scheme::HTTPS, + account_authority: "a.s2.dev".try_into().expect("valid authority"), + basin_authority: BasinAuthority::ParentZone( + "b.s2.dev".try_into().expect("valid authority"), + ), + } + } +} + +#[derive(Debug, Clone, Copy)] +/// Compression algorithm for request and response bodies. +pub enum Compression { + /// No compression. + None, + /// Gzip compression. + Gzip, + /// Zstd compression. + Zstd, +} + +impl From for CompressionAlgorithm { + fn from(value: Compression) -> Self { + match value { + Compression::None => CompressionAlgorithm::None, + Compression::Gzip => CompressionAlgorithm::Gzip, + Compression::Zstd => CompressionAlgorithm::Zstd, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +/// Retry policy for [`append`](crate::S2Stream::append) and +/// [`append_session`](crate::S2Stream::append_session) operations. +pub enum AppendRetryPolicy { + /// Retry all appends. Use when duplicate records on the stream are acceptable. + All, + /// Retry when it can be determined that the request had no side effects. + /// + /// Uses a frame-level signal to detect whether any body frames were consumed + /// by the HTTP transport. If no frames were sent, the server never saw the + /// request, so retry is safe and will not cause duplicate records. + /// + /// Certain server errors (`rate_limited`, `hot_server`) are also safe to + /// retry regardless of frame signal state, since they guarantee no mutation + /// occurred. + NoSideEffects, +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Configuration for retrying requests in case of transient failures. +/// +/// Exponential backoff with jitter is the retry strategy. Below is the pseudocode for the strategy: +/// ```text +/// base_delay = min(min_base_delay · 2ⁿ, max_base_delay) (n = retry attempt, starting from 0) +/// jitter = rand[0, base_delay] +/// delay = base_delay + jitter +/// ```` +pub struct RetryConfig { + /// Total number of attempts including the initial try. A value of `1` means no retries. + /// + /// Defaults to `3`. + pub max_attempts: NonZeroU32, + /// Minimum base delay for retries. + /// + /// Defaults to `100ms`. + pub min_base_delay: Duration, + /// Maximum base delay for retries. + /// + /// Defaults to `1s`. + pub max_base_delay: Duration, + /// Retry policy for [`append`](crate::S2Stream::append) and + /// [`append_session`](crate::S2Stream::append_session) operations. + /// + /// Defaults to `All`. + pub append_retry_policy: AppendRetryPolicy, +} + +impl Default for RetryConfig { + fn default() -> Self { + Self { + max_attempts: NonZeroU32::new(3).expect("valid non-zero u32"), + min_base_delay: Duration::from_millis(100), + max_base_delay: Duration::from_secs(1), + append_retry_policy: AppendRetryPolicy::All, + } + } +} + +impl RetryConfig { + /// Create a new [`RetryConfig`] with default settings. + pub fn new() -> Self { + Self::default() + } + + pub(crate) fn max_retries(&self) -> u32 { + self.max_attempts.get() - 1 + } + + /// Set the total number of attempts including the initial try. + pub fn with_max_attempts(self, max_attempts: NonZeroU32) -> Self { + Self { + max_attempts, + ..self + } + } + + /// Set the minimum base delay for retries. + pub fn with_min_base_delay(self, min_base_delay: Duration) -> Self { + Self { + min_base_delay, + ..self + } + } + + /// Set the maximum base delay for retries. + pub fn with_max_base_delay(self, max_base_delay: Duration) -> Self { + Self { + max_base_delay, + ..self + } + } + + /// Set the retry policy for [`append`](crate::S2Stream::append) and + /// [`append_session`](crate::S2Stream::append_session) operations. + pub fn with_append_retry_policy(self, append_retry_policy: AppendRetryPolicy) -> Self { + Self { + append_retry_policy, + ..self + } + } +} + +/// Overrides for the HTTP/2 transport used by pooled connections. +/// +/// Unset knobs keep the SDK defaults: 90 concurrent requests per connection and +/// Hyper's receive windows (2 MiB per stream, 5 MiB per connection). Receive +/// windows bound unconsumed server-to-client DATA only; they do not affect +/// append throughput, which is governed by the server's receive windows. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct Http2Config { + pub(crate) max_concurrent_requests: Option, + pub(crate) stream_receive_window: Option, + pub(crate) connection_receive_window: Option, +} + +impl Http2Config { + /// Stream limit advertised by S2 servers per HTTP/2 connection. + pub const MAX_CONCURRENT_REQUESTS: usize = 100; + + /// Maximum HTTP/2 flow-control window in bytes (`2^31 - 1`). + pub const MAX_RECEIVE_WINDOW: u32 = i32::MAX as u32; + + /// Minimum HTTP/2 connection receive window in bytes. + /// + /// The connection window cannot be shrunk below the protocol default. + pub const MIN_CONNECTION_RECEIVE_WINDOW: u32 = 65_535; + + /// Start from the SDK defaults. + pub fn new() -> Self { + Self::default() + } + + /// Cap concurrent requests per pooled connection. + /// + /// The cap includes unary requests and streaming sessions until their + /// bodies finish or are dropped. Additional requests use another pooled + /// connection. + /// + /// # Errors + /// + /// Must be between 1 and [`Self::MAX_CONCURRENT_REQUESTS`]. Requests beyond + /// the server's stream limit would queue on the connection instead of + /// spilling over to another one. + pub fn with_max_concurrent_requests( + self, + max_concurrent_requests: usize, + ) -> Result { + if !(1..=Self::MAX_CONCURRENT_REQUESTS).contains(&max_concurrent_requests) { + return Err(format!( + "HTTP/2 concurrent request limit must be between 1 and {}", + Self::MAX_CONCURRENT_REQUESTS + ) + .into()); + } + Ok(Self { + max_concurrent_requests: Some(max_concurrent_requests), + ..self + }) + } + + /// Set the initial per-stream receive window in bytes. + /// + /// # Errors + /// + /// Must be between 1 and [`Self::MAX_RECEIVE_WINDOW`]. + pub fn with_stream_receive_window( + self, + stream_receive_window: u32, + ) -> Result { + if !(1..=Self::MAX_RECEIVE_WINDOW).contains(&stream_receive_window) { + return Err("HTTP/2 stream receive window must be between 1 and 2^31 - 1 bytes".into()); + } + Ok(Self { + stream_receive_window: Some(stream_receive_window), + ..self + }) + } + + /// Set the connection-level receive window in bytes, shared by all streams + /// on the connection. + /// + /// # Errors + /// + /// Must be between [`Self::MIN_CONNECTION_RECEIVE_WINDOW`] and + /// [`Self::MAX_RECEIVE_WINDOW`]. + pub fn with_connection_receive_window( + self, + connection_receive_window: u32, + ) -> Result { + if !(Self::MIN_CONNECTION_RECEIVE_WINDOW..=Self::MAX_RECEIVE_WINDOW) + .contains(&connection_receive_window) + { + return Err( + "HTTP/2 connection receive window must be between 65,535 and 2^31 - 1 bytes".into(), + ); + } + Ok(Self { + connection_receive_window: Some(connection_receive_window), + ..self + }) + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Configuration for [`S2`](crate::S2). +pub struct S2Config { + pub(crate) access_token: AccessToken, + pub(crate) endpoints: S2Endpoints, + pub(crate) http2: Http2Config, + pub(crate) connection_timeout: Duration, + pub(crate) request_timeout: Duration, + pub(crate) retry: RetryConfig, + pub(crate) compression: Compression, + pub(crate) user_agent: HeaderValue, + pub(crate) default_headers: HeaderMap, + pub(crate) insecure_skip_cert_verification: bool, + pub(crate) rustls_crypto_provider: Option>, +} + +impl S2Config { + /// Create a new [`S2Config`] with the given access token and default settings. + pub fn new(access_token: impl Into) -> Self { + Self { + access_token: AccessToken::Static(access_token.into().into()), + endpoints: S2Endpoints::for_cloud(), + http2: Http2Config::new(), + connection_timeout: Duration::from_secs(3), + request_timeout: Duration::from_secs(5), + retry: RetryConfig::new(), + compression: Compression::None, + user_agent: concat!("s2-sdk-rust/", env!("CARGO_PKG_VERSION")) + .parse() + .expect("valid user agent"), + default_headers: HeaderMap::new(), + insecure_skip_cert_verification: false, + rustls_crypto_provider: default_rustls_crypto_provider(), + } + } + + #[cfg(feature = "_hidden")] + #[doc(hidden)] + pub fn with_access_token_provider(self, provider: impl AccessTokenProvider + 'static) -> Self { + Self { + access_token: AccessToken::Provider(Arc::new(provider)), + ..self + } + } + + /// Set the S2 endpoints to connect to. + pub fn with_endpoints(self, endpoints: S2Endpoints) -> Self { + Self { endpoints, ..self } + } + + /// Set additional HTTP headers to send with every request. + /// + /// These headers apply to account, basin, and stream operations, including + /// retries and streaming requests. SDK-generated headers, such as + /// authorization and basin routing, take precedence over these defaults. + /// Calling this method again replaces the previous set of default headers. + /// + /// `Accept-Encoding` defaults are used only when [`Compression::None`] is + /// configured; otherwise the SDK sets the header to the configured + /// compression algorithm. + /// + /// Headers are sent to all configured S2 endpoints. Use + /// [`HeaderValue::set_sensitive`] for values that should be redacted in debug + /// output. Do not use these defaults for per-request identifiers, since the + /// same values are reused across requests. + /// + /// # Errors + /// + /// Returns an error if `default_headers` contains `Content-Type`, + /// `Content-Encoding`, `Content-Length`, or `Transfer-Encoding`. + /// The SDK controls request format and body framing. + /// Use [`Self::with_compression`] to configure request body encoding. + #[cfg(feature = "_hidden")] + #[doc(hidden)] + pub fn with_default_headers(self, default_headers: HeaderMap) -> Result { + if default_headers.contains_key(http::header::CONTENT_ENCODING) { + return Err(ValidationError( + "Content-Encoding cannot be set in default headers; use S2Config::with_compression instead" + .into(), + )); + } + for name in [ + http::header::CONTENT_TYPE, + http::header::CONTENT_LENGTH, + http::header::TRANSFER_ENCODING, + ] { + if default_headers.contains_key(&name) { + return Err(ValidationError(format!( + "{name} cannot be set in default headers; the SDK controls request format and body framing" + ))); + } + } + Ok(Self { + default_headers, + ..self + }) + } + + /// Override HTTP/2 transport settings for pooled connections. + /// + /// Defaults to [`Http2Config::new()`]. + pub fn with_http2(self, http2: Http2Config) -> Self { + Self { http2, ..self } + } + + /// Set the timeout for establishing a connection to the server. + /// + /// Defaults to `3s`. + pub fn with_connection_timeout(self, connection_timeout: Duration) -> Self { + Self { + connection_timeout, + ..self + } + } + + /// Set the timeout for requests. + /// + /// Defaults to `5s`. + pub fn with_request_timeout(self, request_timeout: Duration) -> Self { + Self { + request_timeout, + ..self + } + } + + /// Set the retry configuration for requests. + /// + /// See [`RetryConfig`] for defaults. + pub fn with_retry(self, retry: RetryConfig) -> Self { + Self { retry, ..self } + } + + /// Set the compression algorithm for requests and responses. + /// + /// Defaults to no compression. + pub fn with_compression(self, compression: Compression) -> Self { + Self { + compression, + ..self + } + } + + /// Skip TLS certificate verification (insecure). + /// + /// This is useful for connecting to endpoints with self-signed certificates + /// or certificates that don't match the hostname (similar to `curl -k`). + /// + /// # Warning + /// + /// This disables certificate verification and should only be used for + /// testing or development purposes. **Never use this in production.** + /// + /// Defaults to `false`. + pub fn with_insecure_skip_cert_verification(self, skip: bool) -> Self { + Self { + insecure_skip_cert_verification: skip, + ..self + } + } + + /// Use a specific rustls crypto provider for SDK TLS connections. + /// + /// With default features enabled, the SDK uses the `aws-lc-rs` provider. + /// With default features disabled, the SDK uses rustls's process-global + /// provider if one has been installed, or returns an error otherwise. + /// + /// Use this when your application needs a specific rustls provider, such as + /// `ring` or a custom [`rustls::crypto::CryptoProvider`]. The corresponding + /// rustls provider feature must be enabled in the dependency graph. + pub fn with_rustls_crypto_provider( + self, + provider: impl Into>, + ) -> Self { + Self { + rustls_crypto_provider: Some(provider.into()), + ..self + } + } + + /// Use rustls's `aws-lc-rs` crypto provider. + /// + /// Requires the `rustls-aws-lc-rs` crate feature. + #[cfg(feature = "rustls-aws-lc-rs")] + pub fn with_rustls_aws_lc_rs_crypto_provider(self) -> Self { + self.with_rustls_crypto_provider(rustls::crypto::aws_lc_rs::default_provider()) + } + + /// Use rustls's `ring` crypto provider. + /// + /// Requires the `rustls-ring` crate feature. + #[cfg(feature = "rustls-ring")] + pub fn with_rustls_ring_crypto_provider(self) -> Self { + self.with_rustls_crypto_provider(rustls::crypto::ring::default_provider()) + } + + #[doc(hidden)] + #[cfg(feature = "_hidden")] + pub fn with_user_agent(self, user_agent: impl Into) -> Result { + let user_agent = user_agent + .into() + .parse() + .map_err(|e| ValidationError(format!("invalid user agent: {e}")))?; + Ok(Self { user_agent, ..self }) + } +} + +#[cfg(feature = "rustls-aws-lc-rs")] +fn default_rustls_crypto_provider() -> Option> { + Some(Arc::new(rustls::crypto::aws_lc_rs::default_provider())) +} + +#[cfg(all(not(feature = "rustls-aws-lc-rs"), feature = "rustls-ring"))] +fn default_rustls_crypto_provider() -> Option> { + Some(Arc::new(rustls::crypto::ring::default_provider())) +} + +#[cfg(all(not(feature = "rustls-aws-lc-rs"), not(feature = "rustls-ring")))] +fn default_rustls_crypto_provider() -> Option> { + None +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[non_exhaustive] +/// A page of values. +pub struct Page { + /// Values in this page. + pub values: Vec, + /// Whether there are more pages. + pub has_more: bool, +} + +impl Page { + pub(crate) fn new(values: impl Into>, has_more: bool) -> Self { + Self { + values: values.into(), + has_more, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Retention policy for records in a stream. +pub enum RetentionPolicy { + /// Age in seconds. Records older than this age are automatically trimmed. + Age(u64), + /// Records are retained indefinitely unless explicitly trimmed. + Infinite, +} + +impl From for RetentionPolicy { + fn from(value: api::config::RetentionPolicy) -> Self { + match value { + api::config::RetentionPolicy::Age(secs) => RetentionPolicy::Age(secs), + api::config::RetentionPolicy::Infinite(_) => RetentionPolicy::Infinite, + } + } +} + +impl From for api::config::RetentionPolicy { + fn from(value: RetentionPolicy) -> Self { + match value { + RetentionPolicy::Age(secs) => api::config::RetentionPolicy::Age(secs), + RetentionPolicy::Infinite => { + api::config::RetentionPolicy::Infinite(api::config::InfiniteRetention {}) + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Timestamping mode for appends that influences how timestamps are handled. +pub enum TimestampingMode { + /// Prefer client-specified timestamp if present otherwise use arrival time. + ClientPrefer, + /// Require a client-specified timestamp and reject the append if it is missing. + ClientRequire, + /// Use the arrival time and ignore any client-specified timestamp. + Arrival, +} + +impl From for TimestampingMode { + fn from(value: api::config::TimestampingMode) -> Self { + match value { + api::config::TimestampingMode::ClientPrefer => TimestampingMode::ClientPrefer, + api::config::TimestampingMode::ClientRequire => TimestampingMode::ClientRequire, + api::config::TimestampingMode::Arrival => TimestampingMode::Arrival, + } + } +} + +impl From for api::config::TimestampingMode { + fn from(value: TimestampingMode) -> Self { + match value { + TimestampingMode::ClientPrefer => api::config::TimestampingMode::ClientPrefer, + TimestampingMode::ClientRequire => api::config::TimestampingMode::ClientRequire, + TimestampingMode::Arrival => api::config::TimestampingMode::Arrival, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +/// Configuration for timestamping behavior. +pub struct TimestampingConfig { + /// Timestamping mode for appends that influences how timestamps are handled. + /// + /// Defaults to [`ClientPrefer`](TimestampingMode::ClientPrefer). + pub mode: Option, + /// Whether client-specified timestamps are allowed to exceed the arrival time. + /// + /// Defaults to `false` (client timestamps are capped at the arrival time). + pub uncapped: Option, +} + +impl TimestampingConfig { + /// Create a new [`TimestampingConfig`] with default settings. + pub fn new() -> Self { + Self::default() + } + + /// Set the timestamping mode for appends that influences how timestamps are handled. + pub fn with_mode(self, mode: TimestampingMode) -> Self { + Self { + mode: Some(mode), + ..self + } + } + + /// Set whether client-specified timestamps are allowed to exceed the arrival time. + pub fn with_uncapped(self, uncapped: bool) -> Self { + Self { + uncapped: Some(uncapped), + ..self + } + } +} + +impl From for TimestampingConfig { + fn from(value: api::config::TimestampingConfig) -> Self { + Self { + mode: value.mode.map(Into::into), + uncapped: value.uncapped, + } + } +} + +impl From for api::config::TimestampingConfig { + fn from(value: TimestampingConfig) -> Self { + Self { + mode: value.mode.map(Into::into), + uncapped: value.uncapped, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +/// Configuration for automatically deleting a stream when it becomes empty. +pub struct DeleteOnEmptyConfig { + /// Minimum age in seconds before an empty stream can be deleted. + /// + /// Defaults to `0` (disables automatic deletion). + pub min_age_secs: u64, +} + +impl DeleteOnEmptyConfig { + /// Create a new [`DeleteOnEmptyConfig`] with default settings. + pub fn new() -> Self { + Self::default() + } + + /// Set the minimum age in seconds before an empty stream can be deleted. + pub fn with_min_age(self, min_age: Duration) -> Self { + Self { + min_age_secs: min_age.as_secs(), + } + } +} + +impl From for DeleteOnEmptyConfig { + fn from(value: api::config::DeleteOnEmptyConfig) -> Self { + Self { + min_age_secs: value.min_age_secs, + } + } +} + +impl From for api::config::DeleteOnEmptyConfig { + fn from(value: DeleteOnEmptyConfig) -> Self { + Self { + min_age_secs: value.min_age_secs, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +/// Configuration for a stream. +pub struct StreamConfig { + /// [Storage class](https://s2.dev/docs/storage-classes) for the stream. + pub storage_class: Option, + /// Retention policy for records in the stream. + /// + /// Defaults to `7 days` of retention. + pub retention_policy: Option, + /// Configuration for timestamping behavior. + /// + /// See [`TimestampingConfig`] for defaults. + pub timestamping: Option, + /// Configuration for automatically deleting the stream when it becomes empty. + /// + /// See [`DeleteOnEmptyConfig`] for defaults. + pub delete_on_empty: Option, +} + +impl StreamConfig { + /// Create a new [`StreamConfig`] with default settings. + pub fn new() -> Self { + Self::default() + } + + /// Set the storage class for the stream. + pub fn with_storage_class(self, storage_class: impl Into) -> Self { + Self { + storage_class: Some(storage_class.into()), + ..self + } + } + + /// Set the retention policy for records in the stream. + pub fn with_retention_policy(self, retention_policy: RetentionPolicy) -> Self { + Self { + retention_policy: Some(retention_policy), + ..self + } + } + + /// Set the configuration for timestamping behavior. + pub fn with_timestamping(self, timestamping: TimestampingConfig) -> Self { + Self { + timestamping: Some(timestamping), + ..self + } + } + + /// Set the configuration for automatically deleting the stream when it becomes empty. + pub fn with_delete_on_empty(self, delete_on_empty: DeleteOnEmptyConfig) -> Self { + Self { + delete_on_empty: Some(delete_on_empty), + ..self + } + } +} + +impl From for StreamConfig { + fn from(value: api::config::StreamConfig) -> Self { + Self { + storage_class: value.storage_class, + retention_policy: value.retention_policy.map(Into::into), + timestamping: value.timestamping.map(Into::into), + delete_on_empty: value.delete_on_empty.map(Into::into), + } + } +} + +impl From for api::config::StreamConfig { + fn from(value: StreamConfig) -> Self { + Self { + storage_class: value.storage_class, + retention_policy: value.retention_policy.map(Into::into), + timestamping: value.timestamping.map(Into::into), + delete_on_empty: value.delete_on_empty.map(Into::into), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +/// Configuration for a basin. +pub struct BasinConfig { + /// Default configuration for all streams in the basin. + /// + /// See [`StreamConfig`] for defaults. + pub default_stream_config: Option, + /// Encryption algorithm to apply to newly created streams in the basin. + pub stream_cipher: Option, + /// Whether to create stream on append if it doesn't exist using default stream configuration. + /// + /// Defaults to `false`. + pub create_stream_on_append: bool, + /// Whether to create stream on read if it doesn't exist using default stream configuration. + /// + /// Defaults to `false`. + pub create_stream_on_read: bool, +} + +impl BasinConfig { + /// Create a new [`BasinConfig`] with default settings. + pub fn new() -> Self { + Self::default() + } + + /// Set the default configuration for all streams in the basin. + pub fn with_default_stream_config(self, config: StreamConfig) -> Self { + Self { + default_stream_config: Some(config), + ..self + } + } + + /// Set the encryption algorithm to apply to newly created streams in the basin. + pub fn with_stream_cipher(self, stream_cipher: EncryptionAlgorithm) -> Self { + Self { + stream_cipher: Some(stream_cipher), + ..self + } + } + + /// Set whether to create stream on append if it doesn't exist using default stream + /// configuration. + pub fn with_create_stream_on_append(self, create_stream_on_append: bool) -> Self { + Self { + create_stream_on_append, + ..self + } + } + + /// Set whether to create stream on read if it doesn't exist using default stream configuration. + pub fn with_create_stream_on_read(self, create_stream_on_read: bool) -> Self { + Self { + create_stream_on_read, + ..self + } + } +} + +impl From for BasinConfig { + fn from(value: api::config::BasinConfig) -> Self { + Self { + default_stream_config: value.default_stream_config.map(Into::into), + stream_cipher: value.stream_cipher.map(Into::into), + create_stream_on_append: value.create_stream_on_append, + create_stream_on_read: value.create_stream_on_read, + } + } +} + +impl From for api::config::BasinConfig { + fn from(value: BasinConfig) -> Self { + Self { + default_stream_config: value.default_stream_config.map(Into::into), + stream_cipher: value.stream_cipher.map(Into::into), + create_stream_on_append: value.create_stream_on_append, + create_stream_on_read: value.create_stream_on_read, + } + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Input for [`create_basin`](crate::S2::create_basin) operation. +pub struct CreateBasinInput { + /// Basin name. + pub name: BasinName, + /// Configuration for the basin. + /// + /// See [`BasinConfig`] for defaults. + pub config: Option, + /// Location of the basin. + /// + /// If omitted when creating, uses the default location for the account. + pub location: Option, + idempotency_token: String, +} + +impl CreateBasinInput { + /// Create a new [`CreateBasinInput`] with the given basin name. + pub fn new(name: BasinName) -> Self { + Self { + name, + config: None, + location: None, + idempotency_token: idempotency_token(), + } + } + + /// Set the configuration for the basin. + pub fn with_config(self, config: BasinConfig) -> Self { + Self { + config: Some(config), + ..self + } + } + + /// Set the location of the basin. + pub fn with_location(self, location: S) -> Result + where + S: TryInto, + S::Error: fmt::Display, + { + let location = location + .try_into() + .map_err(|e| ValidationError(e.to_string()))?; + Ok(Self { + location: Some(location), + ..self + }) + } +} + +impl From for (api::basin::CreateBasinRequest, String) { + fn from(value: CreateBasinInput) -> Self { + ( + api::basin::CreateBasinRequest { + basin: value.name, + config: value.config.map(Into::into), + location: value.location, + }, + value.idempotency_token, + ) + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Input for [`ensure_basin`](crate::S2::ensure_basin) operation. +pub struct EnsureBasinInput { + /// Basin name. + pub name: BasinName, + /// Configuration for the basin. + /// + /// See [`BasinConfig`] for defaults. + pub config: Option, + /// Location of the basin. + /// + /// If omitted when creating, uses the default location for the account. Cannot be changed once + /// set. + pub location: Option, +} + +impl EnsureBasinInput { + /// Create a new [`EnsureBasinInput`] with the given basin name. + pub fn new(name: BasinName) -> Self { + Self { + name, + config: None, + location: None, + } + } + + /// Set the configuration for the basin. + pub fn with_config(self, config: BasinConfig) -> Self { + Self { + config: Some(config), + ..self + } + } + + /// Set the location of the basin. + pub fn with_location(self, location: S) -> Result + where + S: TryInto, + S::Error: fmt::Display, + { + let location = location + .try_into() + .map_err(|e| ValidationError(e.to_string()))?; + Ok(Self { + location: Some(location), + ..self + }) + } +} + +impl From for (BasinName, Option) { + fn from(value: EnsureBasinInput) -> Self { + let config = value.config; + let request = if config.is_some() || value.location.is_some() { + Some(api::basin::EnsureBasinRequest { + config: config.map(Into::into), + location: value.location, + }) + } else { + None + }; + (value.name, request) + } +} + +#[derive(Debug, Clone)] +/// Output for `ensure` operations ([`ensure_basin`](crate::S2::ensure_basin), +/// [`ensure_stream`](crate::S2Basin::ensure_stream)). +pub enum EnsureOutput { + /// Resource created. + Created(T), + /// Resource already existed, and its config was updated. + ConfigUpdated(T), + /// Resource already existed, and its config is unchanged. + ConfigUnchanged(T), +} + +impl From> for EnsureOutput { + fn from(result: ProvisionResult) -> Self { + match result { + ProvisionResult::Created(info) => EnsureOutput::Created(info), + ProvisionResult::Updated(info) => EnsureOutput::ConfigUpdated(info), + ProvisionResult::Noop(info) => EnsureOutput::ConfigUnchanged(info), + } + } +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +/// Input for [`list_basins`](crate::S2::list_basins) operation. +pub struct ListBasinsInput { + /// Filter basins whose names begin with this value. + /// + /// Defaults to `""`. + pub prefix: BasinNamePrefix, + /// Filter basins whose names are lexicographically greater than this value. + /// + /// Defaults to `""`. + pub start_after: BasinNameStartAfter, + /// Number of basins to return in a page. Will be clamped to a maximum of `1000`. + /// + /// Defaults to `1000`. + pub limit: Option, +} + +impl ListBasinsInput { + /// Create a new [`ListBasinsInput`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// Set the prefix used to filter basins whose names begin with this value. + pub fn with_prefix(self, prefix: BasinNamePrefix) -> Self { + Self { prefix, ..self } + } + + /// Set the value used to filter basins whose names are lexicographically greater than this + /// value. + pub fn with_start_after(self, start_after: BasinNameStartAfter) -> Self { + Self { + start_after, + ..self + } + } + + /// Set the limit on number of basins to return in a page. + pub fn with_limit(self, limit: usize) -> Self { + Self { + limit: Some(limit), + ..self + } + } +} + +impl From for api::basin::ListBasinsRequest { + fn from(value: ListBasinsInput) -> Self { + Self { + prefix: Some(value.prefix), + start_after: Some(value.start_after), + limit: value.limit, + } + } +} + +#[derive(Debug, Clone, Default)] +/// Input for [`list_all_basins`](crate::S2::list_all_basins) operation. +pub struct ListAllBasinsInput { + /// Filter basins whose names begin with this value. + /// + /// Defaults to `""`. + pub prefix: BasinNamePrefix, + /// Filter basins whose names are lexicographically greater than this value. + /// + /// Defaults to `""`. + pub start_after: BasinNameStartAfter, + /// Whether to include basins that are being deleted. + /// + /// Defaults to `false`. + pub include_deleted: bool, +} + +impl ListAllBasinsInput { + /// Create a new [`ListAllBasinsInput`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// Set the prefix used to filter basins whose names begin with this value. + pub fn with_prefix(self, prefix: BasinNamePrefix) -> Self { + Self { prefix, ..self } + } + + /// Set the value used to filter basins whose names are lexicographically greater than this + /// value. + pub fn with_start_after(self, start_after: BasinNameStartAfter) -> Self { + Self { + start_after, + ..self + } + } + + /// Set whether to include basins that are being deleted. + pub fn with_include_deleted(self, include_deleted: bool) -> Self { + Self { + include_deleted, + ..self + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +/// Basin information. +pub struct BasinInfo { + /// Basin name. + pub name: BasinName, + /// Location of the basin. + pub location: Option, + /// Creation time. + pub created_at: S2DateTime, + /// Deletion time if the basin is being deleted. + pub deleted_at: Option, +} + +impl TryFrom for BasinInfo { + type Error = ValidationError; + + fn try_from(value: api::basin::BasinInfo) -> Result { + Ok(Self { + name: value.name, + location: value.location, + created_at: value.created_at.try_into()?, + deleted_at: value.deleted_at.map(S2DateTime::try_from).transpose()?, + }) + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Input for [`delete_basin`](crate::S2::delete_basin) operation. +pub struct DeleteBasinInput { + /// Basin name. + pub name: BasinName, + /// Whether to ignore `Not Found` error if the basin doesn't exist. + pub ignore_not_found: bool, +} + +impl DeleteBasinInput { + /// Create a new [`DeleteBasinInput`] with the given basin name. + pub fn new(name: BasinName) -> Self { + Self { + name, + ignore_not_found: false, + } + } + + /// Set whether to ignore `Not Found` error if the basin is not existing. + pub fn with_ignore_not_found(self, ignore_not_found: bool) -> Self { + Self { + ignore_not_found, + ..self + } + } +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +/// Reconfiguration for [`TimestampingConfig`]. +pub struct TimestampingReconfiguration { + /// Override for the existing [`mode`](TimestampingConfig::mode). + pub mode: Maybe>, + /// Override for the existing [`uncapped`](TimestampingConfig::uncapped) setting. + pub uncapped: Maybe>, +} + +impl TimestampingReconfiguration { + /// Create a new [`TimestampingReconfiguration`]. + pub fn new() -> Self { + Self::default() + } + + /// Set the override for the existing [`mode`](TimestampingConfig::mode). + pub fn with_mode(self, mode: TimestampingMode) -> Self { + Self { + mode: Maybe::Specified(Some(mode)), + ..self + } + } + + /// Set the override for the existing [`uncapped`](TimestampingConfig::uncapped). + pub fn with_uncapped(self, uncapped: bool) -> Self { + Self { + uncapped: Maybe::Specified(Some(uncapped)), + ..self + } + } +} + +impl From for api::config::TimestampingReconfiguration { + fn from(value: TimestampingReconfiguration) -> Self { + Self { + mode: value.mode.map(|m| m.map(Into::into)), + uncapped: value.uncapped, + } + } +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +/// Reconfiguration for [`DeleteOnEmptyConfig`]. +pub struct DeleteOnEmptyReconfiguration { + /// Override for the existing [`min_age_secs`](DeleteOnEmptyConfig::min_age_secs). + pub min_age_secs: Maybe>, +} + +impl DeleteOnEmptyReconfiguration { + /// Create a new [`DeleteOnEmptyReconfiguration`]. + pub fn new() -> Self { + Self::default() + } + + /// Set the override for the existing [`min_age_secs`](DeleteOnEmptyConfig::min_age_secs). + pub fn with_min_age(self, min_age: Duration) -> Self { + Self { + min_age_secs: Maybe::Specified(Some(min_age.as_secs())), + } + } +} + +impl From for api::config::DeleteOnEmptyReconfiguration { + fn from(value: DeleteOnEmptyReconfiguration) -> Self { + Self { + min_age_secs: value.min_age_secs, + } + } +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +/// Reconfiguration for [`StreamConfig`]. +pub struct StreamReconfiguration { + /// Override for the existing [`storage_class`](StreamConfig::storage_class). + pub storage_class: Maybe>, + /// Override for the existing [`retention_policy`](StreamConfig::retention_policy). + pub retention_policy: Maybe>, + /// Override for the existing [`timestamping`](StreamConfig::timestamping). + pub timestamping: Maybe>, + /// Override for the existing [`delete_on_empty`](StreamConfig::delete_on_empty). + pub delete_on_empty: Maybe>, +} + +impl StreamReconfiguration { + /// Create a new [`StreamReconfiguration`]. + pub fn new() -> Self { + Self::default() + } + + /// Set the override for the existing [`storage_class`](StreamConfig::storage_class). + pub fn with_storage_class(self, storage_class: impl Into) -> Self { + Self { + storage_class: Maybe::Specified(Some(storage_class.into())), + ..self + } + } + + /// Set the override for the existing [`retention_policy`](StreamConfig::retention_policy). + pub fn with_retention_policy(self, retention_policy: RetentionPolicy) -> Self { + Self { + retention_policy: Maybe::Specified(Some(retention_policy)), + ..self + } + } + + /// Set the override for the existing [`timestamping`](StreamConfig::timestamping). + pub fn with_timestamping(self, timestamping: TimestampingReconfiguration) -> Self { + Self { + timestamping: Maybe::Specified(Some(timestamping)), + ..self + } + } + + /// Set the override for the existing [`delete_on_empty`](StreamConfig::delete_on_empty). + pub fn with_delete_on_empty(self, delete_on_empty: DeleteOnEmptyReconfiguration) -> Self { + Self { + delete_on_empty: Maybe::Specified(Some(delete_on_empty)), + ..self + } + } +} + +impl From for api::config::StreamReconfiguration { + fn from(value: StreamReconfiguration) -> Self { + Self { + storage_class: value.storage_class, + retention_policy: value.retention_policy.map(|m| m.map(Into::into)), + timestamping: value.timestamping.map(|m| m.map(Into::into)), + delete_on_empty: value.delete_on_empty.map(|m| m.map(Into::into)), + } + } +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +/// Reconfiguration for [`BasinConfig`]. +pub struct BasinReconfiguration { + /// Override for the existing [`default_stream_config`](BasinConfig::default_stream_config). + pub default_stream_config: Maybe>, + /// Override for the existing [`stream_cipher`](BasinConfig::stream_cipher). + pub stream_cipher: Maybe>, + /// Override for the existing + /// [`create_stream_on_append`](BasinConfig::create_stream_on_append). + pub create_stream_on_append: Maybe, + /// Override for the existing [`create_stream_on_read`](BasinConfig::create_stream_on_read). + pub create_stream_on_read: Maybe, +} + +impl BasinReconfiguration { + /// Create a new [`BasinReconfiguration`]. + pub fn new() -> Self { + Self::default() + } + + /// Set the override for the existing + /// [`default_stream_config`](BasinConfig::default_stream_config). + pub fn with_default_stream_config(self, config: StreamReconfiguration) -> Self { + Self { + default_stream_config: Maybe::Specified(Some(config)), + ..self + } + } + + /// Set the override for the existing [`stream_cipher`](BasinConfig::stream_cipher). + pub fn with_stream_cipher(self, stream_cipher: EncryptionAlgorithm) -> Self { + Self { + stream_cipher: Maybe::Specified(Some(stream_cipher)), + ..self + } + } + + /// Set the override for the existing + /// [`create_stream_on_append`](BasinConfig::create_stream_on_append). + pub fn with_create_stream_on_append(self, create_stream_on_append: bool) -> Self { + Self { + create_stream_on_append: Maybe::Specified(create_stream_on_append), + ..self + } + } + + /// Set the override for the existing + /// [`create_stream_on_read`](BasinConfig::create_stream_on_read). + pub fn with_create_stream_on_read(self, create_stream_on_read: bool) -> Self { + Self { + create_stream_on_read: Maybe::Specified(create_stream_on_read), + ..self + } + } +} + +impl From for api::config::BasinReconfiguration { + fn from(value: BasinReconfiguration) -> Self { + Self { + default_stream_config: value.default_stream_config.map(|m| m.map(Into::into)), + stream_cipher: value.stream_cipher.map(|m| m.map(Into::into)), + create_stream_on_append: value.create_stream_on_append, + create_stream_on_read: value.create_stream_on_read, + } + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Input for [`reconfigure_basin`](crate::S2::reconfigure_basin) operation. +pub struct ReconfigureBasinInput { + /// Basin name. + pub name: BasinName, + /// Reconfiguration for [`BasinConfig`]. + pub config: BasinReconfiguration, +} + +impl ReconfigureBasinInput { + /// Create a new [`ReconfigureBasinInput`] with the given basin name and reconfiguration. + pub fn new(name: BasinName, config: BasinReconfiguration) -> Self { + Self { name, config } + } +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +/// Input for [`list_access_tokens`](crate::S2::list_access_tokens) operation. +pub struct ListAccessTokensInput { + /// Filter access tokens whose IDs begin with this value. + /// + /// Defaults to `""`. + pub prefix: AccessTokenIdPrefix, + /// Filter access tokens whose IDs are lexicographically greater than this value. + /// + /// Defaults to `""`. + pub start_after: AccessTokenIdStartAfter, + /// Number of access tokens to return in a page. Will be clamped to a maximum of `1000`. + /// + /// Defaults to `1000`. + pub limit: Option, +} + +impl ListAccessTokensInput { + /// Create a new [`ListAccessTokensInput`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// Set the prefix used to filter access tokens whose IDs begin with this value. + pub fn with_prefix(self, prefix: AccessTokenIdPrefix) -> Self { + Self { prefix, ..self } + } + + /// Set the value used to filter access tokens whose IDs are lexicographically greater than this + /// value. + pub fn with_start_after(self, start_after: AccessTokenIdStartAfter) -> Self { + Self { + start_after, + ..self + } + } + + /// Set the limit on number of access tokens to return in a page. + pub fn with_limit(self, limit: usize) -> Self { + Self { + limit: Some(limit), + ..self + } + } +} + +impl From for api::access::ListAccessTokensRequest { + fn from(value: ListAccessTokensInput) -> Self { + Self { + prefix: Some(value.prefix), + start_after: Some(value.start_after), + limit: value.limit, + } + } +} + +#[derive(Debug, Clone, Default)] +/// Input for [`list_all_access_tokens`](crate::S2::list_all_access_tokens) operation. +pub struct ListAllAccessTokensInput { + /// Filter access tokens whose IDs begin with this value. + /// + /// Defaults to `""`. + pub prefix: AccessTokenIdPrefix, + /// Filter access tokens whose IDs are lexicographically greater than this value. + /// + /// Defaults to `""`. + pub start_after: AccessTokenIdStartAfter, +} + +impl ListAllAccessTokensInput { + /// Create a new [`ListAllAccessTokensInput`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// Set the prefix used to filter access tokens whose IDs begin with this value. + pub fn with_prefix(self, prefix: AccessTokenIdPrefix) -> Self { + Self { prefix, ..self } + } + + /// Set the value used to filter access tokens whose IDs are lexicographically greater than + /// this value. + pub fn with_start_after(self, start_after: AccessTokenIdStartAfter) -> Self { + Self { + start_after, + ..self + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +/// Location information. +pub struct LocationInfo { + /// Location name. + pub name: LocationName, + /// Location represents a private placement, limited by account. + pub is_private: bool, + /// [Storage classes](https://s2.dev/docs/storage-classes) available to the account in this location. + pub storage_classes: Option>, + /// Default [storage class](https://s2.dev/docs/storage-classes) for this location. + pub default_storage_class: Option, +} + +impl From for LocationInfo { + fn from(value: api::location::LocationInfo) -> Self { + Self { + name: value.name, + is_private: value.is_private, + storage_classes: value.storage_classes, + default_storage_class: value.default_storage_class, + } + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Access token information. +pub struct AccessTokenInfo { + /// Access token ID. + pub id: AccessTokenId, + /// Expiration time, or `None` if the token does not expire. + pub expires_at: Option, + /// Whether to automatically prefix stream names during creation and strip the prefix during + /// listing. + pub auto_prefix_streams: bool, + /// Scope of the access token. + pub scope: AccessTokenScope, +} + +impl TryFrom for AccessTokenInfo { + type Error = ValidationError; + + fn try_from(value: api::access::AccessTokenInfo) -> Result { + let expires_at = value.expires_at.map(S2DateTime::try_from).transpose()?; + Ok(Self { + id: value.id, + expires_at, + auto_prefix_streams: value.auto_prefix_streams, + scope: value.scope.into(), + }) + } +} + +#[derive(Debug, Clone)] +/// Pattern for matching basins. +/// +/// See [`AccessTokenScope::basins`]. +pub enum BasinMatcher { + /// Match no basins. + None, + /// Match exactly this basin. + Exact(BasinName), + /// Match all basins with this prefix. + Prefix(BasinNamePrefix), +} + +#[derive(Debug, Clone)] +/// Pattern for matching streams. +/// +/// See [`AccessTokenScope::streams`]. +pub enum StreamMatcher { + /// Match no streams. + None, + /// Match exactly this stream. + Exact(StreamName), + /// Match all streams with this prefix. + Prefix(StreamNamePrefix), +} + +#[derive(Debug, Clone)] +/// Pattern for matching access tokens. +/// +/// See [`AccessTokenScope::access_tokens`]. +pub enum AccessTokenMatcher { + /// Match no access tokens. + None, + /// Match exactly this access token. + Exact(AccessTokenId), + /// Match all access tokens with this prefix. + Prefix(AccessTokenIdPrefix), +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +/// Permissions indicating allowed operations. +pub struct ReadWritePermissions { + /// Read permission. + /// + /// Defaults to `false`. + pub read: bool, + /// Write permission. + /// + /// Defaults to `false`. + pub write: bool, +} + +impl ReadWritePermissions { + /// Create a new [`ReadWritePermissions`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// Create read-only permissions. + pub fn read_only() -> Self { + Self { + read: true, + write: false, + } + } + + /// Create write-only permissions. + pub fn write_only() -> Self { + Self { + read: false, + write: true, + } + } + + /// Create read-write permissions. + pub fn read_write() -> Self { + Self { + read: true, + write: true, + } + } +} + +impl From for api::access::ReadWritePermissions { + fn from(value: ReadWritePermissions) -> Self { + Self { + read: Some(value.read), + write: Some(value.write), + } + } +} + +impl From for ReadWritePermissions { + fn from(value: api::access::ReadWritePermissions) -> Self { + Self { + read: value.read.unwrap_or_default(), + write: value.write.unwrap_or_default(), + } + } +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +/// Permissions at the operation group level. +/// +/// See [`AccessTokenScope::op_group_perms`]. +pub struct OperationGroupPermissions { + /// Account-level access permissions. + /// + /// Defaults to `None`. + pub account: Option, + /// Basin-level access permissions. + /// + /// Defaults to `None`. + pub basin: Option, + /// Stream-level access permissions. + /// + /// Defaults to `None`. + pub stream: Option, +} + +impl OperationGroupPermissions { + /// Create a new [`OperationGroupPermissions`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// Create read-only permissions for all groups. + pub fn read_only_all() -> Self { + Self { + account: Some(ReadWritePermissions::read_only()), + basin: Some(ReadWritePermissions::read_only()), + stream: Some(ReadWritePermissions::read_only()), + } + } + + /// Create write-only permissions for all groups. + pub fn write_only_all() -> Self { + Self { + account: Some(ReadWritePermissions::write_only()), + basin: Some(ReadWritePermissions::write_only()), + stream: Some(ReadWritePermissions::write_only()), + } + } + + /// Create read-write permissions for all groups. + pub fn read_write_all() -> Self { + Self { + account: Some(ReadWritePermissions::read_write()), + basin: Some(ReadWritePermissions::read_write()), + stream: Some(ReadWritePermissions::read_write()), + } + } + + /// Set account-level access permissions. + pub fn with_account(self, account: ReadWritePermissions) -> Self { + Self { + account: Some(account), + ..self + } + } + + /// Set basin-level access permissions. + pub fn with_basin(self, basin: ReadWritePermissions) -> Self { + Self { + basin: Some(basin), + ..self + } + } + + /// Set stream-level access permissions. + pub fn with_stream(self, stream: ReadWritePermissions) -> Self { + Self { + stream: Some(stream), + ..self + } + } +} + +impl From for api::access::PermittedOperationGroups { + fn from(value: OperationGroupPermissions) -> Self { + Self { + account: value.account.map(Into::into), + basin: value.basin.map(Into::into), + stream: value.stream.map(Into::into), + } + } +} + +impl From for OperationGroupPermissions { + fn from(value: api::access::PermittedOperationGroups) -> Self { + Self { + account: value.account.map(Into::into), + basin: value.basin.map(Into::into), + stream: value.stream.map(Into::into), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// Individual operation that can be permitted. +/// +/// See [`AccessTokenScope::ops`]. +pub enum Operation { + /// List basins. + ListBasins, + /// Create a basin. + CreateBasin, + /// Get basin configuration. + GetBasinConfig, + /// Delete a basin. + DeleteBasin, + /// Reconfigure a basin. + ReconfigureBasin, + /// List access tokens. + ListAccessTokens, + /// Issue an access token. + IssueAccessToken, + /// Revoke an access token. + RevokeAccessToken, + /// Get account metrics. + GetAccountMetrics, + /// Get basin metrics. + GetBasinMetrics, + /// Get stream metrics. + GetStreamMetrics, + /// List streams. + ListStreams, + /// Create a stream. + CreateStream, + /// Get stream configuration. + GetStreamConfig, + /// Delete a stream. + DeleteStream, + /// Reconfigure a stream. + ReconfigureStream, + /// Check the tail of a stream. + CheckTail, + /// Append records to a stream. + Append, + /// Read records from a stream. + Read, + /// Trim records on a stream. + Trim, + /// Set the fencing token on a stream. + Fence, + /// List locations. + ListLocations, + /// Get the default location. + GetDefaultLocation, + /// Set the default location. + SetDefaultLocation, +} + +impl From for api::access::Operation { + fn from(value: Operation) -> Self { + match value { + Operation::ListBasins => api::access::Operation::ListBasins, + Operation::CreateBasin => api::access::Operation::CreateBasin, + Operation::DeleteBasin => api::access::Operation::DeleteBasin, + Operation::ReconfigureBasin => api::access::Operation::ReconfigureBasin, + Operation::GetBasinConfig => api::access::Operation::GetBasinConfig, + Operation::IssueAccessToken => api::access::Operation::IssueAccessToken, + Operation::RevokeAccessToken => api::access::Operation::RevokeAccessToken, + Operation::ListAccessTokens => api::access::Operation::ListAccessTokens, + Operation::ListStreams => api::access::Operation::ListStreams, + Operation::CreateStream => api::access::Operation::CreateStream, + Operation::DeleteStream => api::access::Operation::DeleteStream, + Operation::GetStreamConfig => api::access::Operation::GetStreamConfig, + Operation::ReconfigureStream => api::access::Operation::ReconfigureStream, + Operation::CheckTail => api::access::Operation::CheckTail, + Operation::Append => api::access::Operation::Append, + Operation::Read => api::access::Operation::Read, + Operation::Trim => api::access::Operation::Trim, + Operation::Fence => api::access::Operation::Fence, + Operation::GetAccountMetrics => api::access::Operation::AccountMetrics, + Operation::GetBasinMetrics => api::access::Operation::BasinMetrics, + Operation::GetStreamMetrics => api::access::Operation::StreamMetrics, + Operation::ListLocations => api::access::Operation::ListLocations, + Operation::GetDefaultLocation => api::access::Operation::GetDefaultLocation, + Operation::SetDefaultLocation => api::access::Operation::SetDefaultLocation, + } + } +} + +impl From for Operation { + fn from(value: api::access::Operation) -> Self { + match value { + api::access::Operation::ListBasins => Operation::ListBasins, + api::access::Operation::CreateBasin => Operation::CreateBasin, + api::access::Operation::DeleteBasin => Operation::DeleteBasin, + api::access::Operation::ReconfigureBasin => Operation::ReconfigureBasin, + api::access::Operation::GetBasinConfig => Operation::GetBasinConfig, + api::access::Operation::IssueAccessToken => Operation::IssueAccessToken, + api::access::Operation::RevokeAccessToken => Operation::RevokeAccessToken, + api::access::Operation::ListAccessTokens => Operation::ListAccessTokens, + api::access::Operation::ListStreams => Operation::ListStreams, + api::access::Operation::CreateStream => Operation::CreateStream, + api::access::Operation::DeleteStream => Operation::DeleteStream, + api::access::Operation::GetStreamConfig => Operation::GetStreamConfig, + api::access::Operation::ReconfigureStream => Operation::ReconfigureStream, + api::access::Operation::CheckTail => Operation::CheckTail, + api::access::Operation::Append => Operation::Append, + api::access::Operation::Read => Operation::Read, + api::access::Operation::Trim => Operation::Trim, + api::access::Operation::Fence => Operation::Fence, + api::access::Operation::AccountMetrics => Operation::GetAccountMetrics, + api::access::Operation::BasinMetrics => Operation::GetBasinMetrics, + api::access::Operation::StreamMetrics => Operation::GetStreamMetrics, + api::access::Operation::ListLocations => Operation::ListLocations, + api::access::Operation::GetDefaultLocation => Operation::GetDefaultLocation, + api::access::Operation::SetDefaultLocation => Operation::SetDefaultLocation, + } + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Scope of an access token. +/// +/// **Note:** The final set of permitted operations is the union of [`ops`](AccessTokenScope::ops) +/// and the operations permitted by [`op_group_perms`](AccessTokenScope::op_group_perms). Also, the +/// final set must not be empty. +/// +/// See [`IssueAccessTokenInput::scope`]. +pub struct AccessTokenScopeInput { + basins: Option, + streams: Option, + access_tokens: Option, + op_group_perms: Option, + ops: HashSet, +} + +impl AccessTokenScopeInput { + /// Create a new [`AccessTokenScopeInput`] with the given permitted operations. + pub fn from_ops(ops: impl IntoIterator) -> Self { + Self { + basins: None, + streams: None, + access_tokens: None, + op_group_perms: None, + ops: ops.into_iter().collect(), + } + } + + /// Create a new [`AccessTokenScopeInput`] with the given operation group permissions. + pub fn from_op_group_perms(op_group_perms: OperationGroupPermissions) -> Self { + Self { + basins: None, + streams: None, + access_tokens: None, + op_group_perms: Some(op_group_perms), + ops: HashSet::default(), + } + } + + /// Set the permitted operations. + pub fn with_ops(self, ops: impl IntoIterator) -> Self { + Self { + ops: ops.into_iter().collect(), + ..self + } + } + + /// Set the access permissions at the operation group level. + pub fn with_op_group_perms(self, op_group_perms: OperationGroupPermissions) -> Self { + Self { + op_group_perms: Some(op_group_perms), + ..self + } + } + + /// Set the permitted basins. + /// + /// Defaults to no basins. + pub fn with_basins(self, basins: BasinMatcher) -> Self { + Self { + basins: Some(basins), + ..self + } + } + + /// Set the permitted streams. + /// + /// Defaults to no streams. + pub fn with_streams(self, streams: StreamMatcher) -> Self { + Self { + streams: Some(streams), + ..self + } + } + + /// Set the permitted access tokens. + /// + /// Defaults to no access tokens. + pub fn with_access_tokens(self, access_tokens: AccessTokenMatcher) -> Self { + Self { + access_tokens: Some(access_tokens), + ..self + } + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Scope of an access token. +pub struct AccessTokenScope { + /// Permitted basins. + pub basins: Option, + /// Permitted streams. + pub streams: Option, + /// Permitted access tokens. + pub access_tokens: Option, + /// Permissions at the operation group level. + pub op_group_perms: Option, + /// Permitted operations. + pub ops: HashSet, +} + +impl From for AccessTokenScope { + fn from(value: api::access::AccessTokenScope) -> Self { + Self { + basins: value.basins.map(|rs| match rs { + api::access::ResourceSet::Exact(api::access::MaybeEmpty::NonEmpty(e)) => { + BasinMatcher::Exact(e) + } + api::access::ResourceSet::Exact(api::access::MaybeEmpty::Empty) => { + BasinMatcher::None + } + api::access::ResourceSet::Prefix(p) => BasinMatcher::Prefix(p), + }), + streams: value.streams.map(|rs| match rs { + api::access::ResourceSet::Exact(api::access::MaybeEmpty::NonEmpty(e)) => { + StreamMatcher::Exact(e) + } + api::access::ResourceSet::Exact(api::access::MaybeEmpty::Empty) => { + StreamMatcher::None + } + api::access::ResourceSet::Prefix(p) => StreamMatcher::Prefix(p), + }), + access_tokens: value.access_tokens.map(|rs| match rs { + api::access::ResourceSet::Exact(api::access::MaybeEmpty::NonEmpty(e)) => { + AccessTokenMatcher::Exact(e) + } + api::access::ResourceSet::Exact(api::access::MaybeEmpty::Empty) => { + AccessTokenMatcher::None + } + api::access::ResourceSet::Prefix(p) => AccessTokenMatcher::Prefix(p), + }), + op_group_perms: value.op_groups.map(Into::into), + ops: value + .ops + .map(|ops| ops.into_iter().map(Into::into).collect()) + .unwrap_or_default(), + } + } +} + +impl From for api::access::AccessTokenScope { + fn from(value: AccessTokenScopeInput) -> Self { + Self { + basins: value.basins.map(|rs| match rs { + BasinMatcher::None => { + api::access::ResourceSet::Exact(api::access::MaybeEmpty::Empty) + } + BasinMatcher::Exact(e) => { + api::access::ResourceSet::Exact(api::access::MaybeEmpty::NonEmpty(e)) + } + BasinMatcher::Prefix(p) => api::access::ResourceSet::Prefix(p), + }), + streams: value.streams.map(|rs| match rs { + StreamMatcher::None => { + api::access::ResourceSet::Exact(api::access::MaybeEmpty::Empty) + } + StreamMatcher::Exact(e) => { + api::access::ResourceSet::Exact(api::access::MaybeEmpty::NonEmpty(e)) + } + StreamMatcher::Prefix(p) => api::access::ResourceSet::Prefix(p), + }), + access_tokens: value.access_tokens.map(|rs| match rs { + AccessTokenMatcher::None => { + api::access::ResourceSet::Exact(api::access::MaybeEmpty::Empty) + } + AccessTokenMatcher::Exact(e) => { + api::access::ResourceSet::Exact(api::access::MaybeEmpty::NonEmpty(e)) + } + AccessTokenMatcher::Prefix(p) => api::access::ResourceSet::Prefix(p), + }), + op_groups: value.op_group_perms.map(Into::into), + ops: if value.ops.is_empty() { + None + } else { + Some(value.ops.into_iter().map(Into::into).collect()) + }, + } + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Input for [`issue_access_token`](crate::S2::issue_access_token). +pub struct IssueAccessTokenInput { + /// Access token ID. + pub id: AccessTokenId, + /// Expiration time. + /// + /// Defaults to the expiration time of requestor's access token passed via + /// [`S2Config`](S2Config::new). + pub expires_at: Option, + /// Whether to automatically prefix stream names during creation and strip the prefix during + /// listing. + /// + /// **Note:** [`scope.streams`](AccessTokenScopeInput::with_streams) must be set with the + /// prefix. + /// + /// Defaults to `false`. + pub auto_prefix_streams: bool, + /// Scope of the token. + pub scope: AccessTokenScopeInput, +} + +impl IssueAccessTokenInput { + /// Create a new [`IssueAccessTokenInput`] with the given id and scope. + pub fn new(id: AccessTokenId, scope: AccessTokenScopeInput) -> Self { + Self { + id, + expires_at: None, + auto_prefix_streams: false, + scope, + } + } + + /// Set the expiration time. + pub fn with_expires_at(self, expires_at: S2DateTime) -> Self { + Self { + expires_at: Some(expires_at), + ..self + } + } + + /// Set whether to automatically prefix stream names during creation and strip the prefix during + /// listing. + pub fn with_auto_prefix_streams(self, auto_prefix_streams: bool) -> Self { + Self { + auto_prefix_streams, + ..self + } + } +} + +impl From for api::access::IssueAccessTokenRequest { + fn from(value: IssueAccessTokenInput) -> Self { + Self { + id: value.id, + expires_at: value.expires_at.map(Into::into), + auto_prefix_streams: value.auto_prefix_streams.then_some(true), + scope: value.scope.into(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Interval to accumulate over for timeseries metric sets. +pub enum TimeseriesInterval { + /// Minute. + Minute, + /// Hour. + Hour, + /// Day. + Day, +} + +impl From for api::metrics::TimeseriesInterval { + fn from(value: TimeseriesInterval) -> Self { + match value { + TimeseriesInterval::Minute => api::metrics::TimeseriesInterval::Minute, + TimeseriesInterval::Hour => api::metrics::TimeseriesInterval::Hour, + TimeseriesInterval::Day => api::metrics::TimeseriesInterval::Day, + } + } +} + +impl From for TimeseriesInterval { + fn from(value: api::metrics::TimeseriesInterval) -> Self { + match value { + api::metrics::TimeseriesInterval::Minute => TimeseriesInterval::Minute, + api::metrics::TimeseriesInterval::Hour => TimeseriesInterval::Hour, + api::metrics::TimeseriesInterval::Day => TimeseriesInterval::Day, + } + } +} + +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +/// Time range as Unix epoch seconds. +pub struct TimeRange { + /// Start timestamp (inclusive). + pub start: u32, + /// End timestamp (exclusive). + pub end: u32, +} + +impl TimeRange { + /// Create a new [`TimeRange`] with the given start and end timestamps. + pub fn new(start: u32, end: u32) -> Self { + Self { start, end } + } +} + +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +/// Time range as Unix epoch seconds and accumulation interval. +pub struct TimeRangeAndInterval { + /// Start timestamp (inclusive). + pub start: u32, + /// End timestamp (exclusive). + pub end: u32, + /// Interval to accumulate over for timeseries metric sets. + /// + /// Default is dependent on the requested metric set. + pub interval: Option, +} + +impl TimeRangeAndInterval { + /// Create a new [`TimeRangeAndInterval`] with the given start and end timestamps. + pub fn new(start: u32, end: u32) -> Self { + Self { + start, + end, + interval: None, + } + } + + /// Set the interval to accumulate over for timeseries metric sets. + pub fn with_interval(self, interval: TimeseriesInterval) -> Self { + Self { + interval: Some(interval), + ..self + } + } +} + +#[derive(Debug, Clone, Copy)] +/// Account metric set to return. +pub enum AccountMetricSet { + /// Returns a [`LabelMetric`] representing all basins which had at least one stream within the + /// specified time range. + ActiveBasins(TimeRange), + /// Returns [`AccumulationMetric`]s, one per account operation type. + /// + /// Each metric represents a timeseries of the number of operations, with one accumulated value + /// per interval over the requested time range. + /// + /// [`interval`](TimeRangeAndInterval::interval) defaults to [`hour`](TimeseriesInterval::Hour). + AccountOps(TimeRangeAndInterval), +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Input for [`get_account_metrics`](crate::S2::get_account_metrics) operation. +pub struct GetAccountMetricsInput { + /// Metric set to return. + pub set: AccountMetricSet, +} + +impl GetAccountMetricsInput { + /// Create a new [`GetAccountMetricsInput`] with the given account metric set. + pub fn new(set: AccountMetricSet) -> Self { + Self { set } + } +} + +impl From for api::metrics::AccountMetricSetRequest { + fn from(value: GetAccountMetricsInput) -> Self { + let (set, start, end, interval) = match value.set { + AccountMetricSet::ActiveBasins(args) => ( + api::metrics::AccountMetricSet::ActiveBasins, + args.start, + args.end, + None, + ), + AccountMetricSet::AccountOps(args) => ( + api::metrics::AccountMetricSet::AccountOps, + args.start, + args.end, + args.interval, + ), + }; + Self { + set, + start: Some(start), + end: Some(end), + interval: interval.map(Into::into), + } + } +} + +#[derive(Debug, Clone, Copy)] +/// Basin metric set to return. +pub enum BasinMetricSet { + /// Returns a [`GaugeMetric`] representing a timeseries of total stored bytes across all streams + /// in the basin, with one observed value for each hour over the requested time range. + Storage(TimeRange), + /// Returns [`AccumulationMetric`]s, one per storage class. + /// + /// Each metric represents a timeseries of the number of append operations across all streams + /// in the basin, with one accumulated value per interval over the requested time range. + /// + /// [`interval`](TimeRangeAndInterval::interval) defaults to + /// [`minute`](TimeseriesInterval::Minute). + AppendOps(TimeRangeAndInterval), + /// Returns [`AccumulationMetric`]s, one per read type (unary, streaming). + /// + /// Each metric represents a timeseries of the number of read operations across all streams + /// in the basin, with one accumulated value per interval over the requested time range. + /// + /// [`interval`](TimeRangeAndInterval::interval) defaults to + /// [`minute`](TimeseriesInterval::Minute). + ReadOps(TimeRangeAndInterval), + /// Returns an [`AccumulationMetric`] representing a timeseries of total read bytes + /// across all streams in the basin, with one accumulated value per interval + /// over the requested time range. + /// + /// [`interval`](TimeRangeAndInterval::interval) defaults to + /// [`minute`](TimeseriesInterval::Minute). + ReadThroughput(TimeRangeAndInterval), + /// Returns an [`AccumulationMetric`] representing a timeseries of total appended bytes + /// across all streams in the basin, with one accumulated value per interval + /// over the requested time range. + /// + /// [`interval`](TimeRangeAndInterval::interval) defaults to + /// [`minute`](TimeseriesInterval::Minute). + AppendThroughput(TimeRangeAndInterval), + /// Returns [`AccumulationMetric`]s, one per basin operation type. + /// + /// Each metric represents a timeseries of the number of operations, with one accumulated value + /// per interval over the requested time range. + /// + /// [`interval`](TimeRangeAndInterval::interval) defaults to [`hour`](TimeseriesInterval::Hour). + BasinOps(TimeRangeAndInterval), +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Input for [`get_basin_metrics`](crate::S2::get_basin_metrics) operation. +pub struct GetBasinMetricsInput { + /// Basin name. + pub name: BasinName, + /// Metric set to return. + pub set: BasinMetricSet, +} + +impl GetBasinMetricsInput { + /// Create a new [`GetBasinMetricsInput`] with the given basin name and metric set. + pub fn new(name: BasinName, set: BasinMetricSet) -> Self { + Self { name, set } + } +} + +impl From for (BasinName, api::metrics::BasinMetricSetRequest) { + fn from(value: GetBasinMetricsInput) -> Self { + let (set, start, end, interval) = match value.set { + BasinMetricSet::Storage(args) => ( + api::metrics::BasinMetricSet::Storage, + args.start, + args.end, + None, + ), + BasinMetricSet::AppendOps(args) => ( + api::metrics::BasinMetricSet::AppendOps, + args.start, + args.end, + args.interval, + ), + BasinMetricSet::ReadOps(args) => ( + api::metrics::BasinMetricSet::ReadOps, + args.start, + args.end, + args.interval, + ), + BasinMetricSet::ReadThroughput(args) => ( + api::metrics::BasinMetricSet::ReadThroughput, + args.start, + args.end, + args.interval, + ), + BasinMetricSet::AppendThroughput(args) => ( + api::metrics::BasinMetricSet::AppendThroughput, + args.start, + args.end, + args.interval, + ), + BasinMetricSet::BasinOps(args) => ( + api::metrics::BasinMetricSet::BasinOps, + args.start, + args.end, + args.interval, + ), + }; + ( + value.name, + api::metrics::BasinMetricSetRequest { + set, + start: Some(start), + end: Some(end), + interval: interval.map(Into::into), + }, + ) + } +} + +#[derive(Debug, Clone, Copy)] +/// Stream metric set to return. +pub enum StreamMetricSet { + /// Returns a [`GaugeMetric`] representing a timeseries of total stored bytes for the stream, + /// with one observed value for each minute over the requested time range. + Storage(TimeRange), +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Input for [`get_stream_metrics`](crate::S2::get_stream_metrics) operation. +pub struct GetStreamMetricsInput { + /// Basin name. + pub basin_name: BasinName, + /// Stream name. + pub stream_name: StreamName, + /// Metric set to return. + pub set: StreamMetricSet, +} + +impl GetStreamMetricsInput { + /// Create a new [`GetStreamMetricsInput`] with the given basin name, stream name and metric + /// set. + pub fn new(basin_name: BasinName, stream_name: StreamName, set: StreamMetricSet) -> Self { + Self { + basin_name, + stream_name, + set, + } + } +} + +impl From for (BasinName, StreamName, api::metrics::StreamMetricSetRequest) { + fn from(value: GetStreamMetricsInput) -> Self { + let (set, start, end, interval) = match value.set { + StreamMetricSet::Storage(args) => ( + api::metrics::StreamMetricSet::Storage, + args.start, + args.end, + None, + ), + }; + ( + value.basin_name, + value.stream_name, + api::metrics::StreamMetricSetRequest { + set, + start: Some(start), + end: Some(end), + interval, + }, + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Unit in which metric values are measured. +pub enum MetricUnit { + /// Size in bytes. + Bytes, + /// Number of operations. + Operations, +} + +impl From for MetricUnit { + fn from(value: api::metrics::MetricUnit) -> Self { + match value { + api::metrics::MetricUnit::Bytes => MetricUnit::Bytes, + api::metrics::MetricUnit::Operations => MetricUnit::Operations, + } + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Single named value. +pub struct ScalarMetric { + /// Metric name. + pub name: String, + /// Unit for the metric value. + pub unit: MetricUnit, + /// Metric value. + pub value: f64, +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Named series of `(timestamp, value)` datapoints, each representing an accumulation over a +/// specified interval. +pub struct AccumulationMetric { + /// Timeseries name. + pub name: String, + /// Unit for the accumulated values. + pub unit: MetricUnit, + /// The interval at which datapoints are accumulated. + pub interval: TimeseriesInterval, + /// Series of `(timestamp, value)` datapoints. Each datapoint represents the accumulated + /// `value` for the time period starting at the `timestamp` (in Unix epoch seconds), spanning + /// one `interval`. + pub values: Vec<(u32, f64)>, +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Named series of `(timestamp, value)` datapoints, each representing an instantaneous value. +pub struct GaugeMetric { + /// Timeseries name. + pub name: String, + /// Unit for the instantaneous values. + pub unit: MetricUnit, + /// Series of `(timestamp, value)` datapoints. Each datapoint represents the `value` at the + /// instant of the `timestamp` (in Unix epoch seconds). + pub values: Vec<(u32, f64)>, +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Set of string labels. +pub struct LabelMetric { + /// Label name. + pub name: String, + /// Label values. + pub values: Vec, +} + +#[derive(Debug, Clone)] +/// Individual metric in a returned metric set. +pub enum Metric { + /// Single named value. + Scalar(ScalarMetric), + /// Named series of `(timestamp, value)` datapoints, each representing an accumulation over a + /// specified interval. + Accumulation(AccumulationMetric), + /// Named series of `(timestamp, value)` datapoints, each representing an instantaneous value. + Gauge(GaugeMetric), + /// Set of string labels. + Label(LabelMetric), +} + +impl From for Metric { + fn from(value: api::metrics::Metric) -> Self { + match value { + api::metrics::Metric::Scalar(sm) => Metric::Scalar(ScalarMetric { + name: sm.name.into(), + unit: sm.unit.into(), + value: sm.value, + }), + api::metrics::Metric::Accumulation(am) => Metric::Accumulation(AccumulationMetric { + name: am.name.into(), + unit: am.unit.into(), + interval: am.interval.into(), + values: am.values, + }), + api::metrics::Metric::Gauge(gm) => Metric::Gauge(GaugeMetric { + name: gm.name.into(), + unit: gm.unit.into(), + values: gm.values, + }), + api::metrics::Metric::Label(lm) => Metric::Label(LabelMetric { + name: lm.name.into(), + values: lm.values, + }), + } + } +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +/// Input for [`list_streams`](crate::S2Basin::list_streams) operation. +pub struct ListStreamsInput { + /// Filter streams whose names begin with this value. + /// + /// Defaults to `""`. + pub prefix: StreamNamePrefix, + /// Filter streams whose names are lexicographically greater than this value. + /// + /// Defaults to `""`. + pub start_after: StreamNameStartAfter, + /// Number of streams to return in a page. Will be clamped to a maximum of `1000`. + /// + /// Defaults to `1000`. + pub limit: Option, +} + +impl ListStreamsInput { + /// Create a new [`ListStreamsInput`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// Set the prefix used to filter streams whose names begin with this value. + pub fn with_prefix(self, prefix: StreamNamePrefix) -> Self { + Self { prefix, ..self } + } + + /// Set the value used to filter streams whose names are lexicographically greater than this + /// value. + pub fn with_start_after(self, start_after: StreamNameStartAfter) -> Self { + Self { + start_after, + ..self + } + } + + /// Set the limit on number of streams to return in a page. + pub fn with_limit(self, limit: usize) -> Self { + Self { + limit: Some(limit), + ..self + } + } +} + +impl From for api::stream::ListStreamsRequest { + fn from(value: ListStreamsInput) -> Self { + Self { + prefix: Some(value.prefix), + start_after: Some(value.start_after), + limit: value.limit, + } + } +} + +#[derive(Debug, Clone, Default)] +/// Input for [`list_all_streams`](crate::S2Basin::list_all_streams) operation. +pub struct ListAllStreamsInput { + /// Filter streams whose names begin with this value. + /// + /// Defaults to `""`. + pub prefix: StreamNamePrefix, + /// Filter streams whose names are lexicographically greater than this value. + /// + /// Defaults to `""`. + pub start_after: StreamNameStartAfter, + /// Whether to include streams that are being deleted. + /// + /// Defaults to `false`. + pub include_deleted: bool, +} + +impl ListAllStreamsInput { + /// Create a new [`ListAllStreamsInput`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// Set the prefix used to filter streams whose names begin with this value. + pub fn with_prefix(self, prefix: StreamNamePrefix) -> Self { + Self { prefix, ..self } + } + + /// Set the value used to filter streams whose names are lexicographically greater than this + /// value. + pub fn with_start_after(self, start_after: StreamNameStartAfter) -> Self { + Self { + start_after, + ..self + } + } + + /// Set whether to include streams that are being deleted. + pub fn with_include_deleted(self, include_deleted: bool) -> Self { + Self { + include_deleted, + ..self + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +/// Stream information. +pub struct StreamInfo { + /// Stream name. + pub name: StreamName, + /// Creation time. + pub created_at: S2DateTime, + /// Deletion time if the stream is being deleted. + pub deleted_at: Option, + /// Encryption algorithm for this stream, if encryption is enabled. + pub cipher: Option, +} + +impl TryFrom for StreamInfo { + type Error = ValidationError; + + fn try_from(value: api::stream::StreamInfo) -> Result { + Ok(Self { + name: value.name, + created_at: value.created_at.try_into()?, + deleted_at: value.deleted_at.map(S2DateTime::try_from).transpose()?, + cipher: value.cipher.map(Into::into), + }) + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Input for [`create_stream`](crate::S2Basin::create_stream) operation. +pub struct CreateStreamInput { + /// Stream name. + pub name: StreamName, + /// Configuration for the stream. + /// + /// See [`StreamConfig`] for defaults. + pub config: Option, + idempotency_token: String, +} + +impl CreateStreamInput { + /// Create a new [`CreateStreamInput`] with the given stream name. + pub fn new(name: StreamName) -> Self { + Self { + name, + config: None, + idempotency_token: idempotency_token(), + } + } + + /// Set the configuration for the stream. + pub fn with_config(self, config: StreamConfig) -> Self { + Self { + config: Some(config), + ..self + } + } +} + +impl From for (api::stream::CreateStreamRequest, String) { + fn from(value: CreateStreamInput) -> Self { + ( + api::stream::CreateStreamRequest { + stream: value.name, + config: value.config.map(Into::into), + }, + value.idempotency_token, + ) + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Input for [`ensure_stream`](crate::S2Basin::ensure_stream) +/// operation. +pub struct EnsureStreamInput { + /// Stream name. + pub name: StreamName, + /// Configuration for the stream. + /// + /// See [`StreamConfig`] for defaults. + pub config: Option, +} + +impl EnsureStreamInput { + /// Create a new [`EnsureStreamInput`] with the given stream name. + pub fn new(name: StreamName) -> Self { + Self { name, config: None } + } + + /// Set the configuration for the stream. + pub fn with_config(self, config: StreamConfig) -> Self { + Self { + config: Some(config), + ..self + } + } +} + +impl From for (StreamName, Option) { + fn from(value: EnsureStreamInput) -> Self { + (value.name, value.config.map(Into::into)) + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Input of [`delete_stream`](crate::S2Basin::delete_stream) operation. +pub struct DeleteStreamInput { + /// Stream name. + pub name: StreamName, + /// Whether to ignore `Not Found` error if the stream doesn't exist. + pub ignore_not_found: bool, +} + +impl DeleteStreamInput { + /// Create a new [`DeleteStreamInput`] with the given stream name. + pub fn new(name: StreamName) -> Self { + Self { + name, + ignore_not_found: false, + } + } + + /// Set whether to ignore `Not Found` error if the stream doesn't exist. + pub fn with_ignore_not_found(self, ignore_not_found: bool) -> Self { + Self { + ignore_not_found, + ..self + } + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Input for [`reconfigure_stream`](crate::S2Basin::reconfigure_stream) operation. +pub struct ReconfigureStreamInput { + /// Stream name. + pub name: StreamName, + /// Reconfiguration for [`StreamConfig`]. + pub config: StreamReconfiguration, +} + +impl ReconfigureStreamInput { + /// Create a new [`ReconfigureStreamInput`] with the given stream name and reconfiguration. + pub fn new(name: StreamName, config: StreamReconfiguration) -> Self { + Self { name, config } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Token for fencing appends to a stream. +/// +/// **Note:** It must not exceed 36 bytes in length. +/// +/// See [`CommandRecord::fence`] and [`AppendInput::fencing_token`]. +pub struct FencingToken(String); + +impl FencingToken { + pub(crate) fn from_server(value: String) -> Self { + Self(value) + } + + /// Generate a random alphanumeric fencing token of `n` bytes. + pub fn generate(n: usize) -> Result { + rand::rng() + .sample_iter(&rand::distr::Alphanumeric) + .take(n) + .map(char::from) + .collect::() + .parse() + } +} + +impl FromStr for FencingToken { + type Err = ValidationError; + + fn from_str(s: &str) -> Result { + if s.len() > MAX_FENCING_TOKEN_LENGTH { + return Err(ValidationError(format!( + "fencing token exceeds {MAX_FENCING_TOKEN_LENGTH} bytes in length", + ))); + } + Ok(FencingToken(s.to_string())) + } +} + +impl std::fmt::Display for FencingToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Deref for FencingToken { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +/// A position in a stream. +pub struct StreamPosition { + /// Sequence number assigned by the service. + pub seq_num: u64, + /// Timestamp. When assigned by the service, represents milliseconds since Unix epoch. + /// User-specified timestamps are passed through as-is. + pub timestamp: u64, +} + +impl StreamPosition { + /// Construct a stream position. + /// + /// This is intended for building fixtures in downstream tests. + pub fn new(seq_num: u64, timestamp: u64) -> Self { + Self { seq_num, timestamp } + } +} + +impl std::fmt::Display for StreamPosition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "seq_num={}, timestamp={}", self.seq_num, self.timestamp) + } +} + +impl From for StreamPosition { + fn from(value: api::stream::proto::StreamPosition) -> Self { + Self { + seq_num: value.seq_num, + timestamp: value.timestamp, + } + } +} + +impl From for StreamPosition { + fn from(value: api::stream::StreamPosition) -> Self { + Self { + seq_num: value.seq_num, + timestamp: value.timestamp, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +/// A name-value pair. +pub struct Header { + /// Name. + pub name: Bytes, + /// Value. + pub value: Bytes, +} + +impl Header { + /// Create a new [`Header`] with the given name and value. + pub fn new(name: impl Into, value: impl Into) -> Self { + Self { + name: name.into(), + value: value.into(), + } + } +} + +impl From
for api::stream::proto::Header { + fn from(value: Header) -> Self { + Self { + name: value.name, + value: value.value, + } + } +} + +impl From for Header { + fn from(value: api::stream::proto::Header) -> Self { + Self { + name: value.name, + value: value.value, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +/// A record to append. +pub struct AppendRecord { + body: Bytes, + headers: Vec
, + timestamp: Option, +} + +impl AppendRecord { + fn validate(self) -> Result { + if self.metered_bytes() > RECORD_BATCH_MAX.bytes { + Err(ValidationError(format!( + "metered_bytes: {} exceeds {}", + self.metered_bytes(), + RECORD_BATCH_MAX.bytes + ))) + } else { + Ok(self) + } + } + + /// Create a new [`AppendRecord`] with the given record body. + pub fn new(body: impl Into) -> Result { + let record = Self { + body: body.into(), + headers: Vec::default(), + timestamp: None, + }; + record.validate() + } + + /// Set the headers for this record. + pub fn with_headers( + self, + headers: impl IntoIterator, + ) -> Result { + let record = Self { + headers: headers.into_iter().collect(), + ..self + }; + record.validate() + } + + /// Set the timestamp for this record. + /// + /// Precise semantics depend on [`StreamConfig::timestamping`]. + pub fn with_timestamp(self, timestamp: u64) -> Self { + Self { + timestamp: Some(timestamp), + ..self + } + } + + /// Get the body of this record. + pub fn body(&self) -> &[u8] { + &self.body + } + + /// Get the headers of this record. + pub fn headers(&self) -> &[Header] { + &self.headers + } + + /// Get the timestamp of this record. + pub fn timestamp(&self) -> Option { + self.timestamp + } +} + +impl From for api::stream::proto::AppendRecord { + fn from(value: AppendRecord) -> Self { + Self { + timestamp: value.timestamp, + headers: value.headers.into_iter().map(Into::into).collect(), + body: value.body, + } + } +} + +/// Metered byte size calculation. +/// +/// Formula for a record: +/// ```text +/// 8 + 2 * len(headers) + sum(len(h.name) + len(h.value) for h in headers) + len(body) +/// ``` +pub trait MeteredBytes { + /// Returns the metered byte size. + fn metered_bytes(&self) -> usize; +} + +macro_rules! metered_bytes_impl { + ($ty:ty) => { + impl MeteredBytes for $ty { + fn metered_bytes(&self) -> usize { + 8 + (2 * self.headers.len()) + + self + .headers + .iter() + .map(|h| h.name.len() + h.value.len()) + .sum::() + + self.body.len() + } + } + }; +} + +metered_bytes_impl!(AppendRecord); + +impl MeteredSize for AppendRecord { + fn metered_size(&self) -> usize { + self.metered_bytes() + } +} + +#[derive(Debug, Clone)] +/// A batch of records to append atomically. +/// +/// **Note:** It must contain at least `1` record and no more than `1000`. +/// The total size of the batch must not exceed `1MiB` in metered bytes. +/// +/// See [`AppendRecordBatches`](crate::batching::AppendRecordBatches) and +/// [`AppendInputs`](crate::batching::AppendInputs) for convenient and automatic batching of records +/// that takes care of the abovementioned constraints. +pub struct AppendRecordBatch(Metered>); + +impl From>> for AppendRecordBatch { + fn from(records: Metered>) -> Self { + Self(records) + } +} + +impl AppendRecordBatch { + /// Try to create an [`AppendRecordBatch`] from an iterator of [`AppendRecord`]s. + pub fn try_from_iter(iter: I) -> Result + where + I: IntoIterator, + { + let mut records = Metered::with_capacity(RECORD_BATCH_MAX.count); + + for record in iter { + records.push(Metered::from(record)); + + if records.metered_size() > RECORD_BATCH_MAX.bytes { + return Err(ValidationError(format!( + "batch size in metered bytes ({}) exceeds {}", + records.metered_size(), + RECORD_BATCH_MAX.bytes + ))); + } + + if records.len() > RECORD_BATCH_MAX.count { + return Err(ValidationError(format!( + "number of records in the batch exceeds {}", + RECORD_BATCH_MAX.count + ))); + } + } + + if records.is_empty() { + return Err(ValidationError("batch is empty".into())); + } + + Ok(records.into()) + } +} + +impl Deref for AppendRecordBatch { + type Target = [AppendRecord]; + + fn deref(&self) -> &Self::Target { + &self.0[..] + } +} + +impl MeteredBytes for AppendRecordBatch { + fn metered_bytes(&self) -> usize { + self.0.metered_size() + } +} + +impl IntoIterator for AppendRecordBatch { + type Item = AppendRecord; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl<'a> IntoIterator for &'a AppendRecordBatch { + type Item = &'a AppendRecord; + type IntoIter = std::slice::Iter<'a, AppendRecord>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +#[derive(Debug, Clone)] +/// Command to signal an operation. +pub enum Command { + /// Fence operation. + Fence { + /// Fencing token. + fencing_token: FencingToken, + }, + /// Trim operation. + Trim { + /// Trim point. + trim_point: u64, + }, +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Command record for signaling operations to the service. +/// +/// See [here](https://s2.dev/docs/rest/records/overview#command-records) for more information. +pub struct CommandRecord { + /// Command to signal an operation. + pub command: Command, + /// Timestamp for this record. + pub timestamp: Option, +} + +impl CommandRecord { + const FENCE: &[u8] = b"fence"; + const TRIM: &[u8] = b"trim"; + + /// Create a fence command record with the given fencing token. + /// + /// Fencing is strongly consistent, and subsequent appends that specify a + /// fencing token will fail if it does not match. + pub fn fence(fencing_token: FencingToken) -> Self { + Self { + command: Command::Fence { fencing_token }, + timestamp: None, + } + } + + /// Create a trim command record with the given trim point. + /// + /// Trim point is the desired earliest sequence number for the stream. + /// + /// Trimming is eventually consistent, and trimmed records may be visible + /// for a brief period. + pub fn trim(trim_point: u64) -> Self { + Self { + command: Command::Trim { trim_point }, + timestamp: None, + } + } + + /// Set the timestamp for this record. + pub fn with_timestamp(self, timestamp: u64) -> Self { + Self { + timestamp: Some(timestamp), + ..self + } + } +} + +impl From for AppendRecord { + fn from(value: CommandRecord) -> Self { + let (header_value, body) = match value.command { + Command::Fence { fencing_token } => ( + CommandRecord::FENCE, + Bytes::copy_from_slice(fencing_token.as_bytes()), + ), + Command::Trim { trim_point } => ( + CommandRecord::TRIM, + Bytes::copy_from_slice(&trim_point.to_be_bytes()), + ), + }; + Self { + body, + headers: vec![Header::new("", header_value)], + timestamp: value.timestamp, + } + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Input for [`append`](crate::S2Stream::append) operation and +/// [`AppendSession::submit`](crate::append_session::AppendSession::submit). +pub struct AppendInput { + /// Batch of records to append atomically. + pub records: AppendRecordBatch, + /// Expected sequence number for the first record in the batch. + /// + /// If unspecified, no matching is performed. If specified and mismatched, the append fails. + pub match_seq_num: Option, + /// Fencing token to match against the stream's current fencing token. + /// + /// If unspecified, no matching is performed. If specified and mismatched, + /// the append fails. A stream defaults to `""` as its fencing token. + pub fencing_token: Option, + /// Stream configuration to apply if the stream is created on append. + /// + /// Unset fields inherit the basin's default stream configuration. Ignored if the stream + /// already exists. + /// + /// Only used by [`append`](crate::S2Stream::append). Append sessions send the header once + /// at connect; see + /// [`AppendSessionConfig::with_stream_config`](crate::append_session::AppendSessionConfig::with_stream_config) + /// and [`ProducerConfig::with_stream_config`](crate::producer::ProducerConfig::with_stream_config). + pub stream_config: Option, +} + +impl AppendInput { + /// Create a new [`AppendInput`] with the given batch of records. + pub fn new(records: AppendRecordBatch) -> Self { + Self { + records, + match_seq_num: None, + fencing_token: None, + stream_config: None, + } + } + + /// Set the stream configuration to apply if the stream is created on append. + pub fn with_stream_config(self, stream_config: StreamConfig) -> Self { + Self { + stream_config: Some(stream_config), + ..self + } + } + + /// Set the expected sequence number for the first record in the batch. + pub fn with_match_seq_num(self, match_seq_num: u64) -> Self { + Self { + match_seq_num: Some(match_seq_num), + ..self + } + } + + /// Set the fencing token to match against the stream's current fencing token. + pub fn with_fencing_token(self, fencing_token: FencingToken) -> Self { + Self { + fencing_token: Some(fencing_token), + ..self + } + } +} + +impl From for api::stream::proto::AppendInput { + fn from(value: AppendInput) -> Self { + Self { + records: value.records.iter().cloned().map(Into::into).collect(), + match_seq_num: value.match_seq_num, + fencing_token: value.fencing_token.map(|t| t.to_string()), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +/// Acknowledgement for an [`AppendInput`]. +pub struct AppendAck { + /// Sequence number and timestamp of the first record that was appended. + pub start: StreamPosition, + /// Sequence number of the last record that was appended + 1, and timestamp of the last record + /// that was appended. + /// + /// The difference between `end.seq_num` and `start.seq_num` will be the number of records + /// appended. + pub end: StreamPosition, + /// Sequence number that will be assigned to the next record on the stream, and timestamp of + /// the last record on the stream. + /// + /// This can be greater than the `end` position in case of concurrent appends. + pub tail: StreamPosition, +} + +impl AppendAck { + /// Construct an append acknowledgement. + /// + /// This is intended for building fixtures in downstream tests. + pub fn new(start: StreamPosition, end: StreamPosition, tail: StreamPosition) -> Self { + Self { start, end, tail } + } +} + +impl From for AppendAck { + fn from(value: api::stream::proto::AppendAck) -> Self { + Self { + start: value.start.unwrap_or_default().into(), + end: value.end.unwrap_or_default().into(), + tail: value.tail.unwrap_or_default().into(), + } + } +} + +#[derive(Debug, Clone, Copy)] +/// Starting position for reading from a stream. +pub enum ReadFrom { + /// Read from this sequence number. + SeqNum(u64), + /// Read from this timestamp. + Timestamp(u64), + /// Read from N records before the tail. + TailOffset(u64), +} + +impl Default for ReadFrom { + fn default() -> Self { + Self::SeqNum(0) + } +} + +#[derive(Debug, Default, Clone)] +#[non_exhaustive] +/// Where to start reading. +pub struct ReadStart { + /// Starting position. + /// + /// Defaults to reading from sequence number `0`. + pub from: ReadFrom, + /// Whether to start from tail if the requested starting position is beyond it. + /// + /// Defaults to `false` (errors if position is beyond tail). + pub clamp_to_tail: bool, +} + +impl ReadStart { + /// Create a new [`ReadStart`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// Set the starting position. + pub fn with_from(self, from: ReadFrom) -> Self { + Self { from, ..self } + } + + /// Set whether to start from tail if the requested starting position is beyond it. + pub fn with_clamp_to_tail(self, clamp_to_tail: bool) -> Self { + Self { + clamp_to_tail, + ..self + } + } +} + +impl From for api::stream::ReadStart { + fn from(value: ReadStart) -> Self { + let (seq_num, timestamp, tail_offset) = match value.from { + ReadFrom::SeqNum(n) => (Some(n), None, None), + ReadFrom::Timestamp(t) => (None, Some(t), None), + ReadFrom::TailOffset(o) => (None, None, Some(o)), + }; + Self { + seq_num, + timestamp, + tail_offset, + clamp: if value.clamp_to_tail { + Some(true) + } else { + None + }, + } + } +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +/// Limits on how much to read. +pub struct ReadLimits { + /// Limit on number of records. + /// + /// Defaults to `1000` for non-streaming read. + pub count: Option, + /// Limit on total metered bytes of records. + /// + /// Defaults to `1MiB` for non-streaming read. + pub bytes: Option, +} + +impl ReadLimits { + /// Create a new [`ReadLimits`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// Set the limit on number of records. + pub fn with_count(self, count: usize) -> Self { + Self { + count: Some(count), + ..self + } + } + + /// Set the limit on total metered bytes of records. + pub fn with_bytes(self, bytes: usize) -> Self { + Self { + bytes: Some(bytes), + ..self + } + } +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +/// When to stop reading. +pub struct ReadStop { + /// Limits on how much to read. + /// + /// See [`ReadLimits`] for defaults. + pub limits: ReadLimits, + /// Timestamp at which to stop (exclusive). + /// + /// Defaults to `None`. + pub until: Option>, + /// Duration in seconds to wait for new records before stopping. Will be clamped to `60` + /// seconds for [`read`](crate::S2Stream::read). + /// + /// Defaults to: + /// - `0` (no wait) for [`read`](crate::S2Stream::read). + /// - `0` (no wait) for [`read_session`](crate::S2Stream::read_session) if `limits` or `until` + /// is specified. + /// - Infinite wait for [`read_session`](crate::S2Stream::read_session) if neither `limits` nor + /// `until` is specified. + pub wait: Option, +} + +impl ReadStop { + /// Create a new [`ReadStop`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// Set the limits on how much to read. + pub fn with_limits(self, limits: ReadLimits) -> Self { + Self { limits, ..self } + } + + /// Set the timestamp at which to stop (exclusive). + pub fn with_until(self, until: RangeTo) -> Self { + Self { + until: Some(until), + ..self + } + } + + /// Set the duration in seconds to wait for new records before stopping. + pub fn with_wait(self, wait: u32) -> Self { + Self { + wait: Some(wait), + ..self + } + } +} + +impl From for api::stream::ReadEnd { + fn from(value: ReadStop) -> Self { + Self { + count: value.limits.count, + bytes: value.limits.bytes, + until: value.until.map(|r| r.end), + wait: value.wait, + } + } +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +/// Input for [`read`](crate::S2Stream::read) and [`read_session`](crate::S2Stream::read_session) +/// operations. +pub struct ReadInput { + /// Where to start reading. + /// + /// See [`ReadStart`] for defaults. + pub start: ReadStart, + /// When to stop reading. + /// + /// See [`ReadStop`] for defaults. + pub stop: ReadStop, + /// Whether to filter out command records from the stream when reading. + /// + /// Defaults to `false`. + pub ignore_command_records: bool, + /// Stream configuration to apply if the stream is created on read. + /// + /// Unset fields inherit the basin's default stream configuration. Ignored if the stream + /// already exists. + pub stream_config: Option, +} + +#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)] +#[non_exhaustive] +/// Retry policy for a continuous read session. +pub enum ReadSessionRetryPolicy { + /// Stop after the retry budget configured by [`RetryConfig`] is exhausted. + #[default] + Budgeted, + /// Keep retrying retryable failures after the configured retry budget is exhausted. + /// + /// This also applies while establishing the initial session, so + /// [`read_session`](crate::S2Stream::read_session) may remain pending through retryable + /// failures until it connects or the future is cancelled. + Indefinite, +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +/// Configuration for a continuous read session. +pub struct ReadSessionConfig { + /// Policy for retrying retryable failures. + /// + /// Clean stream ends and non-retryable failures always terminate the session. + /// + /// Defaults to [`ReadSessionRetryPolicy::Budgeted`]. + pub retry_policy: ReadSessionRetryPolicy, +} + +impl ReadSessionConfig { + /// Create a new [`ReadSessionConfig`] with default settings. + pub fn new() -> Self { + Self::default() + } + + /// Set the policy for retrying retryable failures. + pub fn with_retry_policy(self, retry_policy: ReadSessionRetryPolicy) -> Self { + Self { + retry_policy, + ..self + } + } +} + +impl ReadInput { + /// Create a new [`ReadInput`] with default values. + pub fn new() -> Self { + Self::default() + } + + /// Set where to start reading. + pub fn with_start(self, start: ReadStart) -> Self { + Self { start, ..self } + } + + /// Set when to stop reading. + pub fn with_stop(self, stop: ReadStop) -> Self { + Self { stop, ..self } + } + + /// Set whether to filter out command records from the stream when reading. + pub fn with_ignore_command_records(self, ignore_command_records: bool) -> Self { + Self { + ignore_command_records, + ..self + } + } + + /// Set the stream configuration to apply if the stream is created on read. + pub fn with_stream_config(self, stream_config: StreamConfig) -> Self { + Self { + stream_config: Some(stream_config), + ..self + } + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Record that is durably sequenced on a stream. +pub struct SequencedRecord { + /// Sequence number assigned to this record. + pub seq_num: u64, + /// Body of this record. + pub body: Bytes, + /// Headers for this record. + pub headers: Vec
, + /// Timestamp for this record. + pub timestamp: u64, +} + +impl SequencedRecord { + /// Construct a sequenced record from its plain-data fields. + /// + /// This is intended for building fixtures in downstream tests. + pub fn from_parts( + seq_num: u64, + timestamp: u64, + headers: Vec
, + body: impl Into, + ) -> Self { + Self { + seq_num, + timestamp, + body: body.into(), + headers, + } + } + + /// Whether this is a command record. + pub fn is_command_record(&self) -> bool { + self.headers.len() == 1 && *self.headers[0].name == *b"" + } +} + +impl From for SequencedRecord { + fn from(value: api::stream::proto::SequencedRecord) -> Self { + Self { + seq_num: value.seq_num, + body: value.body, + headers: value.headers.into_iter().map(Into::into).collect(), + timestamp: value.timestamp, + } + } +} + +metered_bytes_impl!(SequencedRecord); + +#[derive(Debug, Clone)] +#[non_exhaustive] +/// Batch of records returned by [`read`](crate::S2Stream::read) or streamed by +/// [`read_session`](crate::S2Stream::read_session). +pub struct ReadBatch { + /// Records that are durably sequenced on the stream. + /// + /// It can be empty only for a [`read`](crate::S2Stream::read) operation when: + /// - the [`stop condition`](ReadInput::stop) was already met, or + /// - all records in the batch were command records and + /// [`ignore_command_records`](ReadInput::ignore_command_records) was set to `true`. + pub records: Vec, + /// Sequence number that will be assigned to the next record on the stream, and timestamp of + /// the last record. + /// + /// It will only be present when reading recent records. + pub tail: Option, +} + +impl ReadBatch { + /// Construct a read batch. + /// + /// This is intended for building fixtures in downstream tests. + pub fn new(records: Vec, tail: Option) -> Self { + Self { records, tail } + } + + pub(crate) fn from_api(batch: api::stream::proto::ReadBatch) -> Self { + Self { + records: batch.records.into_iter().map(Into::into).collect(), + tail: batch.tail.map(Into::into), + } + } +} + +/// A stream of values of type `Result`. +pub type Streaming = Pin>>>; + +fn idempotency_token() -> String { + uuid::Uuid::new_v4().simple().to_string() +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use rstest::rstest; + + use super::*; + + type HeaderParts = (Vec, Vec); + type AppendRecordParts = (Vec, Vec); + + fn byte_vec_strategy(max_len: usize) -> impl Strategy> { + prop::collection::vec(any::(), 0..=max_len) + } + + fn header_parts_strategy() -> impl Strategy { + (byte_vec_strategy(32), byte_vec_strategy(64)) + } + + fn string_strategy(max_chars: usize) -> impl Strategy { + prop::collection::vec(any::(), 0..=max_chars) + .prop_map(|chars| chars.into_iter().collect()) + } + + fn read_from_strategy() -> impl Strategy { + prop_oneof![ + any::().prop_map(ReadFrom::SeqNum), + any::().prop_map(ReadFrom::Timestamp), + any::().prop_map(ReadFrom::TailOffset), + ] + } + + fn append_record_parts_strategy() -> impl Strategy { + ( + byte_vec_strategy(256), + prop::collection::vec(header_parts_strategy(), 0..=16), + ) + } + + fn proto_stream_position_strategy() -> impl Strategy + { + (any::(), any::()).prop_map(|(seq_num, timestamp)| { + api::stream::proto::StreamPosition { seq_num, timestamp } + }) + } + + fn headers_from_parts(headers: &[HeaderParts]) -> Vec
{ + headers + .iter() + .map(|(name, value)| Header::new(Bytes::from(name.clone()), Bytes::from(value.clone()))) + .collect() + } + + fn expected_metered_bytes(body: &[u8], headers: &[HeaderParts]) -> usize { + 8 + (2 * headers.len()) + + headers + .iter() + .map(|(name, value)| name.len() + value.len()) + .sum::() + + body.len() + } + + // -- S2DateTime -- + + #[test] + fn s2_datetime_parse_valid_rfc3339() { + let dt: S2DateTime = "2024-01-15T12:30:00Z".parse().unwrap(); + assert_eq!(dt.to_string(), "2024-01-15T12:30:00Z"); + } + + #[test] + fn s2_datetime_parse_with_offset() { + let dt: S2DateTime = "2024-06-01T08:00:00+05:30".parse().unwrap(); + assert_eq!(dt.to_string(), "2024-06-01T08:00:00+05:30"); + + let offset_dt: time::OffsetDateTime = dt.into(); + assert_eq!( + offset_dt.offset(), + time::UtcOffset::from_hms(5, 30, 0).unwrap() + ); + } + + #[test] + fn s2_datetime_parse_invalid() { + let err = "not-a-date".parse::(); + assert!(err.is_err()); + } + + #[test] + fn s2_datetime_roundtrip_via_offset_datetime() { + let odt = time::OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(); + let dt = S2DateTime::try_from(odt).unwrap(); + let back: time::OffsetDateTime = dt.into(); + assert_eq!(odt, back); + } + + // -- AccountEndpoint -- + + #[rstest] + #[case::https_with_scheme("https://aws.s2.dev", Scheme::HTTPS)] + #[case::http_with_scheme("http://localhost:8080", Scheme::HTTP)] + #[case::default_https("aws.s2.dev", Scheme::HTTPS)] + fn account_endpoint_parse(#[case] input: &str, #[case] expected_scheme: Scheme) { + let ep: AccountEndpoint = input.parse().unwrap(); + assert_eq!(ep.scheme, expected_scheme); + } + + // -- BasinEndpoint -- + + #[rstest] + #[case::https_parent_zone("https://{basin}.b.s2.dev", Scheme::HTTPS, true)] + #[case::http_direct("http://localhost:8080", Scheme::HTTP, false)] + #[case::default_https_parent_zone("{basin}.b.s2.dev", Scheme::HTTPS, true)] + fn basin_endpoint_parse( + #[case] input: &str, + #[case] expected_scheme: Scheme, + #[case] expected_parent_zone: bool, + ) { + let ep: BasinEndpoint = input.parse().unwrap(); + assert_eq!(ep.scheme, expected_scheme); + assert_eq!( + matches!(ep.authority, BasinAuthority::ParentZone(_)), + expected_parent_zone + ); + } + + // -- S2Endpoints -- + + #[test] + fn s2_endpoints_new_requires_same_scheme() { + let account: AccountEndpoint = "https://aws.s2.dev".parse().unwrap(); + let basin: BasinEndpoint = "http://localhost:8080".parse().unwrap(); + let err = S2Endpoints::new(account, basin); + assert!(err.is_err()); + } + + #[test] + fn s2_endpoints_new_same_scheme_succeeds() { + let account: AccountEndpoint = "https://aws.s2.dev".parse().unwrap(); + let basin: BasinEndpoint = "https://{basin}.b.s2.dev".parse().unwrap(); + let ep = S2Endpoints::new(account, basin).unwrap(); + assert_eq!(ep.scheme, Scheme::HTTPS); + } + + #[test] + fn s2_endpoints_for_endpoint_defaults_to_https() { + let ep = S2Endpoints::for_endpoint("localhost:8080").unwrap(); + let authority: Authority = "localhost:8080".parse().unwrap(); + assert_eq!(ep.scheme, Scheme::HTTPS); + assert_eq!(ep.account_authority, authority); + assert_eq!(ep.basin_authority, BasinAuthority::Direct(authority)); + } + + #[test] + fn s2_endpoints_for_endpoint_accepts_explicit_scheme() { + let ep = S2Endpoints::for_endpoint("http://localhost:8080").unwrap(); + assert_eq!(ep.scheme, Scheme::HTTP); + } + + #[test] + fn s2_endpoints_for_endpoint_rejects_invalid_endpoint() { + assert!(S2Endpoints::for_endpoint("not a valid endpoint").is_err()); + } + + // -- Compression -- + + #[rstest] + #[case::none(Compression::None, CompressionAlgorithm::None)] + #[case::gzip(Compression::Gzip, CompressionAlgorithm::Gzip)] + #[case::zstd(Compression::Zstd, CompressionAlgorithm::Zstd)] + fn compression_conversion(#[case] sdk: Compression, #[case] api: CompressionAlgorithm) { + assert_eq!(CompressionAlgorithm::from(sdk), api); + } + + // -- RetryConfig -- + + #[test] + fn retry_config_defaults() { + let rc = RetryConfig::default(); + assert_eq!(rc.max_attempts.get(), 3); + assert_eq!(rc.min_base_delay, Duration::from_millis(100)); + assert_eq!(rc.max_base_delay, Duration::from_secs(1)); + assert!(matches!(rc.append_retry_policy, AppendRetryPolicy::All)); + } + + #[test] + fn retry_config_max_retries() { + let rc = RetryConfig::default(); + assert_eq!(rc.max_retries(), 2); + } + + // -- S2Config -- + + #[test] + fn s2_config_defaults() { + let cfg = S2Config::new("test-token"); + assert_eq!(cfg.connection_timeout, Duration::from_secs(3)); + assert_eq!(cfg.request_timeout, Duration::from_secs(5)); + assert!(!cfg.insecure_skip_cert_verification); + } + + #[cfg(feature = "_hidden")] + #[rstest] + #[case::matching_compression("content-encoding", "gzip", Compression::Gzip)] + #[case::mixed_case("Content-Encoding", "identity", Compression::None)] + #[case::empty_value("content-encoding", "", Compression::None)] + fn default_headers_reject_content_encoding( + #[case] name: &str, + #[case] value: &str, + #[case] compression: Compression, + ) { + let headers = HeaderMap::from_iter([( + name.parse::().unwrap(), + HeaderValue::from_str(value).unwrap(), + )]); + let error = S2Config::new("token") + .with_compression(compression) + .with_default_headers(headers) + .unwrap_err(); + assert!(error.0.contains("Content-Encoding")); + assert!(error.0.contains("with_compression")); + } + + #[cfg(feature = "_hidden")] + #[rstest] + #[case::content_type_s2s("content-type", "s2s/proto")] + #[case::content_type_protobuf("content-type", "application/protobuf")] + #[case::content_type_json("content-type", "application/json")] + #[case::content_type_mixed_case("Content-Type", "s2s/proto")] + #[case::content_type_empty("content-type", "")] + #[case::content_length("content-length", "123")] + #[case::content_length_mixed_case("Content-Length", "0")] + #[case::content_length_empty("content-length", "")] + #[case::transfer_encoding("transfer-encoding", "chunked")] + #[case::transfer_encoding_mixed_case("Transfer-Encoding", "chunked")] + #[case::transfer_encoding_empty("transfer-encoding", "")] + fn default_headers_reject_framing_headers(#[case] name: &str, #[case] value: &str) { + let headers = HeaderMap::from_iter([( + name.parse::().unwrap(), + HeaderValue::from_str(value).unwrap(), + )]); + let error = S2Config::new("token") + .with_default_headers(headers) + .unwrap_err(); + assert!(error.0.contains(&name.to_ascii_lowercase())); + assert!(error.0.contains("framing")); + } + + // -- RetentionPolicy -- + + #[rstest] + #[case::age(RetentionPolicy::Age(3600))] + #[case::infinite(RetentionPolicy::Infinite)] + fn retention_policy_roundtrip(#[case] sdk: RetentionPolicy) { + let api: api::config::RetentionPolicy = sdk.into(); + let back: RetentionPolicy = api.into(); + assert_eq!(back, sdk); + } + + // -- TimestampingMode -- + + #[rstest] + #[case::client_prefer( + TimestampingMode::ClientPrefer, + api::config::TimestampingMode::ClientPrefer + )] + #[case::client_require( + TimestampingMode::ClientRequire, + api::config::TimestampingMode::ClientRequire + )] + #[case::arrival(TimestampingMode::Arrival, api::config::TimestampingMode::Arrival)] + fn timestamping_mode_roundtrip( + #[case] sdk: TimestampingMode, + #[case] expected_api: api::config::TimestampingMode, + ) { + let converted: api::config::TimestampingMode = sdk.into(); + assert_eq!(converted, expected_api); + let back: TimestampingMode = converted.into(); + assert_eq!(back, sdk); + } + + // -- TimestampingConfig -- + + #[test] + fn timestamping_config_roundtrip() { + let sdk = TimestampingConfig { + mode: Some(TimestampingMode::Arrival), + uncapped: Some(true), + }; + let api: api::config::TimestampingConfig = sdk.into(); + let back: TimestampingConfig = api.into(); + assert_eq!(back, sdk); + } + + // -- DeleteOnEmptyConfig -- + + #[test] + fn delete_on_empty_config_roundtrip() { + let sdk = DeleteOnEmptyConfig::new().with_min_age(Duration::from_secs(300)); + let api: api::config::DeleteOnEmptyConfig = sdk.into(); + let back: DeleteOnEmptyConfig = api.into(); + assert_eq!(back, sdk); + } + + // -- StreamConfig -- + + #[test] + fn stream_config_builder_and_roundtrip() { + let sdk = StreamConfig::new() + .with_storage_class("express") + .with_retention_policy(RetentionPolicy::Age(86400)) + .with_timestamping(TimestampingConfig { + mode: Some(TimestampingMode::ClientPrefer), + uncapped: None, + }) + .with_delete_on_empty(DeleteOnEmptyConfig { min_age_secs: 60 }); + let api: api::config::StreamConfig = sdk.clone().into(); + let back: StreamConfig = api.into(); + assert_eq!(back, sdk); + } + + // -- BasinConfig -- + + #[test] + fn basin_config_builder_and_roundtrip() { + let sdk = BasinConfig::new() + .with_default_stream_config(StreamConfig::new().with_storage_class("standard")) + .with_create_stream_on_append(true) + .with_create_stream_on_read(false); + let api: api::config::BasinConfig = sdk.clone().into(); + let back: BasinConfig = api.into(); + assert_eq!(back, sdk); + } + + // -- FencingToken -- + + proptest! { + #[test] + fn fencing_token_parse_accepts_only_within_byte_limit( + token in string_strategy(MAX_FENCING_TOKEN_LENGTH + 8), + ) { + let parsed = token.parse::(); + + if token.len() <= MAX_FENCING_TOKEN_LENGTH { + prop_assert_eq!(parsed.unwrap().to_string(), token); + } else { + prop_assert!(parsed.is_err()); + } + } + } + + // -- StreamPosition -- + + #[test] + fn stream_position_display() { + let pos = StreamPosition { + seq_num: 42, + timestamp: 1700000000, + }; + assert_eq!(pos.to_string(), "seq_num=42, timestamp=1700000000"); + } + + proptest! { + #[test] + fn stream_position_conversions_preserve_values(seq_num in any::(), timestamp in any::()) { + let proto: StreamPosition = api::stream::proto::StreamPosition { + seq_num, + timestamp, + } + .into(); + prop_assert_eq!(proto.seq_num, seq_num); + prop_assert_eq!(proto.timestamp, timestamp); + + let api: StreamPosition = api::stream::StreamPosition { + seq_num, + timestamp, + } + .into(); + prop_assert_eq!(api.seq_num, seq_num); + prop_assert_eq!(api.timestamp, timestamp); + } + } + + // -- Header -- + + proptest! { + #[test] + fn header_proto_roundtrip_preserves_binary_parts( + name in byte_vec_strategy(64), + value in byte_vec_strategy(128), + ) { + let header = Header::new(Bytes::from(name.clone()), Bytes::from(value.clone())); + let proto: api::stream::proto::Header = header.into(); + let back: Header = proto.into(); + + prop_assert_eq!(back.name.as_ref(), name.as_slice()); + prop_assert_eq!(back.value.as_ref(), value.as_slice()); + } + } + + // -- AppendRecord -- + + #[test] + fn append_record_too_large() { + let big_body = vec![0u8; RECORD_BATCH_MAX.bytes + 1]; + assert!(AppendRecord::new(big_body).is_err()); + } + + // -- MeteredBytes -- + + proptest! { + #[test] + fn append_record_preserves_fields_and_metered_byte_formula( + (body, headers) in append_record_parts_strategy(), + timestamp in proptest::option::of(any::()), + ) { + let mut record = AppendRecord::new(body.clone()) + .unwrap() + .with_headers(headers_from_parts(&headers)) + .unwrap(); + if let Some(timestamp) = timestamp { + record = record.with_timestamp(timestamp); + } + + prop_assert_eq!(record.body(), body.as_slice()); + prop_assert_eq!(record.headers().len(), headers.len()); + prop_assert_eq!(record.timestamp(), timestamp); + prop_assert_eq!(record.metered_bytes(), expected_metered_bytes(&body, &headers)); + + for (actual, (expected_name, expected_value)) in record.headers().iter().zip(headers.iter()) { + prop_assert_eq!(actual.name.as_ref(), expected_name.as_slice()); + prop_assert_eq!(actual.value.as_ref(), expected_value.as_slice()); + } + } + } + + // -- AppendRecordBatch -- + + #[test] + fn append_record_batch_empty_is_err() { + let result = AppendRecordBatch::try_from_iter(vec![]); + assert!(result.is_err()); + } + + #[test] + fn append_record_batch_too_many_records() { + let records: Vec<_> = (0..1001).map(|_| AppendRecord::new("x").unwrap()).collect(); + let result = AppendRecordBatch::try_from_iter(records); + assert!(result.is_err()); + } + + proptest! { + #[test] + fn append_record_batch_metered_bytes_is_sum_of_records( + records in prop::collection::vec(append_record_parts_strategy(), 1..=32), + ) { + let expected = records + .iter() + .map(|(body, headers)| expected_metered_bytes(body, headers)) + .sum::(); + let records = records + .into_iter() + .map(|(body, headers)| { + AppendRecord::new(body) + .unwrap() + .with_headers(headers_from_parts(&headers)) + .unwrap() + }) + .collect::>(); + + let batch = AppendRecordBatch::try_from_iter(records).unwrap(); + prop_assert_eq!(batch.metered_bytes(), expected); + prop_assert_eq!(batch.iter().map(MeteredBytes::metered_bytes).sum::(), expected); + } + } + + // -- CommandRecord -- + + #[test] + fn command_record_fence() { + let token: FencingToken = "tok".parse().unwrap(); + let cmd = CommandRecord::fence(token); + let record: AppendRecord = cmd.into(); + assert_eq!(record.headers().len(), 1); + assert_eq!(record.headers()[0].name.as_ref(), b""); + assert_eq!(record.headers()[0].value.as_ref(), b"fence"); + assert_eq!(record.body(), b"tok"); + } + + #[test] + fn command_record_trim() { + let cmd = CommandRecord::trim(42); + let record: AppendRecord = cmd.into(); + assert_eq!(record.headers().len(), 1); + assert_eq!(record.headers()[0].value.as_ref(), b"trim"); + assert_eq!(record.body(), &42u64.to_be_bytes()); + } + + // -- SequencedRecord -- + + #[rstest] + #[case::command(vec![Header::new("", "fence")], true)] + #[case::regular(vec![Header::new("key", "value")], false)] + #[case::no_headers(vec![], false)] + fn sequenced_record_command_detection(#[case] headers: Vec
, #[case] expected: bool) { + let record = SequencedRecord { + seq_num: 0, + body: Bytes::from("data"), + headers, + timestamp: 0, + }; + assert_eq!(record.is_command_record(), expected); + } + + // -- ReadStart -- + + proptest! { + #[test] + fn read_start_to_api_sets_only_selected_position_field( + from in read_from_strategy(), + clamp_to_tail in any::(), + ) { + let (seq_num, timestamp, tail_offset) = match from { + ReadFrom::SeqNum(value) => (Some(value), None, None), + ReadFrom::Timestamp(value) => (None, Some(value), None), + ReadFrom::TailOffset(value) => (None, None, Some(value)), + }; + let api: api::stream::ReadStart = ReadStart::new() + .with_from(from) + .with_clamp_to_tail(clamp_to_tail) + .into(); + + prop_assert_eq!(api.seq_num, seq_num); + prop_assert_eq!(api.timestamp, timestamp); + prop_assert_eq!(api.tail_offset, tail_offset); + prop_assert_eq!(api.clamp, clamp_to_tail.then_some(true)); + } + } + + // -- ReadStop -- + + #[test] + fn read_stop_to_api() { + let stop = ReadStop::new() + .with_limits(ReadLimits::new().with_count(50)) + .with_until(..1000) + .with_wait(30); + let api: api::stream::ReadEnd = stop.into(); + assert_eq!(api.count, Some(50)); + assert_eq!(api.until, Some(1000)); + assert_eq!(api.wait, Some(30)); + } + + // -- Operation roundtrip -- + + #[test] + fn operation_roundtrip_all_variants() { + let variants = [ + Operation::ListBasins, + Operation::CreateBasin, + Operation::GetBasinConfig, + Operation::DeleteBasin, + Operation::ReconfigureBasin, + Operation::ListAccessTokens, + Operation::IssueAccessToken, + Operation::RevokeAccessToken, + Operation::GetAccountMetrics, + Operation::GetBasinMetrics, + Operation::GetStreamMetrics, + Operation::ListStreams, + Operation::CreateStream, + Operation::GetStreamConfig, + Operation::DeleteStream, + Operation::ReconfigureStream, + Operation::CheckTail, + Operation::Append, + Operation::Read, + Operation::Trim, + Operation::Fence, + Operation::ListLocations, + Operation::GetDefaultLocation, + Operation::SetDefaultLocation, + ]; + for op in variants { + let api_op: api::access::Operation = op.into(); + let back: Operation = api_op.into(); + assert_eq!(back, op); + } + } + + // -- MetricUnit -- + + #[test] + fn metric_unit_conversion() { + assert_eq!( + MetricUnit::from(api::metrics::MetricUnit::Bytes), + MetricUnit::Bytes + ); + assert_eq!( + MetricUnit::from(api::metrics::MetricUnit::Operations), + MetricUnit::Operations + ); + } + + // -- AppendAck -- + + proptest! { + #[test] + fn append_ack_from_proto_preserves_present_positions_and_defaults_missing( + start in proptest::option::of(proto_stream_position_strategy()), + end in proptest::option::of(proto_stream_position_strategy()), + tail in proptest::option::of(proto_stream_position_strategy()), + ) { + let expected_start = start.unwrap_or_default(); + let expected_end = end.unwrap_or_default(); + let expected_tail = tail.unwrap_or_default(); + let ack: AppendAck = api::stream::proto::AppendAck { start, end, tail }.into(); + + prop_assert_eq!(ack.start.seq_num, expected_start.seq_num); + prop_assert_eq!(ack.start.timestamp, expected_start.timestamp); + prop_assert_eq!(ack.end.seq_num, expected_end.seq_num); + prop_assert_eq!(ack.end.timestamp, expected_end.timestamp); + prop_assert_eq!(ack.tail.seq_num, expected_tail.seq_num); + prop_assert_eq!(ack.tail.timestamp, expected_tail.timestamp); + } + } + + // -- ReadBatch -- + + #[test] + fn read_batch_from_api() { + let proto_batch = api::stream::proto::ReadBatch { + records: vec![api::stream::proto::SequencedRecord { + seq_num: 0, + body: Bytes::from("hi"), + headers: vec![api::stream::proto::Header { + name: Bytes::from("k"), + value: Bytes::from("v"), + }], + timestamp: 42, + }], + tail: Some(api::stream::proto::StreamPosition { + seq_num: 1, + timestamp: 42, + }), + }; + let batch = ReadBatch::from_api(proto_batch); + assert_eq!(batch.records.len(), 1); + assert_eq!(batch.records[0].seq_num, 0); + assert_eq!(batch.records[0].timestamp, 42); + assert_eq!(batch.records[0].body.as_ref(), b"hi"); + assert_eq!(batch.records[0].headers.len(), 1); + assert_eq!(batch.records[0].headers[0].name.as_ref(), b"k"); + assert_eq!(batch.records[0].headers[0].value.as_ref(), b"v"); + assert_eq!( + batch.tail, + Some(StreamPosition { + seq_num: 1, + timestamp: 42, + }) + ); + } + + // -- CreateBasinInput -- + + #[test] + fn create_basin_input_to_api() { + let name: BasinName = "test-basin-name".parse().unwrap(); + let input = CreateBasinInput::new(name.clone()).with_config(BasinConfig::new()); + let (req, token): (api::basin::CreateBasinRequest, String) = input.into(); + assert_eq!(req.basin, name); + assert!(req.config.is_some()); + assert!(!token.is_empty()); + } + + // -- CreateStreamInput -- + + #[test] + fn create_stream_input_to_api() { + let name: StreamName = "my-stream".parse().unwrap(); + let input = CreateStreamInput::new(name.clone()).with_config(StreamConfig::new()); + let (req, token): (api::stream::CreateStreamRequest, String) = input.into(); + assert_eq!(req.stream, name); + assert!(req.config.is_some()); + assert!(!token.is_empty()); + } + + // -- SequencedRecord from proto -- + + #[test] + fn sequenced_record_from_proto() { + let proto = api::stream::proto::SequencedRecord { + seq_num: 99, + body: Bytes::from("data"), + headers: vec![api::stream::proto::Header { + name: Bytes::from("k"), + value: Bytes::from("v"), + }], + timestamp: 1234, + }; + let record: SequencedRecord = proto.into(); + assert_eq!(record.seq_num, 99); + assert_eq!(record.body.as_ref(), b"data"); + assert_eq!(record.headers.len(), 1); + assert_eq!(record.headers[0].name.as_ref(), b"k"); + assert_eq!(record.headers[0].value.as_ref(), b"v"); + assert_eq!(record.timestamp, 1234); + } + + #[test] + fn http2_config_bounds() { + let config = Http2Config::new() + .with_max_concurrent_requests(Http2Config::MAX_CONCURRENT_REQUESTS) + .unwrap() + .with_stream_receive_window(Http2Config::MAX_RECEIVE_WINDOW) + .unwrap() + .with_connection_receive_window(Http2Config::MIN_CONNECTION_RECEIVE_WINDOW) + .unwrap(); + assert_eq!( + config.max_concurrent_requests, + Some(Http2Config::MAX_CONCURRENT_REQUESTS) + ); + assert_eq!( + config.stream_receive_window, + Some(Http2Config::MAX_RECEIVE_WINDOW) + ); + assert_eq!( + config.connection_receive_window, + Some(Http2Config::MIN_CONNECTION_RECEIVE_WINDOW) + ); + + let partial = Http2Config::new().with_stream_receive_window(1).unwrap(); + assert_eq!(partial.max_concurrent_requests, None); + assert_eq!(partial.connection_receive_window, None); + + assert!(Http2Config::new().with_max_concurrent_requests(0).is_err()); + assert!( + Http2Config::new() + .with_max_concurrent_requests(Http2Config::MAX_CONCURRENT_REQUESTS + 1) + .is_err() + ); + assert!(Http2Config::new().with_stream_receive_window(0).is_err()); + assert!( + Http2Config::new() + .with_stream_receive_window(Http2Config::MAX_RECEIVE_WINDOW + 1) + .is_err() + ); + assert!( + Http2Config::new() + .with_connection_receive_window(Http2Config::MIN_CONNECTION_RECEIVE_WINDOW - 1) + .is_err() + ); + assert!( + Http2Config::new() + .with_connection_receive_window(u32::MAX) + .is_err() + ); + } +} diff --git a/sdk/tests/account_ops.rs b/sdk/tests/account_ops.rs new file mode 100644 index 00000000..4576a36a --- /dev/null +++ b/sdk/tests/account_ops.rs @@ -0,0 +1,542 @@ +mod common; + +use std::time::Duration; + +use assert_matches::assert_matches; +use common::{s2, unique_basin_name, uuid}; +use s2_sdk::{ + error::{RequestError, ServerError}, + types::*, +}; + +#[tokio::test] +async fn create_list_and_delete_basin() -> Result<(), Box> { + let s2 = s2(); + let basin_name = unique_basin_name(); + + let basin_info = s2 + .create_basin(CreateBasinInput::new(basin_name.clone())) + .await?; + + assert_eq!(basin_info.name, basin_name); + assert!(time::OffsetDateTime::from(basin_info.created_at) <= time::OffsetDateTime::now_utc()); + assert!(basin_info.deleted_at.is_none()); + + let page = s2 + .list_basins(ListBasinsInput::new().with_prefix(basin_name.clone().into())) + .await?; + + assert_matches!( + page.values.as_slice(), + [BasinInfo { + name, + location, + deleted_at: None, + .. + }] if name == &basin_info.name && location == &basin_info.location + ); + assert!(!page.has_more); + + s2.delete_basin(DeleteBasinInput::new(basin_name.clone())) + .await?; + + let page = s2 + .list_basins(ListBasinsInput::new().with_prefix(basin_name.clone().into())) + .await?; + + match page.values.as_slice() { + [] => {} + [ + BasinInfo { + name, + location, + deleted_at: Some(_), + .. + }, + ] => { + assert_eq!(name, &basin_info.name); + assert_eq!(location, &basin_info.location); + } + values => panic!("unexpected basin listing after delete: {values:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn basin_config_roundtrip() -> Result<(), Box> { + let s2 = s2(); + let basin_name = unique_basin_name(); + let config = BasinConfig::new() + .with_default_stream_config( + StreamConfig::new() + .with_storage_class("express") + .with_delete_on_empty( + DeleteOnEmptyConfig::new().with_min_age(Duration::from_secs(60)), + ), + ) + .with_create_stream_on_read(true); + + s2.create_basin(CreateBasinInput::new(basin_name.clone()).with_config(config.clone())) + .await?; + + let retrieved_config = s2.get_basin_config(basin_name.clone()).await?; + assert_matches!( + retrieved_config, + BasinConfig { + default_stream_config: Some(StreamConfig { + storage_class: Some(ref storage_class), + delete_on_empty: Some(DeleteOnEmptyConfig { + min_age_secs: 60, + .. + }), + .. + }), + create_stream_on_read: true, + .. + } if storage_class == "express" + ); + + s2.delete_basin(DeleteBasinInput::new(basin_name)).await?; + + Ok(()) +} + +#[tokio::test] +async fn reconfigure_basin() -> Result<(), Box> { + let s2 = s2(); + let basin_name = unique_basin_name(); + + s2.create_basin( + CreateBasinInput::new(basin_name.clone()) + .with_config(BasinConfig::new().with_create_stream_on_append(true)), + ) + .await?; + + let new_config = BasinReconfiguration::new() + .with_default_stream_config(StreamReconfiguration::new().with_storage_class("standard")) + .with_create_stream_on_append(false); + + let updated_config = s2 + .reconfigure_basin(ReconfigureBasinInput::new(basin_name.clone(), new_config)) + .await?; + + assert_matches!( + updated_config, + BasinConfig { + default_stream_config: Some(StreamConfig { + storage_class: Some(ref storage_class), + .. + }), + create_stream_on_append: false, + .. + } if storage_class == "standard" + ); + + s2.delete_basin(DeleteBasinInput::new(basin_name)).await?; + + Ok(()) +} + +#[tokio::test] +async fn ensure_basin_created() -> Result<(), Box> { + let s2 = s2(); + let basin_name = unique_basin_name(); + + let output = s2 + .ensure_basin( + EnsureBasinInput::new(basin_name.clone()) + .with_config(BasinConfig::new().with_create_stream_on_read(true)), + ) + .await?; + + assert_matches!(output, EnsureOutput::Created(info) => { + assert_eq!(basin_name, info.name); + }); + + let config = s2.get_basin_config(basin_name).await?; + + assert!(config.create_stream_on_read); + + Ok(()) +} + +#[tokio::test] +async fn ensure_basin_config_updated() -> Result<(), Box> { + let s2 = s2(); + let basin_name = unique_basin_name(); + + let output = s2 + .ensure_basin( + EnsureBasinInput::new(basin_name.clone()) + .with_config(BasinConfig::new().with_create_stream_on_append(true)), + ) + .await?; + + assert_matches!(output, EnsureOutput::Created(info) => { + assert_eq!(basin_name, info.name); + }); + + let output = s2 + .ensure_basin( + EnsureBasinInput::new(basin_name.clone()) + .with_config(BasinConfig::new().with_create_stream_on_append(false)), + ) + .await?; + + assert_matches!(output, EnsureOutput::ConfigUpdated(_)); + + let updated_config = s2.get_basin_config(basin_name).await?; + + assert!(!updated_config.create_stream_on_append); + + Ok(()) +} + +#[tokio::test] +async fn ensure_basin_config_unchanged() -> Result<(), Box> { + let s2 = s2(); + let basin_name = unique_basin_name(); + + let output = s2 + .ensure_basin(EnsureBasinInput::new(basin_name.clone())) + .await?; + + assert_matches!(output, EnsureOutput::Created(info) => { + assert_eq!(basin_name, info.name); + }); + + let config = s2.get_basin_config(basin_name.clone()).await?; + + let output = s2 + .ensure_basin(EnsureBasinInput::new(basin_name.clone()).with_config(config.clone())) + .await?; + + assert_matches!(output, EnsureOutput::ConfigUnchanged(_)); + + let updated_config = s2.get_basin_config(basin_name).await?; + + assert_eq!(config, updated_config); + + Ok(()) +} + +#[tokio::test] +async fn list_basins_with_limit() -> Result<(), Box> { + let s2 = s2(); + let basin_name_1 = unique_basin_name(); + let basin_name_2 = unique_basin_name(); + + s2.create_basin(CreateBasinInput::new(basin_name_1.clone())) + .await?; + s2.create_basin(CreateBasinInput::new(basin_name_2.clone())) + .await?; + + let page = s2.list_basins(ListBasinsInput::new().with_limit(1)).await?; + + assert_eq!(page.values.len(), 1); + assert!(page.has_more); + + s2.delete_basin(DeleteBasinInput::new(basin_name_1)).await?; + s2.delete_basin(DeleteBasinInput::new(basin_name_2)).await?; + + Ok(()) +} + +#[tokio::test] +async fn list_basins_with_prefix() -> Result<(), Box> { + let s2 = s2(); + + let prefix_1: BasinNamePrefix = uuid().parse().expect("valid basin name prefix"); + let prefix_2: BasinNamePrefix = uuid().parse().expect("valid basin name prefix"); + let basin_name_1: BasinName = format!("{}-a", prefix_1).parse().expect("valid basin name"); + let basin_name_2: BasinName = format!("{}-b", prefix_2).parse().expect("valid basin name"); + + s2.create_basin(CreateBasinInput::new(basin_name_1.clone())) + .await?; + s2.create_basin(CreateBasinInput::new(basin_name_2.clone())) + .await?; + + let page = s2 + .list_basins(ListBasinsInput::new().with_prefix(prefix_1)) + .await?; + + assert_eq!(page.values.len(), 1); + assert_matches!(page.values.first(), Some(b) => { + assert_eq!(b.name, basin_name_1) + }); + + s2.delete_basin(DeleteBasinInput::new(basin_name_1)).await?; + s2.delete_basin(DeleteBasinInput::new(basin_name_2)).await?; + + Ok(()) +} + +#[tokio::test] +async fn list_basins_with_prefix_and_start_after() -> Result<(), Box> { + let s2 = s2(); + + let prefix: BasinNamePrefix = uuid().parse().expect("valid prefix"); + let basin_name_1: BasinName = format!("{}-a", prefix).parse().expect("valid basin name"); + let basin_name_2: BasinName = format!("{}-b", prefix).parse().expect("valid basin name"); + + s2.create_basin(CreateBasinInput::new(basin_name_1.clone())) + .await?; + s2.create_basin(CreateBasinInput::new(basin_name_2.clone())) + .await?; + + let page = s2 + .list_basins( + ListBasinsInput::new() + .with_prefix(prefix) + .with_start_after(basin_name_1.as_ref().parse().expect("valid start after")), + ) + .await?; + + assert_eq!(page.values.len(), 1); + assert_eq!(page.values[0].name, basin_name_2); + + s2.delete_basin(DeleteBasinInput::new(basin_name_1)).await?; + s2.delete_basin(DeleteBasinInput::new(basin_name_2)).await?; + + Ok(()) +} + +#[tokio::test] +async fn delete_nonexistent_basin_errors() -> Result<(), Box> { + let s2 = s2(); + let result = s2 + .delete_basin(DeleteBasinInput::new(unique_basin_name())) + .await; + + assert_matches!( + result, + Err(RequestError::Server(ServerError { code, .. })) => { + assert_eq!(code, "basin_not_found") + } + ); + + Ok(()) +} + +#[tokio::test] +async fn delete_nonexistent_basin_with_ignore() -> Result<(), Box> { + let s2 = s2(); + let result = s2 + .delete_basin(DeleteBasinInput::new(unique_basin_name()).with_ignore_not_found(true)) + .await; + + assert_matches!(result, Ok(())); + + Ok(()) +} + +#[tokio::test] +async fn get_basin_config() -> Result<(), Box> { + let s2 = s2(); + let basin_name = unique_basin_name(); + + let config = BasinConfig::new() + .with_default_stream_config(StreamConfig::new().with_storage_class("express")); + + s2.create_basin(CreateBasinInput::new(basin_name.clone()).with_config(config)) + .await?; + + let retrieved_config = s2.get_basin_config(basin_name.clone()).await?; + + assert_matches!( + retrieved_config.default_stream_config, + Some(StreamConfig { + storage_class: Some(ref storage_class), + .. + }) if storage_class == "express" + ); + + s2.delete_basin(DeleteBasinInput::new(basin_name)).await?; + + Ok(()) +} + +#[tokio::test] +async fn issue_list_and_revoke_access_token() -> Result<(), Box> { + let s2 = s2(); + let token_id: AccessTokenId = uuid().parse().expect("valid token id"); + + let _token = s2 + .issue_access_token(IssueAccessTokenInput::new( + token_id.clone(), + AccessTokenScopeInput::from_op_group_perms(OperationGroupPermissions::read_write_all()), + )) + .await?; + + let page = s2 + .list_access_tokens(ListAccessTokensInput::new().with_prefix(token_id.clone().into())) + .await?; + + assert!(page.values.iter().any(|t| t.id == token_id)); + + s2.revoke_access_token(token_id.clone()).await?; + + let page = s2.list_access_tokens(ListAccessTokensInput::new()).await?; + + assert!(!page.values.iter().any(|t| t.id == token_id)); + + Ok(()) +} + +#[tokio::test] +async fn issue_access_token_with_expiration_and_auto_prefix_streams() +-> Result<(), Box> { + let s2 = s2(); + let token_id: AccessTokenId = uuid().parse().expect("valid token id"); + + let expires_at: S2DateTime = + (time::OffsetDateTime::now_utc() + time::Duration::hours(1)).try_into()?; + + let token = s2 + .issue_access_token( + IssueAccessTokenInput::new( + token_id.clone(), + AccessTokenScopeInput::from_op_group_perms( + OperationGroupPermissions::read_write_all(), + ) + .with_streams(StreamMatcher::Prefix( + "namespace".parse().expect("valid prefix"), + )), + ) + .with_expires_at(expires_at) + .with_auto_prefix_streams(true), + ) + .await?; + + assert!(!token.is_empty()); + + let page = s2.list_access_tokens(ListAccessTokensInput::new()).await?; + + let issued_token = page + .values + .iter() + .find(|t| t.id == token_id) + .expect("token should be present"); + assert_eq!(issued_token.expires_at, Some(expires_at)); + assert!(issued_token.auto_prefix_streams); + + s2.revoke_access_token(token_id).await?; + + Ok(()) +} + +#[tokio::test] +async fn issue_access_token_with_auto_prefix_streams_but_without_prefix_errors() +-> Result<(), Box> { + let s2 = s2(); + let token_id: AccessTokenId = uuid().parse().expect("valid token id"); + + let result = s2 + .issue_access_token( + IssueAccessTokenInput::new( + token_id.clone(), + AccessTokenScopeInput::from_op_group_perms( + OperationGroupPermissions::read_write_all(), + ), + ) + .with_auto_prefix_streams(true), + ) + .await; + + assert_matches!(result, Err(RequestError::Server(ServerError { code, message, .. })) => { + assert_eq!(code, "invalid"); + assert_eq!(message, "Auto prefixing is only allowed for streams with prefix matching"); + }); + Ok(()) +} + +#[tokio::test] +async fn issue_access_token_with_no_permitted_ops_errors() -> Result<(), Box> +{ + let s2 = s2(); + let token_id: AccessTokenId = uuid().parse().expect("valid token id"); + + let result_matches = |result: Result| { + assert_matches!(result, Err(RequestError::Server(ServerError { code, message, .. })) => { + assert_eq!(code, "invalid"); + assert_eq!(message, "Access token permissions cannot be empty"); + }); + }; + + let result = s2 + .issue_access_token(IssueAccessTokenInput::new( + token_id.clone(), + AccessTokenScopeInput::from_op_group_perms(OperationGroupPermissions::new()), + )) + .await; + + result_matches(result); + + let result = s2 + .issue_access_token(IssueAccessTokenInput::new( + token_id.clone(), + AccessTokenScopeInput::from_ops(vec![]), + )) + .await; + + result_matches(result); + + Ok(()) +} + +#[tokio::test] +async fn list_access_tokens_with_limit() -> Result<(), Box> { + let s2 = s2(); + + let page = s2 + .list_access_tokens(ListAccessTokensInput::new().with_limit(1)) + .await?; + + assert_eq!(page.values.len(), 1); + + Ok(()) +} + +#[tokio::test] +async fn list_access_tokens_with_prefix() -> Result<(), Box> { + let s2 = s2(); + let prefix = format!("{}", uuid::Uuid::new_v4().simple()); + let token_id_1: AccessTokenId = format!("{}-a", prefix).parse().expect("valid token id"); + let token_id_2: AccessTokenId = format!("{}-b", prefix).parse().expect("valid token id"); + let token_id_3: AccessTokenId = format!("{}-c", uuid::Uuid::new_v4().simple()) + .parse() + .expect("valid token id"); + + let scope = + AccessTokenScopeInput::from_op_group_perms(OperationGroupPermissions::read_write_all()); + + s2.issue_access_token(IssueAccessTokenInput::new( + token_id_1.clone(), + scope.clone(), + )) + .await?; + s2.issue_access_token(IssueAccessTokenInput::new( + token_id_2.clone(), + scope.clone(), + )) + .await?; + s2.issue_access_token(IssueAccessTokenInput::new(token_id_3.clone(), scope)) + .await?; + + let page = s2 + .list_access_tokens( + ListAccessTokensInput::new().with_prefix(prefix.parse().expect("valid prefix")), + ) + .await?; + + assert_eq!(page.values.len(), 2); + assert!(page.values.iter().any(|t| t.id == token_id_1)); + assert!(page.values.iter().any(|t| t.id == token_id_2)); + + s2.revoke_access_token(token_id_1).await?; + s2.revoke_access_token(token_id_2).await?; + s2.revoke_access_token(token_id_3).await?; + + Ok(()) +} diff --git a/sdk/tests/basin_ops.rs b/sdk/tests/basin_ops.rs new file mode 100644 index 00000000..fc9b2cc4 --- /dev/null +++ b/sdk/tests/basin_ops.rs @@ -0,0 +1,1378 @@ +mod common; + +use std::time::Duration; + +use assert_matches::assert_matches; +use common::{S2Basin, SharedS2Basin, unique_stream_name, uuid}; +use futures_util::StreamExt; +use s2_sdk::{ + error::{RequestError, ServerError}, + types::*, +}; +use test_context::test_context; + +#[test_context(S2Basin)] +#[tokio_shared_rt::test(shared)] +async fn create_list_and_delete_stream(basin: &S2Basin) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + + let stream_info = basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + assert_eq!(stream_info.name, stream_name); + + let page = basin.list_streams(ListStreamsInput::new()).await?; + + assert_eq!(page.values, vec![stream_info]); + assert!(!page.has_more); + + basin + .delete_stream(DeleteStreamInput::new(stream_name.clone())) + .await?; + + let page = basin.list_streams(ListStreamsInput::new()).await?; + + match page.values.as_slice() { + [] => {} + [ + StreamInfo { + name, + deleted_at: Some(_), + .. + }, + ] => { + assert_eq!(name, &stream_name); + } + values => panic!("unexpected stream listing after delete: {values:?}"), + } + + Ok(()) +} + +#[test_context(S2Basin)] +#[tokio_shared_rt::test(shared)] +async fn stream_config_roundtrip(basin: &S2Basin) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + let config = StreamConfig::new() + .with_storage_class("standard") + .with_retention_policy(RetentionPolicy::Age(3600)) + .with_timestamping(TimestampingConfig::new().with_mode(TimestampingMode::ClientRequire)); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(config.clone())) + .await?; + + let retrieved_config = basin.get_stream_config(stream_name.clone()).await?; + + assert_matches!( + retrieved_config, + StreamConfig { + storage_class: Some(ref storage_class), + retention_policy: Some(RetentionPolicy::Age(3600)), + timestamping: Some(TimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + .. + }), + .. + } if storage_class == "standard" + ); + + Ok(()) +} + +#[test_context(S2Basin)] +#[tokio_shared_rt::test(shared)] +async fn reconfigure_stream(basin: &S2Basin) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let new_config = StreamReconfiguration::new().with_delete_on_empty( + DeleteOnEmptyReconfiguration::new().with_min_age(Duration::from_hours(12)), + ); + + let updated_config = basin + .reconfigure_stream(ReconfigureStreamInput::new(stream_name.clone(), new_config)) + .await?; + + assert_matches!( + updated_config.delete_on_empty, + Some(DeleteOnEmptyConfig { + min_age_secs: 43200, + .. + }) + ); + + Ok(()) +} + +#[test_context(S2Basin)] +#[tokio_shared_rt::test(shared)] +async fn ensure_stream_created(basin: &S2Basin) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + + let delete_on_empty_config = DeleteOnEmptyConfig::new().with_min_age(Duration::from_hours(24)); + + let output = basin + .ensure_stream( + EnsureStreamInput::new(stream_name.clone()) + .with_config(StreamConfig::new().with_delete_on_empty(delete_on_empty_config)), + ) + .await?; + + assert_matches!(output, EnsureOutput::Created(info) => { + assert_eq!(stream_name, info.name); + }); + + let config = basin.get_stream_config(stream_name).await?; + + assert_eq!(config.delete_on_empty, Some(delete_on_empty_config)); + + Ok(()) +} + +#[test_context(S2Basin)] +#[tokio_shared_rt::test(shared)] +async fn ensure_stream_config_updated(basin: &S2Basin) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + + let output = basin + .ensure_stream(EnsureStreamInput::new(stream_name.clone()).with_config( + StreamConfig::new().with_timestamping( + TimestampingConfig::new().with_mode(TimestampingMode::ClientRequire), + ), + )) + .await?; + + assert_matches!(output, EnsureOutput::Created(info) => { + assert_eq!(stream_name, info.name); + }); + + let output = + basin + .ensure_stream(EnsureStreamInput::new(stream_name.clone()).with_config( + StreamConfig::new().with_timestamping( + TimestampingConfig::new().with_mode(TimestampingMode::Arrival), + ), + )) + .await?; + + assert_matches!(output, EnsureOutput::ConfigUpdated(_)); + + let updated_config = basin.get_stream_config(stream_name).await?; + + assert_matches!( + updated_config.timestamping, + Some(TimestampingConfig { + mode: Some(TimestampingMode::Arrival), + .. + }) + ); + + Ok(()) +} + +#[test_context(S2Basin)] +#[tokio_shared_rt::test(shared)] +async fn ensure_basin_config_unchanged(basin: &S2Basin) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + + let output = basin + .ensure_stream(EnsureStreamInput::new(stream_name.clone())) + .await?; + + assert_matches!(output, EnsureOutput::Created(info) => { + assert_eq!(stream_name, info.name); + }); + + let config = basin.get_stream_config(stream_name.clone()).await?; + + let output = basin + .ensure_stream(EnsureStreamInput::new(stream_name.clone()).with_config(config.clone())) + .await?; + + assert_matches!(output, EnsureOutput::ConfigUnchanged(_)); + + let updated_config = basin.get_stream_config(stream_name).await?; + + assert_eq!(config, updated_config); + + Ok(()) +} + +#[test_context(S2Basin)] +#[tokio_shared_rt::test(shared)] +async fn list_streams_with_limit(basin: &S2Basin) -> Result<(), RequestError> { + let stream_name_1 = unique_stream_name(); + let stream_name_2 = unique_stream_name(); + let stream_name_3 = unique_stream_name(); + + let stream_info_1 = basin + .create_stream(CreateStreamInput::new(stream_name_1.clone())) + .await?; + + let _stream_info_2 = basin + .create_stream(CreateStreamInput::new(stream_name_2.clone())) + .await?; + let _stream_info_3 = basin + .create_stream(CreateStreamInput::new(stream_name_3.clone())) + .await?; + + let page = basin + .list_streams(ListStreamsInput::new().with_limit(1)) + .await?; + + assert_eq!(page.values, vec![stream_info_1]); + assert!(page.has_more); + + Ok(()) +} + +#[test_context(S2Basin)] +#[tokio_shared_rt::test(shared)] +async fn list_streams_with_prefix(basin: &S2Basin) -> Result<(), RequestError> { + let stream_name_1: StreamName = "users/eu/0001".parse().expect("valid stream name"); + let stream_name_2: StreamName = "users/ca/0001".parse().expect("valid stream name"); + let stream_name_3: StreamName = "users/ca/0002".parse().expect("valid stream name"); + + let _stream_info_1 = basin + .create_stream(CreateStreamInput::new(stream_name_1.clone())) + .await?; + let stream_info_2 = basin + .create_stream(CreateStreamInput::new(stream_name_2.clone())) + .await?; + let stream_info_3 = basin + .create_stream(CreateStreamInput::new(stream_name_3.clone())) + .await?; + + let page = basin + .list_streams( + ListStreamsInput::new().with_prefix("users/ca/".parse().expect("valid prefix")), + ) + .await?; + + assert_eq!(page.values, vec![stream_info_2, stream_info_3]); + assert!(!page.has_more); + + Ok(()) +} + +#[test_context(S2Basin)] +#[tokio_shared_rt::test(shared)] +async fn list_streams_with_start_after(basin: &S2Basin) -> Result<(), RequestError> { + let stream_name_1 = unique_stream_name(); + let stream_name_2 = unique_stream_name(); + + let _stream_info_1 = basin + .create_stream(CreateStreamInput::new(stream_name_1.clone())) + .await?; + let stream_info_2 = basin + .create_stream(CreateStreamInput::new(stream_name_2.clone())) + .await?; + + let page = basin + .list_streams( + ListStreamsInput::new() + .with_start_after(stream_name_1.parse().expect("valid start after")), + ) + .await?; + + assert_eq!(page.values, vec![stream_info_2]); + assert!(!page.has_more); + + Ok(()) +} + +#[test_context(S2Basin)] +#[tokio_shared_rt::test(shared)] +async fn list_streams_with_start_after_returns_empty_page( + basin: &S2Basin, +) -> Result<(), RequestError> { + let stream_name_1 = unique_stream_name(); + let stream_name_2 = unique_stream_name(); + + basin + .create_stream(CreateStreamInput::new(stream_name_1.clone())) + .await?; + basin + .create_stream(CreateStreamInput::new(stream_name_2.clone())) + .await?; + + let page = basin + .list_streams( + ListStreamsInput::new() + .with_start_after(stream_name_2.parse().expect("valid start after")), + ) + .await?; + + assert_eq!(page.values.len(), 0); + assert!(!page.has_more); + + Ok(()) +} + +#[test_context(S2Basin)] +#[tokio_shared_rt::test(shared)] +async fn delete_nonexistent_stream_errors(basin: &S2Basin) -> Result<(), RequestError> { + let result = basin + .delete_stream(DeleteStreamInput::new(unique_stream_name())) + .await; + + assert_matches!( + result, + Err(RequestError::Server(ServerError { code, .. })) => { + assert_eq!(code, "stream_not_found") + } + ); + + Ok(()) +} + +#[test_context(S2Basin)] +#[tokio_shared_rt::test(shared)] +async fn delete_nonexistent_stream_with_ignore(basin: &S2Basin) -> Result<(), RequestError> { + let result = basin + .delete_stream(DeleteStreamInput::new(unique_stream_name()).with_ignore_not_found(true)) + .await; + + assert_matches!(result, Ok(())); + + Ok(()) +} + +#[test_context(S2Basin)] +#[tokio_shared_rt::test(shared)] +async fn get_stream_config(basin: &S2Basin) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + + let config = StreamConfig::new().with_storage_class("express"); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(config)) + .await?; + + let retrieved_config = basin.get_stream_config(stream_name.clone()).await?; + + assert_matches!(retrieved_config.storage_class.as_deref(), Some("express")); + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn list_streams_with_limit_zero(basin: &SharedS2Basin) -> Result<(), RequestError> { + let prefix = format!("limit0-{}", uuid()); + let stream_name: StreamName = format!("{}-0001", prefix) + .parse() + .expect("valid stream name"); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let page = basin + .list_streams( + ListStreamsInput::new() + .with_prefix(prefix.parse().expect("valid prefix")) + .with_limit(0), + ) + .await?; + + assert!(page.values.iter().any(|info| info.name == stream_name)); + assert!(page.values.len() <= 1000); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn list_streams_with_limit_over_max(basin: &SharedS2Basin) -> Result<(), RequestError> { + let prefix = format!("limitmax-{}", uuid()); + let stream_name: StreamName = format!("{}-0001", prefix) + .parse() + .expect("valid stream name"); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let page = basin + .list_streams( + ListStreamsInput::new() + .with_prefix(prefix.parse().expect("valid prefix")) + .with_limit(1500), + ) + .await?; + + assert!(page.values.iter().any(|info| info.name == stream_name)); + assert!(page.values.len() <= 1000); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn list_streams_with_pagination(basin: &SharedS2Basin) -> Result<(), RequestError> { + let prefix = format!("page-{}", uuid()); + let stream_names: Vec = (0..3) + .map(|idx| { + format!("{}-{:04}", prefix, idx) + .parse() + .expect("valid stream name") + }) + .collect(); + + for name in &stream_names { + basin + .create_stream(CreateStreamInput::new(name.clone())) + .await?; + } + + let page_1 = basin + .list_streams( + ListStreamsInput::new() + .with_prefix(prefix.parse().expect("valid prefix")) + .with_limit(2), + ) + .await?; + + assert!(!page_1.values.is_empty()); + + let last_name = page_1 + .values + .last() + .expect("page should have value") + .name + .clone(); + + let page_2 = basin + .list_streams( + ListStreamsInput::new() + .with_prefix(prefix.parse().expect("valid prefix")) + .with_start_after(last_name.clone().into()) + .with_limit(2), + ) + .await?; + + assert!( + page_2 + .values + .iter() + .all(|info| info.name.as_ref() > last_name.as_ref()) + ); + + let mut listed: Vec = page_1 + .values + .into_iter() + .chain(page_2.values.into_iter()) + .map(|info| info.name.to_string()) + .collect(); + listed.sort(); + let mut expected: Vec = stream_names.iter().map(|name| name.to_string()).collect(); + expected.sort(); + assert_eq!(listed, expected); + + for name in stream_names { + let _ = basin.delete_stream(DeleteStreamInput::new(name)).await; + } + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn list_streams_returns_lexicographic_order( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + let prefix = format!("order-{}", uuid()); + let stream_names: Vec = (1..=3) + .map(|idx| { + format!("{}-{:04}", prefix, idx) + .parse() + .expect("valid stream name") + }) + .collect(); + + for name in &stream_names { + basin + .create_stream(CreateStreamInput::new(name.clone())) + .await?; + } + + let page = basin + .list_streams(ListStreamsInput::new().with_prefix(prefix.parse().expect("valid prefix"))) + .await?; + + let listed: Vec = page.values.into_iter().map(|info| info.name).collect(); + assert_eq!(listed, stream_names); + + for name in stream_names { + let _ = basin.delete_stream(DeleteStreamInput::new(name)).await; + } + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn list_all_streams_iterates_with_prefix( + basin: &SharedS2Basin, +) -> Result<(), Box> { + let prefix = format!("iter-{}", uuid()); + let stream_names: Vec = (1..=3) + .map(|idx| { + format!("{}-{:04}", prefix, idx) + .parse() + .expect("valid stream name") + }) + .collect(); + + for name in &stream_names { + basin + .create_stream(CreateStreamInput::new(name.clone())) + .await?; + } + + let mut listed = Vec::new(); + let mut stream = basin.list_all_streams( + ListAllStreamsInput::new().with_prefix(prefix.parse().expect("valid prefix")), + ); + while let Some(info) = stream.next().await { + listed.push(info?.name); + } + + assert_eq!(listed, stream_names); + + for name in stream_names { + let _ = basin.delete_stream(DeleteStreamInput::new(name)).await; + } + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn list_all_streams_include_deleted( + basin: &SharedS2Basin, +) -> Result<(), Box> { + let prefix = format!("iter-del-{}", uuid()); + let stream_name: StreamName = format!("{}-0001", prefix) + .parse() + .expect("valid stream name"); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + basin + .delete_stream(DeleteStreamInput::new(stream_name.clone())) + .await?; + + let mut stream = basin.list_all_streams( + ListAllStreamsInput::new() + .with_prefix(prefix.parse().expect("valid prefix")) + .with_include_deleted(true), + ); + + let mut found = None; + while let Some(info) = stream.next().await { + let info = info?; + if info.name == stream_name { + found = Some(info); + break; + } + } + + if let Some(info) = found { + assert!(info.deleted_at.is_some()); + } + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn create_stream_with_full_config(basin: &SharedS2Basin) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + let config = StreamConfig::new() + .with_storage_class("standard") + .with_retention_policy(RetentionPolicy::Age(86400)) + .with_timestamping( + TimestampingConfig::new() + .with_mode(TimestampingMode::ClientRequire) + .with_uncapped(true), + ) + .with_delete_on_empty(DeleteOnEmptyConfig::new().with_min_age(Duration::from_secs(3600))); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(config)) + .await?; + + let retrieved = basin.get_stream_config(stream_name.clone()).await?; + + assert_matches!( + retrieved, + StreamConfig { + storage_class: Some(ref storage_class), + retention_policy: Some(RetentionPolicy::Age(86400)), + timestamping: Some(TimestampingConfig { + mode: Some(TimestampingMode::ClientRequire), + uncapped: Some(true), + .. + }), + delete_on_empty: Some(DeleteOnEmptyConfig { + min_age_secs: 3600, + .. + }), + .. + } if storage_class == "standard" + ); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn create_stream_storage_class_express(basin: &SharedS2Basin) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + let config = StreamConfig::new().with_storage_class("express"); + + let result = basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(config)) + .await; + + let info = match result { + Ok(info) => info, + Err(err) if is_free_tier_limitation(&err) => return Ok(()), + Err(err) => return Err(err), + }; + + assert_eq!(info.name, stream_name); + + let retrieved = basin.get_stream_config(stream_name.clone()).await?; + assert_matches!(retrieved.storage_class.as_deref(), Some("express") | None); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn create_stream_retention_policy_infinite( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + let config = StreamConfig::new().with_retention_policy(RetentionPolicy::Infinite); + + let result = basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(config)) + .await; + + let info = match result { + Ok(info) => info, + Err(err) if is_free_tier_limitation(&err) => return Ok(()), + Err(err) => return Err(err), + }; + + assert_eq!(info.name, stream_name); + + let retrieved = basin.get_stream_config(stream_name.clone()).await?; + assert_matches!(retrieved.retention_policy, Some(RetentionPolicy::Infinite)); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn create_stream_timestamping_modes(basin: &SharedS2Basin) -> Result<(), RequestError> { + let modes = [ + TimestampingMode::ClientPrefer, + TimestampingMode::ClientRequire, + TimestampingMode::Arrival, + ]; + + for mode in modes { + let stream_name = unique_stream_name(); + let config = + StreamConfig::new().with_timestamping(TimestampingConfig::new().with_mode(mode)); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(config)) + .await?; + + let retrieved = basin.get_stream_config(stream_name.clone()).await?; + match mode { + TimestampingMode::ClientPrefer => { + if let Some(timestamping) = retrieved.timestamping { + assert_matches!( + timestamping.mode, + Some(TimestampingMode::ClientPrefer) | None + ); + } + } + _ => { + assert_matches!( + retrieved.timestamping, + Some(TimestampingConfig { + mode: Some(m), + .. + }) if m == mode + ); + } + } + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + } + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn create_stream_timestamping_uncapped(basin: &SharedS2Basin) -> Result<(), RequestError> { + for uncapped in [true, false] { + let stream_name = unique_stream_name(); + let config = StreamConfig::new() + .with_timestamping(TimestampingConfig::new().with_uncapped(uncapped)); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(config)) + .await?; + + let retrieved = basin.get_stream_config(stream_name.clone()).await?; + let timestamping = retrieved + .timestamping + .expect("explicit uncapped setting should be preserved"); + assert_eq!(timestamping.uncapped, Some(uncapped)); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + } + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn create_stream_delete_on_empty_min_age(basin: &SharedS2Basin) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + let config = StreamConfig::new() + .with_delete_on_empty(DeleteOnEmptyConfig::new().with_min_age(Duration::from_secs(3600))); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(config)) + .await?; + + let retrieved = basin.get_stream_config(stream_name.clone()).await?; + assert_matches!( + retrieved.delete_on_empty, + Some(DeleteOnEmptyConfig { + min_age_secs: 3600, + .. + }) + ); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn create_stream_invalid_retention_age_zero( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + let config = StreamConfig::new().with_retention_policy(RetentionPolicy::Age(0)); + + let result = basin + .create_stream(CreateStreamInput::new(stream_name).with_config(config)) + .await; + + assert_matches!( + result, + Err(RequestError::Server(ServerError { code, .. })) => { + assert_eq!(code, "invalid"); + } + ); + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn get_stream_config_nonexistent_errors(basin: &SharedS2Basin) -> Result<(), RequestError> { + let result = basin.get_stream_config(unique_stream_name()).await; + + assert_matches!( + result, + Err(RequestError::Server(ServerError { code, .. })) => { + assert_eq!(code, "stream_not_found"); + } + ); + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn reconfigure_stream_storage_class_standard( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let config = basin + .reconfigure_stream(ReconfigureStreamInput::new( + stream_name.clone(), + StreamReconfiguration::new().with_storage_class("standard"), + )) + .await?; + + assert_matches!(config.storage_class.as_deref(), Some("standard")); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn reconfigure_stream_storage_class_express( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let result = basin + .reconfigure_stream(ReconfigureStreamInput::new( + stream_name.clone(), + StreamReconfiguration::new().with_storage_class("express"), + )) + .await; + + let config = match result { + Ok(config) => config, + Err(err) if is_free_tier_limitation(&err) => { + let _ = basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await; + return Ok(()); + } + Err(err) => return Err(err), + }; + + assert_matches!(config.storage_class.as_deref(), Some("express") | None); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn reconfigure_stream_retention_policy_age( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let config = basin + .reconfigure_stream(ReconfigureStreamInput::new( + stream_name.clone(), + StreamReconfiguration::new().with_retention_policy(RetentionPolicy::Age(3600)), + )) + .await?; + + assert_matches!(config.retention_policy, Some(RetentionPolicy::Age(3600))); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn reconfigure_stream_retention_policy_infinite( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let result = basin + .reconfigure_stream(ReconfigureStreamInput::new( + stream_name.clone(), + StreamReconfiguration::new().with_retention_policy(RetentionPolicy::Infinite), + )) + .await; + + let config = match result { + Ok(config) => config, + Err(err) if is_free_tier_limitation(&err) => { + let _ = basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await; + return Ok(()); + } + Err(err) => return Err(err), + }; + + assert_matches!(config.retention_policy, Some(RetentionPolicy::Infinite)); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn reconfigure_stream_timestamping_modes(basin: &SharedS2Basin) -> Result<(), RequestError> { + let modes = [ + TimestampingMode::ClientPrefer, + TimestampingMode::ClientRequire, + TimestampingMode::Arrival, + ]; + + for mode in modes { + let stream_name = unique_stream_name(); + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let config = basin + .reconfigure_stream(ReconfigureStreamInput::new( + stream_name.clone(), + StreamReconfiguration::new() + .with_timestamping(TimestampingReconfiguration::new().with_mode(mode)), + )) + .await?; + + match mode { + TimestampingMode::ClientPrefer => { + if let Some(timestamping) = config.timestamping { + assert_matches!( + timestamping.mode, + Some(TimestampingMode::ClientPrefer) | None + ); + } + } + _ => { + assert_matches!( + config.timestamping, + Some(TimestampingConfig { + mode: Some(m), + .. + }) if m == mode + ); + } + } + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + } + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn reconfigure_stream_timestamping_uncapped( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + for uncapped in [true, false] { + let stream_name = unique_stream_name(); + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let config = basin + .reconfigure_stream(ReconfigureStreamInput::new( + stream_name.clone(), + StreamReconfiguration::new() + .with_timestamping(TimestampingReconfiguration::new().with_uncapped(uncapped)), + )) + .await?; + + let timestamping = config + .timestamping + .expect("explicit uncapped setting should be preserved"); + assert_eq!(timestamping.uncapped, Some(uncapped)); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + } + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn reconfigure_stream_delete_on_empty(basin: &SharedS2Basin) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let config = basin + .reconfigure_stream(ReconfigureStreamInput::new( + stream_name.clone(), + StreamReconfiguration::new().with_delete_on_empty( + DeleteOnEmptyReconfiguration::new().with_min_age(Duration::from_secs(3600)), + ), + )) + .await?; + + assert_matches!( + config.delete_on_empty, + Some(DeleteOnEmptyConfig { + min_age_secs: 3600, + .. + }) + ); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn reconfigure_stream_disable_delete_on_empty( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config( + StreamConfig::new().with_delete_on_empty( + DeleteOnEmptyConfig::new().with_min_age(Duration::from_secs(3600)), + ), + )) + .await?; + + let config = basin + .reconfigure_stream(ReconfigureStreamInput::new( + stream_name.clone(), + StreamReconfiguration::new().with_delete_on_empty( + DeleteOnEmptyReconfiguration::new().with_min_age(Duration::from_secs(0)), + ), + )) + .await?; + + assert!( + config.delete_on_empty.is_none() + || config.delete_on_empty == Some(DeleteOnEmptyConfig::new()) + ); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn reconfigure_stream_empty_config_no_change( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + basin + .create_stream( + CreateStreamInput::new(stream_name.clone()).with_config( + StreamConfig::new() + .with_storage_class("standard") + .with_retention_policy(RetentionPolicy::Age(3600)), + ), + ) + .await?; + + let _ = basin + .reconfigure_stream(ReconfigureStreamInput::new( + stream_name.clone(), + StreamReconfiguration::new(), + )) + .await?; + + let retrieved = basin.get_stream_config(stream_name.clone()).await?; + assert_matches!(retrieved.storage_class.as_deref(), Some("standard")); + assert_matches!(retrieved.retention_policy, Some(RetentionPolicy::Age(3600))); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn reconfigure_stream_partial_update(basin: &SharedS2Basin) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + basin + .create_stream( + CreateStreamInput::new(stream_name.clone()).with_config( + StreamConfig::new() + .with_retention_policy(RetentionPolicy::Age(3600)) + .with_timestamping( + TimestampingConfig::new().with_mode(TimestampingMode::ClientPrefer), + ), + ), + ) + .await?; + + let _ = basin + .reconfigure_stream(ReconfigureStreamInput::new( + stream_name.clone(), + StreamReconfiguration::new().with_timestamping( + TimestampingReconfiguration::new().with_mode(TimestampingMode::Arrival), + ), + )) + .await?; + + let retrieved = basin.get_stream_config(stream_name.clone()).await?; + assert_matches!(retrieved.retention_policy, Some(RetentionPolicy::Age(3600))); + assert_matches!( + retrieved.timestamping, + Some(TimestampingConfig { + mode: Some(TimestampingMode::Arrival), + .. + }) + ); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn reconfigure_stream_invalid_retention_age_zero( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let result = basin + .reconfigure_stream(ReconfigureStreamInput::new( + stream_name.clone(), + StreamReconfiguration::new().with_retention_policy(RetentionPolicy::Age(0)), + )) + .await; + + assert_matches!( + result, + Err(RequestError::Server(ServerError { code, .. })) => { + assert_eq!(code, "invalid"); + } + ); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn reconfigure_stream_nonexistent_errors(basin: &SharedS2Basin) -> Result<(), RequestError> { + let result = basin + .reconfigure_stream(ReconfigureStreamInput::new( + unique_stream_name(), + StreamReconfiguration::new().with_storage_class("standard"), + )) + .await; + + assert_matches!( + result, + Err(RequestError::Server(ServerError { code, .. })) => { + assert_eq!(code, "stream_not_found"); + } + ); + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn create_stream_duplicate_name_errors(basin: &SharedS2Basin) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let result = basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await; + + assert_matches!( + result, + Err(RequestError::Server(ServerError { code, .. })) => { + assert_eq!(code, "resource_already_exists"); + } + ); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn delete_stream_already_deleting_is_idempotent( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + basin + .delete_stream(DeleteStreamInput::new(stream_name.clone())) + .await?; + + let result = basin + .delete_stream(DeleteStreamInput::new(stream_name.clone())) + .await; + + match result { + Ok(()) => {} + Err(RequestError::Server(ServerError { code, .. })) if code == "stream_not_found" => {} + Err(err) => return Err(err), + } + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn get_stream_config_for_deleting_stream_errors( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + basin + .delete_stream(DeleteStreamInput::new(stream_name.clone())) + .await?; + + let result = basin.get_stream_config(stream_name.clone()).await; + + assert_matches!( + result, + Err(RequestError::Server(ServerError { code, .. })) => { + assert!(code == "stream_deletion_pending" || code == "stream_not_found"); + } + ); + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn deleted_stream_has_deleted_at_when_listed( + basin: &SharedS2Basin, +) -> Result<(), RequestError> { + let stream_name = unique_stream_name(); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + basin + .delete_stream(DeleteStreamInput::new(stream_name.clone())) + .await?; + + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let page = basin + .list_streams(ListStreamsInput::new().with_prefix(stream_name.clone().into())) + .await?; + + let mut found = false; + for info in page.values { + if info.name == stream_name { + found = true; + if info.deleted_at.is_some() { + return Ok(()); + } + } + } + + if !found { + return Ok(()); + } + if std::time::Instant::now() >= deadline { + break; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + panic!("deleted stream still listed without deleted_at after timeout"); +} + +fn is_free_tier_limitation(err: &RequestError) -> bool { + match err { + RequestError::Server(ServerError { code, message, .. }) if code == "invalid" => { + message.to_lowercase().contains("free tier") + } + _ => false, + } +} diff --git a/sdk/tests/common/mod.rs b/sdk/tests/common/mod.rs new file mode 100644 index 00000000..6c8acac1 --- /dev/null +++ b/sdk/tests/common/mod.rs @@ -0,0 +1,210 @@ +#![allow(dead_code)] +use std::{ + ops::Deref, + sync::{ + Arc, + atomic::{AtomicU32, Ordering}, + }, +}; + +use s2_sdk::types::{ + BasinName, Compression, CreateBasinInput, CreateStreamInput, DeleteBasinInput, + DeleteStreamInput, S2Config, S2Endpoints, StreamName, ValidationError, +}; +use test_context::AsyncTestContext; + +pub struct SharedS2Basin(Arc); + +impl Deref for SharedS2Basin { + type Target = S2Basin; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsyncTestContext for SharedS2Basin { + async fn setup() -> Self { + let mut guard = SHARED_BASIN.lock().await; + SHARED_BASIN_USERS.fetch_add(1, Ordering::SeqCst); + let basin = if let Some(basin) = guard.as_ref() { + basin.clone() + } else { + let config = default_s2_config().expect("valid S2 config"); + let s2 = s2_sdk::S2::new(config.clone()).expect("valid S2"); + let basin_name = unique_basin_name(); + s2.create_basin(CreateBasinInput::new(basin_name.clone())) + .await + .expect("valid BasinInfo"); + let basin = s2.basin(basin_name.clone()); + let basin = Arc::new(S2Basin { + s2, + basin, + basin_name, + }); + *guard = Some(basin.clone()); + basin + }; + SharedS2Basin(basin) + } + + async fn teardown(self) { + let mut guard = SHARED_BASIN.lock().await; + if SHARED_BASIN_USERS.fetch_sub(1, Ordering::SeqCst) == 1 + && let Some(basin) = guard.take() + { + let _ = basin + .s2 + .delete_basin(DeleteBasinInput::new(basin.basin_name.clone())) + .await; + } + } +} + +#[derive(Clone)] +pub struct S2Basin { + s2: s2_sdk::S2, + basin: s2_sdk::S2Basin, + basin_name: BasinName, +} + +impl Deref for S2Basin { + type Target = s2_sdk::S2Basin; + + fn deref(&self) -> &Self::Target { + &self.basin + } +} + +impl S2Basin { + pub fn basin_name(&self) -> &BasinName { + &self.basin_name + } +} + +impl AsyncTestContext for S2Basin { + async fn setup() -> Self { + let config = default_s2_config().expect("valid S2 config"); + let s2 = s2_sdk::S2::new(config.clone()).expect("valid S2"); + let basin_name = unique_basin_name(); + s2.create_basin(CreateBasinInput::new(basin_name.clone())) + .await + .expect("successful creation"); + let basin = s2.basin(basin_name.clone()); + S2Basin { + s2, + basin, + basin_name, + } + } + + async fn teardown(self) -> () { + self.s2 + .delete_basin(DeleteBasinInput::new(self.basin_name.clone())) + .await + .expect("successful deletion") + } +} + +pub struct S2Stream { + basin: SharedS2Basin, + stream: s2_sdk::S2Stream, + stream_name: StreamName, +} + +impl Deref for S2Stream { + type Target = s2_sdk::S2Stream; + + fn deref(&self) -> &Self::Target { + &self.stream + } +} + +impl S2Stream { + pub fn basin_name(&self) -> &BasinName { + self.basin.basin_name() + } + + pub fn stream_name(&self) -> &StreamName { + &self.stream_name + } +} + +impl AsyncTestContext for S2Stream { + async fn setup() -> Self { + let basin = SharedS2Basin::setup().await; + + let stream_name = unique_stream_name(); + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await + .expect("stream creation failed"); + let stream = basin.stream(stream_name.clone()); + Self { + basin, + stream, + stream_name, + } + } + + async fn teardown(self) { + // Just a best effort op to ensure basin teardown always happens. + let _ = self + .basin + .delete_stream(DeleteStreamInput::new(self.stream_name)) + .await; + self.basin.teardown().await; + } +} + +pub fn unique_stream_name() -> StreamName { + use std::sync::LazyLock; + static PREFIX: LazyLock = + LazyLock::new(|| uuid::Uuid::new_v4().simple().to_string()[..8].to_string()); + let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + format!("stream-{}-{:04}", *PREFIX, counter) + .parse() + .expect("valid stream name") +} + +static SHARED_BASIN: tokio::sync::Mutex>> = tokio::sync::Mutex::const_new(None); +static SHARED_BASIN_USERS: AtomicU32 = AtomicU32::new(0); + +static TEST_COUNTER: AtomicU32 = AtomicU32::new(0); + +fn default_s2_config() -> Result { + s2_config(Compression::None) +} + +pub fn s2_config(compression: Compression) -> Result { + let access_token = + std::env::var("S2_ACCESS_TOKEN").map_err(|_| "S2_ACCESS_TOKEN env var not set")?; + let mut config = S2Config::new(access_token); + if std::env::var("S2_ACCOUNT_ENDPOINT").is_ok() && std::env::var("S2_BASIN_ENDPOINT").is_ok() { + config = config.with_endpoints(S2Endpoints::from_env()?) + } + if std::env::var("S2_SSL_NO_VERIFY").is_ok() { + config = config.with_insecure_skip_cert_verification(true); + } + config = config.with_compression(compression); + Ok(config) +} + +pub fn s2() -> s2_sdk::S2 { + let config = default_s2_config().expect("valid S2 config"); + s2_sdk::S2::new(config).expect("valid S2") +} + +pub fn unique_basin_name() -> BasinName { + use std::sync::LazyLock; + static PREFIX: LazyLock = + LazyLock::new(|| uuid::Uuid::new_v4().simple().to_string()[..8].to_string()); + let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + format!("basin-{}-{:04}", *PREFIX, counter) + .parse() + .expect("valid basin name") +} + +pub fn uuid() -> String { + format!("{}", uuid::Uuid::new_v4().simple()) +} diff --git a/sdk/tests/metrics_ops.rs b/sdk/tests/metrics_ops.rs new file mode 100644 index 00000000..d8adf274 --- /dev/null +++ b/sdk/tests/metrics_ops.rs @@ -0,0 +1,823 @@ +mod common; + +use std::{future::Future, time::Duration}; + +use assert_matches::assert_matches; +use common::{S2Stream, s2}; +use s2_sdk::{ + error::{ClientError, RequestError, ServerError}, + types::*, +}; +use test_context::test_context; +use time::OffsetDateTime; + +const METRICS_TIMEOUT: Duration = Duration::from_secs(60); +const FAST_METRICS_POLL_MAX: Duration = Duration::from_secs(120); +const FAST_METRICS_POLL_INTERVAL: Duration = Duration::from_secs(5); +const STORAGE_METRICS_POLL_MAX: Duration = Duration::from_secs(8 * 60); +const STORAGE_METRICS_POLL_INTERVAL: Duration = Duration::from_secs(10); + +fn epoch_range(hours_ago: u32) -> (u32, u32) { + let end = OffsetDateTime::now_utc().unix_timestamp() as u32; + let start = end.saturating_sub(hours_ago * 3600); + (start, end) +} + +fn time_range(hours_ago: u32) -> TimeRange { + let (start, end) = epoch_range(hours_ago); + TimeRange::new(start, end) +} + +fn time_range_and_interval( + hours_ago: u32, + interval: Option, +) -> TimeRangeAndInterval { + let (start, end) = epoch_range(hours_ago); + let range = TimeRangeAndInterval::new(start, end); + match interval { + Some(interval) => range.with_interval(interval), + None => range, + } +} + +fn invalid_time_ranges(now: u32) -> [(u32, u32); 3] { + [ + (now, now.saturating_sub(3600)), + (now.saturating_sub(3600), now.saturating_add(600)), + (now.saturating_sub(40 * 24 * 3600), now), + ] +} + +async fn append_sample(stream: &S2Stream) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "metrics", + )?])?); + stream.append(input).await?; + Ok(()) +} + +async fn read_sample(stream: &S2Stream) -> Result<(), Box> { + let _ = stream + .read( + ReadInput::new() + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_count(1))), + ) + .await?; + Ok(()) +} + +fn metric_has_data(metric: &Metric) -> bool { + match metric { + Metric::Scalar(_) => true, + Metric::Accumulation(acc) => !acc.values.is_empty(), + Metric::Gauge(gauge) => !gauge.values.is_empty(), + Metric::Label(label) => !label.values.is_empty(), + } +} + +fn metrics_have_data(metrics: &[Metric]) -> bool { + !metrics.is_empty() && metrics.iter().any(metric_has_data) +} + +fn long_metrics_enabled() -> bool { + std::env::var("S2_METRICS_LONG").is_ok() +} + +async fn poll_metrics( + timeout: Duration, + interval: Duration, + mut fetch: Fetch, + mut ready: Ready, +) -> Result, RequestError> +where + Fetch: FnMut() -> Fut, + Fut: Future, RequestError>>, + Ready: FnMut(&[Metric]) -> bool, +{ + let deadline = tokio::time::Instant::now() + timeout; + let mut last_err: Option = None; + + loop { + match fetch().await { + Ok(value) => { + if ready(&value) { + return Ok(value); + } + } + Err(err) => { + last_err = Some(err); + } + } + + if tokio::time::Instant::now() >= deadline { + break; + } + + tokio::time::sleep(interval).await; + } + + Err(last_err.unwrap_or_else(|| { + RequestError::Client(ClientError::Other(format!( + "metrics not ready after {}s", + timeout.as_secs() + ))) + })) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn account_metrics_active_basins( + stream: &S2Stream, +) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let long_metrics = long_metrics_enabled(); + let metrics = if long_metrics { + poll_metrics( + STORAGE_METRICS_POLL_MAX, + STORAGE_METRICS_POLL_INTERVAL, + || async { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_account_metrics(GetAccountMetricsInput::new( + AccountMetricSet::ActiveBasins(time_range(1)), + )), + ) + .await + .expect("account metrics request timed out") + }, + |metrics| { + metrics + .iter() + .any(|m| matches!(m, Metric::Label(l) if !l.values.is_empty())) + }, + ) + .await? + } else { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_account_metrics(GetAccountMetricsInput::new( + AccountMetricSet::ActiveBasins(time_range(1)), + )), + ) + .await + .expect("account metrics request timed out")? + }; + + assert!(metrics.iter().all(|m| matches!(m, Metric::Label(_)))); + if long_metrics { + assert!( + metrics + .iter() + .any(|m| matches!(m, Metric::Label(l) if !l.values.is_empty())) + ); + } + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn account_metrics_account_ops_default_interval( + stream: &S2Stream, +) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let metrics = poll_metrics( + FAST_METRICS_POLL_MAX, + FAST_METRICS_POLL_INTERVAL, + || async { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_account_metrics(GetAccountMetricsInput::new( + AccountMetricSet::AccountOps(time_range_and_interval(1, None)), + )), + ) + .await + .expect("account metrics request timed out") + }, + metrics_have_data, + ) + .await?; + + assert!(metrics.iter().all(|m| { + matches!( + m, + Metric::Accumulation(acc) + if acc.unit == MetricUnit::Operations + && acc.interval == TimeseriesInterval::Hour + ) + })); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn account_metrics_account_ops_minute_interval( + stream: &S2Stream, +) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let metrics = poll_metrics( + FAST_METRICS_POLL_MAX, + FAST_METRICS_POLL_INTERVAL, + || async { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_account_metrics(GetAccountMetricsInput::new( + AccountMetricSet::AccountOps(time_range_and_interval( + 1, + Some(TimeseriesInterval::Minute), + )), + )), + ) + .await + .expect("account metrics request timed out") + }, + metrics_have_data, + ) + .await?; + + assert!(metrics.iter().all(|m| { + matches!( + m, + Metric::Accumulation(acc) + if acc.interval == TimeseriesInterval::Minute + ) + })); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn account_metrics_account_ops_hour_interval( + stream: &S2Stream, +) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let metrics = poll_metrics( + FAST_METRICS_POLL_MAX, + FAST_METRICS_POLL_INTERVAL, + || async { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_account_metrics(GetAccountMetricsInput::new( + AccountMetricSet::AccountOps(time_range_and_interval( + 24, + Some(TimeseriesInterval::Hour), + )), + )), + ) + .await + .expect("account metrics request timed out") + }, + metrics_have_data, + ) + .await?; + + assert!(metrics.iter().all(|m| { + matches!( + m, + Metric::Accumulation(acc) + if acc.interval == TimeseriesInterval::Hour + ) + })); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn account_metrics_account_ops_day_interval( + stream: &S2Stream, +) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let metrics = poll_metrics( + FAST_METRICS_POLL_MAX, + FAST_METRICS_POLL_INTERVAL, + || async { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_account_metrics(GetAccountMetricsInput::new( + AccountMetricSet::AccountOps(time_range_and_interval( + 24 * 7, + Some(TimeseriesInterval::Day), + )), + )), + ) + .await + .expect("account metrics request timed out") + }, + metrics_have_data, + ) + .await?; + + assert!(metrics.iter().all(|m| { + matches!( + m, + Metric::Accumulation(acc) + if acc.interval == TimeseriesInterval::Day + ) + })); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn account_metrics_empty_time_range( + stream: &S2Stream, +) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let metrics = tokio::time::timeout( + METRICS_TIMEOUT, + client.get_account_metrics(GetAccountMetricsInput::new(AccountMetricSet::ActiveBasins( + TimeRange::new(0, 3600), + ))), + ) + .await + .expect("account metrics request timed out")?; + + assert!(metrics.iter().all(|m| matches!(m, Metric::Label(_)))); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn basin_metrics_storage(stream: &S2Stream) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let long_metrics = long_metrics_enabled(); + let metrics = if long_metrics { + poll_metrics( + STORAGE_METRICS_POLL_MAX, + STORAGE_METRICS_POLL_INTERVAL, + || async { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_basin_metrics(GetBasinMetricsInput::new( + stream.basin_name().clone(), + BasinMetricSet::Storage(time_range(1)), + )), + ) + .await + .expect("basin metrics request timed out") + }, + metrics_have_data, + ) + .await? + } else { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_basin_metrics(GetBasinMetricsInput::new( + stream.basin_name().clone(), + BasinMetricSet::Storage(time_range(1)), + )), + ) + .await + .expect("basin metrics request timed out")? + }; + + assert!( + metrics + .iter() + .all(|m| { matches!(m, Metric::Gauge(g) if g.unit == MetricUnit::Bytes) }) + ); + if long_metrics { + assert!(metrics_have_data(&metrics)); + } + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn basin_metrics_append_ops(stream: &S2Stream) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let metrics = poll_metrics( + FAST_METRICS_POLL_MAX, + FAST_METRICS_POLL_INTERVAL, + || async { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_basin_metrics(GetBasinMetricsInput::new( + stream.basin_name().clone(), + BasinMetricSet::AppendOps(time_range_and_interval(1, None)), + )), + ) + .await + .expect("basin metrics request timed out") + }, + metrics_have_data, + ) + .await?; + + assert!( + metrics.iter().all(|m| { + matches!(m, Metric::Accumulation(acc) if acc.unit == MetricUnit::Operations) + }) + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn basin_metrics_read_ops(stream: &S2Stream) -> Result<(), Box> { + append_sample(stream).await?; + read_sample(stream).await?; + + let client = s2(); + let metrics = poll_metrics( + FAST_METRICS_POLL_MAX, + FAST_METRICS_POLL_INTERVAL, + || async { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_basin_metrics(GetBasinMetricsInput::new( + stream.basin_name().clone(), + BasinMetricSet::ReadOps(time_range_and_interval(1, None)), + )), + ) + .await + .expect("basin metrics request timed out") + }, + metrics_have_data, + ) + .await?; + + assert!( + metrics.iter().all(|m| { + matches!(m, Metric::Accumulation(acc) if acc.unit == MetricUnit::Operations) + }) + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn basin_metrics_read_throughput( + stream: &S2Stream, +) -> Result<(), Box> { + append_sample(stream).await?; + read_sample(stream).await?; + + let client = s2(); + let metrics = poll_metrics( + FAST_METRICS_POLL_MAX, + FAST_METRICS_POLL_INTERVAL, + || async { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_basin_metrics(GetBasinMetricsInput::new( + stream.basin_name().clone(), + BasinMetricSet::ReadThroughput(time_range_and_interval(1, None)), + )), + ) + .await + .expect("basin metrics request timed out") + }, + metrics_have_data, + ) + .await?; + + assert!( + metrics + .iter() + .all(|m| { matches!(m, Metric::Accumulation(acc) if acc.unit == MetricUnit::Bytes) }) + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn basin_metrics_append_throughput( + stream: &S2Stream, +) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let metrics = poll_metrics( + FAST_METRICS_POLL_MAX, + FAST_METRICS_POLL_INTERVAL, + || async { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_basin_metrics(GetBasinMetricsInput::new( + stream.basin_name().clone(), + BasinMetricSet::AppendThroughput(time_range_and_interval(1, None)), + )), + ) + .await + .expect("basin metrics request timed out") + }, + metrics_have_data, + ) + .await?; + + assert!( + metrics + .iter() + .all(|m| { matches!(m, Metric::Accumulation(acc) if acc.unit == MetricUnit::Bytes) }) + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn basin_metrics_basin_ops(stream: &S2Stream) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let metrics = poll_metrics( + FAST_METRICS_POLL_MAX, + FAST_METRICS_POLL_INTERVAL, + || async { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_basin_metrics(GetBasinMetricsInput::new( + stream.basin_name().clone(), + BasinMetricSet::BasinOps(time_range_and_interval(1, None)), + )), + ) + .await + .expect("basin metrics request timed out") + }, + metrics_have_data, + ) + .await?; + + assert!( + metrics.iter().all(|m| { + matches!(m, Metric::Accumulation(acc) if acc.unit == MetricUnit::Operations) + }) + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn stream_metrics_storage(stream: &S2Stream) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let long_metrics = long_metrics_enabled(); + let metrics = if long_metrics { + poll_metrics( + STORAGE_METRICS_POLL_MAX, + STORAGE_METRICS_POLL_INTERVAL, + || async { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_stream_metrics(GetStreamMetricsInput::new( + stream.basin_name().clone(), + stream.stream_name().clone(), + StreamMetricSet::Storage(time_range(1)), + )), + ) + .await + .expect("stream metrics request timed out") + }, + metrics_have_data, + ) + .await? + } else { + tokio::time::timeout( + METRICS_TIMEOUT, + client.get_stream_metrics(GetStreamMetricsInput::new( + stream.basin_name().clone(), + stream.stream_name().clone(), + StreamMetricSet::Storage(time_range(1)), + )), + ) + .await + .expect("stream metrics request timed out")? + }; + + assert!( + metrics + .iter() + .all(|m| { matches!(m, Metric::Gauge(g) if g.unit == MetricUnit::Bytes) }) + ); + if long_metrics { + assert!(metrics_have_data(&metrics)); + } + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn account_metrics_invalid_time_ranges( + stream: &S2Stream, +) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let now = OffsetDateTime::now_utc().unix_timestamp() as u32; + + for (start, end) in invalid_time_ranges(now) { + let result = tokio::time::timeout( + METRICS_TIMEOUT, + client.get_account_metrics(GetAccountMetricsInput::new( + AccountMetricSet::ActiveBasins(TimeRange::new(start, end)), + )), + ) + .await + .expect("account metrics request timed out"); + + assert_matches!( + result, + Err(RequestError::Server(ServerError { code, .. })) => { + assert_eq!(code, "invalid"); + } + ); + } + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn account_metrics_all_sets(stream: &S2Stream) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let range = time_range(1); + let range_with_interval = time_range_and_interval(1, None); + let sets = [ + AccountMetricSet::ActiveBasins(range), + AccountMetricSet::AccountOps(range_with_interval), + ]; + + for set in sets { + let _ = tokio::time::timeout( + METRICS_TIMEOUT, + client.get_account_metrics(GetAccountMetricsInput::new(set)), + ) + .await + .expect("account metrics request timed out")?; + } + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn basin_metrics_empty_time_range( + stream: &S2Stream, +) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let metrics = tokio::time::timeout( + METRICS_TIMEOUT, + client.get_basin_metrics(GetBasinMetricsInput::new( + stream.basin_name().clone(), + BasinMetricSet::Storage(TimeRange::new(0, 3600)), + )), + ) + .await + .expect("basin metrics request timed out")?; + + assert!(metrics.iter().all(|m| matches!(m, Metric::Gauge(_)))); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn basin_metrics_invalid_time_ranges( + stream: &S2Stream, +) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let now = OffsetDateTime::now_utc().unix_timestamp() as u32; + + for (start, end) in invalid_time_ranges(now) { + let result = tokio::time::timeout( + METRICS_TIMEOUT, + client.get_basin_metrics(GetBasinMetricsInput::new( + stream.basin_name().clone(), + BasinMetricSet::Storage(TimeRange::new(start, end)), + )), + ) + .await + .expect("basin metrics request timed out"); + + assert_matches!( + result, + Err(RequestError::Server(ServerError { code, .. })) => { + assert_eq!(code, "invalid"); + } + ); + } + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn basin_metrics_all_sets(stream: &S2Stream) -> Result<(), Box> { + append_sample(stream).await?; + read_sample(stream).await?; + + let client = s2(); + let range = time_range(1); + let range_with_interval = time_range_and_interval(1, None); + let sets = [ + BasinMetricSet::Storage(range), + BasinMetricSet::AppendOps(range_with_interval), + BasinMetricSet::ReadOps(range_with_interval), + BasinMetricSet::ReadThroughput(range_with_interval), + BasinMetricSet::AppendThroughput(range_with_interval), + BasinMetricSet::BasinOps(range_with_interval), + ]; + + for set in sets { + let _ = tokio::time::timeout( + METRICS_TIMEOUT, + client.get_basin_metrics(GetBasinMetricsInput::new(stream.basin_name().clone(), set)), + ) + .await + .expect("basin metrics request timed out")?; + } + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn stream_metrics_empty_time_range( + stream: &S2Stream, +) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let metrics = tokio::time::timeout( + METRICS_TIMEOUT, + client.get_stream_metrics(GetStreamMetricsInput::new( + stream.basin_name().clone(), + stream.stream_name().clone(), + StreamMetricSet::Storage(TimeRange::new(0, 3600)), + )), + ) + .await + .expect("stream metrics request timed out")?; + + assert!(metrics.iter().all(|m| matches!(m, Metric::Gauge(_)))); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn stream_metrics_invalid_time_ranges( + stream: &S2Stream, +) -> Result<(), Box> { + append_sample(stream).await?; + + let client = s2(); + let now = OffsetDateTime::now_utc().unix_timestamp() as u32; + + for (start, end) in invalid_time_ranges(now) { + let result = tokio::time::timeout( + METRICS_TIMEOUT, + client.get_stream_metrics(GetStreamMetricsInput::new( + stream.basin_name().clone(), + stream.stream_name().clone(), + StreamMetricSet::Storage(TimeRange::new(start, end)), + )), + ) + .await + .expect("stream metrics request timed out"); + + assert_matches!( + result, + Err(RequestError::Server(ServerError { code, .. })) => { + assert_eq!(code, "invalid"); + } + ); + } + + Ok(()) +} diff --git a/sdk/tests/stream_ops.rs b/sdk/tests/stream_ops.rs new file mode 100644 index 00000000..feb7cad7 --- /dev/null +++ b/sdk/tests/stream_ops.rs @@ -0,0 +1,2326 @@ +mod common; + +use std::time::Duration; + +use assert_matches::assert_matches; +use common::{S2Stream, SharedS2Basin, s2, s2_config, unique_basin_name, unique_stream_name}; +use futures_util::{StreamExt, poll}; +use rstest::rstest; +use s2_sdk::{ + append_session::AppendSessionConfig, + batching::{BatchLimits, BatchingConfig}, + error::*, + producer::ProducerConfig, + types::*, +}; +use test_context::test_context; +use time::OffsetDateTime; + +fn now_millis() -> u64 { + (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000) as u64 +} + +fn past_millis(offset_ms: u64) -> u64 { + now_millis().saturating_sub(offset_ms) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn tail_of_new_stream(stream: &S2Stream) -> Result<(), Box> { + let tail = stream.check_tail().await?; + + assert_eq!(tail.seq_num, 0); + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn tail_of_nonexistent_stream_errors( + basin: &SharedS2Basin, +) -> Result<(), Box> { + let stream = basin.stream(unique_stream_name()); + + let result = stream.check_tail().await; + + assert_matches!( + result, + Err(ReadError::Request(RequestError::Server(ServerError { code, .. }))) => { + assert_eq!(code, "stream_not_found"); + } + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn single_append(stream: &S2Stream) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + assert_eq!(ack.tail.seq_num, 1); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn multiple_appends(stream: &S2Stream) -> Result<(), Box> { + let input1 = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + let input2 = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "ipsum", + )?])?); + let input3 = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("dolor")?, + AppendRecord::new("sit")?, + ])?); + + let ack1 = stream.append(input1).await?; + let ack2 = stream.append(input2).await?; + let ack3 = stream.append(input3).await?; + + assert_eq!(ack1.start.seq_num, 0); + assert_eq!(ack1.end.seq_num, 1); + assert_eq!(ack2.start.seq_num, 1); + assert_eq!(ack2.end.seq_num, 2); + assert_eq!(ack3.start.seq_num, 2); + assert_eq!(ack3.end.seq_num, 4); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_with_headers(stream: &S2Stream) -> Result<(), Box> { + let headers = vec![Header::new("key1", "value1"), Header::new("key2", "value2")]; + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )? + .with_headers(headers.clone())?])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + + let batch = stream.read(ReadInput::new()).await?; + + assert_eq!(batch.records.len(), 1); + assert_eq!(batch.records[0].headers.len(), headers.len()); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_with_match_seq_num(stream: &S2Stream) -> Result<(), Box> { + let input1 = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?, + AppendRecord::new("ipsum")?, + ])?) + .with_match_seq_num(0); + + let ack1 = stream.append(input1).await?; + + assert_eq!(ack1.start.seq_num, 0); + assert_eq!(ack1.end.seq_num, 2); + + let input2 = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "dolor", + )?])?) + .with_match_seq_num(2); + + let ack2 = stream.append(input2).await?; + + assert_eq!(ack2.start.seq_num, 2); + assert_eq!(ack2.end.seq_num, 3); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_with_count_limit_partial( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?, + AppendRecord::new("ipsum")?, + AppendRecord::new("dolor")?, + ])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 3); + + let batch = stream + .read( + ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::SeqNum(1))) + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_count(2))), + ) + .await?; + + assert_eq!(batch.records.len(), 2); + assert_eq!(batch.records[0].seq_num, 1); + assert_eq!(batch.records[0].body, "ipsum"); + assert_eq!(batch.records[1].seq_num, 2); + assert_eq!(batch.records[1].body, "dolor"); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_with_count_limit_exact(stream: &S2Stream) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?, + AppendRecord::new("ipsum")?, + AppendRecord::new("dolor")?, + ])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 3); + + let batch = stream + .read( + ReadInput::new() + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_count(3))), + ) + .await?; + + assert_eq!(batch.records.len(), 3); + assert_eq!(batch.records[0].seq_num, 0); + assert_eq!(batch.records[0].body, "lorem"); + assert_eq!(batch.records[2].seq_num, 2); + assert_eq!(batch.records[2].body, "dolor"); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_with_count_limit_exceeds( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?, + AppendRecord::new("ipsum")?, + ])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 2); + + let batch = stream + .read( + ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::SeqNum(0))) + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_count(5))), + ) + .await?; + + assert_eq!(batch.records.len(), 2); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_with_count_over_max_clamps( + stream: &S2Stream, +) -> Result<(), Box> { + let records = (0..1000) + .map(|i| AppendRecord::new(format!("record-{i}"))) + .collect::, _>>()?; + let input = AppendInput::new(AppendRecordBatch::try_from_iter(records)?); + stream.append(input).await?; + + let records = (0..5) + .map(|i| AppendRecord::new(format!("tail-{i}"))) + .collect::, _>>()?; + let input = AppendInput::new(AppendRecordBatch::try_from_iter(records)?); + stream.append(input).await?; + + let batch = stream + .read( + ReadInput::new() + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_count(2000))), + ) + .await?; + + assert_eq!(batch.records.len(), 1000); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_with_bytes_limit_partial( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?, + AppendRecord::new("ipsum")?, + AppendRecord::new("dolor")?, + ])?); + let bytes_limit = input.records.metered_bytes() - 5; + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 3); + + let batch = stream + .read( + ReadInput::new() + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_bytes(bytes_limit))), + ) + .await?; + + assert_eq!(batch.records.len(), 2); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_with_bytes_limit_exact(stream: &S2Stream) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + let bytes_limit = input.records.metered_bytes(); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let batch = stream + .read( + ReadInput::new() + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_bytes(bytes_limit))), + ) + .await?; + + assert_eq!(batch.records.len(), 1); + assert_eq!(batch.records[0].body, "lorem"); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_with_bytes_limit_exceeds( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?, + AppendRecord::new("ipsum")?, + ])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 2); + + let batch = stream + .read( + ReadInput::new() + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_bytes(1000))), + ) + .await?; + + assert_eq!(batch.records.len(), 2); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_with_bytes_over_max_clamps( + stream: &S2Stream, +) -> Result<(), Box> { + let body = "a".repeat(700_000); + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + body.clone(), + )?])?); + stream.append(input).await?; + + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + body, + )?])?); + stream.append(input).await?; + + let batch = + stream + .read(ReadInput::new().with_stop( + ReadStop::new().with_limits(ReadLimits::new().with_bytes(2 * 1024 * 1024)), + )) + .await?; + + let read_bytes: usize = batch.records.iter().map(|r| r.metered_bytes()).sum(); + assert!(read_bytes <= 1024 * 1024); + assert_eq!(batch.records.len(), 1); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_from_seq_num_with_bytes_limit( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?, + AppendRecord::new("ipsum")?, + AppendRecord::new("dolor")?, + AppendRecord::new("sit")?, + ])?); + let bytes_limit = input.records[1].metered_bytes() + input.records[2].metered_bytes(); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 4); + + let batch = stream + .read( + ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::SeqNum(1))) + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_bytes(bytes_limit))), + ) + .await?; + + assert_eq!(batch.records.len(), 2); + assert_eq!(batch.records[0].seq_num, 1); + assert_eq!(batch.records[0].body, "ipsum"); + assert_eq!(batch.records[1].seq_num, 2); + assert_eq!(batch.records[1].body, "dolor"); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_with_zero_bytes_limit(stream: &S2Stream) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let batch = stream + .read( + ReadInput::new() + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_bytes(0))), + ) + .await?; + + assert_eq!(batch.records.len(), 0); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_with_unbounded_limit(stream: &S2Stream) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?, + AppendRecord::new("ipsum")?, + ])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 2); + + let batch = stream + .read(ReadInput::new().with_stop(ReadStop::new().with_limits(ReadLimits::new()))) + .await?; + + assert_eq!(batch.records.len(), 2); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_empty_body_record(stream: &S2Stream) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new("")?])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_with_mismatched_seq_num_errors( + stream: &S2Stream, +) -> Result<(), Box> { + let input1 = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + + let ack = stream.append(input1).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let input2 = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "ipsum", + )?])?) + .with_match_seq_num(0); + + let result = stream.append(input2).await; + + assert_matches!( + result, + Err(AppendError::ConditionFailed( + AppendConditionFailed::SeqNumMismatch(1) + )) + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_session_with_mismatched_seq_num_errors( + stream: &S2Stream, +) -> Result<(), Box> { + let session = stream.append_session(AppendSessionConfig::new()); + + let input1 = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + + let ack = session.submit(input1).await?.await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let input2 = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "ipsum", + )?])?) + .with_match_seq_num(0); + + let result = session.submit(input2).await?.await; + + assert_matches!( + result, + Err(AppendSessionError::Append(AppendError::ConditionFailed( + AppendConditionFailed::SeqNumMismatch(1) + ))) + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_with_matching_fencing_token_succeeds( + stream: &S2Stream, +) -> Result<(), Box> { + let fencing_token = FencingToken::generate(30).expect("valid fencing token"); + let input = AppendInput::new(AppendRecordBatch::try_from_iter([CommandRecord::fence( + fencing_token.clone(), + ) + .into()])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "ipsum", + )?])?) + .with_fencing_token(fencing_token); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 1); + assert_eq!(ack.end.seq_num, 2); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_with_mismatched_fencing_token_errors( + stream: &S2Stream, +) -> Result<(), Box> { + let fencing_token_1 = FencingToken::generate(30).expect("valid fencing token"); + let input1 = AppendInput::new(AppendRecordBatch::try_from_iter([CommandRecord::fence( + fencing_token_1.clone(), + ) + .into()])?); + + let ack = stream.append(input1).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let fencing_token_2 = FencingToken::generate(30).expect("valid fencing token"); + let input2 = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "ipsum", + )?])?) + .with_fencing_token(fencing_token_2); + + let result = stream.append(input2).await; + + assert_matches!( + result, + Err(AppendError::ConditionFailed(AppendConditionFailed::FencingTokenMismatch(fencing_token))) => { + assert_eq!(fencing_token, fencing_token_1) + } + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_session_with_mismatched_fencing_token_errors( + stream: &S2Stream, +) -> Result<(), Box> { + let session = stream.append_session(AppendSessionConfig::new()); + + let fencing_token_1 = FencingToken::generate(30).expect("valid fencing token"); + let input1 = AppendInput::new(AppendRecordBatch::try_from_iter([CommandRecord::fence( + fencing_token_1.clone(), + ) + .into()])?); + + let ack = session.submit(input1).await?.await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let fencing_token_2 = FencingToken::generate(30).expect("valid fencing token"); + let input2 = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "ipsum", + )?])?) + .with_fencing_token(fencing_token_2); + + let result = session.submit(input2).await?.await; + + assert_matches!( + result, + Err(AppendSessionError::Append(AppendError::ConditionFailed(AppendConditionFailed::FencingTokenMismatch(fencing_token)))) => { + assert_eq!(fencing_token, fencing_token_1) + } + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_empty_stream_errors(stream: &S2Stream) -> Result<(), Box> { + let result = stream.read(ReadInput::new()).await; + + assert_matches!( + result, + Err(ReadError::ReadUnwritten(StreamPosition { + seq_num: 0, + timestamp: 0, + .. + })) + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_beyond_tail_errors(stream: &S2Stream) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let result = stream + .read(ReadInput::new().with_start(ReadStart::new().with_from(ReadFrom::SeqNum(10)))) + .await; + + assert_matches!( + result, + Err(ReadError::ReadUnwritten(StreamPosition { seq_num: 1, .. })) + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_beyond_tail_with_clamp_to_tail_errors( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let result = stream + .read( + ReadInput::new().with_start( + ReadStart::new() + .with_from(ReadFrom::SeqNum(10)) + .with_clamp_to_tail(true), + ), + ) + .await; + + assert_matches!( + result, + Err(ReadError::ReadUnwritten(StreamPosition { seq_num: 1, .. })) + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_beyond_tail_with_clamp_to_tail_and_wait_returns_empty_batch( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let batch = stream + .read( + ReadInput::new() + .with_start( + ReadStart::new() + .with_from(ReadFrom::SeqNum(10)) + .with_clamp_to_tail(true), + ) + .with_stop(ReadStop::new().with_wait(1)), + ) + .await?; + + assert!(batch.records.is_empty()); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_session_beyond_tail_errors( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let result = stream + .read_session( + ReadInput::new().with_start(ReadStart::new().with_from(ReadFrom::SeqNum(10))), + ReadSessionConfig::default(), + ) + .await; + + assert!(result.is_err()); + assert_matches!( + result.err().expect("should be err"), + ReadSessionError::Read(ReadError::ReadUnwritten(StreamPosition { seq_num: 1, .. })) + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_session_beyond_tail_with_clamp_to_tail( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let mut session = stream + .read_session( + ReadInput::new().with_start( + ReadStart::new() + .with_from(ReadFrom::SeqNum(10)) + .with_clamp_to_tail(true), + ), + ReadSessionConfig::default(), + ) + .await?; + + assert_eq!(session.resume_seq_num(), None); + let result = tokio::time::timeout(Duration::from_secs(1), session.next()).await; + assert_matches!(result, Err(tokio::time::error::Elapsed { .. })); + assert_eq!(session.resume_seq_num(), Some(ack.tail.seq_num)); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_session_reports_caught_up_after_tail_delivery( + stream: &S2Stream, +) -> Result<(), Box> { + let ack = stream + .append(AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("first")?, + AppendRecord::new("second")?, + ])?)) + .await?; + let mut session = stream + .read_session( + ReadInput::new().with_start(ReadStart::new().with_from(ReadFrom::TailOffset(2))), + ReadSessionConfig::default(), + ) + .await?; + let mut caught_up = session.caught_up(); + let mut seq_nums = Vec::new(); + + assert!(!session.is_caught_up()); + assert_eq!(session.resume_seq_num(), None); + let caught_up_tail = tokio::time::timeout(Duration::from_secs(30), async { + loop { + tokio::select! { + tail = &mut caught_up => break tail, + batch = session.next() => { + let batch = batch.expect("session should reach the tail")?; + seq_nums.extend(batch.records.into_iter().map(|record| record.seq_num)); + } + } + } + }) + .await + .expect("session should reach the tail within 30 seconds")?; + + assert!(session.is_caught_up()); + assert_eq!(seq_nums, [ack.tail.seq_num - 2, ack.tail.seq_num - 1]); + assert_eq!(caught_up_tail, ack.tail); + assert_eq!(session.resume_seq_num(), Some(ack.tail.seq_num)); + + let next_ack = stream + .append(AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("third")?, + ])?)) + .await?; + let next_batch = tokio::time::timeout(Duration::from_secs(30), session.next()) + .await + .expect("session should keep reading after catching up") + .expect("session should remain open")?; + + assert_eq!( + next_batch + .records + .iter() + .map(|record| record.seq_num) + .collect::>(), + [ack.tail.seq_num] + ); + assert!(session.is_caught_up()); + assert_eq!(next_batch.tail, Some(next_ack.tail)); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_with_empty_header_value( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )? + .with_headers([Header::new("key1", ""), Header::new("key2", "")])?])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let batch = stream.read(ReadInput::new()).await?; + + assert_eq!(batch.records.len(), 1); + assert_eq!(batch.records[0].headers.len(), 2); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_mixed_records_with_and_without_headers( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?.with_headers([Header::new("key1", "value1")])?, + AppendRecord::new("ipsum")? + .with_headers([Header::new("key2", ""), Header::new("key3", "value3")])?, + AppendRecord::new("dolor")?, + ])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 3); + + let batch = stream.read(ReadInput::new()).await?; + + assert_eq!(batch.records.len(), 3); + assert_eq!(batch.records[0].headers.len(), 1); + assert_eq!(batch.records[1].headers.len(), 2); + assert_eq!(batch.records[2].headers.len(), 0); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn check_tail_after_multiple_appends( + stream: &S2Stream, +) -> Result<(), Box> { + let input1 = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + + let ack1 = stream.append(input1).await?; + + assert_eq!(ack1.start.seq_num, 0); + assert_eq!(ack1.end.seq_num, 1); + + let tail1 = stream.check_tail().await?; + assert_eq!(tail1.seq_num, 1); + + let input2 = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("ipsum")?, + AppendRecord::new("dolor")?, + ])?); + + let ack2 = stream.append(input2).await?; + + assert_eq!(ack2.start.seq_num, 1); + assert_eq!(ack2.end.seq_num, 3); + + let tail2 = stream.check_tail().await?; + assert_eq!(tail2.seq_num, 3); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_from_timestamp(stream: &S2Stream) -> Result<(), Box> { + let base_timestamp = past_millis(10_000); + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?.with_timestamp(base_timestamp), + AppendRecord::new("ipsum")?.with_timestamp(base_timestamp + 1), + AppendRecord::new("dolor")?.with_timestamp(base_timestamp + 2), + AppendRecord::new("sit")?.with_timestamp(base_timestamp + 3), + ])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.start.timestamp, base_timestamp); + assert_eq!(ack.end.seq_num, 4); + assert_eq!(ack.end.timestamp, base_timestamp + 3); + + let batch = stream + .read( + ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::Timestamp(base_timestamp + 1))), + ) + .await?; + + assert_eq!(batch.records.len(), 3); + assert_eq!(batch.records[0].seq_num, 1); + assert_eq!(batch.records[0].timestamp, base_timestamp + 1); + assert_eq!(batch.records[1].seq_num, 2); + assert_eq!(batch.records[1].timestamp, base_timestamp + 2); + assert_eq!(batch.records[2].seq_num, 3); + assert_eq!(batch.records[2].timestamp, base_timestamp + 3); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_from_timestamp_with_count_limit( + stream: &S2Stream, +) -> Result<(), Box> { + let base_timestamp = past_millis(10_000); + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?.with_timestamp(base_timestamp), + AppendRecord::new("ipsum")?.with_timestamp(base_timestamp + 1), + AppendRecord::new("dolor")?.with_timestamp(base_timestamp + 2), + AppendRecord::new("sit")?.with_timestamp(base_timestamp + 3), + ])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.start.timestamp, base_timestamp); + assert_eq!(ack.end.seq_num, 4); + assert_eq!(ack.end.timestamp, base_timestamp + 3); + + let batch = stream + .read( + ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::Timestamp(base_timestamp + 2))) + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_count(1))), + ) + .await?; + + assert_eq!(batch.records.len(), 1); + assert_eq!(batch.records[0].seq_num, 2); + assert_eq!(batch.records[0].timestamp, base_timestamp + 2); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_from_timestamp_with_bytes_limit( + stream: &S2Stream, +) -> Result<(), Box> { + let base_timestamp = past_millis(10_000); + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?.with_timestamp(base_timestamp), + AppendRecord::new("ipsum")?.with_timestamp(base_timestamp + 1), + AppendRecord::new("dolor")?.with_timestamp(base_timestamp + 2), + AppendRecord::new("sit")?.with_timestamp(base_timestamp + 3), + ])?); + let bytes_limit = input.records[1].metered_bytes() + 5; + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.start.timestamp, base_timestamp); + assert_eq!(ack.end.seq_num, 4); + assert_eq!(ack.end.timestamp, base_timestamp + 3); + + let batch = stream + .read( + ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::Timestamp(base_timestamp + 1))) + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_bytes(bytes_limit))), + ) + .await?; + + assert_eq!(batch.records.len(), 1); + assert_eq!(batch.records[0].seq_num, 1); + assert_eq!(batch.records[0].timestamp, base_timestamp + 1); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_from_timestamp_in_future_errors( + stream: &S2Stream, +) -> Result<(), Box> { + let base_timestamp = past_millis(10_000); + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?.with_timestamp(base_timestamp), + AppendRecord::new("ipsum")?.with_timestamp(base_timestamp + 1), + ])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.start.timestamp, base_timestamp); + assert_eq!(ack.end.seq_num, 2); + assert_eq!(ack.end.timestamp, base_timestamp + 1); + + let result = stream + .read( + ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::Timestamp(base_timestamp + 100))), + ) + .await; + + assert_matches!(result, Err(ReadError::ReadUnwritten(tail)) => { + assert_eq!(tail.seq_num, 2); + assert_eq!(tail.timestamp, base_timestamp + 1); + }); + + Ok(()) +} + +#[test] +fn append_record_batch_rejects_empty() { + let result = AppendRecordBatch::try_from_iter(std::iter::empty::()); + + assert_matches!(result, Err(ValidationError(msg)) => { + assert!(msg.contains("batch is empty")); + }); +} + +#[test] +fn append_record_batch_rejects_too_many_records() { + let records = (0..1001).map(|_| AppendRecord::new("a").expect("valid record")); + let result = AppendRecordBatch::try_from_iter(records); + + assert_matches!(result, Err(ValidationError(msg)) => { + assert!(msg.contains("number of records")); + }); +} + +#[test] +fn append_record_rejects_too_large() { + let body = "a".repeat(1024 * 1024 + 1); + let result = AppendRecord::new(body); + + assert_matches!(result, Err(ValidationError(msg)) => { + assert!(msg.contains("metered_bytes")); + }); +} + +#[test] +fn fencing_token_rejects_too_long() { + let result: Result = "a".repeat(37).parse(); + + assert!(result.is_err()); +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_max_batch_size(stream: &S2Stream) -> Result<(), Box> { + let records = (0..1000) + .map(|_| AppendRecord::new("a")) + .collect::, _>>()?; + let input = AppendInput::new(AppendRecordBatch::try_from_iter(records)?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1000); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_with_client_timestamp(stream: &S2Stream) -> Result<(), Box> { + let timestamp = past_millis(1_000); + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )? + .with_timestamp(timestamp)])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.timestamp, timestamp); + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn append_without_timestamp_client_require_errors( + basin: &SharedS2Basin, +) -> Result<(), Box> { + let stream_name = unique_stream_name(); + let config = StreamConfig::new() + .with_timestamping(TimestampingConfig::new().with_mode(TimestampingMode::ClientRequire)); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(config)) + .await?; + + let stream = basin.stream(stream_name.clone()); + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + + let result = stream.append(input).await; + + assert_matches!( + result, + Err(AppendError::Request(RequestError::Server(ServerError { code, .. }))) => { + assert_eq!(code, "invalid"); + } + ); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn append_with_future_timestamp_uncapped_false_caps( + basin: &SharedS2Basin, +) -> Result<(), Box> { + let stream_name = unique_stream_name(); + let config = + StreamConfig::new().with_timestamping(TimestampingConfig::new().with_uncapped(false)); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(config)) + .await?; + + let stream = basin.stream(stream_name.clone()); + let now = now_millis(); + let future = now + 3_600_000; + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )? + .with_timestamp(future)])?); + + let ack = stream.append(input).await?; + + assert!(ack.start.timestamp < future); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn append_with_future_timestamp_uncapped_true_preserves( + basin: &SharedS2Basin, +) -> Result<(), Box> { + let stream_name = unique_stream_name(); + let config = + StreamConfig::new().with_timestamping(TimestampingConfig::new().with_uncapped(true)); + + basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(config)) + .await?; + + let stream = basin.stream(stream_name.clone()); + let now = now_millis(); + let future = now + 3_600_000; + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )? + .with_timestamp(future)])?); + + let ack = stream.append(input).await?; + + assert_eq!(ack.start.timestamp, future); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_with_past_timestamp_adjusts_monotonic( + stream: &S2Stream, +) -> Result<(), Box> { + let base = past_millis(10_000); + let first_timestamp = base + 10; + let past_timestamp = base; + + let input_1 = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "first", + )? + .with_timestamp(first_timestamp)])?); + let ack_1 = stream.append(input_1).await?; + + let input_2 = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "second", + )? + .with_timestamp(past_timestamp)])?); + let ack_2 = stream.append(input_2).await?; + + assert!(ack_2.start.timestamp >= ack_1.end.timestamp); + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn append_to_nonexistent_stream_errors( + basin: &SharedS2Basin, +) -> Result<(), Box> { + let stream = basin.stream(unique_stream_name()); + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + + let result = stream.append(input).await; + + assert_matches!( + result, + Err(AppendError::Request(RequestError::Server(ServerError { code, .. }))) => { + assert_eq!(code, "stream_not_found"); + } + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_invalid_command_header_errors( + stream: &S2Stream, +) -> Result<(), Box> { + let record = AppendRecord::new("lorem")?.with_headers([Header::new("", "not-a-command")])?; + let input = AppendInput::new(AppendRecordBatch::try_from_iter([record])?); + + let result = stream.append(input).await; + + assert_matches!( + result, + Err(AppendError::Request(RequestError::Server(ServerError { code, .. }))) => { + assert_eq!(code, "invalid"); + } + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_invalid_command_header_with_extra_headers_errors( + stream: &S2Stream, +) -> Result<(), Box> { + let record = AppendRecord::new("lorem")? + .with_headers([Header::new("", "fence"), Header::new("extra", "value")])?; + let input = AppendInput::new(AppendRecordBatch::try_from_iter([record])?); + + let result = stream.append(input).await; + + assert_matches!( + result, + Err(AppendError::Request(RequestError::Server(ServerError { code, .. }))) => { + assert_eq!(code, "invalid"); + } + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn fence_set_and_clear_token(stream: &S2Stream) -> Result<(), Box> { + let token = FencingToken::generate(30).expect("valid fencing token"); + let set_input = AppendInput::new(AppendRecordBatch::try_from_iter([CommandRecord::fence( + token.clone(), + ) + .into()])?); + let ack_1 = stream.append(set_input).await?; + + assert_eq!(ack_1.start.seq_num, 0); + + let clear_token: FencingToken = "".parse().expect("valid fencing token"); + let clear_input = AppendInput::new(AppendRecordBatch::try_from_iter([CommandRecord::fence( + clear_token, + ) + .into()])?); + let ack_2 = stream.append(clear_input).await?; + + assert_eq!(ack_2.start.seq_num, 1); + + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + let ack_3 = stream.append(input).await?; + + assert_eq!(ack_3.start.seq_num, 2); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn trim_command_is_accepted(stream: &S2Stream) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("record-1")?, + AppendRecord::new("record-2")?, + AppendRecord::new("record-3")?, + ])?); + let _ = stream.append(input).await?; + + let trim_input = AppendInput::new(AppendRecordBatch::try_from_iter([ + CommandRecord::trim(2).into() + ])?); + let ack = stream.append(trim_input).await?; + + assert!(ack.end.seq_num > 0); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn trim_to_future_seq_num_noop(stream: &S2Stream) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "record-1", + )?])?); + let _ = stream.append(input).await?; + + let trim_input = AppendInput::new(AppendRecordBatch::try_from_iter([CommandRecord::trim( + 999_999, + ) + .into()])?); + let ack = stream.append(trim_input).await?; + + assert!(ack.end.seq_num > 0); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_empty_stream_with_wait_returns_empty( + stream: &S2Stream, +) -> Result<(), Box> { + let batch = stream + .read(ReadInput::new().with_stop(ReadStop::new().with_wait(1))) + .await?; + + assert!(batch.records.is_empty()); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_with_count_zero_returns_empty( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?, + AppendRecord::new("ipsum")?, + ])?); + let _ = stream.append(input).await?; + + let batch = stream + .read( + ReadInput::new() + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_count(0))), + ) + .await?; + + assert!(batch.records.is_empty()); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_with_bytes_zero_returns_empty( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?, + AppendRecord::new("ipsum")?, + ])?); + let _ = stream.append(input).await?; + + let batch = stream + .read( + ReadInput::new() + .with_stop(ReadStop::new().with_limits(ReadLimits::new().with_bytes(0))), + ) + .await?; + + assert!(batch.records.is_empty()); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_from_tail_offset_variants( + stream: &S2Stream, +) -> Result<(), Box> { + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("record-0")?, + AppendRecord::new("record-1")?, + AppendRecord::new("record-2")?, + AppendRecord::new("record-3")?, + AppendRecord::new("record-4")?, + ])?); + let _ = stream.append(input).await?; + + let result = stream + .read(ReadInput::new().with_start(ReadStart::new().with_from(ReadFrom::TailOffset(0)))) + .await; + assert_matches!(result, Err(ReadError::ReadUnwritten(StreamPosition { .. }))); + + let batch = stream + .read(ReadInput::new().with_start(ReadStart::new().with_from(ReadFrom::TailOffset(3)))) + .await?; + assert_eq!(batch.records.len(), 3); + assert_eq!(batch.records[0].seq_num, 2); + + let batch = stream + .read(ReadInput::new().with_start(ReadStart::new().with_from(ReadFrom::TailOffset(999)))) + .await?; + assert_eq!(batch.records[0].seq_num, 0); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_until_timestamp(stream: &S2Stream) -> Result<(), Box> { + let base_timestamp = past_millis(10_000); + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?.with_timestamp(base_timestamp), + AppendRecord::new("ipsum")?.with_timestamp(base_timestamp + 1), + AppendRecord::new("dolor")?.with_timestamp(base_timestamp + 2), + ])?); + let _ = stream.append(input).await?; + + let batch = stream + .read(ReadInput::new().with_stop(ReadStop::new().with_until(..(base_timestamp + 2)))) + .await?; + + assert_eq!(batch.records.len(), 2); + assert_eq!(batch.records[0].timestamp, base_timestamp); + assert_eq!(batch.records[1].timestamp, base_timestamp + 1); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn read_start_timestamp_ge_until_errors( + stream: &S2Stream, +) -> Result<(), Box> { + let base_timestamp = past_millis(10_000); + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?.with_timestamp(base_timestamp), + AppendRecord::new("ipsum")?.with_timestamp(base_timestamp + 1), + AppendRecord::new("dolor")?.with_timestamp(base_timestamp + 2), + ])?); + let _ = stream.append(input).await?; + + let result = stream + .read( + ReadInput::new() + .with_start(ReadStart::new().with_from(ReadFrom::Timestamp(base_timestamp + 2))) + .with_stop(ReadStop::new().with_until(..(base_timestamp + 2))), + ) + .await; + + assert_matches!( + result, + Err(ReadError::Request(RequestError::Server(ServerError { code, .. }))) => { + assert_eq!(code, "invalid"); + } + ); + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn read_nonexistent_stream_errors( + basin: &SharedS2Basin, +) -> Result<(), Box> { + let stream = basin.stream(unique_stream_name()); + let result = stream.read(ReadInput::new()).await; + + assert_matches!( + result, + Err(ReadError::Request(RequestError::Server(ServerError { code, .. }))) => { + assert_eq!(code, "stream_not_found"); + } + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn append_session_close_delivers_all_acks( + stream: &S2Stream, +) -> Result<(), Box> { + let session = stream.append_session(AppendSessionConfig::default()); + + let ticket1 = session + .submit(AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?, + AppendRecord::new("ipsum")?, + ])?)) + .await?; + let ticket2 = session + .submit(AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("dolor")?, + ])?)) + .await?; + let ticket3 = session + .submit(AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("sit")?, + ])?)) + .await?; + + session.close().await?; + + let ack1 = ticket1.await?; + let ack2 = ticket2.await?; + let ack3 = ticket3.await?; + + assert_eq!(ack1.start.seq_num, 0); + assert_eq!(ack1.end.seq_num, 2); + assert_eq!(ack2.start.seq_num, 2); + assert_eq!(ack2.end.seq_num, 3); + assert_eq!(ack3.start.seq_num, 3); + assert_eq!(ack3.end.seq_num, 4); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn batch_submit_ticket_drop_should_not_affect_others( + stream: &S2Stream, +) -> Result<(), Box> { + let session = stream.append_session(AppendSessionConfig::default()); + + let _ticket1 = session + .submit(AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?, + AppendRecord::new("ipsum")?, + ])?)) + .await?; + drop(_ticket1); + + let ticket2 = session + .submit(AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("dolor")?, + ])?)) + .await?; + + session.close().await?; + + let ack2 = ticket2.await?; + assert_eq!(ack2.start.seq_num, 2); + assert_eq!(ack2.end.seq_num, 3); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn producer_delivers_all_acks(stream: &S2Stream) -> Result<(), Box> { + let producer = stream.producer(ProducerConfig::default()); + + let ack1 = producer.submit(AppendRecord::new("lorem")?).await?.await?; + let ack2 = producer.submit(AppendRecord::new("ipsum")?).await?.await?; + let ack3 = producer.submit(AppendRecord::new("dolor")?).await?.await?; + + assert_eq!(ack1.seq_num, 0); + assert_eq!(ack2.seq_num, 1); + assert_eq!(ack3.seq_num, 2); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn producer_flushes_partial_batch_without_waiting_for_linger( + stream: &S2Stream, +) -> Result<(), Box> { + let producer = stream.producer( + ProducerConfig::new() + .with_batching(BatchingConfig::new().with_linger(Duration::from_secs(60 * 60))), + ); + let ticket = producer.submit(AppendRecord::new("lorem")?).await?; + + tokio::time::timeout(Duration::from_secs(10), producer.flush()) + .await + .expect("producer flush timed out")?; + + let ack = ticket + .now_or_never() + .expect("covered ticket should be ready after flush")?; + assert_eq!(ack.seq_num, 0); + + producer.close().await?; + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn producer_flush_delivers_all_indexed_acks( + stream: &S2Stream, +) -> Result<(), Box> { + let producer = stream.producer( + ProducerConfig::new() + .with_batching(BatchingConfig::new().with_linger(Duration::from_secs(60 * 60))), + ); + let ticket1 = producer.submit(AppendRecord::new("lorem")?).await?; + let ticket2 = producer.submit(AppendRecord::new("ipsum")?).await?; + let ticket3 = producer.submit(AppendRecord::new("dolor")?).await?; + + producer.flush().await?; + + let ack1 = ticket1 + .now_or_never() + .expect("covered ticket1 should be ready after flush")?; + let ack2 = ticket2 + .now_or_never() + .expect("covered ticket2 should be ready after flush")?; + let ack3 = ticket3 + .now_or_never() + .expect("covered ticket3 should be ready after flush")?; + + assert_eq!(ack1.seq_num, 0); + assert_eq!(ack2.seq_num, 1); + assert_eq!(ack3.seq_num, 2); + assert_eq!(ack1.batch, ack2.batch); + assert_eq!(ack2.batch, ack3.batch); + + producer.close().await?; + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn producer_flush_is_reusable_and_excludes_later_submission( + stream: &S2Stream, +) -> Result<(), Box> { + let producer = stream.producer( + ProducerConfig::new() + .with_match_seq_num(0) + .with_batching(BatchingConfig::new().with_linger(Duration::from_secs(60 * 60))), + ); + let ticket1 = producer.submit(AppendRecord::new("lorem")?).await?; + + let record2 = AppendRecord::new("ipsum")?; + let permit2 = producer.reserve(record2.metered_bytes() as u32).await?; + let mut flush = Box::pin(producer.flush()); + assert!(poll!(flush.as_mut()).is_pending()); + let mut ticket2 = Box::pin(permit2.submit(record2)); + + tokio::time::timeout(Duration::from_secs(10), flush) + .await + .expect("first producer flush timed out")?; + let ack1 = ticket1 + .now_or_never() + .expect("record before the barrier should be ready")?; + assert_eq!(ack1.seq_num, 0); + assert!( + tokio::time::timeout(Duration::from_millis(25), ticket2.as_mut()) + .await + .is_err(), + "record after the barrier should not be included in its durability wait" + ); + + tokio::time::timeout(Duration::from_secs(10), producer.flush()) + .await + .expect("second producer flush timed out")?; + let ack2 = ticket2.await?; + assert_eq!(ack2.seq_num, 1); + assert_ne!(ack1.batch, ack2.batch); + + producer.close().await?; + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn producer_empty_flush_is_immediate_and_reusable( + stream: &S2Stream, +) -> Result<(), Box> { + let producer = stream.producer( + ProducerConfig::new() + .with_batching(BatchingConfig::new().with_linger(Duration::from_secs(60 * 60))), + ); + + tokio::time::timeout(Duration::from_secs(10), producer.flush()) + .await + .expect("empty producer flush timed out")?; + assert_eq!(stream.check_tail().await?.seq_num, 0); + + let ticket = producer.submit(AppendRecord::new("lorem")?).await?; + producer.flush().await?; + let ack = ticket + .now_or_never() + .expect("ticket should be ready after the second flush")?; + assert_eq!(ack.seq_num, 0); + + producer.close().await?; + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn producer_flush_propagates_condition_failure_to_covered_ticket( + stream: &S2Stream, +) -> Result<(), Box> { + stream + .append(AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("lorem")?, + ])?)) + .await?; + + let producer = stream.producer( + ProducerConfig::new() + .with_match_seq_num(0) + .with_batching(BatchingConfig::new().with_linger(Duration::from_secs(60 * 60))), + ); + let ticket = producer.submit(AppendRecord::new("ipsum")?).await?; + + let flush_result = tokio::time::timeout(Duration::from_secs(10), producer.flush()) + .await + .expect("failing producer flush timed out"); + assert_matches!( + flush_result, + Err(ProducerError::Append(AppendSessionError::Append( + AppendError::ConditionFailed(AppendConditionFailed::SeqNumMismatch(1)) + ))) + ); + + let ticket_result = ticket + .now_or_never() + .expect("covered ticket should be ready when flush fails"); + assert_matches!( + ticket_result, + Err(ProducerError::Append(AppendSessionError::Append( + AppendError::ConditionFailed(AppendConditionFailed::SeqNumMismatch(1)) + ))) + ); + assert_matches!( + producer.flush().await, + Err(ProducerError::Append(AppendSessionError::Append( + AppendError::ConditionFailed(AppendConditionFailed::SeqNumMismatch(1)) + ))) + ); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn producer_close_delivers_all_indexed_acks_from_same_ack( + stream: &S2Stream, +) -> Result<(), Box> { + let producer = stream.producer(ProducerConfig::default()); + + let ticket1 = producer.submit(AppendRecord::new("lorem")?).await?; + let ticket2 = producer.submit(AppendRecord::new("ipsum")?).await?; + let ticket3 = producer.submit(AppendRecord::new("dolor")?).await?; + + producer.close().await?; + + let ack1 = ticket1.await?; + let ack2 = ticket2.await?; + let ack3 = ticket3.await?; + + assert_eq!(ack1.seq_num, 0); + assert_eq!(ack2.seq_num, 1); + assert_eq!(ack3.seq_num, 2); + + assert_eq!(ack1.batch, ack2.batch); + assert_eq!(ack2.batch, ack3.batch); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn producer_close_delivers_all_indexed_acks_from_different_acks( + stream: &S2Stream, +) -> Result<(), Box> { + let producer = stream.producer(ProducerConfig::default().with_batching( + BatchingConfig::default().with_limits(BatchLimits::default().with_max_batch_records(1)?), + )); + + let ticket1 = producer.submit(AppendRecord::new("lorem")?).await?; + let ticket2 = producer.submit(AppendRecord::new("ipsum")?).await?; + let ticket3 = producer.submit(AppendRecord::new("dolor")?).await?; + + producer.close().await?; + + let ack1 = ticket1.await?; + let ack2 = ticket2.await?; + let ack3 = ticket3.await?; + + assert_eq!(ack1.seq_num, 0); + assert_eq!(ack2.seq_num, 1); + assert_eq!(ack3.seq_num, 2); + + assert_ne!(ack1.batch, ack2.batch); + assert_ne!(ack2.batch, ack3.batch); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn producer_drop_errors_all_claimable_tickets( + stream: &S2Stream, +) -> Result<(), Box> { + let producer = stream.producer( + ProducerConfig::default() + .with_batching(BatchingConfig::default().with_linger(Duration::from_secs(1))), + ); + + let ticket1 = producer.submit(AppendRecord::new("lorem")?).await?; + let ticket2 = producer.submit(AppendRecord::new("ipsum")?).await?; + + drop(producer); + + let result1 = ticket1.await; + let result2 = ticket2.await; + + assert_matches!(result1, Err(ProducerError::ProducerDropped) => {}); + assert_matches!(result2, Err(ProducerError::ProducerDropped) => {}); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn producer_drop_errors_no_claimable_tickets( + stream: &S2Stream, +) -> Result<(), Box> { + let producer = stream.producer(ProducerConfig::default()); + + let ticket1 = producer.submit(AppendRecord::new("lorem")?).await?; + let ticket2 = producer.submit(AppendRecord::new("ipsum")?).await?; + + let ack1 = tokio::time::timeout(Duration::from_secs(10), ticket1) + .await + .expect("ticket1 timed out")?; + let ack2 = tokio::time::timeout(Duration::from_secs(10), ticket2) + .await + .expect("ticket2 timed out")?; + + drop(producer); + + assert_eq!(ack1.seq_num, 0); + assert_eq!(ack2.seq_num, 1); + + Ok(()) +} + +#[test_context(S2Stream)] +#[tokio_shared_rt::test(shared)] +async fn record_submit_ticket_drop_should_not_affect_others( + stream: &S2Stream, +) -> Result<(), Box> { + let producer = stream.producer(ProducerConfig::default()); + + let _ticket1 = producer.submit(AppendRecord::new("lorem")?).await?; + drop(_ticket1); + + let ticket2 = producer.submit(AppendRecord::new("ipsum")?).await?; + + producer.close().await?; + + let ack2 = ticket2.await?; + assert_eq!(ack2.seq_num, 1); + + Ok(()) +} + +#[tokio::test] +async fn create_stream_inherits_basin_default_config() -> Result<(), Box> { + let config = s2_config(Compression::None).expect("valid S2 config"); + let s2 = s2_sdk::S2::new(config).expect("valid S2"); + + let basin_name = unique_basin_name(); + let default_stream_config = StreamConfig::new() + .with_storage_class("standard") + .with_retention_policy(RetentionPolicy::Age(3600)) + .with_delete_on_empty(DeleteOnEmptyConfig::new().with_min_age(Duration::from_secs(3600))); + let basin_config = BasinConfig::new().with_default_stream_config(default_stream_config); + + s2.create_basin(CreateBasinInput::new(basin_name.clone()).with_config(basin_config)) + .await?; + + let basin = s2.basin(basin_name.clone()); + let stream_name = unique_stream_name(); + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let stream_config = basin.get_stream_config(stream_name.clone()).await?; + assert_matches!( + stream_config, + StreamConfig { + storage_class: Some(ref storage_class), + retention_policy: Some(RetentionPolicy::Age(3600)), + delete_on_empty: Some(DeleteOnEmptyConfig { + min_age_secs: 3600, + .. + }), + .. + } if storage_class == "standard" + ); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + s2.delete_basin(DeleteBasinInput::new(basin_name)).await?; + + Ok(()) +} + +#[rstest] +#[case::gzip(Compression::Gzip)] +#[case::zstd(Compression::Zstd)] +#[tokio::test] +async fn compression_roundtrip_unary( + #[case] compression: Compression, +) -> Result<(), Box> { + let config = s2_config(compression).expect("valid S2 config"); + let s2 = s2_sdk::S2::new(config).expect("valid S2"); + + let basin_name = unique_basin_name(); + let basin_config = BasinConfig::new() + .with_default_stream_config(StreamConfig::new().with_storage_class("standard")); + s2.create_basin(CreateBasinInput::new(basin_name.clone()).with_config(basin_config)) + .await?; + + let basin = s2.basin(basin_name.clone()); + let stream_name = unique_stream_name(); + let stream_config = + StreamConfig::new().with_timestamping(TimestampingConfig::new().with_uncapped(true)); + basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(stream_config)) + .await?; + + let stream = basin.stream(stream_name.clone()); + + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("s".repeat(2048))?, + AppendRecord::new("2".repeat(2048))?, + ])?); + let ack = stream.append(input).await?; + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 2); + + let batch = stream.read(ReadInput::new()).await?; + assert_eq!(batch.records.len(), 2); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + s2.delete_basin(DeleteBasinInput::new(basin_name)).await?; + + Ok(()) +} + +#[rstest] +#[case::gzip(Compression::Gzip)] +#[case::zstd(Compression::Zstd)] +#[tokio::test] +async fn compression_with_no_side_effects_unary( + #[case] compression: Compression, +) -> Result<(), Box> { + let config = s2_config(compression) + .expect("valid S2 config") + .with_retry(RetryConfig::new().with_append_retry_policy(AppendRetryPolicy::NoSideEffects)); + let s2 = s2_sdk::S2::new(config).expect("valid S2"); + + let basin_name = unique_basin_name(); + let basin_config = BasinConfig::new() + .with_default_stream_config(StreamConfig::new().with_storage_class("standard")); + s2.create_basin(CreateBasinInput::new(basin_name.clone()).with_config(basin_config)) + .await?; + + let basin = s2.basin(basin_name.clone()); + let stream_name = unique_stream_name(); + basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await?; + + let stream = basin.stream(stream_name.clone()); + + let input = AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("s".repeat(2048))?, + AppendRecord::new("2".repeat(2048))?, + ])?); + let ack = stream.append(input).await?; + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 2); + + let batch = stream.read(ReadInput::new()).await?; + assert_eq!(batch.records.len(), 2); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + s2.delete_basin(DeleteBasinInput::new(basin_name)).await?; + + Ok(()) +} + +#[rstest] +#[case::gzip(Compression::Gzip)] +#[case::zstd(Compression::Zstd)] +#[tokio::test] +async fn compression_roundtrip_session( + #[case] compression: Compression, +) -> Result<(), Box> { + let config = s2_config(compression).expect("valid S2 config"); + let s2 = s2_sdk::S2::new(config).expect("valid S2"); + + let basin_name = unique_basin_name(); + let basin_config = BasinConfig::new().with_default_stream_config( + StreamConfig::new() + .with_timestamping(TimestampingConfig::new().with_mode(TimestampingMode::Arrival)), + ); + s2.create_basin(CreateBasinInput::new(basin_name.clone()).with_config(basin_config)) + .await?; + + let basin = s2.basin(basin_name.clone()); + let stream_name = unique_stream_name(); + let stream_config = StreamConfig::new().with_storage_class("standard"); + basin + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(stream_config)) + .await?; + + let stream = basin.stream(stream_name.clone()); + + // Payload must be >= 1KiB to trigger compression (COMPRESSION_THRESHOLD_BYTES) + let session = stream.append_session(AppendSessionConfig::default()); + let ticket = session + .submit(AppendInput::new(AppendRecordBatch::try_from_iter([ + AppendRecord::new("s2".repeat(10240))?, + ])?)) + .await?; + session.close().await?; + + let ack = ticket.await?; + assert_eq!(ack.start.seq_num, 0); + assert_eq!(ack.end.seq_num, 1); + + let mut batches = stream + .read_session(ReadInput::new(), ReadSessionConfig::default()) + .await?; + let batch = batches.next().await.expect("should have batch")?; + assert_eq!(batch.records.len(), 1); + assert_eq!(batch.records[0].body.len(), 20480); + + basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await?; + s2.delete_basin(DeleteBasinInput::new(basin_name)).await?; + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn append_session_for_non_existent_stream_errors( + basin: &SharedS2Basin, +) -> Result<(), Box> { + let stream = basin.stream(unique_stream_name()); + + let session = stream.append_session(AppendSessionConfig::new()); + + let input = AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "lorem", + )?])?); + + let result = session.submit(input).await?.await; + + assert_matches!(result, Err(AppendSessionError::Append(AppendError::Request(RequestError::Server(err)))) => { + assert_eq!(err.code, "stream_not_found"); + }); + + Ok(()) +} + +#[test_context(SharedS2Basin)] +#[tokio_shared_rt::test(shared)] +async fn producer_for_non_existent_stream_errors( + basin: &SharedS2Basin, +) -> Result<(), Box> { + let stream = basin.stream(unique_stream_name()); + + let producer = stream.producer(ProducerConfig::new().with_batching( + BatchingConfig::new().with_limits(BatchLimits::new().with_max_batch_records(10)?), + )); + let num_records = 2000; + + let mut tickets = Vec::with_capacity(num_records); + for i in 0..num_records { + match producer + .submit(AppendRecord::new(format!("record-{i}"))?) + .await + { + Ok(ticket) => tickets.push(ticket), + Err(ProducerError::Append(AppendSessionError::Append(AppendError::Request( + RequestError::Server(err), + )))) => { + assert_eq!(err.code, "stream_not_found"); + } + Err(e) => return Err(e.into()), + } + } + + for ticket in tickets { + let result = ticket.await; + assert_matches!(result, Err(ProducerError::Append(AppendSessionError::Append(AppendError::Request(RequestError::Server(err))))) => { + assert_eq!(err.code, "stream_not_found"); + }); + } + + Ok(()) +} + +#[tokio::test] +async fn stream_config_applies_only_when_append_creates_stream() +-> Result<(), Box> { + let s2 = s2(); + let basin_name = unique_basin_name(); + s2.create_basin( + CreateBasinInput::new(basin_name.clone()).with_config( + BasinConfig::new() + .with_create_stream_on_append(true) + .with_default_stream_config(StreamConfig::new().with_storage_class("standard")), + ), + ) + .await?; + let basin = s2.basin(basin_name.clone()); + + let stream_config = StreamConfig::new() + .with_retention_policy(RetentionPolicy::Age(3600)) + .with_delete_on_empty(DeleteOnEmptyConfig::new().with_min_age(Duration::from_secs(300))); + + let unary_stream = unique_stream_name(); + basin + .stream(unary_stream.clone()) + .append( + AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "hello", + )?])?) + .with_stream_config(stream_config.clone()), + ) + .await?; + let config = basin.get_stream_config(unary_stream).await?; + assert_matches!( + config, + StreamConfig { + storage_class: Some(ref storage_class), + retention_policy: Some(RetentionPolicy::Age(3600)), + delete_on_empty: Some(DeleteOnEmptyConfig { + min_age_secs: 300, + .. + }), + .. + } if storage_class == "standard" + ); + + let session_stream = unique_stream_name(); + let producer = basin + .stream(session_stream.clone()) + .producer(ProducerConfig::new().with_stream_config(stream_config.clone())); + producer.submit(AppendRecord::new("hello")?).await?.await?; + producer.close().await?; + let config = basin.get_stream_config(session_stream).await?; + assert_matches!( + config, + StreamConfig { + retention_policy: Some(RetentionPolicy::Age(3600)), + delete_on_empty: Some(DeleteOnEmptyConfig { + min_age_secs: 300, + .. + }), + .. + } + ); + + let existing_stream = unique_stream_name(); + basin + .create_stream(CreateStreamInput::new(existing_stream.clone())) + .await?; + let before = basin.get_stream_config(existing_stream.clone()).await?; + basin + .stream(existing_stream.clone()) + .append( + AppendInput::new(AppendRecordBatch::try_from_iter([AppendRecord::new( + "hello", + )?])?) + .with_stream_config(stream_config), + ) + .await?; + let after = basin.get_stream_config(existing_stream).await?; + assert_eq!(after, before); + assert_ne!(after.retention_policy, Some(RetentionPolicy::Age(3600))); + + s2.delete_basin(DeleteBasinInput::new(basin_name)).await?; + Ok(()) +} + +#[tokio::test] +async fn stream_config_applies_when_read_creates_stream() -> Result<(), Box> +{ + let s2 = s2(); + let basin_name = unique_basin_name(); + s2.create_basin( + CreateBasinInput::new(basin_name.clone()) + .with_config(BasinConfig::new().with_create_stream_on_read(true)), + ) + .await?; + let basin = s2.basin(basin_name.clone()); + + let stream_config = StreamConfig::new().with_retention_policy(RetentionPolicy::Age(3600)); + + let session_stream = unique_stream_name(); + let _session = basin + .stream(session_stream.clone()) + .read_session( + ReadInput::new().with_stream_config(stream_config), + ReadSessionConfig::default(), + ) + .await?; + let config = basin.get_stream_config(session_stream).await?; + assert_eq!(config.retention_policy, Some(RetentionPolicy::Age(3600))); + + s2.delete_basin(DeleteBasinInput::new(basin_name)).await?; + Ok(()) +} diff --git a/sim/Cargo.lock b/sim/Cargo.lock new file mode 100644 index 00000000..19ae3e7f --- /dev/null +++ b/sim/Cargo.lock @@ -0,0 +1,5497 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aegis" +version = "0.9.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58541132f980da31e9aa99f7bdee69bc84bf1e168b9b91ef2dbe8abb7b4ce5dd" +dependencies = [ + "cc", + "softaes", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ctutils", + "ghash", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "antithesis_sdk" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08410fcac93669a476c006cd6c4512ac1e2b30fd117231a5d55d8a2c76599b82" +dependencies = [ + "libc", + "libloading", + "linkme", + "once_cell", + "rand 0.8.8", + "rand_core 0.6.4", + "rustc_version_runtime", + "serde", + "serde_json", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[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 2.0.20", + "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.119", + "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.119", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "asyncband" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d85fd3d291fabcc40c7232c92c280ec1754fd7b5d7ea769f143222143e179a" + +[[package]] +name = "atoi" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a8bbe9949e43a1edaa043038c68703b04774156afdfb62ba2cef5bf93d67be" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-config" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d7b388a9fc3a6db15a5ec778c38b354eff1364882c94d08e0252f7a47dcaa4" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.5.0", + "sha1 0.10.7", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "aws-runtime" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef47857a1d4488b528f4a5d5715fa7c3300820897824152234d3fa22b1426657" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.5.0", + "http-body 1.1.0", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.109.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3cfe74df5d9ad2fedd691973ad3521ebf4f27a3c68c792556686aedb5519bab" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.111.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b0ec31ed6191bd11350aae4b2004198f2db21350cb0a20c57e0a92e55dd161" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef45745026107ec30c4ef86bd8ae4b002e7e5f6a86e4225240bdf6b06a0b944a" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.5.0", + "percent-encoding", + "sha2", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebfd138fac0337cee7516c352757ea73b9f2266e57d0bcb5bc70e9547e45aef1" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.5.0", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512346c7212ab7436df2d77a16d976a468ae44a418835511d2a69269810aaf62" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b82e438d30e02a825d363bd639a9efaed68a8089d86101054b0081e7e0d3e606" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "http-body 0.4.6", + "http-body 1.1.0", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c054752dd9e4dc73d0b75748c99ac2d0feafbf2f25c7b0516f03a3534161223" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.5.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.5.0", +] + +[[package]] +name = "aws-smithy-types" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f94d16e797ec62cd999fc9d5942b48fa7050c3093ddadff48e4d7528d16fcb9" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.5.0", + "http-body 0.4.6", + "http-body 1.1.0", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce84f71c72fee2cbbadde6e7d082f5fb466e3a84733855295fa7aafd1b31b7d8" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "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 1.5.0", + "http-body 1.1.0", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http 1.5.0", + "http-body 1.1.0", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[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.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "bytesize" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b" + +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "serde", + "static_assertions", + "zmij", +] + +[[package]] +name = "compression-codecs" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8" +dependencies = [ + "compression-core", + "flate2", + "memchr", + "zstd 0.14.0", + "zstd-safe 8.0.0", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_affinity" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342" +dependencies = [ + "libc", + "num_cpus", + "winapi", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "datasketches" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c4cf71a36b46dcfc00e5014c0c20ccad2b1b6a008304d7d57d2749b2d41b3d" + +[[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" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "duration-str" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f88959de2d447fd3eddcf1909d1f19fe084e27a056a6904203dc5d8b9e771c1e" +dependencies = [ + "rust_decimal", + "serde", + "thiserror 2.0.20", + "time", + "winnow 0.6.26", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "enumset" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "eyre" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" +dependencies = [ + "autocfg", + "indenter", + "once_cell", +] + +[[package]] +name = "fail-parallel" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c29b33a0187823f1fa88b36980227dc96c7504ede2288e7d2a77d9d6d88b260c" +dependencies = [ + "log", + "once_cell", + "rand 0.9.5", + "tokio", +] + +[[package]] +name = "fastant" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e825441bfb2d831c47c97d05821552db8832479f44c571b97fededbf0099c07" +dependencies = [ + "small_ctor", + "web-time", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "serde_json", + "serde_yaml", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "foyer" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a59f42276891c0a4ce683fcda1aa1b4fd1065d68116c264e4bf49b9d318a0ed" +dependencies = [ + "anyhow", + "asyncband", + "equivalent", + "foyer-common", + "foyer-memory", + "foyer-storage", + "foyer-tokio", + "futures-util", + "mixtrics", + "pin-project", + "serde", + "tracing", +] + +[[package]] +name = "foyer-common" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643b47d510032a01e5af70dca288b0c5ec531646c1a8392e793fb5b0c13bef30" +dependencies = [ + "anyhow", + "bincode", + "bytes", + "cfg-if", + "foyer-tokio", + "mixtrics", + "parking_lot", + "pin-project", + "serde", + "twox-hash", +] + +[[package]] +name = "foyer-intrusive-collections" +version = "0.10.0-dev" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4fee46bea69e0596130e3210e65d3424e0ac1e6df3bde6636304bdf1ca4a3b" +dependencies = [ + "memoffset", +] + +[[package]] +name = "foyer-memory" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae51f5089f3d9025ae77f17ea66d2489ac0f82fbb1d91462807bea174d486d82" +dependencies = [ + "anyhow", + "asyncband", + "bitflags", + "datasketches", + "equivalent", + "foyer-common", + "foyer-intrusive-collections", + "foyer-tokio", + "futures-util", + "hashbrown 0.17.1", + "itertools 0.15.0", + "mixtrics", + "parking_lot", + "paste", + "pin-project", + "serde", + "tracing", +] + +[[package]] +name = "foyer-storage" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "118192f532ba013f0cdc607efc22324b9ed855baed185608af0daa986e835c6b" +dependencies = [ + "allocator-api2", + "anyhow", + "asyncband", + "bytes", + "core_affinity", + "equivalent", + "fastant", + "foyer-common", + "foyer-memory", + "foyer-tokio", + "fs4", + "futures-core", + "futures-util", + "hashbrown 0.17.1", + "io-uring", + "itertools 0.15.0", + "libc", + "lz4", + "parking_lot", + "pin-project", + "rand 0.10.2", + "serde", + "tracing", + "twox-hash", + "zstd 0.13.3", +] + +[[package]] +name = "foyer-tokio" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6741d1133dfaab64d3f8a9290bbfed3685084add28f8b6ee7a6264aa4dc26918" +dependencies = [ + "tokio", +] + +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "git+https://github.com/s2-streamstore/getrandom?rev=9634f379845b3d4ea4a9f2d20b9fca28b1476e0c#9634f379845b3d4ea4a9f2d20b9fca28b1476e0c" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", + "zeroize", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.5.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7685beb53fc20efc2605f32f5d51e9ba18b8ef237961d1760169d2290d3bee" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http 1.5.0", + "http-body 1.1.0", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.5.0", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "io-uring" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linkme" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3045e122bd98aef8ec3ad58ce84f0791f64e70163d1a02710af4aa11a4d54cc5" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77060ebe535362c3da75682cd17b0431017b6e7c5661e714fc69a7ad017d1301" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "mad-turmoil" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06138aebc1442e86b201f492616a51690a758d7b75253162f9e9208382ca2bb7" +dependencies = [ + "libc", + "rand 0.10.2", + "tokio", + "turmoil", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mixtrics" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c46b5adfb7a3ae4996d327a5bdc90e78fec025806dd312bdbe6f07a755e0ec9" +dependencies = [ + "itertools 0.15.0", + "parking_lot", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[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 = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +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.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +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", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "numeric_cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3c00a0c9600379bd32f8972de90676a7672cba3bf4886986bc05902afc1e093" + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "object_store" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64 0.22.1", + "bytes", + "chrono", + "crc-fast", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body-util", + "humantime", + "hyper", + "itertools 0.15.0", + "md-5", + "nix", + "parking_lot", + "percent-encoding", + "quick-xml", + "rand 0.10.2", + "reqwest", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.20", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", + "windows-sys 0.61.2", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", + "zeroize", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + +[[package]] +name = "procfs" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" +dependencies = [ + "bitflags", + "hex", + "procfs-core", + "rustix 0.38.44", +] + +[[package]] +name = "procfs-core" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" +dependencies = [ + "bitflags", + "hex", +] + +[[package]] +name = "prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "libc", + "memchr", + "parking_lot", + "procfs", + "protobuf", + "thiserror 2.0.20", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.5", +] + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "h2", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "num-traits", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustc_version_runtime" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" +dependencies = [ + "rustc_version", + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "s2-api" +version = "0.31.5" +dependencies = [ + "axum", + "base64ct", + "bytes", + "compact_str", + "flate2", + "futures-core", + "futures-util", + "http 1.5.0", + "itertools 0.15.0", + "mime", + "prost", + "s2-common", + "serde", + "serde_json", + "strum", + "thiserror 2.0.20", + "time", + "tokio-util", + "zstd 0.14.0", +] + +[[package]] +name = "s2-common" +version = "0.41.3" +dependencies = [ + "axum", + "base64ct", + "bytes", + "clap", + "compact_str", + "enumset", + "http 1.5.0", + "rand 0.10.2", + "secrecy", + "serde", + "strum", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "s2-lite" +version = "0.42.13" +dependencies = [ + "async-stream", + "async-trait", + "aws-config", + "aws-credential-types", + "axum", + "axum-server", + "bytes", + "bytesize", + "clap", + "dashmap", + "eyre", + "futures", + "http 1.5.0", + "indexmap", + "itertools 0.15.0", + "parking_lot", + "prometheus", + "prost", + "rand 0.10.2", + "rcgen", + "rustls", + "s2-api", + "s2-common", + "s2-resource-spec", + "s2-storage", + "serde", + "serde_json", + "slatedb", + "strum", + "thiserror 2.0.20", + "tikv-jemallocator", + "time", + "tokio", + "tokio-util", + "tower-http 0.7.1", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "s2-resource-spec" +version = "0.2.2" +dependencies = [ + "compact_str", + "humantime", + "s2-common", + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "s2-sdk" +version = "0.34.9" +dependencies = [ + "async-compression", + "async-stream", + "async-trait", + "bytes", + "compact_str", + "futures-core", + "futures-util", + "h2", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "prost", + "rand 0.10.2", + "rustls", + "s2-api", + "s2-common", + "secrecy", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-muxt", + "tokio-stream", + "tokio-util", + "tracing", + "urlencoding", + "uuid", +] + +[[package]] +name = "s2-sim" +version = "0.0.0" +dependencies = [ + "async-trait", + "axum", + "blake3", + "bytes", + "bytesize", + "clap", + "eyre", + "fastrand", + "futures", + "getrandom 0.2.15", + "http 1.5.0", + "http-body-util", + "hyper", + "hyper-util", + "mad-turmoil", + "rand 0.10.2", + "s2-lite", + "s2-sdk", + "s2-verification", + "s3s", + "serde_json", + "slatedb", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "turmoil", +] + +[[package]] +name = "s2-storage" +version = "0.2.5" +dependencies = [ + "aegis", + "aes-gcm", + "blake3", + "bytes", + "rand 0.10.2", + "s2-common", + "secrecy", + "serde", + "thiserror 2.0.20", +] + +[[package]] +name = "s2-verification" +version = "0.4.0" +source = "git+https://github.com/s2-streamstore/s2-verification?rev=b4af8c8ef4965d9b335101c422eadb33f3169004#b4af8c8ef4965d9b335101c422eadb33f3169004" +dependencies = [ + "antithesis_sdk", + "clap", + "eyre", + "futures", + "rand 0.8.8", + "s2-sdk", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tracing", + "tracing-subscriber", + "xxhash-rust", +] + +[[package]] +name = "s3s" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "652451a66fefda01a13dc7df24aa2af9e70c7058f98bc08fe2f7c552eaa9f1e3" +dependencies = [ + "arc-swap", + "arrayvec", + "async-trait", + "atoi", + "base64-simd", + "bytes", + "bytestring", + "cfg-if", + "chrono", + "crc-fast", + "futures", + "hex-simd", + "hmac", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "httparse", + "hyper", + "itoa", + "md-5", + "memchr", + "mime", + "nom 8.0.0", + "numeric_cast", + "pin-project-lite", + "quick-xml", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "sha1 0.11.0", + "sha2", + "smallvec", + "std-next", + "subtle", + "sync_wrapper", + "thiserror 2.0.20", + "time", + "tokio", + "tower", + "tracing", + "transform-stream", + "url", + "urlencoding", + "xxhash-rust", + "zeroize", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slatedb" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb40332f41e231926df3a0ef742c05316b43368ba4b520433d2a1eba1ab00f0f" +dependencies = [ + "async-channel", + "async-trait", + "atomic", + "backon", + "bitflags", + "bytes", + "chrono", + "crc32fast", + "crossbeam-skiplist", + "dotenvy", + "duration-str", + "fail-parallel", + "figment", + "flatbuffers", + "foyer", + "futures", + "log", + "lru", + "lz4_flex", + "object_store", + "ouroboros", + "parking_lot", + "rand 0.9.5", + "serde", + "serde_json", + "siphasher", + "slatedb-common", + "slatedb-txn-obj", + "smallvec", + "sysinfo", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", + "ulid", + "url", + "uuid", + "walkdir", + "zstd 0.13.3", +] + +[[package]] +name = "slatedb-common" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b77f238f348e95e1653a5235f880ac703011b82582bd556f2aba405abf8180e" +dependencies = [ + "chrono", + "log", + "object_store", + "rand 0.9.5", + "rand_xoshiro", + "serde", + "thread_local", + "tokio", +] + +[[package]] +name = "slatedb-txn-obj" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad63b6c250219e26d71f497820f970274c301f896366daa56915f2a05df0e0a" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "log", + "object_store", + "parking_lot", + "slatedb-common", + "thiserror 1.0.69", +] + +[[package]] +name = "small_ctor" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88414a5ca1f85d82cc34471e975f0f74f6aa54c40f062efa42c0080e7f763f81" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softaes" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e14297decde697ddf377c25752aead0927d5cfc89c2684d2af96901a4ceeea" + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "std-next" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04082e93ed1a06debd9148c928234b46d2cf260bc65f44e1d1d3fa594c5beebc" +dependencies = [ + "simdutf8", + "thiserror 2.0.20", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sysinfo" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3ffa3e4ff2b324a57f7aeb3c349656c7b127c3c189520251a648102a92496e" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tikv-jemalloc-sys" +version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2825c78386b4ae0314074867860ba9577875de945f05992c38815cbec327f0" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249f09e49ab1609436f34c776e84231bead18d6a955f119f939bdc1d847561bd" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-muxt" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79eac432d68f5cc5ca99654adddb6ffc9501618bc267303d18a1f768ea01e7c" +dependencies = [ + "pin-project", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "hashbrown 0.15.5", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "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", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "transform-stream" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1a814d25437963577f6221d33a2aaa60bfb44acc3330cdc7c334644e9832022" +dependencies = [ + "futures-core", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "turmoil" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0061c28f1469e94771bb8aa626ce6b29265c2fb5034115046a170b0cba2e33d2" +dependencies = [ + "bytes", + "indexmap", + "rand 0.9.5", + "rand_distr", + "scoped-tls", + "tokio", + "tracing", +] + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.5", + "serde", + "web-time", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "rand 0.10.2", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.6.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe 7.3.0", +] + +[[package]] +name = "zstd" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e" +dependencies = [ + "zstd-safe 8.0.0", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-safe" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/sim/Cargo.toml b/sim/Cargo.toml new file mode 100644 index 00000000..1293bd1e --- /dev/null +++ b/sim/Cargo.toml @@ -0,0 +1,81 @@ +[workspace] +resolver = "2" + +[package] +name = "s2-sim" +version = "0.0.0" +description = "Deterministic simulation testing for s2-lite using turmoil" +publish = false +edition = "2024" +license = "MIT" +repository = "https://github.com/s2-streamstore/s2" +homepage = "https://s2.dev" + +[[bin]] +name = "sim" +path = "src/main.rs" + +[dependencies] +async-trait = "0.1" +axum = "0.8" +blake3 = "1.8" +bytes = "1.12" +bytesize = "2.7" +clap = { version = "4.6", features = ["derive", "env"] } +eyre = "0.6" +fastrand = "2" +futures = "0.3" +# Activates determinism on Linux in the patched getrandom (see [patch.crates-io]). +getrandom = { version = "0.2", features = ["deterministic-simulation"] } +http = "1.5" +http-body-util = "0.1" +hyper = { version = "1.11", features = ["client", "server", "http1", "http2"] } +hyper-util = { version = "0.1", features = [ + "client-legacy", + "http1", + "http2", + "server", + "server-auto", + "service", + "tokio", +] } +mad-turmoil = "0.2.1" +rand = "0.10" +s2-lite = { path = "../lite" } +s2-sdk = { path = "../sdk", features = ["_hidden"] } +# The Go checker built in CI must be pinned to the same rev (see the +# S2_VERIFICATION_REV env in .github/workflows/ci.yml). +s2-verification = { git = "https://github.com/s2-streamstore/s2-verification", rev = "b4af8c8ef4965d9b335101c422eadb33f3169004" } +s3s = "0.15.0" +serde_json = "1.0" +slatedb = { version = "0.16.0", features = ["lz4", "zstd"] } +tokio = { version = "1.53", features = ["macros", "rt", "sync", "time", "io-util"] } +tower = "0.5" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +turmoil = "0.7.2" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(tokio_unstable)"] } + +[patch.crates-io] +# Unifies s2-verification's crates.io s2-sdk dependency with the local sdk, +# so the sim crate can hand local S2Stream values to verification's workflow +# clients. +s2-sdk = { path = "../sdk" } +# On Linux, upstream getrandom 0.2 invokes the raw SYS_getrandom syscall, which +# bypasses mad-turmoil's libc interposition and injects real entropy into +# deterministic simulations. The fork's `deterministic-simulation` feature +# routes through the interposable libc wrapper instead; without the feature it +# behaves identically to upstream. Scoped to this workspace so it does not +# affect the main workspace's dependency tree. +getrandom = { git = "https://github.com/s2-streamstore/getrandom", rev = "9634f379845b3d4ea4a9f2d20b9fca28b1476e0c" } + +# Optimized profile for the deterministic simulator: release-grade runtime +# without the release profile's LTO link tax, with assertions kept on since +# the simulator exists to catch bugs. +[profile.sim] +inherits = "release" +lto = false +debug-assertions = true +overflow-checks = true diff --git a/sim/README.md b/sim/README.md new file mode 100644 index 00000000..3bc844e7 --- /dev/null +++ b/sim/README.md @@ -0,0 +1,25 @@ +# s2-sim + +Deterministic simulation plumbing for s2-lite. + +Basic smoke test: + +```bash +just sim smoke --seed 12345 +``` + +Meta-test, which compares the output of two runs of a selected sim and compares logging output. Helpful for determining if determinism has been broken. + +```bash +# RUST_LOG determines the logging level of the `smoke` test; trace is most likely to uncover determinism regressions +RUST_LOG=trace just sim meta smoke --seed 12345 +``` + +Collect logs for linearizability testing using [s2-verification](https://github.com/s2-streamstore/s2-verification): + +```bash +just sim linearizable --seed 12345 + +# with `s2-porcupine` from installed: +s2-porcupine -file history.12345.jsonl +``` \ No newline at end of file diff --git a/sim/src/history.rs b/sim/src/history.rs new file mode 100644 index 00000000..edbbdab6 --- /dev/null +++ b/sim/src/history.rs @@ -0,0 +1,30 @@ +//! Collection of operation histories for offline linearizability checking. + +use std::{ + fs::File, + io::{BufWriter, Write as _}, + path::PathBuf, +}; + +use s2_verification::history::LabeledEvent; +use tokio::sync::mpsc::UnboundedReceiver; +use tracing::info; + +/// Drain all buffered events and write them as JSONL to `history..jsonl` +/// in the current directory. +/// +/// Call after the simulation has completed: every sender has hung up by then, +/// so everything the workload emitted is sitting in the channel. +pub fn save(rx: &mut UnboundedReceiver, seed: u64) -> eyre::Result { + let path = PathBuf::from(format!("history.{seed}.jsonl")); + let mut file = BufWriter::new(File::create(&path)?); + let mut events = 0u64; + while let Ok(event) = rx.try_recv() { + serde_json::to_writer(&mut file, &event)?; + file.write_all(b"\n")?; + events += 1; + } + file.flush()?; + info!(events, path = %path.display(), "history saved"); + Ok(path) +} diff --git a/sim/src/lite_host.rs b/sim/src/lite_host.rs new file mode 100644 index 00000000..7a88e20c --- /dev/null +++ b/sim/src/lite_host.rs @@ -0,0 +1,57 @@ +//! s2-lite, run as a turmoil host. +//! +//! Mirrors `s2_lite::server::run`, but with the pieces the simulation needs to +//! control: the object store is an S3 client wired over the turmoil network to +//! the mock S3 host, and the HTTP server runs on a turmoil listener instead of +//! a real socket. + +use std::{sync::Arc, time::Duration}; + +use bytesize::ByteSize; +use s2_lite::{backend::Backend, handlers}; +use slatedb::object_store::{self, aws::AmazonS3Builder}; +use tracing::info; + +use crate::{object_store_http::TurmoilHttpConnector, s3}; + +pub const HOST: &str = "s2-lite"; +pub const PORT: u16 = 80; + +pub fn endpoint() -> String { + format!("http://{HOST}:{PORT}") +} + +pub async fn serve() -> turmoil::Result { + let store: Arc = Arc::new( + AmazonS3Builder::new() + .with_bucket_name(s3::BUCKET) + .with_region("sim") + .with_endpoint(s3::endpoint()) + .with_allow_http(true) + // Path-style addressing keeps the turmoil host name ("s3") intact; + // virtual-hosted style would dial "sim-bucket.s3". + .with_virtual_hosted_style_request(false) + .with_access_key_id(s3::ACCESS_KEY) + .with_secret_access_key(s3::SECRET_KEY) + .with_http_connector(TurmoilHttpConnector) + .build()?, + ); + + let db_settings = slatedb::Settings { + flush_interval: Some(Duration::from_millis(50)), + ..Default::default() + }; + + let db = slatedb::Db::builder("", store) + .with_settings(db_settings) + .build() + .await?; + + let backend = Backend::new(db, ByteSize::mib(128)); + s2_lite::backend::bgtasks::spawn(&backend); + + let app = handlers::router().with_state(backend); + + info!(host = HOST, port = PORT, "s2-lite listening"); + crate::net::serve(PORT, app).await +} diff --git a/sim/src/main.rs b/sim/src/main.rs new file mode 100644 index 00000000..f2b93879 --- /dev/null +++ b/sim/src/main.rs @@ -0,0 +1,169 @@ +//! Deterministic simulation testing for s2-lite. +//! +//! Runs turmoil hosts on a simulated network: +//! - `s3`: a mock S3 service (see [`s3`]) +//! - `s2-lite`: the lite server, slatedb backend pointed at the mock S3 +//! - `workload`: a scenario driving s2-sdk clients against the lite server (see [`scenarios`]) +//! +//! Determinism relies on `mad-turmoil` (shadowed clocks and entropy via libc +//! interposition, a global seeded RNG) plus `--cfg tokio_unstable` so turmoil +//! can seed tokio's internal RNG. A determinism "meta test" (`sim meta`) runs +//! the same seed twice and requires byte-identical output. + +mod history; +mod lite_host; +mod meta; +mod net; +mod object_store_http; +mod s3; +mod scenarios; + +use std::time::{Duration, SystemTime}; + +use clap::Parser; +use rand::{SeedableRng, rngs::StdRng}; +use tracing::info; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[derive(Parser, Debug)] +#[command(about = "Deterministic simulation testing for s2-lite")] +struct Args { + /// RNG seed; the same seed must yield an identical simulation. + #[arg(long, env = "SIM_SEED", default_value_t = 0, global = true)] + seed: u64, + + /// Probability [0, 1) of network message loss. + #[arg(long, default_value_t = 0.0, global = true)] + fail_rate: f64, + + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(clap::Subcommand, Debug)] +enum Cmd { + /// Create/append/read smoke test. + Smoke, + /// Concurrent clients recording an operation history for the + /// linearizability checker (s2-verification / s2-porcupine). + Linearizable(scenarios::linearizable::Config), + /// Determinism meta-test: run the given simulation twice and require + /// byte-identical output (e.g. `sim meta linearizable --seed 42`). + Meta(meta::MetaArgs), +} + +fn main() -> eyre::Result<()> { + // A compile-time check would be nicer, but would break workspace-wide + // builds (`just test` / `just clippy`) that don't set the flag. + if cfg!(not(tokio_unstable)) { + eyre::bail!( + "must be built with RUSTFLAGS=\"--cfg tokio_unstable\" so turmoil can seed tokio's \ + internal RNG; use `just sim`" + ); + } + + let args = Args::parse(); + + if let Cmd::Meta(meta) = args.cmd { + // The meta harness only spawns child processes; it must not install + // simulated clocks or otherwise behave like a simulation itself. + init_tracing(); + return meta::run(meta, args.seed, args.fail_rate); + } + + // Keep shadowed clocks installed for the lifetime of the simulation, so + // wall-clock reads inside lite/slatedb/sdk observe simulated time. + let _clocks_guard = mad_turmoil::time::SimClocksGuard::init(); + mad_turmoil::rand::set_rng(StdRng::seed_from_u64(args.seed)); + fastrand::seed(args.seed); + + init_tracing(); + info!(?args, "starting simulation"); + + let mut sim = init_sim(args.seed, args.fail_rate); + + sim.host(s3::HOST, || async { + s3::serve().await.inspect_err(log_host_exit(s3::HOST)) + }); + sim.host(lite_host::HOST, || async { + lite_host::serve() + .await + .inspect_err(log_host_exit(lite_host::HOST)) + }); + + match args.cmd { + Cmd::Meta(_) => unreachable!("handled above"), + Cmd::Smoke => { + sim.client("workload", scenarios::smoke::workload()); + run(sim)?; + } + Cmd::Linearizable(config) => { + let (history_tx, mut history_rx) = tokio::sync::mpsc::unbounded_channel(); + sim.client( + "workload", + scenarios::linearizable::workload(config, history_tx), + ); + run(sim)?; + let path = history::save(&mut history_rx, args.seed)?; + info!( + "check linearizability with: s2-porcupine -file={}", + path.display() + ); + } + } + + info!("simulation completed"); + Ok(()) +} + +fn run(mut sim: turmoil::Sim<'_>) -> eyre::Result<()> { + sim.run() + .map_err(|err| eyre::eyre!("simulation failed: {err}")) +} + +fn init_sim(seed: u64, fail_rate: f64) -> turmoil::Sim<'static> { + let mut builder = turmoil::Builder::new(); + builder + .rng_seed(seed) + // Backstop: a wedged system (e.g. a server stuck erroring forever + // while clients still have ops to attempt) should fail the simulation + // deterministically instead of running unbounded. Scenarios complete + // in simulated minutes; leave generous headroom for fault backoffs. + .simulation_duration(Duration::from_secs(4 * 60 * 60)) + .min_message_latency(Duration::from_millis(2)) + .max_message_latency(Duration::from_millis(30)) + .tcp_capacity(10_000) + .tick_duration(Duration::from_millis(1)) + .epoch(SystemTime::UNIX_EPOCH); + if fail_rate > 0.0 { + builder.fail_rate(fail_rate); + } + builder.build() +} + +fn log_host_exit(host: &str) -> impl Fn(&Box) + use<'_> { + move |err| tracing::error!(host, "host exited with error: {err}") +} + +fn init_tracing() { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .with(tracing_subscriber::fmt::layer().with_timer(SimStepTimeFormat)) + .init(); +} + +/// Timestamps log lines with simulated elapsed time and an event ordinal +/// instead of wall-clock time, keeping output stable across runs of the same +/// seed (a prerequisite for the determinism meta test). +struct SimStepTimeFormat; + +impl tracing_subscriber::fmt::time::FormatTime for SimStepTimeFormat { + fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result { + static EVENT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let event = EVENT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let step = turmoil::sim_elapsed().unwrap_or_default().as_millis(); + write!(w, "[s{step} e{event}]") + } +} diff --git a/sim/src/meta.rs b/sim/src/meta.rs new file mode 100644 index 00000000..4db58c8a --- /dev/null +++ b/sim/src/meta.rs @@ -0,0 +1,268 @@ +//! Determinism meta-test: run the same simulation twice and require +//! byte-identical output. +//! +//! Spawns this binary as two concurrent child processes with identical +//! arguments, each in its own temp directory, and compares stdout/stderr in a +//! streaming fashion: dedicated threads read both pipes in lockstep, one line +//! at a time, so memory stays constant regardless of output volume (trace +//! logging produces tens of MB). On the first divergence both child processes +//! are terminated immediately. Exit statuses and any produced `history.*.jsonl` files +//! are compared as well. +//! +//! Log lines use simulated time rather than wall-clock time (see +//! `SimStepTimeFormat`), so no normalization is needed. + +use std::{ + collections::BTreeSet, + fs::{self, File}, + io::Read, + path::Path, + process::{Child, Command, Stdio}, + sync::mpsc, +}; + +use eyre::{WrapErr, bail, ensure, eyre}; +use tracing::info; + +#[derive(clap::Args, Debug)] +pub struct MetaArgs { + /// Arguments to run the child simulations with, passed through verbatim + /// (e.g. `sim meta linearizable --seed 42`). + #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)] + pub args: Vec, +} + +pub fn run(meta: MetaArgs, seed: u64, fail_rate: f64) -> eyre::Result<()> { + let exe = std::env::current_exe()?; + let base = std::env::temp_dir().join(format!("s2-sim-meta-{}", std::process::id())); + + // Global flags given before `meta` are consumed by our own parser; forward + // them to the children unless the trailing args already specify them. + let mut args = meta.args; + for (flag, value) in [ + ("--seed", seed.to_string()), + ("--fail-rate", fail_rate.to_string()), + ] { + if !args + .iter() + .any(|a| a == flag || a.starts_with(&format!("{flag}="))) + { + args.extend([flag.to_string(), value]); + } + } + + let dir_a = base.join("a"); + let dir_b = base.join("b"); + fs::create_dir_all(&dir_a)?; + fs::create_dir_all(&dir_b)?; + + info!(?args, "running both child simulations concurrently"); + let mut child_a = spawn_child(&exe, &args, &dir_a)?; + let mut child_b = spawn_child(&exe, &args, &dir_b)?; + + // Each thread compares one stream pair and reports its result as soon as + // it finishes — which is early, on the first mismatch. The other side may + // then be blocked on a full pipe; terminating the child processes + // unblocks it. + type ChildOutput = Box; + let (results_tx, results_rx) = mpsc::channel(); + let streams: [(&'static str, ChildOutput, ChildOutput); 2] = [ + ( + "stdout", + Box::new(pipe(child_a.stdout.take())?), + Box::new(pipe(child_b.stdout.take())?), + ), + ( + "stderr", + Box::new(pipe(child_a.stderr.take())?), + Box::new(pipe(child_b.stderr.take())?), + ), + ]; + for (name, pipe_a, pipe_b) in streams { + let results_tx = results_tx.clone(); + std::thread::spawn(move || { + let _ = results_tx.send(compare_streams(name, pipe_a, pipe_b)); + }); + } + drop(results_tx); + + let mut comparisons = Vec::new(); + let mut killed = false; + while let Ok(result) = results_rx.recv() { + let comparison = result?; + if comparison.first_mismatch.is_some() && !killed { + child_a.kill().ok(); + child_b.kill().ok(); + killed = true; + } + comparisons.push(comparison); + } + let status_a = child_a.wait()?; + let status_b = child_b.wait()?; + + let result = (|| -> eyre::Result<(usize, usize)> { + for comparison in &comparisons { + comparison.ensure_identical()?; + } + ensure!( + status_a == status_b, + "exit status differs: {status_a} vs {status_b}" + ); + ensure!(status_a.success(), "child simulations failed ({status_a})"); + compare_history_files(&dir_a, &dir_b) + })(); + + match &result { + Ok((history_files, history_lines)) => { + fs::remove_dir_all(&base).ok(); + let lines = |name| { + comparisons + .iter() + .find(|c| c.name == name) + .map_or(0, |c| c.lines) + }; + info!( + stdout_lines = lines("stdout"), + stderr_lines = lines("stderr"), + history_files, + history_lines, + "deterministic: both runs produced identical output" + ); + Ok(()) + } + Err(err) => { + tracing::error!(artifacts = %base.display(), "determinism violated: {err}"); + result.map(|_| ()) + } + } +} + +fn spawn_child(exe: &Path, args: &[String], dir: &Path) -> eyre::Result { + Command::new(exe) + .args(args) + .current_dir(dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .wrap_err("failed to spawn child simulation") +} + +fn pipe(pipe: Option) -> eyre::Result { + pipe.ok_or_else(|| eyre!("missing child pipe")) +} + +struct StreamComparison { + name: &'static str, + first_mismatch: Option, + /// Lines read from the first stream. + lines: usize, +} + +struct Mismatch { + line: usize, + a: String, + b: String, +} + +impl StreamComparison { + fn ensure_identical(&self) -> eyre::Result<()> { + if let Some(m) = &self.first_mismatch { + bail!( + "{} differs at line {}:\n a: {}\n b: {}", + self.name, + m.line, + m.a, + m.b, + ); + } + Ok(()) + } +} + +/// Compare two byte streams line by line, in lockstep, returning at the first +/// mismatch (the caller is expected to terminate the producing processes to +/// unblock the remaining stream). +fn compare_streams( + name: &'static str, + a: impl Read, + b: impl Read, +) -> eyre::Result { + let mut a = std::io::BufReader::with_capacity(64 * 1024, a); + let mut b = std::io::BufReader::with_capacity(64 * 1024, b); + let mut line_a = Vec::new(); + let mut line_b = Vec::new(); + let mut lines = 0; + + loop { + line_a.clear(); + line_b.clear(); + let read_a = read_line(&mut a, &mut line_a)?; + let read_b = read_line(&mut b, &mut line_b)?; + if read_a == 0 && read_b == 0 { + break; + } + if read_a > 0 { + lines += 1; + } + if line_a != line_b { + return Ok(StreamComparison { + name, + first_mismatch: Some(Mismatch { + line: lines, + a: preview(&line_a), + b: preview(&line_b), + }), + lines, + }); + } + } + + Ok(StreamComparison { + name, + first_mismatch: None, + lines, + }) +} + +fn read_line(reader: &mut impl std::io::BufRead, buf: &mut Vec) -> std::io::Result { + reader.read_until(b'\n', buf) +} + +fn preview(line: &[u8]) -> String { + let mut s = String::from_utf8_lossy(line.strip_suffix(b"\n").unwrap_or(line)).into_owned(); + s.truncate(512); + s +} + +/// Compare `history.*.jsonl` files produced by the two runs, streaming from +/// disk. Returns (file count, total lines). +fn compare_history_files(dir_a: &Path, dir_b: &Path) -> eyre::Result<(usize, usize)> { + let names_a = history_file_names(dir_a)?; + let names_b = history_file_names(dir_b)?; + ensure!( + names_a == names_b, + "history file sets differ: {names_a:?} vs {names_b:?}" + ); + let mut total_lines = 0; + for name in &names_a { + let cmp = compare_streams( + "history", + File::open(dir_a.join(name))?, + File::open(dir_b.join(name))?, + )?; + cmp.ensure_identical().wrap_err_with(|| name.clone())?; + total_lines += cmp.lines; + } + Ok((names_a.len(), total_lines)) +} + +fn history_file_names(dir: &Path) -> eyre::Result> { + let mut names = BTreeSet::new(); + for entry in fs::read_dir(dir)? { + let name = entry?.file_name().to_string_lossy().into_owned(); + if name.starts_with("history.") && name.ends_with(".jsonl") { + names.insert(name); + } + } + Ok(names) +} diff --git a/sim/src/net.rs b/sim/src/net.rs new file mode 100644 index 00000000..da6168c7 --- /dev/null +++ b/sim/src/net.rs @@ -0,0 +1,135 @@ +//! Networking adapters that bridge hyper/axum onto the turmoil simulated network. + +use std::{ + future::Future, + io, + pin::Pin, + task::{Context, Poll}, + time::Duration, +}; + +use hyper::rt::{Read, ReadBufCursor, Write}; +use hyper_util::{ + client::legacy::connect::{Connected, Connection}, + rt::{TokioExecutor, TokioIo}, + server::conn::auto, + service::TowerToHyperService, +}; +use tower::Service; +use tracing::debug; +use turmoil::net::{TcpListener, TcpStream}; + +/// A `hyper_util::client::legacy::connect::Connect` implementation that dials +/// over the turmoil simulated network, resolving turmoil host names. +/// +/// Usable both by the s2-sdk (via `S2::new_with_connector`) and by hyper +/// clients embedded in other adapters (see [`crate::object_store_http`]). +#[derive(Debug, Clone)] +pub struct TurmoilConnector; + +pub struct TurmoilConnection(TokioIo); + +impl Read for TurmoilConnection { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: ReadBufCursor<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_read(cx, buf) + } +} + +impl Write for TurmoilConnection { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.0).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_shutdown(cx) + } +} + +impl Connection for TurmoilConnection { + fn connected(&self) -> Connected { + Connected::new() + } +} + +impl Service for TurmoilConnector { + type Response = TurmoilConnection; + type Error = io::Error; + type Future = + Pin> + Send + 'static>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, uri: http::Uri) -> Self::Future { + // Under message loss, a dropped SYN would otherwise hang the connect + // future forever (turmoil does not retransmit); time out and let the + // caller retry. Timeouts use simulated time, so this is deterministic. + const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + Box::pin(async move { + let host = uri + .host() + .ok_or_else(|| io::Error::other("uri has no host"))?; + let port = uri.port_u16().unwrap_or(match uri.scheme_str() { + Some("https") => 443, + _ => 80, + }); + let stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect((host, port))) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + format!("connect timeout: {host}:{port}"), + ) + })??; + Ok(TurmoilConnection(TokioIo::new(stream))) + }) + } +} + +/// Serve an axum router on the turmoil network, on all interfaces of the +/// current turmoil host. +pub async fn serve(port: u16, app: axum::Router) -> turmoil::Result { + serve_hyper(port, TowerToHyperService::new(app)).await +} + +/// Serve a hyper service on the turmoil network, on all interfaces of the +/// current turmoil host. +pub async fn serve_hyper(port: u16, service: S) -> turmoil::Result +where + S: hyper::service::Service, Response = http::Response> + + Clone + + Send + + 'static, + S::Future: Send + 'static, + S::Error: Into>, + B: hyper::body::Body + Send + 'static, + B::Data: Send, + B::Error: Into>, +{ + let listener = TcpListener::bind(("0.0.0.0", port)).await?; + loop { + let (stream, peer) = listener.accept().await?; + let service = service.clone(); + tokio::spawn(async move { + if let Err(err) = auto::Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(TokioIo::new(stream), service) + .await + { + debug!(%peer, "connection error: {err}"); + } + }); + } +} diff --git a/sim/src/object_store_http.rs b/sim/src/object_store_http.rs new file mode 100644 index 00000000..b986c481 --- /dev/null +++ b/sim/src/object_store_http.rs @@ -0,0 +1,73 @@ +//! An `object_store` HTTP connector that routes requests over the turmoil +//! simulated network, instead of object_store's default reqwest client (which +//! would dial real sockets and escape the simulation). +//! +//! This is what lets s2-lite's slatedb backend talk to the mock S3 host. + +use std::time::Duration; + +use async_trait::async_trait; +use http_body_util::BodyExt; +use hyper_util::{client::legacy::Client, rt::TokioExecutor}; +use slatedb::object_store::{ + self, + client::{ + ClientOptions, HttpClient, HttpConnector, HttpError, HttpErrorKind, HttpRequest, + HttpRequestBody, HttpResponse, HttpService, + }, +}; + +use crate::net::TurmoilConnector; + +/// Factory handed to `AmazonS3Builder::with_http_connector`. +#[derive(Debug, Default)] +pub struct TurmoilHttpConnector; + +impl HttpConnector for TurmoilHttpConnector { + fn connect(&self, _options: &ClientOptions) -> object_store::Result { + let client = Client::builder(TokioExecutor::new()).build(TurmoilConnector); + Ok(HttpClient::new(TurmoilHttpService { client })) + } +} + +#[derive(Debug)] +struct TurmoilHttpService { + client: Client, +} + +#[async_trait] +impl HttpService for TurmoilHttpService { + async fn call(&self, req: HttpRequest) -> Result { + // Under message loss an in-flight request on an established connection + // can stall forever (turmoil does not retransmit); time out so the + // object_store retry layer kicks in. Simulated time, so deterministic. + const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + tokio::time::timeout(REQUEST_TIMEOUT, self.call_inner(req)) + .await + .map_err(|_| { + HttpError::new( + HttpErrorKind::Timeout, + std::io::Error::new(std::io::ErrorKind::TimedOut, "request timeout"), + ) + })? + } +} + +impl TurmoilHttpService { + async fn call_inner(&self, req: HttpRequest) -> Result { + let response = self + .client + .request(req) + .await + .map_err(|err| HttpError::new(HttpErrorKind::Connect, err))?; + let (parts, body) = response.into_parts(); + // hyper's `Incoming` body is not `Sync`, which `HttpResponseBody::new` + // requires; responses in simulation are small, so buffer them. + let bytes = body + .collect() + .await + .map_err(|err| HttpError::new(HttpErrorKind::Interrupted, err))? + .to_bytes(); + Ok(HttpResponse::from_parts(parts, bytes.into())) + } +} diff --git a/sim/src/s3.rs b/sim/src/s3.rs new file mode 100644 index 00000000..58029f94 --- /dev/null +++ b/sim/src/s3.rs @@ -0,0 +1,325 @@ +//! Mock S3 service, run as a turmoil host. +//! +//! The S3 wire protocol (routing, XML, header parsing) is handled by the +//! `s3s` crate; this module implements the [`s3s::S3`] trait over an +//! in-memory map, covering the subset of operations slatedb's `object_store` +//! S3 client uses. Unimplemented operations default to a `NotImplemented` +//! error, which surfaces loudly if slatedb starts relying on something new +//! (likely candidates: multipart uploads for large SSTs, CopyObject, +//! batch DeleteObjects). +//! +//! No auth is configured, so requests are accepted without signature checks. + +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, + time::SystemTime, +}; + +use bytes::Bytes; +use futures::StreamExt as _; +use s3s::{ + S3, S3Request, S3Response, S3Result, + dto::{self, ETag, ETagCondition, Timestamp}, + s3_error, + service::S3ServiceBuilder, +}; + +pub const HOST: &str = "s3"; +pub const PORT: u16 = 9000; +pub const BUCKET: &str = "sim-bucket"; +pub const ACCESS_KEY: &str = "sim-access-key"; +pub const SECRET_KEY: &str = "sim-secret-key"; + +pub fn endpoint() -> String { + format!("http://{HOST}:{PORT}") +} + +pub async fn serve() -> turmoil::Result { + let mut builder = S3ServiceBuilder::new(InMemoryS3::default()); + // s3s rejects signed requests outright if no auth provider is configured, + // and the object_store client always signs. + builder.set_auth(s3s::auth::SimpleAuth::from_single(ACCESS_KEY, SECRET_KEY)); + let service = builder.build(); + crate::net::serve_hyper(PORT, service).await +} + +#[derive(Debug, Clone, Default)] +struct InMemoryS3 { + objects: Arc>>, +} + +#[derive(Debug, Clone)] +struct StoredObject { + data: Bytes, + etag: ETag, + last_modified: Timestamp, + /// User metadata (`x-amz-meta-*`). Load-bearing: slatedb stamps each write + /// with a `slatedbputid` ULID and, after a put whose response was lost, + /// re-reads the object to check whether that write was in fact its own + /// (vs. a competing writer). Dropping metadata makes slatedb misread its + /// own retried write as a fence and shut down permanently. + metadata: Option, +} + +impl StoredObject { + fn new(data: Bytes, metadata: Option) -> Self { + Self { + etag: ETag::Strong(blake3::hash(&data).to_hex().to_string()), + // SystemTime is shadowed by mad-turmoil, so this is simulated + // (deterministic) time. + last_modified: SystemTime::now().into(), + data, + metadata, + } + } +} + +fn check_bucket(bucket: &str) -> S3Result<()> { + if bucket == BUCKET { + Ok(()) + } else { + Err(s3_error!( + NoSuchBucket, + "only bucket {BUCKET:?} exists in the simulation" + )) + } +} + +/// Whether a parsed `If-Match`/`If-None-Match` condition matches the current +/// state of the object. s3s parses the headers; enforcement is ours. +fn condition_matches(cond: &ETagCondition, existing: Option<&StoredObject>) -> bool { + match (cond, existing) { + (_, None) => false, + (ETagCondition::Any, Some(_)) => true, + (ETagCondition::ETag(etag), Some(object)) => etag.value() == object.etag.value(), + } +} + +async fn collect_body(body: Option) -> S3Result { + let Some(mut blob) = body else { + return Ok(Bytes::new()); + }; + let mut buf = Vec::new(); + while let Some(chunk) = blob.next().await { + let chunk = chunk.map_err(|err| s3_error!(InternalError, "failed to read body: {err}"))?; + buf.extend_from_slice(&chunk); + } + Ok(buf.into()) +} + +#[async_trait::async_trait] +impl S3 for InMemoryS3 { + async fn put_object( + &self, + req: S3Request, + ) -> S3Result> { + let input = req.input; + check_bucket(&input.bucket)?; + let data = collect_body(input.body).await?; + + let mut objects = self.objects.lock().expect("mutex poisoned"); + let existing = objects.get(&input.key); + // `If-None-Match: *` is slatedb's put-if-absent (manifest fencing); + // `If-Match: ` is its CAS update. + if let Some(cond) = &input.if_none_match + && condition_matches(cond, existing) + { + return Err(s3_error!(PreconditionFailed)); + } + if let Some(cond) = &input.if_match + && !condition_matches(cond, existing) + { + return Err(s3_error!(PreconditionFailed)); + } + + let size = data.len() as i64; + let object = StoredObject::new(data, input.metadata); + let etag = object.etag.clone(); + objects.insert(input.key, object); + + Ok(S3Response::new(dto::PutObjectOutput { + e_tag: Some(etag), + size: Some(size), + ..Default::default() + })) + } + + async fn get_object( + &self, + req: S3Request, + ) -> S3Result> { + let input = req.input; + check_bucket(&input.bucket)?; + if input.if_match.is_some() + || input.if_none_match.is_some() + || input.if_modified_since.is_some() + || input.if_unmodified_since.is_some() + { + return Err(s3_error!(NotImplemented, "conditional GET not implemented")); + } + + let objects = self.objects.lock().expect("mutex poisoned"); + let Some(object) = objects.get(&input.key) else { + return Err(s3_error!(NoSuchKey)); + }; + + let total = object.data.len() as u64; + let (data, content_range) = match input.range { + None => (object.data.clone(), None), + Some(range) => { + let last_valid = total.saturating_sub(1); + let (first, last) = match range { + dto::Range::Int { first, last } => { + (first, last.unwrap_or(last_valid).min(last_valid)) + } + dto::Range::Suffix { length } => (total.saturating_sub(length), last_valid), + }; + if first >= total || first > last { + return Err(s3_error!(InvalidRange, "range {range:?} of {total} bytes")); + } + let data = object.data.slice(first as usize..=last as usize); + (data, Some(format!("bytes {first}-{last}/{total}"))) + } + }; + + Ok(S3Response::new(dto::GetObjectOutput { + content_length: Some(data.len() as i64), + content_range, + body: Some(dto::StreamingBlob::from(s3s::Body::from(data))), + e_tag: Some(object.etag.clone()), + last_modified: Some(object.last_modified.clone()), + accept_ranges: Some("bytes".to_owned()), + metadata: object.metadata.clone(), + ..Default::default() + })) + } + + async fn head_object( + &self, + req: S3Request, + ) -> S3Result> { + let input = req.input; + check_bucket(&input.bucket)?; + + let objects = self.objects.lock().expect("mutex poisoned"); + let Some(object) = objects.get(&input.key) else { + return Err(s3_error!(NoSuchKey)); + }; + + Ok(S3Response::new(dto::HeadObjectOutput { + content_length: Some(object.data.len() as i64), + e_tag: Some(object.etag.clone()), + last_modified: Some(object.last_modified.clone()), + accept_ranges: Some("bytes".to_owned()), + metadata: object.metadata.clone(), + ..Default::default() + })) + } + + async fn delete_object( + &self, + req: S3Request, + ) -> S3Result> { + let input = req.input; + check_bucket(&input.bucket)?; + // S3 deletes are idempotent: deleting a missing key succeeds. + self.objects + .lock() + .expect("mutex poisoned") + .remove(&input.key); + Ok(S3Response::new(dto::DeleteObjectOutput::default())) + } + + async fn list_objects_v2( + &self, + req: S3Request, + ) -> S3Result> { + let input = req.input; + check_bucket(&input.bucket)?; + + let prefix = input.prefix.clone().unwrap_or_default(); + let max_keys = input.max_keys.unwrap_or(1000).clamp(1, 1000) as usize; + // Our continuation token is the last underlying key consumed by the + // previous page; both it and start-after mean "resume strictly after". + let after = match (input.continuation_token.clone(), input.start_after.clone()) { + (Some(token), Some(start_after)) => Some(token.max(start_after)), + (token, start_after) => token.or(start_after), + }; + + let objects = self.objects.lock().expect("mutex poisoned"); + let mut contents = Vec::new(); + let mut common_prefixes: Vec = Vec::new(); + let mut is_truncated = false; + let mut next_continuation_token = None; + // The greatest key consumed into the response so far; becomes the + // continuation token if we truncate. + let mut last_consumed: Option = None; + + let mut iter = objects.range(prefix.clone()..).peekable(); + while let Some((key, object)) = iter.next() { + if !key.starts_with(&prefix) { + break; + } + if let Some(after) = &after + && key <= after + { + continue; + } + if contents.len() + common_prefixes.len() >= max_keys { + is_truncated = true; + next_continuation_token = last_consumed.clone(); + break; + } + + let rollup = input.delimiter.as_ref().and_then(|delimiter| { + let rest = &key[prefix.len()..]; + rest.find(delimiter.as_str()) + .map(|idx| format!("{prefix}{}{delimiter}", &rest[..idx])) + }); + match rollup { + Some(group) => { + // Consume the whole group so the continuation token can + // remain a plain key. + last_consumed = Some(key.clone()); + while let Some((key, _)) = iter.peek() { + if !key.starts_with(&group) { + break; + } + last_consumed = Some((*key).clone()); + iter.next(); + } + common_prefixes.push(dto::CommonPrefix { + prefix: Some(group), + }); + } + None => { + last_consumed = Some(key.clone()); + contents.push(dto::Object { + key: Some(key.clone()), + size: Some(object.data.len() as i64), + e_tag: Some(object.etag.clone()), + last_modified: Some(object.last_modified.clone()), + ..Default::default() + }); + } + } + } + + let key_count = (contents.len() + common_prefixes.len()) as i32; + Ok(S3Response::new(dto::ListObjectsV2Output { + name: Some(input.bucket), + prefix: input.prefix, + delimiter: input.delimiter, + start_after: input.start_after, + continuation_token: input.continuation_token, + max_keys: Some(max_keys as i32), + key_count: Some(key_count), + contents: Some(contents), + common_prefixes: Some(common_prefixes), + is_truncated: Some(is_truncated), + next_continuation_token, + ..Default::default() + })) + } +} diff --git a/sim/src/scenarios/linearizable.rs b/sim/src/scenarios/linearizable.rs new file mode 100644 index 00000000..6142dc26 --- /dev/null +++ b/sim/src/scenarios/linearizable.rs @@ -0,0 +1,74 @@ +//! Concurrent clients recording an operation history for linearizability +//! checking. +//! +//! The workload logic lives in `s2_verification::history`: each logical client +//! randomly mixes appends, reads, and check-tails against one stream, emitting +//! a `LabeledEvent` at every operation start and finish. Appends that fail +//! *indefinitely* (the client cannot know whether the records became durable) +//! are deferred and flushed only after all clients finish — the checker treats +//! them as "may or may not have happened". +//! +//! The resulting `history..jsonl` is verified offline by the Porcupine +//! model in s2-verification (`s2-porcupine -file=...`). + +use std::sync::{Arc, atomic::AtomicU64}; + +use s2_verification::history::{LabeledEvent, client, fencing_token_client, match_seq_num_client}; +use tokio::sync::{Barrier, mpsc::UnboundedSender}; +use tracing::info; + +#[derive(clap::Args, Debug, Clone)] +pub struct Config { + /// Number of concurrent logical clients. + #[arg(long, default_value_t = 3)] + pub clients: usize, + + /// Operations attempted per client. + #[arg(long, default_value_t = 50)] + pub ops_per_client: usize, +} + +pub async fn workload( + config: Config, + history_tx: UnboundedSender, +) -> turmoil::Result { + let stream = super::provision_stream().await.map_err(|e| e.to_string())?; + info!(basin = super::BASIN, stream = super::STREAM, "provisioned"); + + let client_ids = Arc::new(AtomicU64::new(0)); + let op_ids = Arc::new(AtomicU64::new(0)); + let barrier = Arc::new(Barrier::new(config.clients)); + + let mut tasks = Vec::new(); + for i in 0..config.clients { + let stream = stream.clone(); + let client_ids = client_ids.clone(); + let op_ids = op_ids.clone(); + let history_tx = history_tx.clone(); + let barrier = barrier.clone(); + let num_ops = config.ops_per_client; + tasks.push(tokio::spawn(async move { + let tx = history_tx.clone(); + let deferred = match i % 3 { + 0 => client(num_ops, stream, client_ids, op_ids, tx).await, + 1 => match_seq_num_client(num_ops, stream, client_ids, op_ids, tx).await, + _ => fencing_token_client(num_ops, stream, client_ids, op_ids, tx).await, + } + .map_err(|e| e.to_string())?; + info!(task = i, deferred = deferred.len(), "client workflow done"); + + // Hold deferred (indefinite-failure) events until every client is + // done issuing operations, so they land at the end of the history. + barrier.wait().await; + for event in deferred { + history_tx.send(event).map_err(|e| e.to_string())?; + } + Ok::<_, String>(()) + })); + } + + for task in tasks { + task.await??; + } + Ok(()) +} diff --git a/sim/src/scenarios/mod.rs b/sim/src/scenarios/mod.rs new file mode 100644 index 00000000..f2034e35 --- /dev/null +++ b/sim/src/scenarios/mod.rs @@ -0,0 +1,81 @@ +//! Simulation scenarios, selected via CLI subcommand. +//! +//! A scenario is an async fn run as the turmoil client named "workload"; the +//! simulation ends when it returns. Hosts (s3, s2-lite) are registered by +//! `main` and shared by all scenarios. + +pub mod linearizable; +pub mod smoke; + +use std::num::NonZeroU32; + +use s2_sdk::{ + S2, S2Stream, + types::{ + AppendRetryPolicy, BasinName, EnsureBasinInput, EnsureStreamInput, RetryConfig, S2Config, + S2Endpoints, StreamName, + }, +}; + +use crate::{lite_host, net}; + +pub const BASIN: &str = "sim-basin"; +pub const STREAM: &str = "sim-stream"; + +/// Provisioning races s2-lite's startup, which under injected faults can take a +/// while; it needs a much larger retry budget than workload operations. +const PROVISION_ATTEMPTS: u32 = 30; + +fn client(retry: RetryConfig) -> eyre::Result { + let endpoints = S2Endpoints::new( + lite_host::endpoint().parse()?, + lite_host::endpoint().parse()?, + )?; + Ok(S2::new_with_connector( + S2Config::new("unused-token") + .with_endpoints(endpoints) + .with_retry(retry), + net::TurmoilConnector, + )?) +} + +/// The workload's SDK client. +/// +/// Retries are deliberately bounded (SDK default): under injected faults the +/// server can wedge, and a workload op that can't complete should surface as an +/// error the workload records and the checker models — not retry forever. +/// +/// `AppendRetryPolicy::NoSideEffects` is required for linearizability: the +/// default `All` re-sends appends of unknown outcome, which can duplicate +/// records. Here an append makes at most one effective attempt; a maybe-applied +/// failure is recorded as indefinite. +pub fn s2_client() -> eyre::Result { + client(RetryConfig::new().with_append_retry_policy(AppendRetryPolicy::NoSideEffects)) +} + +/// Create the scenario's basin and stream, returning a stream handle for the +/// workload to drive. +/// +/// Provisioning uses a separate client with a large retry budget to outlast +/// s2-lite's startup, and the idempotent `ensure_*` operations so a retry after +/// a lost response is safe (a `create_*` retry would fail with "already +/// exists"). The returned stream handle, by contrast, is on the bounded +/// [`s2_client`] so the workload's own operations don't inherit provisioning's +/// generous retries. +pub async fn provision_stream() -> eyre::Result { + let basin_name: BasinName = BASIN.parse()?; + let stream_name: StreamName = STREAM.parse()?; + + let retry = + RetryConfig::new().with_max_attempts(NonZeroU32::new(PROVISION_ATTEMPTS).expect("nonzero")); + let provisioner = client(retry)?; + provisioner + .ensure_basin(EnsureBasinInput::new(basin_name.clone())) + .await?; + provisioner + .basin(basin_name.clone()) + .ensure_stream(EnsureStreamInput::new(stream_name.clone())) + .await?; + + Ok(s2_client()?.basin(basin_name).stream(stream_name)) +} diff --git a/sim/src/scenarios/smoke.rs b/sim/src/scenarios/smoke.rs new file mode 100644 index 00000000..83755669 --- /dev/null +++ b/sim/src/scenarios/smoke.rs @@ -0,0 +1,22 @@ +//! Minimal end-to-end check: create a basin and stream, append a couple of +//! records, read them back. + +use s2_sdk::types::{AppendInput, AppendRecord, AppendRecordBatch, ReadInput}; +use tracing::info; + +pub async fn workload() -> turmoil::Result { + let stream = super::provision_stream().await.map_err(|e| e.to_string())?; + info!(basin = super::BASIN, stream = super::STREAM, "provisioned"); + + let batch = AppendRecordBatch::try_from_iter([ + AppendRecord::new("hello")?, + AppendRecord::new("turmoil")?, + ])?; + let ack = stream.append(AppendInput::new(batch)).await?; + info!(?ack, "appended records"); + + let batch = stream.read(ReadInput::new()).await?; + info!(?batch, "read records"); + + Ok(()) +} diff --git a/storage/CHANGELOG.md b/storage/CHANGELOG.md new file mode 100644 index 00000000..aeb91386 --- /dev/null +++ b/storage/CHANGELOG.md @@ -0,0 +1,86 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.2.6] - 2026-09-25 + +### Miscellaneous Tasks + +- Updated the following local packages: s2-common + + + +## [0.2.5] - 2026-09-22 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.2.4] - 2026-07-22 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.2.3] - 2026-07-07 + +### Miscellaneous Tasks + +- Updated the following local packages: s2-common + + + +## [0.2.2] - 2026-07-06 + +### Miscellaneous Tasks + +- Upgrade aes-gcm ([#615](https://github.com/s2-streamstore/s2/issues/615)) + + + +## [0.2.1] - 2026-07-02 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.2.0] - 2026-06-22 + +### Miscellaneous Tasks + +- Remove unused StoredRecord::encryption_algorithm and EncryptedRecord::algorithm ([#579](https://github.com/s2-streamstore/s2/issues/579)) + + + +## [0.1.2] - 2026-06-22 + +### Miscellaneous Tasks + +- Updated the following local packages: s2-common + + + +## [0.1.1] - 2026-06-13 + +### Features + +- Add stored record test fixture helper ([#543](https://github.com/s2-streamstore/s2/issues/543)) + + + +## [0.1.0] - 2026-06-12 + +### Refactor + +- Split storage internals from common ([#533](https://github.com/s2-streamstore/s2/issues/533)) + + +# Changelog + +All notable changes to this project will be documented in this file. diff --git a/storage/Cargo.toml b/storage/Cargo.toml new file mode 100644 index 00000000..1fccbb72 --- /dev/null +++ b/storage/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "s2-storage" +version = "0.2.6" +description = "Storage-layer internals shared by S2 server implementations" +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords = ["s2", "durable", "streams", "storage"] + +[features] +test-util = [] + +[dependencies] +aegis = { workspace = true } +aes-gcm = { workspace = true, features = ["zeroize"] } +blake3 = { workspace = true } +bytes = { workspace = true } +rand = { workspace = true } +s2-common = { workspace = true } +secrecy = { workspace = true } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +rstest = { workspace = true } diff --git a/storage/src/bash.rs b/storage/src/bash.rs new file mode 100644 index 00000000..22fe52f1 --- /dev/null +++ b/storage/src/bash.rs @@ -0,0 +1,99 @@ +use bytes::Bytes; + +/// BLAKE3 hash (32 bytes) of any number of fields. +/// +/// Default SerDe implementation uses hex representation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Bash(blake3::Hash); + +impl Bash { + pub const LEN: usize = 32; + + /// Hashes components separated by a delimiter byte. + /// Callers must ensure components do not contain the delimiter. + pub fn delimited(components: &[&[u8]], delimiter: u8) -> Self { + let mut hasher = blake3::Hasher::new(); + for component in components { + hasher.update(component); + hasher.update(&[delimiter]); + } + Self(hasher.finalize()) + } + + /// Hashes components with length prefixes to avoid separator ambiguity. + pub fn length_prefixed(components: &[&[u8]]) -> Self { + let mut hasher = blake3::Hasher::new(); + for component in components { + hasher.update(&(component.len() as u64).to_le_bytes()); + hasher.update(component); + } + Self(hasher.finalize()) + } + + pub fn as_bytes(&self) -> &[u8; Bash::LEN] { + self.0.as_bytes() + } +} + +impl std::fmt::Display for Bash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0.to_hex().as_str()) + } +} + +impl AsRef<[u8]> for Bash { + fn as_ref(&self) -> &[u8] { + self.as_bytes() + } +} + +impl From for [u8; Bash::LEN] { + fn from(bash: Bash) -> Self { + bash.0.into() + } +} + +impl From<[u8; Bash::LEN]> for Bash { + fn from(bytes: [u8; Bash::LEN]) -> Self { + Self(blake3::Hash::from_bytes(bytes)) + } +} + +impl From for Bytes { + fn from(bash: Bash) -> Self { + Bytes::copy_from_slice(bash.as_bytes()) + } +} + +impl serde::Serialize for Bash { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0.to_hex()) + } +} + +impl<'de> serde::Deserialize<'de> for Bash { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let hash = blake3::Hash::from_hex(s.as_bytes()).map_err(serde::de::Error::custom)?; + Ok(Self(hash)) + } +} + +#[cfg(test)] +mod tests { + use super::Bash; + + #[test] + fn bash_len_prefixed_components_are_unambiguous() { + let bash1 = Bash::length_prefixed(&[b"a\0", b"b"]); + let bash2 = Bash::length_prefixed(&[b"a", b"\0b"]); + + assert_ne!(bash1, bash2); + } +} diff --git a/storage/src/lib.rs b/storage/src/lib.rs new file mode 100644 index 00000000..b3b4111e --- /dev/null +++ b/storage/src/lib.rs @@ -0,0 +1,4 @@ +//! Storage/backend shared code for S2. + +pub mod bash; +pub mod record; diff --git a/storage/src/record/batcher.rs b/storage/src/record/batcher.rs new file mode 100644 index 00000000..bcaf6b35 --- /dev/null +++ b/storage/src/record/batcher.rs @@ -0,0 +1,447 @@ +use std::iter::FusedIterator; + +use s2_common::{ + caps, + read_extent::{EvaluatedReadLimit, ReadLimit, ReadUntil}, + record::{Metered, MeteredSize, Sequenced}, +}; + +use super::StoredRecord; + +pub struct RecordBatch +where + T: MeteredSize, +{ + pub records: Metered>>, + pub is_terminal: bool, +} + +impl std::fmt::Debug for RecordBatch +where + T: MeteredSize, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RecordBatch") + .field("num_records", &self.records.len()) + .field("metered_size", &self.records.metered_size()) + .field("is_terminal", &self.is_terminal) + .finish() + } +} + +pub struct RecordBatcher +where + T: MeteredSize, + I: Iterator>, E>>, +{ + record_iterator: I, + buffered_records: Metered>>, + buffered_error: Option, + read_limit: EvaluatedReadLimit, + until: ReadUntil, + is_terminated: bool, +} + +fn make_records(read_limit: &EvaluatedReadLimit) -> Metered>> +where + T: MeteredSize, +{ + match read_limit { + EvaluatedReadLimit::Remaining(limit) => { + Metered::with_capacity(limit.count().map_or(caps::RECORD_BATCH_MAX.count, |n| { + n.min(caps::RECORD_BATCH_MAX.count) + })) + } + EvaluatedReadLimit::Exhausted => Metered::default(), + } +} + +impl RecordBatcher +where + T: MeteredSize, + I: Iterator>, E>>, +{ + pub fn new(record_iterator: I, read_limit: ReadLimit, until: ReadUntil) -> Self { + let read_limit = read_limit.remaining(0, 0); + Self { + record_iterator, + buffered_records: make_records(&read_limit), + buffered_error: None, + read_limit, + until, + is_terminated: false, + } + } + + fn iter_next(&mut self) -> Option, E>> { + let EvaluatedReadLimit::Remaining(remaining_limit) = self.read_limit else { + return None; + }; + + let mut stashed_record = None; + while self.buffered_error.is_none() { + match self.record_iterator.next() { + Some(Ok(record)) => { + if remaining_limit.deny( + self.buffered_records.len() + 1, + self.buffered_records.metered_size() + record.metered_size(), + ) || self.until.deny(record.position().timestamp) + { + self.read_limit = EvaluatedReadLimit::Exhausted; + break; + } + + if self.buffered_records.len() == caps::RECORD_BATCH_MAX.count + || self.buffered_records.metered_size() + record.metered_size() + > caps::RECORD_BATCH_MAX.bytes + { + // It would would violate the per-batch limits. + stashed_record = Some(record); + break; + } + + self.buffered_records.push(record); + } + Some(Err(err)) => { + self.buffered_error = Some(err); + break; + } + None => { + break; + } + } + } + if !self.buffered_records.is_empty() { + self.read_limit = match self.read_limit { + EvaluatedReadLimit::Remaining(read_limit) => read_limit.remaining( + self.buffered_records.len(), + self.buffered_records.metered_size(), + ), + EvaluatedReadLimit::Exhausted => EvaluatedReadLimit::Exhausted, + }; + let is_terminal = self.read_limit == EvaluatedReadLimit::Exhausted; + let records = std::mem::replace( + &mut self.buffered_records, + if is_terminal || self.buffered_error.is_some() { + Metered::default() + } else { + let mut buf = make_records(&self.read_limit); + if let Some(record) = stashed_record.take() { + buf.push(record); + } + buf + }, + ); + return Some(Ok(RecordBatch { + records, + is_terminal, + })); + } + if let Some(err) = self.buffered_error.take() { + return Some(Err(err)); + } + None + } +} + +impl Iterator for RecordBatcher +where + T: MeteredSize, + I: Iterator>, E>>, +{ + type Item = Result, E>; + + fn next(&mut self) -> Option { + if self.is_terminated { + return None; + } + let item = self.iter_next(); + self.is_terminated = matches!(&item, None | Some(Err(_))); + item + } +} + +impl FusedIterator for RecordBatcher +where + T: MeteredSize, + I: Iterator>, E>>, +{ +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use s2_common::{ + caps, + read_extent::{ReadLimit, ReadUntil}, + record::{ + CommandRecord, EnvelopeRecord, Metered, MeteredExt, MeteredSize, Record, SeqNum, + Sequenced, SequencedRecord, StreamPosition, Timestamp, + }, + }; + + use crate::record::{ + RecordBatch, RecordBatcher, StoredRecord, StoredRecordDecodeError, StoredRecordIterator, + StoredSequencedBytes, StoredSequencedRecord, encode_stored_record, + }; + + fn test_logical_record(seq_num: SeqNum, timestamp: Timestamp) -> SequencedRecord { + Record::Command(CommandRecord::Trim(seq_num)) + .metered() + .sequenced(StreamPosition { seq_num, timestamp }) + .into_inner() + } + + fn test_record(seq_num: SeqNum, timestamp: Timestamp) -> StoredSequencedRecord { + StoredRecord::from(Record::Command(CommandRecord::Trim(seq_num))) + .metered() + .sequenced(StreamPosition { seq_num, timestamp }) + .into_inner() + } + + fn test_large_record( + seq_num: SeqNum, + timestamp: Timestamp, + body_len: usize, + ) -> StoredSequencedRecord { + StoredRecord::from(Record::Envelope( + EnvelopeRecord::try_from_parts(vec![], Bytes::from(vec![0; body_len])).unwrap(), + )) + .metered() + .sequenced(StreamPosition { seq_num, timestamp }) + .into_inner() + } + + fn to_iter( + records: Vec, + ) -> impl Iterator, StoredRecordDecodeError>> { + records.into_iter().map(Metered::from).map(Ok) + } + + fn to_logical_iter( + records: Vec, + ) -> impl Iterator, StoredRecordDecodeError>> { + records.into_iter().map(Metered::from).map(Ok) + } + + fn to_stored_bytes_iter( + records: Vec, + ) -> impl Iterator> { + records + .into_iter() + .map(|record| { + let (position, record) = record.into_parts(); + Sequenced::new(position, encode_stored_record((&record).metered())) + }) + .map(Ok) + } + + fn assert_batch(batch: &RecordBatch, expected: &[StoredSequencedRecord], is_terminal: bool) { + assert_eq!(batch.is_terminal, is_terminal); + assert_eq!(batch.records.len(), expected.len()); + let expected_size: usize = expected.iter().map(|r| r.metered_size()).sum(); + assert_eq!(batch.records.metered_size(), expected_size); + for (actual, expected) in batch.records.iter().zip(expected.iter()) { + assert_eq!(actual, expected); + } + } + + #[test] + fn collects_records_until_iterator_ends() { + let expected = vec![test_record(1, 10), test_record(2, 11), test_record(3, 12)]; + let mut batcher = RecordBatcher::new( + to_iter(expected.clone()), + ReadLimit::Unbounded, + ReadUntil::Unbounded, + ); + let batch = batcher.next().expect("batch expected").expect("ok batch"); + assert_batch(&batch, &expected, false); + assert!(batcher.next().is_none()); + } + + #[test] + fn generic_batcher_collects_logical_records() { + let expected = vec![ + test_logical_record(1, 10), + test_logical_record(2, 11), + test_logical_record(3, 12), + ]; + let mut batcher = RecordBatcher::new( + to_logical_iter(expected.clone()), + ReadLimit::Unbounded, + ReadUntil::Unbounded, + ); + + let batch = batcher.next().expect("batch expected").expect("ok batch"); + assert!(!batch.is_terminal); + assert_eq!(batch.records.len(), expected.len()); + let expected_size: usize = expected.iter().map(|r| r.metered_size()).sum(); + assert_eq!(batch.records.metered_size(), expected_size); + for (actual, expected) in batch.records.iter().zip(expected.iter()) { + assert_eq!(actual, expected); + } + assert!(batcher.next().is_none()); + } + + #[test] + fn stops_at_count_read_limit() { + let expected = vec![test_record(1, 10), test_record(2, 11), test_record(3, 12)]; + let mut batcher = RecordBatcher::new( + to_iter(expected.clone()), + ReadLimit::Count(2), + ReadUntil::Unbounded, + ); + + let batch = batcher.next().expect("batch expected").expect("ok batch"); + assert_batch(&batch, &expected[..2], true); + assert!(batcher.next().is_none()); + } + + #[test] + fn stops_at_byte_read_limit() { + let expected = vec![test_record(1, 10), test_record(2, 11)]; + let first_size = expected[0].metered_size(); + let mut batcher = RecordBatcher::new( + to_iter(expected.clone()), + ReadLimit::Bytes(first_size), + ReadUntil::Unbounded, + ); + + let batch = batcher.next().expect("batch expected").expect("ok batch"); + assert_batch(&batch, &expected[..1], true); + assert!(batcher.next().is_none()); + } + + #[test] + fn stops_at_timestamp_limit() { + let expected = vec![test_record(1, 10), test_record(2, 19), test_record(3, 20)]; + let mut batcher = RecordBatcher::new( + to_iter(expected.clone()), + ReadLimit::Unbounded, + ReadUntil::Timestamp(20), + ); + + let batch = batcher.next().expect("batch expected").expect("ok batch"); + assert_batch(&batch, &expected[..2], true); + assert!(batcher.next().is_none()); + } + + #[test] + fn splits_batches_when_caps_are_hit() { + let mut records = Vec::with_capacity(caps::RECORD_BATCH_MAX.count + 1); + for index in 0..=(caps::RECORD_BATCH_MAX.count as SeqNum) { + records.push(test_record(index, index + 10)); + } + let mut batcher = RecordBatcher::new( + to_iter(records.clone()), + ReadLimit::Unbounded, + ReadUntil::Unbounded, + ); + + let first_batch = batcher + .next() + .expect("first batch expected") + .expect("first batch ok"); + assert_batch( + &first_batch, + &records[..caps::RECORD_BATCH_MAX.count], + false, + ); + + let second_batch = batcher + .next() + .expect("second batch expected") + .expect("second batch ok"); + assert_batch( + &second_batch, + &records[caps::RECORD_BATCH_MAX.count..], + false, + ); + assert!(batcher.next().is_none()); + } + + #[test] + fn splits_batches_when_byte_cap_is_hit() { + let records = vec![ + test_large_record(1, 10, caps::RECORD_BATCH_MAX.bytes / 2 + 1), + test_large_record(2, 11, caps::RECORD_BATCH_MAX.bytes / 2 + 1), + ]; + assert!(records[0].metered_size() <= caps::RECORD_BATCH_MAX.bytes); + assert!(records[1].metered_size() <= caps::RECORD_BATCH_MAX.bytes); + assert!( + records[0].metered_size() + records[1].metered_size() > caps::RECORD_BATCH_MAX.bytes + ); + + let mut batcher = RecordBatcher::new( + to_iter(records.clone()), + ReadLimit::Unbounded, + ReadUntil::Unbounded, + ); + + let first_batch = batcher + .next() + .expect("first batch expected") + .expect("first batch ok"); + assert_batch(&first_batch, &records[..1], false); + + let second_batch = batcher + .next() + .expect("second batch expected") + .expect("second batch ok"); + assert_batch(&second_batch, &records[1..], false); + assert!(batcher.next().is_none()); + } + + #[test] + fn surfaces_decode_errors_after_draining_buffer() { + let records = vec![test_record(1, 10), test_record(2, 11)]; + let invalid_data = Sequenced::new( + StreamPosition { + seq_num: 3, + timestamp: 12, + }, + Bytes::new(), + ); + + let mut batcher = RecordBatcher::new( + StoredRecordIterator::new( + to_stored_bytes_iter(records.clone()).chain(std::iter::once(Ok(invalid_data))), + ), + ReadLimit::Unbounded, + ReadUntil::Unbounded, + ); + + let batch = batcher.next().expect("batch expected").expect("ok batch"); + assert_batch(&batch, &records, false); + + let error = batcher + .next() + .expect("error expected") + .expect_err("expected decode error"); + assert!(matches!( + error, + StoredRecordDecodeError::Truncated("MagicByte") + )); + assert!(batcher.next().is_none()); + } + + #[test] + fn surfaces_iterator_errors_immediately() { + let iterator = StoredRecordIterator::new(std::iter::once::< + Result, + >(Err( + StoredRecordDecodeError::InvalidValue("test", "boom"), + ))); + let mut batcher = RecordBatcher::new(iterator, ReadLimit::Unbounded, ReadUntil::Unbounded); + + let error = batcher + .next() + .expect("error expected") + .expect_err("expected iterator error"); + assert!(matches!( + error, + StoredRecordDecodeError::InvalidValue("test", "boom") + )); + assert!(batcher.next().is_none()); + } +} diff --git a/storage/src/record/codec.rs b/storage/src/record/codec.rs new file mode 100644 index 00000000..76b59121 --- /dev/null +++ b/storage/src/record/codec.rs @@ -0,0 +1,673 @@ +//! Stored record body encoding. +//! +//! Outer stored-record framing lives in `framing.rs`; this module encodes and +//! decodes the bytes that follow the magic byte and metered-size prefix. +//! +//! Command body layout: +//! +//! ```text +//! +-----------------+-----------------+ +//! | command_op: u8 | payload: bytes | +//! +-----------------+-----------------+ +//! ``` +//! +//! Envelope body layout: +//! +//! ```text +//! +-----------------+------------------------------+-------------------+ +//! | header_flag: u8 | num_headers: N bytes, if any | headers... | +//! +-----------------+------------------------------+-------------------+ +//! | body: remaining bytes | +//! +--------------------------------------------------------------------+ +//! +//! header_flag: +//! bits 7..6: reserved, must be 0 +//! bits 5..4: num_headers byte width, where 0 means no headers +//! bits 3..2: header name length byte width minus 1 +//! bits 1..0: header value length byte width minus 1 +//! +//! each header: +//! +------------------------+------------+-------------------------+-------------+ +//! | name_len: name_width | name bytes | value_len: value_width | value bytes | +//! +------------------------+------------+-------------------------+-------------+ +//! ``` +//! +//! Variable-width integers are big-endian. Header count uses 1-3 bytes when +//! present; header name/value lengths use 1-4 bytes. When the header-count +//! width is 0, the decoder treats the record as having no headers and the +//! remaining bytes are the body. + +use std::num::NonZeroU8; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use s2_common::record::{ + CommandOp, CommandPayloadError, CommandRecord, EnvelopeRecord, Header, HeaderValidationError, + RecordPartsError, +}; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum StoredRecordDecodeError { + #[error("truncated: {0}")] + Truncated(&'static str), + #[error("invalid value [{0}]: {1}")] + InvalidValue(&'static str, &'static str), +} + +pub(crate) trait WireEncode { + fn to_bytes(&self) -> Bytes { + let expected_size = self.encoded_size(); + let mut buf = BytesMut::with_capacity(expected_size); + self.encode_into(&mut buf); + assert_eq!(buf.len(), expected_size, "no reallocation"); + buf.freeze() + } + + fn encoded_size(&self) -> usize; + + fn encode_into(&self, buf: &mut impl BufMut); +} + +const COMMAND_ORDINAL_FENCE: u8 = 0; +const COMMAND_ORDINAL_TRIM: u8 = 1; + +fn command_op_ordinal(op: CommandOp) -> u8 { + match op { + CommandOp::Fence => COMMAND_ORDINAL_FENCE, + CommandOp::Trim => COMMAND_ORDINAL_TRIM, + } +} + +fn command_op_from_ordinal(ordinal: u8) -> Option { + match ordinal { + COMMAND_ORDINAL_FENCE => Some(CommandOp::Fence), + COMMAND_ORDINAL_TRIM => Some(CommandOp::Trim), + _ => None, + } +} + +impl From for StoredRecordDecodeError { + fn from(e: CommandPayloadError) -> Self { + match e { + CommandPayloadError::InvalidUtf8(_) => StoredRecordDecodeError::InvalidValue( + "CommandPayload", + "fencing token not valid utf8", + ), + CommandPayloadError::FencingTokenTooLong(_) => { + StoredRecordDecodeError::InvalidValue("CommandPayload", "fencing token too long") + } + CommandPayloadError::TrimPointSize(_) => { + StoredRecordDecodeError::InvalidValue("CommandPayload", "trim point size") + } + } + } +} + +impl WireEncode for CommandRecord { + fn encoded_size(&self) -> usize { + 1 + match self { + CommandRecord::Fence(token) => token.len(), + CommandRecord::Trim(trim_point) => size_of_val(trim_point), + } + } + + fn encode_into(&self, buf: &mut impl BufMut) { + buf.put_u8(command_op_ordinal(self.op())); + match self { + CommandRecord::Fence(token) => { + buf.put_slice(token.as_bytes()); + } + CommandRecord::Trim(trim_point) => { + buf.put_u64(*trim_point); + } + } + } +} + +pub(super) fn decode_command_record( + record: &[u8], +) -> Result { + if record.is_empty() { + return Err(StoredRecordDecodeError::Truncated("CommandOrdinal")); + } + let op = command_op_from_ordinal(record[0]).ok_or(StoredRecordDecodeError::InvalidValue( + "CommandOrdinal", + "unknown", + ))?; + CommandRecord::try_from_parts(op, &record[1..]).map_err(Into::into) +} + +const EMPTY_HEADER_FLAG: HeaderFlag = HeaderFlag { + num_headers_length_bytes: 0, + name_length_bytes: NonZeroU8::new(1).unwrap(), + value_length_bytes: NonZeroU8::new(1).unwrap(), +}; + +/// A compact per-envelope header layout byte. +/// +/// Header count width can be zero, which means there are no headers and no +/// encoded count follows. Name and value length widths are stored as width - 1 +/// because valid header length fields are always 1-4 bytes. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +struct HeaderFlag { + num_headers_length_bytes: u8, + name_length_bytes: NonZeroU8, + value_length_bytes: NonZeroU8, +} + +impl HeaderFlag { + const RESERVED_MASK: u8 = 0b1100_0000; + const NUM_HEADERS_LENGTH_MASK: u8 = 0b0011_0000; + const NUM_HEADERS_LENGTH_SHIFT: u8 = 4; + const NAME_LENGTH_MASK: u8 = 0b0000_1100; + const NAME_LENGTH_SHIFT: u8 = 2; + const VALUE_LENGTH_MASK: u8 = 0b0000_0011; +} + +impl From for u8 { + fn from(value: HeaderFlag) -> Self { + (value.num_headers_length_bytes << HeaderFlag::NUM_HEADERS_LENGTH_SHIFT) + | ((value.name_length_bytes.get() - 1) << HeaderFlag::NAME_LENGTH_SHIFT) + | (value.value_length_bytes.get() - 1) + } +} + +impl TryFrom for HeaderFlag { + type Error = &'static str; + + fn try_from(value: u8) -> Result { + if (value & HeaderFlag::RESERVED_MASK) != 0 { + return Err("reserved bit set"); + } + Ok(Self { + num_headers_length_bytes: (value & HeaderFlag::NUM_HEADERS_LENGTH_MASK) + >> HeaderFlag::NUM_HEADERS_LENGTH_SHIFT, + name_length_bytes: NonZeroU8::new( + ((value & HeaderFlag::NAME_LENGTH_MASK) >> HeaderFlag::NAME_LENGTH_SHIFT) + 1, + ) + .unwrap(), + value_length_bytes: NonZeroU8::new((value & HeaderFlag::VALUE_LENGTH_MASK) + 1) + .unwrap(), + }) + } +} + +const EMPTY_HEADERS_ENCODING_INFO: EncodingInfo = EncodingInfo { + headers_total_bytes: 0, + flag: EMPTY_HEADER_FLAG, +}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +struct EncodingInfo { + headers_total_bytes: usize, + flag: HeaderFlag, +} + +impl EncodingInfo { + fn for_record(record: &EnvelopeRecord) -> Self { + Self::from_header_sizing( + record.headers().len(), + record.headers_total_bytes(), + record.header_name_length_width_bytes(), + record.header_value_length_width_bytes(), + ) + .expect("envelope record headers should be validated") + } + + fn from_header_sizing( + header_count: usize, + headers_total_bytes: usize, + name_length_width_bytes: usize, + value_length_width_bytes: usize, + ) -> Result { + fn size_bytes_header_count(count: u64) -> Result { + let size = 8 - count.leading_zeros() / 8; + if size <= 3 { + Ok(size as u8) + } else { + Err(HeaderValidationError::TooMany) + } + } + + fn header_part_width(width: usize) -> Result { + let width = u8::try_from(width).map_err(|_| HeaderValidationError::TooLong)?; + if (1..=4).contains(&width) { + Ok(NonZeroU8::new(width).expect("header part width should be non-zero")) + } else { + Err(HeaderValidationError::TooLong) + } + } + + if header_count == 0 { + return Ok(EMPTY_HEADERS_ENCODING_INFO); + } + + let num_headers_length_bytes = size_bytes_header_count(header_count as u64)?; + let name_length_bytes = header_part_width(name_length_width_bytes)?; + let value_length_bytes = header_part_width(value_length_width_bytes)?; + + Ok(Self { + headers_total_bytes, + flag: HeaderFlag { + num_headers_length_bytes, + name_length_bytes, + value_length_bytes, + }, + }) + } +} + +impl WireEncode for EnvelopeRecord { + fn encoded_size(&self) -> usize { + let encoding_info = EncodingInfo::for_record(self); + 1 + encoding_info.flag.num_headers_length_bytes as usize + + self.headers().len() + * (encoding_info.flag.name_length_bytes.get() as usize + + encoding_info.flag.value_length_bytes.get() as usize) + + encoding_info.headers_total_bytes + + self.body().len() + } + + fn encode_into(&self, buf: &mut impl BufMut) { + let encoding_info = EncodingInfo::for_record(self); + buf.put_u8(encoding_info.flag.into()); + buf.put_uint( + self.headers().len() as u64, + encoding_info.flag.num_headers_length_bytes as usize, + ); + for Header { name, value } in self.headers() { + buf.put_uint( + name.len() as u64, + encoding_info.flag.name_length_bytes.get() as usize, + ); + buf.put_slice(name); + buf.put_uint( + value.len() as u64, + encoding_info.flag.value_length_bytes.get() as usize, + ); + buf.put_slice(value); + } + buf.put_slice(self.body()); + } +} + +pub(super) fn decode_envelope_record( + mut buf: Bytes, +) -> Result { + if buf.is_empty() { + return Err(StoredRecordDecodeError::InvalidValue( + "HeaderFlag", + "missing", + )); + } + + let flag: HeaderFlag = buf + .get_u8() + .try_into() + .map_err(|info| StoredRecordDecodeError::InvalidValue("HeaderFlag", info))?; + if flag.num_headers_length_bytes == 0 { + return EnvelopeRecord::try_from_parts(vec![], buf).map_err(record_parts_decode_error); + } + + let num_headers = buf + .try_get_uint(flag.num_headers_length_bytes as usize) + .map_err(|_| StoredRecordDecodeError::Truncated("NumHeaders"))?; + let num_headers = usize::try_from(num_headers) + .map_err(|_| StoredRecordDecodeError::InvalidValue("NumHeaders", "too many"))?; + + let mut headers: Vec
= Vec::with_capacity(num_headers); + for _ in 0..num_headers { + let name_len = buf + .try_get_uint(flag.name_length_bytes.get() as usize) + .map_err(|_| StoredRecordDecodeError::Truncated("HeaderNameLen"))? + as usize; + if name_len == 0 { + return Err(StoredRecordDecodeError::InvalidValue("HeaderName", "empty")); + } + if buf.remaining() < name_len { + return Err(StoredRecordDecodeError::Truncated("HeaderName")); + } + let name = buf.split_to(name_len); + + let value_len = buf + .try_get_uint(flag.value_length_bytes.get() as usize) + .map_err(|_| StoredRecordDecodeError::Truncated("HeaderValueLen"))? + as usize; + if buf.remaining() < value_len { + return Err(StoredRecordDecodeError::Truncated("HeaderValue")); + } + let value = buf.split_to(value_len); + + headers.push(Header { name, value }) + } + + EnvelopeRecord::try_from_parts(headers, buf).map_err(record_parts_decode_error) +} + +fn record_parts_decode_error(error: RecordPartsError) -> StoredRecordDecodeError { + match error { + RecordPartsError::Header(HeaderValidationError::NameEmpty) => { + StoredRecordDecodeError::InvalidValue("HeaderName", "empty") + } + RecordPartsError::Header(HeaderValidationError::TooMany) => { + StoredRecordDecodeError::InvalidValue("NumHeaders", "too many") + } + RecordPartsError::Header(HeaderValidationError::TooLong) => { + StoredRecordDecodeError::InvalidValue("Header", "too long") + } + RecordPartsError::UnknownCommand | RecordPartsError::CommandPayload(_, _) => { + StoredRecordDecodeError::InvalidValue("EnvelopeRecord", "unexpected command record") + } + } +} + +#[cfg(test)] +mod tests { + use bytes::{BufMut, Bytes, BytesMut}; + use rstest::rstest; + use s2_common::record::{FencingToken, FencingTokenTooLongError, SeqNum}; + + use super::*; + + fn roundtrip_command(cmd: CommandRecord, expected_len: usize) { + assert_eq!(cmd.encoded_size(), expected_len); + let encoded = cmd.to_bytes(); + assert_eq!(encoded.len(), expected_len); + assert_eq!(decode_command_record(encoded.as_ref()), Ok(cmd)); + } + + #[rstest] + #[case::empty("")] + #[case::arbit("arbitrary")] + #[case::full("0123456789012345")] + fn command_fence_roundtrip(#[case] token: &str) { + let cmd = CommandRecord::Fence(token.parse::().unwrap()); + roundtrip_command(cmd, 1 + token.len()); + } + + #[rstest] + #[case::zero(0)] + #[case::large(SeqNum::MAX)] + fn command_trim_roundtrip(#[case] trim_point: SeqNum) { + roundtrip_command(CommandRecord::Trim(trim_point), 1 + size_of::()); + } + + #[test] + fn decode_invalid_command() { + let try_convert = |raw: &[u8]| decode_command_record(raw); + assert_eq!( + try_convert(&[]), + Err(StoredRecordDecodeError::Truncated("CommandOrdinal")) + ); + assert_eq!( + try_convert(&[0xff]), + Err(StoredRecordDecodeError::InvalidValue( + "CommandOrdinal", + "unknown" + )) + ); + assert_eq!( + try_convert(&[command_op_ordinal(CommandOp::Fence), 0xff, 0xff]), + Err(StoredRecordDecodeError::InvalidValue( + "CommandPayload", + "fencing token not valid utf8" + )) + ); + assert_eq!( + try_convert(&[ + command_op_ordinal(CommandOp::Fence), + b'0', + b'1', + b'2', + b'3', + b'4', + b'5', + b'6', + b'7', + b'8', + b'9', + b'0', + b'1', + b'2', + b'3', + b'4', + b'5', + b'6', + b'7', + b'8', + b'9', + b'0', + b'1', + b'2', + b'3', + b'4', + b'5', + b'6', + b'7', + b'8', + b'9', + b'0', + b'1', + b'2', + b'3', + b'4', + b'5', + b'6', + b'7', + b'8', + b'9', + ]), + Err(CommandPayloadError::FencingTokenTooLong(FencingTokenTooLongError(40)).into()) + ); + assert_eq!( + try_convert(&[command_op_ordinal(CommandOp::Trim), 0xff]), + Err(CommandPayloadError::TrimPointSize(1).into()) + ); + } + + fn roundtrip_envelope_parts(headers: Vec
, body: Bytes) { + let encoded: Bytes = EnvelopeRecord::try_from_parts(headers.clone(), body.clone()) + .unwrap() + .to_bytes(); + let decoded = decode_envelope_record(encoded).unwrap(); + assert_eq!(decoded.headers(), headers); + assert_eq!(decoded.body(), &body); + } + + #[test] + fn envelope_framed_with_headers() { + roundtrip_envelope_parts( + vec![ + Header { + name: Bytes::from("key_1"), + value: Bytes::from("val_1"), + }, + Header { + name: Bytes::from("key_2"), + value: Bytes::from("val_2"), + }, + Header { + name: Bytes::from("key_3"), + value: Bytes::from("val_3"), + }, + Header { + name: Bytes::from("key_4"), + value: Bytes::from("val_4"), + }, + ], + Bytes::from("hello"), + ); + } + + #[test] + fn envelope_framed_no_headers() { + roundtrip_envelope_parts(vec![], Bytes::from("hello")); + } + + #[test] + fn envelope_decode_rejects_empty_header_name() { + let mut encoded = BytesMut::new(); + encoded.put_u8( + HeaderFlag { + num_headers_length_bytes: 1, + name_length_bytes: NonZeroU8::new(1).unwrap(), + value_length_bytes: NonZeroU8::new(1).unwrap(), + } + .into(), + ); + encoded.put_u8(1); + encoded.put_u8(0); + encoded.put_u8(5); + encoded.put_slice(b"value"); + encoded.put_slice(b"body"); + + assert_eq!( + decode_envelope_record(encoded.freeze()), + Err(StoredRecordDecodeError::InvalidValue("HeaderName", "empty")) + ); + } + + #[test] + fn envelope_framed_duplicate_keys() { + roundtrip_envelope_parts( + vec![ + Header { + name: Bytes::from("b"), + value: Bytes::from("val_1"), + }, + Header { + name: Bytes::from("b"), + value: Bytes::from("val_2"), + }, + Header { + name: Bytes::from("a"), + value: Bytes::from("val_3"), + }, + ], + Bytes::from("hello"), + ); + } + + #[test] + fn flag_ex1() { + assert_eq!( + Ok(HeaderFlag { + num_headers_length_bytes: 2, + name_length_bytes: NonZeroU8::new(1).unwrap(), + value_length_bytes: NonZeroU8::new(1).unwrap(), + }), + 0b00100000.try_into() + ); + + let u8_repr: u8 = HeaderFlag { + num_headers_length_bytes: 2, + name_length_bytes: NonZeroU8::new(1).unwrap(), + value_length_bytes: NonZeroU8::new(1).unwrap(), + } + .into(); + assert_eq!(u8_repr, 0b00100000); + } + + #[test] + fn flag_ex2() { + assert_eq!( + Ok(HeaderFlag { + num_headers_length_bytes: 1, + name_length_bytes: NonZeroU8::new(1).unwrap(), + value_length_bytes: NonZeroU8::new(1).unwrap(), + }), + 0b00010000.try_into() + ); + + let u8_repr: u8 = HeaderFlag { + num_headers_length_bytes: 1, + name_length_bytes: NonZeroU8::new(1).unwrap(), + value_length_bytes: NonZeroU8::new(1).unwrap(), + } + .into(); + assert_eq!(u8_repr, 0b00010000); + } + + #[rstest] + #[case::one_byte_widths(1, 1)] + #[case::two_byte_widths(2, 2)] + #[case::three_byte_widths(3, 3)] + #[case::four_byte_widths(4, 4)] + #[case::mixed_widths(2, 4)] + fn encoding_info_uses_cached_header_length_widths( + #[case] name_length_width_bytes: usize, + #[case] value_length_width_bytes: usize, + ) { + let encoding_info = EncodingInfo::from_header_sizing( + 1, + 42, + name_length_width_bytes, + value_length_width_bytes, + ) + .unwrap(); + + assert_eq!(encoding_info.headers_total_bytes, 42); + assert_eq!( + encoding_info.flag, + HeaderFlag { + num_headers_length_bytes: 1, + name_length_bytes: NonZeroU8::new(name_length_width_bytes as u8).unwrap(), + value_length_bytes: NonZeroU8::new(value_length_width_bytes as u8).unwrap(), + } + ); + } + + #[rstest] + #[case::zero_name_width(0, 1)] + #[case::too_large_name_width(5, 1)] + #[case::zero_value_width(1, 0)] + #[case::too_large_value_width(1, 5)] + fn encoding_info_rejects_invalid_cached_header_length_widths( + #[case] name_length_width_bytes: usize, + #[case] value_length_width_bytes: usize, + ) { + assert_eq!( + EncodingInfo::from_header_sizing( + 1, + 42, + name_length_width_bytes, + value_length_width_bytes, + ), + Err(HeaderValidationError::TooLong) + ); + } + + #[test] + fn empty_envelope_size() { + assert_eq!( + 1, + EnvelopeRecord::try_from_parts(vec![], Bytes::new()) + .unwrap() + .to_bytes() + .len() + ); + } + + #[test] + fn truncated_envelope_returns_error() { + let record = EnvelopeRecord::try_from_parts( + vec![Header { + name: Bytes::from("key"), + value: Bytes::from("value"), + }], + Bytes::new(), + ) + .unwrap(); + let encoded = record.to_bytes(); + + for len in 1..encoded.len() { + let truncated = encoded.slice(..len); + assert!( + matches!( + decode_envelope_record(truncated), + Err(StoredRecordDecodeError::Truncated(_)) + ), + "expected Truncated error for len {len}" + ); + } + } +} diff --git a/storage/src/record/encryption.rs b/storage/src/record/encryption.rs new file mode 100644 index 00000000..1f8034e8 --- /dev/null +++ b/storage/src/record/encryption.rs @@ -0,0 +1,879 @@ +//! Encrypted record storage, wire format, and raw cryptography. +//! +//! ```text +//! [format_id: 1 byte] [nonce] [ciphertext] [tag] +//! ``` +//! +//! | format_id | Format | Nonce | Tag | +//! |-----------|----------------|--------|------| +//! | 0x01 | AEGIS-256 v1 | 32 B | 16 B | +//! | 0x02 | AES-256-GCM v1 | 12 B | 16 B | +//! +//! The leading format byte identifies the full encrypted record framing, +//! including the framing version and encryption algorithm. This leaves room for +//! future layout changes without a separate version byte. +//! +//! AAD is caller-supplied associated data and is not stored in the encoded +//! record. +//! +//! Plaintext records are stored as `StoredRecord::Plaintext(Record)` and use +//! the same command/envelope framing as the logical record layer. +//! +//! Encrypted envelope records are stored as `StoredRecord::Encrypted`. Their +//! outer record type is `RecordType::EncryptedEnvelope`, and the encoded body is +//! an [`EncryptedRecord`] containing encrypted bytes for the byte-for-byte +//! plaintext [`EnvelopeRecord`](s2_common::record::EnvelopeRecord) encoding. +//! +//! The stored `metered_size` remains the logical plaintext metered size rather +//! than the encoded encrypted record size, so protection does not change +//! append/read metering, limits, or accounting. + +use aegis::aegis256::Aegis256; +use aes_gcm::{Aes256Gcm, KeyInit, aead::AeadInOut}; +use bytes::{BufMut, Bytes, BytesMut}; +use rand::random; +use s2_common::{ + deep_size::DeepSize, + encryption::{EncryptionAlgorithm, EncryptionSpec}, + record::{EnvelopeRecord, Metered, MeteredExt as _, MeteredSize, Record, SeqNum, Sequenced}, + stream::{ + AppendInput, AppendRecord, AppendRecordBatch, AppendRecordParts, ReadBatch, + ReadSessionOutput, + }, +}; +use secrecy::ExposeSecret as _; + +use super::{ + StoredAppendInput, StoredReadBatch, StoredReadSessionOutput, StoredRecord, + StoredRecordDecodeError, WireEncode, codec::decode_envelope_record, +}; + +const FORMAT_ID_LEN: usize = 1; + +const FORMAT_ID_AEGIS256_V1: u8 = 0x01; +const FORMAT_ID_AES256GCM_V1: u8 = 0x02; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EncryptedRecordFormat { + Aegis256V1, + Aes256GcmV1, +} + +impl EncryptedRecordFormat { + const fn try_from_format_id(format_id: u8) -> Result { + match format_id { + FORMAT_ID_AEGIS256_V1 => Ok(Self::Aegis256V1), + FORMAT_ID_AES256GCM_V1 => Ok(Self::Aes256GcmV1), + _ => Err(StoredRecordDecodeError::InvalidValue( + "EncryptedRecord", + "invalid encrypted record format id", + )), + } + } + + const fn format_id(self) -> u8 { + match self { + Self::Aegis256V1 => FORMAT_ID_AEGIS256_V1, + Self::Aes256GcmV1 => FORMAT_ID_AES256GCM_V1, + } + } + + const fn algorithm(self) -> EncryptionAlgorithm { + match self { + Self::Aegis256V1 => EncryptionAlgorithm::Aegis256, + Self::Aes256GcmV1 => EncryptionAlgorithm::Aes256Gcm, + } + } + + const fn nonce_len(self) -> usize { + match self { + Self::Aegis256V1 => 32, + Self::Aes256GcmV1 => 12, + } + } + + const fn tag_len(self) -> usize { + match self { + Self::Aegis256V1 => 16, + Self::Aes256GcmV1 => 16, + } + } + + fn put_random_nonce(self, buf: &mut impl BufMut) { + match self { + Self::Aegis256V1 => buf.put_slice(&random::<[u8; 32]>()), + Self::Aes256GcmV1 => buf.put_slice(&random::<[u8; 12]>()), + } + } + + const fn max_assignable_seq_num(self) -> SeqNum { + match self { + Self::Aegis256V1 => SeqNum::MAX, + Self::Aes256GcmV1 => (1u64 << 32) - 1, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RecordDecryptionError { + #[error("record encryption algorithm mismatch")] + AlgorithmMismatch { + expected: Option, + actual: Option, + }, + #[error("record decryption failed")] + AuthenticationFailed, + #[error("malformed encrypted record")] + MalformedEncryptedRecord, + #[error("decrypted record metered size mismatch: stored {stored}, actual {actual}")] + MeteredSizeMismatch { stored: usize, actual: usize }, + #[error("malformed decrypted record: {0}")] + MalformedDecryptedRecord(#[from] StoredRecordDecodeError), +} + +#[derive(PartialEq, Eq, Clone)] +pub struct EncryptedRecord { + encoded: Bytes, + format: EncryptedRecordFormat, +} + +impl EncryptedRecord { + fn new(encoded: Bytes, format: EncryptedRecordFormat) -> Self { + debug_assert!(!encoded.is_empty()); + debug_assert_eq!(encoded[0], format.format_id()); + debug_assert!(encoded.len() >= FORMAT_ID_LEN + format.nonce_len() + format.tag_len()); + Self { encoded, format } + } + + pub fn max_assignable_seq_num(&self) -> SeqNum { + self.format.max_assignable_seq_num() + } + + pub(crate) fn nonce(&self) -> &[u8] { + let start = FORMAT_ID_LEN; + let end = start + self.format.nonce_len(); + &self.encoded[start..end] + } + + pub(crate) fn ciphertext(&self) -> &[u8] { + let start = FORMAT_ID_LEN + self.format.nonce_len(); + let end = self.encoded.len() - self.format.tag_len(); + &self.encoded[start..end] + } + + pub(crate) fn tag(&self) -> &[u8] { + let start = self.encoded.len() - self.format.tag_len(); + let end = self.encoded.len(); + &self.encoded[start..end] + } + + fn into_mut_encoded(self) -> BytesMut { + self.encoded + .try_into_mut() + .unwrap_or_else(|encoded| BytesMut::from(encoded.as_ref())) + } +} + +impl std::fmt::Debug for EncryptedRecord { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EncryptedRecord") + .field("format_id", &self.encoded[0]) + .field("format", &self.format) + .field("algorithm", &self.format.algorithm()) + .field("nonce.len", &self.nonce().len()) + .field("ciphertext.len", &self.ciphertext().len()) + .field("tag.len", &self.tag().len()) + .finish() + } +} + +impl DeepSize for EncryptedRecord { + fn deep_size(&self) -> usize { + self.encoded.len() + } +} + +impl WireEncode for EncryptedRecord { + fn encoded_size(&self) -> usize { + self.encoded.len() + } + + fn encode_into(&self, buf: &mut impl BufMut) { + buf.put_slice(self.encoded.as_ref()); + } +} + +pub fn encrypt_record( + record: Metered, + encryption: &EncryptionSpec, + aad: &[u8], +) -> Metered { + let metered_size = record.metered_size(); + let record = match (record.into_inner(), encryption) { + (record @ Record::Command(_), _) => StoredRecord::Plaintext(record), + (record @ Record::Envelope(_), EncryptionSpec::Plain) => StoredRecord::Plaintext(record), + (Record::Envelope(envelope), EncryptionSpec::Aegis256(key)) => encrypt_envelope_record( + &envelope, + metered_size, + EncryptedRecordFormat::Aegis256V1, + |nonce, payload| { + let nonce: &[u8; 32] = nonce.try_into().expect("AEGIS-256 nonce must be 32 bytes"); + Aegis256::<16>::new(key.expose_secret(), nonce).encrypt_in_place(payload, aad) + }, + ), + (Record::Envelope(envelope), EncryptionSpec::Aes256Gcm(key)) => encrypt_envelope_record( + &envelope, + metered_size, + EncryptedRecordFormat::Aes256GcmV1, + |nonce, payload| { + let nonce: &aes_gcm::aead::Nonce = nonce + .try_into() + .expect("AES-256-GCM nonce must be 12 bytes"); + let cipher = Aes256Gcm::new_from_slice(key.expose_secret()) + .expect("AES-256-GCM key must be 32 bytes"); + cipher + .encrypt_inout_detached(nonce, aad, payload.into()) + .expect("AES-256-GCM encryption should not fail on size validation") + }, + ), + }; + record.metered() +} + +fn encrypt_envelope_record( + envelope: &EnvelopeRecord, + metered_size: usize, + format: EncryptedRecordFormat, + encrypt_payload: impl FnOnce(&[u8], &mut [u8]) -> Tag, +) -> StoredRecord +where + Tag: AsRef<[u8]>, +{ + let payload_start = FORMAT_ID_LEN + format.nonce_len(); + let mut encoded = + BytesMut::with_capacity(payload_start + envelope.encoded_size() + format.tag_len()); + encoded.put_u8(format.format_id()); + format.put_random_nonce(&mut encoded); + envelope.encode_into(&mut encoded); + + let (prefix, payload) = encoded.split_at_mut(payload_start); + let tag = encrypt_payload(&prefix[FORMAT_ID_LEN..], payload); + debug_assert_eq!(tag.as_ref().len(), format.tag_len()); + encoded.put_slice(tag.as_ref()); + + let encrypted = EncryptedRecord::new(encoded.freeze(), format); + StoredRecord::encrypted(encrypted, metered_size) +} + +pub fn encrypt_append_input( + input: AppendInput, + encryption: &EncryptionSpec, + aad: &[u8], +) -> StoredAppendInput { + let AppendInput { + records, + match_seq_num, + fencing_token, + } = input; + let records = records + .into_iter() + .map(|record| { + let AppendRecordParts { timestamp, record } = record.into_parts(); + AppendRecord::try_from(AppendRecordParts { + timestamp, + record: encrypt_record(record, encryption, aad), + }) + .expect("record encryption preserves append record limits") + }) + .collect::>(); + + AppendInput { + records: AppendRecordBatch::try_from(records) + .expect("record encryption preserves append batch limits"), + match_seq_num, + fencing_token, + } +} + +impl TryFrom for EncryptedRecord { + type Error = StoredRecordDecodeError; + + fn try_from(encoded: Bytes) -> Result { + if encoded.len() < FORMAT_ID_LEN { + return Err(StoredRecordDecodeError::Truncated( + "EncryptedRecordFormatId", + )); + } + + let format = EncryptedRecordFormat::try_from_format_id(encoded[0])?; + let nonce_len = format.nonce_len(); + let tag_len = format.tag_len(); + if encoded.len() < FORMAT_ID_LEN + nonce_len + tag_len { + return Err(StoredRecordDecodeError::Truncated("EncryptedRecordFrame")); + } + + Ok(Self::new(encoded, format)) + } +} + +pub fn decrypt_stored_record( + record: StoredRecord, + encryption: &EncryptionSpec, + aad: &[u8], +) -> Result, RecordDecryptionError> { + match record { + StoredRecord::Plaintext(record @ Record::Command(_)) => Ok(record.metered()), + StoredRecord::Plaintext(record @ Record::Envelope(_)) => match encryption { + EncryptionSpec::Plain => Ok(record.metered()), + EncryptionSpec::Aegis256(_) => Err(RecordDecryptionError::AlgorithmMismatch { + expected: Some(EncryptionAlgorithm::Aegis256), + actual: None, + }), + EncryptionSpec::Aes256Gcm(_) => Err(RecordDecryptionError::AlgorithmMismatch { + expected: Some(EncryptionAlgorithm::Aes256Gcm), + actual: None, + }), + }, + StoredRecord::Encrypted { + metered_size, + record: encrypted, + } => { + let plaintext = decrypt_payload(encrypted, encryption, aad)?; + let record = Record::Envelope(decode_envelope_record(plaintext)?); + let actual_metered_size = record.metered_size(); + if metered_size != actual_metered_size { + return Err(RecordDecryptionError::MeteredSizeMismatch { + stored: metered_size, + actual: actual_metered_size, + }); + } + Ok(record.metered()) + } + } +} + +pub fn decrypt_read_session_output( + output: StoredReadSessionOutput, + encryption: &EncryptionSpec, + aad: &[u8], +) -> Result { + match output { + ReadSessionOutput::Heartbeat(tail) => Ok(ReadSessionOutput::Heartbeat(tail)), + ReadSessionOutput::Batch(batch) => { + decrypt_read_batch(batch, encryption, aad).map(ReadSessionOutput::Batch) + } + } +} + +fn decrypt_read_batch( + batch: StoredReadBatch, + encryption: &EncryptionSpec, + aad: &[u8], +) -> Result { + let records: Result>>, RecordDecryptionError> = batch + .records + .into_inner() + .into_iter() + .map(|record| { + let (position, record) = record.into_parts(); + decrypt_stored_record(record, encryption, aad).map(|record| record.sequenced(position)) + }) + .collect(); + + Ok(ReadBatch { + records: records?, + tail: batch.tail, + }) +} + +fn decrypt_payload( + record: EncryptedRecord, + encryption: &EncryptionSpec, + aad: &[u8], +) -> Result { + let format = record.format; + let (mut encoded, payload_start, payload_end) = decryption_layout(record, format)?; + let plaintext_len = payload_end - payload_start; + + match (format, encryption) { + (EncryptedRecordFormat::Aegis256V1, EncryptionSpec::Aegis256(key)) => { + let (prefix, payload_and_tag) = encoded.split_at_mut(payload_start); + let nonce: &[u8; 32] = prefix + .get(FORMAT_ID_LEN..) + .ok_or(RecordDecryptionError::MalformedEncryptedRecord)? + .try_into() + .map_err(|_| RecordDecryptionError::MalformedEncryptedRecord)?; + let (ciphertext, tag) = payload_and_tag.split_at_mut(plaintext_len); + let tag: &[u8; 16] = tag + .as_ref() + .try_into() + .map_err(|_| RecordDecryptionError::MalformedEncryptedRecord)?; + Aegis256::<16>::new(key.expose_secret(), nonce) + .decrypt_in_place(ciphertext, tag, aad) + .map_err(|_| RecordDecryptionError::AuthenticationFailed)?; + Ok(decryption_finish(encoded, payload_start, plaintext_len)) + } + (EncryptedRecordFormat::Aegis256V1, EncryptionSpec::Plain) => { + Err(RecordDecryptionError::AlgorithmMismatch { + expected: None, + actual: Some(EncryptionAlgorithm::Aegis256), + }) + } + (EncryptedRecordFormat::Aegis256V1, EncryptionSpec::Aes256Gcm(_)) => { + Err(RecordDecryptionError::AlgorithmMismatch { + expected: Some(EncryptionAlgorithm::Aes256Gcm), + actual: Some(EncryptionAlgorithm::Aegis256), + }) + } + (EncryptedRecordFormat::Aes256GcmV1, EncryptionSpec::Aes256Gcm(key)) => { + let (prefix, payload_and_tag) = encoded.split_at_mut(payload_start); + let nonce: &aes_gcm::aead::Nonce = prefix + .get(FORMAT_ID_LEN..) + .ok_or(RecordDecryptionError::MalformedEncryptedRecord)? + .try_into() + .map_err(|_| RecordDecryptionError::MalformedEncryptedRecord)?; + let (ciphertext, tag) = payload_and_tag.split_at_mut(plaintext_len); + let tag: &aes_gcm::aead::Tag = tag + .as_ref() + .try_into() + .map_err(|_| RecordDecryptionError::MalformedEncryptedRecord)?; + let cipher = Aes256Gcm::new_from_slice(key.expose_secret()) + .expect("AES-256-GCM key must be 32 bytes"); + cipher + .decrypt_inout_detached(nonce, aad, ciphertext.into(), tag) + .map_err(|_| RecordDecryptionError::AuthenticationFailed)?; + Ok(decryption_finish(encoded, payload_start, plaintext_len)) + } + (EncryptedRecordFormat::Aes256GcmV1, EncryptionSpec::Plain) => { + Err(RecordDecryptionError::AlgorithmMismatch { + expected: None, + actual: Some(EncryptionAlgorithm::Aes256Gcm), + }) + } + (EncryptedRecordFormat::Aes256GcmV1, EncryptionSpec::Aegis256(_)) => { + Err(RecordDecryptionError::AlgorithmMismatch { + expected: Some(EncryptionAlgorithm::Aegis256), + actual: Some(EncryptionAlgorithm::Aes256Gcm), + }) + } + } +} + +fn decryption_layout( + record: EncryptedRecord, + format: EncryptedRecordFormat, +) -> Result<(BytesMut, usize, usize), RecordDecryptionError> { + let payload_start = FORMAT_ID_LEN + format.nonce_len(); + let payload_end = record + .encoded + .len() + .checked_sub(format.tag_len()) + .ok_or(RecordDecryptionError::MalformedEncryptedRecord)?; + if payload_start > payload_end { + return Err(RecordDecryptionError::MalformedEncryptedRecord); + } + Ok((record.into_mut_encoded(), payload_start, payload_end)) +} + +fn decryption_finish(mut encoded: BytesMut, payload_start: usize, plaintext_len: usize) -> Bytes { + let _ = encoded.split_to(payload_start); + encoded.truncate(plaintext_len); + encoded.freeze() +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use rstest::rstest; + use s2_common::record::{CommandRecord, EnvelopeRecord, FencingToken, Header, MeteredExt}; + + use super::*; + + const TEST_KEY: [u8; 32] = [0x42; 32]; + const OTHER_TEST_KEY: [u8; 32] = [0x99; 32]; + + fn test_encryption(alg: EncryptionAlgorithm) -> EncryptionSpec { + match alg { + EncryptionAlgorithm::Aegis256 => EncryptionSpec::aegis256(TEST_KEY), + EncryptionAlgorithm::Aes256Gcm => EncryptionSpec::aes256_gcm(TEST_KEY), + } + } + + fn other_test_encryption(alg: EncryptionAlgorithm) -> EncryptionSpec { + match alg { + EncryptionAlgorithm::Aegis256 => EncryptionSpec::aegis256(OTHER_TEST_KEY), + EncryptionAlgorithm::Aes256Gcm => EncryptionSpec::aes256_gcm(OTHER_TEST_KEY), + } + } + + fn encrypt_test_record( + plaintext: EnvelopeRecord, + alg: EncryptionAlgorithm, + aad: &[u8], + ) -> EncryptedRecord { + let stored = encrypt_record( + Record::Envelope(plaintext).metered(), + &test_encryption(alg), + aad, + ) + .into_inner(); + let StoredRecord::Encrypted { record, .. } = stored else { + panic!("expected encrypted envelope record"); + }; + record + } + + fn make_encrypted_record( + format: EncryptedRecordFormat, + nonce: impl AsRef<[u8]>, + ciphertext: impl AsRef<[u8]>, + tag: impl AsRef<[u8]>, + ) -> EncryptedRecord { + let nonce = nonce.as_ref(); + let ciphertext = ciphertext.as_ref(); + let tag = tag.as_ref(); + + assert_eq!(nonce.len(), format.nonce_len()); + assert_eq!(tag.len(), format.tag_len()); + + let mut encoded = + BytesMut::with_capacity(FORMAT_ID_LEN + nonce.len() + ciphertext.len() + tag.len()); + encoded.put_u8(format.format_id()); + encoded.put_slice(nonce); + encoded.put_slice(ciphertext); + encoded.put_slice(tag); + + EncryptedRecord::new(encoded.freeze(), format) + } + + fn aad() -> [u8; 32] { + [0xA5; 32] + } + + fn make_envelope(headers: Vec
, body: Bytes) -> EnvelopeRecord { + EnvelopeRecord::try_from_parts(headers, body).unwrap() + } + + fn make_plaintext_envelope(headers: Vec
, body: Bytes) -> Record { + Record::Envelope(make_envelope(headers, body)) + } + + fn make_encrypted_stored_record( + encryption: &EncryptionSpec, + headers: Vec
, + body: Bytes, + aad: &[u8], + ) -> StoredRecord { + let stored = encrypt_record( + make_plaintext_envelope(headers, body).metered(), + encryption, + aad, + ) + .into_inner(); + let StoredRecord::Encrypted { .. } = &stored else { + panic!("plain encryption should not produce an encrypted record"); + }; + stored + } + + #[rstest] + #[case::aegis_unique(EncryptionAlgorithm::Aegis256, false)] + #[case::aegis_shared(EncryptionAlgorithm::Aegis256, true)] + #[case::aes_unique(EncryptionAlgorithm::Aes256Gcm, false)] + #[case::aes_shared(EncryptionAlgorithm::Aes256Gcm, true)] + fn encrypted_payload_roundtrips( + #[case] algorithm: EncryptionAlgorithm, + #[case] shared_encoded_record_buffer: bool, + ) { + let headers = vec![Header { + name: Bytes::from_static(b"x-test"), + value: Bytes::from_static(b"hello"), + }]; + let body = Bytes::from_static(b"secret payload"); + + let aad = aad(); + let plaintext = make_envelope(headers.clone(), body.clone()); + let encryption = test_encryption(algorithm); + let encrypted_record = encrypt_test_record(plaintext, algorithm, &aad); + let encrypted_record = if shared_encoded_record_buffer { + let shared = encrypted_record.encoded.clone(); + EncryptedRecord::try_from(shared).unwrap() + } else { + encrypted_record + }; + let decrypted = decrypt_payload(encrypted_record, &encryption, &aad).unwrap(); + let (out_headers, out_body) = decode_envelope_record(decrypted).unwrap().into_parts(); + + assert_eq!(out_headers, headers); + assert_eq!(out_body, body); + } + + #[rstest] + #[case(EncryptionAlgorithm::Aegis256)] + #[case(EncryptionAlgorithm::Aes256Gcm)] + fn wrong_key_fails(#[case] algorithm: EncryptionAlgorithm) { + let aad = aad(); + let plaintext = make_envelope(vec![], Bytes::from_static(b"data")); + let encrypted_record = encrypt_test_record(plaintext, algorithm, &aad); + let result = decrypt_payload(encrypted_record, &other_test_encryption(algorithm), &aad); + assert!(matches!( + result, + Err(RecordDecryptionError::AuthenticationFailed) + )); + } + + #[test] + fn empty_body_fails() { + let result = EncryptedRecord::try_from(Bytes::new()); + assert!(matches!( + result, + Err(StoredRecordDecodeError::Truncated( + "EncryptedRecordFormatId" + )) + )); + } + + #[test] + fn format_id_byte_present() { + let aad = aad(); + let plaintext = make_envelope(vec![], Bytes::from_static(b"data")); + let encrypted_record = encrypt_test_record(plaintext, EncryptionAlgorithm::Aegis256, &aad); + let encoded = encrypted_record.to_bytes(); + assert_eq!(encrypted_record.format, EncryptedRecordFormat::Aegis256V1); + assert_eq!(encoded[0], 0x01); + } + + #[test] + fn format_id_flip_detected() { + let aad = aad(); + let plaintext = make_envelope(vec![], Bytes::from_static(b"data")); + let mut encoded_record = + encrypt_test_record(plaintext, EncryptionAlgorithm::Aegis256, &aad) + .to_bytes() + .to_vec(); + assert_eq!(encoded_record[0], 0x01); + encoded_record[0] = 0x02; + let encrypted_record = EncryptedRecord::try_from(Bytes::from(encoded_record)).unwrap(); + let result = decrypt_payload( + encrypted_record, + &test_encryption(EncryptionAlgorithm::Aegis256), + &aad, + ); + assert!(matches!( + result, + Err(RecordDecryptionError::AlgorithmMismatch { + expected: Some(EncryptionAlgorithm::Aegis256), + actual: Some(EncryptionAlgorithm::Aes256Gcm), + }) + )); + } + + #[test] + fn wrong_aad_fails() { + let aad = aad(); + let other_aad = [0x5A; 32]; + let plaintext = make_envelope(vec![], Bytes::from_static(b"data")); + let encrypted_record = encrypt_test_record(plaintext, EncryptionAlgorithm::Aegis256, &aad); + let result = decrypt_payload( + encrypted_record, + &test_encryption(EncryptionAlgorithm::Aegis256), + &other_aad, + ); + assert!(matches!( + result, + Err(RecordDecryptionError::AuthenticationFailed) + )); + } + + #[test] + fn malformed_encrypted_record_layout_returns_error_instead_of_panicking() { + let aad = aad(); + let record = EncryptedRecord { + encoded: Bytes::from_static(b"\x01short"), + format: EncryptedRecordFormat::Aegis256V1, + }; + + let result = decrypt_payload( + record, + &test_encryption(EncryptionAlgorithm::Aegis256), + &aad, + ); + + assert!(matches!( + result, + Err(RecordDecryptionError::MalformedEncryptedRecord) + )); + } + + #[test] + fn encrypted_record_roundtrips_aes256gcm() { + let record = make_encrypted_record( + EncryptedRecordFormat::Aes256GcmV1, + Bytes::from_static(b"0123456789ab"), + Bytes::from_static(b"ciphertext"), + Bytes::from_static(b"0123456789abcdef"), + ); + + let bytes = record.to_bytes(); + let decoded = EncryptedRecord::try_from(bytes).unwrap(); + + assert_eq!(decoded, record); + assert_eq!(decoded.format, EncryptedRecordFormat::Aes256GcmV1); + assert_eq!(decoded.encoded[0], FORMAT_ID_AES256GCM_V1); + assert_eq!(decoded.nonce(), b"0123456789ab"); + assert_eq!(decoded.ciphertext(), b"ciphertext"); + assert_eq!(decoded.tag(), b"0123456789abcdef"); + } + + #[test] + fn rejects_invalid_format_id() { + let err = EncryptedRecord::try_from(Bytes::from_static(b"\xFFpayload")).unwrap_err(); + assert_eq!( + err, + StoredRecordDecodeError::InvalidValue( + "EncryptedRecord", + "invalid encrypted record format id" + ) + ); + } + + #[test] + fn rejects_truncated_layout() { + let err = EncryptedRecord::try_from(Bytes::from_static(b"\x01tiny")).unwrap_err(); + assert_eq!( + err, + StoredRecordDecodeError::Truncated("EncryptedRecordFrame") + ); + } + + #[test] + fn encrypt_record_encrypts_envelope_records() { + let aad = aad(); + let encryption = test_encryption(EncryptionAlgorithm::Aegis256); + let headers = vec![Header { + name: Bytes::from_static(b"x-test"), + value: Bytes::from_static(b"hello"), + }]; + let body = Bytes::from_static(b"secret payload"); + let record = make_plaintext_envelope(headers.clone(), body.clone()).metered(); + + let stored = encrypt_record(record, &encryption, &aad).into_inner(); + let StoredRecord::Encrypted { + record: envelope, .. + } = &stored + else { + panic!("expected encrypted envelope record"); + }; + assert_eq!(envelope.format, EncryptedRecordFormat::Aegis256V1); + + let decrypted = decrypt_stored_record(stored, &encryption, &aad).unwrap(); + let Record::Envelope(record) = decrypted.into_inner() else { + panic!("expected envelope record"); + }; + assert_eq!(record.headers(), headers.as_slice()); + assert_eq!(record.body().as_ref(), body.as_ref()); + } + + #[test] + fn decrypt_stored_record_preserves_plaintext_command_records() { + let token: FencingToken = "fence-test".parse().unwrap(); + let record = StoredRecord::Plaintext(Record::Command(CommandRecord::Fence(token.clone()))); + + let decrypted = decrypt_stored_record( + record, + &test_encryption(EncryptionAlgorithm::Aegis256), + &aad(), + ) + .unwrap(); + + let Record::Command(record) = decrypted.into_inner() else { + panic!("expected command record"); + }; + assert_eq!(record, CommandRecord::Fence(token)); + } + + #[test] + fn decrypt_stored_record_decrypts_encrypted_records() { + let aad = aad(); + let record = make_encrypted_stored_record( + &test_encryption(EncryptionAlgorithm::Aegis256), + vec![Header { + name: Bytes::from_static(b"x-test"), + value: Bytes::from_static(b"hello"), + }], + Bytes::from_static(b"secret payload"), + &aad, + ); + + let decrypted = decrypt_stored_record( + record, + &test_encryption(EncryptionAlgorithm::Aegis256), + &aad, + ) + .unwrap(); + + let Record::Envelope(record) = decrypted.into_inner() else { + panic!("expected envelope record"); + }; + assert_eq!(record.headers().len(), 1); + assert_eq!(record.headers()[0].name.as_ref(), b"x-test"); + assert_eq!(record.headers()[0].value.as_ref(), b"hello"); + assert_eq!(record.body().as_ref(), b"secret payload"); + } + + #[test] + fn decrypt_stored_record_plain_rejects_encrypted_records() { + let aad = aad(); + let record = make_encrypted_stored_record( + &test_encryption(EncryptionAlgorithm::Aegis256), + vec![], + Bytes::from_static(b"secret payload"), + &aad, + ); + + let result = decrypt_stored_record(record, &EncryptionSpec::Plain, &aad); + + assert!(matches!( + result, + Err(RecordDecryptionError::AlgorithmMismatch { + expected: None, + actual: Some(EncryptionAlgorithm::Aegis256), + }) + )); + } + + #[test] + fn decode_stored_record_rejects_encrypted_metered_size_mismatch() { + let aad = aad(); + let stored = make_encrypted_stored_record( + &test_encryption(EncryptionAlgorithm::Aegis256), + vec![Header { + name: Bytes::from_static(b"x-test"), + value: Bytes::from_static(b"hello"), + }], + Bytes::from_static(b"secret payload"), + &aad, + ); + let StoredRecord::Encrypted { + metered_size, + record, + } = stored + else { + panic!("expected encrypted stored record"); + }; + + let result = decrypt_stored_record( + StoredRecord::encrypted(record, metered_size + 1), + &test_encryption(EncryptionAlgorithm::Aegis256), + &aad, + ); + + assert!(matches!( + result, + Err(RecordDecryptionError::MeteredSizeMismatch { + stored, + actual + }) if stored == metered_size + 1 && actual == metered_size + )); + } +} diff --git a/storage/src/record/framing.rs b/storage/src/record/framing.rs new file mode 100644 index 00000000..452792ed --- /dev/null +++ b/storage/src/record/framing.rs @@ -0,0 +1,574 @@ +#[cfg(test)] +use bytes::BytesMut; +use bytes::{Buf, BufMut, Bytes}; +use s2_common::{ + deep_size::DeepSize, + record::{CommandRecord, Metered, MeteredSize, Record, SeqNum, Sequenced}, +}; + +use super::{ + codec::{StoredRecordDecodeError, WireEncode, decode_command_record, decode_envelope_record}, + encryption::EncryptedRecord, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(u8)] +enum RecordType { + Command = 1, + Envelope = 2, + EncryptedEnvelope = 3, +} + +impl TryFrom for RecordType { + type Error = &'static str; + + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(Self::Command), + 2 => Ok(Self::Envelope), + 3 => Ok(Self::EncryptedEnvelope), + _ => Err("invalid record type ordinal"), + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +struct MagicByte { + record_type: RecordType, + metered_size_varlen: u8, +} + +/// Read bytes to u32 in big-endian order. +fn read_vint_u32_be(bytes: &[u8]) -> u32 { + if bytes.len() > size_of::() || bytes.is_empty() { + panic!("invalid variable int bytes = {} len", bytes.len()) + } + let mut acc: u32 = 0; + for &byte in bytes { + acc = (acc << 8) | byte as u32; + } + acc +} + +pub fn try_metered_size(record_bytes: &[u8]) -> Result { + let magic_byte_u8 = *record_bytes.first().ok_or("byte range is empty")?; + let magic_byte = MagicByte::try_from(magic_byte_u8)?; + Ok(read_vint_u32_be( + record_bytes + .get(1..1 + magic_byte.metered_size_varlen as usize) + .ok_or("byte range doesn't include bytes for metered size")?, + )) +} + +impl TryFrom for MagicByte { + type Error = &'static str; + + fn try_from(value: u8) -> Result { + let record_type = RecordType::try_from(value & 0b111)?; + Ok(Self { + record_type, + metered_size_varlen: match (value >> 3) & 0b11 { + 0 => 1u8, + 1 => 2u8, + 2 => 3u8, + _ => Err("invalid metered_size_varlen")?, + }, + }) + } +} + +impl From for u8 { + fn from(value: MagicByte) -> Self { + ((value.metered_size_varlen - 1) << 3) | value.record_type as u8 + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum StoredRecord { + Plaintext(Record), + /// Encrypted envelope record bytes plus the logical plaintext metered size. + /// + /// The stored `metered_size` must match the decrypted envelope record's + /// metered size. Decoding preserves the encoded prefix, and decryption + /// validates it before returning a logical record. + Encrypted { + metered_size: usize, + record: EncryptedRecord, + }, +} + +impl StoredRecord { + pub(crate) fn encrypted(record: EncryptedRecord, metered_size: usize) -> Self { + Self::Encrypted { + metered_size, + record, + } + } + + fn record_type(&self) -> RecordType { + match self { + Self::Plaintext(Record::Command(_)) => RecordType::Command, + Self::Plaintext(Record::Envelope(_)) => RecordType::Envelope, + Self::Encrypted { .. } => RecordType::EncryptedEnvelope, + } + } + + fn encoded_body_size(&self) -> usize { + match self { + Self::Plaintext(Record::Command(record)) => record.encoded_size(), + Self::Plaintext(Record::Envelope(record)) => record.encoded_size(), + Self::Encrypted { record, .. } => record.encoded_size(), + } + } + + fn encode_body_into(&self, buf: &mut impl BufMut) { + match self { + Self::Plaintext(Record::Command(record)) => record.encode_into(buf), + Self::Plaintext(Record::Envelope(record)) => record.encode_into(buf), + Self::Encrypted { record, .. } => record.encode_into(buf), + } + } + + pub fn max_assignable_seq_num(&self) -> SeqNum { + match self { + Self::Plaintext(_) => SeqNum::MAX, + Self::Encrypted { record, .. } => record.max_assignable_seq_num(), + } + } +} + +impl DeepSize for StoredRecord { + fn deep_size(&self) -> usize { + match self { + Self::Plaintext(record) => record.deep_size(), + Self::Encrypted { + metered_size, + record, + } => metered_size.deep_size() + record.deep_size(), + } + } +} + +impl MeteredSize for StoredRecord { + fn metered_size(&self) -> usize { + match self { + Self::Plaintext(record) => record.metered_size(), + Self::Encrypted { metered_size, .. } => *metered_size, + } + } +} + +impl From for StoredRecord { + fn from(value: Record) -> Self { + Self::Plaintext(value) + } +} + +pub fn decode_if_command_record( + record: &[u8], +) -> Result, StoredRecordDecodeError> { + if record.is_empty() { + return Err(StoredRecordDecodeError::Truncated("MagicByte")); + } + let magic_byte = MagicByte::try_from(record[0]) + .map_err(|msg| StoredRecordDecodeError::InvalidValue("MagicByte", msg))?; + match magic_byte.record_type { + RecordType::Command => { + let offset = 1 + magic_byte.metered_size_varlen as usize; + if record.len() < offset { + return Err(StoredRecordDecodeError::Truncated("MeteredSize")); + } + Ok(Some(decode_command_record(&record[offset..])?)) + } + RecordType::Envelope | RecordType::EncryptedEnvelope => Ok(None), + } +} + +pub fn encode_stored_record(record: Metered<&StoredRecord>) -> Bytes { + record.to_bytes() +} + +pub fn stored_record_encoded_size(record: Metered<&StoredRecord>) -> usize { + record.encoded_size() +} + +pub fn encode_stored_record_into(record: Metered<&StoredRecord>, buf: &mut impl BufMut) { + record.encode_into(buf); +} + +impl WireEncode for Metered<&StoredRecord> { + fn encoded_size(&self) -> usize { + 1 + magic_byte(self).metered_size_varlen as usize + self.encoded_body_size() + } + + fn encode_into(&self, buf: &mut impl BufMut) { + let magic_byte = magic_byte(self); + buf.put_u8(magic_byte.into()); + buf.put_uint( + self.metered_size() as u64, + magic_byte.metered_size_varlen as usize, + ); + self.encode_body_into(buf); + } +} + +fn magic_byte(record: &Metered<&StoredRecord>) -> MagicByte { + let metered_size = record.metered_size(); + let metered_size_varlen = 8 - (metered_size.leading_zeros() / 8) as u8; + if metered_size_varlen > 3 { + panic!("illegal metered size varlen {metered_size} for record") + } + MagicByte { + record_type: record.record_type(), + metered_size_varlen, + } +} + +pub type StoredSequencedBytes = Sequenced; +pub type StoredSequencedRecord = Sequenced; + +pub fn decode_stored_record( + mut buf: Bytes, +) -> Result, StoredRecordDecodeError> { + if buf.is_empty() { + return Err(StoredRecordDecodeError::Truncated("MagicByte")); + } + let magic_byte = MagicByte::try_from(buf.get_u8()) + .map_err(|msg| StoredRecordDecodeError::InvalidValue("MagicByte", msg))?; + + let metered_size = + buf.try_get_uint(magic_byte.metered_size_varlen as usize) + .map_err(|_| StoredRecordDecodeError::Truncated("MeteredSize"))? as usize; + + let record = match magic_byte.record_type { + RecordType::Command => { + StoredRecord::Plaintext(Record::Command(decode_command_record(buf.as_ref())?)) + } + RecordType::Envelope => { + StoredRecord::Plaintext(Record::Envelope(decode_envelope_record(buf)?)) + } + RecordType::EncryptedEnvelope => { + StoredRecord::encrypted(EncryptedRecord::try_from(buf)?, metered_size) + } + }; + Ok(Metered::with_size(metered_size, record)) +} + +pub fn decode_record(buf: Bytes) -> Result, StoredRecordDecodeError> { + let stored = decode_stored_record(buf)?; + let metered_size = stored.metered_size(); + match stored.into_inner() { + StoredRecord::Plaintext(record) => Ok(record), + StoredRecord::Encrypted { .. } => Err(StoredRecordDecodeError::InvalidValue( + "RecordType", + "encrypted envelope requires decryption", + )), + } + .map(|record| Metered::with_size(metered_size, record)) +} + +#[cfg(test)] +mod test { + use proptest::prelude::*; + use rstest::rstest; + use s2_common::record::{ + EnvelopeRecord, Header, MAX_FENCING_TOKEN_LENGTH, MeteredExt, StreamPosition, Timestamp, + }; + + use super::*; + + struct LegacyPlaintextFrame<'a> { + record: &'a Record, + } + + impl LegacyPlaintextFrame<'_> { + fn magic_byte(&self) -> MagicByte { + let metered_size = self.record.metered_size(); + let metered_size_varlen = 8 - (metered_size.leading_zeros() / 8) as u8; + assert!(metered_size_varlen <= 3); + + MagicByte { + record_type: match self.record { + Record::Command(_) => RecordType::Command, + Record::Envelope(_) => RecordType::Envelope, + }, + metered_size_varlen, + } + } + } + + impl WireEncode for LegacyPlaintextFrame<'_> { + fn encoded_size(&self) -> usize { + let body_size = match self.record { + Record::Command(record) => record.encoded_size(), + Record::Envelope(record) => record.encoded_size(), + }; + 1 + self.magic_byte().metered_size_varlen as usize + body_size + } + + fn encode_into(&self, buf: &mut impl BufMut) { + let magic_byte = self.magic_byte(); + buf.put_u8(magic_byte.into()); + buf.put_uint( + self.record.metered_size() as u64, + magic_byte.metered_size_varlen as usize, + ); + match self.record { + Record::Command(record) => record.encode_into(buf), + Record::Envelope(record) => record.encode_into(buf), + } + } + } + + fn legacy_plaintext_bytes(record: &Record) -> Bytes { + LegacyPlaintextFrame { record }.to_bytes() + } + + fn semantic_metered_size(record: &Record) -> usize { + let (headers, body) = record.clone().into_parts(); + 8 + (2 * headers.len()) + + headers + .iter() + .map(|header| header.name.len() + header.value.len()) + .sum::() + + body.len() + } + + fn bytes_strategy(allow_empty: bool) -> impl Strategy { + prop_oneof![ + prop::collection::vec(any::(), (if allow_empty { 0 } else { 1 })..10) + .prop_map(Bytes::from), + prop::collection::vec(any::(), 100..1000).prop_map(Bytes::from), + ] + } + + fn header_strategy() -> impl Strategy { + (bytes_strategy(false), bytes_strategy(true)) + .prop_map(|(name, value)| Header { name, value }) + } + + fn headers_strategy() -> impl Strategy> { + prop_oneof![ + prop::collection::vec(header_strategy(), 0..10), + prop::collection::vec(header_strategy(), 200..300), + ] + } + + fn command_strategy() -> impl Strategy { + prop_oneof![ + proptest::string::string_regex(&format!("[ -~]{{0,{MAX_FENCING_TOKEN_LENGTH}}}")) + .unwrap() + .prop_map(|token| CommandRecord::Fence(token.parse().unwrap())), + any::().prop_map(CommandRecord::Trim), + ] + } + + proptest!( + #![proptest_config(ProptestConfig::with_cases(10))] + #[test] + fn roundtrip_envelope( + seq_num in any::(), + timestamp in any::(), + headers in headers_strategy(), + body in bytes_strategy(true), + ) { + let record = Record::try_from_parts(headers, body).unwrap(); + let metered_record: Metered = record.clone().into(); + let encoded_record = + encode_stored_record(StoredRecord::from(record.clone()).metered().as_ref()); + let legacy_record = legacy_plaintext_bytes(&record); + prop_assert_eq!(encoded_record.as_ref(), legacy_record.as_ref()); + let decoded_record = decode_record(encoded_record).unwrap(); + prop_assert_eq!(&decoded_record, &metered_record); + let sequenced = decoded_record.sequenced(StreamPosition { seq_num, timestamp }); + let (position, sequenced_record) = sequenced.into_parts(); + assert_eq!(position, StreamPosition { seq_num, timestamp }); + assert_eq!(sequenced_record.into_inner(), record); + } + ); + + proptest!( + #![proptest_config(ProptestConfig::with_cases(10))] + #[test] + fn roundtrip_metered( + headers in headers_strategy(), + body in bytes_strategy(true), + ) { + let record = Record::try_from_parts(headers.clone(), body.clone()).unwrap(); + let encoded_record = + encode_stored_record(StoredRecord::from(record.clone()).metered().as_ref()); + assert_eq!(record.metered_size(), semantic_metered_size(&record)); + assert_eq!(record.metered_size(), try_metered_size(encoded_record.as_ref()).unwrap() as usize); + } + ); + + proptest!( + #![proptest_config(ProptestConfig::with_cases(10))] + #[test] + fn roundtrip_command_metered(command in command_strategy()) { + let record = Record::Command(command); + let encoded_record = + encode_stored_record(StoredRecord::from(record.clone()).metered().as_ref()); + let expected_metered = semantic_metered_size(&record); + let wire_metered = try_metered_size(encoded_record.as_ref()).unwrap() as usize; + let decoded_record = decode_record(encoded_record).unwrap(); + + assert_eq!(record.metered_size(), expected_metered); + assert_eq!(record.metered_size(), wire_metered); + prop_assert_eq!(decoded_record, Metered::::from(record)); + } + ); + + #[test] + fn roundtrip_encrypted_stored_record() { + let mut encoded = BytesMut::with_capacity(1 + 12 + 10 + 16); + encoded.put_u8(0x02); + encoded.put_slice(b"0123456789ab"); + encoded.put_slice(b"ciphertext"); + encoded.put_slice(b"0123456789abcdef"); + let record = + StoredRecord::encrypted(EncryptedRecord::try_from(encoded.freeze()).unwrap(), 123); + let metered_record = record.clone().metered(); + let encoded_record = encode_stored_record(metered_record.as_ref()); + let decoded_record = decode_stored_record(encoded_record).unwrap(); + assert_eq!(decoded_record, metered_record); + } + + #[rstest] + #[case(0b0000_0010, MagicByte { record_type: RecordType::Envelope, metered_size_varlen: 1})] + #[case(0b0001_0010, MagicByte { record_type: RecordType::Envelope, metered_size_varlen: 3})] + #[case(0b0000_0011, MagicByte { record_type: RecordType::EncryptedEnvelope, metered_size_varlen: 1})] + #[case(0b0000_1001, MagicByte { record_type: RecordType::Command, metered_size_varlen: 2})] + fn valid_magic_byte_parsing(#[case] as_u8: u8, #[case] magic_byte: MagicByte) { + assert_eq!(MagicByte::try_from(as_u8).unwrap(), magic_byte); + assert_eq!(u8::from(magic_byte), as_u8); + } + + #[rstest] + #[case(0b0000_1101, "invalid record type ordinal")] + #[case(0b0001_1001, "invalid metered_size_varlen")] + fn invalid_magic_byte_parsing(#[case] as_u8: u8, #[case] expected: &'static str) { + assert_eq!(MagicByte::try_from(as_u8), Err(expected)); + } + + #[test] + fn metered_record_truncated_after_magic_byte_returns_error() { + // Magic byte: Envelope (0b0000_0010), metered_size_varlen = 1 -> expects 1 more byte. + let truncated = Bytes::from_static(&[0b0000_0010]); + let result = decode_record(truncated); + assert_eq!( + result, + Err(StoredRecordDecodeError::Truncated("MeteredSize")) + ); + } + + #[rstest] + #[case::envelope_empty_headers( + StoredRecord::from(Record::Envelope( + EnvelopeRecord::try_from_parts(vec![], Bytes::from_static(b"hello")).unwrap() + )), + &[ + 0x02, 0x0d, // envelope record, metered size 13 + 0x00, // no headers + b'h', b'e', b'l', b'l', b'o', + ], + )] + #[case::envelope_with_header( + StoredRecord::from(Record::Envelope( + EnvelopeRecord::try_from_parts( + vec![Header { + name: Bytes::from_static(b"k"), + value: Bytes::from_static(b"v"), + }], + Bytes::from_static(b"b"), + ).unwrap() + )), + &[ + 0x02, 0x0d, // envelope record, metered size 13 + 0x10, 0x01, // one header, one byte for num headers + 0x01, b'k', + 0x01, b'v', + b'b', + ], + )] + #[case::command_trim( + StoredRecord::from(Record::Command(CommandRecord::Trim(42))), + &[ + 0x01, 0x16, // command record, metered size 22 + 0x01, // trim command ordinal + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x2a, + ], + )] + fn stored_record_encoding_matches_existing_wire_format( + #[case] record: StoredRecord, + #[case] expected: &[u8], + ) { + let metered_record = record.clone().metered(); + let encoded_size = stored_record_encoded_size(metered_record.as_ref()); + let encoded = encode_stored_record(metered_record.as_ref()); + let mut encoded_into = BytesMut::with_capacity(encoded_size); + encode_stored_record_into(metered_record.as_ref(), &mut encoded_into); + + assert_eq!(encoded.len(), encoded_size); + assert_eq!(encoded.as_ref(), expected); + assert_eq!(encoded_into.as_ref(), expected); + assert_eq!(decode_stored_record(encoded).unwrap().into_inner(), record); + } + + #[test] + fn encrypted_stored_record_encoding_matches_existing_wire_format() { + let encrypted_payload = Bytes::from_static(b"\x020123456789abciphertext0123456789abcdef"); + let record = StoredRecord::encrypted( + EncryptedRecord::try_from(encrypted_payload.clone()).unwrap(), + 123, + ); + + let encoded = encode_stored_record(record.clone().metered().as_ref()); + + assert_eq!( + encoded.as_ref(), + [&[0x03, 0x7b], encrypted_payload.as_ref()].concat() + ); + assert_eq!(decode_stored_record(encoded).unwrap().into_inner(), record); + } + + #[test] + fn decode_stored_record_preserves_encoded_metered_size_prefix() { + let record = StoredRecord::from(Record::Envelope( + EnvelopeRecord::try_from_parts(vec![], Bytes::from_static(b"hello")).unwrap(), + )); + let mut encoded = encode_stored_record(record.clone().metered().as_ref()).to_vec(); + encoded[1] = 99; + + let decoded = decode_stored_record(Bytes::from(encoded)).unwrap(); + + assert_eq!(decoded.metered_size(), 99); + assert_eq!(decoded.into_inner(), record); + } + + #[test] + fn decode_record_preserves_encoded_metered_size_prefix() { + let record = Record::Envelope( + EnvelopeRecord::try_from_parts(vec![], Bytes::from_static(b"hello")).unwrap(), + ); + let mut encoded = + encode_stored_record(StoredRecord::from(record.clone()).metered().as_ref()).to_vec(); + encoded[1] = 99; + + let decoded = decode_record(Bytes::from(encoded)).unwrap(); + + assert_eq!(decoded.metered_size(), 99); + assert_eq!(decoded.into_inner(), record); + } + + #[test] + fn test_read_varint() { + let data = [0u8, 0, 0, 1, 0, 0, 0]; + + assert_eq!(read_vint_u32_be(&data[..4]), 1u32); + assert_eq!(read_vint_u32_be(&data[2..5]), 2u32.pow(8)); + assert_eq!(read_vint_u32_be(&data[2..6]), 2u32.pow(16)); + assert_eq!(read_vint_u32_be(&data[3..]), 2u32.pow(24)); + } +} diff --git a/storage/src/record/iterator.rs b/storage/src/record/iterator.rs new file mode 100644 index 00000000..35aca44f --- /dev/null +++ b/storage/src/record/iterator.rs @@ -0,0 +1,169 @@ +use std::iter::FusedIterator; + +use s2_common::record::Metered; + +use super::{ + StoredRecordDecodeError, StoredSequencedBytes, StoredSequencedRecord, decode_stored_record, +}; + +pub struct StoredRecordIterator { + inner: I, +} + +impl StoredRecordIterator { + pub fn new(inner: I) -> Self { + Self { inner } + } +} + +impl Iterator for StoredRecordIterator +where + I: Iterator>, + E: std::fmt::Debug + Into, +{ + type Item = Result, StoredRecordDecodeError>; + + fn next(&mut self) -> Option { + self.inner.next().map(|result| { + let (position, bytes) = result.map_err(Into::into)?.into_parts(); + let record = decode_stored_record(bytes)?; + Ok(record.sequenced(position)) + }) + } +} + +impl FusedIterator for StoredRecordIterator +where + I: FusedIterator>, + E: std::fmt::Debug + Into, +{ +} + +#[cfg(test)] +mod tests { + use bytes::{BufMut, Bytes, BytesMut}; + use s2_common::record::{ + EnvelopeRecord, Metered, MeteredExt, MeteredSize, Record, SeqNum, Sequenced, + StreamPosition, Timestamp, + }; + + use super::*; + use crate::record::{ + EncryptedRecord, StoredRecord, StoredSequencedBytes, StoredSequencedRecord, + encode_stored_record, + }; + + fn test_stored_plaintext_record( + seq_num: SeqNum, + timestamp: Timestamp, + body: &'static [u8], + ) -> Metered { + StoredRecord::Plaintext(Record::Envelope( + EnvelopeRecord::try_from_parts(vec![], Bytes::from_static(body)).unwrap(), + )) + .metered() + .sequenced(StreamPosition { seq_num, timestamp }) + } + + fn test_stored_encrypted_record( + seq_num: SeqNum, + timestamp: Timestamp, + ) -> Metered { + let metered_size = Record::Envelope( + EnvelopeRecord::try_from_parts(vec![], Bytes::from_static(b"secret payload")).unwrap(), + ) + .metered_size(); + + let mut encoded = BytesMut::with_capacity(1 + 12 + 10 + 16); + encoded.put_u8(0x02); + encoded.put_bytes(0xAB, 12); + encoded.put_slice(b"ciphertext"); + encoded.put_bytes(0xCD, 16); + let record = EncryptedRecord::try_from(encoded.freeze()).unwrap(); + + StoredRecord::Encrypted { + metered_size, + record, + } + .metered() + .sequenced(StreamPosition { seq_num, timestamp }) + } + + fn to_stored_bytes_iter( + records: Vec>, + ) -> impl Iterator> { + records + .into_iter() + .map(|record| { + let (position, record) = record.into_parts(); + Sequenced::new(position, encode_stored_record(record.as_ref())) + }) + .map(Ok) + } + + #[test] + fn stored_iterator_decodes_plaintext_records() { + let expected = vec![ + test_stored_plaintext_record(1, 10, b"p0"), + test_stored_plaintext_record(2, 11, b"p1"), + ]; + let actual = StoredRecordIterator::new(to_stored_bytes_iter(expected.clone())) + .collect::, _>>() + .unwrap(); + + assert_eq!(actual, expected); + } + + #[test] + fn stored_iterator_preserves_encrypted_records() { + let expected = vec![test_stored_encrypted_record(1, 10)]; + + let actual = StoredRecordIterator::new(to_stored_bytes_iter(expected.clone())) + .collect::, _>>() + .unwrap(); + + assert_eq!(actual, expected); + } + + #[test] + fn stored_iterator_surfaces_decode_errors() { + let invalid_data = Sequenced::new( + StreamPosition { + seq_num: 1, + timestamp: 10, + }, + Bytes::new(), + ); + let mut iter = StoredRecordIterator::new(std::iter::once::< + Result, + >(Ok(invalid_data))); + + let error = iter + .next() + .expect("error expected") + .expect_err("expected error"); + assert!(matches!( + error, + StoredRecordDecodeError::Truncated("MagicByte") + )); + assert!(iter.next().is_none()); + } + + #[test] + fn stored_iterator_preserves_source_errors() { + let mut iter = StoredRecordIterator::new(std::iter::once::< + Result, + >(Err( + StoredRecordDecodeError::InvalidValue("test", "boom"), + ))); + + let error = iter + .next() + .expect("error expected") + .expect_err("expected error"); + assert!(matches!( + error, + StoredRecordDecodeError::InvalidValue("test", "boom") + )); + } +} diff --git a/storage/src/record/mod.rs b/storage/src/record/mod.rs new file mode 100644 index 00000000..cbbe17da --- /dev/null +++ b/storage/src/record/mod.rs @@ -0,0 +1,31 @@ +mod batcher; +mod codec; +mod encryption; +mod framing; +mod iterator; +#[cfg(any(test, feature = "test-util"))] +pub mod test_util; + +pub use batcher::{RecordBatch, RecordBatcher}; +pub use codec::StoredRecordDecodeError; +pub(crate) use codec::WireEncode; +pub use encryption::{ + EncryptedRecord, RecordDecryptionError, decrypt_read_session_output, decrypt_stored_record, + encrypt_append_input, encrypt_record, +}; +pub use framing::{ + StoredRecord, StoredSequencedBytes, StoredSequencedRecord, decode_if_command_record, + decode_record, decode_stored_record, encode_stored_record, encode_stored_record_into, + stored_record_encoded_size, try_metered_size, +}; +pub use iterator::StoredRecordIterator; +use s2_common::stream::{ + AppendInput, AppendRecord, AppendRecordBatch, AppendRecordParts, ReadBatch, ReadSessionOutput, +}; + +pub type StoredAppendRecord = AppendRecord; +pub type StoredAppendRecordParts = AppendRecordParts; +pub type StoredAppendRecordBatch = AppendRecordBatch; +pub type StoredAppendInput = AppendInput; +pub type StoredReadBatch = ReadBatch; +pub type StoredReadSessionOutput = ReadSessionOutput; diff --git a/storage/src/record/test_util.rs b/storage/src/record/test_util.rs new file mode 100644 index 00000000..b692053e --- /dev/null +++ b/storage/src/record/test_util.rs @@ -0,0 +1,208 @@ +use bytes::Bytes; +use s2_common::record::{EnvelopeRecord, Header, MeteredExt as _, Record}; + +use super::{StoredRecord, encode_stored_record}; + +const MAX_METERED_SIZE: usize = 0xFF_FFFF; +const MAX_STORED_ENVELOPE_RECORD_LEN: usize = MAX_METERED_SIZE - 2; +const MAX_EMPTY_ENVELOPE_BODY_LEN: usize = MAX_METERED_SIZE - 8; +const EMPTY_ENVELOPE_METERED_OVERHEAD: usize = MAX_METERED_SIZE - MAX_EMPTY_ENVELOPE_BODY_LEN; + +/// Build a stored plaintext envelope record with no headers and `body_len` body bytes. +/// +/// The fixture is produced through the stored-record encoder, so callers get a valid storage +/// frame. +/// +/// # Panics +/// +/// Panics if the resulting record's metered size cannot fit in the stored-record metered-size +/// prefix. +pub fn stored_envelope_record_with_body_len(body_len: usize) -> Bytes { + assert!( + body_len <= MAX_EMPTY_ENVELOPE_BODY_LEN, + "stored envelope record body length must be <= {MAX_EMPTY_ENVELOPE_BODY_LEN}" + ); + + encode_stored_envelope_record(vec![], body_len) +} + +/// Build a stored plaintext envelope record with no headers and the exact logical +/// `metered_size`. +/// +/// The fixture is produced through the stored-record encoder, so callers get a valid storage +/// frame. +/// +/// # Panics +/// +/// Panics if `metered_size` is outside the representable range for an empty envelope record. +pub fn stored_envelope_record_with_metered_size(metered_size: usize) -> Bytes { + assert!( + (EMPTY_ENVELOPE_METERED_OVERHEAD..=MAX_METERED_SIZE).contains(&metered_size), + "stored envelope record metered size must be in {EMPTY_ENVELOPE_METERED_OVERHEAD}..={MAX_METERED_SIZE}" + ); + + stored_envelope_record_with_body_len(metered_size - EMPTY_ENVELOPE_METERED_OVERHEAD) +} + +/// Build a stored plaintext envelope record whose encoded length is exactly `encoded_len`. +/// +/// The fixture is produced through the stored-record encoder, so callers get a valid storage frame +/// with a controlled encoded size. +/// +/// # Panics +/// +/// Panics if `encoded_len` is outside the representable range for this fixture. +pub fn stored_envelope_record_with_encoded_len(encoded_len: usize) -> Bytes { + // Empty envelopes cover the smallest lengths and the exact points where the metered-size + // prefix widens. A single one-byte header fills the adjacent gaps. + let (headers, body_len) = match encoded_len { + 3..=6 => (vec![], encoded_len - 3), + 7..=251 => (single_header(), encoded_len - 7), + 252 => (vec![], encoded_len - 4), + 253..=65_532 => (single_header(), encoded_len - 8), + 65_533 => (vec![], encoded_len - 5), + 65_534..=MAX_STORED_ENVELOPE_RECORD_LEN => (single_header(), encoded_len - 9), + _ => panic!( + "stored envelope record encoded length must be in 3..={MAX_STORED_ENVELOPE_RECORD_LEN}" + ), + }; + + let encoded = encode_stored_envelope_record(headers, body_len); + assert_eq!(encoded.len(), encoded_len); + + encoded +} + +fn encode_stored_envelope_record(headers: Vec
, body_len: usize) -> Bytes { + let envelope = + EnvelopeRecord::try_from_parts(headers, Bytes::from(vec![0u8; body_len])).unwrap(); + let stored = StoredRecord::from(Record::Envelope(envelope)).metered(); + encode_stored_record(stored.as_ref()) +} + +fn single_header() -> Vec
{ + vec![Header { + name: Bytes::from_static(b"x"), + value: Bytes::new(), + }] +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use rstest::rstest; + use s2_common::record::{Metered, MeteredSize as _, Record}; + + use super::{ + EMPTY_ENVELOPE_METERED_OVERHEAD, MAX_EMPTY_ENVELOPE_BODY_LEN, MAX_METERED_SIZE, + MAX_STORED_ENVELOPE_RECORD_LEN, stored_envelope_record_with_body_len, + stored_envelope_record_with_encoded_len, stored_envelope_record_with_metered_size, + }; + use crate::record::{StoredRecord, decode_stored_record, encode_stored_record}; + + #[rstest] + #[case(3)] + #[case(4)] + #[case(6)] + #[case(7)] + #[case(8)] + #[case(250)] + #[case(251)] + #[case(252)] + #[case(253)] + #[case(254)] + #[case(32_768)] + #[case(65_532)] + #[case(65_533)] + #[case(65_534)] + #[case(65_535)] + #[case(MAX_STORED_ENVELOPE_RECORD_LEN - 1)] + #[case(MAX_STORED_ENVELOPE_RECORD_LEN)] + fn exact_encoded_len_fixture_is_valid_at_boundaries(#[case] encoded_len: usize) { + let encoded = stored_envelope_record_with_encoded_len(encoded_len); + assert_valid_stored_envelope(encoded, encoded_len); + } + + #[rstest] + #[case(0)] + #[case(1)] + #[case(247)] + #[case(248)] + #[case(65_527)] + #[case(65_528)] + #[case(MAX_EMPTY_ENVELOPE_BODY_LEN)] + fn body_len_fixture_is_valid(#[case] body_len: usize) { + let encoded = stored_envelope_record_with_body_len(body_len); + + let decoded = assert_valid_stored_envelope(encoded.clone(), encoded.len()); + let StoredRecord::Plaintext(Record::Envelope(envelope)) = decoded.into_inner() else { + panic!("expected plaintext envelope record"); + }; + assert_eq!(envelope.headers(), []); + assert_eq!(envelope.body().len(), body_len); + } + + #[rstest] + #[case(EMPTY_ENVELOPE_METERED_OVERHEAD)] + #[case(EMPTY_ENVELOPE_METERED_OVERHEAD + 1)] + #[case(255)] + #[case(256)] + #[case(65_535)] + #[case(65_536)] + #[case(MAX_METERED_SIZE)] + fn metered_size_fixture_is_valid(#[case] metered_size: usize) { + let encoded = stored_envelope_record_with_metered_size(metered_size); + + let decoded = assert_valid_stored_envelope(encoded.clone(), encoded.len()); + assert_eq!(decoded.metered_size(), metered_size); + let StoredRecord::Plaintext(Record::Envelope(envelope)) = decoded.into_inner() else { + panic!("expected plaintext envelope record"); + }; + assert_eq!(envelope.headers(), []); + } + + #[test] + #[should_panic(expected = "stored envelope record encoded length must be in")] + fn exact_encoded_len_rejects_too_small_len() { + stored_envelope_record_with_encoded_len(2); + } + + #[test] + #[should_panic(expected = "stored envelope record encoded length must be in")] + fn exact_encoded_len_rejects_too_large_len() { + stored_envelope_record_with_encoded_len(MAX_STORED_ENVELOPE_RECORD_LEN + 1); + } + + #[test] + #[should_panic(expected = "stored envelope record body length must be <=")] + fn body_len_rejects_too_large_body() { + stored_envelope_record_with_body_len(MAX_EMPTY_ENVELOPE_BODY_LEN + 1); + } + + #[test] + #[should_panic(expected = "stored envelope record metered size must be in")] + fn metered_size_rejects_too_small_size() { + stored_envelope_record_with_metered_size(EMPTY_ENVELOPE_METERED_OVERHEAD - 1); + } + + #[test] + #[should_panic(expected = "stored envelope record metered size must be in")] + fn metered_size_rejects_too_large_size() { + stored_envelope_record_with_metered_size(MAX_METERED_SIZE + 1); + } + + fn assert_valid_stored_envelope(encoded: Bytes, expected_len: usize) -> Metered { + assert_eq!(encoded.len(), expected_len); + + let decoded = decode_stored_record(encoded.clone()).unwrap(); + let decoded_record = decoded.clone().into_inner(); + assert!(matches!( + decoded_record, + StoredRecord::Plaintext(Record::Envelope(_)) + )); + assert_eq!(decoded.metered_size(), decoded_record.metered_size()); + assert_eq!(encode_stored_record(decoded.as_ref()), encoded); + + decoded + } +} diff --git a/testcontainers/CHANGELOG.md b/testcontainers/CHANGELOG.md new file mode 100644 index 00000000..ff6e0b55 --- /dev/null +++ b/testcontainers/CHANGELOG.md @@ -0,0 +1,143 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.43.1] - 2026-09-28 + + + +## [0.43.0] - 2026-09-25 + + + +## [0.42.14] - 2026-09-24 + + + +## [0.42.13] - 2026-09-22 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.42.12] - 2026-09-16 + + + +## [0.42.11] - 2026-09-11 + + + +## [0.42.10] - 2026-09-11 + + + +## [0.42.9] - 2026-09-10 + + + +## [0.42.8] - 2026-09-01 + + + +## [0.42.7] - 2026-08-18 + + + +## [0.42.6] - 2026-08-13 + + + +## [0.42.5] - 2026-08-07 + + + +## [0.42.4] - 2026-08-07 + + + +## [0.42.3] - 2026-08-05 + + + +## [0.42.2] - 2026-08-05 + + + +## [0.42.1] - 2026-08-01 + + + +## [0.42.0] - 2026-07-31 + +### Features + +- [**breaking**] Replace S2Error with surface-specific errors ([#653](https://github.com/s2-streamstore/s2/issues/653)) + + + +## [0.41.2] - 2026-07-28 + + + +## [0.41.1] - 2026-07-24 + + + +## [0.41.0] - 2026-07-23 + + + +## [0.40.1] - 2026-07-23 + + + +## [0.40.0] - 2026-07-22 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.39.3] - 2026-07-17 + + + +## [0.39.2] - 2026-07-16 + + + +## [0.39.1] - 2026-07-07 + + + +## [0.39.0] - 2026-07-06 + + + +## [0.38.0] - 2026-07-02 + +### Miscellaneous Tasks + +- Update Cargo.toml dependencies + + + +## [0.37.1] - 2026-06-22 + + + +## [0.37.0] - 2026-06-22 + + + +## [0.36.8] - 2026-06-15 + +### Features + +- Add s2-testcontainers crate ([#551](https://github.com/s2-streamstore/s2/issues/551)) + + diff --git a/testcontainers/Cargo.toml b/testcontainers/Cargo.toml new file mode 100644 index 00000000..9d2ed490 --- /dev/null +++ b/testcontainers/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "s2-testcontainers" +version = "0.43.1" +description = "Testcontainers helpers for the S2 Docker image" +edition.workspace = true +license.workspace = true +repository = "https://github.com/s2-streamstore/s2/tree/main/testcontainers" +homepage.workspace = true +documentation = "https://docs.rs/s2-testcontainers/latest/s2_testcontainers/" +keywords = ["s2", "testcontainers", "integration-testing", "streams"] +categories = ["development-tools::testing", "database"] +readme = "README.md" + +[dependencies] +reqwest = { workspace = true } +s2-sdk = { workspace = true } +testcontainers = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["time"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/testcontainers/README.md b/testcontainers/README.md new file mode 100644 index 00000000..ad760266 --- /dev/null +++ b/testcontainers/README.md @@ -0,0 +1,16 @@ +# s2-testcontainers + +Testcontainers helpers for the S2 Docker image, with a paved path for `s2-lite` integration tests. + +See [`examples/s2_lite.rs`](examples/s2_lite.rs) for a complete example that +starts `s2-lite`, builds an SDK client, and ensures a basin/stream. + +For lower-level composition with `testcontainers`, use `s2_image()` for the raw S2 Docker image or `s2_lite_image()` for a container request with the `lite` subcommand configured: + +```rust +use s2_testcontainers::{s2_config_for_endpoint, s2_image, s2_lite_image}; + +let image = s2_image(); +let request = s2_lite_image(); +let config = s2_config_for_endpoint("http://localhost:8080", "ignored").unwrap(); +``` diff --git a/testcontainers/examples/s2_lite.rs b/testcontainers/examples/s2_lite.rs new file mode 100644 index 00000000..701479bb --- /dev/null +++ b/testcontainers/examples/s2_lite.rs @@ -0,0 +1,26 @@ +use s2_sdk::types::{BasinName, EnsureBasinInput, EnsureStreamInput, StreamName}; +use s2_testcontainers::S2Lite; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> s2_testcontainers::Result<()> { + let s2 = S2Lite::start().await?; + + let client = s2.client()?; + let basin_name: BasinName = "test-basin".parse()?; + client + .ensure_basin(EnsureBasinInput::new(basin_name.clone())) + .await?; + + let basin = client.basin(basin_name.clone()); + let stream_name: StreamName = "test-stream".parse()?; + basin + .ensure_stream(EnsureStreamInput::new(stream_name.clone())) + .await?; + + println!( + "s2-lite is running at {} with basin {basin_name} and stream {stream_name}", + s2.endpoint() + ); + + Ok(()) +} diff --git a/testcontainers/src/lib.rs b/testcontainers/src/lib.rs new file mode 100644 index 00000000..9e946451 --- /dev/null +++ b/testcontainers/src/lib.rs @@ -0,0 +1,227 @@ +#![doc = include_str!("../README.md")] +#![warn(missing_docs)] + +use std::time::Duration; + +use s2_sdk::{ + S2, + error::RequestError, + types::{AccountEndpoint, BasinEndpoint, S2Config, S2Endpoints, ValidationError}, +}; +use testcontainers::{ + ContainerAsync, ContainerRequest, GenericImage, ImageExt, TestcontainersError, + core::IntoContainerPort, runners::AsyncRunner, +}; +use tokio::time::{Instant, sleep, timeout}; + +/// Image repository for the S2 Docker image. +pub const IMAGE: &str = "ghcr.io/s2-streamstore/s2"; +/// Default S2 image tag. +pub const DEFAULT_TAG: &str = env!("CARGO_PKG_VERSION"); +/// Port exposed by s2-lite. +pub const PORT: u16 = 80; +/// Default access token used by [`S2Lite::client`]. +pub const DEFAULT_ACCESS_TOKEN: &str = "ignored"; + +const HEALTH_TIMEOUT: Duration = Duration::from_secs(30); +const HEALTH_POLL_INTERVAL: Duration = Duration::from_millis(100); +const HEALTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); + +/// Result type for this crate. +pub type Result = std::result::Result; + +/// Errors from s2-testcontainers helpers. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Error from Testcontainers. + #[error("testcontainers error: {0}")] + Testcontainers(#[from] TestcontainersError), + /// Request error from the S2 SDK. + #[error("s2 sdk request error: {0}")] + Request(#[from] RequestError), + /// S2 endpoint or resource name validation error. + #[error("validation error: {0}")] + Validation(#[from] ValidationError), + /// s2-lite did not become healthy before the startup timeout. + #[error("s2-lite did not become healthy at {endpoint}")] + NotHealthy { + /// Endpoint that did not become healthy. + endpoint: String, + }, +} + +/// Running s2-lite Testcontainers instance. +#[derive(Debug)] +pub struct S2Lite { + container: ContainerAsync, + endpoint: String, + client: S2, +} + +impl S2Lite { + /// Start s2-lite with the default image tag. + pub async fn start() -> Result { + Self::start_with(DEFAULT_TAG).await + } + + /// Start s2-lite with a specific image tag. + pub async fn start_with(tag: impl Into) -> Result { + let container = s2_lite_image_with_tag(tag).start().await?; + let host = container.get_host().await?; + let port = container.get_host_port_ipv4(PORT).await?; + let endpoint = format!("http://{host}:{port}"); + + wait_until_healthy(&endpoint).await?; + + let client = S2::new(s2_config_for_endpoint(&endpoint, DEFAULT_ACCESS_TOKEN)?)?; + + Ok(Self { + container, + endpoint, + client, + }) + } + + /// Return the mapped HTTP endpoint for this s2-lite instance. + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + /// Build an [`S2Config`] for this s2-lite instance with the provided access token. + pub fn config(&self, access_token: impl Into) -> Result { + s2_config_for_endpoint(&self.endpoint, access_token) + } + + /// Build an [`S2`] client for this s2-lite instance. + pub fn client(&self) -> Result { + Ok(self.client.clone()) + } + + /// Return the underlying Testcontainers container. + pub fn container(&self) -> &ContainerAsync { + &self.container + } +} + +/// Return the default S2 Docker [`GenericImage`]. +pub fn s2_image() -> GenericImage { + s2_image_with_tag(DEFAULT_TAG) +} + +/// Return an S2 Docker [`GenericImage`] with a specific tag. +pub fn s2_image_with_tag(tag: impl Into) -> GenericImage { + GenericImage::new(IMAGE.to_string(), tag.into()) +} + +/// Return the default S2 Docker [`ContainerRequest`] configured to run `s2 lite`. +pub fn s2_lite_image() -> ContainerRequest { + s2_lite_image_with_tag(DEFAULT_TAG) +} + +/// Return an S2 Docker [`ContainerRequest`] with a specific tag configured to run `s2 lite`. +pub fn s2_lite_image_with_tag(tag: impl Into) -> ContainerRequest { + s2_image_with_tag(tag) + .with_exposed_port(PORT.tcp()) + .with_cmd(["lite"]) +} + +/// Build an [`S2Config`] wired to use an endpoint for both account and basin APIs. +pub fn s2_config_for_endpoint( + endpoint: impl AsRef, + access_token: impl Into, +) -> Result { + let endpoint = endpoint.as_ref(); + let endpoints = S2Endpoints::new( + AccountEndpoint::new(endpoint)?, + BasinEndpoint::new(endpoint)?, + )?; + + Ok(S2Config::new(access_token).with_endpoints(endpoints)) +} + +async fn wait_until_healthy(endpoint: &str) -> Result<()> { + let client = reqwest::Client::new(); + let health_url = format!("{endpoint}/health"); + let deadline = Instant::now() + HEALTH_TIMEOUT; + + loop { + let now = Instant::now(); + if now >= deadline { + return Err(Error::NotHealthy { + endpoint: endpoint.to_string(), + }); + } + + let request_timeout = HEALTH_REQUEST_TIMEOUT.min(deadline - now); + if let Ok(Ok(response)) = timeout(request_timeout, client.get(&health_url).send()).await + && response.status().is_success() + { + return Ok(()); + } + + let now = Instant::now(); + if now >= deadline { + return Err(Error::NotHealthy { + endpoint: endpoint.to_string(), + }); + } + + sleep(HEALTH_POLL_INTERVAL.min(deadline - now)).await; + } +} + +#[cfg(test)] +mod tests { + use s2_sdk::types::{BasinName, EnsureBasinInput, EnsureStreamInput, StreamName}; + use testcontainers::Image; + + use super::*; + + #[test] + fn s2_image_defaults_to_versioned_docker_image() { + let image = s2_image_with_tag("test-tag"); + + assert_eq!(image.name(), IMAGE); + assert_eq!(image.tag(), "test-tag"); + assert!(image.expose_ports().is_empty()); + } + + #[test] + fn s2_lite_image_defaults_to_lite_command() { + let request = s2_lite_image_with_tag("test-tag"); + + assert_eq!(request.image().name(), IMAGE); + assert_eq!(request.image().tag(), "test-tag"); + assert_eq!(request.image().expose_ports(), &[PORT.tcp()]); + assert_eq!(request.cmd().collect::>(), ["lite"]); + } + + #[tokio::test] + async fn config_uses_same_endpoint_for_account_and_basin() { + let config = s2_config_for_endpoint("http://localhost:8080", "ignored").unwrap(); + + S2::new(config).unwrap(); + } + + #[tokio::test] + async fn starts_s2_lite_and_ensures_resources() { + let s2 = S2Lite::start().await.unwrap(); + + let client = s2.client().unwrap(); + let basin_name = "test-basin".parse::().unwrap(); + client + .ensure_basin(EnsureBasinInput::new(basin_name.clone())) + .await + .unwrap(); + + let basin = client.basin(basin_name.clone()); + let stream_name = "test-stream".parse::().unwrap(); + basin + .ensure_stream(EnsureStreamInput::new(stream_name.clone())) + .await + .unwrap(); + + assert_eq!(basin_name.as_ref(), "test-basin"); + assert_eq!(stream_name.as_ref(), "test-stream"); + } +}