Skip to content

Commit 3017e09

Browse files
kriszypclaude
andauthored
fix(databases): sync non-indexed attributes on worker-restart schema reload (RE-7) (#1183)
* fix(databases): sync non-indexed attributes in initStores() on resetDatabases() (RE-7) `initStores()` only merged indexed attributes back into `table.attributes` when updating an existing table (e.g. after a hot-reload–triggered `resetDatabases()`). Non-indexed, non-primary-key fields (e.g. `name: String`, `breed: String`) were written to `attributesDbi` by `table()` in the restarted worker and signalled via ITC, but the main thread's `resetDatabases()` → `initStores()` path skipped them — leaving `describe_database` returning only the old attribute list until a full kill+restart. Fix: extend the existing-table update loop in `initStores()` to add/replace non-indexed attributes as well as indexed ones, and remove them when they are no longer present in the persisted schema. Adds two regression tests in schemaMigrationFragility.test.js (RE-7 suite). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(databases): fix RE-7 regression test to actually exercise the INSERT path The previous setup called table() with all four attributes, which populated Table.attributes before resetDatabases() ran. initStores() always found existingIdx >= 0, so the INSERT path (existingIdx < 0) was never reached and the test passed with or without the fix. Now simulates the true RE-7 mismatch: after table() writes all four attrs to attributesDbi, reset in-memory Table.attributes back to [id] to mirror the stale main-thread state that exists after a worker-only restart. resetDatabases() must now re-sync from attributesDbi, exercising the INSERT path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(databases): address PR review — use attribute.attribute and fix removal test - resources/databases.ts: use `attribute.attribute` instead of `attribute.name` in the non-indexed attribute findIndex (per cb1kenobi's suggestion; `attribute.name` can be falsey for attributes that arrive with only the `attribute` field set). - unitTests/resources/schemaMigrationFragility.test.js: fix removal regression test — after `table()` updates in-memory Table.attributes, re-create the stale main-thread state (still holding breed/age) before calling resetDatabases(), so the removal loop in initStores() is actually exercised. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: run prettier on schemaMigrationFragility test * fix(databases): preserve runtime-only relationship attrs on resetDatabases() (RE-7) The removal loop in initStores() was dropping runtime-only attributes — like relationship attrs added via GraphQL `@relationship` — because table()'s persistence loop intentionally `continue`s past them (databases.ts:1138), so they appear in `existingAttributes` (table.attributes) but not in the `attributes` list rebuilt from attributesDbi. updatedAttributes() then stripped the resolver/search support, breaking nested GraphQL queries after any resetDatabases() / hot-reload — the symptom that showed up in CI as 8 failing `handles query by nested attribute` assertions in graphql-querying-test and the 404/501 RESTProperties failures. Also fixes a splice-while-iterating bug in the same loop: splicing the array being walked by `for...of` skipped the next element, so two adjacent removals (e.g. `breed` and `age` in the regression test) only dropped one. The loop now collects into `toRemove` and applies the splices after iteration. Adds a unit test that injects a relationship attr and asserts it survives a resetDatabases() call, and extends the removal test's comment to explain why the two-adjacent-removal pattern is intentional. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(databases): set attributesUpdated on non-indexed attribute update path When an existing non-indexed attribute was already present in existingAttributes (existingIdx >= 0) and got replaced by the splice, attributesUpdated was never set to true. As a result the schemaVersion bump and updatedAttributes() call at line 682 were skipped, leaving downstream code with stale property resolvers when a plain field's type/nullability changed via hot-reload. The add path (existingIdx < 0) was already correct; this brings the update path into parity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c000908 commit 3017e09

2 files changed

Lines changed: 148 additions & 2 deletions

File tree

