Share daily limits through the usage table, drop the midnight reset and the stats queue

The server builds the usage store on the user database (the shared pool on
Postgres, the auth-file on SQLite) and starts the limiter before serving. A
visitor without a user writes its message count from a tenth of the cap on,
everyone else from the first message; anonymous emails get the daily cap the
replenish interval implies. The account API reports reset_at. The user stats
columns are no longer read or written.

Claude-Session: https://claude.ai/code/session_01DeXuhSjq5Gta5qPJgSceFe
This commit is contained in:
binwiederhier committed 2026-10-30 20:41:36 +00:00
1 parent dad9cfa03b
commit c113e20677
17 files changed
+176 -394

No files matched your search

+19 -32
View File
@@ -301,7 +301,6 @@ func New(conf *Config) (*Server, error) {
messages: messages,
messagesHistory: []int64{messages},
messagesFlushed: messages, // The loaded total is already persisted; only new publishes are deltas
limiter: limit.New(&limit.Config{}),
stripe: stripe,
streams: newStreamRegistry(),
}
@@ -348,6 +347,9 @@ func New(conf *Config) (*Server, error) {
firebaseClient = newFirebaseClient(sender, auther)
}
s.userManager = userManager
s.limiter = limit.New(&limit.Config{
Store: createUsageStore(pool, userManager),
})
s.visitors = newVisitorEnv(conf, s.limiter, messageCache, userManager)
s.firebaseClient = firebaseClient
if pool != nil {
@@ -376,6 +378,9 @@ func New(conf *Config) (*Server, error) {
s.tail = tail
go s.runTail()
}
if err := s.limiter.Start(context.Background()); err != nil {
return nil, err
}
return s, nil
}
@@ -392,6 +397,18 @@ func (s *Server) dbPoolStats() []*metrics.DBPool {
return pools
}
// createUsageStore returns the store of the usage table, which is part of the user database: the
// shared pool on Postgres, the auth-file on SQLite. Without a user database, the daily counts are
// kept in memory only.
func createUsageStore(pool *db.DB, userManager *user.Manager) *limit.Store {
if pool != nil {
return limit.NewPostgresStore(pool)
} else if userManager != nil {
return limit.NewSQLiteStore(userManager.DB())
}
return nil
}
func createMessageCache(conf *Config, pool *db.DB) (*message.Cache, error) {
if conf.CacheDuration == 0 {
return message.NewNopStore()
@@ -532,7 +549,6 @@ func (s *Server) Run() error {
}
s.mu.Unlock()
go s.runManager()
go s.runStatsResetter()
go s.runDelayedSender()
go s.runFirebaseKeepaliver()
@@ -585,6 +601,7 @@ func (s *Server) stop() {
if s.tail != nil {
s.tail.Close() // Before the databases close under it
}
s.limiter.Close() // Writes the unflushed daily counts, before the databases close under it
s.closeDatabases()
if s.ban != nil {
s.ban.Close()
@@ -2136,36 +2153,6 @@ func (s *Server) runManager() {
}
}
// runStatsResetter runs once a day (usually midnight UTC) to reset all the visitor's message and
// email counters. The stats are used to display the counters in the web app, as well as for rate limiting.
func (s *Server) runStatsResetter() {
for {
runAt := util.NextOccurrenceUTC(s.config.VisitorStatsResetTime, time.Now())
timer := time.NewTimer(time.Until(runAt))
log.Tag(tagResetter).Debug("Waiting until %v to reset visitor stats", runAt)
select {
case <-timer.C:
log.Tag(tagResetter).Debug("Running stats resetter")
s.resetStats()
case <-s.closeChan:
log.Tag(tagResetter).Debug("Stopping stats resetter")
timer.Stop()
return
}
}
}
func (s *Server) resetStats() {
log.Info("Resetting all visitor stats (daily task)")
s.mu.Lock()
defer s.mu.Unlock() // Includes the database query to avoid races with other processes
if s.userManager != nil {
if err := s.userManager.ResetStats(); err != nil {
log.Tag(tagResetter).Warn("Failed to write to database: %s", err.Error())
}
}
}
func (s *Server) runFirebaseKeepaliver() {
if s.firebaseClient == nil {
return