mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-12 16:02:44 +00:00
156 lines
4.7 KiB
Go
156 lines
4.7 KiB
Go
package wmail
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// syncStateHeartbeat bounds how often an unchanged state is still relayed, so
|
|
// last_synced_at stays fresh without a database write per mailbox per tick.
|
|
const syncStateHeartbeat = 10 * time.Minute
|
|
|
|
// syncTracker is the worker's live copy of a mailbox's sync state. Every
|
|
// mutation goes through it so the change is relayed as SYNC_STATE at the end
|
|
// of the tick; the consumer persists it and the loader hands it back on the
|
|
// next assignment.
|
|
type syncTracker struct {
|
|
state models.SyncState
|
|
dirty bool
|
|
lastSent time.Time
|
|
emit func(models.SyncState) error
|
|
}
|
|
|
|
func newSyncTracker(seed *models.SyncState, emit func(models.SyncState) error) *syncTracker {
|
|
t := &syncTracker{emit: emit}
|
|
if seed != nil {
|
|
t.state = *seed
|
|
}
|
|
if t.state.BackfillStatus == "" {
|
|
t.state.BackfillStatus = models.SyncBackfillPending
|
|
}
|
|
if t.state.BackfillCursor.Folders == nil {
|
|
t.state.BackfillCursor.Folders = map[string]models.SyncFolderCursor{}
|
|
}
|
|
return t
|
|
}
|
|
|
|
func (t *syncTracker) mark() { t.dirty = true }
|
|
|
|
// throttle records that fair use is holding live mail. Only a later Until
|
|
// extends the hold; a burst denial never shortens a daily one.
|
|
func (t *syncTracker) throttle(a Admission) {
|
|
if a.OK {
|
|
return
|
|
}
|
|
if t.state.ThrottledUntil == nil || a.Until.After(*t.state.ThrottledUntil) {
|
|
until := a.Until
|
|
t.state.ThrottledUntil = &until
|
|
t.state.ThrottleReason = a.Reason
|
|
t.dirty = true
|
|
}
|
|
}
|
|
|
|
// release clears an expired hold. Called at the start of a tick so a mailbox
|
|
// that is back within budget reports itself so even before it stores anything.
|
|
func (t *syncTracker) release(now time.Time) {
|
|
if t.state.ThrottledUntil != nil && !t.state.ThrottledUntil.After(now) {
|
|
t.state.ThrottledUntil = nil
|
|
t.state.ThrottleReason = ""
|
|
t.dirty = true
|
|
}
|
|
}
|
|
|
|
func (t *syncTracker) setDeferred(n int) {
|
|
if t.state.Deferred != n {
|
|
t.state.Deferred = n
|
|
t.dirty = true
|
|
}
|
|
}
|
|
|
|
// setFoldersSkipped records what the folder listing could not follow. Both
|
|
// are conditions the user can fix, so they are relayed every pass and clear
|
|
// themselves rather than leaving a warning nobody can withdraw.
|
|
func (t *syncTracker) setFoldersSkipped(cap, conflict int) {
|
|
if t.state.FoldersSkippedCap != cap || t.state.FoldersSkippedConflict != conflict {
|
|
t.state.FoldersSkippedCap = cap
|
|
t.state.FoldersSkippedConflict = conflict
|
|
t.dirty = true
|
|
}
|
|
}
|
|
|
|
// touch stamps the tick and relays the state when it changed or the
|
|
// heartbeat is due.
|
|
func (t *syncTracker) touch(now time.Time) {
|
|
stamp := now
|
|
t.state.LastSyncedAt = &stamp
|
|
if !t.dirty && now.Sub(t.lastSent) < syncStateHeartbeat {
|
|
return
|
|
}
|
|
if err := t.emit(t.state); err != nil {
|
|
log.Warn().Err(err).Msg("sync state relay failed")
|
|
return
|
|
}
|
|
t.dirty = false
|
|
t.lastSent = now
|
|
}
|
|
|
|
// startBackfill fixes the window when the import first runs, so an operator
|
|
// changing the setting later does not move the goalposts mid-walk.
|
|
func (t *syncTracker) startBackfill(now time.Time, days int) {
|
|
if t.state.BackfillStatus != models.SyncBackfillPending {
|
|
return
|
|
}
|
|
since := now.Add(-time.Duration(days) * 24 * time.Hour)
|
|
t.state.BackfillSince = &since
|
|
t.state.BackfillStartedAt = &now
|
|
t.state.BackfillStatus = models.SyncBackfillRunning
|
|
t.dirty = true
|
|
}
|
|
|
|
func (t *syncTracker) completeBackfill(now time.Time) {
|
|
if t.state.BackfillStatus == models.SyncBackfillComplete {
|
|
return
|
|
}
|
|
t.state.BackfillStatus = models.SyncBackfillComplete
|
|
t.state.BackfillCompletedAt = &now
|
|
t.dirty = true
|
|
}
|
|
|
|
func (t *syncTracker) folder(key string) models.SyncFolderCursor {
|
|
return t.state.BackfillCursor.Folders[key]
|
|
}
|
|
|
|
func (t *syncTracker) setFolder(key string, c models.SyncFolderCursor) {
|
|
t.state.BackfillCursor.Folders[key] = c
|
|
t.dirty = true
|
|
}
|
|
|
|
// clearFolder forgets a folder's backfill floor.
|
|
//
|
|
// It has to go when the folder does. A name is reusable, so a floor left
|
|
// behind is inherited by whatever is created under that name next: a "done"
|
|
// cursor skips the new folder's history entirely, and a UID floor skips
|
|
// everything below it. Neither shows up as an error, because the messages are
|
|
// not new mail either; they are simply never imported.
|
|
func (t *syncTracker) clearFolder(name string) {
|
|
if _, ok := t.state.BackfillCursor.Folders[name]; !ok {
|
|
return
|
|
}
|
|
delete(t.state.BackfillCursor.Folders, name)
|
|
t.dirty = true
|
|
}
|
|
|
|
// renameFolder moves a folder's backfill floor to its new name, so a rename
|
|
// costs nothing rather than restarting the folder's import from the top.
|
|
func (t *syncTracker) renameFolder(from, to string) {
|
|
cur, ok := t.state.BackfillCursor.Folders[from]
|
|
if !ok {
|
|
return
|
|
}
|
|
delete(t.state.BackfillCursor.Folders, from)
|
|
t.state.BackfillCursor.Folders[to] = cur
|
|
t.dirty = true
|
|
}
|