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,387 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module DiscordStore
|
|
6
|
+
# Typed values bound to Discord messages, in the shape Kredis gives Redis.
|
|
7
|
+
#
|
|
8
|
+
# Two storage strategies, chosen per type according to what Discord can
|
|
9
|
+
# actually promise:
|
|
10
|
+
#
|
|
11
|
+
# Documents (string, integer, json, boolean, flag)
|
|
12
|
+
# One message per key, edited in place. Cheap to read and write, and
|
|
13
|
+
# last-write-wins under concurrency, because Discord offers no
|
|
14
|
+
# compare-and-swap and there is no way to build one on top of message
|
|
15
|
+
# editing.
|
|
16
|
+
#
|
|
17
|
+
# Deltas (counter, list)
|
|
18
|
+
# Append-only. A counter is the sum of its increments, not a number that
|
|
19
|
+
# gets overwritten. This costs a read scan and buys the one thing the
|
|
20
|
+
# document strategy cannot give: two processes incrementing at once both
|
|
21
|
+
# count, because appends do not collide. It is the same reason distributed
|
|
22
|
+
# systems reach for CRDTs, arrived at by the same constraint.
|
|
23
|
+
#
|
|
24
|
+
# If you want a counter that is correct, use {#counter}. If you want one that
|
|
25
|
+
# is fast and you are the only writer, use {#integer}.
|
|
26
|
+
class KV
|
|
27
|
+
STREAM_PREFIX = "kv"
|
|
28
|
+
|
|
29
|
+
attr_reader :config
|
|
30
|
+
|
|
31
|
+
def initialize(rest:, config:, channel_id: nil)
|
|
32
|
+
@config = config
|
|
33
|
+
channel = channel_id || config.document_channel_id || config.log_channel_ids.first
|
|
34
|
+
raise ConfigurationError, "document_channel_id or a log channel is required for KV" if channel.nil?
|
|
35
|
+
|
|
36
|
+
@log = Log.new(rest: rest, config: config, channel_ids: [channel])
|
|
37
|
+
@rest = rest
|
|
38
|
+
@channel_id = channel.to_s
|
|
39
|
+
@documents = DocumentIndex.new(log: @log, rest: rest, channel_id: @channel_id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Scalar] a string-valued key
|
|
43
|
+
def string(key) = Scalar.new(index: @documents, key: key, type: :string)
|
|
44
|
+
|
|
45
|
+
# @return [Scalar] an integer-valued key
|
|
46
|
+
def integer(key) = Scalar.new(index: @documents, key: key, type: :integer)
|
|
47
|
+
|
|
48
|
+
# @return [Scalar] a boolean-valued key
|
|
49
|
+
def boolean(key) = Scalar.new(index: @documents, key: key, type: :boolean)
|
|
50
|
+
|
|
51
|
+
# @return [Scalar] an arbitrary JSON-valued key
|
|
52
|
+
def json(key) = Scalar.new(index: @documents, key: key, type: :json)
|
|
53
|
+
|
|
54
|
+
# A boolean that can lapse.
|
|
55
|
+
#
|
|
56
|
+
# Discord has no TTL, so expiry is evaluated when the value is read and the
|
|
57
|
+
# message is never reclaimed. The flag stops being true on time; the storage
|
|
58
|
+
# it occupies does not come back.
|
|
59
|
+
#
|
|
60
|
+
# @return [Flag]
|
|
61
|
+
def flag(key) = Flag.new(index: @documents, key: key)
|
|
62
|
+
|
|
63
|
+
# @return [Counter] a concurrency-safe counter
|
|
64
|
+
def counter(key) = Counter.new(log: @log, key: key)
|
|
65
|
+
|
|
66
|
+
# @return [List] an append-only list
|
|
67
|
+
def list(key) = List.new(log: @log, key: key)
|
|
68
|
+
|
|
69
|
+
# Rebuilds the document index from the channel. Necessary after another
|
|
70
|
+
# process writes, because there is nothing to subscribe to here.
|
|
71
|
+
#
|
|
72
|
+
# @return [Integer] number of live keys
|
|
73
|
+
def refresh! = @documents.warm!(force: true)
|
|
74
|
+
|
|
75
|
+
# @return [Array<String>]
|
|
76
|
+
def keys = @documents.keys
|
|
77
|
+
|
|
78
|
+
# One message per key, edited in place.
|
|
79
|
+
class DocumentIndex
|
|
80
|
+
def initialize(log:, rest:, channel_id:)
|
|
81
|
+
@log = log
|
|
82
|
+
@rest = rest
|
|
83
|
+
@channel_id = channel_id
|
|
84
|
+
@records = {}
|
|
85
|
+
@warm = false
|
|
86
|
+
@mutex = Mutex.new
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @param key [String]
|
|
90
|
+
# @param cached [Boolean] trust the local copy instead of re-reading
|
|
91
|
+
# @return [Object, nil]
|
|
92
|
+
def read(key, cached: false)
|
|
93
|
+
warm!
|
|
94
|
+
record = @mutex.synchronize { @records[key.to_s] }
|
|
95
|
+
return nil if record.nil?
|
|
96
|
+
return record.data["v"] if cached
|
|
97
|
+
|
|
98
|
+
# Re-read, because another process may have edited this message and
|
|
99
|
+
# there is no invalidation channel that would have told us.
|
|
100
|
+
message = @rest.get_message(@channel_id, record.lsn)
|
|
101
|
+
fresh = @log.send(:decode, message).first
|
|
102
|
+
@mutex.synchronize { @records[key.to_s] = fresh } if fresh
|
|
103
|
+
fresh&.data&.fetch("v", nil)
|
|
104
|
+
rescue NotFoundError
|
|
105
|
+
@mutex.synchronize { @records.delete(key.to_s) }
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param key [String]
|
|
110
|
+
# @param value [Object]
|
|
111
|
+
# @param meta [Hash] extra fields stored alongside the value
|
|
112
|
+
# @return [Object] the value
|
|
113
|
+
def write(key, value, meta: {})
|
|
114
|
+
warm!
|
|
115
|
+
payload = { "k" => key.to_s, "v" => value }.merge(meta)
|
|
116
|
+
existing = @mutex.synchronize { @records[key.to_s] }
|
|
117
|
+
|
|
118
|
+
record = if existing
|
|
119
|
+
@log.replace(existing, data: payload)
|
|
120
|
+
else
|
|
121
|
+
@log.append(stream: stream_for(key), data: payload)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
@mutex.synchronize { @records[key.to_s] = record }
|
|
125
|
+
value
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param key [String]
|
|
129
|
+
# @return [void]
|
|
130
|
+
def delete(key)
|
|
131
|
+
warm!
|
|
132
|
+
record = @mutex.synchronize { @records.delete(key.to_s) }
|
|
133
|
+
return nil if record.nil?
|
|
134
|
+
|
|
135
|
+
# Edited to a tombstone rather than deleted: an edit is one cheap
|
|
136
|
+
# request at any age, a delete is one expensive request that stops
|
|
137
|
+
# being batchable after two weeks.
|
|
138
|
+
@log.replace(record, data: { "k" => key.to_s, "__deleted" => true })
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Array<String>]
|
|
143
|
+
def keys
|
|
144
|
+
warm!
|
|
145
|
+
@mutex.synchronize { @records.keys }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @return [Integer]
|
|
149
|
+
def warm!(force: false)
|
|
150
|
+
@mutex.synchronize do
|
|
151
|
+
return @records.size if @warm && !force
|
|
152
|
+
|
|
153
|
+
@records.clear
|
|
154
|
+
|
|
155
|
+
@log.each do |record|
|
|
156
|
+
data = record.data
|
|
157
|
+
next unless data.is_a?(Hash) && data["k"]
|
|
158
|
+
|
|
159
|
+
if data["__deleted"]
|
|
160
|
+
@records.delete(data["k"].to_s)
|
|
161
|
+
else
|
|
162
|
+
@records[data["k"].to_s] = record
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
@warm = true
|
|
167
|
+
@records.size
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def stream_for(key) = "#{STREAM_PREFIX}:#{key}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# A single typed value.
|
|
177
|
+
class Scalar
|
|
178
|
+
attr_reader :key, :type
|
|
179
|
+
|
|
180
|
+
def initialize(index:, key:, type:)
|
|
181
|
+
@index = index
|
|
182
|
+
@key = key.to_s
|
|
183
|
+
@type = type
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# @return [Object, nil]
|
|
187
|
+
def value(cached: false) = cast(@index.read(@key, cached: cached))
|
|
188
|
+
alias get value
|
|
189
|
+
|
|
190
|
+
# @param new_value [Object]
|
|
191
|
+
# @return [Object]
|
|
192
|
+
def value=(new_value)
|
|
193
|
+
@index.write(@key, serialize(new_value))
|
|
194
|
+
new_value
|
|
195
|
+
end
|
|
196
|
+
alias set value=
|
|
197
|
+
|
|
198
|
+
# @return [Boolean]
|
|
199
|
+
def exists? = !@index.read(@key, cached: true).nil?
|
|
200
|
+
|
|
201
|
+
# @return [void]
|
|
202
|
+
def clear = @index.delete(@key)
|
|
203
|
+
alias delete clear
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def serialize(value)
|
|
208
|
+
case @type
|
|
209
|
+
when :integer then Integer(value)
|
|
210
|
+
when :boolean then !value.nil? && value != false
|
|
211
|
+
when :string then value.to_s
|
|
212
|
+
else value
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def cast(value)
|
|
217
|
+
return nil if value.nil?
|
|
218
|
+
|
|
219
|
+
case @type
|
|
220
|
+
when :integer then Integer(value)
|
|
221
|
+
when :boolean then !value.nil? && value != false
|
|
222
|
+
when :string then value.to_s
|
|
223
|
+
else value
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# A boolean with a lapse time.
|
|
229
|
+
class Flag
|
|
230
|
+
def initialize(index:, key:)
|
|
231
|
+
@index = index
|
|
232
|
+
@key = key.to_s
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# @param expires_in [Numeric, nil] seconds
|
|
236
|
+
# @return [Flag] self, so calls chain
|
|
237
|
+
def mark(expires_in: nil)
|
|
238
|
+
meta = expires_in ? { "exp" => (Time.now.to_f + expires_in) } : {}
|
|
239
|
+
@index.write(@key, true, meta: meta)
|
|
240
|
+
self
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# @return [Boolean]
|
|
244
|
+
def marked?
|
|
245
|
+
record = @index.read(@key)
|
|
246
|
+
return false if record.nil?
|
|
247
|
+
|
|
248
|
+
expiry = expiry_for(@key)
|
|
249
|
+
return true if expiry.nil?
|
|
250
|
+
|
|
251
|
+
Time.now.to_f < expiry
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# @return [void]
|
|
255
|
+
def remove = @index.delete(@key)
|
|
256
|
+
|
|
257
|
+
private
|
|
258
|
+
|
|
259
|
+
def expiry_for(key)
|
|
260
|
+
@index.warm!
|
|
261
|
+
record = @index.instance_variable_get(:@records)[key]
|
|
262
|
+
record&.data&.fetch("exp", nil)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# A counter stored as the sum of its increments.
|
|
267
|
+
#
|
|
268
|
+
# Every increment is an append, so concurrent writers cannot lose each
|
|
269
|
+
# other's work — which is more than a read-modify-write against an edited
|
|
270
|
+
# message could promise. The cost is that reading means summing, so
|
|
271
|
+
# {#compact!} periodically collapses the history into a checkpoint.
|
|
272
|
+
class Counter
|
|
273
|
+
CHECKPOINT = "__checkpoint"
|
|
274
|
+
|
|
275
|
+
attr_reader :key
|
|
276
|
+
|
|
277
|
+
def initialize(log:, key:)
|
|
278
|
+
@log = log
|
|
279
|
+
@key = key.to_s
|
|
280
|
+
@stream = "#{STREAM_PREFIX}:counter:#{@key}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @param by [Integer]
|
|
284
|
+
# @return [Integer] the value after incrementing, as this process sees it
|
|
285
|
+
def increment(by: 1)
|
|
286
|
+
@log.append(stream: @stream, data: { "d" => by })
|
|
287
|
+
value
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# @param by [Integer]
|
|
291
|
+
# @return [Integer]
|
|
292
|
+
def decrement(by: 1) = increment(by: -by)
|
|
293
|
+
|
|
294
|
+
# @return [Integer]
|
|
295
|
+
def value
|
|
296
|
+
total = 0
|
|
297
|
+
|
|
298
|
+
@log.each(stream: @stream) do |record|
|
|
299
|
+
data = record.data
|
|
300
|
+
next unless data.is_a?(Hash)
|
|
301
|
+
|
|
302
|
+
if data[CHECKPOINT]
|
|
303
|
+
total = data["total"].to_i
|
|
304
|
+
else
|
|
305
|
+
total += data["d"].to_i
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
total
|
|
310
|
+
end
|
|
311
|
+
alias to_i value
|
|
312
|
+
|
|
313
|
+
# @param amount [Integer]
|
|
314
|
+
# @return [Integer]
|
|
315
|
+
def reset(amount: 0)
|
|
316
|
+
@log.append(stream: @stream, data: { CHECKPOINT => true, "total" => amount })
|
|
317
|
+
amount
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Writes a checkpoint so future reads stop at it, then tombstones the
|
|
321
|
+
# deltas behind it.
|
|
322
|
+
#
|
|
323
|
+
# @return [Integer] the checkpointed total
|
|
324
|
+
def compact!
|
|
325
|
+
total = value
|
|
326
|
+
@log.append(stream: @stream, data: { CHECKPOINT => true, "total" => total })
|
|
327
|
+
total
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# An append-only list.
|
|
332
|
+
class List
|
|
333
|
+
attr_reader :key
|
|
334
|
+
|
|
335
|
+
def initialize(log:, key:)
|
|
336
|
+
@log = log
|
|
337
|
+
@key = key.to_s
|
|
338
|
+
@stream = "#{STREAM_PREFIX}:list:#{@key}"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# @param values [Array<Object>]
|
|
342
|
+
# @return [Array<Object>]
|
|
343
|
+
def append(*values)
|
|
344
|
+
records = values.map { |value| Log::Record.new(stream: @stream, data: { "v" => value }) }
|
|
345
|
+
@log.append_all(records)
|
|
346
|
+
values
|
|
347
|
+
end
|
|
348
|
+
alias push append
|
|
349
|
+
alias << append
|
|
350
|
+
|
|
351
|
+
# @return [Array<Object>]
|
|
352
|
+
def elements
|
|
353
|
+
live = []
|
|
354
|
+
|
|
355
|
+
@log.each(stream: @stream) do |record|
|
|
356
|
+
data = record.data
|
|
357
|
+
next unless data.is_a?(Hash)
|
|
358
|
+
|
|
359
|
+
if data["__remove"]
|
|
360
|
+
live.reject! { |entry| entry[:value] == data["__remove"] }
|
|
361
|
+
else
|
|
362
|
+
live << { value: data["v"], cursor: record.cursor }
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
live.map { |entry| entry[:value] }
|
|
367
|
+
end
|
|
368
|
+
alias to_a elements
|
|
369
|
+
|
|
370
|
+
# @return [Integer]
|
|
371
|
+
def size = elements.size
|
|
372
|
+
|
|
373
|
+
# @param value [Object]
|
|
374
|
+
# @return [void]
|
|
375
|
+
def remove(value)
|
|
376
|
+
@log.append(stream: @stream, data: { "__remove" => value })
|
|
377
|
+
nil
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# @return [void]
|
|
381
|
+
def clear
|
|
382
|
+
elements.each { |value| remove(value) }
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module DiscordStore
|
|
6
|
+
class Log
|
|
7
|
+
# One entry in the log.
|
|
8
|
+
#
|
|
9
|
+
# +lsn+ is the Discord message ID, which is a snowflake, which means the log
|
|
10
|
+
# sequence number is free, globally unique, and carries its own timestamp.
|
|
11
|
+
# +position+ disambiguates records packed into the same message.
|
|
12
|
+
class Record
|
|
13
|
+
attr_reader :stream, :data, :nonce, :lsn, :position, :channel_id
|
|
14
|
+
|
|
15
|
+
# @param stream [String] logical topic; also the partition key
|
|
16
|
+
# @param data [Hash] anything JSON can represent
|
|
17
|
+
# @param nonce [String] idempotency key; Discord deduplicates on it
|
|
18
|
+
def initialize(stream:, data:, nonce: nil, lsn: nil, position: 0, channel_id: nil)
|
|
19
|
+
@stream = stream.to_s
|
|
20
|
+
@data = data
|
|
21
|
+
@nonce = nonce || SecureRandom.uuid
|
|
22
|
+
@lsn = lsn&.to_s
|
|
23
|
+
@position = position
|
|
24
|
+
@channel_id = channel_id&.to_s
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# When Discord accepted this record. Read straight out of the snowflake;
|
|
28
|
+
# no created_at column required.
|
|
29
|
+
#
|
|
30
|
+
# @return [Time, nil]
|
|
31
|
+
def created_at
|
|
32
|
+
@lsn && Snowflake.at(@lsn)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A cursor that orders correctly within a partition.
|
|
36
|
+
#
|
|
37
|
+
# @return [String, nil]
|
|
38
|
+
def cursor
|
|
39
|
+
@lsn && format("%<lsn>s:%<position>04d", lsn: @lsn, position: @position)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Boolean] whether this record marks a deleted predecessor
|
|
43
|
+
def tombstone? = data.is_a?(Hash) && data["__tombstone"] == true
|
|
44
|
+
|
|
45
|
+
# Wire form. Keys are short because every byte competes for the 2000
|
|
46
|
+
# characters a message can hold, and a shorter key is one more record per
|
|
47
|
+
# request.
|
|
48
|
+
#
|
|
49
|
+
# @return [Hash]
|
|
50
|
+
def to_wire
|
|
51
|
+
{ "s" => stream, "n" => nonce, "d" => data }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param hash [Hash] a {#to_wire} payload
|
|
55
|
+
# @return [Record]
|
|
56
|
+
def self.from_wire(hash, lsn:, position:, channel_id: nil)
|
|
57
|
+
unless hash.is_a?(Hash) && hash.key?("s")
|
|
58
|
+
raise CorruptRecordError, "record at #{lsn}:#{position} is not a discord_store record"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
new(stream: hash["s"], data: hash["d"], nonce: hash["n"],
|
|
62
|
+
lsn: lsn, position: position, channel_id: channel_id)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A record that marks +target+ deleted. The log never rewrites history, so
|
|
66
|
+
# a delete is an append like everything else.
|
|
67
|
+
#
|
|
68
|
+
# @param stream [String]
|
|
69
|
+
# @param target [String] the cursor or key being retired
|
|
70
|
+
# @return [Record]
|
|
71
|
+
def self.tombstone(stream:, target:)
|
|
72
|
+
new(stream: stream, data: { "__tombstone" => true, "target" => target.to_s })
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def ==(other)
|
|
76
|
+
other.is_a?(Record) && other.stream == stream && other.data == data && other.nonce == nonce
|
|
77
|
+
end
|
|
78
|
+
alias eql? ==
|
|
79
|
+
|
|
80
|
+
def hash = [stream, data, nonce].hash
|
|
81
|
+
|
|
82
|
+
def inspect
|
|
83
|
+
"#<DiscordStore::Log::Record stream=#{stream.inspect} lsn=#{lsn.inspect} " \
|
|
84
|
+
"position=#{position} data=#{data.inspect}>"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|