Skip to content

v0.0.1-beta.5

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 26 Aug 11:24
· 47 commits to main since this release

[v0.0.1-beta.5] - 2026-08-26

Breaking

  • Changing or resetting a password now revokes all of that user's existing JWTs immediately (they get 401 and must log in again) (spec 2026-08-25-jwt-password-revocation-design); previously tokens stayed valid until their natural ≤24 h expiry. Mechanism: users gains a token_version column (default 0; column ADD auto-applies at startup migrate), Issue stamps the current version into the JWT (ver claim) at login/registration, and both /api/user (RequireJWT) and the admin JWT path (adminAuth) reject when the in-memory user snapshot's version differs from the claim — no Redis denylist, no refresh tokens. Both password-write paths (self-service change-password and email-code reset) bump the version in a single atomic statement plus the standard invalidate+NOTIFY pair. Existing tokens keep working after upgrade until each user's first password change (missing ver decodes to 0 = DB default).

  • Email verification codes moved from PostgreSQL to Redis (spec 2026-08-25-emailcode-redis-migration): the email_codes table is removed (internal/ent regenerated; a leftover email_codes table on an old database is dead data — not dropped, not migrated, per the fresh-setup policy). Codes now live as one Redis HASH per (purpose, email) under c3api:emailcode:<purpose>:<email> (internal/verification, injected via Service.SetEmailCodeStore) with a native TTL replacing the expires_at column, so an expired code now answers "code invalid" instead of "code expired" (same 400 sentinel class, handler contract unchanged). Unconsumed codes do not survive a restart (compose Redis keeps appendonly = no) — users simply request a new code; resend rate limiting (60 s window) is preserved via the updated_at field.

  • Redis is now a required dependency alongside PostgreSQL (spec 2026-08-25-redis-foundation-design): [redis].addr is mandatory (empty = startup fatal; unreachable at startup = fatal via a fail-fast Ping), with C3API_REDIS_ADDR / C3API_REDIS_PASSWORD / C3API_REDIS_DB env overrides and placeholder-password rejection reusing the existing secret check. Upgrading = provisioning a Redis instance (redis:8-alpine joins the compose stack as a required service). Redis carries only discardable ephemeral coordination state — never a cache layer or system of record.

  • The manual cluster.instances setting is removed (spec 2026-08-25-redis-instance-discovery-design): multi-instance budget sharing now auto-discovers the live instance count via Redis ZSET heartbeats (internal/discovery, 1 s tick / 15 s member TTL with clock-skew margin, graceful-stop ZREM for immediate scale-down; on Redis outage the last count freezes instead of failing closed). The admin Settings "Cluster" tab is gone.

  • InputTokens ledger semantics changed for OpenAI-family upstreams (chat / responses REST+WS / codex): usage_logs.input_tokens now carries billable input (wire value = input_tokens − cached_tokens; OpenAI semantics have cached ⊆ input — previously the cached-hit portion was double-billed at both the input rate and the cache-read rate); cached reads continue to be billed separately via cache_read_tokens × CacheReadPerM. Historical rows keep the old semantics (no backfill — Beta has no migration path); total_tokens is numerically unchanged everywhere (quota deduction and stats totals unaffected); Anthropic upstreams are unaffected (their cache_read is not part of input_tokens to begin with).

  • Billing ledger rewritten as a cursor consumer over usage_logs: the in-memory billing queue (which lost 4.16 M ledger rows under load — usage logs survived, deductions never happened) is gone. usage_logs gains a billed boolean with a partial index (WHERE NOT billed) that acts as the durable cursor; the usage flusher is now the table's single writer and stamps each row's fate at birth (billing.capture=false or anonymous requests are born absorbed). The billing worker sweeps unbilled rows in batches (session-scoped advisory lock, per-user grouped concurrent deduction, poison-row quarantine, zero-cost bulk marking) with crash-safe exactly-once semantics: deduction + marking commit atomically, uncommitted work replays. Settlement executes as per-lane set-based SQL statements (balance lane / FEFO temp lane / zero-cost sweep) instead of per-user round trips; measured sustained drain 11 k+ ledger rows/s on the reference storm (vs ~100 rows/s for the retired in-memory queue path), and the legacy per-group deduction surface has been fully retired.

  • Ops overview alerts contract changed: the billing trio is now billing_lag_ms / billing_unbilled_rows / billing_quarantined_rows (replacing billing_pending_waterline_ms, which measured an in-memory queue depth that no longer exists).

  • Pricing storage unified: the three price tables (pricing / image_prices / function_prices) are retired in favor of the two-table price_entries + price_variants model; the admin endpoints GET /api/admin/pricing, GET /api/admin/image-prices, GET /api/admin/function-prices and their PUT/DELETE counterparts are removed and consolidated into GET /api/admin/prices (list filters page/page_size/mode/source/model/sort/order) plus GET|PUT|DELETE /api/admin/prices/entry?model= and GET|PUT|DELETE /api/admin/prices/variants?model= (model as a required query parameter); POST /pricing/sync stays and gains a preview counterpart POST /pricing/sync/preview; above_threshold tiered-pricing semantics are retired in favor of whole-entry variant switching (first match wins); resp-detect requests carrying images but no per-image price now bill as pure tokens (previously the whole request zeroed out as no_price).

  • Stats API redesigned around bounded query shapes: GET /api/admin/stats (unbounded raw-bucket dump) is removed and replaced by GET /api/admin/stats/trend (time series), /stats/top (entity leaderboard), /stats/entity-trend (single-entity drill-down) and /stats/ttft (TTFT percentile card — histogram sketch platform-wide, exact percentile_cont when entity-filtered). /api/user/stats now returns aggregated trend points instead of raw buckets (optional model filter gained), and /api/user/stats/ttft is new. Storage: usage_stats slimmed to hour × group × model keys (account/template/user/is_error dimensions dropped, is_error demoted to a column) and a new daily-partitioned usage_entity_stats hourly rollup now backs every entity-scoped view.

