Files
fizzy/test/models/storage/tracked_test.rb
T
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

120 lines
4.1 KiB
Ruby

require "test_helper"
class Storage::TrackedTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
@account = accounts("37s")
@board1 = boards(:writebook)
@board2 = boards(:private)
@card = cards(:logo)
end
test "storage_bytes returns 0 when no attachments" do
assert_equal 0, @card.storage_bytes
end
test "storage_bytes sums all attachment blob sizes" do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
assert_equal 1024, @card.storage_bytes
end
test "board transfer creates transfer_out entry for old board" do
@card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png"
old_board_id = @card.board_id
assert_difference "Storage::Entry.count", +2 do
@card.update!(board: @board2)
end
transfer_out = Storage::Entry.find_by(board_id: old_board_id, operation: "transfer_out")
assert_not_nil transfer_out
assert_equal -2048, transfer_out.delta
assert_equal @account.id, transfer_out.account_id
assert_equal @card.class.name, transfer_out.recordable_type
assert_equal @card.id, transfer_out.recordable_id
end
test "board transfer creates transfer_in entry for new board" do
@card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png"
@card.update!(board: @board2)
transfer_in = Storage::Entry.find_by(board_id: @board2.id, operation: "transfer_in")
assert_not_nil transfer_in
assert_equal 2048, transfer_in.delta
assert_equal @account.id, transfer_in.account_id
end
test "board transfer does not create entries when no attachments" do
# Ensure card has no attachments
@card.image.purge if @card.image.attached?
# Count only transfer entries
initial_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count
@card.update!(board: @board2)
final_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count
assert_equal initial_count, final_count
end
test "board transfer net effect on account is zero" do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
# Materialize account storage before transfer
@account.materialize_storage
initial_account_bytes = @account.bytes_used
@card.update!(board: @board2)
# Materialize again
@account.materialize_storage
# Account total should be unchanged (transfer_out + transfer_in = 0 for account)
assert_equal initial_account_bytes, @account.bytes_used
end
test "board transfer correctly moves storage between boards" do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
# Materialize both boards
@board1.materialize_storage
@board2.materialize_storage
board1_initial = @board1.bytes_used
board2_initial = @board2.bytes_used
# Small delay to ensure UUIDv7 timestamp advances for transfer entries
travel 1.second
@card.update!(board: @board2)
# Materialize again
@board1.materialize_storage
@board2.materialize_storage
# Board1 loses 1024, Board2 gains 1024
assert_equal board1_initial - 1024, @board1.bytes_used
assert_equal board2_initial + 1024, @board2.bytes_used
end
test "non-board updates do not trigger transfer tracking" do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
initial_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count
@card.update!(title: "New Title")
final_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count
assert_equal initial_count, final_count
end
test "attachments_for_storage returns all direct attachments" do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
attachments = @card.send(:attachments_for_storage)
assert_equal 1, attachments.count
assert_equal @card.image.blob.byte_size, attachments.first.blob.byte_size
end
end