resources/databases.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -620,11 +620,29 @@ function initStores(
620620
if (existingAttribute) existingAttributes.splice(existingAttributes.indexOf(existingAttribute), 1, attribute);
621621
else existingAttributes.push(attribute);
622622
attributesUpdated = true;
623+
} else if (!attribute.isPrimaryKey) {
624+
// Non-indexed, non-primary-key attributes (e.g. plain schema fields like `name: String`)
625+
// must also be kept in sync so that describe_database reflects schema changes after a
626+
// hot-reload / worker restart. Without this, resetDatabases() re-reads these attributes
627+
// from attributesDbi but never merges them back into table.attributes — causing stale
628+
// schema metadata until a full kill+restart. (RE-7)
629+
const existingIdx = existingAttributes.findIndex((ea) => ea.name === attribute.attribute);
630+
if (existingIdx >= 0) {
631+
existingAttributes.splice(existingIdx, 1, attribute);
632+
attributesUpdated = true;
633+
} else {
634+
existingAttributes.push(attribute);
635+
attributesUpdated = true;
636+
}
623637
}
624638
} catch (error) {
625639
logger.error(`Error trying to update attribute`, attribute, existingAttributes, indices, error);
626640
}
627641
}
642+
// Collect removals first; splicing while iterating `existingAttributes` skips adjacent
643+
// elements, which would silently leave stale fields behind when two or more were dropped
644+
// in the same reload.
645+
const toRemove = [];
628646
for (const existingAttribute of existingAttributes) {
629647
const attribute = attributes.find((attribute) => attribute.name === existingAttribute.name);
630648
if (!attribute) {
@@ -645,11 +663,22 @@ function initStores(
645663
}
646664
if (existingAttribute.indexed) {
647665
// we only remove attributes if they were indexed, in order to support dropAttribute that removes dynamic indexed attributes
648-
existingAttributes.splice(existingAttributes.indexOf(existingAttribute), 1);
649-
attributesUpdated = true;
666+
toRemove.push(existingAttribute);
667+
} else if (!existingAttribute.isPrimaryKey) {
668+
// Skip runtime-only attributes (e.g. relationship attrs — table()'s persistence loop
669+
// `continue`s past them at line 1138). They are present in `existingAttributes` but
670+
// never in the `attributes` list rebuilt from attributesDbi; removing them would drop
671+
// the resolver/search support added by updatedAttributes(). Computed attrs ARE
672+
// persisted, so only `relationship` is excluded here.
673+
if (existingAttribute.relationship) continue;
674+
toRemove.push(existingAttribute);
650675
}
651676
}
652677
}
678+
for (const existingAttribute of toRemove) {
679+
existingAttributes.splice(existingAttributes.indexOf(existingAttribute), 1);
680+
attributesUpdated = true;
681+
}
653682
if (table && !recreateForEngineChange) {
654683
if (attributesUpdated) {
655684
table.schemaVersion++;

unitTests/resources/schemaMigrationFragility.test.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,123 @@ describe('schema-migration fragility: stale `changed` reused after re-fetch unde
382382
});
383383
});
384384

385+
describe('schema-migration fragility: non-indexed attributes missing from table.attributes after resetDatabases() (RE-7)', () => {
386+
if (process.env.HARPER_STORAGE_ENGINE === 'lmdb') return;
387+
388+
const DB = 're7NonIndexedAttrs';
389+
const TABLE = 'Pet';
390+
const testRoot = path.resolve(__dirname, '../envDir/re7NonIndexedAttrs');
391+
const dbDir = path.join(testRoot, terms.DATABASES_DIR_NAME);
392+
393+
before(async () => {
394+
setMainIsWorker(true);
395+
await fs.remove(testRoot);
396+
await fs.mkdirp(dbDir);
397+
env.setProperty(terms.HDB_SETTINGS_NAMES.HDB_ROOT_KEY, testRoot);
398+
env.setProperty(terms.CONFIG_PARAMS.ROOTPATH, testRoot);
399+
env.setProperty(terms.CONFIG_PARAMS.STORAGE_PATH, dbDir);
400+
env.setProperty(terms.CONFIG_PARAMS.DATABASES, {});
401+
402+
resetDatabases();
403+
// Initial schema: only the primary key — simulates the main thread's stale view
404+
// before a worker restart expands the schema.
405+
table({
406+
table: TABLE,
407+
database: DB,
408+
attributes: [{ name: 'id', isPrimaryKey: true }],
409+
});
410+
// Simulate a worker restart writing the expanded schema to attributesDbi.
411+
// table() updates BOTH in-memory Table.attributes AND attributesDbi, so after
412+
// this call attributesDbi has all four attributes.
413+
table({
414+
table: TABLE,
415+
database: DB,
416+
attributes: [{ name: 'id', isPrimaryKey: true }, { name: 'name' }, { name: 'breed' }, { name: 'age' }],
417+
});
418+
// Re-create the stale main-thread state: reset in-memory Table.attributes back to
419+
// [id] only, while attributesDbi still has all four. This is exactly the mismatch
420+
// that exists on the main thread after a worker restarts and writes a new schema —
421+
// the main thread's Table.attributes hasn't been updated yet.
422+
const staleTable = getDatabases()[DB]?.[TABLE];
423+
staleTable.attributes.splice(0, staleTable.attributes.length, { name: 'id', isPrimaryKey: true });
424+
// Simulate the ITC schema-change handler calling resetDatabases() in the main thread
425+
// (the path that describe_database goes through). Without the fix, initStores()
426+
// only re-syncs indexed/pk attributes and the non-indexed fields stay missing.
427+
resetDatabases();
428+
});
429+
430+
after(async () => {
431+
await fs.remove(testRoot);
432+
});
433+
434+
it('table.attributes includes all non-indexed fields after resetDatabases()', () => {
435+
const tbl = getDatabases()[DB]?.[TABLE];
436+
assert.ok(tbl, `${DB}.${TABLE} should be registered after resetDatabases()`);
437+
const attrNames = tbl.attributes.map((a) => a.name);
438+
assert.ok(attrNames.includes('name'), `expected "name" in attributes, got: ${attrNames}`);
439+
assert.ok(attrNames.includes('breed'), `expected "breed" in attributes, got: ${attrNames}`);
440+
assert.ok(attrNames.includes('age'), `expected "age" in attributes, got: ${attrNames}`);
441+
});
442+
443+
it('removes non-indexed attributes dropped from the schema after resetDatabases()', () => {
444+
// Simulate schema shrinking (field removal), then another resetDatabases().
445+
table({
446+
table: TABLE,
447+
database: DB,
448+
attributes: [{ name: 'id', isPrimaryKey: true }, { name: 'name' }],
449+
});
450+
// table() updates in-memory Table.attributes directly (databases.ts:997), so after the
451+
// call above the in-memory state is already [id, name]. Re-create the stale main-thread
452+
// view — still holding the old [id, name, breed, age] — so that the removal loop in
453+
// initStores() actually needs to drop breed and age. Two adjacent removals (breed AND
454+
// age) also guard against a regression of the splice-while-iterating bug: splicing the
455+
// array being iterated by `for...of` skipped the next element, so the loop must collect
456+
// removals first and apply them after the iteration.
457+
const tblForRemoval = getDatabases()[DB]?.[TABLE];
458+
tblForRemoval.attributes.splice(
459+
0,
460+
tblForRemoval.attributes.length,
461+
{ name: 'id', isPrimaryKey: true },
462+
{ name: 'name' },
463+
{ name: 'breed' },
464+
{ name: 'age' }
465+
);
466+
resetDatabases();
467+
const tbl = getDatabases()[DB]?.[TABLE];
468+
const attrNames = tbl.attributes.map((a) => a.name);
469+
assert.ok(attrNames.includes('name'), `expected "name" in attributes`);
470+
assert.ok(!attrNames.includes('breed'), `"breed" should be removed, got: ${attrNames}`);
471+
assert.ok(!attrNames.includes('age'), `"age" should be removed, got: ${attrNames}`);
472+
});
473+
474+
it('preserves runtime-only relationship attributes across resetDatabases()', () => {
475+
// Relationship attrs are runtime-only — table()'s persistence loop skips them
476+
// (databases.ts:1138, `if (attribute.relationship) continue`), so they are present in
477+
// table.attributes but never written to attributesDbi. After resetDatabases(),
478+
// initStores() rebuilds `attributes` from attributesDbi only — so the relationship attr
479+
// won't appear there. The removal loop must not drop it; otherwise updatedAttributes()
480+
// would strip the resolver/search support and downstream GraphQL nested queries return
481+
// undefined (the integration symptom: graphql-querying-test "handles query by nested
482+
// attribute" assertions).
483+
table({
484+
table: TABLE,
485+
database: DB,
486+
attributes: [{ name: 'id', isPrimaryKey: true }, { name: 'name' }],
487+
});
488+
const tblWithRel = getDatabases()[DB]?.[TABLE];
489+
// Inject a runtime-only relationship attribute, mirroring what graphql.ts does after
490+
// parsing a `@relationship` directive — these never round-trip through attributesDbi.
491+
tblWithRel.attributes.push({ name: 'related', relationship: { from: 'relatedId' } });
492+
resetDatabases();
493+
const tbl = getDatabases()[DB]?.[TABLE];
494+
const attrNames = tbl.attributes.map((a) => a.name);
495+
assert.ok(
496+
attrNames.includes('related'),
497+
`relationship attribute "related" should survive resetDatabases() but was dropped — got: ${attrNames}`
498+
);
499+
});
500+
});
501+
385502
describe('schema-migration fragility: stale store reused after LMDB to RocksDB engine migration (F4)', () => {
386503
if (process.env.HARPER_STORAGE_ENGINE === 'lmdb') return;
387504

0 commit comments

Comments
 (0)