Rebuild: re-uploads are releases of their own
Test and deploy / test (push) Successful in 26s
Test and deploy / deploy (push) Skipped

A poster re-uploading under the same name made one release of all
uploads: in a sample, 24% of releases with 2+ files had a file name more
than once (4.3% of files extra copies), 70% of those copies posted a day
or more apart; their NZBs listed each copy.

- File assembly keeps a re-upload's copy apart: a part already in every
  open copy, posted more than 3 h from them, starts another copy; a
  missing part fills the nearest copy (a re-send of what was missing),
  a re-sent part near a copy that has it is ignored as before.
- A key's files in a batch split into uploads where posting pauses for
  more than 3 h, counted from the latest article posted so far (a file
  that takes hours to post doesn't split it); each upload is judged and
  chunked on its own (ReleaseChunk.upload, .postedLast; candidates carry
  their upload).
- The catalog matches a chunk to the release of its key whose articles
  the range's group owns and whose posting window is within 3 h, else
  starts one (key, key:2, …): releases get identity_key, group_id,
  last_posted_at. Keys are locked in order (advisory locks), so
  concurrent batches neither deadlock nor both start one; the merger
  writes in key order too. The same upload multi-posted to other groups
  is a release per group.
- The gap: 91% of uploads post within 3 h; 73% of re-uploaded copies
  come later (releases:upload-gaps). releases:duplicates measures the
  duplicate names.

Both implementations; check.ts compares the windows and uploads too
(identical on all captures; Rust tests mirror the TypeScript cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-09 13:31:35 +02:00
1 parent d0f3497ec9
commit 0613984de9
15 files changed
+715 -140

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
export { RangeIndexer, chunkOf, candidatesOf, releaseKeyOf, type EdgeBatch, type ReleaseChunk, type DroppedChunk, type ProbeCandidate, type CandidateKind, type RuleCount, type Fragment, type RangeIndexerOptions } from './range_indexer.js'
export { RangeIndexer, chunkOf, candidatesOf, releaseKeyOf, uploadsOf, UPLOAD_GAP_MS, type EdgeBatch, type ReleaseChunk, type DroppedChunk, type ProbeCandidate, type CandidateKind, type RuleCount, type Fragment, type RangeIndexerOptions } from './range_indexer.js'
export { encodeFrame, decodeFrames, isKeepalive, KEEPALIVE, type Frame } from './stream.js'
export { toWire, fromWire, type WireBatch } from './wire.js'
export type { Lease } from './lease.js'
+77 -20
View File
@@ -8,12 +8,16 @@ import { encodeChunk, type ChunkFile } from '../chunks/index.js'
/** A release's files from one batch (REBUILD.md, chunk store): what the catalog adds up, and the encoded chunk. */
export interface ReleaseChunk {
key: string
/** Which upload of the key in this batch (0, 1, …; uploadsOf): the catalog keeps uploads apart. */
upload: number
name: string
source: string
fileTotal: number | null
poster: string
groups: string[]
postedAt: number | null
/** The latest article's date: with postedAt, the upload's posting window. */
postedLast: number | null
files: number
segments: number
bytes: number
@@ -35,6 +39,8 @@ export type CandidateKind = 'archive' | 'par2' | 'lone' | '7z-tail'
/** An article phase 2 should read (REBUILD.md, probe queue). */
export interface ProbeCandidate {
releaseKey: string
/** Its chunk's upload (ReleaseChunk.upload). */
upload: number
kind: CandidateKind
messageId: string
file: string
@@ -106,7 +112,7 @@ const MAX_CANDIDATES = 4
* waiting for parts.
*/
export class RangeIndexer {
private aggregator = new FileAggregator<EdgeSegment>()
private aggregator = new FileAggregator<EdgeSegment>(UPLOAD_GAP_MS)
private scanner = new SegmentScanner()
private segments: EdgeSegment[] = []
private rules: RuleSet
@@ -194,22 +200,24 @@ export class RangeIndexer {
}
}
/** Judges and encodes the releases completed in this batch. */
/** Judges and encodes the releases completed in this batch: each upload of a key on its own. */
private judgeReleases() {
const byRelease = this.completed
this.completed = new Map()
for (const [key, list] of byRelease) {
const verdict = this.rules.judge(
key,
list.map((f) => ({ name: f.segments[0].file.name, totalParts: f.segments[0].totalParts, groups: f.segments[0].file.groups, firstMessageId: f.segments[0].messageId }))
)
if (verdict.rule === null) {
this.batch.releases.push(chunkOf(key, list))
this.batch.candidates.push(...candidatesOf(key, list))
continue
}
this.count(verdict.rule, verdict.key, list.reduce((n, f) => n + f.segments.length, 0), list.length)
if (verdict.sample) this.batch.samples.push({ ...chunkOf(key, list), rule: verdict.rule, ruleKey: verdict.key })
for (const [key, all] of byRelease) {
uploadsOf(all).forEach((list, upload) => {
const verdict = this.rules.judge(
key,
list.map((f) => ({ name: f.segments[0].file.name, totalParts: f.segments[0].totalParts, groups: f.segments[0].file.groups, firstMessageId: f.segments[0].messageId }))
)
if (verdict.rule === null) {
this.batch.releases.push(chunkOf(key, list, upload))
this.batch.candidates.push(...candidatesOf(key, list, upload))
return
}
this.count(verdict.rule, verdict.key, list.reduce((n, f) => n + f.segments.length, 0), list.length)
if (verdict.sample) this.batch.samples.push({ ...chunkOf(key, list, upload), rule: verdict.rule, ruleKey: verdict.key })
})
}
}
@@ -284,6 +292,51 @@ export function releaseKeyOf(s: EdgeSegment): string {
return s.file.release?.key ?? hash('sha1', `file\n${s.fileKey}`)
}
/**
* A pause of more than this in posting starts another upload of the same
* release key (a re-upload): 91% of uploads post within 3 h, 73% of
* re-uploaded copies come later (LOG.md 2026-10-09 (10)).
*/
export const UPLOAD_GAP_MS = 3 * 60 * 60 * 1000
/**
* A key's files split into uploads where posting pauses for more than
* UPLOAD_GAP_MS: files in order of their first article's date; a file
* starts another upload when it begins more than UPLOAD_GAP_MS after the
* latest article posted so far (so a file that takes hours to post doesn't
* split its upload). Earliest upload first, each in the files' own order;
* files without a date go with the first.
*/
export function uploadsOf<F extends AggregatedFile<EdgeSegment>>(files: F[]): F[][] {
if (files.length < 2) return [files]
const first = files.map((f) => postedOf(f.segments))
const last = files.map((f) => latestOf(f.segments))
const dated = files.map((_, i) => i).filter((i) => first[i] !== null).sort((a, b) => first[a]! - first[b]! || a - b)
if (dated.length < 2) return [files]
const upload = new Array<number>(files.length).fill(0)
let current = 0
let end = last[dated[0]]!
for (let k = 1; k < dated.length; k++) {
const i = dated[k]
if (first[i]! - end > UPLOAD_GAP_MS) {
current++
end = last[i]!
} else if (last[i]! > end) {
end = last[i]!
}
upload[i] = current
}
if (current === 0) return [files]
return Array.from({ length: current + 1 }, (_, u) => files.filter((_, i) => upload[i] === u))
}
/** The latest article date of a file's segments, or null. */
function latestOf(segments: EdgeSegment[]): number | null {
let max: number | null = null
for (const s of segments) if (Number.isFinite(s.postedAt) && (max === null || s.postedAt > max)) max = s.postedAt
return max
}
function postedOf(segments: EdgeSegment[]): number | null {
let min: number | null = null
for (const s of segments) {
@@ -293,7 +346,7 @@ function postedOf(segments: EdgeSegment[]): number | null {
return min
}
export function chunkOf(key: string, files: AggregatedFile<EdgeSegment>[]): ReleaseChunk {
export function chunkOf(key: string, files: AggregatedFile<EdgeSegment>[], upload = 0): ReleaseChunk {
const first = files[0].segments[0].file
const chunkFiles: ChunkFile[] = files.map((f) => {
const m = f.segments[0].file
@@ -304,6 +357,7 @@ export function chunkOf(key: string, files: AggregatedFile<EdgeSegment>[]): Rele
let firstArticle = Number.POSITIVE_INFINITY
let lastArticle = 0
let postedAt: number | null = null
let postedLast: number | null = null
for (const f of files) {
for (const s of f.segments) {
segments++
@@ -311,18 +365,21 @@ export function chunkOf(key: string, files: AggregatedFile<EdgeSegment>[]): Rele
const n = s.articleNumber
if (n < firstArticle) firstArticle = n
if (n > lastArticle) lastArticle = n
if (Number.isFinite(s.postedAt) && (postedLast === null || s.postedAt > postedLast)) postedLast = s.postedAt
}
const t = postedOf(f.segments)
if (t !== null && (postedAt === null || t < postedAt)) postedAt = t
}
return {
key,
upload,
name: first.release?.name ?? first.name,
source: first.release?.source ?? 'file',
fileTotal: files.find((f) => f.segments[0].file.release?.fileTotal)?.segments[0].file.release?.fileTotal ?? null,
poster: first.poster,
groups: first.groups,
postedAt,
postedLast,
files: files.length,
segments,
bytes,
@@ -339,7 +396,7 @@ export function chunkOf(key: string, files: AggregatedFile<EdgeSegment>[]): Rele
* volume, of its par2 index, of a lone random-named file, and a 7z set's
* last volume (its listing is in the tail). At most a few per release.
*/
export function candidatesOf(key: string, files: AggregatedFile<EdgeSegment>[]): ProbeCandidate[] {
export function candidatesOf(key: string, files: AggregatedFile<EdgeSegment>[], upload = 0): ProbeCandidate[] {
const out: ProbeCandidate[] = []
let tail: { volume: number; file: AggregatedFile<EdgeSegment> } | null = null
for (const f of files) {
@@ -349,12 +406,12 @@ export function candidatesOf(key: string, files: AggregatedFile<EdgeSegment>[]):
if (!tail || volume > tail.volume) tail = { volume, file: f }
continue
}
if (isArchiveStart(name)) out.push({ releaseKey: key, kind: 'archive', messageId: f.segments[0].messageId, file: name })
else if (/\.par2$/i.test(name) && !isLaterVolume(name)) out.push({ releaseKey: key, kind: 'par2', messageId: f.segments[0].messageId, file: name })
if (isArchiveStart(name)) out.push({ releaseKey: key, upload, kind: 'archive', messageId: f.segments[0].messageId, file: name })
else if (/\.par2$/i.test(name) && !isLaterVolume(name)) out.push({ releaseKey: key, upload, kind: 'par2', messageId: f.segments[0].messageId, file: name })
}
if (tail) out.push({ releaseKey: key, kind: '7z-tail', messageId: tail.file.segments.at(-1)!.messageId, file: tail.file.segments[0].file.name })
if (tail) out.push({ releaseKey: key, upload, kind: '7z-tail', messageId: tail.file.segments.at(-1)!.messageId, file: tail.file.segments[0].file.name })
if (out.length === 0 && files.length === 1 && isRandomFile(files[0].segments[0].file.name)) {
out.push({ releaseKey: key, kind: 'lone', messageId: files[0].segments[0].messageId, file: files[0].segments[0].file.name })
out.push({ releaseKey: key, upload, kind: 'lone', messageId: files[0].segments[0].messageId, file: files[0].segments[0].file.name })
}
return out.slice(0, MAX_CANDIDATES)
}