From ed1c5672991034258ecfdb4aa6ffaf8d6da06626 Mon Sep 17 00:00:00 2001 From: Alexey Zorkaltsev Date: Mon, 1 Apr 2024 18:12:50 +0300 Subject: [PATCH] chore: add src/context --- .github/workflows/ci.yml | 2 +- jest.config.coverage.js | 2 +- ...onfig.development.js => jest.config.dev.js | 0 ...onfig.production.js => jest.config.prod.js | 0 package.json | 8 +- src/__tests__/unit/context-ensure.test.ts | 78 +++++ src/__tests__/unit/context.test.ts | 318 ++++++++++++++++++ src/context/Context.ts | 210 ++++++++++++ src/context/ensureContext.ts | 35 ++ src/context/symbols.ts | 5 + 10 files changed, 651 insertions(+), 7 deletions(-) rename jest.config.development.js => jest.config.dev.js (100%) rename jest.config.production.js => jest.config.prod.js (100%) create mode 100644 src/__tests__/unit/context-ensure.test.ts create mode 100644 src/__tests__/unit/context.test.ts create mode 100644 src/context/Context.ts create mode 100644 src/context/ensureContext.ts create mode 100644 src/context/symbols.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fddb0ca5..cd5c8dc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,6 @@ jobs: - name: Build examples run: npm link && cd examples && npm i && npm link ydb-sdk && npm run build - name: Run tests - run: npm run test:all + run: npm run test:prod env: YDB_SSL_ROOT_CERTIFICATES_FILE: /tmp/ydb_certs/ca.pem diff --git a/jest.config.coverage.js b/jest.config.coverage.js index 04b6e49c..09daf715 100644 --- a/jest.config.coverage.js +++ b/jest.config.coverage.js @@ -1,4 +1,4 @@ -const config = require('./jest.config.development'); +const config = require('./jest.config.dev'); /* * For a detailed explanation regarding each configuration property and type check, visit: diff --git a/jest.config.development.js b/jest.config.dev.js similarity index 100% rename from jest.config.development.js rename to jest.config.dev.js diff --git a/jest.config.production.js b/jest.config.prod.js similarity index 100% rename from jest.config.production.js rename to jest.config.prod.js diff --git a/package.json b/package.json index 2f5e5939..5a9b17e6 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,10 @@ "build/**" ], "scripts": { - "test:unit": "exit 0", - "test:integration:development": "cross-env TEST_ENVIRONMENT=dev jest --config jest.config.development.js", - "test:integration:production": "jest --config jest.config.production.js", - "test:all": "run-p test:unit test:integration:production", + "test:dev": "cross-env TEST_ENVIRONMENT=dev jest --config jest.config.dev.js", + "test:prod": "jest --config jest.config.prod.js", "test:coverage": "cross-env TEST_ENVIRONMENT=dev jest --config jest.config.coverage.js", - "test": "npm run test:unit", + "test": "npm run test:dev", "build": "tsc -p tsconfig-esm.json && tsc -p tsconfig-cjs.json", "clean": "rimraf build", "prepublish": "npm run clean && npm run build && node ./fixup.js" diff --git a/src/__tests__/unit/context-ensure.test.ts b/src/__tests__/unit/context-ensure.test.ts new file mode 100644 index 00000000..47125088 --- /dev/null +++ b/src/__tests__/unit/context-ensure.test.ts @@ -0,0 +1,78 @@ +import {Context} from "../../context/Context"; +import {EnsureContext} from "../../context/ensureContext"; + +describe('ensureContext', () => { + it('positional args', () => { + class Test { + // @ts-ignore + noArgs(): void; + noArgs(ctx: Context): void; + @EnsureContext(true) + noArgs(ctx: Context): void { + expect(ctx instanceof Context).toBeTruthy(); + } + + // @ts-ignore + posArgs(n: number, s: string): void; + posArgs(ctx: Context, n: number, s: string): void; + @EnsureContext(true) + posArgs(ctx: Context, n: number, s: string) { + expect(ctx instanceof Context).toBeTruthy(); + expect(n).toBe(12); + expect(s).toBe('test'); + } + + // @ts-ignore + static staticNoArgs(): void; + static staticNoArgs(ctx: Context): void; + + @EnsureContext(true) + static staticNoArgs(ctx: Context) { + expect(ctx instanceof Context).toBeTruthy(); + } + } + + const test = new Test(); + + test.noArgs(); + test.noArgs(Context.createNew().ctx); + + test.posArgs(12, 'test'); + test.posArgs(Context.createNew().ctx, 12, 'test'); + + Test.staticNoArgs(); + }); + + it('named args', () => { + class Test { + // noArgs(): void; + // noArgs(opts: { + // ctx?: Context, + // }): void; + @EnsureContext() + noArgs(opts?: { + ctx?: Context, + }): void { + const ctx = opts!.ctx!; + expect(ctx instanceof Context).toBeTruthy(); + } + + @EnsureContext(false) // should throw error cause fire arg is not obj + mismatchTypeOfArgs(n: number, s: string) { + expect(n).toBe(12); + expect(s).toBe('test'); + } + } + + const test = new Test(); + + test.noArgs(); + test.noArgs({}); + test.noArgs({ + ctx: Context.createNew().ctx, + }); + + expect(() => test.mismatchTypeOfArgs(12, 'test')).rejects + .toThrow('An object with options or undefined is expected as the first argument'); + }); +}); diff --git a/src/__tests__/unit/context.test.ts b/src/__tests__/unit/context.test.ts new file mode 100644 index 00000000..546035a1 --- /dev/null +++ b/src/__tests__/unit/context.test.ts @@ -0,0 +1,318 @@ +// @ts-ignore +import {Context, setContextIdGenerator} from '../../context/Context'; +// @ts-ignore +import {ensureContext} from '../../context/ensureContext'; +// @ts-ignore +import {cancelListenersSymbol, errSymbol} from '../../context/symbols'; + +describe('Context', () => { + beforeEach(() => { + setContextIdGenerator(); // back to default + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('id generation', () => { + expect(Context.createNew().ctx.id).toBe('0001'); + expect(Context.createNew().ctx.toString()).toBe('0002'); + setContextIdGenerator(() => ''); + expect(Context.createNew().ctx.id).toBe(''); + expect(Context.createNew().ctx.id).toBe(''); + }); + + it('simple', () => { + const {ctx: ctx1, cancel: cancel1, dispose: dispose1, done: done1} = + Context.createNew(); + expect(ctx1).toBeDefined(); + expect(ctx1.onCancel).toBeUndefined(); + expect(cancel1).toBeUndefined(); + expect(dispose1).toBeUndefined(); + expect(done1).toBeUndefined(); + + const {ctx: ctx2, cancel: cancel2, dispose: dispose2, done: done2} = + ctx1.createChild(); + expect(ctx2).toBeDefined(); + expect(ctx2.onCancel).toBeUndefined(); + expect(cancel2).toBeUndefined(); + expect(dispose2).toBeUndefined(); + expect(done2).toBeUndefined(); + + expect(() => ctx1.createChild({id: '123'})).toThrow('This method cannot change the context id'); + }); + + for (const mode of [ + 'cancel1', + 'cancel2', + 'cancel3', + 'unsub', + 'dispose', + ]) + it(`cancel; mode: ${mode}`, () => { + const {ctx: ctx1, cancel: cancel1, dispose: dispose1, done: done1} = + Context.createNew({cancel: true}); + expect(ctx1).toBeDefined(); + expect(ctx1.err).toBeUndefined(); + expect(ctx1.onCancel).toBeDefined(); // becuase was implicitly requested + expect(cancel1).toBeDefined(); + expect(dispose1).toBeUndefined(); + expect(done1).toBeUndefined(); + + const {ctx: ctx2, cancel: cancel2, dispose: dispose2, done: done2} = + ctx1.createChild({cancel: true}); + expect(ctx2).toBeDefined(); + expect(ctx2.err).toBeUndefined(); + expect(ctx2.onCancel).toBeDefined(); // becuase was implicitly requested and parent has + expect(cancel2).toBeDefined(); + expect(dispose2).toBeDefined(); // dispose cancel chain + expect(done2).toBeUndefined(); + + const {ctx: ctx3, cancel: cancel3, dispose: dispose3, done: done3} = + ctx1.createChild(); + expect(ctx3).toBeDefined(); + expect(ctx3.err).toBeUndefined(); + expect(ctx3.onCancel).toBeDefined(); // because parent ctx has cancel + expect(cancel3).toBeUndefined(); // cancel was not requested through options + expect(dispose3).toBeDefined(); // dispose cancel chain + expect(done3).toBeUndefined(); + + const testCancel = new Error('Test cancel'); + let cnt1 = 0, cnt2 = 0, cnt3 = 0; + const unsub1 = ctx1.onCancel!((cause) => { + cnt1++; + expect(cause!.message).toBe('Test cancel'); + }); + const unsub2 = ctx2.onCancel!((cause) => { + cnt2++; + expect(cause!.message).toBe('Test cancel'); + }); + const unsub3 = ctx3.onCancel!((cause) => { + cnt3++; + expect(cause!.message).toBe('Test cancel'); + }); + + expect(ctx1[cancelListenersSymbol]).toHaveLength(3); // two sub-ctx and one onCancel + expect(ctx2[cancelListenersSymbol]).toHaveLength(1); // one onCancel + expect(ctx3[cancelListenersSymbol]).toHaveLength(1); // one onCancel + + switch (mode) { + case 'cancel1': { + if (cancel1) cancel1(testCancel); + expect(cnt1).toBe(1); + expect(cnt2).toBe(1); + expect(cnt3).toBe(1); + expect(ctx1[cancelListenersSymbol]).toBeUndefined(); + expect(ctx1.err).toBe(testCancel); + expect(ctx2[cancelListenersSymbol]).toBeUndefined(); + expect(ctx2.err).toBe(testCancel); + expect(ctx3[cancelListenersSymbol]).toBeUndefined(); + expect(ctx3.err).toBe(testCancel); + } + break; + case 'cancel2': { + if (cancel2) cancel2(testCancel); + expect(cnt1).toBe(0); + expect(cnt2).toBe(1); + expect(cnt3).toBe(0); + + expect(ctx1[cancelListenersSymbol]).toHaveLength(3); + expect(ctx1.err).toBeUndefined(); + expect(ctx2.hasOwnProperty(cancelListenersSymbol)).toBeFalsy(); + expect(ctx2.err).toBe(testCancel); + expect(ctx3[cancelListenersSymbol]).toHaveLength(1); + expect(ctx3.err).toBeUndefined(); + } + break; + case 'cancel3': { + if (cancel3) cancel3(testCancel); + expect(cnt1).toBe(0); + expect(cnt2).toBe(0); + expect(cnt3).toBe(0); // no cancel3; + expect(ctx1[cancelListenersSymbol]).toHaveLength(3); // two sub-ctx and one onCancel + expect(ctx2[cancelListenersSymbol]).toHaveLength(1); // one onCancel + expect(ctx3[cancelListenersSymbol]).toHaveLength(1); // one onCancel + } + break; + case 'unsub': { + unsub1(); + unsub2(); + unsub3(); + unsub1(); // second time call won't affect + unsub2(); // second time call won't affect + unsub3(); // second time call won't affect + expect(ctx1[cancelListenersSymbol]).toHaveLength(2); // two listening child ctx + expect(ctx2[cancelListenersSymbol]).toHaveLength(0); + expect(ctx3[cancelListenersSymbol]).toHaveLength(0); + } + break; + case 'dispose': { + expect(ctx1[cancelListenersSymbol]).toHaveLength(3); // one onCancel and two listening child ctx + if (dispose2) dispose2(); // dispose ctx2 + expect(ctx1[cancelListenersSymbol]).toHaveLength(2); // one onCancel and one listening child ctx + if (dispose2) dispose2(); // // second time call won't affect + expect(ctx1[cancelListenersSymbol]).toHaveLength(2); // one onCancel and one listening child ctx + if (dispose3) dispose3(); // dispose ctx3 + expect(ctx1[cancelListenersSymbol]).toHaveLength(1); // one onCancel + unsub1(); + expect(ctx1[cancelListenersSymbol]).toHaveLength(0); // nothing + unsub1(); // second time call won't affect + } + break; + case 'timeout': { + const {ctx: _ctx4, dispose: dispose4} = ctx1.createChild({timeout: 2_000}); + jest.advanceTimersByTime(10_000); + dispose4!(); + } + break; + case 'timeout dispose': { + const {ctx: _ctx4, dispose: dispose4} = ctx1.createChild({timeout: 2_000}); + dispose4!(); + } + break; + } + }); + + for (const mode of [ + 'dispose after timeout', + 'dispose before timeout', + ]) + it(`dispose with timeout; mode: ${mode}`, () => { + jest.useFakeTimers(); + const {ctx: ctx1} = Context.createNew({cancel: true}); + // dispose2 whould combine cancel timer and unsubscribe from ctx1.onCancel + const {ctx: ctx2, dispose: dispose2} = ctx1.createChild({timeout: 2_000}); + let cnt = 0; + ctx2.onCancel!((cause) => { + cnt++; + expect(Context.isTimeout(cause)).toBeTruthy(); + }) + switch (mode) { + case 'dispose after timeout': { + jest.advanceTimersByTime(10_000); + dispose2!(); + dispose2!(); // second time call won't affect + expect(cnt).toBe(1); + } + break; + case 'dispose before timeout': { + dispose2!(); + jest.advanceTimersByTime(10_000); + dispose2!(); // second time call won't affect + expect(cnt).toBe(0); + } + break; + } + }); + + for (const mode of [ + 'general', + 'dispose', + ]) + it(`timeout; mode: ${mode}`, () => { + jest.useFakeTimers(); + const {ctx, cancel, dispose, done} = Context.createNew({ + timeout: 2_000, + }); + expect(cancel).toBeUndefined(); + expect(dispose).toBeDefined(); + expect(done).toBeUndefined(); + expect(ctx.onCancel).toBeDefined(); + switch (mode) { + case 'general': { + let cnt = 0; + ctx.onCancel!((cause) => { + cnt++; + expect(Context.isTimeout(cause)).toBeTruthy(); + expect(Context.isDone(cause)).toBeFalsy(); + }); + expect(cnt).toBe(0); + jest.advanceTimersByTime(1_000); + expect(cnt).toBe(0); + jest.advanceTimersByTime(1_000); + expect(cnt).toBe(1); + expect(Context.isTimeout(ctx.err!)).toBeTruthy(); + jest.advanceTimersByTime(1_000); + expect(cnt).toBe(1); + } + break; + case 'dispose': { + dispose!(); + dispose!(); // second time call won't affect + } + break; + } + }); + + it('done', () => { + const {ctx, done} = Context.createNew({ + done: true, + }); + expect(ctx.onCancel).toBeDefined(); + let cnt = 0; + ctx.onCancel!((cause) => { + cnt++; + expect(Context.isDone(cause)).toBeTruthy(); + expect(Context.isTimeout(cause)).toBeFalsy(); + }); + expect(cnt).toBe(0); + done!(); + expect(cnt).toBe(1); + expect(Context.isDone(ctx.err!)).toBeTruthy(); + }); + + it('values', () => { + const Symbol1 = Symbol('a'); + const Symbol2 = Symbol('b'); + + const {ctx: ctx1} = Context.createNew(); + ctx1[Symbol1] = 'aaa'; + + const {ctx: ctx2} = ctx1.createChild(); + ctx2[Symbol2] = 'bbb'; + + const {ctx: ctx3} = ctx2.createChild(); + expect(ctx2[Symbol1]).toBe('aaa'); + expect(ctx3[Symbol2]).toBe('bbb'); + }); + + it('break cancel-chain', () => { + const Symbol1 = Symbol('a'); + + const {ctx: ctx1, cancel: cancel1} = Context.createNew({cancel: true}); + ctx1[Symbol1] = 'aaa'; + + const {ctx: ctx2, cancel: cancel2, dispose, done} = ctx1.createChild({cancel: false}); + + expect(ctx1.onCancel).toBeDefined(); + expect(cancel1).toBeDefined(); + + expect(ctx2.onCancel).toBeUndefined(); + expect(cancel2).toBeUndefined(); + expect(dispose).toBeUndefined(); + expect(done).toBeUndefined(); + + expect(ctx2[Symbol1]).toBe('aaa'); + }); + + it('make 100% coverage', () => { + { + const {ctx} = Context.createNew({id: 'test'}); + expect(ctx.id).toBe('test'); // context.id might be set for new ctx + } + { + const {ctx, cancel} = Context.createNew({cancel: true}); + cancel!(); // cancel without case + let cnt = 0; + ctx.onCancel!((cause) => { // subscribe after cancel + cnt++; + expect(cause.message).toBe('Unknown'); // default cause + }); + } + { + const {cancel} = Context.createNew({cancel: true}); + cancel!(); + cancel!(); // already cancelled + } + }); +}) diff --git a/src/context/Context.ts b/src/context/Context.ts new file mode 100644 index 00000000..bb9a3c6a --- /dev/null +++ b/src/context/Context.ts @@ -0,0 +1,210 @@ +import {idSymbol, cancelListenersSymbol, errSymbol, doneSymbol, timeoutSymbol} from './symbols'; +import Timeout = NodeJS.Timeout; + + +let count = 0; +const defaultIdGenerator = () => { + return (++count).toString().padStart(4, '0'); +}; +let idGenerator = defaultIdGenerator; + +/** + * Sets the Context.id generation rule for new contexts. It is desirable to call this funtion at the very beginning of + * the application before the contexts are used. + */ +export function setContextIdGenerator(_idGenerator?: () => string) { + idGenerator = _idGenerator ?? defaultIdGenerator; +} + +interface IContextOpts { + /** + * Id for new Context from layer above. + */ + id?: string, + /** + * true - make cancellable context. false - cancel cancellable context. + * undefined - if parent context is cancelable, then child context is also cancellable and vice versa. + */ + cancel?: boolean, + /** + * cancel context after done. + */ + done?: boolean, + /** + * cancel context after timeout. + */ + timeout?: number, +} + +interface IContextCreateResult { + ctx: Context, + cancel?: (cause?: Error) => void, + done?: () => void, + dispose?: () => void, +} + +type OnCancelListener = (cause: Error) => void; + +/** + * TypeScript Context implementation inspired by golang context (https://pkg.go.dev/context). + * + * Supports cancel, timeout, value, done, cancel cancel-chain behaviours. + */ +export class Context { + [idSymbol]: string; + + [cancelListenersSymbol]?: OnCancelListener[]; + + [errSymbol]?: Error; + + /** + * Similar to value in go context passes arbitrary values through a hierarchy of contexts. + */ + [key: symbol]: any; + + private constructor(id: string) { + this[idSymbol] = id; + } + + /** + * Unique id of Context Useful for tracing. + */ + public get id() { + return this[idSymbol]; + } + + /** + * That is the cause that was passed to cancel. + * + * If defined, the context is cancelled. + */ + public get err() { + return this[errSymbol]; + } + + /** + * If defined, then the context supports cancel. And it is possible to subscribe to it. + * + * @return Function that removes just signed listener from listeners. + */ + public onCancel?: (listener: OnCancelListener) => () => void; + + /** + * Creates a new context. + */ + public static createNew(opts: IContextOpts = {}): IContextCreateResult { + const ctx = new Context(typeof opts.id === 'string' ? opts.id : idGenerator()); + const res: any = initContext.call(ctx, opts); + res.ctx = ctx; + return res; + } + + /** + * Creates a child context from the this one. + */ + public createChild(opts: IContextOpts = {}): IContextCreateResult { + if (opts.id) throw new Error('This method cannot change the context id'); + const ctx = Object.create(this) as Context; + const originOpts = opts; + if (this.onCancel) + if (opts.cancel === false) ctx.onCancel = undefined; // block parent onCancel + else opts = {...opts, cancel: true}; + const res: any = initContext.call(ctx, opts); + if (this.onCancel && res.cancel) { + const unsub = this.onCancel(res.cancel); + if (res.dispose) { + const parentDispose = res.dispose; + res.dispose = () => { + parentDispose(); + unsub(); + }; + } else res.dispose = unsub; + } + if (originOpts.cancel !== true) delete res.cancel; + res.ctx = ctx; + return res; + } + + /** + * True if the reason for canceling is timeout. + */ + public static isTimeout(cause: Error) { + return (cause as any).cause === timeoutSymbol; + } + + /** + * True if the reason for canceling is call of ctx.Done() . + */ + public static isDone(cause: Error) { + return (cause as any).cause === doneSymbol; + } + + public toString() { + return this[idSymbol]; + } +} + +function makeContextCancellable(context: Context) { + context.onCancel = (listener) => { + if (context[errSymbol]) setImmediate(listener.bind(undefined, context[errSymbol])); + else if (context.hasOwnProperty(cancelListenersSymbol)) context[cancelListenersSymbol]!.push(listener); + else context[cancelListenersSymbol] = [listener]; + + function unsubscribe() { // remove listener from list + if (context[cancelListenersSymbol]) { + const index = context[cancelListenersSymbol].indexOf(listener); + if (index > -1) context[cancelListenersSymbol].splice(index, 1); + } + } + + return unsubscribe; + } + + function cancel(cause?: Error) { + if (context.hasOwnProperty(errSymbol)) return; // already cancelled + if (!cause) cause = new Error('Unknown'); + context[errSymbol] = cause; + context[cancelListenersSymbol]?.forEach((l) => l(cause)); + delete context[cancelListenersSymbol]; + } + + return cancel; +} + +function setContextTimeout(timeout: number, cancel: OnCancelListener) { + let timer: Timeout | undefined = setTimeout(() => { + // An error is always created rather than using a constant to have an actual callstack + const err = new Error('Timeout'); + (err as any).cause = timeoutSymbol; + cancel(err); + }, timeout); + + function dispose() { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + } + + return dispose; +} + +function createDone(cancel: OnCancelListener) { + function done() { + // An error is always created rather than using a constant to have an actual callstack + const err = new Error('Done'); + (err as any).cause = doneSymbol; + cancel(err); + } + + return done; +} + +function initContext(this: Context, opts: IContextOpts) { + const res: Omit = {}; + let cancel: OnCancelListener; + if (opts.cancel === true) res.cancel = cancel = makeContextCancellable(this); + if (opts.timeout && opts.timeout > 0) res.dispose = setContextTimeout(opts.timeout, cancel! || (cancel = makeContextCancellable(this))); + if (opts.done) res.done = createDone(cancel! || (cancel = makeContextCancellable(this))); + return res; +} diff --git a/src/context/ensureContext.ts b/src/context/ensureContext.ts new file mode 100644 index 00000000..85e2b1ed --- /dev/null +++ b/src/context/ensureContext.ts @@ -0,0 +1,35 @@ +import {Context} from "./Context"; + +/** + * Decorator that ensures: + * - in the case of positional arguments, the first argument type is Context. + * - in case of named arguments there is a non-null named argument ctx of type Context. + * + * If the context was not passed in the initial parameters, a new context with a unique id + * will be added to the parameters by this decorator. + * + * @param isPositionalArgs + */ +export function EnsureContext(isPositionalArgs?: boolean) { // TODO: Should I got logger somehow? + return (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) => { + const originalMethod = descriptor.value; + // const wrappedMethodName = `${target.constructor.name}::${propertyKey}`; // for regular method + // const wrappedMethodName = ???; // for static method + descriptor.value = async function (...args: any[]) { + if (isPositionalArgs) { + if (!(args[0] instanceof Context)) { + args.unshift(Context.createNew().ctx); + } + } else { + let opts = args[0] as any; + if (opts === undefined) + args[0] = opts = {}; + else if (!(typeof opts === 'object' && opts !== null)) + throw new Error('An object with options or undefined is expected as the first argument'); + if (!(opts.ctx instanceof Context)) + opts.ctx = Context.createNew().ctx; + } + return originalMethod.apply(this, args); + }; + }; +} diff --git a/src/context/symbols.ts b/src/context/symbols.ts new file mode 100644 index 00000000..0d7d41e9 --- /dev/null +++ b/src/context/symbols.ts @@ -0,0 +1,5 @@ +export const idSymbol = Symbol('id'); +export const cancelListenersSymbol = Symbol('listeners'); +export const errSymbol = Symbol('err'); +export const timeoutSymbol = Symbol('timeout'); +export const doneSymbol = Symbol('done');