返回 DeepSeek-Reasonix
projectiondb.go
根目录 / internal / projectiondb / projectiondb.go
1 // Package projectiondb owns the lifecycle of disposable SQLite projections.
2 // Business data must remain authoritative outside the database: callers are
3 // expected to be able to discard and rebuild every database opened here.
4 package projectiondb
5
6 import (
7 "context"
8 "database/sql"
9 "errors"
10 "fmt"
11 "net/url"
12 "os"
13 "path/filepath"
14 "runtime"
15 "strings"
16 "sync/atomic"
17 "time"
18
19 filelock "reasonix/internal/identitylock"
20 "reasonix/internal/sqliteuri"
21
22 moderncsqlite "modernc.org/sqlite"
23 sqlite3 "modernc.org/sqlite/lib"
24 )
25
26 type Mode string
27
28 var memoryDatabaseSequence atomic.Uint64
29
30 const (
31 ModeDisk Mode = "disk"
32 ModeMemory Mode = "memory"
33 )
34
35 type State string
36
37 const (
38 StateReady State = "ready"
39 StateDegraded State = "degraded"
40 )
41
42 type Status struct {
43 State State `json:"state"`
44 Mode Mode `json:"mode"`
45 Path string `json:"path,omitempty"`
46 Revision uint64 `json:"revision"`
47 Indexed int64 `json:"indexed"`
48 Total int64 `json:"total"`
49 Pending int64 `json:"pending"`
50 Failed int64 `json:"failed"`
51 LastError string `json:"lastError,omitempty"`
52 QuarantinedPath string `json:"quarantinedPath,omitempty"`
53 }
54
55 type Migration struct {
56 Version int
57 Apply func(context.Context, *sql.Tx) error
58 }
59
60 type OpenOptions struct {
61 Path string
62 MemoryName string
63 Migrations []Migration
64 InMemory bool
65 // RequireDisk disables the process-local memory fallback. Rebuild uses this
66 // so a failed temporary open reports the real disk error instead of
67 // "could not use disk storage" after silently opening :memory:.
68 RequireDisk bool
69 MaxOpenConns int
70 Now func() time.Time
71 SecureDelete bool
72 AutoVacuum bool
73 // RetainBackup keeps the previous database at its generated .replaced-
74 // timestamp path so disposable projections can offer a rollback point.
75 RetainBackup bool
76 // QuickCheck uses SQLite's bounded quick_check for a disposable projection.
77 // Authoritative stores keep the full integrity_check default.
78 QuickCheck bool
79 }
80
81 type Handle struct {
82 DB *sql.DB
83 Status Status
84 }
85
86 type FutureSchemaError struct {
87 Found int
88 Supported int
89 }
90
91 func (e *FutureSchemaError) Error() string {
92 return fmt.Sprintf("projection schema %d is newer than supported %d", e.Found, e.Supported)
93 }
94
95 func Open(ctx context.Context, opts OpenOptions) (*Handle, error) {
96 if opts.Now == nil {
97 opts.Now = time.Now
98 }
99 if opts.MemoryName == "" {
100 opts.MemoryName = "projection"
101 }
102 // Blank paths must never become relative "v1.sqlite" files under cwd.
103 if strings.TrimSpace(opts.Path) == "" {
104 opts.Path = ""
105 opts.InMemory = true
106 }
107 mode := ModeDisk
108 status := Status{State: StateReady, Mode: mode, Path: opts.Path}
109 useMemory := opts.InMemory || PathLooksRemote(opts.Path)
110 if !useMemory {
111 if err := os.MkdirAll(filepath.Dir(opts.Path), 0o700); err != nil {
112 if opts.RequireDisk {
113 return nil, fmt.Errorf("create projection directory: %w", err)
114 }
115 useMemory = true
116 status.LastError = err.Error()
117 } else {
118 _ = os.Chmod(filepath.Dir(opts.Path), 0o700)
119 if filesystemRemote(filepath.Dir(opts.Path)) {
120 if opts.RequireDisk {
121 return nil, errors.New("projection cache is on a remote filesystem")
122 }
123 useMemory = true
124 status.LastError = "projection cache is on a remote filesystem; using memory"
125 }
126 }
127 }
128 if useMemory {
129 if opts.RequireDisk {
130 return nil, errors.New("projection requires disk storage")
131 }
132 mode = ModeMemory
133 }
134
135 db, err := open(ctx, opts, mode)
136 if err != nil && mode == ModeDisk {
137 var future *FutureSchemaError
138 switch {
139 case errors.As(err, &future):
140 // A newer process wrote this projection. Keep the file intact and
141 // serve an empty memory projection so startup never quarantines a
142 // healthy future schema.
143 if opts.RequireDisk {
144 return nil, err
145 }
146 status.State = StateDegraded
147 status.LastError = err.Error()
148 mode = ModeMemory
149 db, err = open(ctx, opts, mode)
150 case isCorruptionError(err):
151 // Only integrity-level failures may rename the on-disk projection.
152 status.QuarantinedPath = Quarantine(opts.Path, opts.Now())
153 db, err = open(ctx, opts, mode)
154 if err != nil {
155 if opts.RequireDisk {
156 return nil, err
157 }
158 status.State = StateDegraded
159 status.LastError = err.Error()
160 mode = ModeMemory
161 db, err = open(ctx, opts, mode)
162 }
163 default:
164 // Busy, permission, IO, or transient open errors must never rename
165 // a healthy database. Fall back to memory for this process only.
166 if opts.RequireDisk {
167 return nil, err
168 }
169 status.State = StateDegraded
170 status.LastError = err.Error()
171 mode = ModeMemory
172 db, err = open(ctx, opts, mode)
173 }
174 }
175 if err != nil {
176 return nil, err
177 }
178 status.Mode = mode
179 if mode == ModeMemory {
180 status.Path = ""
181 }
182 return &Handle{DB: db, Status: status}, nil
183 }
184
185 func open(ctx context.Context, opts OpenOptions, mode Mode) (*sql.DB, error) {
186 var dsn string
187 if mode == ModeMemory {
188 // time.Now has coarse resolution on some platforms, notably Windows.
189 // A process-local sequence prevents concurrently opened projections with
190 // the same logical name from sharing one SQLite memory database by accident.
191 dsn = fmt.Sprintf("file:reasonix-%s-%d-%d?mode=memory&cache=shared", url.PathEscape(opts.MemoryName),
192 opts.Now().UnixNano(), memoryDatabaseSequence.Add(1))
193 } else {
194 var err error
195 dsn, err = sqliteuri.Disk(opts.Path, url.Values{
196 "_pragma": {"busy_timeout(150)", "foreign_keys(1)"},
197 })
198 if err != nil {
199 return nil, err
200 }
201 }
202 db, err := sql.Open("sqlite", dsn)
203 if err != nil {
204 return nil, err
205 }
206 maxOpen := opts.MaxOpenConns
207 if maxOpen <= 0 {
208 maxOpen = 4
209 }
210 // Shared-cache memory databases cannot safely pool concurrent writers.
211 // Disk catalogs retain their requested pool and WAL read concurrency.
212 if mode == ModeMemory {
213 maxOpen = 1
214 }
215 db.SetMaxOpenConns(maxOpen)
216 db.SetMaxIdleConns(min(maxOpen, 2))
217 fail := func(err error) (*sql.DB, error) {
218 _ = db.Close()
219 return nil, err
220 }
221 if err := db.PingContext(ctx); err != nil {
222 return fail(err)
223 }
224 if mode == ModeDisk {
225 if _, err := db.ExecContext(ctx, `PRAGMA journal_mode=WAL`); err != nil {
226 return fail(err)
227 }
228 }
229 for _, pragma := range []string{`PRAGMA synchronous=NORMAL`, `PRAGMA foreign_keys=ON`, `PRAGMA busy_timeout=150`} {
230 if _, err := db.ExecContext(ctx, pragma); err != nil {
231 return fail(err)
232 }
233 }
234 if opts.SecureDelete {
235 // Best-effort: some builds/filesystems reject the pragma without making
236 // the projection unusable.
237 _, _ = db.ExecContext(ctx, `PRAGMA secure_delete=ON`)
238 }
239 if opts.AutoVacuum {
240 // auto_vacuum can only be changed on an empty database; ignore failures
241 // on already-initialized files so open does not degrade to memory.
242 _, _ = db.ExecContext(ctx, `PRAGMA auto_vacuum=INCREMENTAL`)
243 }
244 check := `PRAGMA integrity_check`
245 if opts.QuickCheck {
246 check = `PRAGMA quick_check(1)`
247 }
248 var integrity string
249 if err := db.QueryRowContext(ctx, check).Scan(&integrity); err != nil {
250 return fail(err)
251 }
252 if integrity != "ok" {
253 return fail(fmt.Errorf("projection integrity check: %s", integrity))
254 }
255 if err := ApplyMigrations(ctx, db, opts.Migrations, opts.Now); err != nil {
256 return fail(err)
257 }
258 if mode == ModeDisk {
259 _ = os.Chmod(opts.Path, 0o600)
260 _ = os.Chmod(opts.Path+"-wal", 0o600)
261 _ = os.Chmod(opts.Path+"-shm", 0o600)
262 }
263 return db, nil
264 }
265
266 func ApplyMigrations(ctx context.Context, db *sql.DB, migrations []Migration, now func() time.Time) error {
267 if now == nil {
268 now = time.Now
269 }
270 if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
271 version INTEGER PRIMARY KEY,
272 applied_at INTEGER NOT NULL
273 )`); err != nil {
274 return err
275 }
276 var current int
277 if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&current); err != nil {
278 return err
279 }
280 supported := 0
281 for _, migration := range migrations {
282 if migration.Version > supported {
283 supported = migration.Version
284 }
285 }
286 if current > supported {
287 return &FutureSchemaError{Found: current, Supported: supported}
288 }
289 for _, migration := range migrations {
290 if migration.Version <= current {
291 continue
292 }
293 if migration.Version != current+1 || migration.Apply == nil {
294 return fmt.Errorf("projection migration gap after version %d", current)
295 }
296 tx, err := db.BeginTx(ctx, nil)
297 if err != nil {
298 return err
299 }
300 if err := migration.Apply(ctx, tx); err != nil {
301 _ = tx.Rollback()
302 return fmt.Errorf("apply projection migration %d: %w", migration.Version, err)
303 }
304 if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, migration.Version, now().UnixMilli()); err != nil {
305 _ = tx.Rollback()
306 return err
307 }
308 if err := tx.Commit(); err != nil {
309 return err
310 }
311 current = migration.Version
312 }
313 return nil
314 }
315
316 type Inspection struct {
317 Exists bool `json:"exists"`
318 Path string `json:"path"`
319 Schema int `json:"schema"`
320 Integrity string `json:"integrity,omitempty"`
321 Size int64 `json:"size"`
322 Error string `json:"error,omitempty"`
323 }
324
325 // Inspect is deliberately read-only: it never creates, migrates, repairs, or
326 // quarantines a database.
327 func Inspect(ctx context.Context, path string) Inspection {
328 out := Inspection{Path: path}
329 info, err := os.Stat(path)
330 if errors.Is(err, os.ErrNotExist) {
331 return out
332 }
333 if err != nil {
334 out.Error = err.Error()
335 return out
336 }
337 out.Exists = true
338 out.Size = info.Size()
339 // A live projection may hold its schema and latest commits only in WAL.
340 // immutable=1 would ignore that WAL and report a healthy database as broken.
341 dsn, err := sqliteuri.Disk(path, url.Values{
342 "_pragma": {"busy_timeout(150)", "foreign_keys(1)"},
343 "mode": {"ro"},
344 })
345 if err != nil {
346 out.Error = err.Error()
347 return out
348 }
349 db, err := sql.Open("sqlite", dsn)
350 if err != nil {
351 out.Error = err.Error()
352 return out
353 }
354 defer db.Close()
355 if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&out.Integrity); err != nil {
356 out.Error = err.Error()
357 return out
358 }
359 if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM schema_migrations`).Scan(&out.Schema); err != nil {
360 out.Error = err.Error()
361 }
362 return out
363 }
364
365 func Quarantine(path string, now time.Time) string {
366 if strings.TrimSpace(path) == "" {
367 return ""
368 }
369 quarantined := fmt.Sprintf("%s.corrupt-%d", path, now.UnixMilli())
370 if err := os.Rename(path, quarantined); err != nil {
371 return ""
372 }
373 for _, suffix := range []string{"-wal", "-shm"} {
374 _ = os.Rename(path+suffix, quarantined+suffix)
375 }
376 return quarantined
377 }
378
379 // isCorruptionError reports whether err proves the on-disk projection is unsafe
380 // to keep open. Temporary busy/permission/IO failures must return false so a
381 // multi-process client never renames a healthy database out from under peers.
382 func isCorruptionError(err error) bool {
383 if err == nil {
384 return false
385 }
386 var future *FutureSchemaError
387 if errors.As(err, &future) {
388 return false
389 }
390 var se *moderncsqlite.Error
391 if errors.As(err, &se) {
392 switch se.Code() & 0xff {
393 case sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB:
394 return true
395 }
396 }
397 msg := strings.ToLower(err.Error())
398 return strings.Contains(msg, "integrity check") ||
399 strings.Contains(msg, "malformed") ||
400 strings.Contains(msg, "file is not a database") ||
401 strings.Contains(msg, "not a database")
402 }
403
404 func PathLooksRemote(path string) bool {
405 clean := filepath.Clean(path)
406 if runtime.GOOS == "windows" && strings.HasPrefix(clean, `\\`) {
407 return true
408 }
409 slash := filepath.ToSlash(clean)
410 return strings.HasPrefix(slash, "/net/") || strings.HasPrefix(slash, "/nfs/") || strings.HasPrefix(slash, "/afs/")
411 }
412
413 // Rebuild constructs and validates a replacement beside the live database,
414 // then swaps it into place. The old projection remains untouched if building,
415 // validation, or the platform rename fails (notably an open database on
416 // Windows). Rebuild never touches authoritative business files.
417 func Rebuild(ctx context.Context, opts OpenOptions, populate func(context.Context, *sql.DB) error) error {
418 if strings.TrimSpace(opts.Path) == "" || opts.InMemory {
419 return errors.New("projection rebuild requires a disk path")
420 }
421 if err := os.MkdirAll(filepath.Dir(opts.Path), 0o700); err != nil {
422 return fmt.Errorf("create projection rebuild directory: %w", err)
423 }
424 release, err := filelock.Acquire(ctx, opts.Path+".rebuild.lock")
425 if err != nil {
426 return fmt.Errorf("lock projection rebuild: %w", err)
427 }
428 defer release()
429 if opts.Now == nil {
430 opts.Now = time.Now
431 }
432 temporary := fmt.Sprintf("%s.rebuild-%d", opts.Path, opts.Now().UnixNano())
433 replacement := opts
434 replacement.Path = temporary
435 replacement.InMemory = false
436 replacement.RequireDisk = true
437 handle, err := Open(ctx, replacement)
438 if err != nil {
439 return fmt.Errorf("open projection replacement: %w", err)
440 }
441 cleanupTemporary := func() {
442 _ = os.Remove(temporary)
443 _ = os.Remove(temporary + "-wal")
444 _ = os.Remove(temporary + "-shm")
445 }
446 if handle.Status.Mode != ModeDisk {
447 _ = handle.DB.Close()
448 cleanupTemporary()
449 detail := strings.TrimSpace(handle.Status.LastError)
450 if detail == "" {
451 detail = "unknown open fallback"
452 }
453 return fmt.Errorf("projection replacement could not use disk storage: %s", detail)
454 }
455 if populate != nil {
456 if err := populate(ctx, handle.DB); err != nil {
457 _ = handle.DB.Close()
458 cleanupTemporary()
459 return fmt.Errorf("populate projection replacement: %w", err)
460 }
461 }
462 check := `PRAGMA integrity_check`
463 if opts.QuickCheck {
464 check = `PRAGMA quick_check(1)`
465 }
466 var integrity string
467 if err := handle.DB.QueryRowContext(ctx, check).Scan(&integrity); err != nil || integrity != "ok" {
468 _ = handle.DB.Close()
469 cleanupTemporary()
470 if err != nil {
471 return fmt.Errorf("validate projection replacement: %w", err)
472 }
473 return fmt.Errorf("validate projection replacement: %s", integrity)
474 }
475 _, _ = handle.DB.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`)
476 if err := handle.DB.Close(); err != nil {
477 cleanupTemporary()
478 return err
479 }
480
481 backup := fmt.Sprintf("%s.replaced-%d", opts.Path, opts.Now().UnixNano())
482 hadOld := false
483 if _, err := os.Stat(opts.Path); err == nil {
484 if err := os.Rename(opts.Path, backup); err != nil {
485 cleanupTemporary()
486 return fmt.Errorf("projection database is busy: %w", err)
487 }
488 hadOld = true
489 for _, suffix := range []string{"-wal", "-shm"} {
490 if err := os.Rename(opts.Path+suffix, backup+suffix); err != nil && !errors.Is(err, os.ErrNotExist) {
491 _ = os.Rename(backup, opts.Path)
492 for _, restored := range []string{"-wal", "-shm"} {
493 _ = os.Rename(backup+restored, opts.Path+restored)
494 }
495 cleanupTemporary()
496 return fmt.Errorf("projection database is busy: %w", err)
497 }
498 }
499 } else if !errors.Is(err, os.ErrNotExist) {
500 cleanupTemporary()
501 return err
502 }
503 if err := os.Rename(temporary, opts.Path); err != nil {
504 if hadOld {
505 _ = os.Rename(backup, opts.Path)
506 for _, suffix := range []string{"-wal", "-shm"} {
507 _ = os.Rename(backup+suffix, opts.Path+suffix)
508 }
509 }
510 cleanupTemporary()
511 return fmt.Errorf("install projection replacement: %w", err)
512 }
513 _ = os.Chmod(opts.Path, 0o600)
514 if hadOld && !opts.RetainBackup {
515 _ = os.Remove(backup)
516 _ = os.Remove(backup + "-wal")
517 _ = os.Remove(backup + "-shm")
518 }
519 cleanupTemporary()
520 return nil
521 }
522
522 lines GO