activerecord-materialized 0.1.0 → 0.2.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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/README.md +139 -324
  4. data/lib/activerecord/materialized/aggregate_analysis.rb +10 -31
  5. data/lib/activerecord/materialized/async_refresher.rb +18 -27
  6. data/lib/activerecord/materialized/cache_table_schema.rb +53 -34
  7. data/lib/activerecord/materialized/change_source.rb +25 -0
  8. data/lib/activerecord/materialized/cold_read.rb +1 -10
  9. data/lib/activerecord/materialized/column_type_inference.rb +167 -0
  10. data/lib/activerecord/materialized/configurable_table_name.rb +45 -0
  11. data/lib/activerecord/materialized/configuration.rb +104 -29
  12. data/lib/activerecord/materialized/connection_routing.rb +29 -0
  13. data/lib/activerecord/materialized/data_verification_result.rb +41 -0
  14. data/lib/activerecord/materialized/data_verifier.rb +162 -0
  15. data/lib/activerecord/materialized/debezium_envelope.rb +105 -0
  16. data/lib/activerecord/materialized/delta_maintainer.rb +4 -15
  17. data/lib/activerecord/materialized/dependency_registry.rb +43 -35
  18. data/lib/activerecord/materialized/dependency_trackable.rb +7 -14
  19. data/lib/activerecord/materialized/incremental_maintainer.rb +45 -28
  20. data/lib/activerecord/materialized/incremental_refresh.rb +63 -0
  21. data/lib/activerecord/materialized/instrumentation.rb +102 -0
  22. data/lib/activerecord/materialized/maintenance_delta.rb +4 -19
  23. data/lib/activerecord/materialized/maintenance_delta_builder.rb +46 -14
  24. data/lib/activerecord/materialized/maintenance_store.rb +58 -21
  25. data/lib/activerecord/materialized/metadata/maintenance_payload.rb +1 -7
  26. data/lib/activerecord/materialized/metadata/reconciliation.rb +25 -0
  27. data/lib/activerecord/materialized/metadata/schema.rb +19 -41
  28. data/lib/activerecord/materialized/metadata/timestamps.rb +0 -5
  29. data/lib/activerecord/materialized/metadata.rb +41 -25
  30. data/lib/activerecord/materialized/metadata_record.rb +2 -16
  31. data/lib/activerecord/materialized/migration_builder.rb +8 -8
  32. data/lib/activerecord/materialized/module_api.rb +219 -22
  33. data/lib/activerecord/materialized/partition_filter.rb +45 -0
  34. data/lib/activerecord/materialized/partition_keyed_store.rb +72 -0
  35. data/lib/activerecord/materialized/partition_record.rb +2 -16
  36. data/lib/activerecord/materialized/partition_snapshot.rb +79 -0
  37. data/lib/activerecord/materialized/partition_state.rb +47 -46
  38. data/lib/activerecord/materialized/query_expressions.rb +0 -12
  39. data/lib/activerecord/materialized/railtie.rb +15 -3
  40. data/lib/activerecord/materialized/read_router.rb +51 -0
  41. data/lib/activerecord/materialized/reconcile_job.rb +22 -0
  42. data/lib/activerecord/materialized/reconcile_result.rb +36 -0
  43. data/lib/activerecord/materialized/reconciler.rb +79 -0
  44. data/lib/activerecord/materialized/refresh_callbacks.rb +4 -18
  45. data/lib/activerecord/materialized/refresh_job.rb +0 -4
  46. data/lib/activerecord/materialized/refresh_result.rb +2 -10
  47. data/lib/activerecord/materialized/refresh_scheduler.rb +5 -7
  48. data/lib/activerecord/materialized/refresher.rb +35 -50
  49. data/lib/activerecord/materialized/registry.rb +26 -16
  50. data/lib/activerecord/materialized/relation_cache_writer.rb +12 -46
  51. data/lib/activerecord/materialized/schema_verifier.rb +0 -9
  52. data/lib/activerecord/materialized/source_watermark.rb +93 -0
  53. data/lib/activerecord/materialized/source_watermark_record.rb +16 -0
  54. data/lib/activerecord/materialized/summary_delta.rb +1 -17
  55. data/lib/activerecord/materialized/summary_delta_builder.rb +2 -10
  56. data/lib/activerecord/materialized/table_model_registry.rb +1 -8
  57. data/lib/activerecord/materialized/table_swap.rb +106 -0
  58. data/lib/activerecord/materialized/tasks.rb +46 -38
  59. data/lib/activerecord/materialized/version.rb +1 -2
  60. data/lib/activerecord/materialized/view.rb +11 -22
  61. data/lib/activerecord/materialized/view_configuration_class_methods.rb +23 -35
  62. data/lib/activerecord/materialized/view_definition.rb +76 -65
  63. data/lib/activerecord/materialized/view_incremental_class_methods.rb +57 -60
  64. data/lib/activerecord/materialized/view_loader.rb +49 -0
  65. data/lib/activerecord/materialized/view_query_access_class_methods.rb +100 -57
  66. data/lib/activerecord/materialized/view_refresh_policy_class_methods.rb +70 -37
  67. data/lib/activerecord/materialized/write_change.rb +75 -26
  68. data/lib/activerecord/materialized/write_maintenance.rb +73 -0
  69. data/lib/activerecord/materialized/write_outbox.rb +345 -0
  70. data/lib/activerecord/materialized/write_outbox_record.rb +17 -0
  71. data/lib/activerecord/materialized.rb +27 -6
  72. data/lib/generators/activerecord_materialized/install/templates/create_ar_materialized_view_metadata.rb.erb +14 -0
  73. data/lib/generators/activerecord_materialized/install_generator.rb +1 -6
  74. data/lib/generators/activerecord_materialized/migration_generator.rb +3 -9
  75. data/lib/generators/activerecord_materialized/outbox_generator.rb +36 -0
  76. data/lib/generators/activerecord_materialized/templates/materialized_view_migration.rb.erb +5 -1
  77. data/lib/generators/activerecord_materialized/templates/write_outbox_migration.rb.erb +21 -0
  78. data/lib/generators/activerecord_materialized/view_generator.rb +0 -4
  79. metadata +28 -19
  80. data/lib/activerecord/materialized/type_reexports.rb +0 -14
  81. data/lib/activerecord/materialized/view_class.rb +0 -8
  82. data/lib/activerecord_materialized_types.rb +0 -18
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
@@ -8,62 +7,112 @@ module ActiveRecord
8
7
  # changed columns — so maintenance can compute deltas even when the GROUP BY
