Files
fizzy/app/models/card.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

99 lines
2.9 KiB
Ruby

class Card < ApplicationRecord
include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable
belongs_to :account, default: -> { board.account }
belongs_to :board
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :comments, dependent: :destroy
has_one_attached :image, dependent: :purge_later
has_rich_text :description
before_save :set_default_title, if: :published?
before_create :assign_number
after_save -> { board.touch }, if: :published?
after_touch -> { board.touch }, if: :published?
after_update :handle_board_change, if: :saved_change_to_board_id?
scope :reverse_chronologically, -> { order created_at: :desc, id: :desc }
scope :chronologically, -> { order created_at: :asc, id: :asc }
scope :latest, -> { order last_active_at: :desc, id: :desc }
scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) }
scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds }
scope :indexed_by, ->(index) do
case index
when "stalled" then stalled
when "postponing_soon" then postponing_soon
when "closed" then closed
when "not_now" then postponed.latest
when "golden" then golden
when "draft" then drafted
else all
end
end
scope :sorted_by, ->(sort) do
case sort
when "newest" then reverse_chronologically
when "oldest" then chronologically
when "latest" then latest
else latest
end
end
delegate :accessible_to?, to: :board
def card
self
end
def to_param
number.to_s
end
def move_to(new_board)
transaction do
card.update!(board: new_board)
card.events.update_all(board_id: new_board.id)
end
end
def filled?
title.present? || description.present?
end
private
def set_default_title
self.title = "Untitled" if title.blank?
end
def handle_board_change
old_board = account.boards.find_by(id: board_id_before_last_save)
transaction do
update! column: nil
track_board_change_event(old_board.name)
grant_access_to_assignees unless board.all_access?
end
remove_inaccessible_notifications_later
end
def track_board_change_event(old_board_name)
track_event "board_changed", particulars: { old_board: old_board_name, new_board: board.name }
end
def grant_access_to_assignees
board.accesses.grant_to(assignees)
end
def assign_number
self.number ||= account.increment!(:cards_count).cards_count
end
end