Skip to content

perf(relay): per-cell inventory locks, delta counters, and a pool statement timeout - #18722

Merged
Jinwoo-H merged 1 commit into
mainfrom
relay-per-cell-locks-and-pool-timeout
Sep 4, 2026
Merged

Jinwoo-H merged 1 commit into
mainfrom
relay-per-cell-locks-and-pool-timeout

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 5 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​452 $\color{#cf222e}{\Huge{\mathbf{−}}}$​8 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​444
Prod 2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​119 $\color{#cf222e}{\Huge{\mathbf{−}}}$​29 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​90

Why

postgres_retries on the director is ~100% 55P03 against one self-imposed critical
section: SELECT * FROM relay_cells ORDER BY cell_id ASC FOR UPDATE over a 23-row table,
taken by the assignment hot path and by director sweeps alike. #18521 bounded the wait to
500 ms and #18606 took per-connection paths off it, but two paths that mutate one or two
cells still took all 23:

  • the sticky refresh retry. A returning host whose first NOWAIT probe of its own cell
    row loses retries by taking a cell row before the assignment row (the order that avoids
    the legacy cell-first cycle). That retry took the whole inventory, so a rebind anywhere in
    the fleet stalled every other cell's reconnects.
  • reconcileReservationAccounting. It repairs exactly two cells' counters — source and
    target of a migration — and locked the other 21 to do it. It runs on nine migration paths.

Separately, statement_timeout was hardcoded, DDL ran on the same deadline-bearing pool as
requests, and 57014 was not in retryablePostgresTransactionError, so a statement
deadline reached the caller as a terminal failure rather than a retry.

Before / after locking model

path before after
sticky refresh retry relay_cells ORDER BY cell_id ASC FOR UPDATE (all 23) unlocked pin read, then FOR UPDATE on that one row
reservation reconciliation all 23 FOR UPDATE FOR UPDATE on {source, target}; unlocked SELECT cell_id for the existence check
leastLoadedCell optional lockedCells, else a second fleet-wide lock rows is required; the fallback is gone
sticky grant counter SET reserved_requests = <snapshot + 1> adjustCellReservationAtomically (reserved_requests + $1 with the capacity guard in the same statement)
placement ordered inventory lock unchanged, deliberately

Placement keeps the narrow global lock. Choosing the least-loaded cell is a genuinely
fleet-wide decision, and the existing comment records that dynamically locking only the
selected target is what allowed cross-cell cycles. Row order is still ascending cell_id
everywhere (lockCellRows keeps its ORDER BY), so the per-cell locks cannot cycle with it.

lockCellRows now takes the caller's CellInventoryLockMode instead of hardcoding the
500 ms request bound, so a sweep-reachable site does not turn ordinary contention into a
terminal 55P03. It still does not sample hold time: cellInventoryHoldMs* tracks the
fleet-wide lock these rows replace, and mixing in short single-row holds would flatter it.

Invariants preserved, and how each is tested

  • Lock order (cell before assignment on the retry). postgres-transaction-recovery.test.ts
    now traces assignment, activity, cell, cell-rows, assignment, activity against a legacy
    cell-first holder. The pin read is classified in the same hook but must not appear —
    only locked statements reach it, so its absence is the proof it stays unlocked.
  • A held cell row blocks only that cell. New cell-inventory-per-cell-locking-postgres.test.ts
    pins the host to the last cell id, holds that row, and NOWAIT-probes the two earlier
    rows while the refresh waits. Mutation-checked: restoring lockCellInventory there fails
    with ["percell-postgres-a", "percell-postgres-b"].
  • Counters stay exact under concurrency. Six hosts across three connections acquire and
    release splices on one cell; reserved_requests lands on 18 then back to 6.
  • Capacity / headroom / supersession / admission selector. Unchanged code paths; the
    full suite (591 tests) passes against real PostgreSQL, including the connection-headroom,
    control-supersession, migration-recovery and control-lease-recovery suites.
  • Census. cell-inventory-lock-census.test.ts still requires every lockCellInventory
    site to be classified and every raw relay_cells lock to be routed through a named
    helper, so re-adding a fleet-wide lock at any of the three removed sites fails the test.
  • Statement deadline is retryable, not terminal. Unit test with a faked 57014 and a
    real-PostgreSQL test that drives pg_sleep(2) under a 300 ms deadline; both die when
    57014 is removed from retryablePostgresTransactionError.
  • DDL is untimed. Unit test pins the schema pool's shape (max: 1,
    statement_timeout: 0) and the lifecycle order open schema → end schema → open serving,
    and asserts only CREATE … statements run before the serving pool exists. A
    real-PostgreSQL test holds ACCESS EXCLUSIVE on relay_invites (whose
    CREATE INDEX IF NOT EXISTS genuinely queues, unlike CREATE TABLE IF NOT EXISTS) and
    confirms the blocked backend is the …/schema connection and that the open still
    completes with a 200 ms serving deadline.

The timeout value and its derivation

Default stays 5 s, now overridable with ORCA_RELAY_POSTGRES_STATEMENT_TIMEOUT_MS.
A control renewal must land inside its own tick, which is
RELAY_PROTOCOL_LIMITS.controlPingIntervalMs * 2 = 30 s. A transaction gets
POSTGRES_TRANSACTION_ATTEMPTS = 3 tries, so the worst case a renewal spends in PostgreSQL
is attempts × timeout. 5 s keeps that at 15 s — half the tick — with room for the 2 s
connect timeout. The parser refuses 0: that is PostgreSQL's "no timeout", and a typo
should not disable the deadline this exists to enforce.

The DDL pool keeps lock_timeout (1 s) so a blocked CREATE INDEX yields to
applyPostgresSchema's bounded retry instead of pinning a connection forever; only
statement_timeout is disabled there.

Rollout

No protocol change and no schema change; wire-compatible in both directions.

Ships in the next cell roll, director first — the director is the process taking the
contended lock, and a director on the new build against cells on the old one still only
reduces contention. Note that each process now opens one extra max: 1 connection at
startup, ended before the serving pool opens; the Cloud SQL budget model reads poolMax
from config.ts and terraform, so it is unchanged, but a fleet-wide simultaneous restart
adds up to one transient connection per process.

Expect orca_relay_postgres_transaction_exhausted to fall: per the #471 rollout, the 500 ms
bound raised exhaustions ~30x because waiters that used to succeed slowly now fail fast, and
this PR removes the contention that made them wait. Watch cellInventoryHoldMsP95 and the
exhausted-retry rate on the director before rolling cells.

…tement timeout

The sticky refresh path and reservation reconciliation both took the fleet-wide
`relay_cells ... FOR UPDATE` scan to mutate one or two rows, so one busy cell
queued unrelated reconnects and migration completions behind it. Both now lock
only the rows they touch, in the same ascending cell_id order, and the sticky
grant moves its counter by a delta instead of writing back a snapshot value.

Placement keeps the ordered inventory lock: choosing the least-loaded cell is a
genuinely fleet-wide decision, and dynamically locking only the selected target
is what allowed cross-cell cycles before.

The pool's statement_timeout becomes env-configurable and a 57014 now reaches
the bounded transaction retry instead of surfacing as a terminal failure.
Schema DDL moves to its own `max: 1`, statement_timeout-free pool that is ended
before the serving pool opens, so a slow CREATE INDEX cannot inherit a request
deadline it will never fit inside.
Jinwoo-H added a commit that referenced this pull request Sep 4, 2026
@coderabbitai

coderabbitai Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The relay assignment store now uses per-cell row locks for sticky assignment retries and reservation reconciliation. Reservation updates use atomic deltas. PostgreSQL database opening now applies schema DDL through an untimed pool and uses a configurable timeout for the serving pool. SQLSTATE 57014 is retryable. New unit and PostgreSQL integration tests verify locking behavior, pool lifecycle, timeout configuration, and transaction recovery.

Merge Risk: 🔵 Low · up to 88b96

An explicit database timeout override can disable the serving query deadline, allowing blocked queries to run indefinitely. Validate explicit overrides before merge.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description provides strong technical detail about the changes, rationale, testing, and rollout. However, it omits required template sections, leaves the mandatory Linked Issue as Fixes #, and d… Add a valid linked issue number, add the missing ELI5, What Changed, Visual Proof, AI Disclosure, Review, Agent skill upstream boundary, Notes, and Checklist sections, and mark or explain the applicable testing and validation items.
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 7 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: per-cell inventory locks, atomic delta counters, and configurable PostgreSQL statement timeouts. It is specific and concise enough for repository hist…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description provides strong technical detail about the changes, rationale, testing, and rollout. However, it omits required template sections, leaves the mandatory Linked Issue as Fixes #, and does not provide the required Visual Proof entry or checklist confirmations.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 8e5744e2-df85-475a-b2e7-0d0e3234b3fc

📥 Commits

Reviewing files that changed from the base of the PR and between 7cb0547 and 88b96cf.

📒 Files selected for processing (7)
  • cloud/apps/relay/src/assignment-store.ts
  • cloud/apps/relay/src/cell-inventory-lock-census.test.ts
  • cloud/apps/relay/src/cell-inventory-per-cell-locking-postgres.test.ts
  • cloud/apps/relay/src/database-postgres-timeout.test.ts
  • cloud/apps/relay/src/database-statement-timeout-postgres.test.ts
  • cloud/apps/relay/src/database.ts
  • cloud/apps/relay/src/postgres-transaction-recovery.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review.

return false
}
const blocked = await blockedOnSchemaConnection()
releaseTable()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Hold the DDL lock beyond the serving deadline.

