- commit
- a383a0ccd4794b91d540c8ed5d5646e046fe6bf4
- parent
- fe21b98becb1c55df48758e819b069cddf510b2f
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 5 | ++ |
| M | src/ | 17 | +++-- |
| M | test/ | 98 | +++++++++++++++++++++++++++++- |
@@ -79,6 +79,11 @@ new tip's objects arrive.
7979 8080 ## Pull requests and purgatory8181 82+Promotion checks for relevant pending events before loading Git packs. An83+empty queue or another repository's pending work does not trigger that read.84+A retried Git write still completes any pending promotion left by an earlier85+successful publication; expiry and authority are checked again after reads.86+8287 A pull request or update can name a Git ref as8388 `refs/nostr/<event-id>`. The event ID is checked as a 64-character lowercase8489 hex value. The relay can hold the event and the Git data while it waits for@@ -67,17 +67,22 @@ function prTip(relay: Relay, repo: RepositoryAnnouncement, id: string): string |
6767 return /^[0-9a-f]{40}$/.test(tag(e, "c")) ? tag(e, "c") : false;6868 }6969 70-// promote makes only verified, present Git tips visible to ordinary queries.70+// promote checks pending work before loading Git packs, and rechecks authority71+// after the read. Replays still finish promotion left pending by an earlier write.7172 async function promote(relay: Relay, repo: RepositoryAnnouncement, wal: WalRepository) {73+ const requiredFor = (e: Event) => {74+ if (relay.settings.isEventHidden(e.id) || relay.settings.isEventBanned(e.id) || relay.settings.isBanned(e.pubkey)) return null;75+ const relevant = e.kind === KIND_REPO ? repository(relay, repo.owner, repo.identifier)?.id === e.id : e.kind === KIND_REPO_STATE ? tag(e, "d") === repo.identifier && repositoryState(relay, repo)?.id === e.id : eventRepository(relay, e)?.id === repo.id;76+ return relevant ? requiredObjects(relay, e) : null;77+ };78+ const pending = () => relay.sql.exec<{ raw: string }>(`SELECT raw FROM events JOIN grasp_pending ON grasp_pending.id=events.id WHERE grasp_pending.until>? AND (events.expires=0 OR events.expires>?) LIMIT 1024`, now(), now()).toArray();79+ if (!pending().some(({ raw }) => requiredFor(JSON.parse(raw) as Event) !== null)) return;7280 const snapshot = await wal.load();7381 if (!snapshot.sequence) return;7482 const objects = await readObjects(snapshot.packs);75- const pending = relay.sql.exec<{ raw: string }>(`SELECT raw FROM events JOIN grasp_pending ON grasp_pending.id=events.id WHERE grasp_pending.until>? LIMIT 1024`, now()).toArray();76- for (const { raw } of pending) {83+ for (const { raw } of pending()) {7784 const e = JSON.parse(raw) as Event;78- const relevant = e.kind === KIND_REPO ? e.id === repo.id : e.kind === KIND_REPO_STATE ? tag(e, "d") === repo.identifier && repositoryState(relay, repo)?.id === e.id : eventRepository(relay, e)?.id === repo.id;79- if (!relevant) continue;80- const required = requiredObjects(relay, e);85+ const required = requiredFor(e);8186 if (required && required.every((oid) => objects.has(oid))) {8287 relay.sql.exec(`DELETE FROM grasp_pending WHERE id=?`, e.id);8388 relay.broadcast(e);@@ -264,10 +264,10 @@ describe("GRASP", () => {
264264 expect((await c.ok(ev(owner, KIND_REPO_STATE, "", [["d", "correction"], ["HEAD", "ref: refs/heads/main"], ["refs/heads/main", first.commitID]]))).ok).toBe(true);265265 c.ws.close();266266 const path = gitRepositoryPath(npubEncode(pk(owner)), "correction");267- expect(await (await receiveSettled(path, host, null, first.commitID, "refs/heads/main", first.pack)).text()).toContain("ok refs/heads/main");267+ expect(await (await receiveSettled(path, host, null, first.commitID, "refs/heads/main", first.pack, "benchmark-initial")).text()).toContain("ok refs/heads/main");268268 const pr = ev(owner, KIND_GIT_PR, "", [["a", `30617:${pk(owner)}:correction`], ["c", next.commitID]]);269269 const ref = `refs/nostr/${pr.id}`;270- expect(await (await receiveSettled(path, host, null, first.commitID, ref)).text()).toContain(`ok ${ref}`);270+ expect(await (await receiveSettled(path, host, null, first.commitID, ref, undefined, "benchmark-unknown")).text()).toContain(`ok ${ref}`);271271 const publisher = await WS.connect(host);272272 expect((await publisher.ok(pr)).ok).toBe(true);273273 publisher.ws.close();@@ -315,6 +315,11 @@ describe("GRASP", () => {
315315 return snapshot.sequence;316316 } finally { relay.graspBusy = false; }317317 });318+ // Pending work for another repository does not reload this one's packs.319+ const other = await WS.connect(host);320+ expect((await other.ok(repository(owner, host, "other"))).ok).toBe(true);321+ expect((await other.ok(ev(owner, KIND_REPO_STATE, "", [["d", "other"], ["HEAD", "ref: refs/heads/main"], ["refs/heads/main", first.commitID]]))).ok).toBe(true);322+ other.ws.close();318323 const retried = await runInDurableObject(stub, async (relay) => {319324 const bucket = relay.media;320325 const counters = { gets: 0, getBytes: 0, puts: 0, heads: 0 };@@ -344,6 +349,7 @@ describe("GRASP", () => {
344349 });345350 expect(retried.body).toContain(`ok ${ref}`);346351 expect(retried.counters.puts).toBe(0);352+ expect(retried.counters.gets).toBe(23);347353 console.info("checkpoint old PR correction HTTP retry", JSON.stringify({ sequence, ...retried.counters }));348354 await runInDurableObject(stub, async (relay) => {349355 const wal = await gitRepository(relay, storedRepository(relay, pk(owner), "correction")!);@@ -355,6 +361,94 @@ describe("GRASP", () => {
355361 expect(body).toContain("symref=HEAD:refs/heads/main");356362 });357363 364+ it.each(["expired", "moderated"])("an identical Git retry completes interrupted promotion and rechecks %s candidates", async (mode) => {365+ const host = `grasp-promotion-${mode}.bind.ws`;366+ const owner = generateSecretKey();367+ const pack = await tinyRepository();368+ await rpc(host, owner, "claim");369+ await rpc(host, owner, "applypreset", "grasp");370+ const announcement = repository(owner, host, "promotion");371+ const state = ev(owner, KIND_REPO_STATE, "", [["d", "promotion"], ["HEAD", "ref: refs/heads/main"], ["refs/heads/main", pack.commitID]]);372+ const c = await WS.connect(host);373+ expect((await c.ok(announcement)).ok).toBe(true);374+ expect((await c.ok(state)).ok).toBe(true);375+ const pr = ev(owner, KIND_GIT_PR, "", [["a", `30617:${pk(owner)}:promotion`], ["c", pack.commitID]]);376+ expect((await c.ok(pr)).ok).toBe(true);377+ c.ws.close();378+ const path = gitRepositoryPath(npubEncode(pk(owner)), "promotion");379+ const requestId = "promotion-recovery";380+ const stub = env.RELAY.getByName(`grasp-promotion-${mode}`);381+ await runInDurableObject(stub, async (relay) => {382+ const bucket = relay.media;383+ let published = false;384+ let failed = false;385+ const observed = new Proxy(bucket, { get(target, name) {386+ if (name === "get") return (key: string, options?: R2GetOptions) => {387+ if (published && key.endsWith("/root.json")) { failed = true; throw new Error("publication succeeded before promotion read failed"); }388+ return target.get(key, options);389+ };390+ if (name === "put") return async (...args: unknown[]) => {391+ const result = await Reflect.apply(target.put, target, args);392+ if (String(args[0]).endsWith("/root.json") && result) published = true;393+ return result;394+ };395+ const value = Reflect.get(target, name, target);396+ return typeof value === "function" ? value.bind(target) : value;397+ } });398+ Object.defineProperty(relay, "media", { value: observed, configurable: true });399+ try {400+ const response = await relay.fetch(new Request(`http://${host}${path}/git-receive-pack`, {401+ method: "POST",402+ headers: { "content-type": "application/x-git-receive-pack-request", "X-Git-Request-Id": requestId },403+ body: concat(packet(`${"0".repeat(40)} ${pack.commitID} refs/heads/main\0report-status\n`), flush(), pack.pack),404+ }));405+ expect(await response.text()).not.toContain("ok refs/heads/main");406+ expect(published).toBe(true);407+ expect(failed).toBe(true);408+ } finally { Reflect.deleteProperty(relay, "media"); }409+ const wal = await gitRepository(relay, storedRepository(relay, pk(owner), "promotion")!);410+ expect((await wal.load()).sequence).toBe(1);411+ expect(relay.sql.exec("SELECT id FROM grasp_pending WHERE id IN (?,?)", announcement.id, state.id).toArray()).toHaveLength(2);412+ });413+ const replay = await runInDurableObject(stub, async (relay) => {414+ const bucket = relay.media;415+ let roots = 0;416+ const observed = new Proxy(bucket, { get(target, name) {417+ if (name === "get") return (key: string, options?: R2GetOptions) => {418+ // The replay loads the WAL, then promotion loads it again. Change419+ // eligibility only after promotion's initial candidate check.420+ if (key.endsWith("/root.json") && ++roots === 2) {421+ if (mode === "expired") relay.sql.exec("UPDATE events SET expires=1 WHERE id=?", pr.id);422+ else relay.settings.setEvent(pr.id, "hide");423+ }424+ return target.get(key, options);425+ };426+ const value = Reflect.get(target, name, target);427+ return typeof value === "function" ? value.bind(target) : value;428+ } });429+ Object.defineProperty(relay, "media", { value: observed, configurable: true });430+ try {431+ const response = await relay.fetch(new Request(`http://${host}${path}/git-receive-pack`, {432+ method: "POST",433+ headers: { "content-type": "application/x-git-receive-pack-request", "X-Git-Request-Id": requestId },434+ body: concat(packet(`${"0".repeat(40)} ${pack.commitID} refs/heads/main\0report-status\n`), flush(), pack.pack),435+ }));436+ expect(roots).toBeGreaterThanOrEqual(2);437+ return await response.text();438+ } finally { Reflect.deleteProperty(relay, "media"); }439+ });440+ expect(replay).toContain("ok refs/heads/main");441+ await runInDurableObject(stub, async (relay) => {442+ const wal = await gitRepository(relay, storedRepository(relay, pk(owner), "promotion")!);443+ expect((await wal.load()).sequence).toBe(1);444+ expect(relay.sql.exec("SELECT id FROM grasp_pending WHERE id IN (?,?)", announcement.id, state.id).toArray()).toEqual([]);445+ expect(relay.sql.exec("SELECT id FROM grasp_pending WHERE id=?", pr.id).toArray()).toHaveLength(1);446+ });447+ const reader = await WS.connect(host);448+ expect((await reader.query({ ids: [announcement.id, state.id] })).events).toHaveLength(2);449+ reader.ws.close();450+ });451+358452 it("removes the public Git contract when reads become restricted", async () => {359453 const host = "grasp-restricted.bind.ws";360454 const owner = generateSecretKey();