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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/README.md +139 -324
- data/lib/activerecord/materialized/aggregate_analysis.rb +10 -31
- data/lib/activerecord/materialized/async_refresher.rb +18 -27
- data/lib/activerecord/materialized/cache_table_schema.rb +53 -34
- data/lib/activerecord/materialized/change_source.rb +25 -0
- data/lib/activerecord/materialized/cold_read.rb +1 -10
- data/lib/activerecord/materialized/column_type_inference.rb +167 -0
- data/lib/activerecord/materialized/configurable_table_name.rb +45 -0
- data/lib/activerecord/materialized/configuration.rb +104 -29
- data/lib/activerecord/materialized/connection_routing.rb +29 -0
- data/lib/activerecord/materialized/data_verification_result.rb +41 -0
- data/lib/activerecord/materialized/data_verifier.rb +162 -0
- data/lib/activerecord/materialized/debezium_envelope.rb +105 -0
- data/lib/activerecord/materialized/delta_maintainer.rb +4 -15
- data/lib/activerecord/materialized/dependency_registry.rb +43 -35
- data/lib/activerecord/materialized/dependency_trackable.rb +7 -14
- data/lib/activerecord/materialized/incremental_maintainer.rb +45 -28
- data/lib/activerecord/materialized/incremental_refresh.rb +63 -0
- data/lib/activerecord/materialized/instrumentation.rb +102 -0
- data/lib/activerecord/materialized/maintenance_delta.rb +4 -19
- data/lib/activerecord/materialized/maintenance_delta_builder.rb +46 -14
- data/lib/activerecord/materialized/maintenance_store.rb +58 -21
- data/lib/activerecord/materialized/metadata/maintenance_payload.rb +1 -7
- data/lib/activerecord/materialized/metadata/reconciliation.rb +25 -0
- data/lib/activerecord/materialized/metadata/schema.rb +19 -41
- data/lib/activerecord/materialized/metadata/timestamps.rb +0 -5
- data/lib/activerecord/materialized/metadata.rb +41 -25
- data/lib/activerecord/materialized/metadata_record.rb +2 -16
- data/lib/activerecord/materialized/migration_builder.rb +8 -8
- data/lib/activerecord/materialized/module_api.rb +219 -22
- data/lib/activerecord/materialized/partition_filter.rb +45 -0
- data/lib/activerecord/materialized/partition_keyed_store.rb +72 -0
- data/lib/activerecord/materialized/partition_record.rb +2 -16
- data/lib/activerecord/materialized/partition_snapshot.rb +79 -0
- data/lib/activerecord/materialized/partition_state.rb +47 -46
- data/lib/activerecord/materialized/query_expressions.rb +0 -12
- data/lib/activerecord/materialized/railtie.rb +15 -3
- data/lib/activerecord/materialized/read_router.rb +51 -0
- data/lib/activerecord/materialized/reconcile_job.rb +22 -0
- data/lib/activerecord/materialized/reconcile_result.rb +36 -0
- data/lib/activerecord/materialized/reconciler.rb +79 -0
- data/lib/activerecord/materialized/refresh_callbacks.rb +4 -18
- data/lib/activerecord/materialized/refresh_job.rb +0 -4
- data/lib/activerecord/materialized/refresh_result.rb +2 -10
- data/lib/activerecord/materialized/refresh_scheduler.rb +5 -7
- data/lib/activerecord/materialized/refresher.rb +35 -50
- data/lib/activerecord/materialized/registry.rb +26 -16
- data/lib/activerecord/materialized/relation_cache_writer.rb +12 -46
- data/lib/activerecord/materialized/schema_verifier.rb +0 -9
- data/lib/activerecord/materialized/source_watermark.rb +93 -0
- data/lib/activerecord/materialized/source_watermark_record.rb +16 -0
- data/lib/activerecord/materialized/summary_delta.rb +1 -17
- data/lib/activerecord/materialized/summary_delta_builder.rb +2 -10
- data/lib/activerecord/materialized/table_model_registry.rb +1 -8
- data/lib/activerecord/materialized/table_swap.rb +106 -0
- data/lib/activerecord/materialized/tasks.rb +46 -38
- data/lib/activerecord/materialized/version.rb +1 -2
- data/lib/activerecord/materialized/view.rb +11 -22
- data/lib/activerecord/materialized/view_configuration_class_methods.rb +23 -35
- data/lib/activerecord/materialized/view_definition.rb +76 -65
- data/lib/activerecord/materialized/view_incremental_class_methods.rb +57 -60
- data/lib/activerecord/materialized/view_loader.rb +49 -0
- data/lib/activerecord/materialized/view_query_access_class_methods.rb +100 -57
- data/lib/activerecord/materialized/view_refresh_policy_class_methods.rb +70 -37
- data/lib/activerecord/materialized/write_change.rb +75 -26
- data/lib/activerecord/materialized/write_maintenance.rb +73 -0
- data/lib/activerecord/materialized/write_outbox.rb +345 -0
- data/lib/activerecord/materialized/write_outbox_record.rb +17 -0
- data/lib/activerecord/materialized.rb +27 -6
- data/lib/generators/activerecord_materialized/install/templates/create_ar_materialized_view_metadata.rb.erb +14 -0
- data/lib/generators/activerecord_materialized/install_generator.rb +1 -6
- data/lib/generators/activerecord_materialized/migration_generator.rb +3 -9
- data/lib/generators/activerecord_materialized/outbox_generator.rb +36 -0
- data/lib/generators/activerecord_materialized/templates/materialized_view_migration.rb.erb +5 -1
- data/lib/generators/activerecord_materialized/templates/write_outbox_migration.rb.erb +21 -0
- data/lib/generators/activerecord_materialized/view_generator.rb +0 -4
- metadata +28 -19
- data/lib/activerecord/materialized/type_reexports.rb +0 -14
- data/lib/activerecord/materialized/view_class.rb +0 -8
- 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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
13
|
+
OPERATIONS = %i[create update destroy].freeze
|
|
14
|
+
EMPTY_ATTRIBUTES = {}.freeze
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
attr_reader :table_name
|
|
16
|
+
attr_reader :table_name, :operation, :before, :after, :source_ts
|
|
18
17
|
|
|
19
|
-
|
|
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
|
-
|
|
30
|
+
from_descriptor(table_name: table_name, operation: :create, after: record.attributes)
|
|
42
31
|
when :update
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
36
|
+
from_descriptor(table_name: table_name, operation: :destroy, before: record.attributes)
|
|
48
37
|
else
|
|
49
|
-
|
|
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
|
-
|
|
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, :
|
|
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 "
|
|
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
|
-
|
|
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)
|