Files
fizzy/app/models/storage/attachment_tracking.rb
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

53 lines
1.5 KiB
Ruby

module Storage::AttachmentTracking
extend ActiveSupport::Concern
included do
# Snapshot IDs in before_destroy since parent record may be deleted
# by the time after_destroy_commit runs
before_destroy :snapshot_storage_context
after_create_commit :record_storage_attach
after_destroy_commit :record_storage_detach
end
private
def record_storage_attach
return unless storage_tracked_record
Storage::Entry.record \
account: storage_tracked_record.account,
board: storage_tracked_record.board_for_storage_tracking,
recordable: storage_tracked_record,
blob: blob,
delta: blob.byte_size,
operation: "attach"
end
def record_storage_detach
return unless @storage_snapshot
Storage::Entry.record \
account: @storage_snapshot[:account],
board: @storage_snapshot[:board],
recordable: @storage_snapshot[:recordable],
blob: blob,
delta: -blob.byte_size,
operation: "detach"
end
# Snapshot records in before_destroy since parent may be deleted by the time
# after_destroy_commit runs. The records may be destroyed but .id still works.
def snapshot_storage_context
return unless storage_tracked_record
@storage_snapshot = {
account: storage_tracked_record.account,
board: storage_tracked_record.board_for_storage_tracking,
recordable: storage_tracked_record
}
end
def storage_tracked_record
record.try(:storage_tracked_record)
end
end