Added

  • Cross-instance concurrency consensus for all three limit layers (user / API-key / upstream account): each instance admits against a local share max(1, floor(limit/N)) with N auto-discovered via Redis heartbeats; overflow borrows through a cluster view built from per-instance in-flight reports reconciled every 500 ms into a locally-cached snapshot. The request path stays 100 % local memory — zero Redis round-trips, and N=1 short-circuits mathematically (no code path touches Redis at all). Redis outages degrade fail-open to pre-consensus per-instance behavior instead of rejecting traffic; measured drift under 2× oversubscription stays within the documented N × tick-window bound.

  • Ops observability for the two concurrency-view sync workers (conc-sync / account-conc-sync: last_tick_ok / consecutive_errors / tracked_entries) — the fail-open degradation above is silent by design, so these counters are its only visible trace; worker cards on the ops page now show localized display names.

  • Redis infrastructure (foundation for the required-dependency model): pkg/redisx is the repo-wide sole client construction point (Open = construct + fail-fast Ping; no command-level wrapper by design), internal/config gains a strictly-validated [redis] section, and internal/discovery implements ZSET-membership instance discovery wired as a worker (registered between billFlusher and listener/authSync so graceful shutdown removes the member before the final billing-cursor sweep, observable via GET /api/admin/ops/workers as instances/last_tick_ok/consecutive_errors). Future consumers on the roadmap: JWT revocation denylist, email verification codes (both since shipped — see above).

  • Billing lag observability: worker stats expose cursor lag (LagMs/UnbilledRows/QuarantinedRows/LastCycleUnixMs) with a guardrail warning when unpaid backlog exceeds 80 % of the retention window; benchmark-adapted deduction path sustains ~50 k ledger rows/s (mark step ~98 k rows/s measured).

  • Email service: registration email verification codes and password reset by emailed code — SMTP relay configured through runtime settings (mail.*, admin console → Settings → Mail tab, disabled by default; TLS defaults to implicit SMTPS on port 465), editable email templates with built-in English defaults and fallback. Delivery runs on a dedicated background mail worker (bounded queue, 3-attempt retry with backoff) observable via /api/admin/ops/workers.

  • Admin settings page reorganized into category tabs (signup / defaults / pricing sync / tier policy / cluster / mail), with a two-column Mail tab covering SMTP config and template editors; new user-facing pages for register code entry and forgot-password flow.

  • Loadtest tooling for full-surface hammering: -mode api-admin (26 weighted scenarios incl. the new stats shapes, redemption codes handed off via -codes-out) and -mode api-user (JWT pool + code redemption, -codes-in), -format images, -api-reads-only, plus a fake-upstream images endpoint.

Changed

  • Statistics reads are fully pushed down to PostgreSQL: dashboards aggregate via GROUP BY date_trunc server-side instead of pulling whole dimension cubes into gateway memory (the old endpoint materialized up to millions of rows per call and was OOM-killed at 33.6 GB under load). Measured on the high-cardinality stress dataset: stats endpoints 24–27 s average → hundreds of ms, stats-related live heap 20 GB (87.7 % of total) → ~5 MB (<0.1 %), process RSS now plateaus at the known stream watermark.
  • Entity-scoped views (user self-service stats, account drill-down, leaderboards) are served from the new hourly entity rollup — row count scales with active entities, not request volume; exact TTFT percentiles are computed from raw logs only within entity-filtered windows, while platform-wide cards read the retained per-bucket histogram sketch (~2 ms over 24 M samples).
  • Admin/user console statistics pages consume the new endpoints directly; the client-side cross-dimension bucket merge (and its "pN of the largest row" approximation) is retired — TTFT p50/p95/p99 shown in the UI are server-computed. A per-model filter was added to user stats.
  • TTFT percentile cards are TTL-cached (30 s, per-key request deduplication): concurrent identical dashboard queries share one database round-trip, and leader cancellation no longer poisons waiting requests.
  • Settlement batch size is now adaptive: the billing worker's per-statement batch limit self-tunes between 500 and 64 000 rows (seeded at 8 000 — startup behavior unchanged) from measured statement duration against an 8 s budget (0.8 × the repository-side 10 s settlement timeout): fast statements double, slow or timed-out statements halve, other errors hold. This replaces the fragile fixed constant whose oversized value stalled settlement permanently on production-scale dirty visibility maps. Worst-case drain-cycle overrun is now bounded by a single statement up to the 10 s settlement timeout (two lanes sequential per cycle) instead of the prior fixed ~2.6 s.
  • Loadtest setup supports multi-run campaigns on one database (-reuse-template-ids, -run-tag) without deterministic-name 409 collisions.