Skip to content

Commit 26d7df4

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(datasource): persist runtime datasources across restart (#2096)
UI-created (runtime) datasources vanished on a node restart: datasource-admin persists via MetadataManager.register(), which is in-memory only unless a writable `datasource:` loader is wired — and standalone `serve` wires none. (Runtime objects survive because the protocol writes them straight to sys_metadata; datasources never touched that durable path.) Fix, self-contained in the datasource-admin plugin (no change to global register() semantics, no protocol surgery): - On write, persist the record to the durable `sys_metadata` table via the data engine (same store + row shape objects use) in addition to the in-memory register; remove the row on delete. Only the opaque `external.credentialsRef` is stored — never credential cleartext. - On boot (`start()`, before pool rehydration) restore persisted runtime datasources from sys_metadata back into the registry, so `listDatasources()` and pool rebuild see them. Code-defined (artifact) datasources are unaffected. - Degrades gracefully to prior in-memory behavior when no data engine is present. Tests: +2 (persist→restart→restored; delete removes the durable row); service- datasource 65 green. Verified live: created a runtime datasource, restarted the backend, it survived (PERSISTED ACROSS RESTART). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cfd86ce commit 26d7df4

2 files changed

Lines changed: 172 additions & 3 deletions

File tree

packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,62 @@ describe('DatasourceAdminServicePlugin: persistence + bound count', () => {
229229
await expect(service.removeDatasource('reporting')).rejects.toThrow(/1 object\(s\)/);
230230
});
231231
});
232+
233+
describe('DatasourceAdminServicePlugin: runtime datasource durability', () => {
234+
/** In-memory `sys_metadata` engine shared across two boots (a "restart"). */
235+
function fakeSysMetadataEngine() {
236+
const rows: Array<Record<string, unknown>> = [];
237+
return {
238+
rows,
239+
registerDriver() {},
240+
registerDatasourceDef() {},
241+
getDriverByName() { return undefined; },
242+
findOne: async (_o: string, q: { where?: Record<string, unknown> }) => {
243+
const w = q.where ?? {};
244+
return rows.find((r) => Object.entries(w).every(([k, v]) => r[k] === v));
245+
},
246+
find: async (_o: string, q: { where?: Record<string, unknown> }) => {
247+
const w = q.where ?? {};
248+
return rows.filter((r) => Object.entries(w).every(([k, v]) => r[k] === v));
249+
},
250+
insert: async (_o: string, row: Record<string, unknown>) => { rows.push({ ...row }); },
251+
update: async (_o: string, row: Record<string, unknown>, opts: { where: Record<string, unknown> }) => {
252+
const i = rows.findIndex((r) => r.id === opts.where.id);
253+
if (i >= 0) rows[i] = { ...rows[i], ...row };
254+
},
255+
delete: async (_o: string, opts: { where: Record<string, unknown> }) => {
256+
const i = rows.findIndex((r) => r.id === opts.where.id);
257+
if (i >= 0) rows.splice(i, 1);
258+
},
259+
};
260+
}
261+
262+
it('persists a UI-created datasource to sys_metadata and restores it after a restart', async () => {
263+
const data = fakeSysMetadataEngine();
264+
265+
// Boot #1: create a runtime sqlite datasource (no secret needed).
266+
const b1 = await boot({ services: { data } });
267+
await b1.service.createDatasource({ name: 'demo_ext', driver: 'sqlite', config: { filename: '/tmp/x.db' } });
268+
// It is durably written to sys_metadata (not just the in-memory registry).
269+
expect(data.rows.filter((r) => r.type === 'datasource' && r.name === 'demo_ext')).toHaveLength(1);
270+
271+
// Boot #2 = "restart": fresh in-memory registry, SAME sys_metadata engine.
272+
const b2 = await boot({ services: { data } });
273+
// Before restore, the fresh registry is empty.
274+
expect(await b2.service.listDatasources()).toHaveLength(0);
275+
// start() restores runtime rows from sys_metadata into the registry.
276+
await b2.plugin.start(b2.ctx);
277+
const after = await b2.service.listDatasources();
278+
expect(after.map((d) => d.name)).toContain('demo_ext');
279+
expect(after.find((d) => d.name === 'demo_ext')?.origin).toBe('runtime');
280+
});
281+
282+
it('removes the durable sys_metadata row when a datasource is deleted', async () => {
283+
const data = fakeSysMetadataEngine();
284+
const b = await boot({ services: { data } });
285+
await b.service.createDatasource({ name: 'gone', driver: 'sqlite', config: { filename: '/tmp/y.db' } });
286+
expect(data.rows.some((r) => r.name === 'gone')).toBe(true);
287+
await b.service.removeDatasource('gone');
288+
expect(data.rows.some((r) => r.name === 'gone')).toBe(false);
289+
});
290+
});

