discord_store 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE.txt +21 -0
- data/README.md +439 -0
- data/lib/active_record/connection_adapters/discord_adapter.rb +271 -0
- data/lib/active_storage/service/discord_service.rb +232 -0
- data/lib/discord_store/blob_store.rb +453 -0
- data/lib/discord_store/channel_shard.rb +73 -0
- data/lib/discord_store/cipher.rb +195 -0
- data/lib/discord_store/codec.rb +231 -0
- data/lib/discord_store/configuration.rb +197 -0
- data/lib/discord_store/errors.rb +92 -0
- data/lib/discord_store/guild_limits.rb +117 -0
- data/lib/discord_store/journal.rb +262 -0
- data/lib/discord_store/kv.rb +387 -0
- data/lib/discord_store/log/record.rb +88 -0
- data/lib/discord_store/log.rb +299 -0
- data/lib/discord_store/railtie.rb +27 -0
- data/lib/discord_store/replay.rb +136 -0
- data/lib/discord_store/snowflake.rb +109 -0
- data/lib/discord_store/tasks.rake +92 -0
- data/lib/discord_store/transport/bucket.rb +141 -0
- data/lib/discord_store/transport/fake.rb +394 -0
- data/lib/discord_store/transport/http.rb +174 -0
- data/lib/discord_store/transport/quota.rb +138 -0
- data/lib/discord_store/transport/rate_limiter.rb +148 -0
- data/lib/discord_store/transport/rest.rb +361 -0
- data/lib/discord_store/version.rb +5 -0
- data/lib/discord_store.rb +125 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: beaeca859e4b101de87ff0fd0c779311606c36bf800f2ed766066bbe868f4847
|
|
4
|
+
data.tar.gz: 3b2dc1bead35992ed63ba051a32e7867b19bb0c6e50e3c02dd3977ab12fe5902
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 14516705bd0e8d8af2272b6383de32c9faa3497b3f3b3f9dc1468732c5fa84f34f77da5b9d50d81259354e804b93f0aa6fff38eea6e738c5a972d26eadb3132b
|
|
7
|
+
data.tar.gz: fe1d4aee25808ee3c4be6372e3e953c557f806812055a79eae16d57e2dad5624ae5637a80f0407479abce31e61c967061332fadb4dd0a2ff91e154a10b28c4c8
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. This project follows
|
|
4
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
|
|
6
|
+
## [0.1.0] - 2026-09-09
|
|
7
|
+
|
|
8
|
+
First release.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Transport** — a rate-limit-aware Discord REST client that learns bucket
|
|
13
|
+
topology from response headers, honours `X-RateLimit-Scope` on 429s, retries
|
|
14
|
+
with full jitter, and caches connections per fiber rather than per thread.
|
|
15
|
+
- **Quota** — a token-bucket semaphore over the bot's global request budget,
|
|
16
|
+
replacing the connection pool. The scarce resource over an HTTP API is
|
|
17
|
+
permission to ask, not sockets.
|
|
18
|
+
- **Log** — an encrypted append-only log across one or more channels. Records
|
|
19
|
+
are packed many per message, spilled to attachments when oversized, and
|
|
20
|
+
deduplicated on retry via the Discord message `nonce`. Channels are
|
|
21
|
+
partitions: total order within one, none across them.
|
|
22
|
+
- **Cipher** — AES-256-GCM with additional authenticated data binding each
|
|
23
|
+
payload to its channel, and a separate binary envelope for attachments that
|
|
24
|
+
avoids the 33% base64 penalty.
|
|
25
|
+
- **Snowflake** — treats Discord message IDs as the k-sortable, time-encoded
|
|
26
|
+
primary keys they are, including synthetic bounds for time-range pagination.
|
|
27
|
+
- **BlobStore** — chunked object storage that never persists a CDN URL and
|
|
28
|
+
re-resolves on every read, so it survives Discord's expiring signed links.
|
|
29
|
+
Range reads fetch only the chunks they touch.
|
|
30
|
+
- **`ActiveStorage::Service::DiscordService`** — with proxy mode as the
|
|
31
|
+
documented configuration. Passes Rails' own `SharedServiceTests` conformance
|
|
32
|
+
suite in full, including `compose` and 5 MB streaming slices.
|
|
33
|
+
- **`ActiveRecord::ConnectionAdapters::DiscordAdapter`** — a SQLite adapter that
|
|
34
|
+
mirrors every write into a Discord channel and can rebuild the database from
|
|
35
|
+
it. Journal modes `:sync`, `:async` and `:off`. Savepoints are refused while
|
|
36
|
+
journalling, because a sent message cannot be un-sent.
|
|
37
|
+
- **Replay** — idempotent, resumable reconstruction of a local database from the
|
|
38
|
+
log, with cursor tracking.
|
|
39
|
+
- **KV** — Kredis-shaped values. Scalars are last-write-wins documents; counters
|
|
40
|
+
and lists are append-only, so concurrent writers do not lose each other's work.
|
|
41
|
+
- **`Transport::Fake`** — an in-memory Discord that models monotonic snowflakes,
|
|
42
|
+
nonce deduplication, the 14-day bulk-delete window, rate limiting, foreign
|
|
43
|
+
authors, and CDN links that genuinely expire.
|
|
44
|
+
- **Rake tasks** — `discord:replay`, `discord:status`, `discord:compact`,
|
|
45
|
+
`discord:doctor`, `discord:probe`, `discord:key`.
|
|
46
|
+
- **Safety** — the library refuses to make a request without an explicit
|
|
47
|
+
Terms of Service acknowledgement, and reads only messages its own bot wrote.
|
|
48
|
+
|
|
49
|
+
### Verified against
|
|
50
|
+
|
|
51
|
+
- Ruby 3.1, 3.2, 3.3 and 3.4.
|
|
52
|
+
- Rails 7.2, 8.0 and 8.1, via Appraisal. The floor is 7.2 because
|
|
53
|
+
`ActiveRecord::ConnectionAdapters.register` does not exist before it.
|
|
54
|
+
- Rails' vendored ActiveStorage conformance suite, 14 cases, nothing skipped.
|
|
55
|
+
|
|
56
|
+
Not verified against live Discord. The attachment ceiling, CDN `Range` support
|
|
57
|
+
and real throughput are discovered at runtime rather than hardcoded, and none of
|
|
58
|
+
them have been measured against a real guild.
|
|
59
|
+
|
|
60
|
+
[0.1.0]: https://github.com/chayuto/discord_store/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chayut Orapinpatipat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
# discord_store
|
|
2
|
+
|
|
3
|
+
[](https://github.com/chayuto/discord_store/actions/workflows/main.yml)
|
|
4
|
+
[](https://rubygems.org/gems/discord_store)
|
|
5
|
+
[](https://www.ruby-lang.org)
|
|
6
|
+
[](https://rubyonrails.org)
|
|
7
|
+
|
|
8
|
+
Uses Discord as a database.
|
|
9
|
+
|
|
10
|
+
It works. The data goes in, the data comes back out, ActiveRecord can query it,
|
|
11
|
+
ActiveStorage can serve files from it, and a machine with an empty disk can
|
|
12
|
+
rebuild the whole database from a chat channel and a bot token.
|
|
13
|
+
|
|
14
|
+
You should almost certainly not use it.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Read this part first
|
|
19
|
+
|
|
20
|
+
Storing application data in Discord messages and attachments is an explicit
|
|
21
|
+
violation of the [Discord Developer Terms of Service][tos] and the
|
|
22
|
+
[Discord API Developer Policy][policy]. Discord has acted against this class of
|
|
23
|
+
project before — in late 2023 it began signing CDN links with an expiring HMAC,
|
|
24
|
+
which broke essentially every Discord-backed filesystem in existence within
|
|
25
|
+
about twenty-four hours — and it can revoke your bot token, remove your
|
|
26
|
+
application, and action your account without warning.
|
|
27
|
+
|
|
28
|
+
This library will not make a network request until you say so in code:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
DiscordStore.configure do |config|
|
|
32
|
+
config.i_understand_this_violates_discord_tos = true
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Do not put this in an application you did not build for yourself, and do not put
|
|
37
|
+
anybody else's data in it.
|
|
38
|
+
|
|
39
|
+
[tos]: https://support-dev.discord.com/hc/en-us/articles/8562894815383-Discord-Developer-Terms-of-Service
|
|
40
|
+
[policy]: https://support-dev.discord.com/hc/en-us/articles/8563934450327-Discord-Developer-Policy
|
|
41
|
+
|
|
42
|
+
### What it will not do
|
|
43
|
+
|
|
44
|
+
discord_store only ever reads messages **its own bot wrote**. That check lives in
|
|
45
|
+
code, not in this README:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# lib/discord_store/transport/rest.rb
|
|
49
|
+
def own_message?(message)
|
|
50
|
+
message.dig("author", "id").to_s == config.application_id.to_s
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
There is no message search, no member enumeration, no history export, and there
|
|
55
|
+
will not be. The distance between a storage backend and a scraper is exactly
|
|
56
|
+
whether it can read other people's messages, and this one cannot.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## The honest numbers
|
|
61
|
+
|
|
62
|
+
| | |
|
|
63
|
+
|---|---|
|
|
64
|
+
| Write throughput | ~1 transaction/second (5 messages per 5 seconds, per channel) |
|
|
65
|
+
| Bulk throughput | ~12 log records per request when batched |
|
|
66
|
+
| Object storage | a few MiB/s, hard-capped by rate limits nobody can raise for you |
|
|
67
|
+
| Read latency (adapter) | SQLite speed — reads never touch the network |
|
|
68
|
+
| Durability | at the pleasure of a company that owes you nothing |
|
|
69
|
+
| Transactions | one message = one atomic append; that is all there is |
|
|
70
|
+
| Deletes | expensive, and worse after 14 days |
|
|
71
|
+
|
|
72
|
+
If those numbers work for your problem, your problem is small enough that
|
|
73
|
+
SQLite would also have worked, and SQLite does not have a Terms of Service.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Why it isn't a stunt
|
|
78
|
+
|
|
79
|
+
The naive version of this idea maps a channel to a table and scans it for every
|
|
80
|
+
query. That version tops out in the single-digit megabytes per second and cannot
|
|
81
|
+
do a join, because Discord gives a bot no query interface at all: no `WHERE`, no
|
|
82
|
+
index, no server-side filter. There is a channel, and you may page through it a
|
|
83
|
+
hundred messages at a time.
|
|
84
|
+
|
|
85
|
+
So Discord is not asked to be a query engine here. It is asked to be a durable,
|
|
86
|
+
replicated, ordered **write-ahead log** — which it is unexpectedly good at — and
|
|
87
|
+
a local SQLite file is the materialized view you actually query.
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
writes ──▶ Discord channel (the log, source of truth)
|
|
91
|
+
│
|
|
92
|
+
└── replay ──▶ SQLite file (the materialized view)
|
|
93
|
+
▲
|
|
94
|
+
reads ───────────────────────────────┘
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
That is a materialized view over a replicated log, which is a normal thing that
|
|
98
|
+
normal systems do. The unusual part is only where the log segment lives.
|
|
99
|
+
|
|
100
|
+
The test that decides whether this is a real design:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
$ rm storage/production.sqlite3 && rake discord:replay
|
|
104
|
+
replayed 9 transactions (10 statements) in 0.3s
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Binary columns, timestamps, schema and all, on any machine, from nothing but a
|
|
108
|
+
token and a channel ID.
|
|
109
|
+
|
|
110
|
+
### Three things Discord is accidentally good at
|
|
111
|
+
|
|
112
|
+
**Snowflake IDs are a real clustered primary key.** Message IDs encode a
|
|
113
|
+
millisecond timestamp in their high bits and are monotonic within a channel, so
|
|
114
|
+
the log sequence number, the `created_at`, and cursor pagination are all free.
|
|
115
|
+
Better still, a wall-clock range maps onto an ID range, and Discord's
|
|
116
|
+
`before`/`after` parameters take snowflakes — so "everything written between
|
|
117
|
+
09:00 and 10:00" is a server-side range scan, not a client-side filter.
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
DiscordStore::Snowflake.at(175928847299117063)
|
|
121
|
+
# => 2016-04-30 11:18:25 UTC
|
|
122
|
+
|
|
123
|
+
# a synthetic bound that sorts exactly where a real ID would
|
|
124
|
+
DiscordStore::Snowflake.from_time(Time.utc(2026, 9, 9))
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Editing is a real UPDATE.** A bot can edit its own messages forever, so a
|
|
128
|
+
mutable value is one message that gets rewritten, not a delete-and-reinsert.
|
|
129
|
+
|
|
130
|
+
**Deleting is not.** Bulk deletion only covers messages under two weeks old;
|
|
131
|
+
past that it is one request per message against the limit that was already
|
|
132
|
+
binding. So the cheap way to retire a record is to append a marker saying it is
|
|
133
|
+
gone and let readers skip it.
|
|
134
|
+
|
|
135
|
+
That marker is a tombstone. Cassandra — the database Discord itself ran on —
|
|
136
|
+
does exactly this, for exactly this reason, and the volume of tombstones a chat
|
|
137
|
+
app generates is precisely what made Cassandra untenable for them and forced
|
|
138
|
+
their migration to ScyllaDB. Building a store on Discord means writing
|
|
139
|
+
tombstones into a database whose tombstones are stored as tombstones.
|
|
140
|
+
|
|
141
|
+
Compaction is therefore not optional. `rake discord:compact STREAM=orders`.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Compatibility
|
|
146
|
+
|
|
147
|
+
| | |
|
|
148
|
+
|---|---|
|
|
149
|
+
| Ruby | 3.1, 3.2, 3.3, 3.4 |
|
|
150
|
+
| Rails | 7.2, 8.0, 8.1 — for the ActiveRecord and ActiveStorage layers |
|
|
151
|
+
|
|
152
|
+
The gem itself depends on no part of Rails; the adapters are optional and load
|
|
153
|
+
only if you require them. `base64` is the single runtime dependency, and only
|
|
154
|
+
because it leaves the default gems in Ruby 3.4.
|
|
155
|
+
|
|
156
|
+
The Rails floor is 7.2 and it is a hard one:
|
|
157
|
+
`ActiveRecord::ConnectionAdapters.register` arrived in 7.2, and registering an
|
|
158
|
+
adapter before that means defining a `discord_connection` factory method
|
|
159
|
+
instead. Supporting both paths is possible; claiming to support both without
|
|
160
|
+
running the suite against both is not.
|
|
161
|
+
|
|
162
|
+
CI runs every supported combination — ten of them — on every push.
|
|
163
|
+
|
|
164
|
+
## Install
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# Gemfile
|
|
168
|
+
gem "discord_store"
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The version number is an honest 0. Three values in here are discovered at
|
|
172
|
+
runtime rather than hardcoded — the real attachment ceiling, whether the CDN
|
|
173
|
+
honours HTTP `Range`, and sharded throughput — and none of them has been
|
|
174
|
+
measured against a live guild. That is deliberate, because hardcoding them is
|
|
175
|
+
exactly how every previous library in this genre broke, but it does mean the
|
|
176
|
+
library has never met the thing it is named after. `rake discord:doctor` and
|
|
177
|
+
`rake discord:probe` are how you find out; reports are welcome.
|
|
178
|
+
|
|
179
|
+
Create a bot, invite it to a server you own with **Send Messages**, **Read
|
|
180
|
+
Message History** and **Manage Messages**, and make some channels for it.
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
$ rake discord:key
|
|
184
|
+
K7XmPq2vN8wLdR4tYbG9hJfE3sA6uZcW1nT5oI0pQxM=
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
DiscordStore.configure do |config|
|
|
189
|
+
config.i_understand_this_violates_discord_tos = true
|
|
190
|
+
config.token = ENV.fetch("DISCORD_BOT_TOKEN")
|
|
191
|
+
config.application_id = ENV.fetch("DISCORD_APPLICATION_ID")
|
|
192
|
+
config.guild_id = ENV.fetch("DISCORD_GUILD_ID")
|
|
193
|
+
config.secret_key = ENV.fetch("DISCORD_STORE_KEY")
|
|
194
|
+
config.log_channel_ids = ["1234567890123456789"]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
DiscordStore.client.verify!
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`rake discord:doctor` checks the token, the channels, and the attachment ceiling.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## The four layers
|
|
205
|
+
|
|
206
|
+
Each is usable on its own.
|
|
207
|
+
|
|
208
|
+
### L0 — Transport
|
|
209
|
+
|
|
210
|
+
A rate-limit-aware REST client. It learns Discord's bucket topology from
|
|
211
|
+
response headers rather than hardcoding a table of limits that will be wrong by
|
|
212
|
+
next quarter, honours `X-RateLimit-Scope` on 429s, and retries with full jitter.
|
|
213
|
+
|
|
214
|
+
The interesting part is what replaces the connection pool. A conventional
|
|
215
|
+
adapter checks out a socket, because sockets are scarce and the database will
|
|
216
|
+
answer as fast as you can ask. Over an HTTP API the opposite holds: connections
|
|
217
|
+
are free and *permission to ask* is what runs out. So the checkout primitive is
|
|
218
|
+
a semaphore over quota, `pool:` in `database.yml` means nothing, and the timeout
|
|
219
|
+
error says so:
|
|
220
|
+
|
|
221
|
+
> waited 15.0s for Discord request quota (45/s) and never got it. This is not a
|
|
222
|
+
> connection pool problem and a larger pool will not fix it.
|
|
223
|
+
|
|
224
|
+
Connections are cached per fiber, not per thread — under a fiber scheduler two
|
|
225
|
+
fibers on one thread would otherwise interleave bytes on one socket.
|
|
226
|
+
|
|
227
|
+
### L1 — Log
|
|
228
|
+
|
|
229
|
+
An encrypted, append-only log across one or more channels.
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
log = DiscordStore.client.log
|
|
233
|
+
|
|
234
|
+
log.append(stream: "orders", data: { id: 1, total: 9.99 })
|
|
235
|
+
|
|
236
|
+
log.transaction do |tx| # one message = one atomic append
|
|
237
|
+
tx.append(stream: "orders", data: { id: 2 })
|
|
238
|
+
tx.append(stream: "orders", data: { id: 3 })
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
log.read(stream: "orders", after: cursor, limit: 100)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Records are packed many-per-message (a message holds 2000 characters, and a
|
|
245
|
+
request that carries one 80-byte record spends the same permit as one carrying
|
|
246
|
+
twenty), encrypted with AES-256-GCM, and spilled to an attachment when too
|
|
247
|
+
large. Retries are deduplicated by Discord itself using the message `nonce`.
|
|
248
|
+
|
|
249
|
+
**Channels are partitions.** More channels means more throughput, because the
|
|
250
|
+
harshest limit is per-channel — and it means less ordering, because message IDs
|
|
251
|
+
are only ordered *within* a channel. So the promise is Kafka's: total order
|
|
252
|
+
within a partition, none across them, and a stream always lands in the same
|
|
253
|
+
partition. Rendezvous hashing, so adding a channel moves 1/n of the streams
|
|
254
|
+
rather than most of them.
|
|
255
|
+
|
|
256
|
+
### L2a — ActiveStorage
|
|
257
|
+
|
|
258
|
+
```yaml
|
|
259
|
+
# config/storage.yml
|
|
260
|
+
discord:
|
|
261
|
+
service: Discord
|
|
262
|
+
token: <%= ENV["DISCORD_BOT_TOKEN"] %>
|
|
263
|
+
application_id: <%= ENV["DISCORD_APPLICATION_ID"] %>
|
|
264
|
+
secret_key: <%= ENV["DISCORD_STORE_KEY"] %>
|
|
265
|
+
blob_channel_ids: ["...", "..."]
|
|
266
|
+
manifest_channel_id: "..."
|
|
267
|
+
i_understand_this_violates_discord_tos: true
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
# config/environments/production.rb
|
|
272
|
+
config.active_storage.resolve_model_to_route = :rails_storage_proxy
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
That last line is the whole reason this layer is worth building on Rails.
|
|
276
|
+
|
|
277
|
+
When Discord started signing CDN links with an expiring HMAC, every project that
|
|
278
|
+
had stored URLs in a database broke about a day later. The community's fix was
|
|
279
|
+
to deploy caching proxies on Cloudflare Workers that re-fetch the message and
|
|
280
|
+
hand back a fresh link — which works, and which concedes the entire premise,
|
|
281
|
+
because the storage is only free if you ignore the server you now have to run.
|
|
282
|
+
|
|
283
|
+
Rails has shipped that proxy for years. It is
|
|
284
|
+
`ActiveStorage::Blobs::ProxyController`. In proxy mode the browser gets a Rails
|
|
285
|
+
URL that never expires, and the Discord link is re-resolved per request,
|
|
286
|
+
server-side, where it belongs.
|
|
287
|
+
|
|
288
|
+
This library never stores a URL — only `channel_id`, `message_id` and
|
|
289
|
+
`attachment_id` — and resolves at read time. Redirect mode works, but only for
|
|
290
|
+
blobs small enough to be a single attachment: a blob split across attachments has
|
|
291
|
+
no single URL, and there is no honest way to invent one.
|
|
292
|
+
|
|
293
|
+
### L2b — ActiveRecord
|
|
294
|
+
|
|
295
|
+
```yaml
|
|
296
|
+
# config/database.yml
|
|
297
|
+
production:
|
|
298
|
+
adapter: discord
|
|
299
|
+
database: storage/production.sqlite3
|
|
300
|
+
discord:
|
|
301
|
+
i_understand_this_violates_discord_tos: true
|
|
302
|
+
token: <%= ENV["DISCORD_BOT_TOKEN"] %>
|
|
303
|
+
application_id: <%= ENV["DISCORD_APPLICATION_ID"] %>
|
|
304
|
+
secret_key: <%= ENV["DISCORD_STORE_KEY"] %>
|
|
305
|
+
log_channel_ids: ["1234567890123456789"]
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Every write is mirrored to the channel; every read is served by SQLite. Joins,
|
|
309
|
+
indexes, aggregates and migrations all work, because SQLite does them.
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
rake discord:status # how far behind the local file is
|
|
313
|
+
rake discord:replay # rebuild it from the channel
|
|
314
|
+
rake discord:compact STREAM=wal DRY_RUN=1
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Three details that are load-bearing:
|
|
318
|
+
|
|
319
|
+
- **The log is written before SQLite commits, not after.** There is no two-phase
|
|
320
|
+
commit between a SQLite file and a chat server, so one has to go first, and it
|
|
321
|
+
has to be the one that can reconstruct the other.
|
|
322
|
+
- **Savepoints are refused while journalling.** A sent message cannot be
|
|
323
|
+
un-sent, so a rolled-back savepoint would still replay.
|
|
324
|
+
- **`journal_mode:`** is `:sync` (one message per transaction, ~1 tx/s),
|
|
325
|
+
`:async` (batched, ~12 tx/s, with a durability window), or `:off`.
|
|
326
|
+
|
|
327
|
+
### L3 — KV
|
|
328
|
+
|
|
329
|
+
Kredis-shaped values, with the storage strategy chosen per type according to
|
|
330
|
+
what Discord can actually promise.
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
kv = DiscordStore.client.kv
|
|
334
|
+
|
|
335
|
+
kv.string("layout").set("cards") # one message, edited in place
|
|
336
|
+
kv.counter("views").increment # an append; sums on read
|
|
337
|
+
kv.list("recent").append("a", "b")
|
|
338
|
+
kv.flag("onboarded").mark(expires_in: 1.hour)
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Scalars are last-write-wins, because Discord has no compare-and-swap and there is
|
|
342
|
+
no way to build one on top of message editing. Counters are append-only for that
|
|
343
|
+
exact reason: two processes incrementing at once both count, because appends do
|
|
344
|
+
not collide. It is the reason distributed systems reach for CRDTs, arrived at
|
|
345
|
+
from the same constraint.
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
a.increment # from one process
|
|
349
|
+
b.increment # from another, no coordination
|
|
350
|
+
a.value # => 2
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## What does not work
|
|
356
|
+
|
|
357
|
+
- **No server-side query.** Anything not in the local view means scanning a
|
|
358
|
+
channel.
|
|
359
|
+
- **No cross-partition atomicity.** One message is the largest all-or-nothing
|
|
360
|
+
unit that exists.
|
|
361
|
+
- **No compare-and-swap**, so no safe read-modify-write on a document.
|
|
362
|
+
- **No direct upload.** Every byte goes through your application.
|
|
363
|
+
- **Anyone with Manage Messages can delete your database** from the Discord
|
|
364
|
+
client, and `NotFoundError` is a routine occurrence rather than an exceptional
|
|
365
|
+
one. AES-GCM at least means tampering surfaces as a decryption failure instead
|
|
366
|
+
of silently corrupt data.
|
|
367
|
+
- **Non-deterministic SQL** (`RANDOM()`, `CURRENT_TIMESTAMP`) will not replay
|
|
368
|
+
identically. The adapter warns when it journals one.
|
|
369
|
+
- **The attachment ceiling moves.** It has been 8 MiB, 25 MB and 10 MB, and
|
|
370
|
+
published write-ups disagree because each was right when written. This library
|
|
371
|
+
discovers it from the guild's boost tier, and `rake discord:probe` measures it
|
|
372
|
+
empirically.
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## Testing without a token
|
|
377
|
+
|
|
378
|
+
The whole stack runs against an in-memory Discord, including the parts that only
|
|
379
|
+
break in production:
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
fake = DiscordStore::Transport::Fake.new(application_id: "111111111111111111")
|
|
383
|
+
client = DiscordStore::Client.new(config: my_config, http: fake)
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
It models monotonic snowflakes, nonce deduplication, the 14-day bulk-delete
|
|
387
|
+
window, rate-limit headers and 429s on demand, messages from other authors — and
|
|
388
|
+
CDN links that carry a real expiry and stop working when it passes. Code that
|
|
389
|
+
caches a URL instead of re-resolving it fails in the test suite rather than in
|
|
390
|
+
production a day after deploy:
|
|
391
|
+
|
|
392
|
+
```ruby
|
|
393
|
+
@now += 48 * 3600
|
|
394
|
+
assert_equal data, @blobs.get("k") # passes: it re-resolves
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
```sh
|
|
398
|
+
bundle exec rake # 145 tests and RuboCop, no network
|
|
399
|
+
bundle exec appraisal rake # the same, against Rails 7.2, 8.0 and 8.1
|
|
400
|
+
ruby -Ilib examples/replay_demo.rb
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Somebody else's tests
|
|
404
|
+
|
|
405
|
+
Everything above tests this library against expectations this library's author
|
|
406
|
+
wrote down, which is a closed loop. So the suite also vendors
|
|
407
|
+
[Rails' own ActiveStorage conformance tests][shared] verbatim and points them at
|
|
408
|
+
the Discord service — the same suite Disk, S3, GCS and Azure have to pass,
|
|
409
|
+
written by people who have never heard of this gem and have no stake in it
|
|
410
|
+
passing.
|
|
411
|
+
|
|
412
|
+
It found two real bugs on the first run. `compose` was simply missing. And
|
|
413
|
+
streaming downloads were yielding one Discord chunk at a time — sized by the
|
|
414
|
+
guild's attachment ceiling, 8 MiB to 100 MiB — where every other service yields
|
|
415
|
+
5 MB slices and Rails asserts it exactly. That is storage geometry leaking into
|
|
416
|
+
an interface, and no test written from inside this project was ever going to
|
|
417
|
+
catch it.
|
|
418
|
+
|
|
419
|
+
All 14 pass, with nothing skipped.
|
|
420
|
+
|
|
421
|
+
[shared]: https://github.com/rails/rails/blob/v8.1.3.1/activestorage/test/service/shared_service_tests.rb
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## Prior art
|
|
426
|
+
|
|
427
|
+
This is a Ruby entry in a long tradition, and the interesting parts of the design
|
|
428
|
+
came from reading what broke for everyone else: `discord-fs` for chunking and
|
|
429
|
+
journaling, `DiscordFS` for streaming rather than buffering, `discord-drive` for
|
|
430
|
+
the semaphore-bounded concurrency, `discbase` and `Discord_DB` for the mapping,
|
|
431
|
+
and `discord-cdn-proxy` for the re-resolution trick that Rails turns out to ship
|
|
432
|
+
already.
|
|
433
|
+
|
|
434
|
+
The tombstone observation is owed to Discord's own engineering write-ups on
|
|
435
|
+
migrating from Cassandra to ScyllaDB.
|
|
436
|
+
|
|
437
|
+
## License
|
|
438
|
+
|
|
439
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|