Skip to content

Commit e0feb77

Browse files
Eliran Eretz-KedoshaEliran Eretz-Kedosha
authored andcommitted
Fix stale index copies on scoped put after get
Jeremie flagged that putInIndexAfterGet_DoNotUse could leave a stale copy of an item in any index that wasn't part of the caller's scoped indexNames list, since each InMemoryIndex holds its own independent copy of an item's data. When an existing item is being overwritten, always remove the stale copy from every index it's actually cached by (not just the requested ones), then repopulate the union of "indexes that had it" and the caller's requested indexes. New items (never cached before) are unaffected and still only populate the requested/scoped index(es), preserving the original anti-"memory island" guarantee. - InMemoryIndex.remove() now returns whether it actually removed an entry, so callers can tell which indexes held the item. - _removeFromIndices() returns the list of indexes an item was actually removed from. - _putInternal (backing both put() and putInIndexAfterGet_DoNotUse()) uses that list to compute which indexes to repopulate. Added a regression test exercising Jeremie's exact scenario: an item cached across 3 indexes via a normal put(), then a scoped put via 2 of them with changed data -- asserts the 3rd (untouched by indexNames) index reflects the new data instead of the old, stale copy.
1 parent b3b485a commit e0feb77

2 files changed

Lines changed: 180 additions & 24 deletions

File tree

