@@ -52,13 +52,20 @@ type scoredEpisode struct {
5252// Thread-safety: all exported methods hold vi.mu as appropriate.
5353// Per-directory singleton: see sharedEpisodeIndex.
5454type episodeVectorIndex struct {
55- mu sync.RWMutex
56- store * vector.Store
57- emb textEmbedder
58- dir string
59- ready bool
60- dirty bool
61- failedAt time.Time // last failed rebuild; zero = never failed
55+ mu sync.RWMutex
56+ store * vector.Store
57+ emb textEmbedder
58+ // newEmb builds a FRESH embedder instance for a rebuild. Rebuilds embed on
59+ // a detached instance off-lock (see ensureFresh), so the live (emb, store)
60+ // pair stays valid and consistent until the atomic swap — a query never
61+ // embeds against a half-refitted embedder.
62+ newEmb func () textEmbedder
63+ dir string
64+ ready bool
65+ dirty bool
66+ rebuilding bool // single-flight guard: a rebuild is embedding off-lock
67+ dirtySeq uint64 // bumped by markDirty; reconciled after an off-lock rebuild
68+ failedAt time.Time // last failed rebuild; zero = never failed
6269}
6370
6471// indexMeta is the persisted embedding-space identity (episodeIndexMetaFile).
@@ -104,7 +111,7 @@ func sharedEpisodeIndex(dir string, newEmb func() textEmbedder) *episodeVectorIn
104111 if vi , ok := epIdxes [key ]; ok {
105112 return vi
106113 }
107- vi := & episodeVectorIndex {dir : abs , emb : emb }
114+ vi := & episodeVectorIndex {dir : abs , emb : emb , newEmb : newEmb }
108115 epIdxes [key ] = vi
109116 return vi
110117}
@@ -117,6 +124,7 @@ func sharedEpisodeIndex(dir string, newEmb func() textEmbedder) *episodeVectorIn
117124func (vi * episodeVectorIndex ) markDirty () {
118125 vi .mu .Lock ()
119126 vi .dirty = true
127+ vi .dirtySeq ++
120128 vi .failedAt = time.Time {}
121129 vi .mu .Unlock ()
122130}
@@ -150,34 +158,78 @@ func (vi *episodeVectorIndex) search(query string, k int) []scoredEpisode {
150158
151159// ensureFresh loads or rebuilds the index as needed. Must NOT be called while
152160// holding vi.mu (it acquires it internally).
161+ //
162+ // The expensive part of a rebuild — fitting the embedder and embedding the
163+ // whole corpus, which is a blocking network call for the HTTP backend — runs
164+ // OFF the lock on a fresh embedder instance, then the result is swapped in
165+ // atomically under the lock. This keeps a slow embedding backend from
166+ // serializing every concurrent recall behind one rebuild (search runs per
167+ // turn; under `odek serve` all connections funnel through this per-dir
168+ // singleton). A single-flight guard (rebuilding) collapses a thundering herd
169+ // of concurrent rebuilds into one, and a dirty-sequence check folds in any
170+ // episode write that lands mid-rebuild.
153171func (vi * episodeVectorIndex ) ensureFresh () {
154172 vi .mu .RLock ()
155- if vi .ready && ! vi .dirty {
156- vi .mu .RUnlock ()
173+ ready := vi .ready && ! vi .dirty
174+ vi .mu .RUnlock ()
175+ if ready {
157176 return
158177 }
159- vi .mu .RUnlock ()
160178
161179 vi .mu .Lock ()
162- defer vi .mu .Unlock ()
163180 if vi .ready && ! vi .dirty {
164- return // double-checked
181+ vi .mu .Unlock ()
182+ return
165183 }
166184 // Back off after a failed rebuild so a down embedding backend is not
167185 // re-hit on every loop turn (search runs per turn).
168186 if ! vi .failedAt .IsZero () && time .Since (vi .failedAt ) < rebuildRetryInterval {
187+ vi .mu .Unlock ()
169188 return
170189 }
171-
172- // Cold start without a pending write: try the persisted state first.
190+ // Single-flight: if another goroutine is already rebuilding off-lock, serve
191+ // the current state for this turn rather than launch a duplicate (and a
192+ // duplicate batch embed) — the in-flight rebuild will publish shortly.
193+ if vi .rebuilding {
194+ vi .mu .Unlock ()
195+ return
196+ }
197+ // Cold start without a pending write: try the persisted state first
198+ // (disk-only, fast — fine to do under the lock).
173199 if ! vi .ready && ! vi .dirty {
174200 if vi .tryLoadLocked () {
201+ vi .mu .Unlock ()
175202 return
176203 }
177204 }
178- // Either cold-start without usable persisted state, or dirty after a
179- // write — full rebuild.
180- vi .rebuildLocked ()
205+ // Rebuild needed. Snapshot the dirty sequence, take a fresh embedder, and
206+ // release the lock before the network-bound embedding work.
207+ vi .rebuilding = true
208+ seq := vi .dirtySeq
209+ emb := vi .newEmb ()
210+ vi .mu .Unlock ()
211+
212+ store := buildEpisodeStore (vi .readAllSummaries (), emb )
213+
214+ vi .mu .Lock ()
215+ defer vi .mu .Unlock ()
216+ vi .rebuilding = false
217+ if store == nil {
218+ // Embedding failed (e.g. backend down) — keep the previous index, if
219+ // any, serving and start the retry cool-down.
220+ vi .failedAt = time .Now ()
221+ return
222+ }
223+ vi .store = store
224+ vi .emb = emb
225+ vi .ready = true
226+ vi .failedAt = time.Time {}
227+ // Only clear dirty if no write landed while we were rebuilding off-lock;
228+ // otherwise leave it set so the next search rebuilds with the newest data.
229+ if vi .dirtySeq == seq {
230+ vi .dirty = false
231+ }
232+ vi .saveLocked ()
181233}
182234
183235// tryLoadLocked attempts to load persisted state. Returns true on success.
@@ -219,26 +271,23 @@ func legacyRPFingerprint() string {
219271 return newRPTextEmbedder (episodeVectorDim ).fingerprint ()
220272}
221273
222- // rebuildLocked reads all episode summaries from disk, fits the embedder on
223- // the full corpus, and persists the result. On embedding failure (e.g. a
224- // remote backend being down) the previous index — if any — is kept serving
225- // and a retry cool-down starts. Caller must hold vi.mu (write lock).
226- func (vi * episodeVectorIndex ) rebuildLocked () {
227- texts := vi .readAllSummaries ()
228-
274+ // buildEpisodeStore fits emb on the full corpus and returns a populated vector
275+ // store, or nil on any embedding failure (e.g. a remote backend being down) so
276+ // the caller can keep the previous index serving. It takes NO lock and touches
277+ // no shared index state — it operates entirely on its arguments and a fresh
278+ // embedder, which is what lets ensureFresh run it off vi.mu.
279+ func buildEpisodeStore (texts []idText , emb textEmbedder ) * vector.Store {
229280 corpus := make ([]string , len (texts ))
230281 for i , t := range texts {
231282 corpus [i ] = t .text
232283 }
233284
234- if err := vi .emb .fit (corpus ); err != nil {
235- vi .failedAt = time .Now ()
236- return
285+ if err := emb .fit (corpus ); err != nil {
286+ return nil
237287 }
238- vecs , err := vi . emb .embedAll (corpus )
288+ vecs , err := emb .embedAll (corpus )
239289 if err != nil {
240- vi .failedAt = time .Now ()
241- return
290+ return nil
242291 }
243292
244293 store := vector .NewStore (vector .CosineDistance )
@@ -248,12 +297,7 @@ func (vi *episodeVectorIndex) rebuildLocked() {
248297 }
249298 store .Add (t .id , vecs [i ])
250299 }
251-
252- vi .store = store
253- vi .ready = true
254- vi .dirty = false
255- vi .failedAt = time.Time {}
256- vi .saveLocked ()
300+ return store
257301}
258302
259303type idText struct {
0 commit comments