From 738cad5d0e6bb0d375dd571caf6644e0283709e8 Mon Sep 17 00:00:00 2001 From: yumauri Date: Wed, 4 Dec 2024 01:41:22 +0300 Subject: [PATCH 1/3] Add PoC for async object validation early abort --- .../src/schemas/object/objectAsync.test.ts | 79 ++++++++++++++++++- library/src/schemas/object/objectAsync.ts | 68 +++++++++++++--- 2 files changed, 136 insertions(+), 11 deletions(-) diff --git a/library/src/schemas/object/objectAsync.test.ts b/library/src/schemas/object/objectAsync.test.ts index 3d8f4917b..43f5356a9 100644 --- a/library/src/schemas/object/objectAsync.test.ts +++ b/library/src/schemas/object/objectAsync.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, test } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { checkAsync } from '../../actions/index.ts'; +import { pipeAsync } from '../../index.ts'; import type { FailureDataset, InferIssue } from '../../types/index.ts'; import { expectNoSchemaIssueAsync, @@ -289,4 +291,79 @@ describe('objectAsync', () => { } satisfies FailureDataset>); }); }); + + describe('should abort async validation early', () => { + function timeout(ms: number, result: T): Promise { + return new Promise((resolve) => setTimeout(resolve, ms, result)); + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test.only('with checkAsync', async () => { + const schema = objectAsync({ + key1: string(), + key2: pipeAsync( + string(), + checkAsync(() => timeout(1000, true)) + ), + }); + + const resultPromise = schema['~run']( + { + value: { + key1: 42, + key2: 'string', + }, + }, + { abortEarly: true } + ); + + const result = Promise.race([ + resultPromise, + timeout(1, 'validation was not aborted early'), + ]); + + // advance `timeout(0)` promise to resolve + await vi.advanceTimersToNextTimerAsync(); + + // assert that `result` is resolved to validation result + expect(await result).toStrictEqual({ + issues: [ + { + abortEarly: true, + abortPipeEarly: undefined, + expected: 'string', + input: 42, + issues: undefined, + kind: 'schema', + lang: undefined, + message: 'Invalid type: Expected string but received 42', + path: [ + { + input: { + key1: 42, + key2: 'string', + }, + key: 'key1', + origin: 'value', + type: 'object', + value: 42, + }, + ], + received: '42', + requirement: undefined, + type: 'string', + }, + ], + typed: false, + value: {}, + }); + }); + }); }); diff --git a/library/src/schemas/object/objectAsync.ts b/library/src/schemas/object/objectAsync.ts index edfb29b24..971e3d6cc 100644 --- a/library/src/schemas/object/objectAsync.ts +++ b/library/src/schemas/object/objectAsync.ts @@ -1,4 +1,5 @@ import type { + BaseIssue, BaseSchemaAsync, ErrorMessage, InferObjectInput, @@ -111,16 +112,63 @@ export function objectAsync( // Hint: We do not distinguish between missing and `undefined` entries. // The reason for this decision is that it reduces the bundle size, and // we also expect that most users will expect this behavior. - const valueDatasets = await Promise.all( - Object.entries(this.entries).map(async ([key, schema]) => { - const value = input[key as keyof typeof input]; - return [ - key, - value, - await schema['~run']({ value }, config), - ] as const; - }) - ); + + // Type of value dataset + type ValueDataset = [ + /* key */ string, + /* value */ never, + /* result */ OutputDataset>, + /* index */ number, + ]; + + // Array of value datasets promises + const valueDatasetsPromises: Array< + ValueDataset | Promise + > = []; + + // Add each value dataset promise to array, but do not await them yet + let index = 0; + for (const [key, schema] of Object.entries(this.entries)) { + const idx = index; + const value = input[key as keyof typeof input]; + const result = schema['~run']({ value }, config); + + index = valueDatasetsPromises.push( + 'then' in result && typeof result.then === 'function' + ? result.then((resolved) => [key, value, resolved, idx] as const) + : ([key, value, result, index] as ValueDataset) + ); + } + + // Array of value datasets + let valueDatasets: ValueDataset[]; + + // If abort early is not enabled, await all values datasets + if (!config.abortEarly) { + valueDatasets = await Promise.all(valueDatasetsPromises); + } + + // If abort early is enabled, await values datasets one by one + else { + valueDatasets = []; + while (valueDatasetsPromises.length > 0) { + const earliest = await Promise.race( + valueDatasetsPromises + ); + + // Add earliest resolved value dataset to array + valueDatasets.push(earliest); + + // If there are issues, abort early + if (earliest[2].issues) { + break; + } + + // Remove earliest resolved value dataset from array, + // and go on to the second earliest, and so on + valueDatasetsPromises.splice(earliest[3], 1); + } + } // Process each value dataset for (const [key, value, valueDataset] of valueDatasets) { From e9c0fe4d116415baa69f71b704f1937804f1daab Mon Sep 17 00:00:00 2001 From: yumauri Date: Wed, 4 Dec 2024 01:42:51 +0300 Subject: [PATCH 2/3] Fix format --- library/src/schemas/object/objectAsync.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/library/src/schemas/object/objectAsync.ts b/library/src/schemas/object/objectAsync.ts index 971e3d6cc..b4db971f3 100644 --- a/library/src/schemas/object/objectAsync.ts +++ b/library/src/schemas/object/objectAsync.ts @@ -152,9 +152,7 @@ export function objectAsync( else { valueDatasets = []; while (valueDatasetsPromises.length > 0) { - const earliest = await Promise.race( - valueDatasetsPromises - ); + const earliest = await Promise.race(valueDatasetsPromises); // Add earliest resolved value dataset to array valueDatasets.push(earliest); From a38f258805edc91b17e372476729a8b1d9030688 Mon Sep 17 00:00:00 2001 From: yumauri Date: Fri, 6 Dec 2024 00:01:42 +0300 Subject: [PATCH 3/3] Fix PoC async object validation with early abort --- .../src/schemas/object/objectAsync.test.ts | 137 ++++++++++++++++-- library/src/schemas/object/objectAsync.ts | 78 +++++----- 2 files changed, 172 insertions(+), 43 deletions(-) diff --git a/library/src/schemas/object/objectAsync.test.ts b/library/src/schemas/object/objectAsync.test.ts index 43f5356a9..123181872 100644 --- a/library/src/schemas/object/objectAsync.test.ts +++ b/library/src/schemas/object/objectAsync.test.ts @@ -294,7 +294,7 @@ describe('objectAsync', () => { describe('should abort async validation early', () => { function timeout(ms: number, result: T): Promise { - return new Promise((resolve) => setTimeout(resolve, ms, result)); + return new Promise((resolve) => setTimeout(() => resolve(result), ms)); } beforeEach(() => { @@ -305,7 +305,7 @@ describe('objectAsync', () => { vi.useRealTimers(); }); - test.only('with checkAsync', async () => { + test('with sync validation failed', async () => { const schema = objectAsync({ key1: string(), key2: pipeAsync( @@ -315,12 +315,7 @@ describe('objectAsync', () => { }); const resultPromise = schema['~run']( - { - value: { - key1: 42, - key2: 'string', - }, - }, + { value: { key1: 42, key2: 'string' } }, { abortEarly: true } ); @@ -329,7 +324,7 @@ describe('objectAsync', () => { timeout(1, 'validation was not aborted early'), ]); - // advance `timeout(0)` promise to resolve + // advance `timeout(1)` promise to resolve await vi.advanceTimersToNextTimerAsync(); // assert that `result` is resolved to validation result @@ -365,5 +360,129 @@ describe('objectAsync', () => { value: {}, }); }); + + test('with fast async validation failed', async () => { + const schema = objectAsync({ + key1: pipeAsync( + string(), + checkAsync(() => timeout(1000, false)) + ), + key2: pipeAsync( + string(), + checkAsync(() => timeout(5000, true)) + ), + }); + + const resultPromise = schema['~run']( + { value: { key1: 'string', key2: 'string' } }, + { abortEarly: true } + ); + + const result = Promise.race([ + resultPromise, + timeout(2000, 'validation was not aborted early'), + ]); + + // advance `timeout(1000)` validation promise and `timeout(2000)` limit promiseto resolve + await vi.advanceTimersByTimeAsync(3000); + + // assert that `result` is resolved to validation result + expect(await result).toStrictEqual({ + issues: [ + { + abortEarly: true, + abortPipeEarly: undefined, + expected: null, + input: 'string', + issues: undefined, + kind: 'validation', + lang: undefined, + message: 'Invalid input: Received "string"', + path: [ + { + input: { + key1: 'string', + key2: 'string', + }, + key: 'key1', + origin: 'value', + type: 'object', + value: 'string', + }, + ], + received: '"string"', + requirement: expect.any(Function), + type: 'check', + }, + ], + typed: false, + value: {}, + }); + }); + + test('should not execute async validation at all in case of sync validation failure', async () => { + const watch = vi.fn(); + + const schema = objectAsync({ + key1: string(), + key2: pipeAsync( + string(), + checkAsync(() => { + watch(); + return timeout(1000, true); + }) + ), + }); + + const resultPromise = schema['~run']( + { value: { key1: 42, key2: 'string' } }, + { abortEarly: true } + ); + + const result = Promise.race([ + resultPromise, + timeout(1, 'validation was not aborted early'), + ]); + + // advance `timeout(1)` promise to resolve + await vi.advanceTimersToNextTimerAsync(); + + // assert that `result` is resolved to validation result + expect(await result).not.toEqual('validation was not aborted early'); + + // assert that `watch` was not called + // meaning that async validation was not executed + expect(watch).toHaveBeenCalledTimes(0); + }); + + test('async validation should be executed simultaneously', async () => { + const schema = objectAsync({ + key1: pipeAsync( + string(), + checkAsync(() => timeout(1000, true)) + ), + key2: pipeAsync( + string(), + checkAsync(() => timeout(1000, true)) + ), + }); + + const resultPromise = schema['~run']( + { value: { key1: 'string', key2: 'string' } }, + { abortEarly: true } + ); + + const result = Promise.race([ + resultPromise, + timeout(1500, 'validation too long'), // ensure that async validation is not sequential + ]); + + await vi.advanceTimersByTimeAsync(2000); + + // assert that `result` is resolved to validation result + // meaning that async validation was executed simultaneously, + // both promises were resolved in 1000ms + expect(await result).not.toBe('validation too long'); + }); }); }); diff --git a/library/src/schemas/object/objectAsync.ts b/library/src/schemas/object/objectAsync.ts index b4db971f3..abbf30e2b 100644 --- a/library/src/schemas/object/objectAsync.ts +++ b/library/src/schemas/object/objectAsync.ts @@ -116,58 +116,68 @@ export function objectAsync( // Type of value dataset type ValueDataset = [ /* key */ string, - /* value */ never, + /* value */ unknown, /* result */ OutputDataset>, - /* index */ number, ]; + const isPromise = (value: unknown): value is PromiseLike => + typeof value === 'object' && value !== null && 'then' in value; + + // Flag to abort early synchronously + let shouldAbortEarly = false; + + // Array of value datasets + const valueDatasets: ValueDataset[] = []; + // Array of value datasets promises - const valueDatasetsPromises: Array< - ValueDataset | Promise - > = []; + const valueDatasetsAsync: Array> = []; // Add each value dataset promise to array, but do not await them yet - let index = 0; for (const [key, schema] of Object.entries(this.entries)) { - const idx = index; const value = input[key as keyof typeof input]; const result = schema['~run']({ value }, config); - index = valueDatasetsPromises.push( - 'then' in result && typeof result.then === 'function' - ? result.then((resolved) => [key, value, resolved, idx] as const) - : ([key, value, result, index] as ValueDataset) - ); - } - - // Array of value datasets - let valueDatasets: ValueDataset[]; - - // If abort early is not enabled, await all values datasets - if (!config.abortEarly) { - valueDatasets = await Promise.all(valueDatasetsPromises); - } - - // If abort early is enabled, await values datasets one by one - else { - valueDatasets = []; - while (valueDatasetsPromises.length > 0) { - const earliest = await Promise.race(valueDatasetsPromises); + // If validation result is a promise - add promise to array, to await later + if (isPromise(result)) { + valueDatasetsAsync.push( + result.then((resolved) => [key, value, resolved]) + ); + } - // Add earliest resolved value dataset to array - valueDatasets.push(earliest); + // If got validation result synchronously - add it to result array right away + else { + // Add sync dataset to result array + valueDatasets.push([key, value, result]); // If there are issues, abort early - if (earliest[2].issues) { + if (config.abortEarly && result.issues) { + shouldAbortEarly = true; break; } - - // Remove earliest resolved value dataset from array, - // and go on to the second earliest, and so on - valueDatasetsPromises.splice(earliest[3], 1); } } + // Await for async datasets + if (valueDatasetsAsync.length > 0 && !shouldAbortEarly) { + await new Promise((resolve) => { + let awaited = 0; + for (const promise of valueDatasetsAsync) { + promise.then((dataset) => { + if (awaited > -1) { + valueDatasets.push(dataset); + if ( + ++awaited === valueDatasetsAsync.length || + (dataset[2].issues && config.abortEarly) + ) { + awaited = -1; + resolve(); + } + } + }); + } + }); + } + // Process each value dataset for (const [key, value, valueDataset] of valueDatasets) { // If there are issues, capture them