|
10 | 10 | (:import |
11 | 11 | [java.io OutputStream RandomAccessFile] |
12 | 12 | [java.nio.channels FileChannel FileLock] |
13 | | - [java.nio.file AtomicMoveNotSupportedException CopyOption Files StandardCopyOption] |
14 | | - [java.nio.file.attribute FileAttribute] |
| 13 | + [java.nio.file AtomicMoveNotSupportedException CopyOption Files LinkOption StandardCopyOption] |
| 14 | + [java.nio.file.attribute BasicFileAttributes FileAttribute] |
15 | 15 | [java.util.concurrent ConcurrentHashMap])) |
16 | 16 |
|
17 | 17 | (set! *warn-on-reflection* true) |
|
31 | 31 | :providers-config-hash :string |
32 | 32 | :last-config-notified ::any-map |
33 | 33 | :stopping :boolean |
| 34 | + ;; chat ids deleted in this session; excluded from workspace cache writes so |
| 35 | + ;; the merge-on-write never resurrects them from a shared cache file. |
| 36 | + :deleted-chat-ids #{:string} |
34 | 37 | :models {"<model-name>" {:web-search :boolean |
35 | 38 | :tools :boolean |
36 | 39 | :reason? :boolean |
|
150 | 153 | ;; {tool-name {:remember-to-approve? boolean |
151 | 154 | ;; :remembered-command-keys #{string}}} |
152 | 155 | :tool-calls {} |
| 156 | + ;; Chat ids deleted in this session (not cached), see _db-spec. |
| 157 | + :deleted-chat-ids #{} |
153 | 158 |
|
154 | 159 | ;; cacheable, bump db `version` when changing any below |
155 | 160 | :chats {} |
|
305 | 310 | {} |
306 | 311 | chat-maps)) |
307 | 312 |
|
| 313 | +(defn ^:private with-os-file-lock-fn |
| 314 | + "Run `f` while holding both a JVM monitor for `lock-file` and an OS advisory |
| 315 | + exclusive lock on it. The JVM monitor avoids `OverlappingFileLockException` |
| 316 | + when two threads in the same ECA server race; the file lock serializes |
| 317 | + across `eca server` processes that share the same cache dir. Blocks until |
| 318 | + both are acquired." |
| 319 | + [^java.io.File lock-file f] |
| 320 | + ;; `file-lock` interns the lock object in `file-locks`, so it is |
| 321 | + ;; not actually local to this scope; suppress the false positive. |
| 322 | + #_{:clj-kondo/ignore [:locking-suspicious-lock]} |
| 323 | + (locking (file-lock lock-file) |
| 324 | + (io/make-parents lock-file) |
| 325 | + (let [^RandomAccessFile raf (RandomAccessFile. lock-file "rw") |
| 326 | + ^FileChannel channel (.getChannel raf) |
| 327 | + lock-ref (volatile! nil)] |
| 328 | + (try |
| 329 | + (vreset! lock-ref ^FileLock (.lock channel)) |
| 330 | + (f) |
| 331 | + (finally |
| 332 | + (when-let [^FileLock lock @lock-ref] |
| 333 | + (try (.release lock) |
| 334 | + (catch Throwable e |
| 335 | + (logger/warn logger-tag "Could not release cache lock" e)))) |
| 336 | + (try (.close channel) (catch Throwable _)) |
| 337 | + (try (.close raf) (catch Throwable _))))))) |
| 338 | + |
| 339 | +(defn ^:private workspace-cache-lock-file ^java.io.File [^java.io.File cache-file] |
| 340 | + (io/file (str (.getPath cache-file) ".lock"))) |
| 341 | + |
| 342 | +(defonce ^:private ^ConcurrentHashMap last-workspace-write-attrs (ConcurrentHashMap.)) |
| 343 | + |
| 344 | +(defn ^:private cache-file-attrs |
| 345 | + "Returns [last-modified-time size] for `f`, or nil when it does not exist." |
| 346 | + [^java.io.File f] |
| 347 | + (try |
| 348 | + (when (.exists f) |
| 349 | + (let [^BasicFileAttributes attrs (Files/readAttributes |
| 350 | + (.toPath f) |
| 351 | + BasicFileAttributes |
| 352 | + ^"[Ljava.nio.file.LinkOption;" (into-array LinkOption []))] |
| 353 | + [(.lastModifiedTime attrs) (.size attrs)])) |
| 354 | + (catch Throwable _ nil))) |
| 355 | + |
| 356 | +(defn ^:private record-workspace-write-attrs! |
| 357 | + "Remembers the on-disk attributes of `f` right after this process wrote it, |
| 358 | + so the next write can cheaply detect whether another process wrote in |
| 359 | + between (see `workspace-cache-changed-on-disk?`)." |
| 360 | + [^java.io.File f] |
| 361 | + (if-let [attrs (cache-file-attrs f)] |
| 362 | + (.put last-workspace-write-attrs (.getAbsolutePath f) attrs) |
| 363 | + (.remove last-workspace-write-attrs (.getAbsolutePath f)))) |
| 364 | + |
| 365 | +(defn ^:private workspace-cache-changed-on-disk? |
| 366 | + "True when `f` exists with different attributes than the last write this |
| 367 | + process made to it - i.e. another process wrote it (or this process never |
| 368 | + wrote it yet), so its content must be merged instead of overwritten." |
| 369 | + [^java.io.File f] |
| 370 | + (let [attrs (cache-file-attrs f)] |
| 371 | + (boolean (and attrs (not= attrs (.get last-workspace-write-attrs (.getAbsolutePath f))))))) |
| 372 | + |
308 | 373 | (defn consolidate-workspace-cache! |
309 | 374 | "Heals chat caches that fragmented across multiple directories for the same |
310 | | - workspace set (legacy hash-only dirs, or dirs prefixed from a different folder |
311 | | - order). Merges every matching cache into the canonical dir (newest chat wins) |
312 | | - and removes the redundant dirs. Best-effort and idempotent." |
| 375 | + workspace set (legacy hash-only dirs, dirs prefixed from a different folder |
| 376 | + order, or per-worktree dirs from before worktree canonicalization). Merges |
| 377 | + every matching cache into the canonical dir (newest chat wins) and removes |
| 378 | + the redundant dirs. Best-effort and idempotent; runs under the workspace |
| 379 | + cache file lock so it cannot race writes from another live server." |
313 | 380 | [workspaces metrics] |
314 | 381 | (try |
315 | 382 | (let [redundant (cache/redundant-workspace-cache-files workspaces "db.transit.json" shared/uri->filename)] |
316 | 383 | (when (seq redundant) |
317 | | - (let [canonical (transit-global-by-workspaces-db-file workspaces) |
318 | | - caches (keep #(read-cache % metrics) (cons canonical redundant)) |
319 | | - merged (merge-chats (map :chats caches))] |
320 | | - (logger/info logger-tag (str "Consolidating " (count redundant) " redundant workspace cache dir(s) into " canonical)) |
321 | | - (upsert-cache! {:chats merged :version version} canonical metrics) |
322 | | - (doseq [^java.io.File f redundant] |
323 | | - (try |
324 | | - (fs/delete-tree (.getParentFile f)) |
325 | | - (catch Throwable e |
326 | | - (logger/warn logger-tag (str "Could not remove redundant cache dir " (.getParentFile f)) e))))))) |
| 384 | + (let [canonical (transit-global-by-workspaces-db-file workspaces)] |
| 385 | + (with-os-file-lock-fn |
| 386 | + (workspace-cache-lock-file canonical) |
| 387 | + (fn [] |
| 388 | + (let [caches (keep #(read-cache % metrics) (cons canonical redundant)) |
| 389 | + merged (merge-chats (map :chats caches))] |
| 390 | + (logger/info logger-tag (str "Consolidating " (count redundant) " redundant workspace cache dir(s) into " canonical)) |
| 391 | + (upsert-cache! {:chats merged :version version} canonical metrics) |
| 392 | + (record-workspace-write-attrs! canonical) |
| 393 | + (doseq [^java.io.File f redundant] |
| 394 | + (try |
| 395 | + (fs/delete-tree (.getParentFile f)) |
| 396 | + (catch Throwable e |
| 397 | + (logger/warn logger-tag (str "Could not remove redundant cache dir " (.getParentFile f)) e)))))))))) |
327 | 398 | (catch Throwable e |
328 | 399 | (logger/warn logger-tag "Could not consolidate workspace cache" e)))) |
329 | 400 |
|
|
363 | 434 | (defn ^:private normalize-db-for-global-write [db] |
364 | 435 | (select-keys db [:auth :mcp-auth])) |
365 | 436 |
|
366 | | -(defn update-workspaces-cache! [db metrics] |
367 | | - (-> (normalize-db-for-workspace-write db) |
368 | | - (assoc :version version) |
369 | | - (upsert-cache! (transit-global-by-workspaces-db-file (or (:initial-workspace-folders db) |
370 | | - (:workspace-folders db))) metrics))) |
| 437 | +(defn update-workspaces-cache! |
| 438 | + "Persists the workspace-scoped db slice (chats) to the workspace cache file. |
| 439 | +
|
| 440 | + Safe across processes: multiple ECA servers can share one cache file (e.g. a |
| 441 | + repo and its worktrees, #558), so when the file changed on disk since this |
| 442 | + process last wrote it, the on-disk chats are merged in (newest wins, ties |
| 443 | + keep the in-memory copy) instead of blindly overwritten. Chats deleted in |
| 444 | + this session (`:deleted-chat-ids`) are never resurrected by the merge. Runs |
| 445 | + under a cross-process file lock; if locking fails it falls back to a plain |
| 446 | + overwrite (previous behavior)." |
| 447 | + [db metrics] |
| 448 | + (let [dest (transit-global-by-workspaces-db-file (or (:initial-workspace-folders db) |
| 449 | + (:workspace-folders db))) |
| 450 | + payload (-> (normalize-db-for-workspace-write db) |
| 451 | + (assoc :version version)) |
| 452 | + deleted-ids (not-empty (:deleted-chat-ids db))] |
| 453 | + (try |
| 454 | + (with-os-file-lock-fn |
| 455 | + (workspace-cache-lock-file dest) |
| 456 | + (fn [] |
| 457 | + (let [disk-chats (when (workspace-cache-changed-on-disk? dest) |
| 458 | + (:chats (read-cache dest metrics))) |
| 459 | + chats (cond-> (:chats payload) |
| 460 | + (seq disk-chats) (as-> $ (merge-chats [$ disk-chats])) |
| 461 | + deleted-ids (as-> $ (apply dissoc $ deleted-ids)))] |
| 462 | + (upsert-cache! (assoc payload :chats chats) dest metrics) |
| 463 | + (record-workspace-write-attrs! dest)))) |
| 464 | + (catch Throwable e |
| 465 | + (logger/warn logger-tag (str "Workspace cache lock failed, writing without merge: " (ex-message e))) |
| 466 | + (upsert-cache! payload dest metrics))))) |
371 | 467 |
|
372 | 468 | (defn update-global-cache! [db metrics] |
373 | 469 | (-> (normalize-db-for-global-write db) |
374 | 470 | (assoc :version version) |
375 | 471 | (upsert-cache! (transit-global-db-file) metrics))) |
376 | 472 |
|
377 | | -(def ^:private global-cache-lock-sentinel (Object.)) |
378 | | - |
379 | 473 | (defn ^:private global-cache-lock-file [] |
380 | 474 | (io/file (cache/global-dir) "db.transit.json.lock")) |
381 | 475 |
|
|
386 | 480 | race a renew; the file lock serializes across `eca server` processes |
387 | 481 | that share `~/.cache/eca/`. Blocks until both are acquired." |
388 | 482 | [f] |
389 | | - (locking global-cache-lock-sentinel |
390 | | - (let [^java.io.File lock-file (global-cache-lock-file) |
391 | | - _ (io/make-parents lock-file) |
392 | | - ^RandomAccessFile raf (RandomAccessFile. lock-file "rw") |
393 | | - ^FileChannel channel (.getChannel raf) |
394 | | - lock-ref (volatile! nil)] |
395 | | - (try |
396 | | - (vreset! lock-ref ^FileLock (.lock channel)) |
397 | | - (f) |
398 | | - (finally |
399 | | - (when-let [^FileLock lock @lock-ref] |
400 | | - (try (.release lock) |
401 | | - (catch Throwable e |
402 | | - (logger/warn logger-tag "Could not release global cache lock" e)))) |
403 | | - (try (.close channel) (catch Throwable _)) |
404 | | - (try (.close raf) (catch Throwable _))))))) |
| 483 | + (with-os-file-lock-fn (global-cache-lock-file) f)) |
405 | 484 |
|
406 | 485 | (defmacro with-global-cache-lock |
407 | 486 | "See `with-global-cache-lock-fn`. Runs `body` while holding the lock." |
|
438 | 517 | (when (pos? retention-days) |
439 | 518 | (let [retention-ms (* retention-days 24 60 60 1000) |
440 | 519 | cutoff (- (System/currentTimeMillis) retention-ms) |
441 | | - removed (atom 0)] |
| 520 | + removed-ids* (atom #{})] |
442 | 521 | (swap! db* update :chats |
443 | 522 | (fn [chats] |
444 | 523 | (into {} |
445 | | - (filter (fn [[_id chat]] |
| 524 | + (filter (fn [[id chat]] |
446 | 525 | (let [created-at (:created-at chat)] |
447 | 526 | (if (and created-at (< created-at cutoff)) |
448 | | - (do (swap! removed inc) false) |
| 527 | + (do (swap! removed-ids* conj id) false) |
449 | 528 | true)))) |
450 | 529 | chats))) |
451 | | - (when (pos? @removed) |
452 | | - (logger/info logger-tag (str "Cleaned up " @removed " chat(s) older than " retention-days " days")) |
| 530 | + (when-let [removed-ids (not-empty @removed-ids*)] |
| 531 | + ;; Tombstone the ids so the merge-on-write in update-workspaces-cache! |
| 532 | + ;; does not resurrect them from a cache file shared with another server. |
| 533 | + (swap! db* update :deleted-chat-ids (fnil into #{}) removed-ids) |
| 534 | + (logger/info logger-tag (str "Cleaned up " (count removed-ids) " chat(s) older than " retention-days " days")) |
453 | 535 | (update-workspaces-cache! @db* metrics))))) |
0 commit comments