fix(watcher): unique archive copies, read caps at the read, stale markers (review of #210)
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user