9
8
  # key or a summed column did not change.
10
9
  class WriteChange
11
- extend T::Sig
10
+ # A snapshot supplied to {from_descriptor}/{Materialized.ingest_change}: keys
11
+ # may be strings or symbols and are normalized to strings.
12
12
 
13
- Operation = T.type_alias { T.any(Symbol, String) }
14
- Attributes = T.type_alias { T::Hash[String, T.untyped] }
13
+ OPERATIONS = %i[create update destroy].freeze
14
+ EMPTY_ATTRIBUTES = {}.freeze
15
15
 
16
- sig { returns(String) }
17
- attr_reader :table_name
16
+ attr_reader :table_name, :operation, :before, :after, :source_ts
18
17
 
19
- sig { returns(Operation) }
20
- attr_reader :operation
21
-
22
- sig { returns(Attributes) }
23
- attr_reader :before
24
-
25
- sig { returns(Attributes) }
26
- attr_reader :after
27
-
28
- sig { params(table_name: String, operation: Operation, before: Attributes, after: Attributes).void }
29
- def initialize(table_name:, operation:, before:, after:)
18
+ def initialize(table_name:, operation:, before:, after:, source_ts: nil)
30
19
  @table_name = table_name
31
20
  @operation = operation
32
21
  @before = before
33
22
  @after = after
23
+ @source_ts = source_ts # optional monotonic CDC watermark (e.g. Debezium ts_ms); nil for in-app writes
34
24
  end
35
25
 
36
- sig { params(record: ::ActiveRecord::Base, operation: Operation).returns(WriteChange) }
37
26
  def self.from_record(record, operation)
38
27
  table_name = record.class.table_name
39
28
  case operation.to_sym
40
29
  when :create
41
- new(table_name: table_name, operation: :create, before: {}, after: stringify_keys(record.attributes))
30
+ from_descriptor(table_name: table_name, operation: :create, after: record.attributes)
42
31
  when :update
43
- new(table_name: table_name, operation: :update, before: old_attributes(record),
44
- after: stringify_keys(record.attributes))
32
+ from_descriptor(table_name: table_name, operation: :update,
33
+ before: old_attributes(record), after: record.attributes)
45
34
  when :destroy
46
35
  # attributes_in_database is emptied once the row is gone; use in-memory attributes.
47
- new(table_name: table_name, operation: :destroy, before: stringify_keys(record.attributes), after: {})
36
+ from_descriptor(table_name: table_name, operation: :destroy, before: record.attributes)
48
37
  else
49
- raise ArgumentError, "unsupported write operation: #{operation}"
38
+ raise_unsupported_operation!(operation)
50
39
  end
51
40
  end
52
41
 
