- commit
- c62c853a7bbe99c8e3ac3b99ef48c3bad03247fb
- parent
- 863614a0e4994b664cf51641d8ff723147a19c98
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 44 | ++++++++++++ |
| A | test/ | 106 | ++++++++++++++++++++++++++++++ |
@@ -203,6 +203,50 @@ within a live instance. Async handlers can interleave across awaits. Those
203203 flags are admission controls, not persisted crash recovery or authority to204204 delete data. Repository CAS and persisted authority remain necessary.205205 206+The coordination tests pause actual Git HTTP and alarm requests at an R2207+root read. Competing Git requests return 429 without reading R2. While HTTP208+holds `graspBusy`, signed event writes and HTTP management are refused and209+an alarm defers its storage work. While an alarm holds `graspControls`, a210+signed repository-state event still enters purgatory. This is intentional211+evidence of a narrower admission fence, not proof of exclusive maintenance.212+That event changes SQL authority without an R2 operation in the paused213+fixture; it does not demonstrate an existing Git publication or data-loss bug.214+Both successful and failed R2 reads release their flags. Consuming the215+completed HTTP response performs no more R2 reads in this fixture.216+217+| Entry point | Existing coordination | Limit before collection |218+|---|---|---|219+| Git advertisement, fetch and push | `grasp` sets `graspBusy` before its first await and rejects either occupied flag | Live-instance admission only; no persisted reader lease |220+| Accepted state and promotion | Run inside the HTTP fence or alarm controls; promotion rechecks candidates after reads | An immutable read session caches bytes but acquires no lock |221+| Non-GET HTTP management | `Relay.fetch` holds `graspControls` while the route runs and refuses `graspBusy` | Other controls and event writes can still interleave |222+| Signed events, imports and pulls | `acceptAny` or `writeGate` refuses `graspBusy` | `graspControls` alone does not refuse event writes |223+| Alarm, PR expiry and pending promotion | Alarm defers during `graspBusy`, otherwise holds `graspControls` | Alarm entry does not refuse an already active control operation |224+| Whole-relay deletion | Management or lease-expiry alarm calls `teardown` | Teardown has no internal lock and deletes the whole slug prefix; it is not a metadata collector |225+| Direct repository helper calls | Production callers are the HTTP handler and `graspTick` | `gitRepository` and `checkpoint()` acquire no host fence themselves; test calls bypass admission |226+227+The current HTTP handler constructs its response bytes before the read228+session closes. A future streaming path would need to preserve coordination229+until its last storage read. None of these in-memory flags coordinates an230+independent R2 client, another object ID sharing a prefix, or recovery after231+the owning instance is replaced. CAS protects root publication, but does not232+prevent deletion of an object that a reader or unpublished writer still233+needs. Removing a repository announcement also leaves its WAL and quota234+rows behind; visibility is not a storage-liveness test.235+236+Before metadata collection, a complete inventory needs explicit object,237+byte and operation budgets and must refuse an incomplete mark. The host238+also needs a proven ownership and recovery protocol covering active239+readers, unpublished writes, administrative deletion and maintenance.240+Budget exhaustion, missing or corrupt dependencies and changed roots must241+prevent a deletion decision. Stable before/after roots alone are insufficient:242+a reader may hold an older root, or a publisher may have uploaded objects243+without publishing its new root yet. This remains a collection blocker;244+the tests introduce no collector or runtime coordination change.245+246+`npm test -- test/object/grasp-coordination.test.ts` reproduces the paused247+HTTP/alarm checks. The checkpoint inventory above remains an isolated,248+quiescent fixture rather than an online maintenance API.249+206250 Provider request accounting separates HTTP/RPC/alarm invocations from207251 incoming WebSocket messages, which have a 20:1 billing ratio. Socket frames208252 do not become Worker requests. DO duration counts overlapping work once per@@ -0,0 +1,106 @@
1+// Real HTTP and alarm reads pause at R2 to expose the live instance's2+// admission fences. These tests establish no crash-safe collection lease.3+import { env, runInDurableObject } from "cloudflare:test";4+import { describe, expect, it } from "vitest";5+import { generateSecretKey } from "nostr-tools/pure";6+import { npubEncode } from "nostr-tools/nip19";7+import { KIND_REPO, KIND_REPO_STATE } from "../../src/kinds.ts";8+import { gitRepositoryPath } from "../../src/grasp-policy.ts";9+import { ev, pk, rpc } from "../helpers/relay.ts";10+import { WS } from "../helpers/ws.ts";11+12+describe("GRASP coordination", () => {13+ it.each([14+ { mode: "http", outcome: "success" }, { mode: "http", outcome: "failure" },15+ { mode: "alarm", outcome: "success" }, { mode: "alarm", outcome: "failure" },16+ ])("a paused $mode read holds admission until $outcome", async ({ mode, outcome }) => {17+ const host = `grasp-coordination-${mode}-${outcome}.bind.ws`;18+ const owner = generateSecretKey();19+ await rpc(host, owner, "claim");20+ await rpc(host, owner, "applypreset", "grasp");21+ const path = gitRepositoryPath(npubEncode(pk(owner)), "coordination");22+ const announcement = ev(owner, KIND_REPO, "", [23+ ["d", "coordination"], ["clone", `https://${host}${path}`],24+ ["relays", `wss://${host}`], ["maintainers", pk(owner)],25+ ]);26+ const connection = await WS.connect(host);27+ expect((await connection.ok(announcement)).ok).toBe(true);28+ connection.ws.close();29+ const state = ev(owner, KIND_REPO_STATE, "", [["d", "coordination"], ["refs/heads/main", "a".repeat(40)]]);30+ await runInDurableObject(env.RELAY.getByName(host.split(".")[0]), async (relay) => {31+ const bucket = relay.media;32+ let entered!: () => void;33+ let release!: () => void;34+ const atRead = new Promise<void>((resolve) => { entered = resolve; });35+ const gate = new Promise<void>((resolve) => { release = resolve; });36+ let gets = 0;37+ let otherOperations = 0;38+ let held = false;39+ const observed = new Proxy(bucket, { get(target, name) {40+ if (name === "get") return async (key: string, options?: R2GetOptions) => {41+ gets++;42+ if (!held && key.endsWith("/root.json")) {43+ held = true;44+ entered();45+ await gate;46+ if (outcome === "failure") throw new Error("injected R2 read failure");47+ }48+ return target.get(key, options);49+ };50+ const value = Reflect.get(target, name, target);51+ return typeof value === "function" ? (...args: unknown[]) => {52+ otherOperations++;53+ return Reflect.apply(value, target, args);54+ } : value;55+ } });56+ Object.defineProperty(relay, "media", { value: observed, configurable: true });57+ const request = () => new Request(`http://${host}${path}/info/refs?service=git-upload-pack`);58+ const active = mode === "http" ? relay.fetch(request()) : relay.alarm();59+ try {60+ await atRead;61+ expect(relay.graspBusy).toBe(mode === "http");62+ expect(relay.graspControls).toBe(mode === "alarm" ? 1 : 0);63+ const before = gets;64+ const otherBefore = otherOperations;65+ const refused = await relay.fetch(request());66+ expect(refused.status).toBe(429);67+ await refused.arrayBuffer();68+ expect(gets).toBe(before);69+ const admission = await relay.acceptAny(state, relay.virtualConn(host, pk(owner)));70+ expect(gets).toBe(before);71+ expect(otherOperations).toBe(otherBefore);72+ if (mode === "http") {73+ expect(admission.ok).toBe(false);74+ expect(admission.msg).toContain("Git transaction in progress");75+ const management = await relay.fetch(new Request(`http://${host}/`, { method: "POST", body: "{}" }));76+ expect(management.status).toBe(429);77+ await management.arrayBuffer();78+ await relay.alarm();79+ expect(gets).toBe(before);80+ expect(relay.graspBusy).toBe(true);81+ expect(relay.graspControls).toBe(0);82+ } else {83+ // Controls blocks Git admission, but event writes can still alter84+ // authority while alarm work awaits R2. It is not a general lock.85+ expect(admission.ok).toBe(true);86+ expect(relay.sql.exec("SELECT id FROM events WHERE id=?", state.id).toArray()).toHaveLength(1);87+ expect(relay.graspControls).toBe(1);88+ }89+ release();90+ const response = await active;91+ expect(relay.graspBusy).toBe(false);92+ expect(relay.graspControls).toBe(0);93+ const completedGets = gets;94+ if (response) {95+ expect(response.status).toBe(outcome === "failure" ? 503 : 200);96+ await response.arrayBuffer();97+ expect(gets).toBe(completedGets);98+ }99+ } finally {100+ release();101+ await active;102+ Reflect.deleteProperty(relay, "media");103+ }104+ });105+ });106+});