Coverage for astrocyte/pipeline/compile_trigger.py: 91%

105 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-10-29 22:10 +0000

1"""M8 W4: Threshold trigger + async compile queue. 

2 

3CompileQueue monitors per-bank memory growth and staleness, enqueueing 

4background compile jobs when either threshold is exceeded — without ever 

5blocking the retain path. 

6 

7Design reference: docs/_design/llm-wiki-compile.md §3.1, §8. 

8""" 

9 

10from __future__ import annotations 

11 

12import asyncio 

13import logging 

14from dataclasses import dataclass 

15from datetime import UTC, datetime 

16from typing import TYPE_CHECKING 

17 

18from astrocyte._log_safety import safe as _safe_log 

19 

20if TYPE_CHECKING: 

21 from astrocyte.pipeline.compile import CompileEngine 

22 

23_logger = logging.getLogger("astrocyte.compile_trigger") 

24 

25# Sentinel pushed into the queue to signal the worker to stop. 

26_STOP: str | None = None 

27 

28 

29# --------------------------------------------------------------------------- 

30# Configuration 

31# --------------------------------------------------------------------------- 

32 

33 

34@dataclass 

35class CompileTriggerConfig: 

36 """Thresholds that govern automatic compile triggering. 

37 

38 Either condition is sufficient to enqueue a compile job: 

39 

40 - **Size**: ``memories_since_last_compile >= size_threshold`` 

41 - **Staleness**: last compile is older than ``staleness_days`` AND 

42 ``memories_since_last_compile >= staleness_min_memories`` 

43 

44 Use ``size_threshold=0`` to trigger on every retain (useful for tests or 

45 high-frequency low-cost scenarios). 

46 """ 

47 

48 size_threshold: int = 50 

49 """New memories since last compile required to trigger on size alone.""" 

50 

51 staleness_days: float = 7.0 

52 """Days since last compile required for the staleness trigger.""" 

53 

54 staleness_min_memories: int = 10 

55 """Minimum new memories for the staleness condition to fire.""" 

56 

57 

58# --------------------------------------------------------------------------- 

59# Per-bank state 

60# --------------------------------------------------------------------------- 

61 

62 

63@dataclass 

64class BankCompileState: 

65 """Runtime state for one bank's compile trigger.""" 

66 

67 bank_id: str 

68 memories_since_last_compile: int = 0 

69 last_compile_at: datetime | None = None 

70 

71 def should_trigger(self, config: CompileTriggerConfig) -> bool: 

72 """Return True if either trigger condition is met.""" 

73 n = self.memories_since_last_compile 

74 

75 # Size trigger: enough new memories regardless of time 

76 if n >= config.size_threshold: 

77 return True 

78 

79 # Staleness trigger: old enough AND at least staleness_min_memories 

80 if self.last_compile_at is not None and n >= config.staleness_min_memories: 

81 age_days = (datetime.now(UTC) - self.last_compile_at).total_seconds() / 86400.0 

82 if age_days >= config.staleness_days: 

83 return True 

84 

85 return False 

86 

87 

88# --------------------------------------------------------------------------- 

89# Compile queue 

90# --------------------------------------------------------------------------- 

91 

92 

93class CompileQueue: 

94 """Async background queue for compile jobs. 

95 

96 Usage:: 

97 

98 engine = CompileEngine(vs, llm, ws) 

99 queue = CompileQueue(engine, CompileTriggerConfig(size_threshold=10)) 

100 await queue.start() 

101 

102 # In retain path (sync-safe — notify_retain is non-blocking): 

103 queue.notify_retain("user-alice") 

104 

105 # Graceful shutdown: 

106 await queue.stop() 

107 

108 Call :meth:`drain` in tests to wait for all pending jobs to finish before 

109 making assertions:: 

110 

111 queue.notify_retain("bank") 

112 await queue.drain() 

113 # CompileEngine.run("bank") has now completed 

114 """ 

115 

116 def __init__( 

117 self, 

118 engine: CompileEngine, 

119 config: CompileTriggerConfig | None = None, 

120 *, 

121 max_queue_size: int = 100, 

122 ) -> None: 

123 self._engine = engine 

124 self._config = config or CompileTriggerConfig() 

125 self._queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=max_queue_size) 

126 self._pending: set[str] = set() # banks queued but not yet running 

127 self._state: dict[str, BankCompileState] = {} 

128 self._task: asyncio.Task[None] | None = None 

129 self._started = False 

130 

131 # ------------------------------------------------------------------ 

132 # Public API 

133 # ------------------------------------------------------------------ 

134 

135 def notify_retain(self, bank_id: str) -> None: 

