standard_ledger 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,30 +5,19 @@ module StandardLedger
5
5
  #
6
6
  # @see StandardLedger.configure
7
7
  class Config
8
- # The ActiveJob class used by `:async` mode projections. Defaults to
9
- # `StandardLedger::ProjectionJob`. Hosts can supply their own job class
10
- # (e.g. for custom queue routing or per-projection telemetry) via
11
- # `c.default_async_job = Orders::FulfillableProjectionJob`.
12
- attr_accessor :default_async_job
13
-
14
- # Total attempts (including the first) for an `:async` projection before
15
- # the failure is propagated. Default: 3 (one initial run + two retries).
16
- # Matches ActiveJob's `retry_on attempts:` semantics.
17
- attr_accessor :default_async_retries
18
-
19
- # Scheduler backend for `:matview` refresh jobs. One of
20
- # `:solid_queue`, `:sidekiq_cron`, `:custom`. Default: `:solid_queue`
21
- # (matches all four consuming apps).
22
- attr_accessor :scheduler
23
-
24
- # Default refresh strategy for `:matview` projections. Either
25
- # `:concurrent` (REFRESH MATERIALIZED VIEW CONCURRENTLY — requires a
26
- # unique index on the view) or `:blocking`. Default: `:concurrent`.
8
+ # Default for `StandardLedger.refresh!` calls that omit `concurrently:`.
9
+ # One of:
10
+ #
11
+ # - `:concurrent` (default) — `REFRESH MATERIALIZED VIEW CONCURRENTLY`;
12
+ # requires a populated view with a unique index, outside a transaction.
13
+ # - `:blocking` — plain `REFRESH MATERIALIZED VIEW`.
14
+ # - `:auto` — concurrent when possible, plain otherwise (see
15
+ # `StandardLedger.refresh!`).
27
16
  attr_accessor :matview_refresh_strategy
28
17
 
29
18
  # Optional: the host application's Result class. When set together with
30
- # `result_adapter`, `StandardLedger.post` returns instances of this class
31
- # instead of `StandardLedger::Result`.
19
+ # `result_adapter`, `StandardLedger.post` and `StandardLedger.refresh!`
20
+ # return instances of this class instead of `StandardLedger::Result`.
32
21
  attr_accessor :result_class
33
22
 
34
23
  # Optional: a callable that translates the gem's result fields into the
@@ -37,17 +26,13 @@ module StandardLedger
37
26
  # Required when `result_class` is set.
38
27
  attr_accessor :result_adapter
39
28
 
40
- # Prefix for `ActiveSupport::Notifications` events emitted by the gem.
41
- # Default: `"standard_ledger"`. Events:
42
- # `<prefix>.entry.created`, `<prefix>.projection.applied`,
43
- # `<prefix>.projection.failed`, `<prefix>.projection.refreshed`,
44
- # `<prefix>.projection.rebuilt`.
29
+ # Prefix for events emitted by the gem (via `Rails.event` on Rails 8.1+,
30
+ # `ActiveSupport::Notifications` otherwise). Default: `"standard_ledger"`.
31
+ # Events: `<prefix>.entry.created`, `<prefix>.projection.refreshed`,
32
+ # `<prefix>.projection.failed`.
45
33
  attr_accessor :notification_namespace
46
34
 
47
35
  def initialize
48
- @default_async_job = nil # resolved lazily to avoid loading the job constant before Rails boots
49
- @default_async_retries = 3
50
- @scheduler = :solid_queue
51
36
  @matview_refresh_strategy = :concurrent
52
37
  @result_class = nil
53
38
  @result_adapter = nil
@@ -2,16 +2,16 @@ require "active_support/concern"
2
2
 
3
3
  module StandardLedger
4
4
  # Marks an ActiveRecord model as a ledger entry: an immutable, append-only
5
- # row that may project onto one or more aggregate targets.
5
+ # journal row.
6
6
  #
7
7
  # Including this concern installs:
8
8
  # - the `ledger_entry` class macro (declares immutability + idempotency)
9
9
  # - read-only behavior post-creation (when `immutable: true`, the default)
10
10
  # - idempotency-by-unique-index (when `idempotency_key:` is non-nil)
11
11
  #
12
- # Projection registration happens via the separate `Projector` concern —
13
- # the two are decoupled so that an entry can be marked immutable without
14
- # also opting into projections, and vice versa.
12
+ # Aggregates derived from entries are the host's concern — a plain
13
+ # `StandardLedger::Projection` subclass or a materialized view refreshed via
14
+ # `StandardLedger.refresh!`.
15
15
  #
16
16
  # @example
