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
+35 -10
View File
@@ -17,23 +17,34 @@ func genDeletionToken() string {
// TimeNow is overridable in tests to inject the clock.
var TimeNow = time.Now
// registerRead applies the burn-after-read budget for one view (#49).
// RegisterRead applies the burn-after-read budget for one view (#49).
// For pastes with reads_limit set: the viewer's paste_views row is checked;
// a view within the burn viewer window of the viewer's last view is deduped
// (count=false). Otherwise reads_used is incremented, and the paste is
// soft-deleted (burned) once reads_used reaches reads_limit. Viewers without
// a cookie (plain API clients) count as their own viewer id "".
// For legacy plain burn_after_read pastes (no reads_limit), any read burns.
// Returns the number of reads remaining (0 when burned), or nil when no
// budget is set. view_count is tracked separately and unaffected.
func (s *Store) RegisterRead(row *PasteRow, viewerID string, burnWindowMinutes int) (remaining *int, count bool) {
// #58: admission is atomic. Returns (remaining, admitted). admitted is true
// only when this caller may serve the content: for legacy burn pastes the
// caller wins exactly when its conditional soft delete flipped deleted_at
// (RowsAffected), and for read-budget pastes the caller wins exactly when its
// conditional UPDATE (reads_used < reads_limit) incremented the counter - so
// concurrent readers can never both consume the last read. Losing callers
// must treat the paste as gone. view_count is tracked separately and
// unaffected.
func (s *Store) RegisterRead(row *PasteRow, viewerID string, burnWindowMinutes int) (remaining *int, admitted bool) {
if !row.ReadsLimit.Valid {
if row.BurnAfterRead {
s.SoftDelete(row.ID)
// #58: atomic claim - only the caller whose UPDATE actually
// flips deleted_at from NULL may serve the content.
ok, err := s.SoftDelete(row.ID)
r := 0
if err != nil || !ok {
return &r, false
}
return &r, true
}
return nil, false
return nil, true
}
now := TimeNow().Unix()
var last sql.NullInt64
@@ -44,14 +55,28 @@ func (s *Store) RegisterRead(row *PasteRow, viewerID string, burnWindowMinutes i
if r < 0 {
r = 0
}
return &r, false
return &r, true
}
s.db.Exec(`INSERT INTO paste_views (paste_id, viewer_id, last_viewed) VALUES (?,?,?)
ON CONFLICT(paste_id, viewer_id) DO UPDATE SET last_viewed = excluded.last_viewed`,
row.ID, viewerID, now)
used := row.ReadsUsed + 1
s.db.Exec(`UPDATE pastes SET reads_used=? WHERE id=?`, used, row.ID)
if int64(used) >= row.ReadsLimit.Int64 {
// #58: conditional increment - only succeeds while budget remains, so
// concurrent readers cannot both consume the final read.
res, err := s.db.Exec(`UPDATE pastes SET reads_used = reads_used + 1
WHERE id = ? AND deleted_at IS NULL AND reads_used < ?`, row.ID, row.ReadsLimit.Int64)
if err != nil {
r := 0
return &r, false
}
if n, _ := res.RowsAffected(); n == 0 {
// Lost the race: budget exhausted (or paste already burned).
r := 0
return &r, false
}
var used int64
s.db.QueryRow(`SELECT reads_used FROM pastes WHERE id = ?`, row.ID).Scan(&used)
if used >= row.ReadsLimit.Int64 {
// Atomic burn; either way the paste is gone for future readers.
s.SoftDelete(row.ID)
}
r := int(row.ReadsLimit.Int64) - int(used)
+16 -3
View File
@@ -81,6 +81,11 @@ func OpenStore(path string) (*Store, error) {
if err != nil {
return nil, err
}
// #58: a single write connection. SQLite allows only one writer at a
// time; with multiple pooled connections concurrent writes surface as
// SQLITE_BUSY errors ("database is locked") instead of serializing, and
// the burn-after-read race tests saw spurious 500s under parallel reads.
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.migrate(); err != nil {
return nil, err
@@ -320,9 +325,17 @@ func (s *Store) MineOwner(id string) (string, error) {
return vid.String, nil
}
func (s *Store) SoftDelete(id string) error {
_, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id)
return err
// SoftDelete marks a paste deleted (burned) atomically (#58): the deleted_at
// IS NULL guard means only the first caller flips the row. Returns true when
// this call performed the delete (RowsAffected > 0), false when the paste was
// already deleted - callers use this to decide read admission atomically.
func (s *Store) SoftDelete(id string) (bool, error) {
res, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
func (s *Store) IncrementViews(id string) {