Skip to content

Commit db61676

Browse files
committed
feat(xo-server/backups): set the full listing interval of a repository in its URL
An entry of the VM backups cache is listed in full again an hour after it was built, to bound the drift from the changes its journal does not record. The hour suits neither a large repository on an object storage, where each listing reads the metadata of every VM, nor one whose backups are often changed outside the journal. The `fullListingInterval` parameter of the URL of a repository now sets this delay, as a duration (`?fullListingInterval=6h`) or a number of milliseconds, read through `parseDuration()` like the durations of the configuration. Without it, the delay is still an hour; with an invalid value too, with a warning, so that a typo does not make the repository unreadable. It is read when the entry is built: the URL changing already rebuilds the entry, which then reads the new value.
1 parent a68252f commit db61676

4 files changed

Lines changed: 94 additions & 9 deletions

File tree

‎CHANGELOG.unreleased.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
- [Tasks] Add link and object resolution to Tasks Overview panel, better text flow for resolved task names (PR [#10265](https://github.com/vatesfr/xen-orchestra/pull/10265))
1616
- [XO5/Settings/IPs] Show an example of the expected IP format when adding IPs to an IP pool (PR [#10522](https://github.com/vatesfr/xen-orchestra/pull/10522))
1717
- [BRs] Add backup repository create form (PR [#10271](https://github.com/vatesfr/xen-orchestra/pull/10271))
18-
- [REST API] SSE now supports the `backup-archive` collection: backups appearing, being merged or disappearing from a backup repository are pushed to the subscribers, instead of each client listing the repositories again to spot them. They are sent as soon as a backup job or a deletion changes a backup repository, and within a couple of minutes for changes made outside this XO; a repository attached to a proxy which has not been updated yet is read every 10 minutes instead. When XO starts, it lists every enabled backup repository and sends the archives they already hold as `add` events; a repository it cannot reach then is sent the next time a client lists it (PR [#10472](https://github.com/vatesfr/xen-orchestra/pull/10472))
18+
- [REST API] SSE now supports the `backup-archive` collection: backups appearing, being merged or disappearing from a backup repository are pushed to the subscribers, instead of each client listing the repositories again to spot them. They are sent as soon as a backup job or a deletion changes a backup repository, and within a couple of minutes for changes made outside this XO; a repository attached to a proxy which has not been updated yet is read every 10 minutes instead. When XO starts, it lists every enabled backup repository and sends the archives they already hold as `add` events; a repository it cannot reach then is sent the next time a client lists it. Each repository is also listed in full every hour, for the changes its journal does not record; the `fullListingInterval` parameter of its URL changes that delay, e.g. `?fullListingInterval=6h` (PR [#10472](https://github.com/vatesfr/xen-orchestra/pull/10472))
1919
- [Plugins/load balancer] Added VM-to-host affinity to force VMs to run on a given set of hosts if possible (PR [#10207](https://github.com/vatesfr/xen-orchestra/pull/10207))
2020
- [Plugins/load balancer] Improved migration decision making (PR [#10207](https://github.com/vatesfr/xen-orchestra/pull/10207))
2121
- [New VM] Allow removing existing (template) disks (PR [#10292](https://github.com/vatesfr/xen-orchestra/pull/10292))

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

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { compareTimestamp } from '@xen-orchestra/backups/RemoteAdapter.mjs'
55
import { createLogger } from '@xen-orchestra/log'
66
import { EventEmitter } from 'node:events'
77
import { journalCursorAt } from '@xen-orchestra/backups/_backupJournal.mjs'
8+
import { parse } from 'xo-remote-parser'
9+
import { parseDuration } from '@vates/parse-duration'
810

911
/**
1012
* A backup repository, as required by `VmBackupsCache`.
@@ -67,6 +69,8 @@ import { journalCursorAt } from '@xen-orchestra/backups/_backupJournal.mjs'
6769
* successfully once, i.e. whether it disappearing on the next replay is anomalous rather than benign
6870
* @property {Repository['options']} [options]
6971
* @property {string} [proxy]
72+
* @property {number} rebuildInterval how long after `builtAt` the repository is listed in full again,
73+
* see `rebuildIntervalOf()`
7074
* @property {number} refreshedAt when this process last brought the entry up to date: with `builtAt`,
7175
* the only fields which may be compared with its clock
7276
* @property {boolean} stale
@@ -87,8 +91,8 @@ const CLOCK_SKEW_TOLERANCE = 5 * 60 * 1e3
8791

8892
// how long an entry is brought up to date from the journal before it is listed in full again, i.e. the
8993
// longest the drift described in `VmBackupsCache` lasts, at the price of one full listing of each
90-
// repository per period
91-
const REBUILD_INTERVAL = 60 * 60 * 1e3
94+
// repository per period, unless its URL says otherwise, see `rebuildIntervalOf()`
95+
const DEFAULT_REBUILD_INTERVAL = 60 * 60 * 1e3
9296

9397
/**
9498
* @param {Entry} entry
@@ -98,6 +102,39 @@ const REBUILD_INTERVAL = 60 * 60 * 1e3
98102
const isSameRepository = (entry, repository) =>
99103
entry.url === repository.url && entry.options === repository.options && entry.proxy === repository.proxy
100104

105+
/**
106+
* How long an entry of a repository is brought up to date from the journal before it is listed in
107+
* full again: the `fullListingInterval` parameter of its URL, a duration such as `6h` or a number of
108+
* milliseconds, otherwise `DEFAULT_REBUILD_INTERVAL`.
109+
*
110+
* Read when the entry is built: the URL changing rebuilds the entry anyway, see `isSameRepository()`.
111+
*
112+
* @param {Repository} repository
113+
* @returns {number}
114+
*/
115+
const rebuildIntervalOf = repository => {
116+
const { fullListingInterval } = parse(repository.url)
117+
if (fullListingInterval === undefined) {
118+
return DEFAULT_REBUILD_INTERVAL
119+
}
120+
121+
try {
122+
return parseDuration(fullListingInterval)
123+
} catch (error) {
124+
if (!(error instanceof TypeError)) {
125+
throw error
126+
}
127+
128+
// a typo in an optional setting must not make the repository unreadable
129+
warn('invalid fullListingInterval, using the default one', {
130+
repositoryId: repository.id,
131+
fullListingInterval,
132+
defaultInterval: DEFAULT_REBUILD_INTERVAL,
133+
})
134+
return DEFAULT_REBUILD_INTERVAL
135+
}
136+
}
137+
101138
/**
102139
* forgets a backup, and the VM it belonged to when it was its last one
103140
*
@@ -279,7 +316,8 @@ export function serveVmBackups(backupsByVm, remoteId, vmId) {
279316
* How a repository is read is `VmBackupsSource`'s business: this class only decides *when* to list
280317
* it, when to replay it, and what to serve in the meantime.
281318
*
282-
* Entries are rebuilt from scratch an hour after they were built, which bounds the drift accumulated
319+
* Entries are rebuilt from scratch an hour after they were built, or after the `fullListingInterval`
320+
* of the URL of the repository, see `rebuildIntervalOf()`, which bounds the drift accumulated
283321
* from the events which could not be journaled, or which are not journaled at all (e.g. the
284322
* `immutable-backups` daemon lifting the immutability of a backup), when the remote is re-pointed,
285323
* reconfigured or moved to another proxy, which the entry detects by itself from what it was built
@@ -315,7 +353,8 @@ export class VmBackupsCache {
315353
first: async (/** @type {string} */ id) => this.#listArchives().find(archive => archive.id === id),
316354
}
317355

318-
// repository id → { backupsByVm, builtAt, cursor, journalConfirmed, options, proxy, refreshedAt, stale, url }
356+
// repository id → { backupsByVm, builtAt, cursor, journalConfirmed, options, proxy, rebuildInterval,
357+
// refreshedAt, stale, url }
319358
/** @type {Map<string, Entry>} */
320359
#entries = new Map()
321360

@@ -519,7 +558,7 @@ export class VmBackupsCache {
519558
const now = Date.now()
520559

521560
try {
522-
if (entry === undefined || !isSameRepository(entry, repository) || now - entry.builtAt >= REBUILD_INTERVAL) {
561+
if (entry === undefined || !isSameRepository(entry, repository) || now - entry.builtAt >= entry.rebuildInterval) {
523562
return await this.#build(repository)
524563
}
525564

@@ -672,6 +711,7 @@ export class VmBackupsCache {
672711
cursor: journalCursorAt(now - CLOCK_SKEW_TOLERANCE),
673712
options: repository.options,
674713
proxy: repository.proxy,
714+
rebuildInterval: rebuildIntervalOf(repository),
675715
refreshedAt: now,
676716
stale: false,
677717
url: repository.url,

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { normalize } from '@xen-orchestra/fs/path'
77
import { filenameOf, journalEntryPath, metadataOf, OTHER_VM, VM } from './_vmBackupsFixtures.mjs'
88
import { serveVmBackups, VmBackupsCache } from './_vmBackupsCache.mjs'
99

10-
const REPOSITORY = { id: 'repository' }
10+
const REPOSITORY = { id: 'repository', url: 'file:///media/backup' }
1111

1212
const enoent = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
1313

@@ -319,6 +319,50 @@ describe('VmBackupsCache', () => {
319319
assert.equal(repository.nJournalReads, 1)
320320
})
321321

322+
it('rebuilds after the fullListingInterval of the URL of the repository', async t => {
323+
const { tick } = mockTime(t, Date.parse('2026-08-11T10:00:00Z'))
324+
const repository = new Repository([metadataOf(VM, '20260811T090000')])
325+
const cache = new VmBackupsCache(repository.source, { minRefreshDelay: 60e3 })
326+
const withInterval = { ...REPOSITORY, url: 'file:///media/backup?fullListingInterval=10m' }
327+
328+
await cache.get(withInterval)
329+
tick(9 * 60e3)
330+
await cache.get(withInterval)
331+
assert.equal(repository.nListings, 1)
332+
333+
tick(60e3)
334+
await cache.get(withInterval)
335+
assert.equal(repository.nListings, 2)
336+
})
337+
338+
it('reads a fullListingInterval in milliseconds', async t => {
339+
const { tick } = mockTime(t, Date.parse('2026-08-11T10:00:00Z'))
340+
const repository = new Repository([metadataOf(VM, '20260811T090000')])
341+
const cache = new VmBackupsCache(repository.source, { minRefreshDelay: 60e3 })
342+
const withInterval = { ...REPOSITORY, url: 'file:///media/backup?fullListingInterval=600000' }
343+
344+
await cache.get(withInterval)
345+
tick(10 * 60e3)
346+
await cache.get(withInterval)
347+
assert.equal(repository.nListings, 2)
348+
})
349+
350+
it('rebuilds an hour after the entry was built when the fullListingInterval is invalid', async t => {
351+
const { tick } = mockTime(t, Date.parse('2026-08-11T10:00:00Z'))
352+
const repository = new Repository([metadataOf(VM, '20260811T090000')])
353+
const cache = new VmBackupsCache(repository.source, { minRefreshDelay: 60e3 })
354+
const withInterval = { ...REPOSITORY, url: 'file:///media/backup?fullListingInterval=often' }
355+
356+
await cache.get(withInterval)
357+
tick(59 * 60e3)
358+
await cache.get(withInterval)
359+
assert.equal(repository.nListings, 1)
360+
361+
tick(60e3)
362+
await cache.get(withInterval)
363+
assert.equal(repository.nListings, 2)
364+
})
365+
322366
it('rebuilds when the remote is re-pointed or reconfigured', async t => {
323367
mockTime(t, Date.parse('2026-08-11T10:00:00Z'))
324368
const repository = new Repository([metadataOf(VM, '20260811T090000')])

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -751,8 +751,9 @@ export default class BackupNg {
751751
// listed since, and the timers of `#pollVmBackupArchives()` read all of them
752752
//
753753
// one after another rather than all at once: each listing reads the metadata of every VM of the
754-
// repository; it also staggers their hourly rebuilds, which count from the listing, by as long as
755-
// the listings take, i.e. the most for the repositories which are the slowest to list
754+
// repository; it also staggers their periodic rebuilds (hourly unless the URL of the repository sets
755+
// `fullListingInterval`), which count from the listing, by as long as the listings take, i.e. the
756+
// most for the repositories which are the slowest to list
756757
//
757758
// through `_listVmBackupsWithBackoff()`, which never rejects: a repository which cannot be read then
758759
// is left out of the collection until a client lists it

0 commit comments

Comments
 (0)