# Garbage collection

> Collect expired Session data under explicit retention rules.

- Path: `Guides > Harness > Session Persistence > Session Store > Garbage collection`
- Human: https://looprig.com/docs/guides/harness/session-persistence/session-store/garbage-collection
- Machine index: https://looprig.com/llms.txt

Sessionstore GC reclaims only orphaned offload blobs. It is not a journal
compaction operation and it never deletes ledger history. Because an upload is
durable before its ledger pointer, a crash can leave an unreferenced blob; GC
scans the ledger for live pointers and sweeps the remainder.

## Object GC contract

The exact public types are:

```go
type GCResult struct {
	Scanned     int
	Referenced  int
	Deleted     int
	DeletedKeys []string
}

func (s *Store) OpenObjectGC(
	id uuid.UUID, lease journal.Lease,
) (*ObjectGC, error)

func (g *ObjectGC) GC(context.Context) (GCResult, error)
```

`OpenObjectGC` rejects a nil lease with `*sessionstore.NilLeaseError`. The GC
does not acquire or release ownership; the composition root supplies the lease
and remains responsible for its lifetime.

## Live-set sweep

One pass performs these steps:

1. Check `lease.Valid()` and `lease.Lost()`; otherwise return
   `*GCLeaseNotHeldError` and delete nothing.
2. Read every ledger envelope under `sessions/<uuid>` and collect referenced
   blob keys from `blobptr` frames.
3. List `sessions/<uuid>/blobs/`.
4. Delete listed keys absent from the complete live set, in sorted order.
5. Recheck lease ownership before each delete. A loss stops the sweep.

```mermaid
%%{init: {"theme":"dark"}}%%
flowchart TD
    A[GC(ctx)] --> B{lease still held?}
    B -- no --> X[GCLeaseNotHeldError, no deletes]
    B -- yes --> C[scan every ledger frame]
    C -->|scan/decode error| Y[GCScanError, no deletes]
    C --> D[list blob prefix]
    D -->|list error| Z[GCListError, no deletes]
    D --> E[set difference: listed minus referenced]
    E --> F{lease before each delete?}
    F -- no --> Q[stop with GCLeaseNotHeldError]
    F -- yes --> G[delete orphan]
    G --> H[GCResult]
```

`GCScanError` and `GCListError` fail closed because an incomplete live set is
unsafe. A delete failure returns `*GCDeleteError` with the blob key; previous
deletes are not rolled back, so operators can run another pass.

## Lease and shutdown

Storage has no per-blob modification time, so this GC has no grace period. The
lease guard alone is not enough if an active append can upload between the scan
and sweep. The caller must serialize GC with every journal append, normally by
using the runtime's offload-GC admission wrapper while the session is idle or
owned by the same writer.

The session runtime stops and joins its offload-GC runner before appending
`SessionStopped` and releasing the session lease. Do not run a standalone GC
against a live session while the runtime can still offload frames.

## Workspace reference scan

Sessionstore also exposes operator-facing workspace discovery:

```go
func (s *Store) WorkspaceLiveRefs(
	context.Context, []uuid.UUID,
) (map[workspacestore.Ref]struct{}, error)
func (s *Store) WorkspaceCheckpointBySeq(
	context.Context, uuid.UUID, uint64,
) (CheckpointSummary, bool, error)
func (s *Store) WorkspaceCheckpointByTurn(
	context.Context, uuid.UUID, uuid.UUID,
) (CheckpointSummary, bool, error)
```

`WorkspaceLiveRefs` scans every retained session's public event history and
keeps refs from both `WorkspaceCheckpointed` and `WorkspaceRestored`. It only
discovers the live set; actual workspace collection remains an explicit
operator action serialized against snapshot writers. Incomplete scans return
`*WorkspaceJournalScanError` and no set is returned.

## GC example

```go
lease, err := store.AcquireLease(ctx, id)
if err != nil {
	return err
}
defer lease.Release(context.Background())

gc, err := store.OpenObjectGC(id, lease)
if err != nil {
	return err
}
result, err := gc.GC(ctx)
if err != nil {
	var notHeld *sessionstore.GCLeaseNotHeldError
	if errors.As(err, &notHeld) {
		return fmt.Errorf("ownership changed; retry after reopening: %w", err)
	}
	return err
}
log.Printf("scanned=%d kept=%d deleted=%d", result.Scanned, result.Referenced, result.Deleted)
```

The runtime normally owns the lease release, so a caller using
`Rig.NewSession` or `RestoreSession` should not acquire a second lease just to
run the configured GC runner.

## Source and proof

- [`ObjectGC`, `GCResult`, and workspace scans](https://github.com/looprig/harness/blob/main/pkg/sessionstore/gc.go)
- [`session journal offload order`](https://github.com/looprig/harness/blob/main/pkg/sessionstore/journal.go)
- [`offload GC tests`](https://github.com/looprig/harness/blob/main/pkg/sessionstore/gc_test.go)
- [`lifecycle GC wiring`](https://github.com/looprig/harness/blob/main/internal/sessionruntime/lifecycle.go)
