diff --git a/.changeset/famous-birds-promise.md b/.changeset/famous-birds-promise.md new file mode 100644 index 000000000..f47d7dbbf --- /dev/null +++ b/.changeset/famous-birds-promise.md @@ -0,0 +1,7 @@ +--- +"@dataplan/pg": patch +"grafast": patch +--- + +Add support for stable deduplication of object/list arguments to +loadOne/loadMany, reducing redundant fetches. diff --git a/grafast/grafast/__tests__/loadOne-test.ts b/grafast/grafast/__tests__/loadOne-test.ts index e17ccfd72..487eae84b 100644 --- a/grafast/grafast/__tests__/loadOne-test.ts +++ b/grafast/grafast/__tests__/loadOne-test.ts @@ -2,33 +2,71 @@ import { expect } from "chai"; import type { ExecutionResult } from "graphql"; import { it } from "mocha"; -import type { ExecutableStep, LoadOneCallback } from "../dist/index.js"; -import { - grafast, - list, - loadOne, - makeGrafastSchema, - object, +import type { + ExecutableStep, + LoadedRecordStep, + LoadOneCallback, } from "../dist/index.js"; +import { context, grafast, loadOne, makeGrafastSchema } from "../dist/index.js"; interface Thing { id: number; + orgId: number; + orgRegNo: number; name: string; reallyLongBio: string; } const THINGS: Thing[] = [ { id: 1, + orgId: 27, + orgRegNo: 93, name: "Eyedee Won", reallyLongBio: "Really long bio. ".repeat(1000), }, { id: 2, + orgId: 42, + orgRegNo: 120, name: "Idee Too", reallyLongBio: "Super long bio. ".repeat(1000), }, + { + id: 2003, + orgId: 27, + orgRegNo: 987, + name: "Eye D. Tree", + reallyLongBio: "Somewhat long bio. ".repeat(1000), + }, + { + id: 2004, + orgId: 42, + orgRegNo: 987, + name: "I.D. Phwoar", + reallyLongBio: "Quite long bio. ".repeat(1000), + }, ]; +interface Org { + id: number; +} +const ORGS: Org[] = [ + { + id: 27, + }, + { + id: 42, + }, +]; + +declare global { + namespace Grafast { + interface Context { + orgId: number; + } + } +} + function pick( obj: T, keys: readonly K[], @@ -39,7 +77,13 @@ function pick( } let CALLS: { - specs: readonly (number | { identifier: number } | readonly number[])[]; + specs: ReadonlyArray< + | number + | { identifier: number } + | readonly [identifier: number] + | { orgId: number; regNo: number } + | readonly [orgId: number, regNo: number] + >; result: object; attributes: readonly (keyof Thing)[] | null; params: object; @@ -80,6 +124,45 @@ const loadThingByIdentifierLists: LoadOneCallback< return result; }; +const loadThingByOrgIdRegNoObjs: LoadOneCallback< + { orgId: number; regNo: number }, + Thing, + Record +> = (specs, { attributes, params }) => { + const result = specs + .map((spec) => + THINGS.find((t) => t.orgId === spec.orgId && t.orgRegNo === spec.regNo), + ) + .map((t) => (t && attributes ? pick(t, attributes) : t)); + CALLS.push({ specs, result, attributes, params }); + return result; +}; + +const loadThingByOrgIdRegNoTuples: LoadOneCallback< + readonly [orgId: number, regNo: number], + Thing, + Record +> = (specs, { attributes, params }) => { + const result = specs + .map((spec) => + THINGS.find((t) => t.orgId === spec[0] && t.orgRegNo === spec[1]), + ) + .map((t) => (t && attributes ? pick(t, attributes) : t)); + CALLS.push({ specs, result, attributes, params }); + return result; +}; + +const loadOrgByIds: LoadOneCallback> = ( + specs, + { attributes, params }, +) => { + const result = specs + .map((id) => ORGS.find((t) => t.id === id)) + .map((t) => (t && attributes ? pick(t, attributes) : t)); + // CALLS.push({ specs, result, attributes, params }); + return result; +}; + const makeSchema = (useStreamableStep = false) => { return makeGrafastSchema({ typeDefs: /* GraphQL */ ` @@ -87,11 +170,20 @@ const makeSchema = (useStreamableStep = false) => { id: Int! name: String! reallyLongBio: String! + org: Org! + orgRegNo: Int! + } + type Org { + id: Int! + thingByTuple(regNo: Int!): Thing + thingByObj(regNo: Int!): Thing } type Query { thingById(id: Int!): Thing thingByIdObj(id: Int!): Thing thingByIdList(id: Int!): Thing + thingByOrgIdRegNoTuple(regNo: Int!): Thing + thingByOrgIdRegNoObj(regNo: Int!): Thing } `, plans: { @@ -113,6 +205,47 @@ const makeSchema = (useStreamableStep = false) => { loadThingByIdentifierLists, ); }, + thingByOrgIdRegNoTuple(_, { $regNo }) { + const $orgId = context().get("orgId"); + return loadOne( + [$orgId, $regNo], + // Deliberately not using ioEquivalence here to test stable object/tuple creation + //["orgId", "orgRegNo"], + loadThingByOrgIdRegNoTuples, + ); + }, + thingByOrgIdRegNoObj(_, { $regNo }) { + const $orgId = context().get("orgId"); + return loadOne( + { orgId: $orgId, regNo: $regNo }, + // Deliberately not using ioEquivalence here to test stable object/tuple creation + //{ orgId: "orgId", regNo: "orgRegNo" }, + loadThingByOrgIdRegNoObjs, + ); + }, + }, + Thing: { + org($thing: LoadedRecordStep) { + return loadOne($thing.get("orgId"), "id", loadOrgByIds); + }, + }, + Org: { + thingByTuple($org: LoadedRecordStep, { $regNo }) { + const $orgId = $org.get("id"); + return loadOne( + [$orgId, $regNo], + ["orgId", "orgRegNo"], + loadThingByOrgIdRegNoTuples, + ); + }, + thingByObj($org: LoadedRecordStep, { $regNo }) { + const $orgId = $org.get("id"); + return loadOne( + { orgId: $orgId, regNo: $regNo }, + { orgId: "orgId", regNo: "orgRegNo" }, + loadThingByOrgIdRegNoObjs, + ); + }, }, }, enableDeferStream: true, @@ -366,3 +499,101 @@ it("supports no ioEquivalence", async () => { expect(CALLS[2].specs).to.deep.equal([[1]]); expect(CALLS[2].attributes).to.deep.equal(["name"]); }); + +it("uses stable identifiers to avoid the need for double-fetches (tuple)", async () => { + const source = /* GraphQL */ ` + { + t1: thingByOrgIdRegNoTuple(regNo: 987) { + id + name + org { + id + t1: thingByTuple(regNo: 987) { + id + name + } + } + } + } + `; + const schema = makeSchema(false); + + CALLS = []; + const result = (await grafast( + { + schema, + source, + contextValue: { + orgId: 27, + }, + }, + {}, + {}, + )) as ExecutionResult; + expect(result).to.deep.equal({ + data: { + t1: { + id: 2003, + name: "Eye D. Tree", + org: { + id: 27, + t1: { + id: 2003, + name: "Eye D. Tree", + }, + }, + }, + }, + }); + expect(CALLS).to.have.length(1); + expect(CALLS[0].attributes).to.deep.equal(["id", "name", "orgId"]); +}); + +it("uses stable identifiers to avoid the need for double-fetches (obj)", async () => { + const source = /* GraphQL */ ` + { + t1: thingByOrgIdRegNoObj(regNo: 987) { + id + name + org { + id + t1: thingByObj(regNo: 987) { + id + name + } + } + } + } + `; + const schema = makeSchema(false); + + CALLS = []; + const result = (await grafast( + { + schema, + source, + contextValue: { + orgId: 27, + }, + }, + {}, + {}, + )) as ExecutionResult; + expect(result).to.deep.equal({ + data: { + t1: { + id: 2003, + name: "Eye D. Tree", + org: { + id: 27, + t1: { + id: 2003, + name: "Eye D. Tree", + }, + }, + }, + }, + }); + expect(CALLS).to.have.length(1); + expect(CALLS[0].attributes).to.deep.equal(["id", "name", "orgId"]); +}); diff --git a/grafast/grafast/src/multistep.ts b/grafast/grafast/src/multistep.ts index f007d80d9..9a16f0859 100644 --- a/grafast/grafast/src/multistep.ts +++ b/grafast/grafast/src/multistep.ts @@ -38,17 +38,37 @@ export type UnwrapMultistep = : never; }; +interface MultistepCacheConfig { + identifier: string; + cacheSize: number; +} + export function multistep( spec: TMultistepSpec, + stable?: string | true | MultistepCacheConfig, ): ExecutableStep> { if (spec == null) { return constant(spec) as any; } else if (spec instanceof ExecutableStep) { return spec; } else if (isTuple(spec)) { - return list(spec) as any; + const config = + stable === true + ? { identifier: `multistep` } + : typeof stable === "string" + ? { identifier: stable } + : stable; + const $step = list(spec, config); + return $step as any; } else { - return object(spec) as any; + const config = + stable === true + ? { identifier: `multistep` } + : typeof stable === "string" + ? { identifier: stable } + : stable; + const $step = object(spec, config); + return $step as any; } } diff --git a/grafast/grafast/src/steps/list.ts b/grafast/grafast/src/steps/list.ts index ffdb6228c..00883280b 100644 --- a/grafast/grafast/src/steps/list.ts +++ b/grafast/grafast/src/steps/list.ts @@ -8,6 +8,13 @@ import type { ExecutableStep } from "../step.js"; import { UnbatchedExecutableStep } from "../step.js"; import { constant, ConstantStep } from "./constant.js"; +const DEFAULT_CACHE_SIZE = 100; + +interface ListStepCacheConfig { + identifier?: string; + cacheSize?: number; +} + export class ListStep< const TPlanTuple extends readonly ExecutableStep[], > extends UnbatchedExecutableStep> { @@ -18,9 +25,21 @@ export class ListStep< isSyncAndSafe = true; allowMultipleOptimizations = true; optimizeMetaKey = "ListStep"; + private cacheSize: number; + private valueCount: number; - constructor(list: TPlanTuple) { + constructor(list: TPlanTuple, cacheConfig?: ListStepCacheConfig) { super(); + this.valueCount = list.length; + this.cacheSize = + cacheConfig?.cacheSize ?? + (cacheConfig?.identifier ? DEFAULT_CACHE_SIZE : 0); + this.metaKey = + this.cacheSize <= 0 + ? undefined + : cacheConfig?.identifier + ? `list|${list.length}|${cacheConfig.identifier}` + : this.id; for (let i = 0, l = list.length; i < l; i++) { this.addDependency({ step: list[i], skipDeduplication: true }); } @@ -45,6 +64,35 @@ export class ListStep< return values as any; } + deduplicatedUnbatchedExecute( + { meta: inMeta }: UnbatchedExecutionExtra, + ...values: any[] //UnwrapPlanTuple, + ): UnwrapPlanTuple { + const meta = inMeta as { + nextIndex: number | undefined; + results: Array; + }; + if (meta.nextIndex !== undefined) { + outer: for (let i = 0, l = meta.results.length; i < l; i++) { + const cachedValues = meta.results[i]; + for (let j = 0, c = this.valueCount; j < c; j++) { + if (values[j] !== cachedValues[j]) { + continue outer; + } + } + return cachedValues as any; + } + } else { + meta.nextIndex = 0; + meta.results = []; + } + meta.results[meta.nextIndex] = values; + // Only cache this.cacheSize results, use a round-robin + const maxIndex = this.cacheSize - 1; + meta.nextIndex = meta.nextIndex === maxIndex ? 0 : meta.nextIndex + 1; + return values as any; + } + deduplicate(peers: ListStep[]): ListStep[] { return peers; } @@ -79,6 +127,13 @@ export class ListStep< return this; } + finalize() { + if (this.cacheSize > 0) { + this.unbatchedExecute = this.deduplicatedUnbatchedExecute; + } + super.finalize(); + } + /** * Get the original plan at the given index back again. */ @@ -103,6 +158,7 @@ export class ListStep< */ export function list( list: TPlanTuple, + cacheConfig?: ListStepCacheConfig, ): ListStep { - return new ListStep(list); + return new ListStep(list, cacheConfig); } diff --git a/grafast/grafast/src/steps/load.ts b/grafast/grafast/src/steps/load.ts index 346466f03..867755b85 100644 --- a/grafast/grafast/src/steps/load.ts +++ b/grafast/grafast/src/steps/load.ts @@ -97,7 +97,7 @@ let loadCounter = 0; */ export class LoadedRecordStep< TItem, - TParams extends Record, + TParams extends Record = Record, > extends ExecutableStep { static $$export = { moduleName: "grafast", @@ -208,9 +208,10 @@ export class LoadStep< >, ) { super(); - const $spec = multistep(spec); + const $spec = multistep(spec, "load"); this.addDependency($spec); - const $unarySpec = unarySpec == null ? null : multistep(unarySpec); + const $unarySpec = + unarySpec == null ? null : multistep(unarySpec, "loadUnary"); if ($unarySpec) { this.unaryDepId = this.addUnaryDependency($unarySpec); } diff --git a/grafast/grafast/src/steps/object.ts b/grafast/grafast/src/steps/object.ts index 40df16f6f..0ffcb6861 100644 --- a/grafast/grafast/src/steps/object.ts +++ b/grafast/grafast/src/steps/object.ts @@ -14,6 +14,8 @@ import { UnbatchedExecutableStep } from "../step.js"; import { constant, ConstantStep } from "./constant.js"; import type { SetterCapableStep } from "./setter.js"; +const DEFAULT_CACHE_SIZE = 100; + const EMPTY_OBJECT = Object.freeze(Object.create(null)); // const debugObjectPlan = debugFactory("grafast:ObjectStep"); @@ -37,6 +39,11 @@ export interface ObjectPlanMeta< results: Results; } +interface ObjectStepCacheConfig { + identifier?: string; + cacheSize?: number; +} + /** * A plan that represents an object using the keys given and the values being * the results of the associated plans. @@ -59,10 +66,19 @@ export class ObjectStep< // Optimize needs the same 'meta' for all ObjectSteps optimizeMetaKey = "ObjectStep"; + private cacheSize: number; - constructor(obj: TPlans) { + constructor(obj: TPlans, cacheConfig?: ObjectStepCacheConfig) { super(); - this.metaKey = this.id; + this.cacheSize = + cacheConfig?.cacheSize ?? + (cacheConfig?.identifier ? DEFAULT_CACHE_SIZE : 0); + this.metaKey = + this.cacheSize <= 0 + ? undefined + : cacheConfig?.identifier + ? `object|${JSON.stringify(Object.keys(obj))}|${cacheConfig.identifier}` + : this.id; this.keys = Object.keys(obj); for (let i = 0, l = this.keys.length; i < l; i++) { this.addDependency({ step: obj[this.keys[i]], skipDeduplication: true }); @@ -172,13 +188,15 @@ ${te.join( "", )}\ `; - return te.runInBatch[0]>( - te`\ -(function ({ meta }, ${te.join( - this.keys.map((_k, i) => te.identifier(`val${i}`)), - ", ", - )}) { - if (meta.nextIndex) { + const vals = te.join( + this.keys.map((_k, i) => te.identifier(`val${i}`)), + ", ", + ); + if (this.cacheSize > 0) { + return te.runInBatch[0]>( + te`\ +(function ({ meta }, ${vals}) { + if (meta.nextIndex != null) { for (let i = 0, l = meta.results.length; i < l; i++) { const [values, obj] = meta.results[i]; if (${te.join( @@ -192,21 +210,31 @@ ${te.join( } } else { meta.nextIndex = 0; - if (!meta.results) { - meta.results = []; - } + meta.results = []; } ${inner} meta.results[meta.nextIndex] = [[${te.join( this.keys.map((_key, i) => te.identifier(`val${i}`)), ",", )}], newObj]; - // Only cache 10 results, use a round-robin - meta.nextIndex = meta.nextIndex === 9 ? 0 : meta.nextIndex + 1; + // Only cache ${te.lit(this.cacheSize)} results, use a round-robin + meta.nextIndex = meta.nextIndex === ${te.lit( + this.cacheSize - 1, + )} ? 0 : meta.nextIndex + 1; return newObj; })`, - callback, - ); + callback, + ); + } else { + return te.runInBatch[0]>( + te`\ +(function (_, ${vals}) { +${inner} + return newObj; +})`, + callback, + ); + } } finalize() { @@ -295,6 +323,7 @@ ${inner} */ export function object( obj: TPlans, + cacheConfig?: ObjectStepCacheConfig, ): ObjectStep { - return new ObjectStep(obj); + return new ObjectStep(obj, cacheConfig); }