diff --git a/.eslintrc b/.eslintrc index ed7535105..44a8d5ac5 100644 --- a/.eslintrc +++ b/.eslintrc @@ -115,7 +115,7 @@ "@typescript-eslint/consistent-type-imports": ["error"], "@typescript-eslint/consistent-type-exports": ["error"], "no-throw-literal": "off", - "@typescript-eslint/no-throw-literal": ["error"], + "@typescript-eslint/no-throw-literal": "off", "@typescript-eslint/no-floating-promises": ["error", { "ignoreVoid": true, "ignoreIIFE": true diff --git a/package-lock.json b/package-lock.json index 835225da2..c17e588f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "GPL-3.0", "dependencies": { "@grpc/grpc-js": "1.6.7", + "@matrixai/async-cancellable": "^1.0.2", "@matrixai/async-init": "^1.8.2", "@matrixai/async-locks": "^3.1.2", "@matrixai/db": "^5.0.3", @@ -17,6 +18,7 @@ "@matrixai/id": "^3.3.3", "@matrixai/logger": "^3.0.0", "@matrixai/resources": "^1.1.4", + "@matrixai/timer": "^1.0.0", "@matrixai/workers": "^1.3.6", "ajv": "^7.0.4", "bip39": "^3.0.3", @@ -54,14 +56,14 @@ "@types/google-protobuf": "^3.7.4", "@types/jest": "^28.1.3", "@types/nexpect": "^0.4.31", - "@types/node": "^16.11.7", + "@types/node": "^16.11.57", "@types/node-forge": "^0.10.4", "@types/pako": "^1.0.2", "@types/prompts": "^2.0.13", "@types/readable-stream": "^2.3.11", "@types/uuid": "^8.3.0", - "@typescript-eslint/eslint-plugin": "^5.23.0", - "@typescript-eslint/parser": "^5.23.0", + "@typescript-eslint/eslint-plugin": "^5.36.2", + "@typescript-eslint/parser": "^5.36.2", "babel-jest": "^28.1.3", "benny": "^3.7.1", "common-tags": "^1.8.2", @@ -88,7 +90,7 @@ "ts-node": "^10.9.1", "tsconfig-paths": "^3.9.0", "typedoc": "^0.22.15", - "typescript": "^4.5.2" + "typescript": "^4.7.4" } }, "node_modules/@ampproject/remapping": { @@ -2623,6 +2625,11 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@matrixai/async-cancellable": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@matrixai/async-cancellable/-/async-cancellable-1.0.2.tgz", + "integrity": "sha512-ugMfKtp7MlhXfBP//jGEAEEDbkVlr1aw8pqe2NrEUyyfKrZlX2jib50YocQYf+CcP4XnFAEzBDIpTAmqjukCug==" + }, "node_modules/@matrixai/async-init": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/@matrixai/async-init/-/async-init-1.8.2.tgz", @@ -2689,6 +2696,14 @@ "resolved": "https://registry.npmjs.org/@matrixai/resources/-/resources-1.1.4.tgz", "integrity": "sha512-YZSMtklbXah0+SxcKOVEm0ONQdWhlJecQ1COx6hg9Dl80WOybZjZ9A+N+OZfvWk9y25NuoIPzOsjhr8G1aTnIg==" }, + "node_modules/@matrixai/timer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@matrixai/timer/-/timer-1.0.0.tgz", + "integrity": "sha512-ZcsgIW+gMfoU206aryeDFPymSz/FVCY4w6Klw0CCQxSRpa20bdzFJ9UdCMJZzHiEBD1TSAdc2wPTqeXq5OUlPw==", + "dependencies": { + "@matrixai/async-cancellable": "^1.0.2" + } + }, "node_modules/@matrixai/workers": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/@matrixai/workers/-/workers-1.3.6.tgz", @@ -3027,9 +3042,9 @@ } }, "node_modules/@types/node": { - "version": "16.11.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.39.tgz", - "integrity": "sha512-K0MsdV42vPwm9L6UwhIxMAOmcvH/1OoVkZyCgEtVu4Wx7sElGloy/W7kMBNe/oJ7V/jW9BVt1F6RahH6e7tPXw==" + "version": "16.11.57", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.57.tgz", + "integrity": "sha512-diBb5AE2V8h9Fs9zEDtBwSeLvIACng/aAkdZ3ujMV+cGuIQ9Nc/V+wQqurk9HJp8ni5roBxQHW21z/ZYbGDivg==" }, "node_modules/@types/node-forge": { "version": "0.10.10", @@ -3099,14 +3114,14 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.28.0.tgz", - "integrity": "sha512-DXVU6Cg29H2M6EybqSg2A+x8DgO9TCUBRp4QEXQHJceLS7ogVDP0g3Lkg/SZCqcvkAP/RruuQqK0gdlkgmhSUA==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.2.tgz", + "integrity": "sha512-OwwR8LRwSnI98tdc2z7mJYgY60gf7I9ZfGjN5EjCwwns9bdTuQfAXcsjSB2wSQ/TVNYSGKf4kzVXbNGaZvwiXw==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "5.28.0", - "@typescript-eslint/type-utils": "5.28.0", - "@typescript-eslint/utils": "5.28.0", + "@typescript-eslint/scope-manager": "5.36.2", + "@typescript-eslint/type-utils": "5.36.2", + "@typescript-eslint/utils": "5.36.2", "debug": "^4.3.4", "functional-red-black-tree": "^1.0.1", "ignore": "^5.2.0", @@ -3147,14 +3162,14 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.28.0.tgz", - "integrity": "sha512-ekqoNRNK1lAcKhZESN/PdpVsWbP9jtiNqzFWkp/yAUdZvJalw2heCYuqRmM5eUJSIYEkgq5sGOjq+ZqsLMjtRA==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.36.2.tgz", + "integrity": "sha512-qS/Kb0yzy8sR0idFspI9Z6+t7mqk/oRjnAYfewG+VN73opAUvmYL3oPIMmgOX6CnQS6gmVIXGshlb5RY/R22pA==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "5.28.0", - "@typescript-eslint/types": "5.28.0", - "@typescript-eslint/typescript-estree": "5.28.0", + "@typescript-eslint/scope-manager": "5.36.2", + "@typescript-eslint/types": "5.36.2", + "@typescript-eslint/typescript-estree": "5.36.2", "debug": "^4.3.4" }, "engines": { @@ -3174,13 +3189,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.28.0.tgz", - "integrity": "sha512-LeBLTqF/he1Z+boRhSqnso6YrzcKMTQ8bO/YKEe+6+O/JGof9M0g3IJlIsqfrK/6K03MlFIlycbf1uQR1IjE+w==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.36.2.tgz", + "integrity": "sha512-cNNP51L8SkIFSfce8B1NSUBTJTu2Ts4nWeWbFrdaqjmn9yKrAaJUBHkyTZc0cL06OFHpb+JZq5AUHROS398Orw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.28.0", - "@typescript-eslint/visitor-keys": "5.28.0" + "@typescript-eslint/types": "5.36.2", + "@typescript-eslint/visitor-keys": "5.36.2" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -3191,12 +3206,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.28.0.tgz", - "integrity": "sha512-SyKjKh4CXPglueyC6ceAFytjYWMoPHMswPQae236zqe1YbhvCVQyIawesYywGiu98L9DwrxsBN69vGIVxJ4mQQ==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.36.2.tgz", + "integrity": "sha512-rPQtS5rfijUWLouhy6UmyNquKDPhQjKsaKH0WnY6hl/07lasj8gPaH2UD8xWkePn6SC+jW2i9c2DZVDnL+Dokw==", "dev": true, "dependencies": { - "@typescript-eslint/utils": "5.28.0", + "@typescript-eslint/typescript-estree": "5.36.2", + "@typescript-eslint/utils": "5.36.2", "debug": "^4.3.4", "tsutils": "^3.21.0" }, @@ -3217,9 +3233,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.28.0.tgz", - "integrity": "sha512-2OOm8ZTOQxqkPbf+DAo8oc16sDlVR5owgJfKheBkxBKg1vAfw2JsSofH9+16VPlN9PWtv8Wzhklkqw3k/zCVxA==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.36.2.tgz", + "integrity": "sha512-9OJSvvwuF1L5eS2EQgFUbECb99F0mwq501w0H0EkYULkhFa19Qq7WFbycdw1PexAc929asupbZcgjVIe6OK/XQ==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -3230,13 +3246,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.28.0.tgz", - "integrity": "sha512-9GX+GfpV+F4hdTtYc6OV9ZkyYilGXPmQpm6AThInpBmKJEyRSIjORJd1G9+bknb7OTFYL+Vd4FBJAO6T78OVqA==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.2.tgz", + "integrity": "sha512-8fyH+RfbKc0mTspfuEjlfqA4YywcwQK2Amcf6TDOwaRLg7Vwdu4bZzyvBZp4bjt1RRjQ5MDnOZahxMrt2l5v9w==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.28.0", - "@typescript-eslint/visitor-keys": "5.28.0", + "@typescript-eslint/types": "5.36.2", + "@typescript-eslint/visitor-keys": "5.36.2", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -3272,15 +3288,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.28.0.tgz", - "integrity": "sha512-E60N5L0fjv7iPJV3UGc4EC+A3Lcj4jle9zzR0gW7vXhflO7/J29kwiTGITA2RlrmPokKiZbBy2DgaclCaEUs6g==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.36.2.tgz", + "integrity": "sha512-uNcopWonEITX96v9pefk9DC1bWMdkweeSsewJ6GeC7L6j2t0SJywisgkr9wUTtXk90fi2Eljj90HSHm3OGdGRg==", "dev": true, "dependencies": { "@types/json-schema": "^7.0.9", - "@typescript-eslint/scope-manager": "5.28.0", - "@typescript-eslint/types": "5.28.0", - "@typescript-eslint/typescript-estree": "5.28.0", + "@typescript-eslint/scope-manager": "5.36.2", + "@typescript-eslint/types": "5.36.2", + "@typescript-eslint/typescript-estree": "5.36.2", "eslint-scope": "^5.1.1", "eslint-utils": "^3.0.0" }, @@ -3296,12 +3312,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.28.0.tgz", - "integrity": "sha512-BtfP1vCor8cWacovzzPFOoeW4kBQxzmhxGoOpt0v1SFvG+nJ0cWaVdJk7cky1ArTcFHHKNIxyo2LLr3oNkSuXA==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.2.tgz", + "integrity": "sha512-BtRvSR6dEdrNt7Net2/XDjbYKU5Ml6GqJgVfXT0CxTCJlnIqK7rAGreuWKMT2t8cFUT2Msv5oxw0GMRD7T5J7A==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.28.0", + "@typescript-eslint/types": "5.36.2", "eslint-visitor-keys": "^3.3.0" }, "engines": { @@ -11134,9 +11150,9 @@ } }, "node_modules/typescript": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.3.tgz", - "integrity": "sha512-WOkT3XYvrpXx4vMMqlD+8R8R37fZkjyLGlxavMc4iB8lrl8L0DeTcHbYgw/v0N/z9wAFsgBhcsF0ruoySS22mA==", + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.2.tgz", + "integrity": "sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -13388,6 +13404,11 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@matrixai/async-cancellable": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@matrixai/async-cancellable/-/async-cancellable-1.0.2.tgz", + "integrity": "sha512-ugMfKtp7MlhXfBP//jGEAEEDbkVlr1aw8pqe2NrEUyyfKrZlX2jib50YocQYf+CcP4XnFAEzBDIpTAmqjukCug==" + }, "@matrixai/async-init": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/@matrixai/async-init/-/async-init-1.8.2.tgz", @@ -13449,6 +13470,14 @@ "resolved": "https://registry.npmjs.org/@matrixai/resources/-/resources-1.1.4.tgz", "integrity": "sha512-YZSMtklbXah0+SxcKOVEm0ONQdWhlJecQ1COx6hg9Dl80WOybZjZ9A+N+OZfvWk9y25NuoIPzOsjhr8G1aTnIg==" }, + "@matrixai/timer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@matrixai/timer/-/timer-1.0.0.tgz", + "integrity": "sha512-ZcsgIW+gMfoU206aryeDFPymSz/FVCY4w6Klw0CCQxSRpa20bdzFJ9UdCMJZzHiEBD1TSAdc2wPTqeXq5OUlPw==", + "requires": { + "@matrixai/async-cancellable": "^1.0.2" + } + }, "@matrixai/workers": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/@matrixai/workers/-/workers-1.3.6.tgz", @@ -13749,9 +13778,9 @@ } }, "@types/node": { - "version": "16.11.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.39.tgz", - "integrity": "sha512-K0MsdV42vPwm9L6UwhIxMAOmcvH/1OoVkZyCgEtVu4Wx7sElGloy/W7kMBNe/oJ7V/jW9BVt1F6RahH6e7tPXw==" + "version": "16.11.57", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.57.tgz", + "integrity": "sha512-diBb5AE2V8h9Fs9zEDtBwSeLvIACng/aAkdZ3ujMV+cGuIQ9Nc/V+wQqurk9HJp8ni5roBxQHW21z/ZYbGDivg==" }, "@types/node-forge": { "version": "0.10.10", @@ -13821,14 +13850,14 @@ "dev": true }, "@typescript-eslint/eslint-plugin": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.28.0.tgz", - "integrity": "sha512-DXVU6Cg29H2M6EybqSg2A+x8DgO9TCUBRp4QEXQHJceLS7ogVDP0g3Lkg/SZCqcvkAP/RruuQqK0gdlkgmhSUA==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.2.tgz", + "integrity": "sha512-OwwR8LRwSnI98tdc2z7mJYgY60gf7I9ZfGjN5EjCwwns9bdTuQfAXcsjSB2wSQ/TVNYSGKf4kzVXbNGaZvwiXw==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "5.28.0", - "@typescript-eslint/type-utils": "5.28.0", - "@typescript-eslint/utils": "5.28.0", + "@typescript-eslint/scope-manager": "5.36.2", + "@typescript-eslint/type-utils": "5.36.2", + "@typescript-eslint/utils": "5.36.2", "debug": "^4.3.4", "functional-red-black-tree": "^1.0.1", "ignore": "^5.2.0", @@ -13849,52 +13878,53 @@ } }, "@typescript-eslint/parser": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.28.0.tgz", - "integrity": "sha512-ekqoNRNK1lAcKhZESN/PdpVsWbP9jtiNqzFWkp/yAUdZvJalw2heCYuqRmM5eUJSIYEkgq5sGOjq+ZqsLMjtRA==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.36.2.tgz", + "integrity": "sha512-qS/Kb0yzy8sR0idFspI9Z6+t7mqk/oRjnAYfewG+VN73opAUvmYL3oPIMmgOX6CnQS6gmVIXGshlb5RY/R22pA==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "5.28.0", - "@typescript-eslint/types": "5.28.0", - "@typescript-eslint/typescript-estree": "5.28.0", + "@typescript-eslint/scope-manager": "5.36.2", + "@typescript-eslint/types": "5.36.2", + "@typescript-eslint/typescript-estree": "5.36.2", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.28.0.tgz", - "integrity": "sha512-LeBLTqF/he1Z+boRhSqnso6YrzcKMTQ8bO/YKEe+6+O/JGof9M0g3IJlIsqfrK/6K03MlFIlycbf1uQR1IjE+w==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.36.2.tgz", + "integrity": "sha512-cNNP51L8SkIFSfce8B1NSUBTJTu2Ts4nWeWbFrdaqjmn9yKrAaJUBHkyTZc0cL06OFHpb+JZq5AUHROS398Orw==", "dev": true, "requires": { - "@typescript-eslint/types": "5.28.0", - "@typescript-eslint/visitor-keys": "5.28.0" + "@typescript-eslint/types": "5.36.2", + "@typescript-eslint/visitor-keys": "5.36.2" } }, "@typescript-eslint/type-utils": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.28.0.tgz", - "integrity": "sha512-SyKjKh4CXPglueyC6ceAFytjYWMoPHMswPQae236zqe1YbhvCVQyIawesYywGiu98L9DwrxsBN69vGIVxJ4mQQ==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.36.2.tgz", + "integrity": "sha512-rPQtS5rfijUWLouhy6UmyNquKDPhQjKsaKH0WnY6hl/07lasj8gPaH2UD8xWkePn6SC+jW2i9c2DZVDnL+Dokw==", "dev": true, "requires": { - "@typescript-eslint/utils": "5.28.0", + "@typescript-eslint/typescript-estree": "5.36.2", + "@typescript-eslint/utils": "5.36.2", "debug": "^4.3.4", "tsutils": "^3.21.0" } }, "@typescript-eslint/types": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.28.0.tgz", - "integrity": "sha512-2OOm8ZTOQxqkPbf+DAo8oc16sDlVR5owgJfKheBkxBKg1vAfw2JsSofH9+16VPlN9PWtv8Wzhklkqw3k/zCVxA==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.36.2.tgz", + "integrity": "sha512-9OJSvvwuF1L5eS2EQgFUbECb99F0mwq501w0H0EkYULkhFa19Qq7WFbycdw1PexAc929asupbZcgjVIe6OK/XQ==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.28.0.tgz", - "integrity": "sha512-9GX+GfpV+F4hdTtYc6OV9ZkyYilGXPmQpm6AThInpBmKJEyRSIjORJd1G9+bknb7OTFYL+Vd4FBJAO6T78OVqA==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.2.tgz", + "integrity": "sha512-8fyH+RfbKc0mTspfuEjlfqA4YywcwQK2Amcf6TDOwaRLg7Vwdu4bZzyvBZp4bjt1RRjQ5MDnOZahxMrt2l5v9w==", "dev": true, "requires": { - "@typescript-eslint/types": "5.28.0", - "@typescript-eslint/visitor-keys": "5.28.0", + "@typescript-eslint/types": "5.36.2", + "@typescript-eslint/visitor-keys": "5.36.2", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -13914,26 +13944,26 @@ } }, "@typescript-eslint/utils": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.28.0.tgz", - "integrity": "sha512-E60N5L0fjv7iPJV3UGc4EC+A3Lcj4jle9zzR0gW7vXhflO7/J29kwiTGITA2RlrmPokKiZbBy2DgaclCaEUs6g==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.36.2.tgz", + "integrity": "sha512-uNcopWonEITX96v9pefk9DC1bWMdkweeSsewJ6GeC7L6j2t0SJywisgkr9wUTtXk90fi2Eljj90HSHm3OGdGRg==", "dev": true, "requires": { "@types/json-schema": "^7.0.9", - "@typescript-eslint/scope-manager": "5.28.0", - "@typescript-eslint/types": "5.28.0", - "@typescript-eslint/typescript-estree": "5.28.0", + "@typescript-eslint/scope-manager": "5.36.2", + "@typescript-eslint/types": "5.36.2", + "@typescript-eslint/typescript-estree": "5.36.2", "eslint-scope": "^5.1.1", "eslint-utils": "^3.0.0" } }, "@typescript-eslint/visitor-keys": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.28.0.tgz", - "integrity": "sha512-BtfP1vCor8cWacovzzPFOoeW4kBQxzmhxGoOpt0v1SFvG+nJ0cWaVdJk7cky1ArTcFHHKNIxyo2LLr3oNkSuXA==", + "version": "5.36.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.2.tgz", + "integrity": "sha512-BtRvSR6dEdrNt7Net2/XDjbYKU5Ml6GqJgVfXT0CxTCJlnIqK7rAGreuWKMT2t8cFUT2Msv5oxw0GMRD7T5J7A==", "dev": true, "requires": { - "@typescript-eslint/types": "5.28.0", + "@typescript-eslint/types": "5.36.2", "eslint-visitor-keys": "^3.3.0" } }, @@ -19749,9 +19779,9 @@ } }, "typescript": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.3.tgz", - "integrity": "sha512-WOkT3XYvrpXx4vMMqlD+8R8R37fZkjyLGlxavMc4iB8lrl8L0DeTcHbYgw/v0N/z9wAFsgBhcsF0ruoySS22mA==", + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.2.tgz", + "integrity": "sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==", "dev": true }, "uglify-js": { diff --git a/package.json b/package.json index 29403fed3..b003138d9 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "postbuild": "shx cp -fR src/proto dist && shx cp -f src/notifications/*.json dist/notifications/ && shx cp -f src/claims/*.json dist/claims/ && shx cp -f src/status/*.json dist/status/", "postversion": "npm install --package-lock-only --ignore-scripts --silent", "ts-node": "ts-node", + "ts-node-inspect": "node --require ts-node/register --inspect", "test": "jest", "lint": "eslint '{src,tests,scripts,benches}/**/*.{js,ts}'", "lintfix": "eslint '{src,tests,scripts,benches}/**/*.{js,ts}' --fix", @@ -77,6 +78,7 @@ }, "dependencies": { "@grpc/grpc-js": "1.6.7", + "@matrixai/async-cancellable": "^1.0.2", "@matrixai/async-init": "^1.8.2", "@matrixai/async-locks": "^3.1.2", "@matrixai/db": "^5.0.3", @@ -85,6 +87,7 @@ "@matrixai/logger": "^3.0.0", "@matrixai/resources": "^1.1.4", "@matrixai/workers": "^1.3.6", + "@matrixai/timer": "^1.0.0", "ajv": "^7.0.4", "bip39": "^3.0.3", "canonicalize": "^1.0.5", @@ -117,14 +120,14 @@ "@types/google-protobuf": "^3.7.4", "@types/jest": "^28.1.3", "@types/nexpect": "^0.4.31", - "@types/node": "^16.11.7", + "@types/node": "^16.11.57", "@types/node-forge": "^0.10.4", "@types/pako": "^1.0.2", "@types/prompts": "^2.0.13", "@types/readable-stream": "^2.3.11", "@types/uuid": "^8.3.0", - "@typescript-eslint/eslint-plugin": "^5.23.0", - "@typescript-eslint/parser": "^5.23.0", + "@typescript-eslint/eslint-plugin": "^5.36.2", + "@typescript-eslint/parser": "^5.36.2", "babel-jest": "^28.1.3", "benny": "^3.7.1", "common-tags": "^1.8.2", @@ -151,6 +154,6 @@ "ts-node": "^10.9.1", "tsconfig-paths": "^3.9.0", "typedoc": "^0.22.15", - "typescript": "^4.5.2" + "typescript": "^4.7.4" } } diff --git a/src/contexts/decorators/cancellable.ts b/src/contexts/decorators/cancellable.ts new file mode 100644 index 000000000..c76ce8b20 --- /dev/null +++ b/src/contexts/decorators/cancellable.ts @@ -0,0 +1,47 @@ +import type { ContextCancellable } from '../types'; +import { setupCancellable } from '../functions/cancellable'; +import * as contextsUtils from '../utils'; + +function cancellable(lazy: boolean = false) { + return < + T extends TypedPropertyDescriptor< + (...params: Array) => PromiseLike + >, + >( + target: any, + key: string | symbol, + descriptor: T, + ): T => { + // Target is instance prototype for instance methods // or the class prototype for static methods + const targetName = target['name'] ?? target.constructor.name; + const f = descriptor['value']; + if (typeof f !== 'function') { + throw new TypeError( + `\`${targetName}.${key.toString()}\` is not a function`, + ); + } + const contextIndex = contextsUtils.getContextIndex(target, key, targetName); + descriptor['value'] = function (...args) { + let ctx: Partial = args[contextIndex]; + if (ctx === undefined) { + ctx = {}; + args[contextIndex] = ctx; + } + // Runtime type check on the context parameter + contextsUtils.checkContextCancellable(ctx, key, targetName); + return setupCancellable( + (_, ...args) => f.apply(this, args), + lazy, + ctx, + args, + ); + }; + // Preserve the name + Object.defineProperty(descriptor['value'], 'name', { + value: typeof key === 'symbol' ? `[${key.description}]` : key, + }); + return descriptor; + }; +} + +export default cancellable; diff --git a/src/contexts/decorators/context.ts b/src/contexts/decorators/context.ts new file mode 100644 index 000000000..fe4b0ae21 --- /dev/null +++ b/src/contexts/decorators/context.ts @@ -0,0 +1,18 @@ +import * as contextsUtils from '../utils'; + +/** + * Context parameter decorator + * It is only allowed to be used once + */ +function context(target: any, key: string | symbol, index: number) { + const targetName = target['name'] ?? target.constructor.name; + const method = target[key]; + if (contextsUtils.contexts.has(method)) { + throw new TypeError( + `\`${targetName}.${key.toString()}\` redeclares \`@context\` decorator`, + ); + } + contextsUtils.contexts.set(method, index); +} + +export default context; diff --git a/src/contexts/decorators/index.ts b/src/contexts/decorators/index.ts new file mode 100644 index 000000000..e8997e285 --- /dev/null +++ b/src/contexts/decorators/index.ts @@ -0,0 +1,4 @@ +export { default as context } from './context'; +export { default as cancellable } from './cancellable'; +export { default as timed } from './timed'; +export { default as timedCancellable } from './timedCancellable'; diff --git a/src/contexts/decorators/timed.ts b/src/contexts/decorators/timed.ts new file mode 100644 index 000000000..08345f0a6 --- /dev/null +++ b/src/contexts/decorators/timed.ts @@ -0,0 +1,145 @@ +import type { ContextTimed } from '../types'; +import { setupTimedContext } from '../functions/timed'; +import * as contextsUtils from '../utils'; +import * as contextsErrors from '../errors'; +import * as utils from '../../utils'; + +/** + * Timed method decorator + */ +function timed( + delay: number = Infinity, + errorTimeoutConstructor: new () => Error = contextsErrors.ErrorContextsTimedTimeOut, +) { + return ( + target: any, + key: string | symbol, + descriptor: TypedPropertyDescriptor<(...params: Array) => any>, + ) => { + // Target is instance prototype for instance methods + // or the class prototype for static methods + const targetName = target['name'] ?? target.constructor.name; + const f = descriptor['value']; + if (typeof f !== 'function') { + throw new TypeError( + `\`${targetName}.${key.toString()}\` is not a function`, + ); + } + const contextIndex = contextsUtils.getContextIndex(target, key, targetName); + if (f instanceof utils.AsyncFunction) { + descriptor['value'] = async function (...args) { + let ctx: Partial = args[contextIndex]; + if (ctx === undefined) { + ctx = {}; + args[contextIndex] = ctx; + } + // Runtime type check on the context parameter + contextsUtils.checkContextTimed(ctx, key, targetName); + const teardownContext = setupTimedContext( + delay, + errorTimeoutConstructor, + ctx, + ); + try { + return await f.apply(this, args); + } finally { + teardownContext(); + } + }; + } else if (f instanceof utils.GeneratorFunction) { + descriptor['value'] = function* (...args) { + let ctx: Partial = args[contextIndex]; + if (ctx === undefined) { + ctx = {}; + args[contextIndex] = ctx; + } + // Runtime type check on the context parameter + contextsUtils.checkContextTimed(ctx, key, targetName); + const teardownContext = setupTimedContext( + delay, + errorTimeoutConstructor, + ctx, + ); + try { + return yield* f.apply(this, args); + } finally { + teardownContext(); + } + }; + } else if (f instanceof utils.AsyncGeneratorFunction) { + descriptor['value'] = async function* (...args) { + let ctx: Partial = args[contextIndex]; + if (ctx === undefined) { + ctx = {}; + args[contextIndex] = ctx; + } + // Runtime type check on the context parameter + contextsUtils.checkContextTimed(ctx, key, targetName); + const teardownContext = setupTimedContext( + delay, + errorTimeoutConstructor, + ctx, + ); + try { + return yield* f.apply(this, args); + } finally { + teardownContext(); + } + }; + } else { + descriptor['value'] = function (...args) { + let ctx: Partial = args[contextIndex]; + if (ctx === undefined) { + ctx = {}; + args[contextIndex] = ctx; + } + // Runtime type check on the context parameter + contextsUtils.checkContextTimed(ctx, key, targetName); + const teardownContext = setupTimedContext( + delay, + errorTimeoutConstructor, + ctx, + ); + const result = f.apply(this, args); + if (utils.isPromiseLike(result)) { + return result.then( + (r) => { + teardownContext(); + return r; + }, + (e) => { + teardownContext(); + throw e; + }, + ); + } else if (utils.isGenerator(result)) { + return (function* () { + try { + return yield* result; + } finally { + teardownContext(); + } + })(); + } else if (utils.isAsyncGenerator(result)) { + return (async function* () { + try { + return yield* result; + } finally { + teardownContext(); + } + })(); + } else { + teardownContext(); + return result; + } + }; + } + // Preserve the name + Object.defineProperty(descriptor['value'], 'name', { + value: typeof key === 'symbol' ? `[${key.description}]` : key, + }); + return descriptor; + }; +} + +export default timed; diff --git a/src/contexts/decorators/timedCancellable.ts b/src/contexts/decorators/timedCancellable.ts new file mode 100644 index 000000000..46c7196fa --- /dev/null +++ b/src/contexts/decorators/timedCancellable.ts @@ -0,0 +1,55 @@ +import type { ContextTimed } from '../types'; +import { setupTimedCancellable } from '../functions/timedCancellable'; +import * as contextsUtils from '../utils'; +import * as contextsErrors from '../errors'; + +function timedCancellable( + lazy: boolean = false, + delay: number = Infinity, + errorTimeoutConstructor: new () => Error = contextsErrors.ErrorContextsTimedTimeOut, +) { + return < + T extends TypedPropertyDescriptor< + (...params: Array) => PromiseLike + >, + >( + target: any, + key: string | symbol, + descriptor: T, + ) => { + // Target is instance prototype for instance methods + // or the class prototype for static methods + const targetName: string = target['name'] ?? target.constructor.name; + const f = descriptor['value']; + if (typeof f !== 'function') { + throw new TypeError( + `\`${targetName}.${key.toString()}\` is not a function`, + ); + } + const contextIndex = contextsUtils.getContextIndex(target, key, targetName); + descriptor['value'] = function (...args) { + let ctx: Partial = args[contextIndex]; + if (ctx === undefined) { + ctx = {}; + args[contextIndex] = ctx; + } + // Runtime type check on the context parameter + contextsUtils.checkContextTimed(ctx, key, targetName); + return setupTimedCancellable( + (_, ...args) => f.apply(this, args), + lazy, + delay, + errorTimeoutConstructor, + ctx, + args, + ); + }; + // Preserve the name + Object.defineProperty(descriptor['value'], 'name', { + value: typeof key === 'symbol' ? `[${key.description}]` : key, + }); + return descriptor; + }; +} + +export default timedCancellable; diff --git a/src/contexts/errors.ts b/src/contexts/errors.ts new file mode 100644 index 000000000..78c5b5af6 --- /dev/null +++ b/src/contexts/errors.ts @@ -0,0 +1,10 @@ +import { ErrorPolykey, sysexits } from '../errors'; + +class ErrorContexts extends ErrorPolykey {} + +class ErrorContextsTimedTimeOut extends ErrorContexts { + static description = 'Aborted due to timer expiration'; + exitCode = sysexits.UNAVAILABLE; +} + +export { ErrorContexts, ErrorContextsTimedTimeOut }; diff --git a/src/contexts/functions/cancellable.ts b/src/contexts/functions/cancellable.ts new file mode 100644 index 000000000..77fd8e898 --- /dev/null +++ b/src/contexts/functions/cancellable.ts @@ -0,0 +1,84 @@ +import type { ContextCancellable } from '../types'; +import { PromiseCancellable } from '@matrixai/async-cancellable'; + +type ContextRemaining = Omit; + +type ContextAndParameters< + C, + P extends Array, +> = keyof ContextRemaining extends never + ? [Partial?, ...P] + : [Partial & ContextRemaining, ...P]; + +function setupCancellable< + C extends ContextCancellable, + P extends Array, + R, +>( + f: (ctx: C, ...params: P) => PromiseLike, + lazy: boolean, + ctx: Partial, + args: P, +): PromiseCancellable { + if (ctx.signal === undefined) { + const abortController = new AbortController(); + ctx.signal = abortController.signal; + const result = f(ctx as C, ...args); + return new PromiseCancellable((resolve, reject, signal) => { + if (!lazy) { + signal.addEventListener('abort', () => { + reject(signal.reason); + }); + } + void result.then(resolve, reject); + }, abortController); + } else { + // In this case, `context.signal` is set + // and we chain the upsteam signal to the downstream signal + const abortController = new AbortController(); + const signalUpstream = ctx.signal; + const signalHandler = () => { + abortController.abort(signalUpstream.reason); + }; + if (signalUpstream.aborted) { + abortController.abort(signalUpstream.reason); + } else { + signalUpstream.addEventListener('abort', signalHandler); + } + // Overwrite the signal property with this context's `AbortController.signal` + ctx.signal = abortController.signal; + const result = f(ctx as C, ...args); + // The `abortController` must be shared in the `finally` clause + // to link up final promise's cancellation with the target + // function's signal + return new PromiseCancellable((resolve, reject, signal) => { + if (!lazy) { + if (signal.aborted) { + reject(signal.reason); + } else { + signal.addEventListener('abort', () => { + reject(signal.reason); + }); + } + } + void result.then(resolve, reject); + }, abortController).finally(() => { + signalUpstream.removeEventListener('abort', signalHandler); + }, abortController); + } +} + +function cancellable, R>( + f: (ctx: C, ...params: P) => PromiseLike, + lazy: boolean = false, +): (...params: ContextAndParameters) => PromiseCancellable { + return (...params) => { + const ctx = params[0] ?? {}; + const args = params.slice(1) as P; + return setupCancellable(f, lazy, ctx, args); + }; +} + +export default cancellable; + +export { setupCancellable }; diff --git a/src/contexts/functions/index.ts b/src/contexts/functions/index.ts new file mode 100644 index 000000000..f3165cf18 --- /dev/null +++ b/src/contexts/functions/index.ts @@ -0,0 +1,3 @@ +export { default as cancellable } from './cancellable'; +export { default as timed } from './timed'; +export { default as timedCancellable } from './timedCancellable'; diff --git a/src/contexts/functions/timed.ts b/src/contexts/functions/timed.ts new file mode 100644 index 000000000..3c4e621c6 --- /dev/null +++ b/src/contexts/functions/timed.ts @@ -0,0 +1,218 @@ +import type { ContextTimed } from '../types'; +import { Timer } from '@matrixai/timer'; +import * as contextsErrors from '../errors'; +import * as utils from '../../utils'; + +type ContextRemaining = Omit; + +type ContextAndParameters< + C, + P extends Array, +> = keyof ContextRemaining extends never + ? [Partial?, ...P] + : [Partial & ContextRemaining, ...P]; + +function setupTimedContext( + delay: number, + errorTimeoutConstructor: new () => Error, + ctx: Partial, +): () => void { + // There are 3 properties of timer and signal: + // + // A. If timer times out, signal is aborted + // B. If signal is aborted, timer is cancelled + // C. If timer is owned by the wrapper, then it must be cancelled when the target finishes + // + // There are 4 cases where the wrapper is used: + // + // 1. Nothing is inherited - A B C + // 2. Signal is inherited - A B C + // 3. Timer is inherited - A + // 4. Both signal and timer are inherited - A* + // + // Property B and C only applies to case 1 and 2 because the timer is owned + // by the wrapper and it is not inherited, if it is inherited, the caller may + // need to reuse the timer. + // In situation 4, there's a caveat for property A: it is assumed that the + // caller has already setup the property A relationship, therefore this + // wrapper will not re-setup this property A relationship. + if (ctx.timer === undefined && ctx.signal === undefined) { + const abortController = new AbortController(); + const e = new errorTimeoutConstructor(); + // Property A + const timer = new Timer(() => void abortController.abort(e), delay); + abortController.signal.addEventListener('abort', () => { + // Property B + timer.cancel(); + }); + ctx.signal = abortController.signal; + ctx.timer = timer; + return () => { + // Property C + timer.cancel(); + }; + } else if (ctx.timer === undefined && ctx.signal instanceof AbortSignal) { + const abortController = new AbortController(); + const e = new errorTimeoutConstructor(); + // Property A + const timer = new Timer(() => void abortController.abort(e), delay); + const signalUpstream = ctx.signal; + const signalHandler = () => { + // Property B + timer.cancel(); + abortController.abort(signalUpstream.reason); + }; + // If already aborted, abort target and cancel the timer + if (signalUpstream.aborted) { + // Property B + timer.cancel(); + abortController.abort(signalUpstream.reason); + } else { + signalUpstream.addEventListener('abort', signalHandler); + } + // Overwrite the signal property with this ctx's `AbortController.signal` + ctx.signal = abortController.signal; + ctx.timer = timer; + return () => { + signalUpstream.removeEventListener('abort', signalHandler); + // Property C + timer.cancel(); + }; + } else if (ctx.timer instanceof Timer && ctx.signal === undefined) { + const abortController = new AbortController(); + const e = new errorTimeoutConstructor(); + let finished = false; + // If the timer resolves, then abort the target function + void ctx.timer.then( + (r: any, s: AbortSignal) => { + // If the timer is aborted after it resolves + // then don't bother aborting the target function + if (!finished && !s.aborted) { + // Property A + abortController.abort(e); + } + return r; + }, + () => { + // Ignore any upstream cancellation + }, + ); + ctx.signal = abortController.signal; + return () => { + finished = true; + }; + } else { + // In this case, `ctx.timer` and `ctx.signal` are both instances of + // `Timer` and `AbortSignal` respectively + // It is assumed that both the timer and signal are already hooked up to each other + return () => {}; + } +} + +/** + * Timed HOF + * This overloaded signature is external signature + */ +function timed, R>( + f: (ctx: C, ...params: P) => R, + delay?: number, + errorTimeoutConstructor?: new () => Error, +): (...params: ContextAndParameters) => R; +function timed>( + f: (ctx: C, ...params: P) => any, + delay: number = Infinity, + errorTimeoutConstructor: new () => Error = contextsErrors.ErrorContextsTimedTimeOut, +): (...params: ContextAndParameters) => any { + if (f instanceof utils.AsyncFunction) { + return async (...params) => { + const ctx = params[0] ?? {}; + const args = params.slice(1) as P; + const teardownContext = setupTimedContext( + delay, + errorTimeoutConstructor, + ctx, + ); + try { + return await f(ctx as C, ...args); + } finally { + teardownContext(); + } + }; + } else if (f instanceof utils.GeneratorFunction) { + return function* (...params) { + const ctx = params[0] ?? {}; + const args = params.slice(1) as P; + const teardownContext = setupTimedContext( + delay, + errorTimeoutConstructor, + ctx, + ); + try { + return yield* f(ctx as C, ...args); + } finally { + teardownContext(); + } + }; + } else if (f instanceof utils.AsyncGeneratorFunction) { + return async function* (...params) { + const ctx = params[0] ?? {}; + const args = params.slice(1) as P; + const teardownContext = setupTimedContext( + delay, + errorTimeoutConstructor, + ctx, + ); + try { + return yield* f(ctx as C, ...args); + } finally { + teardownContext(); + } + }; + } else { + return (...params) => { + const ctx = params[0] ?? {}; + const args = params.slice(1) as P; + const teardownContext = setupTimedContext( + delay, + errorTimeoutConstructor, + ctx, + ); + const result = f(ctx as C, ...args); + if (utils.isPromiseLike(result)) { + return result.then( + (r) => { + teardownContext(); + return r; + }, + (e) => { + teardownContext(); + throw e; + }, + ); + } else if (utils.isGenerator(result)) { + return (function* () { + try { + return yield* result; + } finally { + teardownContext(); + } + })(); + } else if (utils.isAsyncGenerator(result)) { + return (async function* () { + try { + return yield* result; + } finally { + teardownContext(); + } + })(); + } else { + teardownContext(); + return result; + } + }; + } +} + +export default timed; + +export { setupTimedContext }; diff --git a/src/contexts/functions/timedCancellable.ts b/src/contexts/functions/timedCancellable.ts new file mode 100644 index 000000000..332302358 --- /dev/null +++ b/src/contexts/functions/timedCancellable.ts @@ -0,0 +1,171 @@ +import type { ContextTimed } from '../types'; +import { PromiseCancellable } from '@matrixai/async-cancellable'; +import { Timer } from '@matrixai/timer'; +import * as contextsErrors from '../errors'; + +type ContextRemaining = Omit; + +type ContextAndParameters< + C, + P extends Array, +> = keyof ContextRemaining extends never + ? [Partial?, ...P] + : [Partial & ContextRemaining, ...P]; + +function setupTimedCancellable, R>( + f: (ctx: C, ...params: P) => PromiseLike, + lazy: boolean, + delay: number, + errorTimeoutConstructor: new () => Error = contextsErrors.ErrorContextsTimedTimeOut, + ctx: Partial, + args: P, +): PromiseCancellable { + // There are 3 properties of timer and signal: + // + // A. If timer times out, signal is aborted + // B. If signal is aborted, timer is cancelled + // C. If timer is owned by the wrapper, then it must be cancelled when the target finishes + // + // There are 4 cases where the wrapper is used: + // + // 1. Nothing is inherited - A B C + // 2. Signal is inherited - A B C + // 3. Timer is inherited - A + // 4. Both signal and timer are inherited - A* + // + // Property B and C only applies to case 1 and 2 because the timer is owned + // by the wrapper and it is not inherited, if it is inherited, the caller may + // need to reuse the timer. + // In situation 4, there's a caveat for property A: it is assumed that the + // caller has already setup the property A relationship, therefore this + // wrapper will not re-setup this property A relationship. + let abortController: AbortController; + let teardownContext: () => void; + if (ctx.timer === undefined && ctx.signal === undefined) { + abortController = new AbortController(); + const e = new errorTimeoutConstructor(); + // Property A + const timer = new Timer(() => void abortController.abort(e), delay); + abortController.signal.addEventListener('abort', () => { + // Property B + timer.cancel(); + }); + ctx.signal = abortController.signal; + ctx.timer = timer; + teardownContext = () => { + // Property C + timer.cancel(); + }; + } else if (ctx.timer === undefined && ctx.signal instanceof AbortSignal) { + abortController = new AbortController(); + const e = new errorTimeoutConstructor(); + // Property A + const timer = new Timer(() => void abortController.abort(e), delay); + const signalUpstream = ctx.signal; + const signalHandler = () => { + // Property B + timer.cancel(); + abortController.abort(signalUpstream.reason); + }; + // If already aborted, abort target and cancel the timer + if (signalUpstream.aborted) { + // Property B + timer.cancel(); + abortController.abort(signalUpstream.reason); + } else { + signalUpstream.addEventListener('abort', signalHandler); + } + // Overwrite the signal property with this ctx's `AbortController.signal` + ctx.signal = abortController.signal; + ctx.timer = timer; + teardownContext = () => { + signalUpstream.removeEventListener('abort', signalHandler); + // Property C + timer.cancel(); + }; + } else if (ctx.timer instanceof Timer && ctx.signal === undefined) { + abortController = new AbortController(); + const e = new errorTimeoutConstructor(); + let finished = false; + // If the timer resolves, then abort the target function + void ctx.timer.then( + (r: any, s: AbortSignal) => { + // If the timer is aborted after it resolves + // then don't bother aborting the target function + if (!finished && !s.aborted) { + // Property A + abortController.abort(e); + } + return r; + }, + () => { + // Ignore any upstream cancellation + }, + ); + ctx.signal = abortController.signal; + teardownContext = () => { + finished = true; + }; + } else { + // In this case, `context.timer` and `context.signal` are both instances of + // `Timer` and `AbortSignal` respectively + // It is assumed that both the timer and signal are already hooked up to each other + abortController = new AbortController(); + const signalUpstream = ctx.signal!; + const signalHandler = () => { + abortController.abort(signalUpstream.reason); + }; + if (signalUpstream.aborted) { + abortController.abort(signalUpstream.reason); + } else { + signalUpstream.addEventListener('abort', signalHandler); + } + // Overwrite the signal property with this context's `AbortController.signal` + ctx.signal = abortController.signal; + teardownContext = () => { + signalUpstream.removeEventListener('abort', signalHandler); + }; + } + const result = f(ctx as C, ...args); + // The `abortController` must be shared in the `finally` clause + // to link up final promise's cancellation with the target + // function's signal + return new PromiseCancellable((resolve, reject, signal) => { + if (!lazy) { + if (signal.aborted) { + reject(signal.reason); + } else { + signal.addEventListener('abort', () => { + reject(signal.reason); + }); + } + } + void result.then(resolve, reject); + }, abortController).finally(() => { + teardownContext(); + }, abortController); +} + +function timedCancellable, R>( + f: (ctx: C, ...params: P) => PromiseLike, + lazy: boolean = false, + delay: number = Infinity, + errorTimeoutConstructor: new () => Error = contextsErrors.ErrorContextsTimedTimeOut, +): (...params: ContextAndParameters) => PromiseCancellable { + return (...params) => { + const ctx = params[0] ?? {}; + const args = params.slice(1) as P; + return setupTimedCancellable( + f, + lazy, + delay, + errorTimeoutConstructor, + ctx, + args, + ); + }; +} + +export default timedCancellable; + +export { setupTimedCancellable }; diff --git a/src/contexts/index.ts b/src/contexts/index.ts new file mode 100644 index 000000000..9432815a9 --- /dev/null +++ b/src/contexts/index.ts @@ -0,0 +1,4 @@ +export * from './decorators'; +export * from './utils'; +export * as types from './types'; +export * as errors from './errors'; diff --git a/src/contexts/types.ts b/src/contexts/types.ts new file mode 100644 index 000000000..047368657 --- /dev/null +++ b/src/contexts/types.ts @@ -0,0 +1,11 @@ +import type { Timer } from '@matrixai/timer'; + +type ContextCancellable = { + signal: AbortSignal; +}; + +type ContextTimed = ContextCancellable & { + timer: Timer; +}; + +export type { ContextCancellable, ContextTimed }; diff --git a/src/contexts/utils.ts b/src/contexts/utils.ts new file mode 100644 index 000000000..6a9ba00c1 --- /dev/null +++ b/src/contexts/utils.ts @@ -0,0 +1,63 @@ +import { Timer } from '@matrixai/timer'; + +const contexts = new WeakMap(); + +function getContextIndex( + target: any, + key: string | symbol, + targetName: string, +): number { + const contextIndex = contexts.get(target[key]); + if (contextIndex == null) { + throw new TypeError( + `\`${targetName}.${key.toString()}\` does not have a \`@context\` parameter decorator`, + ); + } + return contextIndex; +} + +function checkContextCancellable( + ctx: any, + key: string | symbol, + targetName: string, +): void { + if (typeof ctx !== 'object' || ctx === null) { + throw new TypeError( + `\`${targetName}.${key.toString()}\` decorated \`@context\` parameter is not a context object`, + ); + } + if (ctx.signal !== undefined && !(ctx.signal instanceof AbortSignal)) { + throw new TypeError( + `\`${targetName}.${key.toString()}\` decorated \`@context\` parameter's \`signal\` property is not an instance of \`AbortSignal\``, + ); + } +} + +function checkContextTimed( + ctx: any, + key: string | symbol, + targetName: string, +): void { + if (typeof ctx !== 'object' || ctx === null) { + throw new TypeError( + `\`${targetName}.${key.toString()}\` decorated \`@context\` parameter is not a context object`, + ); + } + if (ctx.signal !== undefined && !(ctx.signal instanceof AbortSignal)) { + throw new TypeError( + `\`${targetName}.${key.toString()}\` decorated \`@context\` parameter's \`signal\` property is not an instance of \`AbortSignal\``, + ); + } + if (ctx.timer !== undefined && !(ctx.timer instanceof Timer)) { + throw new TypeError( + `\`${targetName}.${key.toString()}\` decorated \`@context\` parameter's \`timer\` property is not an instance of \`Timer\``, + ); + } +} + +export { + contexts, + getContextIndex, + checkContextCancellable, + checkContextTimed, +}; diff --git a/src/discovery/Discovery.ts b/src/discovery/Discovery.ts index 37bc416f6..834b6c733 100644 --- a/src/discovery/Discovery.ts +++ b/src/discovery/Discovery.ts @@ -489,7 +489,7 @@ class Discovery { // Get our own auth identity id const authIdentityIds = await provider.getAuthIdentityIds(); // If we don't have one then we can't request data so just skip - if (authIdentityIds === [] || authIdentityIds[0] == null) { + if (authIdentityIds.length === 0 || authIdentityIds[0] == null) { return undefined; } const authIdentityId = authIdentityIds[0]; diff --git a/src/tasks/TaskEvent.ts b/src/tasks/TaskEvent.ts new file mode 100644 index 000000000..54439c1f9 --- /dev/null +++ b/src/tasks/TaskEvent.ts @@ -0,0 +1,33 @@ +import type { TaskIdEncoded } from './types'; + +class TaskEvent extends Event { + public detail: + | { + status: 'success'; + result: T; + } + | { + status: 'failure'; + reason: any; + }; + + constructor( + type: TaskIdEncoded, + options: EventInit & { + detail: + | { + status: 'success'; + result: T; + } + | { + status: 'failure'; + reason: any; + }; + }, + ) { + super(type, options); + this.detail = options.detail; + } +} + +export default TaskEvent; diff --git a/src/tasks/TaskManager.ts b/src/tasks/TaskManager.ts new file mode 100644 index 000000000..6dc221def --- /dev/null +++ b/src/tasks/TaskManager.ts @@ -0,0 +1,1251 @@ +import type { DB, DBTransaction, LevelPath, KeyPath } from '@matrixai/db'; +import type { ResourceRelease } from '@matrixai/resources'; +import type { + TaskHandlerId, + TaskHandler, + TaskId, + TaskIdEncoded, + Task, + TaskInfo, + TaskData, + TaskStatus, + TaskParameters, + TaskTimestamp, + TaskPath, +} from './types'; +import Logger from '@matrixai/logger'; +import { IdInternal } from '@matrixai/id'; +import { + CreateDestroyStartStop, + ready, +} from '@matrixai/async-init/dist/CreateDestroyStartStop'; +import { Lock } from '@matrixai/async-locks'; +import { PromiseCancellable } from '@matrixai/async-cancellable'; +import { extractTs } from '@matrixai/id/dist/IdSortable'; +import { Timer } from '@matrixai/timer'; +import TaskEvent from './TaskEvent'; +import * as tasksErrors from './errors'; +import * as tasksUtils from './utils'; +import * as utils from '../utils'; + +const abortSchedulingLoopReason = Symbol('abort scheduling loop reason'); +const abortQueuingLoopReason = Symbol('abort queuing loop reason'); + +@CreateDestroyStartStop( + new tasksErrors.ErrorTaskManagerRunning(), + new tasksErrors.ErrorTaskManagerDestroyed(), +) +class TaskManager { + public static async createTaskManager({ + db, + handlers = {}, + lazy = false, + activeLimit = Infinity, + logger = new Logger(this.name), + fresh = false, + }: { + db: DB; + handlers?: Record; + lazy?: boolean; + activeLimit?: number; + logger?: Logger; + fresh?: boolean; + }) { + logger.info(`Creating ${this.name}`); + const tasks = new this({ + db, + activeLimit, + logger, + }); + await tasks.start({ + handlers, + lazy, + fresh, + }); + logger.info(`Created ${this.name}`); + return tasks; + } + + protected logger: Logger; + protected schedulerLogger: Logger; + protected queueLogger: Logger; + protected db: DB; + protected handlers: Map = new Map(); + protected activeLimit: number; + protected generateTaskId: () => TaskId; + protected taskPromises: Map> = + new Map(); + protected activePromises: Map> = + new Map(); + protected taskEvents: EventTarget = new EventTarget(); + protected tasksDbPath: LevelPath = [this.constructor.name]; + /** + * Tasks collection + * `Tasks/tasks/{TaskId} -> {json(TaskData)}` + */ + protected tasksTaskDbPath: LevelPath = [...this.tasksDbPath, 'task']; + /** + * Scheduled Tasks + * This is indexed by `TaskId` at the end to avoid conflicts + * `Tasks/scheduled/{lexi(TaskTimestamp + TaskDelay)}/{TaskId} -> null` + */ + protected tasksScheduledDbPath: LevelPath = [ + ...this.tasksDbPath, + 'scheduled', + ]; + /** + * Queued Tasks + * This is indexed by `TaskId` at the end to avoid conflicts + * `Tasks/queued/{lexi(TaskPriority)}/{lexi(TaskTimestamp + TaskDelay)}/{TaskId} -> null` + */ + protected tasksQueuedDbPath: LevelPath = [...this.tasksDbPath, 'queued']; + /** + * Tracks actively running tasks + * `Tasks/active/{TaskId} -> null` + */ + protected tasksActiveDbPath: LevelPath = [...this.tasksDbPath, 'active']; + /** + * Tasks indexed path + * `Tasks/path/{...TaskPath}/{TaskId} -> null` + */ + protected tasksPathDbPath: LevelPath = [...this.tasksDbPath, 'path']; + /** + * Maintain last Task ID to preserve monotonicity across process restarts + * `Tasks/lastTaskId -> {raw(TaskId)}` + */ + protected tasksLastTaskIdPath: KeyPath = [...this.tasksDbPath, 'lastTaskId']; + /** + * Asynchronous scheduling loop + * This is blocked by the `schedulingLock` + * The `null` indicates that the scheduling loop isn't running + */ + protected schedulingLoop: PromiseCancellable | null = null; + /** + * Timer used to unblock the scheduling loop + * This releases the `schedulingLock` if it is locked + * The `null` indicates there is no timer running + */ + protected schedulingTimer: Timer | null = null; + /** + * Lock controls whether to run an iteration of the scheduling loop + */ + protected schedulingLock: Lock = new Lock(); + /** + * Releases the scheduling lock + * On the first iteration of the scheduling loop + * the lock may not be acquired yet, and therefore releaser is not set + */ + protected schedulingLockReleaser?: ResourceRelease; + /** + * Asynchronous queuing loop + * This is blocked by the `queuingLock` + * The `null` indicates that the queuing loop isn't running + */ + protected queuingLoop: PromiseCancellable | null = null; + /** + * Lock controls whether to run an iteration of the queuing loop + */ + protected queuingLock: Lock = new Lock(); + /** + * Releases the queuing lock + * On the first iteration of the queuing loop + * the lock may not be acquired yet, and therefore releaser is not set + */ + protected queuingLockReleaser?: ResourceRelease; + + public get activeCount(): number { + return this.activePromises.size; + } + + public constructor({ + db, + activeLimit, + logger, + }: { + db: DB; + activeLimit: number; + logger: Logger; + }) { + this.logger = logger; + this.schedulerLogger = logger.getChild('scheduler'); + this.queueLogger = logger.getChild('queue'); + this.db = db; + this.activeLimit = Math.max(1, activeLimit); + } + + public async start({ + handlers = {}, + lazy = false, + fresh = false, + }: { + handlers?: Record; + lazy?: boolean; + fresh?: boolean; + } = {}): Promise { + this.logger.info( + `Starting ${this.constructor.name} ${ + lazy ? 'in Lazy Mode' : 'in Eager Mode' + }`, + ); + if (fresh) { + this.handlers.clear(); + await this.db.clear(this.tasksDbPath); + } else { + await this.repairDanglingTasks(); + } + const lastTaskId = await this.getLastTaskId(); + this.generateTaskId = tasksUtils.createTaskIdGenerator(lastTaskId); + for (const taskHandlerId in handlers) { + this.handlers.set( + taskHandlerId as TaskHandlerId, + handlers[taskHandlerId], + ); + } + if (!lazy) { + await this.startProcessing(); + } + this.logger.info(`Started ${this.constructor.name}`); + } + + public async stop() { + this.logger.info(`Stopping ${this.constructor.name}`); + await this.stopProcessing(); + await this.stopTasks(); + this.logger.info(`Stopped ${this.constructor.name}`); + } + + public async destroy() { + this.logger.info(`Destroying ${this.constructor.name}`); + this.handlers.clear(); + await this.db.clear(this.tasksDbPath); + this.logger.info(`Destroyed ${this.constructor.name}`); + } + + /** + * Start scheduling and queuing loop + * This call is idempotent + * Use this when `Tasks` is started in lazy mode + */ + @ready(new tasksErrors.ErrorTaskManagerNotRunning(), false, ['starting']) + public async startProcessing(): Promise { + await Promise.all([this.startScheduling(), this.startQueueing()]); + } + + /** + * Stop the scheduling and queuing loop + * This call is idempotent + */ + @ready(new tasksErrors.ErrorTaskManagerNotRunning(), false, ['stopping']) + public async stopProcessing(): Promise { + await Promise.all([this.stopQueueing(), this.stopScheduling()]); + } + + /** + * Stop the active tasks + * This call is idempotent + */ + @ready(new tasksErrors.ErrorTaskManagerNotRunning(), false, ['stopping']) + public async stopTasks(): Promise { + for (const [, activePromise] of this.activePromises) { + activePromise.cancel(new tasksErrors.ErrorTaskStop()); + } + await Promise.allSettled(this.activePromises.values()); + } + + public getHandler(handlerId: TaskHandlerId): TaskHandler | undefined { + return this.handlers.get(handlerId); + } + + public getHandlers(): Record { + return Object.fromEntries(this.handlers); + } + + public registerHandler(handlerId: TaskHandlerId, handler: TaskHandler) { + this.handlers.set(handlerId, handler); + } + + public deregisterHandler(handlerId: TaskHandlerId) { + this.handlers.delete(handlerId); + } + + @ready(new tasksErrors.ErrorTaskManagerNotRunning(), false, ['starting']) + public async getLastTaskId( + tran?: DBTransaction, + ): Promise { + const lastTaskIdBuffer = await (tran ?? this.db).get( + this.tasksLastTaskIdPath, + true, + ); + if (lastTaskIdBuffer == null) return; + return IdInternal.fromBuffer(lastTaskIdBuffer); + } + + @ready(new tasksErrors.ErrorTaskManagerNotRunning()) + public async getTask( + taskId: TaskId, + lazy: boolean = false, + tran?: DBTransaction, + ): Promise { + if (tran == null) { + return this.db.withTransactionF((tran) => + this.getTask(taskId, lazy, tran), + ); + } + const taskIdBuffer = taskId.toBuffer(); + const taskData = await tran.get([ + ...this.tasksTaskDbPath, + taskIdBuffer, + ]); + if (taskData == null) { + return; + } + let promise: () => PromiseCancellable; + if (lazy) { + promise = () => this.getTaskPromise(taskId); + } else { + const taskPromise = this.getTaskPromise(taskId, tran); + tran.queueFailure((e) => { + taskPromise.cancel(e); + }); + promise = () => taskPromise; + } + const cancel = (reason: any) => this.cancelTask(taskId, reason); + const taskScheduleTime = taskData.timestamp + taskData.delay; + let taskStatus: TaskStatus; + if ( + (await tran.get([...this.tasksActiveDbPath, taskId.toBuffer()])) !== + undefined + ) { + taskStatus = 'active'; + } else if ( + (await tran.get([ + ...this.tasksQueuedDbPath, + utils.lexiPackBuffer(taskData.priority), + utils.lexiPackBuffer(taskScheduleTime), + taskIdBuffer, + ])) !== undefined + ) { + taskStatus = 'queued'; + } else if ( + (await tran.get([ + ...this.tasksScheduledDbPath, + utils.lexiPackBuffer(taskScheduleTime), + taskIdBuffer, + ])) !== undefined + ) { + taskStatus = 'scheduled'; + } + return { + id: taskId, + status: taskStatus!, + promise, + cancel, + handlerId: taskData.handlerId, + parameters: taskData.parameters, + delay: tasksUtils.fromDelay(taskData.delay), + deadline: tasksUtils.fromDeadline(taskData.deadline), + priority: tasksUtils.fromPriority(taskData.priority), + path: taskData.path, + created: new Date(taskData.timestamp), + scheduled: new Date(taskScheduleTime), + }; + } + + @ready(new tasksErrors.ErrorTaskManagerNotRunning()) + public async *getTasks( + order: 'asc' | 'desc' = 'asc', + lazy: boolean = false, + path?: TaskPath, + tran?: DBTransaction, + ): AsyncGenerator { + if (tran == null) { + return yield* this.db.withTransactionG((tran) => + this.getTasks(order, lazy, path, tran), + ); + } + if (path == null) { + for await (const [[taskIdBuffer]] of tran.iterator( + [...this.tasksTaskDbPath], + { values: false, reverse: order !== 'asc' }, + )) { + const taskId = IdInternal.fromBuffer(taskIdBuffer as Buffer); + const task = (await this.getTask(taskId, lazy, tran))!; + yield task; + } + } else { + for await (const [kP] of tran.iterator( + [...this.tasksPathDbPath, ...path], + { values: false, reverse: order !== 'asc' }, + )) { + const taskIdBuffer = kP[kP.length - 1] as Buffer; + const taskId = IdInternal.fromBuffer(taskIdBuffer); + const task = (await this.getTask(taskId, lazy, tran))!; + yield task; + } + } + } + + @ready(new tasksErrors.ErrorTaskManagerNotRunning()) + public getTaskPromise( + taskId: TaskId, + tran?: DBTransaction, + ): PromiseCancellable { + const taskIdEncoded = tasksUtils.encodeTaskId(taskId); + // If the task promise is already running, return the existing promise + // this is because the task promise has a singleton cleanup operation attached + let taskPromiseCancellable = this.taskPromises.get(taskIdEncoded); + if (taskPromiseCancellable != null) return taskPromiseCancellable; + const abortController = new AbortController(); + const taskPromise = new Promise((resolve, reject) => { + // Signals cancellation to the active promise + // the active promise is lazy so the task promise is also lazy + // this means cancellation does not result in eager rejection + const signalHandler = () => + this.cancelTask(taskId, abortController.signal.reason); + const taskListener = (event: TaskEvent) => { + abortController.signal.removeEventListener('abort', signalHandler); + if (event.detail.status === 'success') { + resolve(event.detail.result); + } else { + reject(event.detail.reason); + } + }; + // Event listeners are registered synchronously + // this ensures that dispatched `TaskEvent` will be received + abortController.signal.addEventListener('abort', signalHandler); + this.taskEvents.addEventListener(taskIdEncoded, taskListener, { + once: true, + }); + // The task may not actually exist anymore + // in which case, the task listener will never settle + // Here we concurrently check if the task exists + // if it doesn't, remove all listeners and reject early + void (tran ?? this.db) + .get([...this.tasksTaskDbPath, taskId.toBuffer()]) + .then( + (taskData: TaskData | undefined) => { + if (taskData == null) { + // Rollback the event listeners + this.taskEvents.removeEventListener(taskIdEncoded, taskListener); + abortController.signal.removeEventListener( + 'abort', + signalHandler, + ); + reject(new tasksErrors.ErrorTaskMissing(taskIdEncoded)); + } + }, + (reason) => { + reject(reason); + }, + ); + }).finally(() => { + this.taskPromises.delete(taskIdEncoded); + }); + taskPromiseCancellable = PromiseCancellable.from( + taskPromise, + abortController, + ); + // Empty catch handler to ignore unhandled rejections + taskPromiseCancellable.catch(() => {}); + this.taskPromises.set(taskIdEncoded, taskPromiseCancellable); + return taskPromiseCancellable; + } + + /** + * Schedules a task + * If `this.schedulingLoop` isn't running, then this will not + * attempt to reset the `this.schedulingTimer` + */ + @ready(new tasksErrors.ErrorTaskManagerNotRunning()) + public async scheduleTask( + { + handlerId, + parameters = [], + delay = 0, + deadline = Infinity, + priority = 0, + path = [], + lazy = false, + }: { + handlerId: TaskHandlerId; + parameters?: TaskParameters; + delay?: number; + deadline?: number; + priority?: number; + path?: TaskPath; + lazy?: boolean; + }, + tran?: DBTransaction, + ): Promise { + if (tran == null) { + return this.db.withTransactionF((tran) => + this.scheduleTask( + { + handlerId, + parameters, + delay, + priority, + deadline, + path, + lazy, + }, + tran, + ), + ); + } + await this.lockLastTaskId(tran); + const taskId = this.generateTaskId(); + const taskIdEncoded = tasksUtils.encodeTaskId(taskId); + this.logger.debug( + `Scheduling Task ${taskIdEncoded} with handler \`${handlerId}\``, + ); + const taskIdBuffer = taskId.toBuffer(); + // Timestamp extracted from `IdSortable` is a floating point in seconds + // with subsecond fractionals, multiply it by 1000 gives us milliseconds + const taskTimestamp = Math.trunc(extractTs(taskId) * 1000) as TaskTimestamp; + const taskPriority = tasksUtils.toPriority(priority); + const taskDelay = tasksUtils.toDelay(delay); + const taskDeadline = tasksUtils.toDeadline(deadline); + const taskScheduleTime = taskTimestamp + taskDelay; + const taskData: TaskData = { + handlerId, + parameters, + timestamp: taskTimestamp, + priority: taskPriority, + delay: taskDelay, + deadline: taskDeadline, + path, + }; + // Saving the task + await tran.put([...this.tasksTaskDbPath, taskIdBuffer], taskData); + // Saving last task ID + await tran.put(this.tasksLastTaskIdPath, taskIdBuffer, true); + // Putting task into scheduled index + await tran.put( + [ + ...this.tasksScheduledDbPath, + utils.lexiPackBuffer(taskScheduleTime), + taskIdBuffer, + ], + null, + ); + // Putting the task into the path index + await tran.put([...this.tasksPathDbPath, ...path, taskIdBuffer], null); + // Transaction success triggers timer interception + tran.queueSuccess(() => { + // If the scheduling loop is not set then the `Tasks` system was created + // in lazy mode or the scheduling loop was explicitly stopped in either + // case, we do not attempt to intercept the scheduling timer + if (this.schedulingLoop != null) { + this.triggerScheduling(taskScheduleTime); + } + }); + let promise: () => PromiseCancellable; + if (lazy) { + promise = () => this.getTaskPromise(taskId); + } else { + const taskPromise = this.getTaskPromise(taskId, tran); + tran.queueFailure((e) => { + taskPromise.cancel(e); + }); + promise = () => taskPromise; + } + const cancel = (reason: any) => this.cancelTask(taskId, reason); + this.logger.debug( + `Scheduled Task ${taskIdEncoded} with handler \`${handlerId}\``, + ); + return { + id: taskId, + status: 'scheduled', + promise, + cancel, + handlerId, + parameters, + delay: tasksUtils.fromDelay(taskDelay), + deadline: tasksUtils.fromDeadline(taskDeadline), + priority: tasksUtils.fromPriority(taskPriority), + path, + created: new Date(taskTimestamp), + scheduled: new Date(taskScheduleTime), + }; + } + + @ready(new tasksErrors.ErrorTaskManagerNotRunning()) + public async updateTask( + taskId: TaskId, + taskPatch: Partial<{ + handlerId: TaskHandlerId; + parameters: TaskParameters; + delay: number; + deadline: number; + priority: number; + path: TaskPath; + }>, + tran?: DBTransaction, + ): Promise { + if (tran == null) { + return this.db.withTransactionF((tran) => + this.updateTask(taskId, taskPatch, tran), + ); + } + // Copy the patch POJO to avoid parameter mutation + const taskDataPatch = { ...taskPatch }; + if (taskDataPatch.delay != null) { + taskDataPatch.delay = tasksUtils.toDelay(taskDataPatch.delay); + } + if (taskDataPatch.deadline != null) { + taskDataPatch.deadline = tasksUtils.toDeadline(taskDataPatch.deadline); + } + if (taskDataPatch.priority != null) { + taskDataPatch.priority = tasksUtils.toPriority(taskDataPatch.priority); + } + await this.lockTask(tran, taskId); + const taskIdBuffer = taskId.toBuffer(); + const taskIdEncoded = tasksUtils.encodeTaskId(taskId); + const taskData = await tran.get([ + ...this.tasksTaskDbPath, + taskIdBuffer, + ]); + if (taskData == null) { + throw new tasksErrors.ErrorTaskMissing(taskIdEncoded); + } + if ( + (await tran.get([ + ...this.tasksScheduledDbPath, + utils.lexiPackBuffer(taskData.timestamp + taskData.delay), + taskIdBuffer, + ])) === undefined + ) { + // Cannot update the task if the task is already running + throw new tasksErrors.ErrorTaskRunning(taskIdEncoded); + } + const taskDataNew = { + ...taskData, + ...taskDataPatch, + }; + // Save updated task + await tran.put([...this.tasksTaskDbPath, taskIdBuffer], taskDataNew); + // Update the path index + if (taskDataPatch.path != null) { + await tran.del([...this.tasksPathDbPath, ...taskData.path, taskIdBuffer]); + await tran.put( + [...this.tasksPathDbPath, ...taskDataPatch.path, taskIdBuffer], + true, + ); + } + // Update the schedule time and trigger scheduling if delay is updated + if (taskDataPatch.delay != null) { + const taskScheduleTime = taskData.timestamp + taskData.delay; + const taskScheduleTimeNew = taskData.timestamp + taskDataPatch.delay; + await tran.del([ + ...this.tasksScheduledDbPath, + utils.lexiPackBuffer(taskScheduleTime), + taskIdBuffer, + ]); + await tran.put( + [ + ...this.tasksScheduledDbPath, + utils.lexiPackBuffer(taskScheduleTimeNew), + taskIdBuffer, + ], + null, + ); + tran.queueSuccess(async () => { + if (this.schedulingLoop != null) { + this.triggerScheduling(taskScheduleTimeNew); + } + }); + } + } + + /** + * Transition tasks from `scheduled` to `queued` + */ + protected async startScheduling() { + if (this.schedulingLoop != null) return; + this.schedulerLogger.info('Starting Scheduling Loop'); + const abortController = new AbortController(); + const abortP = utils.signalPromise(abortController.signal); + // First iteration must run + if (this.schedulingLockReleaser != null) { + await this.schedulingLockReleaser(); + } + const schedulingLoop = (async () => { + try { + while (!abortController.signal.aborted) { + // Blocks the scheduling loop until lock is released + // this ensures that each iteration of the loop is only + // run when it is required + try { + await Promise.race([this.schedulingLock.waitForUnlock(), abortP]); + } catch (e) { + if (e === abortSchedulingLoopReason) { + break; + } else { + throw e; + } + } + this.schedulerLogger.debug(`Begin scheduling loop iteration`); + [this.schedulingLockReleaser] = await this.schedulingLock.lock()(); + // Peek ahead by 100 ms in-order to prefetch some tasks + const now = + Math.trunc(performance.timeOrigin + performance.now()) + 100; + await this.db.withTransactionF(async (tran) => { + // Queue up all the tasks that are scheduled to be executed before `now` + for await (const [kP] of tran.iterator(this.tasksScheduledDbPath, { + // Upper bound of `{lexi(TaskTimestamp + TaskDelay)}/{TaskId}` + // notice the usage of `''` as the upper bound of `TaskId` + lte: [utils.lexiPackBuffer(now), ''], + values: false, + })) { + if (abortController.signal.aborted) return; + const taskIdBuffer = kP[1] as Buffer; + const taskId = IdInternal.fromBuffer(taskIdBuffer); + // If the task gets cancelled here, then queuing must be a noop + await this.queueTask(taskId); + } + }); + if (abortController.signal.aborted) break; + await this.db.withTransactionF(async (tran) => { + // Get the next task to be scheduled and set the timer accordingly + let nextScheduleTime: number | undefined; + for await (const [kP] of tran.iterator(this.tasksScheduledDbPath, { + limit: 1, + values: false, + })) { + nextScheduleTime = utils.lexiUnpackBuffer(kP[0] as Buffer); + } + if (abortController.signal.aborted) return; + if (nextScheduleTime == null) { + this.logger.debug( + 'Scheduling loop iteration found no more scheduled tasks', + ); + } else { + this.triggerScheduling(nextScheduleTime); + } + this.schedulerLogger.debug('Finish scheduling loop iteration'); + }); + } + } catch (e) { + this.schedulerLogger.error(`Failed scheduling loop ${String(e)}`); + throw new tasksErrors.ErrorTaskManagerScheduler(undefined, { + cause: e, + }); + } + })(); + this.schedulingLoop = PromiseCancellable.from( + schedulingLoop, + abortController, + ); + this.schedulerLogger.info('Started Scheduling Loop'); + } + + protected async stopScheduling(): Promise { + if (this.schedulingLoop == null) return; + this.logger.info('Stopping Scheduling Loop'); + // Cancel the timer if it exists + this.schedulingTimer?.cancel(); + this.schedulingTimer = null; + // Cancel the scheduling loop + this.schedulingLoop.cancel(abortSchedulingLoopReason); + // Wait for the cancellation signal to resolve the promise + await this.schedulingLoop; + // Indicates that the loop is no longer running + this.schedulingLoop = null; + this.logger.info('Stopped Scheduling Loop'); + } + + protected async startQueueing() { + if (this.queuingLoop != null) return; + this.queueLogger.info('Starting Queueing Loop'); + const abortController = new AbortController(); + const abortP = utils.signalPromise(abortController.signal); + // First iteration must run + if (this.queuingLockReleaser != null) await this.queuingLockReleaser(); + const queuingLoop = (async () => { + try { + while (!abortController.signal.aborted) { + try { + await Promise.race([this.queuingLock.waitForUnlock(), abortP]); + } catch (e) { + if (e === abortQueuingLoopReason) { + break; + } else { + throw e; + } + } + this.queueLogger.debug(`Begin queuing loop iteration`); + [this.queuingLockReleaser] = await this.queuingLock.lock()(); + await this.db.withTransactionF(async (tran) => { + for await (const [kP] of tran.iterator(this.tasksQueuedDbPath, { + values: false, + })) { + if (abortController.signal.aborted) break; + if (this.activePromises.size >= this.activeLimit) break; + const taskId = IdInternal.fromBuffer(kP[2] as Buffer); + await this.startTask(taskId); + } + }); + this.queueLogger.debug(`Finish queuing loop iteration`); + } + } catch (e) { + this.queueLogger.error(`Failed queuing loop ${String(e)}`); + throw new tasksErrors.ErrorTaskManagerQueue(undefined, { cause: e }); + } + })(); + // Cancellation is always a resolution + // the promise must resolve, by waiting for resolution + // it's graceful termination of the loop + this.queuingLoop = PromiseCancellable.from(queuingLoop, abortController); + this.queueLogger.info('Started Queueing Loop'); + } + + protected async stopQueueing() { + if (this.queuingLoop == null) return; + this.logger.info('Stopping Queuing Loop'); + this.queuingLoop.cancel(abortQueuingLoopReason); + await this.queuingLoop; + this.queuingLoop = null; + this.logger.info('Stopped Queuing Loop'); + } + + /** + * Triggers the scheduler on a delayed basis + * If the delay is 0, the scheduler is triggered immediately + * The scheduling timer is a singleton that can be set by both + * `this.schedulingLoop` and `this.scheduleTask` + * This ensures that the timer is set to the earliest scheduled task + */ + protected triggerScheduling(scheduleTime: number) { + if (this.schedulingTimer != null) { + if (scheduleTime >= this.schedulingTimer.scheduled!.getTime()) return; + this.schedulingTimer.cancel(); + this.schedulingTimer = null; + } + const now = Math.trunc(performance.timeOrigin + performance.now()); + const delay = Math.max(scheduleTime - now, 0); + if (delay === 0) { + this.schedulerLogger.debug( + `Setting scheduling loop iteration immediately (delay: ${delay} ms)`, + ); + this.schedulingTimer = null; + if (this.schedulingLockReleaser != null) { + void this.schedulingLockReleaser(); + } + } else { + this.schedulerLogger.debug( + `Setting scheduling loop iteration for ${new Date( + scheduleTime, + ).toISOString()} (delay: ${delay} ms)`, + ); + this.schedulingTimer = new Timer(() => { + this.schedulingTimer = null; + if (this.schedulingLockReleaser != null) { + void this.schedulingLockReleaser(); + } + }, delay); + } + } + + /** + * Same idea as triggerScheduling + * But this time unlocking the queue to proceed + * If already unlocked, subsequent unlocking is idempotent + * The unlocking of the scheduling is delayed + * Whereas this unlocking is not + * Remember the queuing just keeps running until finished + */ + protected triggerQueuing() { + if (this.activePromises.size >= this.activeLimit) return; + if (this.queuingLockReleaser != null) { + void this.queuingLockReleaser(); + } + } + + /** + * Transition from scheduled to queued + * If the task is cancelled, then this does nothing + */ + protected async queueTask(taskId: TaskId): Promise { + const taskIdBuffer = taskId.toBuffer(); + const taskIdEncoded = tasksUtils.encodeTaskId(taskId); + this.schedulerLogger.debug(`Queuing Task ${taskIdEncoded}`); + await this.db.withTransactionF(async (tran) => { + // Mutually exclude `this.updateTask` and `this.gcTask` + await this.lockTask(tran, taskId); + const taskData = await tran.get([ + ...this.tasksTaskDbPath, + taskIdBuffer, + ]); + // If the task was garbage collected, due to potentially cancellation + // then we can skip the task, as it no longer exists + if (taskData == null) { + this.schedulerLogger.debug( + `Skipped Task ${taskIdEncoded} - it is cancelled`, + ); + return; + } + // Remove task from the scheduled index + await tran.del([ + ...this.tasksScheduledDbPath, + utils.lexiPackBuffer(taskData.timestamp + taskData.delay), + taskIdBuffer, + ]); + // Put task into the queue index + await tran.put( + [ + ...this.tasksQueuedDbPath, + utils.lexiPackBuffer(taskData.priority), + utils.lexiPackBuffer(taskData.timestamp + taskData.delay), + taskIdBuffer, + ], + null, + ); + tran.queueSuccess(() => { + this.triggerQueuing(); + }); + }); + this.schedulerLogger.debug(`Queued Task ${taskIdEncoded}`); + } + + /** + * Transition from queued to active + * If the task is cancelled, then this does nothing + */ + protected async startTask(taskId: TaskId): Promise { + const taskIdBuffer = taskId.toBuffer(); + const taskIdEncoded = tasksUtils.encodeTaskId(taskId); + this.queueLogger.debug(`Starting Task ${taskIdEncoded}`); + await this.db.withTransactionF(async (tran) => { + await this.lockTask(tran, taskId); + const taskData = await tran.get([ + ...this.tasksTaskDbPath, + taskIdBuffer, + ]); + // If the task was garbage collected, due to potentially cancellation + // then we can skip the task, as it no longer exists + if (taskData == null) { + this.queueLogger.debug( + `Skipped Task ${taskIdEncoded} - it is cancelled`, + ); + return; + } + const taskHandler = this.getHandler(taskData.handlerId); + if (taskHandler == null) { + this.queueLogger.error( + `Failed Task ${taskIdEncoded} - No Handler Registered`, + ); + await this.gcTask(taskId, tran); + tran.queueSuccess(() => { + // THIS only runs after the transaction is committed + // IS IT POSSIBLE + // that I HAVE REGISTERED EVENT HANDLERS is at there + // cause if so, it would then be able to + // to get an event listener registered + // only afterwards + + this.taskEvents.dispatchEvent( + new TaskEvent(taskIdEncoded, { + detail: { + status: 'failure', + reason: new tasksErrors.ErrorTaskHandlerMissing(), + }, + }), + ); + }); + return; + } + // Remove task from the queued index + await tran.del([ + ...this.tasksQueuedDbPath, + utils.lexiPackBuffer(taskData.priority), + utils.lexiPackBuffer(taskData.timestamp + taskData.delay), + taskIdBuffer, + ]); + // Put task into the active index + // this index will be used to retry tasks if they don't finish + await tran.put([...this.tasksActiveDbPath, taskIdBuffer], null); + tran.queueSuccess(() => { + const abortController = new AbortController(); + const timeoutError = new tasksErrors.ErrorTaskTimeOut(); + const timer = new Timer( + () => void abortController.abort(timeoutError), + tasksUtils.fromDeadline(taskData.deadline), + ); + const ctx = { + timer, + signal: abortController.signal, + }; + const activePromise = (async () => { + const taskLogger = this.logger.getChild(`task ${taskIdEncoded}`); + try { + let succeeded: boolean; + let taskResult: any; + let taskReason: any; + const taskInfo: TaskInfo = { + id: taskId, + handlerId: taskData.handlerId, + parameters: taskData.parameters, + delay: tasksUtils.fromDelay(taskData.delay), + priority: tasksUtils.fromPriority(taskData.priority), + deadline: tasksUtils.fromDeadline(taskData.deadline), + path: taskData.path, + created: new Date(taskData.timestamp), + scheduled: new Date(taskData.timestamp + taskData.delay), + }; + try { + taskResult = await taskHandler( + ctx, + taskInfo, + ...taskData.parameters, + ); + succeeded = true; + } catch (e) { + taskReason = e; + succeeded = false; + } + // If the reason is `tasksErrors.ErrorTaskRetry` + // the task is not finished, and should be requeued + if (taskReason instanceof tasksErrors.ErrorTaskRetry) { + try { + await this.requeueTask(taskId); + } catch (e) { + this.logger.error(`Failed Requeuing Task ${taskIdEncoded}`); + // This is an unrecoverable error + throw new tasksErrors.ErrorTaskRequeue(taskIdEncoded, { + cause: e, + }); + } + } else { + if (succeeded) { + taskLogger.debug('Succeeded'); + } else { + taskLogger.warn(`Failed - Reason: ${String(taskReason)}`); + } + // GC the task before dispatching events + try { + await this.gcTask(taskId); + } catch (e) { + this.logger.error( + `Failed Garbage Collecting Task ${taskIdEncoded}`, + ); + // This is an unrecoverable error + throw new tasksErrors.ErrorTaskGarbageCollection( + taskIdEncoded, + { cause: e }, + ); + } + if (succeeded) { + this.taskEvents.dispatchEvent( + new TaskEvent(taskIdEncoded, { + detail: { + status: 'success', + result: taskResult, + }, + }), + ); + } else { + this.taskEvents.dispatchEvent( + new TaskEvent(taskIdEncoded, { + detail: { + status: 'failure', + reason: taskReason, + }, + }), + ); + } + } + } finally { + // Task has finished, cancel the timer + timer.cancel(); + // Remove from active promises + this.activePromises.delete(taskIdEncoded); + // Slot has opened up, trigger queueing + this.triggerQueuing(); + } + })(); + // This will be a lazy `PromiseCancellable` + const activePromiseCancellable = PromiseCancellable.from( + activePromise, + abortController, + ); + this.activePromises.set(taskIdEncoded, activePromiseCancellable); + this.queueLogger.debug(`Started Task ${taskIdEncoded}`); + }); + }); + } + + /** + * This is used to garbage collect tasks that have settled + * Explicit removal of tasks can only be done through task cancellation + */ + protected async gcTask(taskId: TaskId, tran?: DBTransaction): Promise { + if (tran == null) { + return this.db.withTransactionF((tran) => this.gcTask(taskId, tran)); + } + const taskIdEncoded = tasksUtils.encodeTaskId(taskId); + const taskIdBuffer = taskId.toBuffer(); + await this.lockTask(tran, taskId); + const taskData = await tran.get([ + ...this.tasksTaskDbPath, + taskId.toBuffer(), + ]); + if (taskData == null) return; + this.logger.debug(`Garbage Collecting Task ${taskIdEncoded}`); + const taskScheduleTime = taskData.timestamp + taskData.delay; + await tran.del([ + ...this.tasksPathDbPath, + ...taskData.path, + taskId.toBuffer(), + ]); + await tran.del([...this.tasksActiveDbPath, taskId.toBuffer()]); + await tran.del([ + ...this.tasksQueuedDbPath, + utils.lexiPackBuffer(taskData.priority), + utils.lexiPackBuffer(taskScheduleTime), + taskIdBuffer, + ]); + await tran.del([ + ...this.tasksScheduledDbPath, + utils.lexiPackBuffer(taskScheduleTime), + taskIdBuffer, + ]); + await tran.del([...this.tasksTaskDbPath, taskId.toBuffer()]); + this.logger.debug(`Garbage Collected Task ${taskIdEncoded}`); + } + + protected async requeueTask( + taskId: TaskId, + tran?: DBTransaction, + ): Promise { + if (tran == null) { + return this.db.withTransactionF((tran) => this.requeueTask(taskId, tran)); + } + const taskIdBuffer = taskId.toBuffer(); + const taskIdEncoded = tasksUtils.encodeTaskId(taskId); + this.logger.debug(`Requeuing Task ${taskIdEncoded}`); + await this.lockTask(tran, taskId); + const taskData = await tran.get([ + ...this.tasksTaskDbPath, + taskIdBuffer, + ]); + if (taskData == null) { + throw new tasksErrors.ErrorTaskMissing(taskIdEncoded); + } + // Put task into the active index + // this index will be used to retry tasks if they don't finish + await tran.del([...this.tasksActiveDbPath, taskIdBuffer]); + // Put task back into the queued index + await tran.put( + [ + ...this.tasksQueuedDbPath, + utils.lexiPackBuffer(taskData.priority), + utils.lexiPackBuffer(taskData.timestamp + taskData.delay), + taskIdBuffer, + ], + null, + ); + this.logger.debug(`Requeued Task ${taskIdEncoded}`); + } + + protected async cancelTask(taskId: TaskId, cancelReason: any): Promise { + const taskIdEncoded = tasksUtils.encodeTaskId(taskId); + this.logger.debug(`Cancelling Task ${taskIdEncoded}`); + const activePromise = this.activePromises.get(taskIdEncoded); + if (activePromise != null) { + // If the active promise exists, then we only signal for cancellation + // the active promise will clean itself up when it settles + activePromise.cancel(cancelReason); + } else { + try { + await this.gcTask(taskId); + } catch (e) { + this.logger.error( + `Failed Garbage Collecting Task ${taskIdEncoded} - ${String(e)}`, + ); + // This is an unrecoverable error + throw new tasksErrors.ErrorTaskGarbageCollection(taskIdEncoded, { + cause: e, + }); + } + this.taskEvents.dispatchEvent( + new TaskEvent(taskIdEncoded, { + detail: { + status: 'failure', + reason: cancelReason, + }, + }), + ); + } + this.logger.debug(`Cancelled Task ${taskIdEncoded}`); + } + + /** + * Mutually exclude last task ID mutation + * Prevents "counter racing" for the last task ID + */ + protected async lockLastTaskId(tran: DBTransaction): Promise { + return tran.lock(this.tasksLastTaskIdPath.join('')); + } + + /** + * Mutual exclusion for task mutation + * Used to lock: + * - `this.updateTask` + * - `this.queueTask` + * - `this.startTask` + * - `this.gcTask` + * - `this.requeueTask` + */ + protected async lockTask(tran: DBTransaction, taskId: TaskId): Promise { + return tran.lock([...this.tasksDbPath, taskId.toString()].join('')); + } + + /** + * If the process was killed ungracefully then we may need to + * repair active dangling tasks by moving them back to the queued index + */ + protected async repairDanglingTasks() { + await this.db.withTransactionF(async (tran) => { + this.logger.info('Begin Tasks Repair'); + // Move tasks from active to queued + // these tasks will be retried + for await (const [kP] of tran.iterator(this.tasksActiveDbPath, { + values: false, + })) { + const taskIdBuffer = kP[0] as Buffer; + const taskId = IdInternal.fromBuffer(taskIdBuffer); + const taskIdEncoded = tasksUtils.encodeTaskId(taskId); + const taskData = await tran.get([ + ...this.tasksTaskDbPath, + taskIdBuffer, + ]); + if (taskData == null) { + // Removing dangling task from active index + // this should not happen + await tran.del([...this.tasksActiveDbPath, ...kP]); + this.logger.warn(`Removing Dangling Active Task ${taskIdEncoded}`); + } else { + // Put task back into the queue index + await tran.put( + [ + ...this.tasksQueuedDbPath, + utils.lexiPackBuffer(taskData.priority), + utils.lexiPackBuffer(taskData.timestamp + taskData.delay), + taskIdBuffer, + ], + null, + ); + // Removing task from active index + await tran.del([...this.tasksActiveDbPath, ...kP]); + this.logger.warn( + `Moving Task ${taskIdEncoded} from Active to Queued`, + ); + } + } + this.logger.info('Finish Tasks Repair'); + }); + } +} + +export default TaskManager; diff --git a/src/tasks/errors.ts b/src/tasks/errors.ts new file mode 100644 index 000000000..601eaf223 --- /dev/null +++ b/src/tasks/errors.ts @@ -0,0 +1,118 @@ +import { ErrorPolykey, sysexits } from '../errors'; + +class ErrorTasks extends ErrorPolykey {} + +class ErrorTaskManagerRunning extends ErrorTasks { + static description = 'TaskManager is running'; + exitCode = sysexits.USAGE; +} + +class ErrorTaskManagerNotRunning extends ErrorTasks { + static description = 'TaskManager is not running'; + exitCode = sysexits.USAGE; +} + +class ErrorTaskManagerDestroyed extends ErrorTasks { + static description = 'TaskManager is destroyed'; + exitCode = sysexits.USAGE; +} + +/** + * This is an unrecoverable error + */ +class ErrorTaskManagerScheduler extends ErrorTasks { + static description = + 'TaskManager scheduling loop encountered an unrecoverable error'; + exitCode = sysexits.SOFTWARE; +} + +/** + * This is an unrecoverable error + */ +class ErrorTaskManagerQueue extends ErrorTasks { + static description = + 'TaskManager queuing loop encountered an unrecoverable error'; + exitCode = sysexits.SOFTWARE; +} + +class ErrorTask extends ErrorTasks { + static description = 'Task error'; + exitCode = sysexits.USAGE; +} + +class ErrorTaskMissing extends ErrorTask { + static description = + 'Task does not (or never) existed anymore, it may have been fulfilled or cancelled'; + exitCode = sysexits.UNAVAILABLE; +} + +class ErrorTaskHandlerMissing extends ErrorTask { + static description = 'Task handler is not registered'; + exitCode = sysexits.UNAVAILABLE; +} + +class ErrorTaskRunning extends ErrorTask { + static description = 'Task is running, it cannot be updated'; + exitCode = sysexits.USAGE; +} + +/** + * This is used as a signal reason when the `TaskDeadline` is reached + */ +class ErrorTaskTimeOut extends ErrorTask { + static description = 'Task exhausted deadline'; + exitCode = sysexits.UNAVAILABLE; +} + +/** + * This is used as a signal reason when calling `TaskManager.stopTasks()` + * If the task should be retried, then the task handler should throw `ErrorTaskRetry` + */ +class ErrorTaskStop extends ErrorTask { + static description = 'TaskManager is stopping, task is being cancelled'; + exitCode = sysexits.OK; +} + +/** + * If this is thrown by the task, the task will be requeued so it can be + * retried, if the task rejects or resolves in any other way, the task + * will be considered to have completed + */ +class ErrorTaskRetry extends ErrorTask { + static description = 'Task should be retried'; + exitCode = sysexits.TEMPFAIL; +} + +/** + * This error indicates a bug + */ +class ErrorTaskRequeue extends ErrorTask { + static description = 'Task could not be requeued'; + exitCode = sysexits.SOFTWARE; +} + +/** + * This error indicates a bug + */ +class ErrorTaskGarbageCollection extends ErrorTask { + static description = 'Task could not be garbage collected'; + exitCode = sysexits.SOFTWARE; +} + +export { + ErrorTasks, + ErrorTaskManagerRunning, + ErrorTaskManagerNotRunning, + ErrorTaskManagerDestroyed, + ErrorTaskManagerScheduler, + ErrorTaskManagerQueue, + ErrorTask, + ErrorTaskMissing, + ErrorTaskHandlerMissing, + ErrorTaskRunning, + ErrorTaskTimeOut, + ErrorTaskStop, + ErrorTaskRetry, + ErrorTaskRequeue, + ErrorTaskGarbageCollection, +}; diff --git a/src/tasks/index.ts b/src/tasks/index.ts new file mode 100644 index 000000000..11ffc0c80 --- /dev/null +++ b/src/tasks/index.ts @@ -0,0 +1,4 @@ +export { default as TaskManager } from './TaskManager'; +export * as types from './types'; +export * as utils from './utils'; +export * as errors from './errors'; diff --git a/src/tasks/types.ts b/src/tasks/types.ts new file mode 100644 index 000000000..0789d078e --- /dev/null +++ b/src/tasks/types.ts @@ -0,0 +1,121 @@ +import type { Id } from '@matrixai/id'; +import type { PromiseCancellable } from '@matrixai/async-cancellable'; +import type { Opaque } from '../types'; +import type { ContextTimed } from '../contexts/types'; + +type TaskHandlerId = Opaque<'TaskHandlerId', string>; + +type TaskHandler = ( + ctx: ContextTimed, + taskInfo: TaskInfo, + ...params: TaskParameters +) => PromiseLike; + +type TaskId = Opaque<'TaskId', Id>; +type TaskIdEncoded = Opaque<'TaskIdEncoded', string>; + +/** + * Task POJO returned to the user + */ +type Task = { + id: TaskId; + status: TaskStatus; + promise: () => PromiseCancellable; + cancel: (reason: any) => void; + handlerId: TaskHandlerId; + parameters: TaskParameters; + delay: number; + priority: number; + deadline: number; + path: TaskPath; + created: Date; + scheduled: Date; +}; + +/** + * Task data decoded for the task handler + */ +type TaskInfo = Omit; + +/** + * Task data that will be encoded into JSON for persistence + */ +type TaskData = { + handlerId: TaskHandlerId; + parameters: TaskParameters; + timestamp: TaskTimestamp; + delay: TaskDelay; + deadline: TaskDeadline; + priority: TaskPriority; + path: TaskPath; +}; + +/** + * Task state machine diagram + * ┌───────────┐ + * │ │ + * ───────► Scheduled │ + * │ │ + * └─────┬─────┘ + * ┌─────▼─────┐ + * │ │ + * │ Queued │ + * │ │ + * └─────┬─────┘ + * ┌─────▼─────┐ + * │ │ + * │ Active │ + * │ │ + * └───────────┘ + */ +type TaskStatus = 'scheduled' | 'queued' | 'active'; + +/** + * Task parameters + */ +type TaskParameters = Array; + +/** + * Timestamp unix time in milliseconds + */ +type TaskTimestamp = Opaque<'TaskTimestamp', number>; + +/** + * Timestamp milliseconds is a number between 0 and maximum timeout + * It is not allowed for there to be an infinite delay + */ +type TaskDelay = Opaque<'TaskDelay', number>; + +/** + * Deadline milliseconds is a number between 0 and maximum timeout + * or it can be `null` to indicate `Infinity` + */ +type TaskDeadline = Opaque<'TaskDeadline', number | null>; + +/** + * Task priority is an `uint8` [0 to 255] + * Where `0` is the highest priority and `255` is the lowest priority + */ +type TaskPriority = Opaque<'TaskPriority', number>; + +/** + * Task Path, a LevelPath + */ +type TaskPath = Array; + +export type { + TaskHandlerId, + TaskHandler, + TaskId, + TaskIdEncoded, + Task, + TaskInfo, + TaskData, + TaskStatus, + TaskParameters, + TaskTimestamp, + TaskDelay, + TaskDeadline, + TaskPriority, + TaskPath, +}; diff --git a/src/tasks/utils.ts b/src/tasks/utils.ts new file mode 100644 index 000000000..da179a0ce --- /dev/null +++ b/src/tasks/utils.ts @@ -0,0 +1,129 @@ +import type { + TaskId, + TaskIdEncoded, + TaskPriority, + TaskDelay, + TaskDeadline, +} from './types'; +import { IdInternal, IdSortable } from '@matrixai/id'; + +/** + * Generates TaskId + * TaskIds are lexicographically sortable 128 bit IDs + * They are strictly monotonic and unique with respect to the `nodeId` + * When the `NodeId` changes, make sure to regenerate this generator + */ +function createTaskIdGenerator(lastTaskId?: TaskId) { + const generator = new IdSortable({ + lastId: lastTaskId, + }); + return () => generator.get(); +} + +/** + * Encodes the TaskId as a `base32hex` string + */ +function encodeTaskId(taskId: TaskId): TaskIdEncoded { + return taskId.toMultibase('base32hex') as TaskIdEncoded; +} + +/** + * Decodes an encoded TaskId string into a TaskId + */ +function decodeTaskId(taskIdEncoded: any): TaskId | undefined { + if (typeof taskIdEncoded !== 'string') { + return; + } + const taskId = IdInternal.fromMultibase(taskIdEncoded); + if (taskId == null) { + return; + } + // All TaskIds are 16 bytes long + if (taskId.length !== 16) { + return; + } + return taskId; +} + +/** + * Encodes delay milliseconds + */ +function toDelay(delay: number): TaskDelay { + if (isNaN(delay)) { + delay = 0; + } else { + delay = Math.max(delay, 0); + delay = Math.min(delay, 2 ** 31 - 1); + } + return delay as TaskDelay; +} + +/** + * Decodes task delay + */ +function fromDelay(taskDelay: TaskDelay): number { + return taskDelay; +} + +/** + * Encodes deadline milliseconds + * If deadline is `Infinity`, it is encoded as `null` + * If deadline is `NaN, it is encoded as `0` + */ +function toDeadline(deadline: number): TaskDeadline { + let taskDeadline: number | null; + if (isNaN(deadline)) { + taskDeadline = 0; + } else { + taskDeadline = Math.max(deadline, 0); + // Infinity is converted to `null` because `Infinity` is not supported in JSON + if (!isFinite(taskDeadline)) taskDeadline = null; + } + return taskDeadline as TaskDeadline; +} + +/** + * Decodes task deadline + * If task deadline is `null`, it is decoded as `Infinity` + */ +function fromDeadline(taskDeadline: TaskDeadline): number { + if (taskDeadline == null) return Infinity; + return taskDeadline; +} + +/** + * Converts `int8` to flipped `uint8` task priority + * Clips number to between -128 to 127 inclusive + */ +function toPriority(n: number): TaskPriority { + if (isNaN(n)) n = 0; + n = Math.min(n, 127); + n = Math.max(n, -128); + n *= -1; + n -= 1; + n += 128; + return n as TaskPriority; +} + +/** + * Converts flipped `uint8` task priority to `int8` + */ +function fromPriority(p: TaskPriority): number { + let n = p - 128; + n += 1; + // Prevent returning `-0` + if (n !== 0) n *= -1; + return n; +} + +export { + createTaskIdGenerator, + encodeTaskId, + decodeTaskId, + toDelay, + fromDelay, + toDeadline, + fromDeadline, + toPriority, + fromPriority, +}; diff --git a/src/types.ts b/src/types.ts index d0d73eef5..216f4fc49 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,11 @@ interface ToString { toString(): string; } +/** + * Recursive readonly + */ +type DeepReadonly = { readonly [K in keyof T]: DeepReadonly }; + /** * Wrap a type to be reference counted * Useful for when we need to garbage collect data @@ -122,6 +127,7 @@ export type { Initial, InitialParameters, ToString, + DeepReadonly, Ref, Timer, PromiseDeconstructed, diff --git a/src/utils/debug.ts b/src/utils/debug.ts new file mode 100644 index 000000000..a2c83fbef --- /dev/null +++ b/src/utils/debug.ts @@ -0,0 +1,29 @@ +function isPrintableASCII(str: string): boolean { + return /^[\x20-\x7E]*$/.test(str); +} + +/** + * Used for debugging DB dumps + */ +function inspectBufferStructure(obj: any): any { + if (obj instanceof Buffer) { + const str = obj.toString('utf8'); + if (isPrintableASCII(str)) { + return str; + } else { + return '0x' + obj.toString('hex'); + } + } else if (Array.isArray(obj)) { + return obj.map(inspectBufferStructure); + } else if (typeof obj === 'object') { + const obj_: any = {}; + for (const k in obj) { + obj_[k] = inspectBufferStructure(obj[k]); + } + return obj_; + } else { + return obj; + } +} + +export { isPrintableASCII, inspectBufferStructure }; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index f7c904194..0d5fdf553 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -7,8 +7,13 @@ import type { import os from 'os'; import process from 'process'; import path from 'path'; +import lexi from 'lexicographic-integer'; import * as utilsErrors from './errors'; +const AsyncFunction = (async () => {}).constructor; +const GeneratorFunction = function* () {}.constructor; +const AsyncGeneratorFunction = async function* () {}.constructor; + function getDefaultNodePath(): string | undefined { const prefix = 'polykey'; const platform = os.platform(); @@ -81,8 +86,8 @@ function pathIncludes(p1: string, p2: string): boolean { ); } -async function sleep(ms: number) { - return await new Promise((r) => setTimeout(r, ms)); +async function sleep(ms: number): Promise { + return await new Promise((r) => setTimeout(r, ms)); } function isEmptyObject(o) { @@ -191,6 +196,22 @@ function promise(): PromiseDeconstructed { }; } +/** + * Promise constructed from signal + * This rejects when the signal is aborted + */ +function signalPromise(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + signal.addEventListener('abort', () => { + reject(signal.reason); + }); + }); +} + function timerStart(timeout: number): Timer { const timer = {} as Timer; timer.timedOut = false; @@ -309,7 +330,67 @@ function debounce

( }; } +function isPromise(v: any): v is Promise { + return ( + v instanceof Promise || + (v != null && + typeof v.then === 'function' && + typeof v.catch === 'function' && + typeof v.finally === 'function') + ); +} + +function isPromiseLike(v: any): v is PromiseLike { + return v != null && typeof v.then === 'function'; +} + +/** + * Is generator object + * Use this to check for generators + */ +function isGenerator(v: any): v is Generator { + return ( + v != null && + typeof v[Symbol.iterator] === 'function' && + typeof v.next === 'function' && + typeof v.return === 'function' && + typeof v.throw === 'function' + ); +} + +/** + * Is async generator object + * Use this to check for async generators + */ +function isAsyncGenerator(v: any): v is AsyncGenerator { + return ( + v != null && + typeof v === 'object' && + typeof v[Symbol.asyncIterator] === 'function' && + typeof v.next === 'function' && + typeof v.return === 'function' && + typeof v.throw === 'function' + ); +} + +/** + * Encodes whole numbers (inc of 0) to lexicographic buffers + */ +function lexiPackBuffer(n: number): Buffer { + return Buffer.from(lexi.pack(n)); +} + +/** + * Decodes lexicographic buffers to whole numbers (inc of 0) + */ +function lexiUnpackBuffer(b: Buffer): number { + return lexi.unpack([...b]); +} + export { + AsyncFunction, + GeneratorFunction, + AsyncGeneratorFunction, getDefaultNodePath, never, mkdirExists, @@ -322,6 +403,7 @@ export { poll, promisify, promise, + signalPromise, timerStart, timerStop, arraySet, @@ -331,4 +413,10 @@ export { asyncIterableArray, bufferSplit, debounce, + isPromise, + isPromiseLike, + isGenerator, + isAsyncGenerator, + lexiPackBuffer, + lexiUnpackBuffer, }; diff --git a/tests/contexts/decorators/cancellable.test.ts b/tests/contexts/decorators/cancellable.test.ts new file mode 100644 index 000000000..f1b08298f --- /dev/null +++ b/tests/contexts/decorators/cancellable.test.ts @@ -0,0 +1,401 @@ +import type { ContextCancellable } from '@/contexts/types'; +import { PromiseCancellable } from '@matrixai/async-cancellable'; +import context from '@/contexts/decorators/context'; +import cancellable from '@/contexts/decorators/cancellable'; +import { AsyncFunction, sleep } from '@/utils'; + +describe('context/decorators/cancellable', () => { + describe('cancellable decorator runtime validation', () => { + test('cancellable decorator requires context decorator', async () => { + expect(() => { + class C { + @cancellable() + async f(_ctx: ContextCancellable): Promise { + return 'hello world'; + } + } + return C; + }).toThrow(TypeError); + }); + test('cancellable decorator fails on invalid context', async () => { + await expect(async () => { + class C { + @cancellable() + async f(@context _ctx: ContextCancellable): Promise { + return 'hello world'; + } + } + const c = new C(); + // @ts-ignore invalid context signal + await c.f({ signal: 'lol' }); + }).rejects.toThrow(TypeError); + }); + }); + describe('cancellable decorator syntax', () => { + // Decorators cannot change type signatures + // use overloading to change required context parameter to optional context parameter + const symbolFunction = Symbol('sym'); + class X { + functionPromise( + ctx?: Partial, + ): PromiseCancellable; + @cancellable() + functionPromise(@context ctx: ContextCancellable): Promise { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + return new Promise((resolve) => void resolve()); + } + + asyncFunction( + ctx?: Partial, + ): PromiseCancellable; + @cancellable(true) + async asyncFunction(@context ctx: ContextCancellable): Promise { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + } + + [symbolFunction]( + ctx?: Partial, + ): PromiseCancellable; + @cancellable(false) + [symbolFunction](@context ctx: ContextCancellable): Promise { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + return new Promise((resolve) => void resolve()); + } + } + const x = new X(); + test('functionPromise', async () => { + const pC = x.functionPromise(); + expect(pC).toBeInstanceOf(PromiseCancellable); + await pC; + await x.functionPromise({}); + await x.functionPromise({ signal: new AbortController().signal }); + expect(x.functionPromise).toBeInstanceOf(Function); + expect(x.functionPromise.name).toBe('functionPromise'); + }); + test('asyncFunction', async () => { + const pC = x.asyncFunction(); + expect(pC).toBeInstanceOf(PromiseCancellable); + await pC; + await x.asyncFunction({}); + await x.asyncFunction({ signal: new AbortController().signal }); + expect(x.asyncFunction).toBeInstanceOf(Function); + expect(x.asyncFunction).not.toBeInstanceOf(AsyncFunction); + expect(x.asyncFunction.name).toBe('asyncFunction'); + }); + test('symbolFunction', async () => { + const pC = x[symbolFunction](); + expect(pC).toBeInstanceOf(PromiseCancellable); + await pC; + await x[symbolFunction]({}); + await x[symbolFunction]({ signal: new AbortController().signal }); + expect(x[symbolFunction]).toBeInstanceOf(Function); + expect(x[symbolFunction].name).toBe('[sym]'); + }); + }); + describe('cancellable decorator cancellation', () => { + test('async function cancel - eager', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @cancellable() + async f(@context ctx: ContextCancellable): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) break; + await sleep(1); + } + return 'hello world'; + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel(); + await expect(pC).rejects.toBeUndefined(); + }); + test('async function cancel - lazy', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @cancellable(true) + async f(@context ctx: ContextCancellable): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) break; + await sleep(1); + } + return 'hello world'; + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel(); + await expect(pC).resolves.toBe('hello world'); + }); + test('async function cancel with custom error and eager rejection', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @cancellable() + async f(@context ctx: ContextCancellable): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) break; + await sleep(1); + } + return 'hello world'; + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('async function cancel with custom error and lazy rejection', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @cancellable(true) + async f(@context ctx: ContextCancellable): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw ctx.signal.reason; + } + await sleep(1); + } + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('promise cancellable function - eager rejection', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @cancellable() + f(@context ctx: ContextCancellable): PromiseCancellable { + const pC = new PromiseCancellable( + (resolve, reject, signal) => { + if (signal.aborted) { + reject('eager 2:' + signal.reason); + } else { + signal.onabort = () => { + reject('lazy 2:' + signal.reason); + }; + } + void sleep(10).then(() => { + resolve('hello world'); + }); + }, + ); + if (ctx.signal.aborted) { + pC.cancel('eager 1:' + ctx.signal.reason); + } else { + ctx.signal.onabort = () => { + pC.cancel('lazy 1:' + ctx.signal.reason); + }; + } + return pC; + } + } + const c = new C(); + // Signal is aborted afterwards + const pC1 = c.f(); + pC1.cancel('cancel reason'); + await expect(pC1).rejects.toBe('cancel reason'); + // Signal is already aborted + const abortController = new AbortController(); + abortController.abort('cancel reason'); + const pC2 = c.f({ signal: abortController.signal }); + await expect(pC2).rejects.toBe('cancel reason'); + }); + test('promise cancellable function - lazy rejection', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @cancellable(true) + f(@context ctx: ContextCancellable): PromiseCancellable { + const pC = new PromiseCancellable( + (resolve, reject, signal) => { + if (signal.aborted) { + reject('eager 2:' + signal.reason); + } else { + signal.onabort = () => { + reject('lazy 2:' + signal.reason); + }; + } + void sleep(10).then(() => { + resolve('hello world'); + }); + }, + ); + if (ctx.signal.aborted) { + pC.cancel('eager 1:' + ctx.signal.reason); + } else { + ctx.signal.onabort = () => { + pC.cancel('lazy 1:' + ctx.signal.reason); + }; + } + return pC; + } + } + const c = new C(); + // Signal is aborted afterwards + const pC1 = c.f(); + pC1.cancel('cancel reason'); + await expect(pC1).rejects.toBe('lazy 2:lazy 1:cancel reason'); + // Signal is already aborted + const abortController = new AbortController(); + abortController.abort('cancel reason'); + const pC2 = c.f({ signal: abortController.signal }); + await expect(pC2).rejects.toBe('lazy 2:eager 1:cancel reason'); + }); + }); + describe('cancellable decorator propagation', () => { + test('propagate signal', async () => { + let signal: AbortSignal; + class C { + f(ctx?: Partial): PromiseCancellable; + @cancellable(true) + async f(@context ctx: ContextCancellable): Promise { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + signal = ctx.signal; + return await this.g(ctx); + } + + g(ctx?: Partial): PromiseCancellable; + @cancellable(true) + g(@context ctx: ContextCancellable): Promise { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + // The signal is actually not the same + // it is chained instead + expect(signal).not.toBe(ctx.signal); + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject('early:' + ctx.signal.reason); + } else { + const timeout = setTimeout(() => { + resolve('g'); + }, 10); + ctx.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject('during:' + ctx.signal.reason); + }); + } + }); + } + } + const c = new C(); + const pC1 = c.f(); + await expect(pC1).resolves.toBe('g'); + expect(signal!.aborted).toBe(false); + const pC2 = c.f(); + pC2.cancel('cancel reason'); + await expect(pC2).rejects.toBe('during:cancel reason'); + expect(signal!.aborted).toBe(true); + const abortController = new AbortController(); + abortController.abort('cancel reason'); + const pC3 = c.f({ signal: abortController.signal }); + await expect(pC3).rejects.toBe('early:cancel reason'); + expect(signal!.aborted).toBe(true); + }); + test('nested cancellable - lazy then lazy', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @cancellable(true) + @cancellable(true) + async f(@context ctx: ContextCancellable): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw 'throw:' + ctx.signal.reason; + } + await sleep(1); + } + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('throw:cancel reason'); + }); + test('nested cancellable - lazy then eager', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @cancellable(true) + @cancellable(false) + async f(@context ctx: ContextCancellable): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw 'throw:' + ctx.signal.reason; + } + await sleep(1); + } + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('nested cancellable - eager then lazy', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @cancellable(false) + @cancellable(true) + async f(@context ctx: ContextCancellable): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw 'throw:' + ctx.signal.reason; + } + await sleep(1); + } + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('signal event listeners are removed', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @cancellable() + async f(@context _ctx: ContextCancellable): Promise { + return 'hello world'; + } + } + const abortController = new AbortController(); + let listenerCount = 0; + const signal = new Proxy(abortController.signal, { + get(target, prop, receiver) { + if (prop === 'addEventListener') { + return function addEventListener(...args) { + listenerCount++; + return target[prop].apply(this, args); + }; + } else if (prop === 'removeEventListener') { + return function addEventListener(...args) { + listenerCount--; + return target[prop].apply(this, args); + }; + } else { + return Reflect.get(target, prop, receiver); + } + }, + }); + const c = new C(); + await c.f({ signal }); + await c.f({ signal }); + const pC = c.f({ signal }); + pC.cancel(); + await expect(pC).rejects.toBe(undefined); + expect(listenerCount).toBe(0); + }); + }); +}); diff --git a/tests/contexts/decorators/context.test.ts b/tests/contexts/decorators/context.test.ts new file mode 100644 index 000000000..09627a359 --- /dev/null +++ b/tests/contexts/decorators/context.test.ts @@ -0,0 +1,27 @@ +import context from '@/contexts/decorators/context'; +import * as contextsUtils from '@/contexts/utils'; + +describe('contexts/utils', () => { + test('context parameter decorator', () => { + class C { + f(@context _a: any) {} + g(_a: any, @context _b: any) {} + h(_a: any, _b: any, @context ..._rest: Array) {} + } + expect(contextsUtils.contexts.get(C.prototype.f)).toBe(0); + expect(contextsUtils.contexts.get(C.prototype.g)).toBe(1); + expect(contextsUtils.contexts.get(C.prototype.h)).toBe(2); + const c = new C(); + expect(contextsUtils.contexts.get(c.f)).toBe(0); + expect(contextsUtils.contexts.get(c.g)).toBe(1); + expect(contextsUtils.contexts.get(c.h)).toBe(2); + }); + test('context parameter decorator can only be used once', () => { + expect(() => { + class C { + f(@context _a: any, @context _b: any) {} + } + new C(); + }).toThrow(TypeError); + }); +}); diff --git a/tests/contexts/decorators/timed.test.ts b/tests/contexts/decorators/timed.test.ts new file mode 100644 index 000000000..b5d0ce0b7 --- /dev/null +++ b/tests/contexts/decorators/timed.test.ts @@ -0,0 +1,767 @@ +import type { ContextTimed } from '@/contexts/types'; +import { Timer } from '@matrixai/timer'; +import context from '@/contexts/decorators/context'; +import timed from '@/contexts/decorators/timed'; +import * as contextsErrors from '@/contexts/errors'; +import { + AsyncFunction, + GeneratorFunction, + AsyncGeneratorFunction, + sleep, +} from '@/utils'; + +describe('context/decorators/timed', () => { + describe('timed decorator runtime validation', () => { + test('timed decorator requires context decorator', async () => { + expect(() => { + class C { + @timed(50) + async f(_ctx: ContextTimed): Promise { + return 'hello world'; + } + } + return C; + }).toThrow(TypeError); + }); + test('timed decorator fails on invalid context', async () => { + await expect(async () => { + class C { + @timed(50) + async f(@context _ctx: ContextTimed): Promise { + return 'hello world'; + } + } + const c = new C(); + // @ts-ignore invalid context timer + await c.f({ timer: 1 }); + }).rejects.toThrow(TypeError); + await expect(async () => { + class C { + @timed(50) + async f(@context _ctx: ContextTimed): Promise { + return 'hello world'; + } + } + const c = new C(); + // @ts-ignore invalid context signal + await c.f({ signal: 'lol' }); + }).rejects.toThrow(TypeError); + }); + }); + describe('timed decorator syntax', () => { + // Decorators cannot change type signatures + // use overloading to change required context parameter to optional context parameter + const symbolFunction = Symbol('sym'); + class X { + functionValue( + ctx?: Partial, + check?: (t: Timer) => any, + ): string; + @timed(1000) + functionValue( + @context ctx: ContextTimed, + check?: (t: Timer) => any, + ): string { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(ctx.timer).toBeInstanceOf(Timer); + if (check != null) check(ctx.timer); + return 'hello world'; + } + + functionValueArray( + ctx?: Partial, + check?: (t: Timer) => any, + ): Array; + @timed(1000) + functionValueArray( + @context ctx: ContextTimed, + check?: (t: Timer) => any, + ): Array { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(ctx.timer).toBeInstanceOf(Timer); + if (check != null) check(ctx.timer); + return [1, 2, 3, 4]; + } + + functionPromise( + ctx?: Partial, + check?: (t: Timer) => any, + ): Promise; + @timed(1000) + functionPromise( + @context ctx: ContextTimed, + check?: (t: Timer) => any, + ): Promise { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(ctx.timer).toBeInstanceOf(Timer); + if (check != null) check(ctx.timer); + return new Promise((resolve) => void resolve()); + } + + asyncFunction( + ctx?: Partial, + check?: (t: Timer) => any, + ): Promise; + @timed(Infinity) + async asyncFunction( + @context ctx: ContextTimed, + check?: (t: Timer) => any, + ): Promise { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(ctx.timer).toBeInstanceOf(Timer); + if (check != null) check(ctx.timer); + } + + generator( + ctx?: Partial, + check?: (t: Timer) => any, + ): Generator; + @timed(0) + *generator( + @context ctx: ContextTimed, + check?: (t: Timer) => any, + ): Generator { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(ctx.timer).toBeInstanceOf(Timer); + if (check != null) check(ctx.timer); + } + + functionGenerator( + ctx?: Partial, + check?: (t: Timer) => any, + ): Generator; + @timed(0) + functionGenerator( + @context ctx: ContextTimed, + check?: (t: Timer) => any, + ): Generator { + return this.generator(ctx, check); + } + + asyncGenerator( + ctx?: Partial, + check?: (t: Timer) => any, + ): AsyncGenerator; + @timed(NaN) + async *asyncGenerator( + @context ctx: ContextTimed, + check?: (t: Timer) => any, + ): AsyncGenerator { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(ctx.timer).toBeInstanceOf(Timer); + if (check != null) check(ctx.timer); + } + + functionAsyncGenerator( + ctx?: Partial, + check?: (t: Timer) => any, + ): AsyncGenerator; + @timed(NaN) + functionAsyncGenerator( + @context ctx: ContextTimed, + check?: (t: Timer) => any, + ): AsyncGenerator { + return this.asyncGenerator(ctx, check); + } + + [symbolFunction]( + ctx?: Partial, + check?: (t: Timer) => any, + ): Promise; + @timed() + [symbolFunction]( + @context ctx: ContextTimed, + check?: (t: Timer) => any, + ): Promise { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(ctx.timer).toBeInstanceOf(Timer); + if (check != null) check(ctx.timer); + return new Promise((resolve) => void resolve()); + } + } + const x = new X(); + test('functionValue', () => { + expect(x.functionValue()).toBe('hello world'); + expect(x.functionValue({})).toBe('hello world'); + expect( + x.functionValue({ timer: new Timer({ delay: 100 }) }, (t) => { + expect(t.delay).toBe(100); + }), + ).toBe('hello world'); + expect(x.functionValue).toBeInstanceOf(Function); + expect(x.functionValue.name).toBe('functionValue'); + }); + test('functionValueArray', () => { + expect(x.functionValueArray()).toStrictEqual([1, 2, 3, 4]); + expect(x.functionValueArray({})).toStrictEqual([1, 2, 3, 4]); + expect( + x.functionValueArray({ timer: new Timer({ delay: 100 }) }, (t) => { + expect(t.delay).toBe(100); + }), + ).toStrictEqual([1, 2, 3, 4]); + expect(x.functionValueArray).toBeInstanceOf(Function); + expect(x.functionValueArray.name).toBe('functionValueArray'); + }); + test('functionPromise', async () => { + await x.functionPromise(); + await x.functionPromise({}); + await x.functionPromise({ timer: new Timer({ delay: 100 }) }, (t) => { + expect(t.delay).toBe(100); + }); + expect(x.functionPromise).toBeInstanceOf(Function); + expect(x.functionPromise.name).toBe('functionPromise'); + }); + test('asyncFunction', async () => { + await x.asyncFunction(); + await x.asyncFunction({}); + await x.asyncFunction({ timer: new Timer({ delay: 50 }) }, (t) => { + expect(t.delay).toBe(50); + }); + expect(x.asyncFunction).toBeInstanceOf(AsyncFunction); + expect(x.asyncFunction.name).toBe('asyncFunction'); + }); + test('generator', () => { + for (const _ of x.generator()) { + // NOOP + } + for (const _ of x.generator({})) { + // NOOP + } + for (const _ of x.generator({ timer: new Timer({ delay: 150 }) }, (t) => { + expect(t.delay).toBe(150); + })) { + // NOOP + } + expect(x.generator).toBeInstanceOf(GeneratorFunction); + expect(x.generator.name).toBe('generator'); + }); + test('functionGenerator', () => { + for (const _ of x.functionGenerator()) { + // NOOP + } + for (const _ of x.functionGenerator({})) { + // NOOP + } + for (const _ of x.functionGenerator( + { timer: new Timer({ delay: 150 }) }, + (t) => { + expect(t.delay).toBe(150); + }, + )) { + // NOOP + } + expect(x.functionGenerator).toBeInstanceOf(Function); + expect(x.functionGenerator.name).toBe('functionGenerator'); + }); + test('asyncGenerator', async () => { + for await (const _ of x.asyncGenerator()) { + // NOOP + } + for await (const _ of x.asyncGenerator({})) { + // NOOP + } + for await (const _ of x.asyncGenerator( + { timer: new Timer({ delay: 200 }) }, + (t) => { + expect(t.delay).toBe(200); + }, + )) { + // NOOP + } + expect(x.asyncGenerator).toBeInstanceOf(AsyncGeneratorFunction); + expect(x.asyncGenerator.name).toBe('asyncGenerator'); + }); + test('functionAsyncGenerator', async () => { + for await (const _ of x.functionAsyncGenerator()) { + // NOOP + } + for await (const _ of x.functionAsyncGenerator({})) { + // NOOP + } + for await (const _ of x.functionAsyncGenerator( + { timer: new Timer({ delay: 200 }) }, + (t) => { + expect(t.delay).toBe(200); + }, + )) { + // NOOP + } + expect(x.functionAsyncGenerator).toBeInstanceOf(Function); + expect(x.functionAsyncGenerator.name).toBe('functionAsyncGenerator'); + }); + test('symbolFunction', async () => { + await x[symbolFunction](); + await x[symbolFunction]({}); + await x[symbolFunction]({ timer: new Timer({ delay: 250 }) }, (t) => { + expect(t.delay).toBe(250); + }); + expect(x[symbolFunction]).toBeInstanceOf(Function); + expect(x[symbolFunction].name).toBe('[sym]'); + }); + }); + describe('timed decorator expiry', () => { + // Timed decorator does not automatically reject the promise + // it only signals that it is aborted + // it is up to the function to decide how to reject + test('async function expiry', async () => { + class C { + /** + * Async function + */ + f(ctx?: Partial): Promise; + @timed(50) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + await sleep(15); + expect(ctx.signal.aborted).toBe(false); + await sleep(40); + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + return 'hello world'; + } + } + const c = new C(); + await expect(c.f()).resolves.toBe('hello world'); + }); + test('async function expiry with custom error', async () => { + class ErrorCustom extends Error {} + class C { + /** + * Async function + */ + f(ctx?: Partial): Promise; + @timed(50, ErrorCustom) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + await sleep(15); + expect(ctx.signal.aborted).toBe(false); + await sleep(40); + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf(ErrorCustom); + throw ctx.signal.reason; + } + } + const c = new C(); + await expect(c.f()).rejects.toBeInstanceOf(ErrorCustom); + }); + test('promise function expiry', async () => { + class C { + /** + * Regular function returning promise + */ + f(ctx?: Partial): Promise; + @timed(50) + f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + return sleep(15) + .then(() => { + expect(ctx.signal.aborted).toBe(false); + }) + .then(() => sleep(40)) + .then(() => { + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + }) + .then(() => { + return 'hello world'; + }); + } + } + const c = new C(); + await expect(c.f()).resolves.toBe('hello world'); + }); + test('promise function expiry and late rejection', async () => { + let timeout: ReturnType | undefined; + class C { + /** + * Regular function that actually rejects + * when the signal is aborted + */ + f(ctx?: Partial): Promise; + @timed(50) + f(@context ctx: ContextTimed): Promise { + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason); + } + timeout = setTimeout(() => { + resolve('hello world'); + }, 50000); + ctx.signal.onabort = () => { + clearTimeout(timeout); + timeout = undefined; + reject(ctx.signal.reason); + }; + }); + } + } + const c = new C(); + await expect(c.f()).rejects.toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + expect(timeout).toBeUndefined(); + }); + test('promise function expiry and early rejection', async () => { + let timeout: ReturnType | undefined; + class C { + /** + * Regular function that actually rejects immediately + */ + f(ctx?: Partial): Promise; + @timed(0) + f(@context ctx: ContextTimed): Promise { + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason); + } + timeout = setTimeout(() => { + resolve('hello world'); + }, 50000); + ctx.signal.onabort = () => { + clearTimeout(timeout); + timeout = undefined; + reject(ctx.signal.reason); + }; + }); + } + } + const c = new C(); + await expect(c.f()).rejects.toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + expect(timeout).toBeUndefined(); + }); + test('async generator expiry', async () => { + class C { + f(ctx?: Partial): AsyncGenerator; + @timed(50) + async *f(@context ctx: ContextTimed): AsyncGenerator { + while (true) { + if (ctx.signal.aborted) { + throw ctx.signal.reason; + } + yield 'hello world'; + } + } + } + const c = new C(); + const g = c.f(); + await expect(g.next()).resolves.toEqual({ + value: 'hello world', + done: false, + }); + await expect(g.next()).resolves.toEqual({ + value: 'hello world', + done: false, + }); + await sleep(50); + await expect(g.next()).rejects.toThrow( + contextsErrors.ErrorContextsTimedTimeOut, + ); + }); + test('generator expiry', async () => { + class C { + f(ctx?: Partial): Generator; + @timed(50) + *f(@context ctx: ContextTimed): Generator { + while (true) { + if (ctx.signal.aborted) { + throw ctx.signal.reason; + } + yield 'hello world'; + } + } + } + const c = new C(); + const g = c.f(); + expect(g.next()).toEqual({ value: 'hello world', done: false }); + expect(g.next()).toEqual({ value: 'hello world', done: false }); + await sleep(50); + expect(() => g.next()).toThrow(contextsErrors.ErrorContextsTimedTimeOut); + }); + }); + describe('timed decorator propagation', () => { + test('propagate timer and signal', async () => { + let timer: Timer; + let signal: AbortSignal; + class C { + f(ctx?: Partial): Promise; + @timed(50) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await this.g(ctx); + } + + g(ctx?: Partial): Promise; + @timed(25) + async g(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + // Timer and signal will be propagated + expect(timer).toBe(ctx.timer); + expect(signal).toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(50); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + } + } + const c = new C(); + await expect(c.f()).resolves.toBe('g'); + }); + test('propagate timer only', async () => { + let timer: Timer; + let signal: AbortSignal; + class C { + f(ctx?: Partial): Promise; + @timed(50) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await this.g({ timer: ctx.timer }); + } + + g(ctx?: Partial): Promise; + @timed(25) + async g(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(timer).toBe(ctx.timer); + expect(signal).not.toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(50); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + } + } + const c = new C(); + await expect(c.f()).resolves.toBe('g'); + }); + test('propagate signal only', async () => { + let timer: Timer; + let signal: AbortSignal; + class C { + f(ctx?: Partial): Promise; + @timed(50) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await this.g({ signal: ctx.signal }); + } + + g(ctx?: Partial): Promise; + @timed(25) + async g(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + // Even though signal is propagated + // because the timer isn't, the signal here is chained + expect(timer).not.toBe(ctx.timer); + expect(signal).not.toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(25); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + } + } + const c = new C(); + await expect(c.f()).resolves.toBe('g'); + }); + test('propagate nothing', async () => { + let timer: Timer; + let signal: AbortSignal; + class C { + f(ctx?: Partial): Promise; + @timed(50) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await this.g(); + } + + g(ctx?: Partial): Promise; + @timed(25) + async g(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(timer).not.toBe(ctx.timer); + expect(signal).not.toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(25); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + } + } + const c = new C(); + await expect(c.f()).resolves.toBe('g'); + }); + test('propagated expiry', async () => { + class C { + f(ctx?: Partial): Promise; + @timed(25) + async f(@context ctx: ContextTimed): Promise { + // The `g` will use up all the remaining time + const counter = await this.g(ctx.timer.getTimeout()); + expect(counter).toBeGreaterThan(0); + // The `h` will reject eventually + // it may reject immediately + // it may reject after some time + await this.h(ctx); + return 'hello world'; + } + + async g(timeout: number): Promise { + const start = performance.now(); + let counter = 0; + while (true) { + if (performance.now() - start > timeout) { + break; + } + await sleep(1); + counter++; + } + return counter; + } + + h(ctx?: Partial): Promise; + @timed(25) + async h(@context ctx: ContextTimed): Promise { + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason); + return; + } + const timeout = setTimeout(() => { + resolve('hello world'); + }, 25); + ctx.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(ctx.signal.reason); + }); + }); + } + } + const c = new C(); + await expect(c.f()).rejects.toThrow( + contextsErrors.ErrorContextsTimedTimeOut, + ); + }); + }); + describe('timed decorator explicit timer cancellation or signal abortion', () => { + // If the timer is cancelled + // there will be no timeout error + let ctx_: ContextTimed | undefined; + class C { + f(ctx?: Partial): Promise; + @timed(50) + f(@context ctx: ContextTimed): Promise { + ctx_ = ctx; + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason + ' begin'); + return; + } + const timeout = setTimeout(() => { + resolve('hello world'); + }, 25); + ctx.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(ctx.signal.reason + ' during'); + }); + }); + } + } + const c = new C(); + beforeEach(() => { + ctx_ = undefined; + }); + test('explicit timer cancellation - begin', async () => { + const timer = new Timer({ delay: 100 }); + timer.cancel('reason'); + const p = c.f({ timer }); + await expect(p).resolves.toBe('hello world'); + expect(ctx_!.signal.aborted).toBe(false); + }); + test('explicit timer cancellation - during', async () => { + const timer = new Timer({ delay: 100 }); + const p = c.f({ timer }); + timer.cancel('reason'); + await expect(p).resolves.toBe('hello world'); + expect(ctx_!.signal.aborted).toBe(false); + }); + test('explicit timer cancellation - during after sleep', async () => { + const timer = new Timer({ delay: 20 }); + const p = c.f({ timer }); + await sleep(1); + timer.cancel('reason'); + await expect(p).resolves.toBe('hello world'); + expect(ctx_!.signal.aborted).toBe(false); + }); + test('explicit signal abortion - begin', async () => { + const abortController = new AbortController(); + abortController.abort('reason'); + const p = c.f({ signal: abortController.signal }); + expect(ctx_!.timer.status).toBe('settled'); + await expect(p).rejects.toBe('reason begin'); + }); + test('explicit signal abortion - during', async () => { + const abortController = new AbortController(); + const p = c.f({ signal: abortController.signal }); + abortController.abort('reason'); + // Timer is also cancelled immediately + expect(ctx_!.timer.status).toBe('settled'); + await expect(p).rejects.toBe('reason during'); + }); + test('explicit signal signal abortion with passed in timer - during', async () => { + // By passing in the timer and signal explicitly + // it is expected that the timer and signal handling is already setup + const abortController = new AbortController(); + const timer = new Timer({ + handler: () => { + abortController.abort(new contextsErrors.ErrorContextsTimedTimeOut()); + }, + delay: 100, + }); + abortController.signal.addEventListener('abort', () => { + timer.cancel(); + }); + const p = c.f({ timer, signal: abortController.signal }); + abortController.abort('abort reason'); + expect(ctx_!.timer.status).toBe('settled'); + expect(timer.status).toBe('settled'); + expect(ctx_!.signal.aborted).toBe(true); + await expect(p).rejects.toBe('abort reason during'); + }); + test('explicit timer cancellation and signal abortion - begin', async () => { + const timer = new Timer({ delay: 100 }); + timer.cancel('timer reason'); + const abortController = new AbortController(); + abortController.abort('abort reason'); + const p = c.f({ timer, signal: abortController.signal }); + expect(ctx_!.timer.status).toBe('settled'); + expect(ctx_!.signal.aborted).toBe(true); + await expect(p).rejects.toBe('abort reason begin'); + }); + }); +}); diff --git a/tests/contexts/decorators/timedCancellable.test.ts b/tests/contexts/decorators/timedCancellable.test.ts new file mode 100644 index 000000000..d32dfdcbe --- /dev/null +++ b/tests/contexts/decorators/timedCancellable.test.ts @@ -0,0 +1,872 @@ +import type { ContextTimed } from '@/contexts/types'; +import { Timer } from '@matrixai/timer'; +import { PromiseCancellable } from '@matrixai/async-cancellable'; +import context from '@/contexts/decorators/context'; +import timedCancellable from '@/contexts/decorators/timedCancellable'; +import * as contextsErrors from '@/contexts/errors'; +import { AsyncFunction, sleep, promise } from '@/utils'; + +describe('context/decorators/timedCancellable', () => { + describe('timedCancellable decorator runtime validation', () => { + test('timedCancellable decorator requires context decorator', async () => { + expect(() => { + class C { + @timedCancellable() + async f(_ctx: ContextTimed): Promise { + return 'hello world'; + } + } + return C; + }).toThrow(TypeError); + }); + test('cancellable decorator fails on invalid context', async () => { + await expect(async () => { + class C { + @timedCancellable() + async f(@context _ctx: ContextTimed): Promise { + return 'hello world'; + } + } + const c = new C(); + // @ts-ignore invalid context signal + await c.f({ signal: 'lol' }); + }).rejects.toThrow(TypeError); + }); + }); + describe('timedCancellable decorator syntax', () => { + // Decorators cannot change type signatures + // use overloading to change required context parameter to optional context parameter + const symbolFunction = Symbol('sym'); + class X { + functionPromise( + ctx?: Partial, + check?: (t: Timer) => any, + ): PromiseCancellable; + @timedCancellable(false, 1000) + functionPromise( + @context ctx: ContextTimed, + check?: (t: Timer) => any, + ): Promise { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(ctx.timer).toBeInstanceOf(Timer); + if (check != null) check(ctx.timer); + return new Promise((resolve) => void resolve()); + } + + asyncFunction( + ctx?: Partial, + check?: (t: Timer) => any, + ): PromiseCancellable; + @timedCancellable(true, Infinity) + async asyncFunction( + @context ctx: ContextTimed, + check?: (t: Timer) => any, + ): Promise { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(ctx.timer).toBeInstanceOf(Timer); + if (check != null) check(ctx.timer); + } + + [symbolFunction]( + ctx?: Partial, + check?: (t: Timer) => any, + ): PromiseCancellable; + @timedCancellable() + [symbolFunction]( + @context ctx: ContextTimed, + check?: (t: Timer) => any, + ): Promise { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(ctx.timer).toBeInstanceOf(Timer); + if (check != null) check(ctx.timer); + return new Promise((resolve) => void resolve()); + } + } + const x = new X(); + test('functionPromise', async () => { + const pC = x.functionPromise(); + expect(pC).toBeInstanceOf(PromiseCancellable); + await pC; + await x.functionPromise({}); + await x.functionPromise({ timer: new Timer({ delay: 100 }) }, (t) => { + expect(t.delay).toBe(100); + }); + expect(x.functionPromise).toBeInstanceOf(Function); + expect(x.functionPromise.name).toBe('functionPromise'); + }); + test('asyncFunction', async () => { + const pC = x.asyncFunction(); + expect(pC).toBeInstanceOf(PromiseCancellable); + await pC; + await x.asyncFunction({}); + await x.asyncFunction({ timer: new Timer({ delay: 50 }) }, (t) => { + expect(t.delay).toBe(50); + }); + expect(x.functionPromise).toBeInstanceOf(Function); + // Returning `PromiseCancellable` means it cannot be an async function + expect(x.asyncFunction).not.toBeInstanceOf(AsyncFunction); + expect(x.asyncFunction.name).toBe('asyncFunction'); + }); + test('symbolFunction', async () => { + const pC = x[symbolFunction](); + expect(pC).toBeInstanceOf(PromiseCancellable); + await pC; + await x[symbolFunction]({}); + await x[symbolFunction]({ timer: new Timer({ delay: 250 }) }, (t) => { + expect(t.delay).toBe(250); + }); + expect(x[symbolFunction]).toBeInstanceOf(Function); + expect(x[symbolFunction].name).toBe('[sym]'); + }); + }); + describe('timedCancellable decorator expiry', () => { + test('async function expiry - eager', async () => { + const { p: finishedP, resolveP: resolveFinishedP } = promise(); + class C { + /** + * Async function + */ + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(false, 50) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + await sleep(15); + expect(ctx.signal.aborted).toBe(false); + await sleep(40); + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + resolveFinishedP(); + return 'hello world'; + } + } + const c = new C(); + await expect(c.f()).rejects.toThrow( + contextsErrors.ErrorContextsTimedTimeOut, + ); + // Eager rejection allows the promise finish its side effects + await expect(finishedP).resolves.toBeUndefined(); + }); + test('async function expiry - lazy', async () => { + class C { + /** + * Async function + */ + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(true, 50) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + await sleep(15); + expect(ctx.signal.aborted).toBe(false); + await sleep(40); + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + return 'hello world'; + } + } + const c = new C(); + await expect(c.f()).resolves.toBe('hello world'); + }); + test('async function expiry with custom error - eager', async () => { + class ErrorCustom extends Error {} + class C { + /** + * Async function + */ + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(false, 50, ErrorCustom) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + await sleep(15); + expect(ctx.signal.aborted).toBe(false); + await sleep(40); + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf(ErrorCustom); + throw ctx.signal.reason; + } + } + const c = new C(); + await expect(c.f()).rejects.toBeInstanceOf(ErrorCustom); + }); + test('async function expiry with custom error - lazy', async () => { + class ErrorCustom extends Error {} + class C { + /** + * Async function + */ + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(true, 50, ErrorCustom) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + await sleep(15); + expect(ctx.signal.aborted).toBe(false); + await sleep(40); + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf(ErrorCustom); + throw ctx.signal.reason; + } + } + const c = new C(); + await expect(c.f()).rejects.toBeInstanceOf(ErrorCustom); + }); + test('promise function expiry - lazy', async () => { + class C { + /** + * Regular function returning promise + */ + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(true, 50) + f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + return sleep(15) + .then(() => { + expect(ctx.signal.aborted).toBe(false); + }) + .then(() => sleep(40)) + .then(() => { + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + }) + .then(() => { + return 'hello world'; + }); + } + } + const c = new C(); + await expect(c.f()).resolves.toBe('hello world'); + }); + test('promise function expiry and late rejection - lazy', async () => { + let timeout: ReturnType | undefined; + class C { + /** + * Regular function that actually rejects + * when the signal is aborted + */ + f(ctx?: Partial): Promise; + @timedCancellable(true, 50) + f(@context ctx: ContextTimed): Promise { + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason); + } + timeout = setTimeout(() => { + resolve('hello world'); + }, 50000); + ctx.signal.onabort = () => { + clearTimeout(timeout); + timeout = undefined; + reject(ctx.signal.reason); + }; + }); + } + } + const c = new C(); + await expect(c.f()).rejects.toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + expect(timeout).toBeUndefined(); + }); + test('promise function expiry and early rejection - lazy', async () => { + let timeout: ReturnType | undefined; + class C { + /** + * Regular function that actually rejects immediately + */ + f(ctx?: Partial): Promise; + @timedCancellable(true, 0) + f(@context ctx: ContextTimed): Promise { + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason); + } + timeout = setTimeout(() => { + resolve('hello world'); + }, 50000); + ctx.signal.onabort = () => { + clearTimeout(timeout); + timeout = undefined; + reject(ctx.signal.reason); + }; + }); + } + } + const c = new C(); + await expect(c.f()).rejects.toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + expect(timeout).toBeUndefined(); + }); + }); + describe('timedCancellable decorator cancellation', () => { + test('async function cancel - eager', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable() + async f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) break; + await sleep(1); + } + return 'hello world'; + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel(); + await expect(pC).rejects.toBeUndefined(); + }); + test('async function cancel - lazy', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(true) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) break; + await sleep(1); + } + return 'hello world'; + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel(); + await expect(pC).resolves.toBe('hello world'); + }); + test('async function cancel with custom error and eager rejection', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable() + async f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) break; + await sleep(1); + } + return 'hello world'; + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('async function cancel with custom error and lazy rejection', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(true) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw ctx.signal.reason; + } + await sleep(1); + } + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('promise timedCancellable function - eager rejection', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable() + f(@context ctx: ContextTimed): PromiseCancellable { + const pC = new PromiseCancellable( + (resolve, reject, signal) => { + if (signal.aborted) { + reject('eager 2:' + signal.reason); + } else { + signal.onabort = () => { + reject('lazy 2:' + signal.reason); + }; + } + void sleep(10).then(() => { + resolve('hello world'); + }); + }, + ); + if (ctx.signal.aborted) { + pC.cancel('eager 1:' + ctx.signal.reason); + } else { + ctx.signal.onabort = () => { + pC.cancel('lazy 1:' + ctx.signal.reason); + }; + } + return pC; + } + } + const c = new C(); + // Signal is aborted afterwards + const pC1 = c.f(); + pC1.cancel('cancel reason'); + await expect(pC1).rejects.toBe('cancel reason'); + // Signal is already aborted + const abortController = new AbortController(); + abortController.abort('cancel reason'); + const pC2 = c.f({ signal: abortController.signal }); + await expect(pC2).rejects.toBe('cancel reason'); + }); + test('promise timedCancellable function - lazy rejection', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(true) + f(@context ctx: ContextTimed): PromiseCancellable { + const pC = new PromiseCancellable( + (resolve, reject, signal) => { + if (signal.aborted) { + reject('eager 2:' + signal.reason); + } else { + signal.onabort = () => { + reject('lazy 2:' + signal.reason); + }; + } + void sleep(10).then(() => { + resolve('hello world'); + }); + }, + ); + if (ctx.signal.aborted) { + pC.cancel('eager 1:' + ctx.signal.reason); + } else { + ctx.signal.onabort = () => { + pC.cancel('lazy 1:' + ctx.signal.reason); + }; + } + return pC; + } + } + const c = new C(); + // Signal is aborted afterwards + const pC1 = c.f(); + pC1.cancel('cancel reason'); + await expect(pC1).rejects.toBe('lazy 2:lazy 1:cancel reason'); + // Signal is already aborted + const abortController = new AbortController(); + abortController.abort('cancel reason'); + const pC2 = c.f({ signal: abortController.signal }); + await expect(pC2).rejects.toBe('lazy 2:eager 1:cancel reason'); + }); + }); + describe('timedCancellable decorator propagation', () => { + test('propagate timer and signal', async () => { + let timer: Timer; + let signal: AbortSignal; + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(true, 50) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await this.g(ctx); + } + + g(ctx?: Partial): PromiseCancellable; + @timedCancellable(true, 25) + async g(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + // Timer will be propagated + expect(timer).toBe(ctx.timer); + // Signal will be chained + expect(signal).not.toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(50); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + } + } + const c = new C(); + await expect(c.f()).resolves.toBe('g'); + }); + test('propagate timer only', async () => { + let timer: Timer; + let signal: AbortSignal; + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(true, 50) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await this.g({ timer: ctx.timer }); + } + + g(ctx?: Partial): PromiseCancellable; + @timedCancellable(true, 25) + async g(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(timer).toBe(ctx.timer); + expect(signal).not.toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(50); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + } + } + const c = new C(); + await expect(c.f()).resolves.toBe('g'); + }); + test('propagate signal only', async () => { + let timer: Timer; + let signal: AbortSignal; + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(true, 50) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + if (!signal.aborted) { + expect(timer.getTimeout()).toBeGreaterThan(0); + } else { + expect(timer.getTimeout()).toBe(0); + } + return await this.g({ signal: ctx.signal }); + } + + g(ctx?: Partial): PromiseCancellable; + @timedCancellable(true, 25) + g(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + // Even though signal is propagated + // because the timer isn't, the signal here is chained + expect(timer).not.toBe(ctx.timer); + expect(signal).not.toBe(ctx.signal); + if (!signal.aborted) { + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(25); + } else { + expect(timer.getTimeout()).toBe(0); + } + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject('early:' + ctx.signal.reason); + } else { + const timeout = setTimeout(() => { + resolve('g'); + }, 10); + ctx.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject('during:' + ctx.signal.reason); + }); + } + }); + } + } + const c = new C(); + const pC1 = c.f(); + await expect(pC1).resolves.toBe('g'); + expect(signal!.aborted).toBe(false); + const pC2 = c.f(); + pC2.cancel('cancel reason'); + await expect(pC2).rejects.toBe('during:cancel reason'); + expect(signal!.aborted).toBe(true); + const abortController = new AbortController(); + abortController.abort('cancel reason'); + const pC3 = c.f({ signal: abortController.signal }); + await expect(pC3).rejects.toBe('early:cancel reason'); + expect(signal!.aborted).toBe(true); + }); + test('propagate nothing', async () => { + let timer: Timer; + let signal: AbortSignal; + class C { + f(ctx?: Partial): Promise; + @timedCancellable(true, 50) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await this.g(); + } + + g(ctx?: Partial): Promise; + @timedCancellable(true, 25) + async g(@context ctx: ContextTimed): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(timer).not.toBe(ctx.timer); + expect(signal).not.toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(25); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + } + } + const c = new C(); + await expect(c.f()).resolves.toBe('g'); + }); + test('propagated expiry', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(true, 25) + async f(@context ctx: ContextTimed): Promise { + // The `g` will use up all the remaining time + const counter = await this.g(ctx.timer.getTimeout()); + expect(counter).toBeGreaterThan(0); + // The `h` will reject eventually + // it may reject immediately + // it may reject after some time + await this.h(ctx); + return 'hello world'; + } + + async g(timeout: number): Promise { + const start = performance.now(); + let counter = 0; + while (true) { + if (performance.now() - start > timeout) { + break; + } + await sleep(1); + counter++; + } + return counter; + } + + h(ctx?: Partial): PromiseCancellable; + @timedCancellable(true, 25) + async h(@context ctx: ContextTimed): Promise { + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason); + return; + } + const timeout = setTimeout(() => { + resolve('hello world'); + }, 25); + ctx.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(ctx.signal.reason); + }); + }); + } + } + const c = new C(); + await expect(c.f()).rejects.toThrow( + contextsErrors.ErrorContextsTimedTimeOut, + ); + }); + test('nested cancellable - lazy then lazy', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(true) + @timedCancellable(true) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw 'throw:' + ctx.signal.reason; + } + await sleep(1); + } + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('throw:cancel reason'); + }); + test('nested cancellable - lazy then eager', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(true) + @timedCancellable(false) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw 'throw:' + ctx.signal.reason; + } + await sleep(1); + } + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('nested cancellable - eager then lazy', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable(false) + @timedCancellable(true) + async f(@context ctx: ContextTimed): Promise { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw 'throw:' + ctx.signal.reason; + } + await sleep(1); + } + } + } + const c = new C(); + const pC = c.f(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('signal event listeners are removed', async () => { + class C { + f(ctx?: Partial): PromiseCancellable; + @timedCancellable() + async f(@context _ctx: ContextTimed): Promise { + return 'hello world'; + } + } + const abortController = new AbortController(); + let listenerCount = 0; + const signal = new Proxy(abortController.signal, { + get(target, prop, receiver) { + if (prop === 'addEventListener') { + return function addEventListener(...args) { + listenerCount++; + return target[prop].apply(this, args); + }; + } else if (prop === 'removeEventListener') { + return function addEventListener(...args) { + listenerCount--; + return target[prop].apply(this, args); + }; + } else { + return Reflect.get(target, prop, receiver); + } + }, + }); + const c = new C(); + await c.f({ signal }); + await c.f({ signal }); + const pC = c.f({ signal }); + pC.cancel(); + await expect(pC).rejects.toBe(undefined); + expect(listenerCount).toBe(0); + }); + }); + describe('timedCancellable decorator explicit timer cancellation or signal abortion', () => { + // If the timer is cancelled + // there will be no timeout error + let ctx_: ContextTimed | undefined; + class C { + f(ctx?: Partial): Promise; + @timedCancellable(true, 50) + f(@context ctx: ContextTimed): Promise { + ctx_ = ctx; + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason + ' begin'); + return; + } + const timeout = setTimeout(() => { + resolve('hello world'); + }, 25); + ctx.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(ctx.signal.reason + ' during'); + }); + }); + } + } + const c = new C(); + beforeEach(() => { + ctx_ = undefined; + }); + test('explicit timer cancellation - begin', async () => { + const timer = new Timer({ delay: 100 }); + timer.cancel('reason'); + const p = c.f({ timer }); + await expect(p).resolves.toBe('hello world'); + expect(ctx_!.signal.aborted).toBe(false); + }); + test('explicit timer cancellation - during', async () => { + const timer = new Timer({ delay: 100 }); + const p = c.f({ timer }); + timer.cancel('reason'); + await expect(p).resolves.toBe('hello world'); + expect(ctx_!.signal.aborted).toBe(false); + }); + test('explicit timer cancellation - during after sleep', async () => { + const timer = new Timer({ delay: 20 }); + const p = c.f({ timer }); + await sleep(1); + timer.cancel('reason'); + await expect(p).resolves.toBe('hello world'); + expect(ctx_!.signal.aborted).toBe(false); + }); + test('explicit signal abortion - begin', async () => { + const abortController = new AbortController(); + abortController.abort('reason'); + const p = c.f({ signal: abortController.signal }); + expect(ctx_!.timer.status).toBe('settled'); + await expect(p).rejects.toBe('reason begin'); + }); + test('explicit signal abortion - during', async () => { + const abortController = new AbortController(); + const p = c.f({ signal: abortController.signal }); + abortController.abort('reason'); + // Timer is also cancelled immediately + expect(ctx_!.timer.status).toBe('settled'); + await expect(p).rejects.toBe('reason during'); + }); + test('explicit signal signal abortion with passed in timer - during', async () => { + // By passing in the timer and signal explicitly + // it is expected that the timer and signal handling is already setup + const abortController = new AbortController(); + const timer = new Timer({ + handler: () => { + abortController.abort(new contextsErrors.ErrorContextsTimedTimeOut()); + }, + delay: 100, + }); + abortController.signal.addEventListener('abort', () => { + timer.cancel(); + }); + const p = c.f({ timer, signal: abortController.signal }); + abortController.abort('abort reason'); + expect(ctx_!.timer.status).toBe('settled'); + expect(timer.status).toBe('settled'); + expect(ctx_!.signal.aborted).toBe(true); + await expect(p).rejects.toBe('abort reason during'); + }); + test('explicit timer cancellation and signal abortion - begin', async () => { + const timer = new Timer({ delay: 100 }); + timer.cancel('timer reason'); + const abortController = new AbortController(); + abortController.abort('abort reason'); + const p = c.f({ timer, signal: abortController.signal }); + expect(ctx_!.timer.status).toBe('settled'); + expect(ctx_!.signal.aborted).toBe(true); + await expect(p).rejects.toBe('abort reason begin'); + }); + }); +}); diff --git a/tests/contexts/functions/cancellable.test.ts b/tests/contexts/functions/cancellable.test.ts new file mode 100644 index 000000000..8a0992e98 --- /dev/null +++ b/tests/contexts/functions/cancellable.test.ts @@ -0,0 +1,280 @@ +import type { ContextCancellable } from '@/contexts/types'; +import { PromiseCancellable } from '@matrixai/async-cancellable'; +import cancellable from '@/contexts/functions/cancellable'; +import { AsyncFunction, sleep } from '@/utils'; + +describe('context/functions/cancellable', () => { + describe('cancellable decorator syntax', () => { + test('async function', async () => { + const f = async function ( + ctx: ContextCancellable, + a: number, + b: number, + ): Promise { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + return a + b; + }; + const fCancellable = cancellable(f); + const pC = fCancellable(undefined, 1, 2); + expect(pC).toBeInstanceOf(PromiseCancellable); + await pC; + await fCancellable({}, 1, 2); + await fCancellable({ signal: new AbortController().signal }, 1, 2); + expect(fCancellable).toBeInstanceOf(Function); + expect(fCancellable).not.toBeInstanceOf(AsyncFunction); + }); + }); + describe('cancellable cancellation', () => { + test('async function cancel - eager', async () => { + const f = async (ctx: ContextCancellable): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) break; + await sleep(1); + } + return 'hello world'; + }; + const fCancellable = cancellable(f); + const pC = fCancellable(); + await sleep(1); + pC.cancel(); + await expect(pC).rejects.toBeUndefined(); + }); + test('async function cancel - lazy', async () => { + const f = async (ctx: ContextCancellable): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) break; + await sleep(1); + } + return 'hello world'; + }; + const fCancellable = cancellable(f, true); + const pC = fCancellable(); + await sleep(1); + pC.cancel(); + await expect(pC).resolves.toBe('hello world'); + }); + test('async function cancel with custom error and eager rejection', async () => { + const f = async (ctx: ContextCancellable): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) break; + await sleep(1); + } + return 'hello world'; + }; + const fCancellable = cancellable(f, false); + const pC = fCancellable(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('async function cancel with custom error and lazy rejection', async () => { + const f = async (ctx: ContextCancellable): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw ctx.signal.reason; + } + await sleep(1); + } + }; + const fCancellable = cancellable(f, true); + const pC = fCancellable(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('promise cancellable function - eager rejection', async () => { + const f = (ctx: ContextCancellable): PromiseCancellable => { + const pC = new PromiseCancellable((resolve, reject, signal) => { + if (signal.aborted) { + reject('eager 2:' + signal.reason); + } else { + signal.onabort = () => { + reject('lazy 2:' + signal.reason); + }; + } + void sleep(10).then(() => { + resolve('hello world'); + }); + }); + if (ctx.signal.aborted) { + pC.cancel('eager 1:' + ctx.signal.reason); + } else { + ctx.signal.onabort = () => { + pC.cancel('lazy 1:' + ctx.signal.reason); + }; + } + return pC; + }; + const fCancellable = cancellable(f); + // Signal is aborted afterwards + const pC1 = fCancellable(); + pC1.cancel('cancel reason'); + await expect(pC1).rejects.toBe('cancel reason'); + // Signal is already aborted + const abortController = new AbortController(); + abortController.abort('cancel reason'); + const pC2 = fCancellable({ signal: abortController.signal }); + await expect(pC2).rejects.toBe('cancel reason'); + }); + test('promise cancellable function - lazy rejection', async () => { + const f = (ctx: ContextCancellable): PromiseCancellable => { + const pC = new PromiseCancellable((resolve, reject, signal) => { + if (signal.aborted) { + reject('eager 2:' + signal.reason); + } else { + signal.onabort = () => { + reject('lazy 2:' + signal.reason); + }; + } + void sleep(10).then(() => { + resolve('hello world'); + }); + }); + if (ctx.signal.aborted) { + pC.cancel('eager 1:' + ctx.signal.reason); + } else { + ctx.signal.onabort = () => { + pC.cancel('lazy 1:' + ctx.signal.reason); + }; + } + return pC; + }; + const fCancellable = cancellable(f, true); + // Signal is aborted afterwards + const pC1 = fCancellable(); + pC1.cancel('cancel reason'); + await expect(pC1).rejects.toBe('lazy 2:lazy 1:cancel reason'); + // Signal is already aborted + const abortController = new AbortController(); + abortController.abort('cancel reason'); + const pC2 = fCancellable({ signal: abortController.signal }); + await expect(pC2).rejects.toBe('lazy 2:eager 1:cancel reason'); + }); + }); + describe('cancellable propagation', () => { + test('propagate signal', async () => { + let signal: AbortSignal; + const g = async (ctx: ContextCancellable): Promise => { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + // The signal is actually not the same + // it is chained instead + expect(signal).not.toBe(ctx.signal); + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject('early:' + ctx.signal.reason); + } else { + const timeout = setTimeout(() => { + resolve('g'); + }, 10); + ctx.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject('during:' + ctx.signal.reason); + }); + } + }); + }; + const gCancellable = cancellable(g, true); + const f = async (ctx: ContextCancellable): Promise => { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + signal = ctx.signal; + return await gCancellable(ctx); + }; + const fCancellable = cancellable(f, true); + const pC1 = fCancellable(); + await expect(pC1).resolves.toBe('g'); + expect(signal!.aborted).toBe(false); + const pC2 = fCancellable(); + pC2.cancel('cancel reason'); + await expect(pC2).rejects.toBe('during:cancel reason'); + expect(signal!.aborted).toBe(true); + const abortController = new AbortController(); + abortController.abort('cancel reason'); + const pC3 = fCancellable({ signal: abortController.signal }); + await expect(pC3).rejects.toBe('early:cancel reason'); + expect(signal!.aborted).toBe(true); + }); + test('nested cancellable - lazy then lazy', async () => { + const f = async (ctx: ContextCancellable): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw 'throw:' + ctx.signal.reason; + } + await sleep(1); + } + }; + const fCancellable = cancellable(cancellable(f, true), true); + const pC = fCancellable(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('throw:cancel reason'); + }); + test('nested cancellable - lazy then eager', async () => { + const f = async (ctx: ContextCancellable): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw 'throw:' + ctx.signal.reason; + } + await sleep(1); + } + }; + const fCancellable = cancellable(cancellable(f, true), false); + const pC = fCancellable(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('nested cancellable - eager then lazy', async () => { + const f = async (ctx: ContextCancellable): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw 'throw:' + ctx.signal.reason; + } + await sleep(1); + } + }; + const fCancellable = cancellable(cancellable(f, false), true); + const pC = fCancellable(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('signal event listeners are removed', async () => { + const f = async (_ctx: ContextCancellable): Promise => { + return 'hello world'; + }; + const abortController = new AbortController(); + let listenerCount = 0; + const signal = new Proxy(abortController.signal, { + get(target, prop, receiver) { + if (prop === 'addEventListener') { + return function addEventListener(...args) { + listenerCount++; + return target[prop].apply(this, args); + }; + } else if (prop === 'removeEventListener') { + return function addEventListener(...args) { + listenerCount--; + return target[prop].apply(this, args); + }; + } else { + return Reflect.get(target, prop, receiver); + } + }, + }); + const fCancellable = cancellable(f); + await fCancellable({ signal }); + await fCancellable({ signal }); + const pC = fCancellable({ signal }); + pC.cancel(); + await expect(pC).rejects.toBe(undefined); + expect(listenerCount).toBe(0); + }); + }); +}); diff --git a/tests/contexts/functions/timed.test.ts b/tests/contexts/functions/timed.test.ts new file mode 100644 index 000000000..2cacc61bb --- /dev/null +++ b/tests/contexts/functions/timed.test.ts @@ -0,0 +1,575 @@ +import type { ContextTimed } from '@/contexts/types'; +import { Timer } from '@matrixai/timer'; +import timed from '@/contexts/functions/timed'; +import * as contextsErrors from '@/contexts/errors'; +import { + AsyncFunction, + GeneratorFunction, + AsyncGeneratorFunction, + sleep, +} from '@/utils'; + +describe('context/functions/timed', () => { + describe('timed syntax', () => { + test('function value', () => { + const f = function ( + ctx: ContextTimed, + check?: (t: Timer) => any, + ): string { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + if (check != null) check(ctx.timer); + return 'hello world'; + }; + const fTimed = timed(f); + expect(fTimed(undefined)).toBe('hello world'); + expect(fTimed({})).toBe('hello world'); + expect( + fTimed({ timer: new Timer({ delay: 50 }) }, (t) => { + expect(t.delay).toBe(50); + }), + ).toBe('hello world'); + expect(fTimed).toBeInstanceOf(Function); + }); + test('function value array', () => { + const f = function ( + ctx: ContextTimed, + check?: (t: Timer) => any, + ): Array { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + if (check != null) check(ctx.timer); + return [1, 2, 3, 4]; + }; + const fTimed = timed(f); + expect(fTimed(undefined)).toStrictEqual([1, 2, 3, 4]); + expect(fTimed({})).toStrictEqual([1, 2, 3, 4]); + expect( + fTimed({ timer: new Timer({ delay: 50 }) }, (t) => { + expect(t.delay).toBe(50); + }), + ).toStrictEqual([1, 2, 3, 4]); + expect(fTimed).toBeInstanceOf(Function); + }); + test('function promise', async () => { + const f = function ( + ctx: ContextTimed, + check?: (t: Timer) => any, + ): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + if (check != null) check(ctx.timer); + return new Promise((resolve) => void resolve()); + }; + const fTimed = timed(f); + expect(await fTimed(undefined)).toBeUndefined(); + expect(await fTimed({})).toBeUndefined(); + expect( + await fTimed({ timer: new Timer({ delay: 50 }) }, (t) => { + expect(t.delay).toBe(50); + }), + ).toBeUndefined(); + expect(fTimed).toBeInstanceOf(Function); + }); + test('async function', async () => { + const f = async function ( + ctx: ContextTimed, + check?: (t: Timer) => any, + ): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + if (check != null) check(ctx.timer); + return; + }; + const fTimed = timed(f); + await fTimed(undefined); + await fTimed({}); + await fTimed({ timer: new Timer({ delay: 50 }) }, (t) => { + expect(t.delay).toBe(50); + }); + expect(fTimed).toBeInstanceOf(AsyncFunction); + }); + test('generator', () => { + const f = function* ( + ctx: ContextTimed, + check?: (t: Timer) => any, + ): Generator { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + if (check != null) check(ctx.timer); + return; + }; + const fTimed = timed(f); + for (const _ of fTimed()) { + // NOOP + } + for (const _ of fTimed({})) { + // NOOP + } + for (const _ of fTimed({ timer: new Timer({ delay: 150 }) }, (t) => { + expect(t.delay).toBe(150); + })) { + // NOOP + } + expect(fTimed).toBeInstanceOf(GeneratorFunction); + const g = (ctx: ContextTimed, check?: (t: Timer) => any) => f(ctx, check); + const gTimed = timed(g); + for (const _ of gTimed()) { + // NOOP + } + for (const _ of gTimed({})) { + // NOOP + } + for (const _ of gTimed({ timer: new Timer({ delay: 150 }) }, (t) => { + expect(t.delay).toBe(150); + })) { + // NOOP + } + expect(gTimed).not.toBeInstanceOf(GeneratorFunction); + }); + test('async generator', async () => { + const f = async function* ( + ctx: ContextTimed, + check?: (t: Timer) => any, + ): AsyncGenerator { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + if (check != null) check(ctx.timer); + return; + }; + const fTimed = timed(f); + for await (const _ of fTimed()) { + // NOOP + } + for await (const _ of fTimed({})) { + // NOOP + } + for await (const _ of fTimed( + { timer: new Timer({ delay: 200 }) }, + (t) => { + expect(t.delay).toBe(200); + }, + )) { + // NOOP + } + expect(fTimed).toBeInstanceOf(AsyncGeneratorFunction); + const g = (ctx: ContextTimed, check?: (t: Timer) => any) => f(ctx, check); + const gTimed = timed(g); + for await (const _ of gTimed()) { + // NOOP + } + for await (const _ of gTimed({})) { + // NOOP + } + for await (const _ of gTimed( + { timer: new Timer({ delay: 200 }) }, + (t) => { + expect(t.delay).toBe(200); + }, + )) { + // NOOP + } + expect(gTimed).not.toBeInstanceOf(AsyncGeneratorFunction); + }); + }); + describe('timed expiry', () => { + // Timed decorator does not automatically reject the promise + // it only signals that it is aborted + // it is up to the function to decide how to reject + test('async function expiry', async () => { + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + await sleep(15); + expect(ctx.signal.aborted).toBe(false); + await sleep(40); + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + return 'hello world'; + }; + const fTimed = timed(f, 50); + await expect(fTimed()).resolves.toBe('hello world'); + }); + test('async function expiry with custom error', async () => { + class ErrorCustom extends Error {} + /** + * Async function + */ + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + await sleep(15); + expect(ctx.signal.aborted).toBe(false); + await sleep(40); + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf(ErrorCustom); + throw ctx.signal.reason; + }; + const fTimed = timed(f, 50, ErrorCustom); + await expect(fTimed()).rejects.toBeInstanceOf(ErrorCustom); + }); + test('promise function expiry', async () => { + /** + * Regular function returning promise + */ + const f = (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + return sleep(15) + .then(() => { + expect(ctx.signal.aborted).toBe(false); + }) + .then(() => sleep(40)) + .then(() => { + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + }) + .then(() => { + return 'hello world'; + }); + }; + const fTimed = timed(f, 50); + // Const c = new C(); + await expect(fTimed()).resolves.toBe('hello world'); + }); + test('promise function expiry and late rejection', async () => { + let timeout: ReturnType | undefined; + /** + * Regular function that actually rejects + * when the signal is aborted + */ + const f = (ctx: ContextTimed): Promise => { + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason); + } + timeout = setTimeout(() => { + resolve('hello world'); + }, 50000); + ctx.signal.onabort = () => { + clearTimeout(timeout); + timeout = undefined; + reject(ctx.signal.reason); + }; + }); + }; + const fTimed = timed(f, 50); + await expect(fTimed()).rejects.toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + expect(timeout).toBeUndefined(); + }); + test('promise function expiry and early rejection', async () => { + let timeout: ReturnType | undefined; + /** + * Regular function that actually rejects immediately + */ + const f = (ctx: ContextTimed): Promise => { + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason); + } + timeout = setTimeout(() => { + resolve('hello world'); + }, 50000); + ctx.signal.onabort = () => { + clearTimeout(timeout); + timeout = undefined; + reject(ctx.signal.reason); + }; + }); + }; + const fTimed = timed(f, 0); + await expect(fTimed()).rejects.toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + expect(timeout).toBeUndefined(); + }); + test('async generator expiry', async () => { + const f = async function* (ctx: ContextTimed): AsyncGenerator { + while (true) { + if (ctx.signal.aborted) { + throw ctx.signal.reason; + } + yield 'hello world'; + } + }; + const fTimed = timed(f, 50); + const g = fTimed(); + await expect(g.next()).resolves.toEqual({ + value: 'hello world', + done: false, + }); + await expect(g.next()).resolves.toEqual({ + value: 'hello world', + done: false, + }); + await sleep(50); + await expect(g.next()).rejects.toThrow( + contextsErrors.ErrorContextsTimedTimeOut, + ); + }); + test('generator expiry', async () => { + const f = function* (ctx: ContextTimed): Generator { + while (true) { + if (ctx.signal.aborted) { + throw ctx.signal.reason; + } + yield 'hello world'; + } + }; + const fTimed = timed(f, 50); + const g = fTimed(); + expect(g.next()).toEqual({ value: 'hello world', done: false }); + expect(g.next()).toEqual({ value: 'hello world', done: false }); + await sleep(50); + expect(() => g.next()).toThrow(contextsErrors.ErrorContextsTimedTimeOut); + }); + }); + describe('timed propagation', () => { + test('propagate timer and signal', async () => { + let timer: Timer; + let signal: AbortSignal; + const g = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + // Timer and signal will be propagated + expect(timer).toBe(ctx.timer); + expect(signal).toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(50); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + }; + const gTimed = timed(g, 25); + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await gTimed(ctx); + }; + const fTimed = timed(f, 50); + await expect(fTimed()).resolves.toBe('g'); + }); + test('propagate timer only', async () => { + let timer: Timer; + let signal: AbortSignal; + const g = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(timer).toBe(ctx.timer); + expect(signal).not.toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(50); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + }; + const gTimed = timed(g, 25); + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await gTimed({ timer: ctx.timer }); + }; + const fTimed = timed(f, 50); + await expect(fTimed()).resolves.toBe('g'); + }); + test('propagate signal only', async () => { + let timer: Timer; + let signal: AbortSignal; + const g = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + // Even though signal is propagated + // because the timer isn't, the signal here is chained + expect(timer).not.toBe(ctx.timer); + expect(signal).not.toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(25); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + }; + const gTimed = timed(g, 25); + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await gTimed({ signal: ctx.signal }); + }; + const fTimed = timed(f, 50); + await expect(fTimed()).resolves.toBe('g'); + }); + test('propagate nothing', async () => { + let timer: Timer; + let signal: AbortSignal; + const g = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(timer).not.toBe(ctx.timer); + expect(signal).not.toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(25); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + }; + const gTimed = timed(g, 25); + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await gTimed(); + }; + const fTimed = timed(f, 50); + await expect(fTimed()).resolves.toBe('g'); + }); + test('propagated expiry', async () => { + const g = async (timeout: number): Promise => { + const start = performance.now(); + let counter = 0; + while (true) { + if (performance.now() - start > timeout) { + break; + } + await sleep(1); + counter++; + } + return counter; + }; + const h = async (ctx: ContextTimed): Promise => { + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason); + return; + } + const timeout = setTimeout(() => { + resolve('hello world'); + }, 25); + ctx.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(ctx.signal.reason); + }); + }); + }; + const hTimed = timed(h, 25); + const f = async (ctx: ContextTimed): Promise => { + // The `g` will use up all the remaining time + const counter = await g(ctx.timer.getTimeout()); + expect(counter).toBeGreaterThan(0); + // The `h` will reject eventually + // it may reject immediately + // it may reject after some time + await hTimed(ctx); + return 'hello world'; + }; + const fTimed = timed(f, 25); + await expect(fTimed()).rejects.toThrow( + contextsErrors.ErrorContextsTimedTimeOut, + ); + }); + }); + describe('timed explicit timer cancellation or signal abortion', () => { + // If the timer is cancelled + // there will be no timeout error + let ctx_: ContextTimed | undefined; + const f = (ctx: ContextTimed): Promise => { + ctx_ = ctx; + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason + ' begin'); + return; + } + const timeout = setTimeout(() => { + resolve('hello world'); + }, 25); + ctx.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(ctx.signal.reason + ' during'); + }); + }); + }; + const fTimed = timed(f, 50); + beforeEach(() => { + ctx_ = undefined; + }); + test('explicit timer cancellation - begin', async () => { + const timer = new Timer({ delay: 100 }); + timer.cancel('reason'); + const p = fTimed({ timer }); + await expect(p).resolves.toBe('hello world'); + expect(ctx_!.signal.aborted).toBe(false); + }); + test('explicit timer cancellation - during', async () => { + const timer = new Timer({ delay: 100 }); + const p = fTimed({ timer }); + timer.cancel('reason'); + await expect(p).resolves.toBe('hello world'); + expect(ctx_!.signal.aborted).toBe(false); + }); + test('explicit timer cancellation - during after sleep', async () => { + const timer = new Timer({ delay: 20 }); + const p = fTimed({ timer }); + await sleep(1); + timer.cancel('reason'); + await expect(p).resolves.toBe('hello world'); + expect(ctx_!.signal.aborted).toBe(false); + }); + test('explicit signal abortion - begin', async () => { + const abortController = new AbortController(); + abortController.abort('reason'); + const p = fTimed({ signal: abortController.signal }); + expect(ctx_!.timer.status).toBe('settled'); + await expect(p).rejects.toBe('reason begin'); + }); + test('explicit signal abortion - during', async () => { + const abortController = new AbortController(); + const p = fTimed({ signal: abortController.signal }); + abortController.abort('reason'); + // Timer is also cancelled immediately + expect(ctx_!.timer.status).toBe('settled'); + await expect(p).rejects.toBe('reason during'); + }); + test('explicit signal signal abortion with passed in timer - during', async () => { + // By passing in the timer and signal explicitly + // it is expected that the timer and signal handling is already setup + const abortController = new AbortController(); + const timer = new Timer({ + handler: () => { + abortController.abort(new contextsErrors.ErrorContextsTimedTimeOut()); + }, + delay: 100, + }); + abortController.signal.addEventListener('abort', () => { + timer.cancel(); + }); + const p = fTimed({ timer, signal: abortController.signal }); + abortController.abort('abort reason'); + expect(ctx_!.timer.status).toBe('settled'); + expect(timer.status).toBe('settled'); + expect(ctx_!.signal.aborted).toBe(true); + await expect(p).rejects.toBe('abort reason during'); + }); + test('explicit timer cancellation and signal abortion - begin', async () => { + const timer = new Timer({ delay: 100 }); + timer.cancel('timer reason'); + const abortController = new AbortController(); + abortController.abort('abort reason'); + const p = fTimed({ timer, signal: abortController.signal }); + expect(ctx_!.timer.status).toBe('settled'); + expect(ctx_!.signal.aborted).toBe(true); + await expect(p).rejects.toBe('abort reason begin'); + }); + }); +}); diff --git a/tests/contexts/functions/timedCancellable.test.ts b/tests/contexts/functions/timedCancellable.test.ts new file mode 100644 index 000000000..579a0195e --- /dev/null +++ b/tests/contexts/functions/timedCancellable.test.ts @@ -0,0 +1,674 @@ +import type { ContextTimed } from '@/contexts/types'; +import { Timer } from '@matrixai/timer'; +import { PromiseCancellable } from '@matrixai/async-cancellable'; +import timedCancellable from '@/contexts/functions/timedCancellable'; +import * as contextsErrors from '@/contexts/errors'; +import { AsyncFunction, sleep, promise } from '@/utils'; + +describe('context/functions/timedCancellable', () => { + describe('timedCancellable syntax', () => { + test('function promise', async () => { + const f = function ( + ctx: ContextTimed, + check?: (t: Timer) => any, + ): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + if (check != null) check(ctx.timer); + return new Promise((resolve) => void resolve()); + }; + const fTimedCancellable = timedCancellable(f, true); + const pC = fTimedCancellable(undefined); + expect(pC).toBeInstanceOf(PromiseCancellable); + await pC; + expect(await fTimedCancellable({})).toBeUndefined(); + expect( + await fTimedCancellable({ timer: new Timer({ delay: 50 }) }, (t) => { + expect(t.delay).toBe(50); + }), + ).toBeUndefined(); + expect(fTimedCancellable).toBeInstanceOf(Function); + }); + test('async function', async () => { + const f = async function ( + ctx: ContextTimed, + check?: (t: Timer) => any, + ): Promise { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + if (check != null) check(ctx.timer); + return; + }; + const fTimedCancellable = timedCancellable(f, true); + const pC = fTimedCancellable(undefined); + expect(pC).toBeInstanceOf(PromiseCancellable); + await pC; + await fTimedCancellable({}); + await fTimedCancellable({ timer: new Timer({ delay: 50 }) }, (t) => { + expect(t.delay).toBe(50); + }); + expect(fTimedCancellable).not.toBeInstanceOf(AsyncFunction); + }); + }); + describe('timedCancellable expiry', () => { + test('async function expiry - eager', async () => { + const { p: finishedP, resolveP: resolveFinishedP } = promise(); + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + await sleep(15); + expect(ctx.signal.aborted).toBe(false); + await sleep(40); + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + resolveFinishedP(); + return 'hello world'; + }; + const fTimedCancellable = timedCancellable(f, false, 50); + await expect(fTimedCancellable()).rejects.toThrow( + contextsErrors.ErrorContextsTimedTimeOut, + ); + // Eager rejection allows the promise finish its side effects + await expect(finishedP).resolves.toBeUndefined(); + }); + test('async function expiry - lazy', async () => { + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + await sleep(15); + expect(ctx.signal.aborted).toBe(false); + await sleep(40); + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + return 'hello world'; + }; + const fTimedCancellable = timedCancellable(f, true, 50); + await expect(fTimedCancellable()).resolves.toBe('hello world'); + }); + test('async function expiry with custom error - eager', async () => { + class ErrorCustom extends Error {} + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + await sleep(15); + expect(ctx.signal.aborted).toBe(false); + await sleep(40); + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf(ErrorCustom); + throw ctx.signal.reason; + }; + const fTimedCancellable = timedCancellable(f, false, 50, ErrorCustom); + await expect(fTimedCancellable()).rejects.toBeInstanceOf(ErrorCustom); + }); + test('async function expiry with custom error - lazy', async () => { + class ErrorCustom extends Error {} + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + await sleep(15); + expect(ctx.signal.aborted).toBe(false); + await sleep(40); + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf(ErrorCustom); + throw ctx.signal.reason; + }; + const fTimedCancellable = timedCancellable(f, true, 50, ErrorCustom); + await expect(fTimedCancellable()).rejects.toBeInstanceOf(ErrorCustom); + }); + test('promise function expiry - lazy', async () => { + const f = (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + return sleep(15) + .then(() => { + expect(ctx.signal.aborted).toBe(false); + }) + .then(() => sleep(40)) + .then(() => { + expect(ctx.signal.aborted).toBe(true); + expect(ctx.signal.reason).toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + }) + .then(() => { + return 'hello world'; + }); + }; + const fTimedCancellable = timedCancellable(f, true, 50); + await expect(fTimedCancellable()).resolves.toBe('hello world'); + }); + test('promise function expiry and late rejection - lazy', async () => { + let timeout: ReturnType | undefined; + const f = (ctx: ContextTimed): Promise => { + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason); + } + timeout = setTimeout(() => { + resolve('hello world'); + }, 50000); + ctx.signal.onabort = () => { + clearTimeout(timeout); + timeout = undefined; + reject(ctx.signal.reason); + }; + }); + }; + const fTimedCancellable = timedCancellable(f, true, 50); + await expect(fTimedCancellable()).rejects.toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + expect(timeout).toBeUndefined(); + }); + test('promise function expiry and early rejection - lazy', async () => { + let timeout: ReturnType | undefined; + const f = (ctx: ContextTimed): Promise => { + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason); + } + timeout = setTimeout(() => { + resolve('hello world'); + }, 50000); + ctx.signal.onabort = () => { + clearTimeout(timeout); + timeout = undefined; + reject(ctx.signal.reason); + }; + }); + }; + const fTimedCancellable = timedCancellable(f, true, 0); + await expect(fTimedCancellable()).rejects.toBeInstanceOf( + contextsErrors.ErrorContextsTimedTimeOut, + ); + expect(timeout).toBeUndefined(); + }); + }); + describe('timedCancellable cancellation', () => { + test('async function cancel - eager', async () => { + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) break; + await sleep(1); + } + return 'hello world'; + }; + const fTimedCancellable = timedCancellable(f); + const pC = fTimedCancellable(); + await sleep(1); + pC.cancel(); + await expect(pC).rejects.toBeUndefined(); + }); + test('async function cancel - lazy', async () => { + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) break; + await sleep(1); + } + return 'hello world'; + }; + const fTimedCancellable = timedCancellable(f, true); + const pC = fTimedCancellable(); + await sleep(1); + pC.cancel(); + await expect(pC).resolves.toBe('hello world'); + }); + test('async function cancel with custom error and eager rejection', async () => { + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) break; + await sleep(1); + } + return 'hello world'; + }; + const fTimedCancellable = timedCancellable(f); + const pC = fTimedCancellable(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('async function cancel with custom error and lazy rejection', async () => { + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw ctx.signal.reason; + } + await sleep(1); + } + }; + const fTimedCancellable = timedCancellable(f, true); + const pC = fTimedCancellable(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('promise timedCancellable function - eager rejection', async () => { + const f = (ctx: ContextTimed): PromiseCancellable => { + const pC = new PromiseCancellable((resolve, reject, signal) => { + if (signal.aborted) { + reject('eager 2:' + signal.reason); + } else { + signal.onabort = () => { + reject('lazy 2:' + signal.reason); + }; + } + void sleep(10).then(() => { + resolve('hello world'); + }); + }); + if (ctx.signal.aborted) { + pC.cancel('eager 1:' + ctx.signal.reason); + } else { + ctx.signal.onabort = () => { + pC.cancel('lazy 1:' + ctx.signal.reason); + }; + } + return pC; + }; + const fTimedCancellable = timedCancellable(f); + // Signal is aborted afterwards + const pC1 = fTimedCancellable(); + pC1.cancel('cancel reason'); + await expect(pC1).rejects.toBe('cancel reason'); + // Signal is already aborted + const abortController = new AbortController(); + abortController.abort('cancel reason'); + const pC2 = fTimedCancellable({ signal: abortController.signal }); + await expect(pC2).rejects.toBe('cancel reason'); + }); + test('promise timedCancellable function - lazy rejection', async () => { + const f = (ctx: ContextTimed): PromiseCancellable => { + const pC = new PromiseCancellable((resolve, reject, signal) => { + if (signal.aborted) { + reject('eager 2:' + signal.reason); + } else { + signal.onabort = () => { + reject('lazy 2:' + signal.reason); + }; + } + void sleep(10).then(() => { + resolve('hello world'); + }); + }); + if (ctx.signal.aborted) { + pC.cancel('eager 1:' + ctx.signal.reason); + } else { + ctx.signal.onabort = () => { + pC.cancel('lazy 1:' + ctx.signal.reason); + }; + } + return pC; + }; + const fTimedCancellable = timedCancellable(f, true); + // Signal is aborted afterwards + const pC1 = fTimedCancellable(); + pC1.cancel('cancel reason'); + await expect(pC1).rejects.toBe('lazy 2:lazy 1:cancel reason'); + // Signal is already aborted + const abortController = new AbortController(); + abortController.abort('cancel reason'); + const pC2 = fTimedCancellable({ signal: abortController.signal }); + await expect(pC2).rejects.toBe('lazy 2:eager 1:cancel reason'); + }); + }); + describe('timedCancellable propagation', () => { + test('propagate timer and signal', async () => { + let timer: Timer; + let signal: AbortSignal; + const g = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + // Timer will be propagated + expect(timer).toBe(ctx.timer); + // Signal will be chained + expect(signal).not.toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(50); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + }; + const gTimedCancellable = timedCancellable(g, true, 25); + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await gTimedCancellable(ctx); + }; + const fTimedCancellable = timedCancellable(f, true, 50); + await expect(fTimedCancellable()).resolves.toBe('g'); + }); + test('propagate timer only', async () => { + let timer: Timer; + let signal: AbortSignal; + const g = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(timer).toBe(ctx.timer); + expect(signal).not.toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(50); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + }; + const gTimedCancellable = timedCancellable(g, true, 25); + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await gTimedCancellable({ timer: ctx.timer }); + }; + const fTimedCancellable = timedCancellable(f, true, 50); + await expect(fTimedCancellable()).resolves.toBe('g'); + }); + test('propagate signal only', async () => { + let timer: Timer; + let signal: AbortSignal; + const g = (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + // Even though signal is propagated + // because the timer isn't, the signal here is chained + expect(timer).not.toBe(ctx.timer); + expect(signal).not.toBe(ctx.signal); + if (!signal.aborted) { + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(25); + } else { + expect(timer.getTimeout()).toBe(0); + } + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject('early:' + ctx.signal.reason); + } else { + const timeout = setTimeout(() => { + resolve('g'); + }, 10); + ctx.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject('during:' + ctx.signal.reason); + }); + } + }); + }; + const gTimedCancellable = timedCancellable(g, true, 25); + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + if (!signal.aborted) { + expect(timer.getTimeout()).toBeGreaterThan(0); + } else { + expect(timer.getTimeout()).toBe(0); + } + return await gTimedCancellable({ signal: ctx.signal }); + }; + const fTimedCancellable = timedCancellable(f, true, 50); + const pC1 = fTimedCancellable(); + await expect(pC1).resolves.toBe('g'); + expect(signal!.aborted).toBe(false); + const pC2 = fTimedCancellable(); + pC2.cancel('cancel reason'); + await expect(pC2).rejects.toBe('during:cancel reason'); + expect(signal!.aborted).toBe(true); + const abortController = new AbortController(); + abortController.abort('cancel reason'); + const pC3 = fTimedCancellable({ signal: abortController.signal }); + await expect(pC3).rejects.toBe('early:cancel reason'); + expect(signal!.aborted).toBe(true); + }); + test('propagate nothing', async () => { + let timer: Timer; + let signal: AbortSignal; + const g = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + expect(timer).not.toBe(ctx.timer); + expect(signal).not.toBe(ctx.signal); + expect(ctx.timer.getTimeout()).toBeGreaterThan(0); + expect(ctx.timer.delay).toBe(25); + expect(ctx.signal.aborted).toBe(false); + return 'g'; + }; + const gTimedCancellable = timedCancellable(g, true, 25); + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.timer).toBeInstanceOf(Timer); + expect(ctx.signal).toBeInstanceOf(AbortSignal); + timer = ctx.timer; + signal = ctx.signal; + expect(timer.getTimeout()).toBeGreaterThan(0); + expect(signal.aborted).toBe(false); + return await gTimedCancellable(); + }; + const fTimedCancellable = timedCancellable(f, true, 50); + await expect(fTimedCancellable()).resolves.toBe('g'); + }); + test('propagated expiry', async () => { + const g = async (timeout: number): Promise => { + const start = performance.now(); + let counter = 0; + while (true) { + if (performance.now() - start > timeout) { + break; + } + await sleep(1); + counter++; + } + return counter; + }; + const h = async (ctx: ContextTimed): Promise => { + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason); + return; + } + const timeout = setTimeout(() => { + resolve('hello world'); + }, 25); + ctx.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(ctx.signal.reason); + }); + }); + }; + const hTimedCancellable = timedCancellable(h, true, 25); + const f = async (ctx: ContextTimed): Promise => { + // The `g` will use up all the remaining time + const counter = await g(ctx.timer.getTimeout()); + expect(counter).toBeGreaterThan(0); + // The `h` will reject eventually + // it may reject immediately + // it may reject after some time + await hTimedCancellable(ctx); + return 'hello world'; + }; + const fTimedCancellable = timedCancellable(f, true, 25); + await expect(fTimedCancellable()).rejects.toThrow( + contextsErrors.ErrorContextsTimedTimeOut, + ); + }); + test('nested cancellable - lazy then lazy', async () => { + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw 'throw:' + ctx.signal.reason; + } + await sleep(1); + } + }; + const fTimedCancellable = timedCancellable( + timedCancellable(f, true), + true, + ); + const pC = fTimedCancellable(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('throw:cancel reason'); + }); + test('nested cancellable - lazy then eager', async () => { + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw 'throw:' + ctx.signal.reason; + } + await sleep(1); + } + }; + const fCancellable = timedCancellable(timedCancellable(f, true), false); + const pC = fCancellable(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('nested cancellable - eager then lazy', async () => { + const f = async (ctx: ContextTimed): Promise => { + expect(ctx.signal.aborted).toBe(false); + while (true) { + if (ctx.signal.aborted) { + throw 'throw:' + ctx.signal.reason; + } + await sleep(1); + } + }; + const fCancellable = timedCancellable(timedCancellable(f, false), true); + const pC = fCancellable(); + await sleep(1); + pC.cancel('cancel reason'); + await expect(pC).rejects.toBe('cancel reason'); + }); + test('signal event listeners are removed', async () => { + const f = async (_ctx: ContextTimed): Promise => { + return 'hello world'; + }; + const abortController = new AbortController(); + let listenerCount = 0; + const signal = new Proxy(abortController.signal, { + get(target, prop, receiver) { + if (prop === 'addEventListener') { + return function addEventListener(...args) { + listenerCount++; + return target[prop].apply(this, args); + }; + } else if (prop === 'removeEventListener') { + return function addEventListener(...args) { + listenerCount--; + return target[prop].apply(this, args); + }; + } else { + return Reflect.get(target, prop, receiver); + } + }, + }); + const fTimedCancellable = timedCancellable(f); + await fTimedCancellable({ signal }); + await fTimedCancellable({ signal }); + const pC = fTimedCancellable({ signal }); + pC.cancel(); + await expect(pC).rejects.toBe(undefined); + expect(listenerCount).toBe(0); + }); + }); + describe('timedCancellable explicit timer cancellation or signal abortion', () => { + // If the timer is cancelled + // there will be no timeout error + let ctx_: ContextTimed | undefined; + const f = (ctx: ContextTimed): Promise => { + ctx_ = ctx; + return new Promise((resolve, reject) => { + if (ctx.signal.aborted) { + reject(ctx.signal.reason + ' begin'); + return; + } + const timeout = setTimeout(() => { + resolve('hello world'); + }, 25); + ctx.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(ctx.signal.reason + ' during'); + }); + }); + }; + const fTimedCancellable = timedCancellable(f, true, 50); + beforeEach(() => { + ctx_ = undefined; + }); + test('explicit timer cancellation - begin', async () => { + const timer = new Timer({ delay: 100 }); + timer.cancel('reason'); + const p = fTimedCancellable({ timer }); + await expect(p).resolves.toBe('hello world'); + expect(ctx_!.signal.aborted).toBe(false); + }); + test('explicit timer cancellation - during', async () => { + const timer = new Timer({ delay: 100 }); + const p = fTimedCancellable({ timer }); + timer.cancel('reason'); + await expect(p).resolves.toBe('hello world'); + expect(ctx_!.signal.aborted).toBe(false); + }); + test('explicit timer cancellation - during after sleep', async () => { + const timer = new Timer({ delay: 20 }); + const p = fTimedCancellable({ timer }); + await sleep(1); + timer.cancel('reason'); + await expect(p).resolves.toBe('hello world'); + expect(ctx_!.signal.aborted).toBe(false); + }); + test('explicit signal abortion - begin', async () => { + const abortController = new AbortController(); + abortController.abort('reason'); + const p = fTimedCancellable({ signal: abortController.signal }); + expect(ctx_!.timer.status).toBe('settled'); + await expect(p).rejects.toBe('reason begin'); + }); + test('explicit signal abortion - during', async () => { + const abortController = new AbortController(); + const p = fTimedCancellable({ signal: abortController.signal }); + abortController.abort('reason'); + // Timer is also cancelled immediately + expect(ctx_!.timer.status).toBe('settled'); + await expect(p).rejects.toBe('reason during'); + }); + test('explicit signal signal abortion with passed in timer - during', async () => { + // By passing in the timer and signal explicitly + // it is expected that the timer and signal handling is already setup + const abortController = new AbortController(); + const timer = new Timer({ + handler: () => { + abortController.abort(new contextsErrors.ErrorContextsTimedTimeOut()); + }, + delay: 100, + }); + abortController.signal.addEventListener('abort', () => { + timer.cancel(); + }); + const p = fTimedCancellable({ timer, signal: abortController.signal }); + abortController.abort('abort reason'); + expect(ctx_!.timer.status).toBe('settled'); + expect(timer.status).toBe('settled'); + expect(ctx_!.signal.aborted).toBe(true); + await expect(p).rejects.toBe('abort reason during'); + }); + test('explicit timer cancellation and signal abortion - begin', async () => { + const timer = new Timer({ delay: 100 }); + timer.cancel('timer reason'); + const abortController = new AbortController(); + abortController.abort('abort reason'); + const p = fTimedCancellable({ timer, signal: abortController.signal }); + expect(ctx_!.timer.status).toBe('settled'); + expect(ctx_!.signal.aborted).toBe(true); + await expect(p).rejects.toBe('abort reason begin'); + }); + }); +}); diff --git a/tests/globalTeardown.ts b/tests/globalTeardown.ts index c199c4d5b..0e3e5d30d 100644 --- a/tests/globalTeardown.ts +++ b/tests/globalTeardown.ts @@ -10,7 +10,7 @@ async function teardown() { console.log('GLOBAL TEARDOWN'); const globalDataDir = process.env['GLOBAL_DATA_DIR']!; console.log(`Destroying Global Data Dir: ${globalDataDir}`); - await fs.promises.rm(globalDataDir, { recursive: true }); + await fs.promises.rm(globalDataDir, { recursive: true, force: true }); } export default teardown; diff --git a/tests/tasks/TaskManager.test.ts b/tests/tasks/TaskManager.test.ts new file mode 100644 index 000000000..57d50ce34 --- /dev/null +++ b/tests/tasks/TaskManager.test.ts @@ -0,0 +1,1238 @@ +import type { PromiseCancellable } from '@matrixai/async-cancellable'; +import type { ContextTimed } from '@/contexts/types'; +import type { Task, TaskHandlerId, TaskPath } from '@/tasks/types'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { DB } from '@matrixai/db'; +import Logger, { LogLevel, StreamHandler } from '@matrixai/logger'; +import { Lock } from '@matrixai/async-locks'; +import * as fc from 'fast-check'; +import TaskManager from '@/tasks/TaskManager'; +import * as tasksErrors from '@/tasks/errors'; +import * as utils from '@/utils'; +import { promise, sleep, never } from '@/utils'; + +describe(TaskManager.name, () => { + const logger = new Logger(`${TaskManager.name} test`, LogLevel.WARN, [ + new StreamHandler(), + ]); + const handlerId = 'testId' as TaskHandlerId; + let dataDir: string; + let db: DB; + + beforeEach(async () => { + dataDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'polykey-test-'), + ); + const dbPath = path.join(dataDir, 'db'); + db = await DB.createDB({ + dbPath, + logger, + }); + }); + afterEach(async () => { + await db.stop(); + await fs.promises.rm(dataDir, { recursive: true, force: true }); + }); + + test('can start and stop', async () => { + const taskManager = await TaskManager.createTaskManager({ + db, + lazy: false, + logger, + }); + await taskManager.stop(); + await taskManager.start(); + await taskManager.stop(); + }); + // TODO: use timer mocking to speed up testing + test('tasks persist between Tasks object creation', async () => { + let taskManager = await TaskManager.createTaskManager({ + db, + lazy: true, + logger, + }); + const handlerId = 'asd' as TaskHandlerId; + const handler = jest.fn(); + handler.mockImplementation(async () => {}); + taskManager.registerHandler(handlerId, handler); + + await taskManager.startProcessing(); + await taskManager.scheduleTask({ + handlerId, + parameters: [1], + delay: 1000, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [2], + delay: 100, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [3], + delay: 2000, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [4], + delay: 10, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [5], + delay: 10, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [6], + delay: 10, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [7], + delay: 3000, + lazy: true, + }); + + await sleep(500); + await taskManager.stop(); + expect(handler).toHaveBeenCalledTimes(4); + + handler.mockClear(); + taskManager = await TaskManager.createTaskManager({ + db, + lazy: true, + logger, + }); + taskManager.registerHandler(handlerId, handler); + await taskManager.startProcessing(); + await sleep(4000); + await taskManager.stop(); + expect(handler).toHaveBeenCalledTimes(3); + }); + // TODO: use timer mocking to speed up testing + test('tasks persist between Tasks stop and starts', async () => { + const taskManager = await TaskManager.createTaskManager({ + db, + lazy: true, + logger, + }); + const handlerId = 'asd' as TaskHandlerId; + const handler = jest.fn(); + handler.mockImplementation(async () => {}); + taskManager.registerHandler(handlerId, handler); + + await taskManager.startProcessing(); + await taskManager.scheduleTask({ + handlerId, + parameters: [1], + delay: 1000, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [2], + delay: 100, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [3], + delay: 2000, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [4], + delay: 10, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [5], + delay: 10, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [6], + delay: 10, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [7], + delay: 3000, + lazy: true, + }); + + await sleep(500); + await taskManager.stop(); + expect(handler).toHaveBeenCalledTimes(4); + handler.mockClear(); + await taskManager.start(); + await sleep(4000); + await taskManager.stop(); + expect(handler).toHaveBeenCalledTimes(3); + }); + // FIXME: needs more experimenting to get this to work. + test.skip('tasks persist between Tasks stop and starts TIMER FAKING', async () => { + const taskManager = await TaskManager.createTaskManager({ + db, + lazy: true, + logger, + }); + const handlerId = 'asd' as TaskHandlerId; + const handler = jest.fn(); + handler.mockImplementation(async () => {}); + taskManager.registerHandler(handlerId, handler); + // Console.log('a'); + await taskManager.scheduleTask({ handlerId, parameters: [1], delay: 1000 }); + const t1 = await taskManager.scheduleTask({ + handlerId, + parameters: [1], + delay: 100, + lazy: false, + }); + await taskManager.scheduleTask({ handlerId, parameters: [1], delay: 2000 }); + await taskManager.scheduleTask({ handlerId, parameters: [1], delay: 10 }); + await taskManager.scheduleTask({ handlerId, parameters: [1], delay: 10 }); + await taskManager.scheduleTask({ handlerId, parameters: [1], delay: 10 }); + await taskManager.scheduleTask({ handlerId, parameters: [1], delay: 3000 }); + + // Setting up actions + jest.useFakeTimers(); + setTimeout(async () => { + // Console.log('starting processing'); + await taskManager.startProcessing(); + }, 0); + setTimeout(async () => { + // Console.log('stop'); + await taskManager.stop(); + }, 500); + setTimeout(async () => { + // Console.log('start'); + await taskManager.start(); + }, 1000); + + // Running tests here... + // after 600 ms we should stop and 4 taskManager should've run + jest.advanceTimersByTime(400); + jest.runAllTimers(); + jest.advanceTimersByTime(200); + // Console.log(jest.getTimerCount()); + jest.runAllTimers(); + // Console.log(jest.getTimerCount()); + await t1.promise(); + expect(handler).toHaveBeenCalledTimes(4); + // After another 5000ms the rest should've been called + handler.mockClear(); + jest.advanceTimersByTime(5000); + // Expect(handler).toHaveBeenCalledTimes(3); + jest.useRealTimers(); + await taskManager.stop(); + }); + test('activeLimit is enforced', async () => { + const activeLimit = 5; + + const taskArb = fc + .record({ + handlerId: fc.constant(handlerId), + delay: fc.integer({ min: 10, max: 1000 }), + parameters: fc.constant([]), + priority: fc.integer({ min: -200, max: 200 }), + }) + .noShrink(); + + const scheduleCommandArb = taskArb.map( + (taskSpec) => async (context: { taskManager: TaskManager }) => { + return await context.taskManager.scheduleTask({ + ...taskSpec, + lazy: false, + }); + }, + ); + + const sleepCommandArb = fc + .integer({ min: 10, max: 100 }) + .noShrink() + .map((value) => async (_context) => { + await sleep(value); + }); + + const commandsArb = fc.array( + fc.oneof( + { arbitrary: scheduleCommandArb, weight: 2 }, + { arbitrary: sleepCommandArb, weight: 1 }, + ), + { maxLength: 50, minLength: 50 }, + ); + + await fc.assert( + fc.asyncProperty(commandsArb, async (commands) => { + const taskManager = await TaskManager.createTaskManager({ + activeLimit, + db, + fresh: true, + logger, + }); + const handler = jest.fn(); + handler.mockImplementation(async () => { + await sleep(200); + }); + taskManager.registerHandler(handlerId, handler); + await taskManager.startProcessing(); + const context = { taskManager }; + + // Scheduling taskManager to be scheduled + const pendingTasks: Array> = []; + for (const command of commands) { + expect(taskManager.activeCount).toBeLessThanOrEqual(activeLimit); + const task = await command(context); + if (task != null) pendingTasks.push(task.promise()); + } + + let completed = false; + const waitForcompletionProm = (async () => { + await Promise.all(pendingTasks); + completed = true; + })(); + + // Check for active tasks while tasks are still running + while (!completed) { + expect(taskManager.activeCount).toBeLessThanOrEqual(activeLimit); + await Promise.race([sleep(100), waitForcompletionProm]); + } + + await taskManager.stop(); + }), + { interruptAfterTimeLimit: globalThis.defaultTimeout - 2000, numRuns: 3 }, + ); + }); + // TODO: Use fastCheck for this + test('tasks are handled exactly once per task', async () => { + const handler = jest.fn(); + const pendingLock = new Lock(); + const [lockReleaser] = await pendingLock.lock()(); + const resolvedTasks = new Map(); + const totalTasks = 50; + handler.mockImplementation(async (_ctx, _taskInfo, number: number) => { + resolvedTasks.set(number, (resolvedTasks.get(number) ?? 0) + 1); + if (resolvedTasks.size >= totalTasks) await lockReleaser(); + }); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + logger, + }); + + await db.withTransactionF(async (tran) => { + for (let i = 0; i < totalTasks; i++) { + await taskManager.scheduleTask( + { + handlerId, + parameters: [i], + lazy: true, + }, + tran, + ); + } + }); + + await pendingLock.waitForUnlock(); + // Each task called exactly once + resolvedTasks.forEach((value) => expect(value).toEqual(1)); + + await taskManager.stop(); + expect(handler).toHaveBeenCalledTimes(totalTasks); + }); + // TODO: use fastCheck + test('awaited taskPromises resolve', async () => { + const handler = jest.fn(); + handler.mockImplementation(async (_ctx, _taskInfo, fail) => { + if (!fail) throw Error('three'); + return fail; + }); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + logger, + }); + + const taskSucceed = await taskManager.scheduleTask({ + handlerId, + parameters: [true], + lazy: false, + }); + + // Promise should succeed with result + const taskSucceedP = taskSucceed!.promise(); + await expect(taskSucceedP).resolves.toBe(true); + + await taskManager.stop(); + }); + // TODO: use fastCheck + test('awaited taskPromises reject', async () => { + const handler = jest.fn(); + handler.mockImplementation(async (_ctx, _taskInfo, fail) => { + if (!fail) throw Error('three'); + return fail; + }); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + logger, + }); + + const taskFail = await taskManager.scheduleTask({ + handlerId, + parameters: [false], + lazy: false, + }); + + // Promise should throw + const taskFailP = taskFail.promise(); + await expect(taskFailP).rejects.toThrow(Error); + + await taskManager.stop(); + }); + // TODO: use fastCheck + test('awaited taskPromises resolve or reject', async () => { + const handler = jest.fn(); + handler.mockImplementation(async (_ctx, _taskInfo, fail) => { + if (!fail) throw Error('three'); + return fail; + }); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + logger, + }); + + const taskFail = await taskManager.scheduleTask({ + handlerId, + parameters: [false], + lazy: false, + }); + + const taskSuccess = await taskManager.scheduleTask({ + handlerId, + parameters: [true], + lazy: false, + }); + + // Promise should succeed with result + await expect(taskSuccess.promise()).resolves.toBe(true); + await expect(taskFail.promise()).rejects.toThrow(Error); + + await taskManager.stop(); + }); + test('tasks fail with no handler', async () => { + const taskManager = await TaskManager.createTaskManager({ + db, + logger, + }); + + const taskFail = await taskManager.scheduleTask({ + handlerId, + parameters: [], + lazy: false, + }); + + // Promise should throw + const taskFailP = taskFail.promise(); + await expect(taskFailP).rejects.toThrow( + tasksErrors.ErrorTaskHandlerMissing, + ); + + await taskManager.stop(); + }); + test('tasks fail with unregistered handler', async () => { + const handler = jest.fn(); + handler.mockImplementation(async (_ctx, _taskInfo, fail) => { + if (!fail) throw Error('three'); + return fail; + }); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + logger, + }); + + const taskSucceed = await taskManager.scheduleTask({ + handlerId, + parameters: [false], + lazy: false, + }); + + // Promise should succeed + const taskSucceedP = taskSucceed.promise(); + await expect(taskSucceedP).rejects.not.toThrow( + tasksErrors.ErrorTaskHandlerMissing, + ); + + // Deregister + taskManager.deregisterHandler(handlerId); + const taskFail = await taskManager.scheduleTask({ + handlerId, + parameters: [false], + lazy: false, + }); + const taskFailP = taskFail.promise(); + await expect(taskFailP).rejects.toThrow( + tasksErrors.ErrorTaskHandlerMissing, + ); + + await taskManager.stop(); + }); + test('eager taskPromise resolves when awaited after task completion', async () => { + const handler = jest.fn(); + handler.mockImplementation(async (_ctx, _taskInfo, fail) => { + if (!fail) throw Error('three'); + return fail; + }); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + lazy: true, + logger, + }); + + const taskSucceed1 = await taskManager.scheduleTask({ + handlerId, + parameters: [true], + lazy: false, + }); + await taskManager.startProcessing(); + await expect(taskSucceed1.promise()).resolves.toBe(true); + const taskSucceed2 = await taskManager.scheduleTask({ + handlerId, + parameters: [true], + lazy: false, + }); + await expect(taskSucceed2.promise()).resolves.toBe(true); + await taskManager.stop(); + }); + test('lazy taskPromise rejects when awaited after task completion', async () => { + const handler = jest.fn(); + handler.mockImplementation(async () => {}); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + lazy: true, + logger, + }); + + const taskSucceed = await taskManager.scheduleTask({ + handlerId, + parameters: [], + lazy: true, + }); + const taskProm = taskManager.getTaskPromise(taskSucceed.id); + await taskManager.startProcessing(); + await taskProm; + await expect(taskSucceed.promise()).rejects.toThrow(); + await taskManager.stop(); + }); + test('Task Promises should be singletons', async () => { + const taskManager = await TaskManager.createTaskManager({ + db, + lazy: true, + logger, + }); + + const task1 = await taskManager.scheduleTask({ + handlerId, + parameters: [], + lazy: false, + }); + const task2 = await taskManager.scheduleTask({ + handlerId, + parameters: [], + lazy: true, + }); + expect(task1.promise()).toBe(task1.promise()); + expect(task1.promise()).toBe(taskManager.getTaskPromise(task1.id)); + expect(taskManager.getTaskPromise(task1.id)).toBe( + taskManager.getTaskPromise(task1.id), + ); + expect(task2.promise()).toBe(task2.promise()); + expect(task2.promise()).toBe(taskManager.getTaskPromise(task2.id)); + expect(taskManager.getTaskPromise(task2.id)).toBe( + taskManager.getTaskPromise(task2.id), + ); + await taskManager.stop(); + }); + test('can cancel scheduled task, clean up and reject taskPromise', async () => { + const taskManager = await TaskManager.createTaskManager({ + db, + lazy: true, + logger, + }); + + const task1 = await taskManager.scheduleTask({ + handlerId, + parameters: [], + lazy: false, + }); + const task2 = await taskManager.scheduleTask({ + handlerId, + parameters: [], + lazy: true, + }); + + // Cancellation should reject promise + const taskPromise = task1.promise(); + taskPromise.cancel('cancelled'); + await expect(taskPromise).rejects.toBe('cancelled'); + // Should cancel without awaiting anything + task2.cancel('cancelled'); + await sleep(200); + + // Task should be cleaned up + expect(await taskManager.getTask(task1.id)).toBeUndefined(); + expect(await taskManager.getTask(task2.id)).toBeUndefined(); + + await taskManager.stop(); + }); + test('can cancel queued task, clean up and reject taskPromise', async () => { + const taskManager = await TaskManager.createTaskManager({ + db, + lazy: true, + logger, + }); + + const task1 = await taskManager.scheduleTask({ + handlerId, + parameters: [], + lazy: false, + }); + const task2 = await taskManager.scheduleTask({ + handlerId, + parameters: [], + lazy: true, + }); + // @ts-ignore: private method + await taskManager.startScheduling(); + await sleep(100); + + // Cancellation should reject promise + const taskPromise = task1.promise(); + taskPromise.cancel('cancelled'); + await expect(taskPromise).rejects.toBe('cancelled'); + task2.cancel('cancelled'); + await sleep(200); + + // Task should be cleaned up + expect(await taskManager.getTask(task1.id)).toBeUndefined(); + expect(await taskManager.getTask(task2.id)).toBeUndefined(); + + await taskManager.stop(); + }); + test('can cancel active task, clean up and reject taskPromise', async () => { + const handler = jest.fn(); + const pauseProm = promise(); + handler.mockImplementation(async (ctx: ContextTimed) => { + const abortProm = new Promise((resolve, reject) => + ctx.signal.addEventListener('abort', () => reject(ctx.signal.reason)), + ); + await Promise.race([pauseProm.p, abortProm]); + }); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + lazy: true, + logger, + }); + + const task1 = await taskManager.scheduleTask({ + handlerId, + parameters: [], + lazy: false, + }); + const task2 = await taskManager.scheduleTask({ + handlerId, + parameters: [], + lazy: true, + }); + await taskManager.startProcessing(); + await sleep(100); + + // Cancellation should reject promise + const taskPromise = task1.promise(); + taskPromise.cancel('cancelled'); + // Await taskPromise.catch(reason => console.error(reason)); + await expect(taskPromise).rejects.toBe('cancelled'); + task2.cancel('cancelled'); + await sleep(200); + + // Task should be cleaned up + expect(await taskManager.getTask(task1.id, true)).toBeUndefined(); + expect(await taskManager.getTask(task2.id, true)).toBeUndefined(); + pauseProm.resolveP(); + + await taskManager.stop(); + }); + test('incomplete active tasks cleaned up during startup', async () => { + const handler = jest.fn(); + handler.mockImplementation(async () => {}); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + lazy: true, + logger, + }); + + // Seeding data + const task = await taskManager.scheduleTask({ + handlerId, + parameters: [], + deadline: 100, + lazy: false, + }); + + // Moving task to active in database + const taskScheduleTime = task.scheduled.getTime(); + // @ts-ignore: private property + const tasksScheduledDbPath = taskManager.tasksScheduledDbPath; + // @ts-ignore: private property + const tasksActiveDbPath = taskManager.tasksActiveDbPath; + const taskIdBuffer = task.id.toBuffer(); + await db.withTransactionF(async (tran) => { + await tran.del([ + ...tasksScheduledDbPath, + utils.lexiPackBuffer(taskScheduleTime), + taskIdBuffer, + ]); + await tran.put([...tasksActiveDbPath, taskIdBuffer], null); + }); + + // Task should be active + const newTask1 = await taskManager.getTask(task.id); + expect(newTask1!.status).toBe('active'); + + // Restart to clean up + await taskManager.stop(); + await taskManager.start({ lazy: true }); + + // Task should be back to queued + const newTask2 = await taskManager.getTask(task.id, false); + expect(newTask2!.status).toBe('queued'); + await taskManager.startProcessing(); + await newTask2!.promise(); + + await taskManager.stop(); + }); + test('stopping should gracefully end active tasks', async () => { + const handler = jest.fn(); + const pauseProm = promise(); + handler.mockImplementation(async (ctx: ContextTimed) => { + const abortProm = new Promise((resolve, reject) => + ctx.signal.addEventListener('abort', () => + reject( + new tasksErrors.ErrorTaskRetry(undefined, { + cause: ctx.signal.reason, + }), + ), + ), + ); + await Promise.race([pauseProm.p, abortProm]); + }); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + lazy: true, + logger, + }); + + const task1 = await taskManager.scheduleTask({ + handlerId, + parameters: [], + lazy: true, + }); + const task2 = await taskManager.scheduleTask({ + handlerId, + parameters: [], + lazy: true, + }); + await taskManager.startProcessing(); + await sleep(100); + await taskManager.stop(); + + // TaskManager should still exist. + await taskManager.start({ lazy: true }); + expect(await taskManager.getTask(task1.id)).toBeDefined(); + expect(await taskManager.getTask(task2.id)).toBeDefined(); + + await taskManager.stop(); + }); + test('stopped tasks should run again if allowed', async () => { + const pauseProm = promise(); + const handlerId1 = 'handler1' as TaskHandlerId; + const handler1 = jest.fn(); + handler1.mockImplementation(async (ctx: ContextTimed) => { + const abortProm = new Promise((resolve, reject) => + ctx.signal.addEventListener('abort', () => + reject( + new tasksErrors.ErrorTaskRetry(undefined, { + cause: ctx.signal.reason, + }), + ), + ), + ); + await Promise.race([pauseProm.p, abortProm]); + }); + const handlerId2 = 'handler2' as TaskHandlerId; + const handler2 = jest.fn(); + handler2.mockImplementation(async (ctx: ContextTimed) => { + const abortProm = new Promise((resolve, reject) => + ctx.signal.addEventListener('abort', () => reject(ctx.signal.reason)), + ); + await Promise.race([pauseProm.p, abortProm]); + }); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId1]: handler1, [handlerId2]: handler2 }, + lazy: true, + logger, + }); + + const task1 = await taskManager.scheduleTask({ + handlerId: handlerId1, + parameters: [], + lazy: true, + }); + const task2 = await taskManager.scheduleTask({ + handlerId: handlerId2, + parameters: [], + lazy: true, + }); + await taskManager.startProcessing(); + await sleep(100); + await taskManager.stop(); + + // Tasks were run + expect(handler1).toHaveBeenCalled(); + expect(handler2).toHaveBeenCalled(); + handler1.mockClear(); + handler2.mockClear(); + + await taskManager.start({ lazy: true }); + const task1New = await taskManager.getTask(task1.id, false); + const task2New = await taskManager.getTask(task2.id, false); + await taskManager.startProcessing(); + // Task1 should still exist + expect(task1New).toBeDefined(); + // Task2 should've been removed + expect(task2New).toBeUndefined(); + pauseProm.resolveP(); + await expect(task1New?.promise()).resolves.toBeUndefined(); + + // Tasks were run + expect(handler1).toHaveBeenCalled(); + expect(handler2).not.toHaveBeenCalled(); + + await taskManager.stop(); + }); + test('tests for taskPath', async () => { + const taskManager = await TaskManager.createTaskManager({ + db, + lazy: true, + logger, + }); + + await taskManager.scheduleTask({ + handlerId, + parameters: [1], + path: ['one'], + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [2], + path: ['two'], + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [3], + path: ['two'], + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [4], + path: ['group1', 'three'], + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [5], + path: ['group1', 'four'], + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [6], + path: ['group1', 'four'], + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [7], + path: ['group2', 'five'], + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + parameters: [8], + path: ['group2', 'six'], + lazy: true, + }); + + const listTasks = async (taskGroup: TaskPath) => { + const taskManagerList: Array = []; + for await (const task of taskManager.getTasks( + undefined, + true, + taskGroup, + )) { + taskManagerList.push(task); + } + return taskManagerList; + }; + + expect(await listTasks(['one'])).toHaveLength(1); + expect(await listTasks(['two'])).toHaveLength(2); + expect(await listTasks(['group1'])).toHaveLength(3); + expect(await listTasks(['group1', 'four'])).toHaveLength(2); + expect(await listTasks(['group2'])).toHaveLength(2); + expect(await listTasks([])).toHaveLength(8); + }); + test('getTask', async () => { + const taskManager = await TaskManager.createTaskManager({ + db, + lazy: true, + logger, + }); + + const task1 = await taskManager.scheduleTask({ + handlerId, + parameters: [1], + lazy: true, + }); + const task2 = await taskManager.scheduleTask({ + handlerId, + parameters: [2], + lazy: true, + }); + + const gotTask1 = await taskManager.getTask(task1.id, true); + expect(task1.toString()).toEqual(gotTask1?.toString()); + const gotTask2 = await taskManager.getTask(task2.id, true); + expect(task2.toString()).toEqual(gotTask2?.toString()); + }); + test('getTasks', async () => { + const taskManager = await TaskManager.createTaskManager({ + db, + lazy: true, + logger, + }); + + await taskManager.scheduleTask({ handlerId, parameters: [1], lazy: true }); + await taskManager.scheduleTask({ handlerId, parameters: [2], lazy: true }); + await taskManager.scheduleTask({ handlerId, parameters: [3], lazy: true }); + await taskManager.scheduleTask({ handlerId, parameters: [4], lazy: true }); + + const taskList: Array = []; + for await (const task of taskManager.getTasks()) { + taskList.push(task); + } + + expect(taskList.length).toBe(4); + }); + test('updating tasks while scheduled', async () => { + const handlerId1 = 'handler1' as TaskHandlerId; + const handlerId2 = 'handler2' as TaskHandlerId; + const handler1 = jest.fn(); + const handler2 = jest.fn(); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId1]: handler1, [handlerId2]: handler2 }, + lazy: true, + logger, + }); + + const task1 = await taskManager.scheduleTask({ + handlerId: handlerId1, + delay: 100000, + parameters: [], + lazy: false, + }); + await taskManager.updateTask(task1.id, { + handlerId: handlerId2, + delay: 0, + parameters: [1], + priority: 100, + deadline: 100, + path: ['newPath'], + }); + + // Task should be updated + const oldTask = await taskManager.getTask(task1.id); + if (oldTask == null) never(); + expect(oldTask.id.equals(task1.id)).toBeTrue(); + expect(oldTask.handlerId).toEqual(handlerId2); + expect(oldTask.delay).toBe(0); + expect(oldTask.parameters).toEqual([1]); + expect(oldTask.priority).toEqual(100); + expect(oldTask.deadline).toEqual(100); + expect(oldTask.path).toEqual(['newPath']); + + // Path should've been updated + let task_: Task | undefined; + for await (const task of taskManager.getTasks(undefined, true, [ + 'newPath', + ])) { + task_ = task; + expect(task.id.equals(task1.id)).toBeTrue(); + } + expect(task_).toBeDefined(); + + await taskManager.stop(); + }); + test('updating tasks while queued or active should fail', async () => { + const handler = jest.fn(); + handler.mockImplementation(async (_ctx, _taskInfo, value) => value); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + lazy: true, + logger, + }); + // @ts-ignore: private method, only schedule tasks + await taskManager.startScheduling(); + + const task1 = await taskManager.scheduleTask({ + handlerId, + delay: 0, + parameters: [], + lazy: false, + }); + + await sleep(100); + + await expect( + taskManager.updateTask(task1.id, { + delay: 1000, + parameters: [1], + }), + ).rejects.toThrow(tasksErrors.ErrorTaskRunning); + + // Task has not been updated + const oldTask = await taskManager.getTask(task1.id); + if (oldTask == null) never(); + expect(oldTask.delay).toBe(0); + expect(oldTask.parameters).toEqual([]); + + await taskManager.stop(); + }); + test('updating tasks delay should update schedule timer', async () => { + const handlerId1 = 'handler1' as TaskHandlerId; + const handlerId2 = 'handler2' as TaskHandlerId; + const handler1 = jest.fn(); + const handler2 = jest.fn(); + handler1.mockImplementation(async (_ctx, _taskInfo, value) => value); + handler2.mockImplementation(async (_ctx, _taskInfo, value) => value); + + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId1]: handler1, [handlerId2]: handler2 }, + lazy: true, + logger, + }); + + const task1 = await taskManager.scheduleTask({ + handlerId: handlerId1, + delay: 100000, + parameters: [], + lazy: false, + }); + const task2 = await taskManager.scheduleTask({ + handlerId: handlerId1, + delay: 100000, + parameters: [], + lazy: false, + }); + + await taskManager.updateTask(task1.id, { + delay: 0, + parameters: [1], + }); + + // Task should be updated + const newTask = await taskManager.getTask(task1.id); + if (newTask == null) never(); + expect(newTask.delay).toBe(0); + expect(newTask.parameters).toEqual([1]); + + // Task should resolve with new parameter + await taskManager.startProcessing(); + await expect(task1.promise()).resolves.toBe(1); + + await sleep(100); + expect(handler1).toHaveBeenCalledTimes(1); + + // Updating task should update existing timer + await taskManager.updateTask(task2.id, { + delay: 0, + parameters: [1], + handlerId: handlerId2, + }); + await expect(task2.promise()).resolves.toBe(1); + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + + await taskManager.stop(); + }); + test('task should run after scheduled delay', async () => { + const handler = jest.fn(); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + lazy: true, + logger, + }); + + // Edge case delays + // same as 0 delay + await taskManager.scheduleTask({ + handlerId, + delay: NaN, + lazy: true, + }); + // Same as max delay + await taskManager.scheduleTask({ + handlerId, + delay: Infinity, + lazy: true, + }); + + // Normal delays + await taskManager.scheduleTask({ + handlerId, + delay: 500, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + delay: 1000, + lazy: true, + }); + await taskManager.scheduleTask({ + handlerId, + delay: 1500, + lazy: true, + }); + + expect(handler).toHaveBeenCalledTimes(0); + await taskManager.startProcessing(); + await sleep(250); + expect(handler).toHaveBeenCalledTimes(1); + await sleep(500); + expect(handler).toHaveBeenCalledTimes(2); + await sleep(500); + expect(handler).toHaveBeenCalledTimes(3); + await sleep(500); + expect(handler).toHaveBeenCalledTimes(4); + + await taskManager.stop(); + }); + test('queued tasks should be started in priority order', async () => { + const handler = jest.fn(); + const pendingProm = promise(); + const totalTasks = 31; + const completedTaskOrder: Array = []; + handler.mockImplementation(async (_ctx, _taskInfo, priority) => { + completedTaskOrder.push(priority); + if (completedTaskOrder.length >= totalTasks) pendingProm.resolveP(); + }); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + lazy: true, + logger, + }); + const expectedTaskOrder: Array = []; + for (let i = 0; i < totalTasks; i += 1) { + const priority = 150 - i * 10; + expectedTaskOrder.push(priority); + await taskManager.scheduleTask({ + handlerId, + parameters: [priority], + priority, + lazy: true, + }); + } + + // @ts-ignore: start scheduling first + await taskManager.startScheduling(); + await sleep(500); + // @ts-ignore: Then queueing + await taskManager.startQueueing(); + // Wait for all tasks to complete + await pendingProm.p; + expect(completedTaskOrder).toEqual(expectedTaskOrder); + + await taskManager.stop(); + }); + test('task exceeding deadline should abort and clean up', async () => { + const handler = jest.fn(); + const pauseProm = promise(); + handler.mockImplementation(async (ctx: ContextTimed) => { + const abortProm = new Promise((resolve, reject) => + ctx.signal.addEventListener('abort', () => reject(ctx.signal.reason)), + ); + await Promise.race([pauseProm.p, abortProm]); + }); + const taskManager = await TaskManager.createTaskManager({ + db, + handlers: { [handlerId]: handler }, + lazy: true, + logger, + }); + + const task = await taskManager.scheduleTask({ + handlerId, + parameters: [], + deadline: 100, + lazy: false, + }); + await taskManager.startProcessing(); + + // Cancellation should reject promise + const taskPromise = task.promise(); + // FIXME: check for deadline timeout error + await expect(taskPromise).rejects.toThrow(tasksErrors.ErrorTaskTimeOut); + + // Task should be cleaned up + const oldTask = await taskManager.getTask(task.id); + expect(oldTask).toBeUndefined(); + pauseProm.resolveP(); + + await taskManager.stop(); + }); + test.todo('scheduled task times should not conflict'); + // TODO: this should move the clock backwards with mocking + test.todo('taskIds are monotonic'); + // TODO: needs fast check + test.todo('general concurrent API usage to test robustness'); +}); diff --git a/tests/tasks/utils.test.ts b/tests/tasks/utils.test.ts new file mode 100644 index 000000000..179cf91f5 --- /dev/null +++ b/tests/tasks/utils.test.ts @@ -0,0 +1,98 @@ +import type { + TaskPriority, + TaskDeadline, + TaskDelay, + TaskId, +} from '@/tasks/types'; +import { IdInternal } from '@matrixai/id'; +import * as tasksUtils from '@/tasks/utils'; + +describe('tasks/utils', () => { + test('encode priority from `int8` to flipped `uint8`', () => { + expect(tasksUtils.toPriority(128)).toBe(0); + expect(tasksUtils.toPriority(127)).toBe(0); + expect(tasksUtils.toPriority(126)).toBe(1); + expect(tasksUtils.toPriority(2)).toBe(125); + expect(tasksUtils.toPriority(1)).toBe(126); + expect(tasksUtils.toPriority(0)).toBe(127); + expect(tasksUtils.toPriority(-1)).toBe(128); + expect(tasksUtils.toPriority(-2)).toBe(129); + expect(tasksUtils.toPriority(-127)).toBe(254); + expect(tasksUtils.toPriority(-128)).toBe(255); + expect(tasksUtils.toPriority(-129)).toBe(255); + }); + test('decode from priority from flipped `uint8` to `int8`', () => { + expect(tasksUtils.fromPriority(0 as TaskPriority)).toBe(127); + expect(tasksUtils.fromPriority(1 as TaskPriority)).toBe(126); + expect(tasksUtils.fromPriority(125 as TaskPriority)).toBe(2); + expect(tasksUtils.fromPriority(126 as TaskPriority)).toBe(1); + expect(tasksUtils.fromPriority(127 as TaskPriority)).toBe(0); + expect(tasksUtils.fromPriority(128 as TaskPriority)).toBe(-1); + expect(tasksUtils.fromPriority(129 as TaskPriority)).toBe(-2); + expect(tasksUtils.fromPriority(254 as TaskPriority)).toBe(-127); + expect(tasksUtils.fromPriority(255 as TaskPriority)).toBe(-128); + }); + test('toDeadline', async () => { + expect(tasksUtils.toDeadline(NaN)).toBe(0); + expect(tasksUtils.toDeadline(0)).toBe(0); + expect(tasksUtils.toDeadline(100)).toBe(100); + expect(tasksUtils.toDeadline(1000)).toBe(1000); + expect(tasksUtils.toDeadline(Infinity)).toBe(null); + }); + test('fromDeadline', async () => { + expect(tasksUtils.fromDeadline(0 as TaskDeadline)).toBe(0); + expect(tasksUtils.fromDeadline(100 as TaskDeadline)).toBe(100); + expect(tasksUtils.fromDeadline(1000 as TaskDeadline)).toBe(1000); + // @ts-ignore: typescript complains about null here + expect(tasksUtils.fromDeadline(null as TaskDeadline)).toBe(Infinity); + }); + test('toDelay', async () => { + expect(tasksUtils.toDelay(NaN)).toBe(0); + expect(tasksUtils.toDelay(0)).toBe(0); + expect(tasksUtils.toDelay(100)).toBe(100); + expect(tasksUtils.toDelay(1000)).toBe(1000); + expect(tasksUtils.toDelay(2 ** 31 - 1)).toBe(2 ** 31 - 1); + expect(tasksUtils.toDelay(2 ** 31 + 100)).toBe(2 ** 31 - 1); + expect(tasksUtils.toDelay(Infinity)).toBe(2 ** 31 - 1); + }); + test('fromDelay', async () => { + expect(tasksUtils.fromDelay((2 ** 31 - 1) as TaskDelay)).toBe(2 ** 31 - 1); + expect(tasksUtils.fromDelay((2 ** 31 + 100) as TaskDelay)).toBe( + 2 ** 31 + 100, + ); + expect(tasksUtils.fromDelay(1000 as TaskDelay)).toBe(1000); + expect(tasksUtils.fromDelay(100 as TaskDelay)).toBe(100); + expect(tasksUtils.fromDelay(0 as TaskDelay)).toBe(0); + }); + test('encodeTaskId', async () => { + const taskId1 = IdInternal.fromBuffer(Buffer.alloc(16, 0)); + const taskId2 = IdInternal.fromBuffer(Buffer.alloc(16, 100)); + const taskId3 = IdInternal.fromBuffer(Buffer.alloc(16, 255)); + + expect(tasksUtils.encodeTaskId(taskId1)).toBe( + 'v00000000000000000000000000', + ); + expect(tasksUtils.encodeTaskId(taskId2)).toBe( + 'vchi68p34chi68p34chi68p34cg', + ); + expect(tasksUtils.encodeTaskId(taskId3)).toBe( + 'vvvvvvvvvvvvvvvvvvvvvvvvvvs', + ); + }); + test('decodeTaskId', async () => { + const taskId1 = IdInternal.fromBuffer(Buffer.alloc(16, 0)); + const taskId2 = IdInternal.fromBuffer(Buffer.alloc(16, 100)); + const taskId3 = IdInternal.fromBuffer(Buffer.alloc(16, 255)); + + expect( + tasksUtils.decodeTaskId('v00000000000000000000000000')?.equals(taskId1), + ).toBe(true); + expect( + tasksUtils.decodeTaskId('vchi68p34chi68p34chi68p34cg')?.equals(taskId2), + ).toBe(true); + expect( + tasksUtils.decodeTaskId('vvvvvvvvvvvvvvvvvvvvvvvvvvs')?.equals(taskId3), + ).toBe(true); + }); + test; +}); diff --git a/tests/utils/utils.ts b/tests/utils/utils.ts index 96a831828..b2fa14e2b 100644 --- a/tests/utils/utils.ts +++ b/tests/utils/utils.ts @@ -2,6 +2,7 @@ import type { NodeId } from '@/nodes/types'; import type { PrivateKeyPem } from '@/keys/types'; import type { StatusLive } from '@/status/types'; import type Logger from '@matrixai/logger'; +import type * as fc from 'fast-check'; import path from 'path'; import fs from 'fs'; import readline from 'readline'; @@ -157,6 +158,15 @@ function describeIf(condition: boolean) { return condition ? describe : describe.skip; } +/** + * Used with fast-check to schedule calling of a function + */ +const scheduleCall = ( + s: fc.Scheduler, + f: () => Promise, + label: string = 'scheduled call', +) => s.schedule(Promise.resolve(label)).then(() => f()); + export { setupGlobalKeypair, setupTestAgent, @@ -164,4 +174,5 @@ export { expectRemoteError, testIf, describeIf, + scheduleCall, }; diff --git a/tsconfig.json b/tsconfig.json index 2fffd2833..a12043658 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "resolveJsonModule": true, "moduleResolution": "node", "module": "CommonJS", - "target": "ES2021", + "target": "ES2022", "baseUrl": "./src", "paths": { "@": ["index"],