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
|
@@ -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
|