Skip to content

Commit ff46d4c

Browse files
committed
test(xo-server/backups): cover the immediate push of the VM backup archives changes
A deletion and the export of a VM announced without waiting for a listing, a repository not in the collection left alone, and a `refresh()` during a replay or a listing reading again, as many times as it is called, unless reading again means listing the repository in full.
1 parent 82e6f9a commit ff46d4c

2 files changed

Lines changed: 184 additions & 5 deletions

File tree

‎packages/xo-server/src/xo-mixins/backups-ng/_vmBackupsCache.test.mjs‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,119 @@ describe('VmBackupsCache collection', () => {
682682
assert.equal(events[1].payload[0].size, 42)
683683
})
684684

685+
it('reads the journal again when refresh() is called while it is being replayed', async t => {
686+
const { tick } = mockTime(t, Date.parse('2026-08-11T10:00:00Z'))
687+
const repository = new Repository([metadataOf(VM, '20260811T090000')])
688+
const source = repository.source
689+
const cache = new VmBackupsCache(source, { minRefreshDelay: 60e3 })
690+
691+
await cache.get(REPOSITORY)
692+
const events = recordEvents(cache)
693+
tick(60e3)
694+
695+
const held = holdNextRead(source, 'readJournal')
696+
const pending = cache.get(REPOSITORY)
697+
await held.read
698+
699+
// written after the journal has been read, e.g. by the next VM of a backup job
700+
const added = metadataOf(OTHER_VM, '20260811T100000')
701+
repository.add(added, Date.now())
702+
cache.refresh(REPOSITORY.id)
703+
held.release()
704+
705+
assert.deepEqual(filenames(await pending).sort(), [filenameOf(VM, '20260811T090000'), added._filename].sort())
706+
assert.equal(repository.nJournalReads, 2)
707+
assert.deepEqual(summarize(events), [{ event: 'add', ids: [archiveIdOf(OTHER_VM, '20260811T100000')] }])
708+
})
709+
710+
it('reads the journal again for every refresh() called while it is being read again', async t => {
711+
const { tick } = mockTime(t, Date.parse('2026-08-11T10:00:00Z'))
712+
const repository = new Repository([metadataOf(VM, '20260811T090000')])
713+
const source = repository.source
714+
const cache = new VmBackupsCache(source, { minRefreshDelay: 60e3 })
715+
716+
await cache.get(REPOSITORY)
717+
tick(60e3)
718+
719+
const firstRead = holdNextRead(source, 'readJournal')
720+
const pending = cache.get(REPOSITORY)
721+
await firstRead.read
722+
723+
const added = metadataOf(OTHER_VM, '20260811T100000')
724+
repository.add(added, Date.now())
725+
cache.refresh(REPOSITORY.id)
726+
const secondRead = holdNextRead(source, 'readJournal')
727+
firstRead.release()
728+
await secondRead.read
729+
730+
// e.g. the VM after it in the same backup job
731+
const addedLater = metadataOf(VM, '20260811T100000')
732+
repository.add(addedLater, Date.now())
733+
cache.refresh(REPOSITORY.id)
734+
secondRead.release()
735+
736+
assert.deepEqual(
737+
filenames(await pending).sort(),
738+
[filenameOf(VM, '20260811T090000'), added._filename, addedLater._filename].sort()
739+
)
740+
assert.equal(repository.nJournalReads, 3)
741+
})
742+
743+
it('reads the journal when refresh() is called while the repository is being listed', async t => {
744+
mockTime(t, Date.parse('2026-08-11T10:00:00Z'))
745+
const repository = new Repository([metadataOf(VM, '20260811T090000')])
746+
const source = repository.source
747+
const cache = new VmBackupsCache(source, { minRefreshDelay: 60e3 })
748+
const events = recordEvents(cache)
749+
750+
const held = holdNextRead(source, 'listAll')
751+
const pending = cache.get(REPOSITORY)
752+
await held.read
753+
754+
const added = metadataOf(OTHER_VM, '20260811T100000')
755+
repository.add(added, Date.now())
756+
cache.refresh(REPOSITORY.id)
757+
held.release()
758+
759+
assert.deepEqual(filenames(await pending).sort(), [filenameOf(VM, '20260811T090000'), added._filename].sort())
760+
assert.equal(repository.nListings, 1)
761+
assert.equal(repository.nJournalReads, 1)
762+
assert.deepEqual(summarize(events), [
763+
{ event: 'add', ids: [archiveIdOf(VM, '20260811T090000')] },
764+
{ event: 'add', ids: [archiveIdOf(OTHER_VM, '20260811T100000')] },
765+
])
766+
})
767+
768+
it('leaves a refresh() called while a repository is listed in full to the next read', async t => {
769+
const { tick } = mockTime(t, Date.parse('2026-08-11T10:00:00Z'))
770+
const repository = new Repository([metadataOf(VM, '20260811T090000')])
771+
repository.cannotReplay = true
772+
const source = repository.source
773+
const cache = new VmBackupsCache(source, { minRefreshDelay: 60e3 })
774+
775+
// the cache only finds out on the first replay
776+
await cache.get(REPOSITORY)
777+
tick(60e3)
778+
await cache.get(REPOSITORY)
779+
tick(60e3)
780+
781+
const held = holdNextRead(source, 'listAll')
782+
const pending = cache.get(REPOSITORY)
783+
await held.read
784+
785+
const added = metadataOf(OTHER_VM, '20260811T100000')
786+
repository.add(added, Date.now())
787+
cache.refresh(REPOSITORY.id)
788+
held.release()
789+
790+
assert.deepEqual(filenames(await pending), [filenameOf(VM, '20260811T090000')])
791+
assert.equal(repository.nListings, 3)
792+
793+
// still stale: listed again without waiting for the end of the refresh window
794+
assert.ok(filenames(await cache.get(REPOSITORY)).includes(added._filename))
795+
assert.equal(repository.nListings, 4)
796+
})
797+
685798
it('announces nothing when a journal event changed nothing', async t => {
686799
const { tick } = mockTime(t, Date.parse('2026-08-11T10:00:00Z'))
687800
const metadata = metadataOf(VM, '20260811T090000')

‎packages/xo-server/src/xo-mixins/backups-ng/index.test.mjs‎

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import EventEmitter from 'node:events'
44
import test from 'node:test'
55
import TimeoutError from 'promise-toolbox/TimeoutError'
66
import { formatJournalEvents, formatVmBackups } from '@xen-orchestra/backups/formatVmBackups.mjs'
7+
import { makeOnProgress } from '@vates/task/combineEvents'
78

89
import BackupNg, { backupsListingRetryDelay } from './index.mjs'
910
import { filenameOf, journalEntryPath, metadataOf, VM } from './_vmBackupsFixtures.mjs'
@@ -430,10 +431,12 @@ const createBackupNgWithRepository = repository => {
430431
const PROXY_ID = 'a-proxy-id'
431432

432433
// serves the same in-memory repository through the RPCs a proxy exposes
433-
const createBackupNgWithProxiedRepository = (repository, { journal = true, failsToList = false } = {}) => {
434+
//
435+
// `backupLogs` is what the proxy streams when it runs a backup job: an async iterable of task events
436+
const createBackupNgWithProxiedRepository = (repository, { journal = true, failsToList = false, backupLogs } = {}) => {
434437
const adapter = repository.adapter
435438
const app = {
436-
config: { getDuration: () => LISTING_DEBOUNCE },
439+
config: { get: () => ({}), getDuration: () => LISTING_DEBOUNCE },
437440
getRemoteWithCredentials: async id => {
438441
assert.equal(id, REMOTE_ID)
439442
return { id: REMOTE_ID, url: 'file:///media/backup', proxy: PROXY_ID }
@@ -458,10 +461,18 @@ const createBackupNgWithProxiedRepository = (repository, { journal = true, fails
458461
return adapter.deleteVmBackups(params.filenames)
459462
}
460463

464+
if (method === 'backup.run') {
465+
return backupLogs()
466+
}
467+
461468
throw new Error(`unexpected proxy method ${method}`)
462469
},
463470
hooks: new EventEmitter(),
464-
registerJobExecutor() {},
471+
jobExecutors: {},
472+
registerJobExecutor(type, executor) {
473+
this.jobExecutors[type] = executor
474+
},
475+
tasks: { createExternalProgressHandler: makeOnProgress },
465476
}
466477
return new BackupNg(app)
467478
}
@@ -618,17 +629,72 @@ describe('the backup-archive collection', () => {
618629
assert.deepEqual(events, [{ event: 'add', ids: [idOf('20260811T090000')] }])
619630
})
620631

621-
it('announces a backup deleted through the mixin', async () => {
632+
it('announces a backup deleted through the mixin, without waiting for a listing', async () => {
622633
const repository = new Repository([metadataOf(VM, '20260811T090000'), metadataOf(VM, '20260811T093000')])
623634
const backupNg = createBackupNgWithRepository(repository)
624635

625636
await backupNg.listVmBackupsNg([REMOTE_ID])
626637
const events = recordEvents(backupNg)
627638

628639
await backupNg.deleteVmBackupsNg([idOf('20260811T093000')])
629-
await backupNg.listVmBackupsNg([REMOTE_ID])
640+
await tick(0)
630641

631642
assert.deepEqual(events, [{ event: 'remove', ids: [idOf('20260811T093000')] }])
643+
assert.equal(repository.nListings, 1)
644+
})
645+
646+
it('does not read a repository whose archives are not in the collection after a deletion', async () => {
647+
const repository = new Repository([metadataOf(VM, '20260811T090000'), metadataOf(VM, '20260811T093000')])
648+
const backupNg = createBackupNgWithRepository(repository)
649+
const events = recordEvents(backupNg)
650+
651+
await backupNg.deleteVmBackupsNg([idOf('20260811T093000')])
652+
await tick(0)
653+
654+
assert.equal(repository.nListings, 0)
655+
assert.deepEqual(events, [])
656+
})
657+
658+
it('announces the backup of a VM once it is exported, before the end of the job', async () => {
659+
const repository = new Repository([metadataOf(VM, '20260811T090000')])
660+
let eventsWhenExported
661+
const backupNg = createBackupNgWithProxiedRepository(repository, {
662+
// a single VM exported to a single repository
663+
async *backupLogs() {
664+
yield { type: 'start', id: 'run', timestamp: 0, properties: { name: 'backup run' } }
665+
yield {
666+
type: 'start',
667+
id: 'export',
668+
parentId: 'run',
669+
timestamp: 0,
670+
properties: { id: REMOTE_ID, name: 'export', type: 'remote' },
671+
}
672+
repository.add(metadataOf(VM, '20260811T100000'))
673+
yield { type: 'end', id: 'export', timestamp: 0, status: 'success' }
674+
675+
await tick(0)
676+
eventsWhenExported = events.slice()
677+
678+
yield { type: 'end', id: 'run', timestamp: 0, status: 'success' }
679+
},
680+
})
681+
const { hooks, jobExecutors } = backupNg._app
682+
683+
await backupNg.listVmBackupsNg([REMOTE_ID])
684+
const events = recordEvents(backupNg)
685+
hooks.emit('start')
686+
687+
await jobExecutors.mirrorBackup({
688+
job: { id: 'job', name: 'job', proxy: PROXY_ID, remotes: { id: REMOTE_ID }, settings: { schedule: {} } },
689+
jobData: {},
690+
jobUpdateFct: async () => {},
691+
schedule: { id: 'schedule' },
692+
})
693+
694+
assert.deepEqual(eventsWhenExported, [{ event: 'add', ids: [idOf('20260811T100000')] }])
695+
assert.equal(repository.nListings, 1)
696+
697+
hooks.emit('stop')
632698
})
633699

634700
it('reads the repositories of the collection on a timer, for the changes made by other processes', async () => {

0 commit comments

Comments
 (0)