standard_ledger 0.5.0 → 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.
@@ -1,146 +0,0 @@
1
- module StandardLedger
2
- module Modes
3
- # `:matview` mode: backs the projection with a host-owned PostgreSQL
4
- # materialized view. The host creates and owns the view (via a migration —
5
- # `scenic` or hand-rolled SQL); the gem owns the refresh schedule and the
6
- # ad-hoc refresh API.
7
- #
8
- # Unlike `:inline`, this strategy does NOT install a per-entry callback —
9
- # matview projections are scheduled, not entry-driven. The strategy's job
10
- # is to (a) record the matview registration on the entry class so callers
11
- # can enumerate `:matview` projections (used by `StandardLedger.rebuild!`
12
- # and the host's scheduler wiring), and (b) provide the `#refresh!`
13
- # primitive that issues the actual `REFRESH MATERIALIZED VIEW` SQL.
14
- #
15
- # Hosts wire their scheduler (SolidQueue Recurring Tasks, sidekiq-cron,
16
- # etc.) at `StandardLedger::MatviewRefreshJob` to drive scheduled
17
- # refreshes. Ad-hoc refreshes go through `StandardLedger.refresh!` for
18
- # read-your-write semantics after a critical write.
19
- #
20
- # See `standard_ledger-design.md` §5.3.5 for the full contract.
21
- class Matview
22
- # Mark the entry class as having at least one `:matview` projection
23
- # registered. The actual matview definition lives on the entry class's
24
- # `standard_ledger_projections` array; this method exists only to
25
- # mirror the `Modes::Inline.install!` shape and to mark the class so
26
- # repeated registrations are recognised as idempotent installs.
27
- #
28
- # Idempotent — multiple `:matview` projections on the same entry class
29
- # do not cause double registration. Unlike `:inline`, no `after_create`
30
- # callback is installed.
31
- #
32
- # @param entry_class [Class] the host entry class.
33
- # @return [void]
34
- def self.install!(entry_class)
35
- return if entry_class.instance_variable_get(:@_standard_ledger_matview_installed)
36
-
37
- entry_class.instance_variable_set(:@_standard_ledger_matview_installed, true)
38
- end
39
-
40
- # Issue `REFRESH MATERIALIZED VIEW [CONCURRENTLY] <view_name>` against
41
- # the active connection and emit the standard `<prefix>.projection.refreshed`
42
- # notification on success (or `<prefix>.projection.failed` on raise,
43
- # before re-raising). The view name is validated against a SQL-identifier
44
- # regex at the boundary as a defence-in-depth check — the value normally
45
- # comes from a `view:` DSL keyword in source code, but a careless host
46
- # could pass through a config value or other untrusted string. Anything
47
- # that isn't a bare or schema-qualified identifier raises.
48
- #
49
- # The default `:concurrent` strategy (and `concurrently: true` per-call)
50
- # requires a unique index on the matview — Postgres rejects
51
- # `REFRESH MATERIALIZED VIEW CONCURRENTLY` otherwise. Hosts who haven't
52
- # added one should pass `concurrently: false` or set
53
- # `Config#matview_refresh_strategy = :blocking`.
54
- #
55
- # @param view_name [String, Symbol] the materialized view to refresh.
56
- # Must match `/\A[a-zA-Z_][a-zA-Z0-9_.]*\z/` so a single dot is
57
- # permitted for `schema.view` qualified names.
58
- # @param concurrently [Boolean] when true, adds `CONCURRENTLY` (which
59
- # requires a unique index on the view).
60
- # @return [void]
61
- # @raise [ArgumentError] when `view_name` is not a valid SQL identifier.
62
- # @raise [StandardError] anything the connection raises while running
63
- # the SQL — re-raised after the `failed` event fires.
64
- def self.refresh!(view_name, concurrently:)
65
- validate_view_name!(view_name)
66
- check_transaction_state!(view_name, concurrently: concurrently)
67
-
68
- prefix = StandardLedger.config.notification_namespace
69
- sql = build_refresh_sql(view_name, concurrently: concurrently)
70
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
71
-
72
- ActiveRecord::Base.connection.execute(sql)
73
-
74
- duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
75
- StandardLedger::EventEmitter.emit(
76
- "#{prefix}.projection.refreshed",
77
- view: view_name.to_s, concurrently: concurrently, duration_ms: duration_ms
78
- )
79
- rescue StandardError => e
80
- # ArgumentError from the validator and RefreshInsideTransaction from
81
- # the boundary check should propagate without firing the failed
82
- # notification — the SQL was never issued.
83
- raise if e.is_a?(ArgumentError) || e.is_a?(StandardLedger::RefreshInsideTransaction)
84
-
85
- StandardLedger::EventEmitter.emit(
86
- "#{prefix}.projection.failed",
87
- view: view_name.to_s, concurrently: concurrently, mode: :matview, error: e
88
- )
89
- raise
90
- end
91
-
92
- # Reject anything that isn't a bare or schema-qualified SQL identifier
93
- # — the matching regex allows a leading letter/underscore followed by
94
- # alphanumerics, underscores, or a single dot for `schema.view`. Names
95
- # containing semicolons, quotes, comment markers (`--`), whitespace, or
96
- # other punctuation are rejected at the `refresh!` boundary so SQL
97
- # injection isn't possible even when a host carelessly pipes a config
98
- # value into the call.
99
- def self.validate_view_name!(view_name)
100
- # Bare identifier OR exactly one schema-qualified `schema.view` part.
101
- # The previous shape `\A[a-zA-Z_][a-zA-Z0-9_.]*\z` allowed trailing
102
- # dots (`reporting.`) and unlimited qualification (`a.b.c.d`); both
103
- # would round-trip to a Postgres syntax error at `connection.execute`
104
- # time rather than the gem boundary.
105
- return if view_name.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?\z/)
106
-
107
- raise ArgumentError,
108
- "view_name must be a valid SQL identifier; got #{view_name.inspect}"
109
- end
110
- private_class_method :validate_view_name!
111
-
112
- # Reject `refresh!` calls issued from inside an open transaction when
113
- # `concurrently: true`. Postgres rejects
114
- # `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside a transaction block
115
- # (and would otherwise raise `PG::ActiveSqlTransaction` mid-call); we
116
- # catch it at the gem boundary so the failure is attributable.
117
- #
118
- # The non-concurrent (`concurrently: false`) form *is* permitted inside
119
- # a transaction by Postgres, so we only guard the concurrent path.
120
- #
121
- # Use `connection.add_transaction_record { … }` to defer to after-commit
122
- # if you need read-your-write semantics from inside a transactional
123
- # operation; otherwise, move the refresh outside the transaction.
124
- def self.check_transaction_state!(view_name, concurrently:)
125
- return unless concurrently
126
- return unless ActiveRecord::Base.connection.transaction_open?
127
-
128
- raise StandardLedger::RefreshInsideTransaction,
129
- "StandardLedger.refresh!(#{view_name.inspect}) cannot run inside a transaction with " \
130
- "concurrently: true — Postgres rejects `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside " \
131
- "transaction blocks. Move the call outside the transaction, or defer it via " \
132
- "`connection.add_transaction_record { ... }` for after-commit execution."
133
- end
134
- private_class_method :check_transaction_state!
135
-
136
- def self.build_refresh_sql(view_name, concurrently:)
137
- if concurrently
138
- "REFRESH MATERIALIZED VIEW CONCURRENTLY #{view_name}"
139
- else
140
- "REFRESH MATERIALIZED VIEW #{view_name}"
141
- end
142
- end
143
- private_class_method :build_refresh_sql
144
- end
145
- end
146
- end
@@ -1,132 +0,0 @@
1
- module StandardLedger
2
- module Modes
3
- # `:sql` mode: applies the projection by running a single recompute
4
- # `UPDATE` against the target table, inside the entry's `after_create`
5
- # callback. The recompute SQL is supplied via the block-DSL's
6
- # `recompute "..."` clause; the `:target_id` placeholder is bound to
7
- # the foreign-key value the entry holds for the target association.
8
- #
9
- # Lower-overhead than `:inline` for projections expressible as
10
- # `UPDATE target SET col = (SELECT aggregate(...) FROM entries WHERE
11
- # ...)` — there's no Ruby-side handler invocation, no per-counter
12
- # in-memory mutation, and no AR object load. Naturally rebuildable:
13
- # `StandardLedger.rebuild!` runs the same statement against each
14
- # target the log references, so the `after_create` and `rebuild!`
15
- # paths share the same recompute SQL.
16
- #
17
- # Like `:inline`, this fires from `after_create` (in the entry's
18
- # transaction), so failures roll the entry back alongside the
19
- # projection. The notification payload's `:target` field is `nil` for
20
- # `:sql` mode — loading the target object would defeat the point of
21
- # the mode (it's meant to avoid Ruby-side reads). Subscribers get
22
- # `entry`, the projection definition, and the timing.
23
- class Sql
24
- # Install the `after_create` callback on `entry_class` exactly once.
25
- # Subsequent calls (e.g. when a second `:sql` projection is added
26
- # later in the class body) are no-ops — the same callback handles
27
- # all `:sql` projections registered on the class.
28
- #
29
- # @param entry_class [Class] the host entry class.
30
- # @return [void]
31
- # @raise [ArgumentError] when `entry_class` is not ActiveRecord-backed
32
- # (no `after_create` hook available). `:sql` mode requires an
33
- # AR-backed entry class — the recompute SQL runs through
34
- # `entry.class.connection.exec_update`.
35
- def self.install!(entry_class)
36
- return if entry_class.instance_variable_get(:@_standard_ledger_sql_installed)
37
-
38
- unless entry_class.respond_to?(:after_create)
39
- raise ArgumentError,
40
- "#{entry_class.name || entry_class.inspect} cannot use mode: :sql " \
41
- "because it does not respond to `after_create`. `:sql` mode requires " \
42
- "an ActiveRecord-backed entry class."
43
- end
44
-
45
- entry_class.after_create { StandardLedger::Modes::Sql.new.call(self) }
46
- entry_class.instance_variable_set(:@_standard_ledger_sql_installed, true)
47
- end
48
-
49
- # Apply every `:sql` projection registered on the entry's class.
50
- # Called from the `after_create` callback installed by `.install!`.
51
- #
52
- # For each definition: resolve the target's foreign key from the
53
- # entry, evaluate the optional `if:` guard, and run the recompute
54
- # SQL with `:target_id` bound to the FK value.
55
- #
56
- # A nil FK (target unset for this entry) is silently skipped — the
57
- # entry simply doesn't project onto a target this round. A guard
58
- # returning false is also silently skipped.
59
- #
60
- # Any exception raised by the SQL escapes — the entry's transaction
61
- # rolls back with the projection. The
62
- # `<prefix>.projection.failed` notification fires before the
63
- # re-raise so subscribers see the failure.
64
- #
65
- # @param entry [ActiveRecord::Base] the just-created entry.
66
- # @return [void]
67
- def call(entry)
68
- definitions = sql_definitions_for(entry.class)
69
- return if definitions.empty?
70
-
71
- definitions.each do |definition|
72
- next if definition.guard && !entry.instance_exec(&definition.guard)
73
-
74
- target_id = resolve_target_id(entry, definition)
75
- next if target_id.nil?
76
-
77
- run_recompute_sql(entry, definition, target_id)
78
- end
79
- end
80
-
81
- private
82
-
83
- def sql_definitions_for(entry_class)
84
- return [] unless entry_class.respond_to?(:standard_ledger_projections_for)
85
-
86
- entry_class.standard_ledger_projections_for(:sql)
87
- end
88
-
89
- # Pull the FK value off the entry without loading the target. The
90
- # reflection's `foreign_key` is the column name (e.g.
91
- # `"voucher_scheme_id"`); reading it via `public_send` avoids
92
- # triggering an AR association load.
93
- def resolve_target_id(entry, definition)
94
- reflection = entry.class.reflect_on_association(definition.target_association)
95
- return nil if reflection.nil?
96
-
97
- entry.public_send(reflection.foreign_key)
98
- end
99
-
100
- # Run the recompute statement, instrumenting success and failure
101
- # symmetrically with the inline mode's two-event split. The target
102
- # is intentionally `nil` in the payload — loading it would defeat
103
- # the mode's "no Ruby-side reads" contract; subscribers that want
104
- # the target should reload it themselves from the
105
- # `definition.target_association` + the entry.
106
- def run_recompute_sql(entry, definition, target_id)
107
- prefix = StandardLedger.config.notification_namespace
108
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
-
110
- begin
111
- sql = ActiveRecord::Base.sanitize_sql_array([ definition.recompute_sql, { target_id: target_id } ])
112
- entry.class.connection.exec_update(sql)
113
- rescue StandardError => e
114
- duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
115
- StandardLedger::EventEmitter.emit(
116
- "#{prefix}.projection.failed",
117
- entry: entry, target: nil, projection: definition.target_association,
118
- mode: :sql, error: e, duration_ms: duration_ms
119
- )
120
- raise
121
- end
122
-
123
- duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
124
- StandardLedger::EventEmitter.emit(
125
- "#{prefix}.projection.applied",
126
- entry: entry, target: nil, projection: definition.target_association,
127
- mode: :sql, duration_ms: duration_ms
128
- )
129
- end
130
- end
131
- end
132
- end
@@ -1,50 +0,0 @@
1
- module StandardLedger
2
- module Modes
3
- # `:trigger` mode: the host owns a database trigger (created in a Rails
4
- # migration) that updates the projection target's columns on every
5
- # entry INSERT. The gem records the trigger's name and the equivalent
6
- # rebuild SQL, but does **not** create or manage the trigger itself —
7
- # giving a Ruby DSL the power to install/replace triggers is a deploy
8
- # footgun (silent re-creation on `db:schema:load` against a non-empty
9
- # DB), and triggers are versioned by `db/schema.rb` like any other DDL.
10
- #
11
- # Two consumers read the recorded metadata:
12
- #
13
- # - `StandardLedger.rebuild!` runs the rebuild SQL when invoked,
14
- # binding `:target_id` to each target the log references. The same
15
- # recompute path as `:sql` mode — the only difference is that the
16
- # after-create application is performed by the database trigger
17
- # rather than a Ruby callback.
18
- # - The `standard_ledger:doctor` rake task verifies that the named
19
- # trigger exists in the connected schema. Migration drift (a missing
20
- # or renamed trigger) is caught at deploy time, not at runtime.
21
- #
22
- # Unlike `:inline` and `:sql`, this strategy does NOT install an
23
- # `after_create` callback — the trigger fires from the database. The
24
- # `install!` no-op only marks the entry class as having at least one
25
- # `:trigger` projection registered (mirrors the `Modes::Matview` shape).
26
- #
27
- # See `standard_ledger-design.md` §5.3.4 for the full contract.
28
- class Trigger
29
- # Mark the entry class as having at least one `:trigger` projection
30
- # registered. The actual trigger metadata lives on the entry class's
31
- # `standard_ledger_projections` array; this method exists only to
32
- # mirror the `Modes::*.install!` shape so `Projector#install_mode_callbacks_for`
33
- # can dispatch uniformly across modes.
34
- #
35
- # No `after_create` callback is installed — the trigger runs in the
36
- # database, not Ruby. Idempotent across multiple `:trigger`
37
- # projections on the same entry class.
38
- #
39
- # @param entry_class [Class] the host entry class (unused — kept for
40
- # parity with the other strategy classes).
41
- # @return [void]
42
- def self.install!(_entry_class)
43
- # Intentionally empty: trigger projections fire from the DB, so
44
- # there's no Ruby-side callback to wire. The DSL has already
45
- # captured `trigger_name` and `recompute_sql` on the Definition;
46
- # `rebuild!` and the `doctor` rake task consume those directly.
47
- end
48
- end
49
- end
50
- end