active_chronicle 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.
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mutex_m'
4
+
5
+ module Chronicle
6
+ module Resilience
7
+ class CircuitBreaker
8
+ attr_reader :state, :failure_count, :opened_at
9
+
10
+ def initialize(threshold: nil, cooldown: nil)
11
+ @mutex = Mutex.new
12
+ @state = :closed
13
+ @failure_count = 0
14
+ @opened_at = nil
15
+ @threshold = threshold
16
+ @cooldown = cooldown
17
+ end
18
+
19
+ def threshold
20
+ @threshold || (Chronicle.configuration ? Chronicle.configuration.circuit_breaker_threshold : 5)
21
+ end
22
+
23
+ def cooldown
24
+ @cooldown || (Chronicle.configuration ? Chronicle.configuration.circuit_breaker_cooldown : 10.0)
25
+ end
26
+
27
+ def check_state!
28
+ @mutex.synchronize do
29
+ case @state
30
+ when :open
31
+ if Time.now - @opened_at >= cooldown
32
+ @state = :half_open
33
+ else
34
+ raise Chronicle::CircuitBreakerError,
35
+ "Circuit breaker is OPEN. Datomic Transactor requests failing fast (opened at #{@opened_at})."
36
+ end
37
+ when :half_open, :closed
38
+ # Allow execution
39
+ end
40
+ end
41
+ end
42
+
43
+ def record_success
44
+ @mutex.synchronize do
45
+ @failure_count = 0
46
+ @state = :closed
47
+ @opened_at = nil
48
+ end
49
+ end
50
+
51
+ def record_failure
52
+ @mutex.synchronize do
53
+ @failure_count += 1
54
+ if @failure_count >= threshold || @state == :half_open
55
+ @state = :open
56
+ @opened_at = Time.now
57
+ end
58
+ end
59
+ end
60
+
61
+ def reset!
62
+ @mutex.synchronize do
63
+ @state = :closed
64
+ @failure_count = 0
65
+ @opened_at = nil
66
+ end
67
+ end
68
+
69
+ def force_open!
70
+ @mutex.synchronize do
71
+ @state = :open
72
+ @opened_at = Time.now
73
+ end
74
+ end
75
+ end
76
+
77
+ class << self
78
+ def global_circuit_breaker
79
+ @global_circuit_breaker ||= CircuitBreaker.new
80
+ end
81
+
82
+ def reset_circuit_breaker!
83
+ global_circuit_breaker.reset!
84
+ end
85
+
86
+ def retryable_exceptions
87
+ @retryable_exceptions ||= [
88
+ Chronicle::ConnectionError,
89
+ Chronicle::TransactionError,
90
+ Faraday::TimeoutError,
91
+ Faraday::ConnectionFailed,
92
+ Errno::ECONNREFUSED,
93
+ Errno::ETIMEDOUT
94
+ ]
95
+ end
96
+
97
+ def with_retry(max_retries: nil, base_delay: nil, max_delay: nil, circuit_breaker: nil)
98
+ nested_retry = Thread.current[:chronicle_retry_depth].to_i.positive?
99
+ cb = circuit_breaker || (nested_retry ? CircuitBreaker.new : global_circuit_breaker)
100
+ config = Chronicle.configuration
101
+
102
+ retries = max_retries || (config ? config.max_retries : 5)
103
+ delay = base_delay || (config ? config.retry_base_delay : 0.1)
104
+ max_d = max_delay || (config ? config.retry_max_delay : 2.0)
105
+
106
+ begin
107
+ Thread.current[:chronicle_retry_depth] = Thread.current[:chronicle_retry_depth].to_i + 1
108
+ cb.check_state!
109
+
110
+ attempt = 0
111
+ begin
112
+ attempt += 1
113
+ result = yield
114
+ cb.record_success
115
+ result
116
+ rescue StandardError => e
117
+ if retryable?(e)
118
+ cb.record_failure
119
+ if attempt <= retries
120
+ sleep_time = [delay * (2**(attempt - 1)), max_d].min
121
+ jitter = rand(0.0..0.1) * sleep_time
122
+ sleep_backoff(sleep_time + jitter)
123
+ retry
124
+ end
125
+ end
126
+ raise e
127
+ end
128
+ ensure
129
+ Thread.current[:chronicle_retry_depth] -= 1
130
+ end
131
+ end
132
+
133
+ def sleep_backoff(duration)
134
+ if defined?(Fiber) && Fiber.respond_to?(:scheduler) && Fiber.scheduler
135
+ Fiber.scheduler.kernel_sleep(duration)
136
+ else
137
+ sleep(duration)
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def retryable?(exception)
144
+ retryable_exceptions.any? { |klass| exception.is_a?(klass) } ||
145
+ exception.message.to_s.match?(/transactor-unavailable|queue-full|backpressure|timeout/i)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chronicle
4
+ class Schema
5
+ class TableDefinition
6
+ attr_reader :entity_name, :attributes
7
+
8
+ TYPE_MAPPINGS = {
9
+ string: 'string',
10
+ integer: 'long',
11
+ bigint: 'long',
12
+ float: 'double',
13
+ boolean: 'boolean',
14
+ datetime: 'instant',
15
+ ref: 'ref',
16
+ uuid: 'uuid',
17
+ bytes: 'bytes',
18
+ uri: 'uri'
19
+ }.freeze
20
+
21
+ def initialize(entity_name)
22
+ @entity_name = entity_name.to_s.singularize
23
+ @attributes = []
24
+ end
25
+
26
+ def string(name, **)
27
+ add_attribute(name, :string, **)
28
+ end
29
+
30
+ def integer(name, **)
31
+ add_attribute(name, :integer, **)
32
+ end
33
+
34
+ def bigint(name, **)
35
+ add_attribute(name, :bigint, **)
36
+ end
37
+
38
+ def float(name, **)
39
+ add_attribute(name, :float, **)
40
+ end
41
+
42
+ def boolean(name, **)
43
+ add_attribute(name, :boolean, **)
44
+ end
45
+
46
+ def datetime(name, **)
47
+ add_attribute(name, :datetime, **)
48
+ end
49
+
50
+ def ref(name, **)
51
+ add_attribute(name, :ref, **)
52
+ end
53
+
54
+ def uuid(name, **)
55
+ add_attribute(name, :uuid, **)
56
+ end
57
+
58
+ def timestamps
59
+ datetime(:created_at)
60
+ datetime(:updated_at)
61
+ end
62
+
63
+ def to_datoms
64
+ @attributes.map do |attr|
65
+ datomic_type = TYPE_MAPPINGS[attr[:type]] || 'string'
66
+ cardinality = attr[:options][:cardinality] == :many || attr[:options][:array] ? ':db.cardinality/many' : ':db.cardinality/one'
67
+
68
+ datom = {
69
+ ':db/ident' => ":#{@entity_name}/#{attr[:name]}",
70
+ ':db/valueType' => ":db.type/#{datomic_type}",
71
+ ':db/cardinality' => cardinality
72
+ }
73
+
74
+ if attr[:options][:unique] == :identity
75
+ datom[':db/unique'] = ':db.unique/identity'
76
+ elsif attr[:options][:unique] == :value
77
+ datom[':db/unique'] = ':db.unique/value'
78
+ end
79
+
80
+ datom[':db/index'] = true if attr[:options][:index]
81
+ datom[':db/doc'] = attr[:options][:doc] if attr[:options][:doc]
82
+ datom
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def add_attribute(name, type, **options)
89
+ @attributes << { name:, type:, options: }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'schema/table_definition'
4
+
5
+ module Chronicle
6
+ class Schema
7
+ class << self
8
+ def create_attribute(entity_name, attribute_name, value_type, options = {})
9
+ ident = ":#{entity_name.to_s.underscore}/#{attribute_name}"
10
+ cardinality = options[:cardinality] == :many ? ':db.cardinality/many' : ':db.cardinality/one'
11
+ type_str = ":db.type/#{value_type}"
12
+
13
+ schema_datom = {
14
+ ':db/ident' => ident,
15
+ ':db/valueType' => type_str,
16
+ ':db/cardinality' => cardinality
17
+ }
18
+
19
+ schema_datom[':db/unique'] = ':db.unique/identity' if options[:unique] == :identity
20
+ schema_datom[':db/doc'] = options[:doc] if options[:doc]
21
+
22
+ schema_datom
23
+ end
24
+
25
+ def define(entity_name)
26
+ table_def = TableDefinition.new(entity_name)
27
+ yield(table_def) if block_given?
28
+ table_def.to_datoms
29
+ end
30
+
31
+ def drop_attribute_datom(entity_name, attribute_name, timestamp = Time.now.strftime('%Y%m%d%H%M%S'))
32
+ old_ident = ":#{entity_name.to_s.underscore}/#{attribute_name}"
33
+ new_ident = ":deprecated.#{entity_name.to_s.underscore}.#{timestamp}/#{attribute_name}"
34
+
35
+ {
36
+ ':db/id' => old_ident,
37
+ ':db/ident' => new_ident,
38
+ ':db/doc' => "DEPRECATED and dropped via Chronicle migration at #{timestamp}"
39
+ }
40
+ end
41
+
42
+ def deprecate_attribute_datom(entity_name, attribute_name, reason: 'Deprecated in migration')
43
+ ident = ":#{entity_name.to_s.underscore}/#{attribute_name}"
44
+ {
45
+ ':db/id' => ident,
46
+ ':db/doc' => "DEPRECATED: #{reason}"
47
+ }
48
+ end
49
+
50
+ def rename_attribute_datom(entity_name, old_name, new_name)
51
+ old_ident = ":#{entity_name.to_s.underscore}/#{old_name}"
52
+ new_ident = ":#{entity_name.to_s.underscore}/#{new_name}"
53
+
54
+ {
55
+ ':db/id' => old_ident,
56
+ ':db/ident' => new_ident
57
+ }
58
+ end
59
+ end
60
+ end
61
+
62
+ module MigrationExtension
63
+ def create_datomic_schema(entity_name, &)
64
+ datoms = Chronicle::Schema.define(entity_name, &)
65
+ transport = Chronicle::Transport.client
66
+ transport.transact(datoms)
67
+ end
68
+
69
+ def drop_datomic_attribute(entity_name, attribute_name)
70
+ datom = Chronicle::Schema.drop_attribute_datom(entity_name, attribute_name)
71
+ transport = Chronicle::Transport.client
72
+ transport.transact([datom])
73
+ end
74
+
75
+ def deprecate_datomic_attribute(entity_name, attribute_name, reason: 'Deprecated')
76
+ datom = Chronicle::Schema.deprecate_attribute_datom(entity_name, attribute_name, reason:)
77
+ transport = Chronicle::Transport.client
78
+ transport.transact([datom])
79
+ end
80
+
81
+ def rename_datomic_attribute(entity_name, old_name, new_name)
82
+ datom = Chronicle::Schema.rename_attribute_datom(entity_name, old_name, new_name)
83
+ transport = Chronicle::Transport.client
84
+ transport.transact([datom])
85
+ end
86
+ end
87
+ end
88
+
89
+ ActiveRecord::Migration.include(Chronicle::MigrationExtension) if defined?(ActiveRecord::Migration)
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chronicle
4
+ class TransactionCoordinator
5
+ attr_reader :basis_t, :tx_id, :transport, :transacted_datoms, :created_entity_ids, :datomic_result
6
+
7
+ class << self
8
+ def transact(transport: nil, &)
9
+ new(transport:).transact(&)
10
+ end
11
+ end
12
+
13
+ def initialize(transport: nil)
14
+ @transport = transport || Chronicle::Transport.client
15
+ @transacted_datoms = []
16
+ @created_entity_ids = []
17
+ @basis_t = nil
18
+ @tx_id = nil
19
+ @datomic_result = nil
20
+ end
21
+
22
+ def transact
23
+ yield(self)
24
+ self
25
+ rescue StandardError => e
26
+ rollback!(e)
27
+ end
28
+
29
+ def datomic(datoms_or_model)
30
+ datoms = convert_to_datoms(datoms_or_model)
31
+ @transacted_datoms.concat(datoms)
32
+
33
+ @datomic_result = @transport.transact(datoms) || {}
34
+
35
+ @basis_t = @datomic_result[:basis_t] || @datomic_result['basis_t'] || Time.now.to_i
36
+ @tx_id = @datomic_result[:tx_id] || @datomic_result['tx_id'] || @basis_t
37
+
38
+ @created_entity_ids.concat(@datomic_result[:tempids].values) if @datomic_result[:tempids]
39
+
40
+ datoms.each do |d|
41
+ @created_entity_ids << d[1] if d.is_a?(Array) && d[1].is_a?(Numeric)
42
+ end
43
+ @created_entity_ids.uniq!
44
+
45
+ @datomic_result
46
+ end
47
+
48
+ def postgres
49
+ raise Chronicle::TransactionError, 'No Datomic transaction has been executed yet' unless @basis_t
50
+
51
+ if defined?(ActiveRecord::Base)
52
+ ActiveRecord::Base.transaction do
53
+ yield(self)
54
+ end
55
+ else
56
+ yield(self)
57
+ end
58
+ end
59
+
60
+ alias sqlite postgres
61
+ alias relational postgres
62
+
63
+ def read_your_own_write(model_class, finder_attributes = {})
64
+ raise Chronicle::TransactionError, 'No basis-t available for read-your-own-write' unless @basis_t
65
+
66
+ model_class.as_of(@basis_t).where(finder_attributes)
67
+ end
68
+
69
+ private
70
+
71
+ def convert_to_datoms(datoms_or_model)
72
+ if datoms_or_model.respond_to?(:to_datoms)
73
+ datoms_or_model.to_datoms
74
+ elsif datoms_or_model.is_a?(Array)
75
+ datoms_or_model
76
+ else
77
+ raise ArgumentError, 'Expected datoms array or an object responding to #to_datoms'
78
+ end
79
+ end
80
+
81
+ def rollback!(cause)
82
+ execute_compensating_retraction if @datomic_result && (@created_entity_ids.any? || @transacted_datoms.any?)
83
+
84
+ raise Chronicle::TransactionError, "Cross-database transaction rolled back due to: #{cause.message}"
85
+ end
86
+
87
+ def execute_compensating_retraction
88
+ retraction_datoms = @created_entity_ids.map do |eid|
89
+ [':db/retractEntity', eid]
90
+ end
91
+
92
+ if retraction_datoms.empty?
93
+ @transacted_datoms.each do |datom|
94
+ retraction_datoms << [':db/retract', datom[1], datom[2], datom[3]] if datom.is_a?(Array) && datom[0] == ':db/add'
95
+ end
96
+ end
97
+
98
+ @transport.transact(retraction_datoms) if retraction_datoms.any?
99
+ rescue StandardError => e
100
+ warn("[Chronicle] Compensating rollback failed: #{e.message}")
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chronicle
4
+ module Transport
5
+ class Base
6
+ attr_reader :config
7
+
8
+ def initialize(config = {})
9
+ @config = config
10
+ end
11
+
12
+ def connect!
13
+ raise NotImplementedError, "#{self.class}#connect! must be implemented"
14
+ end
15
+
16
+ def transact(tx_data)
17
+ raise NotImplementedError, "#{self.class}#transact must be implemented"
18
+ end
19
+
20
+ def q(query, *args)
21
+ raise NotImplementedError, "#{self.class}#q must be implemented"
22
+ end
23
+
24
+ def db(as_of: nil, since: nil)
25
+ raise NotImplementedError, "#{self.class}#db must be implemented"
26
+ end
27
+
28
+ def basis_t
29
+ raise NotImplementedError, "#{self.class}#basis_t must be implemented"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+ require 'time'
6
+ require 'uri'
7
+
8
+ Object.const_set('Bignum', Integer) unless Object.const_defined?('Bignum')
9
+ require 'edn'
10
+
11
+ module Chronicle
12
+ module Transport
13
+ class CRubyClient < Base
14
+ attr_reader :connection, :current_basis_t
15
+
16
+ def initialize(config = {})
17
+ super
18
+ @endpoint = config[:client_endpoint] || config['client_endpoint'] || ENV.fetch('DATOMIC_CLIENT_ENDPOINT', 'http://localhost:8989')
19
+ @uri = config[:uri] || config['uri'] || ENV.fetch('DATOMIC_URI', nil)
20
+ @rest = config[:rest] || config['rest']
21
+ @storage_alias, @database_name = parse_datomic_uri(@uri) if @rest
22
+ connection_options = { url: @endpoint }
23
+ connection_options[:ssl] = { verify: false } if @endpoint.start_with?('https://') && config.fetch(:verify_ssl, false) == false
24
+ @connection = Faraday.new(**connection_options) do |f|
25
+ f.request :json
26
+ f.response :json
27
+ f.adapter Faraday.default_adapter
28
+ end
29
+ end
30
+
31
+ def connect!
32
+ if @rest
33
+ result = rest_connect!
34
+ @connected = true
35
+ return result
36
+ end
37
+
38
+ Chronicle::Resilience.with_retry do
39
+ response = @connection.post('/api/connect', { uri: @uri })
40
+ raise Chronicle::ConnectionError, "Failed to connect to Datomic Client HTTP API at #{@endpoint}" unless response.success?
41
+
42
+ @connected = true
43
+ true
44
+ end
45
+ end
46
+
47
+ def connected?
48
+ !!@connected
49
+ end
50
+
51
+ def disconnect!
52
+ @connected = false
53
+ end
54
+
55
+ def transact(tx_data)
56
+ return rest_transact(tx_data) if @rest
57
+
58
+ Chronicle::Resilience.with_retry do
59
+ payload = { uri: @uri, tx_data: }
60
+ response = @connection.post('/api/transact', payload)
61
+ raise Chronicle::TransactionError, "Datomic Transact Error: #{response.body}" unless response.success?
62
+
63
+ body = response.body
64
+ @current_basis_t = body['basis_t'] || body['db_after_t']
65
+ { db_before: body['db_before'], db_after: body['db_after'], tx_data: body['tx_data'],
66
+ basis_t: @current_basis_t }
67
+ end
68
+ end
69
+
70
+ def q(query, *args)
71
+ return rest_query(query, *args) if @rest
72
+
73
+ Chronicle::Resilience.with_retry do
74
+ payload = { uri: @uri, query:, args: }
75
+ response = @connection.post('/api/query', payload)
76
+ raise Chronicle::Error, "Datomic Query Error: #{response.body}" unless response.success?
77
+
78
+ response.body['results'] || []
79
+ end
80
+ end
81
+
82
+ def db(as_of: nil, since: nil)
83
+ if @rest
84
+ database = { 'db/alias' => "#{@storage_alias}/#{@database_name}" }
85
+ database['as-of'] = as_of if as_of
86
+ database['since'] = since if since
87
+ return database
88
+ end
89
+
90
+ { uri: @uri, as_of:, since:, basis_t: @current_basis_t }
91
+ end
92
+
93
+ def basis_t
94
+ @current_basis_t || 0
95
+ end
96
+
97
+ private
98
+
99
+ def parse_datomic_uri(uri)
100
+ match = uri.to_s.match(%r{\Adatomic:([^:]+)://[^/]+/(.+)\z})
101
+ raise ArgumentError, "Invalid Datomic URI: #{uri}" unless match
102
+
103
+ match.captures
104
+ end
105
+
106
+ def rest_connect!
107
+ Chronicle::Resilience.with_retry do
108
+ response = @connection.post("/data/#{@storage_alias}/") do |request|
109
+ request.headers['Content-Type'] = 'application/edn'
110
+ request.headers['Accept'] = 'application/edn'
111
+ request.body = edn('db-name' => @database_name)
112
+ end
113
+ raise Chronicle::ConnectionError, "Failed to connect to Datomic REST API at #{@endpoint}" unless response.success?
114
+
115
+ true
116
+ end
117
+ end
118
+
119
+ def rest_transact(tx_data)
120
+ Chronicle::Resilience.with_retry do
121
+ response = @connection.post("/data/#{@storage_alias}/#{@database_name}/") do |request|
122
+ request.headers['Content-Type'] = 'application/edn'
123
+ request.headers['Accept'] = 'application/edn'
124
+ request.body = edn('tx-data' => tx_data)
125
+ end
126
+ raise Chronicle::TransactionError, "Datomic REST transaction failed: #{response.body}" unless response.success?
127
+
128
+ parsed = EDN.read(response.body)
129
+ @current_basis_t = parsed.dig('db-after', 'basis-t')
130
+ parsed
131
+ end
132
+ end
133
+
134
+ def rest_query(query, *args)
135
+ Chronicle::Resilience.with_retry do
136
+ response = @connection.post('/api/query') do |request|
137
+ request.headers['Content-Type'] = 'application/edn'
138
+ request.headers['Accept'] = 'application/edn'
139
+ request.body = edn(q: query, args:)
140
+ end
141
+ raise Chronicle::Error, "Datomic REST query failed: #{response.body}" unless response.success?
142
+
143
+ EDN.read(response.body)
144
+ end
145
+ end
146
+
147
+ def edn(value)
148
+ case value
149
+ when Hash
150
+ "{#{value.map { |key, item| "#{edn_key(key)} #{edn(item)}" }.join(' ')}}"
151
+ when Array
152
+ return "(#{value.map { |item| edn(item) }.join(' ')})" if value.first == :pull
153
+
154
+ "[#{value.map { |item| edn(item) }.join(' ')}]"
155
+ when Symbol
156
+ return 'pull' if value == :pull
157
+
158
+ value.to_s.start_with?(':') ? value.to_s : ":#{value}"
159
+ when String
160
+ return '*' if value == '*'
161
+
162
+ value.match?(/\A(?:\?|\$|\.\.\.)/) ? value : JSON.generate(value)
163
+ when NilClass
164
+ 'nil'
165
+ when Time
166
+ "#inst #{JSON.generate(value.utc.iso8601(3))}"
167
+ when TrueClass, FalseClass, Numeric
168
+ value.to_s
169
+ else
170
+ JSON.generate(value.to_s)
171
+ end
172
+ end
173
+
174
+ def edn_key(key)
175
+ key_string = key.to_s
176
+ key_string.start_with?(':') ? key_string : ":#{key_string}"
177
+ end
178
+ end
179
+ end
180
+ end