Voyager 3 schema: the slim catalog (migration 6000, the reset) and blocks (6100)
releases rebuilt without its text copies: poster_id, group_set_id, bytea keys, source smallint, edge, settled_at as the finish. posters by id, group_sets. blocks as the lease unit, ranges.block_id. fragments, range_blocks, release_final, the settler and the fragment merger gone; release_chunks, rule_counts and rule_samples keyed by block. Cold tables on CHUNK_TABLESPACE. Migration 6000 truncates the catalog and refuses without VOYAGER3_RESET=1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
1a448f37f9
commit
346493b6c3
17 files changed
+527
-545
No files matched your search
@@ -1,95 +0,0 @@
|
||||
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 RangeBlocks from '#services/range_blocks'
|
||||
import SearchService from '#services/search_service'
|
||||
import { RangeIndexer, postedSpan } from '#lib/edge/index'
|
||||
import { DEFAULT_RULES } from '#lib/rules/index'
|
||||
import type { Overview } from '#lib/nntp/index'
|
||||
|
||||
const GROUP = 'alt.binaries.final'
|
||||
const at = (iso: string) => new Date(iso)
|
||||
|
||||
test.group('Final completeness', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
|
||||
test("a batch's posting times, without the earliest and latest 1%, become the range's span", async ({ assert }) => {
|
||||
assert.deepEqual(postedSpan([]), [null, null])
|
||||
const dates = Array.from({ length: 200 }, (_, i) => 1_000_000 + i)
|
||||
dates.push(5, 9_999_999_999)
|
||||
// 202 dates: 2 trimmed at each end, so the two wild ones go.
|
||||
assert.deepEqual(postedSpan(dates), [1_000_001, 1_000_198])
|
||||
|
||||
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
|
||||
const [node] = await db.table('nodes').insert({ name: 'final-node' }).returning('id')
|
||||
const [range] = await db
|
||||
.table('ranges')
|
||||
.insert({ group_id: g.id, start_id: 1, end_id: 1000, next_id: 1, status: 'leased', node_id: node.id, rule_version: 1 })
|
||||
.returning('id')
|
||||
const overviews = Array.from({ length: 10 }, (_, i) => {
|
||||
const date = new Date(Date.parse('2026-01-01T10:00:00Z') + i * 60_000).toUTCString().replace('GMT', '+0000')
|
||||
return [1 + i, { 'subject': `[1/1] - "Some.Thing.2026.rar" yEnc (${i + 1}/10)`, 'from': 'p <p@x>', 'date': date, 'message-id': `<f${i}@x>`, ':bytes': '1000', 'xref': `x ${GROUP}:${1 + i}` }] as Overview
|
||||
})
|
||||
const ix = new RangeIndexer({ group: GROUP, start: 1, end: 1000, rules: { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } } })
|
||||
ix.add(overviews, 11)
|
||||
await BatchStore.store(Number(range.id), Number(node.id), ix.take())
|
||||
const stored = await db.from('ranges').where('id', range.id).firstOrFail()
|
||||
assert.equal(new Date(stored.posted_low).toISOString(), '2026-01-01T10:00:00.000Z')
|
||||
assert.equal(new Date(stored.posted_high).toISOString(), '2026-01-01T10:09:00.000Z')
|
||||
})
|
||||
|
||||
test('a release is final once one block of done ranges covers its posting times and 6 hours either side', async ({ assert }) => {
|
||||
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
|
||||
const range = (start: number, status: string, low: string | null, high: string | null) =>
|
||||
db.table('ranges').insert({ group_id: g.id, start_id: start, end_id: start + 99_999, next_id: start, status, posted_low: low && at(low), posted_high: high && at(high) })
|
||||
await range(1, 'done', '2026-01-01T00:00:00Z', '2026-01-01T12:00:00Z')
|
||||
await range(100_001, 'done', '2026-01-01T12:00:00Z', '2026-01-01T23:00:00Z')
|
||||
await range(200_001, 'planned', null, null)
|
||||
await range(300_001, 'done', '2026-01-02T10:00:00Z', '2026-01-02T20:00:00Z')
|
||||
assert.equal(await RangeBlocks.rebuild(), 2)
|
||||
|
||||
let n = 0
|
||||
const release = async (first: number, posted: string, extra: Record<string, unknown> = {}) => {
|
||||
n++
|
||||
const key = `fc${n}`.padEnd(40, '0')
|
||||
const [row] = await db
|
||||
.table('releases')
|
||||
.insert({ release_key: key, identity_key: key, name: `Final.Test.${n}`, source: 'stem', groups: '[]', group_id: g.id, files: 3, file_total: 5, complete: false, first_article: first, last_article: first + 10, posted_at: at(posted), last_posted_at: at(posted), readable: true, settled_at: new Date(), ...extra })
|
||||
.returning('id')
|
||||
return Number(row.id)
|
||||
}
|
||||
const final = async (id: number) => {
|
||||
const r = await db.from('releases').where('id', id).firstOrFail()
|
||||
const { rows } = await db.rawQuery(`SELECT release_final(?, ?, ?, ?, ?, ?) AS final`, [r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete])
|
||||
return rows[0].final as boolean
|
||||
}
|
||||
|
||||
// Mid-block: final either way.
|
||||
const middle = await release(90_000, '2026-01-01T11:00:00Z')
|
||||
// In the first range: nothing older to wait for, although 6 hours before it isn't covered.
|
||||
const first = await release(1_000, '2026-01-01T02:00:00Z')
|
||||
// Near the block's end, the next range unread: its missing files may be there.
|
||||
const edge = await release(195_000, '2026-01-01T22:00:00Z')
|
||||
// In the island after the gap: the gap is before it.
|
||||
const island = await release(310_000, '2026-01-02T12:00:00Z')
|
||||
assert.isTrue(await final(middle))
|
||||
assert.isTrue(await final(first))
|
||||
assert.isFalse(await final(edge))
|
||||
// Complete there: every file found, nothing to wait for.
|
||||
assert.isTrue(await final(await release(196_000, '2026-01-01T22:30:00Z', { files: 5, complete: true, name: 'Complete.Edge' })))
|
||||
assert.isFalse(await final(island))
|
||||
|
||||
// Search: an incomplete release that isn't final isn't left out as incomplete; the API says which is final.
|
||||
const shown = await SearchService.search({ q: 'Final.Test', notIncomplete: true })
|
||||
assert.sameMembers(shown.releases.map((r) => r.id), [edge, island])
|
||||
assert.isTrue(shown.releases.every((r) => r.final === false))
|
||||
|
||||
// The gap read: one block, the edge release final (the island's 12:00 + 6 h is inside 20:00).
|
||||
await db.from('ranges').where('start_id', 200_001).update({ status: 'done', posted_low: at('2026-01-01T23:00:00Z'), posted_high: at('2026-01-02T10:00:00Z') })
|
||||
assert.equal(await RangeBlocks.rebuild(), 1)
|
||||
assert.isTrue(await final(edge))
|
||||
assert.isTrue(await final(island))
|
||||
assert.equal((await SearchService.search({ q: 'Final.Test', notIncomplete: true })).total, 0)
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
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 FragmentMerger from '#services/fragment_merger'
|
||||
import { RangeIndexer } from '#lib/edge/index'
|
||||
import { decodeChunk } from '#lib/chunks/index'
|
||||
import { DEFAULT_RULES } from '#lib/rules/index'
|
||||
import type { Overview } from '#lib/nntp/index'
|
||||
|
||||
const GROUP = 'alt.binaries.test'
|
||||
const rules = { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } }
|
||||
|
||||
function parts(name: string, total: number, numbers: number[], at: number): Overview[] {
|
||||
return numbers.map((n, i) => [at + i, {
|
||||
'subject': `Big.Upload.2020 [1/1] - "${name}" yEnc (${n}/${total})`, 'from': 'p <p@x>', 'date': 'Mon, 05 Oct 2026 10:00:00 +0000',
|
||||
'message-id': `<${name}-${n}@test>`, ':bytes': '700000', 'xref': `news.example ${GROUP}:${at + i}`,
|
||||
}] as Overview)
|
||||
}
|
||||
|
||||
/** Indexes one range's overviews and stores them as the range's only batch; the range ends done. */
|
||||
async function indexRange(groupId: number, nodeId: number, start: number, end: number, overviews: Overview[]) {
|
||||
const [r] = await db.table('ranges').insert({ group_id: groupId, start_id: start, end_id: end, next_id: start, status: 'leased', node_id: nodeId }).returning('id')
|
||||
const indexer = new RangeIndexer({ group: GROUP, start, end, rules, edgeArticles: 100 })
|
||||
indexer.add(overviews, end + 1)
|
||||
indexer.finish()
|
||||
await BatchStore.store(Number(r.id), nodeId, indexer.take())
|
||||
await db.from('ranges').where('id', r.id).update({ status: 'done' })
|
||||
return Number(r.id)
|
||||
}
|
||||
|
||||
test.group('FragmentMerger', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
|
||||
test('a file cut by a range edge is completed from both sides once both ranges are done', async ({ assert }) => {
|
||||
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1, low: 1 }).returning('id')
|
||||
const [n] = await db.table('nodes').insert({ name: 'merge-node' }).returning('id')
|
||||
// Parts 1–2 at the end of the first range (still open there), 3–4 at the start of the next.
|
||||
await indexRange(g.id, n.id, 1, 1000, parts('big.part1.rar', 4, [1, 2], 990))
|
||||
assert.equal((await db.from('fragments').count('* as c'))[0].c, '1')
|
||||
assert.include(await FragmentMerger.run(), { completed: 0, incomplete: 0, waiting: 1 })
|
||||
|
||||
await indexRange(g.id, n.id, 1001, 2000, parts('big.part1.rar', 4, [3, 4], 1001))
|
||||
assert.include(await FragmentMerger.run(), { completed: 1, incomplete: 0 })
|
||||
assert.equal((await db.from('fragments').count('* as c'))[0].c, '0')
|
||||
const release = await db.from('releases').first()
|
||||
assert.include(release, { files: 1, chunks: 1 })
|
||||
const chunk = await db.from('release_chunks').where('release_id', release.id).first()
|
||||
assert.deepEqual(decodeChunk(chunk.payload)[0].segments.map((s) => s[0]), [1, 2, 3, 4])
|
||||
assert.deepEqual((await db.from('probe_candidates')).map((c) => c.kind), ['archive'])
|
||||
})
|
||||
|
||||
test('a file still missing parts when all its neighbours are done is dropped', async ({ assert }) => {
|
||||
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1, low: 1 }).returning('id')
|
||||
const [n] = await db.table('nodes').insert({ name: 'merge-node' }).returning('id')
|
||||
await indexRange(g.id, n.id, 1, 1000, parts('gap.part1.rar', 5, [1, 2], 990))
|
||||
await indexRange(g.id, n.id, 1001, 2000, parts('gap.part1.rar', 5, [4, 5], 1001))
|
||||
// Its later neighbour isn't there yet: the missing part could still come.
|
||||
assert.include(await FragmentMerger.run(), { completed: 0, incomplete: 0, waiting: 1 })
|
||||
await indexRange(g.id, n.id, 2001, 3000, [])
|
||||
assert.include(await FragmentMerger.run(), { completed: 0, incomplete: 1 })
|
||||
assert.equal((await db.from('fragments').count('* as c'))[0].c, '0')
|
||||
assert.lengthOf(await db.from('releases'), 0)
|
||||
})
|
||||
})
|
||||
@@ -3,12 +3,13 @@ import db from '@adonisjs/lucid/services/db'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import { ReleaseJudge } from '#services/release_judge'
|
||||
import SearchService from '#services/search_service'
|
||||
import RangeBlocks from '#services/range_blocks'
|
||||
|
||||
test.group('ReleaseJudge', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
|
||||
test('judges dead, fake, repeated, locked and unusable releases, and takes a verdict back', async ({ assert }) => {
|
||||
test('judges dead, fake, repeated, locked and unusable releases, and takes a verdict back', async ({
|
||||
assert,
|
||||
}) => {
|
||||
let n = 0
|
||||
const settled = new Date(Date.now() - 3 * 86400_000)
|
||||
const release = async (name: string, extra: Record<string, unknown> = {}) => {
|
||||
@@ -16,19 +17,40 @@ test.group('ReleaseJudge', (group) => {
|
||||
const key = `jj${String(n).padStart(6, '0')}`.padEnd(40, '0')
|
||||
const [row] = await db
|
||||
.table('releases')
|
||||
.insert({ release_key: key, identity_key: key, name, source: 'stem', groups: '[]', group_id: 1, files: 3, posted_at: settled, readable: true, settled_at: settled, ...extra })
|
||||
.insert({
|
||||
release_key: key,
|
||||
identity_key: key,
|
||||
name,
|
||||
source: 'stem',
|
||||
groups: '[]',
|
||||
group_id: 1,
|
||||
files: 3,
|
||||
posted_at: settled,
|
||||
readable: true,
|
||||
settled_at: settled,
|
||||
...extra,
|
||||
})
|
||||
.returning('id')
|
||||
return Number(row.id)
|
||||
}
|
||||
let candidate = 9000
|
||||
const probe = async (releaseId: number, status: string) => {
|
||||
candidate++
|
||||
await db.table('probe_candidates').insert({ id: candidate, release_id: releaseId, kind: 'lone', message_id: `<${candidate}@x>`, file: 'x', status })
|
||||
await db.table('probe_candidates').insert({
|
||||
id: candidate,
|
||||
release_id: releaseId,
|
||||
kind: 'lone',
|
||||
message_id: `<${candidate}@x>`,
|
||||
file: 'x',
|
||||
status,
|
||||
})
|
||||
return candidate
|
||||
}
|
||||
const inspect = async (releaseId: number, kind: string, result: Record<string, unknown>) => {
|
||||
const id = await probe(releaseId, 'done')
|
||||
await db.table('release_inspections').insert({ release_id: releaseId, candidate_id: id, kind, result: JSON.stringify(result) })
|
||||
await db
|
||||
.table('release_inspections')
|
||||
.insert({ release_id: releaseId, candidate_id: id, kind, result: JSON.stringify(result) })
|
||||
}
|
||||
|
||||
const dead = await release('Gone.Movie.2020.1080p')
|
||||
@@ -39,7 +61,11 @@ test.group('ReleaseJudge', (group) => {
|
||||
await probe(halfDead, 'done')
|
||||
|
||||
const fakeArchive = await release('Das.A-Team.S02E23.Das.Spiel.ist.aus')
|
||||
await inspect(fakeArchive, 'archive', { type: 'rar4', names: ['Das.A-Team.S02E23.Das.Spiel.ist.aus.exe'], entries: 1 })
|
||||
await inspect(fakeArchive, 'archive', {
|
||||
type: 'rar4',
|
||||
names: ['Das.A-Team.S02E23.Das.Spiel.ist.aus.exe'],
|
||||
entries: 1,
|
||||
})
|
||||
const fakeFile = await release('Some.Film.2021.1080p.WEB-DL')
|
||||
// Only "H.264" and "WEB-DL" say it's media here (a "?" in the pattern never matched).
|
||||
const fakeCodec = await release('Some.Show.H.264.WEB-DL')
|
||||
@@ -54,34 +80,71 @@ test.group('ReleaseJudge', (group) => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const id = await release(`kantodo${i}`)
|
||||
repeatedIds.push(id)
|
||||
await db.table('file_hashes').insert({ candidate_id: 20000 + i, release_id: id, hash16k: hash, size: 1073741824 })
|
||||
await db
|
||||
.table('file_hashes')
|
||||
.insert({ candidate_id: 20000 + i, release_id: id, hash16k: hash, size: 1073741824 })
|
||||
}
|
||||
|
||||
const scraped = await release('Jamie Lee Curtis Nude Celebrity Pictures', { poster: '@_CelebMovie_@', source: 'subject' })
|
||||
const celeborn = await release('Some.Album.2010', { poster: 'celeborn@not.here (Celeborn)', source: 'subject' })
|
||||
const scraped = await release('Jamie Lee Curtis Nude Celebrity Pictures', {
|
||||
poster: '@_CelebMovie_@',
|
||||
source: 'subject',
|
||||
})
|
||||
const celeborn = await release('Some.Album.2010', {
|
||||
poster: 'celeborn@not.here (Celeborn)',
|
||||
source: 'subject',
|
||||
})
|
||||
// Scraped posters are found through posters (the batch store adds each release's).
|
||||
await db.table('posters').multiInsert([{ poster: '@_CelebMovie_@' }, { poster: 'celeborn@not.here (Celeborn)' }])
|
||||
await db
|
||||
.table('posters')
|
||||
.multiInsert([{ poster: '@_CelebMovie_@' }, { poster: 'celeborn@not.here (Celeborn)' }])
|
||||
const locked = await release('Locked.Thing', { passworded: true })
|
||||
|
||||
const unlocked = await release('Known.Password', { passworded: true })
|
||||
await db.table('release_passwords').insert({ release_id: unlocked, password: 'pw', source: 'spot-text' })
|
||||
await db
|
||||
.table('release_passwords')
|
||||
.insert({ release_id: unlocked, password: 'pw', source: 'spot-text' })
|
||||
|
||||
// A block of done ranges around the unusable ones (release_final); one beside an unread range isn't judged yet.
|
||||
await db.table('groups').insert({ id: 1, name: 'alt.binaries.judged', priority: 1 }).onConflict('id').ignore()
|
||||
await db
|
||||
.table('groups')
|
||||
.insert({ id: 1, name: 'alt.binaries.judged', priority: 1 })
|
||||
.onConflict('id')
|
||||
.ignore()
|
||||
await db.table('ranges').multiInsert([
|
||||
{ group_id: 1, start_id: 1, end_id: 1000, next_id: 1001, status: 'done', posted_low: new Date(settled.getTime() - 86400_000), posted_high: new Date(settled.getTime() + 86400_000) },
|
||||
{
|
||||
group_id: 1,
|
||||
start_id: 1,
|
||||
end_id: 1000,
|
||||
next_id: 1001,
|
||||
status: 'done',
|
||||
posted_low: new Date(settled.getTime() - 86400_000),
|
||||
posted_high: new Date(settled.getTime() + 86400_000),
|
||||
},
|
||||
{ group_id: 1, start_id: 1001, end_id: 2000, next_id: 1001, status: 'planned' },
|
||||
])
|
||||
await RangeBlocks.rebuild()
|
||||
const placed = { first_article: 100, last_article: 110 }
|
||||
const unusable = await release('ab12CD34ef56GH78ij90', { readable: false, ...placed })
|
||||
const pending = await release('pp12CD34ef56GH78ij90', { readable: false, first_article: 990, last_article: 1000, posted_at: new Date(settled.getTime() + 86400_000 - 3600_000) })
|
||||
const fresh = await release('zz12CD34ef56GH78ij90', { readable: false, settled_at: new Date(), ...placed })
|
||||
const pending = await release('pp12CD34ef56GH78ij90', {
|
||||
readable: false,
|
||||
first_article: 990,
|
||||
last_article: 1000,
|
||||
posted_at: new Date(settled.getTime() + 86400_000 - 3600_000),
|
||||
})
|
||||
const fresh = await release('zz12CD34ef56GH78ij90', {
|
||||
readable: false,
|
||||
settled_at: new Date(),
|
||||
...placed,
|
||||
})
|
||||
const spotted = await release('yy12CD34ef56GH78ij90', { readable: false, ...placed })
|
||||
await db.table('release_spots').insert({ release_id: spotted, spot_id: 's@x', files: 1 })
|
||||
|
||||
const round = await new ReleaseJudge().full()
|
||||
const verdicts = new Map((await db.from('release_verdicts').select('release_id', 'rule')).map((v) => [Number(v.release_id), v.rule]))
|
||||
const verdicts = new Map(
|
||||
(await db.from('release_verdicts').select('release_id', 'rule')).map((v) => [
|
||||
Number(v.release_id),
|
||||
v.rule,
|
||||
])
|
||||
)
|
||||
assert.equal(verdicts.get(dead), 'dead')
|
||||
assert.isUndefined(verdicts.get(halfDead))
|
||||
assert.equal(verdicts.get(fakeArchive), 'fake')
|
||||
@@ -106,7 +169,9 @@ test.group('ReleaseJudge', (group) => {
|
||||
assert.equal((await SearchService.search({ q: 'Locked', judged: true })).total, 1)
|
||||
|
||||
// A password found: the next round takes the verdict back.
|
||||
await db.table('release_passwords').insert({ release_id: locked, password: 'found', source: 'nzb' })
|
||||
await db
|
||||
.table('release_passwords')
|
||||
.insert({ release_id: locked, password: 'found', source: 'nzb' })
|
||||
const again = await new ReleaseJudge().full()
|
||||
assert.equal(again.rules.locked.removed, 1)
|
||||
// Verdicts that stay are not written again.
|
||||
@@ -115,7 +180,9 @@ test.group('ReleaseJudge', (group) => {
|
||||
assert.isNull(await db.from('release_verdicts').where('release_id', locked).first())
|
||||
})
|
||||
|
||||
test('judges the releases whose facts changed, and those a hash of theirs tipped over', async ({ assert }) => {
|
||||
test('judges the releases whose facts changed, and those a hash of theirs tipped over', async ({
|
||||
assert,
|
||||
}) => {
|
||||
let n = 0
|
||||
const settled = new Date(Date.now() - 3 * 86400_000)
|
||||
const release = async (name: string, extra: Record<string, unknown> = {}) => {
|
||||
@@ -123,12 +190,24 @@ test.group('ReleaseJudge', (group) => {
|
||||
const key = `jc${String(n).padStart(6, '0')}`.padEnd(40, '0')
|
||||
const [row] = await db
|
||||
.table('releases')
|
||||
.insert({ release_key: key, identity_key: key, name, source: 'stem', groups: '[]', files: 3, posted_at: settled, readable: true, settled_at: settled, ...extra })
|
||||
.insert({
|
||||
release_key: key,
|
||||
identity_key: key,
|
||||
name,
|
||||
source: 'stem',
|
||||
groups: '[]',
|
||||
files: 3,
|
||||
posted_at: settled,
|
||||
readable: true,
|
||||
settled_at: settled,
|
||||
...extra,
|
||||
})
|
||||
.returning('id')
|
||||
return Number(row.id)
|
||||
}
|
||||
const judge = new ReleaseJudge()
|
||||
const verdict = async (id: number) => (await db.from('release_verdicts').where('release_id', id).first())?.rule
|
||||
const verdict = async (id: number) =>
|
||||
(await db.from('release_verdicts').where('release_id', id).first())?.rule
|
||||
const dirty = async () => Number((await db.from('judge_dirty').count('* as n'))[0].n)
|
||||
|
||||
// New releases are noted; a passworded one is locked, until a password turns up.
|
||||
@@ -138,20 +217,31 @@ test.group('ReleaseJudge', (group) => {
|
||||
assert.equal(await verdict(locked), 'locked')
|
||||
assert.equal(round.rules.locked.added, 1)
|
||||
assert.equal(await dirty(), 0)
|
||||
await db.table('release_passwords').insert({ release_id: locked, password: 'pw', source: 'nzb' })
|
||||
await db
|
||||
.table('release_passwords')
|
||||
.insert({ release_id: locked, password: 'pw', source: 'nzb' })
|
||||
round = await judge.changed()
|
||||
assert.isUndefined(await verdict(locked))
|
||||
assert.equal(round.rules.locked.removed, 1)
|
||||
|
||||
// Probes: a release whose probed articles are all missing is dead once the prober's statuses come in.
|
||||
const dead = await release('Gone.Movie.2020.1080p')
|
||||
const ids = await db.table('probe_candidates').multiInsert([
|
||||
{ release_id: dead, kind: 'lone', message_id: '<jc1@x>', file: 'a', status: 'waiting' },
|
||||
{ release_id: dead, kind: 'lone', message_id: '<jc2@x>', file: 'b', status: 'waiting' },
|
||||
]).returning('id')
|
||||
const ids = await db
|
||||
.table('probe_candidates')
|
||||
.multiInsert([
|
||||
{ release_id: dead, kind: 'lone', message_id: '<jc1@x>', file: 'a', status: 'waiting' },
|
||||
{ release_id: dead, kind: 'lone', message_id: '<jc2@x>', file: 'b', status: 'waiting' },
|
||||
])
|
||||
.returning('id')
|
||||
await judge.changed()
|
||||
assert.isUndefined(await verdict(dead))
|
||||
await db.from('probe_candidates').whereIn('id', ids.map((r: { id: string }) => r.id)).update({ status: 'missing' })
|
||||
await db
|
||||
.from('probe_candidates')
|
||||
.whereIn(
|
||||
'id',
|
||||
ids.map((r: { id: string }) => r.id)
|
||||
)
|
||||
.update({ status: 'missing' })
|
||||
await judge.changed()
|
||||
assert.equal(await verdict(dead), 'dead')
|
||||
|
||||
@@ -159,13 +249,19 @@ test.group('ReleaseJudge', (group) => {
|
||||
// comes first), though only the 20th changed.
|
||||
const hash = 'b'.repeat(32)
|
||||
const holders: number[] = []
|
||||
for (let i = 0; i < 19; i++) holders.push(await release(`holder${i}`, i === 0 ? { passworded: true } : {}))
|
||||
for (const [i, id] of holders.entries()) await db.table('file_hashes').insert({ candidate_id: 30000 + i, release_id: id, hash16k: hash, size: 5000 })
|
||||
for (let i = 0; i < 19; i++)
|
||||
holders.push(await release(`holder${i}`, i === 0 ? { passworded: true } : {}))
|
||||
for (const [i, id] of holders.entries())
|
||||
await db
|
||||
.table('file_hashes')
|
||||
.insert({ candidate_id: 30000 + i, release_id: id, hash16k: hash, size: 5000 })
|
||||
await judge.changed()
|
||||
assert.equal(await verdict(holders[0]), 'locked')
|
||||
assert.isUndefined(await verdict(holders[1]))
|
||||
const twentieth = await release('holder19')
|
||||
await db.table('file_hashes').insert({ candidate_id: 30019, release_id: twentieth, hash16k: hash, size: 5000 })
|
||||
await db
|
||||
.table('file_hashes')
|
||||
.insert({ candidate_id: 30019, release_id: twentieth, hash16k: hash, size: 5000 })
|
||||
round = await judge.changed()
|
||||
assert.equal(round.releases, 20)
|
||||
const rules = new Set()
|
||||
@@ -173,7 +269,9 @@ test.group('ReleaseJudge', (group) => {
|
||||
assert.deepEqual([...rules], ['repeated'])
|
||||
// A 21st: the file was in 20 releases without it, so only it is judged.
|
||||
const twentyFirst = await release('holder20')
|
||||
await db.table('file_hashes').insert({ candidate_id: 30020, release_id: twentyFirst, hash16k: hash, size: 5000 })
|
||||
await db
|
||||
.table('file_hashes')
|
||||
.insert({ candidate_id: 30020, release_id: twentyFirst, hash16k: hash, size: 5000 })
|
||||
round = await judge.changed()
|
||||
assert.equal(round.releases, 1)
|
||||
assert.equal(await verdict(twentyFirst), 'repeated')
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { test } from '@japa/runner'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import Settler from '#services/settler'
|
||||
|
||||
test.group('Settler', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
|
||||
test('settles quiet releases: complete by the [i/N] counter, unknown without one', async ({ assert }) => {
|
||||
const old = new Date(Date.now() - 7 * 3600 * 1000)
|
||||
const row = (key: string, files: number, fileTotal: number | null, lastSeen: Date) => ({
|
||||
release_key: key.padEnd(40, '0'), identity_key: key.padEnd(40, '0'), name: key, source: 'subject', groups: '[]', files, file_total: fileTotal, last_seen_at: lastSeen,
|
||||
})
|
||||
await db.table('releases').multiInsert([row('a', 10, 10, old), row('b', 8, 10, old), row('c', 3, null, old), row('d', 10, 10, new Date())])
|
||||
assert.equal(await Settler.run(), 3)
|
||||
const byName = Object.fromEntries((await db.from('releases').select('name', 'complete', 'settled_at')).map((r) => [r.name, [r.complete, r.settled_at !== null]]))
|
||||
assert.deepEqual(byName, { a: [true, true], b: [false, true], c: [null, true], d: [null, false] })
|
||||
})
|
||||
|
||||
test('settles history releases (posted long ago) once unchanged for half an hour', async ({ assert }) => {
|
||||
const posted = new Date('2015-01-01T00:00:00Z')
|
||||
const row = (key: string, lastSeen: Date, lastPosted: Date) => ({
|
||||
release_key: key.padEnd(40, '0'), identity_key: key.padEnd(40, '0'), name: key, source: 'subject', groups: '[]', files: 1, last_seen_at: lastSeen, posted_at: lastPosted, last_posted_at: lastPosted,
|
||||
})
|
||||
const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000)
|
||||
await db.table('releases').multiInsert([row('old', minutesAgo(31), posted), row('fresh', minutesAgo(5), posted), row('tail', minutesAgo(31), minutesAgo(40))])
|
||||
const ids = Object.fromEntries((await db.from('releases').select('id', 'name')).map((r) => [r.name, r.id]))
|
||||
await db.table('probe_candidates').multiInsert([
|
||||
{ release_id: ids.old, kind: 'lone', message_id: '<a@x>', file: 'a' },
|
||||
{ release_id: ids.fresh, kind: 'lone', message_id: '<b@x>', file: 'b' },
|
||||
])
|
||||
assert.equal(await Settler.run(), 1)
|
||||
// The settled release's candidates are claimable now, as unreadable (readable defaults to false).
|
||||
const ready = await db.from('probe_candidates').orderBy('message_id').select('ready', 'unreadable')
|
||||
assert.deepEqual(ready.map((r) => [r.ready, r.unreadable]), [[true, true], [false, false]])
|
||||
const settled = (await db.from('releases').whereNotNull('settled_at').select('name')).map((r) => r.name)
|
||||
assert.deepEqual(settled, ['old'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test } from '@japa/runner'
|
||||
import { SOURCES, sourceCode, sourceName } from '#lib/release/source'
|
||||
|
||||
test.group('release source codes', () => {
|
||||
test('names and codes map both ways; the codes are fixed', ({ assert }) => {
|
||||
assert.deepEqual(SOURCES, { subject: 1, post: 2, stem: 3, spot: 4, nzb: 5, album: 6 })
|
||||
for (const [name, code] of Object.entries(SOURCES)) {
|
||||
assert.equal(sourceCode(name), code)
|
||||
assert.equal(sourceName(code), name)
|
||||
assert.equal(sourceName(String(code)), name)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unknown name throws, an unknown code reads as unknown', ({ assert }) => {
|
||||
assert.throws(() => sourceCode('subjekt'), /Unknown release source/)
|
||||
assert.equal(sourceName(99), 'unknown')
|
||||
assert.equal(sourceName(null), 'unknown')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user