返回 DeepSeek-Reasonix
session.go
根目录 / internal / session / session.go
1 package session
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "strings"
11 "sync"
12 "time"
13
14 "reasonix/internal/provider"
15 "reasonix/internal/sessioncontent"
16 "reasonix/internal/transcript"
17 )
18
19 // Session owns the live business state for one session identity: sequence
20 // allocation, the bounded accepted tail, compact operation identities, and the
21 // current projection. Durable history bodies and searchable operation details
22 // live behind Query; the physical handle owns bytes and the writer lease.
23 //
24 // A commit becomes observable here before it is durable. That is deliberate and
25 // mirrors DSH: accepting an event updates the projection and the UI, while the
26 // binding batches the write-behind and Flush marks a semantic checkpoint.
27 type Session struct {
28 transcript *transcript.Projection
29 mu sync.Mutex
30 id string
31 manifest Manifest
32 next uint64
33 commits []Commit
34 operations map[string]operationRecord
35 projection Projection
36 binding *PersistenceBinding
37 sealed bool
38 sealedError error
39 readOnly bool
40 // externalHistory means durable UI messages live in HistoryQuery rather
41 // than this runtime projection. Messages then contains only the accepted,
42 // not-yet-durable tail; ModelMessages remains the exact provider workset.
43 externalHistory bool
44 catalogPreview string
45 recentMessages []provider.Message
46 durableRecent []provider.Message
47 storageGeneration string
48 recovery *recoveryStore
49 // coldHandle backs a read-only session, which has no binding because it
50 // never enqueues or drains anything.
51 coldHandle SessionHandle
52 }
53
54 // PreparedBatch is a validated, self-contained commit payload. Every expensive
55 // or fallible step — payload copying, schema validation, turn identity
56 // derivation, and the operation hash — happens here, before the caller takes
57 // the activity commit gate. CommitPrepared then only assigns identity and
58 // extends the log.
59 type PreparedBatch struct {
60 sessionID string
61 writerGeneration uint64
62 operationID string
63 turnID string
64 events []Event
65 storedEvents []Event
66 hash string
67 reservation *queueReservation
68 prior *operationRecord
69 }
70
71 // OperationID reports the stable idempotency key of the prepared batch.
72 func (p PreparedBatch) OperationID() string { return p.operationID }
73
74 // Empty reports whether the batch carries no committable event.
75 func (p PreparedBatch) Empty() bool { return len(p.events) == 0 }
76
77 // Release returns queue capacity when a prepared batch loses its activity or
78 // CAS race before acceptance. It is safe after CommitPrepared consumes it.
79 func (p PreparedBatch) Release() { p.reservation.release() }
80
81 // newSession builds the live in-memory session over an already-open binding.
82 // commits is the durable prefix replayed by the handle; the projection is
83 // rebuilt from it so no business state is inherited from the physical layer.
84 func newSession(id string, manifest Manifest, commits []Commit, projection Projection, binding *PersistenceBinding) *Session {
85 operations := make(map[string]operationRecord, len(commits))
86 next := uint64(1)
87 for _, commit := range commits {
88 operations[commit.OperationID] = compactOperationRecord(commit)
89 next = commit.LastSequence() + 1
90 }
91 return &Session{
92 id: id, manifest: manifest, next: next,
93 operations: operations, projection: projection, binding: binding,
94 }
95 }
96
97 // ID returns the immutable session identity.
98 func (s *Session) ID() string {
99 if s == nil {
100 return ""
101 }
102 s.mu.Lock()
103 defer s.mu.Unlock()
104 return s.id
105 }
106
107 // Manifest returns the persistence manifest that describes this session.
108 func (s *Session) Manifest() Manifest {
109 if s == nil {
110 return Manifest{}
111 }
112 s.mu.Lock()
113 defer s.mu.Unlock()
114 manifest := s.manifest
115 if manifest.Source != nil {
116 source := *manifest.Source
117 manifest.Source = &source
118 }
119 return manifest
120 }
121
122 // EventSequence reports the last accepted sequence. Accepted events may still
123 // be waiting in the binding's write-behind queue.
124 func (s *Session) EventSequence() uint64 {
125 if s == nil {
126 return 0
127 }
128 s.mu.Lock()
129 defer s.mu.Unlock()
130 return s.next - 1
131 }
132
133 // PrepareBatch validates the logical batch and computes its idempotency digest
134 // without touching the commit lock. Callers must treat the result as immutable.
135 func (s *Session) PrepareBatch(operationID string, batch Batch) (PreparedBatch, error) {
136 return s.PrepareBatchContext(context.Background(), operationID, batch)
137 }
138
139 // PrepareBatchContext publishes large immutable payloads before the batch can
140 // enter the accepted sequence. It performs all disk I/O outside the session
141 // commit lock; CommitPrepared only revalidates identity and generation.
142 func (s *Session) PrepareBatchContext(ctx context.Context, operationID string, batch Batch) (PreparedBatch, error) {
143 if s == nil {
144 return PreparedBatch{}, fmt.Errorf("session: nil session")
145 }
146 operationID = strings.TrimSpace(operationID)
147 if operationID == "" {
148 operationID = strings.TrimSpace(batch.OperationID)
149 }
150 if operationID == "" || len(batch.Events) == 0 {
151 return PreparedBatch{}, fmt.Errorf("session: operation id and events are required")
152 }
153 turnID := strings.TrimSpace(batch.TurnID)
154 if turnID == "" && batchContains(batch.Events, "turn/start") {
155 turnID = deterministicID("turn\x00" + s.id + "\x00" + operationID)
156 }
157 events := cloneEvents(batch.Events)
158 for i := range events {
159 event := &events[i]
160 event.Kind = strings.TrimSpace(event.Kind)
161 if event.Kind == "message/retract" {
162 if event.Optional {
163 return PreparedBatch{}, fmt.Errorf("message/retract must be required")
164 }
165 event.Required = true
166 }
167 if event.Kind == "" {
168 return PreparedBatch{}, fmt.Errorf("session: events[%d].kind is required", i)
169 }
170 if !event.Optional && !ProjectionKinds[event.Kind] {
171 return PreparedBatch{}, fmt.Errorf("%w: unknown required event %q", ErrUnsupportedVersion, event.Kind)
172 }
173 // The durable sequence is assigned at commit time so a rejected batch
174 // never consumes one.
175 event.Sequence = 0
176 }
177 // The digest covers exactly what the caller supplied. Generated event ids
178 // are deliberately excluded so retrying the same logical batch is
179 // idempotent instead of reporting a spurious operation conflict.
180 hash, err := hashOperation(s.id, turnID, events)
181 if err != nil {
182 return PreparedBatch{}, err
183 }
184 prior, found, err := s.lookupOperation(operationID)
185 if err != nil {
186 return PreparedBatch{}, fmt.Errorf("session: lookup operation %q: %w", operationID, err)
187 }
188 if found && prior.hash != hash {
189 return PreparedBatch{}, fmt.Errorf("%w: %q", ErrOperationConflict, operationID)
190 }
191 for i := range events {
192 if events[i].ID == "" {
193 events[i].ID = randomID()
194 }
195 }
196 storedEvents := cloneEvents(events)
197 content := s.contentStore()
198 for i := range storedEvents {
199 if len(storedEvents[i].Payload) <= v4InlinePayloadBytes {
200 continue
201 }
202 if content == nil {
203 return PreparedBatch{}, errors.New("session: content store unavailable for large event payload")
204 }
205 ref, err := content.Put(ctx, bytes.NewReader(storedEvents[i].Payload), sessioncontent.Metadata{MediaType: "application/json"})
206 if err != nil {
207 return PreparedBatch{}, fmt.Errorf("prepare event %s content: %w", storedEvents[i].ID, err)
208 }
209 storedEvents[i].Payload = nil
210 storedEvents[i].PayloadRef = &ref
211 }
212 s.mu.Lock()
213 sessionID, writerGeneration, binding := s.id, s.manifest.WriterGeneration, s.binding
214 s.mu.Unlock()
215 if binding == nil {
216 return PreparedBatch{}, ErrReadOnly
217 }
218 reservation, err := binding.reserve(ctx, commitHotBytes(Commit{Events: storedEvents}))
219 if err != nil {
220 return PreparedBatch{}, err
221 }
222 prepared := PreparedBatch{sessionID: sessionID, writerGeneration: writerGeneration, operationID: operationID, turnID: turnID, events: events, storedEvents: storedEvents, hash: hash, reservation: reservation}
223 if found {
224 copy := prior
225 prepared.prior = &copy
226 }
227 return prepared, nil
228 }
229
230 func (s *Session) lookupOperation(operationID string) (operationRecord, bool, error) {
231 if s == nil {
232 return operationRecord{}, false, nil
233 }
234 s.mu.Lock()
235 if record, ok := s.operations[operationID]; ok {
236 s.mu.Unlock()
237 return record, true, nil
238 }
239 recovery := s.recovery
240 s.mu.Unlock()
241 return recovery.lookupOperation(operationID)
242 }
243
244 func (s *Session) contentStore() *sessioncontent.Store {
245 if s == nil || s.binding == nil {
246 return nil
247 }
248 store, _ := s.binding.handle.(*Store)
249 if store == nil {
250 return nil
251 }
252 return store.content
253 }
254
255 func (s *Session) ContentStore() *sessioncontent.Store { return s.contentStore() }
256
257 func (s *Session) StorageGeneration() string {
258 if s == nil {
259 return ""
260 }
261 s.mu.Lock()
262 defer s.mu.Unlock()
263 return s.storageGeneration
264 }
265
266 // CommitPrepared appends an already validated batch under one short memory
267 // lock. The persistence binding only receives an immutable batch into its
268 // write-behind queue, so this never performs file I/O and never blocks on a
269 // subscriber.
270 func (s *Session) CommitPrepared(prepared PreparedBatch) (Commit, error) {
271 return s.commitPrepared(prepared, nil)
272 }
273
274 func (s *Session) commitPrepared(prepared PreparedBatch, expectedTitleSequence *uint64) (Commit, error) {
275 defer prepared.Release()
276 if s == nil {
277 return Commit{}, fmt.Errorf("session: nil session")
278 }
279 if prepared.Empty() {
280 return Commit{}, fmt.Errorf("session: operation id and events are required")
281 }
282 s.mu.Lock()
283 if expectedTitleSequence != nil && s.projection.TitleSequence != *expectedTitleSequence {
284 s.mu.Unlock()
285 return Commit{}, ErrSessionTitleChanged
286 }
287 if prepared.sessionID != s.id || prepared.writerGeneration != s.manifest.WriterGeneration {
288 s.mu.Unlock()
289 return Commit{}, ErrStaleGeneration
290 }
291 if s.readOnly {
292 s.mu.Unlock()
293 return Commit{}, ErrReadOnly
294 }
295 if s.sealed {
296 err := s.sealedError
297 s.mu.Unlock()
298 if err == nil {
299 err = osClosedError()
300 }
301 return Commit{}, err
302 }
303 if prior, ok := s.operations[prepared.operationID]; ok {
304 if prior.hash != prepared.hash {
305 s.mu.Unlock()
306 return Commit{}, fmt.Errorf("%w: %q", ErrOperationConflict, prepared.operationID)
307 }
308 commit := prior.commit
309 commit.Events = cloneEvents(prepared.events)
310 for i := range commit.Events {
311 commit.Events[i].Sequence = commit.FirstSequence + uint64(i)
312 }
313 s.mu.Unlock()
314 return commit, nil
315 }
316 if prepared.prior != nil {
317 commit := prepared.prior.commit
318 commit.Events = cloneEvents(prepared.events)
319 for i := range commit.Events {
320 commit.Events[i].Sequence = commit.FirstSequence + uint64(i)
321 }
322 s.mu.Unlock()
323 return commit, nil
324 }
325 commit := Commit{
326 SchemaVersion: SchemaVersion, Codec: Codec, RecordType: "commit", ID: randomID(),
327 OperationID: prepared.operationID, OperationHash: prepared.hash, FirstSequence: s.next,
328 EventCount: len(prepared.events), TurnID: prepared.turnID,
329 WriterGeneration: s.manifest.WriterGeneration, CreatedAt: time.Now().UTC(),
330 // PreparedBatch already owns a private clone. Transfer it into the
331 // immutable accepted commit instead of copying every payload again.
332 Events: prepared.events,
333 }
334 for i := range commit.Events {
335 commit.Events[i].Sequence = commit.FirstSequence + uint64(i)
336 }
337 storedCommit := commit
338 storedCommit.Events = prepared.storedEvents
339 for i := range storedCommit.Events {
340 storedCommit.Events[i].Sequence = storedCommit.FirstSequence + uint64(i)
341 }
342 projection := cloneProjection(s.projection)
343 if err := applyProjectionCommit(&projection, commit); err != nil {
344 s.mu.Unlock()
345 return Commit{}, err
346 }
347 binding := s.binding
348 if binding == nil {
349 s.mu.Unlock()
350 return Commit{}, ErrReadOnly
351 }
352 // Session and the persistence queue form one in-memory acceptance boundary.
353 // accept performs no I/O or callbacks; after projection validation there are
354 // no remaining fallible state changes in the closure.
355 err := binding.accept(storedCommit, prepared.reservation, func() {
356 s.commits = append(s.commits, commit)
357 s.projection = projection
358 _ = applyRecentCommit(&s.recentMessages, commit)
359 s.next = commit.LastSequence() + 1
360 s.operations[prepared.operationID] = compactOperationRecord(commit)
361 s.acceptTranscriptCommit(commit)
362 })
363 s.mu.Unlock()
364 if err != nil {
365 return Commit{}, err
366 }
367 return cloneCommit(commit), nil
368 }
369
370 // AppendBatch is the convenience form used by callers that do not need to
371 // separate validation from the commit.
372 func (s *Session) AppendBatch(ctx context.Context, operationID string, events []Event) (Commit, error) {
373 return s.Append(ctx, Batch{OperationID: operationID, Events: events})
374 }
375
376 // Append validates and commits a logical batch in one step.
377 func (s *Session) Append(ctx context.Context, batch Batch) (Commit, error) {
378 if err := ctx.Err(); err != nil {
379 return Commit{}, err
380 }
381 prepared, err := s.PrepareBatchContext(ctx, batch.OperationID, batch)
382 if err != nil {
383 return Commit{}, err
384 }
385 return s.CommitPrepared(prepared)
386 }
387
388 // Snapshot returns the full live view, including message and turn history.
389 func (s *Session) Snapshot() Snapshot { return s.snapshot(true, true) }
390
391 // StateSnapshot omits history so progress notifications do not copy every
392 // message and completed turn on each activity update.
393 func (s *Session) StateSnapshot() Snapshot { return s.snapshot(false, false) }
394
395 // ExecutionSnapshot exposes the current provider projection and business
396 // state without materializing durable UI history. Controllers use it for
397 // turn/model decisions; UI history is obtained from Query.
398 func (s *Session) ExecutionSnapshot() Snapshot { return s.snapshot(false, true) }
399
400 func (s *Session) snapshot(includeHistory, includeModel bool) Snapshot {
401 if s == nil {
402 return Snapshot{PersistenceStatus: PersistenceFailed, PersistenceError: "nil session"}
403 }
404 s.mu.Lock()
405 projection := s.projection
406 sequence := s.next - 1
407 externalHistory := s.externalHistory
408 accepted := cloneCommits(s.commits)
409 var history eventPageReader
410 if s.binding != nil {
411 history = s.binding.handle
412 } else if s.coldHandle != nil {
413 history = s.coldHandle
414 }
415 s.mu.Unlock()
416 if includeHistory && externalHistory {
417 // Snapshot is the explicit full-history compatibility boundary. Service
418 // progress and Goal paths use StateSnapshot; paged clients use Query.
419 // Reconstructing here preserves existing callers without keeping a second
420 // durable UI transcript resident in every runtime.
421 if messages, err := materializeSnapshotMessages(history, accepted, sequence); err == nil {
422 projection.Messages = messages
423 }
424 }
425 if !includeHistory {
426 projection.Messages = nil
427 }
428 if !includeModel {
429 projection.ModelMessages, projection.Turns = nil, nil
430 }
431 snapshot := Snapshot{EventSequence: sequence, Projection: cloneProjection(projection)}
432 if s.binding != nil {
433 durable, status, detail := s.binding.progress()
434 snapshot.DurableSequence, snapshot.PersistenceStatus, snapshot.PersistenceError = durable, status, detail
435 } else {
436 snapshot.PersistenceStatus = PersistenceReady
437 }
438 // Nested provider metadata is immutable internally but Go cannot freeze
439 // returned slices. Detach it outside the commit lock before exposing it.
440 snapshot.Projection.Messages = detachMessages(snapshot.Projection.Messages)
441 snapshot.Projection.ModelMessages = detachMessages(snapshot.Projection.ModelMessages)
442 return snapshot
443 }
444
445 // CatalogMetadata returns the rebuildable list projection of this session.
446 func (s *Session) CatalogMetadata() catalogMetadata {
447 if s == nil {
448 return catalogMetadata{Version: catalogMetadataVersion, Codec: Codec}
449 }
450 s.mu.Lock()
451 defer s.mu.Unlock()
452 metadata := metadataFromProjection(s.manifest, s.next-1, s.projection)
453 return metadata
454 }
455
456 // DeriveMessages returns the model history projection.
457 func (s *Session) DeriveMessages() []provider.Message {
458 if s == nil {
459 return nil
460 }
461 s.mu.Lock()
462 messages := detachMessages(s.projection.ModelMessages)
463 s.mu.Unlock()
464 return messages
465 }
466
467 func (s *Session) cacheWeight() int64 {
468 if s == nil {
469 return 0
470 }
471 s.mu.Lock()
472 defer s.mu.Unlock()
473 weight := int64(64 << 10)
474 for _, message := range s.projection.ModelMessages {
475 weight += int64(len(message.ID) + len(message.Content) + len(message.RawContent) + len(message.ProviderContent) + len(message.ReasoningContent) + len(message.ReasoningSignature) + len(message.Original))
476 for _, image := range message.Images {
477 weight += int64(len(image))
478 }
479 for _, call := range message.ToolCalls {
480 weight += int64(len(call.ID) + len(call.Name) + len(call.Arguments) + len(call.Diff))
481 }
482 for _, item := range message.ResponsesItems {
483 weight += int64(len(item))
484 }
485 for _, block := range message.ThinkingBlocks {
486 encoded, _ := json.Marshal(block)
487 weight += int64(len(encoded))
488 }
489 }
490 weight += int64(len(s.projection.PlanState) + len(s.projection.GoalState))
491 return weight
492 }
493
494 // RecentSnapshot returns the bounded chat baseline without consulting the
495 // history locator or search index.
496 func (s *Session) RecentSnapshot() RecentSnapshot {
497 if s == nil {
498 return RecentSnapshot{}
499 }
500 durable := uint64(0)
501 if s.binding != nil {
502 durable = s.binding.durableSequence()
503 }
504 s.mu.Lock()
505 messages := detachMessages(s.durableRecent)
506 sessionDir := ""
507 if s.binding != nil {
508 sessionDir = s.binding.dir
509 }
510 snapshot := RecentSnapshot{
511 Version: recoveryFormatVersion, SessionID: s.id, StorageGeneration: s.storageGeneration,
512 DurableSequence: durable,
513 Title: s.projection.Title, ModelRef: s.projection.ModelRef, ModelIdentity: s.projection.ModelIdentity,
514 TotalTurns: visibleBoundaryCount(s.projection, false),
515 }
516 s.mu.Unlock()
517 if sessionDir != "" {
518 snapshot.Entries, _ = buildRecentEntries(context.Background(), sessionDir, messages, durable, snapshot.TotalTurns)
519 }
520 return snapshot
521 }
522
523 func materializeSnapshotMessages(history eventPageReader, accepted []Commit, acceptedSequence uint64) ([]provider.Message, error) {
524 projection, _ := Project(nil)
525 var cursor uint64
526 if history != nil {
527 for {
528 startCursor := cursor
529 page, err := history.Read(context.Background(), cursor, 1000)
530 if err != nil {
531 return nil, err
532 }
533 for _, commit := range page.Commits {
534 if commit.LastSequence() > acceptedSequence {
535 break
536 }
537 if err := applyProjectionCommit(&projection, commit); err != nil {
538 return nil, err
539 }
540 // Only UI messages are requested at this compatibility boundary.
541 // Clearing the provider projection after each commit prevents a
542 // second cumulative model-history allocation during reconstruction.
543 projection.ModelMessages = nil
544 cursor = commit.LastSequence()
545 }
546 if !page.Truncated || cursor >= acceptedSequence {
547 break
548 }
549 if cursor <= startCursor {
550 return nil, fmt.Errorf("%w: full snapshot cursor did not advance", ErrDamagedStore)
551 }
552 }
553 }
554 for _, commit := range accepted {
555 if commit.LastSequence() <= cursor || commit.FirstSequence > acceptedSequence {
556 continue
557 }
558 if err := applyProjectionCommit(&projection, commit); err != nil {
559 return nil, err
560 }
561 projection.ModelMessages = nil
562 cursor = commit.LastSequence()
563 }
564 return projection.Messages, nil
565 }
566
567 // externalizeDurableHistory switches a Service-owned runtime to the bounded
568 // history model. It is intentionally not used by the low-level Store API,
569 // whose compatibility callers still request a complete projection.
570 func (s *Session) externalizeDurableHistory() {
571 if s == nil {
572 return
573 }
574 s.mu.Lock()
575 defer s.mu.Unlock()
576 if s.externalHistory {
577 return
578 }
579 for _, message := range s.projection.Messages {
580 if s.catalogPreview == "" {
581 s.catalogPreview = catalogMessagePreview(message)
582 }
583 }
584 s.projection.Messages = nil
585 s.externalHistory = true
586 }
587
588 // AcceptedPage returns the live accepted prefix, including events that have not
589 // crossed a durability checkpoint yet. SessionHandle.Read on the physical layer
590 // continues to expose only durable records.
591 func (s *Session) AcceptedPage(ctx context.Context, offset uint64, limit int) (EventPage, error) {
592 if err := ctx.Err(); err != nil {
593 return EventPage{}, err
594 }
595 if limit == 0 {
596 limit = 100
597 }
598 if limit < 1 || limit > 1000 {
599 return EventPage{}, fmt.Errorf("session: read limit must be 1..1000 commits")
600 }
601 s.mu.Lock()
602 tail := cloneCommits(s.commits)
603 var handle SessionHandle
604 if s.binding != nil {
605 handle = s.binding.handle
606 } else {
607 handle = s.coldHandle
608 }
609 s.mu.Unlock()
610 page := EventPage{Commits: []Commit{}}
611 if handle != nil {
612 var err error
613 page, err = handle.Read(ctx, offset, limit)
614 if err != nil {
615 return EventPage{}, err
616 }
617 if page.Truncated || len(page.Commits) == limit {
618 return page, nil
619 }
620 }
621 for _, commit := range tail {
622 if commit.LastSequence() <= offset {
623 continue
624 }
625 if len(page.Commits) > 0 && commit.LastSequence() <= page.Commits[len(page.Commits)-1].LastSequence() {
626 continue
627 }
628 if len(page.Commits) == limit {
629 page.Truncated = true
630 break
631 }
632 page.Commits = append(page.Commits, cloneCommit(commit))
633 page.Next = commit.LastSequence()
634 }
635 return page, nil
636 }
637
638 // Flush drains the write-behind queue and returns the durable sequence.
639 func (s *Session) Flush(ctx context.Context) (DurableReceipt, error) {
640 if s == nil || s.binding == nil {
641 return DurableReceipt{}, ErrReadOnly
642 }
643 return s.binding.Flush(ctx)
644 }
645
646 // FlushThrough waits only for the captured accepted prefix.
647 func (s *Session) FlushThrough(ctx context.Context, through uint64) (DurableReceipt, error) {
648 if s == nil || s.binding == nil {
649 return DurableReceipt{}, ErrReadOnly
650 }
651 if through > s.EventSequence() {
652 return DurableReceipt{}, fmt.Errorf("session: watermark exceeds accepted sequence")
653 }
654 return s.binding.FlushThrough(ctx, through)
655 }
656
657 // Read exposes the durable prefix through a paged read. Events accepted but not
658 // yet checkpointed are visible through AcceptedPage instead.
659 func (s *Session) Read(ctx context.Context, offset uint64, limit int) (EventPage, error) {
660 handle := s.Handle()
661 if handle == nil {
662 return EventPage{}, os.ErrClosed
663 }
664 return handle.Read(ctx, offset, limit)
665 }
666
667 // Close releases persistence ownership for this session. It is idempotent and
668 // uncancellable in effect: every caller observes the same close result.
669 func (s *Session) Close(ctx context.Context) error { return s.close(ctx) }
670
671 // Sync forces the physical log to stable storage without draining new work.
672 func (s *Session) Sync(ctx context.Context) (DurableReceipt, error) {
673 handle := s.Handle()
674 if handle == nil {
675 return DurableReceipt{}, ErrReadOnly
676 }
677 return handle.Sync(ctx)
678 }
679
680 // newReadSession builds a cold session over a read-only handle. It replays
681 // nothing: cold callers consume the durable prefix through paged Read, which is
682 // what keeps catalog and history queries independent of log length.
683 func newReadSession(handle SessionHandle) *Session {
684 manifest := handle.Manifest()
685 return &Session{
686 id: manifest.SessionID, manifest: manifest, next: 1,
687 operations: map[string]operationRecord{}, readOnly: true,
688 coldHandle: handle,
689 }
690 }
691
692 // Handle exposes the physical persistence handle. Callers must not treat it as
693 // a business-state owner: reads see only the durable prefix.
694 func (s *Session) Handle() SessionHandle {
695 if s == nil {
696 return nil
697 }
698 s.mu.Lock()
699 defer s.mu.Unlock()
700 if s.binding == nil {
701 return s.coldHandle
702 }
703 return s.binding.handle
704 }
705
706 // WritableHandle exposes the leased physical handle for fork, export, and
707 // recovery operations that are defined in terms of durable bytes.
708 func (s *Session) WritableHandle() WritableSessionHandle {
709 if s == nil {
710 return nil
711 }
712 s.mu.Lock()
713 defer s.mu.Unlock()
714 if s.binding == nil {
715 return nil
716 }
717 handle, _ := s.binding.handle.(WritableSessionHandle)
718 return handle
719 }
720
721 // seal stops accepting new commits. It is the admission boundary that Runtime
722 // close establishes before the binding is drained.
723 func (s *Session) seal(err error) {
724 if s == nil {
725 return
726 }
727 s.mu.Lock()
728 s.sealed = true
729 s.sealedError = err
730 binding := s.binding
731 s.mu.Unlock()
732 if binding != nil {
733 binding.stopAccepting()
734 }
735 }
736
737 // close seals the session, drains the binding, and closes the physical handle.
738 // It is uncancellable and idempotent: every caller observes the same result.
739 func (s *Session) close(ctx context.Context) error {
740 if s == nil {
741 return nil
742 }
743 s.seal(osClosedError())
744 s.mu.Lock()
745 binding, cold := s.binding, s.coldHandle
746 s.mu.Unlock()
747 if binding != nil {
748 return binding.Close(ctx)
749 }
750 if cold != nil {
751 // A cold session holds no binding, so its handle is released directly.
752 // Leaving it open would leak the read handle for every catalog scan.
753 return cold.Close(ctx)
754 }
755 return nil
756 }
757
757 lines GO