@lota-sdk/core 0.4.7 → 0.4.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.
Files changed (259) hide show
  1. package/package.json +11 -12
  2. package/src/ai/embedding-cache.ts +94 -22
  3. package/src/ai-gateway/ai-gateway.ts +738 -223
  4. package/src/config/agent-defaults.ts +176 -75
  5. package/src/config/agent-types.ts +54 -4
  6. package/src/config/constants.ts +8 -2
  7. package/src/config/logger.ts +286 -19
  8. package/src/config/model-constants.ts +1 -0
  9. package/src/config/thread-defaults.ts +33 -21
  10. package/src/create-runtime.ts +725 -383
  11. package/src/db/base.service.ts +52 -28
  12. package/src/db/cursor-pagination.ts +71 -30
  13. package/src/db/memory-store.helpers.ts +4 -7
  14. package/src/db/memory-store.ts +856 -598
  15. package/src/db/memory.ts +398 -275
  16. package/src/db/record-id.ts +32 -10
  17. package/src/db/schema-fingerprint.ts +30 -12
  18. package/src/db/service-normalization.ts +255 -0
  19. package/src/db/service.ts +726 -761
  20. package/src/db/startup.ts +140 -66
  21. package/src/db/transaction-conflict.ts +15 -0
  22. package/src/effect/awaitable-effect.ts +87 -0
  23. package/src/effect/errors.ts +121 -0
  24. package/src/effect/helpers.ts +98 -0
  25. package/src/effect/index.ts +22 -0
  26. package/src/effect/layers.ts +228 -0
  27. package/src/effect/runtime-ref.ts +25 -0
  28. package/src/effect/runtime.ts +31 -0
  29. package/src/effect/services.ts +57 -0
  30. package/src/effect/zod.ts +43 -0
  31. package/src/embeddings/provider.ts +122 -71
  32. package/src/index.ts +46 -1
  33. package/src/openrouter/direct-provider.ts +29 -0
  34. package/src/queues/autonomous-job.queue.ts +130 -74
  35. package/src/queues/context-compaction.queue.ts +60 -15
  36. package/src/queues/delayed-node-promotion.queue.ts +52 -15
  37. package/src/queues/document-processor.queue.ts +52 -77
  38. package/src/queues/memory-consolidation.queue.ts +47 -32
  39. package/src/queues/organization-learning.queue.ts +13 -4
  40. package/src/queues/plan-agent-heartbeat.queue.ts +65 -21
  41. package/src/queues/plan-scheduler.queue.ts +107 -31
  42. package/src/queues/post-chat-memory.queue.ts +66 -24
  43. package/src/queues/queue-factory.ts +142 -52
  44. package/src/queues/standalone-worker.ts +39 -0
  45. package/src/queues/title-generation.queue.ts +54 -9
  46. package/src/redis/connection.ts +84 -32
  47. package/src/redis/index.ts +6 -8
  48. package/src/redis/org-memory-lock.ts +60 -27
  49. package/src/redis/redis-lease-lock.ts +200 -121
  50. package/src/redis/runtime-connection.ts +10 -0
  51. package/src/redis/stream-context.ts +84 -46
  52. package/src/runtime/agent-identity-overrides.ts +2 -2
  53. package/src/runtime/agent-runtime-policy.ts +4 -1
  54. package/src/runtime/agent-stream-helpers.ts +20 -9
  55. package/src/runtime/chat-run-orchestration.ts +102 -19
  56. package/src/runtime/chat-run-registry.ts +36 -2
  57. package/src/runtime/context-compaction/context-compaction-runtime.ts +107 -0
  58. package/src/runtime/{context-compaction.ts → context-compaction/context-compaction.ts} +114 -91
  59. package/src/runtime/execution-plan-visibility.ts +2 -2
  60. package/src/runtime/execution-plan.ts +42 -15
  61. package/src/runtime/graph-designer.ts +11 -7
  62. package/src/runtime/helper-model.ts +135 -48
  63. package/src/runtime/index.ts +7 -7
  64. package/src/runtime/indexed-repositories-policy.ts +3 -3
  65. package/src/runtime/{memory-block.ts → memory/memory-block.ts} +40 -36
  66. package/src/runtime/{memory-digest-policy.ts → memory/memory-digest-policy.ts} +1 -1
  67. package/src/runtime/{memory-pipeline.ts → memory/memory-pipeline.ts} +1 -1
  68. package/src/runtime/{memory-prompts-fact.ts → memory/memory-prompts-fact.ts} +2 -2
  69. package/src/runtime/{memory-scope.ts → memory/memory-scope.ts} +12 -6
  70. package/src/runtime/plugin-resolution.ts +144 -24
  71. package/src/runtime/plugin-types.ts +9 -1
  72. package/src/runtime/post-turn-side-effects.ts +197 -130
  73. package/src/runtime/retrieval-adapters.ts +38 -4
  74. package/src/runtime/runtime-config.ts +165 -61
  75. package/src/runtime/runtime-extensions.ts +21 -34
  76. package/src/runtime/social-chat/social-chat-agent-runner.ts +157 -0
  77. package/src/runtime/{social-chat-history.ts → social-chat/social-chat-history.ts} +42 -20
  78. package/src/runtime/social-chat/social-chat.ts +594 -0
  79. package/src/runtime/specialist-runner.ts +36 -10
  80. package/src/runtime/team-consultation/team-consultation-orchestrator.ts +427 -0
  81. package/src/runtime/{team-consultation-prompts.ts → team-consultation/team-consultation-prompts.ts} +6 -2
  82. package/src/runtime/thread-chat-helpers.ts +2 -2
  83. package/src/runtime/thread-plan-turn.ts +2 -1
  84. package/src/runtime/thread-turn-context.ts +172 -94
  85. package/src/runtime/turn-lifecycle.ts +93 -27
  86. package/src/services/agent-activity.service.ts +287 -203
  87. package/src/services/agent-executor.service.ts +329 -217
  88. package/src/services/artifact.service.ts +225 -148
  89. package/src/services/attachment.service.ts +137 -115
  90. package/src/services/autonomous-job.service.ts +888 -491
  91. package/src/services/chat-run-registry.service.ts +11 -1
  92. package/src/services/context-compaction.service.ts +136 -86
  93. package/src/services/document-chunk.service.ts +162 -90
  94. package/src/services/execution-plan/execution-plan-approval.ts +26 -0
  95. package/src/services/execution-plan/execution-plan-context.ts +29 -0
  96. package/src/services/execution-plan/execution-plan-graph.ts +256 -0
  97. package/src/services/execution-plan/execution-plan-schedule.ts +84 -0
  98. package/src/services/execution-plan/execution-plan-spec.ts +75 -0
  99. package/src/services/execution-plan/execution-plan.service.ts +1041 -0
  100. package/src/services/feedback-loop.service.ts +132 -76
  101. package/src/services/global-orchestrator.service.ts +80 -170
  102. package/src/services/graph-full-routing.ts +182 -0
  103. package/src/services/index.ts +18 -20
  104. package/src/services/institutional-memory.service.ts +220 -123
  105. package/src/services/learned-skill.service.ts +364 -259
  106. package/src/services/memory/memory-conversation.ts +95 -0
  107. package/src/services/memory/memory-org-memory.ts +39 -0
  108. package/src/services/memory/memory-preseeded.ts +80 -0
  109. package/src/services/memory/memory-rerank.ts +297 -0
  110. package/src/services/{memory-utils.ts → memory/memory-utils.ts} +5 -5
  111. package/src/services/memory/memory.service.ts +692 -0
  112. package/src/services/memory/rerank.service.ts +209 -0
  113. package/src/services/monitoring-window.service.ts +92 -70
  114. package/src/services/mutating-approval.service.ts +62 -53
  115. package/src/services/node-workspace.service.ts +141 -98
  116. package/src/services/notification.service.ts +17 -16
  117. package/src/services/organization-member.service.ts +120 -66
  118. package/src/services/organization.service.ts +144 -51
  119. package/src/services/ownership-dispatcher.service.ts +415 -264
  120. package/src/services/plan/plan-agent-heartbeat.service.ts +234 -0
  121. package/src/services/plan/plan-agent-query.service.ts +322 -0
  122. package/src/services/plan/plan-approval.service.ts +102 -0
  123. package/src/services/plan/plan-artifact.service.ts +60 -0
  124. package/src/services/plan/plan-builder.service.ts +76 -0
  125. package/src/services/plan/plan-checkpoint.service.ts +103 -0
  126. package/src/services/{plan-compiler.service.ts → plan/plan-compiler.service.ts} +26 -9
  127. package/src/services/plan/plan-completion-side-effects.ts +175 -0
  128. package/src/services/plan/plan-coordination.service.ts +181 -0
  129. package/src/services/plan/plan-cycle.service.ts +398 -0
  130. package/src/services/plan/plan-deadline.service.ts +547 -0
  131. package/src/services/plan/plan-event-delivery.service.ts +261 -0
  132. package/src/services/plan/plan-executor-context.ts +35 -0
  133. package/src/services/plan/plan-executor-graph.ts +475 -0
  134. package/src/services/plan/plan-executor-helpers.ts +322 -0
  135. package/src/services/plan/plan-executor-persistence.ts +209 -0
  136. package/src/services/plan/plan-executor.service.ts +1654 -0
  137. package/src/services/{plan-helpers.ts → plan/plan-helpers.ts} +1 -1
  138. package/src/services/{plan-run-data.ts → plan/plan-run-data.ts} +4 -4
  139. package/src/services/plan/plan-run-serialization.ts +15 -0
  140. package/src/services/plan/plan-run.service.ts +644 -0
  141. package/src/services/plan/plan-scheduler.service.ts +385 -0
  142. package/src/services/plan/plan-template.service.ts +224 -0
  143. package/src/services/plan/plan-transaction-events.ts +33 -0
  144. package/src/services/plan/plan-validator.service.ts +907 -0
  145. package/src/services/plan/plan-workspace.service.ts +125 -0
  146. package/src/services/plugin-executor.service.ts +97 -68
  147. package/src/services/quality-metrics.service.ts +112 -94
  148. package/src/services/queue-job.service.ts +296 -230
  149. package/src/services/recent-activity-title.service.ts +65 -36
  150. package/src/services/recent-activity.service.ts +274 -259
  151. package/src/services/skill-resolver.service.ts +38 -12
  152. package/src/services/social-chat-history.service.ts +176 -125
  153. package/src/services/system-executor.service.ts +91 -61
  154. package/src/services/thread/thread-active-run.ts +203 -0
  155. package/src/services/thread/thread-bootstrap.ts +369 -0
  156. package/src/services/thread/thread-listing.ts +198 -0
  157. package/src/services/thread/thread-memory-block.ts +117 -0
  158. package/src/services/thread/thread-message.service.ts +363 -0
  159. package/src/services/thread/thread-record-store.ts +155 -0
  160. package/src/services/thread/thread-title.service.ts +74 -0
  161. package/src/services/thread/thread-turn-execution.ts +280 -0
  162. package/src/services/thread/thread-turn-message-context.ts +73 -0
  163. package/src/services/thread/thread-turn-preparation.service.ts +1146 -0
  164. package/src/services/thread/thread-turn-streaming.ts +402 -0
  165. package/src/services/thread/thread-turn-tracing.ts +35 -0
  166. package/src/services/thread/thread-turn.ts +343 -0
  167. package/src/services/thread/thread.service.ts +335 -0
  168. package/src/services/user.service.ts +82 -32
  169. package/src/services/write-intent-validator.service.ts +63 -51
  170. package/src/storage/attachment-parser.ts +69 -27
  171. package/src/storage/attachment-storage.service.ts +331 -275
  172. package/src/storage/generated-document-storage.service.ts +66 -34
  173. package/src/system-agents/agent-result.ts +3 -1
  174. package/src/system-agents/context-compaction.agent.ts +2 -2
  175. package/src/system-agents/delegated-agent-factory.ts +159 -90
  176. package/src/system-agents/memory-reranker.agent.ts +2 -2
  177. package/src/system-agents/memory.agent.ts +2 -2
  178. package/src/system-agents/recent-activity-title-refiner.agent.ts +2 -2
  179. package/src/system-agents/regular-chat-memory-digest.agent.ts +2 -2
  180. package/src/system-agents/skill-extractor.agent.ts +2 -2
  181. package/src/system-agents/skill-manager.agent.ts +2 -2
  182. package/src/system-agents/thread-router.agent.ts +157 -113
  183. package/src/system-agents/title-generator.agent.ts +2 -2
  184. package/src/tools/execution-plan.tool.ts +220 -161
  185. package/src/tools/fetch-webpage.tool.ts +21 -17
  186. package/src/tools/firecrawl-client.ts +16 -6
  187. package/src/tools/index.ts +1 -0
  188. package/src/tools/memory-block.tool.ts +14 -6
  189. package/src/tools/plan-approval.tool.ts +49 -47
  190. package/src/tools/read-file-parts.tool.ts +44 -33
  191. package/src/tools/remember-memory.tool.ts +65 -45
  192. package/src/tools/search-web.tool.ts +26 -22
  193. package/src/tools/search.tool.ts +41 -29
  194. package/src/tools/team-think.tool.ts +124 -83
  195. package/src/tools/user-questions.tool.ts +4 -3
  196. package/src/tools/web-tool-shared.ts +6 -0
  197. package/src/utils/async.ts +17 -23
  198. package/src/utils/crypto.ts +21 -0
  199. package/src/utils/date-time.ts +40 -1
  200. package/src/utils/errors.ts +95 -16
  201. package/src/utils/hono-error-handler.ts +24 -39
  202. package/src/utils/index.ts +2 -1
  203. package/src/utils/null-proto-record.ts +41 -0
  204. package/src/utils/sse-keepalive.ts +124 -21
  205. package/src/workers/bootstrap.ts +186 -51
  206. package/src/workers/memory-consolidation.worker.ts +325 -237
  207. package/src/workers/organization-learning.worker.ts +50 -16
  208. package/src/workers/regular-chat-memory-digest.helpers.ts +28 -27
  209. package/src/workers/regular-chat-memory-digest.runner.ts +175 -114
  210. package/src/workers/skill-extraction.runner.ts +176 -93
  211. package/src/workers/utils/file-section-chunker.ts +8 -10
  212. package/src/workers/utils/repo-structure-extractor.ts +349 -260
  213. package/src/workers/utils/repomix-file-sections.ts +2 -2
  214. package/src/workers/utils/thread-message-query.ts +97 -38
  215. package/src/workers/worker-utils.ts +56 -31
  216. package/src/config/debug-logger.ts +0 -47
  217. package/src/redis/connection-accessor.ts +0 -26
  218. package/src/runtime/context-compaction-runtime.ts +0 -87
  219. package/src/runtime/social-chat-agent-runner.ts +0 -118
  220. package/src/runtime/social-chat.ts +0 -516
  221. package/src/runtime/team-consultation-orchestrator.ts +0 -272
  222. package/src/services/adaptive-playbook.service.ts +0 -152
  223. package/src/services/artifact-provenance.service.ts +0 -172
  224. package/src/services/chat-attachments.service.ts +0 -17
  225. package/src/services/context-compaction-runtime.singleton.ts +0 -13
  226. package/src/services/execution-plan.service.ts +0 -1118
  227. package/src/services/memory.service.ts +0 -844
  228. package/src/services/plan-agent-heartbeat.service.ts +0 -136
  229. package/src/services/plan-agent-query.service.ts +0 -267
  230. package/src/services/plan-approval.service.ts +0 -83
  231. package/src/services/plan-artifact.service.ts +0 -50
  232. package/src/services/plan-builder.service.ts +0 -67
  233. package/src/services/plan-checkpoint.service.ts +0 -81
  234. package/src/services/plan-completion-side-effects.ts +0 -80
  235. package/src/services/plan-coordination.service.ts +0 -157
  236. package/src/services/plan-cycle.service.ts +0 -284
  237. package/src/services/plan-deadline.service.ts +0 -430
  238. package/src/services/plan-event-delivery.service.ts +0 -166
  239. package/src/services/plan-executor.service.ts +0 -1950
  240. package/src/services/plan-run.service.ts +0 -515
  241. package/src/services/plan-scheduler.service.ts +0 -240
  242. package/src/services/plan-template.service.ts +0 -177
  243. package/src/services/plan-validator.service.ts +0 -818
  244. package/src/services/plan-workspace.service.ts +0 -83
  245. package/src/services/thread-message.service.ts +0 -275
  246. package/src/services/thread-plan-registry.service.ts +0 -22
  247. package/src/services/thread-title.service.ts +0 -39
  248. package/src/services/thread-turn-preparation.service.ts +0 -1147
  249. package/src/services/thread-turn.ts +0 -172
  250. package/src/services/thread.service.ts +0 -869
  251. package/src/utils/env.ts +0 -8
  252. /package/src/runtime/{context-compaction-constants.ts → context-compaction/context-compaction-constants.ts} +0 -0
  253. /package/src/runtime/{memory-format.ts → memory/memory-format.ts} +0 -0
  254. /package/src/runtime/{memory-prompts-parse.ts → memory/memory-prompts-parse.ts} +0 -0
  255. /package/src/runtime/{memory-prompts-update.ts → memory/memory-prompts-update.ts} +0 -0
  256. /package/src/runtime/{social-chat-prompts.ts → social-chat/social-chat-prompts.ts} +0 -0
  257. /package/src/services/{plan-node-spec.ts → plan/plan-node-spec.ts} +0 -0
  258. /package/src/services/{thread-constants.ts → thread/thread-constants.ts} +0 -0
  259. /package/src/services/{thread.types.ts → thread/thread.types.ts} +0 -0