The schema pool uses statement_timeout: 0 and lock_timeout: 1_000, but releaseTable() runs as soon as the waiter appears. Hold the lock for more than 200 ms and less than 1 second, then assert that startup emits no schema retry or 57014 failure before releasing it.

application_name: input.applicationName,
connectionTimeoutMillis: POSTGRES_CONNECTION_TIMEOUT_MS,
statement_timeout: POSTGRES_STATEMENT_TIMEOUT_MS,
statement_timeout: input.statementTimeoutMs ?? relayPostgresStatementTimeoutMs(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🌐 Web query:

For PostgreSQL, does setting the session statement_timeoutto0 disable statement timeouts?

💡 Result:

Yes, setting the PostgreSQL session parameter statement_timeout to 0 disables the statement timeout [1][2]. According to official PostgreSQL documentation, a value of zero is the default and explicitly disables the timeout, meaning statements will not be aborted due to a duration limit [1][3]. It is important to note that if you observe the timeout seemingly remaining active despite running SET statement_timeout = 0;, it may be due to connection pooling middleware, such as PgBouncer, operating in transaction mode [4]. In such configurations, the connection state is often reset between transactions, causing your SET command to be discarded before the subsequent statement is executed [4]. In these cases, you may need to adjust the configuration at the database or role level, or use session-level configuration within a transaction block if supported by your application architecture [4].

Citations:


🤖 get_repo_knowledge executed:

get_repo_knowledge stablyai/orca /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/learnings /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/conventions

Length of output: 24479


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- changed area ---'
sed -n '1000,1065p' cloud/apps/relay/src/database.ts
printf '%s\n' '--- timeout helper references ---'
rg -n -C 8 'relayPostgresStatementTimeoutMs|statementTimeoutMs|statement_timeout' cloud/apps/relay/src cloud/apps/relay/test cloud/apps/relay/tests 2>/dev/null || true

Repository: stablyai/orca

Length of output: 25853


Validate explicit serving timeouts.

statementTimeoutMs: 0 reaches the serving pg.Pool unchanged. PostgreSQL treats statement_timeout = 0 as disabled, so this removes the serving-query deadline. Apply the existing integer-and-minimum validation to explicit overrides. Add tests for 0, negative, and fractional values.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one coverage-gap note and a comment nit inline.

Reviewed changes

Reviewed the per-cell inventory-lock narrowing, the atomic delta counter write, the DDL-on-untimed-pool split, and the retryable-57014 change, plus their census/postgres tests.

  • Per-cell sticky-refresh lock — assignStickyOnce's retry reads the pin unlocked and locks only that one cell row before the assignment row, with a pin-moved re-check that rethrows database_lock_unavailable.
  • Two-row reconciliation lock — reconcileReservationAccounting locks only {source, target} cells and reads the cell-id existence set unlocked.
  • Atomic delta counter — the sticky control grant now uses adjustCellReservationAtomically (reserved_requests + 1 with an in-statement capacity guard) instead of a snapshot-derived absolute write.
  • Untimed schema pool — DDL runs on a dedicated max: 1 pool with statement_timeout: 0, opened and closed before the serving pool opens.
  • Retryable statement timeout — 57014 joins retryablePostgresTransactionError, so a statement deadline retries rather than surfacing as a terminal failure.

I traced the lock-ordering model myself and independently verified it against the concurrency lens: the assignment→activity→cell vs cell→assignment→activity asymmetry between reconciliation and placement is pre-existing and is broken by the NOWAIT / bounded lock_timeout / retry wrappers, so narrowing the two locks to per-cell rows removes cycle edges without adding any. The pin-moved re-check is correctly fenced by the FOR UPDATE assignment lock, and the delta write is a strict improvement.

ℹ️ The census ratchet no longer polices lockCellRows modes

lockCellRows now carries the same CellInventoryLockMode distinction the census exists to police (including a bounded-request default), but readCallSites() only matches lock(?:General)?CellInventory(, so the "never puts a sweep-reachable site on the bounded wait" and "routes every sweep-only site to NOWAIT" tests can no longer see it. Today the three sites are correct (reconcileReservationAccounting passes 'pool-default'; assignStickyOnce threads lockMode; the two changeActivity-family callers are request-only), so this is a gap rather than a bug — but the PR removed the census entries for assignStickyOnce and reconcileReservationAccounting without re-covering their new lockCellRows form, and a future sweep-reachable bounded lockCellRows call would now slip through undetected.

Technical details
# Extend the lock census to classify lockCellRows

## Affected sites
- cloud/apps/relay/src/cell-inventory-lock-census.test.ts:168 — `readCallSites()` regex `lock(?:General)?CellInventory\(` does not match `lockCellRows(`, so its three call sites are unclassified.
- cloud/apps/relay/src/assignment-store.ts:6985 — `lockCellRows` gains a `mode: CellInventoryLockMode = 'request'` parameter, the exact bounded-wait surface the census's sweep-safety invariants protect.

## Required outcome
- A sweep-reachable `lockCellRows` call that resolves to a bounded (`'request'`) or queueing wait fails the census, matching the existing guarantees for `lockCellInventory`.

## Suggested approach (optional)
- Extend `readCallSites` (and the `CENSUS` shape) to classify `lockCellRows(transaction, ids, mode)` calls by mode, then rely on the existing `derivedReachability` sweep tests unchanged.

ℹ️ Nitpicks

  • The reconcileReservationAccounting comment states "only reconcileCells writes" relay_cells; configureCell (assignment-store.ts:2687) and cell-admission-migration-registration.ts insertCell also insert/upsert it. The safety-relevant fact is that no production path deletes a cell row, which is what keeps the unlocked read safe — wording it that way avoids implying the table has a single writer.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

)
const cells = await this.lockCellInventory(transaction, 'pool-default')
// Only the two cells this repairs need holding. The id set below is an
// existence check against a table that only reconcileCells writes, so it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The justification is slightly off: configureCell (assignment-store.ts:2687) and insertCell in cell-admission-migration-registration.ts:199 also write relay_cells. The safety-relevant fact is that no production path deletes a cell row (all three are inserts/upserts), which is what makes the unlocked read safe — worth wording it that way so a future editor doesn't assume the table has a single writer.

@Jinwoo-H
Jinwoo-H merged commit b6f453d into main Sep 4, 2026
26 checks passed
@Jinwoo-H
Jinwoo-H deleted the relay-per-cell-locks-and-pool-timeout branch September 4, 2026 22:18
Jinwoo-H added a commit that referenced this pull request Sep 6, 2026
…meout aborts (#8129)

Ports the relay's #18722 pattern to the gateway: DDL runs on a one-connection
pool with statement_timeout 0 that is closed before the serving pool opens, and
SQLSTATE 57014 joins the bounded transaction retry path.
Jinwoo-H added a commit that referenced this pull request Sep 7, 2026
…18554)

* feat(cloud): add the mobile push gateway and its contract package (#8129)

A small open-source service that holds the APNs key and FCM credentials and
sends background push to paired phones on the desktop's behalf. Hosts
authenticate with a box challenge and HMAC proof on their pairing key, the
same shape the relay uses, so signed-in and accountless desktops share one
path. Tokens are stored; alert text is held only for the coalescing window.

The contract doc in docs/reference is the source of truth for every wire
shape. The interop test runs the real desktop answerer against a real
gateway-issued challenge so transcript drift fails in CI.

* feat(push): register phones and send background push from the desktop (#8129)

Adds the notifications.remote-push.v1 capability, the registerPush and
unregisterPush RPCs on the mobile allowlist, a gateway client with a cached
session and 401 re-auth, a durable unregister outbox, and a dispatcher that
offers every mobile notification to the gateway after the socket fan-out.
The dispatcher is fire-and-forget with one retry and drops registrations the
gateway reports dead.

Puts agentState on the mobile frame and fixes the #4375 wording so a working
agent is never announced as finished. The relay host-proof code moves onto a
shared envelope module with no behaviour change.

* feat(mobile): background push registration, receive, and settings (#8129)

Fetches the native APNs or FCM token, registers it with every paired host
that advertises the capability, and re-registers on token change. Foreground
pushes are suppressed inside handleNotification against the same seen set
the socket path uses, so nothing shows twice. Taps route by host fingerprint.
One Background notifications switch, off by default, with the disclaimer and
needs-input / finished sub-switches; hidden until a paired desktop is new
enough. Adds google-services.json and the expo-notifications plugin.

* chore(cloud): Terraform and deploy workflow for the push gateway (#8129)

Declares the Cloud Run service, runtime account, secrets, and orca_push
database behind push_gateway_enabled, true only in production. The deploy
workflow is gated like the relay's, deploys with no traffic, probes /ready
and a validate-only FCM send, then shifts traffic. It runs as the shared
production deploy account because the Cloud SQL rollout lease grant is
foundation-owned; its extra authority is three bindings on the push service.
docs/push-gateway.md carries the import commands for the resources created
by hand and the APNs key rotation procedure.

* docs: describe background notifications on the phone (#8129)

* docs: check in the mobile push contract (#8129)

Seven committed files cite it as the source of truth for every wire shape;
docs/reference is allowlisted per file, so add the entry.

* test(push): replay one checked-in host-proof vector on both sides (#8129)

Cloud Verify installs only the cloud workspace, so the gateway suite cannot
import the desktop answerer. Replace the cross-workspace import with a fixed
challenge vector generated from the contract package; the gateway fixture and
the desktop answerer each replay it and must produce the same HMAC. A
transcript drift on either side now fails in that side's own suite.

* fix(cloud): open the push gateway with invoker_iam_disabled, not an allUsers binding (#8129)

The production domain-restricted-sharing policy rejects an allUsers
run.invoker member, which the runbook anticipated. Opt the service out of
invoker IAM the way the relay director already does; the host proof is the
authentication either way.

* docs(cloud): the push.onorca.dev record exists and is hand-managed (#8129)

* fix(push): close review findings in the gateway (#8129)

- Quota reservation takes a per-host advisory lock; READ COMMITTED admitted
  a whole burst past the cap (80/80 without, 60/80 with, against Postgres 16).
- Challenge issuance no longer writes push_hosts; the row lands on proof
  verification. Stale hosts prune after 30 days. Per-IP token bucket on the
  two unauthenticated routes.
- Streaming body limit via hono bodyLimit; a chunked body bypassed the
  Content-Length check.
- registrationIds deduped in the schema; per-host device cap of 64; list
  bounded to its schema.
- Gateway-side challenge TTL is the specified 10 s, not 40 s.
- APNs stream settles on close as well as end/error.

* fix(push): close review findings in the desktop client (#8129)

- A gateway registration the registry cannot persist is enqueued for delete
  instead of leaking a live token.
- Unregister outbox re-reads pending per pass, honours enqueues during a
  drain, and retries with backoff instead of waiting for the next launch.
- Dispatcher batches registrations by 20 rather than starving the rest.
- 401 compare-and-clear; a 401 after re-auth is unreachable; refused
  handshakes and 429s are cached briefly instead of re-handshaking per event.
- Service is stopped on quit.

* fix(mobile): close review findings in push registration and receive (#8129)

- Consent generation guards a register that finishes after the switch went
  off; the host is re-queued for unregister instead of recorded live.
- Foreground pushes seed the watermark before adopting the epoch, so a push
  on a never-connected session cannot wipe a valid watermark.
- aps-environment follows the build via app.config.js; the iOS release
  workflow sets it to production. A bare plugin entry wrote development.
- Pushes the OS showed while closed are marked seen before catch-up replay.
- Token null result is not cached; failed capability probes are retried and
  never block an unregister; coalesced summaries are shown but not marked.
- Unresolvable fingerprint routes nowhere and is suppressed in foreground.
- Android channel ensured at boot; capability hook diffs clients by identity.

* fix(cloud): harden the push deploy workflow and size the gateway to the budget (#8129)

- Roll traffic back on a failed post-shift check; delete a candidate that
  never took traffic; retry the origin probe and the FCM probe.
- Assert Terraform-owned scaling instead of mutating it from the workflow.
- Build before taking the Cloud SQL rollout lease.
- Declare the database pool in Terraform (2 per instance, max 2 instances)
  and add the gateway to the connection budget; the previous default put the
  shared instance 65 connections over its ceiling.
- State plainly that the shared deploy identity's relay authority is inherited.

* fix(push): read the runtime from shared state at push startup (#8129)

Threading the runtime through launchDesktopMode put the launch module one
line over the 300-line lint budget after the rebase.

* fix(push): key the unauthenticated rate limit on the hop Cloud Run wrote (#8129)

Cloud Run appends the connecting peer to x-forwarded-for; the limiter read
the left-most value, which the caller controls, so a forged first hop earned
a fresh bucket per request.

* fix(push): close the final security review findings in the gateway and infra (#8129)

- app.onError logs only the error name and answers a bare 500; hono's default
  handler printed the whole error, and a pg error carries the row in detail
- a second per-IP bucket (240/min) runs ahead of the bearer lookup on every
  authenticated route, so forged bearers cannot spend the two-connection pool
- one live session per host: minting deletes the host's earlier row
- device-less hosts are pruned after 1 h, not 30 d; any keypair mints one free
- notificationId is printable ASCII, since it becomes the APNs collapse header
- the impersonated FCM probe token is masked in the workflow log
- prevent_destroy on the Apple secrets and the orca_push database

* fix(push): close the final security review findings in the desktop client (#8129)

- fetch never follows a redirect: a 307 would replay the host proof and the
  phone's token to whatever origin the redirect named
- registerPush params are strict and the paired identity is spread last
- a per-device bucket (10/min) bounds a phone looping registerPush, which
  costs a gateway write and a synchronous registry write each time

* fix(mobile): close the final security review findings in push receive (#8129)

- a push with no epoch can no longer claim a seq-derived dedup key, in the
  foreground or from the tray; a forged seq:N could otherwise swallow the
  real bell at that seq
- a provider-delivered push with no host catalog, or no fingerprint at all,
  stays unrouted instead of falling back to the hostId its raw data carries

* docs(push): record the ip buckets, session and host retention, and the token-ownership limit (#8129)

* fix(push): apply the schema on an untimed pool and retry statement-timeout aborts (#8129)

Ports the relay's #18722 pattern to the gateway: DDL runs on a one-connection
pool with statement_timeout 0 that is closed before the serving pool opens, and
SQLSTATE 57014 joins the bounded transaction retry path.

* fix: harden mobile push delivery and deployment recovery

* feat: align mobile notification preferences with desktop delivery

* fix: accept variable-length APNs device tokens

* fix: deduplicate native APNs and background socket notifications
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…tement timeout (stablyai#18722)

The sticky refresh path and reservation reconciliation both took the fleet-wide
`relay_cells ... FOR UPDATE` scan to mutate one or two rows, so one busy cell
queued unrelated reconnects and migration completions behind it. Both now lock
only the rows they touch, in the same ascending cell_id order, and the sticky
grant moves its counter by a delta instead of writing back a snapshot value.

Placement keeps the ordered inventory lock: choosing the least-loaded cell is a
genuinely fleet-wide decision, and dynamically locking only the selected target
is what allowed cross-cell cycles before.

The pool's statement_timeout becomes env-configurable and a 57014 now reaches
the bounded transaction retry instead of surfacing as a terminal failure.
Schema DDL moves to its own `max: 1`, statement_timeout-free pool that is ended
before the serving pool opens, so a slow CREATE INDEX cannot inherit a request
deadline it will never fit inside.
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…#8129) (stablyai#18554)

* feat(cloud): add the mobile push gateway and its contract package (stablyai#8129)

A small open-source service that holds the APNs key and FCM credentials and
sends background push to paired phones on the desktop's behalf. Hosts
authenticate with a box challenge and HMAC proof on their pairing key, the
same shape the relay uses, so signed-in and accountless desktops share one
path. Tokens are stored; alert text is held only for the coalescing window.

The contract doc in docs/reference is the source of truth for every wire
shape. The interop test runs the real desktop answerer against a real
gateway-issued challenge so transcript drift fails in CI.

* feat(push): register phones and send background push from the desktop (stablyai#8129)

Adds the notifications.remote-push.v1 capability, the registerPush and
unregisterPush RPCs on the mobile allowlist, a gateway client with a cached
session and 401 re-auth, a durable unregister outbox, and a dispatcher that
offers every mobile notification to the gateway after the socket fan-out.
The dispatcher is fire-and-forget with one retry and drops registrations the
gateway reports dead.

Puts agentState on the mobile frame and fixes the stablyai#4375 wording so a working
agent is never announced as finished. The relay host-proof code moves onto a
shared envelope module with no behaviour change.

* feat(mobile): background push registration, receive, and settings (stablyai#8129)

Fetches the native APNs or FCM token, registers it with every paired host
that advertises the capability, and re-registers on token change. Foreground
pushes are suppressed inside handleNotification against the same seen set
the socket path uses, so nothing shows twice. Taps route by host fingerprint.
One Background notifications switch, off by default, with the disclaimer and
needs-input / finished sub-switches; hidden until a paired desktop is new
enough. Adds google-services.json and the expo-notifications plugin.

* chore(cloud): Terraform and deploy workflow for the push gateway (stablyai#8129)

Declares the Cloud Run service, runtime account, secrets, and orca_push
database behind push_gateway_enabled, true only in production. The deploy
workflow is gated like the relay's, deploys with no traffic, probes /ready
and a validate-only FCM send, then shifts traffic. It runs as the shared
production deploy account because the Cloud SQL rollout lease grant is
foundation-owned; its extra authority is three bindings on the push service.
docs/push-gateway.md carries the import commands for the resources created
by hand and the APNs key rotation procedure.

* docs: describe background notifications on the phone (stablyai#8129)

* docs: check in the mobile push contract (stablyai#8129)

Seven committed files cite it as the source of truth for every wire shape;
docs/reference is allowlisted per file, so add the entry.

* test(push): replay one checked-in host-proof vector on both sides (stablyai#8129)

Cloud Verify installs only the cloud workspace, so the gateway suite cannot
import the desktop answerer. Replace the cross-workspace import with a fixed
challenge vector generated from the contract package; the gateway fixture and
the desktop answerer each replay it and must produce the same HMAC. A
transcript drift on either side now fails in that side's own suite.

* fix(cloud): open the push gateway with invoker_iam_disabled, not an allUsers binding (stablyai#8129)

The production domain-restricted-sharing policy rejects an allUsers
run.invoker member, which the runbook anticipated. Opt the service out of
invoker IAM the way the relay director already does; the host proof is the
authentication either way.

* docs(cloud): the push.onorca.dev record exists and is hand-managed (stablyai#8129)

* fix(push): close review findings in the gateway (stablyai#8129)

- Quota reservation takes a per-host advisory lock; READ COMMITTED admitted
  a whole burst past the cap (80/80 without, 60/80 with, against Postgres 16).
- Challenge issuance no longer writes push_hosts; the row lands on proof
  verification. Stale hosts prune after 30 days. Per-IP token bucket on the
  two unauthenticated routes.
- Streaming body limit via hono bodyLimit; a chunked body bypassed the
  Content-Length check.
- registrationIds deduped in the schema; per-host device cap of 64; list
  bounded to its schema.
- Gateway-side challenge TTL is the specified 10 s, not 40 s.
- APNs stream settles on close as well as end/error.

* fix(push): close review findings in the desktop client (stablyai#8129)

- A gateway registration the registry cannot persist is enqueued for delete
  instead of leaking a live token.
- Unregister outbox re-reads pending per pass, honours enqueues during a
  drain, and retries with backoff instead of waiting for the next launch.
- Dispatcher batches registrations by 20 rather than starving the rest.
- 401 compare-and-clear; a 401 after re-auth is unreachable; refused
  handshakes and 429s are cached briefly instead of re-handshaking per event.
- Service is stopped on quit.

* fix(mobile): close review findings in push registration and receive (stablyai#8129)

- Consent generation guards a register that finishes after the switch went
  off; the host is re-queued for unregister instead of recorded live.
- Foreground pushes seed the watermark before adopting the epoch, so a push
  on a never-connected session cannot wipe a valid watermark.
- aps-environment follows the build via app.config.js; the iOS release
  workflow sets it to production. A bare plugin entry wrote development.
- Pushes the OS showed while closed are marked seen before catch-up replay.
- Token null result is not cached; failed capability probes are retried and
  never block an unregister; coalesced summaries are shown but not marked.
- Unresolvable fingerprint routes nowhere and is suppressed in foreground.
- Android channel ensured at boot; capability hook diffs clients by identity.

* fix(cloud): harden the push deploy workflow and size the gateway to the budget (stablyai#8129)

- Roll traffic back on a failed post-shift check; delete a candidate that
  never took traffic; retry the origin probe and the FCM probe.
- Assert Terraform-owned scaling instead of mutating it from the workflow.
- Build before taking the Cloud SQL rollout lease.
- Declare the database pool in Terraform (2 per instance, max 2 instances)
  and add the gateway to the connection budget; the previous default put the
  shared instance 65 connections over its ceiling.
- State plainly that the shared deploy identity's relay authority is inherited.

* fix(push): read the runtime from shared state at push startup (stablyai#8129)

Threading the runtime through launchDesktopMode put the launch module one
line over the 300-line lint budget after the rebase.

* fix(push): key the unauthenticated rate limit on the hop Cloud Run wrote (stablyai#8129)

Cloud Run appends the connecting peer to x-forwarded-for; the limiter read
the left-most value, which the caller controls, so a forged first hop earned
a fresh bucket per request.

* fix(push): close the final security review findings in the gateway and infra (stablyai#8129)

- app.onError logs only the error name and answers a bare 500; hono's default
  handler printed the whole error, and a pg error carries the row in detail
- a second per-IP bucket (240/min) runs ahead of the bearer lookup on every
  authenticated route, so forged bearers cannot spend the two-connection pool
- one live session per host: minting deletes the host's earlier row
- device-less hosts are pruned after 1 h, not 30 d; any keypair mints one free
- notificationId is printable ASCII, since it becomes the APNs collapse header
- the impersonated FCM probe token is masked in the workflow log
- prevent_destroy on the Apple secrets and the orca_push database

* fix(push): close the final security review findings in the desktop client (stablyai#8129)

- fetch never follows a redirect: a 307 would replay the host proof and the
  phone's token to whatever origin the redirect named
- registerPush params are strict and the paired identity is spread last
- a per-device bucket (10/min) bounds a phone looping registerPush, which
  costs a gateway write and a synchronous registry write each time

* fix(mobile): close the final security review findings in push receive (stablyai#8129)

- a push with no epoch can no longer claim a seq-derived dedup key, in the
  foreground or from the tray; a forged seq:N could otherwise swallow the
  real bell at that seq
- a provider-delivered push with no host catalog, or no fingerprint at all,
  stays unrouted instead of falling back to the hostId its raw data carries

* docs(push): record the ip buckets, session and host retention, and the token-ownership limit (stablyai#8129)

* fix(push): apply the schema on an untimed pool and retry statement-timeout aborts (stablyai#8129)

Ports the relay's stablyai#18722 pattern to the gateway: DDL runs on a one-connection
pool with statement_timeout 0 that is closed before the serving pool opens, and
SQLSTATE 57014 joins the bounded transaction retry path.

* fix: harden mobile push delivery and deployment recovery

* feat: align mobile notification preferences with desktop delivery

* fix: accept variable-length APNs device tokens

* fix: deduplicate native APNs and background socket notifications
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