Skip to content

Commit f3b56cd

Browse files
hmansclaude
andcommitted
perf: update beancore state incrementally instead of full reload
- Accumulate fsnotify events during debounce window - Process only affected files instead of reloading all beans - Update search index incrementally per-bean - Handle edge cases: rapid updates, create+delete, invalid files Tests added: - Multiple changes in single debounce window - Invalid files are skipped gracefully - Rapid updates to same file coalesce properly 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 63bd573 commit f3b56cd

File tree

2 files changed

+285
-40
lines changed

2 files changed

+285
-40
lines changed

internal/beancore/core_test.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package beancore
22

33
import (
4+
"fmt"
45
"os"
56
"path/filepath"
67
"sync"
@@ -851,3 +852,200 @@ func TestSubscribersClosedOnUnwatch(t *testing.T) {
851852
t.Error("expected channel to be closed after Unwatch")
852853
}
853854
}
855+
856+
func TestMultipleChangesInDebounceWindow(t *testing.T) {
857+
core, beansDir := setupTestCore(t)
858+
859+
// Create an initial bean to update
860+
createTestBean(t, core, "upd1", "To Update", "todo")
861+
862+
if err := core.StartWatching(); err != nil {
863+
t.Fatalf("StartWatching() error = %v", err)
864+
}
865+
defer core.Unwatch()
866+
867+
ch, unsub := core.Subscribe()
868+
defer unsub()
869+
870+
time.Sleep(50 * time.Millisecond)
871+
872+
// Make multiple changes rapidly (within debounce window)
873+
// 1. Create a new bean
874+
content1 := `---
875+
title: New Bean
876+
status: todo
877+
---
878+
`
879+
os.WriteFile(filepath.Join(beansDir, "new1--new.md"), []byte(content1), 0644)
880+
881+
// 2. Update existing bean
882+
content2 := `---
883+
title: Updated Bean
884+
status: in-progress
885+
---
886+
`
887+
os.WriteFile(filepath.Join(beansDir, "upd1--to-update.md"), []byte(content2), 0644)
888+
889+
// 3. Create another bean then delete it (net effect: nothing)
890+
os.WriteFile(filepath.Join(beansDir, "tmp1--temp.md"), []byte(content1), 0644)
891+
os.Remove(filepath.Join(beansDir, "tmp1--temp.md"))
892+
893+
// Wait for debounced events
894+
select {
895+
case events := <-ch:
896+
// Should have events for new1 (created) and upd1 (updated)
897+
// tmp1 might or might not appear depending on timing
898+
foundNew := false
899+
foundUpd := false
900+
for _, e := range events {
901+
if e.BeanID == "new1" && e.Type == EventCreated {
902+
foundNew = true
903+
}
904+
if e.BeanID == "upd1" && e.Type == EventUpdated {
905+
foundUpd = true
906+
}
907+
}
908+
if !foundNew {
909+
t.Error("expected EventCreated for new1")
910+
}
911+
if !foundUpd {
912+
t.Error("expected EventUpdated for upd1")
913+
}
914+
case <-time.After(500 * time.Millisecond):
915+
t.Error("timeout waiting for events")
916+
}
917+
918+
// Verify state is correct
919+
_, err := core.Get("new1")
920+
if err != nil {
921+
t.Errorf("new1 should exist: %v", err)
922+
}
923+
924+
upd, err := core.Get("upd1")
925+
if err != nil {
926+
t.Fatalf("upd1 should exist: %v", err)
927+
}
928+
if upd.Title != "Updated Bean" {
929+
t.Errorf("upd1 title = %q, want %q", upd.Title, "Updated Bean")
930+
}
931+
932+
// tmp1 should not exist
933+
_, err = core.Get("tmp1")
934+
if err != ErrNotFound {
935+
t.Error("tmp1 should not exist (was created then deleted)")
936+
}
937+
}
938+
939+
func TestInvalidFileIgnored(t *testing.T) {
940+
core, beansDir := setupTestCore(t)
941+
942+
// Create a valid bean first
943+
createTestBean(t, core, "val1", "Valid Bean", "todo")
944+
945+
if err := core.StartWatching(); err != nil {
946+
t.Fatalf("StartWatching() error = %v", err)
947+
}
948+
defer core.Unwatch()
949+
950+
ch, unsub := core.Subscribe()
951+
defer unsub()
952+
953+
time.Sleep(50 * time.Millisecond)
954+
955+
// Create an invalid bean file (malformed YAML frontmatter)
956+
invalidContent := `---
957+
title: [unclosed bracket
958+
status: {broken yaml
959+
---
960+
`
961+
os.WriteFile(filepath.Join(beansDir, "bad1--invalid.md"), []byte(invalidContent), 0644)
962+
963+
// Also create a valid bean to verify processing continues
964+
validContent := `---
965+
title: Another Valid
966+
status: todo
967+
---
968+
`
969+
os.WriteFile(filepath.Join(beansDir, "val2--another.md"), []byte(validContent), 0644)
970+
971+
// Wait for events
972+
select {
973+
case events := <-ch:
974+
// Should have event for val2 (created), bad1 should be skipped
975+
foundVal2 := false
976+
for _, e := range events {
977+
if e.BeanID == "val2" && e.Type == EventCreated {
978+
foundVal2 = true
979+
}
980+
if e.BeanID == "bad1" {
981+
t.Error("bad1 should not produce an event (invalid file)")
982+
}
983+
}
984+
if !foundVal2 {
985+
t.Error("expected EventCreated for val2")
986+
}
987+
case <-time.After(500 * time.Millisecond):
988+
t.Error("timeout waiting for events")
989+
}
990+
991+
// Valid beans should still be accessible
992+
if _, err := core.Get("val1"); err != nil {
993+
t.Errorf("val1 should still exist: %v", err)
994+
}
995+
if _, err := core.Get("val2"); err != nil {
996+
t.Errorf("val2 should exist: %v", err)
997+
}
998+
}
999+
1000+
func TestRapidUpdatesToSameFile(t *testing.T) {
1001+
core, beansDir := setupTestCore(t)
1002+
1003+
createTestBean(t, core, "rap1", "Rapid Updates", "todo")
1004+
1005+
if err := core.StartWatching(); err != nil {
1006+
t.Fatalf("StartWatching() error = %v", err)
1007+
}
1008+
defer core.Unwatch()
1009+
1010+
ch, unsub := core.Subscribe()
1011+
defer unsub()
1012+
1013+
time.Sleep(50 * time.Millisecond)
1014+
1015+
// Write to the same file multiple times rapidly
1016+
for i := 1; i <= 5; i++ {
1017+
content := fmt.Sprintf(`---
1018+
title: Update %d
1019+
status: todo
1020+
---
1021+
`, i)
1022+
os.WriteFile(filepath.Join(beansDir, "rap1--rapid-updates.md"), []byte(content), 0644)
1023+
time.Sleep(10 * time.Millisecond) // Small delay but within debounce
1024+
}
1025+
1026+
// Should get a single batch of events (debounced)
1027+
select {
1028+
case events := <-ch:
1029+
// Count events for rap1 - should be exactly one
1030+
rap1Count := 0
1031+
var lastEvent BeanEvent
1032+
for _, e := range events {
1033+
if e.BeanID == "rap1" {
1034+
rap1Count++
1035+
lastEvent = e
1036+
}
1037+
}
1038+
if rap1Count != 1 {
1039+
t.Errorf("expected 1 event for rap1, got %d", rap1Count)
1040+
}
1041+
if lastEvent.Type != EventUpdated {
1042+
t.Errorf("expected EventUpdated, got %v", lastEvent.Type)
1043+
}
1044+
// Should have the final value
1045+
if lastEvent.Bean != nil && lastEvent.Bean.Title != "Update 5" {
1046+
t.Errorf("expected title 'Update 5', got %q", lastEvent.Bean.Title)
1047+
}
1048+
case <-time.After(500 * time.Millisecond):
1049+
t.Error("timeout waiting for events")
1050+
}
1051+
}

