fix: atomic burn-after-read claim (#58)
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:
+16
-3
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user