@gravito/horizon 3.0.0 → 3.2.0

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/dist/index.cjs CHANGED
@@ -7822,128 +7822,118 @@ __export(index_exports, {
7822
7822
  module.exports = __toCommonJS(index_exports);
7823
7823
 
7824
7824
  // src/SimpleCronParser.ts
7825
- var SimpleCronParser = {
7825
+ var SimpleCronParser = class {
7826
7826
  /**
7827
- * Check if a cron expression matches the given date.
7828
- * Only supports standard 5-field cron expressions.
7827
+ * Evaluates if a cron expression matches the specified date and timezone.
7829
7828
  *
7830
- * Fields: minute hour day-of-month month day-of-week
7831
- * Values:
7832
- * - * (any)
7833
- * - numbers (0-59, 1-31, 0-11, 0-7)
7834
- * - ranges (1-5)
7835
- * - lists (1,2,3)
7836
- * - steps (*\/5, 1-10/2)
7829
+ * @param expression - Standard 5-part cron expression.
7830
+ * @param timezone - Target timezone for comparison (default: "UTC").
7831
+ * @param date - Reference date to check (default: now).
7832
+ * @returns True if the expression is due at the given minute.
7833
+ * @throws {Error} If the expression is malformed or the timezone is invalid.
7834
+ *
7835
+ * @example
7836
+ * ```typescript
7837
+ * const isDue = SimpleCronParser.isDue('0 * * * *', 'Asia/Taipei');
7838
+ * ```
7837
7839
  */
7838
- isDue(expression, timezone, date) {
7839
- const fields = expression.trim().split(/\s+/);
7840
- if (fields.length !== 5) {
7841
- throw new Error("SimpleCronParser only supports 5-field cron expressions");
7842
- }
7843
- let targetDate = date;
7844
- if (timezone && timezone !== "UTC") {
7845
- try {
7846
- const parts = new Intl.DateTimeFormat("en-US", {
7847
- timeZone: timezone,
7848
- year: "numeric",
7849
- month: "numeric",
7850
- day: "numeric",
7851
- hour: "numeric",
7852
- minute: "numeric",
7853
- second: "numeric",
7854
- hour12: false
7855
- }).formatToParts(date);
7856
- const partMap = {};
7857
- parts.forEach((p) => {
7858
- partMap[p.type] = p.value;
7859
- });
7860
- targetDate = new Date(
7861
- parseInt(partMap.year ?? "0", 10),
7862
- parseInt(partMap.month ?? "1", 10) - 1,
7863
- parseInt(partMap.day ?? "1", 10),
7864
- parseInt(partMap.hour ?? "0", 10) === 24 ? 0 : parseInt(partMap.hour ?? "0", 10),
7865
- parseInt(partMap.minute ?? "0", 10),
7866
- 0
7867
- );
7868
- } catch (_e) {
7869
- throw new Error(`Invalid timezone: ${timezone}`);
7870
- }
7871
- } else if (timezone === "UTC") {
7872
- targetDate = new Date(
7873
- date.getUTCFullYear(),
7874
- date.getUTCMonth(),
7875
- date.getUTCDate(),
7876
- date.getUTCHours(),
7877
- date.getUTCMinutes(),
7878
- 0
7879
- );
7880
- }
7881
- const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
7882
- if (minute === void 0 || hour === void 0 || dayOfMonth === void 0 || month === void 0 || dayOfWeek === void 0) {
7883
- throw new Error("Invalid cron expression");
7840
+ static isDue(expression, timezone = "UTC", date = /* @__PURE__ */ new Date()) {
7841
+ const parts = expression.trim().split(/\s+/);
7842
+ if (parts.length !== 5) {
7843
+ throw new Error(`Invalid cron expression: ${expression}`);
7884
7844
  }
7885
- const matchMinute = matchField(minute, targetDate.getMinutes(), 0, 59);
7886
- const matchHour = matchField(hour, targetDate.getHours(), 0, 23);
7887
- const matchDayOfMonth = matchField(dayOfMonth, targetDate.getDate(), 1, 31);
7888
- const matchMonth = matchField(month, targetDate.getMonth() + 1, 1, 12);
7889
- const matchDayOfWeek = matchField(dayOfWeek, targetDate.getDay(), 0, 7);
7890
- return matchMinute && matchHour && matchDayOfMonth && matchMonth && matchDayOfWeek;
7891
- }
7892
- };
7893
- function matchField(pattern, value, min, max) {
7894
- if (pattern === "*") {
7895
- return true;
7845
+ const targetDate = this.getDateInTimezone(date, timezone);
7846
+ const minutes = targetDate.getMinutes();
7847
+ const hours = targetDate.getHours();
7848
+ const dayOfMonth = targetDate.getDate();
7849
+ const month = targetDate.getMonth() + 1;
7850
+ const dayOfWeek = targetDate.getDay();
7851
+ return this.match(parts[0], minutes, 0, 59) && this.match(parts[1], hours, 0, 23) && this.match(parts[2], dayOfMonth, 1, 31) && this.match(parts[3], month, 1, 12) && this.match(parts[4], dayOfWeek, 0, 6, true);
7896
7852
  }
7897
- if (pattern.includes("/")) {
7898
- const [range, stepStr] = pattern.split("/");
7899
- if (range === void 0 || stepStr === void 0) {
7900
- return false;
7853
+ /**
7854
+ * Internal pattern matching logic for individual cron fields.
7855
+ *
7856
+ * @param pattern - Cron sub-expression (e.g., "1-5").
7857
+ * @param value - Extracted date component value.
7858
+ * @param _min - Field minimum boundary.
7859
+ * @param _max - Field maximum boundary.
7860
+ * @param isDayOfWeek - Special handling for Sunday (0 and 7).
7861
+ * @returns Boolean indicating a match.
7862
+ *
7863
+ * @internal
7864
+ */
7865
+ static match(pattern, value, _min, _max, isDayOfWeek = false) {
7866
+ if (pattern === "*") {
7867
+ return true;
7901
7868
  }
7902
- const step = parseInt(stepStr, 10);
7903
- if (range === "*") {
7904
- return (value - min) % step === 0;
7869
+ if (pattern.includes(",")) {
7870
+ return pattern.split(",").some((p) => this.match(p, value, _min, _max, isDayOfWeek));
7905
7871
  }
7906
- if (range.includes("-")) {
7907
- const [startStr, endStr] = range.split("-");
7908
- if (startStr === void 0 || endStr === void 0) {
7909
- return false;
7910
- }
7911
- const start = parseInt(startStr, 10);
7912
- const end = parseInt(endStr, 10);
7913
- if (value >= start && value <= end) {
7914
- return (value - start) % step === 0;
7872
+ const stepMatch = pattern.match(/^(\*|\d+(-\d+)?)\/(\d+)$/);
7873
+ if (stepMatch) {
7874
+ const range = stepMatch[1];
7875
+ const step = parseInt(stepMatch[3], 10);
7876
+ if (range === "*") {
7877
+ return value % step === 0;
7915
7878
  }
7916
- return false;
7879
+ const [rMin, rMax] = range.split("-").map((n) => parseInt(n, 10));
7880
+ return value >= rMin && value <= rMax && (value - rMin) % step === 0;
7917
7881
  }
7918
- }
7919
- if (pattern.includes(",")) {
7920
- const parts = pattern.split(",");
7921
- return parts.some((part) => matchField(part, value, min, max));
7922
- }
7923
- if (pattern.includes("-")) {
7924
- const [startStr, endStr] = pattern.split("-");
7925
- if (startStr === void 0 || endStr === void 0) {
7926
- return false;
7882
+ if (pattern.includes("-")) {
7883
+ const [rMin, rMax] = pattern.split("-").map((n) => parseInt(n, 10));
7884
+ return value >= rMin && value <= rMax;
7927
7885
  }
7928
- const start = parseInt(startStr, 10);
7929
- const end = parseInt(endStr, 10);
7930
- return value >= start && value <= end;
7886
+ const patternVal = parseInt(pattern, 10);
7887
+ if (isDayOfWeek && patternVal === 7 && value === 0) {
7888
+ return true;
7889
+ }
7890
+ return patternVal === value;
7931
7891
  }
7932
- const expected = parseInt(pattern, 10);
7933
- if (max === 7 && expected === 7 && value === 0) {
7934
- return true;
7892
+ /**
7893
+ * Resolves a Date object to the specified timezone.
7894
+ *
7895
+ * @param date - Source UTC date.
7896
+ * @param timezone - Target timezone string.
7897
+ * @returns Localized Date object.
7898
+ * @throws {Error} If the timezone is invalid.
7899
+ *
7900
+ * @internal
7901
+ */
7902
+ static getDateInTimezone(date, timezone) {
7903
+ try {
7904
+ const tzDate = new Date(date.toLocaleString("en-US", { timeZone: timezone }));
7905
+ if (Number.isNaN(tzDate.getTime())) {
7906
+ throw new Error();
7907
+ }
7908
+ return tzDate;
7909
+ } catch {
7910
+ throw new Error(`Invalid timezone: ${timezone}`);
7911
+ }
7935
7912
  }
7936
- return value === expected;
7937
- }
7913
+ };
7938
7914
 
7939
7915
  // src/CronParser.ts
7940
7916
  var CronParser = class {
7917
+ static cache = /* @__PURE__ */ new Map();
7918
+ /** Cache duration in milliseconds (1 minute). */
7919
+ static CACHE_TTL = 6e4;
7920
+ /** Maximum number of unique expression/timezone combinations to store. */
7921
+ static MAX_CACHE_SIZE = 500;
7941
7922
  /**
7942
- * Get the next execution date based on a cron expression.
7923
+ * Calculates the next occurrence of a cron expression.
7943
7924
  *
7944
- * @param expression - Cron expression
7945
- * @param timezone - Timezone identifier
7946
- * @param currentDate - Reference date
7925
+ * Dynamically loads `cron-parser` to minimize initial bundle size and memory footprint.
7926
+ *
7927
+ * @param expression - Valid 5-part cron expression.
7928
+ * @param timezone - Target timezone for evaluation (default: "UTC").
7929
+ * @param currentDate - Reference time to calculate from (default: now).
7930
+ * @returns Resolves to the next execution Date object.
7931
+ * @throws {Error} If the expression format is invalid.
7932
+ *
7933
+ * @example
7934
+ * ```typescript
7935
+ * const next = await CronParser.nextDate('0 0 * * *');
7936
+ * ```
7947
7937
  */
7948
7938
  static async nextDate(expression, timezone = "UTC", currentDate = /* @__PURE__ */ new Date()) {
7949
7939
  try {
@@ -7958,13 +7948,47 @@ var CronParser = class {
7958
7948
  }
7959
7949
  }
7960
7950
  /**
7961
- * Check if the cron expression is due to run at the current time (minute precision).
7951
+ * Determines if a task is due for execution at the specified time.
7952
+ *
7953
+ * Uses minute-precision caching to optimize repeated evaluations within
7954
+ * the same scheduling window. Implements LRU eviction to maintain memory efficiency.
7962
7955
  *
7963
- * @param expression - Cron expression
7964
- * @param timezone - Timezone identifier
7965
- * @param currentDate - Reference date
7956
+ * @param expression - Cron expression to evaluate.
7957
+ * @param timezone - Execution timezone.
7958
+ * @param currentDate - Reference time for the check.
7959
+ * @returns True if the expression matches the reference time.
7966
7960
  */
7967
7961
  static async isDue(expression, timezone = "UTC", currentDate = /* @__PURE__ */ new Date()) {
7962
+ const minuteKey = `${expression}:${timezone}:${Math.floor(currentDate.getTime() / 6e4)}`;
7963
+ const now = Date.now();
7964
+ const cached = this.cache.get(minuteKey);
7965
+ if (cached) {
7966
+ if (now - cached.timestamp < this.CACHE_TTL) {
7967
+ this.cache.delete(minuteKey);
7968
+ this.cache.set(minuteKey, cached);
7969
+ return cached.result;
7970
+ }
7971
+ this.cache.delete(minuteKey);
7972
+ }
7973
+ const result = await this.computeIsDue(expression, timezone, currentDate);
7974
+ this.cache.set(minuteKey, {
7975
+ result,
7976
+ timestamp: now
7977
+ });
7978
+ this.cleanupCache();
7979
+ return result;
7980
+ }
7981
+ /**
7982
+ * Internal logic for tiered cron evaluation.
7983
+ *
7984
+ * @param expression - Cron expression.
7985
+ * @param timezone - Target timezone.
7986
+ * @param currentDate - Current time.
7987
+ * @returns Boolean indicating if the task is due.
7988
+ *
7989
+ * @internal
7990
+ */
7991
+ static async computeIsDue(expression, timezone, currentDate) {
7968
7992
  try {
7969
7993
  return SimpleCronParser.isDue(expression, timezone, currentDate);
7970
7994
  } catch (_e) {
@@ -7982,6 +8006,33 @@ var CronParser = class {
7982
8006
  return false;
7983
8007
  }
7984
8008
  }
8009
+ /**
8010
+ * Evicts the oldest entry from the cache when capacity is reached.
8011
+ *
8012
+ * @internal
8013
+ */
8014
+ static cleanupCache() {
8015
+ if (this.cache.size <= this.MAX_CACHE_SIZE) {
8016
+ return;
8017
+ }
8018
+ const iterator = this.cache.keys();
8019
+ const oldestKey = iterator.next().value;
8020
+ if (oldestKey) {
8021
+ this.cache.delete(oldestKey);
8022
+ }
8023
+ }
8024
+ /**
8025
+ * Purges all entries from the internal cache.
8026
+ * Useful for testing or when global timezone settings change.
8027
+ */
8028
+ static clearCache() {
8029
+ this.cache.clear();
8030
+ }
8031
+ /**
8032
+ * Compares two dates with minute precision.
8033
+ *
8034
+ * @internal
8035
+ */
7985
8036
  static minuteMatches(date1, date2) {
7986
8037
  return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate() && date1.getHours() === date2.getHours() && date1.getMinutes() === date2.getMinutes();
7987
8038
  }
@@ -7989,22 +8040,55 @@ var CronParser = class {
7989
8040
 
7990
8041
  // src/locks/CacheLockStore.ts
7991
8042
  var CacheLockStore = class {
8043
+ /**
8044
+ * Initializes the store with a cache manager.
8045
+ *
8046
+ * @param cache - The Stasis cache instance.
8047
+ * @param prefix - Key prefix to avoid collisions in the shared namespace.
8048
+ */
7992
8049
  constructor(cache, prefix = "scheduler:lock:") {
7993
8050
  this.cache = cache;
7994
8051
  this.prefix = prefix;
7995
8052
  }
8053
+ /**
8054
+ * Computes the fully qualified cache key.
8055
+ *
8056
+ * @internal
8057
+ */
7996
8058
  getKey(key) {
7997
8059
  return this.prefix + key;
7998
8060
  }
8061
+ /**
8062
+ * Atomic 'add' operation ensures only one node succeeds.
8063
+ *
8064
+ * @param key - Lock key.
8065
+ * @param ttlSeconds - Expiration.
8066
+ */
7999
8067
  async acquire(key, ttlSeconds) {
8000
8068
  return this.cache.add(this.getKey(key), "LOCKED", ttlSeconds);
8001
8069
  }
8070
+ /**
8071
+ * Removes the lock key from cache.
8072
+ *
8073
+ * @param key - Lock key.
8074
+ */
8002
8075
  async release(key) {
8003
8076
  await this.cache.forget(this.getKey(key));
8004
8077
  }
8078
+ /**
8079
+ * Overwrites the lock key, effectively resetting the TTL.
8080
+ *
8081
+ * @param key - Lock key.
8082
+ * @param ttlSeconds - New expiration.
8083
+ */
8005
8084
  async forceAcquire(key, ttlSeconds) {
8006
8085
  await this.cache.put(this.getKey(key), "LOCKED", ttlSeconds);
8007
8086
  }
8087
+ /**
8088
+ * Validates if the lock key is present in the cache.
8089
+ *
8090
+ * @param key - Lock key.
8091
+ */
8008
8092
  async exists(key) {
8009
8093
  return this.cache.has(this.getKey(key));
8010
8094
  }
@@ -8012,7 +8096,14 @@ var CacheLockStore = class {
8012
8096
 
8013
8097
  // src/locks/MemoryLockStore.ts
8014
8098
  var MemoryLockStore = class {
8099
+ /** Map of lock keys to their expiration timestamps (ms). */
8015
8100
  locks = /* @__PURE__ */ new Map();
8101
+ /**
8102
+ * Acquires a local lock if the key is not already active.
8103
+ *
8104
+ * @param key - Lock identifier.
8105
+ * @param ttlSeconds - Expiration duration.
8106
+ */
8016
8107
  async acquire(key, ttlSeconds) {
8017
8108
  const NOW = Date.now();
8018
8109
  const expiresAt = this.locks.get(key);
@@ -8022,12 +8113,30 @@ var MemoryLockStore = class {
8022
8113
  this.locks.set(key, NOW + ttlSeconds * 1e3);
8023
8114
  return true;
8024
8115
  }
8116
+ /**
8117
+ * Deletes the lock from local memory.
8118
+ *
8119
+ * @param key - Lock identifier.
8120
+ */
8025
8121
  async release(key) {
8026
8122
  this.locks.delete(key);
8027
8123
  }
8124
+ /**
8125
+ * Sets or overwrites a local lock.
8126
+ *
8127
+ * @param key - Lock identifier.
8128
+ * @param ttlSeconds - Expiration.
8129
+ */
8028
8130
  async forceAcquire(key, ttlSeconds) {
8029
8131
  this.locks.set(key, Date.now() + ttlSeconds * 1e3);
8030
8132
  }
8133
+ /**
8134
+ * Checks if a local lock is present and hasn't expired.
8135
+ *
8136
+ * Automatically purges expired locks upon checking.
8137
+ *
8138
+ * @param key - Lock identifier.
8139
+ */
8031
8140
  async exists(key) {
8032
8141
  const expiresAt = this.locks.get(key);
8033
8142
  if (!expiresAt) {
@@ -8044,6 +8153,13 @@ var MemoryLockStore = class {
8044
8153
  // src/locks/LockManager.ts
8045
8154
  var LockManager = class {
8046
8155
  store;
8156
+ /**
8157
+ * Initializes the manager with a specific storage driver.
8158
+ *
8159
+ * @param driver - Strategy identifier or a custom `LockStore` implementation.
8160
+ * @param context - Dependencies required by certain drivers (e.g., CacheManager).
8161
+ * @throws {Error} If 'cache' driver is selected but no `CacheManager` is provided.
8162
+ */
8047
8163
  constructor(driver, context) {
8048
8164
  if (typeof driver === "object") {
8049
8165
  this.store = driver;
@@ -8058,15 +8174,43 @@ var LockManager = class {
8058
8174
  this.store = new MemoryLockStore();
8059
8175
  }
8060
8176
  }
8177
+ /**
8178
+ * Attempts to acquire a lock. Fails if the key is already locked.
8179
+ *
8180
+ * @param key - Unique identifier for the lock.
8181
+ * @param ttlSeconds - Time-to-live in seconds before the lock expires.
8182
+ * @returns True if the lock was successfully acquired.
8183
+ */
8061
8184
  async acquire(key, ttlSeconds) {
8062
8185
  return this.store.acquire(key, ttlSeconds);
8063
8186
  }
8187
+ /**
8188
+ * Explicitly releases a held lock.
8189
+ *
8190
+ * @param key - The lock identifier to remove.
8191
+ * @returns Resolves when the lock is deleted.
8192
+ */
8064
8193
  async release(key) {
8065
8194
  return this.store.release(key);
8066
8195
  }
8196
+ /**
8197
+ * Forcibly acquires or overwrites an existing lock.
8198
+ *
8199
+ * Used for execution locks where the latest attempt should take precedence
8200
+ * if the previous one is deemed expired.
8201
+ *
8202
+ * @param key - Lock identifier.
8203
+ * @param ttlSeconds - Expiration duration.
8204
+ */
8067
8205
  async forceAcquire(key, ttlSeconds) {
8068
8206
  return this.store.forceAcquire(key, ttlSeconds);
8069
8207
  }
8208
+ /**
8209
+ * Checks if a specific lock currently exists and is not expired.
8210
+ *
8211
+ * @param key - Lock identifier.
8212
+ * @returns True if the key is locked and active.
8213
+ */
8070
8214
  async exists(key) {
8071
8215
  return this.store.exists(key);
8072
8216
  }
@@ -8093,19 +8237,55 @@ async function runProcess(command) {
8093
8237
  };
8094
8238
  }
8095
8239
  var Process = class {
8240
+ /**
8241
+ * Static alias for `runProcess`.
8242
+ *
8243
+ * @param command - Command to execute.
8244
+ * @returns Process outcome.
8245
+ */
8096
8246
  static async run(command) {
8097
8247
  return runProcess(command);
8098
8248
  }
8099
8249
  };
8100
8250
 
8251
+ // src/utils/validation.ts
8252
+ function parseTime(time) {
8253
+ const timePattern = /^([0-2]\d):([0-5]\d)$/;
8254
+ const match = time.match(timePattern);
8255
+ if (!match) {
8256
+ throw new Error(`Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`);
8257
+ }
8258
+ const hour = Number.parseInt(match[1], 10);
8259
+ const minute = Number.parseInt(match[2], 10);
8260
+ if (hour > 23) {
8261
+ throw new Error(`Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`);
8262
+ }
8263
+ return { hour, minute };
8264
+ }
8265
+ function validateMinute(minute) {
8266
+ if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
8267
+ throw new Error(`Invalid minute: ${minute}. Expected integer 0-59`);
8268
+ }
8269
+ }
8270
+ function validateDayOfWeek(dayOfWeek) {
8271
+ if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) {
8272
+ throw new Error(`Invalid day of week: ${dayOfWeek}. Expected 0-6 (Sunday=0, Saturday=6)`);
8273
+ }
8274
+ }
8275
+ function validateDayOfMonth(dayOfMonth) {
8276
+ if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) {
8277
+ throw new Error(`Invalid day of month: ${dayOfMonth}. Expected 1-31`);
8278
+ }
8279
+ }
8280
+
8101
8281
  // src/TaskSchedule.ts
8102
8282
  var TaskSchedule = class {
8103
8283
  task;
8104
8284
  /**
8105
- * Create a new TaskSchedule instance.
8285
+ * Initializes a new schedule instance with default settings.
8106
8286
  *
8107
- * @param name - The unique name of the task.
8108
- * @param callback - The function to execute.
8287
+ * @param name - Unique task name used for identification and locking.
8288
+ * @param callback - Logic to execute on schedule.
8109
8289
  */
8110
8290
  constructor(name, callback) {
8111
8291
  this.task = {
@@ -8119,168 +8299,217 @@ var TaskSchedule = class {
8119
8299
  // 5 minutes default
8120
8300
  background: false,
8121
8301
  // Wait for task to finish by default
8302
+ preventOverlapping: false,
8303
+ overlappingExpiresAt: 3600,
8304
+ // 1 hour default
8122
8305
  onSuccessCallbacks: [],
8123
8306
  onFailureCallbacks: []
8124
8307
  };
8125
8308
  }
8126
8309
  // --- Frequency Methods ---
8127
8310
  /**
8128
- * Set a custom cron expression.
8311
+ * Configures a raw 5-part cron expression.
8312
+ *
8313
+ * @param expression - Cron string (e.g., "0 0 * * *").
8314
+ * @returns The TaskSchedule instance for chaining.
8315
+ * @throws {Error} If expression format is invalid or contains forbidden characters.
8129
8316
  *
8130
- * @param expression - Standard cron expression (e.g., "* * * * *")
8131
- * @returns The TaskSchedule instance.
8317
+ * @example
8318
+ * ```typescript
8319
+ * schedule.cron('0 9-17 * * 1-5'); // 9-5 on weekdays
8320
+ * ```
8132
8321
  */
8133
8322
  cron(expression) {
8323
+ const parts = expression.trim().split(/\s+/);
8324
+ if (parts.length !== 5) {
8325
+ throw new Error(
8326
+ `Invalid cron expression: "${expression}". Expected 5 parts (minute hour day month weekday), got ${parts.length}.`
8327
+ );
8328
+ }
8329
+ const pattern = /^[0-9,\-/*?L#A-Za-z]+$/;
8330
+ for (let i = 0; i < 5; i++) {
8331
+ if (!pattern.test(parts[i])) {
8332
+ throw new Error(
8333
+ `Invalid cron expression: "${expression}". Part ${i + 1} ("${parts[i]}") contains invalid characters.`
8334
+ );
8335
+ }
8336
+ }
8134
8337
  this.task.expression = expression;
8135
8338
  return this;
8136
8339
  }
8137
8340
  /**
8138
- * Run the task every minute.
8341
+ * Schedules execution every minute.
8139
8342
  *
8140
- * @returns The TaskSchedule instance.
8343
+ * @returns The TaskSchedule instance for chaining.
8141
8344
  */
8142
8345
  everyMinute() {
8143
8346
  return this.cron("* * * * *");
8144
8347
  }
8145
8348
  /**
8146
- * Run the task every 5 minutes.
8349
+ * Schedules execution every five minutes.
8147
8350
  *
8148
- * @returns The TaskSchedule instance.
8351
+ * @returns The TaskSchedule instance for chaining.
8149
8352
  */
8150
8353
  everyFiveMinutes() {
8151
8354
  return this.cron("*/5 * * * *");
8152
8355
  }
8153
8356
  /**
8154
- * Run the task every 10 minutes.
8357
+ * Schedules execution every ten minutes.
8155
8358
  *
8156
- * @returns The TaskSchedule instance.
8359
+ * @returns The TaskSchedule instance for chaining.
8157
8360
  */
8158
8361
  everyTenMinutes() {
8159
8362
  return this.cron("*/10 * * * *");
8160
8363
  }
8161
8364
  /**
8162
- * Run the task every 15 minutes.
8365
+ * Schedules execution every fifteen minutes.
8163
8366
  *
8164
- * @returns The TaskSchedule instance.
8367
+ * @returns The TaskSchedule instance for chaining.
8165
8368
  */
8166
8369
  everyFifteenMinutes() {
8167
8370
  return this.cron("*/15 * * * *");
8168
8371
  }
8169
8372
  /**
8170
- * Run the task every 30 minutes.
8373
+ * Schedules execution every thirty minutes.
8171
8374
  *
8172
- * @returns The TaskSchedule instance.
8375
+ * @returns The TaskSchedule instance for chaining.
8173
8376
  */
8174
8377
  everyThirtyMinutes() {
8175
8378
  return this.cron("0,30 * * * *");
8176
8379
  }
8177
8380
  /**
8178
- * Run the task hourly (at minute 0).
8381
+ * Schedules execution hourly at the top of the hour.
8179
8382
  *
8180
- * @returns The TaskSchedule instance.
8383
+ * @returns The TaskSchedule instance for chaining.
8181
8384
  */
8182
8385
  hourly() {
8183
8386
  return this.cron("0 * * * *");
8184
8387
  }
8185
8388
  /**
8186
- * Run the task hourly at a specific minute.
8389
+ * Schedules execution hourly at a specific minute.
8187
8390
  *
8188
- * @param minute - Minute (0-59)
8189
- * @returns The TaskSchedule instance.
8391
+ * @param minute - Target minute (0-59).
8392
+ * @returns The TaskSchedule instance for chaining.
8393
+ * @throws {Error} If minute is outside 0-59 range.
8190
8394
  */
8191
8395
  hourlyAt(minute) {
8396
+ validateMinute(minute);
8192
8397
  return this.cron(`${minute} * * * *`);
8193
8398
  }
8194
8399
  /**
8195
- * Run the task daily at midnight (00:00).
8400
+ * Schedules execution daily at midnight.
8196
8401
  *
8197
- * @returns The TaskSchedule instance.
8402
+ * @returns The TaskSchedule instance for chaining.
8198
8403
  */
8199
8404
  daily() {
8200
8405
  return this.cron("0 0 * * *");
8201
8406
  }
8202
8407
  /**
8203
- * Run the task daily at a specific time.
8408
+ * Schedules execution daily at a specific time.
8409
+ *
8410
+ * @param time - 24-hour time string in "HH:mm" format.
8411
+ * @returns The TaskSchedule instance for chaining.
8412
+ * @throws {Error} If time format is invalid or values are out of range.
8204
8413
  *
8205
- * @param time - Time in "HH:mm" format (24h)
8206
- * @returns The TaskSchedule instance.
8414
+ * @example
8415
+ * ```typescript
8416
+ * schedule.dailyAt('14:30');
8417
+ * ```
8207
8418
  */
8208
8419
  dailyAt(time) {
8209
- const [hour, minute] = time.split(":");
8210
- return this.cron(`${Number(minute)} ${Number(hour)} * * *`);
8420
+ const { hour, minute } = parseTime(time);
8421
+ return this.cron(`${minute} ${hour} * * *`);
8211
8422
  }
8212
8423
  /**
8213
- * Run the task weekly on Sunday at midnight.
8424
+ * Schedules execution weekly on Sunday at midnight.
8214
8425
  *
8215
- * @returns The TaskSchedule instance.
8426
+ * @returns The TaskSchedule instance for chaining.
8216
8427
  */
8217
8428
  weekly() {
8218
8429
  return this.cron("0 0 * * 0");
8219
8430
  }
8220
8431
  /**
8221
- * Run the task weekly on a specific day and time.
8432
+ * Schedules execution weekly on a specific day and time.
8222
8433
  *
8223
- * @param day - Day of week (0-7, 0 or 7 is Sunday)
8224
- * @param time - Time in "HH:mm" format (default "00:00")
8225
- * @returns The TaskSchedule instance.
8434
+ * @param day - Day index (0-6, where 0 is Sunday).
8435
+ * @param time - Optional 24-hour time string "HH:mm" (default "00:00").
8436
+ * @returns The TaskSchedule instance for chaining.
8437
+ * @throws {Error} If day index or time format is invalid.
8438
+ *
8439
+ * @example
8440
+ * ```typescript
8441
+ * schedule.weeklyOn(1, '09:00'); // Mondays at 9 AM
8442
+ * ```
8226
8443
  */
8227
8444
  weeklyOn(day, time = "00:00") {
8228
- const [hour, minute] = time.split(":");
8229
- return this.cron(`${Number(minute)} ${Number(hour)} * * ${day}`);
8445
+ validateDayOfWeek(day);
8446
+ const { hour, minute } = parseTime(time);
8447
+ return this.cron(`${minute} ${hour} * * ${day}`);
8230
8448
  }
8231
8449
  /**
8232
- * Run the task monthly on the 1st day at midnight.
8450
+ * Schedules execution monthly on the first day at midnight.
8233
8451
  *
8234
- * @returns The TaskSchedule instance.
8452
+ * @returns The TaskSchedule instance for chaining.
8235
8453
  */
8236
8454
  monthly() {
8237
8455
  return this.cron("0 0 1 * *");
8238
8456
  }
8239
8457
  /**
8240
- * Run the task monthly on a specific day and time.
8458
+ * Schedules execution monthly on a specific day and time.
8241
8459
  *
8242
- * @param day - Day of month (1-31)
8243
- * @param time - Time in "HH:mm" format (default "00:00")
8244
- * @returns The TaskSchedule instance.
8460
+ * @param day - Day of month (1-31).
8461
+ * @param time - Optional 24-hour time string "HH:mm" (default "00:00").
8462
+ * @returns The TaskSchedule instance for chaining.
8463
+ * @throws {Error} If day or time format is invalid.
8245
8464
  */
8246
8465
  monthlyOn(day, time = "00:00") {
8247
- const [hour, minute] = time.split(":");
8248
- return this.cron(`${Number(minute)} ${Number(hour)} ${day} * *`);
8466
+ validateDayOfMonth(day);
8467
+ const { hour, minute } = parseTime(time);
8468
+ return this.cron(`${minute} ${hour} ${day} * *`);
8249
8469
  }
8250
8470
  // --- Constraints ---
8251
8471
  /**
8252
- * Set the timezone for the task execution.
8472
+ * Specifies the timezone for evaluating schedule frequency.
8253
8473
  *
8254
- * @param timezone - Timezone identifier (e.g., "Asia/Taipei", "UTC")
8255
- * @returns The TaskSchedule instance.
8474
+ * @param timezone - IANA timezone identifier (e.g., "America/New_York").
8475
+ * @returns The TaskSchedule instance for chaining.
8476
+ * @throws {Error} If the timezone identifier is not recognized by the system.
8256
8477
  */
8257
8478
  timezone(timezone) {
8479
+ try {
8480
+ (/* @__PURE__ */ new Date()).toLocaleString("en-US", { timeZone: timezone });
8481
+ } catch {
8482
+ throw new Error(
8483
+ `Invalid timezone: "${timezone}". See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for valid values.`
8484
+ );
8485
+ }
8258
8486
  this.task.timezone = timezone;
8259
8487
  return this;
8260
8488
  }
8261
8489
  /**
8262
- * Set the time of execution for the current frequency.
8263
- * Useful when chaining with daily(), weekly(), etc.
8490
+ * Modifies the execution time of the existing frequency.
8491
+ * Typically used after frequency methods like daily() or weekly().
8264
8492
  *
8265
- * @param time - Time in "HH:mm" format
8266
- * @returns The TaskSchedule instance.
8493
+ * @param time - 24-hour time string in "HH:mm" format.
8494
+ * @returns The TaskSchedule instance for chaining.
8495
+ * @throws {Error} If time format is invalid.
8267
8496
  */
8268
8497
  at(time) {
8269
- const [hour, minute] = time.split(":");
8498
+ const { hour, minute } = parseTime(time);
8270
8499
  const parts = this.task.expression.split(" ");
8271
8500
  if (parts.length >= 5) {
8272
- parts[0] = String(Number(minute));
8273
- parts[1] = String(Number(hour));
8501
+ parts[0] = String(minute);
8502
+ parts[1] = String(hour);
8274
8503
  this.task.expression = parts.join(" ");
8275
8504
  }
8276
8505
  return this;
8277
8506
  }
8278
8507
  /**
8279
- * Ensure task runs on only one server at a time (Distributed Locking).
8280
- * Requires a configured LockStore (Cache or Redis).
8508
+ * Enables distributed locking to ensure only one instance runs globally.
8509
+ * Prevents duplicate execution when multiple servers are polling the same schedule.
8281
8510
  *
8282
- * @param lockTtlSeconds - Time in seconds to hold the lock (default 300)
8283
- * @returns The TaskSchedule instance.
8511
+ * @param lockTtlSeconds - Duration in seconds to hold the window lock (default 300).
8512
+ * @returns The TaskSchedule instance for chaining.
8284
8513
  */
8285
8514
  onOneServer(lockTtlSeconds = 300) {
8286
8515
  this.task.shouldRunOnOneServer = true;
@@ -8288,41 +8517,85 @@ var TaskSchedule = class {
8288
8517
  return this;
8289
8518
  }
8290
8519
  /**
8291
- * Alias for onOneServer.
8292
- * Prevents overlapping executions of the same task.
8520
+ * Prevents a new task instance from starting if the previous one is still running.
8521
+ *
8522
+ * Unlike `onOneServer()` which uses a time-window lock, this uses an execution lock
8523
+ * to handle long-running tasks that might span multiple scheduling intervals.
8524
+ *
8525
+ * @param expiresAt - Max duration in seconds to keep the execution lock (default 3600).
8526
+ * @returns The TaskSchedule instance for chaining.
8293
8527
  *
8294
- * @param expiresAt - Lock TTL in seconds
8295
- * @returns The TaskSchedule instance.
8528
+ * @example
8529
+ * ```typescript
8530
+ * // Prevents overlapping if the heavy operation takes > 1 minute
8531
+ * scheduler.task('data-sync', heavySync)
8532
+ * .everyMinute()
8533
+ * .withoutOverlapping();
8534
+ * ```
8296
8535
  */
8297
- withoutOverlapping(expiresAt = 300) {
8298
- return this.onOneServer(expiresAt);
8536
+ withoutOverlapping(expiresAt = 3600) {
8537
+ this.task.preventOverlapping = true;
8538
+ this.task.overlappingExpiresAt = expiresAt;
8539
+ return this;
8299
8540
  }
8300
8541
  /**
8301
- * Run task in background (do not wait for completion in the loop).
8302
- * Note: In Node.js non-blocking environment this is largely semantic,
8303
- * but affects how we handle error catching and lock release.
8542
+ * Executes the task in background mode.
8543
+ * The scheduler will not wait for this task to finish before proceeding to the next.
8304
8544
  *
8305
- * @returns The TaskSchedule instance.
8545
+ * @returns The TaskSchedule instance for chaining.
8306
8546
  */
8307
8547
  runInBackground() {
8308
8548
  this.task.background = true;
8309
8549
  return this;
8310
8550
  }
8311
8551
  /**
8312
- * Restrict task execution to a specific node role.
8552
+ * Restricts task execution to nodes with a specific role.
8313
8553
  *
8314
- * @param role - The required node role (e.g., 'api', 'worker')
8315
- * @returns The TaskSchedule instance.
8554
+ * @param role - Target role identifier (e.g., "worker").
8555
+ * @returns The TaskSchedule instance for chaining.
8316
8556
  */
8317
8557
  onNode(role) {
8318
8558
  this.task.nodeRole = role;
8319
8559
  return this;
8320
8560
  }
8321
8561
  /**
8322
- * Set the command string for exec tasks.
8562
+ * Defines a maximum execution time for the task.
8563
+ *
8564
+ * @param ms - Timeout duration in milliseconds.
8565
+ * @returns The TaskSchedule instance for chaining.
8566
+ * @throws {Error} If timeout is not a positive number.
8567
+ */
8568
+ timeout(ms) {
8569
+ if (ms <= 0) {
8570
+ throw new Error("Timeout must be a positive number");
8571
+ }
8572
+ this.task.timeout = ms;
8573
+ return this;
8574
+ }
8575
+ /**
8576
+ * Configures automatic retry behavior for failed executions.
8577
+ *
8578
+ * @param attempts - Max number of retries (default 3).
8579
+ * @param delayMs - Wait time between retries in milliseconds (default 1000).
8580
+ * @returns The TaskSchedule instance for chaining.
8581
+ * @throws {Error} If attempts or delay are negative.
8582
+ */
8583
+ retry(attempts = 3, delayMs = 1e3) {
8584
+ if (attempts < 0) {
8585
+ throw new Error("Retry attempts must be non-negative");
8586
+ }
8587
+ if (delayMs < 0) {
8588
+ throw new Error("Retry delay must be non-negative");
8589
+ }
8590
+ this.task.retries = attempts;
8591
+ this.task.retryDelay = delayMs;
8592
+ return this;
8593
+ }
8594
+ /**
8595
+ * Sets the shell command for execution-based tasks.
8323
8596
  *
8324
8597
  * @param command - The command string.
8325
- * @returns The TaskSchedule instance.
8598
+ * @returns The TaskSchedule instance for chaining.
8326
8599
  * @internal
8327
8600
  */
8328
8601
  setCommand(command) {
@@ -8331,39 +8604,39 @@ var TaskSchedule = class {
8331
8604
  }
8332
8605
  // --- Hooks ---
8333
8606
  /**
8334
- * Register a callback to run on task success.
8607
+ * Registers a callback to execute upon successful task completion.
8335
8608
  *
8336
- * @param callback - The callback function.
8337
- * @returns The TaskSchedule instance.
8609
+ * @param callback - Function to run on success.
8610
+ * @returns The TaskSchedule instance for chaining.
8338
8611
  */
8339
8612
  onSuccess(callback) {
8340
8613
  this.task.onSuccessCallbacks.push(callback);
8341
8614
  return this;
8342
8615
  }
8343
8616
  /**
8344
- * Register a callback to run on task failure.
8617
+ * Registers a callback to execute when the task fails.
8345
8618
  *
8346
- * @param callback - The callback function.
8347
- * @returns The TaskSchedule instance.
8619
+ * @param callback - Function to run on failure.
8620
+ * @returns The TaskSchedule instance for chaining.
8348
8621
  */
8349
8622
  onFailure(callback) {
8350
8623
  this.task.onFailureCallbacks.push(callback);
8351
8624
  return this;
8352
8625
  }
8353
8626
  /**
8354
- * Set a description for the task (useful for listing).
8627
+ * Attaches a human-readable description to the task.
8355
8628
  *
8356
- * @param _text - The description text.
8357
- * @returns The TaskSchedule instance.
8629
+ * @param _text - Description text.
8630
+ * @returns The TaskSchedule instance for chaining.
8358
8631
  */
8359
8632
  description(_text) {
8360
8633
  return this;
8361
8634
  }
8362
8635
  // --- Accessor ---
8363
8636
  /**
8364
- * Get the underlying task configuration.
8637
+ * Returns the final task configuration.
8365
8638
  *
8366
- * @returns The ScheduledTask object.
8639
+ * @returns The constructed ScheduledTask object.
8367
8640
  */
8368
8641
  getTask() {
8369
8642
  return this.task;
@@ -8372,6 +8645,14 @@ var TaskSchedule = class {
8372
8645
 
8373
8646
  // src/SchedulerManager.ts
8374
8647
  var SchedulerManager = class {
8648
+ /**
8649
+ * Initializes the scheduler engine.
8650
+ *
8651
+ * @param lockManager - Backend for distributed locking.
8652
+ * @param logger - Optional logger for operational visibility.
8653
+ * @param hooks - Optional manager for lifecycle event hooks.
8654
+ * @param currentNodeRole - Role identifier for the local node (used for filtering).
8655
+ */
8375
8656
  constructor(lockManager, logger, hooks, currentNodeRole) {
8376
8657
  this.lockManager = lockManager;
8377
8658
  this.logger = logger;
@@ -8380,11 +8661,18 @@ var SchedulerManager = class {
8380
8661
  }
8381
8662
  tasks = [];
8382
8663
  /**
8383
- * Define a new scheduled task.
8664
+ * Registers a new callback-based scheduled task.
8665
+ *
8666
+ * @param name - Unique task name used for identification and locking.
8667
+ * @param callback - Asynchronous function containing the task logic.
8668
+ * @returns A fluent TaskSchedule instance for further configuration.
8384
8669
  *
8385
- * @param name - Unique name for the task
8386
- * @param callback - Function to execute
8387
- * @returns The newly created TaskSchedule.
8670
+ * @example
8671
+ * ```typescript
8672
+ * scheduler.task('process-queues', async () => {
8673
+ * await queue.process();
8674
+ * }).everyMinute();
8675
+ * ```
8388
8676
  */
8389
8677
  task(name, callback) {
8390
8678
  const task = new TaskSchedule(name, callback);
@@ -8392,11 +8680,21 @@ var SchedulerManager = class {
8392
8680
  return task;
8393
8681
  }
8394
8682
  /**
8395
- * Define a new scheduled command execution task.
8683
+ * Registers a shell command as a scheduled task.
8396
8684
  *
8397
- * @param name - Unique name for the task
8398
- * @param command - Shell command to execute
8399
- * @returns The newly created TaskSchedule.
8685
+ * Executes the command via `sh -c` on matching nodes.
8686
+ *
8687
+ * @param name - Unique identifier for the command task.
8688
+ * @param command - Raw shell command string.
8689
+ * @returns A fluent TaskSchedule instance.
8690
+ * @throws {Error} If the shell command returns a non-zero exit code during execution.
8691
+ *
8692
+ * @example
8693
+ * ```typescript
8694
+ * scheduler.exec('log-rotate', 'logrotate /etc/logrotate.conf')
8695
+ * .daily()
8696
+ * .onNode('worker');
8697
+ * ```
8400
8698
  */
8401
8699
  exec(name, command) {
8402
8700
  const task = new TaskSchedule(name, async () => {
@@ -8410,27 +8708,29 @@ var SchedulerManager = class {
8410
8708
  return task;
8411
8709
  }
8412
8710
  /**
8413
- * Add a pre-configured task schedule object.
8711
+ * Injects a pre-configured TaskSchedule instance into the registry.
8414
8712
  *
8415
- * @param schedule - The task schedule to add.
8713
+ * @param schedule - Configured task schedule to register.
8416
8714
  */
8417
8715
  add(schedule) {
8418
8716
  this.tasks.push(schedule);
8419
8717
  }
8420
8718
  /**
8421
- * Get all registered task definitions.
8719
+ * Exports all registered tasks for external inspection or serialization.
8422
8720
  *
8423
- * @returns An array of scheduled tasks.
8721
+ * @returns An array of raw task configurations.
8424
8722
  */
8425
8723
  getTasks() {
8426
8724
  return this.tasks.map((t) => t.getTask());
8427
8725
  }
8428
8726
  /**
8429
- * Trigger the scheduler to check and run due tasks.
8430
- * This is typically called every minute by a system cron or worker loop.
8727
+ * Main evaluation loop that triggers tasks due for execution.
8728
+ *
8729
+ * Should be invoked every minute by a system timer (systemd/cron) or daemon.
8730
+ * Performs frequency checks, role filtering, and parallel execution.
8431
8731
  *
8432
- * @param date - The current reference date (default: now)
8433
- * @returns A promise that resolves when the scheduler run is complete.
8732
+ * @param date - Reference time for cron evaluation (default: current time).
8733
+ * @returns Resolves when all due tasks have been initiated.
8434
8734
  */
8435
8735
  async run(date = /* @__PURE__ */ new Date()) {
8436
8736
  await this.hooks?.doAction("scheduler:run:start", { date });
@@ -8442,6 +8742,14 @@ var SchedulerManager = class {
8442
8742
  }
8443
8743
  }
8444
8744
  if (dueTasks.length > 0) {
8745
+ this.logger?.debug(`[Horizon] Found ${dueTasks.length} due task(s) to execute`, {
8746
+ tasks: dueTasks.map((t) => ({
8747
+ name: t.name,
8748
+ expression: t.expression,
8749
+ background: t.background,
8750
+ oneServer: t.shouldRunOnOneServer
8751
+ }))
8752
+ });
8445
8753
  }
8446
8754
  for (const task of dueTasks) {
8447
8755
  this.runTask(task, date).catch((err) => {
@@ -8451,21 +8759,45 @@ var SchedulerManager = class {
8451
8759
  await this.hooks?.doAction("scheduler:run:complete", { date, dueCount: dueTasks.length });
8452
8760
  }
8453
8761
  /**
8454
- * Execute a specific task with locking logic.
8762
+ * Executes an individual task after validating execution constraints.
8763
+ *
8764
+ * Evaluates node roles, overlapping prevention, and distributed time-window locks
8765
+ * before initiating the actual task logic.
8766
+ *
8767
+ * @param task - Target task configuration.
8768
+ * @param date - Reference time used for lock key generation.
8455
8769
  *
8456
- * @param task - The task to execute.
8457
8770
  * @internal
8458
8771
  */
8459
8772
  async runTask(task, date = /* @__PURE__ */ new Date()) {
8460
8773
  if (task.nodeRole && this.currentNodeRole && task.nodeRole !== this.currentNodeRole) {
8461
8774
  return;
8462
8775
  }
8463
- let acquiredLock = false;
8776
+ const runningLockKey = `task:running:${task.name}`;
8777
+ if (task.preventOverlapping) {
8778
+ const isRunning = await this.lockManager.exists(runningLockKey);
8779
+ if (isRunning) {
8780
+ this.logger?.debug(
8781
+ `[Horizon] Task "${task.name}" is still running, skipping this execution`
8782
+ );
8783
+ await this.hooks?.doAction("scheduler:task:skipped", {
8784
+ name: task.name,
8785
+ reason: "overlapping",
8786
+ timestamp: date
8787
+ });
8788
+ return;
8789
+ }
8790
+ await this.lockManager.forceAcquire(runningLockKey, task.overlappingExpiresAt);
8791
+ }
8792
+ let acquiredTimeLock = false;
8464
8793
  const timestamp = `${date.getFullYear()}${(date.getMonth() + 1).toString().padStart(2, "0")}${date.getDate().toString().padStart(2, "0")}${date.getHours().toString().padStart(2, "0")}${date.getMinutes().toString().padStart(2, "0")}`;
8465
- const lockKey = `task:${task.name}:${timestamp}`;
8794
+ const timeLockKey = `task:${task.name}:${timestamp}`;
8466
8795
  if (task.shouldRunOnOneServer) {
8467
- acquiredLock = await this.lockManager.acquire(lockKey, task.lockTtl);
8468
- if (!acquiredLock) {
8796
+ acquiredTimeLock = await this.lockManager.acquire(timeLockKey, task.lockTtl);
8797
+ if (!acquiredTimeLock) {
8798
+ if (task.preventOverlapping) {
8799
+ await this.lockManager.release(runningLockKey);
8800
+ }
8469
8801
  return;
8470
8802
  }
8471
8803
  }
@@ -8473,59 +8805,118 @@ var SchedulerManager = class {
8473
8805
  if (task.background) {
8474
8806
  this.executeTask(task).catch((err) => {
8475
8807
  this.logger?.error(`Background task ${task.name} failed`, err);
8808
+ }).finally(async () => {
8809
+ if (task.preventOverlapping) {
8810
+ await this.lockManager.release(runningLockKey);
8811
+ }
8476
8812
  });
8477
8813
  } else {
8478
8814
  await this.executeTask(task);
8815
+ if (task.preventOverlapping) {
8816
+ await this.lockManager.release(runningLockKey);
8817
+ }
8479
8818
  }
8480
8819
  } catch (err) {
8481
- if (acquiredLock) {
8482
- await this.lockManager.release(lockKey);
8820
+ if (task.preventOverlapping) {
8821
+ await this.lockManager.release(runningLockKey);
8822
+ }
8823
+ if (acquiredTimeLock) {
8824
+ await this.lockManager.release(timeLockKey);
8483
8825
  }
8484
8826
  throw err;
8485
8827
  }
8486
8828
  }
8487
8829
  /**
8488
- * Execute the task callback and handle hooks.
8830
+ * Internal wrapper for executing task logic with reliability controls.
8489
8831
  *
8490
- * @param task - The task to execute.
8832
+ * Handles timeouts, retries, and emits lifecycle hooks for monitoring.
8833
+ *
8834
+ * @param task - The scheduled task to execute.
8835
+ * @returns Resolves when the task (and its retries) completes or fails permanently.
8836
+ *
8837
+ * @internal
8491
8838
  */
8492
8839
  async executeTask(task) {
8493
8840
  const startTime = Date.now();
8494
8841
  await this.hooks?.doAction("scheduler:task:start", { name: task.name, startTime });
8495
- try {
8496
- await task.callback();
8497
- const duration = Date.now() - startTime;
8498
- await this.hooks?.doAction("scheduler:task:success", { name: task.name, duration });
8499
- for (const cb of task.onSuccessCallbacks) {
8500
- try {
8501
- await cb({ name: task.name });
8502
- } catch {
8503
- }
8842
+ const timeout = task.timeout || 36e5;
8843
+ const maxRetries = task.retries ?? 0;
8844
+ const retryDelay = task.retryDelay ?? 1e3;
8845
+ let lastError;
8846
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
8847
+ if (attempt > 0) {
8848
+ this.logger?.info(
8849
+ `[Horizon] Retrying task "${task.name}" (attempt ${attempt}/${maxRetries})...`
8850
+ );
8851
+ await this.hooks?.doAction("scheduler:task:retry", {
8852
+ name: task.name,
8853
+ attempt,
8854
+ maxRetries,
8855
+ error: lastError,
8856
+ delay: retryDelay
8857
+ });
8858
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
8504
8859
  }
8505
- } catch (err) {
8506
- const duration = Date.now() - startTime;
8507
- this.logger?.error(`Task ${task.name} failed`, err);
8508
- await this.hooks?.doAction("scheduler:task:failure", {
8509
- name: task.name,
8510
- error: err,
8511
- duration
8860
+ let timeoutId;
8861
+ const timeoutPromise = new Promise((_, reject) => {
8862
+ timeoutId = setTimeout(() => {
8863
+ reject(new Error(`Task "${task.name}" timed out after ${timeout}ms`));
8864
+ }, timeout);
8512
8865
  });
8513
- for (const cb of task.onFailureCallbacks) {
8514
- try {
8515
- await cb(err);
8516
- } catch {
8866
+ try {
8867
+ await Promise.race([task.callback(), timeoutPromise]);
8868
+ const duration2 = Date.now() - startTime;
8869
+ await this.hooks?.doAction("scheduler:task:success", {
8870
+ name: task.name,
8871
+ duration: duration2,
8872
+ attempts: attempt + 1
8873
+ });
8874
+ for (const cb of task.onSuccessCallbacks) {
8875
+ try {
8876
+ await cb({ name: task.name });
8877
+ } catch {
8878
+ }
8879
+ }
8880
+ return;
8881
+ } catch (err) {
8882
+ lastError = err;
8883
+ this.logger?.error(`Task ${task.name} failed (attempt ${attempt + 1})`, err);
8884
+ } finally {
8885
+ if (timeoutId) {
8886
+ clearTimeout(timeoutId);
8517
8887
  }
8518
8888
  }
8519
8889
  }
8890
+ const duration = Date.now() - startTime;
8891
+ await this.hooks?.doAction("scheduler:task:failure", {
8892
+ name: task.name,
8893
+ error: lastError,
8894
+ duration,
8895
+ attempts: maxRetries + 1
8896
+ });
8897
+ for (const cb of task.onFailureCallbacks) {
8898
+ try {
8899
+ await cb(lastError);
8900
+ } catch {
8901
+ }
8902
+ }
8520
8903
  }
8521
8904
  };
8522
8905
 
8523
8906
  // src/OrbitHorizon.ts
8524
8907
  var OrbitHorizon = class {
8525
8908
  /**
8526
- * Install the Horizon Orbit into PlanetCore.
8909
+ * Integrates the Horizon scheduler into the PlanetCore lifecycle.
8910
+ *
8911
+ * Orchestrates the initialization sequence:
8912
+ * 1. Resolves lock backend (memory or cache-based).
8913
+ * 2. Instantiates SchedulerManager with global dependencies (logger, hooks).
8914
+ * 3. Registers the scheduler in the IoC container for CLI and global access.
8915
+ * 4. Injects the scheduler into the request context via middleware.
8916
+ *
8917
+ * @param core - PlanetCore instance providing configuration and service container.
8527
8918
  *
8528
- * @param core - The PlanetCore instance.
8919
+ * @throws {Error} If the cache driver is explicitly requested but `CacheManager` is unavailable.
8529
8920
  */
8530
8921
  install(core) {
8531
8922
  const config = core.config.get("scheduler", {});
@@ -8534,7 +8925,7 @@ var OrbitHorizon = class {
8534
8925
  const nodeRole = config.nodeRole;
8535
8926
  let lockManager;
8536
8927
  if (lockDriver === "cache") {
8537
- const cacheManager = core.services.get("cache");
8928
+ const cacheManager = core.container.make("cache");
8538
8929
  if (!cacheManager) {
8539
8930
  core.logger.warn(
8540
8931
  "[OrbitHorizon] Cache driver requested but cache service not found (ensure orbit-cache is loaded first). Falling back to Memory lock."
@@ -8547,7 +8938,7 @@ var OrbitHorizon = class {
8547
8938
  lockManager = new LockManager("memory");
8548
8939
  }
8549
8940
  const scheduler = new SchedulerManager(lockManager, core.logger, core.hooks, nodeRole);
8550
- core.services.set(exposeAs, scheduler);
8941
+ core.container.instance(exposeAs, scheduler);
8551
8942
  core.adapter.use("*", async (c, next) => {
8552
8943
  c.set("scheduler", scheduler);
8553
8944
  return await next();