Rebuild: re-uploads are releases of their own
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:
1 parent
d0f3497ec9
commit
0613984de9
15 files changed
+715
-140
No files matched your search
+77
-20
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user