A group now has two lease lanes (migration 6600): `history` works the ranges the bounds hand out, `tail` the tail planner's, each with its own holder, window, snapshot and takeover redo, so a thread on the tail never wades through a group's history and a held group's tail isn't stuck behind it. The edge puts `--tail-threads` (EDGE_TAIL_THREADS, 1 of 2+ by default) of its threads on the tail lane; the group routes take `?lane=`. The lease order within a lane alternates the group with the most planned articles and the longest unheld one. The takeover redo puts back only the lane's ranges the window did after the snapshot, and a group whose last range is held by a holder gone quiet can be taken over. `coordinator:status` prints the catalog's room, the ranges by band, the last hour's rates, the held groups with their snapshots and the phase-2 backlogs. `tests/e2e` runs the real edge-node binary against the node API: the corpus-fill race, a failed stream and the restart that restores the snapshot, a restart without the snapshot file, a takeover (STREAM_CHUNK_WAIT_MINUTES is a setting now). CI builds the binary before the tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
207 lines
8.4 KiB
TypeScript
207 lines
8.4 KiB
TypeScript
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, { LANES } from '#services/group_leases'
|
||
import BatchStore, { StaleBatch } from '#services/batch_store'
|
||
import Spool, { type ChunkSpan } from '#services/spool'
|
||
import Corpus from '#services/corpus'
|
||
import coordinatorConfig from '#config/coordinator'
|
||
import { KEEPALIVE, encodeFrame, fromWire, type Lane, type WireBatch } from '#lib/edge/index'
|
||
|
||
/** While waiting, an empty frame this often: an HTTP client gives up on a quiet body (Node's after 5 minutes). */
|
||
const KEEPALIVE_MS = 20_000
|
||
|
||
/**
|
||
* The edge workers' API (REBUILD.md, parts 1–2): a lease, the range's
|
||
* chunks as one stream, results per batch, and the end of the range.
|
||
*/
|
||
export default class NodeController {
|
||
private node(ctx: HttpContext) {
|
||
return Number(ctx.auth.use('node').getUserOrFail().id)
|
||
}
|
||
|
||
async lease(ctx: HttpContext) {
|
||
const lease = await Leases.lease(this.node(ctx))
|
||
if (!lease) return ctx.response.noContent()
|
||
return lease
|
||
}
|
||
|
||
// 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.
|
||
// `?lane=tail` is the group's tail cursor (its own holder, window and snapshot); history by default.
|
||
|
||
/** The lane the request names (`?lane=`), or null after a 400 for one that isn't a lane. */
|
||
private lane(ctx: HttpContext): Lane | null {
|
||
const lane = ctx.request.qs().lane ?? 'history'
|
||
if (LANES.includes(lane)) return lane
|
||
ctx.response.badRequest({ error: `lane: ${LANES.join(' or ')}, not "${lane}"` })
|
||
return null
|
||
}
|
||
|
||
async leaseGroup(ctx: HttpContext) {
|
||
const lane = this.lane(ctx)
|
||
if (!lane) return
|
||
const lease = await GroupLeases.lease(this.node(ctx), lane)
|
||
if (!lease) return ctx.response.noContent()
|
||
return lease
|
||
}
|
||
|
||
async nextRange(ctx: HttpContext) {
|
||
const lane = this.lane(ctx)
|
||
if (!lane) return
|
||
try {
|
||
const next = await GroupLeases.next(Number(ctx.params.id), this.node(ctx), lane)
|
||
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) {
|
||
const lane = this.lane(ctx)
|
||
if (!lane) return
|
||
return this.lifecycle(ctx, (groupId, nodeId) => GroupLeases.heartbeat(groupId, nodeId, lane))
|
||
}
|
||
|
||
async releaseGroup(ctx: HttpContext) {
|
||
const lane = this.lane(ctx)
|
||
if (!lane) return
|
||
return this.lifecycle(ctx, (groupId, nodeId) => GroupLeases.release(groupId, nodeId, lane))
|
||
}
|
||
|
||
/**
|
||
* 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 lane = this.lane(ctx)
|
||
if (!lane) return
|
||
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, lane))
|
||
}
|
||
|
||
async getSnapshot(ctx: HttpContext) {
|
||
const lane = this.lane(ctx)
|
||
if (!lane) return
|
||
try {
|
||
const snapshot = await GroupLeases.getSnapshot(Number(ctx.params.id), this.node(ctx), lane)
|
||
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
|
||
// The chunk from the corpus or the spool, whichever has it. Both are polled while waiting: the corpus fill
|
||
// claims a chunk ahead of the leased run (the prefetcher's claims are per range and chunk), stores it in the
|
||
// corpus only, and the run then leaves it there — a stream that looked at the corpus once, before that
|
||
// store, waited the whole hour on the spool (three groups on 2026-10-27, LOG.md (118)).
|
||
const stored = async (span: ChunkSpan) =>
|
||
corpusRange && (await Corpus.has(corpusRange, span)) ? Corpus.read(corpusRange, span) : null
|
||
async function* frames() {
|
||
for (const span of spans) {
|
||
const waitUntil = Date.now() + coordinatorConfig.streamChunkWaitMs
|
||
let quietSince = Date.now()
|
||
let data = await stored(span)
|
||
while (!data) {
|
||
if (await Spool.has(rangeId, span)) {
|
||
data = await Spool.read(rangeId, span)
|
||
break
|
||
}
|
||
if (Date.now() > waitUntil) throw new Error(`Chunk ${span.from}–${span.to} of range ${rangeId} wasn't spooled in time`)
|
||
if (Date.now() - quietSince >= KEEPALIVE_MS) {
|
||
quietSince = Date.now()
|
||
yield encodeFrame(KEEPALIVE)
|
||
await Leases.heartbeat(rangeId, nodeId)
|
||
}
|
||
await sleep(500)
|
||
data = await stored(span)
|
||
}
|
||
yield encodeFrame({ ...span, data })
|
||
// Streaming keeps the lease alive.
|
||
await Leases.heartbeat(rangeId, nodeId)
|
||
}
|
||
}
|
||
ctx.response.header('Content-Type', 'application/octet-stream')
|
||
ctx.response.stream(Readable.from(frames()))
|
||
}
|
||
|
||
async batch(ctx: HttpContext) {
|
||
const nodeId = this.node(ctx)
|
||
const rangeId = Number(ctx.params.id)
|
||
try {
|
||
return await BatchStore.store(rangeId, nodeId, fromWire(ctx.request.body() as WireBatch))
|
||
} catch (error) {
|
||
if (error instanceof StaleBatch) return ctx.response.conflict({ error: error.message })
|
||
throw error
|
||
}
|
||
}
|
||
|
||
async heartbeat(ctx: HttpContext) {
|
||
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.heartbeat(rangeId, nodeId))
|
||
}
|
||
|
||
async finish(ctx: HttpContext) {
|
||
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), GroupLeases.laneOf(Number(range.priority)))
|
||
})
|
||
}
|
||
|
||
/** POST /ranges/:id/corrupt { from }: a chunk that doesn't inflate; the range goes back. */
|
||
async corrupt(ctx: HttpContext) {
|
||
const from = Number(ctx.request.input('from'))
|
||
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.corrupt(rangeId, nodeId, from))
|
||
}
|
||
|
||
async release(ctx: HttpContext) {
|
||
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.release(rangeId, nodeId))
|
||
}
|
||
|
||
private async lifecycle(ctx: HttpContext, action: (rangeId: number, nodeId: number) => Promise<void>) {
|
||
try {
|
||
await action(Number(ctx.params.id), this.node(ctx))
|
||
return { ok: true }
|
||
} catch (error) {
|
||
if (error instanceof LeaseLost) return ctx.response.conflict({ error: error.message })
|
||
throw error
|
||
}
|
||
}
|
||
|
||
private async leased(ctx: HttpContext, rangeId: number, nodeId: number) {
|
||
try {
|
||
return await Leases.leased(rangeId, nodeId)
|
||
} catch (error) {
|
||
if (error instanceof LeaseLost) {
|
||
ctx.response.conflict({ error: error.message })
|
||
return null
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
}
|