| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "reasonix/internal/session" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/transcript" |
| 11 | "reasonix/internal/turnevent" |
| 12 | ) |
| 13 | |
| 14 | var ErrTranscriptProjectionUnavailable = errors.New("transcript projection is unavailable") |
| 15 | |
| 16 | type TranscriptReplayRequest struct { |
| 17 | Identity transcript.Identity `json:"identity"` |
| 18 | After uint64 `json:"after"` |
| 19 | } |
| 20 | |
| 21 | type TranscriptReplay struct { |
| 22 | transcript.Boundary |
| 23 | turnevent.ReplayView |
| 24 | } |
| 25 | |
| 26 | type TranscriptProjectionAPI interface { |
| 27 | TranscriptSnapshot(transcript.PageRequest) (transcript.Snapshot, error) |
| 28 | TranscriptContent(transcript.ContentRequest) (transcript.ContentChunk, error) |
| 29 | TranscriptReplay(TranscriptReplayRequest) (TranscriptReplay, error) |
| 30 | } |
| 31 | |
| 32 | var _ TranscriptProjectionAPI = (*Controller)(nil) |
| 33 | |
| 34 | type TranscriptFollowAPI interface { |
| 35 | TranscriptFollow(context.Context, transcript.FollowRequest) (TranscriptFollowResponse, error) |
| 36 | } |
| 37 | |
| 38 | type TranscriptFollowResponse struct { |
| 39 | transcript.FollowResponse |
| 40 | History *session.HistoryWindowPage `json:"history,omitempty"` |
| 41 | } |
| 42 | |
| 43 | func (c *Controller) TranscriptFollow(ctx context.Context, req transcript.FollowRequest) (TranscriptFollowResponse, error) { |
| 44 | service, runtime, exclusive := c.v3Binding() |
| 45 | if !exclusive || runtime == nil { |
| 46 | return TranscriptFollowResponse{}, ErrTranscriptProjectionUnavailable |
| 47 | } |
| 48 | view, err := runtime.FollowTranscript(ctx, req) |
| 49 | out := TranscriptFollowResponse{FollowResponse: view} |
| 50 | if err != nil || view.Snapshot == nil { |
| 51 | return out, err |
| 52 | } |
| 53 | // Make the frozen cut pageable even when accepted batches exceed the tail. |
| 54 | // The registered subscription queues concurrent commits during flush/read; |
| 55 | // no publisher lock is held. |
| 56 | cut := view.Snapshot.CoveredThroughSeq |
| 57 | if view.Snapshot.DurableSeq < cut { |
| 58 | receipt, flushErr := runtime.Session().Flush(ctx) |
| 59 | if flushErr != nil || receipt.DurableSequence < cut { |
| 60 | _, _ = runtime.Transcript().Follow(context.Background(), transcript.FollowRequest{Subscription: view.Subscription, Close: true}) |
| 61 | if flushErr == nil { |
| 62 | flushErr = errors.New("transcript snapshot persistence is incomplete") |
| 63 | } |
| 64 | return out, flushErr |
| 65 | } |
| 66 | // Publish the new watermark in order, after already queued frames. Do |
| 67 | // not attach it to the older frozen view and then replay older watermarks. |
| 68 | runtime.Transcript().SetDurableSequence(receipt.DurableSequence) |
| 69 | } |
| 70 | var page session.HistoryWindowPage |
| 71 | for { |
| 72 | page, err = service.Query().ReadHistoryWindow(ctx, runtime.Ref(), session.HistoryWindowRequest{Anchor: "newest", Limit: 32, SnapshotSequence: &cut}) |
| 73 | if err != nil || page.Status != "preparing" { |
| 74 | break |
| 75 | } |
| 76 | timer := time.NewTimer(10 * time.Millisecond) |
| 77 | select { |
| 78 | case <-ctx.Done(): |
| 79 | timer.Stop() |
| 80 | err = ctx.Err() |
| 81 | case <-timer.C: |
| 82 | } |
| 83 | if err != nil { |
| 84 | break |
| 85 | } |
| 86 | } |
| 87 | if err != nil { |
| 88 | _, _ = runtime.Transcript().Follow(context.Background(), transcript.FollowRequest{Subscription: view.Subscription, Close: true}) |
| 89 | } |
| 90 | out.History = &page |
| 91 | return out, err |
| 92 | } |
| 93 | |
| 94 | // TranscriptOutlineAPI is an optional capability beside TranscriptProjectionAPI. |
| 95 | // It is deliberately separate so an existing controller implementation keeps |
| 96 | // compiling and a client can negotiate the outline independently of the body. |
| 97 | type TranscriptOutlineAPI interface { |
| 98 | TranscriptOutline(transcript.OutlineRequest) (transcript.OutlinePage, error) |
| 99 | } |
| 100 | |
| 101 | var _ TranscriptOutlineAPI = (*Controller)(nil) |
| 102 | |
| 103 | // SetTurnSubmissionID is called under the transport's admission boundary. |
| 104 | func (c *Controller) SetTurnSubmissionID(submissionID string) { |
| 105 | if ledger := c.turnEventLedger(); ledger != nil { |
| 106 | ledger.SetSubmissionID(submissionID) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // BindTranscriptRuntimeEpoch runs at the surface's idle runtime publication |
| 111 | // boundary. It takes only display/ledger leaf locks and invokes no callbacks. |
| 112 | func (c *Controller) BindTranscriptRuntimeEpoch(epoch string) { |
| 113 | c.turnEvents.commitMu.Lock() |
| 114 | defer c.turnEvents.commitMu.Unlock() |
| 115 | ledger := c.turnEventLedger() |
| 116 | if ledger == nil || ledger.ActiveTurnID() != "" { |
| 117 | return |
| 118 | } |
| 119 | ledger.SetRuntimeEpoch(epoch) |
| 120 | c.turnEvents.mu.RLock() |
| 121 | p := c.turnEvents.projection |
| 122 | c.turnEvents.mu.RUnlock() |
| 123 | if p != nil { |
| 124 | p.SetRuntimeEpoch(epoch) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | func (c *Controller) transcriptProjection() (*transcript.Projection, error) { |
| 129 | if _, runtime, exclusive := c.v3Binding(); exclusive && runtime != nil { |
| 130 | return runtime.Transcript(), nil |
| 131 | } |
| 132 | c.turnEvents.mu.RLock() |
| 133 | defer c.turnEvents.mu.RUnlock() |
| 134 | if c.turnEvents.err != nil { |
| 135 | return nil, errors.Join(ErrTranscriptProjectionUnavailable, c.turnEvents.err) |
| 136 | } |
| 137 | if c.turnEvents.projectionErr != nil { |
| 138 | return nil, errors.Join(ErrTranscriptProjectionUnavailable, c.turnEvents.projectionErr) |
| 139 | } |
| 140 | if c.turnEvents.projection == nil { |
| 141 | return nil, ErrTranscriptProjectionUnavailable |
| 142 | } |
| 143 | return c.turnEvents.projection, nil |
| 144 | } |
| 145 | |
| 146 | func (c *Controller) TranscriptSnapshot(req transcript.PageRequest) (transcript.Snapshot, error) { |
| 147 | p, err := c.transcriptProjection() |
| 148 | if err != nil { |
| 149 | return transcript.Snapshot{}, err |
| 150 | } |
| 151 | return p.Snapshot(req) |
| 152 | } |
| 153 | |
| 154 | // TranscriptOutline pages the complete turn index of one snapshot. It reads the |
| 155 | // same projection the body pages do, so both describe one immutable cut. |
| 156 | func (c *Controller) TranscriptOutline(req transcript.OutlineRequest) (transcript.OutlinePage, error) { |
| 157 | p, err := c.transcriptProjection() |
| 158 | if err != nil { |
| 159 | return transcript.OutlinePage{}, err |
| 160 | } |
| 161 | return p.Outline(req) |
| 162 | } |
| 163 | |
| 164 | func (c *Controller) TranscriptContent(req transcript.ContentRequest) (transcript.ContentChunk, error) { |
| 165 | p, err := c.transcriptProjection() |
| 166 | if err != nil { |
| 167 | return transcript.ContentChunk{}, err |
| 168 | } |
| 169 | return p.Content(req) |
| 170 | } |
| 171 | |
| 172 | func (c *Controller) TranscriptReplay(req TranscriptReplayRequest) (TranscriptReplay, error) { |
| 173 | replay, err := c.transcriptReplay(req) |
| 174 | if err != nil { |
| 175 | return replay, err |
| 176 | } |
| 177 | // The ledger's soft budget permits an oversized first event for progress. |
| 178 | // Modern clients can obtain that data from the bounded snapshot/content |
| 179 | // protocol instead. Never acknowledge a suffix the client cannot receive. |
| 180 | encoded, err := json.Marshal(replay) |
| 181 | if err != nil { |
| 182 | return TranscriptReplay{}, err |
| 183 | } |
| 184 | if len(encoded)+1 > transcript.MaxResponseBytes { |
| 185 | replay.Events = []turnevent.Envelope{} |
| 186 | replay.ResetRequired, replay.HasMore = true, false |
| 187 | replay.NextAfterSequence = req.After |
| 188 | } |
| 189 | return replay, nil |
| 190 | } |
| 191 | |
| 192 | func (c *Controller) transcriptReplay(req TranscriptReplayRequest) (TranscriptReplay, error) { |
| 193 | if _, runtime, exclusive := c.v3Binding(); exclusive && runtime != nil { |
| 194 | return TranscriptReplay{}, errors.New("transcript v2 requires Follow; legacy replay is unavailable") |
| 195 | } |
| 196 | c.turnEvents.commitMu.Lock() |
| 197 | defer c.turnEvents.commitMu.Unlock() |
| 198 | p, err := c.transcriptProjection() |
| 199 | if err != nil { |
| 200 | return TranscriptReplay{}, err |
| 201 | } |
| 202 | boundary := p.Boundary() |
| 203 | if boundary.Identity != req.Identity { |
| 204 | if boundary.Identity.SessionID != req.Identity.SessionID { |
| 205 | return TranscriptReplay{}, errors.New("transcript replay session mismatch") |
| 206 | } |
| 207 | return TranscriptReplay{Boundary: boundary, ReplayView: turnevent.ReplayView{Events: []turnevent.Envelope{}, ResetRequired: true, LatestSequence: boundary.CoveredThroughSeq}}, nil |
| 208 | } |
| 209 | view, err := c.TurnEventReplay(req.After) |
| 210 | return TranscriptReplay{Boundary: boundary, ReplayView: view}, err |
| 211 | } |
| 212 |