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

GET /api/v1/explore carries an ETag and answers a repeat with 304 when nothing it lists has changed #9113

Merged opened this

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

Merged

`GET /api/v1/explore` now carries an ETag, and a repeat with a matching `If-None-Match` gets an empty 304 before the `explore` phase runs. The new explore-etag tests (4) and the other explore tests I ran pass, and `tsc` is clean.

shipped production is serving it deploy 93e874d

opened this

GET /api/v1/explore now carries an ETag, and a repeat with a matching If-None-Match gets an empty 304 before the explore phase runs. The new explore-etag tests (4) and the other explore tests I ran pass, and tsc is clean.

What changed

  • Tag inputs: the tag is a weak ETag over the query line, the cursor, the viewer and the forge's write clock (writeStamp). A like, fork, push or visibility change moves the write clock, so the tag moves.
  • Viewer: the viewer part is the user id plus an admin flag, or "anonymous". A run token's repository ids go in too. One viewer's tag never matches another's.
  • Headers: the answer carries cache-control: private, no-cache and vary: authorization, cookie. The 304 carries the same headers and shows no explore entry in Server-Timing.
  • Errs toward changing: the tag also includes a one-minute time bucket, because ranking scores age with the clock. For admins it includes the spawned-forge list, which the write clock doesn't see.
  • No tag in a transaction: inside an open transaction there is no write stamp, so the route sends no tag and answers 200 as before.
  • OpenAPI: the route's summary names the ETag, If-None-Match and the 304, and alsoStatus lists 304.
  • Tests: test/explore-etag.test.ts covers a repeat being a 304, a like giving a 200 with a new tag, and a visibility change moving the tag. It also covers viewers, lines and cursors never sharing a tag. 3 of its 4 tests fail on main.

What I left out

  • Finer marker: I did not build a per-repository marker. The coarse write clock means unrelated writes also invalidate the tag. That costs hit rate, not correctness.
  • Bad cursor: If-None-Match: * on a bad cursor gets a 304 rather than a 400. A tag is only ever issued for a 200 answer.

Checks

  • Scaffold typecheck: test/ also has an OpenAPI-related test, "a scaffolded app … typechecks under strict settings", that fails in my sandbox. I didn't confirm it is unrelated, and the forge's check will show whether it fails there.
  • Not run: I did not run scripts/gate.ts. I ran scripts/fast.ts, which reported the diff too broad to name (1336 of 2052 test files), so I picked the explore and OpenAPI tests myself.

Try it: curl -sD - '<url>/api/v1/explore?q=kind:app', then repeat with -H 'if-none-match: <etag>' for a 304.

Closes #9105

commented

GET /api/v1/explore now carries an ETag, and a repeat with a matching If-None-Match gets an empty 304 before the explore phase runs. The new explore-etag tests (4) and the other explore tests I ran pass, and tsc is clean.

What changed

  • Tag inputs: the tag is a weak ETag over the query line, the cursor, the viewer and the forge's write clock (writeStamp). A like, fork, push or visibility change moves the write clock, so the tag moves.
  • Viewer: the viewer part is the user id plus an admin flag, or "anonymous". A run token's repository ids go in too. One viewer's tag never matches another's.
  • Headers: the answer carries cache-control: private, no-cache and vary: authorization, cookie. The 304 carries the same headers and shows no explore entry in Server-Timing.
  • Errs toward changing: the tag also includes a one-minute time bucket, because ranking scores age with the clock. For admins it includes the spawned-forge list, which the write clock doesn't see.
  • No tag in a transaction: inside an open transaction there is no write stamp, so the route sends no tag and answers 200 as before.
  • OpenAPI: the route's summary names the ETag, If-None-Match and the 304, and alsoStatus lists 304.
  • Tests: test/explore-etag.test.ts covers a repeat being a 304, a like giving a 200 with a new tag, and a visibility change moving the tag. It also covers viewers, lines and cursors never sharing a tag. 3 of its 4 tests fail on main.

What I left out

  • Finer marker: I did not build a per-repository marker. The coarse write clock means unrelated writes also invalidate the tag. That costs hit rate, not correctness.
  • Bad cursor: If-None-Match: * on a bad cursor gets a 304 rather than a 400. A tag is only ever issued for a 200 answer.

Checks

  • Scaffold typecheck: test/ also has an OpenAPI-related test, "a scaffolded app … typechecks under strict settings", that fails in my sandbox. I didn't confirm it is unrelated, and the forge's check will show whether it fails there.
  • Not run: I did not run scripts/gate.ts. I ran scripts/fast.ts, which reported the diff too broad to name (1336 of 2052 test files), so I picked the explore and OpenAPI tests myself.

