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