volcano-sdk 0.31.0 → 0.32.1
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/README.md +13 -7
- data/lib/volcano/database.rb +28 -37
- data/lib/volcano/durable.rb +6 -6
- data/lib/volcano/durable_models.rb +65 -22
- data/lib/volcano/function_auth.rb +9 -2
- data/lib/volcano/functions.rb +18 -15
- data/lib/volcano/immutable_request_value.rb +21 -0
- data/lib/volcano/logs.rb +14 -9
- data/lib/volcano/models.rb +55 -22
- data/lib/volcano/realtime/broadcast_delivery.rb +26 -2
- data/lib/volcano/realtime/channel_callbacks.rb +23 -15
- data/lib/volcano/realtime/channel_lifecycle.rb +9 -9
- data/lib/volcano/realtime/connection.rb +9 -5
- data/lib/volcano/realtime/connection_callbacks.rb +5 -1
- data/lib/volcano/realtime/lifecycle.rb +2 -1
- data/lib/volcano/realtime/postgres_batch.rb +11 -3
- data/lib/volcano/realtime/postgres_changes.rb +33 -16
- data/lib/volcano/realtime/postgres_database.rb +14 -1
- data/lib/volcano/realtime/postgres_delivery.rb +6 -1
- data/lib/volcano/realtime/postgres_expansion.rb +10 -4
- data/lib/volcano/realtime/presence.rb +18 -4
- data/lib/volcano/realtime/presence_state.rb +23 -6
- data/lib/volcano/realtime/protocol.rb +14 -9
- data/lib/volcano/realtime/protocol_dispatch.rb +25 -7
- data/lib/volcano/realtime/protocol_io.rb +8 -4
- data/lib/volcano/realtime/protocol_lifecycle.rb +2 -0
- data/lib/volcano/realtime/protocol_publication_dispatch.rb +12 -10
- data/lib/volcano/realtime/protocol_recovery.rb +7 -3
- data/lib/volcano/realtime/protocol_recovery_dispatch.rb +2 -0
- data/lib/volcano/realtime/reconnect.rb +4 -1
- data/lib/volcano/realtime.rb +122 -99
- data/lib/volcano/session.rb +14 -3
- data/lib/volcano/transport.rb +53 -11
- data/lib/volcano/user.rb +12 -7
- data/lib/volcano/version.rb +1 -1
- data/lib/volcano.rb +1 -0
- data/sig/auth_session_records.rbs +1 -1
- data/sig/client.rbs +15 -0
- data/sig/core_facades.rbs +81 -0
- data/sig/durable_records.rbs +1 -1
- data/sig/locks.rbs +10 -0
- data/sig/realtime.rbs +45 -0
- data/sig/realtime_postgres_change.rbs +1 -2
- data/sig/user.rbs +1 -1
- metadata +6 -1
data/lib/volcano/models.rb
CHANGED
|
@@ -7,7 +7,14 @@ module Volcano
|
|
|
7
7
|
].freeze
|
|
8
8
|
private_constant :AUTH_SESSION_ATTRIBUTES
|
|
9
9
|
|
|
10
|
-
AuthSession = Data.define(*AUTH_SESSION_ATTRIBUTES)
|
|
10
|
+
AuthSession = Data.define(*AUTH_SESSION_ATTRIBUTES)
|
|
11
|
+
|
|
12
|
+
# Reopens the generated record for checked initialization.
|
|
13
|
+
class AuthSession
|
|
14
|
+
# @dynamic id, user_id, provider, expires_at, is_active, is_current, user_agent
|
|
15
|
+
# @dynamic ip_address, last_ip_address, last_activity_at, session_started_at
|
|
16
|
+
# @dynamic created_at, updated_at, members, with, to_h, deconstruct
|
|
17
|
+
# @dynamic deconstruct_keys, self.[], self.members
|
|
11
18
|
def initialize(**attributes)
|
|
12
19
|
unknown = attributes.keys - AUTH_SESSION_ATTRIBUTES
|
|
13
20
|
raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" unless unknown.empty?
|
|
@@ -18,7 +25,8 @@ module Volcano
|
|
|
18
25
|
values = AUTH_SESSION_ATTRIBUTES.to_h do |name|
|
|
19
26
|
[name, freeze_session_value(attributes[name])]
|
|
20
27
|
end
|
|
21
|
-
|
|
28
|
+
attributes = values
|
|
29
|
+
super
|
|
22
30
|
end
|
|
23
31
|
|
|
24
32
|
private
|
|
@@ -28,19 +36,28 @@ module Volcano
|
|
|
28
36
|
end
|
|
29
37
|
end
|
|
30
38
|
|
|
31
|
-
SessionPage = Data.define(:sessions, :total, :page, :limit, :total_pages)
|
|
39
|
+
SessionPage = Data.define(:sessions, :total, :page, :limit, :total_pages)
|
|
40
|
+
|
|
41
|
+
# Reopens the generated record for checked initialization.
|
|
42
|
+
class SessionPage
|
|
43
|
+
# @dynamic sessions, total, page, limit, total_pages, members, with, to_h
|
|
44
|
+
# @dynamic deconstruct, deconstruct_keys, self.[], self.members
|
|
32
45
|
def initialize(sessions:, total:, page:, limit:, total_pages:)
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
sessions = sessions.to_a.dup.freeze
|
|
47
|
+
super
|
|
35
48
|
end
|
|
36
49
|
end
|
|
37
|
-
LinkedOAuthProvider = Data.define(:provider, :linked_at, :updated_at)
|
|
50
|
+
LinkedOAuthProvider = Data.define(:provider, :linked_at, :updated_at)
|
|
51
|
+
|
|
52
|
+
# Reopens the generated record for checked initialization.
|
|
53
|
+
class LinkedOAuthProvider
|
|
54
|
+
# @dynamic provider, linked_at, updated_at, members, with, to_h, deconstruct
|
|
55
|
+
# @dynamic deconstruct_keys, self.[], self.members
|
|
38
56
|
def initialize(provider:, linked_at:, updated_at:)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
57
|
+
provider = immutable_value(provider)
|
|
58
|
+
linked_at = immutable_value(linked_at)
|
|
59
|
+
updated_at = immutable_value(updated_at)
|
|
60
|
+
super
|
|
44
61
|
end
|
|
45
62
|
|
|
46
63
|
private
|
|
@@ -49,30 +66,46 @@ module Volcano
|
|
|
49
66
|
value.frozen? ? value : value.dup.freeze
|
|
50
67
|
end
|
|
51
68
|
end
|
|
52
|
-
OAuthProviderTokenStatus = Data.define(:message, :provider, :expires_in)
|
|
69
|
+
OAuthProviderTokenStatus = Data.define(:message, :provider, :expires_in)
|
|
70
|
+
|
|
71
|
+
# Reopens the generated record for checked initialization.
|
|
72
|
+
class OAuthProviderTokenStatus
|
|
73
|
+
# @dynamic message, provider, expires_in, members, with, to_h, deconstruct
|
|
74
|
+
# @dynamic deconstruct_keys, self.[], self.members
|
|
53
75
|
def initialize(message:, provider:, expires_in:)
|
|
54
76
|
values = [message, provider]
|
|
55
77
|
valid = values.all? { |value| value.is_a?(String) && !value.strip.empty? }
|
|
56
78
|
raise TypeError, 'Expected complete OAuth provider token status' unless valid && expires_in.is_a?(Integer)
|
|
57
79
|
|
|
58
|
-
|
|
80
|
+
message = message.dup.freeze
|
|
81
|
+
provider = provider.dup.freeze
|
|
82
|
+
super
|
|
59
83
|
end
|
|
60
84
|
end
|
|
61
85
|
EmailChangeResult = Data.define(:message, :new_email)
|
|
62
|
-
LockLease = Data.define(:key, :token, :expires_at, :fencing_token)
|
|
86
|
+
LockLease = Data.define(:key, :token, :expires_at, :fencing_token)
|
|
87
|
+
|
|
88
|
+
# Reopens the generated record for checked initialization.
|
|
89
|
+
class LockLease
|
|
90
|
+
# @dynamic key, token, expires_at, fencing_token, members, with, to_h
|
|
91
|
+
# @dynamic deconstruct, deconstruct_keys, self.[], self.members
|
|
63
92
|
def initialize(key:, token:, expires_at:, fencing_token:)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
fencing_token: fencing_token
|
|
69
|
-
)
|
|
93
|
+
key = key.dup.freeze
|
|
94
|
+
token = token.dup.freeze
|
|
95
|
+
expires_at = expires_at&.dup&.freeze
|
|
96
|
+
super
|
|
70
97
|
end
|
|
71
98
|
end
|
|
72
|
-
LockState = Data.define(:held, :expires_at, :fencing_token)
|
|
99
|
+
LockState = Data.define(:held, :expires_at, :fencing_token)
|
|
100
|
+
|
|
101
|
+
# Reopens the generated record for checked initialization.
|
|
102
|
+
class LockState
|
|
103
|
+
# @dynamic held, expires_at, fencing_token, members, with, to_h, deconstruct
|
|
104
|
+
# @dynamic deconstruct_keys, self.[], self.members
|
|
73
105
|
def initialize(held:, expires_at:, fencing_token:)
|
|
74
106
|
immutable_expiry = expires_at&.then { |value| value.frozen? ? value : value.dup.freeze }
|
|
75
|
-
|
|
107
|
+
expires_at = immutable_expiry
|
|
108
|
+
super
|
|
76
109
|
end
|
|
77
110
|
end
|
|
78
111
|
end
|
|
@@ -4,9 +4,16 @@ module Volcano
|
|
|
4
4
|
class Realtime
|
|
5
5
|
# Delivers broadcasts only while their subscription generation is current.
|
|
6
6
|
module BroadcastDelivery
|
|
7
|
-
|
|
7
|
+
# @dynamic emit, with_lifecycle_lock, broadcast?
|
|
8
|
+
# Captures the subscription generation alongside an immutable publication.
|
|
9
|
+
PublicationContext = Data.define(:protocol, :publication, :generation, :recovered)
|
|
10
|
+
|
|
11
|
+
# Freezes the publication before callbacks can observe or modify it.
|
|
12
|
+
class PublicationContext
|
|
13
|
+
# @dynamic protocol, publication, generation, recovered
|
|
8
14
|
def initialize(protocol:, publication:, generation:, recovered:)
|
|
9
|
-
|
|
15
|
+
publication = Immutable.call(publication)
|
|
16
|
+
super
|
|
10
17
|
end
|
|
11
18
|
end
|
|
12
19
|
private_constant :PublicationContext
|
|
@@ -30,6 +37,14 @@ module Volcano
|
|
|
30
37
|
PublicationContext.new(protocol:, publication:, generation:, recovered:)
|
|
31
38
|
end
|
|
32
39
|
|
|
40
|
+
def publication_rejection(protocol, generation)
|
|
41
|
+
return unless broadcast?
|
|
42
|
+
|
|
43
|
+
lambda do |publication|
|
|
44
|
+
reject_broadcast(publication_context(protocol, publication, generation, false))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
33
48
|
def reject_broadcast(context)
|
|
34
49
|
with_current_publication(context) do
|
|
35
50
|
context.protocol.__send__(:drop_publication, @name, context.publication)
|
|
@@ -57,6 +72,15 @@ module Volcano
|
|
|
57
72
|
@publication_protocol.__send__(:delete_recovery_state, @name)
|
|
58
73
|
@publication_protocol = nil
|
|
59
74
|
end
|
|
75
|
+
|
|
76
|
+
def clear_channel_recovery_state
|
|
77
|
+
return unless broadcast?
|
|
78
|
+
|
|
79
|
+
# @type var position: Hash[Symbol, String | Integer]
|
|
80
|
+
position = {}
|
|
81
|
+
@recovery_position = position.freeze
|
|
82
|
+
@recovery_lineage = nil
|
|
83
|
+
end
|
|
60
84
|
end
|
|
61
85
|
private_constant :BroadcastDelivery
|
|
62
86
|
end
|
|
@@ -4,10 +4,16 @@ module Volcano
|
|
|
4
4
|
class Realtime
|
|
5
5
|
# Registers channel callbacks and connects them to protocol pushes.
|
|
6
6
|
module ChannelCallbacks
|
|
7
|
+
# @dynamic presence?, postgres?, broadcast?, register_presence_handler, detach_presence_handler
|
|
8
|
+
# @dynamic deliver_broadcast, publication_context, reject_broadcast, publication_rejection
|
|
9
|
+
# @dynamic dispatch_postgres_change
|
|
7
10
|
def on(event, callback = nil, &block)
|
|
8
11
|
raise ArgumentError, "unsupported realtime event: #{event}" unless allowed_event?(event)
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
handler = callback || block || raise(ArgumentError, 'callback or block is required')
|
|
14
|
+
raise ArgumentError, 'callback must respond to call' unless handler.respond_to?(:call)
|
|
15
|
+
|
|
16
|
+
@callbacks[event] << handler
|
|
11
17
|
self
|
|
12
18
|
end
|
|
13
19
|
|
|
@@ -37,21 +43,21 @@ module Volcano
|
|
|
37
43
|
generation = @publication_generation
|
|
38
44
|
@publication_protocol = protocol if broadcast?
|
|
39
45
|
@publication_handler = protocol.on_publication(
|
|
40
|
-
@name, on_rejection: publication_rejection(protocol, generation)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
context = publication_context(protocol, publication, generation, recovered)
|
|
45
|
-
deliver_broadcast(event, data, context)
|
|
46
|
-
end
|
|
46
|
+
@name, on_rejection: publication_rejection(protocol, generation),
|
|
47
|
+
&publication_callback(protocol, generation)
|
|
48
|
+
)
|
|
47
49
|
@handler_registered = true
|
|
48
50
|
end
|
|
49
51
|
|
|
50
|
-
def
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
def publication_callback(protocol, generation)
|
|
53
|
+
lambda do |event, data, publication, recovered:|
|
|
54
|
+
# @type var recovered: bool
|
|
55
|
+
if broadcast?
|
|
56
|
+
context = publication_context(protocol, publication, generation, recovered)
|
|
57
|
+
deliver_broadcast(event, data, context)
|
|
58
|
+
else
|
|
59
|
+
deliver_publication(event, data)
|
|
60
|
+
end
|
|
55
61
|
end
|
|
56
62
|
end
|
|
57
63
|
|
|
@@ -67,6 +73,7 @@ module Volcano
|
|
|
67
73
|
callbacks = @callbacks[event].dup
|
|
68
74
|
return if callbacks.empty?
|
|
69
75
|
|
|
76
|
+
# @type var delivery: delivery
|
|
70
77
|
delivery = [event, data, callbacks, before_delivery]
|
|
71
78
|
return defer_callback_delivery(delivery) if callback_dispatching?
|
|
72
79
|
|
|
@@ -109,8 +116,9 @@ module Volcano
|
|
|
109
116
|
end
|
|
110
117
|
|
|
111
118
|
def detach_publication_handler(protocol)
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
handler = @publication_handler
|
|
120
|
+
if handler
|
|
121
|
+
protocol.off_publication(@name, handler)
|
|
114
122
|
@publication_generation += 1 if broadcast?
|
|
115
123
|
end
|
|
116
124
|
detach_presence_handler(protocol)
|
|
@@ -4,6 +4,9 @@ module Volcano
|
|
|
4
4
|
class Realtime
|
|
5
5
|
# Coordinates a channel's protocol subscription and local lifecycle state.
|
|
6
6
|
module ChannelLifecycle
|
|
7
|
+
# @dynamic end_postgres_delivery, reset_presence, detach_publication_handler, ensure_open!
|
|
8
|
+
# @dynamic broadcast?, next_presence_epoch, register_handlers, begin_postgres_delivery
|
|
9
|
+
# @dynamic invalidate_presence_subscription, clear_presence, presence?
|
|
7
10
|
def mark_closed
|
|
8
11
|
@closed = true
|
|
9
12
|
@subscription_desired = @subscribed = false
|
|
@@ -84,14 +87,18 @@ module Volcano
|
|
|
84
87
|
return unless broadcast?
|
|
85
88
|
|
|
86
89
|
_, lineage, = @realtime.__send__(:capture_protocol_session)
|
|
87
|
-
|
|
90
|
+
# @type var binding: recovery_binding
|
|
91
|
+
binding = [lineage, recovery_position(lineage)]
|
|
92
|
+
binding.freeze
|
|
88
93
|
end
|
|
89
94
|
|
|
90
95
|
def recovery_position(lineage)
|
|
91
96
|
return @recovery_position if @recovery_lineage == lineage
|
|
92
97
|
|
|
93
98
|
@recovery_lineage = lineage
|
|
94
|
-
@
|
|
99
|
+
# @type var position: recovery_position
|
|
100
|
+
position = {}
|
|
101
|
+
@recovery_position = position.freeze
|
|
95
102
|
end
|
|
96
103
|
|
|
97
104
|
def validate_recovery_binding!(binding)
|
|
@@ -113,13 +120,6 @@ module Volcano
|
|
|
113
120
|
@recovery_position = protocol.__send__(:position, @name) || @recovery_position
|
|
114
121
|
end
|
|
115
122
|
|
|
116
|
-
def clear_channel_recovery_state
|
|
117
|
-
return unless broadcast?
|
|
118
|
-
|
|
119
|
-
@recovery_position = {}.freeze
|
|
120
|
-
@recovery_lineage = nil
|
|
121
|
-
end
|
|
122
|
-
|
|
123
123
|
def mark_removed
|
|
124
124
|
@callbacks.each_value(&:clear)
|
|
125
125
|
@handler_registered = @subscription_desired = @subscribed = false
|
|
@@ -4,6 +4,9 @@ module Volcano
|
|
|
4
4
|
class Realtime
|
|
5
5
|
# Opens the WebSocket transport and maps its failures to public errors.
|
|
6
6
|
module Connection
|
|
7
|
+
# @dynamic protocol_connected, protocol_error, protocol_closed, protocol_error_started
|
|
8
|
+
# @dynamic protocol_failed, stop_reconnect
|
|
9
|
+
|
|
7
10
|
private
|
|
8
11
|
|
|
9
12
|
def connect_protocol
|
|
@@ -71,9 +74,9 @@ module Volcano
|
|
|
71
74
|
socket: socket,
|
|
72
75
|
secrets: realtime_secrets,
|
|
73
76
|
events: Protocol::Events.new(
|
|
74
|
-
on_close:
|
|
75
|
-
on_error:
|
|
76
|
-
on_failure:
|
|
77
|
+
on_close: ->(error) { protocol_closed(error) },
|
|
78
|
+
on_error: ->(error) { protocol_error_started(error) },
|
|
79
|
+
on_failure: ->(error, disconnected) { protocol_failed(error, disconnected) }
|
|
77
80
|
)
|
|
78
81
|
)
|
|
79
82
|
end
|
|
@@ -87,7 +90,7 @@ module Volcano
|
|
|
87
90
|
def address
|
|
88
91
|
uri = URI(@api_url)
|
|
89
92
|
uri.scheme = uri.scheme == 'https' ? 'wss' : 'ws'
|
|
90
|
-
uri.path = "#{uri.path.delete_suffix('/')}/realtime/v1/websocket"
|
|
93
|
+
uri.path = "#{uri.path.to_s.delete_suffix('/')}/realtime/v1/websocket"
|
|
91
94
|
encoded_key = URI.encode_www_form_component(@client.anon_token).gsub('+', '%20')
|
|
92
95
|
uri.query = "apikey=#{encoded_key}"
|
|
93
96
|
uri.to_s
|
|
@@ -106,7 +109,8 @@ module Volcano
|
|
|
106
109
|
return redacted unless Transport::NETWORK_ERRORS.any? { |type| error.is_a?(type) }
|
|
107
110
|
|
|
108
111
|
transport_error = Error::TransportError.new(redacted.message)
|
|
109
|
-
|
|
112
|
+
trace = redacted.backtrace
|
|
113
|
+
transport_error.set_backtrace(trace) if trace
|
|
110
114
|
transport_error
|
|
111
115
|
end
|
|
112
116
|
end
|
|
@@ -4,6 +4,7 @@ module Volcano
|
|
|
4
4
|
class Realtime
|
|
5
5
|
# Registers and dispatches public connection lifecycle callbacks.
|
|
6
6
|
module ConnectionCallbacks
|
|
7
|
+
# @dynamic start_reconnect
|
|
7
8
|
def on_connect(callback = nil, &block) = register_connection_callback(:connect, callback, block)
|
|
8
9
|
def on_disconnect(callback = nil, &block) = register_connection_callback(:disconnect, callback, block)
|
|
9
10
|
def on_error(callback = nil, &block) = register_connection_callback(:error, callback, block)
|
|
@@ -76,6 +77,7 @@ module Volcano
|
|
|
76
77
|
|
|
77
78
|
def protocol_failed(error, disconnected)
|
|
78
79
|
reset_after_protocol_loss if disconnected
|
|
80
|
+
# @type var events: Array[[connection_event, connection_context]]
|
|
79
81
|
events = [[:error, error_context(error)]]
|
|
80
82
|
events << [:disconnect, disconnect_context(error)] if disconnected
|
|
81
83
|
emit_connection_events(events)
|
|
@@ -84,8 +86,10 @@ module Volcano
|
|
|
84
86
|
|
|
85
87
|
def error_context(error)
|
|
86
88
|
snapshot = immutable_exception(error)
|
|
89
|
+
code = snapshot.respond_to?(:code) ? snapshot.method(:code).call : nil
|
|
90
|
+
code = nil unless code.is_a?(String) || code.is_a?(Integer)
|
|
87
91
|
ErrorContext.new(
|
|
88
|
-
code:
|
|
92
|
+
code: code,
|
|
89
93
|
message: immutable_string(snapshot.message), error: snapshot
|
|
90
94
|
)
|
|
91
95
|
end
|
|
@@ -4,6 +4,7 @@ module Volcano
|
|
|
4
4
|
class Realtime
|
|
5
5
|
# Channel collection and connection-state lifecycle operations.
|
|
6
6
|
module Lifecycle
|
|
7
|
+
# @dynamic channel_lock, normalize_channel_type
|
|
7
8
|
def connected? = !@closed && (@protocol&.connected? || false)
|
|
8
9
|
|
|
9
10
|
def remove_channel(name, type: :broadcast)
|
|
@@ -26,7 +27,7 @@ module Volcano
|
|
|
26
27
|
private
|
|
27
28
|
|
|
28
29
|
def remove_channel_locked(name)
|
|
29
|
-
channel = @channels
|
|
30
|
+
channel = @channels.fetch(name, nil)
|
|
30
31
|
return unless channel
|
|
31
32
|
|
|
32
33
|
channel.__send__(:remove)
|
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Volcano
|
|
4
4
|
class Realtime
|
|
5
|
+
# Immutable limits for one channel's automatic row-fetch queue.
|
|
5
6
|
PostgresBatchConfig = Data.define(
|
|
6
7
|
:auto_fetch, :batch_window_ms, :max_batch_size
|
|
7
|
-
)
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
# Validates the queue limits supplied to a Postgres channel.
|
|
11
|
+
class PostgresBatchConfig
|
|
12
|
+
# @dynamic auto_fetch, batch_window_ms, max_batch_size
|
|
8
13
|
def initialize(auto_fetch:, batch_window_ms:, max_batch_size:)
|
|
9
14
|
unless batch_window_ms.is_a?(Integer) && batch_window_ms.positive?
|
|
10
15
|
raise ArgumentError, 'fetch_batch_window_ms must be a positive integer'
|
|
@@ -20,6 +25,8 @@ module Volcano
|
|
|
20
25
|
|
|
21
26
|
# Collects compatible row lookups and preserves their callback order.
|
|
22
27
|
module PostgresBatch
|
|
28
|
+
# @dynamic postgres_stop?, current_postgres_request?, expanded_postgres_changes, emit
|
|
29
|
+
|
|
23
30
|
private
|
|
24
31
|
|
|
25
32
|
def collect_postgres_batch(first)
|
|
@@ -35,6 +42,7 @@ module Volcano
|
|
|
35
42
|
while requests.length < @postgres_batch_config.max_batch_size
|
|
36
43
|
request = dequeue_postgres_before(deadline)
|
|
37
44
|
return unless request
|
|
45
|
+
return request unless request.is_a?(PostgresDeliveryRequest)
|
|
38
46
|
return request unless compatible_postgres_request?(requests.first, request)
|
|
39
47
|
|
|
40
48
|
requests << request
|
|
@@ -72,8 +80,8 @@ module Volcano
|
|
|
72
80
|
return unless current_postgres_request?(requests.first)
|
|
73
81
|
|
|
74
82
|
changes = expanded_postgres_changes(requests)
|
|
75
|
-
requests.
|
|
76
|
-
deliver_expanded_postgres_change(request,
|
|
83
|
+
requests.each_with_index do |request, index|
|
|
84
|
+
deliver_expanded_postgres_change(request, changes.fetch(index))
|
|
77
85
|
end
|
|
78
86
|
end
|
|
79
87
|
|
|
@@ -4,23 +4,34 @@ module Volcano
|
|
|
4
4
|
class Realtime
|
|
5
5
|
PostgresChange = Data.define(
|
|
6
6
|
:type, :schema, :table, :record, :old_record, :columns, :timestamp, :id, :mode
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
)
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
# Immutable snapshot of a Postgres change publication.
|
|
10
|
+
class PostgresChange
|
|
11
|
+
# @dynamic type, schema, table, record, old_record, columns, timestamp, id, mode
|
|
12
|
+
# @dynamic members, with, to_h, deconstruct, deconstruct_keys, self.[], self.members
|
|
13
|
+
def initialize(type:, schema:, table:, timestamp:, **payload)
|
|
14
|
+
type = Immutable.call(type.to_s)
|
|
15
|
+
schema = Immutable.call(schema.to_s)
|
|
16
|
+
table = Immutable.call(table.to_s)
|
|
17
|
+
timestamp = Immutable.call(timestamp.to_s)
|
|
18
|
+
payload = snapshot_payload(payload)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def snapshot_payload(payload)
|
|
25
|
+
payload = { record: nil, old_record: nil, columns: nil, id: nil, mode: nil }.merge(payload)
|
|
26
|
+
%i[record old_record columns id].each { |field| payload[field] = Immutable.optional(payload[field]) }
|
|
27
|
+
payload[:mode] &&= Immutable.call(payload[:mode].to_s)
|
|
28
|
+
payload
|
|
19
29
|
end
|
|
20
30
|
end
|
|
21
31
|
|
|
22
32
|
# Registers filtered callbacks for Postgres change publications.
|
|
23
33
|
module PostgresChanges
|
|
34
|
+
# @dynamic on, enqueue_postgres_delivery
|
|
24
35
|
CHANGE_EVENTS = %w[INSERT UPDATE DELETE].freeze
|
|
25
36
|
EVENTS = [*CHANGE_EVENTS, '*'].freeze
|
|
26
37
|
|
|
@@ -28,6 +39,8 @@ module Volcano
|
|
|
28
39
|
ensure_postgres!
|
|
29
40
|
event = postgres_event(event)
|
|
30
41
|
handler = callback || block || raise(ArgumentError, 'callback or block is required')
|
|
42
|
+
raise ArgumentError, 'callback must respond to call' unless handler.respond_to?(:call)
|
|
43
|
+
|
|
31
44
|
register_postgres_listener(event, schema.to_s, table.to_s, handler)
|
|
32
45
|
end
|
|
33
46
|
|
|
@@ -41,7 +54,7 @@ module Volcano
|
|
|
41
54
|
end
|
|
42
55
|
|
|
43
56
|
def postgres_change(data)
|
|
44
|
-
return unless valid_postgres_change?(data)
|
|
57
|
+
return unless data.is_a?(Hash) && valid_postgres_change?(data)
|
|
45
58
|
|
|
46
59
|
PostgresChange.new(
|
|
47
60
|
type: data.fetch('type'), schema: data.fetch('schema'), table: data.fetch('table'),
|
|
@@ -58,7 +71,10 @@ module Volcano
|
|
|
58
71
|
end
|
|
59
72
|
|
|
60
73
|
def register_postgres_listener(event, schema, table, handler)
|
|
61
|
-
|
|
74
|
+
# @type var filtered: ^(Object?) -> void
|
|
75
|
+
filtered = lambda do |change|
|
|
76
|
+
handler.call(change) if change.is_a?(PostgresChange) && change.schema == schema && change.table == table
|
|
77
|
+
end
|
|
62
78
|
@postgres_filters[filtered] = [event, schema, table]
|
|
63
79
|
on(event, filtered)
|
|
64
80
|
end
|
|
@@ -77,7 +93,7 @@ module Volcano
|
|
|
77
93
|
end
|
|
78
94
|
|
|
79
95
|
def valid_postgres_change?(data)
|
|
80
|
-
valid_postgres_identity?(data) && valid_postgres_payload?(data)
|
|
96
|
+
data.is_a?(Hash) && valid_postgres_identity?(data) && valid_postgres_payload?(data)
|
|
81
97
|
end
|
|
82
98
|
|
|
83
99
|
def valid_postgres_identity?(data)
|
|
@@ -87,8 +103,9 @@ module Volcano
|
|
|
87
103
|
end
|
|
88
104
|
|
|
89
105
|
def valid_postgres_payload?(data)
|
|
106
|
+
mode = data['mode']
|
|
90
107
|
optional_hash?(data['record']) && optional_hash?(data['old_record']) &&
|
|
91
|
-
optional_array?(data['columns']) &&
|
|
108
|
+
optional_array?(data['columns']) && (mode.nil? || mode == 'lightweight')
|
|
92
109
|
end
|
|
93
110
|
|
|
94
111
|
def optional_hash?(value) = value.nil? || value.is_a?(Hash)
|
|
@@ -10,6 +10,8 @@ module Volcano
|
|
|
10
10
|
|
|
11
11
|
attr_reader :database_name
|
|
12
12
|
|
|
13
|
+
# @dynamic database_name
|
|
14
|
+
|
|
13
15
|
def database_name=(name)
|
|
14
16
|
unless valid_database_name?(name)
|
|
15
17
|
raise ArgumentError,
|
|
@@ -48,12 +50,23 @@ module Volcano
|
|
|
48
50
|
|
|
49
51
|
def fetch_postgres_rows(change, ids, database_name, access_token)
|
|
50
52
|
table = change.schema == 'public' ? change.table : "#{change.schema}.#{change.table}"
|
|
51
|
-
postgres_fetch_semaphore.acquire do
|
|
53
|
+
rows = postgres_fetch_semaphore.acquire do
|
|
52
54
|
BlockingCall.call do
|
|
53
55
|
@client.__send__(:database_with_token, database_name, access_token)
|
|
54
56
|
.from(table).select('*').in('id', ids).execute
|
|
55
57
|
end
|
|
56
58
|
end
|
|
59
|
+
validate_postgres_rows(rows)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_postgres_rows(rows)
|
|
63
|
+
raise TypeError, 'Postgres row lookup must return an array' unless rows.is_a?(Array)
|
|
64
|
+
|
|
65
|
+
rows.map do |row|
|
|
66
|
+
raise TypeError, 'Postgres row lookup must return objects' unless row.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
row
|
|
69
|
+
end
|
|
57
70
|
end
|
|
58
71
|
|
|
59
72
|
def postgres_fetch_semaphore
|
|
@@ -9,6 +9,7 @@ module Volcano
|
|
|
9
9
|
|
|
10
10
|
# Serializes Postgres publications and expands lightweight row identities.
|
|
11
11
|
module PostgresDelivery
|
|
12
|
+
# @dynamic postgres?, collect_postgres_batch, deliver_postgres_batch
|
|
12
13
|
QUEUE_LIMIT = 128
|
|
13
14
|
STOP = Object.new.freeze
|
|
14
15
|
private_constant :QUEUE_LIMIT, :STOP
|
|
@@ -18,7 +19,9 @@ module Volcano
|
|
|
18
19
|
def initialize_postgres_delivery(batch_config)
|
|
19
20
|
require 'async/queue'
|
|
20
21
|
@postgres_batch_config = batch_config
|
|
21
|
-
@
|
|
22
|
+
# @type var empty_filters: Hash[^(Object?) -> void, [String, String, String]]
|
|
23
|
+
empty_filters = {}
|
|
24
|
+
@postgres_filters = empty_filters
|
|
22
25
|
@postgres_queue = Async::LimitedQueue.new(QUEUE_LIMIT)
|
|
23
26
|
@postgres_worker = nil
|
|
24
27
|
@postgres_epoch = 0
|
|
@@ -88,10 +91,12 @@ module Volcano
|
|
|
88
91
|
end
|
|
89
92
|
|
|
90
93
|
def run_postgres_worker
|
|
94
|
+
# @type var pending: Object?
|
|
91
95
|
pending = nil
|
|
92
96
|
loop do
|
|
93
97
|
request = pending || @postgres_queue.dequeue
|
|
94
98
|
break if postgres_stop?(request)
|
|
99
|
+
raise TypeError, 'invalid Postgres delivery request' unless request.is_a?(PostgresDeliveryRequest)
|
|
95
100
|
|
|
96
101
|
requests, pending = collect_postgres_batch(request)
|
|
97
102
|
deliver_postgres_batch(requests)
|
|
@@ -4,6 +4,8 @@ module Volcano
|
|
|
4
4
|
class Realtime
|
|
5
5
|
# Expands lightweight Postgres identities into callback-ready changes.
|
|
6
6
|
module PostgresExpansion
|
|
7
|
+
# @dynamic current_postgres_request?, postgres_batch_key
|
|
8
|
+
|
|
7
9
|
private
|
|
8
10
|
|
|
9
11
|
def expanded_postgres_changes(requests)
|
|
@@ -26,12 +28,16 @@ module Volcano
|
|
|
26
28
|
|
|
27
29
|
def fetch_postgres_records(requests)
|
|
28
30
|
request = requests.first
|
|
31
|
+
database_name = request.database_name
|
|
32
|
+
lineage = request.session_lineage
|
|
33
|
+
raise TypeError, 'invalid Postgres fetch request' unless database_name && lineage.is_a?(SessionOperations)
|
|
34
|
+
|
|
29
35
|
access_token = @realtime.__send__(
|
|
30
|
-
:access_token_for_protocol_lineage,
|
|
36
|
+
:access_token_for_protocol_lineage, lineage
|
|
31
37
|
)
|
|
32
38
|
rows = @realtime.__send__(
|
|
33
39
|
:fetch_postgres_rows, request.change, postgres_ids(requests),
|
|
34
|
-
|
|
40
|
+
database_name, access_token
|
|
35
41
|
)
|
|
36
42
|
rows.to_h { |row| [row.fetch('id').to_s, row] }
|
|
37
43
|
end
|
|
@@ -41,7 +47,7 @@ module Volcano
|
|
|
41
47
|
end
|
|
42
48
|
|
|
43
49
|
def expanded_postgres_record(request, records)
|
|
44
|
-
record = records
|
|
50
|
+
record = records.fetch(request.change.id.to_s, nil)
|
|
45
51
|
unless record
|
|
46
52
|
report_postgres_fetch_error(request, Error::NotFoundError.new('Postgres row not found'))
|
|
47
53
|
return request.change
|
|
@@ -55,7 +61,7 @@ module Volcano
|
|
|
55
61
|
end
|
|
56
62
|
|
|
57
63
|
def delete_postgres_change(change)
|
|
58
|
-
old_record = change.old_record || (change.id
|
|
64
|
+
old_record = change.old_record || (change.id ? { 'id' => change.id } : nil)
|
|
59
65
|
change.with(old_record:, id: nil, mode: nil)
|
|
60
66
|
end
|
|
61
67
|
|