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
+69
-15
@@ -14,11 +14,13 @@ export interface AggregatableSegment {
|
||||
totalParts: number
|
||||
messageId: string
|
||||
bytes: number
|
||||
/** ms since 1970 (NaN or absent: unknown): tells a re-upload's copy of a file from the first. */
|
||||
postedAt?: number
|
||||
}
|
||||
|
||||
export interface AggregatedFile<S extends AggregatableSegment> {
|
||||
fileKey: string
|
||||
/** One segment per part number, in part order; the first article seen wins (a later one is a repost). */
|
||||
/** One segment per part number, in part order; within a copy the first article seen wins (a re-send). */
|
||||
segments: S[]
|
||||
}
|
||||
|
||||
@@ -27,14 +29,32 @@ interface OpenFile<S> {
|
||||
totalParts: number
|
||||
/** Batch in which a segment last arrived. */
|
||||
touched: number
|
||||
/** Posting times seen (NaN: none). */
|
||||
earliest: number
|
||||
latest: number
|
||||
}
|
||||
|
||||
export class FileAggregator<S extends AggregatableSegment> {
|
||||
private open = new Map<string, OpenFile<S>>()
|
||||
/** Per file key its open copies, oldest first: a re-upload of a file is a copy of its own. */
|
||||
private open = new Map<string, OpenFile<S>[]>()
|
||||
private batch = 0
|
||||
/** Segments with a part number outside 1..total: never part of a complete file. */
|
||||
private stray: S[] = []
|
||||
|
||||
/**
|
||||
* `uploadGapMs`: a part already in every open copy, posted further than
|
||||
* this from all of them, starts another copy (a re-upload). A missing part
|
||||
* always fills a copy, the nearest in time (a poster re-sending what was
|
||||
* missing), and a part posted near a copy that has it is a re-send.
|
||||
*/
|
||||
constructor(private uploadGapMs = 3 * 60 * 60 * 1000) {}
|
||||
|
||||
/** How far `t` is from a copy's posting times (0 when unknown or within them). */
|
||||
private distance(file: OpenFile<S>, t: number | undefined): number {
|
||||
if (t === undefined || !Number.isFinite(t) || !Number.isFinite(file.earliest)) return 0
|
||||
return Math.max(0, file.earliest - t, t - file.latest)
|
||||
}
|
||||
|
||||
/** Adds one batch of segments; returns the files they completed. */
|
||||
add(segments: S[]): AggregatedFile<S>[] {
|
||||
this.batch++
|
||||
@@ -44,22 +64,51 @@ export class FileAggregator<S extends AggregatableSegment> {
|
||||
this.stray.push(segment)
|
||||
continue
|
||||
}
|
||||
let file = this.open.get(segment.fileKey)
|
||||
let copies = this.open.get(segment.fileKey)
|
||||
if (!copies) {
|
||||
copies = []
|
||||
this.open.set(segment.fileKey, copies)
|
||||
}
|
||||
const t = segment.postedAt
|
||||
let file: OpenFile<S> | undefined
|
||||
let best = Number.POSITIVE_INFINITY
|
||||
for (const copy of copies) {
|
||||
if (copy.parts.has(segment.number)) continue
|
||||
const d = this.distance(copy, t)
|
||||
if (d < best) {
|
||||
best = d
|
||||
file = copy
|
||||
}
|
||||
}
|
||||
if (!file) {
|
||||
file = { parts: new Map(), totalParts: segment.totalParts, touched: this.batch }
|
||||
this.open.set(segment.fileKey, file)
|
||||
const near = copies.find((copy) => this.distance(copy, t) <= this.uploadGapMs)
|
||||
if (near) {
|
||||
// A re-send of a part this copy has: the first article stays.
|
||||
near.touched = this.batch
|
||||
touched.add(segment.fileKey)
|
||||
continue
|
||||
}
|
||||
file = { parts: new Map(), totalParts: segment.totalParts, touched: this.batch, earliest: Number.NaN, latest: Number.NaN }
|
||||
copies.push(file)
|
||||
}
|
||||
file.parts.set(segment.number, segment)
|
||||
if (t !== undefined && Number.isFinite(t)) {
|
||||
if (!(t >= file.earliest)) file.earliest = t
|
||||
if (!(t <= file.latest)) file.latest = t
|
||||
}
|
||||
if (!file.parts.has(segment.number)) file.parts.set(segment.number, segment)
|
||||
file.touched = this.batch
|
||||
touched.add(segment.fileKey)
|
||||
}
|
||||
|
||||
const complete: AggregatedFile<S>[] = []
|
||||
for (const key of touched) {
|
||||
const file = this.open.get(key)!
|
||||
if (file.parts.size < file.totalParts) continue
|
||||
this.open.delete(key)
|
||||
complete.push({ fileKey: key, segments: [...file.parts.values()].sort((a, b) => a.number - b.number) })
|
||||
const copies = this.open.get(key)!
|
||||
for (const file of [...copies]) {
|
||||
if (file.parts.size < file.totalParts) continue
|
||||
copies.splice(copies.indexOf(file), 1)
|
||||
complete.push({ fileKey: key, segments: [...file.parts.values()].sort((a, b) => a.number - b.number) })
|
||||
}
|
||||
if (copies.length === 0) this.open.delete(key)
|
||||
}
|
||||
return complete
|
||||
}
|
||||
@@ -71,16 +120,21 @@ export class FileAggregator<S extends AggregatableSegment> {
|
||||
evict(idleBatches = 0): S[] {
|
||||
const out = this.stray
|
||||
this.stray = []
|
||||
for (const [key, file] of this.open) {
|
||||
if (idleBatches > 0 && this.batch - file.touched < idleBatches) continue
|
||||
this.open.delete(key)
|
||||
out.push(...file.parts.values())
|
||||
for (const [key, copies] of this.open) {
|
||||
for (const file of [...copies]) {
|
||||
if (idleBatches > 0 && this.batch - file.touched < idleBatches) continue
|
||||
copies.splice(copies.indexOf(file), 1)
|
||||
out.push(...file.parts.values())
|
||||
}
|
||||
if (copies.length === 0) this.open.delete(key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Files still waiting for parts. */
|
||||
get openFiles(): number {
|
||||
return this.open.size
|
||||
let n = 0
|
||||
for (const copies of this.open.values()) n += copies.length
|
||||
return n
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user