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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE +21 -0
- data/README.md +254 -0
- data/lib/active_chronicle.rb +54 -0
- data/lib/chronicle/connection_adapters/datomic_adapter.rb +286 -0
- data/lib/chronicle/datalog/compiler.rb +247 -0
- data/lib/chronicle/datalog/optimizer.rb +89 -0
- data/lib/chronicle/hydrator.rb +164 -0
- data/lib/chronicle/model.rb +80 -0
- data/lib/chronicle/railtie.rb +15 -0
- data/lib/chronicle/relation.rb +89 -0
- data/lib/chronicle/resilience.rb +149 -0
- data/lib/chronicle/schema/table_definition.rb +93 -0
- data/lib/chronicle/schema.rb +89 -0
- data/lib/chronicle/transaction_coordinator.rb +103 -0
- data/lib/chronicle/transport/base.rb +33 -0
- data/lib/chronicle/transport/cruby_client.rb +180 -0
- data/lib/chronicle/transport/jruby_client.rb +181 -0
- data/lib/chronicle/transport/jruby_peer.rb +91 -0
- data/lib/chronicle/transport.rb +30 -0
- data/lib/chronicle/version.rb +5 -0
- data/lib/generators/chronicle/initializer_generator.rb +32 -0
- data/lib/generators/chronicle/migration_generator.rb +46 -0
- data/lib/generators/chronicle/templates/migration.rb.erb +13 -0
- metadata +281 -0
|
@@ -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
|