Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion backend/internal/discord/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 3 additions & 9 deletions backend/internal/discord/poll.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down
50 changes: 50 additions & 0 deletions backend/internal/discord/refresh.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
49 changes: 49 additions & 0 deletions backend/internal/discord/refresh_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 15 additions & 0 deletions backend/internal/discord/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}
19 changes: 19 additions & 0 deletions backend/internal/postgres/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
30 changes: 30 additions & 0 deletions backend/internal/postgres/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "raidy-web",
"private": true,
"version": "0.0.10",
"version": "0.0.11",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
Loading