Files
fizzy/app/models/concerns/storage/tracked.rb
T
Jeremy Daer bd6c1cf34f Storage: harden reconcile for concurrent writes and fix board transfer (#2096)
* Storage: harden reconcile for concurrent writes and fix board transfer

Reconcile now uses two-cursor approach: captures cursor before and after
the storage scan, aborting if they differ (entries added during scan).
Job retries 3x with 1-minute waits and limits concurrency to 1 per owner.

Board transfer now correctly moves storage for card description embeds
and comment embeds, not just direct attachments. Uses batched queries
to handle cards with thousands of comments efficiently.

Also fixes N+1 queries in attachment grouping via lookup maps.

* Storage: fix per-attachment reconcile and enforce no blob reuse

The storage ledger tracks per-attachment (not per-blob) as a business
abstraction for quotas. This fixes reconcile to match that model and
adds enforcement to prevent blob reuse in tracked contexts.

* Reconcile now uses joins(:blob).sum() for per-attachment counting
* New validation prevents reusing blobs across tracked attachments
* Race-safe storage_total creation with create_or_find_by
* Job efficiency: skip find_by when object already available
* Backfill script checks per-attachment, not just per-blob
2025-12-19 13:07:32 -08:00

98 lines
3.0 KiB
Ruby

# Storage tracking is a business abstraction - we count what users upload.
# Original upload bytes only; variants/previews/derivatives excluded.
# Physical storage optimizations (deduplication, compression) don't affect quotas.
module Storage::Tracked
extend ActiveSupport::Concern
included do
before_update :track_board_transfer, if: :board_transfer?
end
# Return self as the trackable record for storage entries
def storage_tracked_record
self
end
# Override in models where board is determined differently (e.g., Board itself)
def board_for_storage_tracking
board
end
# Total bytes for all attachments on this record
def storage_bytes
attachments_for_storage.sum { |a| a.blob.byte_size }
end
private
def board_transfer?
respond_to?(:will_save_change_to_board_id?) && will_save_change_to_board_id?
end
def track_board_transfer
old_board = Board.find_by(id: attribute_in_database(:board_id))
records = storage_transfer_records.compact
return if records.empty?
attachments_by_record = storage_attachments_for_records(records)
attachments_by_record.each do |recordable, attachments|
bytes = attachments.sum { |attachment| attachment.blob.byte_size }
next if bytes.zero?
# Debit old board
if old_board
Storage::Entry.record \
account: account,
board: old_board,
recordable: recordable,
delta: -bytes,
operation: "transfer_out"
end
# Credit new board
Storage::Entry.record \
account: account,
board: board,
recordable: recordable,
delta: bytes,
operation: "transfer_in"
end
end
def storage_transfer_records
[ self ]
end
# Override if needed. Default = all direct attachments
def attachments_for_storage(recordable = self)
storage_attachments_for_records([ recordable ]).fetch(recordable, [])
end
def storage_attachments_for_records(recordables)
records = Array(recordables).compact
return {} if records.empty?
# Build lookup for records by (type, id) to avoid N+1 when resolving RichText parents
records_by_key = records.index_by { |r| [ r.class.name, r.id ] }
rich_texts = ActionText::RichText.where(record: records)
rich_text_to_parent = rich_texts.to_h { |rt| [ rt.id, records_by_key[[ rt.record_type, rt.record_id ]] ] }
attachments = ActiveStorage::Attachment
.where(record: records + rich_texts)
.includes(:blob)
.to_a
attachments.each_with_object(Hash.new { |h, k| h[k] = [] }) do |attachment, grouped|
# Resolve parent without N+1: use lookup for RichText, direct for others
recordable = if attachment.record_type == "ActionText::RichText"
rich_text_to_parent[attachment.record_id]
else
records_by_key[[ attachment.record_type, attachment.record_id ]]
end
grouped[recordable] << attachment if recordable
end
end
end