background-job.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. export * as BackgroundJob from "./background-job"
  2. import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
  3. import { Identifier } from "./id/id"
  4. import { makeGlobalNode } from "./effect/app-node"
  5. export type Status = "running" | "completed" | "error" | "cancelled"
  6. export type Info = {
  7. id: string
  8. type: string
  9. title?: string
  10. status: Status
  11. started_at: number
  12. completed_at?: number
  13. output?: string
  14. error?: string
  15. metadata?: Record<string, unknown>
  16. }
  17. type Active = {
  18. info: Info
  19. done: Deferred.Deferred<Info>
  20. scope: Scope.Closeable
  21. token: object
  22. pending: number
  23. next: number
  24. output?: { sequence: number; text: string }
  25. tail: Deferred.Deferred<void>
  26. promoted: Deferred.Deferred<Info>
  27. onPromote?: Effect.Effect<void>
  28. }
  29. type State = {
  30. jobs: SynchronizedRef.SynchronizedRef<Map<string, Active>>
  31. scope: Scope.Scope
  32. }
  33. type FinishResult = {
  34. info?: Info
  35. done?: Deferred.Deferred<Info>
  36. scope?: Scope.Closeable
  37. }
  38. type PromoteResult = {
  39. info?: Info
  40. promoted?: Deferred.Deferred<Info>
  41. onPromote?: Effect.Effect<void>
  42. }
  43. type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
  44. type ExtendResult =
  45. | { extended: false }
  46. | {
  47. extended: true
  48. previous: Deferred.Deferred<void>
  49. scope: Scope.Closeable
  50. tail: Deferred.Deferred<void>
  51. token: object
  52. sequence: number
  53. }
  54. export type StartInput = {
  55. id?: string
  56. type: string
  57. title?: string
  58. metadata?: Record<string, unknown>
  59. onPromote?: Effect.Effect<void>
  60. run: Effect.Effect<string, unknown>
  61. }
  62. export type ExtendInput = {
  63. id: string
  64. run: Effect.Effect<string, unknown>
  65. }
  66. export type WaitInput = {
  67. id: string
  68. timeout?: number
  69. }
  70. export type WaitResult = {
  71. info?: Info
  72. timedOut: boolean
  73. }
  74. export interface Interface {
  75. readonly list: () => Effect.Effect<Info[]>
  76. readonly get: (id: string) => Effect.Effect<Info | undefined>
  77. readonly start: (input: StartInput) => Effect.Effect<Info>
  78. readonly extend: (input: ExtendInput) => Effect.Effect<boolean>
  79. readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
  80. readonly waitForPromotion: (id: string) => Effect.Effect<Info>
  81. readonly promote: (id: string) => Effect.Effect<Info | undefined>
  82. readonly cancel: (id: string) => Effect.Effect<Info | undefined>
  83. }
  84. export class Service extends Context.Service<Service, Interface>()("@kirincode/BackgroundJob") {}
  85. function snapshot(job: Active): Info {
  86. return {
  87. ...job.info,
  88. ...(job.info.metadata ? { metadata: { ...job.info.metadata } } : {}),
  89. }
  90. }
  91. function errorText(error: unknown) {
  92. if (error instanceof Error) return error.message
  93. return String(error)
  94. }
  95. /**
  96. * Makes one scoped, process-local registry. Entries are intentionally not
  97. * durable: process restart or owner-scope closure loses status and interrupts
  98. * live work. Persisted observation, restart recovery, and remote workers need a
  99. * separate durable ownership slice rather than pretending this registry has
  100. * those semantics.
  101. */
  102. export const make = Effect.gen(function* () {
  103. const state: State = {
  104. jobs: yield* SynchronizedRef.make(new Map()),
  105. scope: yield* Scope.Scope,
  106. }
  107. const settle = Effect.fn("BackgroundJob.settle")(function* (
  108. id: string,
  109. token: object,
  110. sequence: number,
  111. exit: Exit.Exit<string, unknown>,
  112. ) {
  113. const completed_at = yield* Clock.currentTimeMillis
  114. const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
  115. const job = jobs.get(id)
  116. if (!job) return [{}, jobs]
  117. if (job.token !== token) return [{}, jobs]
  118. if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
  119. const pending = job.pending - 1
  120. const output =
  121. Exit.isSuccess(exit) && (!job.output || sequence > job.output.sequence)
  122. ? { sequence, text: exit.value }
  123. : job.output
  124. if (Exit.isSuccess(exit) && pending > 0) {
  125. return [{}, new Map(jobs).set(id, { ...job, pending, output })]
  126. }
  127. const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
  128. ? "completed"
  129. : Cause.hasInterruptsOnly(exit.cause)
  130. ? "cancelled"
  131. : "error"
  132. const next = {
  133. ...job,
  134. onPromote: undefined,
  135. pending: 0,
  136. output,
  137. info: {
  138. ...job.info,
  139. status,
  140. completed_at,
  141. ...(output ? { output: output.text } : {}),
  142. ...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
  143. },
  144. }
  145. return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
  146. })
  147. if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
  148. if (result.scope) {
  149. yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
  150. }
  151. return result.info
  152. })
  153. const fork = Effect.fn("BackgroundJob.fork")(function* (
  154. scope: Scope.Scope,
  155. id: string,
  156. token: object,
  157. sequence: number,
  158. run: Effect.Effect<string, unknown>,
  159. ) {
  160. return yield* run.pipe(
  161. Effect.matchCauseEffect({
  162. onSuccess: (output) => settle(id, token, sequence, Exit.succeed(output)),
  163. onFailure: (cause) => settle(id, token, sequence, Exit.failCause(cause)),
  164. }),
  165. Effect.asVoid,
  166. Effect.forkIn(scope, { startImmediately: true }),
  167. )
  168. })
  169. const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () {
  170. return Array.from((yield* SynchronizedRef.get(state.jobs)).values())
  171. .map(snapshot)
  172. .toSorted((a, b) => a.started_at - b.started_at)
  173. })
  174. const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) {
  175. const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
  176. if (!job) return
  177. return snapshot(job)
  178. })
  179. const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) {
  180. return yield* Effect.uninterruptibleMask((restore) =>
  181. Effect.gen(function* () {
  182. const id = input.id ?? Identifier.ascending("job")
  183. const started_at = yield* Clock.currentTimeMillis
  184. const done = yield* Deferred.make<Info>()
  185. const promoted = yield* Deferred.make<Info>()
  186. const tail = yield* Deferred.make<void>()
  187. const result = yield* SynchronizedRef.modifyEffect(
  188. state.jobs,
  189. Effect.fnUntraced(function* (jobs) {
  190. const existing = jobs.get(id)
  191. if (existing?.info.status === "running") {
  192. return [{ info: snapshot(existing) }, jobs] as readonly [StartResult, Map<string, Active>]
  193. }
  194. const scope = yield* Scope.fork(state.scope, "parallel")
  195. const token = {}
  196. const job = {
  197. info: {
  198. id,
  199. type: input.type,
  200. title: input.title,
  201. status: "running" as const,
  202. started_at,
  203. metadata: input.metadata,
  204. },
  205. done,
  206. scope,
  207. token,
  208. pending: 1,
  209. next: 1,
  210. tail,
  211. promoted,
  212. onPromote: input.onPromote,
  213. }
  214. return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
  215. StartResult,
  216. Map<string, Active>,
  217. ]
  218. }),
  219. )
  220. if ("scope" in result)
  221. yield* fork(
  222. result.scope,
  223. id,
  224. result.token,
  225. 0,
  226. restore(input.run).pipe(Effect.ensuring(Deferred.succeed(tail, undefined))),
  227. )
  228. return result.info
  229. }),
  230. )
  231. })
  232. const extend: Interface["extend"] = Effect.fn("BackgroundJob.extend")(function* (input) {
  233. return yield* Effect.uninterruptibleMask((restore) =>
  234. Effect.gen(function* () {
  235. const tail = yield* Deferred.make<void>()
  236. const result = yield* SynchronizedRef.modify(
  237. state.jobs,
  238. (jobs): readonly [ExtendResult, Map<string, Active>] => {
  239. const job = jobs.get(input.id)
  240. if (!job || job.info.status !== "running") return [{ extended: false }, jobs]
  241. return [
  242. { extended: true, previous: job.tail, scope: job.scope, tail, token: job.token, sequence: job.next },
  243. new Map(jobs).set(input.id, {
  244. ...job,
  245. pending: job.pending + 1,
  246. next: job.next + 1,
  247. tail,
  248. }),
  249. ]
  250. },
  251. )
  252. if (!result.extended) return false
  253. yield* fork(
  254. result.scope,
  255. input.id,
  256. result.token,
  257. result.sequence,
  258. Deferred.await(result.previous).pipe(
  259. Effect.andThen(restore(input.run)),
  260. Effect.ensuring(Deferred.succeed(result.tail, undefined)),
  261. ),
  262. )
  263. return true
  264. }),
  265. )
  266. })
  267. const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) {
  268. const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
  269. if (!job) return { timedOut: false }
  270. if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
  271. if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
  272. if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
  273. const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
  274. if (info._tag === "Some") return { info: info.value, timedOut: false }
  275. return { info: snapshot(job), timedOut: true }
  276. })
  277. const waitForPromotion: Interface["waitForPromotion"] = Effect.fn("BackgroundJob.waitForPromotion")(function* (id) {
  278. const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
  279. if (!job || job.info.status !== "running") return yield* Effect.never
  280. if (job.info.metadata?.background === true) return snapshot(job)
  281. return yield* Deferred.await(job.promoted)
  282. })
  283. const promote: Interface["promote"] = Effect.fn("BackgroundJob.promote")(function* (id) {
  284. const result = yield* SynchronizedRef.modifyEffect(
  285. state.jobs,
  286. Effect.fnUntraced(function* (jobs) {
  287. const job = jobs.get(id)
  288. if (!job || job.info.status !== "running") return [{}, jobs] as readonly [PromoteResult, Map<string, Active>]
  289. if (job.info.metadata?.background === true)
  290. return [{ info: snapshot(job) }, jobs] as readonly [PromoteResult, Map<string, Active>]
  291. const next = {
  292. ...job,
  293. onPromote: undefined,
  294. info: {
  295. ...job.info,
  296. metadata: { ...job.info.metadata, background: true },
  297. },
  298. }
  299. return [
  300. { info: snapshot(next), onPromote: job.onPromote, promoted: job.promoted },
  301. new Map(jobs).set(id, next),
  302. ] as readonly [PromoteResult, Map<string, Active>]
  303. }),
  304. )
  305. if (result.info && result.promoted) yield* Deferred.succeed(result.promoted, result.info).pipe(Effect.ignore)
  306. if (result.onPromote) yield* result.onPromote.pipe(Effect.ignore)
  307. return result.info
  308. })
  309. const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) {
  310. const completed_at = yield* Clock.currentTimeMillis
  311. const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
  312. const job = jobs.get(id)
  313. if (!job) return [{}, jobs]
  314. if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
  315. const next = {
  316. ...job,
  317. onPromote: undefined,
  318. pending: 0,
  319. info: {
  320. ...job.info,
  321. status: "cancelled" as const,
  322. completed_at,
  323. },
  324. }
  325. return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
  326. })
  327. if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
  328. if (result.scope) yield* Scope.close(result.scope, Exit.void)
  329. return result.info
  330. })
  331. return Service.of({ list, get, start, extend, wait, waitForPromotion, promote, cancel })
  332. })
  333. const layer = Layer.effect(Service, make)
  334. export const node = makeGlobalNode({ service: Service, layer, deps: [] })