orbitcast 0.3.1-x86_64-linux-gnu
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/LICENSE.txt +28 -0
- data/README.md +100 -0
- data/app/assets/javascripts/orbitcast/recovery.js +196 -0
- data/db/orbitcast_migrate/001_create_orbitcast_payloads.rb +39 -0
- data/db/orbitcast_migrate/002_create_orbitcast_presence.rb +54 -0
- data/db/orbitcast_migrate/003_create_orbitcast_broker.rb +102 -0
- data/exe/orbitcast +11 -0
- data/lib/action_cable/subscription_adapter/orbitcast.rb +102 -0
- data/lib/generators/orbitcast/install/install_generator.rb +223 -0
- data/lib/generators/orbitcast/install/templates/cable.yml.tt +48 -0
- data/lib/generators/orbitcast/install/templates/orbitcast.rb.tt +156 -0
- data/lib/orbitcast/binary.rb +234 -0
- data/lib/orbitcast/channel.rb +459 -0
- data/lib/orbitcast/channel_registry.rb +131 -0
- data/lib/orbitcast/configuration.rb +636 -0
- data/lib/orbitcast/connection.rb +338 -0
- data/lib/orbitcast/control.rb +72 -0
- data/lib/orbitcast/dispatcher.rb +306 -0
- data/lib/orbitcast/engine.rb +98 -0
- data/lib/orbitcast/error.rb +54 -0
- data/lib/orbitcast/launcher.rb +302 -0
- data/lib/orbitcast/protocol/v1/control_message.rb +134 -0
- data/lib/orbitcast/protocol/v1/effects.rb +172 -0
- data/lib/orbitcast/protocol/v1/reply.rb +268 -0
- data/lib/orbitcast/protocol/v1/request.rb +364 -0
- data/lib/orbitcast/protocol/v1.rb +291 -0
- data/lib/orbitcast/protocol.rb +19 -0
- data/lib/orbitcast/publisher/control.rb +67 -0
- data/lib/orbitcast/publisher/http.rb +180 -0
- data/lib/orbitcast/publisher/instrumentation.rb +47 -0
- data/lib/orbitcast/publisher/postgres.rb +407 -0
- data/lib/orbitcast/rails_endpoint.rb +207 -0
- data/lib/orbitcast/recovery.rb +166 -0
- data/lib/orbitcast/state_codec.rb +186 -0
- data/lib/orbitcast/streams.rb +129 -0
- data/lib/orbitcast/version.rb +10 -0
- data/lib/orbitcast.rb +68 -0
- data/vendor/x86_64-linux-gnu/bin/orbitcast +0 -0
- data/vendor/x86_64-linux-gnu/manifest.json +14 -0
- metadata +130 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "../error"
|
|
6
|
+
|
|
7
|
+
module Orbitcast
|
|
8
|
+
module Protocol
|
|
9
|
+
# Version 1 envelopes: limits, tagged values, and the shared semantic
|
|
10
|
+
# checks.
|
|
11
|
+
#
|
|
12
|
+
# Every constant here mirrors one in `src/protocol/v1/`. Rust is the strict
|
|
13
|
+
# validator of what crosses the wire; Ruby validates its own output before
|
|
14
|
+
# emitting it, so an envelope Rust would reject never leaves this process.
|
|
15
|
+
#
|
|
16
|
+
# Tolerant reading, exactly as documented:
|
|
17
|
+
#
|
|
18
|
+
# | wire condition | result |
|
|
19
|
+
# | --------------------- | --------------------- |
|
|
20
|
+
# | unknown field | ignored |
|
|
21
|
+
# | absent `version` | reads as 1 |
|
|
22
|
+
# | unknown tagged value | hard error |
|
|
23
|
+
# | absent `state`/`null` | unchanged |
|
|
24
|
+
# | `state: {}` | clear |
|
|
25
|
+
module V1
|
|
26
|
+
VERSION = 1
|
|
27
|
+
|
|
28
|
+
# --- operations (also the last path segment of the HTTP binding) -------
|
|
29
|
+
|
|
30
|
+
OPERATION_DESCRIBE = "describe"
|
|
31
|
+
OPERATION_IDENTIFY = "identify"
|
|
32
|
+
OPERATION_OPEN_CHANNEL = "open_channel"
|
|
33
|
+
OPERATION_CLOSE_CHANNEL = "close_channel"
|
|
34
|
+
OPERATION_RUN_ACTION = "run_action"
|
|
35
|
+
OPERATION_RELEASE_CONNECTION = "release_connection"
|
|
36
|
+
|
|
37
|
+
OPERATIONS = [
|
|
38
|
+
OPERATION_DESCRIBE,
|
|
39
|
+
OPERATION_IDENTIFY,
|
|
40
|
+
OPERATION_OPEN_CHANNEL,
|
|
41
|
+
OPERATION_CLOSE_CHANNEL,
|
|
42
|
+
OPERATION_RUN_ACTION,
|
|
43
|
+
OPERATION_RELEASE_CONNECTION
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
# `describe` is a node-level handshake: no session, no revision.
|
|
47
|
+
STATEFUL_OPERATIONS = (OPERATIONS - [OPERATION_DESCRIBE]).freeze
|
|
48
|
+
|
|
49
|
+
# Operations that run *after* the thing they describe is already gone.
|
|
50
|
+
# Their effects would address a subscription the relay has tombstoned.
|
|
51
|
+
TEARDOWN_OPERATIONS = [OPERATION_CLOSE_CHANNEL, OPERATION_RELEASE_CONNECTION].freeze
|
|
52
|
+
|
|
53
|
+
# --- outcomes ---------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
OUTCOME_ACCEPTED = "accepted"
|
|
56
|
+
OUTCOME_DENIED = "denied"
|
|
57
|
+
OUTCOME_FAULT = "fault"
|
|
58
|
+
OUTCOMES = [OUTCOME_ACCEPTED, OUTCOME_DENIED, OUTCOME_FAULT].freeze
|
|
59
|
+
|
|
60
|
+
# --- effects ----------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
EFFECT_ADD_STREAM = "add_stream"
|
|
63
|
+
EFFECT_REMOVE_STREAM = "remove_stream"
|
|
64
|
+
EFFECT_REMOVE_ALL_STREAMS = "remove_all_streams"
|
|
65
|
+
EFFECT_TRANSMIT = "transmit"
|
|
66
|
+
EFFECT_CLOSE = "close"
|
|
67
|
+
EFFECT_PRESENCE_GRANT = "presence_grant"
|
|
68
|
+
EFFECT_PRESENCE_REVOKE = "presence_revoke"
|
|
69
|
+
|
|
70
|
+
EFFECT_KINDS = [
|
|
71
|
+
EFFECT_ADD_STREAM, EFFECT_REMOVE_STREAM, EFFECT_REMOVE_ALL_STREAMS,
|
|
72
|
+
EFFECT_TRANSMIT, EFFECT_CLOSE, EFFECT_PRESENCE_GRANT, EFFECT_PRESENCE_REVOKE
|
|
73
|
+
].freeze
|
|
74
|
+
|
|
75
|
+
# --- presence (src/protocol/v1/presence.rs) ---------------------------
|
|
76
|
+
#
|
|
77
|
+
# Presence needs no transport extension: a client operation rides the
|
|
78
|
+
# `data` of an ordinary `message`/`perform` command under the reserved
|
|
79
|
+
# action below, and a server answer rides an ordinary addressed message.
|
|
80
|
+
#
|
|
81
|
+
# `PRESENCE_OPERATIONS` is one spelling used in two places: the
|
|
82
|
+
# `operations` allowlist of a `presence_grant` effect, and the `op` a
|
|
83
|
+
# client may ask for. The server-to-client `event` values are the same
|
|
84
|
+
# three words.
|
|
85
|
+
|
|
86
|
+
PRESENCE_ACTION = "orbitcast_presence"
|
|
87
|
+
PRESENCE_MESSAGE_TYPE = "presence"
|
|
88
|
+
|
|
89
|
+
PRESENCE_OP_JOIN = "join"
|
|
90
|
+
PRESENCE_OP_LEAVE = "leave"
|
|
91
|
+
PRESENCE_OP_SNAPSHOT = "snapshot"
|
|
92
|
+
|
|
93
|
+
PRESENCE_OPERATIONS = [
|
|
94
|
+
PRESENCE_OP_JOIN, PRESENCE_OP_LEAVE, PRESENCE_OP_SNAPSHOT
|
|
95
|
+
].freeze
|
|
96
|
+
|
|
97
|
+
# Snapshot page window, as the server clamps it.
|
|
98
|
+
DEFAULT_PRESENCE_PAGE_SIZE = 50
|
|
99
|
+
MAX_PRESENCE_PAGE_SIZE = 100
|
|
100
|
+
# Deep paging over a roster is a scan; the ceiling keeps one client from
|
|
101
|
+
# walking an unbounded OFFSET across the table.
|
|
102
|
+
MAX_PRESENCE_PAGE_OFFSET = 100_000
|
|
103
|
+
|
|
104
|
+
# --- close reasons ----------------------------------------------------
|
|
105
|
+
#
|
|
106
|
+
# Rails does not get to invent wire-visible close reasons: the browser
|
|
107
|
+
# facing `disconnect` frame is framed by Rust and the ActionCable client
|
|
108
|
+
# only understands these four.
|
|
109
|
+
|
|
110
|
+
CLOSE_REASON_UNAUTHORIZED = "unauthorized"
|
|
111
|
+
CLOSE_REASON_INVALID_REQUEST = "invalid_request"
|
|
112
|
+
CLOSE_REASON_SERVER_RESTART = "server_restart"
|
|
113
|
+
CLOSE_REASON_REMOTE = "remote"
|
|
114
|
+
|
|
115
|
+
CLOSE_REASONS = [
|
|
116
|
+
CLOSE_REASON_UNAUTHORIZED, CLOSE_REASON_INVALID_REQUEST,
|
|
117
|
+
CLOSE_REASON_SERVER_RESTART, CLOSE_REASON_REMOTE
|
|
118
|
+
].freeze
|
|
119
|
+
|
|
120
|
+
# --- control ----------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
CONTROL_KIND_DISCONNECT = "disconnect"
|
|
123
|
+
CONTROL_KINDS = [CONTROL_KIND_DISCONNECT].freeze
|
|
124
|
+
|
|
125
|
+
# --- limits (src/protocol/v1/rpc.rs) ----------------------------------
|
|
126
|
+
|
|
127
|
+
MAX_EFFECTS = 64
|
|
128
|
+
MAX_IDENTIFIER_LEN = 4096
|
|
129
|
+
MAX_NODE_ID_LEN = 128
|
|
130
|
+
MAX_NAMESPACE_LEN = 128
|
|
131
|
+
MAX_STREAM_LEN = 1024
|
|
132
|
+
MAX_URL_LEN = 8192
|
|
133
|
+
MAX_ADDRESS_LEN = 256
|
|
134
|
+
MAX_HEADERS = 32
|
|
135
|
+
MAX_HEADER_NAME_LEN = 128
|
|
136
|
+
MAX_HEADER_VALUE_LEN = 8192
|
|
137
|
+
MAX_STATE_ENTRIES = 64
|
|
138
|
+
MAX_STATE_KEY_LEN = 128
|
|
139
|
+
MAX_STATE_LEN = 64 * 1024
|
|
140
|
+
MAX_PAYLOAD_LEN = 1024 * 1024
|
|
141
|
+
MAX_TRANSMIT_LEN = MAX_PAYLOAD_LEN
|
|
142
|
+
MAX_SUBSCRIPTION_STREAMS = 64
|
|
143
|
+
MAX_RELEASE_SUBSCRIPTIONS = 256
|
|
144
|
+
MAX_REASON_LEN = 256
|
|
145
|
+
MAX_SUBJECT_ID_LEN = 256
|
|
146
|
+
MAX_PRESENCE_METADATA_LEN = 4096
|
|
147
|
+
MAX_PRESENCE_OPERATIONS = 16
|
|
148
|
+
MAX_DEADLINE_MS = 60_000
|
|
149
|
+
MAX_CAPABILITIES = 64
|
|
150
|
+
MAX_LABEL_LEN = 128
|
|
151
|
+
MAX_IDENTITY_KEY_LEN = 512
|
|
152
|
+
|
|
153
|
+
# Headers that are never forwarded, whatever the allowlist says: they
|
|
154
|
+
# describe the relay's own hop and reconstructing them here would be a
|
|
155
|
+
# request-smuggling hazard.
|
|
156
|
+
HOP_BY_HOP_HEADERS = %w[
|
|
157
|
+
connection content-length host keep-alive proxy-authenticate
|
|
158
|
+
proxy-authorization te trailer transfer-encoding upgrade
|
|
159
|
+
].freeze
|
|
160
|
+
|
|
161
|
+
UUID_PATTERN = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
162
|
+
|
|
163
|
+
class << self
|
|
164
|
+
# An absent version reads as 1; serializers always emit it.
|
|
165
|
+
def version_of(hash)
|
|
166
|
+
value = hash["version"]
|
|
167
|
+
return VERSION if value.nil?
|
|
168
|
+
|
|
169
|
+
value
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def validate_version!(version, field: "version")
|
|
173
|
+
return VERSION if version == VERSION
|
|
174
|
+
|
|
175
|
+
raise ProtocolError.new("unsupported envelope version #{version.inspect}", field: field)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def validate_bounded!(field, value, max)
|
|
179
|
+
unless value.is_a?(String)
|
|
180
|
+
raise ProtocolError.new("#{field} must be a String, got #{value.class}", field: field)
|
|
181
|
+
end
|
|
182
|
+
raise ProtocolError.new("#{field} must not be empty", field: field) if value.empty?
|
|
183
|
+
|
|
184
|
+
if value.bytesize > max
|
|
185
|
+
raise ProtocolError.new(
|
|
186
|
+
"#{field} is #{value.bytesize} bytes; the maximum is #{max}", field: field
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
value
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_namespace!(namespace)
|
|
194
|
+
validate_bounded!("namespace", namespace, MAX_NAMESPACE_LEN)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def validate_stream!(stream)
|
|
198
|
+
validate_bounded!("stream", stream, MAX_STREAM_LEN)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def validate_identifier!(identifier)
|
|
202
|
+
validate_bounded!("identifier", identifier, MAX_IDENTIFIER_LEN)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def validate_uuid!(field, value)
|
|
206
|
+
validate_bounded!(field, value, 64)
|
|
207
|
+
return value if value.match?(UUID_PATTERN)
|
|
208
|
+
|
|
209
|
+
raise ProtocolError.new("#{field} #{value.inspect} is not a UUID", field: field)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def validate_close_reason!(reason)
|
|
213
|
+
raise ProtocolError.new("reason must not be empty", field: "reason") if reason.to_s.empty?
|
|
214
|
+
return reason if CLOSE_REASONS.include?(reason)
|
|
215
|
+
|
|
216
|
+
raise ProtocolError.new(
|
|
217
|
+
"reason #{reason.inspect} is not one of #{CLOSE_REASONS.join(", ")}", field: "reason"
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Bounds the entry count, each key, and the *encoded* size: one huge
|
|
222
|
+
# value is as expensive as many small ones.
|
|
223
|
+
def validate_state_map!(field, map)
|
|
224
|
+
unless map.is_a?(Hash)
|
|
225
|
+
raise ProtocolError.new("#{field} must be an object, got #{map.class}", field: field)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
if map.size > MAX_STATE_ENTRIES
|
|
229
|
+
raise ProtocolError.new(
|
|
230
|
+
"#{field} has #{map.size} entries; the maximum is #{MAX_STATE_ENTRIES}", field: field
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
map.each_key do |key|
|
|
235
|
+
unless key.is_a?(String)
|
|
236
|
+
raise ProtocolError.new("#{field} keys must be Strings, got #{key.class}", field: field)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
validate_bounded!("state_key", key, MAX_STATE_KEY_LEN)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
encoded = JSON.generate(map)
|
|
243
|
+
if encoded.bytesize > MAX_STATE_LEN
|
|
244
|
+
raise ProtocolError.new(
|
|
245
|
+
"#{field} encodes to #{encoded.bytesize} bytes; the maximum is #{MAX_STATE_LEN}",
|
|
246
|
+
field: field
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
map
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Check that an embedded application payload is valid JSON *without*
|
|
254
|
+
# keeping the decoded value: the exact encoded text is what travels.
|
|
255
|
+
def validate_json_text!(field, text)
|
|
256
|
+
unless text.is_a?(String)
|
|
257
|
+
raise ProtocolError.new("#{field} must be a String, got #{text.class}", field: field)
|
|
258
|
+
end
|
|
259
|
+
raise ProtocolError.new("#{field} must not be empty", field: field) if text.empty?
|
|
260
|
+
|
|
261
|
+
if text.bytesize > MAX_PAYLOAD_LEN
|
|
262
|
+
raise ProtocolError.new(
|
|
263
|
+
"#{field} is #{text.bytesize} bytes; the maximum is #{MAX_PAYLOAD_LEN}", field: field
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
begin
|
|
268
|
+
JSON.parse(text)
|
|
269
|
+
rescue JSON::ParserError => e
|
|
270
|
+
raise ProtocolError.new("#{field} is not valid JSON: #{e.message}", field: field)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
text
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def stateful?(operation)
|
|
277
|
+
STATEFUL_OPERATIONS.include?(operation)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def teardown?(operation)
|
|
281
|
+
TEARDOWN_OPERATIONS.include?(operation)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
require_relative "v1/effects"
|
|
289
|
+
require_relative "v1/request"
|
|
290
|
+
require_relative "v1/reply"
|
|
291
|
+
require_relative "v1/control_message"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protocol/v1"
|
|
4
|
+
|
|
5
|
+
module Orbitcast
|
|
6
|
+
# Handwritten JSON envelopes for the Orbitcast wire protocol.
|
|
7
|
+
#
|
|
8
|
+
# There is no schema compiler and no generated code: `src/protocol/` is the
|
|
9
|
+
# authority, these classes are the Ruby reading of it, and
|
|
10
|
+
# `tests/fixtures/**/*.json` is what keeps the two honest. Both suites consume
|
|
11
|
+
# the same fixtures; neither regenerates them.
|
|
12
|
+
#
|
|
13
|
+
# Everything here must load without Rails: the RPC endpoint needs Rails, the
|
|
14
|
+
# envelopes do not.
|
|
15
|
+
module Protocol
|
|
16
|
+
# The only envelope version this release speaks.
|
|
17
|
+
VERSION = V1::VERSION
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "postgres"
|
|
4
|
+
require_relative "../protocol"
|
|
5
|
+
|
|
6
|
+
module Orbitcast
|
|
7
|
+
module Publisher
|
|
8
|
+
# Publishes Rails-initiated control messages over the same PostgreSQL
|
|
9
|
+
# transport as ordinary publications.
|
|
10
|
+
#
|
|
11
|
+
# A control message is not a publication: it never reaches a browser, it
|
|
12
|
+
# carries no application payload, and every node consumes it rather than
|
|
13
|
+
# forwarding it. It does share the transport, so this subclasses the
|
|
14
|
+
# publisher to reuse its connection pool, fork safety, and channel-name
|
|
15
|
+
# derivation instead of opening a second pool for one NOTIFY.
|
|
16
|
+
#
|
|
17
|
+
# ## Channel derivation
|
|
18
|
+
#
|
|
19
|
+
# `orbitcast_` + the first 48 hex characters of SHA-256 over the
|
|
20
|
+
# length-prefixed `(namespace, CONTROL_STREAM)` tuple — the same function
|
|
21
|
+
# every other channel name comes from, with a reserved stream name. A
|
|
22
|
+
# control message for one namespace is therefore invisible to another
|
|
23
|
+
# deployment's listener, and the reserved name cannot collide with an
|
|
24
|
+
# application stream unless the application deliberately publishes to
|
|
25
|
+
# `__orbitcast_control__`.
|
|
26
|
+
#
|
|
27
|
+
# Relay side: the listener session holds a permanent `LISTEN` on this
|
|
28
|
+
# channel (`Backend::spawn_listener` in `src/server.rs`), and the same
|
|
29
|
+
# messages are also accepted over `POST /v1/control` on the private ingress
|
|
30
|
+
# (`src/transport/ingress.rs`). The envelope is pinned by
|
|
31
|
+
# `tests/fixtures/control/*.json` for both languages.
|
|
32
|
+
class Control < Postgres
|
|
33
|
+
# Reserved stream name. Never a browser-visible stream.
|
|
34
|
+
CONTROL_STREAM = "__orbitcast_control__"
|
|
35
|
+
|
|
36
|
+
def self.channel_for(namespace)
|
|
37
|
+
channel_name(namespace, CONTROL_STREAM)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param message [Orbitcast::Protocol::V1::ControlMessage]
|
|
41
|
+
# @return [String] the control UUID, for deduplication in logs
|
|
42
|
+
def publish_control(message)
|
|
43
|
+
message.validate!
|
|
44
|
+
|
|
45
|
+
unless message.namespace == configuration.namespace
|
|
46
|
+
raise ArgumentError,
|
|
47
|
+
"control message namespace #{message.namespace.inspect} does not match the " \
|
|
48
|
+
"configured namespace #{configuration.namespace.inspect}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
wire = message.to_json
|
|
52
|
+
if wire.bytesize >= MAX_NOTIFY_BYTES
|
|
53
|
+
raise PayloadTooLarge,
|
|
54
|
+
"control message is #{wire.bytesize} bytes; PostgreSQL NOTIFY payloads must be " \
|
|
55
|
+
"under #{MAX_NOTIFY_BYTES} bytes"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
channel = self.class.channel_for(configuration.namespace)
|
|
59
|
+
# `@pool` is the parent's bounded, fork-safe pool. Reused deliberately:
|
|
60
|
+
# one process, one set of publisher connections.
|
|
61
|
+
@pool.with { |connection| connection.exec_params(NOTIFY_SQL, [channel, wire]) }
|
|
62
|
+
|
|
63
|
+
message.control_id
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require_relative "postgres"
|
|
9
|
+
require_relative "instrumentation"
|
|
10
|
+
require_relative "../error"
|
|
11
|
+
require_relative "../protocol"
|
|
12
|
+
require_relative "../streams"
|
|
13
|
+
|
|
14
|
+
module Orbitcast
|
|
15
|
+
module Publisher
|
|
16
|
+
# Private HTTP publication ingress: the alternative publication path to
|
|
17
|
+
# PostgreSQL.
|
|
18
|
+
#
|
|
19
|
+
# config.orbitcast.publish_via = :http
|
|
20
|
+
# config.orbitcast.ingress_url = "http://127.0.0.1:28081/v1/publications"
|
|
21
|
+
# config.orbitcast.ingress_token = Rails.application.credentials.dig(:orbitcast, :ingress_token)
|
|
22
|
+
#
|
|
23
|
+
# Chosen when the database credentials must stay on the Rust side, or in a
|
|
24
|
+
# memory-only build where there is no PostgreSQL to publish through. Rails
|
|
25
|
+
# publishes through *one* path: never both this and PostgreSQL for the same
|
|
26
|
+
# broadcast.
|
|
27
|
+
#
|
|
28
|
+
# ## Wire contract
|
|
29
|
+
#
|
|
30
|
+
# POST {ingress_url}
|
|
31
|
+
# Authorization: Bearer <ingress_token>
|
|
32
|
+
# Content-Type: application/json
|
|
33
|
+
#
|
|
34
|
+
# {"version":1,"namespace":"...","id":"<uuid>","stream":"...",
|
|
35
|
+
# "json_payload":"<exact ActionCable-encoded JSON text>"}
|
|
36
|
+
#
|
|
37
|
+
# which is the v1 `Publication` envelope from `src/protocol/v1/publication.rs`,
|
|
38
|
+
# byte for byte. A `202` means the backend accepted and committed the
|
|
39
|
+
# publication, not that a browser saw it.
|
|
40
|
+
#
|
|
41
|
+
# Relay side: `POST /v1/publications` on the private ingress listener
|
|
42
|
+
# (`src/transport/ingress.rs`), which answers `202` with the `IngressAck`
|
|
43
|
+
# envelope from `src/protocol/v1/publication.rs`. The ack is pinned by
|
|
44
|
+
# `tests/fixtures/ingress/ack.json` for both languages; this client treats
|
|
45
|
+
# the status code as the contract and does not require the body.
|
|
46
|
+
#
|
|
47
|
+
# ## Failure policy
|
|
48
|
+
#
|
|
49
|
+
# No retries. A publication that timed out may or may not have been
|
|
50
|
+
# accepted, and replaying it would duplicate a browser-visible message;
|
|
51
|
+
# durable cross-node deduplication belongs to the broker phase.
|
|
52
|
+
class Http
|
|
53
|
+
DEFAULT_OPEN_TIMEOUT = 2.0
|
|
54
|
+
DEFAULT_READ_TIMEOUT = 5.0
|
|
55
|
+
|
|
56
|
+
ACCEPTED_STATUSES = %w[200 201 202].freeze
|
|
57
|
+
|
|
58
|
+
# @param instrumenter [#instrument] defaults to
|
|
59
|
+
# `ActiveSupport::Notifications` when loaded; see
|
|
60
|
+
# `Orbitcast::Publisher::Instrumentation`
|
|
61
|
+
def initialize(configuration = Orbitcast.config, connection_factory: nil, instrumenter: nil)
|
|
62
|
+
@configuration = configuration.validate!
|
|
63
|
+
@instrumenter = Instrumentation.resolve(instrumenter)
|
|
64
|
+
@uri = URI.parse(configuration.ingress_url.to_s)
|
|
65
|
+
@pool = Postgres::Pool.new(
|
|
66
|
+
size: configuration.pool_size,
|
|
67
|
+
timeout: configuration.pool_timeout,
|
|
68
|
+
&(connection_factory || method(:connect))
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
attr_reader :configuration
|
|
73
|
+
|
|
74
|
+
# @param stream [String] logical stream name
|
|
75
|
+
# @param payload [String] JSON text already encoded by ActionCable
|
|
76
|
+
# @return [String] publication UUID
|
|
77
|
+
def publish(stream, payload)
|
|
78
|
+
# Both guards run before anything is instrumented or sent: an invalid
|
|
79
|
+
# stream (empty, over 1024 UTF-8 bytes) or a non-String payload is a
|
|
80
|
+
# caller error, not a publication that failed.
|
|
81
|
+
stream = Streams.validated_stream(stream)
|
|
82
|
+
payload = validated_payload(payload)
|
|
83
|
+
|
|
84
|
+
Instrumentation.publish(@instrumenter, stream: stream, bytes: payload.bytesize, via: :http) do
|
|
85
|
+
if payload.bytesize > configuration.max_publication_size
|
|
86
|
+
raise PayloadTooLarge,
|
|
87
|
+
"publication payload is #{payload.bytesize} bytes; the configured maximum is " \
|
|
88
|
+
"#{configuration.max_publication_size} bytes (max_publication_size)"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
publication_id = SecureRandom.uuid
|
|
92
|
+
body = JSON.generate(
|
|
93
|
+
version: Protocol::V1::VERSION,
|
|
94
|
+
namespace: configuration.namespace,
|
|
95
|
+
id: publication_id,
|
|
96
|
+
stream: stream,
|
|
97
|
+
json_payload: payload
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# `Pool#with` returns the checkin, not the block: capture the response.
|
|
101
|
+
response = nil
|
|
102
|
+
@pool.with { |connection| response = connection.post(@uri, body, bearer) }
|
|
103
|
+
verify!(response)
|
|
104
|
+
|
|
105
|
+
publication_id
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def shutdown
|
|
110
|
+
@pool.shutdown
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def bearer
|
|
116
|
+
token = configuration.ingress_token
|
|
117
|
+
raise ConfigurationError, "ingress_token is required when publish_via is :http" if token.nil?
|
|
118
|
+
|
|
119
|
+
"Bearer #{token}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def verify!(response)
|
|
123
|
+
return if ACCEPTED_STATUSES.include?(response.code.to_s)
|
|
124
|
+
|
|
125
|
+
case response.code.to_s
|
|
126
|
+
when "401", "403"
|
|
127
|
+
raise IngressError, "publication ingress refused the service credential (#{response.code})"
|
|
128
|
+
when "413"
|
|
129
|
+
raise PayloadTooLarge, "publication ingress refused the payload as too large (413)"
|
|
130
|
+
else
|
|
131
|
+
raise IngressError, "publication ingress returned HTTP #{response.code}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def validated_payload(payload)
|
|
136
|
+
unless payload.is_a?(String)
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"payload must be the JSON text encoded by ActionCable, got #{payload.class}"
|
|
139
|
+
end
|
|
140
|
+
raise ArgumentError, "payload is not valid #{payload.encoding}" unless payload.valid_encoding?
|
|
141
|
+
|
|
142
|
+
payload.encoding == Encoding::UTF_8 ? payload : payload.encode(Encoding::UTF_8)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def connect
|
|
146
|
+
Connection.new(@uri, configuration)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# One keep-alive HTTP connection, shaped like the pooled objects the
|
|
150
|
+
# PostgreSQL publisher hands out (`#close`), so both publishers share the
|
|
151
|
+
# same bounded, fork-safe pool implementation.
|
|
152
|
+
class Connection
|
|
153
|
+
def initialize(uri, _configuration)
|
|
154
|
+
@http = Net::HTTP.new(uri.host, uri.port)
|
|
155
|
+
@http.use_ssl = uri.scheme == "https"
|
|
156
|
+
@http.open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
157
|
+
@http.read_timeout = DEFAULT_READ_TIMEOUT
|
|
158
|
+
@http.keep_alive_timeout = 30
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def post(uri, body, authorization)
|
|
162
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
163
|
+
request["content-type"] = "application/json"
|
|
164
|
+
request["accept"] = "application/json"
|
|
165
|
+
request["authorization"] = authorization
|
|
166
|
+
request.body = body
|
|
167
|
+
|
|
168
|
+
@http.start unless @http.started?
|
|
169
|
+
@http.request(request)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def close
|
|
173
|
+
@http.finish if @http.started?
|
|
174
|
+
rescue IOError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Orbitcast
|
|
4
|
+
module Publisher
|
|
5
|
+
# `ActiveSupport::Notifications` for publishers, without requiring it.
|
|
6
|
+
#
|
|
7
|
+
# Every publish, on every transport, emits one `publish.orbitcast` event
|
|
8
|
+
# with `stream:`, `bytes:` (the encoded payload size) and `via:`
|
|
9
|
+
# (`:postgres` or `:http`). A publish that raises still emits the event —
|
|
10
|
+
# `ActiveSupport::Notifications` adds `:exception` / `:exception_object`
|
|
11
|
+
# to the payload and the error propagates unchanged — so a subscriber sees
|
|
12
|
+
# the failure path, never a silent drop:
|
|
13
|
+
#
|
|
14
|
+
# ActiveSupport::Notifications.subscribe("publish.orbitcast") do |event|
|
|
15
|
+
# StatsD.timing("orbitcast.publish", event.duration, tags: ["via:#{event.payload[:via]}"])
|
|
16
|
+
# Rails.error.report(event.payload[:exception_object]) if event.payload[:exception_object]
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# The plain-ruby unit suite has no ActiveSupport; it gets a null
|
|
20
|
+
# instrumenter (or injects a recording one).
|
|
21
|
+
module Instrumentation
|
|
22
|
+
EVENT = "publish.orbitcast"
|
|
23
|
+
|
|
24
|
+
# Yields without recording. Same contract as
|
|
25
|
+
# `ActiveSupport::Notifications.instrument`: the block's value is
|
|
26
|
+
# returned, an exception propagates.
|
|
27
|
+
module Null
|
|
28
|
+
def self.instrument(_name, payload = {})
|
|
29
|
+
yield payload
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
def resolve(instrumenter)
|
|
35
|
+
return instrumenter if instrumenter
|
|
36
|
+
return ::ActiveSupport::Notifications if defined?(::ActiveSupport::Notifications)
|
|
37
|
+
|
|
38
|
+
Null
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def publish(instrumenter, stream:, bytes:, via:, &block)
|
|
42
|
+
instrumenter.instrument(EVENT, { stream: stream, bytes: bytes, via: via }, &block)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|