fix(cordis): make config reload transactional

This commit is contained in:
Tianyi Cui
2026-07-30 03:11:16 +08:00
parent f8eb6a9b84
commit 195f7fa9af
33 changed files with 1020 additions and 268 deletions

View File

@@ -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[]) {