17
17
  # class VoucherRecord < ApplicationRecord
@@ -26,8 +26,7 @@ module StandardLedger
26
26
 
27
27
  class_methods do
28
28
  # Declare the entry's contract. Stores the configuration on the class
29
- # for later inspection by `StandardLedger.post`, `Projection.rebuild!`,
30
- # and the `standard_ledger:doctor` rake task.
29
+ # for later inspection by `StandardLedger.post` and host specs.
31
30
  #
32
31
  # @param kind [Symbol] the column holding the entry's kind/action
33
32
  # discriminator. Defaults to `:kind`.
@@ -285,10 +284,7 @@ module StandardLedger
285
284
  # typical 1–2 belongs_to entry, that's negligible. If profiling on a
286
285
  # high-cardinality entry shows this matters, capture targets earlier
287
286
  # (e.g. in `before_create`) and stash them on the instance — deferred
288
- # to a future PR. Notably, an inline-mode caller has already resolved
289
- # these targets by the time `after_commit` runs, so the SELECTs would
290
- # only happen for entries with belongs_to associations that are *not*
291
- # registered as projection targets.
287
+ # to a future PR.
292
288
  #
293
289
  # @return [Hash{Symbol => ActiveRecord::Base}]
294
290
  def standard_ledger_targets
@@ -1,16 +1,9 @@
1
1
  module StandardLedger
2
2
  class Error < StandardError; end
3
3
 
4
- # Raised at registration time when a `projects_onto` block declares an
5
- # `on(:kind)` for a kind that the entry's enum/set does not include, or
6
- # when an entry is posted with a kind that no projection has registered
7
- # a handler for. Use `permissive: true` on the projection to opt out.
8
- class UnhandledKind < Error; end
9
-
10
- # Raised by `StandardLedger.rebuild!` when the projector does not implement
11
- # `rebuild` and is therefore not replayable from the entry log. Delta-based
12
- # projectors (e.g. `increment_counter`-flavored) typically raise this
13
- # because they cannot be reconstructed without summing the full log.
4
+ # Raised by `StandardLedger::Projection#rebuild` when a projector subclass
5
+ # does not override it — i.e. the projector cannot be recomputed from the
6
+ # entry log.
14
7
  class NotRebuildable < Error; end
15
8
 
16
9
  # Raised at boot when an Entry declares `idempotency_key:` but no matching
@@ -18,26 +11,13 @@ module StandardLedger
18
11
  # admitting duplicates at runtime.
19
12
  class MissingIdempotencyIndex < Error; end
20
13
 
21
- # Raised by `StandardLedger.post` when the inline portion of fan-out
22
- # succeeded but enqueuing one or more async projections failed. The entry
23
- # itself is durable; callers may choose to roll back or accept-and-log.
24
- class PartialFailure < Error
25
- attr_reader :enqueued, :failed
26
-
27
- def initialize(enqueued:, failed:)
28
- @enqueued = enqueued
29
- @failed = failed
30
- super("Enqueued #{enqueued.size} projections; #{failed.size} failed to enqueue")
31
- end
32
- end
33
-
34
14
  # Raised when `StandardLedger.refresh!(view, concurrently: true)` is called
35
15
  # inside an open transaction. PostgreSQL rejects
36
16
  # `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside transaction blocks; the
37
17
  # gem catches this at the boundary so the failure is a clear,
38
18
  # gem-attributable error instead of a raw `PG::ActiveSqlTransaction`.
39
- # Callers wanting read-your-write semantics inside an operation should
40
- # wrap the call in `connection.add_transaction_record { ... }` to defer
41
- # to after-commit, or move the refresh outside the transaction block.
19
+ # Pass `concurrently: :auto` to fall back to a plain refresh instead, move
20
+ # the refresh outside the transaction, or defer it with
21
+ # `connection.add_transaction_record { ... }`.
42
22
  class RefreshInsideTransaction < Error; end
43
23
  end
@@ -15,8 +15,8 @@ module StandardLedger
15
15
 
16
16
  # Emit a single event. Both backends are best-effort: any exception raised
17
17
  # by a subscriber is swallowed so ledger observability never takes down a
18
- # host's request path (the projection has already either succeeded or
19
- # been rolled back by the time we emit).
18
+ # host's request path (the entry is already committed, or the refresh
19
+ # already ran, by the time we emit).
20
20
  def emit(event_name, payload)
21
21
  if (bus = rails_event_bus)
22
22
  bus.notify(event_name, **payload)
