From 4492d824cb8435d9a5cb45a93d6ed0294c791d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Tue, 1 Jul 2025 19:43:04 +0200 Subject: [PATCH] Manager: Add custom Duration type for the configuration file Add a custom `time.Duration` wrapper that can be marshalled to JSON as well. Go's built-in marshaller just treats it as an `int64` and therefore the values are nanoseconds. This new wrapper keeps the JSON representation the same as the YAML marshaller (which uses the `time.Duration.String()` function). In preparation for !104406. --- cmd/flamenco-manager/main.go | 6 +- internal/manager/config/config.go | 12 +- internal/manager/config/defaults.go | 6 +- internal/manager/config/time_duration.go | 57 ++++++++++ internal/manager/config/time_duration_test.go | 104 ++++++++++++++++++ 5 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 internal/manager/config/time_duration.go create mode 100644 internal/manager/config/time_duration_test.go diff --git a/cmd/flamenco-manager/main.go b/cmd/flamenco-manager/main.go index 4a183576..33eec1fb 100644 --- a/cmd/flamenco-manager/main.go +++ b/cmd/flamenco-manager/main.go @@ -193,8 +193,8 @@ func runFlamencoManager() bool { e := buildWebService(flamenco, persist, ssdp, socketio, urls, localStorage) timeoutChecker := timeout_checker.New( - configService.Get().TaskTimeout, - configService.Get().WorkerTimeout, + time.Duration(configService.Get().TaskTimeout), + time.Duration(configService.Get().WorkerTimeout), timeService, persist, taskStateMachine, logStorage, eventBroker) // The main context determines the lifetime of the application. All @@ -240,7 +240,7 @@ func runFlamencoManager() bool { go func() { defer wg.Done() persist.PeriodicIntegrityCheck(mainCtx, - configService.Get().DBIntegrityCheck, + time.Duration(configService.Get().DBIntegrityCheck), mainCtxCancel) }() diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 0129c923..7a013add 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -75,8 +75,8 @@ type Base struct { ManagerName string `yaml:"manager_name"` - DatabaseDSN string `yaml:"database"` - DBIntegrityCheck time.Duration `yaml:"database_check_period"` + DatabaseDSN string `yaml:"database"` + DBIntegrityCheck Duration `yaml:"database_check_period"` Listen string `yaml:"listen"` @@ -92,8 +92,8 @@ type Base struct { Shaman shaman_config.Config `yaml:"shaman"` - TaskTimeout time.Duration `yaml:"task_timeout"` - WorkerTimeout time.Duration `yaml:"worker_timeout"` + TaskTimeout Duration `yaml:"task_timeout"` + WorkerTimeout Duration `yaml:"worker_timeout"` /* This many failures (on a given job+task type combination) will ban a worker * from that task type on that job. */ @@ -109,9 +109,9 @@ type Base struct { // GarbageCollect contains the config options for the GC. type ShamanGarbageCollect struct { // How frequently garbage collection is performed on the file store: - Period time.Duration `yaml:"period"` + Period Duration `yaml:"period"` // How old files must be before they are GC'd: - MaxAge time.Duration `yaml:"maxAge"` + MaxAge Duration `yaml:"maxAge"` // Paths to check for symlinks before GC'ing files. ExtraCheckoutDirs []string `yaml:"extraCheckoutPaths"` diff --git a/internal/manager/config/defaults.go b/internal/manager/config/defaults.go index d3cdc406..1b607fc3 100644 --- a/internal/manager/config/defaults.go +++ b/internal/manager/config/defaults.go @@ -20,7 +20,7 @@ var defaultConfig = Conf{ ManagerName: "Flamenco", Listen: ":8080", DatabaseDSN: "flamenco-manager.sqlite", - DBIntegrityCheck: 10 * time.Minute, + DBIntegrityCheck: Duration(10 * time.Minute), SSDPDiscovery: true, LocalManagerStoragePath: "./flamenco-manager-storage", SharedStoragePath: "", // Empty string means "first run", and should trigger the config setup assistant. @@ -34,8 +34,8 @@ var defaultConfig = Conf{ }, }, - TaskTimeout: 10 * time.Minute, - WorkerTimeout: 1 * time.Minute, + TaskTimeout: Duration(10 * time.Minute), + WorkerTimeout: Duration(1 * time.Minute), BlocklistThreshold: 3, TaskFailAfterSoftFailCount: 3, diff --git a/internal/manager/config/time_duration.go b/internal/manager/config/time_duration.go new file mode 100644 index 00000000..ab762dc5 --- /dev/null +++ b/internal/manager/config/time_duration.go @@ -0,0 +1,57 @@ +package config + +// SPDX-License-Identifier: GPL-3.0-or-later + +import ( + "encoding/json" + "errors" + "time" + + yaml "gopkg.in/yaml.v2" +) + +// Duration is a time.Duration with custom JSON/YAML marshallers. +type Duration time.Duration + +var _ json.Unmarshaler = (*Duration)(nil) +var _ json.Marshaler = Duration(0) +var _ yaml.Unmarshaler = (*Duration)(nil) +var _ yaml.Marshaler = Duration(0) + +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Duration(d).String()) +} + +func (d *Duration) UnmarshalJSON(b []byte) error { + var v any + if err := json.Unmarshal(b, &v); err != nil { + return err + } + return d.unmarshal(v) +} + +func (d Duration) MarshalYAML() (any, error) { + return time.Duration(d).String(), nil +} + +func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error { + stringValue := "" + if err := unmarshal(&stringValue); err != nil { + return err + } + return d.unmarshal(stringValue) +} + +func (d *Duration) unmarshal(v any) error { + switch value := v.(type) { + case string: + timeDuration, err := time.ParseDuration(value) + if err != nil { + return err + } + *d = Duration(timeDuration) + return nil + default: + return errors.New("invalid duration") + } +} diff --git a/internal/manager/config/time_duration_test.go b/internal/manager/config/time_duration_test.go new file mode 100644 index 00000000..54863d59 --- /dev/null +++ b/internal/manager/config/time_duration_test.go @@ -0,0 +1,104 @@ +package config + +// SPDX-License-Identifier: GPL-3.0-or-later + +import ( + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/assert" + yaml "gopkg.in/yaml.v2" +) + +func TestDuration_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + want Duration + input []byte + wantErr bool + }{ + {"60s", Duration(time.Second * 60), []byte(`"60s"`), false}, + {"1m", Duration(time.Second * 60), []byte(`"1m"`), false}, + {"int", Duration(0), []byte("1"), true}, + {"float", Duration(0), []byte("1.0"), true}, + {"empty", Duration(0), []byte{}, true}, + {"undefined", Duration(0), []byte("undefined"), true}, + {"null", Duration(0), []byte("null"), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got Duration + err := got.UnmarshalJSON(tt.input) + + if (err != nil) != tt.wantErr { + t.Errorf("Duration.UnmarshalJSON(%v) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Duration.UnmarshalJSON(%v) got = %v, want = %v", tt.input, got, tt.want) + } + }) + } +} + +func TestDuration_MarshalJSON(t *testing.T) { + tests := []struct { + name string + want []byte + input Duration + wantErr bool + }{ + {"zero", []byte(`"0s"`), Duration(0), false}, + {"1ns", []byte(`"1ns"`), Duration(1), false}, + {"1m", []byte(`"1m0s"`), Duration(time.Second * 60), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.input.MarshalJSON() + + if (err != nil) != tt.wantErr { + t.Errorf("Duration.MarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Duration.MarshalJSON() got = %v, want = %v", string(got), string(tt.want)) + } + }) + } +} + +func TestDuration_JSONDocument(t *testing.T) { + type TestStruct struct { + TestValue Duration `json:"test_value"` + } + + testValue := TestStruct{Duration(time.Hour * 3)} + jsonBytes, err := json.Marshal(testValue) + assert.NoError(t, err) + + assert.Equal(t, `{"test_value":"3h0m0s"}`, string(jsonBytes)) + + roundtripValue := TestStruct{} + err = json.Unmarshal(jsonBytes, &roundtripValue) + assert.NoError(t, err) + assert.Equal(t, testValue, roundtripValue) +} + +func TestDuration_YAMLDocument(t *testing.T) { + type TestStruct struct { + TestValue Duration `yaml:"test_value"` + } + + testValue := TestStruct{Duration(time.Hour * 3)} + yamlBytes, err := yaml.Marshal(testValue) + assert.NoError(t, err) + + assert.Equal(t, "test_value: 3h0m0s\n", string(yamlBytes)) + + roundtripValue := TestStruct{} + err = yaml.Unmarshal(yamlBytes, &roundtripValue) + assert.NoError(t, err) + assert.Equal(t, testValue, roundtripValue) +}