packages/services/service-datasource/src/datasource-admin-plugin.ts

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,82 @@ interface DataEngineLike {
3333
registerDriver?: (driver: unknown, isDefault?: boolean) => void;
3434
registerDatasourceDef?: (def: { name: string; schemaMode?: string; external?: { allowWrites?: boolean } }) => void;
3535
getDriverByName?: (name: string) => unknown;
36+
// sys_metadata CRUD used to persist runtime datasource records durably (same
37+
// table runtime objects use). Optional — absent on lightweight kernels, in
38+
// which case persistence degrades to in-memory (pre-existing behavior).
39+
findOne?: (object: string, query: { where?: Record<string, unknown> }) => Promise<Record<string, unknown> | undefined | null>;
40+
find?: (object: string, query: { where?: Record<string, unknown> }) => Promise<Record<string, unknown>[]>;
41+
insert?: (object: string, row: Record<string, unknown>) => Promise<unknown>;
42+
update?: (object: string, row: Record<string, unknown>, opts: { where: Record<string, unknown> }) => Promise<unknown>;
43+
delete?: (object: string, opts: { where: Record<string, unknown> }) => Promise<unknown>;
44+
}
45+
46+
/**
47+
* Durable persistence for runtime datasource records via the `sys_metadata`
48+
* table — the same store runtime objects use (the protocol writes objects there
49+
* directly). `MetadataManager.register()` alone is in-memory unless a writable
50+
* `datasource:` loader is wired, which standalone `serve` does not do; so a
51+
* UI-created datasource vanished on restart. These helpers persist on write and
52+
* the plugin restores them into the registry on boot before rehydrating pools.
53+
* Credential cleartext is never stored — only the opaque `external.credentialsRef`.
54+
*/
55+
const DS_META_TYPE = 'datasource';
56+
const SYS_METADATA = 'sys_metadata';
57+
58+
function newMetaId(): string {
59+
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
60+
? crypto.randomUUID()
61+
: `meta_${Date.now()}_${Math.random().toString(36).slice(2)}`;
62+
}
63+
64+
async function persistDatasourceRow(engine: DataEngineLike | undefined, record: { name: string }): Promise<void> {
65+
if (!engine?.insert || !engine.findOne) return; // no durable store — in-memory only
66+
const now = new Date().toISOString();
67+
const existing = await engine.findOne(SYS_METADATA, {
68+
where: { type: DS_META_TYPE, name: record.name, state: 'active' },
69+
});
70+
if (existing) {
71+
await engine.update?.(
72+
SYS_METADATA,
73+
{ metadata: JSON.stringify(record), updated_at: now, version: ((existing.version as number) || 0) + 1, state: 'active' },
74+
{ where: { id: existing.id } },
75+
);
76+
} else {
77+
await engine.insert(SYS_METADATA, {
78+
id: newMetaId(),
79+
name: record.name,
80+
type: DS_META_TYPE,
81+
scope: 'platform',
82+
metadata: JSON.stringify(record),
83+
state: 'active',
84+
version: 1,
85+
created_at: now,
86+
updated_at: now,
87+
});
88+
}
89+
}
90+
91+
async function deleteDatasourceRow(engine: DataEngineLike | undefined, name: string): Promise<void> {
92+
if (!engine?.findOne) return;
93+
const existing = await engine.findOne(SYS_METADATA, { where: { type: DS_META_TYPE, name, state: 'active' } });
94+
if (!existing) return;
95+
if (engine.delete) await engine.delete(SYS_METADATA, { where: { id: existing.id } });
96+
else await engine.update?.(SYS_METADATA, { state: 'inactive' }, { where: { id: existing.id } });
97+
}
98+
99+
async function loadDatasourceRows(engine: DataEngineLike | undefined): Promise<Array<Record<string, unknown>>> {
100+
if (!engine?.find) return [];
101+
const rows = await engine.find(SYS_METADATA, { where: { type: DS_META_TYPE, state: 'active' } });
102+
const out: Array<Record<string, unknown>> = [];
103+
for (const r of rows ?? []) {
104+
const raw = (r as { metadata?: unknown }).metadata;
105+
try {
106+
out.push(typeof raw === 'string' ? JSON.parse(raw) : (raw as Record<string, unknown>));
107+
} catch {
108+
/* skip corrupt row */
109+
}
110+
}
111+
return out;
36112
}
37113

