@goodandready/dsh-model-sync 0.2.7 → 0.2.9

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/lib/scheduler.js CHANGED
@@ -1,6 +1,30 @@
1
- export function createSyncScheduler(sync, { getConfig = () => ({}), setIntervalFn = setInterval, clearIntervalFn = clearInterval, logger = console } = {}) {
1
+ function minutes(value, fallback = 60) {
2
+ const number = Number(value)
3
+ if (!Number.isFinite(number) || number <= 0) return fallback
4
+ return Math.min(10080, Math.max(1, number))
5
+ }
6
+
7
+ function optionalMinutes(value, fallback) {
8
+ const number = Number(value)
9
+ if (!Number.isFinite(number) || number <= 0) return fallback
10
+ return Math.min(10080, Math.max(1, number))
11
+ }
12
+
13
+ function errorMessage(error) {
14
+ return error instanceof Error ? error.message : String(error)
15
+ }
16
+
17
+ export function createSyncScheduler(sync, {
18
+ getConfig = () => ({}),
19
+ setIntervalFn = setInterval,
20
+ clearIntervalFn = clearInterval,
21
+ logger = console,
22
+ randomFn = Math.random,
23
+ nowFn = () => Date.now(),
24
+ } = {}) {
2
25
  let timer = null
3
26
  let running = false
27
+ const state = new Map()
4
28
 
5
29
  function stop() {
6
30
  if (timer !== null) clearIntervalFn(timer)
@@ -8,30 +32,112 @@ export function createSyncScheduler(sync, { getConfig = () => ({}), setIntervalF
8
32
  running = false
9
33
  }
10
34
 
35
+ function inventory(config) {
36
+ if (typeof sync.listProviders === 'function') {
37
+ const rows = sync.listProviders().filter((row) => row?.configured && row.provider)
38
+ const configured = Array.isArray(config.providers) ? new Map(config.providers.map((row) => [row.provider, row])) : null
39
+ return rows
40
+ .filter((row) => !configured || configured.size === 0 || configured.get(row.provider)?.enabled !== false)
41
+ .map((row) => ({ ...row, ...(configured?.get(row.provider) ?? {}) }))
42
+ }
43
+ return null
44
+ }
45
+
46
+ function scheduleRows(config) {
47
+ const rows = inventory(config)
48
+ if (rows === null) return [{ provider: undefined, enabled: true }]
49
+ return rows
50
+ }
51
+
52
+ function timing(row, config) {
53
+ const interval = optionalMinutes(row.intervalMinutes, minutes(config.intervalMinutes, 60))
54
+ const ttl = optionalMinutes(row.ttlMinutes, optionalMinutes(config.ttlMinutes, 0))
55
+ const jitter = optionalMinutes(row.jitterMinutes, optionalMinutes(config.jitterMinutes, 0))
56
+ return { interval, ttl, jitter }
57
+ }
58
+
59
+ function delayMs(timingValue) {
60
+ const jitter = timingValue.jitter > 0 ? randomFn() * timingValue.jitter : 0
61
+ return Math.max(60_000, (Math.max(timingValue.interval, timingValue.ttl) + jitter) * 60_000)
62
+ }
63
+
64
+ function ensureState(row, config, now) {
65
+ const key = row.provider ?? '*'
66
+ const existing = state.get(key)
67
+ if (existing) return existing
68
+ const nextAt = now
69
+ const created = { provider: row.provider, nextAt, lastRunAt: null, lastStatus: null, lastError: null }
70
+ state.set(key, created)
71
+ return created
72
+ }
73
+
74
+ function resetStates(config) {
75
+ const rows = scheduleRows(config)
76
+ const active = new Set(rows.map((row) => row.provider ?? '*'))
77
+ for (const key of state.keys()) if (!active.has(key)) state.delete(key)
78
+ const now = nowFn()
79
+ for (const row of rows) ensureState(row, config, now)
80
+ }
81
+
82
+ async function tick() {
83
+ const config = getConfig() ?? {}
84
+ if (config.enabled === false || config.scheduleEnabled === false || running) return
85
+ const rows = scheduleRows(config)
86
+ if (rows.length === 0) return
87
+ const now = nowFn()
88
+ const due = rows.filter((row) => now >= ensureState(row, config, now).nextAt)
89
+ if (due.length === 0) return
90
+ running = true
91
+ try {
92
+ for (const row of due) {
93
+ const item = ensureState(row, config, nowFn())
94
+ const options = { dryRun: config.autoApply === true ? false : true, reportSource: 'schedule' }
95
+ if (row.provider) options.provider = row.provider
96
+ item.lastRunAt = nowFn()
97
+ try {
98
+ const result = await sync.run(options)
99
+ item.lastStatus = result?.results?.find((entry) => entry.provider === row.provider)?.status ?? 'ok'
100
+ item.lastError = null
101
+ } catch (error) {
102
+ item.lastStatus = 'error'
103
+ item.lastError = errorMessage(error)
104
+ logger.warn?.(`[dsh-model-sync] scheduled run failed${row.provider ? ` for ${row.provider}` : ''}: ${item.lastError}`)
105
+ } finally {
106
+ item.nextAt = nowFn() + delayMs(timing(row, config))
107
+ }
108
+ }
109
+ } finally {
110
+ running = false
111
+ }
112
+ }
113
+
11
114
  function start() {
12
115
  stop()
13
116
  const config = getConfig() ?? {}
14
- const minutes = Number(config.intervalMinutes)
15
- if (config.enabled === false || !Number.isFinite(minutes) || minutes <= 0) return false
16
- const period = Math.max(1, minutes) * 60_000
17
- timer = setIntervalFn(async () => {
18
- if (running) return
19
- running = true
20
- try {
21
- await sync.run({ dryRun: config.autoApply !== true })
22
- } catch (error) {
23
- logger.warn?.(`[dsh-model-sync] scheduled run failed: ${error instanceof Error ? error.message : String(error)}`)
24
- } finally {
25
- running = false
26
- }
27
- }, period)
117
+ if (config.enabled === false || config.scheduleEnabled === false) return false
118
+ const rows = scheduleRows(config)
119
+ if (inventory(config) !== null && rows.length === 0) return false
120
+ resetStates(config)
121
+ // One-minute polling is the lower safety bound. Per-provider intervals,
122
+ // TTLs, and jitter are enforced by tick without multiplying timers.
123
+ timer = setIntervalFn(() => tick(), 60_000)
28
124
  return true
29
125
  }
30
126
 
31
- return Object.freeze({
32
- start,
33
- stop,
34
- reconfigure: start,
35
- status: () => ({ active: timer !== null, running }),
36
- })
127
+ function status() {
128
+ const config = getConfig() ?? {}
129
+ resetStates(config)
130
+ const values = [...state.values()]
131
+ const nextAt = values.length > 0 ? Math.min(...values.map((row) => row.nextAt)) : null
132
+ const lastRunAt = values.filter((row) => row.lastRunAt).reduce((max, row) => Math.max(max, row.lastRunAt), 0) || null
133
+ return {
134
+ active: timer !== null,
135
+ running,
136
+ lastRunAt,
137
+ nextRunAt: nextAt,
138
+ providers: Object.fromEntries(values.map((row) => [row.provider ?? '*', { lastRunAt: row.lastRunAt, nextRunAt: row.nextAt, lastStatus: row.lastStatus, ...(row.lastError ? { lastError: row.lastError } : {}) }])),
139
+ }
140
+ }
141
+
142
+ return Object.freeze({ start, stop, reconfigure: start, tick, status })
37
143
  }