fix(cordis): make config reload transactional
This commit is contained in:
114
vendor/include/src/index.ts
vendored
114
vendor/include/src/index.ts
vendored
@@ -35,7 +35,8 @@ const supported = new Set(Object.keys(writable))
|
||||
* Apply patch lists to an entry list — THE patch semantics of this include,
|
||||
* shared by mounting (`applyPatches`) and offline config tooling
|
||||
* (`dsh --dump-config`) so a dump can never drift from what boots. The input
|
||||
* is never mutated: patching shared entry objects would bake earlier patch
|
||||
* is never mutated and the result is always detached from it (even with no
|
||||
* patches): patching or mounting shared entry objects would bake earlier
|
||||
* values into the cached parse, so repeated application (config hot-reloads)
|
||||
* could never revert a removed or changed patch. Inserted entries are indexed
|
||||
* as they are added, so a later patch in the same list can target a row an
|
||||
@@ -50,8 +51,8 @@ export function applyEntryPatches(
|
||||
patches: PatchOptions[] | undefined,
|
||||
warn: (message: string, ...args: any[]) => void,
|
||||
): EntryOptions[] {
|
||||
if (!patches?.length) return [...data]
|
||||
data = structuredClone(data)
|
||||
if (!patches?.length) return data
|
||||
|
||||
const entryMap = new Map<string, EntryOptions>()
|
||||
const buildMap = (entries: EntryOptions[]) => {
|
||||
@@ -117,6 +118,20 @@ export function applyEntryPatches(
|
||||
return data
|
||||
}
|
||||
|
||||
type ConfigUpdateStage = 'read' | 'parse' | 'validate'
|
||||
|
||||
interface ReadCandidate {
|
||||
content: string
|
||||
data: EntryOptions[]
|
||||
}
|
||||
|
||||
class ConfigFileError extends Error {
|
||||
constructor(public readonly stage: ConfigUpdateStage, path: string, cause: unknown) {
|
||||
super(`failed to ${stage} config file ${path}`, { cause })
|
||||
this.name = 'ConfigFileError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Runtime patch applied to entries loaded from an included config file. */
|
||||
export interface PatchOptions {
|
||||
id?: string
|
||||
@@ -169,17 +184,11 @@ export class Include extends EntryTree {
|
||||
this.readonly = !this.type
|
||||
this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href
|
||||
|
||||
ctx.on('internal/update', (config, _, next) => {
|
||||
ctx.on('internal/update', async (config, _, next) => {
|
||||
if (config.path !== this.config.path) return next()
|
||||
// Veto the fiber restart (children update in place), but persist the new
|
||||
// config ourselves — `Fiber.update` only assigns `this.config` behind
|
||||
// `next()`, and a stale `this.config.patches` would make the next
|
||||
// `refresh()` re-apply the old overlay.
|
||||
const data = this.applyPatches(this.data!, config.patches)
|
||||
await this.root.update(data)
|
||||
this.config = config
|
||||
this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => {
|
||||
this.ctx.logger.warn('config update at %C failed', this.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -192,30 +201,31 @@ export class Include extends EntryTree {
|
||||
}
|
||||
}
|
||||
|
||||
private async read(forced = false) {
|
||||
const content = await readFile(this.filename, 'utf8')
|
||||
if (!forced && this.content === content) return false
|
||||
private async read(forced = false): Promise<ReadCandidate | undefined> {
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(this.filename, 'utf8')
|
||||
} catch (error) {
|
||||
throw new ConfigFileError('read', this.filename, error)
|
||||
}
|
||||
if (!forced && this.content === content) return
|
||||
let data: any
|
||||
if (this.type === 'application/yaml') {
|
||||
data = yaml.load(content, { schema })
|
||||
} else if (this.type === 'application/json') {
|
||||
data = JSON.parse(content)
|
||||
} else {
|
||||
const module = await import(/* @vite-ignore */ this.filename)
|
||||
data = module.default || module
|
||||
try {
|
||||
if (this.type === 'application/yaml') {
|
||||
data = yaml.load(content, { schema })
|
||||
} else if (this.type === 'application/json') {
|
||||
data = JSON.parse(content)
|
||||
} else {
|
||||
const module = await import(/* @vite-ignore */ this.filename)
|
||||
data = module.default || module
|
||||
}
|
||||
} catch (error) {
|
||||
throw new ConfigFileError('parse', this.filename, error)
|
||||
}
|
||||
// An empty or truncated file (common mid-edit: editors and `sed -i` write
|
||||
// through temp states) parses to `undefined`, not an error; reject every
|
||||
// non-array shape here so callers see one "invalid file" signal. Content
|
||||
// and data commit only on success, so an edit that is later reverted to
|
||||
// the exact last good content correctly reads as "unchanged".
|
||||
if (!Array.isArray(data)) {
|
||||
throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`)
|
||||
throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array'))
|
||||
}
|
||||
this.content = content
|
||||
this.data = data
|
||||
await this.checkAccess()
|
||||
return true
|
||||
return { content, data }
|
||||
}
|
||||
|
||||
private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] {
|
||||
@@ -225,42 +235,44 @@ export class Include extends EntryTree {
|
||||
}
|
||||
|
||||
async* [Service.init]() {
|
||||
let candidate: ReadCandidate
|
||||
try {
|
||||
await this.read()
|
||||
candidate = (await this.read(true))!
|
||||
} catch (error) {
|
||||
// Only a missing file falls back to `initial` (or the not-found error):
|
||||
// an existing-but-invalid file must fail loud with its real parse error,
|
||||
// never be mislabelled as absent or silently overwritten.
|
||||
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error
|
||||
if (!(error instanceof ConfigFileError) || error.stage !== 'read' || (error.cause as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error
|
||||
if (this.config.initial) {
|
||||
this.writeFile(this.config.initial as any)
|
||||
await this.read()
|
||||
await this._writeFile(this.config.initial as any)
|
||||
candidate = (await this.read(true))!
|
||||
} else {
|
||||
throw new Error(`config file not found: ${this.filename}`)
|
||||
}
|
||||
}
|
||||
|
||||
yield () => this.stop()
|
||||
await this.root.update(this.applyPatches(this.data!))
|
||||
await this.apply(candidate)
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.root.stop()
|
||||
async stop() {
|
||||
await this.root.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the file and refresh child entries when content changed. An
|
||||
* unreadable or unparsable file logs a warning and keeps the last good
|
||||
* tree: a hot-reload of a live app must never take the process down.
|
||||
* Re-read the file and transactionally refresh child entries when content changed.
|
||||
* @returns a promise resolving after the new tree commits, or immediately when unchanged.
|
||||
* @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
|
||||
*/
|
||||
async refresh() {
|
||||
try {
|
||||
if (!await this.read()) return
|
||||
await this.root.update(this.applyPatches(this.data!))
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
const candidate = await this.read()
|
||||
if (!candidate) return
|
||||
await this.apply(candidate)
|
||||
}
|
||||
|
||||
private async apply(candidate: ReadCandidate) {
|
||||
const data = this.applyPatches(candidate.data)
|
||||
await this.root.update(data)
|
||||
this.content = candidate.content
|
||||
this.data = candidate.data
|
||||
await this.checkAccess()
|
||||
}
|
||||
|
||||
private async _writeFile(config: EntryOptions[]) {
|
||||
|
||||
Reference in New Issue
Block a user