Files
Jeremy Daer 761d0b4a76 Speedy, auditable, deadlock-resistant storage tracking (#2026)
An append-only storage ledger replaces deadlock-prone synchronous
counter updates and drift-prone async updates.

All content storage (card images, card/comment/board description embeds)
is tracked. Account exports (which expire) and avatars are not tracked.

Storage is accounted for by Account and Board. Both consume the same
event stream independently: no bubble-up storage bumps triggering
deadlocks due to lock sequencing. Each calculates its own total from
the underlying ledger with independent cursors and materialization.

* Storage::Entry: Append-only ledger recording attach/detach/transfer
  events with delta bytes. Single event stream indexed for both
  Account and Board cursor queries.

* Storage::Total: Polymorphic snapshot cache with cursor (last_entry_id)
  tracking which entries have been materialized.

* Storage::Totaled concern: Provides bytes_used (fast snapshot) and
  bytes_used_exact (snapshot + pending) query modes, plus
  materialize_storage! to roll up pending entries.

* Storage::Tracked concern: For models owning attachments (Card,
  Comment, Board). Provides board_for_storage_tracking for models
  where board is determined differently (Board returns self).
  Handles board transfers by recording transfer_out/transfer_in entries.

* Storage::AttachmentTracking: Hooks ActiveStorage::Attachment lifecycle
  to record attach/detach entries. Handles ActionText::RichText embeds
  by traversing to the actual model. Snapshots context in before_destroy
  to handle cascading deletes where parent record may be gone by
  after_destroy_commit.

* MaterializeJob: Rolls up pending entries into snapshot. Concurrency
  limited per owner to prevent duplicate work.

* ReconcileJob: On-demand reconciliation against actual attachment
  storage for support/debugging. Compares ledger total to real bytes
  from card images, card embeds, comment embeds, and board embeds.

Usage:
* account.bytes_used / board.bytes_used: fast, slightly stale bytesize
* account.bytes_used_exact / board.bytes_used_exact: real-time bytesize
* Storage::Entry: audit trail for debugging and point-in-time queries
2025-12-10 16:04:30 -08:00

84 lines
2.6 KiB
Ruby

require "test_helper"
class Storage::TotalTest < ActiveSupport::TestCase
setup do
@account = accounts("37s")
@board = boards(:writebook)
end
test "pending_entries returns all entries when no cursor" do
# Create some entries
3.times do |i|
Storage::Entry.record \
account: @account,
delta: 1024 * (i + 1),
operation: "attach"
end
total = @account.create_storage_total!
assert_nil total.last_entry_id
assert_equal 3, total.pending_entries.count
end
test "pending_entries returns only entries after cursor" do
# Create first entry and set cursor
entry1 = Storage::Entry.record(account: @account, delta: 1024, operation: "attach")
total = @account.create_storage_total!(last_entry_id: entry1.id, bytes_stored: 1024)
# Advance time to ensure UUIDv7 timestamps sort correctly
travel 1.second
# Create more entries AFTER cursor is set
entry2 = Storage::Entry.record(account: @account, delta: 2048, operation: "attach")
travel 1.second
entry3 = Storage::Entry.record(account: @account, delta: 512, operation: "attach")
pending = total.pending_entries
assert_equal 2, pending.count
assert_includes pending, entry2
assert_includes pending, entry3
assert_not_includes pending, entry1
end
test "current_usage returns snapshot value when no pending entries" do
total = @account.create_storage_total!(bytes_stored: 5000)
# No entries exist, so nothing pending
assert_equal 5000, total.current_usage
end
test "current_usage sums snapshot and pending entries" do
# Create first entry and set cursor
entry1 = Storage::Entry.record(account: @account, delta: 1024, operation: "attach")
total = @account.create_storage_total!(last_entry_id: entry1.id, bytes_stored: 1024)
# Small delay to ensure UUIDv7 timestamp component advances
travel 1.second
# Create more entries AFTER cursor is set
Storage::Entry.record(account: @account, delta: 2048, operation: "attach")
travel 1.second
Storage::Entry.record(account: @account, delta: -512, operation: "detach")
# 1024 (snapshot) + 2048 - 512 (pending) = 2560
assert_equal 2560, total.current_usage
end
test "belongs to owner polymorphically" do
account_total = Storage::Total.create!(owner: @account)
assert_equal @account, account_total.owner
board_total = Storage::Total.create!(owner: @board)
assert_equal @board, board_total.owner
end
test "unique constraint on owner" do
Storage::Total.create!(owner: @account)
assert_raises ActiveRecord::RecordNotUnique do
Storage::Total.create!(owner: @account)
end
end
end