@@ -1,6 +1,9 @@
1
+ import { Duration, Effect, Exit, Fiber } from 'effect'
1
2
  import IORedis from 'ioredis'
2
3
  import type { RedisOptions } from 'ioredis'
3
4
 
5
+ import { ConfigurationError } from '../effect/errors'
6
+ import { effectTryServicePromise } from '../effect/helpers'
4
7
  import { getErrorMessage } from '../utils/errors'
5
8
 
6
9
  export interface RedisConnectionLogger {
@@ -10,7 +13,7 @@ export interface RedisConnectionLogger {
10
13
  error?: (message: string) => void
11
14
  }
12
15
 
13
- interface CreateRedisConnectionManagerOptions {
16
+ export interface CreateRedisConnectionManagerOptions {
14
17
  url: string
15
18
  redisOptions?: RedisOptions
16
19
  healthCheckIntervalMs?: number
@@ -29,6 +32,8 @@ const REDIS_RETRY_STEP_MS = 50
29
32
  const REDIS_RETRY_MAX_DELAY_MS = 2000
30
33
  const REDIS_CONNECT_TIMEOUT_MS = 10_000
31
34
 
35
+ type HealthCheckFiber = ReturnType<typeof Effect.runFork>
36
+
32
37
  export const DEFAULT_REDIS_OPTIONS: RedisOptions = {
33
38
  maxRetriesPerRequest: null,
34
39
  enableReadyCheck: true,
@@ -55,10 +60,11 @@ function log(logger: RedisConnectionLogger | undefined, level: keyof RedisConnec
55
60
  class RedisConnectionManagerImpl implements RedisConnectionManager {
56
61
  private redis: IORedis | null = null
57
62
  private isHealthy = false
58
- private healthCheckInterval: ReturnType<typeof setInterval> | null = null
59
63
  private isInitialized = false
60
64
  private isClosing = false
61
65
  private isHealthCheckRunning = false
66
+ private healthChecksActive = false
67
+ private healthCheckFiber: HealthCheckFiber | null = null
62
68
 
63
69
  constructor(private readonly options: CreateRedisConnectionManagerOptions) {
64
70
  this.initializeConnection()
@@ -123,32 +129,70 @@ class RedisConnectionManagerImpl implements RedisConnectionManager {
123
129
  }
124
130
 
125
131
  private startHealthChecks(): void {
126
- if (this.healthCheckInterval) {
132
+ if (this.healthChecksActive || this.healthCheckFiber) {
127
133
  return
128
134
  }
129
135
 
136
+ this.healthChecksActive = true
130
137
  const intervalMs = this.options.healthCheckIntervalMs ?? DEFAULT_HEALTH_CHECK_INTERVAL_MS
131
- this.healthCheckInterval = setInterval(async () => {
132
- if (this.isHealthCheckRunning) return
133
- this.isHealthCheckRunning = true
134
- try {
135
- if (this.redis && this.redis.status === 'ready') {
136
- await this.redis.ping()
137
- this.isHealthy = true
138
+ const logger = this.options.logger
139
+ const isHealthChecksActive = () => this.healthChecksActive
140
+ const isHealthCheckRunning = () => this.isHealthCheckRunning
141
+ const setHealthCheckRunning = (value: boolean) => {
142
+ this.isHealthCheckRunning = value
143
+ }
144
+ const getRedis = () => this.redis
145
+ const setHealthy = (value: boolean) => {
146
+ this.isHealthy = value
147
+ }
148
+
149
+ this.healthCheckFiber = Effect.runFork(
150
+ Effect.gen(function* () {
151
+ for (;;) {
152
+ yield* Effect.sleep(Duration.millis(intervalMs))
153
+ if (!isHealthChecksActive()) break
154
+ if (isHealthCheckRunning()) continue
155
+
156
+ setHealthCheckRunning(true)
157
+ try {
158
+ const redis = getRedis()
159
+ if (redis && redis.status === 'ready') {
160
+ const pingExit = yield* Effect.exit(
161
+ effectTryServicePromise(() => redis.ping(), 'Redis health check failed'),
162
+ )
163
+ if (Exit.isFailure(pingExit)) {
164
+ log(logger, 'warn', `Redis health check failed: ${getErrorMessage(pingExit.cause)}`)
165
+ setHealthy(false)
166
+ } else {
167
+ setHealthy(true)
168
+ }
169
+ }
170
+ } finally {
171
+ setHealthCheckRunning(false)
172
+ }
138
173
  }
139
- } catch (error) {
140
- log(this.options.logger, 'warn', `Redis health check failed: ${getErrorMessage(error)}`)
141
- this.isHealthy = false
142
- } finally {
143
- this.isHealthCheckRunning = false
144
- }
145
- }, intervalMs)
146
- this.healthCheckInterval.unref()
174
+ }),
175
+ )
176
+ }
177
+
178
+ private stopHealthChecks(): Promise<void> {
179
+ this.healthChecksActive = false
180
+
181
+ const fiber = this.healthCheckFiber
182
+ if (!fiber) {
183
+ return Promise.resolve()
184
+ }
185
+
186
+ this.healthCheckFiber = null
187
+ return Effect.runPromise(Fiber.interrupt(fiber)).then(
188
+ () => undefined,
189
+ () => undefined,
190
+ )
147
191
  }
148
192
 
149
193
  getConnection(): IORedis {
150
194
  if (!this.redis) {
151
- throw new Error('Redis connection not initialized')
195
+ throw new ConfigurationError({ message: 'Redis connection not initialized', key: 'redis' })
152
196
  }
153
197
  return this.redis
154
198
  }
@@ -161,22 +205,30 @@ class RedisConnectionManagerImpl implements RedisConnectionManager {
161
205
  return this.isHealthy && this.redis?.status === 'ready'
162
206
  }
163
207
 
164
- async closeConnection(): Promise<void> {
165
- if (this.healthCheckInterval) {
166
- clearInterval(this.healthCheckInterval)
167
- this.healthCheckInterval = null
168
- }
169
-
208
+ closeConnection(): Promise<void> {
170
209
  this.isClosing = true
171
-
172
- try {
173
- if (this.redis && this.redis.status !== 'end') {
174
- await this.redis.quit()
175
- }
176
- } catch (error) {
177
- log(this.options.logger, 'warn', `Redis quit failed, forcing disconnect: ${getErrorMessage(error)}`)
210
+ const logger = this.options.logger
211
+ const getRedis = () => this.redis
212
+ const stopHealthChecks = () => this.stopHealthChecks()
213
+ const disconnect = () => {
178
214
  this.redis?.disconnect()
179
215
  }
216
+
217
+ return Effect.runPromise(
218
+ Effect.gen(function* () {
219
+ yield* Effect.tryPromise(() => stopHealthChecks())
220
+ const redis = getRedis()
221
+ if (redis && redis.status !== 'end') {
222
+ const quitExit = yield* Effect.exit(
223
+ effectTryServicePromise(() => redis.quit(), 'Failed to close Redis connection manager'),
224
+ )
225
+ if (Exit.isFailure(quitExit)) {
226
+ log(logger, 'warn', `Redis quit failed, forcing disconnect: ${getErrorMessage(quitExit.cause)}`)
227
+ disconnect()
228
+ }
229
+ }
230
+ }),
231
+ )
180
232
  }
181
233
  }
182
234
 
@@ -1,15 +1,13 @@
1
1
  import { createRedisConnectionManager } from './connection'
2
2
  import type { RedisConnectionManager } from './connection'
3
3
  export { DEFAULT_REDIS_OPTIONS, type RedisConnectionLogger } from './connection'
4
+ export { withOrgMemoryLock, withOrgMemoryLockEffect } from './org-memory-lock'
5
+ export { withLeaseLock } from './redis-lease-lock'
4
6
  export {
5
- getRedisConnection,
6
- getRedisConnectionForBullMQ,
7
- setRedisConnectionManager,
8
- type RedisConnectionAccessor,
9
- } from './connection-accessor'
10
- export { withOrgMemoryLock } from './org-memory-lock'
11
- export { LeaseLockLostError, withRedisLeaseLock } from './redis-lease-lock'
12
- export { closeSharedSubscriber, createThreadResumableContext } from './stream-context'
7
+ createThreadResumableContext,
8
+ SharedThreadStreamSubscriberLive,
9
+ SharedThreadStreamSubscriberTag,
10
+ } from './stream-context'
13
11
 
14
12
  export { createRedisConnectionManager }
15
13
  export type { RedisConnectionManager }
@@ -1,6 +1,12 @@
1
+ import { Schema, Effect } from 'effect'
2
+ import type IORedis from 'ioredis'
3
+
1
4
  import { serverLogger } from '../config/logger'
2
- import { getRedisConnection } from './connection-accessor'
3
- import { withRedisLeaseLock } from './redis-lease-lock'
5
+ import { LockAcquisitionError } from '../effect/errors'
6
+ import type { LockLostError, RedisError } from '../effect/errors'
7
+ import { withLeaseLock } from './redis-lease-lock'
8
+ import type { RedisLeaseLockOptions } from './redis-lease-lock'
9
+ import { getRuntimeRedisConnection } from './runtime-connection'
4
10
 
5
11
  const ORG_MEMORY_LOCK_PREFIX = 'lock:org-memory:org:'
6
12
  const ORG_MEMORY_LOCK_TTL_MS = 120_000
@@ -9,35 +15,62 @@ const ORG_MEMORY_LOCK_REFRESH_INTERVAL_MS = 30_000
9
15
  const ORG_MEMORY_LOCK_WAIT_LOG_INTERVAL_MS = 30_000
10
16
  const ORG_MEMORY_LOCK_MAX_WAIT_MS = 15 * 60 * 1000
11
17
 
12
- export async function withOrgMemoryLock<T>(orgId: string, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
18
+ class OrgMemoryLockCallbackError extends Schema.TaggedErrorClass<OrgMemoryLockCallbackError>()(
19
+ 'OrgMemoryLockCallbackError',
20
+ { message: Schema.String, cause: Schema.Defect },
21
+ ) {}
22
+
23
+ function createOrgMemoryLockOptions(redis: IORedis, orgId: string): RedisLeaseLockOptions & { redis: IORedis } {
24
+ return {
25
+ redis,
26
+ lockKey: `${ORG_MEMORY_LOCK_PREFIX}${orgId}`,
27
+ lockTtlMs: ORG_MEMORY_LOCK_TTL_MS,
28
+ retryDelayMs: ORG_MEMORY_LOCK_RETRY_DELAY_MS,
29
+ refreshIntervalMs: ORG_MEMORY_LOCK_REFRESH_INTERVAL_MS,
30
+ waitLogIntervalMs: ORG_MEMORY_LOCK_WAIT_LOG_INTERVAL_MS,
31
+ maxWaitMs: ORG_MEMORY_LOCK_MAX_WAIT_MS,
32
+ label: 'org memory lock',
33
+ logger: {
34
+ debug: (message) => {
35
+ serverLogger.debug`${message}`
36
+ },
37
+ info: (message) => {
38
+ serverLogger.info`${message}`
39
+ },
40
+ warn: (message) => {
41
+ serverLogger.warn`${message}`
42
+ },
43
+ },
44
+ }
45
+ }
46
+
47
+ export function withOrgMemoryLock<T>(orgId: string, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
48
+ return Effect.runPromise(
49
+ withOrgMemoryLockEffect(orgId, (signal) =>
50
+ Effect.tryPromise({
51
+ try: () => fn(signal),
52
+ catch: (cause) => new OrgMemoryLockCallbackError({ message: 'Org memory lock callback failed.', cause }),
53
+ }),
54
+ ),
55
+ )
56
+ }
57
+
58
+ export function withOrgMemoryLockEffect<A, E>(
59
+ orgId: string,
60
+ fn: (signal: AbortSignal) => Effect.Effect<A, E>,
61
+ ): Effect.Effect<A, E | LockAcquisitionError | LockLostError | RedisError> {
13
62
  const normalizedOrgId = orgId.trim()
14
63
 
15
64
  if (!normalizedOrgId) {
16
- throw new Error('Organization id is required for memory lock')
65
+ return Effect.fail(
66
+ new LockAcquisitionError({
67
+ lockKey: `${ORG_MEMORY_LOCK_PREFIX}<missing-org-id>`,
68
+ maxWaitMs: ORG_MEMORY_LOCK_MAX_WAIT_MS,
69
+ }),
70
+ )
17
71
  }
18
72
 
19
- return withRedisLeaseLock(
20
- {
21
- redis: getRedisConnection(),
22
- lockKey: `${ORG_MEMORY_LOCK_PREFIX}${normalizedOrgId}`,
23
- lockTtlMs: ORG_MEMORY_LOCK_TTL_MS,
24
- retryDelayMs: ORG_MEMORY_LOCK_RETRY_DELAY_MS,
25
- refreshIntervalMs: ORG_MEMORY_LOCK_REFRESH_INTERVAL_MS,
26
- waitLogIntervalMs: ORG_MEMORY_LOCK_WAIT_LOG_INTERVAL_MS,
27
- maxWaitMs: ORG_MEMORY_LOCK_MAX_WAIT_MS,
28
- label: 'org memory lock',
29
- logger: {
30
- debug: (message) => {
31
- serverLogger.debug`${message}`
32
- },
33
- info: (message) => {
34
- serverLogger.info`${message}`
35
- },
36
- warn: (message) => {
37
- serverLogger.warn`${message}`
38
- },
39
- },
40
- },
41
- fn,
73
+ return Effect.sync(() => getRuntimeRedisConnection()).pipe(
74
+ Effect.flatMap((redis) => withLeaseLock(createOrgMemoryLockOptions(redis, normalizedOrgId), fn)),
42
75
  )
43
76
  }
@@ -1,7 +1,8 @@
1
- import { setTimeout as delay } from 'node:timers/promises'
2
-
1
+ import { Clock, Deferred, Duration, Effect, Random, Schedule } from 'effect'
3
2
  import type IORedis from 'ioredis'
4
3
 
4
+ import { LockAcquisitionError, LockLostError, RedisError } from '../effect/errors'
5
+ import { RedisServiceTag } from '../effect/services'
5
6
  import { getErrorMessage } from '../utils/errors'
6
7
 
7
8
  interface RedisLeaseLockLogger {
@@ -10,8 +11,8 @@ interface RedisLeaseLockLogger {
10
11
  warn?: (message: string) => void
11
12
  }
12
13
 
13
- interface RedisLeaseLockOptions {
14
- redis: IORedis
14
+ export interface RedisLeaseLockOptions {
15
+ redis?: IORedis
15
16
  lockKey: string
16
17
  lockTtlMs: number
17
18
  retryDelayMs?: number
@@ -47,144 +48,222 @@ function log(logger: RedisLeaseLockLogger | undefined, level: keyof RedisLeaseLo
47
48
  }
48
49
  }
49
50
 
50
- async function acquireLeaseLock(
51
- options: Required<Pick<RedisLeaseLockOptions, 'retryDelayMs' | 'waitLogIntervalMs' | 'maxWaitMs'>> &
52
- RedisLeaseLockOptions & { lockValue: string; label: string },
53
- ): Promise<void> {
54
- const waitStart = Date.now()
55
- let lastWaitLogAt = waitStart
56
-
57
- for (;;) {
58
- const result = await options.redis.set(options.lockKey, options.lockValue, 'PX', options.lockTtlMs, 'NX')
59
-
60
- if (result === 'OK') {
61
- return
62
- }
51
+ function toRedisError(error: unknown, message: string): RedisError {
52
+ return new RedisError({ message: `${message}: ${getErrorMessage(error)}`, cause: error })
53
+ }
63
54
 
64
- const now = Date.now()
65
- const waitedMs = now - waitStart
66
- if (waitedMs >= options.maxWaitMs) {
67
- throw new Error(`Timed out waiting for ${options.label} (${options.lockKey}) waitedMs=${waitedMs}`)
68
- }
55
+ function getNormalizedOptions(options: RedisLeaseLockOptions) {
56
+ const normalizedLockTtlMs = Math.max(1_000, Math.trunc(options.lockTtlMs))
57
+ const requestedRefreshIntervalMs = options.refreshIntervalMs ?? Math.floor(normalizedLockTtlMs / 3)
58
+ const normalizedRefreshIntervalMs = Math.min(
59
+ Math.max(250, Math.trunc(requestedRefreshIntervalMs)),
60
+ Math.max(250, normalizedLockTtlMs - 250),
61
+ )
69
62
 
70
- if (now - lastWaitLogAt >= options.waitLogIntervalMs) {
71
- log(options.logger, 'info', `Waiting for ${options.label} (${options.lockKey}) waitedMs=${waitedMs}`)
72
- lastWaitLogAt = now
73
- }
63
+ return {
64
+ ...options,
65
+ lockKey: options.lockKey.trim(),
66
+ lockTtlMs: normalizedLockTtlMs,
67
+ retryDelayMs: options.retryDelayMs ?? 500,
68
+ refreshIntervalMs: normalizedRefreshIntervalMs,
69
+ waitLogIntervalMs: options.waitLogIntervalMs ?? 30_000,
70
+ maxWaitMs: options.maxWaitMs ?? 45_000,
71
+ acquiredWaitInfoThresholdMs: options.acquiredWaitInfoThresholdMs ?? 1_000,
72
+ heldInfoThresholdMs: options.heldInfoThresholdMs ?? 5_000,
73
+ label: options.label ?? 'redis lease lock',
74
+ }
75
+ }
74
76
 
75
- await delay(options.retryDelayMs)
77
+ function resolveRedisConnection(options: RedisLeaseLockOptions): Effect.Effect<IORedis, RedisError, RedisServiceTag> {
78
+ if (options.redis) {
79
+ return Effect.succeed(options.redis)
76
80
  }
81
+
82
+ return Effect.gen(function* () {
83
+ const redisService = yield* RedisServiceTag
84
+ return redisService.getConnection()
85
+ })
77
86
  }
78
87
 
79
- async function refreshLeaseLock(options: RedisLeaseLockOptions & { lockValue: string; label: string }): Promise<void> {
80
- const refreshed = await options.redis.eval(
81
- REFRESH_LOCK_SCRIPT,
82
- 1,
83
- options.lockKey,
84
- options.lockValue,
85
- options.lockTtlMs.toString(),
86
- )
88
+ function acquireLock(
89
+ redis: IORedis,
90
+ options: ReturnType<typeof getNormalizedOptions>,
91
+ lockValue: string,
92
+ ): Effect.Effect<void, LockAcquisitionError | RedisError> {
93
+ return Effect.gen(function* () {
94
+ const waitStart = yield* Clock.currentTimeMillis
95
+ let lastWaitLogAt = waitStart
87
96
 
88
- const refreshedCount = typeof refreshed === 'number' ? refreshed : Number(refreshed)
89
- if (refreshedCount === 1) return
90
- throw new Error(`${options.label} refresh was rejected for key ${options.lockKey}`)
97
+ const tryOnce = Effect.gen(function* () {
98
+ const result = yield* Effect.tryPromise({
99
+ try: () => redis.set(options.lockKey, lockValue, 'PX', options.lockTtlMs, 'NX'),
100
+ catch: (error) => toRedisError(error, `Failed to acquire ${options.label} (${options.lockKey})`),
101
+ })
102
+
103
+ if (result === 'OK') {
104
+ return
105
+ }
106
+
107
+ const now = yield* Clock.currentTimeMillis
108
+ const waitedMs = now - waitStart
109
+ if (now - lastWaitLogAt >= options.waitLogIntervalMs) {
110
+ log(options.logger, 'info', `Waiting for ${options.label} (${options.lockKey}) waitedMs=${waitedMs}`)
111
+ lastWaitLogAt = now
112
+ }
113
+
114
+ return yield* new LockAcquisitionError({ lockKey: options.lockKey, maxWaitMs: options.maxWaitMs })
115
+ })
116
+
117
+ return yield* Effect.retry(tryOnce, {
118
+ times: Math.max(0, Math.ceil(options.maxWaitMs / options.retryDelayMs) - 1),
119
+ schedule: Schedule.fixed(Duration.millis(options.retryDelayMs)),
120
+ }).pipe(
121
+ Effect.asVoid,
122
+ Effect.catchTag('LockAcquisitionError', () =>
123
+ Effect.fail(new LockAcquisitionError({ lockKey: options.lockKey, maxWaitMs: options.maxWaitMs })),
124
+ ),
125
+ )
126
+ })
91
127
  }
92
128
 
93
- async function releaseLeaseLock(options: RedisLeaseLockOptions & { lockValue: string }): Promise<void> {
94
- await options.redis.eval(RELEASE_LOCK_SCRIPT, 1, options.lockKey, options.lockValue)
129
+ function refreshLock(
130
+ redis: IORedis,
131
+ options: ReturnType<typeof getNormalizedOptions>,
132
+ lockValue: string,
133
+ ): Effect.Effect<void, LockLostError | RedisError> {
134
+ return Effect.tryPromise({
135
+ try: () => redis.eval(REFRESH_LOCK_SCRIPT, 1, options.lockKey, lockValue, options.lockTtlMs.toString()),
136
+ catch: (error) => toRedisError(error, `Failed to refresh ${options.label} (${options.lockKey})`),
137
+ }).pipe(
138
+ Effect.flatMap((refreshed) => {
139
+ const refreshedCount = typeof refreshed === 'number' ? refreshed : Number(refreshed)
140
+ if (refreshedCount === 1) {
141
+ return Effect.void
142
+ }
143
+ return Effect.fail(new LockLostError({ lockKey: options.lockKey }))
144
+ }),
145
+ )
95
146
  }
96
147
 
97
- export class LeaseLockLostError extends Error {
98
- constructor(message: string) {
99
- super(message)
100
- this.name = 'LeaseLockLostError'
101
- }
148
+ function releaseLock(
149
+ redis: IORedis,
150
+ options: ReturnType<typeof getNormalizedOptions>,
151
+ lockValue: string,
152
+ ): Effect.Effect<void, RedisError> {
153
+ return Effect.asVoid(
154
+ Effect.tryPromise({
155
+ try: () => redis.eval(RELEASE_LOCK_SCRIPT, 1, options.lockKey, lockValue),
156
+ catch: (error) => toRedisError(error, `Failed to release ${options.label} (${options.lockKey})`),
157
+ }),
158
+ )
102
159
  }
103
160
 
104
- function startLeaseLockRefreshLoop(
105
- options: Required<Pick<RedisLeaseLockOptions, 'refreshIntervalMs'>> &
106
- RedisLeaseLockOptions & { lockValue: string; label: string },
107
- ac: AbortController,
108
- ): () => void {
109
- let stopped = false
110
-
111
- const timer = setInterval(() => {
112
- if (stopped) return
113
- void refreshLeaseLock(options).catch((error: unknown) => {
114
- stopped = true
115
- clearInterval(timer)
116
- const message = `Failed to refresh ${options.label} (${options.lockKey}): ${getErrorMessage(error)}`
117
- log(options.logger, 'warn', message)
118
- if (!ac.signal.aborted) {
119
- ac.abort(new LeaseLockLostError(message))
120
- }
121
- })
122
- }, options.refreshIntervalMs)
161
+ function startRefreshFiber(
162
+ redis: IORedis,
163
+ options: ReturnType<typeof getNormalizedOptions>,
164
+ lockValue: string,
165
+ abortController: AbortController,
166
+ lockLost: Deferred.Deferred<never, LockLostError | RedisError>,
167
+ ) {
168
+ const handleLockLoss = (error: LockLostError | RedisError) =>
169
+ Deferred.fail(lockLost, error).pipe(
170
+ Effect.andThen(
171
+ Effect.sync(() => {
172
+ const message =
173
+ error._tag === 'LockLostError'
174
+ ? `${options.label} refresh was rejected for key ${options.lockKey}`
175
+ : error.message
123
176
 
124
- timer.unref()
177
+ log(options.logger, 'warn', message)
178
+ if (!abortController.signal.aborted) {
179
+ abortController.abort(error)
180
+ }
181
+ }),
182
+ ),
183
+ Effect.andThen(Effect.fail(error)),
184
+ )
125
185
 
126
- return () => {
127
- stopped = true
128
- clearInterval(timer)
129
- }
186
+ const refreshProgram = Effect.gen(function* () {
187
+ for (;;) {
188
+ yield* Effect.sleep(Duration.millis(options.refreshIntervalMs))
189
+
190
+ yield* refreshLock(redis, options, lockValue).pipe(
191
+ Effect.catchTag('LockLostError', handleLockLoss),
192
+ Effect.catchTag('RedisError', handleLockLoss),
193
+ )
194
+ }
195
+ })
196
+
197
+ return Effect.forkScoped(refreshProgram).pipe(Effect.asVoid)
130
198
  }
131
199
 
132
- export async function withRedisLeaseLock<T>(
200
+ export function withLeaseLock<A, E, R>(
201
+ options: RedisLeaseLockOptions & { redis: IORedis },
202
+ fn: (signal: AbortSignal) => Effect.Effect<A, E, R>,
203
+ ): Effect.Effect<A, E | LockAcquisitionError | LockLostError | RedisError, R>
204
+ export function withLeaseLock<A, E, R>(
133
205
  options: RedisLeaseLockOptions,
134
- fn: (signal: AbortSignal) => Promise<T>,
135
- ): Promise<T> {
136
- const lockKey = options.lockKey.trim()
137
- if (!lockKey) {
138
- throw new Error('Redis lease lock requires a non-empty lock key')
139
- }
206
+ fn: (signal: AbortSignal) => Effect.Effect<A, E, R>,
207
+ ): Effect.Effect<A, E | LockAcquisitionError | LockLostError | RedisError, RedisServiceTag | R> {
208
+ const normalized = getNormalizedOptions(options)
140
209
 
141
- const retryDelayMs = options.retryDelayMs ?? 500
142
- const refreshIntervalMs = options.refreshIntervalMs ?? 30_000
143
- const waitLogIntervalMs = options.waitLogIntervalMs ?? 30_000
144
- const maxWaitMs = options.maxWaitMs ?? 45_000
145
- const acquiredWaitInfoThresholdMs = options.acquiredWaitInfoThresholdMs ?? 1_000
146
- const heldInfoThresholdMs = options.heldInfoThresholdMs ?? 5_000
147
- const label = options.label ?? 'redis lease lock'
148
-
149
- const lockValue = crypto.randomUUID()
150
- const waitStart = Date.now()
151
- await acquireLeaseLock({ ...options, lockKey, lockValue, label, retryDelayMs, waitLogIntervalMs, maxWaitMs })
152
- const waitedMs = Date.now() - waitStart
153
- if (waitedMs >= acquiredWaitInfoThresholdMs) {
154
- log(options.logger, 'info', `Acquired ${label} (${lockKey}) after waiting waitedMs=${waitedMs}`)
155
- }
210
+ return Effect.gen(function* () {
211
+ if (!normalized.lockKey) {
212
+ return yield* new LockAcquisitionError({ lockKey: '', maxWaitMs: normalized.maxWaitMs })
213
+ }
156
214
 
157
- const ac = new AbortController()
158
- const stopRefreshLoop = startLeaseLockRefreshLoop({ ...options, lockKey, lockValue, label, refreshIntervalMs }, ac)
159
- const holdStart = Date.now()
215
+ const redis = yield* resolveRedisConnection(options)
216
+ const lockValue = yield* Random.nextUUIDv4
217
+ const waitStart = yield* Clock.currentTimeMillis
160
218
 
161
- try {
162
- const abortPromise = new Promise<never>((_, reject) => {
163
- if (ac.signal.aborted) {
164
- reject(ac.signal.reason as Error)
165
- return
166
- }
167
- ac.signal.addEventListener(
168
- 'abort',
169
- () => {
170
- reject(ac.signal.reason as Error)
171
- },
172
- { once: true },
219
+ yield* acquireLock(redis, normalized, lockValue)
220
+
221
+ const waitedMs = (yield* Clock.currentTimeMillis) - waitStart
222
+ if (waitedMs >= normalized.acquiredWaitInfoThresholdMs) {
223
+ log(
224
+ normalized.logger,
225
+ 'info',
226
+ `Acquired ${normalized.label} (${normalized.lockKey}) after waiting waitedMs=${waitedMs}`,
173
227
  )
174
- })
175
- return await Promise.race([fn(ac.signal), abortPromise])
176
- } finally {
177
- stopRefreshLoop()
178
- const heldMs = Date.now() - holdStart
179
- if (!ac.signal.aborted) {
180
- await releaseLeaseLock({ ...options, lockKey, lockValue }).catch((error: unknown) => {
181
- log(options.logger, 'warn', `Failed to release ${label} (${lockKey}): ${getErrorMessage(error)}`)
182
- })
183
- }
184
- if (heldMs >= heldInfoThresholdMs) {
185
- log(options.logger, 'info', `Released ${label} (${lockKey}) heldMs=${heldMs}`)
186
- } else {
187
- log(options.logger, 'debug', `Released ${label} (${lockKey}) heldMs=${heldMs}`)
188
228
  }
189
- }
229
+
230
+ const abortController = new AbortController()
231
+ const holdStart = yield* Clock.currentTimeMillis
232
+ const lockLost = yield* Deferred.make<never, LockLostError | RedisError>()
233
+
234
+ const cleanup = Effect.sync(() => {
235
+ if (!abortController.signal.aborted) {
236
+ abortController.abort()
237
+ }
238
+ }).pipe(
239
+ Effect.andThen(
240
+ releaseLock(redis, normalized, lockValue).pipe(
241
+ Effect.catch((error) =>
242
+ Effect.sync(() => {
243
+ log(normalized.logger, 'warn', error.message)
244
+ }),
245
+ ),
246
+ ),
247
+ ),
248
+ Effect.andThen(
249
+ Effect.gen(function* () {
250
+ const heldMs = (yield* Clock.currentTimeMillis) - holdStart
251
+ if (heldMs >= normalized.heldInfoThresholdMs) {
252
+ log(normalized.logger, 'info', `Released ${normalized.label} (${normalized.lockKey}) heldMs=${heldMs}`)
253
+ return
254
+ }
255
+
256
+ log(normalized.logger, 'debug', `Released ${normalized.label} (${normalized.lockKey}) heldMs=${heldMs}`)
257
+ }),
258
+ ),
259
+ Effect.ignore,
260
+ )
261
+
262
+ return yield* Effect.scoped(
263
+ startRefreshFiber(redis, normalized, lockValue, abortController, lockLost).pipe(
264
+ Effect.andThen(fn(abortController.signal).pipe(Effect.raceFirst(Deferred.await(lockLost)))),
265
+ Effect.ensuring(cleanup),
266
+ ),
267
+ )
268
+ })
190
269
  }
@@ -0,0 +1,10 @@
1
+ import { Effect } from 'effect'
2
+ import type IORedis from 'ioredis'
3
+
4
+ import { getCurrentRuntime } from '../effect/runtime-ref'
5
+ import { RedisServiceTag } from '../effect/services'
6
+
7
+ export function getRuntimeRedisConnection(): IORedis {
8
+ const redis = getCurrentRuntime().runSync(Effect.service(RedisServiceTag))
9
+ return redis.getConnection()
10
+ }