返回 DeepSeek-Reasonix
store.go
根目录 / internal / session / store.go
1 // Package session owns Reasonix's canonical session service.
2 //
3 // Runtime owns live business state and activity authority, PersistenceBinding
4 // owns accepted work and durability progress, Store owns framed bytes and the
5 // writer lease, and Query owns rebuildable disk projections. A commit is
6 // accepted before it is durable; Flush establishes a semantic checkpoint.
7 package session
8
9 import (
10 "bufio"
11 "bytes"
12 "context"
13 "crypto/rand"
14 "crypto/sha256"
15 "encoding/hex"
16 "encoding/json"
17 "errors"
18 "fmt"
19 "io"
20 "os"
21 "path/filepath"
22 "strings"
23 "sync"
24 "time"
25
26 "reasonix/internal/filelock"
27 "reasonix/internal/fileutil"
28 "reasonix/internal/provider"
29 "reasonix/internal/sessioncontent"
30 )
31
32 const (
33 SchemaVersion = V4SchemaVersion
34 // StorageRevision distinguishes the final v4 layout from unpublished v4
35 // drafts. Physical layout changes are migration boundaries even when the
36 // logical codec remains v4.
37 StorageRevision = 3
38 // Codec identifies the current framed linear session format. Earlier linear
39 // and prototype stores are immutable migration inputs.
40 Codec = V4Codec
41 FinalV31Codec = "reasonix.session.linear/v3.1"
42 LegacyLinearCodec = "reasonix.session.linear/v3"
43 PrototypeCodec = "reasonix.session.events/v3"
44 LiveBatchDelay = 200 * time.Millisecond
45 currentLogName = "events.frames"
46 legacyLogName = "events.jsonl"
47 )
48
49 var (
50 ErrUnsupportedVersion = errors.New("unsupported session storage version")
51 ErrDamagedStore = errors.New("damaged session store")
52 ErrStaleGeneration = errors.New("stale session writer generation")
53 ErrOperationConflict = errors.New("session operation id conflicts with an earlier batch")
54 ErrPersistenceUncertain = errors.New("session persistence result is uncertain")
55 ErrSessionNotFound = errors.New("session not found")
56 ErrSessionExists = errors.New("session already exists")
57 ErrWriterOwned = errors.New("session writer is owned by another runtime")
58 ErrReadOnly = errors.New("session handle is read-only")
59 )
60
61 type Manifest struct {
62 SchemaVersion int `json:"schemaVersion"`
63 Codec string `json:"codec"`
64 StorageRevision int `json:"storageRevision,omitempty"`
65 ContentRoot string `json:"contentRoot,omitempty"`
66 SessionID string `json:"sessionId"`
67 CreatedAt time.Time `json:"createdAt"`
68 WriterGeneration uint64 `json:"writerGeneration"`
69 InheritedEvents uint64 `json:"inheritedEventCount,omitempty"`
70 Source *Source `json:"source,omitempty"`
71 }
72
73 const sharedContentRoot = "../.content-v1"
74
75 type Source struct {
76 Path string `json:"path"`
77 Size int64 `json:"size"`
78 SHA256 string `json:"sha256"`
79 Version string `json:"version,omitempty"`
80 LegacyHeadID string `json:"legacyHeadId,omitempty"`
81 }
82
83 type Event struct {
84 ID string `json:"id"`
85 Sequence uint64 `json:"seq"`
86 Kind string `json:"kind"`
87 Optional bool `json:"optional,omitempty"`
88 Required bool `json:"required,omitempty"`
89 Payload json.RawMessage `json:"payload,omitempty"`
90 PayloadRef *sessioncontent.Ref `json:"payloadRef,omitempty"`
91 }
92
93 type Commit struct {
94 SchemaVersion int `json:"schemaVersion"`
95 Codec string `json:"codec"`
96 RecordType string `json:"recordType"`
97 ID string `json:"commitId"`
98 OperationID string `json:"operationId"`
99 OperationHash string `json:"operationHash"`
100 FirstSequence uint64 `json:"firstSeq"`
101 EventCount int `json:"eventCount"`
102 TurnID string `json:"turnId,omitempty"`
103 WriterGeneration uint64 `json:"writerGeneration"`
104 CreatedAt time.Time `json:"createdAt"`
105 Events []Event `json:"events"`
106 }
107
108 func (c Commit) LastSequence() uint64 {
109 if c.EventCount == 0 {
110 return c.FirstSequence
111 }
112 return c.FirstSequence + uint64(c.EventCount) - 1
113 }
114
115 type Batch struct {
116 OperationID string
117 TurnID string
118 Events []Event
119 }
120
121 type DurableReceipt struct {
122 DurableSequence uint64 `json:"durableSequence"`
123 }
124
125 type PersistenceStatus string
126
127 const (
128 PersistenceReady PersistenceStatus = "ready"
129 PersistencePending PersistenceStatus = "pending"
130 PersistenceFailed PersistenceStatus = "failed"
131 PersistenceUncertain PersistenceStatus = "uncertain"
132 )
133
134 type Snapshot struct {
135 EventSequence uint64
136 DurableSequence uint64
137 PersistenceStatus PersistenceStatus
138 PersistenceError string
139 Projection Projection
140 }
141
142 type timerHandle interface{ Stop() bool }
143
144 type OpenOptions struct {
145 AfterFunc func(time.Duration, func()) timerHandle
146 Write func(context.Context, io.Writer, []byte) error
147 Sync func(*os.File) error
148 // ExternalHistory keeps durable UI messages out of the live projection.
149 // Production SessionService enables it; low-level compatibility callers
150 // retain the historical full-projection behavior unless requested.
151 ExternalHistory bool
152 // ObserveRecovery receives bounded open-path I/O counters after recovery.
153 // Capacity tests use it to distinguish checkpoint recovery from prefix replay.
154 ObserveRecovery func(RecoveryOpenStats)
155 // DisableRecoveryPublish is a fault-injection hook used to verify that a
156 // durable log tail is replayed from the previous checkpoint.
157 DisableRecoveryPublish bool
158 }
159
160 type operationRecord struct {
161 hash string
162 commit Commit
163 }
164
165 func compactOperationRecord(commit Commit) operationRecord {
166 metadata := commit
167 metadata.Events = nil
168 return operationRecord{hash: commit.OperationHash, commit: metadata}
169 }
170
171 type uncertainWrite struct {
172 start int64
173 stagedPath string
174 stagedBytes int64
175 commitCount int
176 }
177
178 type uncertainAppendError struct {
179 cause error
180 write uncertainWrite
181 }
182
183 func (e *uncertainAppendError) Error() string {
184 return fmt.Sprintf("%v: append at offset %d may have changed the log: %v", ErrPersistenceUncertain, e.write.start, e.cause)
185 }
186
187 func (e *uncertainAppendError) Unwrap() error { return ErrPersistenceUncertain }
188
189 // Store is the physical framed-log handle for one session directory. It owns the
190 // writer lease, the open file, and the rebuildable sparse offset index. It
191 // deliberately holds no projection, operation table, or accepted commit list:
192 // those belong to Session.
193 type Store struct {
194 mu sync.Mutex
195 indexMu sync.Mutex
196 closeOnce sync.Once
197 closeErr error
198 dir string
199 manifest Manifest
200 file *os.File
201 releaseLease func()
202 closed bool
203 index sparseIndex
204 content *sessioncontent.Store
205 startup *startupSessionState
206 recovery *recoveryStore
207 identity storageIdentity
208 tip durableTip
209
210 writeFn func(context.Context, io.Writer, []byte) error
211 syncFn func(*os.File) error
212 }
213
214 type startupSessionState struct {
215 projection Projection
216 operations map[string]operationRecord
217 durable uint64
218 catalogPreview string
219 recentMessages []provider.Message
220 tip durableTip
221 }
222
223 type durableTip struct {
224 LogOffset int64
225 AnchorOffset int64
226 AnchorFirst uint64
227 AnchorCommitID string
228 AnchorHash string
229 }
230
231 // ID returns the immutable session identity of the physical store.
232 func (s *Store) ID() string {
233 return s.SessionID()
234 }
235
236 func (s *Store) SessionID() string {
237 if s == nil {
238 return ""
239 }
240 s.mu.Lock()
241 defer s.mu.Unlock()
242 return s.manifest.SessionID
243 }
244
245 func (s *Store) Manifest() Manifest {
246 if s == nil {
247 return Manifest{}
248 }
249 s.mu.Lock()
250 defer s.mu.Unlock()
251 manifest := s.manifest
252 if manifest.Source != nil {
253 source := *manifest.Source
254 manifest.Source = &source
255 }
256 return manifest
257 }
258
259 // Dir reports the confined directory that backs this handle.
260 func (s *Store) Dir() string {
261 if s == nil {
262 return ""
263 }
264 s.mu.Lock()
265 defer s.mu.Unlock()
266 return s.dir
267 }
268
269 // writableFile returns the open append file for durability repair. The caller
270 // must already hold the writer lease, which the handle acquired at open.
271 func (s *Store) writableFile() (*os.File, error) {
272 if s == nil {
273 return nil, os.ErrClosed
274 }
275 s.mu.Lock()
276 defer s.mu.Unlock()
277 if s.closed || s.file == nil {
278 return nil, os.ErrClosed
279 }
280 return s.file, nil
281 }
282
283 // Open opens an existing legacy-compatible path, creating it when absent. It
284 // returns a live Session: the in-memory log is the caller's business state.
285 func Open(dir, sessionID string) (*Session, error) {
286 return OpenWithOptions(dir, sessionID, OpenOptions{})
287 }
288
289 // OpenWithOptions is the low-level test/import constructor retained while the
290 // old controller adapter is removed. Production callers use
291 // FilesystemPersistence, whose Open is strict and never creates a session.
292 func OpenWithOptions(dir, sessionID string, opts OpenOptions) (*Session, error) {
293 if _, err := os.Stat(filepath.Clean(strings.TrimSpace(dir))); os.IsNotExist(err) {
294 return CreateWithOptions(dir, sessionID, opts)
295 }
296 handle, err := openExistingHandle(dir, sessionID, opts)
297 if err != nil {
298 return nil, err
299 }
300 return bindSession(handle, opts)
301 }
302
303 func CreateStore(dir, sessionID string) (*Session, error) {
304 return CreateWithOptions(dir, sessionID, OpenOptions{})
305 }
306
307 func CreateWithOptions(dir, sessionID string, opts OpenOptions) (*Session, error) {
308 return createWithOptions(dir, sessionID, opts, nil)
309 }
310
311 func createWithOptions(dir, sessionID string, opts OpenOptions, header *SessionHeader) (*Session, error) {
312 dir = filepath.Clean(strings.TrimSpace(dir))
313 sessionID = strings.TrimSpace(sessionID)
314 if dir == "." || sessionID == "" {
315 return nil, fmt.Errorf("session: directory and session id are required")
316 }
317 if err := validateSessionID(sessionID); err != nil {
318 return nil, err
319 }
320 if err := os.MkdirAll(filepath.Dir(dir), 0o700); err != nil {
321 return nil, err
322 }
323 if err := os.Mkdir(dir, 0o700); err != nil {
324 if os.IsExist(err) {
325 return nil, fmt.Errorf("%w: %s", ErrSessionExists, sessionID)
326 }
327 return nil, err
328 }
329 created := true
330 defer func() {
331 if created {
332 _ = os.RemoveAll(dir)
333 }
334 }()
335 createdAt := time.Now().UTC()
336 if header != nil {
337 header.SessionID = sessionID
338 header.CreatedAt = createdAt
339 }
340 manifest := Manifest{SchemaVersion: SchemaVersion, Codec: Codec, StorageRevision: StorageRevision, ContentRoot: sharedContentRoot, SessionID: sessionID, CreatedAt: createdAt}
341 if err := writeManifestFile(filepath.Join(dir, "manifest.json"), manifest); err != nil {
342 return nil, err
343 }
344 if header != nil {
345 if err := writeSessionHeader(dir, *header); err != nil {
346 return nil, err
347 }
348 }
349 if err := fileutil.AtomicWriteFileStrict(filepath.Join(dir, currentLogName), nil, 0o600); err != nil {
350 return nil, err
351 }
352 created = false
353 return OpenWithOptions(dir, sessionID, opts)
354 }
355
356 func openExistingHandle(dir, sessionID string, opts OpenOptions) (*Store, error) {
357 dir = filepath.Clean(strings.TrimSpace(dir))
358 sessionID = strings.TrimSpace(sessionID)
359 if dir == "." || sessionID == "" {
360 return nil, fmt.Errorf("session: directory and session id are required")
361 }
362 if err := validateSessionID(sessionID); err != nil {
363 return nil, err
364 }
365 info, err := os.Stat(dir)
366 if os.IsNotExist(err) {
367 return nil, fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID)
368 }
369 if err != nil {
370 return nil, err
371 }
372 if !info.IsDir() {
373 return nil, fmt.Errorf("session: session path is not a directory: %s", dir)
374 }
375 eventsPath := filepath.Join(dir, currentLogName)
376 releaseLease, err := acquireSessionWriter(dir)
377 if err != nil {
378 if errors.Is(err, filelock.ErrHeld) {
379 return nil, fmt.Errorf("%w: %s", ErrWriterOwned, sessionID)
380 }
381 return nil, err
382 }
383 fail := func(err error) (*Store, error) {
384 releaseLease()
385 return nil, err
386 }
387 manifestPath := filepath.Join(dir, "manifest.json")
388 manifest, err := readManifest(manifestPath)
389 if err != nil {
390 return fail(err)
391 }
392 if manifest.SessionID != sessionID {
393 return fail(fmt.Errorf("session: manifest belongs to %q", manifest.SessionID))
394 }
395 identity, err := ensureStorageIdentity(dir, manifest)
396 if err != nil {
397 return fail(err)
398 }
399 recovery, err := openRecoveryStoreRepair(dir, identity)
400 if err != nil {
401 return fail(err)
402 }
403 failRecovery := func(err error) (*Store, error) {
404 _ = recovery.close()
405 return fail(err)
406 }
407 // Build runtime state in one streaming validation pass. A newer required
408 // event or a damaged complete batch leaves the original tail untouched.
409 startup, durableEnd, torn, stats, usedCheckpoint, err := loadStartupSessionState(context.Background(), dir, eventsPath, opts.ExternalHistory, recovery, identity)
410 if opts.ObserveRecovery != nil {
411 opts.ObserveRecovery(stats)
412 }
413 if err != nil {
414 return failRecovery(err)
415 }
416 if opts.ExternalHistory && startup.catalogPreview == "" {
417 revision, revisionErr := revisionOfLog(dir)
418 cacheDir := filepath.Join(filepath.Dir(dir), ".query-cache", filepath.Base(dir))
419 if revisionErr == nil {
420 if metadata, metadataErr := readCatalogMetadata(cacheDir, manifest, revision); metadataErr == nil {
421 startup.catalogPreview = metadata.Preview
422 }
423 }
424 }
425 if torn {
426 // Cold readers deliberately stop at the last complete record. A writer
427 // may repair that tail only after acquiring the exclusive lease above:
428 // preserve the original bytes first, then truncate back to the durable
429 // commit boundary. This never invents or partially replays an event.
430 if _, err := preserveAndTruncateTail(eventsPath, durableEnd, "torn"); err != nil {
431 return failRecovery(fmt.Errorf("recover torn v3 tail: %w", err))
432 }
433 }
434 if !usedCheckpoint {
435 checkpoint := checkpointFromStartup(manifest, identity, startup)
436 if err := recovery.publish(context.Background(), checkpoint, startup.operations); err == nil {
437 startup.operations = map[string]operationRecord{}
438 }
439 }
440 // Upgrade only after the exclusive writer validated the complete log. Old
441 // readers reject a newer revision before using caches or accepting new writes.
442 manifest.StorageRevision = StorageRevision
443 manifest.WriterGeneration++
444 if err := writeManifestFile(manifestPath, manifest); err != nil {
445 return failRecovery(err)
446 }
447 f, err := os.OpenFile(eventsPath, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o600)
448 if err != nil {
449 return failRecovery(err)
450 }
451 writeFn := opts.Write
452 if writeFn == nil {
453 writeFn = writeAllContext
454 }
455 syncFn := opts.Sync
456 if syncFn == nil {
457 syncFn = func(file *os.File) error { return file.Sync() }
458 }
459 // Opening a runtime must not rebuild a historical seek index. The writer
460 // needs only the durable sequence and byte end; readers maintain their own
461 // disposable locator in the query cache.
462 index := sparseIndex{Codec: sparseIndexCodec, LogSize: durableEnd, LastSequence: startup.durable, partial: startup.durable > 0}
463 return &Store{
464 dir: dir, manifest: manifest, file: f, releaseLease: releaseLease,
465 index: index, content: contentStoreForSessionDir(dir), startup: startup,
466 recovery: recovery, identity: identity, tip: startup.tip,
467 writeFn: writeFn, syncFn: syncFn,
468 }, nil
469 }
470
471 func loadStartupSessionState(ctx context.Context, dir, eventsPath string, externalHistory bool, recovery *recoveryStore, identity storageIdentity) (*startupSessionState, int64, bool, RecoveryOpenStats, bool, error) {
472 file, err := os.Open(eventsPath)
473 if os.IsNotExist(err) {
474 projection, _ := Project(nil)
475 return &startupSessionState{projection: projection, operations: map[string]operationRecord{}}, 0, false, RecoveryOpenStats{}, false, nil
476 }
477 if err != nil {
478 return nil, 0, false, RecoveryOpenStats{}, false, err
479 }
480 defer file.Close()
481 info, err := file.Stat()
482 if err != nil {
483 return nil, 0, false, RecoveryOpenStats{}, false, err
484 }
485 if state, end, torn, stats, ok := loadRecoveryStartupState(ctx, dir, file, info, recovery, identity); ok {
486 return state, end, torn, stats, true, nil
487 }
488 stats := RecoveryOpenStats{LogBytesTotal: info.Size()}
489 if externalHistory {
490 state, end, torn, err := loadBoundedStartupSessionState(ctx, dir, file, info)
491 stats.LogBytesRead = end
492 return state, end, torn, stats, false, err
493 }
494 projection, _ := Project(nil)
495 state := &startupSessionState{projection: projection, operations: map[string]operationRecord{}}
496 var durableEnd int64
497 var projectionErr error
498 err = scanV4CommitFile(ctx, file, 0, 1, contentStoreForSessionDir(dir), nil, func(offset int64, commit Commit) bool {
499 if applyErr := applyProjectionCommit(&state.projection, commit); applyErr != nil {
500 projectionErr = applyErr
501 return false
502 }
503 if externalHistory {
504 for _, message := range state.projection.Messages {
505 if state.catalogPreview == "" {
506 state.catalogPreview = catalogMessagePreview(message)
507 }
508 }
509 state.projection.Messages = nil
510 }
511 state.operations[commit.OperationID] = compactOperationRecord(commit)
512 state.durable = commit.LastSequence()
513 durableEnd, _ = file.Seek(0, io.SeekCurrent)
514 state.tip = durableTip{LogOffset: durableEnd, AnchorOffset: offset, AnchorFirst: commit.FirstSequence, AnchorCommitID: commit.ID, AnchorHash: commit.OperationHash}
515 if err := applyRecentCommit(&state.recentMessages, commit); err != nil {
516 projectionErr = err
517 return false
518 }
519 return true
520 })
521 if err != nil {
522 return nil, 0, false, stats, false, err
523 }
524 if projectionErr != nil {
525 return nil, 0, false, stats, false, projectionErr
526 }
527 stats.LogBytesRead = durableEnd
528 return state, durableEnd, durableEnd < info.Size(), stats, false, nil
529 }
530
531 // loadBoundedStartupSessionState separates lightweight business recovery from
532 // model-context recovery. The first pass validates every transaction without
533 // resolving historical message bodies and locates the newest context reset.
534 // The second pass materializes only the provider workset after that reset.
535 func loadBoundedStartupSessionState(ctx context.Context, dir string, file *os.File, info os.FileInfo) (*startupSessionState, int64, bool, error) {
536 projection, _ := Project(nil)
537 state := &startupSessionState{projection: projection, operations: map[string]operationRecord{}}
538 modelOffset, modelSequence := int64(0), uint64(1)
539 var durableEnd int64
540 var projectionErr error
541 sawModelEvent := false
542 content := contentStoreForSessionDir(dir)
543 err := scanV4CommitFileRefs(ctx, file, 0, 1, content, nil, func(offset int64, commit Commit) bool {
544 business := commit
545 business.Events = nil
546 for _, event := range commit.Events {
547 if modelProjectionEvent(event.Kind) {
548 sawModelEvent = true
549 if modelProjectionReset(event.Kind) {
550 modelOffset, modelSequence = offset, commit.FirstSequence
551 }
552 continue
553 }
554 resolved, err := resolveProjectionEvent(ctx, content, event)
555 if err != nil {
556 projectionErr = err
557 return false
558 }
559 business.Events = append(business.Events, resolved)
560 }
561 if err := applyProjectionCommit(&state.projection, business); err != nil {
562 projectionErr = err
563 return false
564 }
565 recent := commit
566 recent.Events = nil
567 for _, event := range commit.Events {
568 switch event.Kind {
569 case "message/complete", "message/upsert", "message/retract", "history/replace", "legacy/import":
570 resolved, err := resolveProjectionEvent(ctx, content, event)
571 if err != nil {
572 projectionErr = err
573 return false
574 }
575 recent.Events = append(recent.Events, resolved)
576 applyTranscriptMetadata(&state.projection, commit, resolved)
577 }
578 }
579 if err := applyRecentCommit(&state.recentMessages, recent); err != nil {
580 projectionErr = err
581 return false
582 }
583 state.operations[commit.OperationID] = compactOperationRecord(commit)
584 state.durable = commit.LastSequence()
585 durableEnd, _ = file.Seek(0, io.SeekCurrent)
586 state.tip = durableTip{LogOffset: durableEnd, AnchorOffset: offset, AnchorFirst: commit.FirstSequence, AnchorCommitID: commit.ID, AnchorHash: commit.OperationHash}
587 return true
588 })
589 if err != nil {
590 return nil, 0, false, err
591 }
592 if projectionErr != nil {
593 return nil, 0, false, projectionErr
594 }
595 if sawModelEvent {
596 inputs, hidden, retracted := state.projection.TranscriptInputs, state.projection.HiddenTurns, state.projection.RetractedInputs
597 if err := loadCurrentModelProjection(ctx, file, content, state, modelOffset, modelSequence); err != nil {
598 return nil, 0, false, err
599 }
600 state.projection.TranscriptInputs, state.projection.HiddenTurns = inputs, hidden
601 state.projection.RetractedInputs = retracted
602 }
603 state.projection.Messages = nil
604 state.projection.CommittedSequence = state.durable
605 return state, durableEnd, durableEnd < info.Size(), nil
606 }
607
608 func loadCurrentModelProjection(ctx context.Context, file *os.File, content *sessioncontent.Store, state *startupSessionState, offset int64, sequence uint64) error {
609 var projectionErr error
610 err := scanV4CommitFileRefs(ctx, file, offset, sequence, content, nil, func(_ int64, commit Commit) bool {
611 model := commit
612 model.Events = nil
613 for _, event := range commit.Events {
614 if !modelProjectionEvent(event.Kind) {
615 continue
616 }
617 resolved, err := resolveProjectionEvent(ctx, content, event)
618 if err != nil {
619 projectionErr = err
620 return false
621 }
622 model.Events = append(model.Events, resolved)
623 }
624 if err := applyProjectionCommit(&state.projection, model); err != nil {
625 projectionErr = err
626 return false
627 }
628 return true
629 })
630 if err != nil {
631 return err
632 }
633 return projectionErr
634 }
635
636 func resolveProjectionEvent(ctx context.Context, content *sessioncontent.Store, event Event) (Event, error) {
637 if event.PayloadRef == nil {
638 return event, nil
639 }
640 payload, err := resolveContentPayload(ctx, content, *event.PayloadRef)
641 if err != nil {
642 return Event{}, fmt.Errorf("%w: read v4 event %s payload: %w", ErrDamagedStore, event.ID, err)
643 }
644 event.Payload, event.PayloadRef = payload, nil
645 return event, nil
646 }
647
648 func modelProjectionEvent(kind string) bool {
649 switch kind {
650 case "message/complete", "message/upsert", "message/retract", "history/replace", "model/context-replace", "compaction", "legacy/import":
651 return true
652 default:
653 return false
654 }
655 }
656
657 func modelProjectionReset(kind string) bool {
658 switch kind {
659 case "history/replace", "model/context-replace", "compaction", "legacy/import":
660 return true
661 default:
662 return false
663 }
664 }
665
666 // bindSession replays the durable prefix and constructs the in-memory Session
667 // over a binding for the exact handle.
668 func bindSession(handle *Store, opts OpenOptions) (*Session, error) {
669 dir := handle.Dir()
670 state := handle.startup
671 if state == nil {
672 projection, _ := Project(nil)
673 state = &startupSessionState{projection: projection, operations: map[string]operationRecord{}}
674 }
675 handle.startup = nil
676 binding := newPersistenceBinding(handle, dir, state.durable, opts)
677 session := newSession(handle.Manifest().SessionID, handle.Manifest(), nil, state.projection, binding)
678 session.next = state.durable + 1
679 session.operations = state.operations
680 session.externalHistory = opts.ExternalHistory
681 session.catalogPreview = state.catalogPreview
682 session.recentMessages = detachMessages(state.recentMessages)
683 session.durableRecent = detachMessages(state.recentMessages)
684 session.storageGeneration = handle.identity.Generation
685 session.recovery = handle.recovery
686 binding.metadataSource = session.metadataForDurable
687 binding.recoverySource = session.recoveryForDurable
688 binding.recoveryPublished = session.recoveryPublished
689 binding.disableRecoveryPublish = opts.DisableRecoveryPublish
690 return session, nil
691 }
692
693 func writeManifestFile(path string, manifest Manifest) error {
694 b, err := json.MarshalIndent(manifest, "", " ")
695 if err != nil {
696 return err
697 }
698 return fileutil.AtomicWriteFileStrict(path, append(b, '\n'), 0o600)
699 }
700
701 func contentStoreForSessionDir(dir string) *sessioncontent.Store {
702 root := filepath.Join(filepath.Dir(dir), ".content-v1")
703 if data, err := os.ReadFile(filepath.Join(dir, "manifest.json")); err == nil {
704 var location struct {
705 ContentRoot string `json:"contentRoot"`
706 }
707 if json.Unmarshal(data, &location) == nil && strings.TrimSpace(location.ContentRoot) != "" {
708 candidate := filepath.Clean(filepath.Join(dir, location.ContentRoot))
709 relative, relErr := filepath.Rel(dir, candidate)
710 if relErr == nil && (relative == ".content-v1" || relative == sharedContentRoot) {
711 root = candidate
712 }
713 }
714 }
715 return sessioncontent.New(root)
716 }
717
718 func logPathForManifest(dir string, manifest Manifest) string {
719 if manifest.Codec == Codec {
720 return filepath.Join(dir, currentLogName)
721 }
722 return filepath.Join(dir, legacyLogName)
723 }
724
725 func supportedStoredManifest(manifest Manifest) bool {
726 if currentStoredManifest(manifest) {
727 return true
728 }
729 return manifest.SchemaVersion == 3 &&
730 (manifest.Codec == FinalV31Codec || manifest.Codec == LegacyLinearCodec || manifest.Codec == PrototypeCodec)
731 }
732
733 func currentStoredManifest(manifest Manifest) bool {
734 return manifest.SchemaVersion == SchemaVersion && manifest.Codec == Codec &&
735 manifest.StorageRevision >= 1 && manifest.StorageRevision <= StorageRevision
736 }
737
738 func readStoredManifest(path string) (Manifest, error) {
739 b, err := os.ReadFile(path)
740 if err != nil {
741 return Manifest{}, err
742 }
743 var manifest Manifest
744 if err := json.Unmarshal(b, &manifest); err != nil {
745 return Manifest{}, err
746 }
747 if !supportedStoredManifest(manifest) {
748 return Manifest{}, fmt.Errorf("%w: manifest schema or codec", ErrUnsupportedVersion)
749 }
750 return manifest, nil
751 }
752
753 // Append writes already-committed batches in order. Sequence allocation,
754 // validation, and idempotency belong to Session; this method is only the
755 // physical hand-off and reports uncertainty rather than guessing.
756 func (s *Store) Append(ctx context.Context, commits []Commit) error {
757 if s == nil {
758 return fmt.Errorf("session: nil store")
759 }
760 if err := ctx.Err(); err != nil {
761 return err
762 }
763 if len(commits) == 0 {
764 return nil
765 }
766 s.mu.Lock()
767 defer s.mu.Unlock()
768 if s.closed || s.file == nil {
769 return os.ErrClosed
770 }
771 s.indexMu.Lock()
772 next := s.index.LastSequence + 1
773 s.indexMu.Unlock()
774 for i, commit := range commits {
775 if commit.SchemaVersion != SchemaVersion || commit.Codec != Codec || commit.RecordType != "commit" ||
776 commit.ID == "" || commit.OperationID == "" || commit.OperationHash == "" ||
777 commit.WriterGeneration != s.manifest.WriterGeneration || commit.FirstSequence != next ||
778 commit.EventCount == 0 || commit.EventCount != len(commit.Events) {
779 return fmt.Errorf("%w: invalid physical commit %d at sequence %d", ErrDamagedStore, i, next)
780 }
781 for eventIndex, event := range commit.Events {
782 want := commit.FirstSequence + uint64(eventIndex)
783 if event.Sequence != want || event.ID == "" || strings.TrimSpace(event.Kind) == "" {
784 return fmt.Errorf("%w: invalid physical event at sequence %d", ErrDamagedStore, want)
785 }
786 }
787 next = commit.LastSequence() + 1
788 }
789 return s.persist(ctx, s.file, commits)
790 }
791
792 func (s *Store) persist(ctx context.Context, file *os.File, commits []Commit) error {
793 staged, err := os.CreateTemp(s.dir, ".append-*.staged")
794 if err != nil {
795 return fmt.Errorf("stage v4 append: %w", err)
796 }
797 stagedPath := staged.Name()
798 keepStaged := false
799 defer func() {
800 _ = staged.Close()
801 if !keepStaged {
802 _ = os.Remove(stagedPath)
803 }
804 }()
805 lengths, err := encodeV4Commits(ctx, staged, s.content, commits)
806 if err != nil {
807 return err
808 }
809 if err := staged.Sync(); err != nil {
810 return fmt.Errorf("fsync staged v4 append: %w", err)
811 }
812 stagedInfo, err := staged.Stat()
813 if err != nil {
814 return err
815 }
816 if _, err := staged.Seek(0, io.SeekStart); err != nil {
817 return err
818 }
819 start, err := file.Seek(0, io.SeekEnd)
820 if err != nil {
821 return err
822 }
823 if err := copyStagedAppend(ctx, staged, file, s.writeFn); err != nil {
824 end, statErr := file.Seek(0, io.SeekEnd)
825 if statErr == nil && end == start {
826 return err
827 }
828 if statErr == nil && end == start+stagedInfo.Size() {
829 if syncErr := s.syncFn(file); syncErr == nil {
830 s.recordPersistedIndex(file, start, commits, lengths)
831 return nil
832 }
833 }
834 keepStaged = true
835 return &uncertainAppendError{cause: err, write: uncertainWrite{start: start, stagedPath: stagedPath, stagedBytes: stagedInfo.Size(), commitCount: len(commits)}}
836 }
837 if err := s.syncFn(file); err != nil {
838 keepStaged = true
839 return &uncertainAppendError{cause: fmt.Errorf("fsync: %w", err), write: uncertainWrite{start: start, stagedPath: stagedPath, stagedBytes: stagedInfo.Size(), commitCount: len(commits)}}
840 }
841 s.recordPersistedIndex(file, start, commits, lengths)
842 return nil
843 }
844
845 func copyStagedAppend(ctx context.Context, source io.Reader, destination io.Writer, writeFn func(context.Context, io.Writer, []byte) error) error {
846 buffer := make([]byte, 1<<20)
847 for {
848 if err := ctx.Err(); err != nil {
849 return err
850 }
851 n, readErr := source.Read(buffer)
852 if n > 0 {
853 if err := writeFn(ctx, destination, buffer[:n]); err != nil {
854 return err
855 }
856 }
857 if readErr != nil {
858 if errors.Is(readErr, io.EOF) {
859 return nil
860 }
861 return readErr
862 }
863 }
864 }
865
866 // Sync fsyncs the physical log and reports the durable sequence observed on
867 // disk. It is the handle half of a semantic checkpoint; PersistenceBinding
868 // pairs it with queue drain.
869 func (s *Store) Sync(ctx context.Context) (DurableReceipt, error) {
870 if s == nil {
871 return DurableReceipt{}, os.ErrClosed
872 }
873 if err := ctx.Err(); err != nil {
874 return DurableReceipt{}, err
875 }
876 s.mu.Lock()
877 defer s.mu.Unlock()
878 if s.closed || s.file == nil {
879 return DurableReceipt{}, os.ErrClosed
880 }
881 if err := s.syncFn(s.file); err != nil {
882 return DurableReceipt{}, err
883 }
884 s.indexMu.Lock()
885 sequence := s.index.LastSequence
886 s.indexMu.Unlock()
887 return DurableReceipt{DurableSequence: sequence}, nil
888 }
889
890 func (s *Store) Close(_ context.Context) error {
891 if s == nil {
892 return nil
893 }
894 s.closeOnce.Do(func() {
895 s.mu.Lock()
896 file := s.file
897 recovery := s.recovery
898 s.file = nil
899 s.recovery = nil
900 s.closed = true
901 releaseLease := s.releaseLease
902 s.releaseLease = nil
903 s.mu.Unlock()
904 var closeErr error
905 if file != nil {
906 closeErr = file.Close()
907 }
908 if recovery != nil {
909 closeErr = errors.Join(closeErr, recovery.close())
910 }
911 if releaseLease != nil {
912 releaseLease()
913 }
914 s.closeErr = closeErr
915 })
916 return s.closeErr
917 }
918
919 func writeAllContext(ctx context.Context, w io.Writer, data []byte) error {
920 for len(data) > 0 {
921 if err := ctx.Err(); err != nil {
922 return err
923 }
924 n, err := w.Write(data)
925 if err != nil {
926 return err
927 }
928 if n == 0 {
929 return io.ErrShortWrite
930 }
931 data = data[n:]
932 }
933 return nil
934 }
935
936 // Replay returns the complete durable prefix. It ignores only an unterminated
937 // final record; a later write owner preserves and repairs that tail after it
938 // acquires the exclusive lease.
939 func Replay(dir string, knownKinds map[string]bool) ([]Commit, error) {
940 commits := []Commit{}
941 err := scanDurableCommits(dir, knownKinds, func(commit Commit) bool {
942 commits = append(commits, commit)
943 return true
944 })
945 return commits, err
946 }
947
948 // scanDurableCommits validates records in sequence and lets paged readers stop
949 // without materializing the rest of a large log. The next page resumes from a
950 // sequence cursor; a rebuildable offset index can optimize seeking without
951 // changing this validation contract.
952 func scanDurableCommits(dir string, knownKinds map[string]bool, visit func(Commit) bool) error {
953 if knownKinds == nil {
954 knownKinds = ProjectionKinds
955 }
956 manifest, err := readStoredManifest(filepath.Join(dir, "manifest.json"))
957 if err != nil {
958 return err
959 }
960 file, err := os.Open(logPathForManifest(dir, manifest))
961 if os.IsNotExist(err) {
962 return nil
963 }
964 if err != nil {
965 return err
966 }
967 defer file.Close()
968 adapter := func(_ int64, commit Commit) bool {
969 if visit == nil {
970 return true
971 }
972 return visit(commit)
973 }
974 if manifest.Codec == Codec {
975 return scanV4CommitFile(context.Background(), file, 0, 1, contentStoreForSessionDir(dir), knownKinds, adapter)
976 }
977 return scanCommitFileCodec(file, 0, 1, manifest.Codec, knownKinds, adapter)
978 }
979
980 func scanCommitFileCodec(file *os.File, startOffset int64, nextSequence uint64, codec string, knownKinds map[string]bool, visit func(int64, Commit) bool) error {
981 if knownKinds == nil {
982 knownKinds = ProjectionKinds
983 }
984 if _, err := file.Seek(startOffset, io.SeekStart); err != nil {
985 return err
986 }
987 reader := bufio.NewReaderSize(file, 64<<10)
988 next := nextSequence
989 offset := startOffset
990 operations := map[string]string{}
991 for {
992 recordOffset := offset
993 line, readErr := reader.ReadBytes('\n')
994 if errors.Is(readErr, io.EOF) {
995 // Cold readers expose only the complete durable prefix. The exclusive
996 // writer path preserves and repairs this tail before accepting work.
997 break
998 }
999 if readErr != nil {
1000 return readErr
1001 }
1002 offset += int64(len(line))
1003 var commit Commit
1004 if err := json.Unmarshal(bytes.TrimSuffix(line, []byte{'\n'}), &commit); err != nil {
1005 return fmt.Errorf("%w: decode complete commit: %w", ErrDamagedStore, err)
1006 }
1007 if commit.SchemaVersion != 3 || commit.Codec != codec {
1008 return fmt.Errorf("%w: event codec", ErrUnsupportedVersion)
1009 }
1010 if commit.RecordType != "commit" || commit.ID == "" || commit.OperationID == "" ||
1011 commit.OperationHash == "" || commit.WriterGeneration == 0 || commit.FirstSequence != next ||
1012 commit.EventCount != len(commit.Events) || commit.EventCount == 0 {
1013 return fmt.Errorf("%w: invalid commit boundary at sequence %d", ErrDamagedStore, next)
1014 }
1015 if prior, ok := operations[commit.OperationID]; ok && prior != commit.OperationHash {
1016 return fmt.Errorf("%w: conflicting operation %q", ErrDamagedStore, commit.OperationID)
1017 }
1018 operations[commit.OperationID] = commit.OperationHash
1019 for i, event := range commit.Events {
1020 if event.Sequence != next+uint64(i) || event.ID == "" || strings.TrimSpace(event.Kind) == "" {
1021 return fmt.Errorf("%w: invalid event at sequence %d", ErrDamagedStore, next+uint64(i))
1022 }
1023 if !event.Optional && !knownKinds[event.Kind] {
1024 return fmt.Errorf("%w: unknown required event %q", ErrUnsupportedVersion, event.Kind)
1025 }
1026 }
1027 next = commit.LastSequence() + 1
1028 if visit != nil && !visit(recordOffset, commit) {
1029 return nil
1030 }
1031 }
1032 return nil
1033 }
1034
1035 func preserveAndTruncateTail(path string, cut int64, label string) (string, error) {
1036 input, err := os.Open(path)
1037 if err != nil {
1038 return "", err
1039 }
1040 info, err := input.Stat()
1041 if err != nil {
1042 _ = input.Close()
1043 return "", err
1044 }
1045 if cut < 0 || cut > info.Size() {
1046 _ = input.Close()
1047 return "", fmt.Errorf("invalid durable tail offset %d for %d-byte log", cut, info.Size())
1048 }
1049 backup := filepath.Join(filepath.Dir(path), fmt.Sprintf("events.%s-%d.tail", label, time.Now().UTC().UnixNano()))
1050 out, err := os.OpenFile(backup, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
1051 if err != nil {
1052 _ = input.Close()
1053 return "", fmt.Errorf("preserve original tail: %w", err)
1054 }
1055 _, seekErr := input.Seek(cut, io.SeekStart)
1056 _, copyErr := io.CopyBuffer(out, input, make([]byte, 1<<20))
1057 syncErr := out.Sync()
1058 closeOutErr := out.Close()
1059 closeInErr := input.Close()
1060 if err := errors.Join(seekErr, copyErr, syncErr, closeOutErr, closeInErr); err != nil {
1061 _ = os.Remove(backup)
1062 return "", fmt.Errorf("preserve original tail: %w", err)
1063 }
1064 writable, err := os.OpenFile(path, os.O_RDWR, 0o600)
1065 if err != nil {
1066 return "", err
1067 }
1068 truncateErr := writable.Truncate(cut)
1069 syncErr = writable.Sync()
1070 closeErr := writable.Close()
1071 if err := errors.Join(truncateErr, syncErr, closeErr); err != nil {
1072 return "", fmt.Errorf("truncate to durable prefix: %w", err)
1073 }
1074 return backup, nil
1075 }
1076
1077 func hashOperation(sessionID, turnID string, events []Event) (string, error) {
1078 type inputEvent struct {
1079 ID string `json:"id,omitempty"`
1080 Kind string `json:"kind"`
1081 Optional bool `json:"optional,omitempty"`
1082 Payload json.RawMessage `json:"payload,omitempty"`
1083 }
1084 inputs := make([]inputEvent, len(events))
1085 for i, event := range events {
1086 inputs[i] = inputEvent{ID: event.ID, Kind: strings.TrimSpace(event.Kind), Optional: event.Optional, Payload: event.Payload}
1087 }
1088 b, err := json.Marshal(struct {
1089 SessionID string `json:"sessionId"`
1090 TurnID string `json:"turnId"`
1091 Events []inputEvent `json:"events"`
1092 }{SessionID: sessionID, TurnID: turnID, Events: inputs})
1093 if err != nil {
1094 return "", err
1095 }
1096 sum := sha256.Sum256(b)
1097 return hex.EncodeToString(sum[:]), nil
1098 }
1099
1100 func deterministicID(seed string) string {
1101 sum := sha256.Sum256([]byte(seed))
1102 return hex.EncodeToString(sum[:16])
1103 }
1104
1105 func batchContains(events []Event, kind string) bool {
1106 for _, event := range events {
1107 if strings.TrimSpace(event.Kind) == kind {
1108 return true
1109 }
1110 }
1111 return false
1112 }
1113
1114 func sameCommitPrefix(all, prefix []Commit) bool {
1115 for i := range prefix {
1116 if all[i].ID != prefix[i].ID {
1117 return false
1118 }
1119 }
1120 return true
1121 }
1122
1123 func cloneEvents(events []Event) []Event {
1124 out := make([]Event, len(events))
1125 copy(out, events)
1126 for i := range out {
1127 out[i].Payload = append(json.RawMessage(nil), out[i].Payload...)
1128 if out[i].PayloadRef != nil {
1129 ref := *out[i].PayloadRef
1130 out[i].PayloadRef = &ref
1131 }
1132 }
1133 return out
1134 }
1135
1136 func cloneCommit(commit Commit) Commit {
1137 commit.Events = cloneEvents(commit.Events)
1138 return commit
1139 }
1140
1141 func cloneCommits(commits []Commit) []Commit {
1142 out := make([]Commit, len(commits))
1143 for i := range commits {
1144 out[i] = cloneCommit(commits[i])
1145 }
1146 return out
1147 }
1148
1149 func randomID() string {
1150 var b [16]byte
1151 if _, err := rand.Read(b[:]); err != nil {
1152 return fmt.Sprintf("%d", time.Now().UnixNano())
1153 }
1154 return hex.EncodeToString(b[:])
1155 }
1156
1157 func errorString(err error) string {
1158 if err == nil {
1159 return ""
1160 }
1161 return err.Error()
1162 }
1163
1163 lines GO