Просмотр исходного кода

fix: complete entity live reload on config and UI refresh (#1112)

James Read 21 часов назад
Родитель
Сommit
c138699b20

+ 16 - 3
frontend/resources/vue/views/EntitiesView.vue

@@ -23,12 +23,13 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from 'vue'
+import { ref, computed, onMounted, onUnmounted } from 'vue'
 import { CellsIcon } from '@hugeicons/core-free-icons'
 import Section from 'picocrank/vue/components/Section.vue'
 import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
 const definitionsLoaded = ref(false)
 const entityDefinitions = ref([])
+let entityFetchGeneration = 0
 
 const totalInstances = computed(() =>
   entityDefinitions.value.reduce(
@@ -38,19 +39,31 @@ const totalInstances = computed(() =>
 )
 
 async function fetchEntities () {
+  const fetchGeneration = ++entityFetchGeneration
   try {
     const ret = await window.client.getEntities()
+    if (fetchGeneration !== entityFetchGeneration) return
     entityDefinitions.value = ret.entityDefinitions ?? []
   } catch (err) {
+    if (fetchGeneration !== entityFetchGeneration) return
     console.error('Failed to fetch entities:', err)
     window.showBigError('fetch-entities', 'getting entities', err, false)
     entityDefinitions.value = []
   } finally {
-    definitionsLoaded.value = true
+    if (fetchGeneration === entityFetchGeneration) {
+      definitionsLoaded.value = true
+    }
   }
 }
 
 onMounted(() => {
-	    fetchEntities()
+  fetchEntities()
+  window.addEventListener('EventEntityChanged', fetchEntities)
+  window.addEventListener('EventConfigChanged', fetchEntities)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('EventEntityChanged', fetchEntities)
+  window.removeEventListener('EventConfigChanged', fetchEntities)
 })
 </script>

+ 85 - 9
service/internal/entities/entities.go

@@ -7,6 +7,7 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+	"sync"
 
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/filehelper"
@@ -17,8 +18,16 @@ import (
 var (
 	EntityChangedSender chan bool
 	listeners           []func()
+
+	watchedMu       sync.Mutex
+	watchedBindings = map[string]entityWatchBinding{}
 )
 
+type entityWatchBinding struct {
+	entityName string
+	sourceFile string
+}
+
 type Entity struct {
 	Data      any
 	UniqueKey string
@@ -30,11 +39,14 @@ func AddListener(l func()) {
 }
 
 func SetupEntityFileWatchers(cfg *config.Config) {
+	SyncEntityFileWatchers(cfg)
+}
+
+// SyncEntityFileWatchers ensures each configured entity file has a watcher and reloads
+// entity data. Safe to call on config reload; obsolete watchers are stopped.
+func SyncEntityFileWatchers(cfg *config.Config) {
 	baseDir := ResolveEntitiesBaseDir(cfg.GetDir())
-	for i := range cfg.Entities { // #337 - iterate by key, not by value
-		ef := cfg.Entities[i]
-		watchAndLoadEntity(baseDir, ef)
-	}
+	reconcileEntityWatchers(desiredEntityWatchers(baseDir, cfg.Entities))
 }
 
 // ResolveEntitiesBaseDir returns the directory used to resolve relative entity file paths.
@@ -63,16 +75,80 @@ func resolveEntitiesBaseDir(configDir string) string {
 	return absConfigDir
 }
 
-func watchAndLoadEntity(baseDir string, ef *config.EntityFile) {
-	p := ef.File
+func resolveEntityFilePath(baseDir string, file string) string {
+	p := file
 	if !filepath.IsAbs(p) {
 		p = filepath.Join(baseDir, p)
 		log.WithFields(log.Fields{"entityFile": p}).Debugf("Adding config dir to entity file path")
 	}
-	go filehelper.WatchFileWrite(p, func(_ string) { loadEntityFile(p, ef.Name) }, filehelper.WatchMeta{
-		ConfigFile: ef.SourceFile,
+	return p
+}
+
+func desiredEntityWatchers(baseDir string, entityFiles []*config.EntityFile) map[string]entityWatchBinding {
+	desired := make(map[string]entityWatchBinding, len(entityFiles))
+	for i := range entityFiles { // #337 - iterate by key, not by value
+		ef := entityFiles[i]
+		path := resolveEntityFilePath(baseDir, ef.File)
+		desired[path] = entityWatchBinding{
+			entityName: ef.Name,
+			sourceFile: ef.SourceFile,
+		}
+	}
+	return desired
+}
+
+func reconcileEntityWatchers(desired map[string]entityWatchBinding) {
+	stopObsoleteEntityWatchers(desired)
+	for path, binding := range desired {
+		ensureEntityFileWatcher(path, binding)
+	}
+}
+
+func stopObsoleteEntityWatchers(desired map[string]entityWatchBinding) {
+	watchedMu.Lock()
+	defer watchedMu.Unlock()
+
+	for path, binding := range watchedBindings {
+		wanted, ok := desired[path]
+		if ok && wanted == binding {
+			continue
+		}
+		filehelper.StopFileWatch(path)
+		delete(watchedBindings, path)
+	}
+}
+
+func ensureEntityFileWatcher(path string, binding entityWatchBinding) {
+	watchedMu.Lock()
+	existing, watching := watchedBindings[path]
+	if watching && existing == binding {
+		watchedMu.Unlock()
+		loadEntityFile(path, binding.entityName)
+		return
+	}
+	watchedBindings[path] = binding
+	watchedMu.Unlock()
+
+	filehelper.WatchFileWrite(path, makeEntityFileWatchCallback(path), filehelper.WatchMeta{
+		ConfigFile: binding.sourceFile,
 	})
-	loadEntityFile(p, ef.Name)
+	loadEntityFile(path, binding.entityName)
+}
+
+func makeEntityFileWatchCallback(path string) func(string) {
+	return func(_ string) {
+		entityFileWatchCallback(path)
+	}
+}
+
+func entityFileWatchCallback(path string) {
+	watchedMu.Lock()
+	binding, ok := watchedBindings[path]
+	watchedMu.Unlock()
+	if !ok {
+		return
+	}
+	loadEntityFile(path, binding.entityName)
 }
 
 func loadEntityFile(filename string, entityname string) {

+ 124 - 0
service/internal/entities/entities_test.go

@@ -5,6 +5,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -87,6 +88,129 @@ func TestGetEntityInstancesOrdered_emptyOrMissing(t *testing.T) {
 	assert.Nil(t, ordered)
 }
 
+func TestSyncEntityFileWatchers_doesNotDuplicateWatchers(t *testing.T) {
+	ResetEntityWatchersForTests()
+	t.Cleanup(ResetEntityWatchersForTests)
+
+	dir := t.TempDir()
+	yamlPath := filepath.Join(dir, "vehicles.yaml")
+	require.NoError(t, os.WriteFile(yamlPath, []byte("- title: car1\n"), 0o600))
+
+	cfg := config.DefaultConfig()
+	cfg.SetDir(dir)
+	cfg.Entities = []*config.EntityFile{
+		{Name: "vehicle", File: "vehicles.yaml"},
+	}
+
+	SyncEntityFileWatchers(cfg)
+	require.Equal(t, 1, watchedPathCountForTests())
+	require.Len(t, GetEntityInstancesOrdered("vehicle"), 1)
+
+	SyncEntityFileWatchers(cfg)
+	assert.Equal(t, 1, watchedPathCountForTests())
+}
+
+func TestSyncEntityFileWatchers_removedPathDoesNotReload(t *testing.T) {
+	ResetEntityWatchersForTests()
+	t.Cleanup(func() {
+		ResetEntityWatchersForTests()
+		ClearEntitiesOfType("vehicle")
+	})
+
+	dir := t.TempDir()
+	vehiclePath := filepath.Join(dir, "vehicles.yaml")
+	require.NoError(t, os.WriteFile(vehiclePath, []byte("- title: car1\n"), 0o600))
+
+	cfg := config.DefaultConfig()
+	cfg.SetDir(dir)
+	cfg.Entities = []*config.EntityFile{
+		{Name: "vehicle", File: "vehicles.yaml"},
+	}
+
+	SyncEntityFileWatchers(cfg)
+	require.Equal(t, 1, watchedPathCountForTests())
+	require.Len(t, GetEntityInstancesOrdered("vehicle"), 1)
+
+	cfg.Entities = nil
+	SyncEntityFileWatchers(cfg)
+	assert.Equal(t, 0, watchedPathCountForTests())
+
+	ClearEntitiesOfType("vehicle")
+	require.NoError(t, os.WriteFile(vehiclePath, []byte("- title: car2\n"), 0o600))
+	entityFileWatchCallback(vehiclePath)
+	assert.Empty(t, GetEntityInstancesOrdered("vehicle"))
+}
+
+func TestSyncEntityFileWatchers_replacedPathUsesNewBinding(t *testing.T) {
+	ResetEntityWatchersForTests()
+	t.Cleanup(func() {
+		ResetEntityWatchersForTests()
+		ClearEntitiesOfType("vehicle")
+	})
+
+	dir := t.TempDir()
+	vehiclePath := filepath.Join(dir, "vehicles.yaml")
+	carsPath := filepath.Join(dir, "cars.yaml")
+	require.NoError(t, os.WriteFile(vehiclePath, []byte("- title: car1\n"), 0o600))
+	require.NoError(t, os.WriteFile(carsPath, []byte("- title: car2\n"), 0o600))
+
+	cfg := config.DefaultConfig()
+	cfg.SetDir(dir)
+	cfg.Entities = []*config.EntityFile{
+		{Name: "vehicle", File: "vehicles.yaml"},
+	}
+
+	SyncEntityFileWatchers(cfg)
+	require.Equal(t, 1, watchedPathCountForTests())
+	require.Equal(t, "car1", GetEntityInstancesOrdered("vehicle")[0].Title)
+
+	cfg.Entities = []*config.EntityFile{
+		{Name: "vehicle", File: "cars.yaml"},
+	}
+	SyncEntityFileWatchers(cfg)
+	require.Equal(t, 1, watchedPathCountForTests())
+	require.Equal(t, "car2", GetEntityInstancesOrdered("vehicle")[0].Title)
+
+	require.NoError(t, os.WriteFile(vehiclePath, []byte("- title: stale\n"), 0o600))
+	entityFileWatchCallback(vehiclePath)
+	assert.Equal(t, "car2", GetEntityInstancesOrdered("vehicle")[0].Title, "obsolete path should not reload")
+
+	require.NoError(t, os.WriteFile(carsPath, []byte("- title: car3\n"), 0o600))
+	entityFileWatchCallback(carsPath)
+	assert.Equal(t, "car3", GetEntityInstancesOrdered("vehicle")[0].Title)
+}
+
+func TestSyncEntityFileWatchers_picksUpNewEntityType(t *testing.T) {
+	ResetEntityWatchersForTests()
+	t.Cleanup(func() {
+		ResetEntityWatchersForTests()
+		ClearEntitiesOfType("vehicle")
+		ClearEntitiesOfType("server")
+	})
+
+	dir := t.TempDir()
+	vehiclePath := filepath.Join(dir, "vehicles.yaml")
+	serverPath := filepath.Join(dir, "servers.yaml")
+	require.NoError(t, os.WriteFile(vehiclePath, []byte("- title: car1\n"), 0o600))
+
+	cfg := config.DefaultConfig()
+	cfg.SetDir(dir)
+	cfg.Entities = []*config.EntityFile{
+		{Name: "vehicle", File: "vehicles.yaml"},
+	}
+
+	SyncEntityFileWatchers(cfg)
+	require.Equal(t, 1, watchedPathCountForTests())
+
+	require.NoError(t, os.WriteFile(serverPath, []byte("- name: srv1\n"), 0o600))
+	cfg.Entities = append(cfg.Entities, &config.EntityFile{Name: "server", File: "servers.yaml"})
+
+	SyncEntityFileWatchers(cfg)
+	require.Equal(t, 2, watchedPathCountForTests())
+	require.Len(t, GetEntityInstancesOrdered("vehicle"), 1)
+	require.Len(t, GetEntityInstancesOrdered("server"), 1)
+}
+
 func TestLoadEntityFile_preservesEntitiesOnTransientFailure(t *testing.T) {
 	const entityName = "preserve_on_fail"
 	ClearEntitiesOfType(entityName)

+ 21 - 1
service/internal/entities/load_state.go

@@ -1,6 +1,10 @@
 package entities
 
-import "sync"
+import (
+	"sync"
+
+	"github.com/OliveTin/OliveTin/internal/filehelper"
+)
 
 var (
 	loadAttemptedMu sync.RWMutex
@@ -31,3 +35,19 @@ func ResetEntityLoadAttempts() {
 	defer loadAttemptedMu.Unlock()
 	loadAttempted = map[string]bool{}
 }
+
+// ResetEntityWatchersForTests clears watched-path tracking (used by tests).
+func ResetEntityWatchersForTests() {
+	watchedMu.Lock()
+	defer watchedMu.Unlock()
+	for path := range watchedBindings {
+		filehelper.StopFileWatch(path)
+	}
+	watchedBindings = map[string]entityWatchBinding{}
+}
+
+func watchedPathCountForTests() int {
+	watchedMu.Lock()
+	defer watchedMu.Unlock()
+	return len(watchedBindings)
+}

+ 65 - 7
service/internal/filehelper/file_change_notify.go

@@ -15,6 +15,9 @@ var (
 	debounceWriteLog map[string]*FsNotifyLogEntry
 
 	debounceWriteLogMutex = sync.Mutex{}
+
+	fileWatchMu    sync.Mutex
+	fileWatchStops = map[string]chan struct{}{}
 )
 
 func init() {
@@ -54,7 +57,7 @@ func WatchDirectoryCreate(fullpath string, callback func(filename string), meta
 		callback:        callback,
 		interestedEvent: fsnotify.Create,
 		meta:            meta,
-	})
+	}, nil)
 }
 
 func WatchDirectoryWrite(fullpath string, callback func(filename string), meta WatchMeta) {
@@ -64,23 +67,60 @@ func WatchDirectoryWrite(fullpath string, callback func(filename string), meta W
 		callback:        callback,
 		interestedEvent: fsnotify.Write,
 		meta:            meta,
-	})
+	}, nil)
 }
 
 func WatchFileWrite(fullpath string, callback func(filename string), meta WatchMeta) {
 	filename := filepath.Base(fullpath)
 	filedir := filepath.Dir(fullpath)
+	watchKey := filepath.Join(filedir, filename)
 
-	watchPath(&watchContext{
+	done := registerFileWatch(watchKey)
+	go watchPath(&watchContext{
 		filedir:         filedir,
 		filename:        filename,
 		callback:        callback,
 		interestedEvent: fsnotify.Write,
 		meta:            meta,
-	})
+	}, done)
+}
+
+// StopFileWatch stops a file write watcher started by WatchFileWrite.
+func StopFileWatch(fullpath string) {
+	watchKey, err := filepath.Abs(fullpath)
+	if err != nil {
+		watchKey = fullpath
+	}
+
+	fileWatchMu.Lock()
+	done, ok := fileWatchStops[watchKey]
+	if ok {
+		delete(fileWatchStops, watchKey)
+	}
+	fileWatchMu.Unlock()
+
+	if ok {
+		close(done)
+	}
+}
+
+func registerFileWatch(watchKey string) chan struct{} {
+	absKey, err := filepath.Abs(watchKey)
+	if err == nil {
+		watchKey = absKey
+	}
+
+	done := make(chan struct{})
+	fileWatchMu.Lock()
+	if previous, ok := fileWatchStops[watchKey]; ok {
+		close(previous)
+	}
+	fileWatchStops[watchKey] = done
+	fileWatchMu.Unlock()
+	return done
 }
 
-func watchPath(ctx *watchContext) {
+func watchPath(ctx *watchContext, done <-chan struct{}) {
 	watcher, err := fsnotify.NewWatcher()
 	if err != nil {
 		reportWatcherFailure(ctx, err)
@@ -94,7 +134,7 @@ func watchPath(ctx *watchContext) {
 		return
 	}
 
-	for processEvent(ctx, watcher) {
+	for processEvent(ctx, watcher, done) {
 	}
 }
 
@@ -127,7 +167,14 @@ func reportWatcherFailure(ctx *watchContext, err error) {
 
 // processEvent waits for one watcher event. It returns false when the watcher
 // channels are closed so the caller can stop looping.
-func processEvent(ctx *watchContext, watcher *fsnotify.Watcher) bool {
+func processEvent(ctx *watchContext, watcher *fsnotify.Watcher, done <-chan struct{}) bool {
+	if done == nil {
+		return waitForWatcherEvent(ctx, watcher)
+	}
+	return waitForWatcherEventOrCancel(ctx, watcher, done)
+}
+
+func waitForWatcherEvent(ctx *watchContext, watcher *fsnotify.Watcher) bool {
 	select {
 	case event, ok := <-watcher.Events:
 		return handleWatcherEvent(ctx, event, ok)
@@ -136,6 +183,17 @@ func processEvent(ctx *watchContext, watcher *fsnotify.Watcher) bool {
 	}
 }
 
+func waitForWatcherEventOrCancel(ctx *watchContext, watcher *fsnotify.Watcher, done <-chan struct{}) bool {
+	select {
+	case <-done:
+		return false
+	case event, ok := <-watcher.Events:
+		return handleWatcherEvent(ctx, event, ok)
+	case err, ok := <-watcher.Errors:
+		return handleWatcherError(ctx, err, ok)
+	}
+}
+
 func handleWatcherEvent(ctx *watchContext, event fsnotify.Event, ok bool) bool {
 	if !ok {
 		return false

+ 4 - 1
service/main.go

@@ -262,6 +262,9 @@ func main() {
 	executor := executor.DefaultExecutor(cfg)
 	executor.RebuildActionMap()
 	config.AddListener(executor.RebuildActionMap)
+	config.AddListener(func() {
+		entities.SyncEntityFileWatchers(cfg)
+	})
 
 	executor.LoadLogsFromDisk()
 
@@ -273,7 +276,7 @@ func main() {
 	go onfileindir.WatchFilesInDirectory(cfg, executor)
 	go oncalendarfile.Schedule(cfg, executor)
 
-	go entities.SetupEntityFileWatchers(cfg)
+	go entities.SyncEntityFileWatchers(cfg)
 
 	go updatecheck.StartUpdateChecker(cfg)