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
+5 -1
View File
@@ -255,7 +255,11 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
// #49: burn-after-N-reads budget (per-viewer dedupe window).
// Just-created first render does not count as a read for the creator.
if !justCreated {
rem, _ := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
rem, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
if !admitted { // #58: lost the burn claim; do not render content
http.NotFound(w, r)
return
}
h.renderPaste(w, row, false, "", rem)
return
}