Decide completeness by par2 set for releases without a counter
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>
This commit is contained in:
1 parent
f75097d7ac
commit
ebe9f8bb36
10 files changed
+187
-12
No files matched your search
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user