Edge aggregation: seed:range indexes history one row per file
A seeding node fetches a range over one NNTP connection, parses headers with the live rules, groups them into files in memory (lib/aggregate) and stores per complete file its release, the file and a completed binary (SeedService), instead of one parts row per article. What doesn't complete within the window goes through the regular ingest path as fragments. `--dry-run` only fetches and aggregates, to measure a node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7ca338d4f3
commit
68eb9a44fd
5 files changed
+434
No files matched your search
@@ -0,0 +1,107 @@
|
|||||||
|
import db from '@adonisjs/lucid/services/db'
|
||||||
|
import type { AggregatedFile } from '#lib/aggregate/index'
|
||||||
|
import IngestService, { type IngestSegment } from '#services/IngestService'
|
||||||
|
import FileStore from '#services/FileStore'
|
||||||
|
import SfvService from '#services/SfvService'
|
||||||
|
import ContentService from '#services/ContentService'
|
||||||
|
import QueueService from '#services/QueueService'
|
||||||
|
|
||||||
|
export interface SeedStoreResult {
|
||||||
|
files: number
|
||||||
|
/** Files that existed already (same file_key, e.g. from live indexing or a crosspost). */
|
||||||
|
existing: number
|
||||||
|
releases: number
|
||||||
|
inspections: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores files an edge node aggregated from headers (CONCEPT.md D37): their
|
||||||
|
* releases, the files with their segment lists, and one completed binary per
|
||||||
|
* file, as worker:file would have left it, so par2 naming, inspection and GC
|
||||||
|
* treat them like any other file. A batch is a few statements, not one per
|
||||||
|
* article.
|
||||||
|
*/
|
||||||
|
class SeedService {
|
||||||
|
async store(files: AggregatedFile<IngestSegment>[]): Promise<SeedStoreResult> {
|
||||||
|
const result: SeedStoreResult = { files: 0, existing: 0, releases: 0, inspections: 0 }
|
||||||
|
if (files.length === 0) return result
|
||||||
|
const knex = db.connection().getWriteClient()
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
// A file's identity and release come from its first segment, its date from the earliest.
|
||||||
|
const firstOf = (file: AggregatedFile<IngestSegment>) => file.segments[0]
|
||||||
|
const postedOf = (file: AggregatedFile<IngestSegment>) => {
|
||||||
|
const dates = file.segments.map((s) => s.postedAt?.getTime()).filter((t): t is number => Number.isFinite(t))
|
||||||
|
return dates.length > 0 ? new Date(Math.min(...dates)) : undefined
|
||||||
|
}
|
||||||
|
const releaseIds = await IngestService.upsertReleases(
|
||||||
|
knex,
|
||||||
|
files.map((file) => ({ ...firstOf(file), postedAt: postedOf(file) })),
|
||||||
|
now
|
||||||
|
)
|
||||||
|
result.releases = releaseIds.size
|
||||||
|
|
||||||
|
const rows = files.map((file) => {
|
||||||
|
const first = firstOf(file)
|
||||||
|
const postedAt = postedOf(file)
|
||||||
|
return {
|
||||||
|
file_key: file.fileKey,
|
||||||
|
filename: first.name,
|
||||||
|
poster: first.poster,
|
||||||
|
date: (postedAt ?? now).getTime(),
|
||||||
|
parts: file.segments.length,
|
||||||
|
segments: file.segments.map((s) => ({ number: s.number, id: s.messageId, size: s.bytes })),
|
||||||
|
groups: JSON.stringify(first.groups),
|
||||||
|
release_id: first.release ? (releaseIds.get(first.release.key) ?? null) : null,
|
||||||
|
size: file.segments.reduce((sum, s) => sum + s.bytes, 0),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const inserted = await FileStore.insert(rows)
|
||||||
|
result.files = inserted.length
|
||||||
|
result.existing = rows.length - inserted.length
|
||||||
|
|
||||||
|
const fileIds = new Map<string, number>()
|
||||||
|
for (const row of await db.from('files').whereIn('file_key', rows.map((r) => r.file_key)).select('id', 'file_key')) {
|
||||||
|
fileIds.set(row.file_key, Number(row.id))
|
||||||
|
}
|
||||||
|
// The completed binary worker:file would have left behind; an existing one just learns its file.
|
||||||
|
const binaries = rows.map((row, i) => ({
|
||||||
|
file_key: row.file_key,
|
||||||
|
poster: row.poster,
|
||||||
|
subject: firstOf(files[i]).subject,
|
||||||
|
name: row.filename,
|
||||||
|
total_parts: firstOf(files[i]).totalParts,
|
||||||
|
groups: row.groups,
|
||||||
|
posted_at: postedOf(files[i]) ?? null,
|
||||||
|
first_seen_at: now,
|
||||||
|
last_seen_at: now,
|
||||||
|
completed_at: now,
|
||||||
|
release_id: row.release_id,
|
||||||
|
file_id: fileIds.get(row.file_key) ?? null,
|
||||||
|
}))
|
||||||
|
binaries.sort((a, b) => a.file_key.localeCompare(b.file_key))
|
||||||
|
await knex('binaries')
|
||||||
|
.insert(binaries)
|
||||||
|
.onConflict('file_key')
|
||||||
|
.merge({ file_id: knex.raw('coalesce(binaries.file_id, excluded.file_id)') as any })
|
||||||
|
|
||||||
|
// As worker:file does for a new file: an SFV posted earlier may list it (D34), then inspection.
|
||||||
|
const rowById = new Map(rows.map((row) => [fileIds.get(row.file_key), row]))
|
||||||
|
const candidates: { name: string; data: { fileId: number }; opts: { jobId: string } }[] = []
|
||||||
|
for (const file of inserted) {
|
||||||
|
const row = rowById.get(file.id)
|
||||||
|
if (!row) continue
|
||||||
|
await SfvService.claim({ id: file.id, name: row.filename, poster: row.poster, date: row.date, release_id: row.release_id })
|
||||||
|
if (ContentService.isCandidate(row.filename)) {
|
||||||
|
candidates.push({ name: 'process-collection', data: { fileId: file.id }, opts: { jobId: `file-${file.id}` } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length > 0) await QueueService.collectionQueue.addBulk(candidates)
|
||||||
|
result.inspections = candidates.length
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new SeedService()
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
|
||||||
|
import { CommandOptions } from '@adonisjs/core/types/ace'
|
||||||
|
import NntpService from '#services/NntpService'
|
||||||
|
import IngestService, { segmentFromHeader, type IngestSegment } from '#services/IngestService'
|
||||||
|
import StagingService from '#services/StagingService'
|
||||||
|
import SeedService from '#services/SeedService'
|
||||||
|
import QueueService from '#services/QueueService'
|
||||||
|
import { isDeferred } from '#services/DeferredService'
|
||||||
|
import { FileAggregator } from '#lib/aggregate/index'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge aggregation (CONCEPT.md D37): indexes a range of a group's history
|
||||||
|
* over one NNTP connection, grouping headers into files in memory and
|
||||||
|
* storing one row per file. Files cut by the range edges, or that never
|
||||||
|
* complete, go through the regular ingest path as fragments. Header-only,
|
||||||
|
* like the backfill: obfuscated subjects are counted, not downloaded.
|
||||||
|
*
|
||||||
|
* node ace seed:range alt.binaries.ath --start 46000000000 --count 2000000
|
||||||
|
* node ace seed:range alt.binaries.ath --start 46000000000 --count 2000000 --dry-run
|
||||||
|
*/
|
||||||
|
export default class SeedRange extends BaseCommand {
|
||||||
|
public static commandName = 'seed:range'
|
||||||
|
public static description = "Indexes a range of a group's headers by aggregating them into files on this node."
|
||||||
|
|
||||||
|
public static options: CommandOptions = {
|
||||||
|
startApp: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
@args.string({ description: 'Group' })
|
||||||
|
declare group: string
|
||||||
|
|
||||||
|
@flags.string({ description: 'First article number' })
|
||||||
|
declare start: string
|
||||||
|
|
||||||
|
@flags.number({ description: 'Articles to index', default: 1_000_000 })
|
||||||
|
declare count: number
|
||||||
|
|
||||||
|
@flags.number({ description: 'Articles per XOVER', default: 20_000 })
|
||||||
|
declare chunk: number
|
||||||
|
|
||||||
|
@flags.number({ description: 'Batches without a new part before an open file is given up as fragments', default: 5 })
|
||||||
|
declare idle: number
|
||||||
|
|
||||||
|
@flags.boolean({ description: 'Fetch and aggregate only; store nothing' })
|
||||||
|
declare dryRun: boolean
|
||||||
|
|
||||||
|
public async run() {
|
||||||
|
const first = Number(this.start)
|
||||||
|
const last = first + this.count - 1
|
||||||
|
if (!Number.isSafeInteger(first) || first < 1) throw new Error(`--start must be an article number, got ${this.start}`)
|
||||||
|
const aggregator = new FileAggregator<IngestSegment>()
|
||||||
|
const stats = { headers: 0, segments: 0, obfuscated: 0, deferred: 0, complete: 0, files: 0, existing: 0, fragments: 0, inspections: 0 }
|
||||||
|
const time = { fetch: 0, parse: 0, store: 0 }
|
||||||
|
const cpuStart = process.cpuUsage()
|
||||||
|
const started = Date.now()
|
||||||
|
|
||||||
|
const conn = await NntpService.acquire()
|
||||||
|
let failed = false
|
||||||
|
try {
|
||||||
|
await NntpService.timed(conn.group(this.group), `GROUP ${this.group}`)
|
||||||
|
const fetch = async (from: number) => {
|
||||||
|
const to = Math.min(from + this.chunk - 1, last)
|
||||||
|
const t = Date.now()
|
||||||
|
const reply = await NntpService.timed<any>(conn.xover(from, to), `XOVER ${this.group} ${from}-${to}`)
|
||||||
|
time.fetch += Date.now() - t
|
||||||
|
return reply.overviews.filter(([n, h]: [number | null, Record<string, string>]) => n !== null && h?.['message-id'])
|
||||||
|
}
|
||||||
|
|
||||||
|
// One XOVER in flight while the previous batch is parsed and stored.
|
||||||
|
let from = first
|
||||||
|
const prefetch = (at: number) => {
|
||||||
|
const promise = fetch(at)
|
||||||
|
// Awaited after the current batch is stored; until then a failure must not count as unhandled.
|
||||||
|
promise.catch(() => {})
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
let next: Promise<[number, Record<string, string>][]> | null = prefetch(from)
|
||||||
|
while (next) {
|
||||||
|
const overviews = await next
|
||||||
|
from += this.chunk
|
||||||
|
next = from <= last ? prefetch(from) : null
|
||||||
|
|
||||||
|
let t = Date.now()
|
||||||
|
const segments: IngestSegment[] = []
|
||||||
|
for (const [articleNumber, header] of overviews) {
|
||||||
|
const segment = segmentFromHeader(header, this.group, articleNumber)
|
||||||
|
if (segment) segments.push(segment)
|
||||||
|
else if (isDeferred(header)) stats.deferred++
|
||||||
|
else stats.obfuscated++
|
||||||
|
}
|
||||||
|
stats.headers += overviews.length
|
||||||
|
stats.segments += segments.length
|
||||||
|
const complete = aggregator.add(segments)
|
||||||
|
const fragments = aggregator.evict(this.idle)
|
||||||
|
stats.complete += complete.length
|
||||||
|
stats.fragments += fragments.length
|
||||||
|
time.parse += Date.now() - t
|
||||||
|
|
||||||
|
t = Date.now()
|
||||||
|
if (!this.dryRun) await this.store(complete, fragments, stats)
|
||||||
|
time.store += Date.now() - t
|
||||||
|
this.progress(stats, started)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = aggregator.evict(0)
|
||||||
|
stats.fragments += rest.length
|
||||||
|
if (!this.dryRun) await this.store([], rest, stats)
|
||||||
|
} catch (error) {
|
||||||
|
failed = true
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
NntpService.release(conn, failed)
|
||||||
|
await NntpService.shutdown()
|
||||||
|
await QueueService.closeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = (Date.now() - started) / 1000
|
||||||
|
const cpu = process.cpuUsage(cpuStart)
|
||||||
|
this.logger.info(
|
||||||
|
`${this.group} ${first}-${last}: ${stats.headers.toLocaleString('en')} headers in ${seconds.toFixed(0)} s ` +
|
||||||
|
`(${Math.round(stats.headers / seconds).toLocaleString('en')}/s). ` +
|
||||||
|
`${stats.segments.toLocaleString('en')} readable, ${stats.obfuscated.toLocaleString('en')} obfuscated, ${stats.deferred.toLocaleString('en')} deferred. ` +
|
||||||
|
`${stats.complete.toLocaleString('en')} complete files (${stats.files.toLocaleString('en')} new, ${stats.existing.toLocaleString('en')} known), ` +
|
||||||
|
`${stats.fragments.toLocaleString('en')} fragment segments, ${stats.inspections.toLocaleString('en')} queued for inspection.`
|
||||||
|
)
|
||||||
|
this.logger.info(
|
||||||
|
`Time: fetch ${(time.fetch / 1000).toFixed(0)} s (overlapped), parse+aggregate ${(time.parse / 1000).toFixed(0)} s, ` +
|
||||||
|
`store ${(time.store / 1000).toFixed(0)} s; node CPU ${((cpu.user + cpu.system) / 1e6).toFixed(0)} s.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async store(complete: Parameters<typeof SeedService.store>[0], fragments: IngestSegment[], stats: Record<string, number>) {
|
||||||
|
const stored = await SeedService.store(complete)
|
||||||
|
stats.files += stored.files
|
||||||
|
stats.existing += stored.existing
|
||||||
|
stats.inspections += stored.inspections
|
||||||
|
if (fragments.length === 0) return
|
||||||
|
// The regular path: staging (D35) for lone segments, binaries and parts for the rest.
|
||||||
|
const { ingest } = StagingService.enabled(this.group)
|
||||||
|
? await StagingService.filter(fragments, { counters: StagingService.countersEnabled(this.group) })
|
||||||
|
: { ingest: fragments }
|
||||||
|
await IngestService.ingest(ingest)
|
||||||
|
}
|
||||||
|
|
||||||
|
private lastReport = 0
|
||||||
|
private progress(stats: Record<string, number>, started: number) {
|
||||||
|
if (Date.now() - this.lastReport < 30_000) return
|
||||||
|
this.lastReport = Date.now()
|
||||||
|
const seconds = (Date.now() - started) / 1000
|
||||||
|
this.logger.info(
|
||||||
|
`${stats.headers.toLocaleString('en')} headers (${Math.round(stats.headers / seconds).toLocaleString('en')}/s), ` +
|
||||||
|
`${stats.complete.toLocaleString('en')} files, ${stats.fragments.toLocaleString('en')} fragment segments.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Edge aggregation (CONCEPT.md D37): groups header segments into files in
|
||||||
|
* memory, so a seeding node writes one row per file instead of one per
|
||||||
|
* article. A file is done once every part number from 1 to its total has
|
||||||
|
* an article; what never completes within the node's window (an upload cut
|
||||||
|
* by the range edges, a random subject per article) leaves as fragments
|
||||||
|
* for the regular ingest path, where it can meet its other parts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What the aggregator needs of a segment (IngestSegment has all of it). */
|
||||||
|
export interface AggregatableSegment {
|
||||||
|
fileKey: string
|
||||||
|
number: number
|
||||||
|
totalParts: number
|
||||||
|
messageId: string
|
||||||
|
bytes: 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). */
|
||||||
|
segments: S[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenFile<S> {
|
||||||
|
parts: Map<number, S>
|
||||||
|
totalParts: number
|
||||||
|
/** Batch in which a segment last arrived. */
|
||||||
|
touched: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileAggregator<S extends AggregatableSegment> {
|
||||||
|
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[] = []
|
||||||
|
|
||||||
|
/** Adds one batch of segments; returns the files they completed. */
|
||||||
|
add(segments: S[]): AggregatedFile<S>[] {
|
||||||
|
this.batch++
|
||||||
|
const touched = new Set<string>()
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (segment.number < 1 || segment.number > segment.totalParts) {
|
||||||
|
this.stray.push(segment)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let file = this.open.get(segment.fileKey)
|
||||||
|
if (!file) {
|
||||||
|
file = { parts: new Map(), totalParts: segment.totalParts, touched: this.batch }
|
||||||
|
this.open.set(segment.fileKey, file)
|
||||||
|
}
|
||||||
|
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) })
|
||||||
|
}
|
||||||
|
return complete
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives up on files that got no segment in the last `idleBatches` batches
|
||||||
|
* (all open files when 0) and returns their segments, plus stray ones.
|
||||||
|
*/
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Files still waiting for parts. */
|
||||||
|
get openFiles(): number {
|
||||||
|
return this.open.size
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { test } from '@japa/runner'
|
||||||
|
import db from '@adonisjs/lucid/services/db'
|
||||||
|
import testUtils from '@adonisjs/core/services/test_utils'
|
||||||
|
import { segmentFromHeader, type IngestSegment } from '#services/IngestService'
|
||||||
|
import SeedService from '#services/SeedService'
|
||||||
|
import QueueService from '#services/QueueService'
|
||||||
|
import { FileAggregator } from '#lib/aggregate/index'
|
||||||
|
import { segmentsOf } from '#services/FileStore'
|
||||||
|
|
||||||
|
function header(subject: string, n: number) {
|
||||||
|
return {
|
||||||
|
'subject': subject,
|
||||||
|
'from': 'seeder <seed@example.invalid>',
|
||||||
|
'date': 'Mon, 05 Oct 2026 10:00:00 +0000',
|
||||||
|
'message-id': `<seed-${n}-${subject.length}@test>`,
|
||||||
|
':bytes': '700000',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.group('SeedService', (group) => {
|
||||||
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||||
|
group.teardown(() => QueueService.closeAll())
|
||||||
|
|
||||||
|
test('stores aggregated files with their release and a completed binary', async ({ assert }) => {
|
||||||
|
const aggregator = new FileAggregator<IngestSegment>()
|
||||||
|
const subjects = [1, 2].map((i) => `Seed.Test.S01E01.1080p - [1/2] - "Seed.Test.S01E01.1080p.part${i}.mkv" yEnc (%d/3)`)
|
||||||
|
const segments = subjects.flatMap((s) => [1, 2, 3].map((n) => segmentFromHeader(header(s.replace('%d', String(n)), n), 'alt.binaries.test', n)!))
|
||||||
|
const files = aggregator.add(segments)
|
||||||
|
assert.lengthOf(files, 2)
|
||||||
|
|
||||||
|
const result = await SeedService.store(files)
|
||||||
|
assert.include(result, { files: 2, existing: 0, releases: 1 })
|
||||||
|
|
||||||
|
const stored = await db.from('files').where('filename', 'Seed.Test.S01E01.1080p.part1.mkv').first()
|
||||||
|
assert.equal(stored.parts, 3)
|
||||||
|
assert.deepEqual(segmentsOf(stored).map((s) => s.number), [1, 2, 3])
|
||||||
|
const release = await db.from('releases').where('id', stored.release_id).first()
|
||||||
|
assert.equal(release.name, segments[0].release!.name) // named as live indexing would
|
||||||
|
const binary = await db.from('binaries').where('file_key', stored.file_key).first()
|
||||||
|
assert.equal(Number(binary.file_id), Number(stored.id))
|
||||||
|
assert.isNotNull(binary.completed_at)
|
||||||
|
|
||||||
|
// The same files again (a crosspost, a second run): nothing new.
|
||||||
|
const again = await SeedService.store(files)
|
||||||
|
assert.include(again, { files: 0, existing: 2 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { test } from '@japa/runner'
|
||||||
|
import { FileAggregator, type AggregatableSegment } from '#lib/aggregate/index'
|
||||||
|
|
||||||
|
const seg = (fileKey: string, number: number, totalParts: number, messageId = `${fileKey}-${number}@test`): AggregatableSegment => ({
|
||||||
|
fileKey,
|
||||||
|
number,
|
||||||
|
totalParts,
|
||||||
|
messageId,
|
||||||
|
bytes: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
test.group('aggregate / FileAggregator', () => {
|
||||||
|
test('completes a file once every part arrived, across batches and out of order', ({ assert }) => {
|
||||||
|
const aggregator = new FileAggregator()
|
||||||
|
assert.deepEqual(aggregator.add([seg('a', 2, 3), seg('b', 1, 1)]).map((f) => f.fileKey), ['b'])
|
||||||
|
assert.equal(aggregator.openFiles, 1)
|
||||||
|
const [done] = aggregator.add([seg('a', 3, 3), seg('a', 1, 3)])
|
||||||
|
assert.equal(done.fileKey, 'a')
|
||||||
|
assert.deepEqual(done.segments.map((s) => s.number), [1, 2, 3])
|
||||||
|
assert.equal(aggregator.openFiles, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the first article of a part number (a repost) and drops impossible numbers', ({ assert }) => {
|
||||||
|
const aggregator = new FileAggregator()
|
||||||
|
const [done] = aggregator.add([seg('a', 1, 2, 'first@test'), seg('a', 1, 2, 'repost@test'), seg('a', 5, 2), seg('a', 2, 2)])
|
||||||
|
assert.deepEqual(done.segments.map((s) => s.messageId), ['first@test', 'a-2@test'])
|
||||||
|
assert.deepEqual(aggregator.evict().map((s) => s.number), [5])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('gives up idle files as fragments, all of them at the end', ({ assert }) => {
|
||||||
|
const aggregator = new FileAggregator()
|
||||||
|
aggregator.add([seg('old', 1, 3)])
|
||||||
|
aggregator.add([seg('new', 1, 3)])
|
||||||
|
aggregator.add([seg('new', 2, 3)])
|
||||||
|
assert.deepEqual(aggregator.evict(2).map((s) => s.fileKey), ['old'])
|
||||||
|
assert.deepEqual(aggregator.evict(0).map((s) => s.number), [1, 2])
|
||||||
|
assert.equal(aggregator.openFiles, 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user