internal/beancore/watcher.go

Lines changed: 87 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package beancore
22

33
import (
4+
"os"
45
"path/filepath"
56
"strings"
7+
"sync"
68
"sync/atomic"
79
"time"
810

@@ -172,6 +174,8 @@ func (c *Core) watchLoop(watcher *fsnotify.Watcher) {
172174
defer watcher.Close()
173175

174176
var debounceTimer *time.Timer
177+
var pendingMu sync.Mutex
178+
pendingChanges := make(map[string]fsnotify.Op)
175179

176180
for {
177181
select {
@@ -207,12 +211,23 @@ func (c *Core) watchLoop(watcher *fsnotify.Watcher) {
207211
continue
208212
}
209213

214+
// Accumulate changes during debounce window
215+
pendingMu.Lock()
216+
pendingChanges[event.Name] |= event.Op
217+
pendingMu.Unlock()
218+
210219
// Start/reset debounce timer
211220
if debounceTimer != nil {
212221
debounceTimer.Stop()
213222
}
214223
debounceTimer = time.AfterFunc(debounceDelay, func() {
215-
c.handleChange()
224+
// Swap out pending changes atomically
225+
pendingMu.Lock()
226+
changes := pendingChanges
227+
pendingChanges = make(map[string]fsnotify.Op)
228+
pendingMu.Unlock()
229+
230+
c.handleChanges(changes)
216231
})
217232

218233
case err, ok := <-watcher.Errors:
@@ -225,57 +240,83 @@ func (c *Core) watchLoop(watcher *fsnotify.Watcher) {
225240
}
226241
}
227242

228-
// handleChange reloads beans from disk, detects changes, and notifies subscribers.
229-
func (c *Core) handleChange() {
230-
c.mu.Lock()
231-
232-
// Check if we're still watching
233-
if !c.watching {
234-
c.mu.Unlock()
243+
// handleChanges processes only the files that changed, updating state incrementally.
244+
func (c *Core) handleChanges(changes map[string]fsnotify.Op) {
245+
if len(changes) == 0 {
235246
return
236247
}
237248

238-
// Capture old state for comparison
239-
oldBeans := make(map[string]*bean.Bean, len(c.beans))
240-
for id, b := range c.beans {
241-
oldBeans[id] = b
242-
}
249+
c.mu.Lock()
243250

244-
// Reload from disk
245-
if err := c.loadFromDisk(); err != nil {
246-
// On error, just continue - the beans map may be stale but that's better than crashing
251+
// Check if we're still watching
252+
if !c.watching {
247253
c.mu.Unlock()
248254
return
249255
}
250256

251-
// Compute events by comparing states
252257
var events []BeanEvent
253258

254-
// Check for created and updated beans
255-
for id, newBean := range c.beans {
256-
if _, existed := oldBeans[id]; !existed {
257-
events = append(events, BeanEvent{
258-
Type: EventCreated,
259-
Bean: newBean,
260-
BeanID: id,
261-
})
262-
} else {
263-
events = append(events, BeanEvent{
264-
Type: EventUpdated,
265-
Bean: newBean,
266-
BeanID: id,
267-
})
259+
for path, op := range changes {
260+
filename := filepath.Base(path)
261+
id, _ := bean.ParseFilename(filename)
262+
263+
// Handle removes/renames (file is gone)
264+
if op&fsnotify.Remove != 0 || op&fsnotify.Rename != 0 {
265+
// Check if the file actually exists (rename might be followed by create)
266+
if _, exists := c.beans[id]; exists {
267+
// Only delete if it was in our map and file is actually gone
268+
if !c.fileExists(path) {
269+
delete(c.beans, id)
270+
271+
// Update search index
272+
if c.searchIndex != nil {
273+
if err := c.searchIndex.DeleteBean(id); err != nil {
274+
c.logWarn("failed to remove bean %s from search index: %v", id, err)
275+
}
276+
}
277+
278+
events = append(events, BeanEvent{
279+
Type: EventDeleted,
280+
Bean: nil,
281+
BeanID: id,
282+
})
283+
}
284+
}
285+
continue
268286
}
269-
delete(oldBeans, id)
270-
}
271287

272-
// Remaining oldBeans entries were deleted
273-
for id := range oldBeans {
274-
events = append(events, BeanEvent{
275-
Type: EventDeleted,
276-
Bean: nil,
277-
BeanID: id,
278-
})
288+
// Handle creates/writes (file exists or was created)
289+
if op&fsnotify.Create != 0 || op&fsnotify.Write != 0 {
290+
newBean, err := c.loadBean(path)
291+
if err != nil {
292+
c.logWarn("failed to load bean from %s: %v", path, err)
293+
continue
294+
}
295+
296+
_, existed := c.beans[newBean.ID]
297+
c.beans[newBean.ID] = newBean
298+
299+
// Update search index
300+
if c.searchIndex != nil {
301+
if err := c.searchIndex.IndexBean(newBean); err != nil {
302+
c.logWarn("failed to index bean %s: %v", newBean.ID, err)
303+
}
304+
}
305+
306+
if existed {
307+
events = append(events, BeanEvent{
308+
Type: EventUpdated,
309+
Bean: newBean,
310+
BeanID: newBean.ID,
311+
})
312+
} else {
313+
events = append(events, BeanEvent{
314+
Type: EventCreated,
315+
Bean: newBean,
316+
BeanID: newBean.ID,
317+
})
318+
}
319+
}
279320
}
280321

281322
callback := c.onChange
@@ -289,3 +330,9 @@ func (c *Core) handleChange() {
289330
callback()
290331
}
291332
}
333+
334+
// fileExists checks if a file exists at the given path.
335+
func (c *Core) fileExists(path string) bool {
336+
_, err := os.Stat(path)
337+
return err == nil
338+
}

0 commit comments

Comments
 (0)