package archive import ( "context" "log/slog" "time" "go.uber.org/fx" "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" ) // ErrExportArchiveWriterEvicted exposes the sentinel returned by // an evicted archive writer. It carries the Err prefix rather // than this file's usual Export one because it is a sentinel // error. var ErrExportArchiveWriterEvicted = errArchiveWriterEvicted // ExportArchivedEvent aliases the archive row type so black-box // tests can construct and read archive rows. type ExportArchivedEvent = archivedEvent // ExportArchiveWriter wraps an archiveWriter so black-box tests // can exercise the archive file mechanics. type ExportArchiveWriter struct { w *archiveWriter } // NewExportArchiveWriter builds an archive writer for tests, // optionally overriding the reopen debounce (a non-positive // debounce keeps the production default). func NewExportArchiveWriter( path string, log *slog.Logger, debounce time.Duration, ) *ExportArchiveWriter { w := newArchiveWriter(path, log) if debounce > 0 { w.debounce = debounce } return &ExportArchiveWriter{w: w} } // Write archives a row through the writer, into the file named // without a period. func (e *ExportArchiveWriter) Write( row ExportArchivedEvent, expiry time.Duration, ) error { return e.w.write(row, expiry, "") } // WritePeriod archives a row through the writer, into the file for // period. func (e *ExportArchiveWriter) WritePeriod( row ExportArchivedEvent, expiry time.Duration, period string, ) error { return e.w.write(row, expiry, period) } // Open opens the archive file, pruning when expiry is positive. func (e *ExportArchiveWriter) Open(expiry time.Duration) error { return e.w.open(e.w.path, expiry) } // Reopen closes and reopens the archive file. func (e *ExportArchiveWriter) Reopen( expiry time.Duration, ) error { return e.w.reopen(e.w.path, expiry) } // SetNow replaces the clock the writer measures its reopen // debounce on. func (e *ExportArchiveWriter) SetNow(now func() time.Time) { e.w.now = now } // Reopens reports how many times the file has been opened. func (e *ExportArchiveWriter) Reopens() int { return e.w.reopens } // DB returns the writer's current open handle for row // inspection in tests. func (e *ExportArchiveWriter) DB() *gorm.DB { return e.w.db } // Path returns the archive file the writer owns. func (e *ExportArchiveWriter) Path() string { return e.w.path } // OpenExisting opens the archive without permitting creation, // the way the idle sweep does. func (e *ExportArchiveWriter) OpenExisting( expiry time.Duration, ) error { return e.w.openMode(e.w.path, archiveModeExisting, expiry) } // SweepExpired runs an idle sweep of the archive. func (e *ExportArchiveWriter) SweepExpired( expiry time.Duration, ) error { return e.w.sweepExpired(expiry) } // Evict marks the writer evicted and closes its handle, exactly // as leaving the registry does. func (e *ExportArchiveWriter) Evict() { e.w.evict() } // Rename gives the archive file a new name in the same directory, // as a rename of the webhook or target does. func (e *ExportArchiveWriter) Rename(name string) error { return e.w.rename(name) } // HandleOpen reports whether the writer currently holds an open // archive handle. func (e *ExportArchiveWriter) HandleOpen() bool { e.w.mu.Lock() defer e.w.mu.Unlock() return e.w.db != nil } // Same reports whether both wrappers refer to the very same // underlying archive writer, so a test can prove a registry entry // is the writer it was handed rather than a replacement. func (e *ExportArchiveWriter) Same( other *ExportArchiveWriter, ) bool { return other != nil && e.w == other.w } // ExportArchiveWriterFor returns the archive writer the registry // currently caches for a database target, or nil when none is // cached. It never creates one, so a test can hold a reference to // the very writer an eviction is about to detach. func (ws *Writers) ExportArchiveWriterFor( targetID string, ) *ExportArchiveWriter { ws.mu.Lock() defer ws.mu.Unlock() w, ok := ws.writers[targetID] if !ok { return nil } return &ExportArchiveWriter{w: w} } // ExportHasArchiveWriter reports whether the registry currently // caches an archive writer for a target. func (ws *Writers) ExportHasArchiveWriter( targetID string, ) bool { ws.mu.Lock() defer ws.mu.Unlock() _, ok := ws.writers[targetID] return ok } // ExportArchiveHandleOpen reports whether the cached archive // writer for a target holds an open database handle. It // returns false when no writer is cached. func (ws *Writers) ExportArchiveHandleOpen( targetID string, ) bool { ws.mu.Lock() w, ok := ws.writers[targetID] ws.mu.Unlock() if !ok { return false } w.mu.Lock() defer w.mu.Unlock() return w.db != nil } // ExportEnsureArchiveWriter creates (if needed) and returns the // archive file path of the cached writer for a target, so a // test can prime the registry the way a delivery would. func (ws *Writers) ExportEnsureArchiveWriter( targetID string, ) (string, error) { w, err := ws.writerFor(targetID) if err != nil { return "", err } return w.path, nil } // ExportSweepWriterFor takes a target's registry writer exactly // as the idle sweep does, reporting whether the sweep had to // create the entry. It lets a test drive the registry through the // sweep's own entry point instead of choreographing goroutines. func (ws *Writers) ExportSweepWriterFor( targetID string, ) (*ExportArchiveWriter, bool, error) { w, created, err := ws.sweepWriterFor(targetID) if err != nil { return nil, false, err } return &ExportArchiveWriter{w: w}, created, nil } // ExportReleaseSweepWriter releases a sweep-created registry entry // exactly as a finished sweep does. func (ws *Writers) ExportReleaseSweepWriter( targetID string, w *ExportArchiveWriter, ) { ws.releaseSweepWriter(targetID, w.w) } // NewTestSweeper builds a Sweeper backed by the given main // database and archive writers, without the fx lifecycle. // Intended for tests. func NewTestSweeper( db *database.Database, writers *Writers, log *slog.Logger, ) *Sweeper { return &Sweeper{ db: db, writers: writers, log: log, interval: time.Hour, } } // ExportSweep runs a single archive sweep synchronously for // tests. func (s *Sweeper) ExportSweep(ctx context.Context) { s.sweep(ctx) } // ExportStart starts the sweeper's background loop for tests. func (s *Sweeper) ExportStart() { s.start() } // ExportRegisterHooks registers the sweeper's real fx lifecycle // hooks on a lifecycle supplied by a test, so a test can drive // the exact OnStart/OnStop functions the application runs and // hand OnStart the kind of context fx actually supplies. func (s *Sweeper) ExportRegisterHooks(lc fx.Lifecycle) { s.registerHooks(lc) } // ExportStop stops the sweeper's background loop for tests. func (s *Sweeper) ExportStop(ctx context.Context) error { return s.stop(ctx) } // ExportWedgeLoop adds a goroutine to the sweeper's WaitGroup // that never observes cancellation and returns only when release // is closed. It stands in for a prune stuck on a locked archive. func (s *Sweeper) ExportWedgeLoop( release <-chan struct{}, ) { s.wg.Go(func() { <-release }) } // ExportSetInterval overrides the sweep interval for tests. func (s *Sweeper) ExportSetInterval(d time.Duration) { s.interval = d }