-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Typescript implementation #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8864382
a9e3016
c936623
aecaab1
b9cc8bf
4cf047a
5903fa2
ad6a3e1
8cc9f46
31e49fa
38abf11
27dbbe4
c83ce64
df14920
ab258ed
f3ef3a4
53898fa
e8e6621
0d47a84
7c8f136
807ac30
f2ea9b6
a74cf9f
47073f1
9cf8ffb
7c3bc71
dfff3c8
cb766e5
601f8af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,7 @@ | ||
| node_modules | ||
| coverage | ||
|
|
||
| dist | ||
| dist | ||
|
|
||
| .github/copilot-instructions.md | ||
| .gitconfig |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "trailingComma": "all", | ||
| "tabWidth": 2, | ||
| "singleQuote": true, | ||
| "semi": false | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,12 @@ | ||
| // @ts-check | ||
|
|
||
| import eslint from '@eslint/js'; | ||
| import tseslint from 'typescript-eslint'; | ||
| import eslint from '@eslint/js' | ||
| import tseslint from 'typescript-eslint' | ||
|
|
||
| export default tseslint.config( | ||
| eslint.configs.recommended, | ||
| ...tseslint.configs.recommended, | ||
| ); | ||
| { | ||
| ignores: ['node_modules/**', 'dist/**'], | ||
| }, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,206 +1,3 @@ | ||
| import EventEmitter from 'events' | ||
|
|
||
| import { AckPolicy } from '@nats-io/jetstream' | ||
| import { nanos, NatsError } from '@nats-io/nats-core' | ||
| import type { JetStreamClient, Consumer, JsMsg} from '@nats-io/jetstream' | ||
| import type { MsgHdrs } from '@nats-io/nats-core' | ||
|
|
||
| import { createSubject, sleep } from './utils' | ||
| import { FixedWindowLimiter, IntervalLimiter, type Limiter } from './limiter' | ||
|
|
||
|
|
||
| export type QueueOpts | ||
| = { | ||
| client: JetStreamClient | ||
| name: string | ||
|
|
||
| /** | ||
| * ms | ||
| */ | ||
| deduplicateWindow?: number | ||
| } | ||
|
|
||
| export type Job = { | ||
| id?: number | string | ||
| name: string | ||
| data: unknown | ||
| } | ||
|
|
||
| export type AddOptions = { | ||
| id?: string | ||
| headers?: MsgHdrs | ||
| } | ||
|
|
||
| export const DEFAULT_DEDUPLICATE_WINDOW = 2_000 | ||
|
|
||
| export class Queue { | ||
| protected readonly client: JetStreamClient | ||
| protected readonly name: string | ||
|
|
||
| protected readonly deduplicateWindow: number | ||
|
|
||
| constructor(opts: QueueOpts) { | ||
| this.client = opts.client | ||
| this.name = opts.name | ||
|
|
||
| this.deduplicateWindow = opts.deduplicateWindow || DEFAULT_DEDUPLICATE_WINDOW | ||
| } | ||
|
|
||
| async setup() { | ||
| const manager = await this.client.jetstreamManager() | ||
|
|
||
| try { | ||
| await manager.streams.add({ | ||
| name: this.name, | ||
| subjects: [`${this.name}.*`], | ||
| duplicate_window: nanos(this.deduplicateWindow) | ||
| }) | ||
| } catch (e) { | ||
| // TODO smart error handling | ||
| if (!(e instanceof NatsError)) { | ||
| throw e | ||
| } | ||
| await manager.streams.update( | ||
| this.name, | ||
| { | ||
| subjects: [`${this.name}.*`], | ||
| duplicate_window: nanos(this.deduplicateWindow) | ||
| } | ||
| ) | ||
| } | ||
|
|
||
| } | ||
|
|
||
| async add(name: string, data?: unknown, options?: AddOptions) { | ||
| const payload = JSON.stringify(data) | ||
| return this.client.publish(`${this.name}.${name}`, payload, options && { | ||
| msgID: options.id, | ||
| headers: options.headers | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| export type RateLimit = { | ||
| duration: number | ||
| max: number | ||
| } | ||
|
|
||
| export type WorkerOpts = { | ||
| client: JetStreamClient | ||
| name: string | ||
| processor: (job: JsMsg) => Promise<void> | ||
| concurrency?: number | ||
| rateLimit?: RateLimit | ||
| } | ||
|
|
||
| export class Worker extends EventEmitter { | ||
| protected readonly client: JetStreamClient | ||
| protected readonly name: string | ||
| protected readonly processor: (job: JsMsg) => Promise<void> | ||
| protected readonly concurrency: number | ||
| protected readonly limiter: Limiter | ||
| protected readonly fetchInterval: number | ||
| protected readonly fetchTimeout: number | ||
|
|
||
| protected consumer: Consumer | null = null | ||
| protected running = false | ||
| protected processingNow = 0 | ||
| protected loopPromise: Promise<void> | null = null | ||
|
|
||
| constructor(opts: WorkerOpts) { | ||
| super() | ||
|
|
||
| this.client = opts.client | ||
| this.name = opts.name | ||
| this.processor = opts.processor | ||
| this.concurrency = opts.concurrency || 1 | ||
|
|
||
| this.fetchInterval = 150 | ||
| this.fetchTimeout = 3_000 | ||
| this.limiter = opts.rateLimit ? | ||
| new FixedWindowLimiter(opts.rateLimit.max, opts.rateLimit.duration, this.fetchInterval) : | ||
| new IntervalLimiter(this.fetchInterval) | ||
| } | ||
|
|
||
| async setup() { | ||
| const manager = await this.client.jetstreamManager() | ||
|
|
||
| try { | ||
| await manager.streams.add({ | ||
| name: this.name, | ||
| subjects: [createSubject(this.name)], | ||
| }) | ||
| } catch (e) { | ||
| if (!(e instanceof NatsError && e.api_error?.err_code === 10058)) { | ||
| throw e | ||
| } | ||
| } | ||
|
|
||
| await manager.consumers.add(this.name, { | ||
| durable_name: this.name, | ||
| ack_policy: AckPolicy.All, | ||
| }) | ||
|
|
||
| this.consumer = await this.client.consumers.get(this.name, this.name) | ||
| } | ||
|
|
||
| async stop() { | ||
| this.running = false | ||
|
|
||
| if (this.loopPromise) { | ||
| await this.loopPromise | ||
| } | ||
| while (this.processingNow > 0) { | ||
| await sleep(this.fetchInterval) | ||
| } | ||
| } | ||
|
|
||
| start() { | ||
| if (!this.consumer) { | ||
| throw new Error('call setup() before start()') | ||
| } | ||
|
|
||
| if (!this.loopPromise) { | ||
| this.running = true | ||
| this.loopPromise = this.loop() | ||
| } | ||
| } | ||
|
|
||
| protected async loop() { | ||
| while (this.running) { | ||
| const max = this.limiter.get(this.concurrency - this.processingNow) | ||
| const jobs = await this.fetch(max) | ||
|
|
||
| for await (const j of jobs) { | ||
| this.limiter.inc() | ||
| this.process(j) // without await! | ||
| } | ||
|
|
||
| await sleep(this.limiter.timeout()) | ||
| } | ||
| } | ||
|
|
||
| protected async process(j: JsMsg) { | ||
| this.processingNow += 1 | ||
| try { | ||
| this.process(j) | ||
| await j.ackAck() | ||
| } catch (e) { | ||
| await j.term() | ||
| } finally { | ||
| this.processingNow -= 1 | ||
| } | ||
| } | ||
|
|
||
| protected async fetch(count: number) { | ||
| try { | ||
| return this.consumer!.fetch({ | ||
| max_messages: count, | ||
| expires: this.fetchTimeout | ||
| }) | ||
| } catch (e) { | ||
| // TODO | ||
| return [] | ||
| } | ||
| } | ||
| } | ||
| export * from './queue' | ||
| export * from './job' | ||
| export * from './worker' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { JobCreateData } from './types' | ||
|
|
||
| export class Job { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Надо джобу иметь для консьюмера. |
||
| id: string | ||
| name: string | ||
| meta: { | ||
| failed: boolean | ||
| startTime: number | ||
| retryCount: number | ||
| timeout: number | ||
| // parentId?: string | ||
| } | ||
| data: unknown | ||
| queueName: string | ||
|
|
||
| constructor(data: JobCreateData) { | ||
| this.id = data.id ?? `${crypto.randomUUID()}_${Date.now()}` | ||
| this.queueName = data.queueName | ||
| this.name = data.name | ||
| this.data = data.data | ||
| this.meta = { | ||
| retryCount: 0, | ||
| startTime: Date.now() + (data.delay ?? 0), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Лучше вообще не писать свойство, если это можно сделать. |
||
| failed: false, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Это по принципу отсутствие failed === false |
||
| // TODO: Is this correct? | ||
| timeout: data.timeout ?? 0, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Это нафиг |
||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| type Event<T, D> = { | ||
| event: T | ||
| data: D | ||
| } | ||
|
|
||
| export type JobCompletedEvent = Event<'JOB_COMPLETED', { jobId: string }> | ||
| export type JobFailedEvent = Event<'JOB_FAILED', { jobId: string }> | ||
| // export type JobChildCompletedEvent = Event< | ||
| // 'JOB_CHILD_COMPLETED', | ||
| // { childId: string; parentId: string } | ||
| // > | ||
| // export type JobChildFailedEvent = Event< | ||
| // 'JOB_CHILD_FAILED', | ||
| // { childId: string; parentId: string } | ||
| // > | ||
|
|
||
| export type JobEvent = | ||
| // | JobChildFailedEvent | ||
| // | JobChildCompletedEvent | ||
| JobCompletedEvent | JobFailedEvent |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Вот эти зависимости надо указывать в peerDependecies.
И в дев вернуть.