project-nomad/admin/commands/content_auto_update/dry_run.ts

219 lines
7.9 KiB
TypeScript

import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { isWithinWindow } from '../../app/utils/update_window.js'
/**
* Exercise the content auto-update decision pipeline WITHOUT ever dispatching a
* real download.
*
* # Prove the core selection/eligibility/window logic deterministically
* # (no network/DB):
* node ace content-auto-update:dry-run --scenarios
*
* # Evaluate what the next run would do against the currently-persisted
* # available-update state (run a "Check for Content Updates" first to refresh
* # it), forcing the feature on and overriding the cap:
* node ace content-auto-update:dry-run --force-enabled --cap=20 --window-start=00:00 --window-end=23:59
*/
export default class ContentAutoUpdateDryRun extends BaseCommand {
static commandName = 'content-auto-update:dry-run'
static description = 'Dry-run the content auto-update decision pipeline (never dispatches a download)'
@flags.boolean({ description: 'Run the built-in deterministic scenario suite and exit' })
declare scenarios: boolean
@flags.boolean({ description: 'Ignore the persisted enabled setting and treat as enabled' })
declare forceEnabled: boolean
@flags.string({ description: 'Override cool-off hours' })
declare cooloff: string
@flags.string({ description: 'Override window start (HH:MM)' })
declare windowStart: string
@flags.string({ description: 'Override window end (HH:MM)' })
declare windowEnd: string
@flags.string({ description: 'Override per-window data cap in GB (0 = unlimited)' })
declare cap: string
@flags.string({ description: 'Override bytes already used this window' })
declare usedBytes: string
@flags.string({ description: 'Simulate the clock at this ISO timestamp' })
declare now: string
static options: CommandOptions = {
startApp: true,
}
async run() {
const { DateTime } = await import('luxon')
const { DownloadService } = await import('#services/download_service')
const { QueueService } = await import('#services/queue_service')
const { ContentAutoUpdateService } = await import('#services/content_auto_update_service')
const svc = new ContentAutoUpdateService(new DownloadService(QueueService.getInstance()))
if (this.scenarios) {
const ok = this.runScenarios(svc, DateTime)
if (!ok) this.exitCode = 1
return
}
const BYTES_PER_GB = 1024 * 1024 * 1024
const overrides: Record<string, any> = {}
if (this.forceEnabled) overrides.forceEnabled = true
if (this.cooloff) overrides.cooloffHours = Number(this.cooloff)
if (this.windowStart) overrides.windowStart = this.windowStart
if (this.windowEnd) overrides.windowEnd = this.windowEnd
if (this.cap) overrides.maxBytesPerWindow = Math.round(Number(this.cap) * BYTES_PER_GB)
if (this.usedBytes) overrides.windowBytesUsed = Number(this.usedBytes)
if (this.now) overrides.now = DateTime.fromISO(this.now)
this.logger.info('Running content auto-update dry run (no download will be dispatched)...')
const d = await svc.dryRun(overrides)
this.logger.log('')
this.logger.log(` Enabled : ${d.enabled}`)
this.logger.log(
` Window : ${d.config.windowStart}-${d.config.windowEnd} ` +
`(currently ${d.withinWindow ? 'inside' : 'outside'})`
)
this.logger.log(` Cool-off hours : ${d.config.cooloffHours}`)
this.logger.log(
` Data cap : ${d.config.maxBytesPerWindow > 0 ? d.config.maxBytesPerWindow + ' bytes' : 'unlimited'}`
)
this.logger.log(` Eligible : ${d.eligibleCount}`)
this.logger.log(` Would start : ${d.selection.selected.map((c) => c.resource.resource_id).join(', ') || '—'}`)
this.logger.log(
` Skipped (cap) : ${d.selection.skippedOversize.map((c) => c.resource.resource_id).join(', ') || '—'}`
)
this.logger.log(
` Deferred (budget): ${d.selection.deferred.map((c) => c.resource.resource_id).join(', ') || '—'}`
)
this.logger.log('')
}
/**
* Deterministic acceptance suite over the pure decision helpers — no network
* or dispatch. Mirrors the per-resource eligibility, cap selection, and window
* branches reviewers care about.
*/
private runScenarios(svc: any, DateTime: any): boolean {
const now = DateTime.fromISO('2026-06-04T03:00:00Z')
const daysAgo = (d: number) => now.minus({ days: d })
const hoursAgo = (h: number) => now.minus({ hours: h })
const res = (o: Record<string, any> = {}) => ({
resource_id: 'res',
version: '2024-01',
available_update_version: null,
available_update_size_bytes: null,
available_update_first_seen_at: null,
auto_update_disabled_reason: null,
auto_update_consecutive_failures: 0,
installed_at: daysAgo(100),
...o,
})
const cand = (id: string, size: number, installedAt: any = daysAgo(100)) => ({
resource: res({ resource_id: id }),
version: '2024-06',
download_url: `(test)`,
size_bytes: size,
installed_at: installedAt,
})
let passed = 0
let failed = 0
const report = (ok: boolean, message: string) => {
this.logger.log(` ${ok ? this.colors.green('✓') : this.colors.red('✗')} ${message}`)
ok ? passed++ : failed++
}
this.logger.log('')
this.logger.log('Eligibility scenarios:')
report(
svc.resourceEligibility(res(), 72, now).eligible === false,
'no available update → not eligible'
)
report(
svc.resourceEligibility(
res({ available_update_version: '2024-06', available_update_first_seen_at: hoursAgo(10) }),
72,
now
).eligible === false,
'inside cool-off → not eligible'
)
report(
svc.resourceEligibility(
res({ available_update_version: '2024-06', available_update_first_seen_at: daysAgo(5) }),
72,
now
).eligible === true,
'past cool-off → eligible'
)
report(
svc.resourceEligibility(
res({
available_update_version: '2024-06',
available_update_first_seen_at: daysAgo(30),
auto_update_disabled_reason: 'disabled',
}),
72,
now
).eligible === false,
'self-disabled → not eligible'
)
this.logger.log('')
this.logger.log('Cap selection scenarios:')
{
const s = svc.selectUnderCap([cand('a', 1000), cand('b', 2000)], 10000, 0)
report(s.selected.length === 2, 'under cap selects all')
}
{
const s = svc.selectUnderCap([cand('huge', 50000)], 20000, 0)
report(
s.selected.length === 0 && s.skippedOversize.length === 1,
'oversize file → skipped, never selected'
)
}
{
const s = svc.selectUnderCap([cand('mid', 8000)], 10000, 5000)
report(s.selected.length === 0 && s.deferred.length === 1, 'over remaining budget → deferred')
}
{
const s = svc.selectUnderCap([cand('a', 0)], 10000, 0)
report(s.selected.length === 0 && s.deferred.length === 1, 'unknown size → deferred')
}
{
const s = svc.selectUnderCap([cand('big', 9_999_999_999)], 0, 0)
report(s.selected.length === 1, 'cap 0 → unlimited')
}
this.logger.log('')
this.logger.log('Window scenarios:')
report(
isWithinWindow('02:00', '05:00', DateTime.fromISO('2026-06-04T03:00:00')) === true,
'normal 02:00-05:00 @ 03:00 → in'
)
report(
isWithinWindow('22:00', '02:00', DateTime.fromISO('2026-06-04T01:00:00')) === true,
'wrap 22:00-02:00 @ 01:00 → in'
)
report(
isWithinWindow('22:00', '02:00', DateTime.fromISO('2026-06-04T12:00:00')) === false,
'wrap 22:00-02:00 @ 12:00 → out'
)
this.logger.log('')
if (failed === 0) {
this.logger.success(`All ${passed} scenarios passed`)
} else {
this.logger.error(`${failed} scenario(s) failed, ${passed} passed`)
}
return failed === 0
}
}