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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 77755534870e43df67c1865573535b300c890a83f83aa1ebe146fd3b5b7dc917
|
|
4
|
+
data.tar.gz: 650a4513419e86681323b2c34aeef2b7110d9a9506af80c1014d7b094757664c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bd6c59891c305cad7f2df1e6358b4f3211ff1ce22381e48bf924d7c5d9762f0f2a79626a5ff1f2358a0b4bbab68f177b8a34ac94540772879ada1f83169a0c0f
|
|
7
|
+
data.tar.gz: e2e704b542d6555b3b0e8eb10d6552b815ea505d3aacaa416792bd132d4242f61898e9712469fe1eff140edf26d823c263cc24d1c92a831190a657c097a4214b
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, Abdelkader Boudih
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# orbitcast (Ruby gem)
|
|
2
|
+
|
|
3
|
+
Rails integration for the Orbitcast relay. The gem ships the Ruby side plus one
|
|
4
|
+
native executable per platform; the relay itself is a separately supervised Rust
|
|
5
|
+
process.
|
|
6
|
+
|
|
7
|
+
Two modes. In `mode: :signed` (default) the relay authorizes subscriptions
|
|
8
|
+
from signed stream names and no application Ruby runs on the socket path.
|
|
9
|
+
In `mode: :dispatch` the relay calls back into Rails over a private HTTP
|
|
10
|
+
endpoint, and `ApplicationCable::Connection#connect`, channel callbacks,
|
|
11
|
+
and `perform` actions run here as ordinary Rails code.
|
|
12
|
+
|
|
13
|
+
## What is in here
|
|
14
|
+
|
|
15
|
+
| Path | Role |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `exe/orbitcast` | wrapper that execs the vendored binary |
|
|
18
|
+
| `lib/orbitcast/launcher.rb` | argv/env handling, single Rails boot for `serve` |
|
|
19
|
+
| `lib/orbitcast/binary.rb` | vendored binary resolution, `ORBITCAST_BINARY` override |
|
|
20
|
+
| `lib/orbitcast/configuration.rb` | validated settings |
|
|
21
|
+
| `lib/orbitcast/streams.rb` | `Orbitcast.signed_stream_name` |
|
|
22
|
+
| `lib/orbitcast/recovery.rb` | `Orbitcast.recovery_token` (opt-in retained replay) |
|
|
23
|
+
| `lib/orbitcast/publisher/postgres.rb` | pooled `pg_notify` publisher |
|
|
24
|
+
| `lib/orbitcast/publisher/http.rb` | private-ingress publisher (`publish_via: :http`) |
|
|
25
|
+
| `lib/orbitcast/publisher/instrumentation.rb` | `publish.orbitcast` notifications for both publishers |
|
|
26
|
+
| `lib/orbitcast/publisher/control.rb` | control-message publisher (`Orbitcast.disconnect`) |
|
|
27
|
+
| `lib/action_cable/subscription_adapter/orbitcast.rb` | publish-only cable adapter |
|
|
28
|
+
| `lib/orbitcast/connection.rb`, `lib/orbitcast/channel.rb` | dispatch-mode `ApplicationCable` bases |
|
|
29
|
+
| `lib/orbitcast/rails_endpoint.rb` | the private Rack endpoint the relay calls (dispatch mode) |
|
|
30
|
+
| `lib/orbitcast/engine.rb` | `config.orbitcast.*`, `db/orbitcast_migrate` migration path, derived stream key, mounts the RPC endpoint in dispatch mode; never launches the relay |
|
|
31
|
+
| `lib/generators/orbitcast/install/` | `bin/rails generate orbitcast:install` |
|
|
32
|
+
| `db/orbitcast_migrate/` | overflow, presence, and broker migrations |
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bin/rails generate orbitcast:install # or --overflow
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The installer applies, idempotently: `config/cable.yml` (`adapter: orbitcast`),
|
|
41
|
+
`config/initializers/orbitcast.rb`, the four cable lines in
|
|
42
|
+
`config/environments/{development,production}.rb` (`mount_path = nil`, `url`,
|
|
43
|
+
`allowed_request_origins`, `config.orbitcast.mode = :signed` — existing values
|
|
44
|
+
kept), `action_cable_meta_tag` in the layout `<head>`, and a `cable:` entry in
|
|
45
|
+
`Procfile.dev`. Importmap pins and Solid Queue/Cache settings are untouched.
|
|
46
|
+
`--overflow` also copies the overflow migration (picked up by `bin/rails
|
|
47
|
+
db:migrate` through the engine) and sets `overflow_enabled: true`.
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
# config/cable.yml
|
|
51
|
+
production:
|
|
52
|
+
adapter: orbitcast
|
|
53
|
+
database_url: <%= ENV.fetch("ORBITCAST_DATABASE_URL") %>
|
|
54
|
+
namespace: "example_production"
|
|
55
|
+
publish_via: postgres # or http (ingress_url + ingress_token)
|
|
56
|
+
connection: pool # or active_record: join the app transaction
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
# Procfile.dev
|
|
61
|
+
cable: bundle exec orbitcast serve
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`bundle exec orbitcast --version` and `--help` run the binary directly, without
|
|
65
|
+
booting Rails. `serve` (also a bare `orbitcast`, flags understood) inside an
|
|
66
|
+
application boots Rails once, exports an allowlisted `ORBITCAST_*` environment
|
|
67
|
+
(secrets as strict base64, never argv; `RAILS_MASTER_KEY`/`SECRET_KEY_BASE`
|
|
68
|
+
never), and replaces the Ruby process with the relay. The listener defaults to
|
|
69
|
+
`127.0.0.1:28080` in development/test and must be configured elsewhere
|
|
70
|
+
(`listen_addr` or `ORBITCAST_LISTEN_ADDR`).
|
|
71
|
+
|
|
72
|
+
Rails-side keys beyond the basics: `listener_database_url`, `listen_addr`,
|
|
73
|
+
`trusted_proxies`, `outbound_queue_messages/bytes`,
|
|
74
|
+
`streams_previous_secrets`, `turbo_previous_secrets`, `connection`. Without an
|
|
75
|
+
explicit `streams_secret` the stream key is derived from
|
|
76
|
+
`Rails.application.key_generator` under `orbitcast/streams/v1/<namespace>`.
|
|
77
|
+
Every publish emits `publish.orbitcast` through `ActiveSupport::Notifications`
|
|
78
|
+
(`stream:`, `bytes:`, `via:`), failures included.
|
|
79
|
+
|
|
80
|
+
## Platforms
|
|
81
|
+
|
|
82
|
+
`arm64-darwin`, `x86_64-darwin`, `x86_64-linux-gnu`, `aarch64-linux-gnu`,
|
|
83
|
+
`x86_64-linux-musl`. On anything else, build the crate and point
|
|
84
|
+
`ORBITCAST_BINARY` at the result; the gem never downloads or compiles a binary.
|
|
85
|
+
An override is checked with `<binary> doctor --json` before `serve` uses it:
|
|
86
|
+
the reported `version` must equal the gem's, `features` must include
|
|
87
|
+
`postgres` when publishing through PostgreSQL, and `envelope_versions` must
|
|
88
|
+
include `1` — otherwise `Orbitcast::BinaryError` says exactly what is wrong.
|
|
89
|
+
|
|
90
|
+
## Tests
|
|
91
|
+
|
|
92
|
+
No database and no Rails boot required:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
ruby -Ilib -Itest test/configuration_test.rb # etc.
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
BSD-3-Clause. See `LICENSE.txt`.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Orbitcast recovery helper.
|
|
2
|
+
//
|
|
3
|
+
// A plain ES module, importmap-ready: no build step, no bundler, no transpile.
|
|
4
|
+
// It uses only public `@rails/actioncable` APIs and is *never* injected into
|
|
5
|
+
// ordinary Turbo or ActionCable subscriptions - importing this file is the
|
|
6
|
+
// entire opt-in.
|
|
7
|
+
//
|
|
8
|
+
// import { createRecovery } from "orbitcast/recovery"
|
|
9
|
+
//
|
|
10
|
+
// const recovery = createRecovery(consumer, {
|
|
11
|
+
// token: document.querySelector("meta[name=orbitcast-recovery]").content,
|
|
12
|
+
// onMessage: ({ stream, payload, publicationId }) => render(stream, payload),
|
|
13
|
+
// onGap: ({ stream, reason }) => location.reload()
|
|
14
|
+
// })
|
|
15
|
+
//
|
|
16
|
+
// What it does:
|
|
17
|
+
// 1. subscribes to `Orbitcast::RecoveryChannel` with a Rails-minted token,
|
|
18
|
+
// 2. sends `resume` with the cursors it holds, on connect and on every
|
|
19
|
+
// reconnect,
|
|
20
|
+
// 3. tracks the cursor of every delivery it receives,
|
|
21
|
+
// 4. acknowledges receipt periodically (best effort),
|
|
22
|
+
// 5. reports gaps so the application can resynchronize.
|
|
23
|
+
//
|
|
24
|
+
// Delivery semantics - read this before relying on it:
|
|
25
|
+
//
|
|
26
|
+
// * **At-least-once, within retention.** The same publication can arrive
|
|
27
|
+
// twice across a replay-to-live handoff. Deduplicate on `publicationId`;
|
|
28
|
+
// this helper already drops repeats inside a bounded window, but an
|
|
29
|
+
// application that must be exactly-once needs its own idempotency.
|
|
30
|
+
// * **An ack is a receipt, not proof of effect.** It says bytes arrived, not
|
|
31
|
+
// that the UI applied them. It never gates delivery.
|
|
32
|
+
// * **A gap is real.** `onGap` means history the server cannot reconstruct:
|
|
33
|
+
// it expired, it was trimmed, the stream's generation was wiped, another
|
|
34
|
+
// tab won the fencing race, or live traffic outran the replay buffer. The
|
|
35
|
+
// only correct response is an application-level resync (refetch the page,
|
|
36
|
+
// the list, the document), never "ignore it".
|
|
37
|
+
// * **Cursors are opaque.** Store and echo them; never parse or compare them.
|
|
38
|
+
// * **Subscribe first, recover last.** The relay replays a stream only while
|
|
39
|
+
// this connection holds a confirmed subscription to it (the token alone is
|
|
40
|
+
// never enough), and it checks on every `resume`. Create the application
|
|
41
|
+
// subscriptions before calling `createRecovery`; ActionCable confirms
|
|
42
|
+
// subscriptions in creation order, on connect and on every reconnect, so
|
|
43
|
+
// the order you create them in is the order the relay sees. A stream the
|
|
44
|
+
// connection is not subscribed to arrives as an `unauthorized` gap.
|
|
45
|
+
// * **No cursors means "where I left off".** A `resume` sent without any
|
|
46
|
+
// cursors (a fresh page load that did not persist them) continues from the
|
|
47
|
+
// last position this session acknowledged, as long as the relay still has
|
|
48
|
+
// the session; otherwise it starts live. Persist `cursors()` to be exact.
|
|
49
|
+
|
|
50
|
+
const CHANNEL = "Orbitcast::RecoveryChannel";
|
|
51
|
+
const ACTION = "orbitcast_recovery";
|
|
52
|
+
const DELIVERY_TYPE = "orbitcast_delivery";
|
|
53
|
+
const CONTROL_TYPE = "orbitcast_recovery";
|
|
54
|
+
const DEFAULT_ACK_INTERVAL_MS = 5000;
|
|
55
|
+
const DEDUP_WINDOW = 2000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Subscribe to Orbitcast recovery on an existing ActionCable consumer.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} consumer - a `@rails/actioncable` consumer
|
|
61
|
+
* @param {object} options
|
|
62
|
+
* @param {string} options.token - the Rails-minted recovery token
|
|
63
|
+
* @param {function} [options.onMessage] - ({stream, payload, publicationId, cursor})
|
|
64
|
+
* @param {function} [options.onGap] - ({stream, reason})
|
|
65
|
+
* @param {function} [options.onReplayStart] - ({streams})
|
|
66
|
+
* @param {function} [options.onReplayDone] - ({cursors})
|
|
67
|
+
* @param {object} [options.cursors] - previously persisted {stream: cursor}
|
|
68
|
+
* @param {number} [options.ackIntervalMs] - acknowledgement cadence
|
|
69
|
+
* @returns {{subscription: object, cursors: function, unsubscribe: function}}
|
|
70
|
+
*/
|
|
71
|
+
export function createRecovery(consumer, options = {}) {
|
|
72
|
+
const { token, cursors: initialCursors = {}, ackIntervalMs = DEFAULT_ACK_INTERVAL_MS } = options;
|
|
73
|
+
if (!token) throw new Error("orbitcast/recovery: a recovery token is required");
|
|
74
|
+
|
|
75
|
+
const cursors = { ...initialCursors };
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
const order = [];
|
|
78
|
+
let ackTimer = null;
|
|
79
|
+
let acked = null;
|
|
80
|
+
|
|
81
|
+
const call = (name, argument) => {
|
|
82
|
+
const handler = options[name];
|
|
83
|
+
if (typeof handler === "function") handler(argument);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Bounded deduplication: the same publication may legitimately be delivered
|
|
87
|
+
// twice across a replay-to-live handoff.
|
|
88
|
+
const isNew = (publicationId) => {
|
|
89
|
+
if (!publicationId) return true;
|
|
90
|
+
if (seen.has(publicationId)) return false;
|
|
91
|
+
seen.add(publicationId);
|
|
92
|
+
order.push(publicationId);
|
|
93
|
+
if (order.length > DEDUP_WINDOW) seen.delete(order.shift());
|
|
94
|
+
return true;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const subscription = consumer.subscriptions.create({ channel: CHANNEL, token }, {
|
|
98
|
+
// Called on connect *and* on every reconnect: resuming is what turns a
|
|
99
|
+
// reconnect into "continue from my cursor" instead of "start from now".
|
|
100
|
+
connected() {
|
|
101
|
+
send("resume");
|
|
102
|
+
if (ackIntervalMs > 0 && ackTimer === null) {
|
|
103
|
+
ackTimer = setInterval(() => acknowledge(), ackIntervalMs);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
disconnected() {
|
|
108
|
+
if (ackTimer !== null) {
|
|
109
|
+
clearInterval(ackTimer);
|
|
110
|
+
ackTimer = null;
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
received(message) {
|
|
115
|
+
if (!message || typeof message !== "object") return;
|
|
116
|
+
|
|
117
|
+
if (message.type === DELIVERY_TYPE) {
|
|
118
|
+
// The cursor is opaque: store it, never parse it.
|
|
119
|
+
cursors[message.stream] = message.cursor;
|
|
120
|
+
if (!isNew(message.publication_id)) return;
|
|
121
|
+
|
|
122
|
+
let payload = null;
|
|
123
|
+
try {
|
|
124
|
+
// The payload travels as exact JSON *text*, so float and unicode
|
|
125
|
+
// escaping survive the whole path unchanged.
|
|
126
|
+
payload = JSON.parse(message.json_payload);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
call("onGap", { stream: message.stream, reason: "undecodable_payload" });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
call("onMessage", {
|
|
133
|
+
stream: message.stream,
|
|
134
|
+
payload,
|
|
135
|
+
publicationId: message.publication_id,
|
|
136
|
+
sequence: message.sequence, // a decimal string: JS numbers are too small
|
|
137
|
+
generation: message.generation,
|
|
138
|
+
cursor: message.cursor
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (message.type !== CONTROL_TYPE) return;
|
|
144
|
+
|
|
145
|
+
switch (message.event) {
|
|
146
|
+
case "replay_start":
|
|
147
|
+
call("onReplayStart", { streams: message.streams || [] });
|
|
148
|
+
break;
|
|
149
|
+
case "replay_done":
|
|
150
|
+
call("onReplayDone", { cursors: { ...cursors } });
|
|
151
|
+
break;
|
|
152
|
+
case "gap":
|
|
153
|
+
// History the server cannot reconstruct. Resynchronize; the cursor
|
|
154
|
+
// is kept so live delivery continues from the current edge.
|
|
155
|
+
call("onGap", { stream: message.stream, reason: message.reason });
|
|
156
|
+
break;
|
|
157
|
+
default:
|
|
158
|
+
break; // An unknown event is never treated as success.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Receipt only. Sent on a timer, skipped when nothing moved, and never
|
|
164
|
+
// awaited: an unrecorded ack costs a longer replay, never a delivery.
|
|
165
|
+
function acknowledge() {
|
|
166
|
+
const encoded = JSON.stringify(cursors);
|
|
167
|
+
if (encoded === acked) return;
|
|
168
|
+
acked = encoded;
|
|
169
|
+
send("ack");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// `subscription.perform(action, data)` is ActionCable's client API, and it
|
|
173
|
+
// writes `data.action = action` before sending — so a call shaped
|
|
174
|
+
// `perform("message", { action: ACTION, ... })` reaches the server as
|
|
175
|
+
// `{"action":"message", ...}` and the relay never sees `orbitcast_recovery`.
|
|
176
|
+
// The action name must therefore be the first argument; the remaining
|
|
177
|
+
// fields are the v1 recovery command (`version`, `op`, `cursors`).
|
|
178
|
+
function send(op) {
|
|
179
|
+
subscription.perform(ACTION, { version: 1, op, cursors: { ...cursors } });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
subscription,
|
|
184
|
+
/** The cursors held right now; persist them to resume across page loads. */
|
|
185
|
+
cursors: () => ({ ...cursors }),
|
|
186
|
+
/** Force an acknowledgement (for example before unload). */
|
|
187
|
+
acknowledge,
|
|
188
|
+
unsubscribe() {
|
|
189
|
+
if (ackTimer !== null) clearInterval(ackTimer);
|
|
190
|
+
ackTimer = null;
|
|
191
|
+
subscription.unsubscribe();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export default createRecovery;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Overflow relay table for publications too large for a PostgreSQL NOTIFY
|
|
4
|
+
# payload (8000 bytes, envelope and escaping included).
|
|
5
|
+
#
|
|
6
|
+
# Contract shared with the Rust server (`src/pubsub/postgres.rs`); the column
|
|
7
|
+
# names and types below are wire contract, not an implementation detail:
|
|
8
|
+
#
|
|
9
|
+
# orbitcast_payloads(id uuid primary key, namespace text not null,
|
|
10
|
+
# stream text not null, json_payload text not null,
|
|
11
|
+
# expires_at timestamptz not null)
|
|
12
|
+
#
|
|
13
|
+
# The publisher inserts one row and emits its `kind: "overflow"` reference in
|
|
14
|
+
# the *same transaction*, so no node can observe a reference to a row it cannot
|
|
15
|
+
# read. Every listening node fetches the row from the same primary with a fresh
|
|
16
|
+
# READ COMMITTED snapshot; nobody deletes it after their own delivery. Expiry
|
|
17
|
+
# cleanup belongs to the Rust server, which is why `expires_at` is indexed.
|
|
18
|
+
#
|
|
19
|
+
# This is a relay, not durable history: it is not replay, not offline delivery,
|
|
20
|
+
# and not a guarantee of delivery during a listener outage. Install it in the
|
|
21
|
+
# cable database (which may be separate from the primary application database).
|
|
22
|
+
class CreateOrbitcastPayloads < ActiveRecord::Migration[8.1]
|
|
23
|
+
def change
|
|
24
|
+
create_table :orbitcast_payloads, id: false do |t|
|
|
25
|
+
# `default: nil`: the id is always the publication UUID supplied by the
|
|
26
|
+
# publisher, so no gen_random_uuid()/pgcrypto default is needed.
|
|
27
|
+
t.primary_key :id, :uuid, default: nil
|
|
28
|
+
t.text :namespace, null: false
|
|
29
|
+
t.text :stream, null: false
|
|
30
|
+
t.text :json_payload, null: false
|
|
31
|
+
# Explicitly timestamptz rather than `t.datetime`: expiry is compared
|
|
32
|
+
# against the database clock (`now()`), never an application clock, and
|
|
33
|
+
# must not depend on the app's `datetime_type` default.
|
|
34
|
+
t.column :expires_at, :timestamptz, null: false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
add_index :orbitcast_payloads, :expires_at
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Presence leases for authorized, distributed presence.
|
|
4
|
+
#
|
|
5
|
+
# Contract shared with the Rust server (`src/presence/postgres.rs`); the column
|
|
6
|
+
# names and types below are wire contract, not an implementation detail:
|
|
7
|
+
#
|
|
8
|
+
# orbitcast_presence(namespace text not null, stream text not null,
|
|
9
|
+
# subject_id text not null, session_id text not null,
|
|
10
|
+
# node_id text not null, metadata text not null,
|
|
11
|
+
# expires_at timestamptz not null,
|
|
12
|
+
# primary key (namespace, stream, subject_id, session_id))
|
|
13
|
+
#
|
|
14
|
+
# One row is one *session's* lease: a subject with three tabs holds three rows,
|
|
15
|
+
# so one tab's refresh can never preserve another tab's lease. Clients see the
|
|
16
|
+
# aggregate - the first live lease announces a join, the last one to go
|
|
17
|
+
# announces a leave - and that decision is serialized per (namespace, stream,
|
|
18
|
+
# subject_id) with a PostgreSQL advisory transaction lock.
|
|
19
|
+
#
|
|
20
|
+
# `metadata` is text, not jsonb: it holds the exact JSON the Rails
|
|
21
|
+
# `presence_grant` effect produced, byte for byte, and is never queried into.
|
|
22
|
+
# The identity in `subject_id` and everything in `metadata` are *public to the
|
|
23
|
+
# stream's granted subscribers* - never put a session token, an email, or any
|
|
24
|
+
# other sensitive connection attribute there.
|
|
25
|
+
#
|
|
26
|
+
# Rows are removed by whichever node sweeps first; `expires_at` is compared
|
|
27
|
+
# against the database clock (`now()`), never an application clock, which is
|
|
28
|
+
# what bounds staleness when a node dies without releasing its leases. Presence
|
|
29
|
+
# is eventually consistent and is not an authorization database.
|
|
30
|
+
#
|
|
31
|
+
# Install it in the cable database (which may be separate from the primary
|
|
32
|
+
# application database). Only needed for Rails-dispatch deployments that grant
|
|
33
|
+
# presence; a signed-only deployment never writes here.
|
|
34
|
+
class CreateOrbitcastPresence < ActiveRecord::Migration[8.1]
|
|
35
|
+
def change
|
|
36
|
+
create_table :orbitcast_presence, id: false, primary_key: %i[namespace stream subject_id session_id] do |t|
|
|
37
|
+
t.text :namespace, null: false
|
|
38
|
+
t.text :stream, null: false
|
|
39
|
+
t.text :subject_id, null: false
|
|
40
|
+
t.text :session_id, null: false
|
|
41
|
+
t.text :node_id, null: false
|
|
42
|
+
t.text :metadata, null: false
|
|
43
|
+
# Explicitly timestamptz rather than `t.datetime`: expiry is compared
|
|
44
|
+
# against the database clock and must not depend on the app's
|
|
45
|
+
# `datetime_type` default.
|
|
46
|
+
t.column :expires_at, :timestamptz, null: false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The sweeper's scan: expired rows first, bounded per statement.
|
|
50
|
+
add_index :orbitcast_presence, :expires_at
|
|
51
|
+
# Roster reads: snapshots and the per-subject counts a transition takes.
|
|
52
|
+
add_index :orbitcast_presence, %i[namespace stream]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Retained publications, per-stream ordering, and recovery sessions.
|
|
4
|
+
#
|
|
5
|
+
# Contract shared with the Rust server (`src/broker/postgres.rs`); the column
|
|
6
|
+
# names and types below are wire contract, not an implementation detail:
|
|
7
|
+
#
|
|
8
|
+
# orbitcast_messages(namespace text, stream text, generation bigint,
|
|
9
|
+
# seq bigint, publication_id uuid, json_payload text,
|
|
10
|
+
# created_at timestamptz, expires_at timestamptz,
|
|
11
|
+
# primary key (namespace, stream, generation, seq))
|
|
12
|
+
# orbitcast_stream_positions(namespace text, stream text, generation bigint,
|
|
13
|
+
# next_seq bigint, updated_at timestamptz,
|
|
14
|
+
# primary key (namespace, stream))
|
|
15
|
+
# orbitcast_sessions(namespace text, session_token_hash text, snapshot text,
|
|
16
|
+
# fencing_generation bigint, expires_at timestamptz,
|
|
17
|
+
# primary key (namespace, session_token_hash))
|
|
18
|
+
#
|
|
19
|
+
# Ordering. `seq` is assigned inside the transaction that inserts the message,
|
|
20
|
+
# while holding `SELECT ... FOR UPDATE` on that stream's
|
|
21
|
+
# `orbitcast_stream_positions` row. The lock is held until commit, so sequence
|
|
22
|
+
# order *is* commit order and a reader can treat "seq > last_delivered" as
|
|
23
|
+
# "everything I have not seen". A global BIGSERIAL cannot do this: two
|
|
24
|
+
# transactions can take values in one order and commit in the other, which
|
|
25
|
+
# would let a reader skip history permanently. There is deliberately no
|
|
26
|
+
# sequence, no serial column, and no ordering by `created_at`.
|
|
27
|
+
#
|
|
28
|
+
# Generations. `generation` is bumped when retention wipes a stream's history,
|
|
29
|
+
# so an old delivery cursor is recognizably stale instead of silently matching
|
|
30
|
+
# sequence numbers from a new era.
|
|
31
|
+
#
|
|
32
|
+
# Deduplication. The unique index on (namespace, stream, publication_id) makes a
|
|
33
|
+
# repeat publication id a no-op within the retention window: first write wins,
|
|
34
|
+
# and the conflict is counted and warned by the server. After expiry the id is
|
|
35
|
+
# forgotten and may be stored again.
|
|
36
|
+
#
|
|
37
|
+
# Sessions. `session_token_hash` is the SHA-256 hex digest of the recovery
|
|
38
|
+
# token - never the token itself - so a database leak cannot be replayed as a
|
|
39
|
+
# capability. `snapshot` is versioned JSON holding delivery cursors and an
|
|
40
|
+
# identity *reference*: never browser cookies, never Ruby Marshal data, never
|
|
41
|
+
# sensitive connection attributes. `fencing_generation` is bumped atomically by
|
|
42
|
+
# every resume, so two nodes can never restore one session at once.
|
|
43
|
+
#
|
|
44
|
+
# Retention is bounded by time (`expires_at`, swept in batches), by message
|
|
45
|
+
# count per stream (enforced in the publish transaction), and by bytes per
|
|
46
|
+
# stream (swept). Expiry cleanup belongs to the Rust server, which is why both
|
|
47
|
+
# `expires_at` columns are indexed. Install in the cable database (which may be
|
|
48
|
+
# separate from the primary application database); only needed when
|
|
49
|
+
# `broker_enabled` is set on the server.
|
|
50
|
+
class CreateOrbitcastBroker < ActiveRecord::Migration[8.1]
|
|
51
|
+
def change
|
|
52
|
+
create_table :orbitcast_messages, id: false,
|
|
53
|
+
primary_key: %i[namespace stream generation seq] do |t|
|
|
54
|
+
t.text :namespace, null: false
|
|
55
|
+
t.text :stream, null: false
|
|
56
|
+
# The history era this sequence belongs to; bumped on a retention wipe.
|
|
57
|
+
t.bigint :generation, null: false
|
|
58
|
+
# Per-stream commit order, assigned under the stream's position lock.
|
|
59
|
+
t.bigint :seq, null: false
|
|
60
|
+
# The publisher-supplied publication UUID, which is also the client's
|
|
61
|
+
# deduplication key across a replay-to-live handoff.
|
|
62
|
+
t.column :publication_id, :uuid, null: false
|
|
63
|
+
t.text :json_payload, null: false
|
|
64
|
+
# Explicitly timestamptz rather than `t.datetime`: expiry and retention
|
|
65
|
+
# are compared against the database clock (`now()`), never an application
|
|
66
|
+
# clock, and must not depend on the app's `datetime_type` default.
|
|
67
|
+
t.column :created_at, :timestamptz, null: false, default: -> { "now()" }
|
|
68
|
+
t.column :expires_at, :timestamptz, null: false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Deduplication within the retention window.
|
|
72
|
+
add_index :orbitcast_messages, %i[namespace stream publication_id],
|
|
73
|
+
unique: true, name: "index_orbitcast_messages_on_publication"
|
|
74
|
+
# The sweeper's scan: expired rows first, bounded per statement.
|
|
75
|
+
add_index :orbitcast_messages, :expires_at
|
|
76
|
+
|
|
77
|
+
create_table :orbitcast_stream_positions, id: false, primary_key: %i[namespace stream] do |t|
|
|
78
|
+
t.text :namespace, null: false
|
|
79
|
+
t.text :stream, null: false
|
|
80
|
+
t.bigint :generation, null: false, default: 1
|
|
81
|
+
# The sequence the *next* publication will take. The row is locked
|
|
82
|
+
# FOR UPDATE for the whole publish transaction; that lock is the ordering
|
|
83
|
+
# mechanism, so this table is never written outside it.
|
|
84
|
+
t.bigint :next_seq, null: false, default: 1
|
|
85
|
+
t.column :updated_at, :timestamptz, null: false, default: -> { "now()" }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
create_table :orbitcast_sessions, id: false,
|
|
89
|
+
primary_key: %i[namespace session_token_hash] do |t|
|
|
90
|
+
t.text :namespace, null: false
|
|
91
|
+
# SHA-256 hex of the recovery token. The token itself is never stored.
|
|
92
|
+
t.text :session_token_hash, null: false
|
|
93
|
+
# Versioned JSON: {"version":1,"session_id":"...","cursors":{...}}.
|
|
94
|
+
t.text :snapshot, null: false
|
|
95
|
+
# Bumped atomically by every resume; the highest holder owns the session.
|
|
96
|
+
t.bigint :fencing_generation, null: false, default: 0
|
|
97
|
+
t.column :expires_at, :timestamptz, null: false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
add_index :orbitcast_sessions, :expires_at
|
|
101
|
+
end
|
|
102
|
+
end
|
data/exe/orbitcast
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Thin wrapper around the vendored Orbitcast executable.
|
|
5
|
+
#
|
|
6
|
+
# Only the launcher is loaded here. `require "orbitcast"` would mark the gem as
|
|
7
|
+
# loaded before `Bundler.require` runs inside a Rails boot, which would skip
|
|
8
|
+
# `Orbitcast::Engine` registration entirely.
|
|
9
|
+
require "orbitcast/launcher"
|
|
10
|
+
|
|
11
|
+
Orbitcast::Launcher.call(ARGV)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_cable/subscription_adapter/base"
|
|
4
|
+
require "orbitcast"
|
|
5
|
+
|
|
6
|
+
module ActionCable
|
|
7
|
+
module SubscriptionAdapter
|
|
8
|
+
# Publish-only ActionCable adapter.
|
|
9
|
+
#
|
|
10
|
+
# # config/cable.yml
|
|
11
|
+
# production:
|
|
12
|
+
# adapter: orbitcast
|
|
13
|
+
# database_url: <%= ENV.fetch("ORBITCAST_DATABASE_URL") %>
|
|
14
|
+
# namespace: "example_production"
|
|
15
|
+
# publish_via: postgres
|
|
16
|
+
#
|
|
17
|
+
# `ActionCable.server.broadcast` and the Turbo broadcast helpers go through
|
|
18
|
+
# `broadcast`, to `Orbitcast::Publisher::Postgres` (`publish_via: postgres`,
|
|
19
|
+
# the default) or `Orbitcast::Publisher::Http` (`publish_via: http`).
|
|
20
|
+
# `shutdown` releases whichever publisher was built, fork-safely.
|
|
21
|
+
# Subscribing is the Rust relay's job: in signed mode the Ruby
|
|
22
|
+
# WebSocket endpoint is disabled (`config.action_cable.mount_path = nil`), so
|
|
23
|
+
# a local `subscribe` call is a configuration bug, not something to satisfy
|
|
24
|
+
# by starting a second listener.
|
|
25
|
+
#
|
|
26
|
+
# Note the `::` prefixes below: inside this class, a bare `Orbitcast`
|
|
27
|
+
# resolves to the adapter itself, not to the gem's top-level module.
|
|
28
|
+
class Orbitcast < Base
|
|
29
|
+
def initialize(server)
|
|
30
|
+
super
|
|
31
|
+
@publisher = nil
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param channel [String] logical stream name
|
|
36
|
+
# @param payload [String] JSON text ActionCable already encoded; it is
|
|
37
|
+
# embedded verbatim rather than encoded a second time
|
|
38
|
+
def broadcast(channel, payload)
|
|
39
|
+
publisher.publish(channel, payload)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def subscribe(channel, message_callback, success_callback = nil)
|
|
43
|
+
raise NotImplementedError, unsupported_message("subscribe", channel)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unsubscribe(channel, message_callback)
|
|
47
|
+
raise NotImplementedError, unsupported_message("unsubscribe", channel)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def shutdown
|
|
51
|
+
publisher = @mutex.synchronize do
|
|
52
|
+
current = @publisher
|
|
53
|
+
@publisher = nil
|
|
54
|
+
current
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
publisher&.shutdown
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The publisher, built from `cable.yml` merged over `Orbitcast.config`.
|
|
62
|
+
def publisher
|
|
63
|
+
@publisher || @mutex.synchronize { @publisher ||= build_publisher }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def build_publisher
|
|
69
|
+
config = ::Orbitcast.config
|
|
70
|
+
config.assign(cable_options)
|
|
71
|
+
|
|
72
|
+
case config.publish_via
|
|
73
|
+
when :postgres
|
|
74
|
+
::Orbitcast::Publisher::Postgres.new(config)
|
|
75
|
+
when :http
|
|
76
|
+
# Loaded here, not at require time: a PostgreSQL deployment never
|
|
77
|
+
# needs net/http in the adapter's path.
|
|
78
|
+
require "orbitcast/publisher/http"
|
|
79
|
+
::Orbitcast::Publisher::Http.new(config)
|
|
80
|
+
else
|
|
81
|
+
# Configuration#publish_via= already rejects anything else; this is the
|
|
82
|
+
# fail-closed backstop if a new value is added there first.
|
|
83
|
+
raise ::Orbitcast::ConfigurationError,
|
|
84
|
+
"publish_via #{config.publish_via.inspect} has no publisher implementation"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# `cable[:id]` is ActionCable's own connection tag and `adapter` selects
|
|
89
|
+
# us; neither is an Orbitcast setting.
|
|
90
|
+
def cable_options
|
|
91
|
+
server.config.cable.to_h.transform_keys(&:to_sym).except(:adapter, :id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def unsupported_message(operation, channel)
|
|
95
|
+
"Orbitcast does not #{operation} in this process (stream #{channel.inspect}). " \
|
|
96
|
+
"The Rust relay owns WebSocket connections and PostgreSQL LISTEN; the Rails cable " \
|
|
97
|
+
"endpoint is disabled in signed mode. Check that config.action_cable.mount_path is nil " \
|
|
98
|
+
"and that nothing mounts ActionCable::Server in this application."
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|