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