Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .beads/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Database
*.fsqlite-migration-state
*.db
*.db-journal
*.db-shm
Expand Down
3 changes: 3 additions & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
@@ -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}
Expand Down
22 changes: 11 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -133,7 +133,7 @@ jobs:
run: >-
cargo +stable test
-p runlimit-postgres
--test postgres
--tests
--all-features
--locked
--
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

<!-- bv-agent-instructions-v3 -->
Expand Down
118 changes: 115 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Policy = GcraPolicy>` 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

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading