refactor(metrics): count entities on demand with a TTL cache
Instead of priming a counter at startup and keeping it in sync via events, each entity count is now read directly from the database and cached for 30s (countCacheTTL). The cache is the correctness guarantee: counts are at most one TTL stale and self-healing, so they can never permanently drift. This fixes vikunja_user_count never updating after registration (#2650): the count no longer depends on every mutation path dispatching an event.
This commit is contained in:
parent
ec2f154e10
commit
051f734f3d
|
|
@ -17,8 +17,9 @@
|
|||
package metrics
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/modules/keyvalue"
|
||||
|
||||
|
|
@ -36,6 +37,22 @@ const (
|
|||
AttachmentsCountKey = `attachments_count`
|
||||
)
|
||||
|
||||
// countCacheTTL is how long a cached entity count is served before it is recomputed
|
||||
// from the database. The counts are inherently approximate (Prometheus samples them),
|
||||
// so a short staleness window is fine and keeps the cache self-healing — a missed
|
||||
// InvalidateCount call costs at most this much staleness, never a permanent drift.
|
||||
const countCacheTTL = 30 * time.Second
|
||||
|
||||
// countTables maps each count metric key to the database table it counts.
|
||||
var countTables = map[string]string{
|
||||
ProjectCountKey: "projects",
|
||||
UserCountKey: "users",
|
||||
TaskCountKey: "tasks",
|
||||
TeamCountKey: "teams",
|
||||
FilesCountKey: "files",
|
||||
AttachmentsCountKey: "task_attachments",
|
||||
}
|
||||
|
||||
var registry *prometheus.Registry
|
||||
|
||||
func GetRegistry() *prometheus.Registry {
|
||||
|
|
@ -53,7 +70,10 @@ func registerPromMetric(key, description string) {
|
|||
Name: "vikunja_" + key,
|
||||
Help: description,
|
||||
}, func() float64 {
|
||||
count, _ := GetCount(key)
|
||||
count, err := GetCount(key)
|
||||
if err != nil {
|
||||
log.Errorf("Could not get count for metric %s: %s", key, err)
|
||||
}
|
||||
return float64(count)
|
||||
}))
|
||||
if err != nil {
|
||||
|
|
@ -65,8 +85,8 @@ func registerPromMetric(key, description string) {
|
|||
func InitMetrics() {
|
||||
GetRegistry()
|
||||
|
||||
registerPromMetric(ProjectCountKey, "The number of projects on this instance")
|
||||
registerPromMetric(UserCountKey, "The total number of shares on this instance")
|
||||
registerPromMetric(ProjectCountKey, "The total number of projects on this instance")
|
||||
registerPromMetric(UserCountKey, "The total number of users on this instance")
|
||||
registerPromMetric(TaskCountKey, "The total number of tasks on this instance")
|
||||
registerPromMetric(TeamCountKey, "The total number of teams on this instance")
|
||||
registerPromMetric(FilesCountKey, "The total number of files on this instance")
|
||||
|
|
@ -76,26 +96,31 @@ func InitMetrics() {
|
|||
setupActiveLinkSharesMetric()
|
||||
}
|
||||
|
||||
// GetCount returns the current count from keyvalue
|
||||
func GetCount(key string) (count int64, err error) {
|
||||
cnt, exists, err := keyvalue.Get(key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !exists {
|
||||
// GetCount returns the current count for the given metric key. The value is counted
|
||||
// directly from the database and cached for countCacheTTL, so repeated scrapes don't
|
||||
// hit the database on every request.
|
||||
func GetCount(key string) (int64, error) {
|
||||
return keyvalue.RememberFor(key, countCacheTTL, func() (int64, error) {
|
||||
return countFromDatabase(key)
|
||||
})
|
||||
}
|
||||
|
||||
// countFromDatabase runs a COUNT(*) for the table backing the given metric key.
|
||||
func countFromDatabase(key string) (int64, error) {
|
||||
table, has := countTables[key]
|
||||
if !has {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if s, is := cnt.(string); is {
|
||||
count, err = strconv.ParseInt(s, 10, 64)
|
||||
} else {
|
||||
count = cnt.(int64)
|
||||
}
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
return
|
||||
return s.Table(table).Count()
|
||||
}
|
||||
|
||||
// SetCount sets the project count to a given value
|
||||
func SetCount(count int64, key string) error {
|
||||
return keyvalue.Put(key, count)
|
||||
// InvalidateCount drops the cached count for a key so the next read recomputes it from
|
||||
// the database. Use it where instant freshness is worth the extra COUNT(*); everywhere
|
||||
// else the countCacheTTL keeps the value reasonably up to date on its own.
|
||||
func InvalidateCount(key string) error {
|
||||
return keyvalue.Del(key)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,10 @@ import (
|
|||
"crypto/subtle"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/files"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/metrics"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
auth2 "code.vikunja.io/api/pkg/modules/auth"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
|
|
@ -39,47 +37,6 @@ func setupMetrics(a *echo.Group) {
|
|||
|
||||
metrics.InitMetrics()
|
||||
|
||||
type countable struct {
|
||||
Key string
|
||||
Type interface{}
|
||||
}
|
||||
|
||||
for _, c := range []countable{
|
||||
{
|
||||
metrics.ProjectCountKey,
|
||||
models.Project{},
|
||||
},
|
||||
{
|
||||
metrics.UserCountKey,
|
||||
user.User{},
|
||||
},
|
||||
{
|
||||
metrics.TaskCountKey,
|
||||
models.Task{},
|
||||
},
|
||||
{
|
||||
metrics.TeamCountKey,
|
||||
models.Team{},
|
||||
},
|
||||
{
|
||||
metrics.FilesCountKey,
|
||||
files.File{},
|
||||
},
|
||||
{
|
||||
metrics.AttachmentsCountKey,
|
||||
models.TaskAttachment{},
|
||||
},
|
||||
} {
|
||||
// Set initial totals
|
||||
total, err := models.GetTotalCount(c.Type)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not get initial count for %v, error was %s", c.Type, err)
|
||||
}
|
||||
if err := metrics.SetCount(total, c.Key); err != nil {
|
||||
log.Fatalf("Could not set initial count for %v, error was %s", c.Type, err)
|
||||
}
|
||||
}
|
||||
|
||||
r := a.Group("/metrics")
|
||||
|
||||
if config.MetricsUsername.GetString() != "" && config.MetricsPassword.GetString() != "" {
|
||||
|
|
|
|||
Loading…
Reference in New Issue