oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package memoria
2
3import (
4 "context"
5 "errors"
6 "log/slog"
7 "sync"
8 "time"
9
10 "gorm.io/gorm"
11
12 "amadan.net/oficina/memoria/internal/memoria/chunk"
13 "amadan.net/oficina/memoria/internal/memoria/notion"
14 "amadan.net/oficina/memoria/internal/memoria/seal"
15)
16
17// SourceClient is what a sync needs from a source. An interface so the
18// loop can be driven against a fake without the app package importing an
19// HTTP client — which the network gate forbids it anyway.
20type SourceClient interface {
21 // Roots returns the true roots and, separately, the orphans —
22 // results whose parent search did not return. See notion.Roots.
23 Roots(ctx context.Context) (roots, orphans []notion.Ref, err error)
24 Fetch(ctx context.Context, ref notion.Ref) (*notion.Content, error)
25}
26
27// Syncer runs imports.
28type Syncer struct {
29 store *Store
30 logger *slog.Logger
31 // dial builds a client from a token. Swapped in tests.
32 dial func(token string) (SourceClient, error)
33}
34
35// NewSyncer wires a syncer against the real Notion API.
36func NewSyncer(store *Store, logger *slog.Logger) *Syncer {
37 return &Syncer{store: store, logger: logger, dial: func(token string) (SourceClient, error) {
38 return notion.New(token)
39 }}
40}
41
42// Run imports everything reachable from the connector's roots.
43//
44// ONE PASS, not the two phases an earlier design specified. With
45// traversal, enumeration and extraction are the same API call — fetching
46// a page returns its body AND the child pages beneath it — so separating
47// them would double every request against a limit of three per second.
48// The ledger is still written per page, so the ingest work already done
49// survives an interruption; what a resumed run repeats is the traversal,
50// because a page's children cannot be discovered without asking for them
51// and a child's own edits do not change its parent's edit time.
52//
53// Errors divide in two, and the division is load-bearing:
54//
55// - A traversal failure below a page (rate limit exhausted, a broken
56// cursor, an unreadable body) is FATAL. It means the pages beneath
57// that point were never enumerated, and a run that continued would
58// report "done" while missing a whole subtree — an absence produced
59// by a failure, which is the shape this codebase ranks worst.
60// - A single page that has gone (404 between enumeration and fetch) is
61// recorded against that page and the run carries on. One placeholder
62// must not block an import, but it must not vanish either.
63func (s *Syncer) Run(ctx context.Context, connectorID, runID int64) {
64 token, err := s.store.ConnectorToken(ctx, "notion")
65 if err != nil {
66 s.fail(ctx, runID, "unauthorized")
67 return
68 }
69 client, err := s.dial(token)
70 if err != nil {
71 s.fail(ctx, runID, "unauthorized")
72 return
73 }
74 s.RunWith(ctx, client, connectorID, runID)
75}
76
77// runStats is the live count of what a run has done.
78//
79// Guarded, because the heartbeat goroutine reads it while the walk
80// writes it — and it is read for a reason: an earlier version wrote the
81// counters only at the very end, so a thirty-minute import rendered
82// "0 so far" for its whole duration. A count maintained nowhere and
83// displayed as zero is this codebase's second broken rule, on the status
84// page written to honour it.
85type runStats struct {
86 mu sync.Mutex
87 fetched, skipped, empty, failed, unread, seen int
88}
89
90func (r *runStats) add(f func(*runStats)) {
91 r.mu.Lock()
92 defer r.mu.Unlock()
93 f(r)
94}
95
96func (r *runStats) snapshot() map[string]any {
97 r.mu.Lock()
98 defer r.mu.Unlock()
99 return map[string]any{
100 "fetched": r.fetched, "skipped": r.skipped, "empty": r.empty,
101 "failed_pages": r.failed, "unread_blocks": r.unread,
102 }
103}
104
105// beatEvery is how often a run says it is still alive AND publishes its
106// counters. A DURATION, not a page count: pages take wildly different
107// times — one request per nested block, each paced at 350ms, plus an
108// honoured Retry-After — so "every 25 pages" could exceed the staleness
109// window while the run was perfectly healthy. StartRun would then reclaim
110// a LIVE run and start a second goroutine on the same ledger. Found by a
111// code review.
112const beatEvery = 20 * time.Second
113
114// startBeat publishes the heartbeat and the counters until stopped.
115func (s *Syncer) startBeat(ctx context.Context, runID int64, st *runStats) func() {
116 done := make(chan struct{})
117 go func() {
118 t := time.NewTicker(beatEvery)
119 defer t.Stop()
120 for {
121 select {
122 case <-done:
123 return
124 case <-t.C:
125 patch := st.snapshot()
126 patch["heartbeat"] = time.Now().UTC()
127 if err := s.store.UpdateRun(ctx, runID, patch); err != nil {
128 s.logger.Error("sync heartbeat", "run", runID, "err", err)
129 }
130 }
131 }
132 }()
133 return func() { close(done) }
134}
135
136// RunWith is Run against a supplied client.
137func (s *Syncer) RunWith(ctx context.Context, client SourceClient, connectorID, runID int64) {
138 roots, orphans, err := client.Roots(ctx)
139 if err != nil {
140 s.fail(ctx, runID, string(notion.CodeOf(err)))
141 return
142 }
143 if err := s.store.UpdateRun(ctx, runID, map[string]any{
144 "roots": len(roots), "state": RunFetching,
145 }); err != nil {
146 s.fail(ctx, runID, "database")
147 return
148 }
149
150 st := &runStats{}
151 stop := s.startBeat(ctx, runID, st)
152 defer stop()
153
154 visited := map[string]bool{}
155
156 // The real tree first, so every page that HAS an ancestor is reached
157 // through it and inherits the right path.
158 if !s.walk(ctx, client, connectorID, runID, roots, visited, st) {
159 return
160 }
161 // Then the orphans — results whose parent search did not return, most
162 // often a page nested inside a toggle, whose parent is a block. They
163 // go last and skip anything already visited, so an orphan can never
164 // claim a page the traversal would have given real ancestry to.
165 if !s.walk(ctx, client, connectorID, runID, orphans, visited, st) {
166 return
167 }
168
169 now := time.Now().UTC()
170 patch := st.snapshot()
171 st.mu.Lock()
172 patch["total"] = st.seen
173 st.mu.Unlock()
174 patch["state"] = RunDone
175 patch["finished_at"] = now
176 patch["heartbeat"] = now
177 if err := s.store.UpdateRun(ctx, runID, patch); err != nil {
178 s.logger.Error("sync finish", "run", runID, "err", err)
179 }
180 // Counts only. No title, no path, no page id — AGENTS.md's rule, and
181 // the reason this line carries nothing that came from the workspace.
182 s.logger.Info("sync finished", "run", runID, "pages", patch["total"],
183 "fetched", patch["fetched"], "skipped", patch["skipped"],
184 "empty", patch["empty"], "failed", patch["failed_pages"],
185 "unread_blocks", patch["unread_blocks"])
186}
187
188// walk breadth-first from seeds. It reports false when the run has
189// failed, having already marked it.
190func (s *Syncer) walk(ctx context.Context, client SourceClient, connectorID, runID int64,
191 seeds []notion.Ref, visited map[string]bool, st *runStats) bool {
192
193 queue := append([]notion.Ref{}, seeds...)
194 for len(queue) > 0 {
195 ref := queue[0]
196 queue = queue[1:]
197 if visited[ref.ID] {
198 continue
199 }
200 visited[ref.ID] = true
201 st.add(func(r *runStats) { r.seen++ })
202
203 // A data source is a CONTAINER, not a document. It gets no ledger
204 // row: one there would sit "pending" forever, be counted in the
205 // total, and be counted as "already up to date" on every later
206 // run — so the page said "1 of 2 imported" permanently.
207 container := ref.Kind == notion.KindDataSource
208 if !container {
209 // Record the coordinate BEFORE fetching, so a page that then
210 // fails to read has somewhere to be named rather than being
211 // absent.
212 if err := s.store.SeeSourcePage(ctx, connectorID, ref.ID, ref.Title, ref.Path, ref.EditedTime); err != nil {
213 s.logger.Error("sync ledger write", "run", runID, "err", err)
214 s.fail(ctx, runID, "database")
215 return false
216 }
217 }
218
219 content, err := client.Fetch(ctx, ref)
220 if err != nil {
221 if gone(err) {
222 st.add(func(r *runStats) { r.failed++ })
223 if !container {
224 s.markGone(ctx, connectorID, ref, string(notion.CodeOf(err)))
225 }
226 continue
227 }
228 // Not survivable: the subtree below here was never seen.
229 s.fail(ctx, runID, string(notion.CodeOf(err)))
230 return false
231 }
232 queue = append(queue, content.Children...)
233 st.add(func(r *runStats) { r.unread += content.UnreadBlocks })
234
235 if container {
236 continue
237 }
238
239 state, docID, err := s.ingest(ctx, connectorID, ref, content)
240 if err != nil {
241 st.add(func(r *runStats) { r.failed++ })
242 s.markGone(ctx, connectorID, ref, "extract_failed")
243 continue
244 }
245 switch state {
246 case PageEmpty:
247 st.add(func(r *runStats) { r.empty++ })
248 case "":
249 st.add(func(r *runStats) { r.skipped++ })
250 continue
251 default:
252 st.add(func(r *runStats) { r.fetched++ })
253 }
254 s.markByPage(ctx, connectorID, ref, state, "", docID, ref.EditedTime)
255 }
256 return true
257}
258
259// ingest stores one page, or reports that it did not need storing.
260//
261// Returns "" when the page was already up to date. A data source is a
262// container rather than a document and is never stored.
263func (s *Syncer) ingest(ctx context.Context, connectorID int64, ref notion.Ref, content *notion.Content) (string, int64, error) {
264 if ref.Kind == notion.KindDataSource {
265 return "", 0, nil
266 }
267 upToDate, err := s.upToDate(ctx, connectorID, ref)
268 if err != nil {
269 return "", 0, err
270 }
271 if upToDate {
272 return "", 0, nil
273 }
274 id, err := s.store.Ingest(ctx, IngestSpec{
275 Source: "notion",
276 Key: ref.ID,
277 Title: ref.Title,
278 Body: content.Markdown,
279 Path: ref.Path,
280 // On, because an empty page is a fact about the source. See
281 // IngestSpec.AllowTitleOnly.
282 AllowTitleOnly: true,
283 })
284 if err != nil {
285 // ErrIndexLag means the row COMMITTED and only the in-memory
286 // index is behind. The document exists, is in the list, and comes
287 // back at the next rebuild, which is every boot. Recording that
288 // as a failure — and wiping the id — would be telling someone a
289 // document is missing when it is right there.
290 if errors.Is(err, ErrIndexLag) {
291 s.logger.Error("index refresh failed after commit", "source", "notion", "err", err)
292 if content.Markdown == "" {
293 return PageEmpty, id, nil
294 }
295 return PageFetched, id, nil
296 }
297 return "", 0, err
298 }
299 if content.Markdown == "" {
300 return PageEmpty, id, nil
301 }
302 return PageFetched, id, nil
303}
304
305// upToDate reports whether the ledger already holds this page at this
306// edit time AND this ancestry.
307//
308// Both, because an ancestor rename moves the ancestry without moving the
309// page's own edit time — so a check on the edit time alone leaves every
310// descendant of a renamed page stale forever, with nothing upstream ever
311// signalling it.
312func (s *Syncer) upToDate(ctx context.Context, connectorID int64, ref notion.Ref) (bool, error) {
313 rows, err := s.store.pageRow(ctx, connectorID, ref.ID)
314 if err != nil || rows == nil {
315 return false, err
316 }
317 if rows.State != PageFetched && rows.State != PageEmpty {
318 return false, nil
319 }
320 if rows.IngestedEdited != ref.EditedTime {
321 return false, nil
322 }
323 want, err := s.store.pathPrint(ref.Path, ref.Title)
324 if err != nil {
325 return false, err
326 }
327 return rows.IngestedPath == want, nil
328}
329
330// markGone records a failure WITHOUT erasing what memoria already holds.
331//
332// An earlier version routed failures through markByPage with a zero
333// document id and an empty edit time, so a page that had imported fine
334// and then 404'd on a later run had its record wiped: "Imported" dropped,
335// and the next run had to fetch and re-ingest a document that was never
336// gone. Found by a code review.
337func (s *Syncer) markGone(ctx context.Context, connectorID int64, ref notion.Ref, code string) {
338 row, err := s.store.pageRow(ctx, connectorID, ref.ID)
339 if err != nil {
340 s.logger.Error("sync read ledger row", "err", err)
341 return
342 }
343 if row == nil {
344 return
345 }
346 // Keep document_id, ingested_edited and ingested_path exactly as they
347 // were; only the state and the code move.
348 if err := s.store.MarkPage(ctx, row.ID, PageFailed, code,
349 row.DocumentID, row.IngestedEdited, row.IngestedPath); err != nil {
350 s.logger.Error("sync mark page", "err", err)
351 }
352}
353
354func (s *Syncer) markByPage(ctx context.Context, connectorID int64, ref notion.Ref, state, code string, docID int64, edited string) {
355 row, err := s.store.pageRow(ctx, connectorID, ref.ID)
356 if err != nil {
357 s.logger.Error("sync read ledger row", "err", err)
358 return
359 }
360 if row == nil {
361 return
362 }
363 print, err := s.store.pathPrint(ref.Path, ref.Title)
364 if err != nil {
365 s.logger.Error("sync path print", "err", err)
366 return
367 }
368 if edited == "" {
369 print = ""
370 }
371 if err := s.store.MarkPage(ctx, row.ID, state, code, docID, edited, print); err != nil {
372 s.logger.Error("sync mark page", "err", err)
373 }
374}
375
376func (s *Syncer) fail(ctx context.Context, runID int64, code string) {
377 now := time.Now().UTC()
378 if err := s.store.UpdateRun(ctx, runID, map[string]any{
379 "state": RunFailed, "error_code": code, "finished_at": now,
380 }); err != nil {
381 s.logger.Error("sync fail", "run", runID, "err", err)
382 }
383 s.logger.Error("sync failed", "run", runID, "code", code)
384}
385
386func asNotionErr(err error, target **notion.Error) bool { return errors.As(err, target) }
387
388// gone reports whether an error is about one page rather than about the
389// traversal. Only a 404 qualifies: everything else means we do not know
390// what lies below, and continuing would report a complete import.
391func gone(err error) bool {
392 var e *notion.Error
393 if !asNotionErr(err, &e) {
394 return false
395 }
396 return e.Status == 404
397}
398
399// pageRow reads one ledger row by coordinate.
400func (s *Store) pageRow(ctx context.Context, connectorID int64, pageID string) (*SourcePage, error) {
401 print, err := s.sealer.Fingerprint(seal.PurposeCoordinate, []byte("page\x00"+pageID))
402 if err != nil {
403 return nil, err
404 }
405 var row SourcePage
406 res := s.db.G.WithContext(ctx).Where("connector_id = ? AND page_print = ?", connectorID, print).First(&row)
407 if res.Error != nil {
408 // NOT-FOUND is not an error; anything else is. This returned
409 // (nil, nil) for both, so a database failure looked exactly like
410 // "memoria has never seen this page" — the caller then skipped
411 // its mark silently and the ledger under-reported with nothing
412 // logged. The plausible-empty-result shape, in a helper.
413 if errors.Is(res.Error, gorm.ErrRecordNotFound) {
414 return nil, nil
415 }
416 return nil, res.Error
417 }
418 return &row, nil
419}
420
421// pathPrint is the keyed fingerprint of an ancestry plus a title, so a
422// move or a rename is detectable without opening the row.
423func (s *Store) pathPrint(path []string, title string) (string, error) {
424 pathStr := chunk.Chunk{Path: path}.PathString()
425 return s.sealer.Fingerprint(seal.PurposePath, []byte("path\x00"+pathStr+"\x00"+title))
426}
427