Skip to content

auth: API key expiry, rotation grace and batched last-used (#49) - #204

Merged
jsam merged 1 commit into
mainfrom
fm/il-f01
Oct 3, 2026
Merged

jsam merged 1 commit into
mainfrom
fm/il-f01

Conversation

@jsam

@jsam jsam commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Draft for visibility. Stacked on the release checkpoint 1 branch (fm/il-rc1); it will be retargeted to main once checkpoint 1 lands, and validated through the no-mistakes pipeline before merge.

Tracker: #199

API keys were valid until revoked, with no creation, expiry or use times,
so stale credentials could not be found and a key could not be rotated
without cutting clients off.

- `.apikey create <label> [<ttl>]` creates a key that expires `ttl` from
  now (`30s`, `15m`, `24h`, `90d`); without a TTL it never expires.
- `.apikey expire <label> <ttl>` brings a key's expiry forward, never
  back. Rotation: create the replacement, give the old key a grace period
  with `.apikey expire`; both work until it passes. Documented in the
  authentication guide.
- `.apikey list` reports label, owner, created_at, expires_at,
  last_used_at and status (active/expired).

A credential now ends either revoked or expired (CredentialEnded). Expiry
is exact: every fence check compares the clock for a key that has one, so
no result, notification or push leaves after the expiry instant. A
credential upkeep task sleeps until the next expiry, ends the key and
completes its sessions' end signals, so live /ws connections stop their
standing queries, receive "Credential expired; reconnect with valid
credentials" and close. Expired keys reject authentication with "API key
expired".

last_used_at is recorded in memory at most once per second per key and
persisted in one batch every minute and at shutdown; no request or frame
writes to storage.

Times live in `_internal.api_key_times(key_hash, field, at)`, leaving
`api_keys` and existing data dirs unchanged (a key without times never
expires). Writes are ordered fail-closed: times before the key, new rows
before deleting the rows they replace, and on load the earliest expiry
and latest use win.

Hot path: a check on a credential without expiry adds one atomic load;
a key with an expiry adds one vDSO clock read (~40 ns). No new lock,
lookup or storage access per request or frame.

The Python and JS SDKs gain the TTL and expire calls and the full key
info, and now return the created key instead of its label.

Closes #49

Rebased onto main: an ended connection's last frame is a notice, so an
expired key gets the new credential_expired code next to credential_revoked;
key times are decoded with the other stored credentials in auth::stored, and
the end signal stays fired once complete, like the revocation signal it
replaces.
@jsam
jsam changed the base branch from fm/il-rc1 to main October 3, 2026 21:26
@jsam
jsam marked this pull request as ready for review October 3, 2026 21:26
@jsam
jsam merged commit 9ed61b4 into main Oct 3, 2026
15 checks passed
@jsam
jsam deleted the fm/il-f01 branch October 3, 2026 21:37
jsam added a commit that referenced this pull request Oct 4, 2026
…ry request (#249)

The release-checkpoint performance gate fails on main: cheap_query serial
p50 is 1.110x the approved baseline e732cd5 on the 32-vCPU runner
(115 -> 126 us). Concurrent latency and throughput did not move. Bisected
to #214 (per-connection request pipelining), made worse by #222.

Cause: the pipeline runs requests in a `FuturesUnordered`. Its `poll_next`
wakes its own task and returns `Pending` after any poll that polled every
future in the set. With one request in flight, which is every serial
client, that happens on each request's first poll. The connection task
wakes itself, tokio schedules that as a yield, and a yield wakes an idle
worker to take the task. Each request therefore costs one more context
switch (4.0 -> 5.0 per query) and a hand-off of the connection task to
another core. #222 added no work per query but made the hand-off dearer:
user cycles per query rose 15% at the same instruction count.

Fix: keep one never-woken future in the set. After the pipeline's first
poll it is never polled again, so no poll reaches every future and the
forced yield no longer fires. The yield after futures that wake
themselves still applies. Pipelining, ordering, deadlines, cancellation
and the credential fence are unchanged. Nothing is added per request: no
lock, atomic or allocation. Each connection gets one more set node.

Bisect (cheap_query, `?edge(1, Y)`, serial). Server counters per query
from perf stat. Ratios are against e732cd5, from an interleaved probe
with one pinned server per commit, 40 rounds:

| commit | PR | user instr/query | ctx switches/query | p50 ratio |
|---|---|---|---|---|
| e732cd5 | baseline | 1.282M | 4.0 | 1.000 |
| d75ce6e | #208 atomic programs | 1.279M | 4.0 | 0.993 |
| 4578fe6 | #215 notification stream | 1.271M | 4.0 | 0.991 |
| 71a4d24 | #216 online backup | 1.275M | 4.0 | 1.016 |
| 2f621c5 | #200 first-update latency | 1.272M | 4.0 | 1.009 |
| e96df5c | #207 | 1.279M | 4.0 | 0.998 |
| 4dd8fe9 | #209 shard index | 1.272M | 4.0 | 0.987 |
| 277c7f0 | #206 | 1.274M | 4.0 | 1.008 |
| 3554c98 | #214 pipelining, deadlines | 1.296M | 5.0 | 1.020 |
| 3492a59 | #213 | 1.294M | 5.0 | 1.030 |
| 2ba9a5a | #212 provenance | 1.297M | 5.0 | 1.029 |
| 9ed61b4 | #204 key expiry | 1.297M | 5.0 | 1.027 |
| be35ee3 | #221 | 1.297M | 5.0 | 1.013 |
| c98a590 | #222 bounded streaming | 1.298M | 5.0 | 1.047 |
| a6fd628 | #219 wildcard fix | 1.297M | 5.0 | 1.061 |
| b7f7ad6 | #220 WAL durability | 1.300M | 5.0 | 1.056 |
| de54807 | #223 shared standing eval | 1.299M | 5.0 | 1.071 |
| 55ec34d | #225 checkpoint-1 fixes | 1.300M | 5.0 | 1.045 |
| 23712b8 | main | 1.300M | 5.0 | 1.064 |
| this change | | 1.300M | 4.0 | 1.033 |

Confirming A/Bs with the gate's own fixture (fresh pinned servers each
round, 40 rounds, ratio [95% interval]):

- #214 vs its parent 277c7f0: 1.039 [1.027..1.049]
- #222 vs its parent be35ee3: 1.025 [1.006..1.044]
- A/A 3554c98: 1.010 [1.002..1.025]
- e732cd5 -> main: 1.048 [1.028..1.066]
- main -> this change: 0.978 [0.958..0.989]
- e732cd5 -> this change: 1.021 [1.009..1.044], PASS (budget 1.05)

User cycles per query (perf stat, same instruction count): e732cd5 958K,
be35ee3 1053K, c98a590 1206K, this change 1006K.

Full gate, all fixtures, 20 rounds, servers pinned to CPUs 2-15:

- main -> this change: no metric worse. Every required metric is PASS, or
  INCONCLUSIVE with an interval that includes 1.0. cheap_query p50 0.979
  [0.953..1.007].
- e732cd5 -> this change: cheap_query p50 1.029 [1.019..1.072],
  INCONCLUSIVE on a busy host (load average up to 22); bound_query p50
  1.022, PASS. The insert and delta metrics improved since the baseline.

The shared host is about 4x slower per query than the CI runner, so the
same hand-off is a smaller share of each query here than there.

The remaining ~1.7% of user instructions per query since #214 is the
pipeline's own per-request bookkeeping: the boxed request future (one
4-8 KiB allocation), its set node, the request control and the in-flight
maps.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant