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.
@@ -0,0 +1,453 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "stringio"
5
+
6
+ module DiscordStore
7
+ # Stores arbitrary binary data as chunked message attachments.
8
+ #
9
+ # The hard problem here is not chunking, it is addressing. In late 2023
10
+ # Discord began signing CDN links with an HMAC over an expiry timestamp, and
11
+ # every project in this genre that had stored URLs in a database woke up to a
12
+ # dead index roughly a day later. The community's answer was to stand up
13
+ # caching proxies on Cloudflare Workers to re-resolve links on demand, which
14
+ # works and which also concedes the entire premise: the storage is only free
15
+ # if you ignore the server you now have to run.
16
+ #
17
+ # This library stores +channel_id+, +message_id+ and +attachment_id+, never a
18
+ # URL, and re-resolves at read time. Rails users get the rest for free: an
19
+ # ActiveStorage service in +proxy+ mode routes reads through
20
+ # ActiveStorage::Blobs::ProxyController, which is the Cloudflare Worker
21
+ # everybody rebuilt, already in the framework.
22
+ class BlobStore
23
+ # What a stored blob is made of.
24
+ Manifest = Struct.new(:key, :byte_size, :checksum, :content_type, :chunks,
25
+ :channel_id, :message_id, keyword_init: true) do
26
+ # @return [Integer]
27
+ def chunk_count = chunks.size
28
+
29
+ def to_h
30
+ {
31
+ "key" => key, "size" => byte_size, "sum" => checksum, "type" => content_type,
32
+ "chunks" => chunks.map do |c|
33
+ { "m" => c[:message_id], "a" => c[:attachment_id], "c" => c[:channel_id], "s" => c[:size] }
34
+ end
35
+ }
36
+ end
37
+
38
+ def self.from_h(hash, channel_id: nil, message_id: nil)
39
+ new(
40
+ key: hash["key"], byte_size: hash["size"], checksum: hash["sum"],
41
+ content_type: hash["type"], channel_id: channel_id, message_id: message_id,
42
+ chunks: Array(hash["chunks"]).map do |c|
43
+ { message_id: c["m"], attachment_id: c["a"], channel_id: c["c"], size: c["s"] }
44
+ end
45
+ )
46
+ end
47
+ end
48
+
49
+ MANIFEST_STREAM = "__manifest"
50
+
51
+ attr_reader :config, :rest, :index
52
+
53
+ # @param rest [Transport::REST]
54
+ # @param config [Configuration]
55
+ # @param index [#get, #put, #delete, nil] manifest index; defaults to one
56
+ # backed by the manifest channel itself
57
+ def initialize(rest:, config:, index: nil)
58
+ @rest = rest
59
+ @config = config
60
+ @cipher = Cipher.build(config)
61
+ @limits = GuildLimits.new(rest: rest, config: config)
62
+ blob_channels = config.blob_channel_ids.empty? ? config.log_channel_ids : config.blob_channel_ids
63
+ @shard = ChannelShard.new(blob_channels)
64
+ @manifest_log = Log.new(
65
+ rest: rest, config: config,
66
+ channel_ids: [config.manifest_channel_id || @shard.channel_ids.first]
67
+ )
68
+ @index = index || ManifestIndex.new(log: @manifest_log)
69
+ end
70
+
71
+ # @return [Integer] bytes per chunk, discovered from the guild
72
+ def chunk_size = @limits.chunk_size
73
+
74
+ # Stores +data+ under +key+, replacing anything already there.
75
+ #
76
+ # @param key [String]
77
+ # @param data [String, IO]
78
+ # @param content_type [String]
79
+ # @param checksum [String, nil] expected MD5, base64; verified if given
80
+ # @return [Manifest]
81
+ def put(key, data, content_type: "application/octet-stream", checksum: nil)
82
+ io = data.respond_to?(:read) ? data : StringIO.new(data.to_s.dup.force_encoding(Encoding::BINARY))
83
+ channel_id = @shard.for(key)
84
+
85
+ chunks = []
86
+ digest = Digest::MD5.new
87
+ total = 0
88
+
89
+ each_chunk(io) do |bytes, ordinal|
90
+ digest << bytes
91
+ total += bytes.bytesize
92
+ chunks << upload_chunk(channel_id, key, ordinal, bytes)
93
+ end
94
+
95
+ computed = [digest.digest].pack("m0")
96
+
97
+ if checksum && checksum != computed
98
+ chunks.each { |chunk| safe_delete(chunk[:channel_id], chunk[:message_id]) }
99
+ raise Error, "checksum mismatch for #{key}: expected #{checksum}, computed #{computed}"
100
+ end
101
+
102
+ manifest = Manifest.new(key: key, byte_size: total, checksum: computed,
103
+ content_type: content_type, chunks: chunks, channel_id: channel_id)
104
+
105
+ previous = @index.get(key)
106
+ @index.put(key, manifest)
107
+ retire(previous) if previous
108
+
109
+ manifest
110
+ end
111
+
112
+ # @param key [String]
113
+ # @return [String] the blob's bytes
114
+ # @raise [NotFoundError]
115
+ def get(key)
116
+ manifest = fetch_manifest!(key)
117
+ buffer = +""
118
+ buffer.force_encoding(Encoding::BINARY)
119
+ each_chunk_body(manifest) { |bytes| buffer << bytes }
120
+ buffer
121
+ end
122
+
123
+ # Streams the blob without holding all of it.
124
+ #
125
+ # @param key [String]
126
+ # @yieldparam bytes [String]
127
+ # @return [void]
128
+ def download(key, &)
129
+ each_chunk_body(fetch_manifest!(key), &)
130
+ end
131
+
132
+ # Reads a byte range.
133
+ #
134
+ # Only the chunks the range touches are fetched, so a range read of a large
135
+ # blob costs a couple of requests rather than all of them. Whether the CDN
136
+ # honours an HTTP Range header on top of that is a bonus, not a dependency.
137
+ #
138
+ # @param key [String]
139
+ # @param range [Range]
140
+ # @return [String]
141
+ def get_range(key, range)
142
+ manifest = fetch_manifest!(key)
143
+ first, last = clamp_range(range, manifest.byte_size)
144
+ return +"" if first > last
145
+
146
+ out = +""
147
+ out.force_encoding(Encoding::BINARY)
148
+ offset = 0
149
+
150
+ manifest.chunks.each_with_index do |chunk, ordinal|
151
+ chunk_first = offset
152
+ chunk_last = offset + chunk[:size] - 1
153
+ offset += chunk[:size]
154
+
155
+ next if chunk_last < first
156
+ break if chunk_first > last
157
+
158
+ bytes = fetch_chunk(manifest.key, ordinal, chunk)
159
+ from = [first - chunk_first, 0].max
160
+ to = [last - chunk_first, bytes.bytesize - 1].min
161
+ out << bytes.byteslice(from, to - from + 1).to_s
162
+ end
163
+
164
+ out
165
+ end
166
+
167
+ # @param key [String]
168
+ # @return [Boolean]
169
+ def exist?(key) = !@index.get(key).nil?
170
+
171
+ # @param key [String]
172
+ # @return [Integer, nil] byte size, without fetching the data
173
+ def size(key) = @index.get(key)&.byte_size
174
+
175
+ # @param key [String]
176
+ # @return [void]
177
+ def delete(key)
178
+ manifest = @index.get(key)
179
+ return if manifest.nil?
180
+
181
+ retire(manifest)
182
+ @index.delete(key)
183
+ nil
184
+ end
185
+
186
+ # Deletes every blob whose key starts with +prefix+.
187
+ #
188
+ # @param prefix [String]
189
+ # @return [Integer] how many were deleted
190
+ def delete_prefix(prefix)
191
+ keys = @index.keys.select { |key| key.start_with?(prefix) }
192
+ keys.each { |key| delete(key) }
193
+ keys.size
194
+ end
195
+
196
+ # A freshly signed CDN URL for the blob.
197
+ #
198
+ # Only possible for a single-chunk blob: a blob split across attachments has
199
+ # no single URL, and there is no way to make one without a server that
200
+ # concatenates the pieces. That is the whole argument for proxy mode.
201
+ #
202
+ # @param key [String]
203
+ # @return [String]
204
+ # @raise [Error] if the blob has more than one chunk
205
+ def url(key)
206
+ manifest = fetch_manifest!(key)
207
+
208
+ if manifest.chunk_count != 1
209
+ raise Error,
210
+ "blob #{key} spans #{manifest.chunk_count} attachments and has no single URL. " \
211
+ "Configure the ActiveStorage service with a proxy route, or store smaller objects."
212
+ end
213
+
214
+ resolve_urls(manifest.chunks).fetch(manifest.chunks.first[:attachment_id])
215
+ end
216
+
217
+ private
218
+
219
+ # Normalises inclusive, exclusive and endless ranges against the real size.
220
+ #
221
+ # @return [Array(Integer, Integer)] inclusive first and last byte offsets
222
+ def clamp_range(range, byte_size)
223
+ first = [range.begin.to_i, 0].max
224
+
225
+ last = if range.end.nil?
226
+ byte_size - 1
227
+ elsif range.exclude_end?
228
+ range.end.to_i - 1
229
+ else
230
+ range.end.to_i
231
+ end
232
+
233
+ [first, [last, byte_size - 1].min]
234
+ end
235
+
236
+ def each_chunk(io)
237
+ ordinal = 0
238
+ size = chunk_size
239
+
240
+ while (bytes = io.read(size))
241
+ break if bytes.empty?
242
+
243
+ yield bytes.dup.force_encoding(Encoding::BINARY), ordinal
244
+ ordinal += 1
245
+ end
246
+ end
247
+
248
+ def upload_chunk(channel_id, key, ordinal, bytes)
249
+ payload = @cipher.seal_binary(bytes, aad: "#{key}/#{ordinal}")
250
+
251
+ message = @rest.create_message(
252
+ channel_id,
253
+ content: "DS1 blob #{ordinal}",
254
+ nonce: Digest::SHA256.hexdigest("#{key}/#{ordinal}/#{bytes.bytesize}")[0, 24],
255
+ files: [{ filename: "#{ordinal}.ds1", content: payload, content_type: "application/octet-stream" }]
256
+ )
257
+
258
+ attachment = message["attachments"].first
259
+ raise Error, "Discord accepted chunk #{ordinal} of #{key} but returned no attachment" unless attachment
260
+
261
+ { message_id: message["id"], attachment_id: attachment["id"],
262
+ channel_id: channel_id, size: bytes.bytesize, ordinal: ordinal }
263
+ end
264
+
265
+ def each_chunk_body(manifest)
266
+ manifest.chunks.each_with_index do |chunk, ordinal|
267
+ yield decrypt_chunk(manifest.key, ordinal, fetch_chunk_raw(chunk))
268
+ end
269
+ end
270
+
271
+ # The chunk's ordinal is its position in the manifest, not a stored field:
272
+ # it is bound into the encryption AAD, so a chunk that is reordered or
273
+ # swapped between blobs fails to authenticate rather than decoding as
274
+ # plausible-looking garbage.
275
+ def fetch_chunk(key, ordinal, chunk)
276
+ decrypt_chunk(key, ordinal, fetch_chunk_raw(chunk))
277
+ end
278
+
279
+ def fetch_chunk_raw(chunk)
280
+ urls = resolve_urls([chunk])
281
+ url = urls[chunk[:attachment_id]]
282
+ raise NotFoundError.new("attachment #{chunk[:attachment_id]} is gone", status: 404) unless url
283
+
284
+ @rest.download(url)
285
+ end
286
+
287
+ def decrypt_chunk(key, ordinal, payload)
288
+ return payload unless @cipher.encrypting?
289
+
290
+ @cipher.open_binary(payload, aad: "#{key}/#{ordinal}")
291
+ end
292
+
293
+ # Re-resolves attachment URLs by re-reading the messages that hold them.
294
+ #
295
+ # Chunks written together land in consecutive messages, so one paginated
296
+ # range read usually resolves the whole blob at a hundred attachments per
297
+ # request instead of one. Sparse or interleaved chunks fall back to
298
+ # individual fetches rather than paging a channel indefinitely.
299
+ #
300
+ # @param chunks [Array<Hash>]
301
+ # @return [Hash{String => String}] attachment_id => fresh URL
302
+ def resolve_urls(chunks)
303
+ wanted = chunks.to_h { |chunk| [chunk[:attachment_id].to_s, chunk] }
304
+ by_channel = chunks.group_by { |chunk| chunk[:channel_id] }
305
+ resolved = {}
306
+
307
+ by_channel.each do |channel_id, channel_chunks|
308
+ scan_range(channel_id, channel_chunks, wanted, resolved) if channel_chunks.size > 1
309
+ resolve_individually(channel_id, channel_chunks, wanted, resolved)
310
+ end
311
+
312
+ resolved
313
+ end
314
+
315
+ # One paginated sweep over the ID range the chunks occupy. Chunks written
316
+ # together are consecutive, so this usually resolves a whole blob at a
317
+ # hundred attachments per request.
318
+ def scan_range(channel_id, chunks, wanted, resolved)
319
+ ids = chunks.map { |chunk| chunk[:message_id].to_i }
320
+ cursor = (ids.min - 1).to_s
321
+ highest = ids.max
322
+
323
+ # Bounded, so interleaved or sparse chunks fall back rather than paging a
324
+ # busy channel indefinitely.
325
+ page_budget = [(chunks.size / 100.0).ceil * 2, 2].max
326
+
327
+ page_budget.times do
328
+ page = @rest.list_messages(channel_id, after: cursor, limit: 100)
329
+ break if page.empty?
330
+
331
+ page.each { |message| harvest(message, wanted, resolved) }
332
+ cursor = page.map { |message| message["id"].to_i }.max.to_s
333
+ break if cursor.to_i >= highest
334
+ end
335
+ end
336
+
337
+ def resolve_individually(channel_id, chunks, wanted, resolved)
338
+ chunks.each do |chunk|
339
+ next if resolved.key?(chunk[:attachment_id].to_s)
340
+
341
+ harvest(@rest.get_message(channel_id, chunk[:message_id]), wanted, resolved)
342
+ end
343
+ end
344
+
345
+ def harvest(message, wanted, resolved)
346
+ Array(message["attachments"]).each do |attachment|
347
+ id = attachment["id"].to_s
348
+ resolved[id] = attachment["url"] if wanted.key?(id)
349
+ end
350
+ end
351
+
352
+ def fetch_manifest!(key)
353
+ @index.get(key) || raise(NotFoundError.new("no blob stored under #{key.inspect}", status: 404))
354
+ end
355
+
356
+ def retire(manifest)
357
+ case config.delete_policy
358
+ when :tombstone
359
+ # Cheapest and safest: the bytes stay, the index forgets them. Storage is
360
+ # not reclaimed, which on somebody else's infrastructure is a choice
361
+ # worth making deliberately.
362
+ nil
363
+ else
364
+ manifest.chunks.group_by { |chunk| chunk[:channel_id] }.each do |channel_id, chunks|
365
+ @rest.bulk_delete_messages(channel_id, chunks.map { |chunk| chunk[:message_id] })
366
+ end
367
+ end
368
+ end
369
+
370
+ def safe_delete(channel_id, message_id)
371
+ @rest.delete_message(channel_id, message_id)
372
+ rescue APIError
373
+ nil
374
+ end
375
+
376
+ # Maps blob keys to manifests.
377
+ #
378
+ # Discord gives bots no search, so a lookup by key would otherwise mean
379
+ # scanning a channel. This scans it exactly once, at first use, and keeps the
380
+ # result. Manifests are one small message per blob, so the scan is a hundred
381
+ # blobs per request.
382
+ #
383
+ # Swap in your own if you would rather the index lived in Postgres: anything
384
+ # answering get/put/delete/keys will do.
385
+ class ManifestIndex
386
+ def initialize(log:)
387
+ @log = log
388
+ @entries = {}
389
+ @warm = false
390
+ @mutex = Mutex.new
391
+ end
392
+
393
+ # @param key [String]
394
+ # @return [Manifest, nil]
395
+ def get(key)
396
+ warm!
397
+ @mutex.synchronize { @entries[key.to_s] }
398
+ end
399
+
400
+ # @return [Array<String>]
401
+ def keys
402
+ warm!
403
+ @mutex.synchronize { @entries.keys }
404
+ end
405
+
406
+ # @param key [String]
407
+ # @param manifest [Manifest]
408
+ # @return [Manifest]
409
+ def put(key, manifest)
410
+ warm!
411
+ record = @log.append(stream: MANIFEST_STREAM, data: manifest.to_h)
412
+ @mutex.synchronize { @entries[key.to_s] = manifest.tap { |m| m.message_id = record.lsn } }
413
+ end
414
+
415
+ # @param key [String]
416
+ # @return [void]
417
+ def delete(key)
418
+ warm!
419
+ @log.append(stream: MANIFEST_STREAM, data: { "key" => key.to_s, "__deleted" => true })
420
+ @mutex.synchronize { @entries.delete(key.to_s) }
421
+ nil
422
+ end
423
+
424
+ # Replays the manifest channel. Later records win, so a rewrite or a
425
+ # delete simply appends and the last word stands.
426
+ #
427
+ # @param force [Boolean]
428
+ # @return [Integer] number of live manifests
429
+ def warm!(force: false)
430
+ @mutex.synchronize do
431
+ return @entries.size if @warm && !force
432
+
433
+ @entries.clear
434
+
435
+ @log.each(stream: MANIFEST_STREAM) do |record|
436
+ data = record.data
437
+ next unless data.is_a?(Hash)
438
+
439
+ if data["__deleted"]
440
+ @entries.delete(data["key"].to_s)
441
+ elsif data["key"]
442
+ @entries[data["key"].to_s] =
443
+ Manifest.from_h(data, channel_id: record.channel_id, message_id: record.lsn)
444
+ end
445
+ end
446
+
447
+ @warm = true
448
+ @entries.size
449
+ end
450
+ end
451
+ end
452
+ end
453
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module DiscordStore
6
+ # Routes a logical stream to one of several channels.
7
+ #
8
+ # Discord's harshest limit is five messages per five seconds *per channel*.
9
+ # The global token budget is ten times that, so a single-channel store leaves
10
+ # ninety percent of its allowance unused, and the only way to spend it is to
11
+ # write to more channels.
12
+ #
13
+ # That buys throughput and costs ordering, and it is worth being precise about
14
+ # which: message IDs are only strictly ordered within a channel, because
15
+ # Discord issues them from several workers. So this library makes the same
16
+ # promise Kafka does — a channel is a partition, records within a partition
17
+ # are totally ordered, and records in different partitions are not ordered
18
+ # with respect to each other at all. A stream always lands in the same
19
+ # partition, so per-stream order is total.
20
+ #
21
+ # If you need one order across everything, use one channel and accept the
22
+ # ceiling. Correctness first; the ceiling is a documented number, whereas a
23
+ # silently reordered write-ahead log is a bug you find much later.
24
+ class ChannelShard
25
+ # @return [Array<String>]
26
+ attr_reader :channel_ids
27
+
28
+ # @param channel_ids [Array<String>]
29
+ # @raise [ConfigurationError] if no channels were given
30
+ def initialize(channel_ids)
31
+ ids = Array(channel_ids).compact.map(&:to_s).uniq
32
+ raise ConfigurationError, "at least one channel id is required" if ids.empty?
33
+
34
+ @channel_ids = ids.freeze
35
+ end
36
+
37
+ # The channel that owns +key+.
38
+ #
39
+ # Rendezvous hashing rather than modulo, so that adding a channel moves only
40
+ # the fraction of streams that must move (1/n) instead of almost all of
41
+ # them. A stream that changes partition loses its ordering guarantee across
42
+ # the move, so the cheaper the reshuffle the better.
43
+ #
44
+ # @param key [#to_s] the stream name
45
+ # @return [String] a channel id
46
+ def for(key)
47
+ return @channel_ids.first if @channel_ids.one?
48
+
49
+ @channel_ids.max_by { |channel_id| weight(key, channel_id) }
50
+ end
51
+
52
+ # @return [Integer]
53
+ def size = @channel_ids.size
54
+
55
+ # @return [Boolean] whether this shard preserves a single total order
56
+ def totally_ordered? = @channel_ids.one?
57
+
58
+ # Every stream-to-channel assignment for a known set of streams, for
59
+ # inspection and for tests that assert stability across reconfiguration.
60
+ #
61
+ # @param keys [Array<String>]
62
+ # @return [Hash{String => String}]
63
+ def plan(keys)
64
+ keys.to_h { |key| [key.to_s, self.for(key)] }
65
+ end
66
+
67
+ private
68
+
69
+ def weight(key, channel_id)
70
+ Digest::SHA256.digest("#{key}\x00#{channel_id}").unpack1("Q>")
71
+ end
72
+ end
73
+ end