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,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "log/record"
|
|
4
|
+
|
|
5
|
+
module DiscordStore
|
|
6
|
+
# An append-only log whose storage is a set of Discord channels.
|
|
7
|
+
#
|
|
8
|
+
# This is the layer everything else is built on, and it is append-only for a
|
|
9
|
+
# reason that is worth stating plainly, because it is the joke at the centre
|
|
10
|
+
# of this library.
|
|
11
|
+
#
|
|
12
|
+
# Deleting from Discord is expensive and gets worse with age: bulk deletion
|
|
13
|
+
# only covers messages under two weeks old, and past that it is one request
|
|
14
|
+
# per message against a rate limit that is already the binding constraint. So
|
|
15
|
+
# the cheap way to retire a record is to append a marker saying it is gone and
|
|
16
|
+
# let the reader skip it.
|
|
17
|
+
#
|
|
18
|
+
# That marker is a tombstone. Discord's own storage layer, Cassandra, did the
|
|
19
|
+
# same thing for the same reason — and the volume of tombstones a chat app
|
|
20
|
+
# generates is precisely what made Cassandra untenable for them and forced the
|
|
21
|
+
# migration to ScyllaDB. Building a store on Discord means writing tombstones
|
|
22
|
+
# into a database whose tombstones are stored as tombstones.
|
|
23
|
+
#
|
|
24
|
+
# Compaction is therefore not optional at scale; see {#compact}.
|
|
25
|
+
class Log
|
|
26
|
+
# @return [ChannelShard]
|
|
27
|
+
attr_reader :shard
|
|
28
|
+
|
|
29
|
+
# @return [Transport::REST]
|
|
30
|
+
attr_reader :rest
|
|
31
|
+
|
|
32
|
+
# @param rest [Transport::REST]
|
|
33
|
+
# @param config [Configuration]
|
|
34
|
+
# @param channel_ids [Array<String>, nil] defaults to config.log_channel_ids
|
|
35
|
+
# @param cipher [#seal, #open, nil]
|
|
36
|
+
def initialize(rest:, config:, channel_ids: nil, cipher: nil)
|
|
37
|
+
@rest = rest
|
|
38
|
+
@config = config
|
|
39
|
+
@shard = ChannelShard.new(channel_ids || config.log_channel_ids)
|
|
40
|
+
@cipher = cipher || Cipher.build(config)
|
|
41
|
+
@codec = Codec.new(cipher: @cipher, content_budget: config.content_budget)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Appends one record.
|
|
45
|
+
#
|
|
46
|
+
# @param stream [String] logical topic and partition key
|
|
47
|
+
# @param data [Hash]
|
|
48
|
+
# @param nonce [String, nil] pass a stable value to make a retry idempotent
|
|
49
|
+
# @return [Log::Record] with +lsn+ assigned
|
|
50
|
+
def append(stream:, data:, nonce: nil)
|
|
51
|
+
append_all([Record.new(stream: stream, data: data, nonce: nonce)]).first
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Appends several records.
|
|
55
|
+
#
|
|
56
|
+
# Records are grouped by partition and packed, so N records cost far fewer
|
|
57
|
+
# than N requests. With +atomic+, every record must land in one message or
|
|
58
|
+
# the call raises: one message is the largest unit Discord commits
|
|
59
|
+
# all-or-nothing, and that is the only atomicity on offer here.
|
|
60
|
+
#
|
|
61
|
+
# @param records [Array<Log::Record>]
|
|
62
|
+
# @param atomic [Boolean]
|
|
63
|
+
# @return [Array<Log::Record>] the same records, with +lsn+ assigned
|
|
64
|
+
def append_all(records, atomic: false)
|
|
65
|
+
records = Array(records)
|
|
66
|
+
return [] if records.empty?
|
|
67
|
+
|
|
68
|
+
by_channel = records.group_by { |record| @shard.for(record.stream) }
|
|
69
|
+
|
|
70
|
+
if atomic && by_channel.size > 1
|
|
71
|
+
raise PayloadTooLargeError,
|
|
72
|
+
"an atomic batch spans #{by_channel.size} partitions (#{by_channel.keys.join(", ")}). " \
|
|
73
|
+
"Records that must commit together must share a partition; give them the same " \
|
|
74
|
+
"stream, or configure a single log channel."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
by_channel.flat_map do |channel_id, channel_records|
|
|
78
|
+
write_to_channel(channel_id, channel_records, atomic: atomic)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Buffers appends and writes them as one atomic batch on success.
|
|
83
|
+
#
|
|
84
|
+
# log.transaction do |tx|
|
|
85
|
+
# tx.append(stream: "orders", data: { id: 1 })
|
|
86
|
+
# tx.append(stream: "orders", data: { id: 2 })
|
|
87
|
+
# end
|
|
88
|
+
#
|
|
89
|
+
# Nothing is written if the block raises.
|
|
90
|
+
#
|
|
91
|
+
# @yieldparam buffer [Buffer]
|
|
92
|
+
# @return [Array<Log::Record>]
|
|
93
|
+
def transaction
|
|
94
|
+
buffer = Buffer.new
|
|
95
|
+
yield buffer
|
|
96
|
+
return [] if buffer.empty?
|
|
97
|
+
|
|
98
|
+
append_all(buffer.records, atomic: true)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Reads records in partition order.
|
|
102
|
+
#
|
|
103
|
+
# @param stream [String, nil] a single stream, or nil for every partition
|
|
104
|
+
# @param after [String, nil] a cursor from {Record#cursor}; exclusive
|
|
105
|
+
# @param limit [Integer, nil]
|
|
106
|
+
# @yieldparam record [Log::Record]
|
|
107
|
+
# @return [Array<Log::Record>, void]
|
|
108
|
+
def each(stream: nil, after: nil, limit: nil, &block)
|
|
109
|
+
return enum_for(:each, stream: stream, after: after, limit: limit) unless block
|
|
110
|
+
|
|
111
|
+
channels = stream ? [@shard.for(stream)] : @shard.channel_ids
|
|
112
|
+
count = 0
|
|
113
|
+
|
|
114
|
+
channels.each do |channel_id|
|
|
115
|
+
read_channel(channel_id, after: after, stream: stream) do |record|
|
|
116
|
+
block.call(record)
|
|
117
|
+
count += 1
|
|
118
|
+
# Non-local on purpose: the limit applies to the whole walk across
|
|
119
|
+
# every partition, not to the current channel.
|
|
120
|
+
return if limit && count >= limit # rubocop:disable Lint/NonLocalExitFromIterator
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [Array<Log::Record>]
|
|
126
|
+
def read(stream: nil, after: nil, limit: nil)
|
|
127
|
+
each(stream: stream, after: after, limit: limit).to_a
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The cursor of the most recent record in a partition, or nil if empty.
|
|
131
|
+
#
|
|
132
|
+
# @param stream [String, nil]
|
|
133
|
+
# @return [String, nil]
|
|
134
|
+
def tip(stream: nil)
|
|
135
|
+
channel_id = stream ? @shard.for(stream) : @shard.channel_ids.first
|
|
136
|
+
message = @rest.list_messages(channel_id, limit: 1).first
|
|
137
|
+
return nil unless message && @codec.ours?(message)
|
|
138
|
+
|
|
139
|
+
records = decode(message)
|
|
140
|
+
records.last&.cursor
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Appends a tombstone for +target+.
|
|
144
|
+
#
|
|
145
|
+
# @param stream [String]
|
|
146
|
+
# @param target [String] the cursor or key being retired
|
|
147
|
+
# @return [Log::Record]
|
|
148
|
+
def tombstone(stream:, target:)
|
|
149
|
+
append_all([Record.tombstone(stream: stream, target: target)]).first
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Rewrites a message in place. Only valid for a message holding exactly one
|
|
153
|
+
# record; a packed message cannot be edited record-wise without rewriting
|
|
154
|
+
# its neighbours, and rewriting history in an append-only log is how replays
|
|
155
|
+
# start disagreeing with each other.
|
|
156
|
+
#
|
|
157
|
+
# @param record [Log::Record]
|
|
158
|
+
# @param data [Hash]
|
|
159
|
+
# @return [Log::Record]
|
|
160
|
+
def replace(record, data:)
|
|
161
|
+
raise ArgumentError, "record has no lsn" unless record.lsn
|
|
162
|
+
|
|
163
|
+
channel_id = record.channel_id || @shard.for(record.stream)
|
|
164
|
+
updated = Record.new(stream: record.stream, data: data, nonce: record.nonce,
|
|
165
|
+
lsn: record.lsn, position: 0, channel_id: channel_id)
|
|
166
|
+
|
|
167
|
+
packed = @codec.pack([updated], aad: channel_id, atomic: true).first
|
|
168
|
+
|
|
169
|
+
unless packed[:files].empty?
|
|
170
|
+
raise PayloadTooLargeError, "replacement spills to an attachment; append a new record instead"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
@rest.edit_message(channel_id, record.lsn, content: packed[:content])
|
|
174
|
+
updated
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Discards tombstoned records by physically deleting their messages.
|
|
178
|
+
#
|
|
179
|
+
# This is the expensive path, and the only one that reclaims anything. It is
|
|
180
|
+
# deliberately explicit rather than automatic: it costs one request per
|
|
181
|
+
# message once the two-week bulk-delete window has closed, which at the
|
|
182
|
+
# per-channel limit is roughly one message per second.
|
|
183
|
+
#
|
|
184
|
+
# @param stream [String]
|
|
185
|
+
# @param dry_run [Boolean] report what would be deleted without deleting
|
|
186
|
+
# @return [Hash] {scanned:, tombstoned:, deleted:, too_old:}
|
|
187
|
+
def compact(stream:, dry_run: false)
|
|
188
|
+
channel_id = @shard.for(stream)
|
|
189
|
+
targets = []
|
|
190
|
+
scanned = 0
|
|
191
|
+
|
|
192
|
+
read_channel(channel_id, stream: stream) do |record|
|
|
193
|
+
scanned += 1
|
|
194
|
+
targets << record.data["target"] if record.tombstone?
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
doomed = targets.filter_map { |cursor| cursor.to_s.split(":").first }.uniq
|
|
198
|
+
|
|
199
|
+
return { scanned: scanned, tombstoned: doomed.size, deleted: 0, too_old: 0, dry_run: true } if dry_run
|
|
200
|
+
|
|
201
|
+
too_old = @rest.bulk_delete_messages(channel_id, doomed)
|
|
202
|
+
|
|
203
|
+
if @config.delete_policy == :aggressive
|
|
204
|
+
too_old.each { |id| @rest.delete_message(channel_id, id) }
|
|
205
|
+
too_old = []
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
{ scanned: scanned, tombstoned: doomed.size,
|
|
209
|
+
deleted: doomed.size - too_old.size, too_old: too_old.size, dry_run: false }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def write_to_channel(channel_id, records, atomic:)
|
|
215
|
+
packed = @codec.pack(records, aad: channel_id, atomic: atomic)
|
|
216
|
+
|
|
217
|
+
packed.flat_map do |message|
|
|
218
|
+
# One nonce per message, derived from the first record, so that a retry
|
|
219
|
+
# after a timeout is deduplicated by Discord rather than duplicated by us.
|
|
220
|
+
response = @rest.create_message(
|
|
221
|
+
channel_id,
|
|
222
|
+
content: message[:content],
|
|
223
|
+
nonce: message[:records].first.nonce,
|
|
224
|
+
files: message[:files]
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
message[:records].each_with_index.map do |record, position|
|
|
228
|
+
Record.new(stream: record.stream, data: record.data, nonce: record.nonce,
|
|
229
|
+
lsn: response["id"], position: position, channel_id: channel_id)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def read_channel(channel_id, after: nil, stream: nil)
|
|
235
|
+
after_param, cursor_lsn, skip_position = parse_cursor(after)
|
|
236
|
+
|
|
237
|
+
@rest.each_message(channel_id, after: after_param) do |message|
|
|
238
|
+
next unless @codec.ours?(message)
|
|
239
|
+
|
|
240
|
+
decode(message).each do |record|
|
|
241
|
+
next if cursor_lsn && record.lsn == cursor_lsn && record.position <= skip_position
|
|
242
|
+
next if stream && record.stream != stream
|
|
243
|
+
|
|
244
|
+
yield record
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def decode(message)
|
|
250
|
+
body = @codec.spilled?(message) ? fetch_spill(message) : nil
|
|
251
|
+
@codec.unpack(message, attachment_body: body, aad: message["channel_id"])
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Re-resolves the attachment URL from the message we are holding right now.
|
|
255
|
+
# A URL read from anywhere else is a URL that has probably expired.
|
|
256
|
+
def fetch_spill(message)
|
|
257
|
+
attachment = message["attachments"]&.first
|
|
258
|
+
unless attachment
|
|
259
|
+
raise CorruptRecordError,
|
|
260
|
+
"message #{message["id"]} claims a spill but has no attachment"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
@rest.download(attachment["url"])
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Discord's +after+ is exclusive on message ID, so resuming inside a packed
|
|
267
|
+
# message means asking for the message itself back and then skipping the
|
|
268
|
+
# records already consumed.
|
|
269
|
+
#
|
|
270
|
+
# Returns three values, and the distinction between the first two matters:
|
|
271
|
+
# the bound sent to Discord is the cursor's message ID minus one, while the
|
|
272
|
+
# ID compared against each record is the cursor's own. Using the decremented
|
|
273
|
+
# value for both silently re-delivers the last record of every resume.
|
|
274
|
+
#
|
|
275
|
+
# @return [Array(String, String, Integer)] api bound, cursor lsn, skip position
|
|
276
|
+
def parse_cursor(cursor)
|
|
277
|
+
return [nil, nil, nil] if cursor.nil?
|
|
278
|
+
|
|
279
|
+
lsn, position = cursor.to_s.split(":")
|
|
280
|
+
[(lsn.to_i - 1).to_s, lsn, position.to_i]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Collects records for {Log#transaction}.
|
|
284
|
+
class Buffer
|
|
285
|
+
attr_reader :records
|
|
286
|
+
|
|
287
|
+
def initialize = @records = []
|
|
288
|
+
|
|
289
|
+
def append(stream:, data:, nonce: nil)
|
|
290
|
+
record = Record.new(stream: stream, data: data, nonce: nonce)
|
|
291
|
+
@records << record
|
|
292
|
+
record
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def empty? = @records.empty?
|
|
296
|
+
def size = @records.size
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module DiscordStore
|
|
6
|
+
# Loads the rake tasks and the adapter into a Rails application.
|
|
7
|
+
#
|
|
8
|
+
# # Gemfile
|
|
9
|
+
# gem "discord_store", require: "discord_store/railtie"
|
|
10
|
+
class Railtie < ::Rails::Railtie
|
|
11
|
+
rake_tasks do
|
|
12
|
+
load File.expand_path("tasks.rake", __dir__)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
initializer "discord_store.adapter" do
|
|
16
|
+
ActiveSupport.on_load(:active_record) do
|
|
17
|
+
require "active_record/connection_adapters/discord_adapter"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
initializer "discord_store.active_storage" do
|
|
22
|
+
ActiveSupport.on_load(:active_storage_blob) do
|
|
23
|
+
require "active_storage/service/discord_service"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DiscordStore
|
|
4
|
+
# Rebuilds a local database from the Discord log.
|
|
5
|
+
#
|
|
6
|
+
# This is the operation that decides whether any of this is a real design or a
|
|
7
|
+
# stunt. If the channel is the source of truth, then a machine holding no
|
|
8
|
+
# local file must be able to reach the same state by reading it, and it must
|
|
9
|
+
# reach the *same* state every time. That is the whole contract:
|
|
10
|
+
#
|
|
11
|
+
# rm storage/production.sqlite3 && rake discord:replay
|
|
12
|
+
#
|
|
13
|
+
# should leave the database exactly as it was, on any machine, from nothing
|
|
14
|
+
# but a bot token and a channel ID.
|
|
15
|
+
#
|
|
16
|
+
# Statements replay inside a local transaction per journalled transaction, so
|
|
17
|
+
# a batch that was atomic when written is atomic when replayed.
|
|
18
|
+
class Replay
|
|
19
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
|
20
|
+
# @param journal [Journal]
|
|
21
|
+
def initialize(connection:, journal:)
|
|
22
|
+
@connection = connection
|
|
23
|
+
@journal = journal
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param from [String, nil] cursor to resume after; nil replays everything
|
|
27
|
+
# @param progress [#call, nil] called with (applied, cursor) periodically
|
|
28
|
+
# @return [Hash] {applied:, statements:, cursor:, skipped:}
|
|
29
|
+
def call(from: nil, progress: nil)
|
|
30
|
+
applied = 0
|
|
31
|
+
statements = 0
|
|
32
|
+
skipped = 0
|
|
33
|
+
cursor = from
|
|
34
|
+
|
|
35
|
+
# Replay is a rewrite of local state from the log, so it must not be
|
|
36
|
+
# journalled back into the log. Without this a replay would duplicate
|
|
37
|
+
# every transaction it read.
|
|
38
|
+
@journal.while_paused do
|
|
39
|
+
# Inside the pause, because reading the stored cursor creates the
|
|
40
|
+
# bookkeeping table, and that DDL must not become a log entry.
|
|
41
|
+
from ||= stored_cursor
|
|
42
|
+
cursor = from
|
|
43
|
+
|
|
44
|
+
@journal.each_transaction(from: from) do |entry, record_cursor|
|
|
45
|
+
begin
|
|
46
|
+
apply(entry["tx"])
|
|
47
|
+
applied += 1
|
|
48
|
+
statements += entry["tx"].size
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
skipped += 1
|
|
51
|
+
warn "discord_store: replay skipped #{record_cursor}: #{e.class}: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
cursor = record_cursor
|
|
55
|
+
store_cursor(cursor)
|
|
56
|
+
progress&.call(applied, cursor)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@journal.local_cursor = cursor
|
|
61
|
+
{ applied: applied, statements: statements, cursor: cursor, skipped: skipped }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# How far behind the local database is.
|
|
65
|
+
#
|
|
66
|
+
# @return [Hash] {local:, remote:, behind:}
|
|
67
|
+
def status
|
|
68
|
+
local = stored_cursor
|
|
69
|
+
remote = @journal.tip
|
|
70
|
+
{ local: local, remote: remote, behind: local != remote }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def apply(transaction)
|
|
76
|
+
@connection.transaction(requires_new: true) do
|
|
77
|
+
transaction.each do |statement|
|
|
78
|
+
sql = statement["sql"]
|
|
79
|
+
binds = Journal.deserialize_binds(statement["binds"])
|
|
80
|
+
|
|
81
|
+
if binds.empty?
|
|
82
|
+
@connection.execute(sql)
|
|
83
|
+
else
|
|
84
|
+
@connection.exec_query(sql, "Replay", type_casted_binds(sql, binds))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Binds arrive as plain values, but exec_query wants attribute objects.
|
|
91
|
+
#
|
|
92
|
+
# Binary values need the binary type specifically: handed to the generic
|
|
93
|
+
# value type, SQLite's adapter tries to read them as UTF-8 and a byte like
|
|
94
|
+
# 0xFF ends the replay.
|
|
95
|
+
def type_casted_binds(_sql, binds)
|
|
96
|
+
binds.map do |value|
|
|
97
|
+
type = if value.is_a?(String) && (value.encoding == Encoding::BINARY || !value.valid_encoding?)
|
|
98
|
+
ActiveModel::Type::Binary.new
|
|
99
|
+
else
|
|
100
|
+
ActiveModel::Type::Value.new
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
ActiveRecord::Relation::QueryAttribute.new(nil, value, type)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def stored_cursor
|
|
108
|
+
ensure_cursor_table
|
|
109
|
+
row = @connection.select_one("SELECT cursor FROM #{Journal::CURSOR_TABLE} WHERE id = 1")
|
|
110
|
+
row && row["cursor"]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def store_cursor(cursor)
|
|
114
|
+
return if cursor.nil?
|
|
115
|
+
|
|
116
|
+
ensure_cursor_table
|
|
117
|
+
quoted = @connection.quote(cursor)
|
|
118
|
+
@connection.execute(
|
|
119
|
+
"INSERT INTO #{Journal::CURSOR_TABLE} (id, cursor) VALUES (1, #{quoted}) " \
|
|
120
|
+
"ON CONFLICT(id) DO UPDATE SET cursor = #{quoted}"
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def ensure_cursor_table
|
|
125
|
+
return if @cursor_table_ready
|
|
126
|
+
|
|
127
|
+
@connection.execute(<<~SQL)
|
|
128
|
+
CREATE TABLE IF NOT EXISTS #{Journal::CURSOR_TABLE} (
|
|
129
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
130
|
+
cursor TEXT
|
|
131
|
+
)
|
|
132
|
+
SQL
|
|
133
|
+
@cursor_table_ready = true
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DiscordStore
|
|
4
|
+
# Discord snowflake IDs, treated as what they actually are: a k-sortable
|
|
5
|
+
# primary key with a millisecond timestamp baked into the high bits.
|
|
6
|
+
#
|
|
7
|
+
# 63 22 17 12 0
|
|
8
|
+
# +--------------------------------------+------+------+------------+
|
|
9
|
+
# | milliseconds since 2015-01-01 (42b) | wrkr | proc | incr (12b) |
|
|
10
|
+
# +--------------------------------------+------+------+------------+
|
|
11
|
+
#
|
|
12
|
+
# Two properties matter for using Discord as a store:
|
|
13
|
+
#
|
|
14
|
+
# 1. IDs issued later sort after IDs issued earlier, so the natural message
|
|
15
|
+
# order in a channel *is* insertion order, and no separate sequence column
|
|
16
|
+
# is needed. The log sequence number is free.
|
|
17
|
+
#
|
|
18
|
+
# 2. Because the timestamp is recoverable, a wall-clock range maps onto an ID
|
|
19
|
+
# range. Discord's +before+/+after+ pagination parameters take snowflakes,
|
|
20
|
+
# so "every record written between 09:00 and 10:00" is a server-side range
|
|
21
|
+
# scan rather than a client-side filter over the whole channel.
|
|
22
|
+
module Snowflake
|
|
23
|
+
# Discord's epoch: 2015-01-01T00:00:00Z, in milliseconds.
|
|
24
|
+
EPOCH_MS = 1_420_070_400_000
|
|
25
|
+
|
|
26
|
+
TIMESTAMP_SHIFT = 22
|
|
27
|
+
WORKER_SHIFT = 17
|
|
28
|
+
PROCESS_SHIFT = 12
|
|
29
|
+
|
|
30
|
+
WORKER_MASK = 0x1F
|
|
31
|
+
PROCESS_MASK = 0x1F
|
|
32
|
+
INCREMENT_MASK = 0xFFF
|
|
33
|
+
|
|
34
|
+
# The largest value the 42-bit timestamp field can hold.
|
|
35
|
+
MAX_TIMESTAMP_MS = (1 << 42) - 1
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# Milliseconds since the Unix epoch at which +id+ was issued.
|
|
40
|
+
#
|
|
41
|
+
# @param id [Integer, String]
|
|
42
|
+
# @return [Integer]
|
|
43
|
+
def timestamp_ms(id)
|
|
44
|
+
(Integer(id) >> TIMESTAMP_SHIFT) + EPOCH_MS
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param id [Integer, String]
|
|
48
|
+
# @return [Time] UTC time at which +id+ was issued
|
|
49
|
+
def at(id)
|
|
50
|
+
Time.at(timestamp_ms(id) / 1000.0).utc
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The worker/process/increment fields. Present for completeness and for
|
|
54
|
+
# debugging odd ordering; nothing in this library depends on them.
|
|
55
|
+
#
|
|
56
|
+
# @param id [Integer, String]
|
|
57
|
+
# @return [Hash]
|
|
58
|
+
def parts(id)
|
|
59
|
+
id = Integer(id)
|
|
60
|
+
{
|
|
61
|
+
timestamp_ms: timestamp_ms(id),
|
|
62
|
+
worker: (id >> WORKER_SHIFT) & WORKER_MASK,
|
|
63
|
+
process: (id >> PROCESS_SHIFT) & PROCESS_MASK,
|
|
64
|
+
increment: id & INCREMENT_MASK
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Build a synthetic snowflake for the given time, with all low bits zeroed.
|
|
69
|
+
#
|
|
70
|
+
# The result is not a real message ID and will never collide with one, but
|
|
71
|
+
# it sorts exactly where a real ID issued at that instant would sort. That
|
|
72
|
+
# makes it the correct bound for +before+/+after+ pagination when you want
|
|
73
|
+
# a time range rather than a message range.
|
|
74
|
+
#
|
|
75
|
+
# @param time [Time, Integer] a Time, or milliseconds since the Unix epoch
|
|
76
|
+
# @return [Integer]
|
|
77
|
+
# @raise [ArgumentError] if the time predates Discord or overflows 42 bits
|
|
78
|
+
def from_time(time)
|
|
79
|
+
ms = time.is_a?(Time) ? (time.to_f * 1000).floor : Integer(time)
|
|
80
|
+
offset = ms - EPOCH_MS
|
|
81
|
+
|
|
82
|
+
raise ArgumentError, "time predates the Discord epoch (2015-01-01)" if offset.negative?
|
|
83
|
+
raise ArgumentError, "time overflows the 42-bit snowflake timestamp" if offset > MAX_TIMESTAMP_MS
|
|
84
|
+
|
|
85
|
+
offset << TIMESTAMP_SHIFT
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The same instant as {.from_time}, but with every low bit set, so that it
|
|
89
|
+
# sorts after any real ID issued during that millisecond. Use this as an
|
|
90
|
+
# inclusive upper bound.
|
|
91
|
+
#
|
|
92
|
+
# @param time [Time, Integer]
|
|
93
|
+
# @return [Integer]
|
|
94
|
+
def from_time_inclusive(time)
|
|
95
|
+
from_time(time) | ((1 << TIMESTAMP_SHIFT) - 1)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Whether +value+ could plausibly be a snowflake this library issued or read.
|
|
99
|
+
#
|
|
100
|
+
# @param value [Object]
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
def valid?(value)
|
|
103
|
+
id = Integer(value)
|
|
104
|
+
id.positive? && id < (1 << 64) && timestamp_ms(id) >= EPOCH_MS
|
|
105
|
+
rescue ArgumentError, TypeError
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :discord do
|
|
4
|
+
desc "Rebuild the local database from the Discord log"
|
|
5
|
+
task replay: :environment do
|
|
6
|
+
connection = ActiveRecord::Base.connection
|
|
7
|
+
|
|
8
|
+
unless connection.respond_to?(:replay!)
|
|
9
|
+
abort "The current connection is not a discord adapter (it is #{connection.adapter_name})."
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
started = Time.now
|
|
13
|
+
report = connection.replay!(from: ENV.fetch("FROM", nil))
|
|
14
|
+
|
|
15
|
+
puts "replayed #{report[:applied]} transactions (#{report[:statements]} statements) " \
|
|
16
|
+
"in #{(Time.now - started).round(1)}s"
|
|
17
|
+
puts "cursor now #{report[:cursor]}"
|
|
18
|
+
warn "#{report[:skipped]} transactions were skipped; see the log above" if report[:skipped].positive?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc "Show how far the local database is behind the Discord log"
|
|
22
|
+
task status: :environment do
|
|
23
|
+
connection = ActiveRecord::Base.connection
|
|
24
|
+
abort "not a discord adapter" unless connection.respond_to?(:log_status)
|
|
25
|
+
|
|
26
|
+
status = connection.log_status
|
|
27
|
+
puts "journal mode: #{status[:mode]}"
|
|
28
|
+
puts "local cursor: #{status[:local_cursor] || "(never replayed)"}"
|
|
29
|
+
puts "remote cursor: #{status[:remote_cursor] || "(empty log)"}"
|
|
30
|
+
puts "buffered: #{status[:pending]} transactions not yet written" if status[:pending].to_i.positive?
|
|
31
|
+
|
|
32
|
+
if status[:local_cursor] == status[:remote_cursor]
|
|
33
|
+
puts "up to date."
|
|
34
|
+
else
|
|
35
|
+
puts "behind. Run rake discord:replay."
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
desc "Delete tombstoned records from a stream (STREAM=name, DRY_RUN=1)"
|
|
40
|
+
task compact: :environment do
|
|
41
|
+
stream = ENV.fetch("STREAM", nil) or abort "STREAM=name is required"
|
|
42
|
+
dry_run = ENV["DRY_RUN"] == "1"
|
|
43
|
+
|
|
44
|
+
report = DiscordStore.client.log.compact(stream: stream, dry_run: dry_run)
|
|
45
|
+
|
|
46
|
+
puts "scanned #{report[:scanned]} records, #{report[:tombstoned]} tombstoned"
|
|
47
|
+
if dry_run
|
|
48
|
+
puts "(dry run; nothing deleted)"
|
|
49
|
+
else
|
|
50
|
+
puts "deleted #{report[:deleted]} messages"
|
|
51
|
+
if report[:too_old].positive?
|
|
52
|
+
puts "#{report[:too_old]} were older than the 14-day bulk-delete window and were left in " \
|
|
53
|
+
"place. Re-run with delete_policy: :aggressive to remove them one request at a time."
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
desc "Generate an encryption key"
|
|
59
|
+
task :key do
|
|
60
|
+
puts DiscordStore.generate_key
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
desc "Check the token, the application id, and the channels"
|
|
64
|
+
task doctor: :environment do
|
|
65
|
+
client = DiscordStore.client
|
|
66
|
+
user = client.verify!
|
|
67
|
+
puts "token ok: #{user["id"]} (#{user["username"] || "bot"})"
|
|
68
|
+
|
|
69
|
+
client.config.all_channel_ids.each do |channel_id|
|
|
70
|
+
client.rest.get_channel(channel_id)
|
|
71
|
+
puts "channel #{channel_id}: reachable"
|
|
72
|
+
rescue DiscordStore::APIError => e
|
|
73
|
+
puts "channel #{channel_id}: #{e.status} #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
puts "attachment limit: #{client.limits.attachment_limit} bytes"
|
|
77
|
+
puts "chunk size: #{client.limits.chunk_size} bytes"
|
|
78
|
+
rescue DiscordStore::Error => e
|
|
79
|
+
abort "#{e.class}: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
desc "Measure the real attachment ceiling by uploading test files (CHANNEL=id)"
|
|
83
|
+
task probe: :environment do
|
|
84
|
+
channel = ENV["CHANNEL"] || DiscordStore.client.config.blob_channel_ids.first
|
|
85
|
+
abort "CHANNEL=id is required" unless channel
|
|
86
|
+
|
|
87
|
+
puts "probing #{channel}; this uploads and deletes throwaway attachments..."
|
|
88
|
+
limit = DiscordStore.client.limits.probe!(channel_id: channel)
|
|
89
|
+
puts "largest accepted attachment: #{limit} bytes (#{(limit / 1024.0 / 1024).round(2)} MiB)"
|
|
90
|
+
puts "pin it: config.chunk_size = #{limit - DiscordStore::GuildLimits::REQUEST_OVERHEAD}"
|
|
91
|
+
end
|
|
92
|
+
end
|