fix(watcher): unique archive copies, read caps at the read, stale markers (review of #210)
CI / Lint / Test / Vet (pull_request) Successful in 1m5s
CI / Mirror to GitHub (pull_request) Skipped

From the independent review of #210:
- F1: processed/ and failed/ copies are created O_EXCL with -2,-3 suffixes via
  createUnique; same-named notes from different folders no longer replace one
  another's only surviving copy
- F2: extract.TextInMax / readAllMax cap the bytes actually read (a note can
  grow after the Lstat); extract.TextIn gets a 64 MiB default cap
- F3: a regular marker older than its note describes an earlier note of the
  same name; it no longer hides the new one (warn + remove); non-regular
  markers still count as done (no writes through links)
- F4: the planted-marker-link test is renamed to what it observes
Each has a test that fails without it; the read-cap test uses an endless reader
that errors at 8 MiB so a regression fails instead of exhausting memory.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:13:18 +02:00
co-authored by Claude Sonnet 5.5
parent d8c7defc78
commit 1025d77795
6 changed files with 298 additions and 42 deletions
+118 -1
View File
@@ -4,6 +4,7 @@ package watcher
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -497,7 +498,7 @@ func TestProcessDir_ANoteOverTheSizeCapIsRefusedOnceAndKept(t *testing.T) {
first := processDir(context.Background(), cfg, "2026-10-20")
require.NotEmpty(t, first, "the refusal is reported")
assert.Contains(t, first[0].Error(), "too large")
assert.Contains(t, first[0].Error(), "over the 1024 byte cap")
second := processDir(context.Background(), cfg, "2026-10-20")
assert.Empty(t, second, "and reported once, not every poll")
@@ -532,3 +533,119 @@ func TestProcessDir_ALongNameWhoseMarkerCannotBeWrittenIsNotHandledEveryPoll(t *
require.NoError(t, err)
assert.Len(t, archived, 1)
}
// Three notes named n.md in three folders, one poll. The archive and failed/
// copies are the only copy once the originals are retired, so none may replace
// another.
func TestProcessDir_SameNamedNotesAreAllArchivedNoneOverwritten(t *testing.T) {
for _, outcome := range []string{"processed", "failed"} {
t.Run(outcome, func(t *testing.T) {
brainDir := setupBrainDir(t)
for _, d := range []string{"a", "b", "c"} {
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "raw", d), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", d, "n.md"), []byte("CONTENT-"+d), 0o644))
}
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
Complete: successComplete(pipeline.RawPage{Title: "N", Type: "source", Content: "x\n"})}}
if outcome == "failed" {
cfg.Pipeline.Complete = errorComplete
}
processDir(context.Background(), cfg, "2026-10-20")
var all string
files, _ := filepath.Glob(filepath.Join(brainDir, "raw", outcome, "*", "*"))
more, _ := filepath.Glob(filepath.Join(brainDir, "raw", outcome, "*"))
for _, f := range append(files, more...) {
if b, err := os.ReadFile(f); err == nil {
all += string(b)
}
}
for _, d := range []string{"a", "b", "c"} {
assert.Contains(t, all, "CONTENT-"+d, "the copy of note %s was overwritten", d)
}
})
}
}
// A marker older than the note describes an earlier note of the same name; it
// must not hide the new one, silently, forever (the legacy .processed markers
// from before inbox zero are exactly this).
func TestProcessDir_AStaleMarkerDoesNotHideANewerNoteOfTheSameName(t *testing.T) {
brainDir := setupBrainDir(t)
rawDir := filepath.Join(brainDir, "raw")
marker := filepath.Join(rawDir, "idea.md.processed")
require.NoError(t, os.WriteFile(marker, []byte("2026-06-16"), 0o644))
old := time.Now().Add(-48 * time.Hour)
require.NoError(t, os.Chtimes(marker, old, old))
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"), []byte("A new idea, same file name."), 0o644))
var calls atomic.Int32
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
Complete: func(ctx context.Context, s, u string) (string, error) {
calls.Add(1)
return successComplete(pipeline.RawPage{Title: "Idea", Type: "source", Content: "x\n"})(ctx, s, u)
}}}
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.Equal(t, int32(1), calls.Load(), "the new note is processed")
_, err := os.Stat(filepath.Join(rawDir, "idea.md"))
assert.ErrorIs(t, err, os.ErrNotExist, "and retired")
_, err = os.Lstat(marker)
assert.ErrorIs(t, err, os.ErrNotExist, "the stale marker is cleaned up")
}
func TestProcessDir_AMarkerNewerThanTheNoteStillMeansDone(t *testing.T) {
brainDir := setupBrainDir(t)
rawDir := filepath.Join(brainDir, "raw")
note := filepath.Join(rawDir, "stuck.md")
require.NoError(t, os.WriteFile(note, []byte("Content."), 0o644))
past := time.Now().Add(-time.Hour)
require.NoError(t, os.Chtimes(note, past, past))
require.NoError(t, os.WriteFile(note+".processed", []byte("d"), 0o644)) // newer than the note
var calls atomic.Int32
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
Complete: func(context.Context, string, string) (string, error) { calls.Add(1); return "[]", nil }}}
processDir(context.Background(), cfg, "2026-10-20")
assert.Zero(t, calls.Load(), "a fallback marker written after the note still stops reprocessing")
}
// The archive copy is a second read of the note; the cap must hold there too,
// not only at the earlier Lstat (a note can grow in between).
func TestReadNoteMax(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "n.md"), []byte(strings.Repeat("x", 100)), 0o644))
root, err := os.OpenRoot(dir)
require.NoError(t, err)
defer func() { _ = root.Close() }()
got, err := readNoteMax(root, "n.md", 100)
require.NoError(t, err)
assert.Len(t, got, 100, "exactly at the cap is allowed")
_, err = readNoteMax(root, "n.md", 99)
require.Error(t, err)
assert.ErrorIs(t, err, errTooLarge)
}
// countingReader reports how much was pulled from it: an endless file must be
// abandoned at max+1 bytes, not slurped.
type countingReader struct{ n int64 }
func (c *countingReader) Read(p []byte) (int, error) {
if c.n > 8<<20 { // a regression must fail the test, not exhaust memory
return 0, errors.New("endless reader drained")
}
for i := range p {
p[i] = 'x'
}
c.n += int64(len(p))
return len(p), nil
}
func TestReadAllMax_StopsReadingAtTheCap(t *testing.T) {
c := &countingReader{}
_, err := readAllMax(c, 1000)
require.ErrorIs(t, err, errTooLarge)
assert.LessOrEqual(t, c.n, int64(1000+1+32*1024), "pulled far more than the cap from an endless reader: %d", c.n)
}