The window worker: group leases, the stream serving the corpus, the snapshot through the API (design 1 and 5)
Test and deploy / test (push) Successful in 55s
Test and deploy / edge (push) Successful in 2m9s
Test and deploy / deploy (push) Successful in 1m16s

A worker thread leases a group and takes its ranges in article order through
one carried window; the coordinator keeps one cursor a group (group_leases,
migration 6500) and hands out the next range, the rules and nothing about
where the chunks are: the range stream serves a stored range's chunks from the
corpus file and a live one's from the spool. The window's snapshot (bincode:
the frontier, the open files, releases and albums, the upload numbers, the
batch-counter table) goes to the coordinator every EDGE_SNAPSHOT_MINUTES and
when the group is given back, and the next holder restores it; a takeover
after LEASE_TTL_SECONDS plans the ranges from the snapshot's article again.
The prefetcher runs ahead of held groups' cursors and skips chunks the corpus
has. LEASE_MIN_PRIORITY..LEASE_MAX_PRIORITY bound what is leased. edge-node
holds groups by default; --ranges keeps the old per-range path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Fable 5.1 committed 2026-10-26 18:22:18 +01:00
1 parent 3c4fe324a1
commit 1f6e3316ec
19 files changed
+1358 -31

No files matched your search

+76 -2
View File
@@ -1,9 +1,12 @@
import type { HttpContext } from '@adonisjs/core/http'
import { Readable } from 'node:stream'
import { setTimeout as sleep } from 'node:timers/promises'
import db from '@adonisjs/lucid/services/db'
import Leases, { LeaseLost } from '#services/leases'
import GroupLeases from '#services/group_leases'
import BatchStore, { StaleBatch } from '#services/batch_store'
import Spool from '#services/spool'
import Corpus from '#services/corpus'
import { KEEPALIVE, encodeFrame, fromWire, type WireBatch } from '#lib/edge/index'
/**
@@ -30,15 +33,82 @@ export default class NodeController {
return lease
}
/** The range's chunks from its resume point, waiting for those still being fetched. */
// Group leases (REBUILD.md, "Voyager 3: window indexing", 1 and 5): a worker thread holds a group and takes its
// ranges in article order; the window's snapshot goes through here too, so a worker needs no disk of its own.
async leaseGroup(ctx: HttpContext) {
const lease = await GroupLeases.lease(this.node(ctx))
if (!lease) return ctx.response.noContent()
return lease
}
async nextRange(ctx: HttpContext) {
try {
const next = await GroupLeases.next(Number(ctx.params.id), this.node(ctx))
if (!next) return ctx.response.noContent()
return next
} catch (error) {
if (error instanceof LeaseLost) return ctx.response.conflict({ error: error.message })
throw error
}
}
async groupHeartbeat(ctx: HttpContext) {
return this.lifecycle(ctx, (groupId, nodeId) => GroupLeases.heartbeat(groupId, nodeId))
}
async releaseGroup(ctx: HttpContext) {
return this.lifecycle(ctx, (groupId, nodeId) => GroupLeases.release(groupId, nodeId))
}
/**
* PUT /groups/:id/snapshot?next=N: the window's serialized state standing at article N, streamed to disk as sent
* (gzip; the body parser leaves an octet-stream alone, so the request is still readable here).
*/
async putSnapshot(ctx: HttpContext) {
const next = Number(ctx.request.qs().next)
if (!Number.isFinite(next) || next <= 0) return ctx.response.badRequest({ error: 'next (the snapshot\'s article) is needed' })
const raw = ctx.request.raw()
const body = raw !== null ? Readable.from([Buffer.from(raw, 'binary')]) : ctx.request.request
return this.lifecycle(ctx, (groupId, nodeId) => GroupLeases.putSnapshot(groupId, nodeId, next, body))
}
async getSnapshot(ctx: HttpContext) {
try {
const snapshot = await GroupLeases.getSnapshot(Number(ctx.params.id), this.node(ctx))
if (!snapshot) return ctx.response.notFound({ error: 'no snapshot' })
ctx.response.header('Content-Type', 'application/octet-stream')
ctx.response.header('Content-Length', String(snapshot.size))
ctx.response.header('X-Snapshot-Next', String(snapshot.next))
return ctx.response.stream(snapshot.stream)
} catch (error) {
if (error instanceof LeaseLost) return ctx.response.conflict({ error: error.message })
throw error
}
}
/**
* The range's chunks from its resume point: a stored range's from the corpus (a worker needs no mount; REBUILD.md
* design 1), else from the spool, waiting for those still being fetched.
*/
async stream(ctx: HttpContext) {
const nodeId = this.node(ctx)
const rangeId = Number(ctx.params.id)
const range = await this.leased(ctx, rangeId, nodeId)
if (!range) return
const spans = Spool.chunksOf({ start: Number(range.start_id), end: Number(range.end_id) }, Number(range.next_id))
const group = range.capture ? await db.from('groups').where('id', range.group_id).select('name').first() : null
const corpusRange = group ? { group: group.name as string, start: Number(range.start_id), end: Number(range.end_id) } : null
async function* frames() {
for (const span of spans) {
if (corpusRange && (await Corpus.has(corpusRange, span))) {
const data = await Corpus.read(corpusRange, span)
if (data) {
yield encodeFrame({ ...span, data })
await Leases.heartbeat(rangeId, nodeId)
continue
}
}
const waitUntil = Date.now() + CHUNK_WAIT_MS
let quietSince = Date.now()
while (!(await Spool.has(rangeId, span))) {
@@ -75,7 +145,11 @@ export default class NodeController {
}
async finish(ctx: HttpContext) {
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.finish(rangeId, nodeId))
return this.lifecycle(ctx, async (rangeId, nodeId) => {
const range = await Leases.leased(rangeId, nodeId)
await Leases.finish(rangeId, nodeId)
await GroupLeases.rangeDone(Number(range.group_id), Number(range.headers))
})
}
/** POST /ranges/:id/corrupt { from }: a chunk that doesn't inflate; the range goes back. */