diff --git a/.beads/.gitignore b/.beads/.gitignore index 07cf50a..3dc511c 100644 --- a/.beads/.gitignore +++ b/.beads/.gitignore @@ -1,4 +1,5 @@ # Database +*.fsqlite-migration-state *.db *.db-journal *.db-shm diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index fce3edf..6bebffa 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,6 @@ +{"id":"runlimit-1u5","title":"Outcome-aware attempts and durable GCRA","description":"Canonical plan: docs/auth-attempts-gcra-plan.md. Add independent bounded attempt admission/completion and durable GCRA; preserve existing quota consumers and immutable storage protocol.","acceptance_criteria":"Workspace, Clippy, external consumers and live PostgreSQL pass; concurrent attempts, stale fencing, transaction rollback, hard capacity, GCRA atomic batches covered; native review clean.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-09-21T10:20:33.958167Z","created_by":"aa","updated_at":"2026-09-21T11:13:41.315422Z","closed_at":"2026-09-21T11:13:41.315305Z","close_reason":"Children complete; native review 77890cd..ba58c12 clean, six findings fixed, 69 live database tests and packaged consumer pass; PR #9 opened","source_repo":"runlimit","source_repo_path":"/Users/aa/Documents/dealsafe-upstream-work/runlimit","compaction_level":0,"original_size":0} +{"id":"runlimit-1u5.1","title":"T-01: Outcome-aware attempts","description":"docs/auth-attempts-gcra-plan.md#t-01--outcome-aware-attempts; core/memory/postgres attempt policy and fenced completion. No quota API changes. Recovery: disable opt-in adoption, preserve new migration history.","acceptance_criteria":"Deterministic memory and live PostgreSQL concurrency, lease expiry, stale fencing, rollback, success reset and hard bound tests; native review clean.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-09-21T10:29:13.163930Z","created_by":"aa","updated_at":"2026-09-21T12:21:10.869052Z","closed_at":"2026-09-21T11:13:23.444035Z","close_reason":"Implemented and validated; native review 77890cd..ba58c12 clean after six root-cause fixes; 69 live PostgreSQL tests and packaged MSRV consumer pass; delivered PR #9 without merge or publication","source_repo":"runlimit","source_repo_path":"/Users/aa/Documents/dealsafe-upstream-work/runlimit","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"runlimit-1u5.1","depends_on_id":"runlimit-1u5","type":"parent-child","created_at":"2026-09-21T10:29:13.163930Z","created_by":"aa","metadata":"{}","thread_id":""}],"comments":[{"id":1,"issue_id":"runlimit-1u5.1","author":"aa","text":"User review P2 at c335806 verified and fixed: volatile attempt-cleanup expiry cutoff filtered all 65,536 active rows under the shard lock (1,245 shared-buffer hits in the local PostgreSQL 18 fixture). Extracted the exact production SQL and applied the existing MATERIALIZED authoritative clock plus scalar init-plan pattern. Kept the later admission/lease clock sample unchanged. Dense zero/three-expired-row EXPLAIN (ANALYZE, BUFFERS) regression fails before and passes after; expiry is Index Cond, no active filter, zero-expiry cleanup uses two shared-buffer hits. No migration or public API change.","created_at":"2026-09-21T12:21:10Z"}]} +{"id":"runlimit-1u5.2","title":"T-02: Durable GCRA","description":"docs/auth-attempts-gcra-plan.md#t-02--durable-gcra; shared pure evaluator and additive PG backend. Recovery: leave fixed-window storage untouched and disable opt-in backend.","acceptance_criteria":"Live parity, atomic batches, concurrency, hard capacity, clock and cancellation tests; existing memory/external tests pass; native review clean.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-09-21T10:29:13.265952Z","created_by":"aa","updated_at":"2026-09-21T11:13:23.445357Z","closed_at":"2026-09-21T11:13:23.444047Z","close_reason":"Implemented and validated; native review 77890cd..ba58c12 clean after six root-cause fixes; 69 live PostgreSQL tests and packaged MSRV consumer pass; delivered PR #9 without merge or publication","source_repo":"runlimit","source_repo_path":"/Users/aa/Documents/dealsafe-upstream-work/runlimit","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"runlimit-1u5.2","depends_on_id":"runlimit-1u5","type":"parent-child","created_at":"2026-09-21T10:29:13.265952Z","created_by":"aa","metadata":"{}","thread_id":""}]} {"id":"runlimit-25t","title":"Release Runlimit 0.3.0 to crates.io","description":"Finalize, validate, publish, tag, and verify the coordinated Runlimit 0.3.0 workspace release.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-08-24T12:13:20.099647610Z","created_by":"aa","updated_at":"2026-08-24T12:28:00.411381922Z","closed_at":"2026-08-24T12:28:00.410898387Z","close_reason":"Published all five Runlimit 0.3.0 crates with verified registry checksums after green local and GitHub release gates; pushed v0.3.0","source_repo":"runlimit","source_repo_path":"/home/aa/Documents/runlimit","compaction_level":0,"original_size":0} {"id":"runlimit-e42","title":"Remove ambiguous Decision admission aliases","status":"closed","priority":2,"issue_type":"task","created_at":"2026-09-18T16:46:49.899002Z","created_by":"aa","updated_at":"2026-09-18T16:47:44.288112Z","closed_at":"2026-09-18T16:47:44.287954Z","close_reason":"Removed ambiguous Decision aliases, migrated all test callers, replaced README warning with explicit API guidance, and documented breaking change. Formatting, strict Clippy, workspace tests and all 29 disposable PostgreSQL tests passed.","source_repo":"runlimit","source_repo_path":"/Users/aa/Documents/runlimit","compaction_level":0,"original_size":0} {"id":"runlimit-f89","title":"Split GCRA-specific failures from MemoryStoreError","description":"Implement the abstraction review finding: introduce an algorithm-specific GcraStoreError for GCRA admission failures, remove the GCRA-only ArithmeticOverflow variant from MemoryStoreError, preserve common bounded-storage failures, migrate public signatures/tests/docs/smoke coverage, and run the required workspace checks.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-08-24T12:06:07.099669130Z","created_by":"aa","updated_at":"2026-08-24T12:08:54.354393496Z","closed_at":"2026-08-24T12:08:54.354119494Z","close_reason":"Completed GcraStoreError API split, migrated tests/docs/smoke coverage, and passed required workspace checks","source_repo":"runlimit","source_repo_path":"/home/aa/Documents/runlimit","compaction_level":0,"original_size":0} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8aa805..c9ad5e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,11 +22,11 @@ jobs: name: Format, lint, and test runs-on: ubuntu-latest steps: - # actions/checkout v4.3.1 + # actions/checkout v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Install stable Rust run: rustup toolchain install stable --profile minimal --component clippy --component rustfmt - # Swatinem/rust-cache v2.9.1 + # Swatinem/rust-cache v2.9.2 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - run: cargo +stable fmt --all -- --check - name: Lint default features @@ -62,13 +62,13 @@ jobs: name: Packaged archive smoke runs-on: ubuntu-latest steps: - # actions/checkout v4.3.1 + # actions/checkout v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Install Rust toolchains run: | rustup toolchain install stable --profile minimal rustup toolchain install 1.94.0 --profile minimal - # Swatinem/rust-cache v2.9.1 + # Swatinem/rust-cache v2.9.2 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - name: Test packaged crates as an external consumer run: ./scripts/run-external-consumer-smoke.sh @@ -77,11 +77,11 @@ jobs: name: Rust 1.94 runs-on: ubuntu-latest steps: - # actions/checkout v4.3.1 + # actions/checkout v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Install minimum supported Rust run: rustup toolchain install 1.94.0 --profile minimal - # Swatinem/rust-cache v2.9.1 + # Swatinem/rust-cache v2.9.2 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - name: Check default features run: cargo +1.94.0 check --workspace --all-targets --locked @@ -114,17 +114,17 @@ jobs: env: RUNLIMIT_POSTGRES_TEST_DATABASE_URL: postgresql://runlimit:runlimit@127.0.0.1:5432/runlimit steps: - # actions/checkout v4.3.1 + # actions/checkout v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Install stable Rust run: rustup toolchain install stable --profile minimal - # Swatinem/rust-cache v2.9.1 + # Swatinem/rust-cache v2.9.2 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - name: Run PostgreSQL integration tests (default features) run: >- cargo +stable test -p runlimit-postgres - --test postgres + --tests --locked -- --ignored @@ -133,7 +133,7 @@ jobs: run: >- cargo +stable test -p runlimit-postgres - --test postgres + --tests --all-features --locked -- @@ -144,7 +144,7 @@ jobs: name: Cargo deny runs-on: ubuntu-latest steps: - # actions/checkout v4.3.1 + # actions/checkout v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Install stable Rust run: rustup toolchain install stable --profile minimal diff --git a/AGENTS.md b/AGENTS.md index 7604299..e66030a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,7 +143,7 @@ Run the required database suite against disposable PostgreSQL with: ```sh RUNLIMIT_POSTGRES_TEST_DATABASE_URL=postgresql://... \ - cargo test -p runlimit-postgres --test postgres -- --ignored --test-threads=1 + cargo test -p runlimit-postgres --tests -- --ignored --test-threads=1 ``` diff --git a/README.md b/README.md index 567bd43..4b59067 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Runlimit is a framework-neutral Rust library for keyed rate limiting. It provides anchored fixed windows in memory or across PostgreSQL-backed replicas, -plus a hard-bounded process-local GCRA backend for continuously replenished +plus hard-bounded memory and PostgreSQL GCRA backends for continuously replenished quotas. The 0.x API is intentionally pre-stable. Its application boundary is exercised @@ -363,8 +363,53 @@ and full-replenishment durations round up to the next whole millisecond. Each key uses constant-size state, and it becomes eligible for bounded cleanup once its full burst capacity has replenished. -`GcraStore` is process-local. `PostgresLimiter` supports fixed-window policies -only. +`GcraStore` is process-local. For shared continuously replenished quotas, use +`runlimit_postgres::PostgresGcraLimiter`; `PostgresLimiter` keeps its existing +fixed-window contract. + +### Durable GCRA + +`PostgresGcraLimiter::new(pool)` implements `Limiter` and +accepts the same policy-bound checks as `GcraStore`. Call its `migrate()` before +serving traffic and schedule `cleanup_expired(maximum_rows)` to reclaim expired +storage. Its separate `gcra-migrations` stream installs only GCRA tables; +existing fixed-window consumers need no schema or workflow change. Strict host +migrators may vendor `CREATE_RUNLIMIT_GCRA_SQL` followed by +`INDEX_RUNLIMIT_GCRA_SHARD_EXPIRY_SQL` instead. The canonical `migrate()` applies +both in order, including when upgrading a database with the first migration +already installed. Shared SQLx migration +histories require every participating migrator to ignore unrelated versions. + +The memory and PostgreSQL GCRA implementations share one pure, exact evaluator +in `runlimit_core::gcra`. PostgreSQL supplies whole-millisecond time and retains +a committed time watermark per shard, preventing backward clock adjustments +from replenishing quota early. Delays are measured at evaluation time and may +conservatively include later database/transport latency. Allowed batches commit +every member together, preserve input order, and consume nothing on enforced or +shadow denial. The existing `CheckError` and `BatchCheckError` distinguish +definite non-consumption from uncertain commit; uncertain operations are never +automatically replayed. Cancellation while committing is likewise uncertain. + +The independent GCRA v1 storage protocol has 256 shards, a database-enforced +ceiling of 65,536 rows per shard, and configurable lower operational bounds via +`PostgresConfig`. Admission locks affected ledger rows in ascending shard order +before reading or writing counters, including absent keys. This deliberately +serializes keys in the same shard. Cleanup follows the same order, skips busy +shards, and deletes at most its requested bound. Discovery makes at most 256 +shard/expiry index probes under each shard's clamped clock. Deletion reads at +most the requested number of eligible entries per locked shard before selecting +the globally oldest requested rows; unrelated active entries are outside these +index ranges. Active rows are never evicted; +expired rows still occupy a slot until cleanup, but remain reusable for their +existing key. The shard derivation and locking order are persistent protocol +and cannot change in place during rolling deploys. + +Run the opt-in PostgreSQL GCRA suite with: + +```sh +RUNLIMIT_POSTGRES_TEST_DATABASE_URL=postgresql://... \ + cargo test -p runlimit-postgres --test gcra -- --ignored --test-threads=1 +``` ## Shadow mode @@ -495,6 +540,73 @@ their clock before the store exists with timestamps belong to that clock's coordinate system. `with_observer` remains a store builder because attaching telemetry does not reinterpret quota state. +## Outcome-aware authentication attempts + +`runlimit_core::attempts::AttemptPolicy` is separate from ordinary request quota +policies. It admits at most one active attempt per opaque policy-bound subject. +A failure or explicit abandonment increments consecutive failures and doubles +the retry delay up to the configured cap. Full success resets retry state. +Inactive failure state expires after the configured quiet period; application +audit history is independent and is never deleted by Runlimit. Quiet expiry +cannot erase an active lease, even when the lease exceeds the quiet period. + +Derive subjects with `KeyHasher::hash_attempt_for(&policy, normalized_subject)`. +`MemoryAttemptLimiter` and `PostgresAttemptLimiter` return an opaque, consuming +receipt on admission. Dropping it does not refund the attempt: its admission +lease expires as a failure, evaluated at the lease deadline. A late success +cannot reset a newer reservation. PostgreSQL samples its authoritative clock +after locks; process-local storage clamps backwards clock readings. PostgreSQL +uses server wall time, so a server clock correction can extend or shorten +waiting periods; deploy with disciplined database time. + +The policy validates nonzero whole-millisecond initial delay, delay cap, quiet +period, and admission lease. The cap must be at least the initial delay, and +quiet expiry must be at least the cap. Failure counts saturate at `u32::MAX`; +delay arithmetic saturates at the configured cap. Every parameter participates +in the storage fingerprint. These are application-selected security parameters, +not defaults chosen by the library. + +`runlimit_postgres::attempts::PostgresAttemptLimiter` installs only its own +`ATTEMPTS_MIGRATOR` / `CREATE_RUNLIMIT_ATTEMPTS_SQL`. Existing fixed-window +consumers need no new tables. PostgreSQL enforces 256 shards with at most 65,536 +slots each using a checked slot range and unique `(capacity_shard, capacity_slot)`. +`PostgresConfig` may lower the operational bound (4,096 slots by default). +Admission reclaims at most 16 expired rows in its shard, never live rows. Memory +storage has an explicit total capacity and similarly bounded cleanup. Full +storage fails closed; quotas and failure state are never evicted to admit a key. + +Standalone `complete` returns `AttemptCompletionResult` only after acknowledged +commit. `CheckError` preserves uncertain-commit classification; never replay an +uncertain admission or completion automatically. Dropping a pending future gives +the caller no acknowledgement and can race with commit. Existing ordinary quota +admission remains non-refundable. + +For session/audit atomicity, an application transaction owner such as Batter +uses the explicitly low-level PostgreSQL seam: + +1. `claim_in(executor, receipt)` locks and validates the live reservation. +2. Run authoritative application checks and writes in that transaction. +3. `finish_in(executor, claim, final_outcome)` stages success or failure. +4. Commit, then publish the result. Roll back on `Stale`, errors, or cancellation. + +A claim is fenced to the exact PostgreSQL transaction ID as well as the opaque +receipt token, which is rotated transactionally during claim. Rolling back a +containing savepoint reverts the token and invalidates an escaped claim even +though the outer transaction ID remains unchanged. Releasing a savepoint retains +the claim and its row lock. After a valid claim, time elapsed during application work does +not expire its held row lock. Using the claim in a different transaction returns +`Stale`. No transaction remains open during expensive credential verification. +The low-level API cannot own commit acknowledgement; its distinct +`StagedAttemptCompletion` is provisional. A direct `complete_in` operation is +also available for owners whose outcome is already final. An autocommit executor +must not be used for application atomicity. + +Attempt observations use their own enum and panic-isolated observer. Standalone +owners report admitted, denied, completed, stale, and uncertain-commit outcomes; +low-level staging deliberately emits no committed event. The host transaction +owner is responsible for reporting its final disposition. Observations never +include raw subjects or lease tokens. + ## PostgreSQL backend Use `runlimit-postgres` when a quota must be shared across replicas or survive diff --git a/crates/runlimit-core/src/attempts.rs b/crates/runlimit-core/src/attempts.rs new file mode 100644 index 0000000..d265e60 --- /dev/null +++ b/crates/runlimit-core/src/attempts.rs @@ -0,0 +1,266 @@ +//! Outcome-aware, consecutive-failure throttling, separate from request quotas. +use std::time::Duration; + +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::{Delay, PolicyFingerprint, PolicyId, QuotaPeriod, ScopeId, SubjectKey}; + +/// Validated exponential-backoff policy with exactly one in-flight attempt. +/// Every failure doubles the previous delay, up to the cap. Success resets +/// retry state; abandonment and expired leases count as failures. Audit history +/// is application-owned and is never removed by this policy. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AttemptPolicy { + id: PolicyId, + scope: ScopeId, + initial_delay: QuotaPeriod, + maximum_delay: QuotaPeriod, + quiet_period: QuotaPeriod, + lease: QuotaPeriod, + fingerprint: PolicyFingerprint, +} + +/// An invalid relationship between validated policy durations. +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum AttemptPolicyError { + /// The maximum delay was below the initial delay. + #[error("maximum delay must be at least the initial delay")] + MaximumBelowInitial, + /// Quiet expiry could bypass an active failure delay. + #[error("quiet period must be at least the maximum delay")] + QuietBelowMaximum, +} + +impl AttemptPolicy { + /// Constructs a policy. All timing is whole milliseconds. + /// + /// # Errors + /// Rejects a maximum below the initial delay or quiet expiry below the cap. + pub fn new( + id: PolicyId, + scope: ScopeId, + initial_delay: QuotaPeriod, + maximum_delay: QuotaPeriod, + quiet_period: QuotaPeriod, + lease: QuotaPeriod, + ) -> Result { + if maximum_delay < initial_delay { + return Err(AttemptPolicyError::MaximumBelowInitial); + } + if quiet_period < maximum_delay { + return Err(AttemptPolicyError::QuietBelowMaximum); + } + let mut hash = Sha256::new(); + hash.update(b"runlimit/attempt-policy/v1\0"); + hash.update(id.as_str().as_bytes()); + hash.update([0]); + hash.update(scope.as_str().as_bytes()); + hash.update([0]); + for value in [initial_delay, maximum_delay, quiet_period, lease] { + hash.update(value.millis().to_be_bytes()); + } + Ok(Self { + id, + scope, + initial_delay, + maximum_delay, + quiet_period, + lease, + fingerprint: PolicyFingerprint::from_digest(hash.finalize().into()), + }) + } + /// Policy namespace. + pub const fn id(&self) -> &PolicyId { + &self.id + } + /// Subject namespace. + pub const fn scope(&self) -> &ScopeId { + &self.scope + } + /// First failure delay. + pub const fn initial_delay(&self) -> QuotaPeriod { + self.initial_delay + } + /// Upper bound on failure delay. + pub const fn maximum_delay(&self) -> QuotaPeriod { + self.maximum_delay + } + /// Inactivity after which prior failures expire. + pub const fn quiet_period(&self) -> QuotaPeriod { + self.quiet_period + } + /// Maximum duration of one admitted attempt. + pub const fn lease(&self) -> QuotaPeriod { + self.lease + } + /// Stable storage protocol fingerprint, including every timing parameter. + pub const fn fingerprint(&self) -> PolicyFingerprint { + self.fingerprint + } + /// Delay after this many consecutive failures; zero means no delay. + pub fn delay(&self, failures: u32) -> Duration { + if failures == 0 { + return Duration::ZERO; + } + Duration::from_millis( + self.initial_delay + .millis() + .saturating_mul(1_u64.checked_shl(failures - 1).unwrap_or(u64::MAX)) + .min(self.maximum_delay.millis()), + ) + } +} + +/// A subject bound to the precise attempt policy used during keyed derivation. +#[derive(Clone, Copy, Debug)] +pub struct AttemptSubject<'a> { + pub(crate) policy: &'a AttemptPolicy, + pub(crate) subject: SubjectKey, +} + +impl AttemptSubject<'_> { + /// Exact policy reference retained by derivation. + pub const fn policy(&self) -> &AttemptPolicy { + self.policy + } + /// Opaque digest for backend storage. This explicitly leaves policy binding. + pub const fn into_unbound_subject_key(self) -> SubjectKey { + self.subject + } +} + +/// Admission either owns a single-use receipt or explains enforced denial. +#[derive(Debug)] +pub enum AttemptAdmission { + /// One attempt has been reserved until its lease expires. + Admitted(R), + /// No new attempt was reserved. + Denied(AttemptDenial), +} + +/// Attempt-specific enforced denial; ordinary quota decisions are unchanged. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AttemptDenial { + /// Another attempt is active. + Busy { + /// Time until its lease expires. + retry_after: Delay, + }, + /// Consecutive failures require waiting. + Backoff { + /// Remaining delay. + retry_after: Delay, + }, + /// Backend cannot allocate a new subject without exceeding its hard bound. + StorageCapacity, +} + +/// Application outcome for an admitted attempt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AttemptOutcome { + /// Full authentication succeeded; reset consecutive failure state. + Success, + /// Authentication failed; advance consecutive failure state. + Failure, + /// Explicit cancellation after admission; conservatively counts as failure. + Abandoned, +} + +/// Confirmed backend state transition, or provisional inside a host transaction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AttemptCompletion { + outcome: AttemptOutcome, + consecutive_failures: u32, + retry_after: Delay, +} +/// Completion metadata contradicted its application outcome. +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +#[error("successful completion requires zero failures and delay; failure requires both nonzero")] +pub struct AttemptCompletionError; +impl AttemptCompletion { + /// Constructs internally consistent completion metadata. + /// + /// # Errors + /// Rejects success with failure state or failure without a nonzero delay/count. + pub fn new( + outcome: AttemptOutcome, + consecutive_failures: u32, + retry_after: Delay, + ) -> Result { + let valid = match outcome { + AttemptOutcome::Success => { + consecutive_failures == 0 && retry_after.duration().is_zero() + } + AttemptOutcome::Failure | AttemptOutcome::Abandoned => { + consecutive_failures > 0 && !retry_after.duration().is_zero() + } + }; + if !valid { + return Err(AttemptCompletionError); + } + Ok(Self { + outcome, + consecutive_failures, + retry_after, + }) + } + /// Applied application outcome. + pub const fn outcome(self) -> AttemptOutcome { + self.outcome + } + /// Consecutive failures after the transition. + pub const fn consecutive_failures(self) -> u32 { + self.consecutive_failures + } + /// Delay before another attempt. + pub const fn retry_after(self) -> Delay { + self.retry_after + } +} + +/// Acknowledged result from a standalone completion owner. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AttemptCompletionResult { + /// State transition committed (or applied atomically in memory). + Applied(AttemptCompletion), + /// No matching live reservation; no completion was applied. + Stale, +} + +/// Completion may only apply to the still-live reservation that created it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StagedAttemptCompletion { + /// Transition staged; the caller must still commit before publishing it. + Applied(AttemptCompletion), + /// Reservation expired, was completed, or belongs to another store. + Stale, +} + +/// Attempt observations deliberately use a separate enum from quota events. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AttemptObservation { + /// One reservation committed. + Admitted, + /// Admission denied without allocating a reservation. + Denied(AttemptDenial), + /// Completion acknowledged by the transaction owner. + Completed(AttemptCompletion), + /// A stale completion was rejected. + Stale, + /// Commit acknowledgement was lost; no automatic replay is safe. + CommitUncertain, +} + +/// Observer must not contain raw subjects. Panics are isolated by backends. +pub trait AttemptObserver: Send + Sync { + /// Receives an attempt lifecycle event. + fn observe(&self, observation: AttemptObservation); +} + +/// Backend helper that isolates observer panics from decisions. +pub fn observe_attempt_safely(observer: &dyn AttemptObserver, observation: AttemptObservation) { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + observer.observe(observation); + })); +} diff --git a/crates/runlimit-core/src/gcra.rs b/crates/runlimit-core/src/gcra.rs new file mode 100644 index 0000000..0ce2f10 --- /dev/null +++ b/crates/runlimit-core/src/gcra.rs @@ -0,0 +1,95 @@ +//! Exact, storage-independent GCRA transition arithmetic for backend authors. +//! +//! Time is whole milliseconds in a backend-owned epoch. The backend must use +//! one clock authority, clamp regressing observations, serialize transitions, +//! and persist an allowance only after every member of an atomic batch passes. + +use std::time::Duration; + +use thiserror::Error; + +use crate::{Allowance, Check, GcraPolicy, QuotaDenial}; + +/// Exact arithmetic could not represent the supplied backend state or clock. +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +#[error("GCRA arithmetic exceeded the supported exact range")] +pub struct ArithmeticOverflow; + +/// The provisional result of one pure GCRA evaluation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Evaluation { + /// Admission is possible; persist this transition only after batch preflight. + Allowed(PendingAllowance), + /// Admission is unavailable and no state may be consumed. + Denied(QuotaDenial), +} + +/// An evaluated but not yet persisted GCRA transition. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PendingAllowance { + /// New theoretical arrival time, scaled by the policy quota. + pub tat_scaled: u128, + /// Whole-millisecond instant at which the counter becomes unnecessary. + pub expires_at_millis: u128, + /// Validated decision metadata at the evaluation instant. + pub allowance: Allowance, +} + +/// Evaluates one validated check without changing any state. +/// +/// `tat_scaled` is the previously persisted theoretical arrival time for the +/// same configuration fingerprint, or `None` for a missing/expired counter. +/// +/// # Errors +/// +/// Returns [`ArithmeticOverflow`] for unrepresentable clock-relative state. +pub fn evaluate( + check: &Check<'_, GcraPolicy>, + now_millis: u128, + tat_scaled: Option, +) -> Result { + let policy = check.policy(); + let quota = u128::from(policy.quota().get()); + let period = u128::from(policy.period().millis()); + let capacity = policy.burst_capacity(); + let scaled_now = now_millis.checked_mul(quota).ok_or(ArithmeticOverflow)?; + let active_tat = tat_scaled.unwrap_or(scaled_now).max(scaled_now); + let increment = u128::from(check.cost()) + .checked_mul(period) + .ok_or(ArithmeticOverflow)?; + let candidate = active_tat + .checked_add(increment) + .ok_or(ArithmeticOverflow)?; + let burst_span = u128::from(capacity.get()) + .checked_mul(period) + .ok_or(ArithmeticOverflow)?; + let ceiling = scaled_now + .checked_add(burst_span) + .ok_or(ArithmeticOverflow)?; + + if candidate > ceiling { + let retry = (candidate - ceiling).div_ceil(quota); + return Ok(Evaluation::Denied(QuotaDenial::new( + capacity, + duration(retry)?, + ))); + } + + let available = + u64::try_from((ceiling - candidate) / period).map_err(|_| ArithmeticOverflow)?; + let replenish = (candidate - scaled_now).div_ceil(quota); + Ok(Evaluation::Allowed(PendingAllowance { + tat_scaled: candidate, + expires_at_millis: now_millis + .checked_add(replenish) + .ok_or(ArithmeticOverflow)?, + allowance: Allowance::new(capacity, available, duration(replenish)?) + .map_err(|_| ArithmeticOverflow)?, + })) +} + +fn duration(millis: u128) -> Result { + Ok(Duration::from_millis( + u64::try_from(millis).map_err(|_| ArithmeticOverflow)?, + )) +} diff --git a/crates/runlimit-core/src/key.rs b/crates/runlimit-core/src/key.rs index bc33644..c8e7d07 100644 --- a/crates/runlimit-core/src/key.rs +++ b/crates/runlimit-core/src/key.rs @@ -141,6 +141,26 @@ impl Clone for KeyHasher { } impl KeyHasher { + /// Derives an opaque subject bound to an outcome-aware attempt policy. + /// Application normalization remains caller-owned. This domain is distinct + /// from ordinary quota subjects even for identical identifiers. + pub fn hash_attempt_for<'a>( + &self, + policy: &'a crate::attempts::AttemptPolicy, + subject: impl AsRef<[u8]>, + ) -> crate::attempts::AttemptSubject<'a> { + let mut mac = self.template.clone(); + mac.update(b"attempt/v1\0"); + mac.update(policy.id().as_str().as_bytes()); + mac.update(&[0]); + mac.update(policy.scope().as_str().as_bytes()); + mac.update(&[0]); + mac.update(subject.as_ref()); + crate::attempts::AttemptSubject { + policy, + subject: SubjectKey::from_digest(mac.finalize().into_bytes().into()), + } + } /// Minimum accepted secret length in bytes. pub const MINIMUM_SECRET_LENGTH: usize = 32; diff --git a/crates/runlimit-core/src/lib.rs b/crates/runlimit-core/src/lib.rs index 23b57ad..8d8af7b 100644 --- a/crates/runlimit-core/src/lib.rs +++ b/crates/runlimit-core/src/lib.rs @@ -14,10 +14,12 @@ //! wire types. Opaque [`SubjectKey`], [`CounterKey`], and //! [`PolicyFingerprint`] values intentionally remain non-serializable. +pub mod attempts; mod batch; mod check; mod counter; mod decision; +pub mod gcra; mod identifier; mod key; mod limiter; diff --git a/crates/runlimit-memory/src/attempts.rs b/crates/runlimit-memory/src/attempts.rs new file mode 100644 index 0000000..5e2551d --- /dev/null +++ b/crates/runlimit-memory/src/attempts.rs @@ -0,0 +1,260 @@ +//! Bounded process-local outcome-aware attempts. Dropped receipts expire as failures. +use crate::{Clock, SystemClock}; +use runlimit_core::{ + Delay, + attempts::{ + AttemptAdmission, AttemptCompletion, AttemptCompletionResult, AttemptDenial, + AttemptObservation, AttemptObserver, AttemptOutcome, AttemptPolicy, AttemptSubject, + observe_attempt_safely, + }, +}; +use std::{ + collections::BTreeMap, + num::NonZeroUsize, + sync::{Arc, Mutex, Weak}, + time::Duration, +}; +use thiserror::Error; + +type Key = ([u8; 32], [u8; 32]); +struct Entry { + policy: AttemptPolicy, + failures: u32, + last_failure: Duration, + retry_at: Duration, + lease: Option<(u64, Duration)>, +} +#[derive(Default)] +struct State { + entries: BTreeMap, + generation: u64, + now: Duration, + cursor: Option, +} + +/// A consuming reservation, bound to the creating store and exact policy key. +pub struct MemoryAttemptReceipt { + store: Weak>, + key: Key, + generation: u64, +} +impl std::fmt::Debug for MemoryAttemptReceipt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("MemoryAttemptReceipt([REDACTED])") + } +} + +/// A bounded attempt store. Admission cleanup examines at most 16 entries. +pub struct MemoryAttemptLimiter { + state: Arc>, + clock: C, + capacity: NonZeroUsize, + observer: Option>, +} + +/// Process-local storage failures; all fail closed without new admission. +#[derive(Debug, Error)] +pub enum MemoryAttemptError { + /// A previous operation panicked while holding state. + #[error("attempt state mutex poisoned")] + Poisoned, + /// The process exhausted its unique receipt sequence. + #[error("attempt receipt sequence exhausted")] + SequenceExhausted, +} + +/// The only process-local completion failure: poisoned attempt state. +/// Completion consumes an existing receipt and allocates no sequence number. +#[derive(Debug, Error)] +#[error("attempt state mutex poisoned")] +pub struct MemoryAttemptCompletionError; + +impl MemoryAttemptLimiter { + /// Creates a hard-bounded limiter using monotonic system time. + pub fn new(capacity: NonZeroUsize) -> Self { + Self::with_clock(capacity, SystemClock::new()) + } +} +impl MemoryAttemptLimiter { + /// Creates a limiter with an injected monotonic clock. + pub fn with_clock(capacity: NonZeroUsize, clock: C) -> Self { + Self { + state: Arc::default(), + clock, + capacity, + observer: None, + } + } + /// Installs a panic-isolated observer. + #[must_use] + pub fn with_observer(mut self, observer: Arc) -> Self { + self.observer = Some(observer); + self + } + fn observe(&self, observation: AttemptObservation) { + if let Some(observer) = &self.observer { + observe_attempt_safely(observer.as_ref(), observation); + } + } + /// Reserves exactly one attempt. Expired reservations count as failures. + /// + /// # Errors + /// Returns an error on poisoned state or exhausted receipt sequence. + pub fn admit( + &self, + subject: AttemptSubject<'_>, + ) -> Result, MemoryAttemptError> { + let policy = subject.policy().clone(); + let key = ( + policy.fingerprint().into_bytes(), + subject.into_unbound_subject_key().into_bytes(), + ); + let mut state = self + .state + .lock() + .map_err(|_| MemoryAttemptError::Poisoned)?; + state.now = state.now.max(self.clock.now()); + let now = state.now; + cleanup(&mut state, now); + let denial = if let Some(entry) = state.entries.get_mut(&key) { + reconcile(entry, now); + deny(entry, now) + } else if state.entries.len() >= self.capacity.get() { + Some(AttemptDenial::StorageCapacity) + } else { + None + }; + if let Some(denial) = denial { + drop(state); + self.observe(AttemptObservation::Denied(denial)); + return Ok(AttemptAdmission::Denied(denial)); + } + state.generation = state + .generation + .checked_add(1) + .ok_or(MemoryAttemptError::SequenceExhausted)?; + let generation = state.generation; + let entry = state.entries.entry(key).or_insert(Entry { + policy, + failures: 0, + last_failure: now, + retry_at: now, + lease: None, + }); + entry.lease = Some(( + generation, + now.saturating_add(entry.policy.lease().duration()), + )); + drop(state); + self.observe(AttemptObservation::Admitted); + Ok(AttemptAdmission::Admitted(MemoryAttemptReceipt { + store: Arc::downgrade(&self.state), + key, + generation, + })) + } + /// Completes a reservation once. Stale receipts never mutate current state. + /// + /// # Errors + /// Returns [`MemoryAttemptCompletionError`] when the state mutex is poisoned. + #[allow(clippy::needless_pass_by_value)] // A receipt is deliberately single-use. + pub fn complete( + &self, + receipt: MemoryAttemptReceipt, + outcome: AttemptOutcome, + ) -> Result { + let mut state = self + .state + .lock() + .map_err(|_| MemoryAttemptCompletionError)?; + state.now = state.now.max(self.clock.now()); + let now = state.now; + let valid_store = Weak::ptr_eq(&receipt.store, &Arc::downgrade(&self.state)); + let result = match state.entries.get_mut(&receipt.key) { + Some(entry) + if valid_store + && entry.lease.is_some_and(|(generation, until)| { + generation == receipt.generation && now < until + }) => + { + entry.lease = None; + let (failures, delay) = if outcome == AttemptOutcome::Success { + (0, Duration::ZERO) + } else { + fail(entry, now); + (entry.failures, entry.policy.delay(entry.failures)) + }; + if outcome == AttemptOutcome::Success { + state.entries.remove(&receipt.key); + } + AttemptCompletionResult::Applied(completion(outcome, failures, delay)) + } + _ => AttemptCompletionResult::Stale, + }; + drop(state); + self.observe(match result { + AttemptCompletionResult::Applied(completion) => { + AttemptObservation::Completed(completion) + } + AttemptCompletionResult::Stale => AttemptObservation::Stale, + }); + Ok(result) + } +} +fn completion(outcome: AttemptOutcome, failures: u32, delay: Duration) -> AttemptCompletion { + AttemptCompletion::new(outcome, failures, Delay::new(delay)) + .expect("policy transition produces consistent completion metadata") +} +fn fail(entry: &mut Entry, now: Duration) { + entry.failures = entry.failures.saturating_add(1); + entry.last_failure = now; + entry.retry_at = now.saturating_add(entry.policy.delay(entry.failures)); +} +fn reconcile(entry: &mut Entry, now: Duration) { + if let Some((_, until)) = entry.lease + && now >= until + { + entry.lease = None; + fail(entry, until); + } + if entry.lease.is_none() + && now + >= entry + .last_failure + .saturating_add(entry.policy.quiet_period().duration()) + { + entry.failures = 0; + entry.retry_at = now; + } +} +fn deny(entry: &Entry, now: Duration) -> Option { + if let Some((_, until)) = entry.lease { + return Some(AttemptDenial::Busy { + retry_after: Delay::new(until.saturating_sub(now)), + }); + } + (now < entry.retry_at).then(|| AttemptDenial::Backoff { + retry_after: Delay::new(entry.retry_at.saturating_sub(now)), + }) +} +fn cleanup(state: &mut State, now: Duration) { + use std::ops::Bound::{Excluded, Unbounded}; + let keys: Vec = match state.cursor { + Some(cursor) => state + .entries + .range((Excluded(cursor), Unbounded)) + .take(16) + .map(|(key, _)| *key) + .collect(), + None => state.entries.keys().take(16).copied().collect(), + }; + state.cursor = keys.last().copied(); + for key in keys { + if state.entries.get(&key).is_some_and(|entry| { + let origin = entry.lease.map_or(entry.last_failure, |(_, until)| until); + now >= origin.saturating_add(entry.policy.quiet_period().duration()) + }) { + state.entries.remove(&key); + } + } +} diff --git a/crates/runlimit-memory/src/gcra.rs b/crates/runlimit-memory/src/gcra.rs index da11053..c041f64 100644 --- a/crates/runlimit-memory/src/gcra.rs +++ b/crates/runlimit-memory/src/gcra.rs @@ -1,13 +1,11 @@ -use std::{ - fmt, - sync::Arc, - time::{Duration, Instant}, -}; +use std::{fmt, sync::Arc, time::Instant}; use runlimit_core::{ - AdmissionObservation, Allowance, BatchDecision, Capacity, CapacityObservation, Check, - CleanupObservation, CleanupOutcome, ConsumptionStatus, CounterKey, Decision, GcraPolicy, - Limiter, Observation, Observer, QuotaDenial, QuotaMode, observe_safely, validate_batch, + AdmissionObservation, Allowance, BatchDecision, CapacityObservation, Check, CleanupObservation, + CleanupOutcome, ConsumptionStatus, CounterKey, Decision, GcraPolicy, Limiter, Observation, + Observer, QuotaMode, + gcra::{ArithmeticOverflow, Evaluation as QuotaEvaluation, PendingAllowance, evaluate}, + observe_safely, validate_batch, }; use thiserror::Error; @@ -27,52 +25,16 @@ struct Entry { impl Shard { fn evaluate( &self, - check: &PreparedCheck, + check: &PreparedCheck<'_>, now_millis: u128, ) -> Result { - let scaled_now = now_millis - .checked_mul(u128::from(check.quota)) - .ok_or(ArithmeticOverflow)?; let active_tat = self .active_entry(check.counter_key, now_millis) - .map_or(scaled_now, |(entry, _)| entry.tat_scaled.max(scaled_now)); - let increment = u128::from(check.cost) - .checked_mul(u128::from(check.period_millis)) - .ok_or(ArithmeticOverflow)?; - let candidate = active_tat - .checked_add(increment) - .ok_or(ArithmeticOverflow)?; - let burst_span = u128::from(check.burst_capacity.get()) - .checked_mul(u128::from(check.period_millis)) - .ok_or(ArithmeticOverflow)?; - let ceiling = scaled_now - .checked_add(burst_span) - .ok_or(ArithmeticOverflow)?; - - if candidate > ceiling { - let retry_millis = div_ceil(candidate - ceiling, u128::from(check.quota)); - return Ok(QuotaEvaluation::Denied(QuotaDenial::new( - check.burst_capacity, - duration_from_millis(retry_millis), - ))); - } - - let available = (ceiling - candidate) / u128::from(check.period_millis); - let replenish_millis = div_ceil(candidate - scaled_now, u128::from(check.quota)); - let expires_at_millis = now_millis - .checked_add(replenish_millis) - .ok_or(ArithmeticOverflow)?; - - Ok(QuotaEvaluation::Allowed(PendingAllowance { - tat_scaled: candidate, - expires_at_millis, - available: u64::try_from(available) - .expect("available allowance cannot exceed the u64 burst capacity"), - replenishes_after: duration_from_millis(replenish_millis), - })) + .map(|(entry, _)| entry.tat_scaled); + evaluate(&check.check, now_millis, active_tat) } - fn consume(&mut self, check: &PreparedCheck, allowance: PendingAllowance) -> Allowance { + fn consume(&mut self, check: &PreparedCheck<'_>, allowance: PendingAllowance) -> Allowance { self.replace( check.counter_key, Entry { @@ -81,21 +43,10 @@ impl Shard { allowance.expires_at_millis, ); - // `available` is `(ceiling - candidate) / period` with - // `ceiling - candidate <= burst_capacity * period`, so it never exceeds - // the burst capacity. - Allowance::new( - check.burst_capacity, - allowance.available, - allowance.replenishes_after, - ) - .expect("GCRA availability is bounded by the burst capacity") + allowance.allowance } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct ArithmeticOverflow; - /// Failure of a single GCRA memory-store check. #[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] pub enum GcraCheckError { @@ -124,42 +75,22 @@ impl From for GcraBatchError { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum QuotaEvaluation { - Allowed(PendingAllowance), - Denied(QuotaDenial), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct PendingAllowance { - tat_scaled: u128, - expires_at_millis: u128, - available: u64, - replenishes_after: Duration, -} - #[derive(Clone, Copy, Debug)] -struct PreparedCheck { +struct PreparedCheck<'a> { input_index: usize, counter_key: CounterKey, shard_position: usize, - quota: u64, - period_millis: u64, - burst_capacity: Capacity, - cost: u64, + check: Check<'a, GcraPolicy>, quota_mode: QuotaMode, } -impl PreparedCheck { - fn new(input_index: usize, check: &Check<'_, GcraPolicy>, shard_position: usize) -> Self { +impl<'a> PreparedCheck<'a> { + fn new(input_index: usize, check: &Check<'a, GcraPolicy>, shard_position: usize) -> Self { Self { input_index, counter_key: check.counter_key(), shard_position, - quota: check.policy().quota().get(), - period_millis: check.policy().period().millis(), - burst_capacity: check.policy().burst_capacity(), - cost: check.cost(), + check: *check, quota_mode: check.policy().quota_mode(), } } @@ -547,22 +478,6 @@ impl GcraStore { } } -const fn div_ceil(numerator: u128, denominator: u128) -> u128 { - numerator / denominator - + if numerator.is_multiple_of(denominator) { - 0 - } else { - 1 - } -} - -fn duration_from_millis(millis: u128) -> Duration { - Duration::from_millis( - u64::try_from(millis) - .expect("GCRA policy validation bounds every reported duration to u64"), - ) -} - // Both async bodies contain no `.await`: they exist to defer the synchronous // check until the first poll, which the `Limiter` contract requires. #[allow(clippy::unused_async_trait_impl)] diff --git a/crates/runlimit-memory/src/lib.rs b/crates/runlimit-memory/src/lib.rs index 9bba9aa..bfa810f 100644 --- a/crates/runlimit-memory/src/lib.rs +++ b/crates/runlimit-memory/src/lib.rs @@ -18,6 +18,7 @@ //! loading, read-only [`MemoryStoreStats`] serialization, and the corresponding //! `runlimit-core` metadata feature. +pub mod attempts; mod clock; mod config; mod gcra; diff --git a/crates/runlimit-memory/tests/attempts.rs b/crates/runlimit-memory/tests/attempts.rs new file mode 100644 index 0000000..fb43d4d --- /dev/null +++ b/crates/runlimit-memory/tests/attempts.rs @@ -0,0 +1,286 @@ +//! Deterministic lifecycle and bounded-storage tests for outcome-aware attempts. +use runlimit_core::{ + KeyHasher, PolicyId, QuotaPeriod, ScopeId, + attempts::{ + AttemptAdmission, AttemptCompletionResult, AttemptDenial, AttemptOutcome, AttemptPolicy, + AttemptPolicyError, + }, +}; +use runlimit_memory::{ + Clock, + attempts::{MemoryAttemptLimiter, MemoryAttemptReceipt}, +}; +use std::{ + num::NonZeroUsize, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; + +#[derive(Clone, Default)] +struct TestClock(Arc); +impl Clock for TestClock { + fn now(&self) -> Duration { + Duration::from_millis(self.0.load(Ordering::SeqCst)) + } +} +impl TestClock { + fn set(&self, millis: u64) { + self.0.store(millis, Ordering::SeqCst); + } +} +fn period(ms: u64) -> QuotaPeriod { + QuotaPeriod::new(Duration::from_millis(ms)).unwrap() +} +fn policy() -> AttemptPolicy { + AttemptPolicy::new( + PolicyId::new("auth").unwrap(), + ScopeId::new("identifier").unwrap(), + period(10), + period(40), + period(100), + period(20), + ) + .unwrap() +} +fn receipt(value: AttemptAdmission) -> MemoryAttemptReceipt { + match value { + AttemptAdmission::Admitted(receipt) => receipt, + AttemptAdmission::Denied(denial) => panic!("unexpected denial {denial:?}"), + } +} + +#[test] +fn failure_delays_escalate_cap_and_success_resets() { + let clock = TestClock::default(); + let store = MemoryAttemptLimiter::with_clock(NonZeroUsize::new(2).unwrap(), clock.clone()); + let policy = policy(); + let hasher = KeyHasher::new([7; 32]).unwrap(); + let subject = hasher.hash_attempt_for(&policy, b"user"); + let mut now = 0; + for (expected, delay) in [(1, 10), (2, 20), (3, 40), (4, 40)] { + let admission = receipt(store.admit(subject).unwrap()); + assert!(matches!( + store.admit(subject).unwrap(), + AttemptAdmission::Denied(AttemptDenial::Busy { .. }) + )); + let AttemptCompletionResult::Applied(done) = + store.complete(admission, AttemptOutcome::Failure).unwrap() + else { + panic!() + }; + assert_eq!(done.consecutive_failures(), expected); + assert_eq!(done.retry_after().duration(), Duration::from_millis(delay)); + assert!(matches!( + store.admit(subject).unwrap(), + AttemptAdmission::Denied(AttemptDenial::Backoff { .. }) + )); + now += delay; + clock.set(now); + } + let success = receipt(store.admit(subject).unwrap()); + assert!( + matches!(store.complete(success,AttemptOutcome::Success).unwrap(),AttemptCompletionResult::Applied(done) if done.consecutive_failures()==0) + ); + let next = receipt(store.admit(subject).unwrap()); + assert!( + matches!(store.complete(next,AttemptOutcome::Failure).unwrap(),AttemptCompletionResult::Applied(done) if done.consecutive_failures()==1) + ); +} + +#[test] +fn stale_receipts_cannot_reset_new_failure_state_or_other_stores() { + let clock = TestClock::default(); + let store = MemoryAttemptLimiter::with_clock(NonZeroUsize::new(1).unwrap(), clock.clone()); + let other = MemoryAttemptLimiter::with_clock(NonZeroUsize::new(1).unwrap(), clock.clone()); + let policy = policy(); + let hasher = KeyHasher::new([7; 32]).unwrap(); + let subject = hasher.hash_attempt_for(&policy, b"user"); + let old = receipt(store.admit(subject).unwrap()); + clock.set(20); + assert!(matches!( + store.admit(subject).unwrap(), + AttemptAdmission::Denied(AttemptDenial::Backoff { .. }) + )); + clock.set(30); + let current = receipt(store.admit(subject).unwrap()); + assert_eq!( + store.complete(old, AttemptOutcome::Success).unwrap(), + AttemptCompletionResult::Stale + ); + assert_eq!( + other.complete(current, AttemptOutcome::Success).unwrap(), + AttemptCompletionResult::Stale + ); + assert!(matches!( + store.admit(subject).unwrap(), + AttemptAdmission::Denied(AttemptDenial::Busy { .. }) + )); +} + +#[test] +fn bounded_storage_reclaims_quiet_subjects_and_never_evicts_live_lease() { + let clock = TestClock::default(); + let store = MemoryAttemptLimiter::with_clock(NonZeroUsize::new(1).unwrap(), clock.clone()); + let policy = policy(); + let hasher = KeyHasher::new([7; 32]).unwrap(); + let first = hasher.hash_attempt_for(&policy, b"first"); + let second = hasher.hash_attempt_for(&policy, b"second"); + drop(receipt(store.admit(first).unwrap())); + clock.set(19); + assert!(matches!( + store.admit(second).unwrap(), + AttemptAdmission::Denied(AttemptDenial::StorageCapacity) + )); + clock.set(119); + assert!(matches!( + store.admit(second).unwrap(), + AttemptAdmission::Denied(AttemptDenial::StorageCapacity) + )); + clock.set(120); // bounded cursor may wrap on a subsequent operation + for _ in 0..2 { + if matches!(store.admit(second).unwrap(), AttemptAdmission::Admitted(_)) { + return; + } + } + panic!("expired subject not reclaimed"); +} + +#[test] +fn quiet_period_resets_failures_and_abandonment_counts() { + let clock = TestClock::default(); + let store = MemoryAttemptLimiter::with_clock(NonZeroUsize::new(1).unwrap(), clock.clone()); + let policy = policy(); + let hasher = KeyHasher::new([7; 32]).unwrap(); + let subject = hasher.hash_attempt_for(&policy, b"user"); + let first = receipt(store.admit(subject).unwrap()); + assert!( + matches!(store.complete(first,AttemptOutcome::Abandoned).unwrap(),AttemptCompletionResult::Applied(done) if done.consecutive_failures()==1) + ); + clock.set(100); + let next = receipt(store.admit(subject).unwrap()); + assert!( + matches!(store.complete(next,AttemptOutcome::Failure).unwrap(),AttemptCompletionResult::Applied(done) if done.consecutive_failures()==1) + ); +} + +#[test] +fn policy_validates_relations_fingerprints_all_parameters_and_bounds_overflow() { + let original = policy(); + assert_eq!(original.delay(u32::MAX), Duration::from_millis(40)); + let build = |values: [u64; 4]| { + AttemptPolicy::new( + original.id().clone(), + original.scope().clone(), + period(values[0]), + period(values[1]), + period(values[2]), + period(values[3]), + ) + }; + assert_eq!( + build([40, 10, 100, 20]), + Err(AttemptPolicyError::MaximumBelowInitial) + ); + assert_eq!( + build([10, 40, 20, 20]), + Err(AttemptPolicyError::QuietBelowMaximum) + ); + for values in [ + [11, 40, 100, 20], + [10, 41, 100, 20], + [10, 40, 101, 20], + [10, 40, 100, 21], + ] { + assert_ne!(original.fingerprint(), build(values).unwrap().fingerprint()); + } +} + +#[test] +fn backwards_clock_does_not_shorten_delay_or_clear_active_lease() { + let clock = TestClock::default(); + clock.set(100); + let store = MemoryAttemptLimiter::with_clock(NonZeroUsize::new(1).unwrap(), clock.clone()); + let policy = policy(); + let hasher = KeyHasher::new([7; 32]).unwrap(); + let subject = hasher.hash_attempt_for(&policy, b"user"); + let r = receipt(store.admit(subject).unwrap()); + store.complete(r, AttemptOutcome::Failure).unwrap(); + clock.set(1); + assert!( + matches!(store.admit(subject).unwrap(), AttemptAdmission::Denied(AttemptDenial::Backoff {retry_after}) if retry_after.duration()==Duration::from_millis(10)) + ); + clock.set(110); + let current = receipt(store.admit(subject).unwrap()); + clock.set(0); + assert!( + matches!(store.admit(subject).unwrap(), AttemptAdmission::Denied(AttemptDenial::Busy{retry_after}) if retry_after.duration()==Duration::from_millis(20)) + ); + assert!(matches!( + store.complete(current, AttemptOutcome::Success).unwrap(), + AttemptCompletionResult::Applied(_) + )); +} + +#[test] +fn quiet_expiry_never_evicts_a_longer_active_lease() { + let clock = TestClock::default(); + let store = MemoryAttemptLimiter::with_clock(NonZeroUsize::new(1).unwrap(), clock.clone()); + let policy = AttemptPolicy::new( + PolicyId::new("auth").unwrap(), + ScopeId::new("identifier").unwrap(), + period(10), + period(40), + period(100), + period(1000), + ) + .unwrap(); + let hasher = KeyHasher::new([7; 32]).unwrap(); + let first = hasher.hash_attempt_for(&policy, b"first"); + let r = receipt(store.admit(first).unwrap()); + clock.set(500); + assert!(matches!( + store.admit(first).unwrap(), + AttemptAdmission::Denied(AttemptDenial::Busy { .. }) + )); + assert!(matches!( + store + .admit(hasher.hash_attempt_for(&policy, b"other")) + .unwrap(), + AttemptAdmission::Denied(AttemptDenial::StorageCapacity) + )); + assert!(matches!( + store.complete(r, AttemptOutcome::Success).unwrap(), + AttemptCompletionResult::Applied(_) + )); +} + +#[test] +fn completion_error_can_only_report_poisoned_state() { + use runlimit_memory::attempts::MemoryAttemptCompletionError; + use std::sync::atomic::AtomicBool; + #[derive(Clone)] + struct PanickingClock(Arc); + impl Clock for PanickingClock { + fn now(&self) -> Duration { + assert!(!self.0.load(Ordering::SeqCst), "injected clock panic"); + Duration::ZERO + } + } + let panic_clock = Arc::new(AtomicBool::new(false)); + let store = MemoryAttemptLimiter::with_clock( + NonZeroUsize::new(1).unwrap(), + PanickingClock(panic_clock.clone()), + ); + let policy = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let subject = h.hash_attempt_for(&policy, b"user"); + let r = receipt(store.admit(subject).unwrap()); + panic_clock.store(true, Ordering::SeqCst); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| store.admit(subject))).is_err() + ); + let MemoryAttemptCompletionError = store.complete(r, AttemptOutcome::Success).unwrap_err(); +} diff --git a/crates/runlimit-postgres/attempts-migrations/20260921000001_create_runlimit_attempts.sql b/crates/runlimit-postgres/attempts-migrations/20260921000001_create_runlimit_attempts.sql new file mode 100644 index 0000000..4a9bc58 --- /dev/null +++ b/crates/runlimit-postgres/attempts-migrations/20260921000001_create_runlimit_attempts.sql @@ -0,0 +1,21 @@ +-- New protocol; existing quota storage/migrations are unchanged. +-- Slot uniqueness plus the fixed slot range enforce a hard capacity even for +-- writers that do not follow the library's advisory-lock admission protocol. +CREATE TABLE runlimit_attempts ( + config_fingerprint BYTEA NOT NULL CHECK (octet_length(config_fingerprint) = 32), + subject_key BYTEA NOT NULL CHECK (octet_length(subject_key) = 32), + capacity_shard SMALLINT GENERATED ALWAYS AS + ((get_byte(config_fingerprint, 0) # get_byte(subject_key, 0))::SMALLINT) STORED, + capacity_slot INTEGER NOT NULL CHECK (capacity_slot BETWEEN 0 AND 65535), + failures BIGINT NOT NULL CHECK (failures BETWEEN 0 AND 4294967295), + last_failure_ms BIGINT NOT NULL, + retry_at_ms BIGINT NOT NULL, + quiet_ms BIGINT NOT NULL CHECK (quiet_ms BETWEEN 1 AND 9007199254740), + lease_until_ms BIGINT, + lease_token TEXT, + PRIMARY KEY (config_fingerprint, subject_key), + UNIQUE (capacity_shard, capacity_slot), + CHECK ((lease_until_ms IS NULL) = (lease_token IS NULL)) +); +CREATE INDEX runlimit_attempts_expiry ON runlimit_attempts + (capacity_shard, ((COALESCE(lease_until_ms, last_failure_ms)) + quiet_ms)); diff --git a/crates/runlimit-postgres/gcra-migrations/20260922000000_create_runlimit_gcra.sql b/crates/runlimit-postgres/gcra-migrations/20260922000000_create_runlimit_gcra.sql new file mode 100644 index 0000000..60d5600 --- /dev/null +++ b/crates/runlimit-postgres/gcra-migrations/20260922000000_create_runlimit_gcra.sql @@ -0,0 +1,64 @@ +-- GCRA v1 is an independent, opt-in storage and locking protocol. +-- All admission and cleanup transactions lock the affected shard-ledger rows +-- in ascending order BEFORE reading or mutating counters. The shard count, +-- XOR derivation, and hard ceiling are immutable cross-replica protocol. +CREATE TABLE runlimit_gcra_shards ( + capacity_shard SMALLINT PRIMARY KEY CHECK (capacity_shard BETWEEN 0 AND 255), + row_count BIGINT NOT NULL DEFAULT 0 CHECK (row_count BETWEEN 0 AND 65536), + observed_at_ms BIGINT NOT NULL DEFAULT 0 CHECK (observed_at_ms >= 0) +); + +INSERT INTO runlimit_gcra_shards (capacity_shard) +SELECT shard::SMALLINT FROM pg_catalog.generate_series(0, 255) AS shards(shard); + +CREATE TABLE runlimit_gcra ( + config_fingerprint BYTEA NOT NULL CHECK (octet_length(config_fingerprint) = 32), + subject_key BYTEA NOT NULL CHECK (octet_length(subject_key) = 32), + -- Exact integer arithmetic; PostgreSQL BIGINT cannot hold quota-scaled + -- epoch milliseconds for the full validated policy range. + tat_scaled NUMERIC(39, 0) NOT NULL CHECK (tat_scaled >= 0), + expires_at_ms BIGINT NOT NULL CHECK (expires_at_ms >= 0), + capacity_shard SMALLINT GENERATED ALWAYS AS ( + (pg_catalog.get_byte(config_fingerprint, 0) + # pg_catalog.get_byte(subject_key, 0))::SMALLINT + ) STORED NOT NULL REFERENCES runlimit_gcra_shards (capacity_shard), + PRIMARY KEY (config_fingerprint, subject_key) +) WITH (fillfactor = 80); + +CREATE INDEX runlimit_gcra_expiry ON runlimit_gcra (expires_at_ms, capacity_shard); + +CREATE FUNCTION runlimit_gcra_capacity_change() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF NEW.config_fingerprint IS DISTINCT FROM OLD.config_fingerprint + OR NEW.subject_key IS DISTINCT FROM OLD.subject_key THEN + RAISE EXCEPTION 'runlimit GCRA storage keys are immutable' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; + ELSIF TG_OP = 'INSERT' THEN + EXECUTE pg_catalog.format( + 'UPDATE %I.runlimit_gcra_shards SET row_count = row_count + 1 WHERE capacity_shard = $1', + TG_TABLE_SCHEMA) + USING NEW.capacity_shard; + RETURN NEW; + ELSE + EXECUTE pg_catalog.format( + 'UPDATE %I.runlimit_gcra_shards SET row_count = row_count - 1 WHERE capacity_shard = $1', + TG_TABLE_SCHEMA) + USING OLD.capacity_shard; + RETURN OLD; + END IF; +END; +$$; + +CREATE TRIGGER runlimit_gcra_capacity_insert_delete +AFTER INSERT OR DELETE ON runlimit_gcra +FOR EACH ROW EXECUTE FUNCTION runlimit_gcra_capacity_change(); + +CREATE TRIGGER runlimit_gcra_immutable_key +BEFORE UPDATE OF config_fingerprint, subject_key ON runlimit_gcra +FOR EACH ROW EXECUTE FUNCTION runlimit_gcra_capacity_change(); diff --git a/crates/runlimit-postgres/gcra-migrations/20260922000001_index_runlimit_gcra_shard_expiry.sql b/crates/runlimit-postgres/gcra-migrations/20260922000001_index_runlimit_gcra_shard_expiry.sql new file mode 100644 index 0000000..d2b85bd --- /dev/null +++ b/crates/runlimit-postgres/gcra-migrations/20260922000001_index_runlimit_gcra_shard_expiry.sql @@ -0,0 +1,4 @@ +-- Cleanup probes each bounded capacity shard using its own monotonic clock. +-- Keep the original global expiry index for replicas using the older query. +CREATE INDEX runlimit_gcra_shard_expiry + ON runlimit_gcra (capacity_shard, expires_at_ms); diff --git a/crates/runlimit-postgres/src/admission.rs b/crates/runlimit-postgres/src/admission.rs index 8347fb2..63fc8e7 100644 --- a/crates/runlimit-postgres/src/admission.rs +++ b/crates/runlimit-postgres/src/admission.rs @@ -263,13 +263,13 @@ pub(crate) async fn run_check_transaction( /// Keeps every SQL phase on the same remaining operation budget. `PostgreSQL` /// timeouts are per statement/lock, so a value set once at BEGIN is stale after /// an earlier phase waits. No query method exposes the underlying transaction. -struct CheckTransaction<'c> { +pub(crate) struct CheckTransaction<'c> { inner: Transaction<'c, Postgres>, deadline: Instant, } impl<'c> CheckTransaction<'c> { - async fn begin( + pub(crate) async fn begin( connection: &'c mut PoolConnection, deadline: Instant, ) -> Result> { @@ -286,7 +286,7 @@ impl<'c> CheckTransaction<'c> { set_check_server_timeouts(&mut self.inner, self.deadline, phase).await } - async fn execute( + pub(crate) async fn execute( &mut self, phase: CheckPhase, query: Query<'_, Postgres, PgArguments>, @@ -295,7 +295,7 @@ impl<'c> CheckTransaction<'c> { check_before_commit(self.deadline, phase, query.execute(&mut *self.inner)).await } - async fn fetch_all( + pub(crate) async fn fetch_all( &mut self, phase: CheckPhase, query: Query<'_, Postgres, PgArguments>, @@ -304,7 +304,7 @@ impl<'c> CheckTransaction<'c> { check_before_commit(self.deadline, phase, query.fetch_all(&mut *self.inner)).await } - async fn fetch_one( + pub(crate) async fn fetch_one( &mut self, phase: CheckPhase, query: Query<'_, Postgres, PgArguments>, @@ -313,10 +313,14 @@ impl<'c> CheckTransaction<'c> { check_before_commit(self.deadline, phase, query.fetch_one(&mut *self.inner)).await } - async fn commit(mut self) -> Result<(), ConnectionOutcome> { + pub(crate) async fn commit(mut self) -> Result<(), ConnectionOutcome> { self.prepare(CheckPhase::PreparingCommit).await?; commit_check(self.deadline, self.inner).await } + + pub(crate) async fn deny(self, decision: D) -> ConnectionOutcome { + finish_denied_transaction(self.deadline, decision, self.inner.rollback()).await + } } async fn run_check_transaction_inner( diff --git a/crates/runlimit-postgres/src/attempt_cleanup.sql b/crates/runlimit-postgres/src/attempt_cleanup.sql new file mode 100644 index 0000000..2a00446 --- /dev/null +++ b/crates/runlimit-postgres/src/attempt_cleanup.sql @@ -0,0 +1,18 @@ +WITH authoritative_time AS MATERIALIZED ( + SELECT floor(extract(epoch FROM pg_catalog.clock_timestamp()) * 1000)::bigint AS now_ms +) +DELETE FROM runlimit_attempts +WHERE (config_fingerprint, subject_key) IN ( + SELECT config_fingerprint, subject_key + FROM runlimit_attempts + WHERE capacity_shard = $1 + -- The scalar subquery becomes an init-plan parameter, allowing the + -- expiry index to stop before active entries instead of filtering them. + -- This statement runs after the shard lock; admission samples time + -- separately after acquiring the subject row lock. + AND COALESCE(lease_until_ms, last_failure_ms) + quiet_ms + <= (SELECT now_ms FROM authoritative_time) + ORDER BY COALESCE(lease_until_ms, last_failure_ms) + quiet_ms + LIMIT 16 + FOR UPDATE SKIP LOCKED +) diff --git a/crates/runlimit-postgres/src/attempt_complete.sql b/crates/runlimit-postgres/src/attempt_complete.sql new file mode 100644 index 0000000..af2286e --- /dev/null +++ b/crates/runlimit-postgres/src/attempt_complete.sql @@ -0,0 +1,23 @@ +-- The materialized locked row is consumed before sampling time. A statement +-- blocked on the row cannot validate a lease using its stale pre-lock clock. +WITH locked AS MATERIALIZED ( + SELECT * FROM runlimit_attempts + WHERE config_fingerprint = $1 AND subject_key = $2 + FOR UPDATE +), sampled AS MATERIALIZED ( + SELECT locked.*, floor(extract(epoch FROM pg_catalog.clock_timestamp()) * 1000)::bigint AS now_ms + FROM locked +), next AS MATERIALIZED ( + SELECT *, CASE WHEN $4 THEN 0 ELSE LEAST(failures + 1, 4294967295) END AS next_failures, + CASE WHEN $4 THEN 0 ELSE LEAST($6::numeric, $5::numeric * power(2::numeric, LEAST(failures, 63)::numeric))::bigint END AS delay_ms + FROM sampled WHERE lease_token = $3 AND ( + ($7::text IS NULL AND lease_until_ms > now_ms) + OR $7 = pg_catalog.pg_current_xact_id()::text + ) +) +UPDATE runlimit_attempts AS attempts +SET failures = next.next_failures, last_failure_ms = next.now_ms, + retry_at_ms = next.now_ms + next.delay_ms, lease_token = NULL, lease_until_ms = NULL +FROM next +WHERE attempts.config_fingerprint = next.config_fingerprint AND attempts.subject_key = next.subject_key +RETURNING attempts.failures, next.delay_ms diff --git a/crates/runlimit-postgres/src/attempts.rs b/crates/runlimit-postgres/src/attempts.rs new file mode 100644 index 0000000..ff09724 --- /dev/null +++ b/crates/runlimit-postgres/src/attempts.rs @@ -0,0 +1,466 @@ +//! Durable outcome-aware attempts. Exactly one verification lease may be active +//! per policy/subject; crashed or abandoned attempts advance failure state. +//! Standalone calls own bounded transactions. Host transactions use the explicit +//! [`low_level`] seam and must publish results only after acknowledged commit. +use crate::{ + CheckError, CheckPhase, ConnectionCancellationGuard, ConnectionOutcome, PostgresConfig, + admission::{CheckTransaction, acquire_check_connection}, +}; +use runlimit_core::{ + Delay, + attempts::{ + AttemptAdmission, AttemptCompletion, AttemptCompletionResult, AttemptDenial, + AttemptObservation, AttemptObserver, AttemptOutcome, AttemptPolicy, AttemptSubject, + StagedAttemptCompletion, observe_attempt_safely, + }, +}; +use sqlx::{ + PgPool, Postgres, Row, + migrate::{MigrateError, Migrator}, + postgres::{PgArguments, PgRow}, + query::Query, +}; +use std::{sync::Arc, time::Duration}; +use tokio::time::Instant; + +/// Isolated migration history; install only when opting into attempt policies. +pub static ATTEMPTS_MIGRATOR: Migrator = sqlx::migrate!("./attempts-migrations"); +/// SQL suitable for a new forward-only application-owned migration. +pub const CREATE_RUNLIMIT_ATTEMPTS_SQL: &str = + include_str!("../attempts-migrations/20260921000001_create_runlimit_attempts.sql"); +const COMPLETE_SQL: &str = include_str!("attempt_complete.sql"); +const CLEANUP_SQL: &str = include_str!("attempt_cleanup.sql"); +const PHASE: CheckPhase = CheckPhase::AcquiringCounterRowLocks; +// Stable protocol: a distinct two-integer namespace and 256 capacity shards. +const LOCK_NAMESPACE: i32 = 0x524c_4154; + +/// Consuming opaque lease; no clone, serialization, public constructor, or raw token access. +pub struct PgAttemptReceipt { + policy: AttemptPolicy, + subject: [u8; 32], + token: String, +} +/// A live receipt fenced to the exact PostgreSQL transaction that locked it. +/// Completion may outlive its admission lease while the transaction retains +/// the row lock. Claiming transactionally rotates its private token, so rolling +/// back a containing savepoint invalidates the escaped claim even when the +/// outer transaction ID stays the same. The original reservation then expires. +pub struct PgAttemptClaim { + receipt: PgAttemptReceipt, + transaction_id: String, +} +impl std::fmt::Debug for PgAttemptClaim { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PgAttemptClaim([REDACTED])") + } +} +/// A claim must succeed before its owner executes application writes. +#[derive(Debug)] +pub enum PgAttemptClaimResult { + /// This transaction holds the live reservation's row lock. + Claimed(PgAttemptClaim), + /// No live reservation matched; rollback without running application work. + Stale, +} +impl std::fmt::Debug for PgAttemptReceipt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PgAttemptReceipt([REDACTED])") + } +} + +/// Replica-safe attempt limiter with bounded acquisition and transaction budgets. +#[derive(Clone)] +pub struct PostgresAttemptLimiter { + pool: PgPool, + config: PostgresConfig, + observer: Option>, +} +impl PostgresAttemptLimiter { + /// Creates a limiter; install its isolated migration before serving requests. + pub fn new(pool: PgPool) -> Self { + Self { + pool, + config: PostgresConfig::new(), + observer: None, + } + } + /// Sets timeouts and the operational per-shard row bound. + #[must_use] + pub const fn with_config(mut self, config: PostgresConfig) -> Self { + self.config = config; + self + } + /// Installs a panic-isolated lifecycle observer. + #[must_use] + pub fn with_observer(mut self, observer: Arc) -> Self { + self.observer = Some(observer); + self + } + /// Pool that transaction-owning application adapters must use for completion. + pub const fn pool(&self) -> &PgPool { + &self.pool + } + /// Installs attempt tables. Shared application migrators must also ignore + /// missing migrations, or vendor [`CREATE_RUNLIMIT_ATTEMPTS_SQL`] instead. + /// + /// # Errors + /// Returns database or migration errors. The migration connection is always + /// retired so cancellation cannot leak migration advisory locks to the pool. + pub async fn migrate(&self) -> Result<(), MigrateError> { + let mut connection = self.pool.acquire().await?; + connection.close_on_drop(); + let mut migrator = Migrator { + migrations: ATTEMPTS_MIGRATOR.migrations.clone(), + ..Migrator::DEFAULT + }; + migrator.set_ignore_missing(true); + migrator.run(&mut *connection).await + } + fn observe(&self, event: AttemptObservation) { + if let Some(observer) = &self.observer { + observe_attempt_safely(observer.as_ref(), event); + } + } + /// Admits one bounded verification lease, using database time after locks. + /// An unpolled future performs no work. Dropping a receipt leaves its lease + /// to expire as a failure; it never refunds or resets the subject. + /// A capacity denial may still commit cleanup of at most 16 already-expired + /// rows, allowing repeated calls to converge after an operational cap is + /// lowered. It creates no reservation or failure transition. That denial + /// remains authoritative even when cleanup's commit acknowledgement is lost; + /// the uncertain connection is retired and a later call can repeat cleanup. + /// + /// # Errors + /// [`CheckError`] distinguishes no committed effect from uncertain commit. + /// Never automatically retry an uncertain admission. + pub async fn admit( + &self, + subject: AttemptSubject<'_>, + ) -> Result, CheckError> { + let connection = + acquire_check_connection(&self.pool, self.config.pool_acquire_timeout()).await?; + let mut guard = ConnectionCancellationGuard::new(connection); + let deadline = Instant::now() + self.config.operation_timeout(); + let result = admit_transaction( + guard.connection(), + subject, + self.config.maximum_rows_per_shard(), + deadline, + ) + .await; + let result = guard.finish(match result { + Ok(value) => value.map(Ok), + Err(error) => error.map(Err), + }); + match &result { + Ok(AttemptAdmission::Admitted(_)) => self.observe(AttemptObservation::Admitted), + Ok(AttemptAdmission::Denied(denial)) => { + self.observe(AttemptObservation::Denied(*denial)); + } + Err(error) + if error.consumption() == runlimit_core::ConsumptionStatus::PossiblyConsumed => + { + self.observe(AttemptObservation::CommitUncertain); + } + Err(_) => {} + } + result + } + /// Completes a receipt in an owned transaction, returning acknowledged state. + /// The distinct result from [`low_level::complete_in`] remains staged. + /// A stale receipt never resets newer failure state. + /// + /// # Errors + /// Returns bounded database failures, explicitly distinguishing uncertain commit. + pub async fn complete( + &self, + receipt: PgAttemptReceipt, + outcome: AttemptOutcome, + ) -> Result { + let connection = + acquire_check_connection(&self.pool, self.config.pool_acquire_timeout()).await?; + let mut guard = ConnectionCancellationGuard::new(connection); + let deadline = Instant::now() + self.config.operation_timeout(); + let result = async { + let mut transaction = CheckTransaction::begin(guard.connection(), deadline).await?; + let rows = transaction + .fetch_all(PHASE, completion_query(&receipt, outcome, None)) + .await?; + let result = completion_result(&rows, outcome).map_err(ConnectionOutcome::Reusable)?; + transaction.commit().await?; + Ok::<_, ConnectionOutcome>(match result { + StagedAttemptCompletion::Applied(done) => AttemptCompletionResult::Applied(done), + StagedAttemptCompletion::Stale => AttemptCompletionResult::Stale, + }) + } + .await; + let result = guard.finish(match result { + Ok(value) => ConnectionOutcome::Reusable(Ok(value)), + Err(error) => error.map(Err), + }); + match &result { + Ok(AttemptCompletionResult::Applied(completion)) => { + self.observe(AttemptObservation::Completed(*completion)); + } + Ok(AttemptCompletionResult::Stale) => self.observe(AttemptObservation::Stale), + Err(error) + if error.consumption() == runlimit_core::ConsumptionStatus::PossiblyConsumed => + { + self.observe(AttemptObservation::CommitUncertain); + } + Err(_) => {} + } + result + } +} + +async fn admit_transaction( + connection: &mut sqlx::pool::PoolConnection, + subject: AttemptSubject<'_>, + maximum: u32, + deadline: Instant, +) -> Result>, ConnectionOutcome> { + let policy = subject.policy().clone(); + let fingerprint = policy.fingerprint().into_bytes(); + let subject = subject.into_unbound_subject_key().into_bytes(); + let shard = i16::from(fingerprint[0] ^ subject[0]); + let mut tx = CheckTransaction::begin(connection, deadline).await?; + tx.execute( + PHASE, + sqlx::query("SELECT pg_catalog.pg_advisory_xact_lock($1, $2)") + .bind(LOCK_NAMESPACE) + .bind(i32::from(shard)), + ) + .await?; + tx.execute(PHASE, sqlx::query(CLEANUP_SQL).bind(shard)) + .await?; + let rows = tx.fetch_all(PHASE, sqlx::query("SELECT failures,last_failure_ms,retry_at_ms,lease_until_ms FROM runlimit_attempts WHERE config_fingerprint=$1 AND subject_key=$2 FOR UPDATE").bind(fingerprint.as_slice()).bind(subject.as_slice())).await?; + let time = tx.fetch_one(PHASE, sqlx::query("SELECT floor(extract(epoch FROM pg_catalog.clock_timestamp())*1000)::bigint AS now_ms")).await?; + let decode = |error| { + ConnectionOutcome::Reusable(CheckError::storage_decode_invariant( + "decode attempt admission response", + error, + )) + }; + let now: i64 = time.try_get("now_ms").map_err(decode)?; + let mut failures = 0_u32; + let mut last_failure = now; + if let Some(row) = rows.first() { + failures = + u32::try_from(row.try_get::("failures").map_err(decode)?).map_err(|_| { + ConnectionOutcome::Reusable(CheckError::storage_invariant( + "invalid attempt failure count", + )) + })?; + last_failure = row.try_get("last_failure_ms").map_err(decode)?; + let mut retry: i64 = row.try_get("retry_at_ms").map_err(decode)?; + let lease: Option = row.try_get("lease_until_ms").map_err(decode)?; + if let Some(until) = lease { + if now < until { + return finish_denial( + tx, + AttemptDenial::Busy { + retry_after: delay(until - now), + }, + ) + .await; + } + failures = failures.saturating_add(1); + last_failure = until; + retry = until.saturating_add(millis(policy.delay(failures))); + } + if now >= last_failure.saturating_add(integer(policy.quiet_period().millis())) { + failures = 0; + retry = now; + } + if now < retry { + return finish_denial( + tx, + AttemptDenial::Backoff { + retry_after: delay(retry - now), + }, + ) + .await; + } + } + let token_row = if rows.is_empty() { + // A lower runtime bound counts every retained row, including slots + // allocated by replicas using a higher bound. A hole in the new slot + // interval is not proof that total shard occupancy is below the cap. + let occupancy = tx + .fetch_one( + PHASE, + sqlx::query( + "SELECT count(*) AS row_count FROM runlimit_attempts WHERE capacity_shard=$1", + ) + .bind(shard), + ) + .await?; + let row_count: i64 = occupancy.try_get("row_count").map_err(decode)?; + if row_count >= i64::from(maximum) { + return Ok(finish_capacity_denial(tx).await); + } + let slot = tx.fetch_one(PHASE, sqlx::query("SELECT min(slot)::integer AS slot FROM generate_series(0, $2::integer-1) AS slot WHERE NOT EXISTS (SELECT 1 FROM runlimit_attempts WHERE capacity_shard=$1 AND capacity_slot=slot)").bind(shard).bind(i64::from(maximum))).await?; + let Some(slot) = slot.try_get::, _>("slot").map_err(decode)? else { + return Ok(finish_capacity_denial(tx).await); + }; + tx.fetch_one(PHASE, sqlx::query("INSERT INTO runlimit_attempts (config_fingerprint,subject_key,capacity_slot,failures,last_failure_ms,retry_at_ms,quiet_ms,lease_until_ms,lease_token) VALUES ($1,$2,$3,0,$4,$4,$5,$6,pg_catalog.gen_random_uuid()::text) RETURNING lease_token").bind(fingerprint.as_slice()).bind(subject.as_slice()).bind(slot).bind(now).bind(integer(policy.quiet_period().millis())).bind(now.saturating_add(integer(policy.lease().millis())))).await? + } else { + tx.fetch_one(PHASE, sqlx::query("UPDATE runlimit_attempts SET failures=$3,last_failure_ms=$4,retry_at_ms=$5,lease_until_ms=$6,lease_token=pg_catalog.gen_random_uuid()::text WHERE config_fingerprint=$1 AND subject_key=$2 RETURNING lease_token").bind(fingerprint.as_slice()).bind(subject.as_slice()).bind(i64::from(failures)).bind(last_failure).bind(now).bind(now.saturating_add(integer(policy.lease().millis())))).await? + }; + let token = token_row.try_get("lease_token").map_err(decode)?; + tx.commit().await?; + Ok(ConnectionOutcome::Reusable(AttemptAdmission::Admitted( + PgAttemptReceipt { + policy, + subject, + token, + }, + ))) +} +async fn finish_denial( + tx: CheckTransaction<'_>, + denial: AttemptDenial, +) -> Result>, ConnectionOutcome> { + // Cleanup may have removed expired rows but denial owns no reservation. + // A successful rollback restores cleanup too. A rollback failure must close + // the connection before reporting the otherwise valid denial. + Ok(tx.deny(AttemptAdmission::Denied(denial)).await) +} +async fn finish_capacity_denial( + tx: CheckTransaction<'_>, +) -> ConnectionOutcome> { + // No reservation was inserted. Preserve bounded expired-row cleanup even + // when a lowered cap still denies this call; otherwise rolling back every + // batch can make an over-cap shard permanently unreclaimable. The admission + // denial remains certain even if cleanup's commit acknowledgement is lost. + let denial = AttemptAdmission::Denied(AttemptDenial::StorageCapacity); + match tx.commit().await { + Ok(()) => ConnectionOutcome::Reusable(denial), + Err(outcome) => outcome.map(|_| denial), + } +} +fn integer(value: u64) -> i64 { + i64::try_from(value).expect("validated policy duration fits i64") +} +fn millis(value: Duration) -> i64 { + i64::try_from(value.as_millis()).expect("validated delay fits i64") +} +fn delay(value: i64) -> Delay { + Delay::new(Duration::from_millis(value.unsigned_abs())) +} +fn completion_query<'a>( + receipt: &'a PgAttemptReceipt, + outcome: AttemptOutcome, + transaction_id: Option<&'a str>, +) -> Query<'a, Postgres, PgArguments> { + sqlx::query(COMPLETE_SQL) + .bind(receipt.policy.fingerprint().into_bytes().to_vec()) + .bind(receipt.subject.to_vec()) + .bind(&receipt.token) + .bind(outcome == AttemptOutcome::Success) + .bind(integer(receipt.policy.initial_delay().millis())) + .bind(integer(receipt.policy.maximum_delay().millis())) + .bind(transaction_id) +} +fn completion_result( + rows: &[PgRow], + outcome: AttemptOutcome, +) -> Result { + let Some(row) = rows.first() else { + return Ok(StagedAttemptCompletion::Stale); + }; + let decode = + |error| CheckError::storage_decode_invariant("decode attempt completion response", error); + let failures = u32::try_from(row.try_get::("failures").map_err(decode)?) + .map_err(|_| CheckError::storage_invariant("invalid attempt failure count"))?; + let retry: i64 = row.try_get("delay_ms").map_err(decode)?; + if retry < 0 { + return Err(CheckError::storage_invariant("invalid attempt delay")); + } + AttemptCompletion::new(outcome, failures, delay(retry)) + .map(StagedAttemptCompletion::Applied) + .map_err(|_| { + CheckError::storage_invariant("attempt completion metadata contradicts its outcome") + }) +} + +/// Explicit integration seam for a transaction owner such as Batter. +/// Calling this with an autocommit executor breaks application atomicity. +/// The owner must reject stale completion, rollback on every error/cancellation, +/// and publish application output only after acknowledged commit. Claim before +/// application writes, then finish with their final authentication outcome in +/// the same transaction. No await follows +/// query decoding; transaction disposition belongs exclusively to the caller. +pub mod low_level { + use super::{ + PgAttemptClaim, PgAttemptClaimResult, PgAttemptReceipt, completion_query, completion_result, + }; + use runlimit_core::attempts::{AttemptOutcome, StagedAttemptCompletion}; + use sqlx::{Executor, Postgres, Row}; + /// Locks and fences a live reservation before application writes. The lease + /// is validated after acquiring the lock, against authoritative server time. + /// Hold the same transaction through [`finish_in`] and final commit. An + /// autocommit executor produces a claim unusable in subsequent transactions. + /// Rolling back the savepoint containing this operation invalidates the + /// claim by reverting its private token rotation; releasing that savepoint + /// retains the lock and the claim remains valid in the outer transaction. + /// + /// # Errors + /// Returns database/decode failures; the transaction owner must rollback. + pub async fn claim_in<'e, E: Executor<'e, Database = Postgres>>( + executor: E, + mut receipt: PgAttemptReceipt, + ) -> Result { + let row = sqlx::query("WITH locked AS MATERIALIZED (SELECT config_fingerprint,subject_key,lease_token,lease_until_ms FROM runlimit_attempts WHERE config_fingerprint=$1 AND subject_key=$2 FOR UPDATE), sampled AS MATERIALIZED (SELECT *, floor(extract(epoch FROM pg_catalog.clock_timestamp())*1000)::bigint AS now_ms FROM locked) UPDATE runlimit_attempts AS attempts SET lease_token=pg_catalog.gen_random_uuid()::text FROM sampled WHERE attempts.config_fingerprint=sampled.config_fingerprint AND attempts.subject_key=sampled.subject_key AND sampled.lease_token=$3 AND sampled.lease_until_ms>sampled.now_ms RETURNING attempts.lease_token,pg_catalog.pg_current_xact_id()::text AS transaction_id") + .bind(receipt.policy.fingerprint().into_bytes().to_vec()).bind(receipt.subject.to_vec()).bind(&receipt.token).fetch_optional(executor).await?; + let decode = |error| { + sqlx::Error::Decode(Box::new(crate::CheckError::storage_decode_invariant( + "decode attempt claim response", + error, + ))) + }; + match row { + Some(row) => { + receipt.token = row.try_get("lease_token").map_err(decode)?; + Ok(PgAttemptClaimResult::Claimed(PgAttemptClaim { + receipt, + transaction_id: row.try_get("transaction_id").map_err(decode)?, + })) + } + None => Ok(PgAttemptClaimResult::Stale), + } + } + /// Stages the final outcome after application checks, in the claim's exact + /// transaction. Time elapsed since claim does not invalidate its held lock. + /// A different transaction returns `Stale` without changing state. The owner + /// must rollback on `Stale` and publish only after acknowledged commit. + /// + /// # Errors + /// Returns database/decode failures; the transaction owner must rollback. + pub async fn finish_in<'e, E: Executor<'e, Database = Postgres>>( + executor: E, + claim: PgAttemptClaim, + outcome: AttemptOutcome, + ) -> Result { + let rows = completion_query(&claim.receipt, outcome, Some(&claim.transaction_id)) + .fetch_all(executor) + .await?; + completion_result(&rows, outcome).map_err(|error| sqlx::Error::Decode(Box::new(error))) + } + /// Stages one fenced completion in a caller-owned transaction, in one SQL statement. + /// + /// # Errors + /// Returns database/decode errors. The transaction owner must rollback; + /// an error does not authorize retrying or publishing application output. + pub async fn complete_in<'e, E: Executor<'e, Database = Postgres>>( + executor: E, + receipt: PgAttemptReceipt, + outcome: AttemptOutcome, + ) -> Result { + let rows = completion_query(&receipt, outcome, None) + .fetch_all(executor) + .await?; + completion_result(&rows, outcome).map_err(|error| sqlx::Error::Decode(Box::new(error))) + } +} diff --git a/crates/runlimit-postgres/src/gcra.rs b/crates/runlimit-postgres/src/gcra.rs new file mode 100644 index 0000000..0e99280 --- /dev/null +++ b/crates/runlimit-postgres/src/gcra.rs @@ -0,0 +1,599 @@ +//! Opt-in durable GCRA storage; independent from the fixed-window protocol. + +use std::{fmt, future::Future, sync::Arc, time::Instant as WallInstant}; + +use runlimit_core::{ + AdmissionObservation, BatchDecision, Check, CleanupObservation, CleanupOutcome, Decision, + Denial, GcraPolicy, Limiter, Observation, Observer, QuotaMode, gcra, observe_safely, + validate_batch, +}; +use sqlx::{ + Acquire, PgPool, Row, + migrate::{MigrateError, Migrator}, + postgres::PgRow, +}; +use tokio::time::Instant; + +use crate::{ + BatchCheckError, CheckError, CheckPhase, CleanupPhase, ConnectionCancellationGuard, + ConnectionOutcome, MaintenanceError, PostgresConfig, + admission::{ + Admission, CheckTransaction, acquire_check_connection, batch_decision, single_decision, + }, + maintenance::{ + acquire_maintenance_connection, commit_maintenance, maintenance_before_commit, + set_maintenance_server_timeouts, + }, + protocol::{CAPACITY_SHARD_COUNT, capacity_shard}, +}; + +/// Independent, opt-in GCRA storage migration. Strict host migrators may vendor +/// this SQL into their own migration stream; no fixed-window tables are needed. +pub const CREATE_RUNLIMIT_GCRA_SQL: &str = + include_str!("../gcra-migrations/20260922000000_create_runlimit_gcra.sql"); + +/// Additive shard/expiry index for bounded cleanup under regressing clocks. +/// Strict host migrators apply this after [`CREATE_RUNLIMIT_GCRA_SQL`]. +pub const INDEX_RUNLIMIT_GCRA_SHARD_EXPIRY_SQL: &str = + include_str!("../gcra-migrations/20260922000001_index_runlimit_gcra_shard_expiry.sql"); + +/// Raw GCRA migrator with `SQLx`'s strict defaults. Prefer +/// [`PostgresGcraLimiter::migrate`] for cancellation-safe pooled migration. +pub static GCRA_MIGRATOR: Migrator = sqlx::migrate!("./gcra-migrations"); + +const LOCK_SHARDS: &str = "SELECT capacity_shard, row_count, observed_at_ms FROM runlimit_gcra_shards WHERE capacity_shard = ANY($1) ORDER BY capacity_shard FOR UPDATE"; +const SAMPLE_TIME: &str = + "SELECT floor(extract(epoch FROM pg_catalog.clock_timestamp()) * 1000)::BIGINT AS now_ms"; +const READ_COUNTERS: &str = r" +SELECT input.ordinality - 1 AS input_index, stored.tat_scaled::TEXT AS tat_scaled, + stored.expires_at_ms +FROM unnest($1::BYTEA[], $2::BYTEA[]) WITH ORDINALITY AS input(fingerprint, subject, ordinality) +LEFT JOIN runlimit_gcra AS stored ON stored.config_fingerprint = input.fingerprint + AND stored.subject_key = input.subject +ORDER BY input.ordinality"; +const UPSERT: &str = r" +INSERT INTO runlimit_gcra(config_fingerprint, subject_key, tat_scaled, expires_at_ms) +SELECT fingerprint, subject, tat::NUMERIC, expires +FROM unnest($1::BYTEA[], $2::BYTEA[], $3::TEXT[], $4::BIGINT[]) AS input(fingerprint, subject, tat, expires) +ON CONFLICT (config_fingerprint, subject_key) DO UPDATE +SET tat_scaled = EXCLUDED.tat_scaled, expires_at_ms = EXCLUDED.expires_at_ms"; +const CLEANUP_LOCKS: &str = r" +WITH sample AS MATERIALIZED ( + SELECT floor(extract(epoch FROM pg_catalog.clock_timestamp()) * 1000)::BIGINT AS now_ms +), candidates AS MATERIALIZED ( + -- At most 256 indexed probes, each returning at most one candidate. Using + -- the same shard clock as admission makes logically expired slots + -- reclaimable even while the physical clock remains behind its watermark. + SELECT shard.capacity_shard FROM runlimit_gcra_shards AS shard + CROSS JOIN LATERAL ( + SELECT 1 FROM runlimit_gcra AS counter + WHERE counter.capacity_shard = shard.capacity_shard + AND counter.expires_at_ms <= greatest((SELECT now_ms FROM sample), shard.observed_at_ms) + ORDER BY counter.expires_at_ms LIMIT 1 + ) AS expired +) +SELECT capacity.capacity_shard FROM runlimit_gcra_shards AS capacity +WHERE capacity.capacity_shard IN (SELECT capacity_shard FROM candidates) +ORDER BY capacity.capacity_shard FOR UPDATE OF capacity SKIP LOCKED"; +const CLEANUP_ADVANCE_TIME: &str = r" +WITH sample AS MATERIALIZED ( + SELECT floor(extract(epoch FROM pg_catalog.clock_timestamp()) * 1000)::BIGINT AS now_ms +), clamped AS MATERIALIZED ( + SELECT greatest((SELECT now_ms FROM sample), coalesce(max(observed_at_ms), 0)) AS now_ms + FROM runlimit_gcra_shards WHERE capacity_shard = ANY($1) +) +UPDATE runlimit_gcra_shards SET observed_at_ms = (SELECT now_ms FROM clamped) +WHERE capacity_shard = ANY($1)"; +const CLEANUP_DELETE: &str = r" +WITH expired AS MATERIALIZED ( + SELECT candidate.config_fingerprint, candidate.subject_key + FROM runlimit_gcra_shards AS shard + CROSS JOIN LATERAL ( + SELECT config_fingerprint, subject_key, expires_at_ms FROM runlimit_gcra AS counter + WHERE counter.capacity_shard = shard.capacity_shard + AND counter.expires_at_ms <= shard.observed_at_ms + ORDER BY counter.expires_at_ms LIMIT $2 + ) AS candidate + WHERE shard.capacity_shard = ANY($1) + ORDER BY candidate.expires_at_ms LIMIT $2 +) +DELETE FROM runlimit_gcra AS stored USING expired +WHERE stored.config_fingerprint = expired.config_fingerprint AND stored.subject_key = expired.subject_key"; + +/// Replica-safe GCRA quotas using the same exact arithmetic as the memory store. +/// +/// Each opaque policy/subject key stores a scaled theoretical arrival time. +/// Time comes from PostgreSQL and is clamped to the latest committed observation +/// in each affected shard. Full replenishment releases quota continuously; a +/// scheduled [`Self::cleanup_expired`] releases storage slots. +/// +/// The independent GCRA v1 protocol locks its 256 persistent capacity shards in +/// ascending order before observing logical keys. The fingerprint/subject XOR +/// shard derivation, row-lock order, and 65,536-row hard ceiling must not change +/// during rolling deployment. Shards deliberately serialize their admission +/// transactions, including different keys in the same shard. +/// +/// Commit errors have the existing [`CheckError::consumption`] semantics and +/// are never replayed. Cancellation may race with commit; dropping a future +/// returns no evidence of consumption and closes its connection. Prefer the +/// configured deadlines over a shorter outer timeout. Observer callbacks run +/// after releasing database connections. +#[derive(Clone)] +pub struct PostgresGcraLimiter { + pool: PgPool, + config: PostgresConfig, + observer: Option>, +} + +impl fmt::Debug for PostgresGcraLimiter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PostgresGcraLimiter") + .field("pool", &self.pool) + .field("config", &self.config) + .field("has_observer", &self.observer.is_some()) + .finish() + } +} + +impl PostgresGcraLimiter { + /// Creates a limiter with default operational bounds. Migrate before use. + pub const fn new(pool: PgPool) -> Self { + Self { + pool, + config: PostgresConfig::new(), + observer: None, + } + } + + /// Uses explicit operational bounds without changing policy semantics. + #[must_use] + pub const fn with_config(mut self, config: PostgresConfig) -> Self { + self.config = config; + self + } + + /// Installs a low-cardinality observer; callback panics are isolated. + #[must_use] + pub fn with_observer(mut self, observer: Arc) -> Self { + self.observer = Some(observer); + self + } + + /// Returns the underlying pool. + pub const fn pool(&self) -> &PgPool { + &self.pool + } + + /// Returns the runtime configuration. + pub const fn config(&self) -> PostgresConfig { + self.config + } + + /// Applies only the independent GCRA migrations. + /// + /// Every migrator sharing `SQLx` history must ignore unrelated versions. + /// Cancellation/error closes the session rather than leaking migration locks. + /// + /// # Errors + /// Returns `SQLx` migration or acquisition failures. + pub async fn migrate(&self) -> Result<(), MigrateError> { + let mut connection = ConnectionCancellationGuard::new(self.pool.acquire().await?); + let mut migrator = sqlx::migrate!("./gcra-migrations"); + migrator.set_ignore_missing(true); + let result = migrator + .run_direct(None, &mut **connection.connection(), false) + .await; + if result.is_ok() { + connection.reuse(); + } + result + } + + /// Checks and consumes one GCRA quota. Denials consume nothing. + /// + /// # Errors + /// Returns classified pre-commit or uncertain-commit failures. + pub async fn check(&self, check: &Check<'_, GcraPolicy>) -> Result { + let started = WallInstant::now(); + let result = self.run(std::slice::from_ref(check), single_decision).await; + if let Some(observer) = &self.observer { + let observation = match &result { + Ok(decision) => { + AdmissionObservation::from_check(check, decision, started.elapsed()) + } + Err(error) => AdmissionObservation::failed_check( + check, + error.consumption(), + started.elapsed(), + ), + }; + observe_safely(observer.as_ref(), &Observation::Admission(observation)); + } + result + } + + /// Consumes a validated nonempty batch atomically, preserving input order. + /// Shadow-denied batches consume no member. Mixed modes and duplicate keys + /// are rejected before acquiring a connection. + /// + /// # Errors + /// Returns structural batch failures or classified database failures. + pub async fn check_all( + &self, + checks: &[Check<'_, GcraPolicy>], + ) -> Result { + let started = WallInstant::now(); + let result = match validate_batch(checks, self.config.max_batch_size()) { + Ok(()) => self + .run(checks, |admission| batch_decision(checks.len(), admission)) + .await + .map_err(BatchCheckError::from), + Err(error) => Err(error.into()), + }; + if let Some(observer) = &self.observer { + let observation = match &result { + Ok(decision) => { + AdmissionObservation::from_batch(checks, decision, started.elapsed()) + } + Err(error) => AdmissionObservation::failed_batch( + checks, + error.consumption(), + started.elapsed(), + ), + }; + observe_safely(observer.as_ref(), &Observation::Admission(observation)); + } + result + } + + async fn run( + &self, + checks: &[Check<'_, GcraPolicy>], + build: impl FnOnce(Admission) -> Result, + ) -> Result { + let connection = + acquire_check_connection(&self.pool, self.config.pool_acquire_timeout()).await?; + let mut guarded = ConnectionCancellationGuard::new(connection); + let deadline = Instant::now() + self.config.operation_timeout(); + let result = + run_transaction(guarded.connection(), checks, self.config, deadline, build).await; + guarded.finish(match result { + Ok(result) => result.map(Ok), + Err(error) => error.map(Err), + }) + } + + /// Removes at most `maximum_rows` expired counters and releases their slots. + /// Busy shards are skipped. A zero limit performs no database work. + /// + /// # Errors + /// Returns bounded maintenance failures; an uncertain commit may have deleted rows. + pub async fn cleanup_expired(&self, maximum_rows: u32) -> Result { + let started = WallInstant::now(); + let result = self.cleanup(maximum_rows).await; + if let Some(observer) = &self.observer { + let outcome = match &result { + Ok(removed) => CleanupOutcome::Confirmed { removed: *removed }, + Err(error) if error.may_have_removed_rows() => CleanupOutcome::Unknown, + Err(_) => CleanupOutcome::NoEffect, + }; + observe_safely( + observer.as_ref(), + &Observation::Cleanup(CleanupObservation::new( + usize::try_from(maximum_rows).unwrap_or(usize::MAX), + outcome, + started.elapsed(), + )), + ); + } + result + } + + async fn cleanup(&self, maximum_rows: u32) -> Result { + if maximum_rows == 0 { + return Ok(0); + } + let connection = + acquire_maintenance_connection(&self.pool, self.config.pool_acquire_timeout()).await?; + let mut guarded = ConnectionCancellationGuard::new(connection); + let deadline = Instant::now() + self.config.operation_timeout(); + let result = cleanup_transaction(guarded.connection(), maximum_rows, deadline).await; + guarded.finish(match result { + Ok(rows) => ConnectionOutcome::Reusable(Ok(rows)), + Err(error) => error.map(Err), + }) + } +} + +impl Limiter for PostgresGcraLimiter { + type Policy = GcraPolicy; + type CheckError = CheckError; + type CheckAllError = BatchCheckError; + + fn check( + &self, + check: &Check<'_, GcraPolicy>, + ) -> impl Future> + Send { + Self::check(self, check) + } + + fn check_all( + &self, + checks: &[Check<'_, GcraPolicy>], + ) -> impl Future> + Send { + Self::check_all(self, checks) + } +} + +type Failure = ConnectionOutcome; + +fn invariant(detail: &'static str) -> Failure { + ConnectionOutcome::Reusable(CheckError::storage_invariant(detail)) +} + +fn field<'r, T: sqlx::Decode<'r, sqlx::Postgres> + sqlx::Type>( + row: &'r PgRow, + name: &str, +) -> Result { + row.try_get(name).map_err(|error| { + ConnectionOutcome::Reusable(CheckError::storage_decode_invariant( + "invalid GCRA database response", + error, + )) + }) +} + +async fn run_transaction( + connection: &mut sqlx::pool::PoolConnection, + checks: &[Check<'_, GcraPolicy>], + config: PostgresConfig, + deadline: Instant, + build: impl FnOnce(Admission) -> Result, +) -> Result, Failure> { + let mut transaction = CheckTransaction::begin(connection, deadline).await?; + let mut shards = checks + .iter() + .map(|check| capacity_shard(check.counter_key())) + .collect::>(); + shards.sort_unstable(); + shards.dedup(); + let locked = transaction + .fetch_all( + CheckPhase::AcquiringCapacityShardLocks, + sqlx::query(LOCK_SHARDS).bind(&shards), + ) + .await?; + if locked.len() != shards.len() { + return Err(invariant("GCRA capacity ledger is missing a shard")); + } + let mut counts = [0_i64; CAPACITY_SHARD_COUNT]; + let sample = transaction + .fetch_one(CheckPhase::PreflightingCounters, sqlx::query(SAMPLE_TIME)) + .await?; + let mut now: i64 = field(&sample, "now_ms")?; + for (row, expected) in locked.iter().zip(&shards) { + let shard: i16 = field(row, "capacity_shard")?; + let count: i64 = field(row, "row_count")?; + let observed: i64 = field(row, "observed_at_ms")?; + if shard != *expected + || !(0..=i64::from(crate::HARD_MAX_ROWS_PER_SHARD)).contains(&count) + || observed < 0 + { + return Err(invariant("invalid GCRA capacity ledger")); + } + counts[usize::try_from(shard).expect("shard came from validated key")] = count; + now = now.max(observed); + } + let now_u128 = u128::try_from(now).map_err(|_| invariant("negative GCRA database time"))?; + let fingerprints = checks + .iter() + .map(|check| check.counter_key().fingerprint().as_bytes().to_vec()) + .collect::>(); + let subjects = checks + .iter() + .map(|check| check.subject().as_bytes().to_vec()) + .collect::>(); + let rows = transaction + .fetch_all( + CheckPhase::PreflightingCounters, + sqlx::query(READ_COUNTERS) + .bind(&fingerprints) + .bind(&subjects), + ) + .await?; + let pending = evaluate( + checks, + &rows, + &mut counts, + config.maximum_rows_per_shard(), + now_u128, + )?; + match pending { + Pending::Denied(admission) => { + let decision = build(admission).map_err(ConnectionOutcome::Reusable)?; + Ok(transaction.deny(decision).await) + } + Pending::Allowed(allowances) => { + let tats = allowances + .iter() + .map(|allowance| allowance.tat_scaled.to_string()) + .collect::>(); + let expiries = allowances + .iter() + .map(|allowance| { + i64::try_from(allowance.expires_at_millis) + .map_err(|_| invariant("GCRA expiry exceeds database range")) + }) + .collect::, _>>()?; + let decision = build(Admission::Allowed( + allowances.into_iter().map(|item| item.allowance).collect(), + )) + .map_err(ConnectionOutcome::Reusable)?; + let updated = transaction + .execute( + CheckPhase::UpdatingCounters, + sqlx::query(UPSERT) + .bind(&fingerprints) + .bind(&subjects) + .bind(tats) + .bind(expiries), + ) + .await?; + if updated.rows_affected() != checks.len() as u64 { + return Err(invariant("GCRA upsert count disagrees with the batch")); + } + transaction.execute(CheckPhase::UpdatingCounters, sqlx::query("UPDATE runlimit_gcra_shards SET observed_at_ms = $2 WHERE capacity_shard = ANY($1)").bind(&shards).bind(now)).await?; + transaction.commit().await?; + Ok(ConnectionOutcome::Reusable(decision)) + } + } +} + +enum Pending { + Allowed(Vec), + Denied(Admission), +} + +fn evaluate( + checks: &[Check<'_, GcraPolicy>], + rows: &[PgRow], + counts: &mut [i64; CAPACITY_SHARD_COUNT], + maximum: u32, + now: u128, +) -> Result { + if rows.len() != checks.len() { + return Err(invariant("GCRA preflight count disagrees with the batch")); + } + let mut pending = Vec::with_capacity(checks.len()); + let mut denial = None; + for (index, (check, row)) in checks.iter().zip(rows).enumerate() { + let returned_index: i64 = field(row, "input_index")?; + if usize::try_from(returned_index).ok() != Some(index) { + return Err(invariant("GCRA preflight changed input order")); + } + let tat: Option = field(row, "tat_scaled")?; + let expires: Option = field(row, "expires_at_ms")?; + let stored = match (tat, expires) { + (None, None) => None, + (Some(tat), Some(expires)) if expires >= 0 => Some(( + tat.parse::() + .map_err(|_| invariant("invalid GCRA theoretical arrival time"))?, + u128::try_from(expires).expect("nonnegative expiry"), + )), + _ => return Err(invariant("inconsistent GCRA stored counter")), + }; + if let Some((tat, expires)) = stored + && tat.div_ceil(u128::from(check.policy().quota().get())) != expires + { + return Err(invariant( + "GCRA expiry disagrees with theoretical arrival time", + )); + } + let active_tat = stored + .filter(|(_, expires)| *expires > now) + .map(|(tat, _)| tat); + let evaluated = gcra::evaluate(check, now, active_tat) + .map_err(|_| invariant("GCRA arithmetic exceeded exact range"))?; + match evaluated { + gcra::Evaluation::Denied(quota) => { + denial.get_or_insert_with(|| { + if check.policy().quota_mode() == QuotaMode::Shadow { + Admission::ShadowDenied { + index, + denial: quota, + } + } else { + Admission::Denied { + index, + denial: quota.into(), + } + } + }); + } + gcra::Evaluation::Allowed(allowance) => { + if stored.is_none() { + let shard = usize::try_from(capacity_shard(check.counter_key())) + .expect("stable shard fits usize"); + if counts[shard] >= i64::from(maximum) { + denial.get_or_insert(Admission::Denied { + index, + denial: Denial::StorageCapacity { retry_after: None }, + }); + } + counts[shard] += 1; + } + pending.push(allowance); + } + } + } + Ok(match denial { + Some(denial) => Pending::Denied(denial), + None => Pending::Allowed(pending), + }) +} + +async fn cleanup_transaction( + connection: &mut sqlx::pool::PoolConnection, + maximum: u32, + deadline: Instant, +) -> Result> { + let mut transaction = maintenance_before_commit( + deadline, + CleanupPhase::BeginningTransaction, + connection.begin(), + ) + .await?; + set_maintenance_server_timeouts( + &mut transaction, + deadline, + CleanupPhase::ConfiguringTimeouts, + ) + .await?; + let rows = maintenance_before_commit( + deadline, + CleanupPhase::DeletingExpiredWindows, + sqlx::query(CLEANUP_LOCKS).fetch_all(&mut *transaction), + ) + .await?; + let shards = rows + .iter() + .map(|row| row.try_get::("capacity_shard")) + .collect::, _>>() + .map_err(|error| ConnectionOutcome::Reusable(MaintenanceError::Database(error)))?; + set_maintenance_server_timeouts( + &mut transaction, + deadline, + CleanupPhase::ConfiguringTimeouts, + ) + .await?; + // Cleanup forgets theoretical arrival times, so its clock observation must + // survive the deleted rows. Keep that observation monotonic under the same + // shard locks as admission, and persist it in the deletion transaction. + // Use a separate statement: the deletion triggers also update ledger rows, + // and PostgreSQL does not support updating a row twice in one command. + maintenance_before_commit( + deadline, + CleanupPhase::DeletingExpiredWindows, + sqlx::query(CLEANUP_ADVANCE_TIME) + .bind(&shards) + .execute(&mut *transaction), + ) + .await?; + set_maintenance_server_timeouts( + &mut transaction, + deadline, + CleanupPhase::ConfiguringTimeouts, + ) + .await?; + let deleted = maintenance_before_commit( + deadline, + CleanupPhase::DeletingExpiredWindows, + sqlx::query(CLEANUP_DELETE) + .bind(shards) + .bind(i64::from(maximum)) + .execute(&mut *transaction), + ) + .await?; + commit_maintenance(deadline, transaction).await?; + Ok(deleted.rows_affected()) +} diff --git a/crates/runlimit-postgres/src/lib.rs b/crates/runlimit-postgres/src/lib.rs index b1b1c82..7c0be27 100644 --- a/crates/runlimit-postgres/src/lib.rs +++ b/crates/runlimit-postgres/src/lib.rs @@ -1,4 +1,7 @@ -//! Replica-safe `PostgreSQL` storage for Runlimit fixed-window policies. +//! Replica-safe `PostgreSQL` storage for Runlimit quotas and outcome-aware attempts. +//! [`PostgresGcraLimiter`] and [`attempts::PostgresAttemptLimiter`] each use +//! independently opt-in migrations; existing fixed-window consumers need no +//! new schema or workflow changes. //! //! [`PostgresLimiter`] implements the same anchored fixed-window model as the //! in-memory backend: the first admitted check starts a window, and subsequent @@ -120,6 +123,12 @@ impl ConnectionOutcome { } mod admission; +pub mod attempts; +mod gcra; +pub use gcra::{ + CREATE_RUNLIMIT_GCRA_SQL, GCRA_MIGRATOR, INDEX_RUNLIMIT_GCRA_SHARD_EXPIRY_SQL, + PostgresGcraLimiter, +}; mod config; mod errors; mod maintenance; diff --git a/crates/runlimit-postgres/src/maintenance.rs b/crates/runlimit-postgres/src/maintenance.rs index 54956db..c269a02 100644 --- a/crates/runlimit-postgres/src/maintenance.rs +++ b/crates/runlimit-postgres/src/maintenance.rs @@ -64,7 +64,7 @@ async fn run_cleanup_transaction_inner( Ok(rows_affected) } -async fn maintenance_before_commit( +pub(crate) async fn maintenance_before_commit( deadline: Instant, phase: CleanupPhase, future: F, @@ -87,7 +87,7 @@ where }) } -async fn set_maintenance_server_timeouts( +pub(crate) async fn set_maintenance_server_timeouts( transaction: &mut Transaction<'_, Postgres>, deadline: Instant, phase: CleanupPhase, @@ -107,7 +107,7 @@ async fn set_maintenance_server_timeouts( .map(|_| ()) } -async fn commit_maintenance( +pub(crate) async fn commit_maintenance( deadline: Instant, mut transaction: Transaction<'_, Postgres>, ) -> Result<(), ConnectionOutcome> { diff --git a/crates/runlimit-postgres/tests/attempts.rs b/crates/runlimit-postgres/tests/attempts.rs new file mode 100644 index 0000000..45b625b --- /dev/null +++ b/crates/runlimit-postgres/tests/attempts.rs @@ -0,0 +1,838 @@ +//! Live transaction, concurrency, capacity, and fencing tests for attempts. +#[path = "attempts/cleanup.rs"] +mod cleanup; + +use runlimit_core::{ + KeyHasher, PolicyId, QuotaPeriod, ScopeId, + attempts::{ + AttemptAdmission, AttemptCompletionResult, AttemptDenial, AttemptOutcome, AttemptPolicy, + StagedAttemptCompletion, + }, +}; +use runlimit_postgres::{ + PostgresConfig, + attempts::{PgAttemptClaimResult, PgAttemptReceipt, PostgresAttemptLimiter, low_level}, +}; +use sqlx::{AssertSqlSafe, PgPool, postgres::PgPoolOptions}; +use std::{ + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +static NEXT: AtomicU64 = AtomicU64::new(0); +struct Database { + pool: PgPool, + admin: PgPool, + schema: String, +} +impl Database { + async fn new() -> Self { + let url = std::env::var("RUNLIMIT_POSTGRES_TEST_DATABASE_URL") + .expect("disposable PostgreSQL URL required"); + let admin = PgPoolOptions::new() + .max_connections(2) + .connect(&url) + .await + .unwrap(); + let schema = format!( + "runlimit_attempt_{}_{}_{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed) + ); + sqlx::query(AssertSqlSafe(format!("CREATE SCHEMA {schema}"))) + .execute(&admin) + .await + .unwrap(); + let target = schema.clone(); + let pool = PgPoolOptions::new() + .max_connections(20) + .after_connect(move |connection, _| { + let query = format!("SET search_path={target},pg_catalog"); + Box::pin(async move { + sqlx::query(AssertSqlSafe(query)) + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect(&url) + .await + .unwrap(); + PostgresAttemptLimiter::new(pool.clone()) + .migrate() + .await + .unwrap(); + Self { + pool, + admin, + schema, + } + } + async fn close(self) { + self.pool.close().await; + sqlx::query(AssertSqlSafe(format!( + "DROP SCHEMA {} CASCADE", + self.schema + ))) + .execute(&self.admin) + .await + .unwrap(); + self.admin.close().await; + } +} +fn period(ms: u64) -> QuotaPeriod { + QuotaPeriod::new(Duration::from_millis(ms)).unwrap() +} +fn policy() -> AttemptPolicy { + AttemptPolicy::new( + PolicyId::new("auth").unwrap(), + ScopeId::new("identifier").unwrap(), + period(10), + period(40), + period(1000), + period(30_000), + ) + .unwrap() +} +fn receipt(value: AttemptAdmission) -> PgAttemptReceipt { + match value { + AttemptAdmission::Admitted(r) => r, + AttemptAdmission::Denied(d) => panic!("denied {d:?}"), + } +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn failure_escalation_cap_and_success_reset() { + let db = Database::new().await; + let limiter = PostgresAttemptLimiter::new(db.pool.clone()); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let s = h.hash_attempt_for(&p, b"user"); + for (failures, delay) in [(1, 10), (2, 20), (3, 40), (4, 40)] { + let r = receipt(limiter.admit(s).await.unwrap()); + let AttemptCompletionResult::Applied(done) = + limiter.complete(r, AttemptOutcome::Failure).await.unwrap() + else { + panic!() + }; + assert_eq!(done.consecutive_failures(), failures); + assert_eq!(done.retry_after().duration(), Duration::from_millis(delay)); + sqlx::query("UPDATE runlimit_attempts SET retry_at_ms=0") + .execute(&db.pool) + .await + .unwrap(); + } + let r = receipt(limiter.admit(s).await.unwrap()); + assert!( + matches!(limiter.complete(r,AttemptOutcome::Success).await.unwrap(),AttemptCompletionResult::Applied(done) if done.consecutive_failures()==0) + ); + let r = receipt(limiter.admit(s).await.unwrap()); + assert!( + matches!(limiter.complete(r,AttemptOutcome::Abandoned).await.unwrap(),AttemptCompletionResult::Applied(done) if done.consecutive_failures()==1) + ); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn concurrent_replicas_admit_exactly_one_verification() { + let db = Database::new().await; + let mut tasks = Vec::new(); + for _ in 0..16 { + let pool = db.pool.clone(); + tasks.push(tokio::spawn(async move { + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + PostgresAttemptLimiter::new(pool) + .admit(h.hash_attempt_for(&p, b"user")) + .await + .unwrap() + })); + } + let mut admitted = 0; + for task in tasks { + match task.await.unwrap() { + AttemptAdmission::Admitted(_) => admitted += 1, + AttemptAdmission::Denied(AttemptDenial::Busy { .. }) => {} + other @ AttemptAdmission::Denied(_) => panic!("unexpected {other:?}"), + } + } + assert_eq!(admitted, 1); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn completion_and_application_writes_rollback_together() { + let db = Database::new().await; + sqlx::query("CREATE TABLE app_events (id INT)") + .execute(&db.pool) + .await + .unwrap(); + let limiter = PostgresAttemptLimiter::new(db.pool.clone()); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let s = h.hash_attempt_for(&p, b"user"); + let r = receipt(limiter.admit(s).await.unwrap()); + let mut tx = db.pool.begin().await.unwrap(); + let PgAttemptClaimResult::Claimed(claim) = low_level::claim_in(&mut *tx, r).await.unwrap() + else { + panic!() + }; + sqlx::query("INSERT INTO app_events VALUES(1)") + .execute(&mut *tx) + .await + .unwrap(); + assert!(matches!( + low_level::finish_in(&mut *tx, claim, AttemptOutcome::Failure) + .await + .unwrap(), + StagedAttemptCompletion::Applied(_) + )); + tx.rollback().await.unwrap(); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM app_events") + .fetch_one(&db.pool) + .await + .unwrap(), + 0 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT failures FROM runlimit_attempts") + .fetch_one(&db.pool) + .await + .unwrap(), + 0 + ); + assert!(matches!( + limiter.admit(s).await.unwrap(), + AttemptAdmission::Denied(AttemptDenial::Busy { .. }) + )); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn claim_is_transaction_fenced_and_survives_lease_elapsed_while_locked() { + let db = Database::new().await; + let limiter = PostgresAttemptLimiter::new(db.pool.clone()); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let s = h.hash_attempt_for(&p, b"user"); + let r = receipt(limiter.admit(s).await.unwrap()); + let mut tx = db.pool.begin().await.unwrap(); + let PgAttemptClaimResult::Claimed(claim) = low_level::claim_in(&mut *tx, r).await.unwrap() + else { + panic!() + }; + sqlx::query("UPDATE runlimit_attempts SET lease_until_ms=0") + .execute(&mut *tx) + .await + .unwrap(); + assert!(matches!( + low_level::finish_in(&mut *tx, claim, AttemptOutcome::Success) + .await + .unwrap(), + StagedAttemptCompletion::Applied(_) + )); + tx.commit().await.unwrap(); + let r = receipt(limiter.admit(s).await.unwrap()); + let mut tx = db.pool.begin().await.unwrap(); + let PgAttemptClaimResult::Claimed(claim) = low_level::claim_in(&mut *tx, r).await.unwrap() + else { + panic!() + }; + tx.commit().await.unwrap(); + let mut other = db.pool.begin().await.unwrap(); + assert_eq!( + low_level::finish_in(&mut *other, claim, AttemptOutcome::Success) + .await + .unwrap(), + StagedAttemptCompletion::Stale + ); + other.rollback().await.unwrap(); + assert!(matches!( + limiter.admit(s).await.unwrap(), + AttemptAdmission::Denied(AttemptDenial::Busy { .. }) + )); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn expired_receipt_cannot_clear_newer_failures() { + let db = Database::new().await; + let limiter = PostgresAttemptLimiter::new(db.pool.clone()); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let s = h.hash_attempt_for(&p, b"user"); + let old = receipt(limiter.admit(s).await.unwrap()); + sqlx::query("UPDATE runlimit_attempts SET lease_until_ms=floor(extract(epoch FROM clock_timestamp())*1000)::bigint-100").execute(&db.pool).await.unwrap(); + let new = receipt(limiter.admit(s).await.unwrap()); + assert_eq!( + limiter + .complete(old, AttemptOutcome::Success) + .await + .unwrap(), + AttemptCompletionResult::Stale + ); + assert!( + matches!(limiter.complete(new,AttemptOutcome::Failure).await.unwrap(),AttemptCompletionResult::Applied(done) if done.consecutive_failures()==2) + ); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn capacity_is_bounded_and_quiet_expiry_reclaims_slots() { + let db = Database::new().await; + let limiter = PostgresAttemptLimiter::new(db.pool.clone()).with_config( + PostgresConfig::new() + .with_maximum_rows_per_shard(1) + .unwrap(), + ); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let first = h.hash_attempt_for(&p, b"first"); + let shard = first.into_unbound_subject_key().as_bytes()[0]; + let other = (0..10000) + .map(|value| value.to_string()) + .find(|value| { + h.hash_attempt_for(&p, value) + .into_unbound_subject_key() + .as_bytes()[0] + == shard + }) + .unwrap(); + let second = h.hash_attempt_for(&p, &other); + drop(receipt(limiter.admit(first).await.unwrap())); + assert!(matches!( + limiter.admit(second).await.unwrap(), + AttemptAdmission::Denied(AttemptDenial::StorageCapacity) + )); + sqlx::query("UPDATE runlimit_attempts SET lease_until_ms=0") + .execute(&db.pool) + .await + .unwrap(); + drop(receipt(limiter.admit(second).await.unwrap())); + assert!( + sqlx::query("UPDATE runlimit_attempts SET capacity_slot=65536") + .execute(&db.pool) + .await + .is_err() + ); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn dropped_unpolled_admission_does_no_work() { + let db = Database::new().await; + let limiter = PostgresAttemptLimiter::new(db.pool.clone()); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + drop(limiter.admit(h.hash_attempt_for(&p, b"user"))); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM runlimit_attempts") + .fetch_one(&db.pool) + .await + .unwrap(), + 0 + ); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn claim_checks_expiry_after_waiting_for_the_row_lock() { + let db = Database::new().await; + let limiter = PostgresAttemptLimiter::new(db.pool.clone()); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let r = receipt( + limiter + .admit(h.hash_attempt_for(&p, b"user")) + .await + .unwrap(), + ); + let mut blocker = db.pool.begin().await.unwrap(); + sqlx::query("SELECT 1 FROM runlimit_attempts FOR UPDATE") + .execute(&mut *blocker) + .await + .unwrap(); + let mut waiting = db.pool.acquire().await.unwrap(); + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *waiting) + .await + .unwrap(); + let task = tokio::spawn(async move { + use sqlx::Acquire; + let mut tx = waiting.begin().await.unwrap(); + let result = low_level::claim_in(&mut *tx, r).await.unwrap(); + tx.rollback().await.unwrap(); + result + }); + let mut observed_wait = false; + for _ in 0..200 { + let waiting: bool = sqlx::query_scalar( + "SELECT COALESCE(wait_event_type='Lock',false) FROM pg_stat_activity WHERE pid=$1", + ) + .bind(pid) + .fetch_one(&db.admin) + .await + .unwrap(); + if waiting { + observed_wait = true; + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert!(observed_wait, "claim never waited for row lock"); + sqlx::query("UPDATE runlimit_attempts SET lease_until_ms=0") + .execute(&mut *blocker) + .await + .unwrap(); + blocker.commit().await.unwrap(); + assert!(matches!(task.await.unwrap(), PgAttemptClaimResult::Stale)); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn cancelled_host_transaction_does_not_reset_retry_state() { + let db = Database::new().await; + let limiter = PostgresAttemptLimiter::new(db.pool.clone()); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let s = h.hash_attempt_for(&p, b"user"); + let r = receipt(limiter.admit(s).await.unwrap()); + let mut tx = db.pool.begin().await.unwrap(); + let PgAttemptClaimResult::Claimed(claim) = low_level::claim_in(&mut *tx, r).await.unwrap() + else { + panic!() + }; + assert!(matches!( + low_level::finish_in(&mut *tx, claim, AttemptOutcome::Success) + .await + .unwrap(), + StagedAttemptCompletion::Applied(_) + )); + drop(tx); // SQLx queues rollback; no completion is published by the owner. + assert!(matches!( + limiter.admit(s).await.unwrap(), + AttemptAdmission::Denied(AttemptDenial::Busy { .. }) + )); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn lowered_capacity_counts_live_slots_outside_the_new_allocation_interval() { + let db = Database::new().await; + let broad = PostgresAttemptLimiter::new(db.pool.clone()).with_config( + PostgresConfig::new() + .with_maximum_rows_per_shard(2) + .unwrap(), + ); + let narrow = PostgresAttemptLimiter::new(db.pool.clone()).with_config( + PostgresConfig::new() + .with_maximum_rows_per_shard(1) + .unwrap(), + ); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let first = h.hash_attempt_for(&p, b"first"); + let shard = first.into_unbound_subject_key().as_bytes()[0]; + let others: Vec = (0..10000) + .map(|value| value.to_string()) + .filter(|value| { + h.hash_attempt_for(&p, value) + .into_unbound_subject_key() + .as_bytes()[0] + == shard + }) + .take(2) + .collect(); + drop(receipt(broad.admit(first).await.unwrap())); + let live = receipt( + broad + .admit(h.hash_attempt_for(&p, &others[0])) + .await + .unwrap(), + ); + sqlx::query("UPDATE runlimit_attempts SET lease_until_ms=0 WHERE capacity_slot=0") + .execute(&db.pool) + .await + .unwrap(); + // Bounded cleanup finds slot 0, but slot 1 is still live. With a new cap of + // one, that retained row consumes the entire capacity even outside 0..1. + assert!(matches!( + narrow + .admit(h.hash_attempt_for(&p, &others[1])) + .await + .unwrap(), + AttemptAdmission::Denied(AttemptDenial::StorageCapacity) + )); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM runlimit_attempts WHERE lease_until_ms>0" + ) + .fetch_one(&db.pool) + .await + .unwrap(), + 1 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT capacity_slot::bigint FROM runlimit_attempts WHERE lease_until_ms>0" + ) + .fetch_one(&db.pool) + .await + .unwrap(), + 1 + ); + assert!(matches!( + narrow + .complete(live, AttemptOutcome::Success) + .await + .unwrap(), + AttemptCompletionResult::Applied(_) + )); + // Once all retained state is quiet-expired, the lower bound admits a row. + sqlx::query("UPDATE runlimit_attempts SET last_failure_ms=0,lease_until_ms=CASE WHEN lease_until_ms IS NULL THEN NULL ELSE 0 END").execute(&db.pool).await.unwrap(); + drop(receipt( + narrow + .admit(h.hash_attempt_for(&p, &others[1])) + .await + .unwrap(), + )); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM runlimit_attempts") + .fetch_one(&db.pool) + .await + .unwrap(), + 1 + ); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn malformed_admission_state_is_a_semantic_storage_invariant() { + let db = Database::new().await; + let limiter = PostgresAttemptLimiter::new(db.pool.clone()); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let s = h.hash_attempt_for(&p, b"user"); + drop(receipt(limiter.admit(s).await.unwrap())); + sqlx::raw_sql("ALTER TABLE runlimit_attempts DROP CONSTRAINT runlimit_attempts_failures_check; UPDATE runlimit_attempts SET failures=-1;").execute(&db.pool).await.unwrap(); + let error = limiter.admit(s).await.unwrap_err(); + assert_eq!( + error.consumption(), + runlimit_core::ConsumptionStatus::NotConsumed + ); + let runlimit_postgres::CheckError::StorageInvariant(error) = error else { + panic!("expected storage invariant") + }; + assert_eq!(error.detail(), "invalid attempt failure count"); + assert!(std::error::Error::source(&error).is_none()); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT failures FROM runlimit_attempts") + .fetch_one(&db.pool) + .await + .unwrap(), + -1 + ); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn malformed_admission_receipt_is_a_decode_storage_invariant_and_rolls_back() { + let db = Database::new().await; + let limiter = PostgresAttemptLimiter::new(db.pool.clone()); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + sqlx::raw_sql("CREATE FUNCTION corrupt_attempt_receipt() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.lease_token=NULL; NEW.lease_until_ms=NULL; RETURN NEW; END $$; CREATE TRIGGER corrupt_attempt_receipt BEFORE INSERT ON runlimit_attempts FOR EACH ROW EXECUTE FUNCTION corrupt_attempt_receipt();").execute(&db.pool).await.unwrap(); + let error = limiter + .admit(h.hash_attempt_for(&p, b"user")) + .await + .unwrap_err(); + assert_eq!( + error.consumption(), + runlimit_core::ConsumptionStatus::NotConsumed + ); + let runlimit_postgres::CheckError::StorageInvariant(error) = error else { + panic!("expected storage invariant") + }; + assert_eq!(error.detail(), "decode attempt admission response"); + assert!( + std::error::Error::source(&error) + .is_some_and(|source| source.downcast_ref::().is_some()) + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM runlimit_attempts") + .fetch_one(&db.pool) + .await + .unwrap(), + 0 + ); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn contradictory_completion_metadata_is_storage_invariant_and_rolls_back() { + let db = Database::new().await; + let limiter = PostgresAttemptLimiter::new(db.pool.clone()); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let s = h.hash_attempt_for(&p, b"user"); + let r = receipt(limiter.admit(s).await.unwrap()); + sqlx::raw_sql("CREATE FUNCTION corrupt_attempt_completion() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.failures=1; RETURN NEW; END $$; CREATE TRIGGER corrupt_attempt_completion BEFORE UPDATE ON runlimit_attempts FOR EACH ROW EXECUTE FUNCTION corrupt_attempt_completion();").execute(&db.pool).await.unwrap(); + let error = limiter + .complete(r, AttemptOutcome::Success) + .await + .unwrap_err(); + assert_eq!( + error.consumption(), + runlimit_core::ConsumptionStatus::NotConsumed + ); + let runlimit_postgres::CheckError::StorageInvariant(error) = error else { + panic!("expected storage invariant") + }; + assert_eq!( + error.detail(), + "attempt completion metadata contradicts its outcome" + ); + assert!(std::error::Error::source(&error).is_none()); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT failures FROM runlimit_attempts") + .fetch_one(&db.pool) + .await + .unwrap(), + 0 + ); + assert!(matches!( + limiter.admit(s).await.unwrap(), + AttemptAdmission::Denied(AttemptDenial::Busy { .. }) + )); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn malformed_completion_response_retains_decode_source_and_rolls_back() { + let db = Database::new().await; + let limiter = PostgresAttemptLimiter::new(db.pool.clone()); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let s = h.hash_attempt_for(&p, b"user"); + let r = receipt(limiter.admit(s).await.unwrap()); + sqlx::raw_sql("ALTER TABLE runlimit_attempts ALTER COLUMN failures DROP NOT NULL; CREATE FUNCTION null_attempt_completion() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.failures=NULL; RETURN NEW; END $$; CREATE TRIGGER null_attempt_completion BEFORE UPDATE ON runlimit_attempts FOR EACH ROW EXECUTE FUNCTION null_attempt_completion();").execute(&db.pool).await.unwrap(); + let error = limiter + .complete(r, AttemptOutcome::Success) + .await + .unwrap_err(); + assert_eq!( + error.consumption(), + runlimit_core::ConsumptionStatus::NotConsumed + ); + let runlimit_postgres::CheckError::StorageInvariant(error) = error else { + panic!("expected storage invariant") + }; + assert_eq!(error.detail(), "decode attempt completion response"); + assert!( + std::error::Error::source(&error) + .is_some_and(|source| source.downcast_ref::().is_some()) + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT failures FROM runlimit_attempts") + .fetch_one(&db.pool) + .await + .unwrap(), + 0 + ); + assert!(matches!( + limiter.admit(s).await.unwrap(), + AttemptAdmission::Denied(AttemptDenial::Busy { .. }) + )); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn lowered_capacity_reclaims_expired_rows_across_denied_cleanup_batches() { + let db = Database::new().await; + let broad = PostgresAttemptLimiter::new(db.pool.clone()); + let narrow = PostgresAttemptLimiter::new(db.pool.clone()).with_config( + PostgresConfig::new() + .with_maximum_rows_per_shard(1) + .unwrap(), + ); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let first = h.hash_attempt_for(&p, b"seed"); + let shard = first.into_unbound_subject_key().as_bytes()[0]; + let subjects: Vec = (0..100_000) + .map(|value| value.to_string()) + .filter(|value| { + h.hash_attempt_for(&p, value) + .into_unbound_subject_key() + .as_bytes()[0] + == shard + }) + .take(34) + .collect(); + assert_eq!(subjects.len(), 34); + for subject in &subjects[..33] { + drop(receipt( + broad.admit(h.hash_attempt_for(&p, subject)).await.unwrap(), + )); + } + sqlx::query("UPDATE runlimit_attempts SET lease_until_ms=0") + .execute(&db.pool) + .await + .unwrap(); + let fresh = h.hash_attempt_for(&p, &subjects[33]); + assert!(matches!( + narrow.admit(fresh).await.unwrap(), + AttemptAdmission::Denied(AttemptDenial::StorageCapacity) + )); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM runlimit_attempts") + .fetch_one(&db.pool) + .await + .unwrap(), + 17 + ); + assert!(matches!( + narrow.admit(fresh).await.unwrap(), + AttemptAdmission::Denied(AttemptDenial::StorageCapacity) + )); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM runlimit_attempts") + .fetch_one(&db.pool) + .await + .unwrap(), + 1 + ); + drop(receipt(narrow.admit(fresh).await.unwrap())); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM runlimit_attempts") + .fetch_one(&db.pool) + .await + .unwrap(), + 1 + ); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn rolled_back_savepoint_invalidates_claim_even_in_same_transaction() { + use sqlx::Acquire; + let db = Database::new().await; + let limiter = PostgresAttemptLimiter::new(db.pool.clone()); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let r = receipt( + limiter + .admit(h.hash_attempt_for(&p, b"user")) + .await + .unwrap(), + ); + let mut outer = db.pool.begin().await.unwrap(); + let xid_before: String = sqlx::query_scalar("SELECT pg_current_xact_id()::text") + .fetch_one(&mut *outer) + .await + .unwrap(); + let mut savepoint = outer.begin().await.unwrap(); + let PgAttemptClaimResult::Claimed(claim) = + low_level::claim_in(&mut *savepoint, r).await.unwrap() + else { + panic!() + }; + savepoint.rollback().await.unwrap(); + let xid_after: String = sqlx::query_scalar("SELECT pg_current_xact_id()::text") + .fetch_one(&mut *outer) + .await + .unwrap(); + assert_eq!(xid_before, xid_after); + // The lease is now expired, and the savepoint has reverted the private + // token rotation. Reacquiring the row under the same XID cannot revive it. + sqlx::query("UPDATE runlimit_attempts SET lease_until_ms=0,failures=2") + .execute(&mut *outer) + .await + .unwrap(); + assert_eq!( + low_level::finish_in(&mut *outer, claim, AttemptOutcome::Success) + .await + .unwrap(), + StagedAttemptCompletion::Stale + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT failures FROM runlimit_attempts") + .fetch_one(&mut *outer) + .await + .unwrap(), + 2 + ); + assert!( + sqlx::query_scalar::<_, bool>("SELECT lease_token IS NOT NULL FROM runlimit_attempts") + .fetch_one(&mut *outer) + .await + .unwrap() + ); + outer.rollback().await.unwrap(); + db.close().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn released_savepoint_preserves_claim_and_lock_past_admission_expiry() { + use sqlx::Acquire; + let db = Database::new().await; + let limiter = PostgresAttemptLimiter::new(db.pool.clone()); + let p = policy(); + let h = KeyHasher::new([7; 32]).unwrap(); + let r = receipt( + limiter + .admit(h.hash_attempt_for(&p, b"user")) + .await + .unwrap(), + ); + let mut outer = db.pool.begin().await.unwrap(); + let mut savepoint = outer.begin().await.unwrap(); + let PgAttemptClaimResult::Claimed(claim) = + low_level::claim_in(&mut *savepoint, r).await.unwrap() + else { + panic!() + }; + savepoint.commit().await.unwrap(); + sqlx::query("UPDATE runlimit_attempts SET lease_until_ms=0") + .execute(&mut *outer) + .await + .unwrap(); + assert!(matches!( + low_level::finish_in(&mut *outer, claim, AttemptOutcome::Success) + .await + .unwrap(), + StagedAttemptCompletion::Applied(_) + )); + outer.commit().await.unwrap(); + assert!( + sqlx::query_scalar::<_, bool>( + "SELECT lease_token IS NULL AND failures=0 FROM runlimit_attempts" + ) + .fetch_one(&db.pool) + .await + .unwrap() + ); + db.close().await; +} diff --git a/crates/runlimit-postgres/tests/attempts/cleanup.rs b/crates/runlimit-postgres/tests/attempts/cleanup.rs new file mode 100644 index 0000000..ecb20ab --- /dev/null +++ b/crates/runlimit-postgres/tests/attempts/cleanup.rs @@ -0,0 +1,95 @@ +use super::Database; +use sqlx::AssertSqlSafe; + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn dense_shard_cleanup_uses_expiry_as_an_index_condition() { + let db = Database::new().await; + // Fill one shard to the database ceiling, with both idle and leased rows. + // Every row remains active for an hour; no wall-clock latency threshold is + // needed to distinguish an indexed cutoff from filtering all 65,536 rows. + sqlx::query( + "WITH clock AS MATERIALIZED ( + SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint + + 3600000 AS future_ms + ) + INSERT INTO runlimit_attempts ( + config_fingerprint, subject_key, capacity_slot, failures, + last_failure_ms, retry_at_ms, quiet_ms, lease_until_ms, lease_token + ) + SELECT decode(repeat('00', 32), 'hex'), + decode(lpad(to_hex(slot), 64, '0'), 'hex'), slot, 1, + future_ms, future_ms, 60000, + CASE WHEN slot % 2 = 0 THEN future_ms END, + CASE WHEN slot % 2 = 0 THEN 'synthetic-lease' END + FROM generate_series(0, 65535) AS slots(slot), clock", + ) + .execute(&db.pool) + .await + .unwrap(); + + for expired in [0_i64, 3] { + sqlx::query( + "UPDATE runlimit_attempts SET last_failure_ms=0, + lease_until_ms=CASE WHEN lease_token IS NOT NULL THEN 0 END + WHERE capacity_slot < $1", + ) + .bind(expired) + .execute(&db.pool) + .await + .unwrap(); + sqlx::query("ANALYZE runlimit_attempts") + .execute(&db.pool) + .await + .unwrap(); + + let mut tx = db.pool.begin().await.unwrap(); + sqlx::query("SELECT pg_advisory_xact_lock($1, $2)") + .bind(0x524c_4154_i32) + .bind(0_i32) + .execute(&mut *tx) + .await + .unwrap(); + // Explain and execute the exact production DELETE with normal planner + // settings, not a copied candidate SELECT or a forced index scan. + let plan = sqlx::query_scalar::<_, String>(AssertSqlSafe(format!( + "EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF) {}", + include_str!("../../src/attempt_cleanup.sql") + ))) + .bind(0_i16) + .fetch_all(&mut *tx) + .await + .unwrap() + .join("\n"); + println!("attempt cleanup plan ({expired} expired):\n{plan}"); + assert!( + plan.contains("Index Scan using runlimit_attempts_expiry"), + "cleanup must use the shard expiry index ({expired} expired):\n{plan}" + ); + assert!( + plan.lines().any(|line| { + line.contains("Index Cond:") + && line.contains("capacity_shard") + && line.contains("COALESCE(lease_until_ms, last_failure_ms)") + && line.contains("quiet_ms") + && line.contains("<=") + }), + "expiry must bound the index scan ({expired} expired):\n{plan}" + ); + assert!( + !plan.contains("Rows Removed by Filter:"), + "cleanup must not filter active entries ({expired} expired):\n{plan}" + ); + assert!( + plan.contains("Buffers:"), + "missing buffer evidence:\n{plan}" + ); + let retained: i64 = sqlx::query_scalar("SELECT count(*) FROM runlimit_attempts") + .fetch_one(&mut *tx) + .await + .unwrap(); + assert_eq!(retained, 65536 - expired); + tx.rollback().await.unwrap(); + } + db.close().await; +} diff --git a/crates/runlimit-postgres/tests/gcra.rs b/crates/runlimit-postgres/tests/gcra.rs new file mode 100644 index 0000000..ea82b68 --- /dev/null +++ b/crates/runlimit-postgres/tests/gcra.rs @@ -0,0 +1,810 @@ +//! Opt-in GCRA tests using disposable PostgreSQL schemas. + +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use runlimit_core::{ + AdmissionOutcome, BatchDecisionView, BatchError, Check, ConsumptionStatus, DecisionView, + Denial, GcraPolicy, Limiter, MAX_LIMIT, Observation, Observer, PolicyId, QuotaMode, ScopeId, + SubjectKey, +}; +use runlimit_memory::{Clock, GcraStore, MemoryStoreConfig}; +use runlimit_postgres::{ + BatchCheckError, CREATE_RUNLIMIT_GCRA_SQL, CheckError, GCRA_MIGRATOR, + INDEX_RUNLIMIT_GCRA_SHARD_EXPIRY_SQL, MIGRATOR, PostgresConfig, PostgresGcraLimiter, +}; +use sqlx::{AssertSqlSafe, PgPool, postgres::PgPoolOptions}; +use tokio::sync::Barrier; + +static NEXT_SCHEMA: AtomicU64 = AtomicU64::new(0); + +struct Database { + pool: PgPool, + admin: PgPool, + schema: String, +} + +impl Database { + async fn new() -> Self { + let db = Self::unmigrated().await; + db.limiter().migrate().await.unwrap(); + db + } + + async fn unmigrated() -> Self { + let url = std::env::var("RUNLIMIT_POSTGRES_TEST_DATABASE_URL") + .expect("disposable PostgreSQL URL required"); + let admin = PgPoolOptions::new() + .max_connections(2) + .connect(&url) + .await + .unwrap(); + let schema = format!( + "runlimit_gcra_{}_{}_{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT_SCHEMA.fetch_add(1, Ordering::Relaxed) + ); + sqlx::query(AssertSqlSafe(format!("CREATE SCHEMA {schema}"))) + .execute(&admin) + .await + .unwrap(); + let target = schema.clone(); + let pool = PgPoolOptions::new() + .max_connections(40) + .after_connect(move |connection, _| { + let query = format!("SET search_path = {target}, pg_catalog"); + Box::pin(async move { + sqlx::query(AssertSqlSafe(query)) + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect(&url) + .await + .unwrap(); + Self { + pool, + admin, + schema, + } + } + + fn limiter(&self) -> PostgresGcraLimiter { + PostgresGcraLimiter::new(self.pool.clone()) + } + + async fn teardown(self) { + self.pool.close().await; + sqlx::query(AssertSqlSafe(format!( + "DROP SCHEMA {} CASCADE", + self.schema + ))) + .execute(&self.admin) + .await + .unwrap(); + self.admin.close().await; + } + + async fn freeze_time(&self) -> u64 { + let now: i64 = sqlx::query_scalar("WITH sample AS MATERIALIZED (SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::BIGINT + 3600000 AS now_ms) UPDATE runlimit_gcra_shards SET observed_at_ms = (SELECT now_ms FROM sample) RETURNING observed_at_ms").fetch_one(&self.pool).await.unwrap(); + u64::try_from(now).unwrap() + } + + async fn row_count(&self) -> i64 { + sqlx::query_scalar("SELECT count(*) FROM runlimit_gcra") + .fetch_one(&self.pool) + .await + .unwrap() + } + + async fn seed_historical_counter(&self, policy: &GcraPolicy) { + sqlx::query("INSERT INTO runlimit_gcra(config_fingerprint, subject_key, tat_scaled, expires_at_ms) VALUES ($1, $2, 2000, 2000)") + .bind(policy.fingerprint().as_bytes().as_slice()) + .bind(subject(1, 0, policy).as_bytes().as_slice()) + .execute(&self.pool).await.unwrap(); + sqlx::query( + "UPDATE runlimit_gcra_shards SET observed_at_ms = 1000 WHERE capacity_shard = 0", + ) + .execute(&self.pool) + .await + .unwrap(); + } + + async fn watermark(&self) -> i64 { + sqlx::query_scalar( + "SELECT observed_at_ms FROM runlimit_gcra_shards WHERE capacity_shard = 0", + ) + .fetch_one(&self.pool) + .await + .unwrap() + } +} + +fn policy(quota: u64, millis: u64, burst: u64) -> GcraPolicy { + GcraPolicy::new( + PolicyId::new("auth.peer").unwrap(), + ScopeId::new("peer").unwrap(), + quota, + Duration::from_millis(millis), + burst, + ) + .unwrap() +} + +fn subject(id: u8, shard: u8, policy: &GcraPolicy) -> SubjectKey { + let mut bytes = [id; 32]; + bytes[0] = shard ^ policy.fingerprint().as_bytes()[0]; + SubjectKey::from_digest(bytes) +} + +#[derive(Clone, Copy)] +struct FrozenClock(u64); +impl Clock for FrozenClock { + fn now(&self) -> Duration { + Duration::from_millis(self.0) + } +} + +#[derive(Default)] +struct Recorder(Mutex>); +impl Observer for Recorder { + fn observe(&self, event: &Observation<'_>) { + if let Observation::Admission(admission) = event { + self.0 + .lock() + .unwrap() + .push((admission.outcome(), admission.consumption())); + } + } +} + +#[test] +fn migration_streams_are_independent_and_trait_remains_generic() { + fn accepts>() {} + let migrations: Vec<_> = GCRA_MIGRATOR.iter().collect(); + assert_eq!(migrations.len(), 2); + assert_eq!(migrations[0].sql.as_ref(), CREATE_RUNLIMIT_GCRA_SQL); + assert_eq!( + migrations[1].sql.as_ref(), + INDEX_RUNLIMIT_GCRA_SHARD_EXPIRY_SQL + ); + assert!(migrations[0].version < migrations[1].version); + assert!( + MIGRATOR + .iter() + .all(|migration| !migration.sql.as_ref().contains("runlimit_gcra")) + ); + accepts::(); +} + +#[tokio::test] +async fn invalid_batches_and_unpolled_futures_do_not_access_the_database() { + let pool = PgPoolOptions::new() + .connect_lazy("postgresql://invalid:invalid@127.0.0.1:1/invalid") + .unwrap(); + let recorder = Arc::new(Recorder::default()); + let limiter = PostgresGcraLimiter::new(pool).with_observer(recorder.clone()); + let policy = policy(1, 1000, 1); + let check = Check::new(subject(1, 1, &policy).bind(&policy)); + drop(Limiter::check(&limiter, &check)); + drop(Limiter::check_all(&limiter, &[check])); + assert!(recorder.0.lock().unwrap().is_empty()); + assert!(matches!( + limiter.check_all(&[]).await, + Err(BatchCheckError::InvalidBatch(BatchError::EmptyBatch)) + )); + assert!(matches!( + limiter.check_all(&[check, check]).await, + Err(BatchCheckError::InvalidBatch(_)) + )); + assert_eq!(limiter.cleanup_expired(0).await.unwrap(), 0); +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn installation_is_opt_in_and_preserves_unrelated_migrations() { + let db = Database::new().await; + let absent: bool = sqlx::query_scalar("SELECT to_regclass('runlimit_fixed_windows') IS NULL") + .fetch_one(&db.pool) + .await + .unwrap(); + assert!(absent); + db.limiter().migrate().await.unwrap(); + runlimit_postgres::PostgresLimiter::new(db.pool.clone()) + .migrate() + .await + .unwrap(); + db.limiter().migrate().await.unwrap(); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn canonical_migration_upgrades_original_gcra_storage_without_rewriting_it() { + let db = Database::unmigrated().await; + let original = GCRA_MIGRATOR.iter().next().unwrap().clone(); + sqlx::migrate::Migrator::with_migrations(vec![original]) + .run(&db.pool) + .await + .unwrap(); + let policy = policy(1, 1000, 1); + db.seed_historical_counter(&policy).await; + let index_absent: bool = + sqlx::query_scalar("SELECT to_regclass('runlimit_gcra_shard_expiry') IS NULL") + .fetch_one(&db.pool) + .await + .unwrap(); + assert!(index_absent); + + db.limiter().migrate().await.unwrap(); + db.limiter().migrate().await.unwrap(); + let index_definition: String = + sqlx::query_scalar("SELECT pg_get_indexdef('runlimit_gcra_shard_expiry'::REGCLASS)") + .fetch_one(&db.pool) + .await + .unwrap(); + assert!(index_definition.contains("(capacity_shard, expires_at_ms)")); + let applied: i64 = sqlx::query_scalar("SELECT count(*) FROM _sqlx_migrations") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(applied, 2); + assert_eq!(db.row_count().await, 1); + assert_eq!(db.watermark().await, 1000); + assert_eq!(db.limiter().cleanup_expired(1).await.unwrap(), 1); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn exact_memory_parity_covers_fractional_refill_weighted_costs_and_shadow_mode() { + for mode in [QuotaMode::Enforce, QuotaMode::Shadow] { + let db = Database::new().await; + let now = db.freeze_time().await; + let memory = GcraStore::builder(MemoryStoreConfig::new(8).unwrap()) + .with_clock(FrozenClock(now)) + .build(); + let policy = policy(3, 10, 7).with_quota_mode(mode); + let limiter = db.limiter(); + for cost in [3, 2, 3, 1, 2, 1] { + let check = Check::new(subject(1, 0, &policy).bind(&policy)) + .with_cost(cost) + .unwrap(); + assert_eq!( + limiter.check(&check).await.unwrap(), + memory.check(&check).unwrap() + ); + } + let checks = [ + Check::new(subject(2, 0, &policy).bind(&policy)) + .with_cost(4) + .unwrap(), + Check::new(subject(3, 1, &policy).bind(&policy)) + .with_cost(5) + .unwrap(), + ]; + assert_eq!( + limiter.check_all(&checks).await.unwrap(), + memory.check_all(&checks).unwrap() + ); + db.teardown().await; + } +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn denial_rolls_back_every_member_and_returns_first_input_failure() { + let db = Database::new().await; + db.freeze_time().await; + let policy = policy(1, 1000, 1); + let earlier = Check::new(subject(1, 0, &policy).bind(&policy)); + let exhausted = Check::new(subject(2, 1, &policy).bind(&policy)); + let limiter = db.limiter(); + assert!(limiter.check(&exhausted).await.unwrap().permits_request()); + let denial = limiter.check_all(&[earlier, exhausted]).await.unwrap(); + assert!(matches!( + denial.view(), + BatchDecisionView::Denied { index: 1, .. } + )); + assert!(limiter.check(&earlier).await.unwrap().permits_request()); + assert_eq!(db.row_count().await, 2); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn independent_replicas_and_opposite_batches_cannot_over_admit() { + let db = Database::new().await; + db.freeze_time().await; + let policy = Arc::new(policy(1, 1000, 7)); + let barrier = Arc::new(Barrier::new(33)); + let mut tasks = Vec::new(); + for index in 0..32 { + let limiter = db.limiter(); + let policy = policy.clone(); + let barrier = barrier.clone(); + tasks.push(tokio::spawn(async move { + let a = Check::new(subject(1, 0, &policy).bind(policy.as_ref())); + let b = Check::new(subject(2, 255, &policy).bind(policy.as_ref())); + let checks = if index % 2 == 0 { [a, b] } else { [b, a] }; + barrier.wait().await; + limiter.check_all(&checks).await.unwrap().permits_request() + })); + } + barrier.wait().await; + let mut allowed = 0; + for task in tasks { + allowed += usize::from(task.await.unwrap()); + } + assert_eq!(allowed, 7); + assert_eq!(db.row_count().await, 2); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn shard_capacity_is_atomic_enforced_in_shadow_and_existing_rows_remain_usable() { + let db = Database::new().await; + let policy = policy(1, 1000, 3).with_quota_mode(QuotaMode::Shadow); + let limiter = db.limiter().with_config( + PostgresConfig::new() + .with_maximum_rows_per_shard(1) + .unwrap(), + ); + let existing = Check::new(subject(1, 0, &policy).bind(&policy)); + let fresh = Check::new(subject(2, 0, &policy).bind(&policy)); + assert!(limiter.check(&existing).await.unwrap().permits_request()); + let denied = limiter.check_all(&[existing, fresh]).await.unwrap(); + assert!(matches!( + denied.view(), + BatchDecisionView::Denied { + index: 1, + denial: Denial::StorageCapacity { .. }, + .. + } + )); + let allowed = limiter.check(&existing).await.unwrap(); + assert!( + matches!(allowed.view(), DecisionView::Allowed { allowance } if allowance.available() == 1) + ); + assert_eq!(db.row_count().await, 1); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn concurrent_new_subjects_cannot_exceed_capacity() { + let db = Database::new().await; + let policy = Arc::new(policy(1, 1000, 3)); + let mut tasks = Vec::new(); + for id in 0..24 { + let limiter = db.limiter().with_config( + PostgresConfig::new() + .with_maximum_rows_per_shard(2) + .unwrap(), + ); + let policy = policy.clone(); + tasks.push(tokio::spawn(async move { + limiter + .check(&Check::new(subject(id, 0, &policy).bind(&policy))) + .await + .unwrap() + .permits_request() + })); + } + let mut allowed = 0; + for task in tasks { + allowed += usize::from(task.await.unwrap()); + } + assert_eq!(allowed, 2); + assert_eq!(db.row_count().await, 2); + let ledger: i64 = sqlx::query_scalar("SELECT sum(row_count)::BIGINT FROM runlimit_gcra_shards") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(ledger, 2); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn bounded_cleanup_releases_slots_and_skips_busy_shards() { + let db = Database::new().await; + let policy = policy(1, 1000, 1); + let limiter = db.limiter(); + for id in 1..=3 { + assert!( + limiter + .check(&Check::new(subject(id, 0, &policy).bind(&policy))) + .await + .unwrap() + .permits_request() + ); + } + sqlx::query("UPDATE runlimit_gcra SET expires_at_ms = 0, tat_scaled = 0") + .execute(&db.pool) + .await + .unwrap(); + let mut blocker = db.pool.begin().await.unwrap(); + sqlx::query("SELECT 1 FROM runlimit_gcra_shards WHERE capacity_shard = 0 FOR UPDATE") + .execute(&mut *blocker) + .await + .unwrap(); + assert_eq!(limiter.cleanup_expired(2).await.unwrap(), 0); + blocker.rollback().await.unwrap(); + assert_eq!(limiter.cleanup_expired(2).await.unwrap(), 2); + assert_eq!(db.row_count().await, 1); + let ledger: i64 = + sqlx::query_scalar("SELECT row_count FROM runlimit_gcra_shards WHERE capacity_shard = 0") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(ledger, 1); + assert_eq!(limiter.cleanup_expired(2).await.unwrap(), 1); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn database_time_replenishes_continuously_without_window_boundary_burst() { + let db = Database::new().await; + let policy = policy(2, 400, 2); + let check = Check::new(subject(1, 0, &policy).bind(&policy)); + let limiter = db.limiter(); + assert!( + limiter + .check(&check.with_cost(2).unwrap()) + .await + .unwrap() + .permits_request() + ); + tokio::time::sleep(Duration::from_millis(230)).await; + assert!(limiter.check(&check).await.unwrap().permits_request()); + assert!(!limiter.check(&check).await.unwrap().permits_request()); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn committed_database_clock_watermark_survives_clock_regression() { + let db = Database::new().await; + let now = db.freeze_time().await; + let policy = policy(1, 1000, 1); + let check = Check::new(subject(1, 0, &policy).bind(&policy)); + let limiter = db.limiter(); + assert!(limiter.check(&check).await.unwrap().permits_request()); + let decision = limiter.check(&check).await.unwrap(); + assert!( + matches!(decision.view(), DecisionView::Denied { denial: Denial::QuotaExceeded(quota) } if quota.retry_after().duration() == Duration::from_secs(1)) + ); + let observed: i64 = sqlx::query_scalar( + "SELECT observed_at_ms FROM runlimit_gcra_shards WHERE capacity_shard = 0", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(u64::try_from(observed).unwrap(), now); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn cleanup_preserves_its_clock_observation_after_forgetting_quota_state() { + let db = Database::new().await; + let policy = policy(1, 1000, 1); + db.seed_historical_counter(&policy).await; + let before_cleanup: i64 = sqlx::query_scalar( + "SELECT floor(extract(epoch FROM pg_catalog.clock_timestamp()) * 1000)::BIGINT", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + let limiter = db.limiter(); + assert_eq!(limiter.cleanup_expired(1).await.unwrap(), 1); + assert_eq!(db.row_count().await, 0); + let observed = db.watermark().await; + assert!( + observed >= before_cleanup, + "deleting the last counter must retain cleanup's time, not its old admission time" + ); + let check = Check::new(subject(1, 0, &policy).bind(&policy)); + assert!(limiter.check(&check).await.unwrap().permits_request()); + let expiry: i64 = sqlx::query_scalar("SELECT expires_at_ms FROM runlimit_gcra") + .fetch_one(&db.pool) + .await + .unwrap(); + assert!( + expiry >= observed + 1000, + "a recreated counter starts from at least cleanup's logical time" + ); + assert!(!limiter.check(&check).await.unwrap().permits_request()); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn cleanup_clamps_regressing_clock_and_recreated_counter_cannot_refill_twice() { + let db = Database::new().await; + let policy = policy(1, 1000, 1); + db.seed_historical_counter(&policy).await; + // Retain a future observation while the actual PostgreSQL clock is behind + // it. This models a backward clock adjustment without changing server time. + let logical_time = db.freeze_time().await; + let limiter = db.limiter(); + assert_eq!(limiter.cleanup_expired(1).await.unwrap(), 1); + assert_eq!(u64::try_from(db.watermark().await).unwrap(), logical_time); + let check = Check::new(subject(1, 0, &policy).bind(&policy)); + assert!(limiter.check(&check).await.unwrap().permits_request()); + let expiry: i64 = sqlx::query_scalar("SELECT expires_at_ms FROM runlimit_gcra") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(u64::try_from(expiry).unwrap(), logical_time + 1000); + assert!(!limiter.check(&check).await.unwrap().permits_request()); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn cleanup_reclaims_full_shard_at_logical_expiry_while_wall_clock_is_behind() { + let db = Database::new().await; + let start = db.freeze_time().await; + let policy = policy(1, 1000, 1); + let limiter = db.limiter().with_config( + PostgresConfig::new() + .with_maximum_rows_per_shard(1) + .unwrap(), + ); + let old = Check::new(subject(1, 0, &policy).bind(&policy)); + let fresh = Check::new(subject(2, 0, &policy).bind(&policy)); + assert!(limiter.check(&old).await.unwrap().permits_request()); + assert_eq!(limiter.cleanup_expired(1).await.unwrap(), 0); + + // A committed clock observation has reached expiry, then the physical + // clock regresses below both. Cleanup must use the committed boundary. + let expiry = i64::try_from(start + 1000).unwrap(); + sqlx::query("UPDATE runlimit_gcra_shards SET observed_at_ms = $1 WHERE capacity_shard = 0") + .bind(expiry) + .execute(&db.pool) + .await + .unwrap(); + let denied = limiter.check(&fresh).await.unwrap(); + assert!(matches!( + denied.view(), + DecisionView::Denied { + denial: Denial::StorageCapacity { .. } + } + )); + assert_eq!(limiter.cleanup_expired(1).await.unwrap(), 1); + assert_eq!(db.watermark().await, expiry); + assert!(limiter.check(&fresh).await.unwrap().permits_request()); + assert!(!limiter.check(&fresh).await.unwrap().permits_request()); + assert_eq!(limiter.cleanup_expired(1).await.unwrap(), 0); + assert_eq!(db.row_count().await, 1); + let (ledger, stored_expiry): (i64, i64) = sqlx::query_as( + "SELECT shard.row_count, counter.expires_at_ms FROM runlimit_gcra_shards AS shard JOIN runlimit_gcra AS counter USING (capacity_shard) WHERE capacity_shard = 0", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(ledger, 1); + assert_eq!(stored_expiry, expiry + 1000); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn logical_expiry_cleanup_skips_busy_shards_and_preserves_other_shards_clocks() { + let db = Database::new().await; + let start = db.freeze_time().await; + let policy = policy(1, 1000, 1); + let limiter = db.limiter(); + for shard in 0..3 { + assert!( + limiter + .check(&Check::new(subject(1, shard, &policy).bind(&policy))) + .await + .unwrap() + .permits_request() + ); + } + // Shards 0 and 1 have observed their counters' expiry, but shard 2 has not. + sqlx::query( + "UPDATE runlimit_gcra_shards SET observed_at_ms = $1 WHERE capacity_shard IN (0, 1)", + ) + .bind(i64::try_from(start + 1000).unwrap()) + .execute(&db.pool) + .await + .unwrap(); + let mut blocker = db.pool.begin().await.unwrap(); + sqlx::query("SELECT 1 FROM runlimit_gcra_shards WHERE capacity_shard = 0 FOR UPDATE") + .execute(&mut *blocker) + .await + .unwrap(); + assert_eq!(limiter.cleanup_expired(1).await.unwrap(), 1); + let remaining: Vec = + sqlx::query_scalar("SELECT capacity_shard FROM runlimit_gcra ORDER BY capacity_shard") + .fetch_all(&db.pool) + .await + .unwrap(); + assert_eq!(remaining, [0, 2]); + blocker.rollback().await.unwrap(); + assert_eq!(limiter.cleanup_expired(1).await.unwrap(), 1); + assert_eq!(limiter.cleanup_expired(1).await.unwrap(), 0); + assert_eq!(db.row_count().await, 1); + let (shard, observed): (i16, i64) = sqlx::query_as( + "SELECT capacity_shard, observed_at_ms FROM runlimit_gcra_shards WHERE row_count = 1", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(shard, 2); + assert_eq!(u64::try_from(observed).unwrap(), start); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn failed_cleanup_rolls_back_watermark_and_counter_deletion_together() { + let db = Database::new().await; + let policy = policy(1, 1000, 1); + db.seed_historical_counter(&policy).await; + sqlx::raw_sql("CREATE FUNCTION fail_gcra_delete() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected cleanup failure'; END $$; CREATE TRIGGER fail_delete BEFORE DELETE ON runlimit_gcra FOR EACH ROW EXECUTE FUNCTION fail_gcra_delete();") + .execute(&db.pool).await.unwrap(); + let error = db.limiter().cleanup_expired(1).await.unwrap_err(); + assert!(!error.may_have_removed_rows()); + assert_eq!(db.row_count().await, 1); + assert_eq!(db.watermark().await, 1000); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn full_policy_integer_range_stays_exact_and_malformed_state_is_precommit() { + let db = Database::new().await; + db.freeze_time().await; + let policy = policy(MAX_LIMIT, 1000, MAX_LIMIT); + let check = Check::new(subject(1, 0, &policy).bind(&policy)); + let limiter = db.limiter(); + let allowed = limiter + .check(&check.with_cost(MAX_LIMIT).unwrap()) + .await + .unwrap(); + assert!( + matches!(allowed.view(), DecisionView::Allowed { allowance } if allowance.available() == 0) + ); + assert!(!limiter.check(&check).await.unwrap().permits_request()); + sqlx::query("UPDATE runlimit_gcra SET tat_scaled = 999999999999999999999999999999999999999") + .execute(&db.pool) + .await + .unwrap(); + let error = limiter.check(&check).await.unwrap_err(); + assert!(matches!(error, CheckError::StorageInvariant(_))); + assert_eq!(error.consumption(), ConsumptionStatus::NotConsumed); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn shard_lock_timeout_is_not_consumed_and_releases_connection() { + let db = Database::new().await; + let policy = policy(1, 1000, 1); + let check = Check::new(subject(1, 0, &policy).bind(&policy)); + let limiter = db.limiter().with_config( + PostgresConfig::new() + .with_operation_timeout(Duration::from_millis(70)) + .unwrap(), + ); + let mut blocker = db.pool.begin().await.unwrap(); + sqlx::query("SELECT 1 FROM runlimit_gcra_shards WHERE capacity_shard = 0 FOR UPDATE") + .execute(&mut *blocker) + .await + .unwrap(); + let error = limiter.check(&check).await.unwrap_err(); + assert!(matches!(error, CheckError::TimedOutBeforeCommit { .. })); + assert_eq!(error.consumption(), ConsumptionStatus::NotConsumed); + blocker.rollback().await.unwrap(); + assert_eq!(db.row_count().await, 0); + assert!(limiter.check(&check).await.unwrap().permits_request()); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn database_ceiling_enforces_bounds_even_for_direct_inserts() { + let db = Database::new().await; + let policy = policy(1, 1000, 1); + let key = subject(1, 0, &policy); + // Simulate a full ledger without spending time on 65,536 fixture rows. + sqlx::query("UPDATE runlimit_gcra_shards SET row_count = 65536 WHERE capacity_shard = 0") + .execute(&db.pool) + .await + .unwrap(); + let error = sqlx::query("INSERT INTO runlimit_gcra(config_fingerprint, subject_key, tat_scaled, expires_at_ms) VALUES ($1, $2, 1, 1)") + .bind(policy.fingerprint().as_bytes().as_slice()).bind(key.as_bytes().as_slice()) + .execute(&db.pool).await.unwrap_err(); + assert_eq!( + error.as_database_error().unwrap().code().as_deref(), + Some("23514") + ); + assert_eq!(db.row_count().await, 0); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn commit_timeout_is_explicitly_uncertain_and_never_replayed() { + let db = Database::new().await; + sqlx::raw_sql("CREATE FUNCTION slow_gcra_commit() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_sleep(0.3); RETURN NEW; END $$; CREATE CONSTRAINT TRIGGER slow_commit AFTER INSERT ON runlimit_gcra DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION slow_gcra_commit();") + .execute(&db.pool).await.unwrap(); + let policy = policy(1, 1000, 1); + let check = Check::new(subject(1, 0, &policy).bind(&policy)); + let observer = Arc::new(Recorder::default()); + let limiter = db + .limiter() + .with_config( + PostgresConfig::new() + .with_operation_timeout(Duration::from_millis(100)) + .unwrap(), + ) + .with_observer(observer.clone()); + let error = limiter.check(&check).await.unwrap_err(); + assert!(matches!( + error, + CheckError::CommitOutcomeUnknown(_) | CheckError::CommitTimedOut + )); + assert_eq!(error.consumption(), ConsumptionStatus::PossiblyConsumed); + assert_eq!( + observer.0.lock().unwrap().as_slice(), + &[( + AdmissionOutcome::Failed, + ConsumptionStatus::PossiblyConsumed + )] + ); + // The injected server timeout rolls back this particular commit. The API + // conservatively reports uncertainty rather than generalizing this fact. + assert_eq!(db.row_count().await, 0); + db.teardown().await; +} + +#[tokio::test] +#[ignore = "requires disposable PostgreSQL"] +async fn cancellation_before_admission_cannot_late_commit() { + let db = Database::new().await; + let policy = policy(1, 1000, 1); + let mut blocker = db.pool.begin().await.unwrap(); + sqlx::query("SELECT 1 FROM runlimit_gcra_shards WHERE capacity_shard = 0 FOR UPDATE") + .execute(&mut *blocker) + .await + .unwrap(); + let limiter = db.limiter(); + let pending = tokio::spawn(async move { + limiter + .check(&Check::new(subject(1, 0, &policy).bind(&policy))) + .await + }); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let waiters: i64 = sqlx::query_scalar("SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND query LIKE 'SELECT capacity_shard, row_count, observed_at_ms FROM runlimit_gcra_shards%'") + .fetch_one(&db.admin).await.unwrap(); + if waiters > 0 { break; } + tokio::task::yield_now().await; + } + }).await.unwrap(); + pending.abort(); + assert!(pending.await.unwrap_err().is_cancelled()); + blocker.rollback().await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(db.row_count().await, 0); + db.teardown().await; +} diff --git a/docs/auth-attempts-gcra-plan.md b/docs/auth-attempts-gcra-plan.md new file mode 100644 index 0000000..7131348 --- /dev/null +++ b/docs/auth-attempts-gcra-plan.md @@ -0,0 +1,179 @@ +# Outcome-aware attempts and durable GCRA + +Owner: `runlimit-1u5`. Baseline: `77890cdb9ce116cf5da9bb42b8737c6d762a17b7`. + +## 1. Outcome + +Applications can use upstream-owned consecutive-failure controls and durable +continuously replenished traffic quotas without implementing limiter SQL. +Delivery requires live PostgreSQL evidence, unchanged existing-consumer smoke, +a clean native Codex review over the original baseline, and a pull request. + +## 2. Scope + +Add attempt policy, memory and PostgreSQL implementations, opaque receipts, +transaction-scoped completion, and a PostgreSQL GCRA backend. Keep existing +quota APIs, no-refund semantics and published migrations unchanged. Authentication +credentials, audit schemas, HTTP bodies, forwarding trust and production policy +values belong to consumers. Do not add a general refund API or circuit-breaker +state machine. + +## 3. Current-state evidence + +- Fact: `Limiter` separates single/batch errors; its current typed contract is the + compatibility baseline. Earlier pins require an independent adapter migration. +- Fact: `runlimit-memory/src/gcra.rs` implements GCRA; PostgreSQL has fixed windows. +- Fact: `CheckTransaction` owns bounded PostgreSQL completion; existing tables + and advisory-lock derivation are rolling-deployment contracts. +- Integration constraint: Batter exposes an opaque scoped SQL executor, not a + replaceable native connection. Its owned runner releases output after commit. +- Unknown: final consumer policy values. Keep constructors configurable and + require consumers to choose them; examples are not production recommendations. + +## 4. Decisions and design + +### D-01 — Separate attempts from ordinary quota + +- Status: accepted +- Context: successful authentication resets consecutive failures, not traffic. +- Choice: new validated policy/subject/receipt/outcome types and new observation + types, leaving existing exhaustive enums untouched. Escalating delay has an + explicit cap and quiet period; active reservations have a bounded lease. +- Why: additive semantics without weakening ordinary quota consumption. +- Alternatives: quota refunds and application-owned counter SQL are rejected. +- Revisit when: a concrete consumer demonstrates missing generic semantics. + +### D-02 — Owned admission and transaction-scoped finalization + +- Status: accepted +- Context: final authentication decisions can depend on application row locks. +- Choice: admit without holding a transaction through expensive verification; + then claim/fence the receipt in the application transaction before app writes, + determine a typed application outcome, and complete the attempt in that same + transaction. Low-level executor APIs explicitly require one transaction; + Batter owns the protected consumer sequence. Stale receipts cannot mutate newer + state. Abandonment/expiry must not permit unlimited retries. Audit is not reset. +- Why: retry state and authentication state must commit or roll back together. +- Alternatives: post-commit best-effort reset and pre-verification row locks fail + atomicity or availability requirements. +- Revisit when: tests expose an unenforceable cross-boundary invariant. + +GCRA uses the shared pure evaluator, authoritative database time, hard bounded +storage and atomic multi-key admission. New migration bundles are opt-in and do +not edit existing migrations. Raw identifiers never enter storage/observations. + +## 5. Execution graph + +### T-01 — Outcome-aware attempts + +- Outcome: bounded consecutive-failure state works in memory and PostgreSQL. +- Changes: new attempts modules in core/memory/postgres, exports and new migrations. +- Depends on: none +- Verify: deterministic policy/memory tests and live PostgreSQL concurrent + admission, expiry, stale completion, rollback, reset and hard-capacity tests. +- Recovery: disable new API adoption; retain new tables without rewriting history. +- Done when: tests prove parity and transaction semantics; no existing API changes. + +### T-02 — Durable GCRA + +- Outcome: replica-safe replenishing traffic quotas without fixed-window bursts. +- Changes: shared core evaluator, memory delegation, new PostgreSQL backend/tables. +- Depends on: none +- Verify: memory parity, timing boundaries, atomic batch denial, cross-replica + concurrency, hard capacity, cancellation and maintenance tests. +- Recovery: opt-in backend selection; leave existing fixed-window storage intact. +- Done when: live tests and existing external consumer smoke pass. + +T-01 and T-02 run in parallel with separate module ownership; T-01 owns shared +exports/manifests after coordination. The integrator owns plans, commits and PR. + +## 6. Verification + +Run `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, +`cargo test --workspace`, and `./scripts/run-external-consumer-smoke.sh`. +Run all ignored PostgreSQL integration targets with +`RUNLIMIT_POSTGRES_TEST_DATABASE_URL` pointing only at a disposable PostgreSQL +database and `--ignored --test-threads=1`. Add named regressions for abandoned, +concurrent and stale attempts, failure/reset atomic rollback, cardinality limits, +GCRA batch atomicity, and unpolled futures. Record actual outcomes below. + +## 7. Rollout and recovery + +Publish additive migrations and explicit initialization/maintenance guidance; +existing consumers need not install them. Merge/publish is not performed by this +workflow. Push a reviewed branch and open a PR, then integrate its exact SHA in +Batter. Production adoption requires its own policy/load review. Never repair a +failure by editing published migration history or by evicting active state. + +## 8. Risks and open decisions + +The attempts owner resolves expiry/claim races and timing overflow with tests. +The GCRA owner resolves arithmetic/time-watermark and shard-capacity behavior. +The integrator verifies old consumers and the real Batter transaction seam. +Database uncertainty does not authorize replay. Backend observers expose bounded +dimensions, not raw subjects. No throughput or production readiness claim is made. + +## Progress + +- [x] Inspected baseline and agreed additive boundaries with Batter. +- [x] T-01 implemented and validated (`runlimit-1u5.1`). +- [x] T-02 implemented and validated (`runlimit-1u5.2`). +- [x] Full validation and clean native review through `ba58c12`. +- [x] Delivery branch and PR [#9](https://github.com/bpcakes/runlimit/pull/9) opened. + +## Surprises & Discoveries + +The final auth decision cannot be fixed before application SQL checks. The +transaction seam therefore needs claim-before-callback and outcome completion, +not only a preselected success/reset operation. + +## Decision Log + +2026-09-21: separated attempts from quotas and selected transaction-scoped claim +and completion to avoid local consumer orchestration or post-commit cleanup. + +## Outcomes & Retrospective + +2026-09-21 local validation passed: workspace formatting/tests, all-feature +all-target Clippy with warnings denied, and packaged external-consumer smoke on +Rust 1.94.0. PostgreSQL 18.6 passed 9 attempt, 14 GCRA and 32 existing fixed-window +live cases; two additional GCRA non-database tests passed in the workspace run. +Default-feature live coverage also passed before the final two attempt regression +cases were added; all-feature live coverage includes all nine. Memory attempts +passed seven deterministic cases. Native review and PR delivery remain pending. + +The first native review found three implementation defects: cleanup did not +advance the GCRA monotonic clock, lowered attempt capacity did not count retained +high slots, and malformed attempt responses lost storage-invariant classification. +All three received root-cause fixes and regressions. Post-fix validation passed +formatting, workspace tests, all-target/all-feature Clippy, and 15 attempt, +17 GCRA and 32 original fixed-window live cases. Full original-range re-review +and PR delivery remain pending. + +The second review found two new defects, not recurrences: a savepoint rollback +could preserve an escaped claim's top-level XID, and memory completion exposed +an unreachable admission-only error. Claiming now transactionally rotates the +private token; rollback invalidates it while releasing a savepoint preserves it. +Memory completion has a dedicated poisoned-state error. Eight memory and +17 live PostgreSQL attempt cases, workspace tests and Clippy pass after these +fixes. GCRA and fixed-window behavior are unchanged. Final review is pending. + +The third review found a distinct cleanup discovery defect: wall-clock candidate +selection skipped rows expired by their shard's committed logical clock after +clock regression. Cleanup now uses indexed per-shard clamped-clock probes and +bounded deletion. A forward-only index migration is included by the canonical +migrator and tested as an upgrade from the original schema. Validation passed: +17 attempt, 20 GCRA and 32 existing fixed-window live tests, workspace tests, +Clippy, formatting and packaged-consumer smoke. The earlier findings did not recur. + +The fourth native review (`77890cd..ba58c12`) reported no findings and independently +passed all 69 live PostgreSQL cases, workspace tests, formatting, Clippy and the +packaged-consumer smoke. All six findings were fixed with regressions; none was +rejected or escalated. Final tracker-only closure is reviewed over the same base. +No merge, publication, production policy selection or consumer cutover is claimed. + +Storage reclamation is deliberately backend-specific: memory immediately removes +a successful subject; PostgreSQL retains reset state until bounded quiet-period +cleanup. Both permit an immediate next attempt for that subject and preserve +their configured hard storage ceiling. Capacity timing is not a cross-backend +equivalence claim. diff --git a/smoke/external-consumer/Cargo.lock b/smoke/external-consumer/Cargo.lock index 3ed80e6..425dcd6 100644 --- a/smoke/external-consumer/Cargo.lock +++ b/smoke/external-consumer/Cargo.lock @@ -2,6 +2,60 @@ # It is not intended for manual editing. version = 4 +[[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 = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[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" @@ -12,11 +66,61 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[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", +] + +[[package]] +name = "chrono" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] [[package]] name = "cmov" @@ -30,15 +134,70 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" -version = "0.3.0" +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 = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -57,142 +216,1373 @@ dependencies = [ "cmov", ] +[[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.6", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "const-oid", - "crypto-common", + "crypto-common 0.2.2", "ctutils", "zeroize", ] [[package]] -name = "hmac" -version = "0.13.0" +name = "displaydoc" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ - "digest", + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] -name = "hybrid-array" -version = "0.4.13" +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys", +] + +[[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 = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[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 = "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-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[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-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", + "version_check", ] [[package]] -name = "libc" -version = "0.2.189" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "unicode-ident", + "allocator-api2", + "equivalent", + "foldhash", ] [[package]] -name = "quote" -version = "1.0.47" +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "proc-macro2", + "hashbrown 0.16.1", ] [[package]] -name = "runlimit-core" -version = "0.3.0" +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac", - "sha2", - "thiserror", ] [[package]] -name = "runlimit-external-consumer-smoke" -version = "0.0.0" +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "runlimit-core", - "runlimit-memory", + "digest 0.11.3", ] [[package]] -name = "runlimit-memory" -version = "0.3.0" +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ - "runlimit-core", - "thiserror", + "typenum", ] [[package]] -name = "sha2" -version = "0.11.0" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", ] [[package]] -name = "syn" -version = "3.0.3" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "cc", ] [[package]] -name = "thiserror" -version = "2.0.19" +name = "icu_collections" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ - "thiserror-impl", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "thiserror-impl" -version = "2.0.19" +name = "icu_locale_core" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ - "proc-macro2", - "quote", - "syn", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "typenum" -version = "1.20.1" +name = "icu_normalizer" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "icu_normalizer_data" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] -name = "zeroize" -version = "1.9.0" +name = "icu_properties" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +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 = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[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 = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[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 = "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 = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[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 = "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 = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "runlimit-core" +version = "0.3.0" +dependencies = [ + "hmac", + "sha2 0.11.0", + "thiserror", +] + +[[package]] +name = "runlimit-external-consumer-smoke" +version = "0.0.0" +dependencies = [ + "runlimit-core", + "runlimit-memory", + "runlimit-postgres", +] + +[[package]] +name = "runlimit-memory" +version = "0.3.0" +dependencies = [ + "runlimit-core", + "thiserror", +] + +[[package]] +name = "runlimit-postgres" +version = "0.3.1" +dependencies = [ + "runlimit-core", + "sha2 0.11.0", + "sqlx", + "thiserror", + "tokio", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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.6", +] + +[[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 = "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.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +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 = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1", + "sha2 0.11.0", + "sqlx-core", + "thiserror", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[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.6", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys", +] + +[[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 = "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", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[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 = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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-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.6", + "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 = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "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.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/smoke/external-consumer/Cargo.toml b/smoke/external-consumer/Cargo.toml index 1ea84f7..0a92aee 100644 --- a/smoke/external-consumer/Cargo.toml +++ b/smoke/external-consumer/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT OR Apache-2.0" [dependencies] runlimit-core = { version = "0.3.0", path = "../../crates/runlimit-core" } runlimit-memory = { version = "0.3.0", path = "../../crates/runlimit-memory" } +runlimit-postgres = { version = "0.3.1", path = "../../crates/runlimit-postgres" } # Keep this package outside Runlimit's workspace so it exercises path # dependency resolution from an independent consumer. diff --git a/smoke/external-consumer/src/main.rs b/smoke/external-consumer/src/main.rs index ed9f069..51dc2bd 100644 --- a/smoke/external-consumer/src/main.rs +++ b/smoke/external-consumer/src/main.rs @@ -10,6 +10,11 @@ use runlimit_memory::{ }; fn main() -> Result<(), Box> { + fn accepts_gcra>() {} + check_attempt_api()?; + accepts_gcra::(); + assert!(!runlimit_postgres::CREATE_RUNLIMIT_GCRA_SQL.is_empty()); + assert!(!runlimit_postgres::GCRA_MIGRATOR.migrations.is_empty()); let gcra_error = GcraBatchError::from(MemoryBatchError::from(PoisonedShardError { shard_index: 0, })); @@ -94,3 +99,50 @@ fn main() -> Result<(), Box> { Ok(()) } + +fn check_attempt_api() -> Result<(), Box> { + use runlimit_core::{ + Delay, QuotaPeriod, + attempts::{ + AttemptAdmission, AttemptCompletion, AttemptCompletionResult, AttemptOutcome, + AttemptPolicy, AttemptPolicyError, + }, + }; + use runlimit_memory::attempts::MemoryAttemptLimiter; + let period = QuotaPeriod::new(Duration::from_secs(1))?; + let policy = AttemptPolicy::new( + PolicyId::new("login")?, + ScopeId::new("account")?, + period, + period, + period, + period, + )?; + let invalid = AttemptPolicy::new( + policy.id().clone(), + policy.scope().clone(), + period, + QuotaPeriod::new(Duration::from_millis(1))?, + period, + period, + ); + assert_eq!(invalid, Err(AttemptPolicyError::MaximumBelowInitial)); + assert!( + AttemptCompletion::new(AttemptOutcome::Success, 1, Delay::new(Duration::ZERO)).is_err() + ); + let limiter = MemoryAttemptLimiter::new(std::num::NonZeroUsize::new(2).unwrap()); + let hasher = KeyHasher::new([7; 32])?; + let AttemptAdmission::Admitted(receipt) = + limiter.admit(hasher.hash_attempt_for(&policy, b"opaque-subject"))? + else { + panic!("empty store denied") + }; + let AttemptCompletionResult::Applied(completion) = + limiter.complete(receipt, AttemptOutcome::Success)? + else { + panic!("fresh receipt stale") + }; + assert_eq!(completion.consecutive_failures(), 0); + assert!(!runlimit_postgres::attempts::CREATE_RUNLIMIT_ATTEMPTS_SQL.is_empty()); + Ok(()) +}