mirror of
https://github.com/nesterow/offload.git
synced 2024-11-23 09:04:04 +00:00
init
This commit is contained in:
commit
2d559dfb31
175
.gitignore
vendored
Normal file
175
.gitignore
vendored
Normal file
|
@ -0,0 +1,175 @@
|
|||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Caches
|
||||
|
||||
.cache
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Anton Nesterov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
57
README.md
Normal file
57
README.md
Normal file
|
@ -0,0 +1,57 @@
|
|||
# Offload
|
||||
|
||||
Offload cpu-itensive tasks using WebWorkers. Offload creates a limited execution pool and can operate in two modes:
|
||||
- callback mode (default) - spawns web workers on demand when the function is called, self terminated
|
||||
- background - runs workers as backround jobs, distributes load among them, must be terminated manually
|
||||
|
||||
|
||||
*Currently uses unstable api's.*
|
||||
|
||||
To install:
|
||||
|
||||
```bash
|
||||
bun add githib:nesterow/offload # or pnpm
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Considering following worker:
|
||||
|
||||
```typescript
|
||||
// echo.worker.ts
|
||||
import { handler } from "@nesterow/offload";
|
||||
declare var self: Worker;
|
||||
|
||||
handler(async (data: string) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
console.log(data);
|
||||
return true;
|
||||
});
|
||||
```
|
||||
|
||||
In the callback mode, `print()` will spawn a worker and terminate it after the task is done.
|
||||
Maximum of 5 workers may be spawned at the same time, the rest will be queued:
|
||||
|
||||
```typescript
|
||||
import { offload } from "@nesterow/offload";
|
||||
|
||||
const [print, terminate] = offload<boolean, string>("./echo.worker.ts", 5);
|
||||
|
||||
await print("Hello, World!"); // => true
|
||||
```
|
||||
|
||||
In the background mode, offload will spawn 5 workers, `print()` will distribute the tasks among them:
|
||||
|
||||
```typescript
|
||||
import { offload } from "@nesterow/offload";
|
||||
|
||||
const [print, terminate] = offload<boolean, string>("./echo.worker.ts", 5, 'bg');
|
||||
|
||||
await print("Hello, World!"); // => true
|
||||
|
||||
terminate(); // terminate all workers, for example on exit signal
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
28
__test__/count-primes.worker.ts
Normal file
28
__test__/count-primes.worker.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { handler } from "../mod.ts";
|
||||
|
||||
const isPrime = (n: number) => {
|
||||
let result = true;
|
||||
if (n <= 1) {
|
||||
result = false;
|
||||
} else {
|
||||
for (let i = 2; i * i <= n; i++) {
|
||||
if (n % i === 0) {
|
||||
result = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
handler((n: number) => {
|
||||
let result = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (isPrime(i)) {
|
||||
result++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
7
__test__/echo-1s.worker.ts
Normal file
7
__test__/echo-1s.worker.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { handler } from "../mod.ts";
|
||||
declare var self: Worker;
|
||||
|
||||
handler(async (data: any) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return data;
|
||||
});
|
70
__test__/offload.test.ts
Normal file
70
__test__/offload.test.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { test, expect } from "bun:test";
|
||||
import { offload } from "../offload";
|
||||
|
||||
test(
|
||||
"count-primes.worker.ts",
|
||||
async () => {
|
||||
const [calculatePrime, terminate] = offload<number, number>(
|
||||
"__test__/count-primes.worker.ts",
|
||||
);
|
||||
const result = await calculatePrime(1000000);
|
||||
expect(result).toBeNumber();
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
test("throw-error.worker.ts", async () => {
|
||||
const [throwError, terminate] = offload<unknown, void>(
|
||||
"__test__/throw-error.worker.ts",
|
||||
);
|
||||
try {
|
||||
const result = await throwError();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
test("limited queue (size of 3)", async () => {
|
||||
const [echo, terminate] = offload<number, number>(
|
||||
"__test__/echo-1s.worker.ts",
|
||||
3,
|
||||
);
|
||||
const results: number[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
echo(i).then((e) => results.push(e));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1050));
|
||||
expect(results.length).toBe(3);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1050));
|
||||
expect(results.length).toBe(6);
|
||||
console.log("limited queue: Order is not guaranteed:", results);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
expect(results.length).toBe(10);
|
||||
console.log("limited queue: Order is not guaranteed:", results);
|
||||
});
|
||||
|
||||
test(
|
||||
"bg daemons (size of 3)",
|
||||
async () => {
|
||||
const [echo, terminate] = offload<number, number>(
|
||||
"__test__/echo-1s.worker.ts",
|
||||
3,
|
||||
"bg",
|
||||
);
|
||||
const results: number[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
echo(i).then((e) => results.push(e));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1050));
|
||||
expect(results.length).toBeLessThanOrEqual(4);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
expect(results.length).toBe(10);
|
||||
console.log("bg daemons: Order is not guaranteed:", results);
|
||||
|
||||
console.log("bg daemons: Always terminate background jobs");
|
||||
terminate();
|
||||
},
|
||||
{ timeout: 7000 },
|
||||
);
|
6
__test__/throw-error.worker.ts
Normal file
6
__test__/throw-error.worker.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { handler } from "../mod.ts";
|
||||
|
||||
handler(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
throw new Error("This is an error");
|
||||
});
|
5
jsr.json
Normal file
5
jsr.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@nesterow/offload",
|
||||
"version": "0.0.1",
|
||||
"exports": "./mod.ts"
|
||||
}
|
3
mod.ts
Normal file
3
mod.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from "./offload.error.ts";
|
||||
export * from "./offload.handler.ts";
|
||||
export * from "./offload.ts";
|
6
offload.error.ts
Normal file
6
offload.error.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export class OffloadError extends Error {
|
||||
constructor(message: string, options?: any) {
|
||||
super(message, options);
|
||||
this.name = "OffloadError";
|
||||
}
|
||||
}
|
14
offload.handler.ts
Normal file
14
offload.handler.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import type { WorkerResponse, WorkerRequest } from "./offload.interface";
|
||||
|
||||
declare var self: Worker;
|
||||
|
||||
type HandlerCallback<T, E> = ((data: E) => T) | ((data: E) => Awaited<T>);
|
||||
|
||||
export async function handler<T, E>(fn: HandlerCallback<T, E>): Promise<void> {
|
||||
self.addEventListener("message", async (event) => {
|
||||
const request = event.data as WorkerRequest<E>;
|
||||
const result = await fn(request.params);
|
||||
const response: WorkerResponse<T> = { id: request.id, value: result };
|
||||
self.postMessage(response);
|
||||
});
|
||||
}
|
2
offload.interface.ts
Normal file
2
offload.interface.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export type WorkerResponse<T> = { id: number; value: T };
|
||||
export type WorkerRequest<T> = { id: number; params: T };
|
168
offload.ts
Normal file
168
offload.ts
Normal file
|
@ -0,0 +1,168 @@
|
|||
import type { WorkerResponse, WorkerRequest } from "./offload.interface";
|
||||
import { OffloadError } from "./offload.error";
|
||||
|
||||
type URLlike = URL | string;
|
||||
type Callback<T, E> = (data: E) => Promise<T>;
|
||||
type Terminator = () => void; // 🦾
|
||||
type Id = number;
|
||||
type PromiseTable = Map<
|
||||
Id,
|
||||
{ resolve: (value: any) => void; reject: (reason: unknown) => void }
|
||||
>;
|
||||
type TaskCallback<T, E> = ((data: E) => Promise<T>) & { [workerId]: Worker };
|
||||
type WorkerTasks = Map<Worker, PromiseTable>;
|
||||
|
||||
const workerId = Symbol("workerId");
|
||||
const workerTasks: WorkerTasks = new Map();
|
||||
|
||||
/**
|
||||
* OffloadMode
|
||||
* 'cb' - (default) callback mode, spawns a worker on call and terminates it upon completion
|
||||
* 'bg' - runs a max number of workers of poolSize constantly in background, balances callbacks among them
|
||||
*/
|
||||
export type OffloadMode = "cb" | "bg";
|
||||
|
||||
/**
|
||||
* offload - offload a tasks to a worker
|
||||
*/
|
||||
export function offload<Return, Param>(
|
||||
url: URLlike,
|
||||
poolSize = 1,
|
||||
mode: OffloadMode = "cb",
|
||||
): [Callback<Return, Param>, Terminator] {
|
||||
switch (mode) {
|
||||
case "bg":
|
||||
return createPooledCallback(poolSize, () => {
|
||||
const bg = withMessageInterceptor(new Worker(url.toString()));
|
||||
const bgcb = createTaskCallback<Return, Param>(bg);
|
||||
return bgcb;
|
||||
});
|
||||
default:
|
||||
return createBufferedCallback(poolSize, () => {
|
||||
const worker = withMessageInterceptor(new Worker(url.toString()));
|
||||
const cb = createTaskCallback<Return, Param>(worker, () => {
|
||||
worker.terminate();
|
||||
});
|
||||
return cb;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createTaskCallback<T, E>(
|
||||
worker: Worker,
|
||||
eof?: () => void,
|
||||
): TaskCallback<T, E> {
|
||||
const cb = async function (data: E): Promise<T> {
|
||||
const id = createTaskId();
|
||||
worker.addEventListener(
|
||||
"error",
|
||||
(event) => {
|
||||
const error = event.message;
|
||||
workerTasks.get(worker)?.get(id)?.reject(new OffloadError(error, id));
|
||||
workerTasks.get(worker)?.delete(id);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const workerTask = Promise.withResolvers<T>();
|
||||
workerTasks.get(worker)?.set(id, workerTask);
|
||||
const request: WorkerRequest<E> = { id, params: data };
|
||||
worker.postMessage(request);
|
||||
try {
|
||||
const result = await workerTask.promise;
|
||||
workerTasks.get(worker)?.delete(id);
|
||||
if (eof) eof();
|
||||
return result;
|
||||
} catch (error) {
|
||||
workerTasks.get(worker)?.delete(id);
|
||||
if (eof) eof();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
cb[workerId] = worker;
|
||||
return cb;
|
||||
}
|
||||
|
||||
export function createBufferedCallback<T, E>(
|
||||
bufSize: number,
|
||||
fun: () => TaskCallback<T, E>,
|
||||
): [Callback<T, E>, Terminator] {
|
||||
let free = bufSize;
|
||||
const waitFree = async () => {
|
||||
if (free <= 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
return await waitFree();
|
||||
}
|
||||
};
|
||||
const spots: TaskCallback<T, E>[] = [];
|
||||
const term = () => {
|
||||
for (const cb of spots) {
|
||||
if (cb) terminate(cb);
|
||||
}
|
||||
};
|
||||
const call = async (data: E) => {
|
||||
if (free <= 0) await waitFree();
|
||||
--free;
|
||||
const cb = fun();
|
||||
spots[free] = cb;
|
||||
const result = await cb(data);
|
||||
delete spots[free];
|
||||
free++;
|
||||
return result;
|
||||
};
|
||||
return [call, term];
|
||||
}
|
||||
|
||||
export function createPooledCallback<T, E>(
|
||||
poolSize: number,
|
||||
fun: () => TaskCallback<T, E>,
|
||||
): [Callback<T, E>, Terminator] {
|
||||
let free = poolSize;
|
||||
const waitFree = async () => {
|
||||
if (free <= 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
return await waitFree();
|
||||
}
|
||||
};
|
||||
const spots: TaskCallback<T, E>[] = [];
|
||||
for (let i = 0; i < poolSize; i++) {
|
||||
spots[i] = fun();
|
||||
}
|
||||
const term = () => {
|
||||
for (const cb of spots) {
|
||||
terminate(cb);
|
||||
}
|
||||
};
|
||||
const call = async (data: E) => {
|
||||
if (free <= 0) await waitFree();
|
||||
--free;
|
||||
const cb = spots[0];
|
||||
const result = await cb(data);
|
||||
free++;
|
||||
return result;
|
||||
};
|
||||
return [call, term];
|
||||
}
|
||||
|
||||
function useWorker<T, E>(cb: TaskCallback<T, E>): Worker {
|
||||
return cb[workerId];
|
||||
}
|
||||
|
||||
function terminate<T, E>(cb: TaskCallback<T, E>): void {
|
||||
const worker = useWorker(cb);
|
||||
worker.terminate();
|
||||
}
|
||||
|
||||
function createTaskId(): Id {
|
||||
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
function withMessageInterceptor(worker: Worker): Worker {
|
||||
const promiseTable: PromiseTable = new Map();
|
||||
workerTasks.set(worker, promiseTable);
|
||||
worker.addEventListener("message", (event) => {
|
||||
const { id, value } = event.data as WorkerResponse<unknown>;
|
||||
promiseTable.get(id)?.resolve(value);
|
||||
promiseTable.delete(id);
|
||||
});
|
||||
return worker;
|
||||
}
|
13
package.json
Normal file
13
package.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "@nesterow/offload",
|
||||
"version": "0.0.1",
|
||||
"description": "Offload heavy tasks to a separate thread using workers",
|
||||
"module": "offload.ts",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Enable latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue