Skip to content

Commit 2b584a2

Browse files
committed
feat: add bounded-memory pack v3
1 parent f4bebb0 commit 2b584a2

16 files changed

Lines changed: 1136 additions & 132 deletions

‎docs/design.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
CodexFold is an unofficial, local-first tool for measuring, deduplicating, storing, and recovering Codex session rollouts. The public project is standalone and has no private control-plane dependency.
66

7-
The optimization mechanisms and lifecycle boundaries are defined in [the fold taxonomy](fold-taxonomy.md). Real-corpus storage evidence is recorded separately in [the v0.3 validation](validation-real-corpus-v0.3.md).
7+
The optimization mechanisms and lifecycle boundaries are defined in [the fold taxonomy](fold-taxonomy.md). Real-corpus storage evidence is recorded separately in [the v0.3 validation](validation-real-corpus-v0.3.md). The bounded-memory production pack format and loose-object retirement gates are specified in [Pack V3](pack-v3.md).
88

99
The current release capability is `storage-engine`. Transparent normal-path session access is a separate product stage defined by `docs/superpowers/specs/2026-07-11-transparent-session-filesystem-design.md`.
1010

‎docs/pack-v3.md‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Pack V3
2+
3+
Pack V3 is the bounded-memory production pack format. It replaces the monolithic
4+
`pack-v2` JSON index for newly built generations while keeping pack-v1 and
5+
pack-v2 readable.
6+
7+
## Generation layout
8+
9+
Each immutable generation contains:
10+
11+
- `index.meta.json`: version, kind, generation, block size, counts, and pack filenames.
12+
- `objects.idx`: fixed-width 52-byte records sorted by raw SHA-256.
13+
- `blocks.idx`: fixed-width 69-byte block records.
14+
- `pack-*.pack`: raw or independently Zstandard-compressed block payloads.
15+
- `leases/`: runtime generation leases.
16+
17+
An object record stores the 32-byte digest, raw length, first block number, and
18+
block count. A block record stores pack number, pack offset, stored length, raw
19+
offset, raw length, raw block digest, and encoding.
20+
21+
The resolver binary-searches `objects.idx` with `ReadAt`, expands only the
22+
requested object's block records, and keeps only the configured decompressed
23+
block cache in memory. It does not materialize a corpus-wide object map.
24+
25+
## Build contract
26+
27+
Pack build serializes object mutations with the store operation lock. Manifest
28+
references are deduplicated and sorted in a temporary on-disk SQLite index.
29+
Objects are streamed one at a time into pack files and fixed-width indexes. The
30+
temporary reference database is not published into the generation.
31+
32+
Candidate pack payloads and indexes are synced, opened through the production
33+
resolver, and fully verified before the generation directory and `CURRENT` are
34+
published. A failed or interrupted build leaves the previous `CURRENT`
35+
generation unchanged.
36+
37+
After loose-object retirement, a build reads missing source objects from the
38+
current verified pack. A fold can likewise reuse an object already present in
39+
the current pack without recreating a loose copy.
40+
41+
## Compatibility
42+
43+
- New builds emit `version: 3`, `kind: pack-v3`.
44+
- Existing `pack-v1` and `pack-v2` JSON indexes remain readable.
45+
- Fold-v1 manifests remain unchanged and readable.
46+
- A V3 resolver rejects malformed sizes, unsafe pack names, unsorted or
47+
duplicate object records, invalid block ranges, and pack bounds violations.
48+
49+
## Loose retirement gate
50+
51+
`codexfold pack retire-loose` is dry-run by default. `--apply` removes only
52+
loose objects present in the current generation, and only after both the pack
53+
doctor and a pack-only fold doctor reconstruct every current manifest without
54+
issues. Pack build, fold writes, and retirement share the same cross-process
55+
operation lock.
56+
57+
Retirement is restart-safe: deletion is idempotent, the immutable pack remains
58+
readable throughout, and rerunning the command handles any remaining loose
59+
objects. Applied runs write an audit record and report measured physical bytes
60+
reclaimed. Unpacked loose objects are retained.

‎internal/cli/fold.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ func newFoldCommand() *cobra.Command {
3333
if err != nil {
3434
return err
3535
}
36+
resolver, packErr := pack.Open(options.StoreDir, pack.OpenOptions{CacheBytes: -1})
37+
if packErr == nil {
38+
defer resolver.Close()
39+
options.ExistingReader = resolver
40+
}
3641
result, err := fold.Fold(command.Context(), toFoldSession(session), options)
3742
if err != nil {
3843
return err

‎internal/cli/fs.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,6 +1316,7 @@ func newFSCompactCommand() *cobra.Command {
13161316
manifestPath := filepath.Join(store, "manifests", "generations", state.SessionID, fmt.Sprintf("%020d.json", generation))
13171317
options := fold.FoldOptions{
13181318
StoreDir: store, ManifestPathOverride: manifestPath, Apply: true, Overwrite: true,
1319+
ExistingReader: resolver,
13191320
FieldThreshold: currentManifest.Settings.FieldThreshold, MaxJSONLineBytes: currentManifest.Settings.MaxJSONLineBytes,
13201321
CDC: cdc.Options{MinBytes: currentManifest.Settings.CDCMinBytes, AverageBytes: currentManifest.Settings.CDCAverageBytes, MaxBytes: currentManifest.Settings.CDCMaxBytes},
13211322
}

‎internal/cli/fs_test.go‎

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2684,21 +2684,11 @@ func TestStartupStorageGCRunsOnlyAfterHealthyStoreVerification(t *testing.T) {
26842684
if err != nil {
26852685
t.Fatal(err)
26862686
}
2687-
index, err := os.ReadFile(filepath.Join(storeDir, "packs", strings.TrimSpace(string(current)), "index.json"))
2688-
if err != nil {
2689-
t.Fatal(err)
2690-
}
2691-
var decoded struct {
2692-
Objects []struct {
2693-
Blocks []struct {
2694-
Pack string `json:"pack"`
2695-
} `json:"blocks"`
2696-
} `json:"objects"`
2697-
}
2698-
if err := json.Unmarshal(index, &decoded); err != nil || len(decoded.Objects) == 0 || len(decoded.Objects[0].Blocks) == 0 {
2699-
t.Fatalf("decode current pack index: %#v err=%v", decoded, err)
2687+
packs, err := filepath.Glob(filepath.Join(storeDir, "packs", strings.TrimSpace(string(current)), "pack-*.pack"))
2688+
if err != nil || len(packs) == 0 {
2689+
t.Fatalf("find current pack files: %#v err=%v", packs, err)
27002690
}
2701-
packPath := filepath.Join(storeDir, "packs", strings.TrimSpace(string(current)), decoded.Objects[0].Blocks[0].Pack)
2691+
packPath := packs[0]
27022692
if err := os.WriteFile(packPath, []byte("corrupt"), 0o600); err != nil {
27032693
t.Fatal(err)
27042694
}

‎internal/fold/digestset.go‎

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
package fold
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"container/heap"
7+
"context"
8+
"encoding/hex"
9+
"errors"
10+
"fmt"
11+
"io"
12+
"os"
13+
"path/filepath"
14+
"sort"
15+
)
16+
17+
const (
18+
digestBytes = 32
19+
digestChunkCount = 1 << 20
20+
maxMergeRunInputs = 64
21+
)
22+
23+
type diskDigestSet struct {
24+
directory string
25+
input *os.File
26+
closed bool
27+
}
28+
29+
func newDiskDigestSet() (*diskDigestSet, error) {
30+
directory, err := os.MkdirTemp("", "codexfold-digests-")
31+
if err != nil {
32+
return nil, err
33+
}
34+
input, err := os.OpenFile(filepath.Join(directory, "input.bin"), os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
35+
if err != nil {
36+
_ = os.RemoveAll(directory)
37+
return nil, err
38+
}
39+
return &diskDigestSet{directory: directory, input: input}, nil
40+
}
41+
42+
func (s *diskDigestSet) Add(value string) error {
43+
if s.closed {
44+
return errors.New("digest set is closed")
45+
}
46+
digest, err := hex.DecodeString(value)
47+
if err != nil || len(digest) != digestBytes {
48+
return fmt.Errorf("invalid SHA-256 %q", value)
49+
}
50+
_, err = s.input.Write(digest)
51+
return err
52+
}
53+
54+
func (s *diskDigestSet) Count(ctx context.Context) (int, error) {
55+
if s.closed {
56+
return 0, errors.New("digest set is closed")
57+
}
58+
if err := s.input.Sync(); err != nil {
59+
return 0, err
60+
}
61+
if _, err := s.input.Seek(0, io.SeekStart); err != nil {
62+
return 0, err
63+
}
64+
runs, err := s.createRuns(ctx)
65+
if err != nil {
66+
return 0, err
67+
}
68+
for len(runs) > maxMergeRunInputs {
69+
next := make([]string, 0, (len(runs)+maxMergeRunInputs-1)/maxMergeRunInputs)
70+
for start := 0; start < len(runs); start += maxMergeRunInputs {
71+
end := min(start+maxMergeRunInputs, len(runs))
72+
path := filepath.Join(s.directory, fmt.Sprintf("merged-%06d.bin", len(next)))
73+
if _, err := mergeDigestRuns(ctx, runs[start:end], path); err != nil {
74+
return 0, err
75+
}
76+
next = append(next, path)
77+
for _, old := range runs[start:end] {
78+
_ = os.Remove(old)
79+
}
80+
}
81+
runs = next
82+
}
83+
if len(runs) == 0 {
84+
return 0, nil
85+
}
86+
return mergeDigestRuns(ctx, runs, "")
87+
}
88+
89+
func (s *diskDigestSet) createRuns(ctx context.Context) ([]string, error) {
90+
reader := bufio.NewReaderSize(s.input, 1<<20)
91+
runs := make([]string, 0)
92+
for {
93+
if err := ctx.Err(); err != nil {
94+
return nil, err
95+
}
96+
values := make([][digestBytes]byte, 0, digestChunkCount)
97+
for len(values) < digestChunkCount {
98+
var digest [digestBytes]byte
99+
_, err := io.ReadFull(reader, digest[:])
100+
if errors.Is(err, io.EOF) {
101+
break
102+
}
103+
if err != nil {
104+
return nil, err
105+
}
106+
values = append(values, digest)
107+
}
108+
if len(values) == 0 {
109+
break
110+
}
111+
sort.Slice(values, func(left, right int) bool { return bytes.Compare(values[left][:], values[right][:]) < 0 })
112+
path := filepath.Join(s.directory, fmt.Sprintf("run-%06d.bin", len(runs)))
113+
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
114+
if err != nil {
115+
return nil, err
116+
}
117+
writer := bufio.NewWriterSize(file, 1<<20)
118+
var previous [digestBytes]byte
119+
for index, digest := range values {
120+
if index > 0 && digest == previous {
121+
continue
122+
}
123+
if _, err := writer.Write(digest[:]); err != nil {
124+
_ = file.Close()
125+
return nil, err
126+
}
127+
previous = digest
128+
}
129+
if err := writer.Flush(); err != nil {
130+
_ = file.Close()
131+
return nil, err
132+
}
133+
if err := file.Close(); err != nil {
134+
return nil, err
135+
}
136+
runs = append(runs, path)
137+
}
138+
return runs, nil
139+
}
140+
141+
type digestRun struct {
142+
file *os.File
143+
reader *bufio.Reader
144+
digest [digestBytes]byte
145+
ordinal int
146+
}
147+
148+
type digestHeap []*digestRun
149+
150+
func (h digestHeap) Len() int { return len(h) }
151+
func (h digestHeap) Less(left, right int) bool {
152+
comparison := bytes.Compare(h[left].digest[:], h[right].digest[:])
153+
return comparison < 0 || (comparison == 0 && h[left].ordinal < h[right].ordinal)
154+
}
155+
func (h digestHeap) Swap(left, right int) { h[left], h[right] = h[right], h[left] }
156+
func (h *digestHeap) Push(value any) { *h = append(*h, value.(*digestRun)) }
157+
func (h *digestHeap) Pop() any {
158+
old := *h
159+
value := old[len(old)-1]
160+
*h = old[:len(old)-1]
161+
return value
162+
}
163+
164+
func mergeDigestRuns(ctx context.Context, paths []string, outputPath string) (int, error) {
165+
var output *os.File
166+
var writer *bufio.Writer
167+
if outputPath != "" {
168+
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
169+
if err != nil {
170+
return 0, err
171+
}
172+
output = file
173+
writer = bufio.NewWriterSize(file, 1<<20)
174+
defer output.Close()
175+
}
176+
runs := make([]*digestRun, 0, len(paths))
177+
defer func() {
178+
for _, run := range runs {
179+
_ = run.file.Close()
180+
}
181+
}()
182+
queue := digestHeap{}
183+
for ordinal, path := range paths {
184+
file, err := os.Open(path)
185+
if err != nil {
186+
return 0, err
187+
}
188+
run := &digestRun{file: file, reader: bufio.NewReaderSize(file, 64<<10), ordinal: ordinal}
189+
runs = append(runs, run)
190+
if _, err := io.ReadFull(run.reader, run.digest[:]); err == nil {
191+
heap.Push(&queue, run)
192+
} else if !errors.Is(err, io.EOF) {
193+
return 0, err
194+
}
195+
}
196+
count := 0
197+
var previous [digestBytes]byte
198+
havePrevious := false
199+
for queue.Len() > 0 {
200+
if err := ctx.Err(); err != nil {
201+
return 0, err
202+
}
203+
run := heap.Pop(&queue).(*digestRun)
204+
if !havePrevious || run.digest != previous {
205+
count++
206+
previous = run.digest
207+
havePrevious = true
208+
if writer != nil {
209+
if _, err := writer.Write(run.digest[:]); err != nil {
210+
return 0, err
211+
}
212+
}
213+
}
214+
if _, err := io.ReadFull(run.reader, run.digest[:]); err == nil {
215+
heap.Push(&queue, run)
216+
} else if !errors.Is(err, io.EOF) {
217+
return 0, err
218+
}
219+
}
220+
if writer != nil {
221+
if err := writer.Flush(); err != nil {
222+
return 0, err
223+
}
224+
}
225+
return count, nil
226+
}
227+
228+
func (s *diskDigestSet) Close() error {
229+
if s.closed {
230+
return nil
231+
}
232+
s.closed = true
233+
err := s.input.Close()
234+
return errors.Join(err, os.RemoveAll(s.directory))
235+
}

‎internal/fold/digestset_test.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package fold
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"testing"
8+
)
9+
10+
func TestDiskDigestSetCountsExactUniqueDigests(t *testing.T) {
11+
set, err := newDiskDigestSet()
12+
if err != nil {
13+
t.Fatal(err)
14+
}
15+
defer set.Close()
16+
for _, value := range []string{"alpha", "beta", "alpha", "gamma", "beta"} {
17+
digest := sha256.Sum256([]byte(value))
18+
if err := set.Add(hex.EncodeToString(digest[:])); err != nil {
19+
t.Fatal(err)
20+
}
21+
}
22+
count, err := set.Count(context.Background())
23+
if err != nil {
24+
t.Fatal(err)
25+
}
26+
if count != 3 {
27+
t.Fatalf("unique digest count = %d, want 3", count)
28+
}
29+
}
30+
31+
func TestDiskDigestSetRejectsInvalidDigest(t *testing.T) {
32+
set, err := newDiskDigestSet()
33+
if err != nil {
34+
t.Fatal(err)
35+
}
36+
defer set.Close()
37+
if err := set.Add("not-a-digest"); err == nil {
38+
t.Fatal("invalid digest was accepted")
39+
}
40+
}

0 commit comments

Comments
 (0)