working on valuemonitor
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TheTestFilename = "settings_test.settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
exitCode := func() int {
|
||||||
|
if err := os.Remove(TheTestFilename); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
panic("error cleaning up " + TheTestFilename + " from previous run: " + err.Error())
|
||||||
|
} else if err := os.WriteFile(TheTestFilename, nil, 0664); err != nil {
|
||||||
|
panic("error creating empty " + TheTestFilename + ": " + err.Error())
|
||||||
|
}
|
||||||
|
defer os.Remove(TheTestFilename)
|
||||||
|
|
||||||
|
TheSettings = NewSettings(TheTestFilename, false)
|
||||||
|
|
||||||
|
return m.Run()
|
||||||
|
}()
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}
|
||||||
+70
-8
@@ -1,8 +1,10 @@
|
|||||||
package settings
|
package settings
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -24,11 +26,67 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
contents map[string]string
|
context context.Context
|
||||||
filename string
|
contents map[string]string
|
||||||
modtime time.Time
|
monitorKeys map[string]chan<- string
|
||||||
LogUpdates atomic.Bool
|
filename string
|
||||||
|
modtime time.Time
|
||||||
|
LogUpdates atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertOrTimeout(ctx context.Context, c chan<- string, s string) error {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case c <- s:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) RegisterKeyToMonitor(key string, updateOutputChannel chan<- string) error {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
if s.monitorKeys == nil {
|
||||||
|
s.monitorKeys = make(map[string]chan<- string, 1)
|
||||||
|
}
|
||||||
|
if _, found := s.monitorKeys[key]; found {
|
||||||
|
return fmt.Errorf("key %q already in monitorKeys map", key)
|
||||||
|
}
|
||||||
|
if err := insertOrTimeout(s.context, updateOutputChannel, s.contents[key]); err != nil {
|
||||||
|
return fmt.Errorf("error inserting: %w", err)
|
||||||
|
}
|
||||||
|
s.monitorKeys[key] = updateOutputChannel
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) Dump() ([]byte, error) {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
if err := s.DumpToWriter(&buffer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) DumpToWriter(w io.Writer) error {
|
||||||
|
writer := bufio.NewWriter(w)
|
||||||
|
s.mutex.RLock()
|
||||||
|
defer s.mutex.RUnlock()
|
||||||
|
var index int
|
||||||
|
for k, v := range s.contents {
|
||||||
|
writer.WriteString(fmt.Sprintf("%s=%s", k, v))
|
||||||
|
if index < len(s.contents)-1 {
|
||||||
|
writer.WriteByte('\n')
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
return writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) DumpToJson() ([]byte, error) {
|
||||||
|
s.mutex.RLock()
|
||||||
|
defer s.mutex.RUnlock()
|
||||||
|
return json.Marshal(s.contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Settings) GetFilename() string {
|
func (s *Settings) GetFilename() string {
|
||||||
@@ -68,6 +126,9 @@ func (s *Settings) lockedSetKeyValue(key, value string) bool {
|
|||||||
log.Printf("Settings::lockedKeyValue %s: storing key '%s' with value '%s'\n", baseFilename, key, value)
|
log.Printf("Settings::lockedKeyValue %s: storing key '%s' with value '%s'\n", baseFilename, key, value)
|
||||||
}
|
}
|
||||||
s.contents[strings.Clone(key)] = strings.Clone(value)
|
s.contents[strings.Clone(key)] = strings.Clone(value)
|
||||||
|
if updateChannel, found := s.monitorKeys[key]; found {
|
||||||
|
insertOrTimeout(s.context, updateChannel, value)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,10 +193,10 @@ func (s *Settings) Update() error {
|
|||||||
return s.lockedUpdate()
|
return s.lockedUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Settings) maintenanceRoutine(ctx context.Context) {
|
func (s *Settings) maintenanceRoutine() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-s.context.Done():
|
||||||
return
|
return
|
||||||
case <-time.After(MaintenanceRoutinePace):
|
case <-time.After(MaintenanceRoutinePace):
|
||||||
err := s.Update()
|
err := s.Update()
|
||||||
@@ -154,6 +215,7 @@ func NewSettings(filename string, logUpdates bool) *Settings {
|
|||||||
|
|
||||||
func NewSettingsWithContext(ctx context.Context, filename string, logUpdates bool) *Settings {
|
func NewSettingsWithContext(ctx context.Context, filename string, logUpdates bool) *Settings {
|
||||||
settings := &Settings{
|
settings := &Settings{
|
||||||
|
context: ctx,
|
||||||
contents: make(map[string]string),
|
contents: make(map[string]string),
|
||||||
filename: strings.Clone(filename),
|
filename: strings.Clone(filename),
|
||||||
}
|
}
|
||||||
@@ -161,7 +223,7 @@ func NewSettingsWithContext(ctx context.Context, filename string, logUpdates boo
|
|||||||
if err := settings.lockedUpdate(); err != nil && (!errors.Is(err, os.ErrNotExist) || WarnIfFileNotFound) {
|
if err := settings.lockedUpdate(); err != nil && (!errors.Is(err, os.ErrNotExist) || WarnIfFileNotFound) {
|
||||||
log.Printf("NewSettingsWithContext %s error from initial update: %v\n", filepath.Base(filename), err)
|
log.Printf("NewSettingsWithContext %s error from initial update: %v\n", filepath.Base(filename), err)
|
||||||
}
|
}
|
||||||
go settings.maintenanceRoutine(ctx)
|
go settings.maintenanceRoutine()
|
||||||
return settings
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+160
-6
@@ -2,8 +2,11 @@ package settings
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,10 +15,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
TheTestFilename = "settings_test.settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
AdditionalKeyValuePairs = map[string]string{
|
AdditionalKeyValuePairs = map[string]string{
|
||||||
"D": "d",
|
"D": "d",
|
||||||
@@ -43,8 +42,11 @@ func TestSettings(t *testing.T) {
|
|||||||
t.Fatalf("error cleaning up %s from this run: %v\n", TheTestFilename, err)
|
t.Fatalf("error cleaning up %s from this run: %v\n", TheTestFilename, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
settings := NewSettingsWithContext(t.Context(), TheTestFilename, false)
|
settings := TheSettings
|
||||||
if want, got := TheTestFilename, settings.GetFilename(); want != got {
|
err = settings.Update()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error updating settings: %v\n", err)
|
||||||
|
} else if want, got := TheTestFilename, settings.GetFilename(); want != got {
|
||||||
t.Fatalf("error: wanted '%s'; got '%s'\n", want, got)
|
t.Fatalf("error: wanted '%s'; got '%s'\n", want, got)
|
||||||
} else if got := settings.modtime; got.Equal(time.Time{}) {
|
} else if got := settings.modtime; got.Equal(time.Time{}) {
|
||||||
t.Fatalf("error: modtime == (time.Time{}) - not updated when initialized")
|
t.Fatalf("error: modtime == (time.Time{}) - not updated when initialized")
|
||||||
@@ -125,3 +127,155 @@ func TestSettings(t *testing.T) {
|
|||||||
t.Fatalf("error: wanted error indicating 'no such file or directory'; got %v\n", err)
|
t.Fatalf("error: wanted error indicating 'no such file or directory'; got %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDump(t *testing.T) {
|
||||||
|
dump, err := TheSettings.Dump()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error dumping settings: %v\n", err)
|
||||||
|
}
|
||||||
|
scanner := NewScanner(bytes.NewReader(dump))
|
||||||
|
var found int
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
key, value, err := scanner.ReadNextLine()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatalf("error reading next line: %v\n", err)
|
||||||
|
}
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
if want, got := TheSettings.GetKeyValue(key), value; want != got {
|
||||||
|
t.Fatalf("error: wanted key %q value %q; got %q\n", key, want, got)
|
||||||
|
}
|
||||||
|
found++
|
||||||
|
}
|
||||||
|
if want, got := len(TheSettings.contents), found; want != got {
|
||||||
|
t.Fatalf("error: wanted %d entries; got %d\n", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDumpJson(t *testing.T) {
|
||||||
|
marshaled, err := TheSettings.DumpToJson()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error dumping to json: %v\n", err)
|
||||||
|
}
|
||||||
|
var unmarshaled map[string]string
|
||||||
|
err = json.Unmarshal(marshaled, &unmarshaled)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error unmarshaling json dump: %v\n", err)
|
||||||
|
}
|
||||||
|
if want, got := TheSettings.contents, unmarshaled; !maps.Equal(want, got) {
|
||||||
|
t.Fatalf("error: wanted %v; got %v\n", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetKeyValue(t *testing.T) {
|
||||||
|
key, value := rand.Text(), rand.Text()
|
||||||
|
if !TheSettings.SetKeyValue(key, value) {
|
||||||
|
t.Fatalf("SetKeyValue didn't update?\n")
|
||||||
|
}
|
||||||
|
if want, got := value, TheSettings.GetKeyValue(key); want != got {
|
||||||
|
t.Fatalf("error: wanted %q; got %q\n", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBool(t *testing.T) {
|
||||||
|
TheTestItems := map[string]bool{
|
||||||
|
"yes": true,
|
||||||
|
"on": true,
|
||||||
|
"true": true,
|
||||||
|
"True": true,
|
||||||
|
"TRUE": true,
|
||||||
|
"no": false,
|
||||||
|
"off": false,
|
||||||
|
"false": false,
|
||||||
|
}
|
||||||
|
for boolString, want := range TheTestItems {
|
||||||
|
got, err := ParseBool(boolString)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error: %v\n", err)
|
||||||
|
}
|
||||||
|
if want != got {
|
||||||
|
t.Fatalf("error: wanted %s=%v; got %v\n", boolString, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TheBadTestItems := []string{
|
||||||
|
"blue",
|
||||||
|
"cat",
|
||||||
|
"https://google.com",
|
||||||
|
}
|
||||||
|
for _, boolString := range TheBadTestItems {
|
||||||
|
got, err := ParseBool(boolString)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("error: successfully parsed %s as bool %v...\n", boolString, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringOrDefault(t *testing.T) {
|
||||||
|
dump, err := TheSettings.Dump()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error getting dump: %v\n", err)
|
||||||
|
}
|
||||||
|
scanner := NewScanner(bytes.NewReader(dump))
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
key, value, err := scanner.ReadNextLine()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatalf("error reading from dump: %v\n", err)
|
||||||
|
}
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
if want, got := value, TheSettings.GetStringOrDefault(key, "SomeInvalidDefault"); want != got {
|
||||||
|
t.Fatalf("error: wanted key %q=%q; got %q\n", key, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
badKey := "some new key"
|
||||||
|
badValue := "some invalid value"
|
||||||
|
if want, got := badValue, TheSettings.GetStringOrDefault(badKey, badValue); want != got {
|
||||||
|
t.Fatalf("error: wanted key %q=%q; got %q\n", badKey, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBoolOrDefault(t *testing.T) {
|
||||||
|
key, value := "some bool value", "on"
|
||||||
|
TheSettings.SetKeyValue(key, value)
|
||||||
|
if want, got := true, TheSettings.GetBoolOrDefault(key, false); want != got {
|
||||||
|
t.Fatalf("error: wanted %q=%v; got %v\n", key, want, got)
|
||||||
|
}
|
||||||
|
value = "shark"
|
||||||
|
TheSettings.SetKeyValue(key, value)
|
||||||
|
if want, got := false, TheSettings.GetBoolOrDefault(key, false); want != got {
|
||||||
|
t.Fatalf("error: wanted %q=%v; got %v\n", key, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDurationOrDefault(t *testing.T) {
|
||||||
|
key, value := "some duration value", "10s"
|
||||||
|
TheSettings.SetKeyValue(key, value)
|
||||||
|
if want, got := time.Second*10, TheSettings.GetDurationOrDefault(key, time.Minute*10); want != got {
|
||||||
|
t.Fatalf("error: wanted %q=%s; got %s\n", key, want, got)
|
||||||
|
}
|
||||||
|
value = "loud"
|
||||||
|
TheSettings.SetKeyValue(key, value)
|
||||||
|
if want, got := time.Minute*10, TheSettings.GetDurationOrDefault(key, time.Minute*10); want != got {
|
||||||
|
t.Fatalf("error: wanted %q=%s; got %s\n", key, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIntOrDefault(t *testing.T) {
|
||||||
|
key, value := "some int value", "10"
|
||||||
|
TheSettings.SetKeyValue(key, value)
|
||||||
|
if want, got := 10, TheSettings.GetIntOrDefault(key, 200); want != got {
|
||||||
|
t.Fatalf("error: wanted %q=%d; got %d\n", key, want, got)
|
||||||
|
}
|
||||||
|
value = "cat"
|
||||||
|
TheSettings.SetKeyValue(key, value)
|
||||||
|
if want, got := 200, TheSettings.GetIntOrDefault(key, 200); want != got {
|
||||||
|
t.Fatalf("error: wanted default value %d for bad key; got %d\n", want, got)
|
||||||
|
}
|
||||||
|
if want, got := 200, TheSettings.GetIntOrDefault("some fake bad key", 200); want != got {
|
||||||
|
t.Fatalf("error: wanted default value %d for bad key; got %d\n", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ValueMonitorParseFunc[T any] func(string) (T, error)
|
||||||
|
|
||||||
|
type ValueMonitor[T any] struct {
|
||||||
|
mutex sync.RWMutex
|
||||||
|
parent *Settings
|
||||||
|
updates <-chan string
|
||||||
|
key string
|
||||||
|
parserFunc ValueMonitorParseFunc[T]
|
||||||
|
value T
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *ValueMonitor[T]) GetValue() T {
|
||||||
|
v.mutex.RLock()
|
||||||
|
defer v.mutex.RUnlock()
|
||||||
|
return v.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *ValueMonitor[T]) maintenanceThread() {
|
||||||
|
ctx := v.parent.context
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case s, ok := <-v.updates:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := v.parserFunc(s)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ValueMonitor error parsing key %q value %q from channel: %v\n", v.key, s, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
func() {
|
||||||
|
v.mutex.RLock()
|
||||||
|
defer v.mutex.RUnlock()
|
||||||
|
v.value = t
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewValueMonitor[T any](settings *Settings, key string, parserFunc ValueMonitorParseFunc[T]) (*ValueMonitor[T], error) {
|
||||||
|
monitorChannel := make(chan string, 1)
|
||||||
|
err := settings.RegisterKeyToMonitor(key, monitorChannel)
|
||||||
|
if err != nil {
|
||||||
|
close(monitorChannel)
|
||||||
|
return nil, fmt.Errorf("error registering ValueMonitor %w", err)
|
||||||
|
}
|
||||||
|
initialString, ok := <-monitorChannel
|
||||||
|
if !ok {
|
||||||
|
close(monitorChannel)
|
||||||
|
return nil, errors.New("channel closed before reading initial value")
|
||||||
|
}
|
||||||
|
parsed, err := parserFunc(initialString)
|
||||||
|
if err != nil {
|
||||||
|
close(monitorChannel)
|
||||||
|
return nil, fmt.Errorf("error parsing initial value from %q: %v", initialString, err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
<-settings.context.Done()
|
||||||
|
close(monitorChannel)
|
||||||
|
}()
|
||||||
|
results := &ValueMonitor[T]{
|
||||||
|
parent: settings,
|
||||||
|
updates: monitorChannel,
|
||||||
|
key: key,
|
||||||
|
parserFunc: parserFunc,
|
||||||
|
value: parsed,
|
||||||
|
}
|
||||||
|
go results.maintenanceThread()
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStringValueMonitor(t *testing.T) {
|
||||||
|
key, value := "my key to monitor", "first value"
|
||||||
|
monitor, err := NewValueMonitor(TheSettings, key, func(s string) (string, error) { return value, nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error opening new value monitor: %v\n", err)
|
||||||
|
}
|
||||||
|
if want, got := value, monitor.GetValue(); want != got {
|
||||||
|
t.Fatalf("error: wanted %q; got %q\n", want, got)
|
||||||
|
}
|
||||||
|
value = "second value"
|
||||||
|
TheSettings.SetKeyValue(key, value)
|
||||||
|
if want, got := value, monitor.GetValue(); want != got {
|
||||||
|
t.Fatalf("error: wanted %q; got %q\n", want, got)
|
||||||
|
}
|
||||||
|
value = "third value"
|
||||||
|
f, err := os.OpenFile(TheSettings.GetFilename(), os.O_APPEND|os.O_WRONLY, 0664)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error opening file writer to append settings: %v\n", err)
|
||||||
|
}
|
||||||
|
_, err = f.WriteString(fmt.Sprintf("\n%s=%s\n", key, value))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error appending to file: %v\n", err)
|
||||||
|
}
|
||||||
|
err = TheSettings.Update()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error updating the settings: %v\n", err)
|
||||||
|
}
|
||||||
|
if want, got := value, monitor.GetValue(); want != got {
|
||||||
|
t.Fatalf("error: wanted %q; got %q\n", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user