src/InMemoryProvider.ts

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
takeRight,
2222
drop,
2323
take,
24+
unionBy,
2425
} from "lodash";
2526
import {
2627
DbIndexFTSFromRangeQueries,
@@ -403,14 +404,17 @@ class InMemoryStore implements DbStore {
403404
* an optional 3rd parameter on it) so that the normal, always-safe `put()` required by the shared
404405
* `DbStore` interface can never accidentally be called with scoping semantics.
405406
*
406-
* @param indexNames Scoping hint: items are only removed from / re-written into the primary key
407-
* plus the listed index(es) -- every other index on the store is left completely untouched. This
408-
* lets callers who fetched data through a single index (e.g. a ranged read served from that
409-
* index) cache the results without seeding "islands" of items into unrelated indexes that were
410-
* never actually queried/loaded for those items, and without evicting an already-cached item from
411-
* indexes this call didn't touch. Note that an index left untouched here can only go stale in
412-
* content if the item's data changes without ever going through the real (unscoped) `put()` path --
413-
* real writes always resync every index.
407+
* @param indexNames Scoping hint for *newly seen* items only: an item this store has never
408+
* cached before is only written into the primary key plus the listed index(es) -- every other
409+
* index on the store is left untouched, so callers who fetched data through a single index (e.g.
410+
* a ranged read served from that index) can cache the results without seeding "islands" of items
411+
* into unrelated indexes that were never actually queried/loaded for those items.
412+
*
413+
* If the item was already cached, the stale copy is removed from -- and the fresh copy is
414+
* re-written into -- every index it was actually present in (not just the listed one(s)), so an
415+
* index this call didn't ask for can never end up holding stale content. The listed index(es) are
416+
* additionally guaranteed to receive the fresh copy even if the item wasn't previously tracked by
417+
* them, so the ranged read that triggered this call still gets a correctly populated cache there.
414418
*/
415419
putInIndexAfterGet_DoNotUse(
416420
itemOrItems: ItemType | ItemType[],
@@ -434,28 +438,43 @@ class InMemoryStore implements DbStore {
434438
)!!!;
435439
const existingItem = this._mergedData.get(pk);
436440

437-
// Scope both the removal (of the stale copy) and the re-add (of the new copy) to the
438-
// same index list, so an index this call didn't ask for is never touched either way.
439-
const indexesToTouch = indexNames
441+
// Scoping hint from the caller: for a brand-new item (never cached before) this is where
442+
// the fresh copy is written. Left undefined (i.e. every index) for the normal, unscoped
443+
// put() path.
444+
const requestedIndexes = indexNames
440445
? filter(this._storeSchema.indexes, (index) =>
441446
includes(indexNames, index.name)
442447
)
443448
: this._storeSchema.indexes;
444449

450+
let indexesToPopulate = requestedIndexes;
451+
445452
if (existingItem) {
446-
// We're going to overwrite the PK anyways - don't remove PK
447-
this._removeFromIndices(
453+
// Always remove the stale copy from every index it's actually present in -- each index
454+
// holds its own copy of the item, so scoping the removal to just the requested index(es)
455+
// would leave an un-refreshed, stale copy behind in any other index the item was already
456+
// tracked by (see PR #87 discussion).
457+
const indexesThatHadItem = this._removeFromIndices(
448458
pk,
449459
existingItem,
450460
/** RemovePrimaryKey */ false,
451-
indexesToTouch
461+
this._storeSchema.indexes ?? []
452462
);
463+
464+
// Re-populate every index the item was actually already cached by (so it's refreshed,
465+
// never left stale) plus whichever index(es) this call is scoped to (so a ranged read
466+
// still gets a correctly populated cache there, even for an index the item wasn't
467+
// previously tracked by).
468+
indexesToPopulate = indexNames
469+
? unionBy(indexesThatHadItem, requestedIndexes ?? [], "name")
470+
: this._storeSchema.indexes;
453471
}
472+
454473
this._mergedData.set(pk, item);
455474
(this.openPrimaryKey() as InMemoryIndex).put(item);
456475

457-
if (indexesToTouch) {
458-
for (const index of indexesToTouch) {
476+
if (indexesToPopulate) {
477+
for (const index of indexesToPopulate) {
459478
(this.openIndex(index.name) as InMemoryIndex).put(item);
460479
}
461480
}
@@ -605,29 +624,44 @@ class InMemoryStore implements DbStore {
605624
item: ItemType,
606625
removePrimaryKey: boolean,
607626
indexesToRemoveFrom: IndexSchema[] = this._storeSchema.indexes ?? []
608-
) {
627+
): IndexSchema[] {
609628
// Don't need to remove from primary key on Puts because set is enough
610629
// 1. If it's an existing key then it will get overwritten
611630
// 2. If it's a new key then we need to add it
612631
if (removePrimaryKey) {
613632
(this.openPrimaryKey() as InMemoryIndex).remove(key);
614633
}
615634

635+
const indexesThatHadItem: IndexSchema[] = [];
636+
616637
each(indexesToRemoveFrom, (index: IndexSchema) => {
617638
const ind = this.openIndex(index.name) as InMemoryIndex;
618639
const indexKeys = ind.internal_getKeysFromItem(item);
619640

620641
// when it's a unique index, value is the item.
621642
// in case of a non-unique index, value is an array of items,
622643
// and we want to only remove items that have the same primary key
644+
let removedFromThisIndex = false;
623645
if (ind.isUniqueIndex()) {
624-
each(indexKeys, (indexKey: string) => ind.remove(indexKey));
646+
each(indexKeys, (indexKey: string) => {
647+
if (ind.remove(indexKey)) {
648+
removedFromThisIndex = true;
649+
}
650+
});
625651
} else {
626-
each(indexKeys, (idxKey: string) =>
627-
ind.remove({ idxKey, primaryKey: key })
628-
);
652+
each(indexKeys, (idxKey: string) => {
653+
if (ind.remove({ idxKey, primaryKey: key })) {
654+
removedFromThisIndex = true;
655+
}
656+
});
657+
}
658+
659+
if (removedFromThisIndex) {
660+
indexesThatHadItem.push(index);
629661
}
630662
});
663+
664+
return indexesThatHadItem;
631665
}
632666
}
633667

@@ -739,22 +773,24 @@ class InMemoryIndex extends DbIndexFTSFromRangeQueries {
739773
* Removes item from index. For non-unique indices, a pair of index value and a primary key is required.
740774
* @param key a string, if it's a unique index, a pair of key value and a primary key, if it's a non-unique index
741775
* @param skipTransactionOnCreation
742-
* @returns
776+
* @returns Whether an entry was actually found and removed.
743777
*/
744778
public remove(
745779
key: string | { primaryKey: string; idxKey: string },
746780
skipTransactionOnCreation?: boolean
747-
) {
781+
): boolean {
748782
if (!skipTransactionOnCreation && !this._trans!.internal_isOpen()) {
749783
throw new Error("InMemoryTransaction already closed");
750784
}
751785

752786
if (typeof key === "string") {
787+
const hadKey = this._indexTree.has(key);
753788
this._indexTree.delete(key);
789+
return hadKey;
754790
} else {
755791
const idxItems = this._indexTree.get(key.idxKey);
756792
if (!idxItems) {
757-
return;
793+
return false;
758794
}
759795

760796
const idxItemsWithoutItem = idxItems.filter((idxItem) => {
@@ -765,6 +801,11 @@ class InMemoryIndex extends DbIndexFTSFromRangeQueries {
765801
return idxItemPrimaryKeyVal !== key.primaryKey;
766802
});
767803

804+
if (idxItemsWithoutItem.length === idxItems.length) {
805+
// Nothing matched key.primaryKey -- no-op.
806+
return false;
807+
}
808+
768809
// if we removed all items, remove the index tree node.
769810
// otherwise, update the index value with the new array
770811
// sans the primary key item
@@ -773,6 +814,7 @@ class InMemoryIndex extends DbIndexFTSFromRangeQueries {
773814
} else {
774815
this._indexTree.set(key.idxKey, idxItemsWithoutItem);
775816
}
817+
return true;
776818
}
777819
}
778820

src/tests/ObjectStoreProvider.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2279,6 +2279,120 @@ describe("ObjectStoreProvider", function () {
22792279
);
22802280
});
22812281

2282+
it("put with indexNames refreshes -- rather than skips -- any OTHER index the item was already tracked by, so it never goes stale", (done) => {
2283+
// indexNames scoping is an InMemoryProvider-only optimization -- other providers either maintain
2284+
// indexes natively (indexeddb) or ignore the extra parameter entirely.
2285+
if (provName.indexOf("memory") === -1) {
2286+
done();
2287+
return;
2288+
}
2289+
2290+
openProvider(
2291+
provName,
2292+
{
2293+
version: 1,
2294+
stores: [
2295+
{
2296+
name: "test",
2297+
primaryKeyPath: "id",
2298+
indexes: [
2299+
{ name: "indexA", keyPath: "a" },
2300+
{ name: "indexB", keyPath: "b" },
2301+
{ name: "indexC", keyPath: "c" },
2302+
],
2303+
},
2304+
],
2305+
},
2306+
true
2307+
)
2308+
.then((prov) => {
2309+
const maybeScopedPutProv = asScopedIndexPutProvider(prov);
2310+
assert(!!maybeScopedPutProv);
2311+
const scopedPutProv = maybeScopedPutProv!!!;
2312+
2313+
// A real (unscoped) write populates the item into all three indexes.
2314+
return prov
2315+
.put("test", { id: "item1", a: "a-1", b: "b-1", c: "c-1" })
2316+
.then(() =>
2317+
Promise.all([
2318+
prov.getAll("test", "indexA"),
2319+
prov.getAll("test", "indexB"),
2320+
prov.getAll("test", "indexC"),
2321+
])
2322+
)
2323+
.then(([byIndexA, byIndexB, byIndexC]) => {
2324+
assert.equal(byIndexA.length, 1);
2325+
assert.equal(byIndexB.length, 1);
2326+
assert.equal(byIndexC.length, 1);
2327+
})
2328+
.then(() => {
2329+
// A later scoped "write after get" only lists indexA and indexB -- indexC is not
2330+
// in the scoped list, but the item's data has changed. indexC must still be
2331+
// refreshed with the new data (not left holding its own stale, un-refreshed copy)
2332+
// since it already had this item tracked before this call.
2333+
return scopedPutProv.putInIndexAfterGet_DoNotUse(
2334+
"test",
2335+
{
2336+
id: "item1",
2337+
a: "a-1-updated",
2338+
b: "b-1-updated",
2339+
c: "c-1-updated",
2340+
},
2341+
["indexA", "indexB"]
2342+
);
2343+
})
2344+
.then(() =>
2345+
Promise.all([
2346+
prov.getAll("test", "indexA"),
2347+
prov.getAll("test", "indexB"),
2348+
prov.getAll("test", "indexC"),
2349+
])
2350+
)
2351+
.then(([byIndexA, byIndexB, byIndexC]) => {
2352+
assert.equal(byIndexA.length, 1);
2353+
assert.equal((byIndexA[0] as any).a, "a-1-updated");
2354+
2355+
assert.equal(byIndexB.length, 1);
2356+
assert.equal((byIndexB[0] as any).b, "b-1-updated");
2357+
2358+
// The key regression check: indexC was not in the scoped indexNames list for this
2359+
// call, but it already tracked item1, so it must be refreshed with the new data
2360+
// rather than left holding the stale "c-1" copy.
2361+
assert.equal(byIndexC.length, 1);
2362+
assert.equal((byIndexC[0] as any).c, "c-1-updated");
2363+
})
2364+
.then(() => {
2365+
// A brand-new item (never cached before) scoped to a subset of indexes must still
2366+
// only populate the requested index(es) -- the anti-"memory island" guarantee this
2367+
// scoping exists for is unaffected by the staleness fix above.
2368+
return scopedPutProv
2369+
.putInIndexAfterGet_DoNotUse(
2370+
"test",
2371+
{ id: "item2", a: "a-2", b: "b-2", c: "c-2" },
2372+
["indexA"]
2373+
)
2374+
.then(() =>
2375+
Promise.all([
2376+
prov.getAll("test", "indexA"),
2377+
prov.getAll("test", "indexB"),
2378+
prov.getAll("test", "indexC"),
2379+
])
2380+
)
2381+
.then(([byIndexA, byIndexB, byIndexC]) => {
2382+
assert.equal(byIndexA.length, 2);
2383+
assert.equal(byIndexB.length, 1); // item2 not seeded into indexB
2384+
assert.equal(byIndexC.length, 1); // item2 not seeded into indexC
2385+
});
2386+
})
2387+
.then(() => prov.close())
2388+
.catch((e) => prov.close().then(() => Promise.reject(e)));
2389+
})
2390+
.then(
2391+
() => done(),
2392+
(err) => done(err)
2393+
);
2394+
});
2395+
22822396
it("Invalid Key Type", (done) => {
22832397
openProvider(
22842398
provName,

0 commit comments

Comments
 (0)