返回 DeepSeek-Reasonix
reconcile.go
根目录 / internal / sessioncatalog / reconcile.go
1 package sessioncatalog
2
3 import (
4 "context"
5 "crypto/sha256"
6 "database/sql"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "net/url"
11 "os"
12 "path/filepath"
13 "runtime"
14 "strings"
15 "sync"
16 "time"
17
18 "reasonix/internal/agent"
19 "reasonix/internal/projectiondb"
20 "reasonix/internal/sqliteuri"
21 )
22
23 func (c *Catalog) ReconcileDirectory(ctx context.Context, target DirectoryTarget) error {
24 if c == nil {
25 return nil
26 }
27 return c.reconcileDirectory(ctx, target, c.mutationSeq.Add(1))
28 }
29
30 func (c *Catalog) reconcileDirectory(ctx context.Context, target DirectoryTarget, sequence uint64) error {
31 if c == nil || c.db == nil {
32 return nil
33 }
34 target.Path = cleanCatalogAccessPath(target.Path)
35 if target.Path == "" {
36 return nil
37 }
38 lock := c.directoryLock(target.Path)
39 lock.Lock()
40 defer lock.Unlock()
41 target.Scope, target.WorkspaceRoot = normalizeScope(target.Scope, target.WorkspaceRoot)
42 signature, err := directorySignature(target.Path)
43 if err != nil {
44 c.failDirectoryScan(ctx, target.Path, err)
45 return err
46 }
47 if unchanged, err := c.directoryScanCanSkip(ctx, target, signature); err != nil {
48 return err
49 } else if unchanged {
50 return nil
51 }
52 now := c.opts.Now().UnixMilli()
53 generation, _, err := c.beginDirectoryScan(ctx, target, signature, now)
54 if err != nil {
55 return err
56 }
57 content := newStrictRecoveryContentCache(c.testSessionContentLoadHook)
58 ordered, err := listSessionOrderWithContent(target.Path, content)
59 if err != nil {
60 c.failDirectoryScan(ctx, target.Path, err)
61 return err
62 }
63 records := make([]SessionRecord, 0, len(ordered))
64 for start := 0; start < len(ordered); start += 64 {
65 if err := ctx.Err(); err != nil {
66 c.failDirectoryScan(context.Background(), target.Path, err)
67 return err
68 }
69 end := min(start+64, len(ordered))
70 for _, info := range ordered[start:end] {
71 records = append(records, recordFromOrder(target, info))
72 }
73 runtime.Gosched()
74 }
75 records = c.filterPathMutations(records, sequence)
76 records, err = c.preserveKnownSourceStates(ctx, target.Path, records)
77 if err != nil {
78 c.failDirectoryScan(context.Background(), target.Path, err)
79 return err
80 }
81 for i := range records {
82 records[i] = classifyRecoveryLineageWithContent(normalizeSessionRecord(records[i]), content)
83 }
84 records = promoteCanonicalLeavesWithContent(records, content)
85 if err := c.commitDirectoryProjection(ctx, target, signature, generation, now, records); err != nil {
86 c.failDirectoryScan(context.Background(), target.Path, err)
87 return err
88 }
89 c.markDirectoryVerifiedIfStable(ctx, target, signature)
90 for _, record := range records {
91 if record.TurnsState == TurnsUnknown {
92 c.enqueueRepair(record.Path)
93 }
94 }
95 return nil
96 }
97
98 func directorySignature(dir string) (string, error) {
99 // os.ReadDir of a plain file returns the file itself on Windows but
100 // ENOTDIR on POSIX; stat first so both platforms reject non-directories.
101 info, err := os.Stat(dir)
102 if err != nil {
103 if os.IsNotExist(err) {
104 return "missing", nil
105 }
106 return "", err
107 }
108 if !info.IsDir() {
109 return "", fmt.Errorf("not a directory: %s", dir)
110 }
111 entries, err := os.ReadDir(dir)
112 if err != nil {
113 return "", err
114 }
115 hash := sha256.New()
116 for _, entry := range entries {
117 name := entry.Name()
118 if entry.IsDir() || (!strings.HasSuffix(name, ".jsonl") && !strings.HasSuffix(name, ".meta")) {
119 continue
120 }
121 info, err := entry.Info()
122 if err != nil {
123 return "", err
124 }
125 _, _ = fmt.Fprintf(hash, "%s\x00%d\x00%d\x00%d\n", name, info.Size(), info.ModTime().UnixNano(), info.Mode())
126 }
127 return hex.EncodeToString(hash.Sum(nil)), nil
128 }
129
130 func (c *Catalog) directoryLock(path string) *sync.Mutex {
131 path = c.pathKey(path)
132 c.directoryLocksMu.Lock()
133 defer c.directoryLocksMu.Unlock()
134 lock := c.directoryLocks[path]
135 if lock == nil {
136 lock = &sync.Mutex{}
137 c.directoryLocks[path] = lock
138 }
139 return lock
140 }
141
142 // IndexSessionPath indexes one session without walking its directory.
143 func (c *Catalog) IndexSessionPath(ctx context.Context, target DirectoryTarget, path string) error {
144 if c == nil {
145 return nil
146 }
147 return c.indexSessionPath(ctx, target, path, c.mutationSeq.Add(1))
148 }
149
150 func (c *Catalog) indexSessionPath(ctx context.Context, target DirectoryTarget, path string, sequence uint64) error {
151 path = cleanCatalogAccessPath(path)
152 if path == "" {
153 return nil
154 }
155 target.Path = cleanCatalogAccessPath(target.Path)
156 if target.Path == "" {
157 target.Path = filepath.Dir(path)
158 }
159 // Hold the directory lock so a concurrent scan cannot mark this row missing.
160 lock := c.directoryLock(target.Path)
161 lock.Lock()
162 defer lock.Unlock()
163 info, err := os.Stat(path)
164 if err != nil {
165 if os.IsNotExist(err) {
166 return nil
167 }
168 return err
169 }
170 meta, ok, err := agent.LoadBranchMeta(path)
171 if err != nil {
172 return err
173 }
174 order := agent.SessionOrderInfo{
175 Path: path,
176 CreatedAt: info.ModTime(),
177 LastActivityAt: info.ModTime(),
178 ModTime: info.ModTime(),
179 Scope: target.Scope,
180 WorkspaceRoot: target.WorkspaceRoot,
181 }
182 if ok {
183 order.CreatedAt = meta.CreatedAt
184 order.LastActivityAt = meta.UpdatedAt
185 order.ModTime = meta.UpdatedAt
186 order.Scope = meta.DefaultScope()
187 order.WorkspaceRoot = meta.WorkspaceRoot
188 order.TopicID = meta.TopicID
189 order.TopicTitle = meta.TopicTitle
190 order.CustomTitle = meta.CustomTitle
191 order.Recovered = meta.Recovered
192 order.RecoveryReason = meta.RecoveryReason
193 order.RecoveryDigest = meta.RecoveryDigest
194 order.ParentID = meta.ParentID
195 order.RecoveryPreferred = agent.RecoveryPreferenceCurrent(path, meta)
196 order.Turns = meta.Turns
197 order.Preview = meta.Preview
198 order.SchemaVersion = meta.SchemaVersion
199 order.Revision = meta.Revision
200 order.ContentDigest = meta.ContentDigest
201 order.ListingRevision = meta.ListingRevision
202 order.ListingContentDigest = meta.ListingContentDigest
203 order.HeadID, order.HeadCount, order.LogSchema = meta.HeadID, meta.HeadCount, meta.LogSchema
204 }
205 if order.CreatedAt.IsZero() {
206 order.CreatedAt = info.ModTime()
207 }
208 if order.LastActivityAt.IsZero() {
209 order.LastActivityAt = info.ModTime()
210 }
211 record := recordFromOrder(target, order)
212 record.enqueueSequence = sequence
213 projectionDirty, err := c.upsertExactPathSession(ctx, record)
214 if err != nil {
215 return err
216 }
217 if projectionDirty {
218 // Queue after the exact source row is durable. The non-blocking worker
219 // will acquire this directory lock after IndexSessionPath returns and
220 // publish the full sibling-aware projection in one transaction.
221 c.RequestReconcile(target)
222 }
223 if record.TurnsState == TurnsUnknown {
224 c.enqueueRepair(record.Path)
225 }
226 return nil
227 }
228
229 func recordFromOrder(target DirectoryTarget, info agent.SessionOrderInfo) SessionRecord {
230 scope, root := normalizeScope(info.Scope, info.WorkspaceRoot)
231 if info.TopicID == "" {
232 scope, root = target.Scope, target.WorkspaceRoot
233 }
234 // A stale projection is never certified, but its last-known preview and
235 // count stay as display hints so the row does not vanish during repair.
236 turnsState := TurnsValid
237 if !info.ListingProjectionFresh() {
238 turnsState = TurnsUnknown
239 }
240 heads := projectSessionHeads(info)
241 contentFingerprint := sessionContentFingerprint(info.Path) + heads.fingerprint
242 metaFingerprint := fileFingerprint(agent.BranchMetaPath(info.Path))
243 if heads.stale {
244 turnsState = TurnsUnknown
245 }
246 createdAt := unixMilli(info.CreatedAt)
247 lastActivityAt := unixMilli(info.LastActivityAt)
248 // File mtime fills a missing clock. Do not raise a known sidecar UpdatedAt:
249 // repair and other metadata writes bump mtime without new user turns.
250 if st, err := os.Stat(info.Path); err == nil {
251 fileMS := st.ModTime().UnixMilli()
252 if createdAt <= 0 {
253 createdAt = fileMS
254 }
255 if lastActivityAt <= 0 {
256 lastActivityAt = fileMS
257 }
258 }
259 return normalizeSessionRecord(SessionRecord{
260 Path: info.Path,
261 Directory: target.Path,
262 Scope: scope,
263 WorkspaceRoot: root,
264 TopicID: info.TopicID,
265 TopicTitle: info.TopicTitle,
266 CustomTitle: info.CustomTitle,
267 CreatedAt: createdAt,
268 LastActivityAt: lastActivityAt,
269 Preview: info.Preview,
270 Turns: info.Turns,
271 TurnsState: turnsState,
272 Recovered: info.Recovered,
273 RecoveryReason: info.RecoveryReason,
274 RecoveryDigest: info.RecoveryDigest,
275 ParentID: info.ParentID,
276 RecoveryPreferred: info.RecoveryPreferred,
277 RecoveryCopy: false,
278 LogFormat: heads.logFormat,
279 HeadCount: heads.headCount,
280 SelectedHeadID: heads.selected,
281 heads: heads.heads,
282 ContentFingerprint: contentFingerprint,
283 MetaFingerprint: metaFingerprint,
284 Health: HealthOK,
285 })
286 }
287
288 func unixMilli(value time.Time) int64 {
289 if value.IsZero() {
290 return 0
291 }
292 return value.UnixMilli()
293 }
294
295 func fileFingerprint(path string) string {
296 info, err := os.Stat(path)
297 if err != nil {
298 return ""
299 }
300 return fmt.Sprintf("%d:%d", info.Size(), info.ModTime().UnixNano())
301 }
302
303 func sessionContentFingerprint(path string) string {
304 return fileFingerprint(path) + "|" + fileFingerprint(agent.SessionEventLogPath(path))
305 }
306
307 // beginDirectoryScan starts or resumes a directory scan. When the previous
308 // scan for the same signature was interrupted mid-way, the stored scan_cursor
309 // is returned so ReconcileDirectory continues instead of restarting from 0.
310 func (c *Catalog) beginDirectoryScan(ctx context.Context, target DirectoryTarget, signature string, now int64) (int64, string, error) {
311 c.mutationMu.Lock()
312 defer c.mutationMu.Unlock()
313 tx, err := c.db.BeginTx(ctx, nil)
314 if err != nil {
315 return 0, "", err
316 }
317 var previousSig, previousState, previousCursor string
318 var previousGeneration int64
319 pathKey := c.pathKey(target.Path)
320 if err := c.removeRemappedDirectoryIdentity(ctx, tx, target.Path, pathKey); err != nil {
321 _ = tx.Rollback()
322 return 0, "", err
323 }
324 err = tx.QueryRowContext(ctx, `SELECT signature,state,scan_cursor,scan_generation FROM catalog_directories WHERE path_key=?`,
325 pathKey).Scan(&previousSig, &previousState, &previousCursor, &previousGeneration)
326 resume := err == nil && previousState == "scanning" && previousSig == signature && strings.TrimSpace(previousCursor) != ""
327 if errors.Is(err, sql.ErrNoRows) {
328 err = nil
329 }
330 if err != nil {
331 _ = tx.Rollback()
332 return 0, "", err
333 }
334 if resume {
335 if _, err := tx.ExecContext(ctx, `UPDATE catalog_directories SET path=?,scope=?,workspace_root=?,state='scanning',error='',signature=? WHERE path_key=?`,
336 target.Path, target.Scope, target.WorkspaceRoot, signature, pathKey); err != nil {
337 _ = tx.Rollback()
338 return 0, "", err
339 }
340 if previousGeneration == 0 {
341 previousGeneration = 1
342 }
343 return previousGeneration, previousCursor, tx.Commit()
344 }
345 if _, err := tx.ExecContext(ctx, `INSERT INTO catalog_directories(path,path_key,scope,workspace_root,state,error,signature)
346 VALUES(?,?,?,?,'scanning','',?) ON CONFLICT(path_key) DO UPDATE SET
347 path=excluded.path,scope=excluded.scope,workspace_root=excluded.workspace_root,state='scanning',error='',
348 signature=excluded.signature,scan_generation=catalog_directories.scan_generation+1,scan_cursor='',indexed=0`,
349 target.Path, pathKey, target.Scope, target.WorkspaceRoot, signature); err != nil {
350 _ = tx.Rollback()
351 return 0, "", err
352 }
353 var generation int64
354 if err := tx.QueryRowContext(ctx, `SELECT scan_generation FROM catalog_directories WHERE path_key=?`, pathKey).Scan(&generation); err != nil {
355 _ = tx.Rollback()
356 return 0, "", err
357 }
358 if generation == 0 {
359 generation = 1
360 if _, err := tx.ExecContext(ctx, `UPDATE catalog_directories SET scan_generation=1 WHERE path_key=?`, pathKey); err != nil {
361 _ = tx.Rollback()
362 return 0, "", err
363 }
364 }
365 return generation, "", tx.Commit()
366 }
367
368 // commitDirectoryProjection publishes a complete sibling-aware directory
369 // snapshot. Parsing and lineage classification happen before this function;
370 // readers therefore observe either the previous committed projection or every
371 // row, tombstone, missing marker, topic aggregate, and readiness update from
372 // this transaction together.
373 func (c *Catalog) commitDirectoryProjection(ctx context.Context, target DirectoryTarget, signature string, generation, now int64, records []SessionRecord) error {
374 c.mutationMu.Lock()
375 tx, err := c.db.BeginTx(ctx, nil)
376 if err != nil {
377 c.mutationMu.Unlock()
378 return err
379 }
380 rollback := func(commitErr error) error {
381 _ = tx.Rollback()
382 c.mutationMu.Unlock()
383 return commitErr
384 }
385 stmt, err := tx.PrepareContext(ctx, sessionInsertSQL+directoryProjectionUpdateSQL)
386 if err != nil {
387 return rollback(err)
388 }
389 affected := map[TopicKey]struct{}{}
390 directoryKey := c.pathKey(target.Path)
391 for start := 0; start < len(records); start += 64 {
392 if err := ctx.Err(); err != nil {
393 _ = stmt.Close()
394 return rollback(err)
395 }
396 end := min(start+64, len(records))
397 for _, record := range records[start:end] {
398 pathKey := c.pathKey(record.Path)
399 remapped, err := removeRemappedSessionIdentity(ctx, tx, record.Path, pathKey)
400 if err != nil {
401 _ = stmt.Close()
402 return rollback(err)
403 }
404 for _, key := range remapped {
405 affected[key] = struct{}{}
406 }
407 var previous TopicKey
408 if err := tx.QueryRowContext(ctx, `SELECT scope,workspace_root,workspace_root_key,topic_id FROM catalog_sessions WHERE path_key=?`, pathKey).
409 Scan(&previous.Scope, &previous.WorkspaceRoot, &previous.workspaceKey, &previous.TopicID); err == nil && previous.TopicID != "" {
410 affected[previous] = struct{}{}
411 } else if err != nil && !errors.Is(err, sql.ErrNoRows) {
412 _ = stmt.Close()
413 return rollback(err)
414 }
415 if err := c.writeDirectoryRow(ctx, tx, stmt, record, pathKey, directoryKey, generation); err != nil {
416 _ = stmt.Close()
417 return rollback(err)
418 }
419 if record.TopicID != "" {
420 affected[TopicKey{Scope: record.Scope, WorkspaceRoot: record.WorkspaceRoot,
421 workspaceKey: c.workspaceRootKey(record.Scope, record.WorkspaceRoot), TopicID: record.TopicID}] = struct{}{}
422 }
423 if err := c.updateFoldedTopicTombstones(ctx, tx, previous, record, now); err != nil {
424 _ = stmt.Close()
425 return rollback(err)
426 }
427 }
428 if c.testReconcileBatchHook != nil {
429 c.testReconcileBatchHook(end)
430 }
431 runtime.Gosched()
432 }
433 if err := stmt.Close(); err != nil {
434 return rollback(err)
435 }
436
437 rows, err := tx.QueryContext(ctx, `SELECT scope,workspace_root,workspace_root_key,topic_id FROM catalog_sessions
438 WHERE directory_key=? AND seen_generation<? AND topic_id<>''`, directoryKey, generation)
439 if err != nil {
440 return rollback(err)
441 }
442 for rows.Next() {
443 var key TopicKey
444 if err := rows.Scan(&key.Scope, &key.WorkspaceRoot, &key.workspaceKey, &key.TopicID); err != nil {
445 _ = rows.Close()
446 return rollback(err)
447 }
448 affected[key] = struct{}{}
449 }
450 if err := rows.Close(); err != nil {
451 return rollback(err)
452 }
453 if _, err := tx.ExecContext(ctx, `UPDATE catalog_sessions SET
454 missing_since=CASE WHEN missing_since=0 THEN ? ELSE missing_since END,
455 health='missing'
456 WHERE directory_key=? AND seen_generation<?`, now, directoryKey, generation); err != nil {
457 return rollback(err)
458 }
459 cutoff := now - c.opts.MissingGrace.Milliseconds()
460 if _, err := tx.ExecContext(ctx, `DELETE FROM catalog_sessions
461 WHERE directory_key=? AND seen_generation<? AND missing_since>0 AND missing_since<=?`, directoryKey, generation, cutoff); err != nil {
462 return rollback(err)
463 }
464 for key := range affected {
465 if err := c.recomputeTopic(ctx, tx, key); err != nil {
466 return rollback(err)
467 }
468 }
469 if _, err := tx.ExecContext(ctx, `UPDATE catalog_directories SET state='ready',error='',signature=?,
470 scan_cursor='',indexed=?,total=?,completed_at=? WHERE path_key=?`, signature, len(records), len(records), now, directoryKey); err != nil {
471 return rollback(err)
472 }
473 revision, err := bumpRevision(ctx, tx)
474 if err != nil {
475 return rollback(err)
476 }
477 if err := tx.Commit(); err != nil {
478 c.mutationMu.Unlock()
479 return err
480 }
481 c.mutationMu.Unlock()
482
483 c.publishRevision(revision, []string{target.WorkspaceRoot}, "reconcile_complete")
484 c.refreshCounts(ctx)
485 c.statusMu.Lock()
486 c.status.State = StateReady
487 c.status.LastError = ""
488 c.statusMu.Unlock()
489 return nil
490 }
491
492 func (c *Catalog) finishDirectoryScan(ctx context.Context, target DirectoryTarget, signature string, generation, now int64, total int) error {
493 c.mutationMu.Lock()
494 defer c.mutationMu.Unlock()
495 tx, err := c.db.BeginTx(ctx, nil)
496 if err != nil {
497 return err
498 }
499 directoryKey := c.pathKey(target.Path)
500 rows, err := tx.QueryContext(ctx, `SELECT scope,workspace_root,workspace_root_key,topic_id FROM catalog_sessions
501 WHERE directory_key=? AND seen_generation<? AND topic_id<>''`, directoryKey, generation)
502 if err != nil {
503 _ = tx.Rollback()
504 return err
505 }
506 affected := map[TopicKey]struct{}{}
507 for rows.Next() {
508 var key TopicKey
509 if err := rows.Scan(&key.Scope, &key.WorkspaceRoot, &key.workspaceKey, &key.TopicID); err != nil {
510 _ = rows.Close()
511 _ = tx.Rollback()
512 return err
513 }
514 affected[key] = struct{}{}
515 }
516 if err := rows.Close(); err != nil {
517 _ = tx.Rollback()
518 return err
519 }
520 if _, err := tx.ExecContext(ctx, `UPDATE catalog_sessions SET
521 missing_since=CASE WHEN missing_since=0 THEN ? ELSE missing_since END,
522 health='missing'
523 WHERE directory_key=? AND seen_generation<?`, now, directoryKey, generation); err != nil {
524 _ = tx.Rollback()
525 return err
526 }
527 cutoff := now - c.opts.MissingGrace.Milliseconds()
528 if _, err := tx.ExecContext(ctx, `DELETE FROM catalog_sessions
529 WHERE directory_key=? AND seen_generation<? AND missing_since>0 AND missing_since<=?`, directoryKey, generation, cutoff); err != nil {
530 _ = tx.Rollback()
531 return err
532 }
533 for key := range affected {
534 if err := c.recomputeTopic(ctx, tx, key); err != nil {
535 _ = tx.Rollback()
536 return err
537 }
538 }
539 if _, err := tx.ExecContext(ctx, `UPDATE catalog_directories SET state='ready',error='',signature=?,
540 scan_cursor='',indexed=?,total=?,completed_at=? WHERE path_key=?`, signature, total, total, now, directoryKey); err != nil {
541 _ = tx.Rollback()
542 return err
543 }
544 revision, err := bumpRevision(ctx, tx)
545 if err != nil {
546 _ = tx.Rollback()
547 return err
548 }
549 if err := tx.Commit(); err != nil {
550 return err
551 }
552 c.publishRevision(revision, []string{target.WorkspaceRoot}, "reconcile_complete")
553 c.refreshCounts(ctx)
554 c.statusMu.Lock()
555 c.status.State = StateReady
556 c.status.LastError = ""
557 c.statusMu.Unlock()
558 return nil
559 }
560
561 // Rebuild replaces only the disposable catalog. Authoritative sessions and
562 // sidecars are never changed or removed by this operation. The live database
563 // stays in place until a fully-populated replacement is validated and swapped.
564 func Rebuild(ctx context.Context, path string, targets []DirectoryTarget) (Status, error) {
565 return RebuildWithRevisionFloor(ctx, path, targets, 0)
566 }
567
568 // RebuildWithRevisionFloor preserves the caller's revision epoch while
569 // atomically replacing the disposable projection. Desktop clients retain
570 // revision fences across the rebuild, so a replacement must never publish a
571 // lower revision than the catalog they already rendered.
572 func RebuildWithRevisionFloor(ctx context.Context, path string, targets []DirectoryTarget, revisionFloor uint64) (Status, error) {
573 targets = UniqueDirectoryTargets(targets)
574 if strings.TrimSpace(path) == "" {
575 path = DefaultPath()
576 }
577 if strings.TrimSpace(path) == "" {
578 catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
579 if err != nil {
580 return Status{}, err
581 }
582 if err := setCatalogRevisionFloor(ctx, catalog.db, revisionFloor); err != nil {
583 closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
584 _ = catalog.Close(closeCtx)
585 cancel()
586 return Status{}, err
587 }
588 catalog.rememberRevision(revisionFloor)
589 for _, target := range targets {
590 if err := catalog.ReconcileDirectory(ctx, target); err != nil {
591 closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
592 _ = catalog.Close(closeCtx)
593 cancel()
594 return catalog.Status(), err
595 }
596 }
597 status := catalog.Status()
598 closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
599 _ = catalog.Close(closeCtx)
600 cancel()
601 return status, nil
602 }
603 err := projectiondb.Rebuild(ctx, projectiondb.OpenOptions{
604 Path: path,
605 MemoryName: "session-catalog-rebuild",
606 Migrations: sessionMigrations(),
607 RetainBackup: true,
608 }, func(ctx context.Context, db *sql.DB) error {
609 if err := setCatalogRevisionFloor(ctx, db, revisionFloor); err != nil {
610 return err
611 }
612 // Populate through a catalog that owns this temporary database handle
613 // without starting background repair workers.
614 temp := &Catalog{
615 db: db,
616 opts: Options{Path: path, DisableRepair: true, Now: time.Now, MissingGrace: defaultMissingGrace},
617 pathIdentity: PathIdentityKey,
618 writeQueued: map[string]SessionRecord{},
619 directoryLocks: map[string]*sync.Mutex{},
620 stop: make(chan struct{}),
621 status: Status{State: StateReady, Mode: ModeDisk, Path: path, Revision: revisionFloor},
622 }
623 temp.revision.Store(revisionFloor)
624 temp.workerCtx, temp.workerCancel = context.WithCancel(ctx)
625 defer temp.workerCancel()
626 for _, target := range targets {
627 if err := temp.ReconcileDirectory(ctx, target); err != nil {
628 return err
629 }
630 }
631 return nil
632 })
633 if err != nil {
634 return Status{}, err
635 }
636 // Open the published replacement briefly for a status snapshot, then close.
637 catalog, err := Open(ctx, Options{Path: path, DisableRepair: true})
638 if err != nil {
639 return Status{State: StateReady, Mode: ModeDisk, Path: path, Revision: revisionFloor}, nil
640 }
641 status := catalog.Status()
642 closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
643 _ = catalog.Close(closeCtx)
644 cancel()
645 return status, nil
646 }
647
648 func setCatalogRevisionFloor(ctx context.Context, db *sql.DB, revisionFloor uint64) error {
649 if db == nil || revisionFloor == 0 {
650 return nil
651 }
652 _, err := db.ExecContext(ctx, `UPDATE catalog_state SET revision=? WHERE id=1 AND revision<?`, revisionFloor, revisionFloor)
653 return err
654 }
655
656 // Inspect is read-only. It never migrates, repairs, quarantines, or rewrites a
657 // catalog, making it suitable for `reasonix doctor sessions`.
658 func Inspect(ctx context.Context, path string) (Status, error) {
659 if strings.TrimSpace(path) == "" {
660 path = DefaultPath()
661 }
662 status := Status{State: StateDegraded, Mode: ModeDisk, Path: path}
663 if strings.TrimSpace(path) == "" {
664 status.LastError = "catalog path unavailable"
665 return status, nil
666 }
667 inspection := projectiondb.Inspect(ctx, path)
668 if err := ctx.Err(); err != nil {
669 return status, err
670 }
671 if inspection.Error != "" && !inspection.Exists {
672 status.LastError = inspection.Error
673 return status, nil
674 }
675 if !inspection.Exists {
676 status.LastError = "catalog does not exist"
677 return status, nil
678 }
679 if inspection.Integrity != "" && inspection.Integrity != "ok" {
680 status.LastError = inspection.Integrity
681 return status, nil
682 }
683 if inspection.Error != "" {
684 status.LastError = inspection.Error
685 return status, nil
686 }
687 dsn, err := sqliteuri.Disk(path, url.Values{
688 "mode": {"ro"},
689 "_pragma": {"busy_timeout(150)"},
690 })
691 if err != nil {
692 status.LastError = "build read-only database URI: " + err.Error()
693 return status, nil
694 }
695 db, err := sql.Open("sqlite", dsn)
696 if err != nil {
697 status.LastError = "open read-only database: " + err.Error()
698 return status, nil
699 }
700 defer db.Close()
701 result := status
702 fail := func(stage string, err error) (Status, error) {
703 if ctxErr := ctx.Err(); ctxErr != nil {
704 return status, ctxErr
705 }
706 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
707 return status, err
708 }
709 status.LastError = stage + ": " + err.Error()
710 return status, nil
711 }
712 queries := []struct {
713 stage string
714 query string
715 dest any
716 }{
717 {"read catalog revision", `SELECT revision FROM catalog_state WHERE id=1`, &result.Revision},
718 {"count indexed sessions", `SELECT COUNT(*) FROM catalog_sessions`, &result.Indexed},
719 {"count pending repairs", `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown'`, &result.RepairPending},
720 {"count active repairs", `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown' AND repair_state IN ('pending','active')`, &result.RepairActive},
721 {"count deferred repairs", `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown' AND repair_state='deferred'`, &result.RepairDeferred},
722 {"count blocked repairs", `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown' AND repair_state='blocked'`, &result.RepairBlocked},
723 {"read next repair time", `SELECT COALESCE(MIN(repair_retry_at),0) FROM catalog_sessions WHERE turns_state='unknown' AND repair_state='deferred'`, &result.NextRepairAt},
724 {"count physical sessions", `SELECT COUNT(*) FROM catalog_sessions WHERE missing_since=0`, &result.PhysicalSessions},
725 {"count logical sessions", `SELECT COUNT(*) FROM catalog_topics`, &result.LogicalSessions},
726 {"count recovery groups", `SELECT COUNT(DISTINCT recovery_group_id) FROM catalog_sessions WHERE recovered=1 AND recovery_group_id<>'' AND missing_since=0`, &result.RecoveryGroups},
727 {"count recovery branches", `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND missing_since=0`, &result.RecoveryBranches},
728 {"count diverged recoveries", `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND recovery_role='diverged' AND missing_since=0`, &result.RecoveryDiverged},
729 {"count cleanup eligible recoveries", `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND recovery_role='covered_copy' AND missing_since=0`, &result.CleanupEligible},
730 }
731 for _, query := range queries {
732 if err := db.QueryRowContext(ctx, query.query).Scan(query.dest); err != nil {
733 return fail(query.stage, err)
734 }
735 }
736 rows, err := db.QueryContext(ctx, `SELECT repair_error_kind,COUNT(*) FROM catalog_sessions
737 WHERE turns_state='unknown' AND repair_error_kind<>'' GROUP BY repair_error_kind`)
738 if err != nil {
739 return fail("count repair error kinds", err)
740 }
741 result.RepairErrorKinds = map[string]int64{}
742 for rows.Next() {
743 var kind string
744 var count int64
745 if err := rows.Scan(&kind, &count); err != nil {
746 _ = rows.Close()
747 return fail("scan repair error kinds", err)
748 }
749 result.RepairErrorKinds[kind] = count
750 }
751 if err := rows.Err(); err != nil {
752 _ = rows.Close()
753 return fail("iterate repair error kinds", err)
754 }
755 if err := rows.Close(); err != nil {
756 return fail("close repair error kinds", err)
757 }
758 result.State = StateReady
759 result.LastError = ""
760 return result, nil
761 }
762
762 lines GO