42
+ # Builds a change from a normalized descriptor rather than an ActiveRecord
43
+ # record — the seam a CDC / external change stream feeds. Supply full
44
+ # +before+/+after+ images when available, or +key_attributes+ (the GROUP BY
45
+ # columns) to scope maintenance to a partition; with neither — or a partial
46
+ # image that cannot identify every affected partition — it widens to a full
47
+ # recompute. Snapshot keys may be strings or symbols; an unknown operation is
48
+ # rejected.
49
+ def self.from_descriptor(table_name:, operation:, key_attributes: nil, before: nil, after: nil)
50
+ op = operation.to_sym
51
+ raise_unsupported_operation!(operation) unless OPERATIONS.include?(op)
52
+
53
+ resolved_before, resolved_after = snapshots_for(op, key_attributes, before, after)
54
+ new(table_name: table_name, operation: op,
55
+ before: stringify_keys(resolved_before), after: stringify_keys(resolved_after))
56
+ end
57
+
58
+ # A copy of this change stamped with a CDC source watermark. Kept off {from_descriptor} so its
59
+ # (public) keyword list stays within the argument-count limit; {Materialized.ingest_change}
60
+ # applies it. See {SourceWatermark}.
61
+ #
62
+ # @param source_ts [Integer] a monotonic per-partition source watermark
63
+ # @return [WriteChange]
64
+ def with_source_ts(source_ts)
65
+ self.class.new(table_name: table_name, operation: operation,
66
+ before: before, after: after, source_ts: source_ts)
67
+ end
68
+
53
69
  # Full pre-update attributes: current values with changed columns reverted.
54
- sig { params(record: ::ActiveRecord::Base).returns(Attributes) }
55
70
  def self.old_attributes(record)
56
71
  attributes = stringify_keys(record.attributes)
57
72
  record.saved_changes.each_pair { |column, (old_value, _new_value)| attributes[column.to_s] = old_value }
58
73
  attributes
59
74
  end
60
75
 
61
- sig { params(values: T::Hash[T.any(String, Symbol), T.untyped]).returns(Attributes) }
76
+ # Resolves the before/after snapshots to record for a descriptor. A create
77
+ # only affects its new partition and a destroy only its old one; an update
78
+ # can move a row between partitions, so both must be recomputed — when the
79
+ # descriptor cannot identify both (only a partial image, no keys) it widens
80
+ # (empty snapshots => full recompute) rather than silently under-scoping.
81
+ def self.snapshots_for(operation, key_attributes, before, after)
82
+ keys = key_attributes.presence
83
+ case operation
84
+ when :create then [EMPTY_ATTRIBUTES, first_present(after, keys)]
85
+ when :destroy then [first_present(before, keys), EMPTY_ATTRIBUTES]
86
+ else update_snapshots(before.presence, after.presence, keys)
87
+ end
88
+ end
89
+
90
+ # The affected partition's key image, from the row image or the key columns.
91
+ def self.first_present(image, keys)
92
+ image.presence || keys || EMPTY_ATTRIBUTES
93
+ end
94
+
95
+ # An update recomputes both the old and new partition; it scopes only when
96
+ # both are derivable (full images, or key columns for an in-place change) and
97
+ # otherwise widens to a full recompute rather than under-scoping.
98
+ def self.update_snapshots(before, after, keys)
99
+ return [before, after] if before && after
100
+ return [keys, keys] if keys
101
+
102
+ [EMPTY_ATTRIBUTES, EMPTY_ATTRIBUTES]
103
+ end
104
+
105
+ def self.raise_unsupported_operation!(operation)
106
+ raise ArgumentError, "unsupported write operation: #{operation}"
107
+ end
108
+
62
109
  def self.stringify_keys(values)
63
110
  values.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
64
111
  end
65
112
 
66
- private_class_method :old_attributes, :stringify_keys
113
+ private_class_method :old_attributes, :snapshots_for, :first_present, :update_snapshots,
114
+ :raise_unsupported_operation!, :stringify_keys
115
+ private_constant :OPERATIONS, :EMPTY_ATTRIBUTES
67
116
  end
68
117
  end
