whodunit-chronicles 0.3.0 → 0.4.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 +22 -226
- data/LICENSE +1 -1
- data/README.md +96 -599
- data/exe/whodunit-chronicles +6 -0
- data/lib/whodunit/chronicles/chronicler.rb +62 -0
- data/lib/whodunit/chronicles/cli.rb +131 -0
- data/lib/whodunit/chronicles/errors.rb +7 -33
- data/lib/whodunit/chronicles/ledger.rb +69 -0
- data/lib/whodunit/chronicles/ledger_entry.rb +143 -0
- data/lib/whodunit/chronicles/ledger_factory.rb +66 -0
- data/lib/whodunit/chronicles/ledgers/file_ledger.rb +56 -0
- data/lib/whodunit/chronicles/ledgers/memory_ledger.rb +29 -0
- data/lib/whodunit/chronicles/ledgers/sqlite_ledger.rb +172 -0
- data/lib/whodunit/chronicles/version.rb +2 -1
- data/lib/whodunit/chronicles.rb +12 -65
- data/lib/whodunit-chronicles.rb +0 -1
- data/sig/whodunit/chronicles/chronicler.rbs +14 -0
- data/sig/whodunit/chronicles/cli.rbs +17 -0
- data/sig/whodunit/chronicles/errors.rbs +15 -0
- data/sig/whodunit/chronicles/ledger.rbs +13 -0
- data/sig/whodunit/chronicles/ledger_entry.rbs +62 -0
- data/sig/whodunit/chronicles/ledger_factory.rbs +14 -0
- data/sig/whodunit/chronicles/ledgers/file_ledger.rbs +14 -0
- data/sig/whodunit/chronicles/ledgers/memory_ledger.rbs +12 -0
- data/sig/whodunit/chronicles/ledgers/sqlite_ledger.rbs +30 -0
- data/sig/whodunit/chronicles.rbs +5 -0
- metadata +40 -326
- data/.codeclimate.yml +0 -50
- data/.rubocop.yml +0 -93
- data/.yardopts +0 -14
- data/CODE_OF_CONDUCT.md +0 -132
- data/CONTRIBUTING.md +0 -186
- data/Rakefile +0 -18
- data/docker/mysql/init.sql +0 -33
- data/docker/postgres/init.sql +0 -40
- data/docker-compose.yml +0 -138
- data/examples/images/campaign-performance-analytics.png +0 -0
- data/examples/images/candidate-journey-analytics.png +0 -0
- data/examples/images/recruitment-funnel-analytics.png +0 -0
- data/lib/.gitkeep +0 -0
- data/lib/whodunit/chronicles/adapter_loader.rb +0 -69
- data/lib/whodunit/chronicles/adapters/mysql.rb +0 -261
- data/lib/whodunit/chronicles/adapters/postgresql.rb +0 -278
- data/lib/whodunit/chronicles/change_event.rb +0 -201
- data/lib/whodunit/chronicles/composite_processor.rb +0 -86
- data/lib/whodunit/chronicles/configuration.rb +0 -112
- data/lib/whodunit/chronicles/connection.rb +0 -88
- data/lib/whodunit/chronicles/persistence.rb +0 -129
- data/lib/whodunit/chronicles/processor.rb +0 -127
- data/lib/whodunit/chronicles/service.rb +0 -207
- data/lib/whodunit/chronicles/stream_adapter.rb +0 -91
- data/lib/whodunit/chronicles/table.rb +0 -120
- data/whodunit-chronicles.gemspec +0 -79
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cdc_core'
|
|
4
|
+
|
|
5
|
+
require_relative 'ledger_entry'
|
|
6
|
+
|
|
7
|
+
module Whodunit
|
|
8
|
+
module Chronicles
|
|
9
|
+
# Minimal CDC::Core processor that records change events into a ledger.
|
|
10
|
+
#
|
|
11
|
+
# Chronicler is intentionally small. It consumes a {CDC::Core::ChangeEvent},
|
|
12
|
+
# converts it into an immutable {LedgerEntry}, and appends that entry into
|
|
13
|
+
# the provided ledger. Scheduling, retries, backpressure, worker pools, and
|
|
14
|
+
# orchestration belong to CDC runtimes outside this gem.
|
|
15
|
+
class Chronicler < CDC::Core::Processor
|
|
16
|
+
# @return [Object] ledger receiving immutable entries
|
|
17
|
+
attr_reader :ledger
|
|
18
|
+
|
|
19
|
+
# Create a chronicler.
|
|
20
|
+
#
|
|
21
|
+
# @param ledger [#append] ledger-like object
|
|
22
|
+
# @param prepare [Boolean] whether to call ledger.prepare! during initialization
|
|
23
|
+
# @param ensure_indexes [Boolean] whether to call ledger.ensure_indexes! during initialization
|
|
24
|
+
# @param clock [#now] clock used when creating ledger entries
|
|
25
|
+
def initialize(ledger:, prepare: true, ensure_indexes: true, clock: Time)
|
|
26
|
+
@ledger = ledger
|
|
27
|
+
@clock = clock
|
|
28
|
+
@ledger.prepare! if prepare && @ledger.respond_to?(:prepare!)
|
|
29
|
+
@ledger.ensure_indexes! if ensure_indexes && @ledger.respond_to?(:ensure_indexes!)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Process one CDC change event and append one ledger entry.
|
|
33
|
+
#
|
|
34
|
+
# @param event [CDC::Core::ChangeEvent] normalized CDC event
|
|
35
|
+
# @return [CDC::Core::ProcessorResult] standardized processor result
|
|
36
|
+
def process(event)
|
|
37
|
+
entry = LedgerEntry.from_change_event(event, clock: @clock)
|
|
38
|
+
partition_for(entry).append(entry)
|
|
39
|
+
CDC::Core::ProcessorResult.success(event)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
CDC::Core::ProcessorResult.failure(
|
|
42
|
+
e,
|
|
43
|
+
event: event,
|
|
44
|
+
processor: self.class.name,
|
|
45
|
+
retryable: true
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Resolve the ledger partition for an entry.
|
|
52
|
+
#
|
|
53
|
+
# @param entry [LedgerEntry] entry being appended
|
|
54
|
+
# @return [Object] ledger-like target
|
|
55
|
+
def partition_for(entry)
|
|
56
|
+
return @ledger.partition_for(entry) if @ledger.respond_to?(:partition_for)
|
|
57
|
+
|
|
58
|
+
@ledger
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'ledger_factory'
|
|
7
|
+
|
|
8
|
+
module Whodunit
|
|
9
|
+
module Chronicles
|
|
10
|
+
# Small operational command-line interface for ledger lifecycle tasks.
|
|
11
|
+
#
|
|
12
|
+
# The CLI owns book preparation, migration-like operations, index creation,
|
|
13
|
+
# verification, and status checks. The runtime chronicler only appends
|
|
14
|
+
# entries into a ledger it has been handed.
|
|
15
|
+
class CLI
|
|
16
|
+
# Run a command with argv-style arguments.
|
|
17
|
+
#
|
|
18
|
+
# @param argv [Array<String>] command-line arguments
|
|
19
|
+
# @param out [#puts] output stream
|
|
20
|
+
# @param err [#puts] error stream
|
|
21
|
+
# @return [Integer] process-style exit code
|
|
22
|
+
def self.run(argv, out: $stdout, err: $stderr)
|
|
23
|
+
new(argv: argv, out: out, err: err).run
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Create a CLI instance.
|
|
27
|
+
#
|
|
28
|
+
# @param argv [Array<String>] command-line arguments
|
|
29
|
+
# @param out [#puts] output stream
|
|
30
|
+
# @param err [#puts] error stream
|
|
31
|
+
def initialize(argv:, out:, err:)
|
|
32
|
+
@argv = argv.dup
|
|
33
|
+
@out = out
|
|
34
|
+
@err = err
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Execute the requested command.
|
|
38
|
+
#
|
|
39
|
+
# @return [Integer] process-style exit code
|
|
40
|
+
def run
|
|
41
|
+
return usage(0) if @argv.empty? || @argv.first == 'help'
|
|
42
|
+
|
|
43
|
+
namespace = @argv.shift
|
|
44
|
+
command = @argv.shift
|
|
45
|
+
config_path = @argv.shift
|
|
46
|
+
options = @argv.dup
|
|
47
|
+
return usage(1) unless namespace == 'ledger' && command && config_path
|
|
48
|
+
|
|
49
|
+
ledger = LedgerFactory.build(load_config(config_path))
|
|
50
|
+
execute_ledger_command(ledger, command, options)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
@err.puts(e.message)
|
|
53
|
+
1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Execute a ledger lifecycle command.
|
|
59
|
+
def execute_ledger_command(ledger, command, options)
|
|
60
|
+
case command
|
|
61
|
+
when 'prepare'
|
|
62
|
+
return usage(1) unless options.empty?
|
|
63
|
+
|
|
64
|
+
ensure_supported!(ledger, :prepare!)
|
|
65
|
+
ledger.prepare!
|
|
66
|
+
@out.puts('prepared')
|
|
67
|
+
when 'migrate'
|
|
68
|
+
return usage(1) unless options.empty?
|
|
69
|
+
|
|
70
|
+
ensure_supported!(ledger, :migrate!)
|
|
71
|
+
ledger.migrate!
|
|
72
|
+
@out.puts('migrated')
|
|
73
|
+
when 'ensure-indexes', 'indexes'
|
|
74
|
+
return usage(1) unless options.empty?
|
|
75
|
+
|
|
76
|
+
ensure_supported!(ledger, :ensure_indexes!)
|
|
77
|
+
ledger.ensure_indexes!
|
|
78
|
+
@out.puts('indexes ensured')
|
|
79
|
+
when 'verify'
|
|
80
|
+
return usage(1) unless options.empty?
|
|
81
|
+
|
|
82
|
+
ensure_supported!(ledger, :verify)
|
|
83
|
+
raise LedgerError, 'ledger verification failed' unless ledger.verify
|
|
84
|
+
|
|
85
|
+
@out.puts('verified')
|
|
86
|
+
when 'status'
|
|
87
|
+
return usage(1) unless options.empty? || options == ['--json']
|
|
88
|
+
|
|
89
|
+
ensure_supported!(ledger, :status)
|
|
90
|
+
@out.puts(format_status(ledger.status, options))
|
|
91
|
+
else
|
|
92
|
+
return usage(1)
|
|
93
|
+
end
|
|
94
|
+
0
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Load YAML configuration from disk.
|
|
98
|
+
def load_config(path)
|
|
99
|
+
YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false) || {}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Format status for humans.
|
|
103
|
+
def format_status(status, options)
|
|
104
|
+
return JSON.pretty_generate(status) if options == ['--json']
|
|
105
|
+
|
|
106
|
+
status.map { |key, value| "#{key}: #{value.inspect}" }.join("\n")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Ensure a ledger supports an operational method.
|
|
110
|
+
def ensure_supported!(ledger, method_name)
|
|
111
|
+
return true if ledger.respond_to?(method_name)
|
|
112
|
+
|
|
113
|
+
raise LedgerError, "ledger does not support #{method_name}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Print usage information.
|
|
117
|
+
def usage(code)
|
|
118
|
+
@out.puts(<<~TEXT)
|
|
119
|
+
Usage:
|
|
120
|
+
whodunit-chronicles ledger prepare CONFIG
|
|
121
|
+
whodunit-chronicles ledger migrate CONFIG
|
|
122
|
+
whodunit-chronicles ledger ensure-indexes CONFIG
|
|
123
|
+
whodunit-chronicles ledger verify CONFIG
|
|
124
|
+
whodunit-chronicles ledger status CONFIG
|
|
125
|
+
whodunit-chronicles ledger status CONFIG --json
|
|
126
|
+
TEXT
|
|
127
|
+
code
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -2,42 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module Whodunit
|
|
4
4
|
module Chronicles
|
|
5
|
-
# Base
|
|
6
|
-
#
|
|
7
|
-
# Rescuing this class catches every error raised by the gem:
|
|
8
|
-
#
|
|
9
|
-
# begin
|
|
10
|
-
# Whodunit::Chronicles.service.start
|
|
11
|
-
# rescue Whodunit::Chronicles::Error => e
|
|
12
|
-
# logger.error("Chronicles failure: #{e.message}")
|
|
13
|
-
# end
|
|
5
|
+
# Base exception for whodunit-chronicles.
|
|
14
6
|
class Error < StandardError; end
|
|
15
7
|
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
# @example
|
|
19
|
-
# Whodunit::Chronicles.configure do |c|
|
|
20
|
-
# c.adapter = :unknown_db # => raises ConfigurationError
|
|
21
|
-
# end
|
|
22
|
-
class ConfigurationError < Error; end
|
|
23
|
-
|
|
24
|
-
# Raised when a required database driver gem is not installed.
|
|
25
|
-
#
|
|
26
|
-
# @example message
|
|
27
|
-
# "Could not load the 'pg' gem required for the postgresql adapter.
|
|
28
|
-
# Add `gem 'pg', '~> 1.5'` to your Gemfile."
|
|
29
|
-
class AdapterLoadError < Error; end
|
|
30
|
-
|
|
31
|
-
# Raised when a streaming or replication connection fails.
|
|
32
|
-
class ConnectionError < Error; end
|
|
8
|
+
# Base exception for ledger-related failures.
|
|
9
|
+
class LedgerError < Error; end
|
|
33
10
|
|
|
34
|
-
# Raised when
|
|
35
|
-
class
|
|
11
|
+
# Raised when an append operation fails before a result can be returned.
|
|
12
|
+
class AppendError < LedgerError; end
|
|
36
13
|
|
|
37
|
-
# Raised when
|
|
38
|
-
class
|
|
39
|
-
|
|
40
|
-
# Raised when replication related error occurs
|
|
41
|
-
class ReplicationError < Error; end
|
|
14
|
+
# Raised when ledger configuration cannot be built.
|
|
15
|
+
class ConfigurationError < Error; end
|
|
42
16
|
end
|
|
43
17
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whodunit
|
|
4
|
+
module Chronicles
|
|
5
|
+
# Base class for append-only audit ledgers.
|
|
6
|
+
#
|
|
7
|
+
# Ledger is the minimal storage contract consumed by {Chronicler}. Concrete
|
|
8
|
+
# ledgers only need to implement {#append}; preparation, indexing, and
|
|
9
|
+
# partitioning are optional capabilities with safe defaults.
|
|
10
|
+
class Ledger
|
|
11
|
+
# Prepare backing storage if the ledger supports schema creation.
|
|
12
|
+
#
|
|
13
|
+
# @return [Ledger] this ledger
|
|
14
|
+
def prepare!
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Ensure useful indexes if the ledger supports index management.
|
|
19
|
+
#
|
|
20
|
+
# @return [Ledger] this ledger
|
|
21
|
+
def ensure_indexes!
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Apply storage evolution steps if the ledger supports migrations.
|
|
26
|
+
#
|
|
27
|
+
# @return [Ledger] this ledger
|
|
28
|
+
def migrate!
|
|
29
|
+
prepare!
|
|
30
|
+
ensure_indexes!
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Verify that the ledger is operational.
|
|
35
|
+
#
|
|
36
|
+
# @return [Boolean] true when the ledger appears usable
|
|
37
|
+
def verify
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Return lightweight operational status for this ledger.
|
|
42
|
+
#
|
|
43
|
+
# @return [Hash<Symbol, Object>] ledger status
|
|
44
|
+
def status
|
|
45
|
+
{ adapter: self.class.name, ready: verify }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Return the target ledger for an entry.
|
|
49
|
+
#
|
|
50
|
+
# Partition-aware ledgers override this method. Simple ledgers return
|
|
51
|
+
# themselves.
|
|
52
|
+
#
|
|
53
|
+
# @param entry [LedgerEntry] entry being appended
|
|
54
|
+
# @return [Ledger] ledger that should receive the entry
|
|
55
|
+
def partition_for(_entry)
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Append an immutable entry to the ledger.
|
|
60
|
+
#
|
|
61
|
+
# @param entry [LedgerEntry] entry to persist
|
|
62
|
+
# @raise [NotImplementedError] when not implemented by a concrete ledger
|
|
63
|
+
# @return [Object] implementation-defined result
|
|
64
|
+
def append(_entry)
|
|
65
|
+
raise NotImplementedError, "#{self.class} must implement #append"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cdc_core'
|
|
4
|
+
|
|
5
|
+
module Whodunit
|
|
6
|
+
module Chronicles
|
|
7
|
+
# Immutable audit-domain representation of one CDC::Core::ChangeEvent.
|
|
8
|
+
class LedgerEntry < Data.define(
|
|
9
|
+
:event_id,
|
|
10
|
+
:occurred_at,
|
|
11
|
+
:recorded_at,
|
|
12
|
+
:namespace,
|
|
13
|
+
:entity,
|
|
14
|
+
:identity,
|
|
15
|
+
:operation,
|
|
16
|
+
:actor,
|
|
17
|
+
:changes,
|
|
18
|
+
:metadata,
|
|
19
|
+
:payload
|
|
20
|
+
)
|
|
21
|
+
# Build a ledger entry from a normalized CDC change event.
|
|
22
|
+
#
|
|
23
|
+
# @param event [CDC::Core::ChangeEvent] normalized CDC event
|
|
24
|
+
# @param clock [#now] clock used for recorded_at and fallback timestamps
|
|
25
|
+
# @return [LedgerEntry] immutable ledger entry
|
|
26
|
+
# @raise [TypeError] if event is not a CDC::Core::ChangeEvent
|
|
27
|
+
def self.from_change_event(event, clock: Time)
|
|
28
|
+
validate_change_event!(event)
|
|
29
|
+
|
|
30
|
+
new(
|
|
31
|
+
event_id: event_id_for(event),
|
|
32
|
+
occurred_at: event.occurred_at || clock.now,
|
|
33
|
+
recorded_at: clock.now,
|
|
34
|
+
namespace: namespace_for(event),
|
|
35
|
+
entity: entity_for(event),
|
|
36
|
+
identity: event.primary_key,
|
|
37
|
+
operation: event.operation,
|
|
38
|
+
actor: actor_for(event),
|
|
39
|
+
changes: changes_for(event),
|
|
40
|
+
metadata: metadata_for(event),
|
|
41
|
+
payload: event.to_h
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
alias_method :from_event, :from_change_event
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Convert this entry to a Hash keyed by attribute name.
|
|
50
|
+
#
|
|
51
|
+
# @return [Hash<Symbol, Object>] entry attributes
|
|
52
|
+
def to_h
|
|
53
|
+
members.to_h { |name| [name, public_send(name)] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build a stable ordering identity for per-record ordering.
|
|
57
|
+
#
|
|
58
|
+
# @return [String] namespace/entity/identity tuple rendered as a string
|
|
59
|
+
def ordering_identity
|
|
60
|
+
[namespace, entity, identity].compact.join(':')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Verify the event type.
|
|
64
|
+
#
|
|
65
|
+
# @param event [Object] candidate event
|
|
66
|
+
# @return [true]
|
|
67
|
+
def self.validate_change_event!(event)
|
|
68
|
+
return true if event.is_a?(CDC::Core::ChangeEvent)
|
|
69
|
+
|
|
70
|
+
raise TypeError, "expected CDC::Core::ChangeEvent, got #{event.class}"
|
|
71
|
+
end
|
|
72
|
+
private_class_method :validate_change_event!
|
|
73
|
+
|
|
74
|
+
# Derive a stable event id from CDC source coordinates.
|
|
75
|
+
#
|
|
76
|
+
# @param event [CDC::Core::ChangeEvent] normalized CDC event
|
|
77
|
+
# @return [String] deterministic event id
|
|
78
|
+
def self.event_id_for(event)
|
|
79
|
+
require 'digest'
|
|
80
|
+
Digest::SHA256.hexdigest(Marshal.dump(event.to_h))
|
|
81
|
+
end
|
|
82
|
+
private_class_method :event_id_for
|
|
83
|
+
|
|
84
|
+
# Source-neutral namespace for the changed entity.
|
|
85
|
+
#
|
|
86
|
+
# @param event [CDC::Core::ChangeEvent] normalized CDC event
|
|
87
|
+
# @return [String] namespace value
|
|
88
|
+
def self.namespace_for(event)
|
|
89
|
+
event.schema
|
|
90
|
+
end
|
|
91
|
+
private_class_method :namespace_for
|
|
92
|
+
|
|
93
|
+
# Source-neutral entity name for the changed record collection.
|
|
94
|
+
#
|
|
95
|
+
# @param event [CDC::Core::ChangeEvent] normalized CDC event
|
|
96
|
+
# @return [String] entity value
|
|
97
|
+
def self.entity_for(event)
|
|
98
|
+
event.table
|
|
99
|
+
end
|
|
100
|
+
private_class_method :entity_for
|
|
101
|
+
|
|
102
|
+
# Extract actor details from event metadata.
|
|
103
|
+
#
|
|
104
|
+
# @param event [CDC::Core::ChangeEvent] normalized CDC event
|
|
105
|
+
# @return [Object, nil] actor metadata
|
|
106
|
+
def self.actor_for(event)
|
|
107
|
+
event.metadata[:actor] || event.metadata[:whodunit]
|
|
108
|
+
end
|
|
109
|
+
private_class_method :actor_for
|
|
110
|
+
|
|
111
|
+
# Extract column changes in a persistence-friendly shape.
|
|
112
|
+
#
|
|
113
|
+
# @param event [CDC::Core::ChangeEvent] normalized CDC event
|
|
114
|
+
# @return [Array<Hash>] changed column records
|
|
115
|
+
def self.changes_for(event)
|
|
116
|
+
event.changes.map(&:to_h)
|
|
117
|
+
end
|
|
118
|
+
private_class_method :changes_for
|
|
119
|
+
|
|
120
|
+
# Extract ledger metadata from CDC event metadata and source coordinates.
|
|
121
|
+
#
|
|
122
|
+
# @param event [CDC::Core::ChangeEvent] normalized CDC event
|
|
123
|
+
# @return [Hash] metadata hash
|
|
124
|
+
def self.metadata_for(event)
|
|
125
|
+
event.metadata.to_h.merge(
|
|
126
|
+
'transaction_id' => event.transaction_id,
|
|
127
|
+
'source_position' => source_position_for(event),
|
|
128
|
+
'sequence_number' => event.sequence_number
|
|
129
|
+
).compact
|
|
130
|
+
end
|
|
131
|
+
private_class_method :metadata_for
|
|
132
|
+
|
|
133
|
+
# Source-neutral position for checkpoint/replay correlation.
|
|
134
|
+
#
|
|
135
|
+
# @param event [CDC::Core::ChangeEvent] normalized CDC event
|
|
136
|
+
# @return [String, nil] source position
|
|
137
|
+
def self.source_position_for(event)
|
|
138
|
+
event.commit_lsn
|
|
139
|
+
end
|
|
140
|
+
private_class_method :source_position_for
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'ledgers/memory_ledger'
|
|
5
|
+
require_relative 'ledgers/file_ledger'
|
|
6
|
+
require_relative 'ledgers/sqlite_ledger'
|
|
7
|
+
|
|
8
|
+
module Whodunit
|
|
9
|
+
module Chronicles
|
|
10
|
+
# Builds ledger instances from simple configuration hashes.
|
|
11
|
+
class LedgerFactory
|
|
12
|
+
# Build a ledger from configuration.
|
|
13
|
+
#
|
|
14
|
+
# @param config [Hash] configuration hash
|
|
15
|
+
# @return [Ledger] configured ledger
|
|
16
|
+
def self.build(config)
|
|
17
|
+
new(config).build
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Create a factory.
|
|
21
|
+
#
|
|
22
|
+
# @param config [Hash] configuration hash
|
|
23
|
+
def initialize(config)
|
|
24
|
+
@config = stringify_keys(config)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Build the configured ledger.
|
|
28
|
+
#
|
|
29
|
+
# @return [Ledger] configured ledger
|
|
30
|
+
def build
|
|
31
|
+
ledger = stringify_keys(@config.fetch('ledger', @config))
|
|
32
|
+
adapter = required_value(ledger, 'adapter')
|
|
33
|
+
|
|
34
|
+
case adapter
|
|
35
|
+
when 'memory'
|
|
36
|
+
Ledgers::MemoryLedger.new
|
|
37
|
+
when 'file'
|
|
38
|
+
Ledgers::FileLedger.new(path: required_value(ledger, 'path'))
|
|
39
|
+
when 'sqlite'
|
|
40
|
+
Ledgers::SQLiteLedger.new(path: required_value(ledger, 'path'), table_name: ledger.fetch('table_name', Ledgers::SQLiteLedger::DEFAULT_TABLE))
|
|
41
|
+
else
|
|
42
|
+
raise ConfigurationError, "unsupported ledger adapter: #{adapter.inspect}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Convert hash keys to strings recursively.
|
|
49
|
+
def stringify_keys(value)
|
|
50
|
+
case value
|
|
51
|
+
when Hash
|
|
52
|
+
value.to_h { |key, nested| [key.to_s, stringify_keys(nested)] }
|
|
53
|
+
else
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Fetch a required config value with a user-facing error.
|
|
59
|
+
def required_value(config, key)
|
|
60
|
+
config.fetch(key)
|
|
61
|
+
rescue KeyError
|
|
62
|
+
raise ConfigurationError, "missing ledger #{key}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require_relative '../ledger'
|
|
6
|
+
|
|
7
|
+
module Whodunit
|
|
8
|
+
module Chronicles
|
|
9
|
+
module Ledgers
|
|
10
|
+
# Append-only newline-delimited JSON ledger for simple durable local storage.
|
|
11
|
+
class FileLedger < Ledger
|
|
12
|
+
# @return [String] path to the NDJSON ledger file
|
|
13
|
+
attr_reader :path
|
|
14
|
+
|
|
15
|
+
# Create a file-backed ledger.
|
|
16
|
+
#
|
|
17
|
+
# @param path [String] path to the NDJSON ledger file
|
|
18
|
+
def initialize(path:)
|
|
19
|
+
@path = path.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Ensure the parent directory and ledger file exist.
|
|
23
|
+
#
|
|
24
|
+
# @return [FileLedger] this ledger
|
|
25
|
+
def prepare!
|
|
26
|
+
directory = File.dirname(path)
|
|
27
|
+
FileUtils.mkdir_p(directory) unless directory == '.'
|
|
28
|
+
FileUtils.touch(path)
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Append one entry as one JSON line.
|
|
33
|
+
#
|
|
34
|
+
# @param entry [LedgerEntry] entry to append
|
|
35
|
+
# @return [LedgerEntry] appended entry
|
|
36
|
+
def append(entry)
|
|
37
|
+
File.open(path, 'ab') do |file|
|
|
38
|
+
file.flock(File::LOCK_EX)
|
|
39
|
+
file.write(JSON.generate(entry.to_h))
|
|
40
|
+
file.write("\n")
|
|
41
|
+
end
|
|
42
|
+
entry
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Read all entries from the file as hashes.
|
|
46
|
+
#
|
|
47
|
+
# @return [Array<Hash>] decoded ledger lines
|
|
48
|
+
def entries
|
|
49
|
+
return [] unless File.exist?(path)
|
|
50
|
+
|
|
51
|
+
File.readlines(path, chomp: true).reject(&:empty?).map { |line| JSON.parse(line) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../ledger'
|
|
4
|
+
|
|
5
|
+
module Whodunit
|
|
6
|
+
module Chronicles
|
|
7
|
+
module Ledgers
|
|
8
|
+
# In-memory append-only ledger for tests, examples, and small scripts.
|
|
9
|
+
class MemoryLedger < Ledger
|
|
10
|
+
# @return [Array<LedgerEntry>] appended entries in insertion order
|
|
11
|
+
attr_reader :entries
|
|
12
|
+
|
|
13
|
+
# Create an empty in-memory ledger.
|
|
14
|
+
def initialize
|
|
15
|
+
@entries = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Append an entry to memory.
|
|
19
|
+
#
|
|
20
|
+
# @param entry [LedgerEntry] entry to append
|
|
21
|
+
# @return [LedgerEntry] appended entry
|
|
22
|
+
def append(entry)
|
|
23
|
+
@entries << entry
|
|
24
|
+
entry
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|