From 42410393af51898ea0b204a2dfd1ab42df266979 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 29 Oct 2025 16:36:56 -0600 Subject: [PATCH] feat(session): add session lifecycle management Implements Store with Start, Get, Archive, and ActiveByPath operations. Sessions use ULID-based identifiers and track state transitions between active and archived. Co-authored-by: Crush --- internal/session/session.go | 286 +++++++++++++++++++++++++++++++ internal/session/session_test.go | 193 +++++++++++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 internal/session/session.go create mode 100644 internal/session/session_test.go diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000000000000000000000000000000000000..8ea76a36e0334000d47fe58c455561d79a7c291e --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,286 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package session + +import ( + "context" + "crypto/rand" + "encoding/base32" + "errors" + "fmt" + "io" + "time" + + "git.secluded.site/np/internal/db" + "git.secluded.site/np/internal/timeutil" +) + +// State captures the lifecycle of a session. +type State string + +const ( + // StateActive indicates that the session is currently active for a directory. + StateActive State = "active" + // StateArchived indicates that the session has been archived. + StateArchived State = "archived" +) + +// Document represents the persisted session metadata. +type Document struct { + SID string `json:"sid"` + DirPath string `json:"dir_path"` + DirHash string `json:"dir_hash"` + State State `json:"state"` + CreatedAt time.Time `json:"created_at"` + ArchivedAt *time.Time `json:"archived_at"` + LastUpdatedAt time.Time `json:"last_updated_at"` +} + +// ErrNotFound indicates that a session could not be located. +var ErrNotFound = errors.New("session: not found") + +// AlreadyActiveError is returned when attempting to start a session where one already exists. +type AlreadyActiveError struct { + Session Document +} + +func (e AlreadyActiveError) Error() string { + return "session: active session already exists for directory" +} + +// Store provides helpers for session lifecycle management. +type Store struct { + db *db.Database + clock timeutil.Clock +} + +// NewStore constructs a Store. When clock is nil, a UTC clock is used. +func NewStore(database *db.Database, clock timeutil.Clock) *Store { + if clock == nil { + clock = timeutil.UTCClock{} + } + return &Store{ + db: database, + clock: clock, + } +} + +// Start creates a new session bound to path. When a session already exists for +// the directory, an AlreadyActiveError is returned containing the existing +// session document. +func (s *Store) Start(ctx context.Context, path string) (Document, error) { + if s.db == nil { + return Document{}, errors.New("session: database is nil") + } + + canonical, hash, err := db.CanonicalizeAndHash(path) + if err != nil { + return Document{}, fmt.Errorf("session: canonicalise path: %w", err) + } + + var doc Document + err = s.db.Update(ctx, func(txn *db.Txn) error { + keyActive := db.KeyDirActive(hash) + exists, err := txn.Exists(keyActive) + if err != nil { + return err + } + if exists { + sidBytes, err := txn.Get(keyActive) + if err != nil { + return err + } + sid := string(sidBytes) + metaKey := db.KeySessionMeta(sid) + already, err := txn.Exists(metaKey) + if err != nil { + return err + } + if !already { + return fmt.Errorf("session: active session %q missing metadata", sid) + } + var existing Document + if err := txn.GetJSON(metaKey, &existing); err != nil { + return err + } + return AlreadyActiveError{Session: existing} + } + + now := timeutil.EnsureUTC(s.clock.Now()) + sid, err := newSessionID(now) + if err != nil { + return err + } + + doc = Document{ + SID: sid, + DirPath: canonical, + DirHash: hash, + State: StateActive, + CreatedAt: now, + ArchivedAt: nil, + LastUpdatedAt: now, + } + + if err := txn.SetJSON(db.KeySessionMeta(sid), doc); err != nil { + return err + } + if err := txn.Set(db.KeyDirActive(hash), []byte(sid)); err != nil { + return err + } + if err := txn.Set(db.KeyIdxActive(sid), []byte(hash)); err != nil { + return err + } + if err := txn.Set(db.KeySessionEventSeq(sid), make([]byte, 8)); err != nil { + return err + } + return nil + }) + if err != nil { + var already AlreadyActiveError + if errors.As(err, &already) { + return already.Session, already + } + return Document{}, err + } + + return doc, nil +} + +// Get retrieves a session by SID. +func (s *Store) Get(ctx context.Context, sid string) (Document, error) { + var doc Document + err := s.db.View(ctx, func(txn *db.Txn) error { + metaKey := db.KeySessionMeta(sid) + exists, err := txn.Exists(metaKey) + if err != nil { + return err + } + if !exists { + return ErrNotFound + } + return txn.GetJSON(metaKey, &doc) + }) + return doc, err +} + +var crockfordEncoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding) + +func newSessionID(now time.Time) (string, error) { + ms := uint64(now.UnixMilli()) + + var data [16]byte + data[0] = byte(ms >> 40) + data[1] = byte(ms >> 32) + data[2] = byte(ms >> 24) + data[3] = byte(ms >> 16) + data[4] = byte(ms >> 8) + data[5] = byte(ms) + + if _, err := io.ReadFull(rand.Reader, data[6:]); err != nil { + return "", fmt.Errorf("session: generate randomness: %w", err) + } + + id := crockfordEncoding.EncodeToString(data[:]) + if len(id) != 26 { + return "", fmt.Errorf("session: unexpected ulid length %d", len(id)) + } + return id, nil +} + +// ActiveByPath returns the active session for path or any of its parents. The +// returned boolean reports whether a session was found. +func (s *Store) ActiveByPath(ctx context.Context, path string) (Document, bool, error) { + canonical, err := db.CanonicalizeDir(path) + if err != nil { + return Document{}, false, fmt.Errorf("session: canonicalise path: %w", err) + } + + var doc Document + found := false + + err = s.db.View(ctx, func(txn *db.Txn) error { + for _, candidate := range db.ParentWalk(canonical) { + hash := db.DirHash(candidate) + keyActive := db.KeyDirActive(hash) + + exists, err := txn.Exists(keyActive) + if err != nil { + return err + } + if !exists { + continue + } + + sidBytes, err := txn.Get(keyActive) + if err != nil { + return err + } + metaKey := db.KeySessionMeta(string(sidBytes)) + if err := txn.GetJSON(metaKey, &doc); err != nil { + return err + } + found = true + return nil + } + return nil + }) + if err != nil { + return Document{}, false, err + } + return doc, found, nil +} + +// Archive transitions sid to the archived state. When the session is already +// archived, the stored document is returned without error. +func (s *Store) Archive(ctx context.Context, sid string) (Document, error) { + if s.db == nil { + return Document{}, errors.New("session: database is nil") + } + + var doc Document + err := s.db.Update(ctx, func(txn *db.Txn) error { + metaKey := db.KeySessionMeta(sid) + exists, err := txn.Exists(metaKey) + if err != nil { + return err + } + if !exists { + return ErrNotFound + } + if err := txn.GetJSON(metaKey, &doc); err != nil { + return err + } + if doc.State == StateArchived { + return nil + } + + now := timeutil.EnsureUTC(s.clock.Now()) + doc.State = StateArchived + doc.LastUpdatedAt = now + doc.ArchivedAt = &now + + if err := txn.Delete(db.KeyDirActive(doc.DirHash)); err != nil { + return err + } + if err := txn.Delete(db.KeyIdxActive(doc.SID)); err != nil { + return err + } + + tsHex := db.Uint64Hex(uint64(now.UnixNano())) + if err := txn.Set(db.KeyIdxArchived(tsHex, doc.SID), []byte(doc.DirHash)); err != nil { + return err + } + if err := txn.Set(db.KeyDirArchived(doc.DirHash, tsHex, doc.SID), []byte{}); err != nil { + return err + } + if err := txn.SetJSON(metaKey, doc); err != nil { + return err + } + return nil + }) + + return doc, err +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000000000000000000000000000000000000..032dbfaf60bd1bb8c7c2ecc1a73b041b9622b299 --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package session_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "git.secluded.site/np/internal/db" + "git.secluded.site/np/internal/session" + "git.secluded.site/np/internal/testutil" +) + +func TestStoreStartAndArchive(t *testing.T) { + ctx := context.Background() + database := testutil.OpenDB(t) + + clock := &testutil.SequenceClock{ + Times: []time.Time{ + time.Date(2025, time.April, 4, 9, 15, 0, 0, time.FixedZone("A", 3600)), + time.Date(2025, time.April, 4, 11, 45, 0, 0, time.FixedZone("B", -3600)), + time.Date(2025, time.April, 4, 12, 0, 0, 0, time.UTC), + }, + } + + store := session.NewStore(database, clock) + + workdir := t.TempDir() + canonical, hash, err := db.CanonicalizeAndHash(workdir) + if err != nil { + t.Fatalf("canonicalise temp dir: %v", err) + } + + started, err := store.Start(ctx, workdir) + if err != nil { + t.Fatalf("Start: %v", err) + } + + if started.SID == "" { + t.Fatalf("expected SID to be populated") + } + if len(started.SID) != 26 { + t.Fatalf("expected ULID SID length 26, got %d", len(started.SID)) + } + if started.DirPath != canonical { + t.Fatalf("DirPath mismatch: want %q got %q", canonical, started.DirPath) + } + if started.DirHash != hash { + t.Fatalf("DirHash mismatch: want %q got %q", hash, started.DirHash) + } + if started.State != session.StateActive { + t.Fatalf("expected state active, got %s", started.State) + } + if started.ArchivedAt != nil { + t.Fatalf("expected ArchivedAt to be nil") + } + + createdAt := clock.Times[0].UTC() + if !started.CreatedAt.Equal(createdAt) || !started.LastUpdatedAt.Equal(createdAt) { + t.Fatalf("timestamp mismatch: created=%v updated=%v want=%v", started.CreatedAt, started.LastUpdatedAt, createdAt) + } + + err = database.View(ctx, func(txn *db.Txn) error { + active, err := txn.Get(db.KeyDirActive(hash)) + if err != nil { + return err + } + if string(active) != started.SID { + t.Fatalf("active SID mismatch: got %s want %s", string(active), started.SID) + } + + idxActive, err := txn.Get(db.KeyIdxActive(started.SID)) + if err != nil { + return err + } + if string(idxActive) != hash { + t.Fatalf("idx active value mismatch: got %s want %s", string(idxActive), hash) + } + + seq, err := txn.Get(db.KeySessionEventSeq(started.SID)) + if err != nil { + return err + } + if len(seq) != 8 { + t.Fatalf("expected event seq zero buffer length 8, got %d", len(seq)) + } + for i, b := range seq { + if b != 0 { + t.Fatalf("expected zeroed event seq, byte %d was %d", i, b) + } + } + return nil + }) + if err != nil { + t.Fatalf("validate stored keys: %v", err) + } + + _, err = store.Start(ctx, workdir) + if err == nil { + t.Fatalf("expected AlreadyActiveError") + } + var already session.AlreadyActiveError + if !errors.As(err, &already) { + t.Fatalf("expected AlreadyActiveError, got %v", err) + } + if already.Session.SID != started.SID { + t.Fatalf("AlreadyActiveError returned unexpected SID: %s", already.Session.SID) + } + + childDir := filepath.Join(workdir, "nested") + if err := os.MkdirAll(childDir, 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + + found, ok, err := store.ActiveByPath(ctx, childDir) + if err != nil { + t.Fatalf("ActiveByPath child: %v", err) + } + if !ok { + t.Fatalf("expected ActiveByPath to find parent session") + } + if found.SID != started.SID { + t.Fatalf("ActiveByPath returned different SID: %s", found.SID) + } + + archived, err := store.Archive(ctx, started.SID) + if err != nil { + t.Fatalf("Archive: %v", err) + } + if archived.State != session.StateArchived { + t.Fatalf("expected archived state, got %s", archived.State) + } + wantArchivedAt := clock.Times[1].UTC() + if archived.ArchivedAt == nil || !archived.ArchivedAt.Equal(wantArchivedAt) { + t.Fatalf("ArchivedAt mismatch: got %v want %v", archived.ArchivedAt, wantArchivedAt) + } + if !archived.LastUpdatedAt.Equal(wantArchivedAt) { + t.Fatalf("LastUpdatedAt mismatch: got %v want %v", archived.LastUpdatedAt, wantArchivedAt) + } + + err = database.View(ctx, func(txn *db.Txn) error { + if exists, err := txn.Exists(db.KeyDirActive(hash)); err != nil { + return err + } else if exists { + t.Fatalf("expected dir active key to be removed") + } + if exists, err := txn.Exists(db.KeyIdxActive(started.SID)); err != nil { + return err + } else if exists { + t.Fatalf("expected idx active key to be removed") + } + + tsHex := db.Uint64Hex(uint64(wantArchivedAt.UnixNano())) + idxVal, err := txn.Get(db.KeyIdxArchived(tsHex, started.SID)) + if err != nil { + return err + } + if string(idxVal) != hash { + t.Fatalf("idx archived value mismatch: got %s want %s", string(idxVal), hash) + } + + dirArchivedKey := db.KeyDirArchived(hash, tsHex, started.SID) + if exists, err := txn.Exists(dirArchivedKey); err != nil { + return err + } else if !exists { + t.Fatalf("expected dir archived key to exist") + } + return nil + }) + if err != nil { + t.Fatalf("validate archival keys: %v", err) + } + + repeat, err := store.Archive(ctx, started.SID) + if err != nil { + t.Fatalf("Archive second time: %v", err) + } + if repeat.State != session.StateArchived { + t.Fatalf("expected archived state on second archive") + } + + if _, found, err := store.ActiveByPath(ctx, workdir); err != nil { + t.Fatalf("post-archive ActiveByPath error: %v", err) + } else if found { + t.Fatalf("ActiveByPath should not find archived session") + } +}