Skip to content

Commit d080c42

Browse files
authored
Track the schema in the cycle tracker (#1783)
* Enhance cycle tracking so we only consider it a cycle if we have the same object with the same schema. - If the schema is different, we want to still traverse the object, since the other schema may match links * Removed warning when we encounter a cycle, since this is now ok Enhanced MapSet to take an optional equalFn, since it's now possible to add an equivalent schema multiple times. Added unit test that confirms that we call traverseObjectWithSchema on a cell with both its initial schema and its new schema after following a path. * Update this schema tracker to use deepEqual as well * organize imports
1 parent 692c6bc commit d080c42

File tree

3 files changed

+243
-33
lines changed

3 files changed

+243
-33
lines changed

packages/memory/space-schema.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import type { JSONObject, JSONValue, URI } from "@commontools/runner";
1+
import {
2+
deepEqual,
3+
type JSONObject,
4+
type JSONValue,
5+
type SchemaContext,
6+
} from "@commontools/runner";
27
import {
38
BaseMemoryAddress,
49
BaseObjectManager,
5-
CycleTracker,
10+
CompoundCycleTracker,
611
DefaultSchemaSelector,
712
getAtPath,
813
type IAttestation,
@@ -154,8 +159,11 @@ export const selectSchema = <Space extends MemorySpace>(
154159
// Track any docs loaded while traversing the factSelection
155160
const manager = new ServerObjectManager(session, providedClassifications);
156161
// while loading dependent docs, we want to avoid cycles
157-
const tracker = new CycleTracker<Immutable<JSONValue>>();
158-
const schemaTracker = new MapSet<string, SchemaPathSelector>();
162+
const tracker = new CompoundCycleTracker<
163+
Immutable<JSONValue>,
164+
SchemaContext | undefined
165+
>();
166+
const schemaTracker = new MapSet<string, SchemaPathSelector>(deepEqual);
159167

160168
const includedFacts: FactSelection = {}; // we'll store all the raw facts we accesed here
161169
// First, collect all the potentially relevant facts (without dereferencing pointers)

packages/runner/src/traverse.ts

Lines changed: 100 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { refer } from "merkle-reference";
2+
import { SchemaAll } from "@commontools/memory/schema";
23
// TODO(@ubik2): Ideally this would use the following, but rollup has issues
34
//import { isNumber, isObject, isString } from "@commontools/utils/types";
45
import {
56
type Immutable,
67
isNumber,
78
isObject,
9+
isRecord,
810
isString,
911
} from "../../utils/src/types.ts";
1012
import { getLogger } from "../../utils/src/logger.ts";
@@ -17,7 +19,6 @@ import type {
1719
} from "./builder/types.ts";
1820
import { deepEqual } from "./path-utils.ts";
1921
import { isAnyCellLink, parseLink } from "./link-utils.ts";
20-
import type { URI } from "./sigil-types.ts";
2122
import { fromURI } from "./uri-utils.ts";
2223
import type { IAttestation, IMemoryAddress } from "./storage/interface.ts";
2324

@@ -35,20 +36,35 @@ export type SchemaPathSelector = {
3536
* A data structure that maps keys to sets of values, allowing multiple values
3637
* to be associated with a single key without duplication.
3738
*
39+
* While the default behavior is to use object equality, you can provide an
40+
* `equalFn` parameter to the constructor, which will be used for the value
41+
* comparisons.
42+
*
3843
* @template K The type of keys in the map
3944
* @template V The type of values stored in the sets
4045
*/
4146
export class MapSet<K, V> {
4247
private map = new Map<K, Set<V>>();
48+
private equalFn?: (a: V, b: V) => boolean;
49+
50+
constructor(equalFn?: (a: V, b: V) => boolean) {
51+
this.equalFn = equalFn;
52+
}
4353

4454
public get(key: K): Set<V> | undefined {
4555
return this.map.get(key);
4656
}
4757

4858
public add(key: K, value: V) {
49-
if (!this.map.has(key)) {
59+
const values = this.map.get(key);
60+
if (values === undefined) {
5061
const values = new Set<V>([value]);
5162
this.map.set(key, values);
63+
} else if (
64+
this.equalFn !== undefined &&
65+
(values.values().some((item) => this.equalFn!(item, value)))
66+
) {
67+
return;
5268
} else {
5369
this.map.get(key)!.add(value);
5470
}
@@ -60,15 +76,28 @@ export class MapSet<K, V> {
6076

6177
public hasValue(key: K, value: V): boolean {
6278
const values = this.map.get(key);
63-
return (values !== undefined && values.has(value));
79+
if (values !== undefined && this.equalFn !== undefined) {
80+
return values.values().some((item) => this.equalFn!(item, value));
81+
}
82+
return values !== undefined && values.has(value);
6483
}
6584

6685
public deleteValue(key: K, value: V): boolean {
6786
if (!this.map.has(key)) {
6887
return false;
6988
} else {
7089
const values = this.map.get(key)!;
71-
const rv = values.delete(value);
90+
let existing: V = value;
91+
if (this.equalFn !== undefined) {
92+
const match = values.values().find((item) =>
93+
this.equalFn!(item, value)
94+
);
95+
if (match === undefined) {
96+
return false;
97+
}
98+
existing = match;
99+
}
100+
const rv = values.delete(existing);
72101
if (values.size === 0) {
73102
this.map.delete(key);
74103
}
@@ -126,8 +155,57 @@ export class CycleTracker<K> {
126155
}
127156
}
128157

129-
export type PointerCycleTracker = CycleTracker<
130-
Immutable<JSONValue>
158+
/**
159+
* Cycle tracker for more complex objects with multiple parts.
160+
*
161+
* This will not work correctly if the key is modified after being added.
162+
*
163+
* This will do an identity check on the partial key and a deepEqual check on
164+
* the ExtraKey.
165+
*/
166+
export class CompoundCycleTracker<PartialKey, ExtraKey> {
167+
private partial: Map<PartialKey, ExtraKey[]>;
168+
constructor() {
169+
this.partial = new Map<PartialKey, ExtraKey[]>();
170+
}
171+
include(
172+
partialKey: PartialKey,
173+
extraKey: ExtraKey,
174+
context?: unknown,
175+
): Disposable | null {
176+
let existing = this.partial.get(partialKey);
177+
if (existing === undefined) {
178+
existing = [];
179+
this.partial.set(partialKey, existing);
180+
}
181+
if (existing.some((item) => deepEqual(item, extraKey))) {
182+
return null;
183+
}
184+
existing.push(extraKey);
185+
return {
186+
[Symbol.dispose]: () => {
187+
const entries = this.partial.get(partialKey)!;
188+
const index = entries.indexOf(extraKey);
189+
if (index === -1) {
190+
logger.error(() => [
191+
"Failed to dispose of missing key",
192+
extraKey,
193+
context,
194+
]);
195+
}
196+
if (entries.length === 0) {
197+
this.partial.delete(partialKey);
198+
} else {
199+
entries.splice(index, 1);
200+
}
201+
},
202+
};
203+
}
204+
}
205+
206+
export type PointerCycleTracker = CompoundCycleTracker<
207+
Immutable<JSONValue>,
208+
SchemaContext | undefined
131209
>;
132210

133211
export interface ObjectStorageManager<K, S, V> {
@@ -220,7 +298,7 @@ export abstract class BaseObjectTraverser<S extends BaseMemoryAddress> {
220298
if (isPrimitive(doc.value)) {
221299
return doc.value;
222300
} else if (Array.isArray(doc.value)) {
223-
using t = tracker.include(doc.value, doc);
301+
using t = tracker.include(doc.value, SchemaAll, doc);
224302
if (t === null) {
225303
return null;
226304
}
@@ -238,7 +316,7 @@ export abstract class BaseObjectTraverser<S extends BaseMemoryAddress> {
238316
schemaTracker,
239317
)
240318
) as Immutable<JSONValue>[];
241-
} else if (isObject(doc.value)) {
319+
} else if (isRecord(doc.value)) {
242320
// First, see if we need special handling
243321
if (isAnyCellLink(doc.value)) {
244322
const [newDoc, _] = getAtPath(
@@ -254,14 +332,12 @@ export abstract class BaseObjectTraverser<S extends BaseMemoryAddress> {
254332
}
255333
return this.traverseDAG(newDoc, tracker, schemaTracker);
256334
} else {
257-
using t = tracker.include(doc.value, doc);
335+
using t = tracker.include(doc.value, SchemaAll, doc);
258336
if (t === null) {
259337
return null;
260338
}
261339
return Object.fromEntries(
262-
Object.entries(doc.value).map((
263-
[k, value],
264-
) => [
340+
Object.entries(doc.value as JSONObject).map(([k, value]) => [
265341
k,
266342
this.traverseDAG(
267343
{
@@ -388,11 +464,6 @@ function followPointer<S extends BaseMemoryAddress>(
388464
schemaTracker?: MapSet<string, SchemaPathSelector>,
389465
selector?: SchemaPathSelector,
390466
): [IAttestation, SchemaPathSelector | undefined] {
391-
using t = tracker.include(doc.value!, doc);
392-
if (t === null) {
393-
// Cycle detected - treat this as notFound to avoid traversal
394-
return [notFound(doc.address), selector];
395-
}
396467
const link = parseLink(doc.value)!;
397468
const target: BaseMemoryAddress = (link.id !== undefined)
398469
? { id: link.id, type: "application/json" }
@@ -416,6 +487,11 @@ function followPointer<S extends BaseMemoryAddress>(
416487
link.path as string[],
417488
);
418489
}
490+
using t = tracker.include(doc.value!, selector?.schemaContext, doc);
491+
if (t === null) {
492+
// Cycle detected - treat this as notFound to avoid traversal
493+
return [notFound(doc.address), selector];
494+
}
419495
if (link.id !== undefined) {
420496
// We have a reference to a different cell, so track the dependency
421497
// and update our targetDoc
@@ -619,13 +695,14 @@ export class SchemaObjectTraverser<S extends BaseMemoryAddress>
619695
constructor(
620696
manager: BaseObjectManager<S, Immutable<JSONValue> | undefined>,
621697
private selector: SchemaPathSelector,
622-
private tracker: PointerCycleTracker = new CycleTracker<
623-
Immutable<JSONValue>
698+
private tracker: PointerCycleTracker = new CompoundCycleTracker<
699+
Immutable<JSONValue>,
700+
SchemaContext | undefined
624701
>(),
625702
private schemaTracker: MapSet<string, SchemaPathSelector> = new MapSet<
626703
string,
627704
SchemaPathSelector
628-
>(),
705+
>(deepEqual),
629706
) {
630707
super(manager);
631708
}
@@ -730,7 +807,7 @@ export class SchemaObjectTraverser<S extends BaseMemoryAddress>
730807
return this.isValidType(schemaObj, "number") ? doc.value : undefined;
731808
} else if (Array.isArray(doc.value)) {
732809
if (this.isValidType(schemaObj, "array")) {
733-
using t = this.tracker.include(doc.value, doc);
810+
using t = this.tracker.include(doc.value, schemaContext, doc);
734811
if (t === null) {
735812
return null;
736813
}
@@ -746,10 +823,8 @@ export class SchemaObjectTraverser<S extends BaseMemoryAddress>
746823
schema: schemaObj,
747824
rootSchema: schemaContext.rootSchema,
748825
});
749-
// TODO(@ubik2): it might be technically ok to follow the same pointer more than once, since we might have
750-
// a different schema the second time, which could prevent an infinite cycle, but for now, just reject these.
751826
} else if (this.isValidType(schemaObj, "object")) {
752-
using t = this.tracker.include(doc.value, doc);
827+
using t = this.tracker.include(doc.value, schemaContext, doc);
753828
if (t === null) {
754829
return null;
755830
}
@@ -897,7 +972,7 @@ export class SchemaObjectTraverser<S extends BaseMemoryAddress>
897972
// but we may have a pointer cycle of docs, and we've finished resolving
898973
// the pointer now. To avoid descending into a cycle, track entry to the
899974
// doc we were called with (not the one we resolved, which may be a pointer).
900-
using t = this.tracker.include(doc.value!, doc);
975+
using t = this.tracker.include(doc.value!, schemaContext, doc);
901976
if (t === null) {
902977
return null;
903978
}

0 commit comments

Comments
 (0)