diff --git a/README.md b/README.md index 428d75b..7fc918f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Raidy is a Discord-first raid scheduling project. One Go process runs the Discord bot, web API, scheduler, and production web assets, backed by PostgreSQL. -**Status:** `v0.0.10` is an early development snapshot. +**Status:** `v0.0.11` is an early development snapshot. ```text backend/ Go service, Discord interactions, web API, PostgreSQL migrations diff --git a/backend/internal/discord/bot.go b/backend/internal/discord/bot.go index 9854b8b..6d22707 100644 --- a/backend/internal/discord/bot.go +++ b/backend/internal/discord/bot.go @@ -23,10 +23,15 @@ type Bot struct { Log *slog.Logger // ponytail: process-local publication lock matches the one-replica MVP; use DB leasing before adding replicas. publishMu sync.Mutex + refresher *pollRefresher } func New(session *discordgo.Session, store *postgres.Store, log *slog.Logger) *Bot { - return &Bot{Session: session, Store: store, Drafts: team.NewDrafts(), Log: log} + b := &Bot{Session: session, Store: store, Drafts: team.NewDrafts(), Log: log} + b.refresher = newPollRefresher(refreshDelay, log, func(ctx context.Context, pollID int64) error { + return b.editPoll(ctx, pollID, true) + }) + return b } func Commands() []*discordgo.ApplicationCommand { diff --git a/backend/internal/discord/poll.go b/backend/internal/discord/poll.go index 611b341..0e04d6d 100644 --- a/backend/internal/discord/poll.go +++ b/backend/internal/discord/poll.go @@ -209,9 +209,7 @@ func (b *Bot) saveAvailability(ctx context.Context, i *discordgo.Interaction, po if err := b.update(i, "Availability saved.", nil); err != nil { return err } - if err := b.editPoll(ctx, pollID, true); err != nil { - b.Log.Error("poll refresh failed", "poll_id", pollID, "error", err) - } + b.refresher.queue(pollID) return nil } @@ -276,9 +274,7 @@ func (b *Bot) changeOccurrence(ctx context.Context, i *discordgo.Interaction, oc if err := b.update(i, "Raid date updated.", nil); err != nil { return err } - if err := b.editPoll(ctx, pollID, true); err != nil { - b.Log.Error("poll refresh failed", "poll_id", pollID, "occurrence_id", occurrenceID, "error", err) - } + b.refresher.queue(pollID) return nil } @@ -292,9 +288,7 @@ func (b *Bot) ChangeOccurrence(ctx context.Context, occurrenceID int64, leaderID if err != nil { return err } - if err := b.editPoll(ctx, pollID, true); err != nil { - b.Log.Error("poll refresh failed", "poll_id", pollID, "occurrence_id", occurrenceID, "error", err) - } + b.refresher.queue(pollID) return nil } diff --git a/backend/internal/discord/refresh.go b/backend/internal/discord/refresh.go new file mode 100644 index 0000000..d280fef --- /dev/null +++ b/backend/internal/discord/refresh.go @@ -0,0 +1,50 @@ +package discord + +import ( + "context" + "log/slog" + "sync" + "time" +) + +// refreshDelay groups rapid changes (several leader clicks, a burst of votes) +// into one Discord message edit, which keeps us under Discord's per-channel +// edit rate limit. +const refreshDelay = 750 * time.Millisecond + +// pollRefresher updates published timetable messages in the background so +// web and Discord interactions respond as soon as the database commit lands. +type pollRefresher struct { + mu sync.Mutex + pending map[int64]bool + delay time.Duration + log *slog.Logger + refresh func(context.Context, int64) error +} + +func newPollRefresher(delay time.Duration, log *slog.Logger, refresh func(context.Context, int64) error) *pollRefresher { + return &pollRefresher{pending: make(map[int64]bool), delay: delay, log: log, refresh: refresh} +} + +// queue schedules a refresh of the poll's message. Calls made while a refresh +// is already waiting are absorbed by it, since it renders the latest state. +func (r *pollRefresher) queue(pollID int64) { + r.mu.Lock() + if r.pending[pollID] { + r.mu.Unlock() + return + } + r.pending[pollID] = true + r.mu.Unlock() + + time.AfterFunc(r.delay, func() { + r.mu.Lock() + delete(r.pending, pollID) + r.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := r.refresh(ctx, pollID); err != nil { + r.log.Error("poll refresh failed", "poll_id", pollID, "error", err) + } + }) +} diff --git a/backend/internal/discord/refresh_test.go b/backend/internal/discord/refresh_test.go new file mode 100644 index 0000000..54377f5 --- /dev/null +++ b/backend/internal/discord/refresh_test.go @@ -0,0 +1,49 @@ +package discord + +import ( + "context" + "io" + "log/slog" + "sync" + "testing" + "time" +) + +func TestPollRefresherCoalescesBursts(t *testing.T) { + var mu sync.Mutex + calls := map[int64]int{} + done := make(chan struct{}, 10) + r := newPollRefresher(20*time.Millisecond, slog.New(slog.NewTextHandler(io.Discard, nil)), func(_ context.Context, pollID int64) error { + mu.Lock() + calls[pollID]++ + mu.Unlock() + done <- struct{}{} + return nil + }) + + // Five rapid leader clicks on one poll and one on another. + for i := 0; i < 5; i++ { + r.queue(1) + } + r.queue(2) + for i := 0; i < 2; i++ { + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("refresh did not run") + } + } + // A change after the refresh ran must schedule another refresh. + r.queue(1) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("later change was not refreshed") + } + time.Sleep(50 * time.Millisecond) + mu.Lock() + defer mu.Unlock() + if calls[1] != 2 || calls[2] != 1 { + t.Fatalf("refresh calls = %v, want poll 1 twice and poll 2 once", calls) + } +} diff --git a/backend/internal/discord/scheduler.go b/backend/internal/discord/scheduler.go index 8b96220..e42df05 100644 --- a/backend/internal/discord/scheduler.go +++ b/backend/internal/discord/scheduler.go @@ -6,6 +6,7 @@ import ( ) func (b *Bot) RunScheduler(ctx context.Context) { + b.refreshOpenPolls(ctx) b.runSchedulerTick(ctx) ticker := time.NewTicker(time.Minute) defer ticker.Stop() @@ -56,3 +57,17 @@ func (b *Bot) runSchedulerTick(ctx context.Context) { b.Log.Error("poll publication pass failed", "error", err) } } + +// refreshOpenPolls re-renders every published, open timetable at startup. A +// background refresh lost to a crash or restart leaves only a stale Discord +// message, never stale data, so re-rendering from PostgreSQL repairs it. +func (b *Bot) refreshOpenPolls(ctx context.Context) { + ids, err := b.Store.PublishedOpenPollIDs(ctx) + if err != nil { + b.Log.Error("startup poll refresh query failed", "error", err) + return + } + for _, id := range ids { + b.refresher.queue(id) + } +} diff --git a/backend/internal/postgres/store.go b/backend/internal/postgres/store.go index c6f6a44..9eaad56 100644 --- a/backend/internal/postgres/store.go +++ b/backend/internal/postgres/store.go @@ -884,3 +884,22 @@ func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error { _, err := s.pool.Exec(ctx, `DELETE FROM web_sessions WHERE token_hash=$1`, tokenHash) return err } + +// PublishedOpenPollIDs lists open polls that have a Discord message, so their +// messages can be re-rendered after a restart dropped a pending refresh. +func (s *Store) PublishedOpenPollIDs(ctx context.Context) ([]int64, error) { + rows, err := s.pool.Query(ctx, `SELECT id FROM schedule_polls WHERE closed_at IS NULL AND message_id <> '' ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} diff --git a/backend/internal/postgres/store_test.go b/backend/internal/postgres/store_test.go index ad5c761..5901cbf 100644 --- a/backend/internal/postgres/store_test.go +++ b/backend/internal/postgres/store_test.go @@ -68,6 +68,36 @@ func TestForcedPublishDoesNotSkipPeriods(t *testing.T) { } } +func TestPublishedOpenPollIDs(t *testing.T) { + store, pool := openTestStore(t) + ctx := context.Background() + created, err := store.CreateTeam(ctx, "guild", "Raid Night", "leader", "The Echo", "Asia/Tokyo", nil) + if err != nil { + t.Fatal(err) + } + var published, unpublished, closed int64 + for _, row := range []struct { + id *int64 + start string + message string + closed bool + }{{&published, "2026-10-05", "m1", false}, {&unpublished, "2026-10-12", "", false}, {&closed, "2026-09-28", "m0", true}} { + if err := pool.QueryRow(ctx, ` + INSERT INTO schedule_polls (team_id, period_start, period_end, channel_id, message_id, closed_at) + VALUES ($1, $2::date, $2::date + 7, 'channel', $3, CASE WHEN $4 THEN now() END) RETURNING id`, + created.ID, row.start, row.message, row.closed).Scan(row.id); err != nil { + t.Fatal(err) + } + } + ids, err := store.PublishedOpenPollIDs(ctx) + if err != nil { + t.Fatal(err) + } + if len(ids) != 1 || ids[0] != published { + t.Fatalf("PublishedOpenPollIDs() = %v, want only %d (unpublished %d and closed %d excluded)", ids, published, unpublished, closed) + } +} + func TestCatchUpSkipsElapsedPeriods(t *testing.T) { store, pool := openTestStore(t) ctx := context.Background() diff --git a/web/package-lock.json b/web/package-lock.json index 9dd9487..f8ebe1a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "raidy-web", - "version": "0.0.10", + "version": "0.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "raidy-web", - "version": "0.0.10", + "version": "0.0.11", "dependencies": { "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", diff --git a/web/package.json b/web/package.json index e999722..b684ec7 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "raidy-web", "private": true, - "version": "0.0.10", + "version": "0.0.11", "type": "module", "scripts": { "dev": "vite",