Skip to content

Commit 411a31e

Browse files
Add support for parallel execution of readonly functions (#2)
1 parent b6999e7 commit 411a31e

File tree

18 files changed

+345
-28
lines changed

18 files changed

+345
-28
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ This changelog documents the changes between release versions.
44
## main
55
Changes to be included in the next upcoming release
66

7+
- Add support for parallel execution of readonly functions ([#2](https://github.com/hasura/ndc-nodejs-lambda/pull/2))
8+
79
## v0.10.0
810
- Add missing query.variables capability
911

README.md

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ The `package.config` has been created with `start` and `watch` scripts. These us
1919
### Functions
2020
Any functions exported from `functions.ts` are made available as NDC functions/procedures to use in your Hasura metadata and expose as GraphQL fields in queries or mutation.
2121

22+
If you write a function that performs a read-only operation, you should mark it with the `@readonly` JSDoc tag, and it will be exposed as an NDC function, which will ultimately show up as a GraphQL query field in Hasura.
23+
24+
```typescript
25+
/** @readonly */
26+
export function add(x: number, y: number): number {
27+
return x + y;
28+
}
29+
```
30+
31+
Functions without the `@readonly` JSDoc tag are exposed as NDC procedures, which will ultimately show up as a GraphQL mutation field in Hasura.
32+
2233
Arguments to the function end up being field arguments in GraphQL and the return value is what the field will return when queried. Every function must return a value; `void`, `null` or `undefined` is not supported.
2334

2435
```typescript
@@ -136,18 +147,24 @@ These types are unsupported as function parameter types or return types for func
136147
* [`void`](https://www.typescriptlang.org/docs/handbook/2/functions.html#void), [`object`](https://www.typescriptlang.org/docs/handbook/2/functions.html#object), [`unknown`](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown), [`never`](https://www.typescriptlang.org/docs/handbook/2/functions.html#never), [`any`](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#any) types - to accept and return arbitrary JSON, use `sdk.JSONValue` instead
137148
* `null` and `undefined` - unless used in a union with a single other type
138149

139-
140-
### Impure/pure functions
141-
If you write a function that performs a read-only operation, you should mark it with the `@readonly` JSDoc tag, and it will be exposed as an NDC function, which will ultimately show up as a GraphQL query field in Hasura.
142-
143-
```typescript
144-
/** @readonly */
145-
export function add(x: number, y: number): number {
146-
return x + y;
150+
### Parallel execution
151+
If functions are involved remote relationships in your Hasura metadata, then they may be queried in a [batch-based fashion](https://hasura.github.io/ndc-spec/specification/queries/variables.html). In this situation, any async functions that are marked with the `@readonly` JSDoc tag may be executed in parallel. The default degree of parallelism per query request to the connector is 10, but you may customise this by using the `@paralleldegree` JSDoc tag on your function.
152+
153+
``` typescript
154+
/**
155+
* This function will only run up to 5 http requests in parallel per query
156+
*
157+
* @readonly
158+
* @paralleldegree 5
159+
*/
160+
export async function test(statusCode: number): Promise<string> {
161+
const result = await fetch("http://httpstat.us/${statusCode}")
162+
const responseBody = await result.json() as any;
163+
return responseBody.description;
147164
}
148165
```
149166

150-
Functions without the `@readonly` JSDoc tag are exposed as NDC procedures, which will ultimately show up as a GraphQL mutation field in Hasura.
167+
Non-readonly functions are not invoked in parallel within the same mutation request to the connector, so it is invalid to use the @paralleldegree JSDoc tag on those functions.
151168

152169
## Deploying with `hasura3 connector create`
153170

ndc-lambda-sdk/package-lock.json

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ndc-lambda-sdk/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@tsconfig/node18": "^18.2.2",
3636
"commander": "^11.1.0",
3737
"cross-spawn": "^7.0.3",
38+
"p-limit": "^3.1.0",
3839
"ts-api-utils": "^1.0.3",
3940
"ts-node": "^10.9.2",
4041
"ts-node-dev": "^2.0.0",

ndc-lambda-sdk/src/execution.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import * as sdk from "@hasura/ndc-sdk-typescript"
2+
import pLimit from "p-limit";
23
import * as schema from "./schema"
34
import { isArray, mapObjectValues, unreachable } from "./util"
45

56
export type RuntimeFunctions = {
67
[functionName: string]: Function
78
}
89

10+
// This number is chosen arbitrarily, just to place _some_ limit on the amount of
11+
// parallelism going on within a single query
12+
const DEFAULT_PARALLEL_DEGREE = 10;
13+
914
export async function executeQuery(queryRequest: sdk.QueryRequest, functionsSchema: schema.FunctionsSchema, runtimeFunctions: RuntimeFunctions): Promise<sdk.QueryResponse> {
1015
const functionName = queryRequest.collection;
1116

@@ -25,21 +30,21 @@ export async function executeQuery(queryRequest: sdk.QueryRequest, functionsSche
2530
return prepareArguments(resolvedArgs, functionDefinition, functionsSchema.objectTypes);
2631
});
2732

28-
const rowSets: sdk.RowSet[] = [];
29-
for (const invocationPreparedArgs of functionInvocationPreparedArgs) {
33+
const parallelLimit = pLimit(functionDefinition.parallelDegree ?? DEFAULT_PARALLEL_DEGREE);
34+
const functionInvocations: Promise<sdk.RowSet>[] = functionInvocationPreparedArgs.map(invocationPreparedArgs => parallelLimit(async () => {
3035
const result = await invokeFunction(runtimeFunction, invocationPreparedArgs, functionName);
3136
const prunedResult = reshapeResultToNdcResponseValue(result, functionDefinition.resultType, [], queryRequest.query.fields ?? {}, functionsSchema.objectTypes);
32-
rowSets.push({
37+
return {
3338
aggregates: {},
3439
rows: [
3540
{
3641
__value: prunedResult
3742
}
3843
]
39-
});
40-
}
44+
};
45+
}));
4146

42-
return rowSets;
47+
return await Promise.all(functionInvocations);
4348
}
4449

4550
export async function executeMutation(mutationRequest: sdk.MutationRequest, functionsSchema: schema.FunctionsSchema, runtimeFunctions: RuntimeFunctions): Promise<sdk.MutationResponse> {

ndc-lambda-sdk/src/inference.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ function deriveFunctionSchema(functionDeclaration: ts.FunctionDeclaration, expor
168168

169169
const functionDescription = ts.displayPartsToString(functionSymbol.getDocumentationComment(context.typeChecker)).trim();
170170
const markedReadonlyInJsDoc = functionSymbol.getJsDocTags().find(e => e.name === "readonly") !== undefined;
171+
const parallelDegreeResult = getParallelDegreeFromJsDoc(functionSymbol, markedReadonlyInJsDoc);
171172

172173
const functionCallSig = functionType.getCallSignatures()[0] ?? throwError(`Function '${exportedFunctionName}' didn't have a call signature`)
173174
const functionSchemaArguments: Result<schema.ArgumentDefinition[], string[]> = Result.traverseAndCollectErrors(functionCallSig.getParameters(), paramSymbol => {
@@ -187,15 +188,37 @@ function deriveFunctionSchema(functionDeclaration: ts.FunctionDeclaration, expor
187188
const returnType = functionCallSig.getReturnType();
188189
const returnTypeResult = deriveSchemaTypeForTsType(unwrapPromiseType(returnType, context.typeChecker) ?? returnType, [{segmentType: "FunctionReturn", functionName: exportedFunctionName}], context);
189190

190-
return Result.collectErrors(functionSchemaArguments, returnTypeResult)
191-
.map(([functionSchemaArgs, returnType]) => ({
191+
return Result.collectErrors3(functionSchemaArguments, returnTypeResult, parallelDegreeResult)
192+
.map(([functionSchemaArgs, returnType, parallelDegree]) => ({
192193
description: functionDescription ? functionDescription : null,
193194
ndcKind: markedReadonlyInJsDoc ? schema.FunctionNdcKind.Function : schema.FunctionNdcKind.Procedure,
194195
arguments: functionSchemaArgs,
195-
resultType: returnType
196+
resultType: returnType,
197+
parallelDegree,
196198
}));
197199
}
198200

201+
function getParallelDegreeFromJsDoc(functionSymbol: ts.Symbol, functionIsReadonly: boolean): Result<number | null, string[]> {
202+
const parallelDegreeTag = functionSymbol.getJsDocTags().find(e => e.name === "paralleldegree");
203+
if (parallelDegreeTag === undefined) {
204+
return new Ok(null);
205+
} else {
206+
if (!functionIsReadonly)
207+
return new Err(["The @paralleldegree JSDoc tag is only supported on functions also marked with the @readonly JSDoc tag"]);
208+
209+
const tagSymbolDisplayPart = parallelDegreeTag.text?.[0]
210+
if (tagSymbolDisplayPart === undefined)
211+
return new Err(["The @paralleldegree JSDoc tag must specify an integer degree value"]);
212+
213+
const tagText = tagSymbolDisplayPart.text.trim();
214+
const parallelDegreeInt = parseInt(tagText, 10);
215+
if (isNaN(parallelDegreeInt) || parallelDegreeInt <= 0)
216+
return new Err([`The @paralleldegree JSDoc tag must specify an integer degree value that is greater than 0. Current value: '${tagText}'`]);
217+
218+
return new Ok(parallelDegreeInt);
219+
}
220+
}
221+
199222
const MAX_TYPE_DERIVATION_RECURSION = 20; // Better to abort than get into an infinite loop, this could be increased if required.
200223

201224
type TypePathSegment =

ndc-lambda-sdk/src/schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export type FunctionDefinition = {
1515
ndcKind: FunctionNdcKind
1616
description: string | null,
1717
arguments: ArgumentDefinition[] // Function arguments are ordered
18-
resultType: TypeDefinition
18+
resultType: TypeDefinition,
19+
parallelDegree: number | null,
1920
}
2021

2122
export enum FunctionNdcKind {

ndc-lambda-sdk/src/util.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ export function getFlags(flagsEnum: Record<string, string | number>, value: numb
2929
: []
3030
});
3131
}
32+
33+
export function sleep(ms: number): Promise<void> {
34+
return new Promise((r) => setTimeout(r, ms))
35+
}

ndc-lambda-sdk/test/execution/execute-query.test.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { assert } from "chai";
33
import * as sdk from "@hasura/ndc-sdk-typescript"
44
import { executeQuery } from "../../src/execution";
55
import { FunctionNdcKind, FunctionsSchema } from "../../src/schema";
6+
import { sleep } from "../../src/util";
67

78
describe("execute query", function() {
89
it("executes the function", async function() {
@@ -18,6 +19,7 @@ describe("execute query", function() {
1819
"theFunction": {
1920
ndcKind: FunctionNdcKind.Function,
2021
description: null,
22+
parallelDegree: null,
2123
arguments: [
2224
{
2325
argumentName: "param",
@@ -80,6 +82,7 @@ describe("execute query", function() {
8082
"theFunction": {
8183
ndcKind: FunctionNdcKind.Function,
8284
description: null,
85+
parallelDegree: null,
8386
arguments: [
8487
{
8588
argumentName: "param",
@@ -154,5 +157,118 @@ describe("execute query", function() {
154157
}
155158
]);
156159
assert.equal(functionCallCount, 2);
157-
})
160+
});
161+
162+
it("when there are variables, executes the function in parallel, but respecting the configured parallel degree", async function() {
163+
let functionCallCompletions: string[] = [];
164+
const runtimeFunctions = {
165+
"theFunction": async (invocationName: string, sleepMs: number) => {
166+
await sleep(sleepMs);
167+
functionCallCompletions.push(invocationName)
168+
return invocationName;
169+
}
170+
};
171+
const functionSchema: FunctionsSchema = {
172+
functions: {
173+
"theFunction": {
174+
ndcKind: FunctionNdcKind.Function,
175+
description: null,
176+
parallelDegree: 3,
177+
arguments: [
178+
{
179+
argumentName: "invocationName",
180+
description: null,
181+
type: {
182+
type: "named",
183+
kind: "scalar",
184+
name: "String"
185+
}
186+
},
187+
{
188+
argumentName: "sleepMs",
189+
description: null,
190+
type: {
191+
type: "named",
192+
kind: "scalar",
193+
name: "Float"
194+
}
195+
},
196+
],
197+
resultType: {
198+
type: "named",
199+
kind: "scalar",
200+
name: "String"
201+
},
202+
}
203+
},
204+
objectTypes: {},
205+
scalarTypes: {
206+
"String": {}
207+
}
208+
};
209+
const queryRequest: sdk.QueryRequest = {
210+
collection: "theFunction",
211+
query: {
212+
fields: {},
213+
},
214+
arguments: {
215+
"invocationName": {
216+
type: "variable",
217+
name: "invocationName"
218+
},
219+
"sleepMs": {
220+
type: "variable",
221+
name: "sleepMs"
222+
}
223+
},
224+
collection_relationships: {},
225+
variables: [
226+
{
227+
"invocationName": "first",
228+
"sleepMs": 100,
229+
},
230+
{
231+
"invocationName": "second",
232+
"sleepMs": 250,
233+
},
234+
{
235+
"invocationName": "third",
236+
"sleepMs": 150,
237+
},
238+
{
239+
"invocationName": "fourth",
240+
"sleepMs": 100,
241+
},
242+
]
243+
};
244+
245+
const result = await executeQuery(queryRequest, functionSchema, runtimeFunctions);
246+
assert.deepStrictEqual(result, [
247+
{
248+
aggregates: {},
249+
rows: [
250+
{ __value: "first" }
251+
]
252+
},
253+
{
254+
aggregates: {},
255+
rows: [
256+
{ __value: "second" }
257+
]
258+
},
259+
{
260+
aggregates: {},
261+
rows: [
262+
{ __value: "third" }
263+
]
264+
},
265+
{
266+
aggregates: {},
267+
rows: [
268+
{ __value: "fourth" }
269+
]
270+
},
271+
]);
272+
assert.deepStrictEqual(functionCallCompletions, ["first", "third", "fourth", "second"]);
273+
});
158274
});

ndc-lambda-sdk/test/execution/prepare-arguments.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ describe("prepare arguments", function() {
99
const functionDefinition: FunctionDefinition = {
1010
ndcKind: FunctionNdcKind.Function,
1111
description: null,
12+
parallelDegree: null,
1213
arguments: [
1314
{
1415
argumentName: "c",
@@ -60,6 +61,7 @@ describe("prepare arguments", function() {
6061
const functionDefinition: FunctionDefinition = {
6162
ndcKind: FunctionNdcKind.Function,
6263
description: null,
64+
parallelDegree: null,
6365
arguments: [
6466
{
6567
argumentName: "nullOnlyArg",
@@ -314,6 +316,7 @@ describe("prepare arguments", function() {
314316
const functionDefinition: FunctionDefinition = {
315317
ndcKind: FunctionNdcKind.Function,
316318
description: null,
319+
parallelDegree: null,
317320
arguments: [
318321
{
319322
argumentName: "stringArg",
@@ -396,6 +399,7 @@ describe("prepare arguments", function() {
396399
const functionDefinition: FunctionDefinition = {
397400
ndcKind: FunctionNdcKind.Function,
398401
description: null,
402+
parallelDegree: null,
399403
arguments: [
400404
{
401405
argumentName: "dateTime",
@@ -449,6 +453,7 @@ describe("prepare arguments", function() {
449453
const functionDefinition: FunctionDefinition = {
450454
ndcKind: FunctionNdcKind.Function,
451455
description: null,
456+
parallelDegree: null,
452457
arguments: [
453458
{
454459
argumentName: "literalString",

0 commit comments

Comments
 (0)