diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml
index 627f112..da0265b 100644
--- a/.gitea/workflows/deploy.yaml
+++ b/.gitea/workflows/deploy.yaml
@@ -83,8 +83,8 @@ jobs:
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: cargo test
run: cargo test --release --manifest-path edge/Cargo.toml
- - name: Rust and TypeScript identical on the fixtures
- run: edge/check-fixtures.sh
+ - name: The Rust indexer over the fixtures
+ run: edge/bench-fixtures.sh
deploy:
needs: [test, edge]
diff --git a/REBUILD.md b/REBUILD.md
index c2f869d..dd8d9f0 100644
--- a/REBUILD.md
+++ b/REBUILD.md
@@ -401,21 +401,26 @@ releases, a third of them one poster's image dump (27.7 M rows, LOG.md
2. **The window.** The worker reads a block in article order and keeps its
open files and releases in memory (counters, names, sizes; the segment
lists spooled to a file per block on the worker's disk). The *frontier*
- is the median Date of the last 4,096 articles with a plausible date
- (inside the block's span ± 7 days); Date headers alone aren't trusted
- (clock skew, bogus years). A release is **finished** when the frontier
- is 24 h past its last part (`WINDOW_HOURS`), or at the block's end:
- written once, with its completeness decided then (every announced file
- there; par2 decides later for those without a counter), never updated by
- the header pass again. Incomplete releases are kept (user, 2026-10-24).
- A fill posted after the window is a release of its own (the judge's
- `repeated` rule already knows reposts). Releases still open at a block's
- end are written flagged `edge`; the block merger (replaces the fragment
- merger) joins the edge releases of adjacent blocks of a group by
- identity key once both are done, and clears the flag.
- The settler goes: `settled_at` is the finish. The **tail** keeps its
- latency: a tail lease emits a release provisionally as today and the
- window finalizes it once (two writes, not one per range).
+ is the median Date of the last 1,024 articles with a plausible date
+ (within 7 days of the frontier; 1,024 such dates in a row that agree
+ among themselves move it, the group having paused); Date headers alone
+ aren't trusted (clock skew, bogus years). 1,024, not more: the median
+ trails the newest article by half the ring, a day in a group of 500
+ articles a day. A release is **finished** when the frontier is 24 h past
+ its last part (`WINDOW_MS`): written once, with its completeness decided
+ then (every announced file there and no file short of parts; par2
+ decides later for those without a counter), never updated by the header
+ pass again. Incomplete releases are kept (user, 2026-10-24). A fill
+ posted after the window is a release of its own (the judge's `repeated`
+ rule already knows reposts). At the block's end the releases the window
+ hasn't closed on are written **provisionally** (`settled_at` null) and
+ flagged `edge`, as are the finished releases posted within 24 h of the
+ block's first frontier; the block merger (replaces the fragment merger)
+ joins the edge releases of adjacent blocks of a group by identity key
+ once both are done, finishes them and clears the flag. The settler goes:
+ `settled_at` is the finish. The **tail** needs no path of its own: a
+ tail lease's open releases at its end are such provisional edges, the
+ next lease's continuation joins them.
3. **Albums.** Files of one poster in one owner group whose subjects differ
only by a number sequence, or share a directory-like prefix, posted
@@ -571,11 +576,38 @@ service compare the source code. Tests insert fixtures through
`tests/functional/helpers/releases.ts` (`insertRelease`: text keys, poster
and groups by name, source by name). The unit and functional suites pass.
-Next: the window and albums in the Rust edge (`finished`, `edge`, the
-provisional tail and its finish), block leases and the planner (then the
-store keys by the leased block and the lease columns leave `ranges`), the
-block merger, the prober writing `release_texts` kind 2, the corpus replay
-harness.
+### The window in the Rust edge (built 2026-10-25)
+
+`edge/src/indexer.rs`: the scanner and the file aggregator as before; then
+the window instead of the range path. `Frontier` keeps a ring of the last
+`FRONTIER_ARTICLES` (1,024) plausible dates (within `PLAUSIBLE_MS`, 7
+days, of the frontier; 1,024 implausible dates in a row that agree move
+it), recomputed once per chunk; established after 64 dated articles
+(`settle` at the block's end for smaller blocks); its first value is the
+block's start in posting time. Per chunk: completed files, then the files
+whose last part is before frontier − `WINDOW_MS` (24 h; undated ones idle
+for `idle` chunks), go into the open release of their key whose posting
+span is within `UPLOAD_GAP_MS` (3 h) of theirs, else start one (the
+upload number per key, distinct within a batch); releases whose last part
+is before frontier − `WINDOW_MS` are judged and written `finished`, as an
+`edge` when posted within `WINDOW_MS` of the block's start. At the end
+(`finish`) everything left is written: finished when the window closed on
+it, else `finished: false, edge: true`. A file short of parts stays in
+its release and sets `partial` (the store: `complete = false`). A lone
+article of a multi-part random file is still counted `per-article`, not
+written. Gone: fragments and the cut-off, `uploads_of` (online now),
+`check.ts` and the fixture comparison (the parity with the TypeScript
+indexer; CI runs `edge/bench-fixtures.sh` instead), the `incomplete` count
+(incomplete releases are kept). The wire carries `finished`, `edge` and
+`partial`; the store reads them (`partial` into `complete`). Memory: the
+window holds a day of a group's segments in memory (the busiest group's
+~50 M); the spool file per block of the design is not built yet.
+
+Next: albums in the edge (design 3), block leases and the planner (then
+the store keys by the leased block and the lease columns leave `ranges`;
+until then every range end leaves provisional edges), the block merger
+(joins and finishes the edges), the prober writing `release_texts` kind 2,
+the corpus replay harness.
## Decisions
diff --git a/app/services/batch_store.ts b/app/services/batch_store.ts
index a916cb5..c7f9b6d 100644
--- a/app/services/batch_store.ts
+++ b/app/services/batch_store.ts
@@ -198,6 +198,7 @@ class BatchStore {
has_par2: r.hasPar2,
finished: r.finished !== false,
edge: r.edge === true,
+ partial: r.partial === true,
sidecar_only: sidecarOnly(r),
}))
// Inserted, or a provisional release of the key finished (the chunk's files added up); a finished release
@@ -207,11 +208,12 @@ class BatchStore {
first_article, last_article, readable, has_par2, complete, edge, sidecar_only, settled_at)
SELECT decode(v.release_key, 'hex'), decode(v.identity_key, 'hex'), ?, v.group_set_id, v.poster_id, v.name, v.source,
to_timestamp(v.first / 1000.0), to_timestamp(v.last / 1000.0), v.file_total, v.files, v.segments, v.bytes, 1,
- v.first_article, v.last_article, v.readable, v.has_par2, CASE WHEN v.file_total IS NULL THEN NULL ELSE v.files >= v.file_total END,
+ v.first_article, v.last_article, v.readable, v.has_par2,
+ CASE WHEN v.partial THEN false WHEN v.file_total IS NULL THEN NULL ELSE v.files >= v.file_total END,
v.edge, v.sidecar_only, CASE WHEN v.finished THEN now() END
FROM jsonb_to_recordset(?::jsonb) AS v(idx int, release_key text, identity_key text, name text, source int, poster_id int, group_set_id int, first bigint, last bigint,
file_total int, files int, segments bigint, bytes bigint, first_article bigint, last_article bigint, readable boolean, has_par2 boolean,
- finished boolean, edge boolean, sidecar_only boolean)
+ finished boolean, edge boolean, partial boolean, sidecar_only boolean)
ON CONFLICT (release_key) DO UPDATE SET
files = releases.files + excluded.files, segments = releases.segments + excluded.segments, bytes = releases.bytes + excluded.bytes,
chunks = releases.chunks + CASE WHEN excluded.files > 0 THEN 1 ELSE 0 END,
@@ -219,7 +221,8 @@ class BatchStore {
file_total = coalesce(excluded.file_total, releases.file_total),
first_article = least(releases.first_article, excluded.first_article), last_article = greatest(releases.last_article, excluded.last_article),
readable = releases.readable OR excluded.readable, has_par2 = releases.has_par2 OR excluded.has_par2,
- complete = CASE WHEN coalesce(excluded.file_total, releases.file_total) IS NULL THEN releases.complete
+ complete = CASE WHEN releases.complete IS FALSE AND releases.file_total IS NULL THEN false
+ WHEN coalesce(excluded.file_total, releases.file_total) IS NULL THEN releases.complete
ELSE releases.files + excluded.files >= coalesce(excluded.file_total, releases.file_total) END,
edge = excluded.edge, sidecar_only = coalesce(releases.sidecar_only, excluded.sidecar_only), settled_at = excluded.settled_at
WHERE releases.settled_at IS NULL
diff --git a/edge/Cargo.toml b/edge/Cargo.toml
index 54c3e53..849cd6d 100644
--- a/edge/Cargo.toml
+++ b/edge/Cargo.toml
@@ -2,7 +2,7 @@
name = "edge"
version = "0.1.0"
edition = "2021"
-description = "Voyager 2's edge worker (edge-node) and its benchmark (edge-bench): the RangeIndexer in Rust, kept identical to lib/edge (check.ts)"
+description = "The edge worker (edge-node) and its benchmark (edge-bench): voyager 3's window indexing in Rust"
[[bin]]
name = "edge-bench"
diff --git a/edge/README.md b/edge/README.md
index 06e4de2..303e7b5 100644
--- a/edge/README.md
+++ b/edge/README.md
@@ -6,18 +6,21 @@ from the node API, reads their prefetched XOVER chunks, finds releases and
posts batches. `edge-bench` (`src/main.rs`) replays a captured range through
the same indexing (`src/lib.rs`) for timing and for comparing.
-The TypeScript `RangeIndexer` (`lib/edge`, `node ace edge:node`) is the
-reference: `check.ts` runs both on a capture and compares every output field
-by field. An identity or rule change goes into both and has to come out
-identical. CI (`.gitea/workflows/deploy.yaml`, job `edge`) runs `cargo test`
-and `check-fixtures.sh`, which compares both on the committed captures in
-`fixtures/` (25k overview lines each of a.b.models, a.b.ath and
-a.b.boneless, in 5 chunks); a push to `rebuild` deploys only when they
-match.
+Since voyager 3 (2026-10-25) the Rust indexer is the one implementation of
+the window (REBUILD.md, "Voyager 3: window indexing"; `src/indexer.rs`):
+files and releases are finished when the frontier (the median Date of the
+last 1,024 plausibly dated articles) is 24 h past their last part, or at
+the block's end (provisionally, as edges), and written once. The
+TypeScript `RangeIndexer` (`lib/edge`) keeps the range path for the corpus
+scanner and the tests; the field-by-field comparison (`check.ts`) went
+with the parity. CI (`.gitea/workflows/deploy.yaml`, job `edge`) runs
+`cargo test` and `bench-fixtures.sh`, which replays the committed captures
+in `fixtures/` (25k overview lines each of a.b.ath, a.b.boneless, a.b.fta
+and a.b.models, in 5 chunks).
```sh
cargo test --release --manifest-path edge/Cargo.toml
-edge/check-fixtures.sh # from the repository root
+edge/bench-fixtures.sh # from the repository root
```
In production since 2026-10-09: 122M headers in 10 minutes with 0.37 cores
diff --git a/edge/bench-fixtures.sh b/edge/bench-fixtures.sh
new file mode 100755
index 0000000..3fc035c
--- /dev/null
+++ b/edge/bench-fixtures.sh
@@ -0,0 +1,11 @@
+#!/bin/sh
+# The Rust indexer over the committed captures (edge/fixtures: 25k overview lines each of a.b.ath, a.b.boneless,
+# a.b.fta and a.b.models, in 5 chunks): a smoke test of the window on real headers, exiting non-zero when the bench
+# fails on one. CI runs it after cargo test; run it from the repository root after a change to the indexer.
+set -eu
+cd "$(dirname "$0")/.."
+cargo build --release --quiet --manifest-path edge/Cargo.toml --bin edge-bench
+for capture in edge/fixtures/*/; do
+ echo "== $(basename "$capture")"
+ edge/target/release/edge-bench "$capture" --batch-chunks 2 --idle 2 | tail -1
+done
diff --git a/edge/check-fixtures.sh b/edge/check-fixtures.sh
deleted file mode 100755
index 5bf4193..0000000
--- a/edge/check-fixtures.sh
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/bin/sh
-# The Rust and TypeScript RangeIndexers on the committed captures (edge/fixtures: 25k
-# overview lines each of a.b.models, a.b.ath and a.b.boneless, in 5 chunks), compared
-# by check.ts field by field: exits non-zero on any difference. CI runs it; run it from
-# the repository root after any change to either side.
-set -eu
-cd "$(dirname "$0")/.."
-cargo build --release --quiet --manifest-path edge/Cargo.toml --bin edge-bench
-out=$(mktemp -d)
-trap 'rm -rf "$out"' EXIT
-status=0
-for capture in edge/fixtures/*/; do
- name=$(basename "$capture")
- edge/target/release/edge-bench "$capture" --batch-chunks 2 --idle 2 --dump "$out/$name.jsonl" --wire "$out/$name.wire.jsonl" >/dev/null 2>&1
- echo "== $name"
- node --import=@poppinss/ts-exec edge/check.ts "$capture" "$out/$name.jsonl" --wire "$out/$name.wire.jsonl" --batch-chunks 2 --idle 2 || status=1
-done
-exit $status
diff --git a/edge/check.ts b/edge/check.ts
deleted file mode 100644
index c7a3bf3..0000000
--- a/edge/check.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * Compares edge-bench's output (--dump) with the TypeScript RangeIndexer
- * on the same captured range, item by item: releases (every field, and the
- * payload decoded with lib/chunks), samples, probe candidates, rule counts,
- * fragments and batch boundaries.
- *
- * node --import=@poppinss/ts-exec edge/check.ts
[--wire batches.jsonl] [--batch-chunks 10] [--idle 5]
- *
- * With --wire (edge-bench --wire): also each batch as the Rust worker
- * posts it, against toWire of the TypeScript batch, field by field.
- */
-import { readFile, readdir } from 'node:fs/promises'
-import { join } from 'node:path'
-import { unzipSync } from 'node:zlib'
-import { isDeepStrictEqual } from 'node:util'
-import { RangeIndexer, toWire, type EdgeBatch } from '#lib/edge/index'
-import { DEFAULT_RULES, type Rules } from '#lib/rules/index'
-import { decodeChunk } from '#lib/chunks/index'
-
-const [dir, dumpPath] = process.argv.slice(2)
-const flag = (name: string, fallback: number) => {
- const i = process.argv.indexOf(name)
- return i === -1 ? fallback : Number(process.argv[i + 1])
-}
-const batchChunks = flag('--batch-chunks', 10)
-const idle = flag('--idle', 5)
-
-const { group, start, end } = JSON.parse(await readFile(join(dir, 'range.json'), 'utf8'))
-const rules: Rules = { ...DEFAULT_RULES, priorities: { [group]: 1 }, droppedSignatures: [] }
-const indexer = new RangeIndexer({ group, start, end, rules, idleChunks: idle })
-const files = (await readdir(dir)).filter((f) => f.endsWith('.gz')).sort()
-const batches: EdgeBatch[] = []
-for (const [i, file] of files.entries()) {
- const next = Number(file.split('-')[2].replace('.gz', '')) + 1
- indexer.addText(unzipSync(await readFile(join(dir, file))).toString('utf8'), next)
- const last = i === files.length - 1
- if (last) indexer.finish()
- if (last || (i + 1) % batchChunks === 0) batches.push(indexer.take())
-}
-
-/** Canonical lines per kind, in a stable order, from both sides. */
-type Item = Record
-const sortFiles = (decoded: ReturnType) => [...decoded].sort((a, b) => (a.name + a.subject < b.name + b.subject ? -1 : 1))
-const payloadBytes = { ts: 0, rs: 0 }
-let side: 'ts' | 'rs' = 'ts'
-const release = (r: Item, b: number) => {
- const { payload, ...rest } = r as Item & { payload: Buffer }
- // The compressed size isn't compared (node's zstd and the zstd crate frame alike data a little
- // differently); what the payload decodes to is.
- payloadBytes[side] += payload.length
- return { b, ...rest, decoded: sortFiles(decodeChunk(payload)) }
-}
-const ts: Record = { b: [], r: [], s: [], c: [], n: [], f: [] }
-batches.forEach((batch, b) => {
- ts.b.push({ b, from: batch.from, next: batch.next, headers: batch.headers, postedLow: batch.postedLow, postedHigh: batch.postedHigh })
- for (const r of batch.releases) ts.r.push(release({ upload: r.upload, key: r.key, name: r.name, source: r.source, fileTotal: r.fileTotal, poster: r.poster, groups: r.groups, postedAt: r.postedAt, postedLast: r.postedLast, files: r.files, segments: r.segments, bytes: r.bytes, firstArticle: r.firstArticle, lastArticle: r.lastArticle, readable: r.readable, hasPar2: r.hasPar2, payload: r.payload }, b))
- for (const s of batch.samples) ts.s.push({ b, key: s.key, upload: s.upload, rule: s.rule, ruleKey: s.ruleKey, segments: s.segments })
- for (const c of batch.candidates) ts.c.push({ b, upload: c.upload, releaseKey: c.releaseKey, kind: c.kind, messageId: c.messageId, file: c.file })
- for (const n of batch.counts) ts.n.push({ b, rule: n.rule, key: n.key, articles: n.articles, files: n.files })
- for (const f of batch.fragments) ts.f.push({ b, fileKey: f.fileKey, name: f.name, totalParts: f.totalParts, postedAt: f.postedAt, segments: f.segments })
-})
-
-const rs: Record = { b: [], r: [], s: [], c: [], n: [], f: [] }
-side = 'rs'
-let b = -1
-for (const line of (await readFile(dumpPath, 'utf8')).split('\n')) {
- if (!line) continue
- const { t, ...item } = JSON.parse(line)
- if (t === 'b') b++
- if (t === 'r') rs.r.push(release({ ...item, payload: Buffer.from(item.payload, 'base64') }, b))
- else if (t === 's') rs.s.push({ b, key: item.key, upload: item.upload, rule: item.rule, ruleKey: item.ruleKey, segments: item.segments })
- else rs[t].push({ b, ...item })
-}
-
-const keyOf: Record string> = {
- b: (i) => String(i.b),
- r: (i) => `${i.b} ${i.key} ${i.upload}`,
- s: (i) => `${i.b} ${i.key} ${i.upload}`,
- c: (i) => `${i.b} ${i.releaseKey} ${i.upload} ${i.kind} ${i.messageId}`,
- n: (i) => `${i.b} ${i.rule} ${i.key}`,
- f: (i) => `${i.b} ${i.fileKey}`,
-}
-const names: Record = { b: 'batches', r: 'releases', s: 'samples', c: 'candidates', n: 'rule counts', f: 'fragments' }
-let failed = false
-for (const kind of Object.keys(keyOf)) {
- const a = new Map(ts[kind].map((i) => [keyOf[kind](i), i]))
- const z = new Map(rs[kind].map((i) => [keyOf[kind](i), i]))
- const missing = [...a.keys()].filter((k) => !z.has(k))
- const extra = [...z.keys()].filter((k) => !a.has(k))
- const differ = [...a.keys()].filter((k) => z.has(k) && !isDeepStrictEqual(a.get(k), z.get(k)))
- const ok = missing.length === 0 && extra.length === 0 && differ.length === 0
- failed ||= !ok
- console.log(`${ok ? 'same' : 'DIFF'} ${names[kind]}: ${a.size} TypeScript, ${z.size} Rust${ok ? '' : `; ${missing.length} only in TypeScript, ${extra.length} only in Rust, ${differ.length} differ`}`)
- for (const k of [...missing.slice(0, 2), ...extra.slice(0, 2)]) console.log(` only one side: ${k}`)
- for (const k of differ.slice(0, 2)) {
- const x = a.get(k)!
- const y = z.get(k)!
- const fields = Object.keys(x).filter((f) => !isDeepStrictEqual(x[f], y[f]))
- console.log(` ${k}: ${fields.map((f) => `${f}: ${JSON.stringify(x[f]).slice(0, 160)} vs ${JSON.stringify(y[f]).slice(0, 160)}`).join('; ')}`)
- }
-}
-console.log(`payload bytes: ${payloadBytes.ts} TypeScript, ${payloadBytes.rs} Rust`)
-
-const wireIndex = process.argv.indexOf('--wire')
-if (wireIndex !== -1) {
- // Order within a batch doesn't count; payloads by what they decode to.
- const by = (f: (x: any) => string) => (a: any, b: any) => (f(a) < f(b) ? -1 : f(a) > f(b) ? 1 : 0)
- const normal = (w: any) => ({
- ...w,
- releases: w.releases.map((r: any) => ({ ...r, payload: sortFiles(decodeChunk(Buffer.from(r.payload, 'base64'))) })).sort(by((r) => `${r.key} ${r.upload}`)),
- samples: w.samples.map((r: any) => ({ ...r, payload: sortFiles(decodeChunk(Buffer.from(r.payload, 'base64'))) })).sort(by((r) => `${r.key} ${r.upload}`)),
- candidates: [...w.candidates].sort(by((c) => JSON.stringify(c))),
- counts: [...w.counts].sort(by((c) => `${c.rule} ${c.key}`)),
- fragments: [...w.fragments].sort(by((f) => `${f.fileKey} ${f.segments[0]?.[1]}`)),
- })
- const rust = (await readFile(process.argv[wireIndex + 1], 'utf8')).split('\n').filter(Boolean).map((l) => normal(JSON.parse(l)))
- const typescript = batches.map((b) => normal(JSON.parse(JSON.stringify(toWire(b)))))
- let wireSame = rust.length === typescript.length
- const firstDiff = (x: any, y: any, path: string): string | null => {
- if (isDeepStrictEqual(x, y)) return null
- if (typeof x !== 'object' || typeof y !== 'object' || x === null || y === null) return `${path}: ${JSON.stringify(x)?.slice(0, 120)} vs ${JSON.stringify(y)?.slice(0, 120)}`
- for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) {
- const d = firstDiff(x[k], y[k], `${path}.${k}`)
- if (d) return d
- }
- return path
- }
- for (let i = 0; i < Math.max(rust.length, typescript.length); i++) {
- const d = firstDiff(typescript[i], rust[i], `batch ${i}`)
- if (d) {
- wireSame = false
- console.log(`DIFF wire: ${d}`)
- break
- }
- }
- if (wireSame) console.log(`same wire batches: ${typescript.length} TypeScript, ${rust.length} Rust`)
- failed ||= !wireSame
-}
-process.exit(failed ? 1 : 0)
diff --git a/edge/src/indexer.rs b/edge/src/indexer.rs
index f067bd4..50a9091 100644
--- a/edge/src/indexer.rs
+++ b/edge/src/indexer.rs
@@ -1,6 +1,7 @@
-//! lib/edge/scan.ts (SegmentScanner, scanOverviewText), lib/files/aggregator.ts
-//! and lib/edge/range_indexer.ts (RangeIndexer, chunkOf, candidatesOf), in the
-//! same order of operations, so the outputs can be compared one to one.
+//! The edge's indexing: lib/edge/scan.ts (SegmentScanner, scanOverviewText) and lib/files/aggregator.ts as in the
+//! TypeScript, then voyager 3's window (REBUILD.md, "Voyager 3: window indexing", 2): files and releases are
+//! finished when the frontier (the median Date of the last FRONTIER_ARTICLES plausible ones) is WINDOW_MS past
+//! their last part, or at the block's end (provisionally, as edges), and written once.
use crate::codec::{encode_chunk, ChunkFile};
use crate::js::{self, sha1_hex};
@@ -46,6 +47,12 @@ pub struct ReleaseChunk {
pub last_article: i64,
pub readable: bool,
pub has_par2: bool,
+ /// The window closed on it (false: still open at the block's end, written provisionally).
+ pub finished: bool,
+ /// Open at the block's end, or within the window of its start: the block merger's.
+ pub edge: bool,
+ /// A file misses parts.
+ pub partial: bool,
pub payload: Vec,
/// The first files' subjects (at most SUBJECTS): edge-bench's --releases dump for the grouping sweeps.
pub subjects: Vec,
@@ -61,18 +68,6 @@ pub struct Candidate {
pub file: String,
}
-pub struct Fragment {
- pub file_key: String,
- pub name: String,
- pub poster: String,
- pub subject: String,
- pub total_parts: u32,
- pub groups: Vec,
- pub release: Release,
- pub posted_at: Option,
- pub segments: Vec<(u32, String, i64, i64)>,
-}
-
#[derive(Default)]
pub struct Batch {
pub from: i64,
@@ -86,7 +81,6 @@ pub struct Batch {
pub candidates: Vec,
/// (rule, key) → (articles, files)
pub counts: Vec<(&'static str, String, u64, u64)>,
- pub fragments: Vec,
}
struct Counter {
@@ -470,8 +464,108 @@ fn scan_overview_text(text: &str, mut line: impl FnMut(Option, &str, &str,
}
}
-/// A re-upload's copy of a file is kept apart (FileAggregator, uploadGapMs).
+/// A re-upload's copy of a file is kept apart (FileAggregator, uploadGapMs); a file joins the open release of its
+/// key posted within this gap of it, else starts one (uploadsOf, online).
pub const UPLOAD_GAP_MS: i64 = 3 * 60 * 60 * 1000;
+/// The window (REBUILD.md, "Voyager 3: window indexing", 2): a file still missing parts, and a release, are finished
+/// once the frontier is this far past their last part.
+pub const WINDOW_MS: i64 = 24 * 60 * 60 * 1000;
+/// The frontier is the median Date of the last this many plausibly dated articles: enough that a minority of skewed
+/// dates can't move it, few enough that it trails the newest article by little even in a quiet group (half the
+/// ring: a day at 500 articles a day).
+pub const FRONTIER_ARTICLES: usize = 1024;
+/// A Date further than this from the frontier isn't believed (clock skew, bogus years); FRONTIER_ARTICLES of them
+/// in a row that agree among themselves move it (the group paused for longer).
+pub const PLAUSIBLE_MS: i64 = 7 * 24 * 60 * 60 * 1000;
+/// The frontier is established once this many dated articles were seen (a lone bogus date doesn't set it).
+const FRONTIER_MIN: usize = 64;
+
+fn median(dates: &mut [i64]) -> i64 {
+ let mid = dates.len() / 2;
+ *dates.select_nth_unstable(mid).1
+}
+
+/// Where the block is in posting time: the median Date of the last FRONTIER_ARTICLES plausible ones. Date headers
+/// alone aren't trusted: a date more than PLAUSIBLE_MS from the frontier is set aside, and only FRONTIER_ARTICLES
+/// such dates in a row, agreeing among themselves, move the frontier there.
+#[derive(Default)]
+pub struct Frontier {
+ ring: Vec,
+ at: usize,
+ value: Option,
+ /// The frontier when first established: the block's start in posting time (releases within WINDOW_MS of it are edges).
+ start: Option,
+ /// Dates set aside since the last plausible one.
+ aside: Vec,
+ pending: usize,
+}
+
+impl Frontier {
+ pub fn add(&mut self, t: i64) {
+ if let Some(f) = self.value {
+ if (t - f).abs() > PLAUSIBLE_MS {
+ self.aside.push(t);
+ if self.aside.len() >= FRONTIER_ARTICLES {
+ let m = median(&mut self.aside.clone());
+ if self.aside.iter().filter(|&&d| (d - m).abs() <= PLAUSIBLE_MS).count() * 2 >= self.aside.len() {
+ self.ring = self.aside.iter().copied().filter(|&d| (d - m).abs() <= PLAUSIBLE_MS).collect();
+ self.ring.truncate(FRONTIER_ARTICLES);
+ self.at = self.ring.len() % FRONTIER_ARTICLES;
+ self.value = Some(m);
+ }
+ self.aside.clear();
+ }
+ return;
+ }
+ self.aside.clear();
+ }
+ if self.ring.len() < FRONTIER_ARTICLES {
+ self.ring.push(t);
+ } else {
+ self.ring[self.at] = t;
+ }
+ self.at = (self.at + 1) % FRONTIER_ARTICLES;
+ self.pending += 1;
+ }
+
+ /// Recomputes the frontier (once per chunk: a median of FRONTIER_ARTICLES isn't for every article).
+ pub fn update(&mut self) {
+ if self.pending == 0 || (self.value.is_none() && self.ring.len() < FRONTIER_MIN) {
+ return;
+ }
+ self.pending = 0;
+ let m = median(&mut self.ring.clone());
+ self.value = Some(m);
+ if self.start.is_none() {
+ self.start = Some(m);
+ }
+ }
+
+ /// At the block's end: whatever dates there are set the frontier (a small block never reached FRONTIER_MIN).
+ pub fn settle(&mut self) {
+ if self.value.is_none() && !self.ring.is_empty() {
+ self.pending = 1;
+ let m = median(&mut self.ring.clone());
+ self.value = Some(m);
+ self.start = Some(m);
+ } else {
+ self.update();
+ }
+ }
+
+ pub fn value(&self) -> Option {
+ self.value
+ }
+
+ pub fn start(&self) -> Option {
+ self.start
+ }
+
+ /// The posting time before which a last part means the window closed.
+ fn closed_before(&self) -> Option {
+ self.value.map(|f| f - WINDOW_MS)
+ }
+}
struct OpenFile {
parts: Vec,
@@ -570,105 +664,97 @@ impl Aggregator {
complete
}
- /// Files idle for `idle` batches (all when 0): their segments, per file key (its copies one after another).
- fn evict(&mut self, idle: u64) -> Vec> {
+ /// Files the window closed on, still missing parts: those whose last part is before `closed_before`, undated ones
+ /// idle for `idle` batches (all when `all`). Their segments, part order.
+ fn evict(&mut self, closed_before: Option, idle: u64, all: bool) -> Vec> {
let batch = self.batch;
let mut out = Vec::new();
let keys: Vec = self.open.keys().cloned().collect();
for key in keys {
let copies = self.open.get_mut(&key).unwrap();
- let mut gone: Vec = Vec::new();
let mut i = 0;
while i < copies.len() {
- if idle > 0 && batch - copies[i].touched < idle {
+ let c = &copies[i];
+ let closed = match c.latest {
+ Some(l) => closed_before.is_some_and(|before| l < before),
+ None => idle > 0 && batch - c.touched >= idle,
+ };
+ if !all && !closed {
i += 1;
continue;
}
- gone.extend(copies.remove(i).parts);
+ let mut f = copies.remove(i);
+ f.parts.sort_by_key(|s| s.number);
+ out.push(f.parts);
}
if copies.is_empty() {
self.open.remove(&key);
}
- if !gone.is_empty() {
- out.push(gone);
- }
}
out
}
}
-/// uploadsOf: a key's files split where posting pauses for more than UPLOAD_GAP_MS after the latest article so far.
-fn uploads_of(files: Vec>) -> Vec>> {
- if files.len() < 2 {
- return vec![files];
- }
- let first: Vec