59% of settled releases had complete = NULL: without an [i/N] counter the settler left it unknown, and the "phase 2 reads par2 sets for those" step was never built. A release is now complete when every file its par2 sets describe is there by name or by its first-16-KiB hash, or, for an upload renamed after its par2 was made, when it has as many non-par2 files and bytes. Decided when a par2 index is probed, after a par2 merge, and before the pruner drops a set's file list; the batch store, settler and merger keep a decided value instead of resetting it to NULL. releases:par2-complete decides the releases probed before this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
68 lines
4.2 KiB
TypeScript
68 lines
4.2 KiB
TypeScript
import { test } from '@japa/runner'
|
|
import db from '@adonisjs/lucid/services/db'
|
|
import testUtils from '@adonisjs/core/services/test_utils'
|
|
import BatchStore from '#services/batch_store'
|
|
import { decidePar2Completeness, par2Complete } from '#services/par2_completeness'
|
|
import { RangeIndexer } from '#lib/edge/index'
|
|
import { DEFAULT_RULES } from '#lib/rules/index'
|
|
import type { Overview } from '#lib/nntp/index'
|
|
|
|
const GROUP = 'alt.binaries.test'
|
|
|
|
/** A file posted without an [i/N] counter: the release gets no file_total. */
|
|
function file(name: string, parts: number, at: number): Overview[] {
|
|
return Array.from({ length: parts }, (_, i) => [at + i, {
|
|
'subject': `"${name}" yEnc (${i + 1}/${parts})`, 'from': 'p <p@x>', 'date': 'Mon, 05 Oct 2026 10:00:00 +0000',
|
|
'message-id': `<${name}-${i + 1}@test>`, ':bytes': '700000', 'xref': `news.example ${GROUP}:${at + i}`,
|
|
}] as Overview)
|
|
}
|
|
|
|
async function store(start: number, overviews: Overview[]) {
|
|
const g = (await db.from('groups').where('name', GROUP).first()) ?? (await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('*'))[0]
|
|
const [n] = await db.table('nodes').insert({ name: `n${start}` }).returning('id')
|
|
const [r] = await db.table('ranges').insert({ group_id: g.id, start_id: start, end_id: start + 999, next_id: start, status: 'leased', node_id: n.id }).returning('id')
|
|
const indexer = new RangeIndexer({ group: GROUP, start, end: start + 999, rules: { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } } })
|
|
indexer.add(overviews, start + 1000)
|
|
indexer.finish()
|
|
await BatchStore.store(Number(r.id), Number(n.id), indexer.take())
|
|
}
|
|
|
|
const setFile = (name: string, length: number, hash16k = name.padEnd(32, '0').slice(0, 32)) => ({ name, length, hash16k })
|
|
|
|
test.group('par2 completeness', (group) => {
|
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
|
|
|
test('every described file by name or by hash; a renamed upload by its files and bytes', ({ assert }) => {
|
|
const set = [setFile('a.mkv', 1000), setFile('a.nfo', 10)]
|
|
const files = [{ name: 'a.mkv', size: 1100 }, { name: 'a.nfo', size: 12 }, { name: 'a.par2', size: 50 }]
|
|
assert.isTrue(par2Complete(set, files, []))
|
|
assert.isFalse(par2Complete(set, files.slice(0, 1), []))
|
|
// The nfo renamed, but its probe hashed it.
|
|
assert.isTrue(par2Complete(set, [files[0], { name: 'x.nfo', size: 12 }], [{ hash16k: set[1].hash16k, size: 10 }]))
|
|
// Renamed after the par2 was made (random names): two data files and enough bytes; one missing, or too small.
|
|
assert.isTrue(par2Complete(set, [{ name: 'Qx81.bin', size: 1100 }, { name: 'Zr02.bin', size: 12 }, { name: 'Qx.par2', size: 50 }], []))
|
|
assert.isFalse(par2Complete(set, [{ name: 'Qx81.bin', size: 1100 }, { name: 'Qx.par2', size: 50 }], []))
|
|
assert.isFalse(par2Complete(set, [{ name: 'Qx81.bin', size: 500 }, { name: 'Zr02.bin', size: 12 }], []))
|
|
})
|
|
|
|
test('decided once settled with a par2 set, and kept when more files come', async ({ assert }) => {
|
|
await store(1, [...file('Some.Upload.mkv', 3, 10), ...file('Some.Upload.par2', 1, 20)])
|
|
const release = await db.from('releases').firstOrFail()
|
|
assert.isNull(release.file_total)
|
|
await db.table('par2_files').multiInsert([setFile('Some.Upload.mkv', 2_000_000), setFile('Some.Upload.nfo', 100)].map((f, i) => ({ set_id: 's'.repeat(32), file_id: String(i).repeat(32), ...f })))
|
|
await db.table('par2_sets').insert({ set_id: 's'.repeat(32), release_id: release.id, files: 2 })
|
|
// Not settled yet: left alone.
|
|
assert.equal(await decidePar2Completeness(db, [release.id]), 0)
|
|
await db.from('releases').where('id', release.id).update({ settled_at: new Date() })
|
|
assert.equal(await decidePar2Completeness(db, [release.id]), 1)
|
|
assert.isFalse((await db.from('releases').where('id', release.id).firstOrFail()).complete)
|
|
// The nfo comes in a later range: complete, and the batch store's own update keeps it.
|
|
await store(1001, file('Some.Upload.nfo', 1, 1010))
|
|
await decidePar2Completeness(db, [release.id])
|
|
await store(2001, file('Some.Upload.sfv', 1, 2010))
|
|
const after = await db.from('releases').where('id', release.id).firstOrFail()
|
|
assert.equal(after.files, 4)
|
|
assert.isTrue(after.complete)
|
|
})
|
|
})
|