@valentinkolb/sync 2.1.1 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -351,6 +351,11 @@ await sched.register({
351
351
  misfire: "skip", // default: do not replay backlog
352
352
  meta: { owner: "ops" },
353
353
  });
354
+
355
+ await sched.triggerNow({
356
+ id: "cleanup-hourly",
357
+ key: "ops-manual-run-1", // optional but recommended for retry-safe manual triggers
358
+ });
354
359
  ```
355
360
 
356
361
  ### Scheduler features
@@ -358,10 +363,16 @@ await sched.register({
358
363
  - **Idempotent upsert registration**: repeated `register({ id, ... })` creates once, then updates in place.
359
364
  - **No fixed leader pod**: leadership uses a renewable Redis lease (`mutex`) with epoch fencing.
360
365
  - **Durable dispatch**: each cron slot maps to deterministic job key (`scheduleId:slotTs`) to prevent duplicates.
366
+ - **Durable manual trigger**: `triggerNow({ id, key? })` submits immediately through the same durable job path. Durability begins once `triggerNow()` returns a `jobId`.
361
367
  - **Misfire policies**: `skip` (default), `catch_up_one`, `catch_up_all` with cap (`maxCatchUpRuns`).
362
368
  - **Failure isolation**: submit retry + backoff, dispatch DLQ, configurable threshold for auto-advance after repeated failures.
363
369
  - **Handler safety**: optional `strictHandlers` mode (default `true`) relinquishes leadership when required handlers are missing.
364
370
 
371
+ Manual trigger notes:
372
+ - `triggerNow()` does not require `start()` and does not alter cron state (`nextRunAt`, misfire handling, due slots).
373
+ - `triggerNow()` reuses the registered schedule input. If you need custom input per run, call the underlying `job.submit(...)` directly.
374
+ - Pass `key` for retry-safe idempotent manual triggering. Without `key`, repeated calls create additional runs.
375
+
365
376
  ## Ephemeral
366
377
 
367
378
  Typed ephemeral key/value store with TTL semantics and stream events. Useful for short-lived state like presence, worker heartbeats, or temporary coordination hints.
package/index.d.ts CHANGED
@@ -3,6 +3,6 @@ export { mutex, LockError, type Mutex, type Lock, type MutexConfig } from "./src
3
3
  export { queue, type Queue, type QueueConfig, type QueueReader, type QueueRecvConfig, type QueueSendConfig, type QueueReceived, } from "./src/queue";
4
4
  export { topic, type Topic, type TopicConfig, type TopicReader, type TopicRecvConfig, type TopicPubConfig, type TopicDelivery, type TopicLiveConfig, type TopicLiveEvent, } from "./src/topic";
5
5
  export { job, type JobId, type JobStatus, type JobTerminal, type SubmitOptions, type JoinOptions, type CancelOptions, type JobEvent, type JobEvents, type JobContext, type JobHandle, type JobDefinition, } from "./src/job";
6
- export { scheduler, type Scheduler, type SchedulerConfig, type SchedulerRegisterConfig, type SchedulerUnregisterConfig, type SchedulerGetConfig, type SchedulerInfo, type SchedulerMetric, type SchedulerMetricsSnapshot, } from "./src/scheduler";
6
+ export { scheduler, type Scheduler, type SchedulerConfig, type SchedulerRegisterConfig, type SchedulerUnregisterConfig, type SchedulerTriggerNowConfig, type SchedulerGetConfig, type SchedulerInfo, type SchedulerMetric, type SchedulerMetricsSnapshot, } from "./src/scheduler";
7
7
  export { ephemeral, EphemeralCapacityError, EphemeralPayloadTooLargeError, type EphemeralConfig, type EphemeralUpsertConfig, type EphemeralTouchConfig, type EphemeralRemoveConfig, type EphemeralEntry, type EphemeralSnapshot, type EphemeralRecvConfig, type EphemeralEvent, type EphemeralReader, type EphemeralStore, } from "./src/ephemeral";
8
8
  export { retry, isRetryableTransportError, DEFAULT_RETRY_OPTIONS, type RetryOptions, } from "./src/retry";
package/index.js CHANGED
@@ -15745,6 +15745,9 @@ var scheduler = (config2) => {
15745
15745
  dispatchRetried: 0,
15746
15746
  dispatchSkipped: 0,
15747
15747
  dispatchDlq: 0,
15748
+ triggerSubmitted: 0,
15749
+ triggerFailed: 0,
15750
+ triggerRejected: 0,
15748
15751
  tickErrors: 0,
15749
15752
  lastTickAt: null
15750
15753
  };
@@ -15884,22 +15887,17 @@ var scheduler = (config2) => {
15884
15887
  message: cfg.message
15885
15888
  });
15886
15889
  };
15887
- const submitWithRetry = async (cfg) => {
15888
- await retry(async () => {
15889
- if (!await ensureLeadership({ forceRefresh: true })) {
15890
+ const submitScheduledJob = async (cfg) => {
15891
+ return await retry(async () => {
15892
+ if (cfg.requireLeadership && !await ensureLeadership({ forceRefresh: true })) {
15890
15893
  throw new Error("leadership lost during dispatch");
15891
15894
  }
15892
15895
  return await cfg.jobHandle.submit({
15893
15896
  input: cfg.schedule.input,
15894
- key: `${cfg.schedule.id}:${cfg.slotTs}`,
15897
+ key: cfg.key,
15895
15898
  keyTtlMs: scheduledJobKeyTtlMs,
15896
- at: cfg.slotTs,
15897
- meta: {
15898
- ...cfg.schedule.meta ?? {},
15899
- scheduleId: cfg.schedule.id,
15900
- scheduleSlotTs: cfg.slotTs,
15901
- schedulerId: config2.id
15902
- }
15899
+ ...cfg.at !== undefined ? { at: cfg.at } : {},
15900
+ meta: cfg.meta
15903
15901
  });
15904
15902
  }, {
15905
15903
  attempts: submitRetries + 1,
@@ -15911,9 +15909,9 @@ var scheduler = (config2) => {
15911
15909
  const err = asError4(error48);
15912
15910
  if (err.name === "ZodError")
15913
15911
  return false;
15914
- if (err.message === "leadership lost during dispatch")
15912
+ if (cfg.requireLeadership && err.message === "leadership lost during dispatch")
15915
15913
  return false;
15916
- metricsState.dispatchRetried += 1;
15914
+ cfg.onRetry?.();
15917
15915
  return true;
15918
15916
  }
15919
15917
  });
@@ -16000,10 +15998,21 @@ var scheduler = (config2) => {
16000
15998
  break;
16001
15999
  }
16002
16000
  try {
16003
- await submitWithRetry({
16001
+ const jobId = await submitScheduledJob({
16004
16002
  jobHandle,
16005
16003
  schedule,
16006
- slotTs
16004
+ key: `${schedule.id}:${slotTs}`,
16005
+ at: slotTs,
16006
+ meta: {
16007
+ ...schedule.meta ?? {},
16008
+ scheduleId: schedule.id,
16009
+ scheduleSlotTs: slotTs,
16010
+ schedulerId: config2.id
16011
+ },
16012
+ requireLeadership: true,
16013
+ onRetry: () => {
16014
+ metricsState.dispatchRetried += 1;
16015
+ }
16007
16016
  });
16008
16017
  metricsState.dispatchSubmitted += 1;
16009
16018
  submitsRemaining -= 1;
@@ -16014,7 +16023,7 @@ var scheduler = (config2) => {
16014
16023
  ts: Date.now(),
16015
16024
  scheduleId: schedule.id,
16016
16025
  slotTs,
16017
- jobId: schedule.jobId
16026
+ jobId
16018
16027
  });
16019
16028
  } catch (error48) {
16020
16029
  submitFailed = true;
@@ -16188,6 +16197,74 @@ var scheduler = (config2) => {
16188
16197
  jobsById.delete(jobId);
16189
16198
  }
16190
16199
  };
16200
+ const triggerNow = async (cfg) => {
16201
+ const raw = await redis6.get(scheduleKey(cfg.id));
16202
+ if (!raw) {
16203
+ metricsState.triggerRejected += 1;
16204
+ safeMetric(config2.onMetric, {
16205
+ type: "trigger_rejected",
16206
+ ts: Date.now(),
16207
+ scheduleId: cfg.id,
16208
+ reason: "missing_schedule"
16209
+ });
16210
+ throw new Error(`scheduler trigger rejected: missing schedule ${cfg.id}`);
16211
+ }
16212
+ const schedule = parseSchedule(raw);
16213
+ if (!schedule) {
16214
+ metricsState.triggerRejected += 1;
16215
+ safeMetric(config2.onMetric, {
16216
+ type: "trigger_rejected",
16217
+ ts: Date.now(),
16218
+ scheduleId: cfg.id,
16219
+ reason: "invalid_schedule"
16220
+ });
16221
+ throw new Error(`scheduler trigger rejected: invalid schedule ${cfg.id}`);
16222
+ }
16223
+ const jobHandle = jobsById.get(schedule.jobId);
16224
+ if (!jobHandle) {
16225
+ metricsState.triggerRejected += 1;
16226
+ safeMetric(config2.onMetric, {
16227
+ type: "trigger_rejected",
16228
+ ts: Date.now(),
16229
+ scheduleId: schedule.id,
16230
+ reason: "missing_handler"
16231
+ });
16232
+ throw new Error(`scheduler trigger rejected: missing local handler for schedule ${schedule.id}`);
16233
+ }
16234
+ try {
16235
+ const jobId = await submitScheduledJob({
16236
+ jobHandle,
16237
+ schedule,
16238
+ key: cfg.key ? `manual:${schedule.id}:${cfg.key}` : undefined,
16239
+ meta: {
16240
+ ...schedule.meta ?? {},
16241
+ scheduleId: schedule.id,
16242
+ schedulerId: config2.id,
16243
+ scheduleTrigger: "manual",
16244
+ ...cfg.key ? { scheduleManualKey: cfg.key } : {}
16245
+ },
16246
+ requireLeadership: false
16247
+ });
16248
+ metricsState.triggerSubmitted += 1;
16249
+ safeMetric(config2.onMetric, {
16250
+ type: "trigger_submitted",
16251
+ ts: Date.now(),
16252
+ scheduleId: schedule.id,
16253
+ jobId
16254
+ });
16255
+ return jobId;
16256
+ } catch (error48) {
16257
+ metricsState.triggerFailed += 1;
16258
+ const err = asError4(error48);
16259
+ safeMetric(config2.onMetric, {
16260
+ type: "trigger_failed",
16261
+ ts: Date.now(),
16262
+ scheduleId: schedule.id,
16263
+ message: err.message
16264
+ });
16265
+ throw err;
16266
+ }
16267
+ };
16191
16268
  const get = async (cfg) => {
16192
16269
  const raw = await redis6.get(scheduleKey(cfg.id));
16193
16270
  const parsed = parseSchedule(raw);
@@ -16241,6 +16318,7 @@ var scheduler = (config2) => {
16241
16318
  stop,
16242
16319
  register,
16243
16320
  unregister,
16321
+ triggerNow,
16244
16322
  get,
16245
16323
  list,
16246
16324
  metrics
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/sync",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "Distributed synchronization primitives for Bun and TypeScript",
5
5
  "main": "index.js",
6
6
  "module": "index.js",
@@ -63,6 +63,21 @@ export type SchedulerMetric = {
63
63
  scheduleId: string;
64
64
  slotTs: number;
65
65
  failures: number;
66
+ } | {
67
+ type: "trigger_submitted";
68
+ ts: number;
69
+ scheduleId: string;
70
+ jobId: string;
71
+ } | {
72
+ type: "trigger_rejected";
73
+ ts: number;
74
+ scheduleId: string;
75
+ reason: "missing_schedule" | "missing_handler" | "invalid_schedule";
76
+ } | {
77
+ type: "trigger_failed";
78
+ ts: number;
79
+ scheduleId: string;
80
+ message: string;
66
81
  };
67
82
  export type SchedulerConfig = {
68
83
  id: string;
@@ -98,6 +113,10 @@ export type SchedulerRegisterConfig = {
98
113
  export type SchedulerUnregisterConfig = {
99
114
  id: string;
100
115
  };
116
+ export type SchedulerTriggerNowConfig = {
117
+ id: string;
118
+ key?: string;
119
+ };
101
120
  export type SchedulerGetConfig = {
102
121
  id: string;
103
122
  };
@@ -121,6 +140,9 @@ export type SchedulerMetricsSnapshot = {
121
140
  dispatchRetried: number;
122
141
  dispatchSkipped: number;
123
142
  dispatchDlq: number;
143
+ triggerSubmitted: number;
144
+ triggerFailed: number;
145
+ triggerRejected: number;
124
146
  tickErrors: number;
125
147
  lastTickAt: number | null;
126
148
  };
@@ -133,6 +155,7 @@ export type Scheduler = {
133
155
  updated: boolean;
134
156
  }>;
135
157
  unregister(cfg: SchedulerUnregisterConfig): Promise<void>;
158
+ triggerNow(cfg: SchedulerTriggerNowConfig): Promise<string>;
136
159
  get(cfg: SchedulerGetConfig): Promise<SchedulerInfo | null>;
137
160
  list(): Promise<SchedulerInfo[]>;
138
161
  metrics(): SchedulerMetricsSnapshot;