qkeeper/queue.go
Aleksandr Berkuta 5806fe84c4 init commit
2026-03-27 20:27:56 +03:00

320 lines
7.5 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"time"
"unicode/utf8"
)
// ---- Helpers ----
func randHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}
func msNow() int64 { return time.Now().UnixMilli() }
// voteThreshold returns the number of votes required to trigger pending_removal
// for a non-#1 entry. = min(3, ceil(n/2)), minimum 1.
func voteThreshold(n int) int {
half := (n + 1) / 2
if half > 3 {
return 3
}
if half < 1 {
return 1
}
return half
}
func sanitize(s string, maxRunes int) string {
runes := []rune(s)
if len(runes) > maxRunes {
runes = runes[:maxRunes]
}
out := make([]rune, 0, len(runes))
for _, r := range runes {
if r != 0 && utf8.ValidRune(r) {
out = append(out, r)
}
}
return string(out)
}
// ---- Data model ----
type Entry struct {
ID string
Number int
Name string
JoinedAt int64 // Unix ms
Status string // "active" | "pending_removal"
PendingBy []int
PendingAt *int64 // Unix ms
token string
cancelFn context.CancelFunc
}
// pub returns the public (broadcast-safe) view of an entry.
type entryPub struct {
ID string `json:"id"`
Number int `json:"number"`
Name string `json:"name,omitempty"`
JoinedAt int64 `json:"joinedAt"`
Status string `json:"status"`
PendingBy []int `json:"pendingBy,omitempty"`
PendingAt *int64 `json:"pendingAt,omitempty"`
}
func (e *Entry) pub() entryPub {
return entryPub{
ID: e.ID,
Number: e.Number,
Name: e.Name,
JoinedAt: e.JoinedAt,
Status: e.Status,
PendingBy: e.PendingBy,
PendingAt: e.PendingAt,
}
}
// ---- Queue state ----
type QueueState struct {
Name string // display name, set at creation
Entries []*Entry
Counter int
CompletedSlots []float64 // seconds; only self-removals, not voted-out entries
}
// stateJSON serialises the full public state for broadcast.
func (q *QueueState) stateJSON() []byte {
views := make([]entryPub, len(q.Entries))
for i, e := range q.Entries {
views[i] = e.pub()
}
b, _ := json.Marshal(map[string]interface{}{
"type": "state",
"name": q.Name,
"entries": views,
"counter": q.Counter,
"avgDuration": q.avgDuration(),
})
return b
}
// avgDuration returns the average slot duration in seconds, or nil if <3 samples.
func (q *QueueState) avgDuration() interface{} {
if len(q.CompletedSlots) < 3 {
return nil
}
var sum float64
for _, d := range q.CompletedSlots {
sum += d
}
return sum / float64(len(q.CompletedSlots))
}
func (q *QueueState) findByToken(token string) *Entry {
for _, e := range q.Entries {
if e.token == token {
return e
}
}
return nil
}
func (q *QueueState) findByID(id string) *Entry {
for _, e := range q.Entries {
if e.ID == id {
return e
}
}
return nil
}
func (q *QueueState) removeEntry(id string) {
for i, e := range q.Entries {
if e.ID == id {
q.Entries = append(q.Entries[:i], q.Entries[i+1:]...)
return
}
}
}
// findVoteTarget returns the Number of the entry the given voter is targeting,
// or 0 if they have no active vote.
func (q *QueueState) findVoteTarget(voterNumber int) int {
for _, e := range q.Entries {
for _, n := range e.PendingBy {
if n == voterNumber {
return e.Number
}
}
}
return 0
}
// ---- Mutations ----
// join creates a new entry and returns it with its secret token.
func (q *QueueState) join(name string) (*Entry, string) {
name = sanitize(name, 50)
token := randHex(16)
q.Counter++
e := &Entry{
ID: randHex(8),
Number: q.Counter,
Name: name,
JoinedAt: msNow(),
Status: "active",
token: token,
}
q.Entries = append(q.Entries, e)
return e, token
}
// selfRemove removes the entry owned by token, recording the slot duration.
func (q *QueueState) selfRemove(token string) bool {
e := q.findByToken(token)
if e == nil {
return false
}
dur := float64(msNow()-e.JoinedAt) / 1000.0
q.CompletedSlots = append(q.CompletedSlots, dur)
if e.cancelFn != nil {
e.cancelFn()
}
removedNum := e.Number
q.removeEntry(e.ID)
q.cleanupVotes(removedNum)
return true
}
// startPending transitions an entry into pending_removal and launches the 60s timer.
// When the timer fires it sends the entry ID to timerC.
func (q *QueueState) startPending(e *Entry, timerC chan<- string) {
now := msNow()
e.Status = "pending_removal"
e.PendingAt = &now
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
e.cancelFn = cancel
id := e.ID
go func() {
<-ctx.Done()
cancel()
if ctx.Err() == context.DeadlineExceeded {
select {
case timerC <- id:
default:
}
}
}()
}
// markDone marks the #1 entry as pending_removal on behalf of byNumber.
// Returns false if the queue shifted or entry is already pending.
func (q *QueueState) markDone(byNumber int, entryID string, timerC chan<- string) bool {
if len(q.Entries) == 0 {
return false
}
target := q.Entries[0]
if target.ID != entryID {
return false // queue shifted since the client clicked
}
if target.Status == "pending_removal" {
return false
}
target.PendingBy = []int{byNumber}
q.startPending(target, timerC)
return true
}
// voteRemove adds a vote against a non-#1 entry.
// Returns "ok", "already_voting", or "noop".
func (q *QueueState) voteRemove(voterNumber int, targetID string, timerC chan<- string) string {
target := q.findByID(targetID)
if target == nil {
return "noop"
}
// Cannot vote on own entry
if target.Number == voterNumber {
return "noop"
}
// Cannot vote on #1 (use markDone instead)
if len(q.Entries) > 0 && q.Entries[0].ID == targetID {
return "noop"
}
// One active vote per user across the whole queue
if existing := q.findVoteTarget(voterNumber); existing != 0 {
return "already_voting"
}
target.PendingBy = append(target.PendingBy, voterNumber)
if len(target.PendingBy) >= voteThreshold(len(q.Entries)) {
q.startPending(target, timerC)
}
return "ok"
}
// restore cancels pending_removal for the entry owned by token.
func (q *QueueState) restore(token string) bool {
e := q.findByToken(token)
if e == nil || e.Status != "pending_removal" {
return false
}
if e.cancelFn != nil {
e.cancelFn()
e.cancelFn = nil
}
e.Status = "active"
e.PendingAt = nil
e.PendingBy = nil
return true
}
// timerRemove is called when the 60s timer fires for an entry.
func (q *QueueState) timerRemove(entryID string) bool {
e := q.findByID(entryID)
if e == nil || e.Status != "pending_removal" {
return false // already restored
}
removedNum := e.Number
q.removeEntry(entryID)
q.cleanupVotes(removedNum)
return true
}
// cleanupVotes removes a departed entry's number from all PendingBy lists.
// For entries in pending_removal with remaining votes below threshold, restores them.
// Called AFTER the entry has been removed from q.Entries so threshold uses updated count.
func (q *QueueState) cleanupVotes(removedNumber int) {
thresh := voteThreshold(len(q.Entries))
for _, e := range q.Entries {
for i, n := range e.PendingBy {
if n == removedNumber {
e.PendingBy = append(e.PendingBy[:i], e.PendingBy[i+1:]...)
// Restore only if votes remain but are now below threshold (Case B).
// If PendingBy is empty after removal (Case A marker left), let the
// 60s timer run its course — the action was already taken.
if e.Status == "pending_removal" &&
len(e.PendingBy) > 0 &&
len(e.PendingBy) < thresh {
if e.cancelFn != nil {
e.cancelFn()
e.cancelFn = nil
}
e.Status = "active"
e.PendingAt = nil
e.PendingBy = nil
}
break
}
}
}
}