cdc-core 0.0.0 → 0.1.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 +29 -1
- data/LICENSE.txt +2 -1
- data/README.md +145 -22
- data/lib/cdc/core/change_event.rb +116 -0
- data/lib/cdc/core/column_change.rb +55 -0
- data/lib/cdc/core/composite_processor.rb +71 -0
- data/lib/cdc/core/errors.rb +14 -0
- data/lib/cdc/core/event_metadata.rb +78 -0
- data/lib/cdc/core/filter.rb +77 -0
- data/lib/cdc/core/operation.rb +51 -0
- data/lib/cdc/core/pipeline.rb +66 -0
- data/lib/cdc/core/processor.rb +43 -0
- data/lib/cdc/core/processor_result.rb +63 -0
- data/lib/cdc/core/transaction_envelope.rb +58 -0
- data/lib/cdc/core/version.rb +3 -2
- data/lib/cdc/core.rb +20 -4
- data/lib/cdc_core.rb +7 -0
- data/sig/cdc/core/change_event.rbs +226 -0
- data/sig/cdc/core/column_change.rbs +56 -0
- data/sig/cdc/core/composite_processor.rbs +53 -0
- data/sig/cdc/core/errors.rbs +15 -0
- data/sig/cdc/core/event_metadata.rbs +51 -0
- data/sig/cdc/core/filter.rbs +68 -0
- data/sig/cdc/core/operation.rbs +44 -0
- data/sig/cdc/core/pipeline.rbs +55 -0
- data/sig/cdc/core/processor.rbs +35 -0
- data/sig/cdc/core/processor_result.rbs +81 -0
- data/sig/cdc/core/transaction_envelope.rbs +79 -0
- data/sig/cdc/core/version.rbs +6 -0
- data/sig/cdc/core.rbs +8 -3
- data/sig/cdc_core.rbs +0 -0
- metadata +34 -7
- data/CODE_OF_CONDUCT.md +0 -10
- data/Rakefile +0 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5ace8b27c4df8285c5354599650cb450cddaad1402c913689e638fb9ec57d8b9
|
|
4
|
+
data.tar.gz: 381f77ee16278ea147b083b677d5e9248a2fb6bf1955c40f9d2eab82055a1e49
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c0447c01564b04f20170e8a7f9e60cb95c12f77ef87ce54ab8a090083b7054a041224247349a9424e803f22255bbe097b33706a16c267ed1710e67fab7e6fff0
|
|
7
|
+
data.tar.gz: 9aef7f1a3ffd777c6031e700c49f17a3aa460fa46e898d648d55b81c1a2afaf6df4e19fd700bbadad57be382d0809990909f9a97ac721ea7121fc3e327c868b7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
1
8
|
## [Unreleased]
|
|
2
9
|
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Placeholder for future development.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
3
16
|
## [0.1.0] - 2026-05-31
|
|
4
17
|
|
|
5
|
-
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Added immutable `CDC::Core::ChangeEvent`.
|
|
21
|
+
- Added immutable `CDC::Core::ColumnChange`.
|
|
22
|
+
- Added immutable `CDC::Core::TransactionEnvelope`.
|
|
23
|
+
- Added `CDC::Core::EventMetadata`.
|
|
24
|
+
- Added operation validation.
|
|
25
|
+
- Added `CDC::Core::Processor` base contract.
|
|
26
|
+
- Added `CDC::Core::ProcessorResult`.
|
|
27
|
+
- Added `CDC::Core::CompositeProcessor`.
|
|
28
|
+
- Added `CDC::Core::Filter`.
|
|
29
|
+
- Added `CDC::Core::Pipeline`.
|
|
30
|
+
- Added Ractor-safe processor intent declaration via `ractor_safe!`.
|
|
31
|
+
- Added RBS signatures.
|
|
32
|
+
- Added Minitest coverage.
|
|
33
|
+
- Added README and examples.
|
data/LICENSE.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
The MIT License (MIT)
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026
|
|
3
|
+
Copyright (c) 2026 Kenneth C. Demanawa
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -19,3 +19,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
19
19
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
20
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
21
|
THE SOFTWARE.
|
|
22
|
+
|
data/README.md
CHANGED
|
@@ -1,43 +1,166 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# cdc-core
|
|
2
|
+
|
|
3
|
+
Database-agnostic Change Data Capture domain primitives for Ruby.
|
|
4
|
+
|
|
5
|
+
`cdc-core` provides immutable, Ractor-safe event objects and processor contracts for building CDC systems. It intentionally does not connect to databases, parse wire protocols, decode PostgreSQL OIDs, or integrate with Rails.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Ruby 3.4+
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Immutable `ChangeEvent` objects
|
|
14
|
+
- Transaction grouping via `TransactionEnvelope`
|
|
15
|
+
- Column-level change objects
|
|
16
|
+
- Processor and composite processor contracts
|
|
17
|
+
- Event filters
|
|
18
|
+
- Small pipeline orchestration object
|
|
19
|
+
- Ractor-safe event and transaction objects
|
|
20
|
+
- RBS signatures
|
|
21
|
+
- YARD-compatible documentation
|
|
22
|
+
- No runtime dependencies
|
|
23
|
+
|
|
24
|
+
## Ecosystem Position
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
pgoutput-client
|
|
28
|
+
│
|
|
29
|
+
▼
|
|
30
|
+
pgoutput-parser
|
|
31
|
+
│
|
|
32
|
+
▼
|
|
33
|
+
pgoutput-decoder
|
|
34
|
+
│
|
|
35
|
+
▼
|
|
36
|
+
cdc-core
|
|
37
|
+
│
|
|
38
|
+
▼
|
|
39
|
+
whodunit-chronicles
|
|
40
|
+
```
|
|
4
41
|
|
|
5
|
-
|
|
42
|
+
`cdc-core` is the shared vocabulary layer. It defines what a change event, transaction, processor, and pipeline result means without caring where the event came from.
|
|
6
43
|
|
|
7
44
|
## Installation
|
|
8
45
|
|
|
9
|
-
|
|
46
|
+
```ruby
|
|
47
|
+
gem "cdc-core"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
require "cdc/core"
|
|
52
|
+
```
|
|
10
53
|
|
|
11
|
-
|
|
54
|
+
## Change Events
|
|
12
55
|
|
|
13
|
-
```
|
|
14
|
-
|
|
56
|
+
```ruby
|
|
57
|
+
event = CDC::Core::ChangeEvent.new(
|
|
58
|
+
operation: :update,
|
|
59
|
+
schema: "public",
|
|
60
|
+
table: "users",
|
|
61
|
+
old_values: { "email" => "old@example.com" },
|
|
62
|
+
new_values: { "email" => "new@example.com" },
|
|
63
|
+
primary_key: { "id" => 7 },
|
|
64
|
+
transaction_id: 789,
|
|
65
|
+
commit_lsn: "0/16B6C50"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
event.update?
|
|
69
|
+
# => true
|
|
70
|
+
|
|
71
|
+
event.qualified_table_name
|
|
72
|
+
# => "public.users"
|
|
73
|
+
|
|
74
|
+
event.changes.map(&:name)
|
|
75
|
+
# => ["email"]
|
|
15
76
|
```
|
|
16
77
|
|
|
17
|
-
|
|
78
|
+
## Transactions
|
|
18
79
|
|
|
19
|
-
```
|
|
20
|
-
|
|
80
|
+
```ruby
|
|
81
|
+
transaction = CDC::Core::TransactionEnvelope.new(
|
|
82
|
+
transaction_id: 789,
|
|
83
|
+
events: [event],
|
|
84
|
+
commit_lsn: "0/16B6C50",
|
|
85
|
+
committed_at: Time.now.utc
|
|
86
|
+
)
|
|
21
87
|
```
|
|
22
88
|
|
|
23
|
-
|
|
89
|
+
A transaction envelope is the natural unit for future parallel processing because it preserves database transaction boundaries.
|
|
24
90
|
|
|
25
|
-
|
|
91
|
+
## Processors
|
|
26
92
|
|
|
27
|
-
|
|
93
|
+
```ruby
|
|
94
|
+
class AuditProcessor < CDC::Core::Processor
|
|
95
|
+
def process(event)
|
|
96
|
+
puts event.to_h
|
|
97
|
+
CDC::Core::ProcessorResult.success(event)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
28
101
|
|
|
29
|
-
|
|
102
|
+
## Ractor-safe processor intent
|
|
30
103
|
|
|
31
|
-
|
|
104
|
+
```ruby
|
|
105
|
+
class AnalyticsProcessor < CDC::Core::Processor
|
|
106
|
+
ractor_safe!
|
|
32
107
|
|
|
33
|
-
|
|
108
|
+
def process(event)
|
|
109
|
+
CDC::Core::ProcessorResult.success(event)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
34
112
|
|
|
35
|
-
|
|
113
|
+
AnalyticsProcessor.new.ractor_safe?
|
|
114
|
+
# => true
|
|
115
|
+
```
|
|
36
116
|
|
|
37
|
-
|
|
117
|
+
This declares intent only. `cdc-core` does not execute processors in Ractors. A future runtime gem can use this signal.
|
|
118
|
+
|
|
119
|
+
## Composite Processor
|
|
38
120
|
|
|
39
|
-
|
|
121
|
+
```ruby
|
|
122
|
+
processor = CDC::Core::CompositeProcessor.new([
|
|
123
|
+
AuditProcessor.new,
|
|
124
|
+
AnalyticsProcessor.new
|
|
125
|
+
])
|
|
40
126
|
|
|
41
|
-
|
|
127
|
+
results = processor.process(event)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Filters and Pipeline
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
pipeline = CDC::Core::Pipeline.new(
|
|
134
|
+
processor: AuditProcessor.new,
|
|
135
|
+
filters: [
|
|
136
|
+
CDC::Core::Filter.schema("public"),
|
|
137
|
+
CDC::Core::Filter.table("users")
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
result = pipeline.process(event)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Non-goals
|
|
145
|
+
|
|
146
|
+
`cdc-core` does not:
|
|
147
|
+
|
|
148
|
+
- Connect to PostgreSQL
|
|
149
|
+
- Parse `pgoutput`
|
|
150
|
+
- Decode PostgreSQL values
|
|
151
|
+
- Manage replication slots
|
|
152
|
+
- Run Ractor pools
|
|
153
|
+
- Persist audit records
|
|
154
|
+
- Integrate with ActiveRecord
|
|
155
|
+
- Publish to Kafka, Redis, or HTTP sinks
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
bundle exec rake
|
|
161
|
+
bundle exec steep check
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## License
|
|
42
165
|
|
|
43
|
-
|
|
166
|
+
MIT.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CDC
|
|
4
|
+
module Core
|
|
5
|
+
# Immutable representation of one logical database change.
|
|
6
|
+
#
|
|
7
|
+
# ChangeEvent is the core data structure passed through filters, pipelines,
|
|
8
|
+
# and processors. It is database-agnostic but carries common CDC fields such
|
|
9
|
+
# as operation, schema, table, before/after values, primary key, LSN, and
|
|
10
|
+
# metadata.
|
|
11
|
+
class ChangeEvent
|
|
12
|
+
# @return [Symbol] normalized CDC operation
|
|
13
|
+
# @return [String] database schema name
|
|
14
|
+
# @return [String] database table name
|
|
15
|
+
# @return [Hash, nil] values before the change
|
|
16
|
+
# @return [Hash, nil] values after the change
|
|
17
|
+
# @return [Hash, nil] primary key values for the changed row
|
|
18
|
+
# @return [Object, nil] transaction identifier from the upstream source
|
|
19
|
+
# @return [String, nil] commit log sequence number
|
|
20
|
+
# @return [Integer, nil] event sequence within a transaction or stream
|
|
21
|
+
# @return [Time, nil] timestamp associated with the event
|
|
22
|
+
# @return [EventMetadata] additional normalized metadata
|
|
23
|
+
attr_reader :operation, :schema, :table, :old_values, :new_values, :primary_key,
|
|
24
|
+
:transaction_id, :commit_lsn, :sequence_number, :occurred_at, :metadata
|
|
25
|
+
|
|
26
|
+
# Build a change event.
|
|
27
|
+
#
|
|
28
|
+
# @param operation [#to_sym] CDC operation
|
|
29
|
+
# @param schema [#to_s] schema name
|
|
30
|
+
# @param table [#to_s] table name
|
|
31
|
+
# @param old_values [Hash, nil] values before the change
|
|
32
|
+
# @param new_values [Hash, nil] values after the change
|
|
33
|
+
# @param primary_key [Hash, nil] primary key values
|
|
34
|
+
# @param transaction_id [Object, nil] source transaction identifier
|
|
35
|
+
# @param commit_lsn [#to_s, nil] commit log sequence number
|
|
36
|
+
# @param sequence_number [Integer, nil] event sequence number
|
|
37
|
+
# @param occurred_at [Time, nil] event timestamp
|
|
38
|
+
# @param metadata [Hash, EventMetadata] additional event metadata
|
|
39
|
+
def initialize(operation:, schema:, table:, old_values: nil, new_values: nil, primary_key: nil,
|
|
40
|
+
transaction_id: nil, commit_lsn: nil, sequence_number: nil, occurred_at: nil,
|
|
41
|
+
metadata: {})
|
|
42
|
+
@operation = Operation.normalize(operation)
|
|
43
|
+
@schema = String(schema).freeze
|
|
44
|
+
@table = String(table).freeze
|
|
45
|
+
@old_values = freeze_hash_or_nil(old_values)
|
|
46
|
+
@new_values = freeze_hash_or_nil(new_values)
|
|
47
|
+
@primary_key = freeze_hash_or_nil(primary_key)
|
|
48
|
+
@transaction_id = transaction_id
|
|
49
|
+
@commit_lsn = commit_lsn&.to_s&.freeze
|
|
50
|
+
@sequence_number = sequence_number
|
|
51
|
+
@occurred_at = occurred_at
|
|
52
|
+
@metadata = metadata.is_a?(EventMetadata) ? metadata : EventMetadata.new(metadata)
|
|
53
|
+
Ractor.make_shareable(self)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Boolean] true for insert events
|
|
57
|
+
def insert? = operation == Operation::INSERT
|
|
58
|
+
|
|
59
|
+
# @return [Boolean] true for update events
|
|
60
|
+
def update? = operation == Operation::UPDATE
|
|
61
|
+
|
|
62
|
+
# @return [Boolean] true for delete events
|
|
63
|
+
def delete? = operation == Operation::DELETE
|
|
64
|
+
|
|
65
|
+
# Fully qualified table name in schema.table form.
|
|
66
|
+
#
|
|
67
|
+
# @return [String]
|
|
68
|
+
def qualified_table_name = "#{schema}.#{table}".freeze
|
|
69
|
+
|
|
70
|
+
# Compute changed columns by comparing old and new values.
|
|
71
|
+
#
|
|
72
|
+
# Columns with equal old and new values are omitted. Insert and delete
|
|
73
|
+
# events can pass nil for one side; missing values are represented as nil.
|
|
74
|
+
#
|
|
75
|
+
# @return [Array<ColumnChange>] Ractor-shareable changed columns
|
|
76
|
+
def changes
|
|
77
|
+
keys = ((old_values || {}).keys | (new_values || {}).keys)
|
|
78
|
+
keys.filter_map do |key|
|
|
79
|
+
change = ColumnChange.new(name: key, old_value: old_values&.[](key), new_value: new_values&.[](key))
|
|
80
|
+
change if change.changed?
|
|
81
|
+
end.then { |items| Ractor.make_shareable(items.freeze) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Convert the event into a Ractor-shareable hash.
|
|
85
|
+
#
|
|
86
|
+
# @return [Hash{String=>Object,nil}]
|
|
87
|
+
def to_h
|
|
88
|
+
Ractor.make_shareable({
|
|
89
|
+
'operation' => operation,
|
|
90
|
+
'schema' => schema,
|
|
91
|
+
'table' => table,
|
|
92
|
+
'old_values' => old_values,
|
|
93
|
+
'new_values' => new_values,
|
|
94
|
+
'primary_key' => primary_key,
|
|
95
|
+
'transaction_id' => transaction_id,
|
|
96
|
+
'commit_lsn' => commit_lsn,
|
|
97
|
+
'sequence_number' => sequence_number,
|
|
98
|
+
'occurred_at' => occurred_at,
|
|
99
|
+
'metadata' => metadata.to_h
|
|
100
|
+
}.freeze)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# Convert a hash into immutable EventMetadata storage, preserving nil.
|
|
106
|
+
#
|
|
107
|
+
# @param hash [Hash, nil]
|
|
108
|
+
# @return [Hash, nil]
|
|
109
|
+
def freeze_hash_or_nil(hash)
|
|
110
|
+
return nil if hash.nil?
|
|
111
|
+
|
|
112
|
+
EventMetadata.new(hash).to_h
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CDC
|
|
4
|
+
module Core
|
|
5
|
+
# Represents a single column-level value change.
|
|
6
|
+
#
|
|
7
|
+
# ColumnChange is immutable and Ractor-shareable. Values that cannot be made
|
|
8
|
+
# shareable by Ruby are represented by their frozen #inspect string so the
|
|
9
|
+
# enclosing event can still cross Ractor boundaries safely.
|
|
10
|
+
class ColumnChange
|
|
11
|
+
# @return [String] column name
|
|
12
|
+
# @return [Object, nil] value before the change
|
|
13
|
+
# @return [Object, nil] value after the change
|
|
14
|
+
attr_reader :name, :old_value, :new_value
|
|
15
|
+
|
|
16
|
+
# Build a column-level change object.
|
|
17
|
+
#
|
|
18
|
+
# @param name [#to_s] column name
|
|
19
|
+
# @param old_value [Object, nil] previous value
|
|
20
|
+
# @param new_value [Object, nil] new value
|
|
21
|
+
def initialize(name:, old_value:, new_value:)
|
|
22
|
+
@name = String(name).freeze
|
|
23
|
+
@old_value = make_value_shareable(old_value)
|
|
24
|
+
@new_value = make_value_shareable(new_value)
|
|
25
|
+
Ractor.make_shareable(self)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Whether the old and new values differ.
|
|
29
|
+
#
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def changed?
|
|
32
|
+
old_value != new_value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Convert the change into a Ractor-shareable hash.
|
|
36
|
+
#
|
|
37
|
+
# @return [Hash{String=>Object,nil}]
|
|
38
|
+
def to_h
|
|
39
|
+
Ractor.make_shareable({ 'name' => name, 'old_value' => old_value, 'new_value' => new_value }.freeze)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Convert a value into a Ractor-shareable representation.
|
|
45
|
+
#
|
|
46
|
+
# @param value [Object, nil]
|
|
47
|
+
# @return [Object, String, nil]
|
|
48
|
+
def make_value_shareable(value)
|
|
49
|
+
Ractor.make_shareable(value)
|
|
50
|
+
rescue Ractor::Error
|
|
51
|
+
Ractor.make_shareable(value.inspect.freeze)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CDC
|
|
4
|
+
module Core
|
|
5
|
+
# Processor that delegates each event to multiple processors.
|
|
6
|
+
#
|
|
7
|
+
# CompositeProcessor enables fan-out processing while preserving a simple
|
|
8
|
+
# sequential execution model. It normalizes truthy/falsey processor returns
|
|
9
|
+
# into ProcessorResult objects and can stop at the first failure.
|
|
10
|
+
class CompositeProcessor < Processor
|
|
11
|
+
# @return [Array<Processor>] processors executed for each event
|
|
12
|
+
# @return [Boolean] whether processing stops on the first failure
|
|
13
|
+
attr_reader :processors, :fail_fast
|
|
14
|
+
|
|
15
|
+
# Build a composite processor.
|
|
16
|
+
#
|
|
17
|
+
# @param processors [Array<#process>] processors to execute
|
|
18
|
+
# @param fail_fast [Boolean] whether to stop after the first failure
|
|
19
|
+
def initialize(processors, fail_fast: true) # rubocop:disable Lint/MissingSuper
|
|
20
|
+
@processors = processors.freeze
|
|
21
|
+
@fail_fast = fail_fast
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Process an event through each configured processor.
|
|
25
|
+
#
|
|
26
|
+
# @param event [ChangeEvent] event to process
|
|
27
|
+
# @return [Array<ProcessorResult>] result from each attempted processor
|
|
28
|
+
def process(event)
|
|
29
|
+
results = [] # : Array[ProcessorResult]
|
|
30
|
+
processors.each do |processor|
|
|
31
|
+
result = normalize_result(processor.process(event), event)
|
|
32
|
+
results << result
|
|
33
|
+
break if fail_fast && result.failure?
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
result = ProcessorResult.failure(e, event:)
|
|
36
|
+
results << result
|
|
37
|
+
break if fail_fast
|
|
38
|
+
end
|
|
39
|
+
Ractor.make_shareable(results.freeze) if results.none?(&:failure?)
|
|
40
|
+
results.freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Processors that declared Ractor safety.
|
|
44
|
+
#
|
|
45
|
+
# @return [Array<Processor>]
|
|
46
|
+
def ractor_safe_processors
|
|
47
|
+
processors.select(&:ractor_safe?).freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Processors that should remain sequential in the core runtime.
|
|
51
|
+
#
|
|
52
|
+
# @return [Array<Processor>]
|
|
53
|
+
def sequential_processors
|
|
54
|
+
processors.reject(&:ractor_safe?).freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Normalize processor return values into ProcessorResult objects.
|
|
60
|
+
#
|
|
61
|
+
# @param result [Object] raw processor result
|
|
62
|
+
# @param event [ChangeEvent] processed event
|
|
63
|
+
# @return [ProcessorResult]
|
|
64
|
+
def normalize_result(result, event)
|
|
65
|
+
return result if result.is_a?(ProcessorResult)
|
|
66
|
+
|
|
67
|
+
result ? ProcessorResult.success(event) : ProcessorResult.skipped(event)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CDC
|
|
4
|
+
module Core
|
|
5
|
+
# Base error class for all cdc-core specific failures.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when an operation cannot be normalized to a supported CDC action.
|
|
9
|
+
class InvalidOperationError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised by processors when a processor-specific failure needs wrapping.
|
|
12
|
+
class ProcessorError < Error; end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CDC
|
|
4
|
+
module Core
|
|
5
|
+
# Immutable metadata container for CDC domain objects.
|
|
6
|
+
#
|
|
7
|
+
# Metadata keys are normalized to frozen strings. Nested hashes and arrays
|
|
8
|
+
# are recursively converted into Ractor-shareable objects. Values that Ruby
|
|
9
|
+
# cannot make shareable are stored as frozen #inspect strings.
|
|
10
|
+
class EventMetadata
|
|
11
|
+
# @return [Hash{String=>Object}] normalized metadata
|
|
12
|
+
attr_reader :data
|
|
13
|
+
|
|
14
|
+
# Build metadata from a hash-like structure.
|
|
15
|
+
#
|
|
16
|
+
# @param data [Hash] metadata values
|
|
17
|
+
def initialize(data = {})
|
|
18
|
+
@data = deep_shareable_hash(data)
|
|
19
|
+
Ractor.make_shareable(self)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Fetch a metadata value by string or symbol key.
|
|
23
|
+
#
|
|
24
|
+
# @param key [String, Symbol] metadata key
|
|
25
|
+
# @return [Object, nil]
|
|
26
|
+
def [](key)
|
|
27
|
+
data[key] || data[key.to_s] || data[key.to_sym]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Return the normalized Ractor-shareable hash.
|
|
31
|
+
#
|
|
32
|
+
# @return [Hash{String=>Object}]
|
|
33
|
+
def to_h
|
|
34
|
+
data
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Recursively normalize and freeze a hash.
|
|
40
|
+
#
|
|
41
|
+
# @param hash [Hash]
|
|
42
|
+
# @return [Hash{String=>Object}]
|
|
43
|
+
def deep_shareable_hash(hash)
|
|
44
|
+
converted = hash.each_with_object(
|
|
45
|
+
{} # : Hash[String, untyped]
|
|
46
|
+
) do |(key, value), memo|
|
|
47
|
+
memo[normalize_key(key)] = normalize_value(value)
|
|
48
|
+
end
|
|
49
|
+
Ractor.make_shareable(converted.freeze)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Normalize metadata keys to frozen strings.
|
|
53
|
+
#
|
|
54
|
+
# @param key [Object]
|
|
55
|
+
# @return [String]
|
|
56
|
+
def normalize_key(key)
|
|
57
|
+
key.to_s.freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Normalize a metadata value into a shareable representation.
|
|
61
|
+
#
|
|
62
|
+
# @param value [Object]
|
|
63
|
+
# @return [Object]
|
|
64
|
+
def normalize_value(value)
|
|
65
|
+
case value
|
|
66
|
+
when Hash
|
|
67
|
+
deep_shareable_hash(value)
|
|
68
|
+
when Array
|
|
69
|
+
Ractor.make_shareable(value.map { |item| normalize_value(item) }.freeze)
|
|
70
|
+
else
|
|
71
|
+
Ractor.make_shareable(value)
|
|
72
|
+
end
|
|
73
|
+
rescue Ractor::Error
|
|
74
|
+
Ractor.make_shareable(value.inspect.freeze)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CDC
|
|
4
|
+
module Core
|
|
5
|
+
# Predicate object used to decide whether a pipeline should process an event.
|
|
6
|
+
#
|
|
7
|
+
# Filters are composable with #& and #|. A filter only matches when its
|
|
8
|
+
# predicate returns true exactly, keeping accidental truthy values from
|
|
9
|
+
# silently passing events through a pipeline.
|
|
10
|
+
class Filter
|
|
11
|
+
# Match every event.
|
|
12
|
+
#
|
|
13
|
+
# @return [Filter]
|
|
14
|
+
def self.all = new { |_event| true }
|
|
15
|
+
|
|
16
|
+
# Match events from a schema.
|
|
17
|
+
#
|
|
18
|
+
# @param name [#to_s] schema name
|
|
19
|
+
# @return [Filter]
|
|
20
|
+
def self.schema(name) = new { |event| event.schema == name.to_s }
|
|
21
|
+
|
|
22
|
+
# Match events from a table regardless of schema.
|
|
23
|
+
#
|
|
24
|
+
# @param name [#to_s] table name
|
|
25
|
+
# @return [Filter]
|
|
26
|
+
def self.table(name) = new { |event| event.table == name.to_s }
|
|
27
|
+
|
|
28
|
+
# Match events from a fully qualified schema.table name.
|
|
29
|
+
#
|
|
30
|
+
# @param name [#to_s] qualified table name
|
|
31
|
+
# @return [Filter]
|
|
32
|
+
def self.qualified_table(name) = new { |event| event.qualified_table_name == name.to_s }
|
|
33
|
+
|
|
34
|
+
# Match events by operation.
|
|
35
|
+
#
|
|
36
|
+
# @param operation [#to_sym] CDC operation
|
|
37
|
+
# @return [Filter]
|
|
38
|
+
def self.operation(operation) = new { |event| event.operation == Operation.normalize(operation) }
|
|
39
|
+
|
|
40
|
+
# Build a custom filter.
|
|
41
|
+
#
|
|
42
|
+
# @yieldparam event [ChangeEvent] event being tested
|
|
43
|
+
# @yieldreturn [Boolean] true to match the event
|
|
44
|
+
# @raise [ArgumentError] when no predicate block is provided
|
|
45
|
+
def initialize(&predicate)
|
|
46
|
+
raise ArgumentError, 'predicate block required' unless predicate
|
|
47
|
+
|
|
48
|
+
@predicate = predicate
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Whether this filter matches an event.
|
|
52
|
+
#
|
|
53
|
+
# @param event [ChangeEvent] event to test
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def match?(event)
|
|
56
|
+
@predicate.call(event) == true
|
|
57
|
+
end
|
|
58
|
+
alias =~ match?
|
|
59
|
+
|
|
60
|
+
# Compose this filter with another filter using logical AND.
|
|
61
|
+
#
|
|
62
|
+
# @param other [Filter] other filter
|
|
63
|
+
# @return [Filter]
|
|
64
|
+
def &(other)
|
|
65
|
+
self.class.new { |event| match?(event) && other.match?(event) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Compose this filter with another filter using logical OR.
|
|
69
|
+
#
|
|
70
|
+
# @param other [Filter] other filter
|
|
71
|
+
# @return [Filter]
|
|
72
|
+
def |(other)
|
|
73
|
+
self.class.new { |event| match?(event) || other.match?(event) }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|