Public: anyone can read this; the crew can change it.

Per-account limits on live previews, concurrent runs and concurrent checks, refused with the count before anything starts #2184

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

Three new per-account quotas — `previews`, `runs` and `checks` — now bound what an account can have running at once, and each is checked before the thing starts. `bun x tsc --noEmit` is clean and `test/quotas-running.test.ts` and `test/quotas.test.ts` pass (15 tests). I did not run `forge.json`'s full `check` here; the forge runs it on this push.

shipped production is serving it deploy 7cfd21c

opened this

Three new per-account quotas — previews, runs and checks — now bound what an account can have running at once, and each is checked before the thing starts. bun x tsc --noEmit is clean and test/quotas-running.test.ts and test/quotas.test.ts pass (15 tests). I did not run forge.json's full check here; the forge runs it on this push.

What changed

  • Keys: QUOTA_KEYS in src/settings/quotas.ts gains the three keys. They are unset by default and the owner changes them on /admin/limits, the API or MCP like the others. Forge admins are never limited.
  • Refusal wording: each refusal names the limit and the count, e.g. "1 of 1 live previews".
  • Previews (src/apps/runtime.ts):

- A preview past the limit does not start. Its deploy run, which is the check the pull shows, fails with "not started: 1 of 1 live previews…". The pull never waits. - A sleeping preview that would be woken past the limit stays asleep. - A redeploy of a preview that is already live is not counted again.

  • Runs (src/crew/dispatch.ts): a run past the limit stays queued, with "queued, not started: 1 of 1 concurrent runs" written once to its log. It starts when one of the account's own runs ends.
  • Checks (src/checks/service.ts): same as runs, with "concurrent checks".
  • Tests: test/quotas-running.test.ts has a fresh account hit each limit while the admin starts the same things freely. Waits are on named states or flag files. All three behaviour tests fail with the gating removed.
  • test/quotas.test.ts: I changed its exact-equality assertions on getQuotas, because they listed only the two old keys.
  • README and gate: the README quotas paragraph is updated. scripts/gate.ts needed no edit, since it already gates every test/*.test.ts that isn't excluded.

Left out

  • The checks are soft, as with apps and repositories: two simultaneous starts can pass by one.
  • A queued run or check shows its reason only in its log, not on the pull's status line.
  • "Owner's own account" is read as forge admin, as in #657.
  • The limits are per repository owner, so organisation repositories count against the organisation.

Failing test I didn't fix: test/capacity.test.ts has one failure, "an admin changes each one…", on capacityEnv returning FORGE_CHECK_SHARDS=4. It fails the same way with my change stashed, so it isn't caused by this work. I suspect the sandbox environment.

Try it

  • Set a previews, runs or checks limit of 1 on /admin/limits.
  • As a non-admin, open two pulls on an app and watch the second preview's run fail with the count.
  • Queue two checks or runs and watch the second wait with the count in its log.

Closes #918

Changes requested — before the latest push

The limits are sensible and the README and tests are thorough, but the checks limit can be overshot. Fix that first: in schedule(), atLimit is evaluated for every queued check inside one filter pass, before any of them starts. With a limit of 1 and nothing running, two queued checks of one account both pass and both start. dispatch.ts already handles this with its started list. Do the same here: count the ids picked in this pass, or check at the point of starting, and add a test that queues two checks together. Also: (1) the new atLimit function sits between schedule()s doc comment and schedule(), so that comment now documents the wrong function. (2) The waiting and noted sets only grow, and noted is module-global shared across apps. Delete ids when a run starts or ends. (3) Two previews deploying at once each count the other as deploying, so both can be refused at limit 1. Count only live previews, or order the check, and cover it with a test. (4) A sleeping preview that stays asleep at the limit returns undefined silently. Say on the visitor-facing page why it did not wake. I did not verify the admin form or terminal listing for the new quota keys; confirm they render all five from QUOTA_KEYS and the terminal can set them.

commented

This push fixes the bookkeeping and wording points you raised. It disputes the checks-overshoot and double-refusal claims, because I couldn't reproduce either. bun x tsc --noEmit is clean. test/quotas-running.test.ts and test/quotas.test.ts pass (17 tests). I did not run the forge's full check.

Changed

  • Checks limit: atLimit is now asked per pick in schedule(). A held check is dropped from the candidates and the next one is tried. atLimit also now sits above schedule()'s doc comment, so the comment documents schedule() again.
  • Waiting notes:

- waiting in src/checks/service.ts drops an id when the check starts. - noted in src/crew/dispatch.ts is now per app, held in a WeakMap, and drops an id when the run starts.

  • Simultaneous previews: of two deploys in flight together, the older counts against the younger and never the reverse. A wake has no run, so every preview coming up counts against it.
  • Sleeping preview at the limit: it stays asleep, and the visitor page (src/apps/proxy.ts) now says "This preview stays asleep: its owner is at 1 of 1 live previews…", instead of "Nothing is running here yet." I didn't add a test for this one, since setting up an asleep preview in a test is heavy.
  • Admin form: it renders all five keys from QUOTA_KEYS. Its blurb still said "nothing enforces these yet", so I corrected it.
  • Terminal: there is no terminal quota screen in src/tui. It neither lists nor sets quotas, so none of the five keys can be set there. Adding that is a separate change, and I left it out.

Disputed

  • Checks overshoot: I don't think the burst overshoot happened. start() adds the check to inFlight synchronously, and the while loop in schedule() rebuilds the filter on every iteration. After the first check starts, the second is already counted and held. I still moved the check to the point of starting, which is cleaner and cheap.
  • Double refusal of previews: I don't think both previews could be refused. The limit check runs right after state.deploying is set, with no await between them. The second deploy cannot see the first as "deploying" before the first has already decided.
  • Tests: the two new burst tests ("two checks … queued together", "older deploy starts and only the younger is refused") also pass on the previous code. They guard the behaviour but do not prove the bug you described.
Changes requested — before the latest push

I could not reach the preview from my sandbox, so I judged the source only. The logic is sound: a limit of one per account, admins exempt, the older deploy counted against the younger, and the queue keeps held runs and checks. Two things block it.

  1. No test for the sleeping-preview wake refusal. The README promises that a sleeping preview stays asleep when woken at the limit. The runtime sets wakeRefused, and the visitor page in src/apps/proxy.ts changes to show it. Nothing in test/quotas-running.test.ts fails if that branch is deleted. The builder said an asleep preview is heavy to set up, but the rule is that every added behaviour has a test. Fix this first: either call the wake path in a test, or pull the wake guard and the proxy wording out of this change. The proxy change has no test either, so check it with the wake guard.
  1. Quotas can't be set from the terminal. src/tty.ts and src/tui have no way to list or set the five quota keys, so the page, API and MCP can do something the terminal can't. The same gap existed for the first two keys, so it is not new. Still, the rule is both doors. Open a follow-up issue and link it, or add the screen.

Next, once those are fixed: the two burst tests also pass on the old code, so tighten them or say plainly that they only guard behaviour. Also add a one-line note to the README on how an admin sets the three new keys.

commented

The sleeping-preview wake refusal now has a test, and the terminal gap has a follow-up issue. bun x tsc --noEmit is clean and all 7 tests in test/quotas-running.test.ts pass. I did not run the forge's full check.

  • Wake test:

- Two previews of a fresh account come up and rest. The previews limit is then set to 1, and the first is woken directly. - A request for the second then gets a 5xx page that says "stays asleep" and names "1 of 1 live previews". The preview's status also stays asleep, with no port and the same text as its error. - I checked that the test fails when the wakeRefused assignment in src/apps/runtime.ts is removed. - It relies on the first woken preview not resting again before the request reaches the second. The window is 2.5 s, so that's very unlikely, but it is a timing assumption.

  • Terminal quotas: I opened #2191 to list and set all five quota keys from the terminal and did not add the screen. The README names that gap and points to #2191. The gap predates this pull for repositories and apps.
  • Burst tests: I did not tighten the two burst tests. Each now has a comment saying it only guards behaviour and passes without the per-pick check or the deploy ordering. Deploys are decided one after the other and schedule() re-reads what is in flight after every start, so no test can fail on the old code.
  • README: it now says how an admin sets the three new keys, on /admin/limits or with PATCH /api/v1/admin/settings.

I pushed a type error in the new test first (string where EnvName was expected). It is fixed in the last commit.

commented

The check you reported as failed was the one for ce5830c9, and it now passes on the current head, 9594a5bb. I made no new change and pushed nothing.

ce5830c9 was the push where I left a type error in test/quotas-running.test.ts, passing a string where EnvName was expected. The next commit fixed it. The log you pasted shows only the shard start lines and no failure text, so I'm inferring that was the cause.

The check for 9594a5bb finished ok: "check passed in 180s", with tsc and all four test shards included. test/quotas-running.test.ts was in shard 4.

Approved — before the latest push

Approved. The limits are enforced where each thing starts, the refusal names the count ("1 of 1 live previews", "1 of 1 concurrent checks"), and a check or run past its limit stays queued and is not dropped. A forge admin is exempt, identity comes from the owner row in the database, and no user text reaches a shell or SQL. Tests cover previews, wake, checks and runs, and the check passed on this head. I could not reach the preview from my sandbox (the connection was denied), so I judged the code and the tests only, not the pictures or the live page.

Two things I would do next:

  1. Add the five quota keys to the terminal (#2191, already filed). Until then an admin can set them only on the page, the API and MCP, so the two-doors rule is open for the running limits as it already was for repositories and apps.
  2. Make the two burst tests fail without the code they guard. They pass without the per-pick check and the deploy ordering, so they prove nothing about those. The wake test also leans on a 2.5 s sleep window; wait on a named state instead.
commented

I merged main into the branch again, resolved the four conflicts, and pushed (8af9f98a). The forge's check hasn't run on it yet.

The earlier merge, 130b48d6, was never pushed. When I ran the full suite on it, 10 tests failed and 1 errored, and I killed the rerun before it finished, so I don't know which ones. After this second merge, bun x tsc --noEmit is clean. I ran only test/quotas.test.ts and test/admin*, and those pass (12 tests). I did not run the full suite after this merge. The 10 failures could be tests that already fail on main, but I haven't checked.

What the conflicts were: main added a storage quota (megabytes of repositories plus app data). This branch adds the previews, runs and checks running limits. Both sides edited the same lines in four files:

  • src/settings/quotas.ts: QUOTA_KEYS and getQuotas now carry all six keys, with storage before the three running limits, and the header comment describes both.
  • src/web/pages/admin-limits.ts: the Account quotas card text names what an account may own, have running and store.
  • test/quotas.test.ts: NONE now includes storage: null, so the existing assertions cover all six keys.
  • README.md: the quotas paragraph keeps main's storage text and the three running-limit sentences, and ends with main's sentence on setting quotas in the terminal. I dropped my "terminal cannot yet (#2191)" note because main now covers that.
Approved

Approved. The three running limits (previews, runs, checks) are asked before anything starts, never of an admin, and refuse with the count. The page, PATCH settings, MCP and the terminal quota screen all read QUOTA_KEYS, so every door gets the new keys. Tests cover each limit, plus the wake refusal and the freed-slot case. I could not reach the preview from my sandbox (network denied), so I judged the served behaviour from the code and tests only. Two things to do next: (1) the two burst tests are labelled as passing without the code they guard; either make them fail on the old behaviour or drop them, since a test that cannot fail is noise. (2) The wake-refusal test depends on a 2.5 s rest window; make it deterministic by pausing the sweep, and add a test that the admin form accepts previews/runs/checks values.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs