Skip to content

Commit 62a441e

Browse files
Merge pull request #3669 from opral/batch-cache-updates
perf: batch cache updates
2 parents ad2aeb6 + bea3859 commit 62a441e

File tree

6 files changed

+401
-451
lines changed

6 files changed

+401
-451
lines changed

packages/lix-sdk/src/change-set/apply-change-set.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -76,19 +76,19 @@ export async function applyChangeSet(args: {
7676
.execute();
7777

7878
// Write-through cache: populate internal_state_cache for all applied changes
79-
for (const change of changesResult) {
80-
updateStateCache({
81-
lix: args.lix,
82-
change: {
83-
...change,
84-
snapshot_content: change.snapshot_content
85-
? JSON.stringify(change.snapshot_content)
86-
: null,
87-
},
88-
version_id: version.id,
89-
commit_id: version.commit_id,
90-
});
91-
}
79+
const changesForCache = changesResult.map(change => ({
80+
...change,
81+
snapshot_content: change.snapshot_content
82+
? JSON.stringify(change.snapshot_content)
83+
: null,
84+
}));
85+
86+
updateStateCache({
87+
lix: args.lix,
88+
changes: changesForCache,
89+
version_id: version.id,
90+
commit_id: version.commit_id,
91+
});
9292

9393
// Group changes by file_id for processing
9494
const changesGroupedByFile = Object.groupBy(

packages/lix-sdk/src/state/cache/update-state-cache.test.ts

Lines changed: 113 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ test("inserts into cache based on change", async () => {
3838
// Call updateStateCache
3939
updateStateCache({
4040
lix,
41-
change: testChange,
41+
changes: [testChange],
4242
commit_id: commitId,
4343
version_id: versionId,
4444
});
@@ -109,7 +109,7 @@ test("upserts cache entry on conflict", async () => {
109109
// First insert
110110
updateStateCache({
111111
lix,
112-
change: initialChange,
112+
changes: [initialChange],
113113
commit_id: initialCommitId,
114114
version_id: versionId,
115115
});
@@ -153,7 +153,7 @@ test("upserts cache entry on conflict", async () => {
153153
// Second call should trigger onConflict upsert
154154
updateStateCache({
155155
lix,
156-
change: updatedChange,
156+
changes: [updatedChange],
157157
commit_id: updatedCommitId,
158158
version_id: versionId,
159159
});
@@ -233,7 +233,7 @@ test("moves cache entries to children on deletion, clears when no children remai
233233

234234
updateStateCache({
235235
lix,
236-
change: createChange,
236+
changes: [createChange],
237237
commit_id: "parent-commit",
238238
version_id: "parent",
239239
});
@@ -266,17 +266,20 @@ test("moves cache entries to children on deletion, clears when no children remai
266266

267267
updateStateCache({
268268
lix,
269-
change: deleteFromParentChange,
269+
changes: [deleteFromParentChange],
270270
commit_id: "parent-delete-commit",
271271
version_id: "parent",
272272
});
273273

274274
// Verify entity moved to child1 and child2, parent entry removed
275+
// Note: We need to exclude tombstones (inheritance_delete_marker = 1) and entries without content
275276
const cacheAfterParentDelete = await intDb
276277
.selectFrom("internal_state_cache")
277278
.selectAll()
278279
.select(sql`json(snapshot_content)`.as("snapshot_content"))
279280
.where("entity_id", "=", testEntity)
281+
.where("inheritance_delete_marker", "=", 0) // Exclude tombstones
282+
.where("snapshot_content", "is not", null) // Exclude null snapshots
280283
.orderBy("version_id", "asc")
281284
.execute();
282285

@@ -308,7 +311,7 @@ test("moves cache entries to children on deletion, clears when no children remai
308311

309312
updateStateCache({
310313
lix,
311-
change: deleteFromChild1Change,
314+
changes: [deleteFromChild1Change],
312315
commit_id: "child1-delete-commit",
313316
version_id: "child1",
314317
});
@@ -319,6 +322,8 @@ test("moves cache entries to children on deletion, clears when no children remai
319322
.selectAll()
320323
.select(sql`json(snapshot_content)`.as("snapshot_content"))
321324
.where("entity_id", "=", testEntity)
325+
.where("inheritance_delete_marker", "=", 0) // Exclude tombstones
326+
.where("snapshot_content", "is not", null) // Exclude null snapshots
322327
.execute();
323328

324329
expect(cacheAfterChild1Delete).toHaveLength(1);
@@ -343,20 +348,34 @@ test("moves cache entries to children on deletion, clears when no children remai
343348

344349
updateStateCache({
345350
lix,
346-
change: deleteFromChild2Change,
351+
changes: [deleteFromChild2Change],
347352
commit_id: "child2-delete-commit",
348353
version_id: "child2",
349354
});
350355

351-
// Verify entity is completely removed from cache
356+
// Verify tombstones remain in cache (new behavior: tombstones are permanent)
357+
// The important thing is that state_all queries show no active entities
352358
const finalCache = await intDb
353359
.selectFrom("internal_state_cache")
354360
.selectAll()
355361
.select(sql`json(snapshot_content)`.as("snapshot_content"))
356362
.where("entity_id", "=", testEntity)
357363
.execute();
358364

359-
expect(finalCache).toHaveLength(0);
365+
// Should have 3 tombstones (one for each version where we deleted)
366+
expect(finalCache).toHaveLength(3);
367+
expect(finalCache.every(c => c.inheritance_delete_marker === 1)).toBe(true);
368+
expect(finalCache.every(c => c.snapshot_content === null)).toBe(true);
369+
370+
// More importantly, verify that state_all shows no active entities
371+
const stateAllResults = await lix.db
372+
.selectFrom("state_all")
373+
.selectAll()
374+
.where("entity_id", "=", testEntity)
375+
.execute();
376+
377+
// This is what really matters - no visible entities
378+
expect(stateAllResults).toHaveLength(0);
360379
});
361380

362381
test("handles inheritance chain deletions with tombstones", async () => {
@@ -403,7 +422,7 @@ test("handles inheritance chain deletions with tombstones", async () => {
403422

404423
updateStateCache({
405424
lix,
406-
change: createChange,
425+
changes: [createChange],
407426
commit_id: "parent-commit-123",
408427
version_id: "parent-version",
409428
});
@@ -440,7 +459,7 @@ test("handles inheritance chain deletions with tombstones", async () => {
440459

441460
updateStateCache({
442461
lix,
443-
change: deleteChange,
462+
changes: [deleteChange],
444463
commit_id: "child-commit-456",
445464
version_id: "child-version",
446465
});
@@ -519,3 +538,86 @@ test("handles inheritance chain deletions with tombstones", async () => {
519538
// Subchild should show NO entity through state_all (inherits deletion from child)
520539
expect(subchildStateAll).toHaveLength(0);
521540
});
541+
542+
test("copied entries retain original commit_id during deletion copy-down", async () => {
543+
const lix = await openLix({
544+
keyValues: [
545+
{ key: "lix_deterministic_mode", value: { enabled: true, bootstrap: true } },
546+
],
547+
});
548+
549+
// Create inheritance chain: parent -> child1, child2
550+
await createVersion({ lix, id: "parent-cid", inherits_from_version_id: "global" });
551+
await createVersion({ lix, id: "child1-cid", inherits_from_version_id: "parent-cid" });
552+
await createVersion({ lix, id: "child2-cid", inherits_from_version_id: "parent-cid" });
553+
554+
const t1 = timestamp({ lix });
555+
const entityId = "entity-commit-propagation";
556+
557+
// Create in parent with an original commit id
558+
const createChange: LixChangeRaw = {
559+
id: "change-create-cid",
560+
entity_id: entityId,
561+
schema_key: "lix_test",
562+
schema_version: "1.0",
563+
file_id: "lix",
564+
plugin_key: "test_plugin",
565+
snapshot_content: JSON.stringify({ id: entityId, value: "data" }),
566+
created_at: t1,
567+
};
568+
569+
const originalCommitId = "original-commit-id-001";
570+
updateStateCache({ lix, changes: [createChange], commit_id: originalCommitId, version_id: "parent-cid" });
571+
572+
const intDb = lix.db as unknown as Kysely<LixInternalDatabaseSchema>;
573+
574+
// Sanity: parent entry has original commit id
575+
const parentEntry = await intDb
576+
.selectFrom("internal_state_cache")
577+
.selectAll()
578+
.where("entity_id", "=", entityId)
579+
.where("version_id", "=", "parent-cid")
580+
.executeTakeFirstOrThrow();
581+
expect(parentEntry.commit_id).toBe(originalCommitId);
582+
583+
// Delete in parent with a different commit id; this should copy entries to children
584+
const t2 = timestamp({ lix });
585+
const deleteChange: LixChangeRaw = {
586+
id: "change-delete-cid",
587+
entity_id: entityId,
588+
schema_key: "lix_test",
589+
schema_version: "1.0",
590+
file_id: "lix",
591+
plugin_key: "test_plugin",
592+
snapshot_content: null,
593+
created_at: t2,
594+
};
595+
const deletionCommitId = "deletion-commit-id-002";
596+
updateStateCache({ lix, changes: [deleteChange], commit_id: deletionCommitId, version_id: "parent-cid" });
597+
598+
// Verify copied entries exist in both children with the ORIGINAL commit id
599+
const childEntries = await intDb
600+
.selectFrom("internal_state_cache")
601+
.selectAll()
602+
.where("entity_id", "=", entityId)
603+
.where("inheritance_delete_marker", "=", 0)
604+
.where("snapshot_content", "is not", null)
605+
.where("version_id", "in", ["child1-cid", "child2-cid"])
606+
.execute();
607+
608+
expect(childEntries).toHaveLength(2);
609+
for (const entry of childEntries) {
610+
expect(["child1-cid", "child2-cid"]).toContain(entry.version_id);
611+
expect(entry.commit_id).toBe(originalCommitId);
612+
}
613+
614+
// Tombstone in parent should have the deletion commit id
615+
const tombstone = await intDb
616+
.selectFrom("internal_state_cache")
617+
.selectAll()
618+
.where("entity_id", "=", entityId)
619+
.where("version_id", "=", "parent-cid")
620+
.where("inheritance_delete_marker", "=", 1)
621+
.executeTakeFirstOrThrow();
622+
expect(tombstone.commit_id).toBe(deletionCommitId);
623+
});

0 commit comments

Comments
 (0)