fix: atomic burn-after-read claim (#58)
CI / test (pull_request) Successful in 20s
CI / docker (pull_request) Skipped

SoftDelete now reports whether it performed the delete (conditional
UPDATE ... WHERE deleted_at IS NULL checked via RowsAffected).
RegisterRead returns an admitted flag: legacy burn pastes admit exactly
one reader (the atomic soft-delete winner), and burn-after-N pastes
increment reads_used via a conditional UPDATE guarded on
reads_used < reads_limit, so concurrent readers cannot both consume the
final read. API, HTML, and raw read paths return 404 when the reader
loses the burn claim; content is never served twice.

OpenStore pins the SQLite pool to one connection: concurrent writes on
separate pooled connections surfaced SQLITE_BUSY as spurious 500s
instead of serializing.

Adds concurrency regression tests: 24 parallel readers of a burn paste
(exactly one receives content, none of the others leak it) and 30
parallel readers vs a 3-read budget (exactly 3 admitted, then 404).
This commit is contained in:
agent
2026-09-09 09:18:42 -05:00
parent 03bf327f6b
commit 78374b2d49
5 changed files with 178 additions and 23 deletions
+103
View File
@@ -0,0 +1,103 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// TestBurnAfterReadConcurrentRace is the #58 regression test: N concurrent
// readers of a burn-after-read paste must receive exactly one success with
// content; every other reader must get 404 and never any content.
func TestBurnAfterReadConcurrentRace(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 1)
const readers = 24
var wg sync.WaitGroup
var mu sync.Mutex
wins, losses := 0, 0
for i := 0; i < readers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
mu.Lock()
defer mu.Unlock()
if rec.Code == 200 {
wins++
var got struct {
Content string `json:"content"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.Content != "limited" {
t.Errorf("winning read returned wrong content: %v %q", err, got.Content)
}
} else if rec.Code == 404 {
losses++
if strings.Contains(rec.Body.String(), "limited") {
t.Errorf("losing read leaked content: %s", rec.Body.String())
}
} else {
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
}
}(i)
}
wg.Wait()
if wins != 1 {
t.Fatalf("expected exactly 1 winning read of burn paste, got %d (losses=%d)", wins, losses)
}
if losses != readers-1 {
t.Fatalf("expected %d losing reads, got %d", readers-1, losses)
}
}
// TestBurnAfterNReadsConcurrentBudget hammers a burn-after-N paste with many
// more concurrent distinct readers than the budget: total admissions must
// equal exactly N, and no losing read may see content.
func TestBurnAfterNReadsConcurrentBudget(t *testing.T) {
s := testServer(t)
h := s.routes()
const budget = 3
id := createBurnReads(t, h, budget)
const readers = 30
var wg sync.WaitGroup
var mu sync.Mutex
wins := 0
for i := 0; i < readers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
mu.Lock()
defer mu.Unlock()
if rec.Code == 200 {
wins++
} else if rec.Code != 404 {
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
}
}(i)
}
wg.Wait()
if wins != budget {
t.Fatalf("expected exactly %d admitted reads, got %d", budget, wins)
}
// After the race, the paste is burned for everyone.
if rec := getWithCookie(t, h, id, "after-the-fact"); rec.Code != 404 {
t.Fatalf("paste should be burned after budget exhausted, got %d", rec.Code)
}
}