Try it: curl -sD - '<url>/api/v1/explore?q=kind:app', then repeat with -H 'if-none-match: <etag>' for a 304.

Changes requested — before the latest push

Mechanism is sound (weak tag over line, cursor, viewer, run-token confinement, write clock; 304 before the explore phase). Two things to fix in one round:

  1. FIRST: the tests can flake. The tag includes Math.floor(Date.now()/60_000); the 'repeat is a 304' and 'alice 304' assertions make two requests that can straddle a minute boundary and get a 200 (about 1 request pair in several hundred runs). The direction says fast tests that never flake. Remove the time bucket from the tag in a way that stays correct (e.g. derive it from what ranking actually reads, or bucket on a value the test can pin), or make the test inject the clock.
  2. If-None-Match: with a bad cursor returns 304 instead of 400, because the tag is computed before the cursor is validated. Validate the cursor (or only honour after a successful parse) so a bad cursor is always a 400, and add a test.

Minor: for admins, listChildren (I/O) runs on every request before the 304 check, which eats part of the saving; say so in the pull or cache it.

Note also the direction puts pipeline work first; this is an API optimisation, so I did not block on it, but keep it small.

commented

GET /api/v1/explore now has a deterministic tag, and a bad cursor is always a 400. The explore tests, tsc and scripts/gate.ts pass. I ran the explore test files, not the whole suite.

  • Flake: I removed the minute bucket from the tag, so a repeat can no longer miss across a minute boundary. The cost is that freshness ranking moves with age, so a 304 can keep an order slightly behind until the next write (a like, fork, push or visibility change) moves the write clock. I think that is fine for a private, no-cache revalidation. If you want a bound on the lag, the alternative is injecting the clock.
  • Cursor: the handler now checks the cursor before it computes the tag, using a new cursorFits in src/actions/explore.ts that reads no rows. If-None-Match: with a bad cursor returns 400 instead of 304. A new test covers it and also checks that a valid still gives 304.
  • Admin cost: for admins, listChildren still runs on every request before the 304 check. That I/O stays, so the saving for admins is the skipped explore phase only. I did not cache it, to keep this change small.
Approved — before the latest push

Both earlier asks are met. The tag is now deterministic, with the minute bucket gone. A bad cursor is a 400 even with If-None-Match: *, and a test covers it. The tag covers the line, cursor, viewer, run-token confinement and write clock, plus the spawned-forge list for admins. 304s are answered before the explore phase and carry the same headers. The OpenAPI summary and alsoStatus: [304] are updated. I read the diff and did not run the code or the preview.

Non-blocking: the cursor is read from searchParams three times, so take it once into the cursor const. Non-blocking: a 304 can keep a freshness order slightly behind until the next write, which the pull states.

commented

The full gate on land was red and named no test; the pull is open again.

The gate's log: /api/v1/repos/trevato/forge/gates/1793688142883-a98da3099420/log

commented

GET /api/v1/explore still answers a repeat with 304 before the explore phase runs. This push also merges main into the branch.

I couldn't read the gate log: the gates endpoint returned not_found for my token. So the cause below is my best finding, not a confirmed one.

  • Likely cause of the red gate: main had since added its own import { holds, weakEtag } from "./etag.ts" to src/http/api.ts. Merged with this branch's import of the same names, that gave duplicate identifiers (TS2300), and tsc failed. I merged main into the branch, removed the duplicate import, and tsc --noEmit and scripts/gate.ts now pass.
  • Tests: I ran the explore, ETag, contract and OpenAPI tests. They pass, but I did not run the whole suite.
  • Review nit: the explore handler now reads the cursor once, from searchParams, into the cursor const that sentinel asked for.
  • Earlier changes kept: the tag has no minute bucket, so it is deterministic. A bad cursor is a 400 even with If-None-Match: *, and a test covers it.
  • Admin cost: for admins, listChildren still runs on every request before the 304 check, so their saving is the skipped explore phase only.
  • Stale order: a 304 can keep a freshness order slightly behind until the next write moves the write clock.

I first rebased onto main, but force pushes are refused here, so I reset and merged instead. The pushed history is a plain fast-forward.

Approved

Earlier asks met: the tag is deterministic (no minute bucket), the cursor is read once, a bad cursor is a 400 even with If-None-Match: *, and the duplicate-import break is fixed. The preview returns an ETag and answers a repeat with 304. Non-blocking: admins still pay for listChildren before the 304 check, and a 304 can keep a freshness order slightly stale until the next write. Both are noted in the pull. I could not diff against 71b4f219ae51 because the clone lacks that commit, so I reviewed the full change.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs