Config: Add a simple cache to reduce disk I/O under stress

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-10-20 01:41:19 +02:00
parent f23069dd2c
commit 3624e73d36
6 changed files with 62 additions and 19 deletions

View file

@ -43,6 +43,7 @@ import (
_ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/klauspost/cpuid/v2"
gc "github.com/patrickmn/go-cache"
"github.com/pbnjay/memory"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
@ -82,6 +83,7 @@ type Config struct {
env string
start bool
ready atomic.Bool
cache *gc.Cache
}
// Values is a shorthand alias for map[string]interface{}.
@ -163,6 +165,7 @@ func NewConfig(ctx *cli.Context) *Config {
token: rnd.Base36(8),
env: os.Getenv("DOCKER_ENV"),
start: start,
cache: gc.New(time.Minute, 10*time.Minute),
}
// Override options with values from the "options.yml" file, if it exists.

View file

@ -131,33 +131,42 @@ func (c *Config) NodeThemeVersion() string {
// lazily loads the token from disk (or generates a new one) and caches it in
// memory. Example format: k9sEFe6-A7gt6zqm-gY9gFh0.
func (c *Config) JoinToken() string {
if s := strings.TrimSpace(c.options.JoinToken); rnd.IsJoinToken(s, false) {
c.options.JoinToken = s
return s
// Read token from config options (memory).
if rnd.IsJoinToken(c.options.JoinToken, false) {
return c.options.JoinToken
}
if fileName := c.JoinTokenFile(); fs.FileExistsNotEmpty(fileName) {
if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
log.Warnf("config: could not read cluster join token from %s (%s)", fileName, err)
} else if s := strings.TrimSpace(string(b)); rnd.IsJoinToken(s, false) {
c.options.JoinToken = s
return s
} else {
log.Warnf("config: cluster join token from %s is shorter than %d characters", fileName, rnd.JoinTokenLength)
// Read token from file if possible. Uses a cache to reduce I/O.
if fileName := c.JoinTokenFile(); fileName != "" {
if c.cache == nil {
// Skip cache lookup.
} else if s, hit := c.cache.Get(fileName); hit && s != nil {
return s.(string)
}
if fs.FileExistsNotEmpty(fileName) {
if b, err := os.ReadFile(fileName); err != nil || len(b) == 0 {
log.Warnf("config: could not read cluster join token from %s (%s)", fileName, err)
} else if s := strings.TrimSpace(string(b)); rnd.IsJoinToken(s, false) {
if c.cache != nil {
c.cache.SetDefault(fileName, s)
}
return s
} else {
log.Warnf("config: cluster join token from %s is shorter than %d characters", fileName, rnd.JoinTokenLength)
}
}
}
// Do not proceed with generating a token on nodes.
if !c.Portal() {
return ""
}
token, _, err := c.SaveJoinToken("")
if err != nil {
} else if token, _, err := c.SaveJoinToken(""); err != nil {
log.Errorf("config: %v", err)
return ""
} else {
return token
}
return token
}
// SaveJoinToken writes a fresh portal join token to disk and updates the
@ -194,11 +203,20 @@ func (c *Config) SaveJoinToken(customToken string) (token string, fileName strin
return "", "", fmt.Errorf("could not write cluster join token (%w)", err)
}
c.options.JoinToken = token
if c.cache != nil {
c.cache.SetDefault(fileName, token)
}
return token, fileName, nil
}
// clearJoinTokenFileCache invalidates the cached join token file cache.
func (c *Config) clearJoinTokenFileCache() {
if c.cache != nil {
c.cache.Delete(c.JoinTokenFile())
}
}
// JoinTokenFile returns the path where the portal join token is stored for the
// active configuration (portal nodes use config/portal/secrets/join_token,
// regular nodes use config/node/secrets/join_token).

View file

@ -438,6 +438,13 @@ func TestConfig_Cluster(t *testing.T) {
assert.Equal(t, cluster.ExampleClientSecret, c.NodeClientSecret())
assert.Equal(t, cluster.ExampleJoinTokenAlt, c.JoinToken())
// Refreshing the token file should invalidate the cache.
time.Sleep(5 * time.Millisecond)
newToken := cluster.ExampleJoinToken
assert.NoError(t, os.WriteFile(tkFile, []byte(newToken), fs.ModeSecretFile))
c.clearJoinTokenFileCache()
assert.Equal(t, newToken, c.JoinToken())
// Empty / missing should yield empty strings.
t.Setenv("PHOTOPRISM_NODE_CLIENT_SECRET_FILE", filepath.Join(dir, "missing"))
t.Setenv("PHOTOPRISM_JOIN_TOKEN_FILE", filepath.Join(dir, "missing"))

View file

@ -3,7 +3,9 @@ package config
import (
"strings"
"testing"
"time"
gc "github.com/patrickmn/go-cache"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/fs"
@ -233,6 +235,7 @@ func TestConfig_CreateDirectories(t *testing.T) {
c := &Config{
options: NewTestOptions("config"),
token: rnd.Base36(8),
cache: gc.New(time.Second, time.Minute),
}
assert.NoError(t, c.CreateDirectories())
@ -244,6 +247,7 @@ func TestConfig_CreateDirectories(t *testing.T) {
c := &Config{
options: NewTestOptions("config"),
token: rnd.Base36(8),
cache: gc.New(time.Second, time.Minute),
}
c.options.StoragePath = "./testdata"

View file

@ -11,6 +11,7 @@ import (
"testing"
"time"
gc "github.com/patrickmn/go-cache"
"github.com/urfave/cli/v2"
_ "github.com/jinzhu/gorm/dialects/mysql"
@ -293,6 +294,7 @@ func NewIsolatedTestConfig(dbName, dataPath string, createDirs bool) *Config {
c := &Config{
options: opts,
token: rnd.Base36(8),
cache: gc.New(time.Second, time.Minute),
}
if !createDirs {
@ -318,6 +320,7 @@ func NewTestConfig(dbName string) *Config {
cliCtx: CliTestContext(),
options: NewTestOptions(dbName),
token: rnd.Base36(8),
cache: gc.New(time.Second, time.Minute),
}
s := customize.NewSettings(c.DefaultTheme(), c.DefaultLocale(), c.DefaultTimezone().String())
@ -351,7 +354,10 @@ func NewTestConfig(dbName string) *Config {
// NewTestErrorConfig returns an invalid test config.
func NewTestErrorConfig() *Config {
c := &Config{options: NewTestOptionsError()}
c := &Config{
options: NewTestOptionsError(),
cache: gc.New(time.Second, time.Minute),
}
return c
}

View file

@ -132,6 +132,11 @@ func isJoinTokenSeparatorIndex(i int) bool {
// IsJoinToken checks if the string represents a join token.
func IsJoinToken(s string, strict bool) bool {
// Basic mode: No token, not valid.
if s == "" {
return false
}
// Non-strict mode: only enforce minimum length so legacy tokens that were
// longer than the auto-generated format continue to work.
if !strict {