38114
/**
@@ -147,7 +223,10 @@ export class DatasourceAdminServicePlugin implements Plugin {
147223
if (!metadata?.register) {
148224
throw new Error('Metadata service is unavailable; cannot persist datasource.');
149225
}
226+
// In-memory registry (immediate visibility) + durable sys_metadata row
227+
// (survives restart; restored on boot by restoreRuntimeDatasources).
150228
await metadata.register('datasource', record.name, record);
229+
await persistDatasourceRow(engineOf(), record);
151230
},
152231

153232
deleteDatasourceRecord: async (name) => {
@@ -156,6 +235,7 @@ export class DatasourceAdminServicePlugin implements Plugin {
156235
throw new Error('Metadata service is unavailable; cannot remove datasource.');
157236
}
158237
await metadata.unregister('datasource', name);
238+
await deleteDatasourceRow(engineOf(), name);
159239
},
160240

161241
writeSecret: async (input, hint) => {
@@ -265,13 +345,43 @@ export class DatasourceAdminServicePlugin implements Plugin {
265345
}
266346

267347
async start(ctx: PluginContext): Promise<void> {
268-
// Rebuild live connection pools for persisted runtime datasources before
269-
// announcing readiness — a node restart otherwise leaves UI-created
270-
// datasources with a record but no open pool until the next write.
348+
// Restore UI-created (runtime) datasources from the durable sys_metadata
349+
// store back into the in-memory registry, THEN rebuild their live pools.
350+
// `register()` is in-memory only in standalone serve (no writable
351+
// `datasource:` loader), so without this a node restart drops every
352+
// UI-created datasource. Code-defined datasources come from the artifact and
353+
// are unaffected.
354+
await this.restoreRuntimeDatasources(ctx);
271355
await this.rehydratePools();
272356
if (this.service) await ctx.trigger('datasource-admin:ready', this.service);
273357
}
274358

359+
/** Reload persisted runtime datasource rows (sys_metadata) into the registry. */
360+
private async restoreRuntimeDatasources(ctx: PluginContext): Promise<void> {
361+
const engine = safeGetService<DataEngineLike>(ctx, 'data');
362+
const metadata = safeGetService<MetadataServiceLike>(ctx, 'metadata');
363+
if (!engine?.find || !metadata?.register) return;
364+
let rows: Array<Record<string, unknown>>;
365+
try {
366+
rows = await loadDatasourceRows(engine);
367+
} catch (err) {
368+
this.options.logger?.warn?.('datasource restore: reading sys_metadata failed', err);
369+
return;
370+
}
371+
let restored = 0;
372+
for (const rec of rows) {
373+
const name = (rec as { name?: string }).name;
374+
if (!name) continue;
375+
try {
376+
await metadata.register('datasource', name, rec);
377+
restored += 1;
378+
} catch (err) {
379+
this.options.logger?.warn?.(`datasource restore: register '${name}' failed`, err);
380+
}
381+
}
382+
if (restored > 0) this.options.logger?.info?.(`datasource: restored ${restored} runtime record(s) from sys_metadata`);
383+
}
384+
275385
/**
276386
* Boot-time rehydration: list persisted runtime datasources and re-register
277387
* each one's connection pool (driver build → connect → registerDriver),

0 commit comments

Comments
 (0)