69
118
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Materialized
5
+ # Translates a committed dependency write into a view's pending maintenance —
6
+ # a summary delta on a warm delta-maintainable view, otherwise a scoped (or,
7
+ # when the partition key cannot be derived, widened) recompute — and emits the
8
+ # maintenance instrumentation event for the path taken.
9
+ #
10
+ # @api private
11
+ class WriteMaintenance
12
+ def initialize(view_class)
13
+ @view_class = view_class
14
+ end
15
+
16
+ def record!(change)
17
+ return record_summary_delta!(change) if @view_class.delta_maintaining?
18
+ return unless @view_class.incrementally_maintainable?
19
+
20
+ record_scoped_recompute!(change)
21
+ end
22
+
23
+ private
24
+
25
+ def record_scoped_recompute!(change)
26
+ delta = scoped_recompute_delta(change)
27
+ return if delta.nil? # every affected partition already applied a strictly-newer source_ts
28
+
29
+ instrument(change, path: :scoped_recompute, partitions: delta.tracked_partition_count,
30
+ scope: delta.full_partition? ? :full : :scoped)
31
+ store.merge!(delta)
32
+ mark_written_partitions_stale(delta)
33
+ end
34
+
35
+ # The affected-partition delta for a write, with provably-stale partitions suppressed by their
36
+ # source watermark (#106) when the change carries a source_ts.
37
+ def scoped_recompute_delta(change)
38
+ resolver = @view_class.partition_key_resolver_for(change.table_name)
39
+ delta = MaintenanceDeltaBuilder.new(change, @view_class.maintenance_key_columns, resolver: resolver).build
40
+ return delta unless change.source_ts
41
+
42
+ SourceWatermark.new(@view_class).suppress(change.source_ts, delta)
43
+ end
44
+
45
+ def record_summary_delta!(change)
46
+ analysis = @view_class.aggregate_analysis
47
+ summary = SummaryDeltaBuilder.new(change, analysis, @view_class.maintenance_key_columns).build
48
+ return if summary.empty?
49
+
50
+ instrument(change, path: :summary_delta, scope: :scoped, partitions: summary.tracked_partition_count)
51
+ store.merge!(summary)
52
+ end
53
+
54
+ # On a cold view the written partitions are no longer current; drop them from the fresh set until
55
+ # re-maintained. A warm view's cache stays authoritative. A cold-view full_partition (widen) is
56
+ # dropped and the whole fresh set reset at the MaintenanceStore#merge! chokepoint, so a full delta
57
+ # never needs handling here — its early return below still holds (a full delta carries no key_tuples).
58
+ def mark_written_partitions_stale(delta)
59
+ return if @view_class.materialized? || delta.full_partition?
60
+
61
+ PartitionState.new(@view_class).mark_stale!(delta.key_tuples)
62
+ end
63
+
64
+ def instrument(change, path:, scope:, partitions:)
65
+ Instrumentation.maintenance(@view_class, change: change, path: path, scope: scope, partition_count: partitions)
66
+ end
67
+
68
+ def store
69
+ MaintenanceStore.new(@view_class)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ActiveRecord
6
+ module Materialized
7
+ # The trigger/outbox change source — the completeness layer for writes that bypass **both**
8
+ # ActiveRecord callbacks and the ingestion API: raw SQL from a console, a bulk backfill,
9
+ # another service writing the shared database. Database triggers on a dependency table capture
10
+ # the changed partition keys into a durable outbox table, and {.drain!} relays them through the
11
+ # ingestion API ({Materialized.ingest_change}) so the view is maintained exactly as it would be
12
+ # for an ActiveRecord write. See +docs/out-of-band-writes.md+.
13
+ #
14
+ # Capture is *scoped*: each trigger records only the configured key columns (the GROUP BY keys),
15
+ # as JSON, in +key_before+ (the OLD image) and/or +key_after+ (the NEW image). That is precisely
16
+ # what {WriteChange.from_descriptor} needs to scope maintenance to the affected partition(s) —
17
+ # and, for an update that moves a row between partitions, to maintain both the old and the new.
18
+ #
19
+ # This is deliberately not the default change source: triggers are DDL a host app opts into
20
+ # (via the +activerecord_materialized:outbox+ generator) for the tables where out-of-band writes
21
+ # actually happen. Drift detection (#62) + +reconcile_stale!+ remain the time-bounded backstop.
22
+ module WriteOutbox
23
+ module_function
24
+
25
+ # @return [String] the outbox table name (configurable via +config.write_outbox_table_name+)
26
+ def table_name
27
+ ActiveRecord::Materialized.configuration.write_outbox_table_name
28
+ end
29
+
30
+ # Lazily provision the outbox table, mirroring {Metadata::Schema}. Idempotent.
31
+ #
32
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
33
+ # @return [void]
34
+ def ensure_table!(connection = ActiveRecord::Base.connection)
35
+ return if connection.data_source_exists?(table_name)
36
+
37
+ connection.create_table(table_name) do |t|
38
+ t.string :source_table, null: false # the dependency table that was written
39
+ t.string :operation, null: false # one of WriteChange::OPERATIONS ("create"/"update"/"destroy")
40
+ t.text :key_before # JSON of the OLD-image key columns (destroy/update); NULL on create
41
+ t.text :key_after # JSON of the NEW-image key columns (create/update); NULL on destroy
42
+ t.datetime :created_at, null: false
43
+ end
44
+ WriteOutboxRecord.reset_column_information # mirror Metadata::Schema: drop any stale column cache
45
+ end
46
+
47
+ # Install AFTER INSERT/UPDATE/DELETE triggers on +table+ that append the +key_columns+ values to
48
+ # the outbox on every write — including raw SQL that never touches ActiveRecord. Idempotent:
49
+ # re-installing drops and recreates. Portable across the gem's adapters (SQLite/MySQL/Postgres);
50
+ # the generated migration runs this so the correct dialect is emitted at migrate time.
51
+ #
52
+ # @param table [String, Symbol] the dependency table to watch
53
+ # @param key_columns [Array<String, Symbol>] the GROUP BY key columns to capture (empty for an
54
+ # un-grouped/global view — every write then relays an empty image, widening to a full recompute)
55
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
56
+ # @return [void]
57
+ def install_triggers!(table, key_columns:, connection: ActiveRecord::Base.connection)
58
+ ensure_table!(connection)
59
+ Triggers.for(connection).install!(connection, table.to_s, Array(key_columns).map(&:to_s))
60
+ end
61
+
62
+ # Remove the triggers (and, on Postgres, the trigger function) installed by {.install_triggers!}.
63
+ #
64
+ # @param table [String, Symbol]
65
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
66
+ # @return [void]
67
+ def uninstall_triggers!(table, connection: ActiveRecord::Base.connection)
68
+ Triggers.for(connection).uninstall!(connection, table.to_s)
69
+ end
70
+
71
+ # Rows relayed per internal batch, so draining a large backlog (a bulk backfill / a period
72
+ # the drain wasn't running) stays bounded in memory instead of materializing the whole outbox.
73
+ DRAIN_BATCH_SIZE = 1_000
74
+ private_constant :DRAIN_BATCH_SIZE
75
+
76
+ # Relay pending outbox rows through the ingestion API, in write order, deleting each after it is
77
+ # successfully relayed. Run from a poller, cron, or background job. Properties:
78
+ #
79
+ # - **Bounded memory.** Rows are relayed in batches of {DRAIN_BATCH_SIZE}, so even a huge backlog
80
+ # (the bulk-backfill case) drains without loading the whole outbox at once.
81
+ # - **At-least-once.** A row is deleted only after its relay succeeds, so a crash mid-drain
82
+ # re-relays the not-yet-deleted rows next pass — safe, since {Materialized.ingest_change} is
83
+ # convergent (scoped recompute is idempotent).
84
+ # - **Per-row isolation.** A relay that raises (e.g. a view whose scoped recompute fails) leaves
85
+ # only that row in the outbox for retry and is skipped for the rest of this pass, so one poison
86
+ # row can't block the writes queued behind it (mirrors {Reconciler}'s per-view isolation).
87
+ #
88
+ # A no-op (returns 0) before any triggers are installed, so a scheduled drain is safe to run even
89
+ # if the +outbox+ migration hasn't been applied yet.
90
+ #
91
+ # @param limit [Integer, nil] max rows to attempt this pass (nil = drain everything reachable)
92
+ # @return [Integer] the number of outbox rows successfully relayed and deleted
93
+ def drain!(limit: nil)
94
+ return 0 unless WriteOutboxRecord.connection.data_source_exists?(table_name)
95
+
96
+ drained = 0
97
+ attempted = 0
98
+ failed_ids = []
99
+ loop do
100
+ take = limit ? [limit - attempted, DRAIN_BATCH_SIZE].min : DRAIN_BATCH_SIZE
101
+ break if take <= 0
102
+
103
+ rows = pending_rows(failed_ids, take)
104
+ break if rows.empty?
105
+
106
+ attempted += rows.size
107
+ relayed_count, failed = relay_and_delete(rows)
108
+ drained += relayed_count
109
+ failed_ids.concat(failed)
110
+ end
111
+ drained
112
+ end
113
+
114
+ # The next batch of pending rows in write (id) order, excluding rows that already failed to relay
115
+ # this pass (so a poison row is retried once per drain, not re-read on every internal batch).
116
+ def pending_rows(failed_ids, take)
117
+ scope = WriteOutboxRecord.order(:id).limit(take)
118
+ scope = scope.where.not(id: failed_ids) if failed_ids.any?
119
+ scope.to_a
120
+ end
121
+ private_class_method :pending_rows
122
+
123
+ # Relay each row independently, delete the successfully-relayed ones, and return
124
+ # [relayed_count, failed_ids]. A relay that raises leaves its row in the outbox for retry and is
125
+ # skipped for the rest of the pass; the failing view already records its error on its own
126
+ # metadata, and we log so the isolation isn't silent — one poison row can't block the batch.
127
+ def relay_and_delete(rows)
128
+ relayed = []
129
+ failed = []
130
+ rows.each do |row|
131
+ relay(row)
132
+ relayed << row.id
133
+ rescue StandardError => e
134
+ failed << row.id
135
+ log_relay_failure(row, e)
136
+ end
137
+ WriteOutboxRecord.where(id: relayed).delete_all if relayed.any?
138
+ [relayed.size, failed]
139
+ end
140
+ private_class_method :relay_and_delete
141
+
142
+ def log_relay_failure(row, error)
143
+ ActiveRecord::Base.logger&.warn(
144
+ "[activerecord-materialized] WriteOutbox.drain! left outbox row #{row.id} " \
145
+ "(#{row.source_table}/#{row.operation}) for retry: #{error.class}: #{error.message}"
146
+ )
147
+ end
148
+ private_class_method :log_relay_failure
149
+
150
+ # Relay a single outbox row through the ingestion API. The stored key columns become the
151
+ # before/after images {WriteChange.from_descriptor} scopes on: after-only for a create, before-only
152
+ # for a destroy, both for an update (so a partition-moving update maintains old and new).
153
+ #
154
+ # @param row [WriteOutboxRecord]
155
+ # @return [void]
156
+ def relay(row)
157
+ ActiveRecord::Materialized.ingest_change(
158
+ table: row.source_table,
159
+ operation: row.operation.to_sym,
160
+ before: parse_image(row.key_before),
161
+ after: parse_image(row.key_after)
162
+ )
163
+ end
164
+ private_class_method :relay
165
+
166
+ # @return [Hash, nil] the parsed key-column image, or nil when the column is NULL for this op
167
+ def parse_image(json)
168
+ json.nil? ? nil : JSON.parse(json)
169
+ end
170
+ private_class_method :parse_image
171
+
172
+ # Cross-engine trigger DDL. Postgres captures all three operations in one trigger function
173
+ # (branching on +TG_OP+); MySQL and SQLite need one single-operation trigger apiece. All three
174
+ # dialects share the same outbox shape and the same scoped-key JSON, built by {SharedSql}.
175
+ module Triggers
176
+ module_function
177
+
178
+ # @return [Module] the adapter-specific builder for +connection+
179
+ # @raise [NotImplementedError] for an unsupported adapter
180
+ def for(connection)
181
+ case connection.adapter_name
182
+ when /postg/i then PostgreSQL
183
+ when /mysql|trilogy|maria/i then MySQL
184
+ when /sqlite/i then SQLite
185
+ else
186
+ raise NotImplementedError,
187
+ "activerecord-materialized write-outbox triggers are not supported for adapter " \
188
+ "#{connection.adapter_name.inspect}"
189
+ end
190
+ end
191
+
192
+ # One operation's contribution to a trigger: the identifier suffix, the DML event, the
193
+ # {WriteChange} operation name, and the already-built +key_before+/+key_after+ SQL expressions
194
+ # (a JSON constructor or the literal "NULL"). Bundled as a value object so the per-op
195
+ # single-trigger dialects (SQLite/MySQL) share one construction path.
196
+ Op = Data.define(:suffix, :event, :operation, :before, :after)
197
+
198
+ # Dialect-agnostic SQL fragments: identifier naming, the scoped-key JSON expression, the
199
+ # per-operation descriptors, and the shared +INSERT INTO outbox ... VALUES+ every body wraps.
200
+ module SharedSql
201
+ module_function
202
+
203
+ # Base identifier for a table's triggers/function. "arm_wob" = activerecord-materialized
204
+ # write-outbox; the +_ins+/+_upd+/+_del+/+_fn+ suffixes stay within adapter identifier limits.
205
+ def trigger_base(table)
206
+ "#{table}_arm_wob"
207
+ end
208
+
209
+ # A JSON object of the key columns read from the +row_alias+ pseudo-record (NEW or OLD),
210
+ # e.g. +json_object('category', NEW."category")+. An empty +key_columns+ yields an empty
211
+ # object, which widens maintenance to a full recompute (correct for an un-grouped view).
212
+ #
213
+ # @param json_fn [String] the dialect's JSON-object constructor
214
+ # @param row_alias [String] "NEW" or "OLD"
215
+ def json_object(connection, json_fn, row_alias, key_columns)
216
+ pairs = key_columns.flat_map do |col|
217
+ [connection.quote(col), "#{row_alias}.#{connection.quote_column_name(col)}"]
218
+ end
219
+ "#{json_fn}(#{pairs.join(', ')})"
220
+ end
221
+
222
+ # The three per-operation descriptors for a table, given its +before+/+after+ key-image
223
+ # expressions: a create captures only +after+, a destroy only +before+, an update both.
224
+ def ops(before, after)
225
+ [
226
+ Op.new(suffix: "ins", event: "INSERT", operation: "create", before: "NULL", after: after),
227
+ Op.new(suffix: "upd", event: "UPDATE", operation: "update", before: before, after: after),
228
+ Op.new(suffix: "del", event: "DELETE", operation: "destroy", before: before, after: "NULL")
229
+ ]
230
+ end
231
+
232
+ # The +INSERT INTO outbox ... VALUES (...)+ fragment shared by every dialect's trigger body.
233
+ # +CURRENT_TIMESTAMP+ is portable across all three adapters.
234
+ def insert_values(connection, table, op_spec)
235
+ outbox = connection.quote_table_name(WriteOutbox.table_name)
236
+ <<~SQL.squish
237
+ INSERT INTO #{outbox} (source_table, operation, key_before, key_after, created_at)
238
+ VALUES (#{connection.quote(table)}, #{connection.quote(op_spec.operation)}, #{op_spec.before}, #{op_spec.after}, CURRENT_TIMESTAMP)
239
+ SQL
240
+ end
241
+ end
242
+
243
+ # SQLite: one trigger per operation; +json_object+; a +BEGIN ... END+ body.
244
+ module SQLite
245
+ module_function
246
+
247
+ def install!(connection, table, key_columns)
248
+ uninstall!(connection, table)
249
+ before = SharedSql.json_object(connection, "json_object", "OLD", key_columns)
250
+ after = SharedSql.json_object(connection, "json_object", "NEW", key_columns)
251
+ SharedSql.ops(before, after).each { |op_spec| connection.execute(trigger(connection, table, op_spec)) }
252
+ end
253
+
254
+ def uninstall!(connection, table)
255
+ base = SharedSql.trigger_base(table)
256
+ %w[ins upd del].each do |suffix|
257
+ connection.execute("DROP TRIGGER IF EXISTS #{connection.quote_column_name("#{base}_#{suffix}")}")
258
+ end
259
+ end
260
+
261
+ def trigger(connection, table, op_spec)
262
+ name = connection.quote_column_name("#{SharedSql.trigger_base(table)}_#{op_spec.suffix}")
263
+ <<~SQL.squish
264
+ CREATE TRIGGER #{name} AFTER #{op_spec.event} ON #{connection.quote_table_name(table)}
265
+ BEGIN #{SharedSql.insert_values(connection, table, op_spec)}; END
266
+ SQL
267
+ end
268
+ end
269
+
270
+ # MySQL/MariaDB/trilogy: one trigger per operation; +JSON_OBJECT+; a single-statement body
271
+ # (no BEGIN/END, so no DELIMITER handling needed).
272
+ module MySQL
273
+ module_function
274
+
275
+ def install!(connection, table, key_columns)
276
+ uninstall!(connection, table)
277
+ before = SharedSql.json_object(connection, "JSON_OBJECT", "OLD", key_columns)
278
+ after = SharedSql.json_object(connection, "JSON_OBJECT", "NEW", key_columns)
279
+ SharedSql.ops(before, after).each { |op_spec| connection.execute(trigger(connection, table, op_spec)) }
280
+ end
281
+
282
+ def uninstall!(connection, table)
283
+ base = SharedSql.trigger_base(table)
284
+ %w[ins upd del].each do |suffix|
285
+ connection.execute("DROP TRIGGER IF EXISTS #{connection.quote_column_name("#{base}_#{suffix}")}")
286
+ end
287
+ end
288
+
289
+ def trigger(connection, table, op_spec)
290
+ name = connection.quote_column_name("#{SharedSql.trigger_base(table)}_#{op_spec.suffix}")
291
+ <<~SQL.squish
292
+ CREATE TRIGGER #{name} AFTER #{op_spec.event} ON #{connection.quote_table_name(table)}
293
+ FOR EACH ROW #{SharedSql.insert_values(connection, table, op_spec)}
294
+ SQL
295
+ end
296
+ end
297
+
298
+ # Postgres: a single trigger function branches on +TG_OP+ and fires for all three events.
299
+ # +jsonb_build_object(...)::text+ so the images land in the +text+ outbox columns.
300
+ module PostgreSQL
301
+ module_function
302
+
303
+ def install!(connection, table, key_columns)
304
+ uninstall!(connection, table)
305
+ base = SharedSql.trigger_base(table)
306
+ before = "#{SharedSql.json_object(connection, 'jsonb_build_object', 'OLD', key_columns)}::text"
307
+ after = "#{SharedSql.json_object(connection, 'jsonb_build_object', 'NEW', key_columns)}::text"
308
+ connection.execute(function(connection, table, SharedSql.ops(before, after)))
309
+ connection.execute(<<~SQL.squish)
310
+ CREATE TRIGGER #{connection.quote_column_name(base)}
311
+ AFTER INSERT OR UPDATE OR DELETE ON #{connection.quote_table_name(table)}
312
+ FOR EACH ROW EXECUTE FUNCTION #{connection.quote_column_name("#{base}_fn")}()
313
+ SQL
314
+ end
315
+
316
+ def uninstall!(connection, table)
317
+ base = SharedSql.trigger_base(table)
318
+ connection.execute(
319
+ "DROP TRIGGER IF EXISTS #{connection.quote_column_name(base)} ON #{connection.quote_table_name(table)}"
320
+ )
321
+ connection.execute("DROP FUNCTION IF EXISTS #{connection.quote_column_name("#{base}_fn")}()")
322
+ end
323
+
324
+ # The dollar-quoted ($fn$) body avoids escaping the single quotes in the JSON key literals.
325
+ # +ops+ is indexed by event: insert, then update, then destroy.
326
+ def function(connection, table, ops)
327
+ insert, update, destroy = ops
328
+ <<~SQL.squish
329
+ CREATE OR REPLACE FUNCTION #{connection.quote_column_name("#{SharedSql.trigger_base(table)}_fn")}()
330
+ RETURNS trigger AS $fn$
331
+ BEGIN
332
+ IF (TG_OP = 'INSERT') THEN #{SharedSql.insert_values(connection, table, insert)};
333
+ ELSIF (TG_OP = 'UPDATE') THEN #{SharedSql.insert_values(connection, table, update)};
334
+ ELSE #{SharedSql.insert_values(connection, table, destroy)};
335
+ END IF;
336
+ RETURN NULL;
337
+ END;
338
+ $fn$ LANGUAGE plpgsql
339
+ SQL
340
+ end
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Materialized
5
+ # ActiveRecord model backing the write-outbox table — the durable queue that database triggers
6
+ # append to when a dependency table is written out-of-band (raw SQL, another service, a backfill).
7
+ # {WriteOutbox.drain!} relays these rows through the ingestion API. Uses {ConfigurableTableName}'s
8
+ # dynamic resolution so a host app can rename the table via configuration.
9
+ #
10
+ # @api private
11
+ class WriteOutboxRecord < ::ActiveRecord::Base
12
+ include ConfigurableTableName
13
+
14
+ configurable_table_name { ::ActiveRecord::Materialized.configuration.write_outbox_table_name }
15
+ end
16
+ end
17
+ end
@@ -1,7 +1,5 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
- require "sorbet-runtime"
5
3
  require "active_record"
6
4
  require "active_support/concern"
7
5
  require "active_support/core_ext/string/inflections"
@@ -10,13 +8,17 @@ require "active_support/core_ext/integer/time"
10
8
  require "active_support/core_ext/object/blank"
11
9
  require "securerandom"
12
10
 
13
- require_relative "../activerecord_materialized_types"
14
- require_relative "materialized/type_reexports"
11
+ require_relative "materialized/change_source"
15
12
  require_relative "materialized/configuration"
13
+ require_relative "materialized/configurable_table_name"
14
+ require_relative "materialized/connection_routing"
16
15
  require_relative "materialized/module_api"
17
16
  require_relative "materialized/refresh_callbacks"
18
17
  require_relative "materialized/write_change"
18
+ require_relative "materialized/debezium_envelope"
19
+ require_relative "materialized/instrumentation"
19
20
  require_relative "materialized/maintenance_delta_builder"
21
+ require_relative "materialized/write_maintenance"
20
22
  require_relative "materialized/table_model_registry"
21
23
  require_relative "materialized/dependency_trackable"
22
24
  require_relative "materialized/dependency_registry"
@@ -29,27 +31,46 @@ require_relative "materialized/view_refresh_policy_class_methods"
29
31
  require_relative "materialized/view_configuration_class_methods"
30
32
  require_relative "materialized/view_query_access_class_methods"
31
33
  require_relative "materialized/view"
32
- require_relative "materialized/view_class"
33
34
  require_relative "materialized/refresh_result"
34
35
  require_relative "materialized/refresher"
35
36
  require_relative "materialized/maintenance_delta"
37
+ require_relative "materialized/partition_filter"
36
38
  require_relative "materialized/view_definition"
37
39
  require_relative "materialized/aggregate_analysis"
38
40
  require_relative "materialized/summary_delta"
39
41
  require_relative "materialized/summary_delta_builder"
40
42
  require_relative "materialized/query_expressions"
41
43
  require_relative "materialized/cache_table_schema"
44
+ # must follow cache_table_schema: aliases its ColumnDefinition at load time
45
+ require_relative "materialized/column_type_inference"
42
46
  require_relative "materialized/migration_builder"
43
47
  require_relative "materialized/schema_verifier"
48
+ require_relative "materialized/data_verification_result"
49
+ require_relative "materialized/partition_snapshot"
50
+ require_relative "materialized/data_verifier"
44
51
  require_relative "materialized/cold_read"
52
+ require_relative "materialized/read_router"
53
+ require_relative "materialized/table_swap"
45
54
  require_relative "materialized/relation_cache_writer"
46
55
  require_relative "materialized/maintenance_store"
47
56
  require_relative "materialized/incremental_maintainer"
57
+ require_relative "materialized/incremental_refresh"
48
58
  require_relative "materialized/delta_maintainer"
49
59
  require_relative "materialized/metadata"
60
+ require_relative "materialized/reconcile_result"
61
+ require_relative "materialized/reconciler"
62
+ require_relative "materialized/partition_keyed_store"
50
63
  require_relative "materialized/partition_record"
51
64
  require_relative "materialized/partition_state"
65
+ require_relative "materialized/source_watermark_record"
66
+ require_relative "materialized/source_watermark"
67
+ require_relative "materialized/write_outbox_record"
68
+ require_relative "materialized/write_outbox"
69
+ require_relative "materialized/view_loader"
52
70
 
53
- require_relative "materialized/refresh_job" if defined?(ActiveJob::Base)
71
+ if defined?(ActiveJob::Base)
72
+ require_relative "materialized/refresh_job"
73
+ require_relative "materialized/reconcile_job"
74
+ end
54
75
 
55
76
  require_relative "materialized/railtie" if defined?(Rails::Railtie)