schedule.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import type {
  2. BackupContents,
  3. BackupFrequency,
  4. BackupSettings,
  5. } from '@/features/integrations/Lib/backup-types'
  6. import { zonedDate, zonedParts } from '@/lib/timezone'
  7. /**
  8. * When a workshop's backups run, on the workshop's own clock. Pure functions:
  9. * no I/O, no server-local time. Calendar steps (next day, last day of the
  10. * month) are done on UTC calendar dates, which carry no zone; every instant
  11. * comes from `zonedDate` in the workshop's zone.
  12. */
  13. export const BACKUP_SETTING_DEFAULTS: BackupSettings = {
  14. frequency: 'daily',
  15. time: '02:00',
  16. weekday: 0,
  17. monthDay: '1',
  18. contents: 'data',
  19. includeFiles: true,
  20. keepHourly: 24,
  21. keepDaily: 14,
  22. keepWeekly: 8,
  23. keepMonthly: 12,
  24. }
  25. const FREQUENCIES: readonly BackupFrequency[] = ['hourly', 'every4h', 'daily', 'weekly', 'monthly']
  26. /** The frequencies a destination may run at on the cloud instance: once a day at most. */
  27. export const CLOUD_DESTINATION_FREQUENCIES: readonly BackupFrequency[] = [
  28. 'daily',
  29. 'weekly',
  30. 'monthly',
  31. ]
  32. /**
  33. * The settings as the schedule runs them. On the cloud a destination runs
  34. * once a day at most: the hourly restore points there are the platform's,
  35. * and hourly uploads from every workshop would fill the queue a restore then
  36. * waits in. The hourly choices are not offered there (the registry drops
  37. * them from the form); one saved before that, or sent past the form, runs
  38. * daily. Self-hosted, where the destination may be the only backup, keeps
  39. * every choice.
  40. */
  41. export function clampDestinationFrequency(
  42. settings: BackupSettings,
  43. cloud: boolean
  44. ): BackupSettings {
  45. if (!cloud || CLOUD_DESTINATION_FREQUENCIES.includes(settings.frequency)) return settings
  46. return { ...settings, frequency: 'daily' }
  47. }
  48. const CONTENTS: readonly BackupContents[] = ['data', 'dataAndSecrets']
  49. const MINUTE = 60_000
  50. const HOUR = 60 * MINUTE
  51. /** Roughly one period of each frequency; used for the first-run window and alerts. */
  52. export const FREQUENCY_INTERVAL_MS: Record<BackupFrequency, number> = {
  53. hourly: HOUR,
  54. every4h: 4 * HOUR,
  55. daily: 24 * HOUR,
  56. weekly: 7 * 24 * HOUR,
  57. monthly: 31 * 24 * HOUR,
  58. }
  59. /** One period of the settings' frequency, in ms. */
  60. export function intervalMs(settings: Pick<BackupSettings, 'frequency'>): number {
  61. return FREQUENCY_INTERVAL_MS[settings.frequency] ?? FREQUENCY_INTERVAL_MS.daily
  62. }
  63. /** How far back a never-run connection looks for a slot: two cron ticks. */
  64. export const FIRST_RUN_WINDOW_MS = 10 * MINUTE
  65. function toInt(value: unknown): number | null {
  66. if (typeof value === 'number' && Number.isInteger(value)) return value
  67. if (typeof value === 'string' && /^-?\d+$/.test(value.trim())) return Number(value.trim())
  68. return null
  69. }
  70. function toBool(value: unknown, fallback: boolean): boolean {
  71. if (typeof value === 'boolean') return value
  72. if (value === 'true') return true
  73. if (value === 'false') return false
  74. return fallback
  75. }
  76. function keep(value: unknown, fallback: number): number {
  77. const n = toInt(value)
  78. if (n === null) return fallback
  79. return Math.min(1000, Math.max(0, n))
  80. }
  81. /** Saved connection settings, validated, with defaults for anything missing or wrong. */
  82. export function readBackupSettings(raw: Record<string, unknown>): BackupSettings {
  83. const d = BACKUP_SETTING_DEFAULTS
  84. const frequency = FREQUENCIES.includes(raw.frequency as BackupFrequency)
  85. ? (raw.frequency as BackupFrequency)
  86. : d.frequency
  87. const time =
  88. typeof raw.time === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(raw.time.trim())
  89. ? raw.time.trim()
  90. : d.time
  91. const weekdayInt = toInt(raw.weekday)
  92. const weekday = weekdayInt !== null && weekdayInt >= 0 && weekdayInt <= 6 ? weekdayInt : d.weekday
  93. const monthDayRaw = typeof raw.monthDay === 'number' ? String(raw.monthDay) : raw.monthDay
  94. const monthDayInt = toInt(monthDayRaw)
  95. const monthDay =
  96. monthDayRaw === 'last'
  97. ? 'last'
  98. : monthDayInt !== null && monthDayInt >= 1 && monthDayInt <= 28
  99. ? String(monthDayInt)
  100. : d.monthDay
  101. const contents = CONTENTS.includes(raw.contents as BackupContents)
  102. ? (raw.contents as BackupContents)
  103. : d.contents
  104. return {
  105. frequency,
  106. time,
  107. weekday,
  108. monthDay,
  109. contents,
  110. includeFiles: toBool(raw.includeFiles, d.includeFiles),
  111. keepHourly: keep(raw.keepHourly, d.keepHourly),
  112. keepDaily: keep(raw.keepDaily, d.keepDaily),
  113. keepWeekly: keep(raw.keepWeekly, d.keepWeekly),
  114. keepMonthly: keep(raw.keepMonthly, d.keepMonthly),
  115. }
  116. }
  117. interface CalendarDay {
  118. year: number
  119. month: number
  120. day: number
  121. /** 0 = Sunday. */
  122. weekday: number
  123. }
  124. /** A calendar day `offset` days from the given one; zone-free. */
  125. function calendarDay(year: number, month: number, day: number, offset = 0): CalendarDay {
  126. const d = new Date(Date.UTC(year, month - 1, day + offset))
  127. return {
  128. year: d.getUTCFullYear(),
  129. month: d.getUTCMonth() + 1,
  130. day: d.getUTCDate(),
  131. weekday: d.getUTCDay(),
  132. }
  133. }
  134. function daysInMonth(year: number, month: number): number {
  135. return new Date(Date.UTC(year, month, 0)).getUTCDate()
  136. }
  137. function wallKey(date: Date, timeZone: string): number {
  138. const p = zonedParts(date, timeZone)
  139. return (((p.year * 100 + p.month) * 100 + p.day) * 100 + p.hour) * 100 + p.minute
  140. }
  141. /**
  142. * The instant the workshop clock reads this date and time. Once per wall
  143. * time, so an autumn repeat yields one instant. A time that does not exist
  144. * (spring forward) gives the first instant after the gap, e.g. 03:00 for 02:30.
  145. */
  146. function slotInstant(
  147. year: number,
  148. month: number,
  149. day: number,
  150. hour: number,
  151. minute: number,
  152. timeZone: string
  153. ): Date {
  154. const instant = zonedDate(year, month, day, hour, minute, timeZone)
  155. const want = (((year * 100 + month) * 100 + day) * 100 + hour) * 100 + minute
  156. if (wallKey(instant, timeZone) === want) return instant
  157. // Every zone in use shifts on a 15-minute UTC boundary; scan around the guess.
  158. const step = 15 * MINUTE
  159. const start = Math.floor((instant.getTime() - 4 * HOUR) / step) * step
  160. for (let t = start; t <= instant.getTime() + 4 * HOUR; t += step) {
  161. if (wallKey(new Date(t), timeZone) >= want) return new Date(t)
  162. }
  163. return instant
  164. }
  165. function parseTime(time: string): { hour: number; minute: number } {
  166. const m = /^(\d{2}):(\d{2})$/.exec(time)
  167. if (!m) return { hour: 2, minute: 0 }
  168. return { hour: Number(m[1]), minute: Number(m[2]) }
  169. }
  170. /** First slot strictly after `threshold`, without the offset. */
  171. function nextSlot(settings: BackupSettings, threshold: number, timeZone: string): Date {
  172. if (settings.frequency === 'hourly') {
  173. // Every real hour the workshop clock shows :00, both hours of an autumn repeat included.
  174. const step = 15 * MINUTE
  175. for (let t = Math.floor(threshold / step) * step + step; ; t += step) {
  176. if (zonedParts(new Date(t), timeZone).minute === 0) return new Date(t)
  177. }
  178. }
  179. const p = zonedParts(new Date(threshold), timeZone)
  180. // Start a day early: a gap-resolved slot can land on the next wall day.
  181. const first = calendarDay(p.year, p.month, p.day, -1)
  182. if (settings.frequency === 'monthly') {
  183. const { hour, minute } = parseTime(settings.time)
  184. for (let i = 0; i < 26; i++) {
  185. const monthIndex = first.month - 1 + i
  186. const year = first.year + Math.floor(monthIndex / 12)
  187. const month = (monthIndex % 12) + 1
  188. const day =
  189. settings.monthDay === 'last' ? daysInMonth(year, month) : Number(settings.monthDay) || 1
  190. const slot = slotInstant(year, month, day, hour, minute, timeZone)
  191. if (slot.getTime() > threshold) return slot
  192. }
  193. throw new Error('No monthly backup slot found')
  194. }
  195. const times =
  196. settings.frequency === 'every4h'
  197. ? [0, 4, 8, 12, 16, 20].map((hour) => ({ hour, minute: 0 }))
  198. : [parseTime(settings.time)]
  199. for (let i = 0; i < 16; i++) {
  200. const day = calendarDay(first.year, first.month, first.day, i)
  201. if (settings.frequency === 'weekly' && day.weekday !== settings.weekday) continue
  202. for (const { hour, minute } of times) {
  203. const slot = slotInstant(day.year, day.month, day.day, hour, minute, timeZone)
  204. if (slot.getTime() > threshold) return slot
  205. }
  206. }
  207. throw new Error('No backup slot found')
  208. }
  209. /**
  210. * The first scheduled run strictly after `after`: the slot on the workshop
  211. * clock plus `offsetMinutes`. The offset counts as part of the run time, so a
  212. * run at 02:00 + 3 is still ahead at 02:01.
  213. */
  214. export function nextRunAfter(
  215. settings: BackupSettings,
  216. after: Date,
  217. timeZone: string,
  218. offsetMinutes = 0
  219. ): Date {
  220. const offset = offsetMinutes * MINUTE
  221. return new Date(nextSlot(settings, after.getTime() - offset, timeZone).getTime() + offset)
  222. }
  223. /**
  224. * Whether a run is due now. A connection that has never run looks back only
  225. * FIRST_RUN_WINDOW_MS, so connecting waits for the next slot instead of
  226. * running at once; pass the connection's activation time for an exact answer.
  227. */
  228. export function isDue(
  229. settings: BackupSettings,
  230. lastRunAt: Date | null,
  231. now: Date,
  232. timeZone: string,
  233. offsetMinutes = 0
  234. ): boolean {
  235. const from = lastRunAt ?? new Date(now.getTime() - FIRST_RUN_WINDOW_MS)
  236. return nextRunAfter(settings, from, timeZone, offsetMinutes).getTime() <= now.getTime()
  237. }
  238. /**
  239. * A stable 0..59 minute offset per organisation, so the workshops that chose
  240. * the same hour do not all come due at once. The runs are serialised by the
  241. * server-wide lock either way; the spread keeps a busy hour from piling up
  242. * forty queued runs behind the first, and keeps the first ones from
  243. * starting while the hour's traffic is still high.
  244. */
  245. export function spreadOffsetMinutes(organizationId: string): number {
  246. // FNV-1a, 32 bit.
  247. let hash = 0x811c9dc5
  248. for (let i = 0; i < organizationId.length; i++) {
  249. hash ^= organizationId.charCodeAt(i)
  250. hash = Math.imul(hash, 0x01000193) >>> 0
  251. }
  252. return hash % 60
  253. }
  254. /** The `--keep-*` flags for `restic forget`, in order, skipping zeros. */
  255. export function retentionFlags(
  256. settings: Pick<BackupSettings, 'keepHourly' | 'keepDaily' | 'keepWeekly' | 'keepMonthly'>
  257. ): string[] {
  258. const flags: string[] = []
  259. const pairs: [string, number][] = [
  260. ['--keep-hourly', settings.keepHourly],
  261. ['--keep-daily', settings.keepDaily],
  262. ['--keep-weekly', settings.keepWeekly],
  263. ['--keep-monthly', settings.keepMonthly],
  264. ]
  265. for (const [flag, value] of pairs) {
  266. if (Number.isInteger(value) && value > 0) flags.push(flag, String(value))
  267. }
  268. return flags
  269. }