Rebuild: schema for groups, rule sets, ranges, release catalog, chunk store, fragments, probe queue, rule bookkeeping and nodes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-09 00:19:00 +02:00
1 parent a18ead666b
commit 2209921846
11 files changed
+1228

No files matched your search

@@ -0,0 +1,27 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The indexed groups (REBUILD.md, group list). `priority` decides a
* crosspost's owner: the indexed group in its Xref with the lowest number.
* New groups get numbers after the existing ones, so they never take
* articles from groups already done.
*/
export default class extends BaseSchema {
async up() {
this.schema.createTable('groups', (table) => {
table.increments('id')
table.string('name', 255).notNullable().unique()
table.integer('priority').notNullable().unique()
table.boolean('active').notNullable().defaultTo(true)
// From GROUP when planned: the server's oldest and newest article.
table.bigInteger('low').nullable()
table.bigInteger('high').nullable()
table.timestamp('planned_at', { useTz: true }).nullable()
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())
})
}
async down() {
this.schema.dropTable('groups')
}
}
@@ -0,0 +1,21 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Versions of what workers drop (REBUILD.md, rules and recovery): dropped
* signatures and tools, the per-article limit, the sample share. Every range
* records the version it ran with.
*/
export default class extends BaseSchema {
async up() {
this.schema.createTable('rule_sets', (table) => {
table.integer('version').primary()
table.jsonb('rules').notNullable()
table.text('note').nullable()
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())
})
}
async down() {
this.schema.dropTable('rule_sets')
}
}
@@ -0,0 +1,38 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* A group's history in leasable pieces (REBUILD.md, coordinator):
* planned → (spooled by the prefetcher) → leased → done. `next_id` is the
* resume point: everything before it has been stored.
*/
export default class extends BaseSchema {
async up() {
this.schema.createTable('ranges', (table) => {
table.bigIncrements('id')
table.integer('group_id').notNullable().references('id').inTable('groups').onDelete('CASCADE')
table.bigInteger('start_id').notNullable()
table.bigInteger('end_id').notNullable()
table.bigInteger('next_id').notNullable()
table.string('status', 8).notNullable().defaultTo('planned')
// Lower first; the coordinator plans the oldest era first.
table.integer('priority').notNullable().defaultTo(0)
table.integer('rule_version').nullable()
table.integer('node_id').nullable()
table.integer('attempts').notNullable().defaultTo(0)
// Chunks of this range in the spool (prefetch); all of them when spooled_at is set.
table.integer('spooled_chunks').notNullable().defaultTo(0)
table.timestamp('spooled_at', { useTz: true }).nullable()
table.timestamp('leased_at', { useTz: true }).nullable()
table.timestamp('heartbeat_at', { useTz: true }).nullable()
table.timestamp('done_at', { useTz: true }).nullable()
table.bigInteger('headers').notNullable().defaultTo(0)
table.text('error').nullable()
table.unique(['group_id', 'start_id'])
})
this.schema.raw(`CREATE INDEX ranges_todo_idx ON ranges (priority, id) WHERE status IN ('planned', 'leased')`)
}
async down() {
this.schema.dropTable('ranges')
}
}
@@ -0,0 +1,41 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The release catalog (REBUILD.md, part 4): one row per release, the hot
* part on fast disk. Updated with aggregates once per batch; the files
* themselves are in release_chunks.
*/
export default class extends BaseSchema {
async up() {
this.schema.createTable('releases', (table) => {
table.bigIncrements('id')
table.string('release_key', 40).notNullable().unique()
table.text('name').notNullable()
table.string('source', 16).notNullable()
table.text('poster').nullable()
table.jsonb('groups').notNullable()
table.timestamp('posted_at', { useTz: true }).nullable()
// Files announced by the [i/N] counter, when present.
table.integer('file_total').nullable()
table.integer('files').notNullable().defaultTo(0)
table.bigInteger('segments').notNullable().defaultTo(0)
table.bigInteger('bytes').notNullable().defaultTo(0)
table.integer('chunks').notNullable().defaultTo(0)
table.bigInteger('first_article').nullable()
table.bigInteger('last_article').nullable()
table.boolean('readable').notNullable().defaultTo(false)
table.boolean('has_par2').notNullable().defaultTo(false)
table.boolean('complete').nullable()
table.timestamp('settled_at', { useTz: true }).nullable()
table.timestamp('first_seen_at', { useTz: true }).notNullable().defaultTo(this.now())
table.timestamp('last_seen_at', { useTz: true }).notNullable().defaultTo(this.now())
})
this.schema.raw(`CREATE EXTENSION IF NOT EXISTS pg_trgm`)
this.schema.raw(`CREATE INDEX releases_name_trgm ON releases USING gin (name gin_trgm_ops)`)
this.schema.raw(`CREATE INDEX releases_posted_at_idx ON releases (posted_at DESC)`)
}
async down() {
this.schema.dropTable('releases')
}
}
@@ -0,0 +1,34 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
import env from '#start/env'
/**
* The chunk store (REBUILD.md, part 3; decision a): one write-once row per
* release per batch, its files encoded by lib/chunks. Partitioned by month
* of writing, in CHUNK_TABLESPACE (the HDD) when set; ChunkPartitions keeps
* the next months' partitions ready. A re-run of a range replaces its chunks
* (by range_id).
*/
export default class extends BaseSchema {
async up() {
const tablespace = env.get('CHUNK_TABLESPACE')
const where = tablespace ? ` TABLESPACE ${tablespace}` : ''
this.schema.raw(`
CREATE TABLE release_chunks (
id bigserial,
release_id bigint NOT NULL,
range_id bigint NOT NULL,
batch_seq integer NOT NULL,
files integer NOT NULL,
segments integer NOT NULL,
payload bytea NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at)${where}`)
this.schema.raw(`CREATE INDEX release_chunks_release_idx ON release_chunks (release_id)`)
this.schema.raw(`CREATE INDEX release_chunks_range_idx ON release_chunks (range_id)`)
}
async down() {
this.schema.raw(`DROP TABLE IF EXISTS release_chunks CASCADE`)
}
}
@@ -0,0 +1,24 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Segments of files cut off by a range's edge (REBUILD.md, fragments): kept
* by range, merged when the neighbouring range is done, then written as a
* chunk.
*/
export default class extends BaseSchema {
async up() {
this.schema.createTable('fragments', (table) => {
table.bigIncrements('id')
table.bigInteger('range_id').notNullable()
table.string('file_key', 40).notNullable()
table.bigInteger('article_number').notNullable()
table.jsonb('segment').notNullable()
table.index(['file_key'])
table.index(['range_id'])
})
}
async down() {
this.schema.dropTable('fragments')
}
}
@@ -0,0 +1,25 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* The probe queue (REBUILD.md, part 6): per release the few articles phase 2
* should read. Phase 1 only fills it.
*/
export default class extends BaseSchema {
async up() {
this.schema.createTable('probe_candidates', (table) => {
table.bigIncrements('id')
table.bigInteger('release_id').notNullable()
table.string('kind', 8).notNullable()
table.text('message_id').notNullable()
table.text('file').notNullable()
table.string('status', 8).notNullable().defaultTo('waiting')
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())
table.unique(['release_id', 'kind', 'message_id'])
})
this.schema.raw(`CREATE INDEX probe_candidates_waiting_idx ON probe_candidates (id) WHERE status = 'waiting'`)
}
async down() {
this.schema.dropTable('probe_candidates')
}
}
@@ -0,0 +1,35 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* What the rules dropped, per range (REBUILD.md, rules and recovery), and
* the sample of it kept in full.
*/
export default class extends BaseSchema {
async up() {
this.schema.createTable('rule_counts', (table) => {
table.bigInteger('range_id').notNullable()
table.string('rule', 12).notNullable()
table.text('key').notNullable()
table.bigInteger('articles').notNullable().defaultTo(0)
table.bigInteger('files').notNullable().defaultTo(0)
table.primary(['range_id', 'rule', 'key'])
})
this.schema.createTable('rule_samples', (table) => {
table.bigIncrements('id')
table.bigInteger('range_id').notNullable()
table.string('rule', 12).notNullable()
table.text('rule_key').notNullable()
table.string('release_key', 40).notNullable()
table.text('name').notNullable()
table.integer('files').notNullable()
table.integer('segments').notNullable()
table.binary('payload').notNullable()
table.index(['rule', 'rule_key'])
})
}
async down() {
this.schema.dropTable('rule_samples')
this.schema.dropTable('rule_counts')
}
}
@@ -0,0 +1,30 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/** Edge workers and their API tokens (the `node` guard). */
export default class extends BaseSchema {
async up() {
this.schema.createTable('nodes', (table) => {
table.increments('id')
table.string('name', 64).notNullable().unique()
table.timestamp('last_seen_at', { useTz: true }).nullable()
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())
})
this.schema.createTable('node_access_tokens', (table) => {
table.increments('id')
table.integer('tokenable_id').notNullable().unsigned().references('id').inTable('nodes').onDelete('CASCADE')
table.string('type').notNullable()
table.string('name').nullable()
table.string('hash').notNullable()
table.text('abilities').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
table.timestamp('last_used_at').nullable()
table.timestamp('expires_at').nullable()
})
}
async down() {
this.schema.dropTable('node_access_tokens')
this.schema.dropTable('nodes')
}
}