136 """Increment the memory counter for *bank_id* and enqueue if threshold met. 

137 

138 Non-blocking and sync-safe — designed to be called from the hot retain 

139 path without holding any lock or awaiting anything. Duplicate banks 

140 already in the queue are silently skipped so the queue never fills with 

141 the same bank repeated N times. 

142 """ 

143 state = self._state.setdefault(bank_id, BankCompileState(bank_id=bank_id)) 

144 state.memories_since_last_compile += 1 

145 

146 if not state.should_trigger(self._config): 

147 return 

148 

149 if bank_id in self._pending: 

150 # Already queued — the in-flight compile will cover these memories. 

151 return 

152 

153 try: 

154 self._queue.put_nowait(bank_id) 

155 self._pending.add(bank_id) 

156 _logger.debug("Compile job enqueued for bank %r", _safe_log(bank_id)) 

157 except asyncio.QueueFull: 

158 _logger.warning( 

159 "Compile queue full (%d slots); skipping bank %r. " 

160 "Consider increasing max_queue_size or lowering thresholds.", 

161 self._queue.maxsize, 

162 _safe_log(bank_id), 

163 ) 

164 

165 async def start(self) -> None: 

166 """Start the background worker task. Idempotent.""" 

167 if self._started: 

168 return 

169 self._started = True 

170 self._task = asyncio.create_task(self._worker(), name="astrocyte.compile_worker") 

171 _logger.debug("Compile worker started") 

172 

173 async def stop(self, *, timeout: float = 5.0) -> None: 

174 """Signal the worker to stop and wait up to *timeout* seconds.""" 

175 if not self._started or self._task is None: 

176 return 

177 self._started = False 

178 try: 

179 self._queue.put_nowait(_STOP) 

180 except asyncio.QueueFull: 

181 self._task.cancel() 

182 try: 

183 await asyncio.wait_for(self._task, timeout=timeout) 

184 except (asyncio.TimeoutError, asyncio.CancelledError): 

185 _logger.warning("Compile worker did not stop within %.1fs; cancelling.", timeout) 

186 self._task.cancel() 

187 self._task = None 

188 _logger.debug("Compile worker stopped") 

189 

190 async def drain(self, *, timeout: float = 5.0) -> None: 

191 """Wait for all currently queued jobs to finish. 

192 

193 Raises :exc:`asyncio.TimeoutError` if jobs do not complete within 

194 *timeout* seconds. Safe to call when the worker is not started 

195 (returns immediately if the queue is empty). 

196 """ 

197 if self._queue.empty() and not self._pending: 

198 return 

199 await asyncio.wait_for(self._queue.join(), timeout=timeout) 

200 

201 @property 

202 def pending_banks(self) -> set[str]: 

203 """Banks currently in the queue awaiting compilation (snapshot).""" 

204 return set(self._pending) 

205 

206 @property 

207 def state(self) -> dict[str, BankCompileState]: 

208 """Per-bank compile state, keyed by bank_id (snapshot).""" 

209 return dict(self._state) 

210 

211 # ------------------------------------------------------------------ 

212 # Background worker 

213 # ------------------------------------------------------------------ 

214 

215 async def _worker(self) -> None: 

216 """Process compile jobs from the queue until a stop sentinel is received.""" 

217 _logger.debug("Compile worker loop started") 

218 while True: 

219 bank_id = await self._queue.get() 

220 try: 

221 if bank_id is _STOP: 

222 _logger.debug("Compile worker received stop signal") 

223 break 

224 

225 self._pending.discard(bank_id) 

226 _logger.info("Running background compile for bank %r", bank_id) 

227 try: 

228 result = await self._engine.run(bank_id) 

229 _logger.info( 

230 "Background compile done for bank %r: %d created, %d updated, %d noise, %d tokens, %dms", 

231 bank_id, 

232 result.pages_created, 

233 result.pages_updated, 

234 result.noise_memories, 

235 result.tokens_used, 

236 result.elapsed_ms, 

237 ) 

238 if result.error: 

239 _logger.error( 

240 "Background compile for bank %r reported error: %s", 

241 bank_id, 

242 result.error, 

243 ) 

244 # Reset counter; record compile time 

245 state = self._state.setdefault(bank_id, BankCompileState(bank_id=bank_id)) 

246 state.memories_since_last_compile = 0 

247 state.last_compile_at = datetime.now(UTC) 

248 

249 except Exception: 

250 _logger.exception( 

251 "Background compile for bank %r raised unexpectedly; skipping.", 

252 bank_id, 

253 ) 

254 finally: 

255 self._queue.task_done()