@@ -0,0 +1,162 @@
1
+ require "active_support/core_ext/string/filters"
2
+
3
+ module StandardLedger
4
+ # Materialized-view refresh primitive behind `StandardLedger.refresh!`.
5
+ # The host creates and owns the view (via a `scenic` or hand-rolled
6
+ # migration) and schedules refreshes itself; this module only issues the
7
+ # `REFRESH MATERIALIZED VIEW` SQL, decides whether `CONCURRENTLY` is safe
8
+ # when asked to (`concurrently: :auto`), and emits notifications.
9
+ #
10
+ # PostgreSQL only.
11
+ #
12
+ # @api private — call `StandardLedger.refresh!` instead.
13
+ module Matview
14
+ IDENTIFIER = /\A[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?\z/
15
+
16
+ # `REFRESH ... CONCURRENTLY` needs a populated view and at least one
17
+ # valid UNIQUE index that uses only column names and covers all rows (no
18
+ # expressions, no WHERE clause). `to_regclass` returns NULL for a
19
+ # missing relation, so a missing view yields no row.
20
+ CONCURRENCY_PROBE_SQL = <<~SQL.squish.freeze
21
+ SELECT c.relispopulated AS populated,
22
+ EXISTS (
23
+ SELECT 1 FROM pg_index i
24
+ WHERE i.indrelid = c.oid
25
+ AND i.indisunique
26
+ AND i.indisvalid
27
+ AND i.indpred IS NULL
28
+ AND i.indexprs IS NULL
29
+ ) AS unique_index
30
+ FROM pg_class c
31
+ WHERE c.oid = to_regclass(?) AND c.relkind = 'm'
32
+ SQL
33
+
34
+ class << self
35
+ # Resolve a requested `concurrently` value (`true`, `false`, `:auto`)
36
+ # to the Boolean actually used for the refresh.
37
+ #
38
+ # @return [Boolean]
39
+ def resolve_concurrently(view_name, requested)
40
+ return requested unless requested == :auto
41
+
42
+ validate_view_name!(view_name)
43
+ concurrent_refresh_possible?(view_name)
44
+ end
45
+
46
+ # Issue `REFRESH MATERIALIZED VIEW [CONCURRENTLY] <view_name>` and emit
47
+ # `<prefix>.projection.refreshed` on success, or
48
+ # `<prefix>.projection.failed` before re-raising on SQL failure.
49
+ #
50
+ # The view name is validated against a SQL-identifier regex at the
51
+ # boundary as defence in depth — a careless host could pass through a
52
+ # config value or other untrusted string.
53
+ #
54
+ # @param view_name [String, Symbol] bare or `schema.view` identifier.
55
+ # @param concurrently [Boolean]
56
+ # @return [void]
57
+ # @raise [ArgumentError] when `view_name` is not a valid SQL identifier.
58
+ # @raise [StandardLedger::RefreshInsideTransaction] for a concurrent
59
+ # refresh inside an open transaction.
60
+ def refresh!(view_name, concurrently:)
61
+ validate_view_name!(view_name)
62
+ check_transaction_state!(view_name, concurrently: concurrently)
63
+
64
+ prefix = StandardLedger.config.notification_namespace
65
+ sql = build_refresh_sql(view_name, concurrently: concurrently)
66
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+
68
+ begin
69
+ connection.execute(sql)
70
+ rescue StandardError => e
71
+ StandardLedger::EventEmitter.emit(
72
+ "#{prefix}.projection.failed",
73
+ view: view_name.to_s, concurrently: concurrently, mode: :matview, error: e
74
+ )
75
+ raise
76
+ end
77
+
78
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
79
+ StandardLedger::EventEmitter.emit(
80
+ "#{prefix}.projection.refreshed",
81
+ view: view_name.to_s, concurrently: concurrently, duration_ms: duration_ms
82
+ )
83
+ end
84
+
85
+ private
86
+
87
+ def connection
88
+ ActiveRecord::Base.connection
89
+ end
90
+
91
+ # Bare identifier OR exactly one `schema.view` qualification. Names
92
+ # containing semicolons, quotes, comment markers, whitespace, trailing
93
+ # dots or extra qualification are rejected before any SQL is issued.
94
+ def validate_view_name!(view_name)
95
+ return if view_name.to_s.match?(IDENTIFIER)
96
+
97
+ raise ArgumentError,
98
+ "view_name must be a valid SQL identifier; got #{view_name.inspect}"
99
+ end
100
+
101
+ # `view_name` has already passed `validate_view_name!`. It is left
102
+ # unquoted on purpose: quoting would make the name case-sensitive and
103
+ # change which relation a mixed-case name resolves to.
104
+ def build_refresh_sql(view_name, concurrently:)
105
+ if concurrently
106
+ "REFRESH MATERIALIZED VIEW CONCURRENTLY #{view_name}"
107
+ else
108
+ "REFRESH MATERIALIZED VIEW #{view_name}"
109
+ end
110
+ end
111
+
112
+ # Postgres rejects `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside a
113
+ # transaction block; catch it at the gem boundary so the failure is
114
+ # attributable instead of a raw `PG::ActiveSqlTransaction`. The plain
115
+ # form is allowed inside transactions, so only the concurrent path is
116
+ # guarded.
117
+ def check_transaction_state!(view_name, concurrently:)
118
+ return unless concurrently
119
+ return unless connection.transaction_open?
120
+
121
+ raise StandardLedger::RefreshInsideTransaction,
122
+ "StandardLedger.refresh!(#{view_name.inspect}) cannot run inside a transaction with " \
123
+ "concurrently: true — Postgres rejects `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside " \
124
+ "transaction blocks. Move the call outside the transaction, defer it via " \
125
+ "`connection.add_transaction_record { ... }`, or pass `concurrently: :auto`."
126
+ end
127
+
128
+ # `:auto` decision. A missing view returns false so the plain refresh
129
+ # raises Postgres's own "relation does not exist" error. A failing probe
130
+ # is reported (handled) and degrades to a plain refresh, which is
131
+ # always valid.
132
+ def concurrent_refresh_possible?(view_name)
133
+ return false if connection.transaction_open?
134
+
135
+ row = connection.select_one(
136
+ ActiveRecord::Base.sanitize_sql_array([ CONCURRENCY_PROBE_SQL, view_name.to_s ]),
137
+ "StandardLedger refresh probe"
138
+ )
139
+ return false if row.nil?
140
+
141
+ boolean(row["populated"]) && boolean(row["unique_index"])
142
+ rescue StandardError => e
143
+ report_error(e, view: view_name.to_s)
144
+ false
145
+ end
146
+
147
+ def boolean(value)
148
+ ActiveModel::Type::Boolean.new.cast(value) == true
149
+ end
150
+
151
+ def report_error(error, context)
152
+ reporter =
153
+ if defined?(::Rails) && ::Rails.respond_to?(:error)
154
+ ::Rails.error
155
+ elsif ActiveSupport.respond_to?(:error_reporter)
156
+ ActiveSupport.error_reporter
157
+ end
158
+ reporter&.report(error, handled: true, severity: :warning, context: context, source: "standard_ledger")
159
+ end
160
+ end
161
+ end
162
+ end
@@ -1,39 +1,32 @@
1
1
  module StandardLedger
2
- # Base class for projector classes registered via
3
- # `projects_onto :target, via: ProjectorClass`. Subclasses implement
4
- # `apply` (and optionally `rebuild`) to mutate the target based on a new
5
- # entry.
6
- #
7
- # For projections expressible as block DSL (counter increments, simple
8
- # delta updates), prefer the block form on `Projector#projects_onto`
9
- # instead — extracting a class is for non-trivial projectors.
2
+ # Optional base class for host-side projector objects: plain Ruby classes
3
+ # that update an aggregate from ledger entries. The gem does not invoke
4
+ # projectors itself (the declarative `projects_onto` engine was removed in
5
+ # 0.6.0) — the host calls `apply` / `rebuild` from its own operations.
6
+ # Subclassing gives a shared shape and sensible failures for unimplemented
7
+ # methods.
10
8
  #
11
9
  # @example
12
- # class Orders::FulfillableProjector < StandardLedger::Projection
13
- # # Called inside the async job, with target locked.
14
- # def apply(order, _entry)
15
- # order.update!(
16
- # fulfillable_balance: order.fulfillment_records.group(:key).sum(:amount),
17
- # fulfillable_status: order.fulfillment_records.group(:key).sum(:amount).values.all?(&:zero?) ? :fulfilled : :pending
18
- # )
10
+ # class Validations::ProfileProjector < StandardLedger::Projection
11
+ # def apply(profile, validation)
12
+ # profile.increment!(:successful_loans_count) if validation.successful?
19
13
  # end
20
14
  #
21
- # # Called by Projection.rebuild! to recompute from the full log.
22
- # def rebuild(order)
23
- # apply(order, nil)
15
+ # def rebuild(profile)
16
+ # profile.update!(successful_loans_count: profile.validations.successful.count)
24
17
  # end
25
18
  # end
19
+ #
20
+ # Validations::ProfileProjector.new.apply(profile, validation)
26
21
  class Projection
27
- # Apply a single entry's effect to the target. Called inside the
28
- # transactional or async boundary of the chosen mode.
22
+ # Apply a single entry's effect to the target.
29
23
  def apply(_target, _entry)
30
24
  raise NotImplementedError, "#{self.class}#apply must be implemented"
31
25
  end
32
26
 
33
- # Recompute the target's projection from the full entry log. Called by
34
- # `StandardLedger.rebuild!`. Projectors that cannot be rebuilt (e.g.
35
- # delta-only `increment_counter` flavored ones) should raise
36
- # `StandardLedger::NotRebuildable`.
27
+ # Recompute the target from the full entry log. Projectors that cannot
28
+ # be rebuilt (e.g. delta-only ones) leave this unimplemented and it
29
+ # raises `StandardLedger::NotRebuildable`.
37
30
  def rebuild(_target)
38
31
  raise NotRebuildable, "#{self.class}#rebuild not implemented; this projector cannot be rebuilt from the entry log"
39
32
  end
@@ -1,6 +1,6 @@
1
1
  module StandardLedger
2
2
  # The gem's default result type, returned by `StandardLedger.post` and
3
- # `StandardLedger.rebuild!` when the host has not configured an adapter
3
+ # `StandardLedger.refresh!` when the host has not configured an adapter
4
4
  # for its own Result class.
5
5
  #
6
6
  # Hosts with their own Result type (e.g. `ApplicationOperation::Result`)
@@ -16,7 +16,7 @@ module StandardLedger
16
16
  # @param entry [ActiveRecord::Base, nil] the persisted entry record.
17
17
  # @param idempotent [Boolean] true when the create was a no-op because an
18
18
  # existing row already satisfied the idempotency key.
19
- # @param projections [Hash] split by mode: `{ inline: [...], async: [...], matview: [...] }`.
19
+ # @param projections [Hash] `{}` for `post`; `{ refreshed: [{ view:, concurrently: }] }` for `refresh!`.
20
20
  def initialize(success:, value: nil, errors: [], entry: nil, idempotent: false, projections: {})
21
21
  @success = success
22
22
  @value = value
@@ -3,42 +3,20 @@ require "rspec/expectations"
3
3
 
4
4
  require "standard_ledger"
5
5
  require "standard_ledger/rspec/matchers"
6
- require "standard_ledger/rspec/helpers"
7
6
 
8
7
  # Opt-in test support for host apps. Hosts add this require to their
9
8
  # `spec/rails_helper.rb` (or equivalent):
10
9
  #
11
10
  # require "standard_ledger/rspec"
12
11
  #
13
- # Loading this file:
12
+ # Loading this file defines the `post_ledger_entry` matcher for assertions of
13
+ # the form `expect { ... }.to post_ledger_entry(EntryClass).with(kind: ...)`.
14
14
  #
15
- # - Registers a `before(:each)` hook that calls
16
- # `StandardLedger.reset_mode_overrides!` so the thread-local `with_modes`
17
- # override map doesn't leak between examples. We deliberately do *not* call
18
- # the full `reset!` here: hosts often configure the gem from a Rails
19
- # initializer (`StandardLedger.configure { |c| c.result_adapter = ... }`),
20
- # and wiping `@config` between examples would silently undo that
21
- # configuration for every spec. The reset is wired via `RSpec.configure`
22
- # rather than a custom shared context so it applies to every example group
23
- # automatically.
24
- # - Defines the `post_ledger_entry` matcher (see
25
- # `StandardLedger::RSpec::Matchers`) for assertions of the form
26
- # `expect { ... }.to post_ledger_entry(EntryClass).with(kind: ...)`.
27
- # - Includes `StandardLedger::RSpec::Helpers` into every example group so
28
- # specs can call `with_modes(...)` directly without the module prefix.
29
- #
30
- # We intentionally avoid touching subscribers, AR connections, or any host
31
- # state — the gem only owns its own configuration. Hosts that need additional
32
- # cleanup wire their own hooks alongside this one.
15
+ # It registers no hooks: the gem keeps no per-example state (the thread-local
16
+ # `with_modes` overrides it used to reset were removed in 0.6.0), and it never
17
+ # touches the host's `Config`, so an initializer-configured `result_adapter`
18
+ # survives across examples.
33
19
  module StandardLedger
34
20
  module RSpec
35
21
  end
36
22
  end
37
-
38
- ::RSpec.configure do |config|
39
- config.before(:each) do
40
- StandardLedger.reset_mode_overrides!
41
- end
42
-
43
- config.include StandardLedger::RSpec::Helpers
44
- end
@@ -1,3 +1,3 @@
1
1
  module StandardLedger
2
- VERSION = "0.5.1"
2
+ VERSION = "0.6.0"
3
3
  end