write-behind.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. /**
  2. * Bounded per-session write batching for the shared persistence coordinator.
  3. * @module @deepseek-ai/dsh-session-persistence/write-behind
  4. */
  5. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  6. /** Dependencies and scheduling policy for one live session's write controller. */
  7. export interface SessionWriteBehindOptions {
  8. /** Maximum intentional batching wait after an idle queue receives work. */
  9. readonly maxDelayMs: number
  10. /** Persist one stable ordered prefix; resolves only after backend durability. */
  11. readonly write: (events: readonly SessionEvent[]) => Promise<void>
  12. /** Observe a detached background write failure without rejecting the producer. */
  13. readonly reportBackgroundFailure: (error: unknown) => void
  14. }
  15. /**
  16. * Owns one live session's pending events, fixed batching deadline, active write,
  17. * failure retention, and explicit quiescence barrier.
  18. */
  19. export class SessionWriteBehind {
  20. private pending: SessionEvent[] = []
  21. private timer: ReturnType<typeof setTimeout> | undefined
  22. private active: Promise<void> | undefined
  23. private barrier: Promise<void> | undefined
  24. private deadlineExpired = false
  25. private automaticPaused = false
  26. /**
  27. * @param options - fixed scheduling policy and durable batch sink.
  28. */
  29. constructor(private readonly options: SessionWriteBehindOptions) {}
  30. /** Whether this controller owns queued events or an active durable write. */
  31. get hasWork(): boolean {
  32. return this.pending.length > 0 || this.active !== undefined
  33. }
  34. /**
  35. * Copy one event into the persistence-owned queue and start a fixed deadline
  36. * when the automatic path is idle.
  37. * @param event - frozen live event to retain independently of its producer.
  38. */
  39. enqueue(event: SessionEvent): void {
  40. const wasEmpty = this.pending.length === 0
  41. this.pending.push(structuredClone(event))
  42. if (this.barrier !== undefined) return
  43. if (this.automaticPaused) {
  44. this.automaticPaused = false
  45. this.deadlineExpired = false
  46. this.armTimer()
  47. } else if (wasEmpty) {
  48. this.armTimer()
  49. }
  50. }
  51. /**
  52. * Cancel the batching wait and durably drain through a quiescent point.
  53. * Concurrent callers join the same barrier.
  54. * @returns a promise that rejects if the barrier's durable retry fails.
  55. */
  56. flush(): Promise<void> {
  57. if (this.barrier !== undefined) return this.barrier
  58. this.cancelTimer()
  59. this.deadlineExpired = false
  60. this.automaticPaused = false
  61. const barrier = Promise.withResolvers<void>()
  62. this.barrier = barrier.promise
  63. void this.drainBarrier(barrier.resolve, barrier.reject)
  64. return barrier.promise
  65. }
  66. /** Cancel the current automatic deadline without draining retained work. */
  67. cancelAutomaticWait(): void {
  68. this.cancelTimer()
  69. this.deadlineExpired = false
  70. }
  71. /** Start the one fixed window for the current pending prefix. */
  72. private armTimer(): void {
  73. this.timer = setTimeout(() => { this.onDeadline() }, this.options.maxDelayMs)
  74. }
  75. /** Cancel any pending automatic deadline. */
  76. private cancelTimer(): void {
  77. if (this.timer === undefined) return
  78. clearTimeout(this.timer)
  79. this.timer = undefined
  80. }
  81. /** Start a background write now, or remember that an active write used the budget. */
  82. private onDeadline(): void {
  83. this.timer = undefined
  84. if (this.active !== undefined) {
  85. this.deadlineExpired = true
  86. return
  87. }
  88. this.startBackground()
  89. }
  90. /** Start one detached write whose failure is reported and retained. */
  91. private startBackground(): void {
  92. const active = this.startWrite(true)
  93. void active.then(() => { this.continueAutomatic() }, () => {})
  94. }
  95. /** Continue immediately after an over-budget active write, otherwise keep its timer. */
  96. private continueAutomatic(): void {
  97. if (this.barrier !== undefined || this.pending.length === 0) return
  98. if (this.deadlineExpired) {
  99. this.deadlineExpired = false
  100. this.startBackground()
  101. }
  102. }
  103. /** Await overlapping work, drain to quiescence, and settle the shared barrier. */
  104. private async drainBarrier(resolve: () => void, reject: (reason?: unknown) => void): Promise<void> {
  105. try {
  106. const overlapping = this.active
  107. if (overlapping !== undefined) {
  108. await Promise.allSettled([overlapping])
  109. this.automaticPaused = false
  110. }
  111. while (this.pending.length > 0) await this.startWrite(false)
  112. } catch (error: unknown) {
  113. this.barrier = undefined
  114. reject(error)
  115. return
  116. }
  117. // Close admission to this barrier in the same job that observes the empty
  118. // queue, before resolving callers. A later enqueue therefore starts its own
  119. // automatic window instead of being stranded behind a settled barrier.
  120. this.barrier = undefined
  121. resolve()
  122. }
  123. /** Start one stable pending prefix, retaining it in order if durability fails. */
  124. private startWrite(background: boolean): Promise<void> {
  125. const batch = this.pending.splice(0)
  126. this.cancelTimer()
  127. this.deadlineExpired = false
  128. const operation = Promise.resolve().then(() => this.options.write(batch))
  129. const active = operation
  130. .catch((error: unknown) => {
  131. this.pending = batch.concat(this.pending)
  132. this.cancelTimer()
  133. this.deadlineExpired = false
  134. this.automaticPaused = true
  135. if (background) this.options.reportBackgroundFailure(error)
  136. throw error
  137. })
  138. .finally(() => {
  139. this.active = undefined
  140. })
  141. this.active = active
  142. return active
  143. }
  144. }