This commit is contained in:
2026-08-10 16:20:59 +02:00
parent 005fd6ca51
commit bb19bbd30e
19 changed files with 2491 additions and 33 deletions
+192 -7
View File
@@ -37,6 +37,7 @@ type Server struct {
hub *wsx.Hub
runtime *rtx.State
artifactWorker *artifact.Worker
lottery *guessLottery
adminUser, adminPass, staticDir, artifactDir string
upgrader websocket.Upgrader
}
@@ -49,6 +50,7 @@ func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub,
hub: hub,
runtime: runtimeState,
artifactWorker: artifactWorker,
lottery: newGuessLottery(),
adminUser: env("ADMIN_USER", "admin"),
adminPass: env("ADMIN_PASSWORD", "change-me"),
staticDir: env("STATIC_DIR", ""),
@@ -178,6 +180,8 @@ func (s *Server) Routes() http.Handler {
r.Use(func(n http.Handler) http.Handler { return s.require("admin", n) })
r.Get("/api/admin/overview", s.adminOverview)
r.Get("/api/admin/performance", s.adminPerformance)
r.Get("/api/admin/profiles/cleanup-preview", s.adminProfileCleanupPreview)
r.Post("/api/admin/profiles/cleanup", s.adminProfileCleanup)
r.Get("/api/admin/settings", s.adminSettingsGet)
r.Put("/api/admin/settings", s.adminSettingsPut)
r.Get("/api/admin/tasks", s.adminTasks)
@@ -349,6 +353,8 @@ func taskDTO(t data.Task, next int64, sm settings.Runtime) map[string]any {
"next_seq": next,
"server_min_interval_sec": serverMin,
"client_submit_interval_sec": clientSubmit,
"guess_lottery_window_sec": sm.GuessLotteryWindowSec,
"guess_lottery_max_accepted": sm.GuessLotteryMaxAccepted,
"default_max_nodes": sm.DefaultMaxNodes,
"paused": t.Paused,
"revision": t.Revision,
@@ -495,6 +501,78 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 401, false)
return
}
if _, ok := s.runtime.Current(t.Task, c.ClientID); !ok {
snap, _ := s.store.LoadGuessState(r.Context(), t.ID, c.ClientID)
s.runtime.InitGuess(t.Task, c.ClientID, rtx.GuessState{NextSeq: snap.NextSeq, LastGuess: snap.LastGuess, BestScore: snap.BestScore, GuessCount: snap.GuessCount})
}
cfg := s.settings.Get()
serverMin, _ := taskIntervals(t.Task, cfg)
minInterval := time.Duration(serverMin) * time.Second
if err := s.runtime.CanSubmit(t.Task, c.ClientID, in.Seq, minInterval); err != nil {
switch {
case errors.Is(err, rtx.ErrRateLimited):
jsonOut(w, 429, false)
case errors.Is(err, rtx.ErrBadSequence):
expected := int64(0)
if cur, ok := s.runtime.Current(t.Task, c.ClientID); ok {
expected = cur.NextSeq
}
jsonAPIError(w, http.StatusConflict, "sequence_mismatch", "guess sequence is stale", map[string]any{"next_seq": expected})
default:
jsonOut(w, 500, false)
}
return
}
if cfg.GuessLotteryMaxAccepted > 0 {
selected, drawErr := s.lottery.enter(r.Context(), t.ID, c.ClientID, in.Seq, time.Duration(cfg.GuessLotteryWindowSec)*time.Second, cfg.GuessLotteryMaxAccepted)
if drawErr != nil {
switch {
case errors.Is(drawErr, errLotteryDuplicate):
jsonAPIError(w, http.StatusConflict, "lottery_duplicate", "guess is already waiting for the current draw", nil)
case errors.Is(drawErr, errLotteryFull):
jsonAPIError(w, http.StatusTooManyRequests, "lottery_full", "guess lottery window is full", nil)
case errors.Is(drawErr, context.Canceled), errors.Is(drawErr, context.DeadlineExceeded):
return
default:
jsonOut(w, 500, false)
}
return
}
// A task may have been rerolled, paused or completed while this request
// waited for the draw. Never consume/evaluate a stale lottery ticket.
fresh, freshErr := s.store.SecretTask(r.Context(), id)
if freshErr != nil || fresh.Status != "active" {
jsonAPIError(w, http.StatusConflict, "task_inactive", "task is no longer active", nil)
return
}
if fresh.Paused {
jsonOut(w, 423, false)
return
}
if fresh.PublicSeed != t.PublicSeed || fresh.Revision != t.Revision {
jsonAPIError(w, http.StatusConflict, "task_config_changed", "task configuration changed during lottery; resync required", map[string]any{"revision": fresh.Revision})
return
}
t = fresh
if !selected {
next, skipErr := s.runtime.SkipLottery(t.Task, c.ClientID, in.Seq, minInterval)
if skipErr != nil {
if errors.Is(skipErr, rtx.ErrBadSequence) {
jsonAPIError(w, http.StatusConflict, "sequence_mismatch", "guess sequence changed while waiting for lottery", map[string]any{"next_seq": next})
} else if errors.Is(skipErr, rtx.ErrRateLimited) {
jsonOut(w, 429, false)
} else {
jsonOut(w, 500, false)
}
return
}
jsonAPIError(w, http.StatusTooManyRequests, "lottery_not_selected", "guess was not selected in this lottery window", map[string]any{"next_seq": next})
return
}
}
d, err := core.Distance(in.Guess, t.Secret)
if err != nil {
jsonOut(w, 400, false)
@@ -502,12 +580,7 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
}
correct := d.Sign() == 0
score := core.Score(d, t.RangeBits)
if _, ok := s.runtime.Current(t.Task, c.ClientID); !ok {
snap, _ := s.store.LoadGuessState(r.Context(), t.ID, c.ClientID)
s.runtime.InitGuess(t.Task, c.ClientID, rtx.GuessState{NextSeq: snap.NextSeq, LastGuess: snap.LastGuess, BestScore: snap.BestScore, GuessCount: snap.GuessCount})
}
serverMin, _ := taskIntervals(t.Task, s.settings.Get())
accepted, err := s.runtime.Accept(t.Task, c.ClientID, in.Seq, score, time.Duration(serverMin)*time.Second)
accepted, err := s.runtime.Accept(t.Task, c.ClientID, in.Seq, score, minInterval)
if err != nil {
switch {
case errors.Is(err, rtx.ErrRateLimited):
@@ -736,6 +809,108 @@ func (s *Server) serveAdminArtifactPart(w http.ResponseWriter, r *http.Request,
http.ServeFile(w, r, path)
}
type profileCleanupPreview struct {
CutoffMS int64 `json:"cutoff_ms"`
InactiveForSeconds int64 `json:"inactive_for_seconds"`
Eligible int `json:"eligible"`
ProtectedWinners int64 `json:"protected_winners"`
ProtectedConnected int `json:"protected_connected"`
OldestEligibleMS int64 `json:"oldest_eligible_ms,omitempty"`
NewestEligibleMS int64 `json:"newest_eligible_ms,omitempty"`
}
func profileCleanupDurationSeconds(raw string) (int64, error) {
seconds, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil {
return 0, errors.New("inactive_for_seconds must be an integer")
}
// A one-hour minimum prevents an accidental near-live purge while still
// allowing short-lived development/test deployments to clean up quickly.
if seconds < 3600 || seconds > 10*365*24*60*60 {
return 0, errors.New("inactive_for_seconds must be between 3600 seconds and 10 years")
}
return seconds, nil
}
func (s *Server) profileCleanupPreview(ctx context.Context, inactiveForSeconds int64) (profileCleanupPreview, []string, error) {
cutoff := time.Now().UTC().Add(-time.Duration(inactiveForSeconds) * time.Second).UnixMilli()
candidates, err := s.store.InactiveNonWinnerClients(ctx, cutoff)
if err != nil {
return profileCleanupPreview{}, nil, err
}
protectedWinners, err := s.store.OldWinnerCount(ctx, cutoff)
if err != nil {
return profileCleanupPreview{}, nil, err
}
ids := make([]string, 0, len(candidates))
out := profileCleanupPreview{CutoffMS: cutoff, InactiveForSeconds: inactiveForSeconds, ProtectedWinners: protectedWinners}
for _, c := range candidates {
if s.runtime.IsConnected(c.ClientID) {
out.ProtectedConnected++
continue
}
ids = append(ids, c.ClientID)
if out.OldestEligibleMS == 0 || c.LastSeen < out.OldestEligibleMS {
out.OldestEligibleMS = c.LastSeen
}
if c.LastSeen > out.NewestEligibleMS {
out.NewestEligibleMS = c.LastSeen
}
}
out.Eligible = len(ids)
return out, ids, nil
}
func (s *Server) adminProfileCleanupPreview(w http.ResponseWriter, r *http.Request) {
seconds, err := profileCleanupDurationSeconds(r.URL.Query().Get("inactive_for_seconds"))
if err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
preview, _, err := s.profileCleanupPreview(r.Context(), seconds)
if err != nil {
jsonOut(w, 500, map[string]string{"error": "profile cleanup preview failed: " + err.Error()})
return
}
jsonOut(w, 200, preview)
}
func (s *Server) adminProfileCleanup(w http.ResponseWriter, r *http.Request) {
var in struct {
InactiveForSeconds int64 `json:"inactive_for_seconds"`
}
if err := decode(r, &in); err != nil {
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
return
}
seconds, err := profileCleanupDurationSeconds(strconv.FormatInt(in.InactiveForSeconds, 10))
if err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
preview, ids, err := s.profileCleanupPreview(r.Context(), seconds)
if err != nil {
jsonOut(w, 500, map[string]string{"error": "profile cleanup check failed: " + err.Error()})
return
}
deleted, err := s.store.DeleteInactiveNonWinnerClients(r.Context(), preview.CutoffMS, ids)
if err != nil {
jsonOut(w, 500, map[string]string{"error": "profile cleanup failed: " + err.Error()})
return
}
for _, id := range deleted {
s.runtime.ForgetClient(id)
}
jsonOut(w, 200, map[string]any{
"deleted": len(deleted),
"eligible_before": preview.Eligible,
"protected_winners": preview.ProtectedWinners,
"protected_connected": preview.ProtectedConnected,
"cutoff_ms": preview.CutoffMS,
"inactive_for_seconds": seconds,
})
}
func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) {
var clients, activeTasks, completedTasks, guesses, artifacts int64
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*) FROM clients`).Scan(&clients)
@@ -1062,6 +1237,10 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
http.Error(w, "identity not registered", http.StatusUnauthorized)
return
}
// WebSocket use counts as recent profile activity. This also makes a race
// with the admin cleanup safe: a newly connecting identity no longer
// matches an old last_seen cutoff.
s.store.TouchClient(r.Context(), c.ClientID)
t, err := s.store.TaskForClient(r.Context(), c.ClientID)
if err != nil {
http.Error(w, "no task", 503)
@@ -1080,7 +1259,13 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
}
cl := wsx.NewClient(conn, t.ID, c.ClientID, false)
s.hub.Add(cl)
defer func() { s.hub.Remove(cl); s.runtime.ReleasePresence(c.ClientID, c.SessionID, leaseID) }()
defer func() {
s.hub.Remove(cl)
s.runtime.ReleasePresence(c.ClientID, c.SessionID, leaseID)
// Mark the disconnect time as last activity. A client that stayed online
// for days therefore starts its inactivity window only after disconnect.
s.store.TouchClient(context.Background(), c.ClientID)
}()
// A map point is durable only once per client/task. Subsequent losing guesses
// stay in memory; improvements are checkpointed by the guess handler.