From a2067baa5a21bdb1cc52bb1bb2216bd73ee43885 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 07:22:12 +0000 Subject: [PATCH] feat(store): scan orphan workspaces on startup Co-Authored-By: Virgil --- store.go | 6 ++++++ workspace.go | 45 +++++++++++++++++++++++++++++++-------------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/store.go b/store.go index 80b87de..6bf6feb 100644 --- a/store.go +++ b/store.go @@ -128,6 +128,8 @@ func WithPurgeInterval(interval time.Duration) StoreOption { } // Usage example: `storeInstance, err := store.NewConfigured(store.StoreConfig{DatabasePath: ":memory:", Journal: store.JournalConfiguration{EndpointURL: "http://127.0.0.1:8086", Organisation: "core", BucketName: "events"}, PurgeInterval: 20 * time.Millisecond})` +// NewConfigured also scans `.core/state` for leftover `.duckdb` workspace files +// so orphan recovery can happen before the first explicit recovery call. func NewConfigured(config StoreConfig) (*Store, error) { storeInstance, err := openStore("store.NewConfigured", config.DatabasePath) if err != nil { @@ -145,11 +147,14 @@ func NewConfigured(config StoreConfig) (*Store, error) { storeInstance.purgeInterval = config.PurgeInterval } + _ = discoverOrphanWorkspacePaths(defaultWorkspaceStateDirectory) storeInstance.startBackgroundPurge() return storeInstance, nil } // Usage example: `storeInstance, err := store.New("/tmp/go-store.db", store.WithJournal("http://127.0.0.1:8086", "core", "events"))` +// New scans `.core/state` for leftover `.duckdb` workspace files before the +// store starts its background purge loop. func New(databasePath string, options ...StoreOption) (*Store, error) { storeInstance, err := openStore("store.New", databasePath) if err != nil { @@ -160,6 +165,7 @@ func New(databasePath string, options ...StoreOption) (*Store, error) { option(storeInstance) } } + _ = discoverOrphanWorkspacePaths(defaultWorkspaceStateDirectory) storeInstance.startBackgroundPurge() return storeInstance, nil } diff --git a/workspace.go b/workspace.go index 138b414..2adc798 100644 --- a/workspace.go +++ b/workspace.go @@ -109,19 +109,13 @@ func (storeInstance *Store) NewWorkspace(name string) (*Workspace, error) { }, nil } -// RecoverOrphans(".core/state") returns orphaned workspaces such as -// `scroll-session.duckdb` so callers can inspect Aggregate() and then Discard(). -// Usage example: `orphans := storeInstance.RecoverOrphans(".core/state")` -func (storeInstance *Store) RecoverOrphans(stateDirectory string) []*Workspace { - if storeInstance == nil { - return nil - } - +// discoverOrphanWorkspacePaths(".core/state") returns leftover workspace files +// such as `scroll-session.duckdb` without opening them. +func discoverOrphanWorkspacePaths(stateDirectory string) []string { + filesystem := (&core.Fs{}).NewUnrestricted() if stateDirectory == "" { stateDirectory = defaultWorkspaceStateDirectory } - - filesystem := (&core.Fs{}).NewUnrestricted() if !filesystem.Exists(stateDirectory) { return nil } @@ -147,19 +141,42 @@ func (storeInstance *Store) RecoverOrphans(stateDirectory string) []*Workspace { } }) - var orphanWorkspaces []*Workspace + orphanPaths := make([]string, 0, len(directoryEntries)) for _, dirEntry := range directoryEntries { if dirEntry.IsDir() || !core.HasSuffix(dirEntry.Name(), ".duckdb") { continue } - name := core.TrimSuffix(dirEntry.Name(), ".duckdb") - databasePath := workspaceFilePath(stateDirectory, name) + orphanPaths = append(orphanPaths, workspaceFilePath(stateDirectory, core.TrimSuffix(dirEntry.Name(), ".duckdb"))) + } + return orphanPaths +} + +func workspaceNameFromPath(stateDirectory, databasePath string) string { + relativePath := core.TrimPrefix(databasePath, joinPath(stateDirectory, "")) + return core.TrimSuffix(relativePath, ".duckdb") +} + +// RecoverOrphans(".core/state") returns orphaned workspaces such as +// `scroll-session.duckdb` so callers can inspect Aggregate() and then Discard(). +// Usage example: `orphans := storeInstance.RecoverOrphans(".core/state")` +func (storeInstance *Store) RecoverOrphans(stateDirectory string) []*Workspace { + if storeInstance == nil { + return nil + } + + if stateDirectory == "" { + stateDirectory = defaultWorkspaceStateDirectory + } + + filesystem := (&core.Fs{}).NewUnrestricted() + var orphanWorkspaces []*Workspace + for _, databasePath := range discoverOrphanWorkspacePaths(stateDirectory) { workspaceDatabase, err := openWorkspaceDatabase(databasePath) if err != nil { continue } orphanWorkspaces = append(orphanWorkspaces, &Workspace{ - name: name, + name: workspaceNameFromPath(stateDirectory, databasePath), backingStore: storeInstance, database: workspaceDatabase, databasePath: databasePath,