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,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "discord_store"
4
+ require "active_record/connection_adapters/sqlite3_adapter"
5
+ require "discord_store/journal"
6
+ require "discord_store/replay"
7
+
8
+ module ActiveRecord
9
+ module ConnectionAdapters
10
+ # An ActiveRecord adapter whose durable store is a Discord channel.
11
+ #
12
+ # # config/database.yml
13
+ # development:
14
+ # adapter: discord
15
+ # database: storage/development.sqlite3
16
+ # discord:
17
+ # i_understand_this_violates_discord_tos: true
18
+ # token: <%= ENV["DISCORD_BOT_TOKEN"] %>
19
+ # application_id: <%= ENV["DISCORD_APPLICATION_ID"] %>
20
+ # secret_key: <%= ENV["DISCORD_STORE_KEY"] %>
21
+ # log_channel_ids: ["1234567890"]
22
+ #
23
+ # == What this actually is
24
+ #
25
+ # It is a SQLite adapter that mirrors every write into a Discord channel,
26
+ # and it is cheating. The README says so too. Here is why it cheats.
27
+ #
28
+ # Discord gives a bot no query interface whatsoever. There is no WHERE, no
29
+ # index, no server-side filter; there is a channel you may page through a
30
+ # hundred messages at a time. Answering +User.where(email: ...)+ against
31
+ # that means scanning the channel, which is why the honest implementations
32
+ # of this idea top out at a few megabytes per second and cannot do a join at
33
+ # all.
34
+ #
35
+ # So Discord is not asked to be a query engine. It is asked to be the
36
+ # durable, replicated, ordered write-ahead log — a thing it is unexpectedly
37
+ # decent at, because message IDs are snowflakes and therefore already a
38
+ # monotonic sequence with a timestamp in them — and a local SQLite file is
39
+ # the materialized view you actually query. Writes go to the log first, then
40
+ # to SQLite. Reads never touch the network. +rake discord:replay+ rebuilds
41
+ # the SQLite file from the channel, from empty, on any machine.
42
+ #
43
+ # That is a materialized view over a replicated log, which is a normal thing
44
+ # that normal systems do. The unusual part is only where the log lives.
45
+ #
46
+ # == What it costs
47
+ #
48
+ # A transaction is one message, and a channel accepts about five messages
49
+ # every five seconds. So this adapter sustains roughly *one write
50
+ # transaction per second*. That is not a tuning problem, it is the platform,
51
+ # and it is why +journal_mode: :async+ exists (batching many transactions
52
+ # into one message, at the cost of a durability window) and why writes are
53
+ # not sharded across channels by default (a write-ahead log with no total
54
+ # order is not a write-ahead log).
55
+ #
56
+ # Reads are as fast as SQLite, which is to say, fast.
57
+ class DiscordAdapter < SQLite3Adapter
58
+ ADAPTER_NAME = "Discord"
59
+
60
+ # Statements that mutate. Everything else is served locally and never
61
+ # reaches the network.
62
+ WRITE_STATEMENT = /\A\s*(?:INSERT|UPDATE|DELETE|REPLACE|CREATE|ALTER|DROP|TRUNCATE)\b/i
63
+
64
+ # Statements whose result depends on when they run, and which therefore
65
+ # cannot be replayed faithfully.
66
+ NON_DETERMINISTIC = /\b(?:RANDOM|CURRENT_TIMESTAMP|CURRENT_DATE|CURRENT_TIME)\s*(?:\(\s*\))?/i
67
+
68
+ class << self
69
+ # Rails calls this to build the underlying SQLite connection; the Discord
70
+ # half is set up in #initialize, after super.
71
+ def new_client(config)
72
+ super(config.except(:discord, :journal_mode, :replay_on_connect))
73
+ end
74
+ end
75
+
76
+ # @return [DiscordStore::Journal]
77
+ attr_reader :journal
78
+
79
+ def initialize(...)
80
+ super
81
+ @discord_config = extract_discord_config
82
+ @journal = build_journal
83
+ @statement_buffer = nil
84
+ replay_on_connect! if @discord_config[:replay_on_connect]
85
+ end
86
+
87
+ # --- Write interception -------------------------------------------------
88
+
89
+ def exec_insert(sql, name = nil, binds = [], pk = nil, sequence_name = nil, returning: nil)
90
+ record_statement(sql, binds)
91
+ super
92
+ end
93
+
94
+ def exec_update(sql, name = nil, binds = [])
95
+ record_statement(sql, binds)
96
+ super
97
+ end
98
+
99
+ def exec_delete(sql, name = nil, binds = [])
100
+ record_statement(sql, binds)
101
+ super
102
+ end
103
+
104
+ # Catches DDL, which migrations issue through #execute rather than through
105
+ # the exec_* trio. A migration is a write to the log like any other, which
106
+ # is what makes a replay onto an empty file reproduce the schema as well as
107
+ # the rows.
108
+ def execute(sql, name = nil, **kwargs)
109
+ record_statement(sql, []) if sql.is_a?(String) && WRITE_STATEMENT.match?(sql)
110
+ super
111
+ end
112
+
113
+ # --- Transactions -------------------------------------------------------
114
+
115
+ # Note the absence of a begin_db_transaction override.
116
+ #
117
+ # Rails materialises transactions lazily: the first write inside a
118
+ # transaction reaches #exec_insert before BEGIN is ever sent. An override
119
+ # here that initialised the statement buffer would therefore throw that
120
+ # first statement away, which is why #record_statement asks the
121
+ # transaction manager whether a transaction is open instead of trusting a
122
+ # callback that has not fired yet.
123
+ #
124
+ # The log is written before SQLite commits, not after.
125
+ #
126
+ # There is no two-phase commit available between a SQLite file and a chat
127
+ # server, so one of them has to go first and the other has to be the one
128
+ # that can be reconstructed. Writing the log first means a crash in between
129
+ # leaves a record that replay will apply, and the local file catches up.
130
+ # Committing first would leave rows that exist nowhere else, which in a
131
+ # design where the log is the source of truth is simply data loss.
132
+ def commit_db_transaction
133
+ flush_buffer!
134
+ super
135
+ end
136
+
137
+ def exec_rollback_db_transaction
138
+ @statement_buffer = nil
139
+ super
140
+ end
141
+
142
+ # --- Log operations -----------------------------------------------------
143
+
144
+ # Rebuilds this connection's SQLite database from the Discord log.
145
+ #
146
+ # @param from [String, nil] cursor to resume from; nil replays everything
147
+ # @return [Hash] {applied:, skipped:, cursor:}
148
+ def replay!(from: nil)
149
+ DiscordStore::Replay.new(connection: self, journal: @journal).call(from: from)
150
+ end
151
+
152
+ # @return [String, nil] cursor of the newest record in the log
153
+ def log_tip = @journal.tip
154
+
155
+ # Everything the local file has that the log does not, and vice versa.
156
+ #
157
+ # @return [Hash]
158
+ def log_status
159
+ { local_cursor: @journal.local_cursor, remote_cursor: @journal.tip,
160
+ pending: @journal.pending_count, mode: @journal.mode }
161
+ end
162
+
163
+ # Flushes anything buffered by +journal_mode: :async+.
164
+ #
165
+ # @return [Integer] records written
166
+ def flush_journal! = @journal.flush!
167
+
168
+ def supports_savepoints? = true
169
+
170
+ # Discord cannot participate in a savepoint: a message, once sent, is sent.
171
+ # Statements inside a savepoint that later rolls back would still be in the
172
+ # log, so replay would apply work the database rolled back.
173
+ def create_savepoint(name = current_savepoint_name)
174
+ if @journal.recording?
175
+ raise DiscordStore::Error,
176
+ "savepoints cannot be journalled: a sent message cannot be un-sent, so a " \
177
+ "rolled-back savepoint would still replay. Set journal_mode: :off for this " \
178
+ "connection, or avoid nested transactions with requires_new: true."
179
+ end
180
+
181
+ super
182
+ end
183
+
184
+ def disconnect!
185
+ @journal.flush! if @journal&.recording?
186
+ super
187
+ end
188
+
189
+ private
190
+
191
+ def record_statement(sql, binds)
192
+ return unless @journal.recording?
193
+ return unless sql.is_a?(String)
194
+
195
+ # Replay's own bookkeeping table is local state about the log. Writing it
196
+ # into the log would make every replay append the record of itself.
197
+ return if sql.include?(DiscordStore::Journal::CURSOR_TABLE)
198
+
199
+ warn_non_deterministic(sql)
200
+
201
+ entry = { "sql" => sql, "binds" => DiscordStore::Journal.serialize_binds(binds) }
202
+
203
+ if in_open_transaction?
204
+ (@statement_buffer ||= []) << entry
205
+ else
206
+ # An autocommit write is a transaction of one.
207
+ @journal.write([entry])
208
+ end
209
+ end
210
+
211
+ # True from the moment Rails opens a transaction object, which is before it
212
+ # bothers to send BEGIN.
213
+ def in_open_transaction?
214
+ current = transaction_manager.current_transaction
215
+ current.respond_to?(:open?) && current.open?
216
+ rescue StandardError
217
+ false
218
+ end
219
+
220
+ def flush_buffer!
221
+ buffered = @statement_buffer
222
+ @statement_buffer = nil
223
+ return if buffered.nil? || buffered.empty?
224
+
225
+ @journal.write(buffered)
226
+ end
227
+
228
+ # Rails sends timestamps as bind parameters, so this fires rarely; when it
229
+ # does, the statement will replay to a different value than it produced,
230
+ # and silence would be the wrong response.
231
+ def warn_non_deterministic(sql)
232
+ return unless NON_DETERMINISTIC.match?(sql)
233
+
234
+ message = "discord_store: journalled a non-deterministic statement; a replay will " \
235
+ "not reproduce this row exactly: #{sql[0, 200]}"
236
+ (@discord_config[:logger] || ActiveRecord::Base.logger)&.warn(message)
237
+ end
238
+
239
+ def extract_discord_config
240
+ raw = @config[:discord] || @config["discord"] || {}
241
+ raw.to_h.transform_keys(&:to_sym).tap do |options|
242
+ options[:journal_mode] = (options[:journal_mode] || @config[:journal_mode] || :sync).to_sym
243
+ options[:replay_on_connect] = @config[:replay_on_connect] || options[:replay_on_connect]
244
+ end
245
+ end
246
+
247
+ def build_journal
248
+ DiscordStore::Journal.new(
249
+ config: DiscordStore::Journal.build_configuration(@discord_config),
250
+ mode: @discord_config[:journal_mode],
251
+ http: @discord_config[:http] # injectable for tests
252
+ )
253
+ end
254
+
255
+ def replay_on_connect!
256
+ replay!
257
+ rescue DiscordStore::Error => e
258
+ (@discord_config[:logger] || ActiveRecord::Base.logger)&.error(
259
+ "discord_store: replay on connect failed: #{e.message}"
260
+ )
261
+ raise
262
+ end
263
+ end
264
+ end
265
+ end
266
+
267
+ ActiveRecord::ConnectionAdapters.register(
268
+ "discord",
269
+ "ActiveRecord::ConnectionAdapters::DiscordAdapter",
270
+ "active_record/connection_adapters/discord_adapter"
271
+ )
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "discord_store"
4
+ require "tempfile"
5
+
6
+ module ActiveStorage
7
+ class Service
8
+ # An ActiveStorage service backed by Discord message attachments.
9
+ #
10
+ # # config/storage.yml
11
+ # discord:
12
+ # service: Discord
13
+ # token: <%= ENV["DISCORD_BOT_TOKEN"] %>
14
+ # application_id: <%= ENV["DISCORD_APPLICATION_ID"] %>
15
+ # guild_id: <%= ENV["DISCORD_GUILD_ID"] %>
16
+ # secret_key: <%= ENV["DISCORD_STORE_KEY"] %>
17
+ # blob_channel_ids: ["...", "..."]
18
+ # manifest_channel_id: "..."
19
+ # i_understand_this_violates_discord_tos: true
20
+ #
21
+ # Configure the service to be proxied, not redirected:
22
+ #
23
+ # # config/environments/production.rb
24
+ # config.active_storage.resolve_model_to_route = :rails_storage_proxy
25
+ #
26
+ # That line is the whole point of building this on Rails rather than from
27
+ # scratch.
28
+ #
29
+ # When Discord began signing CDN links with an expiring HMAC at the end of
30
+ # 2023, every Discord-backed filesystem that had stored URLs broke about a
31
+ # day later, and the community's fix was to deploy caching proxies on
32
+ # Cloudflare Workers that re-fetch the message and hand back a fresh link.
33
+ # ActiveStorage has shipped that proxy for years — it is
34
+ # ActiveStorage::Blobs::ProxyController — and in proxy mode reads go through
35
+ # it, so the URL a browser sees is a Rails URL that never expires and the
36
+ # Discord link is re-resolved per request, server-side, where it belongs.
37
+ #
38
+ # Redirect mode works only for blobs small enough to be a single attachment,
39
+ # because a blob split across attachments has no single URL to redirect to.
40
+ class DiscordService < Service
41
+ # What every other ActiveStorage service yields per iteration when
42
+ # streaming a download. Not a Discord number -- a Rails one.
43
+ STREAM_CHUNK_SIZE = 5 * 1024 * 1024
44
+
45
+ attr_reader :client, :blobs
46
+
47
+ # @param config [Hash] the storage.yml stanza, symbolized
48
+ def initialize(**config)
49
+ @config = config
50
+ # +http+ is a test seam, the same one S3Service exposes as +client+:
51
+ # it lets the conformance suite run the whole service against an
52
+ # in-memory Discord. Nothing in storage.yml sets it.
53
+ @client = DiscordStore::Client.new(
54
+ config: build_configuration(config), http: config[:http]
55
+ )
56
+ @blobs = @client.blobs
57
+ super()
58
+ end
59
+
60
+ # @return [void]
61
+ def upload(key, io, checksum: nil, content_type: nil, **)
62
+ instrument :upload, key: key, checksum: checksum do
63
+ @blobs.put(key, io, content_type: content_type || "application/octet-stream",
64
+ checksum: checksum)
65
+ end
66
+ rescue DiscordStore::Error => e
67
+ raise ActiveStorage::IntegrityError, e.message if e.message.include?("checksum")
68
+
69
+ raise
70
+ end
71
+
72
+ # @return [String, void]
73
+ def download(key, &block)
74
+ if block
75
+ instrument :streaming_download, key: key do
76
+ stream(key, &block)
77
+ end
78
+ else
79
+ instrument :download, key: key do
80
+ @blobs.get(key)
81
+ end
82
+ end
83
+ rescue DiscordStore::NotFoundError
84
+ raise ActiveStorage::FileNotFoundError
85
+ end
86
+
87
+ # Concatenates several blobs into one.
88
+ #
89
+ # S3 and GCS compose server-side; Discord cannot, so this reads the
90
+ # sources and writes a new blob. It goes through a Tempfile rather than a
91
+ # String because the inputs are attachments and composing four of them at
92
+ # a 100 MiB tier would otherwise put 400 MiB on the heap.
93
+ #
94
+ # +filename+ and +disposition+ are accepted and dropped: Discord stores no
95
+ # per-object metadata, and this service serves downloads through the
96
+ # application anyway, which is where those two get applied.
97
+ #
98
+ # @return [void]
99
+ def compose(source_keys, destination_key, content_type: nil, **)
100
+ instrument :compose, key: destination_key, source_keys: source_keys do
101
+ Tempfile.create(["discord-store-compose", ".bin"]) do |scratch|
102
+ scratch.binmode
103
+ source_keys.each { |source_key| @blobs.download(source_key) { |bytes| scratch.write(bytes) } }
104
+ scratch.rewind
105
+ @blobs.put(destination_key, scratch,
106
+ content_type: content_type || "application/octet-stream")
107
+ end
108
+ end
109
+ end
110
+
111
+ # @param key [String]
112
+ # @param range [Range]
113
+ # @return [String]
114
+ def download_chunk(key, range)
115
+ instrument :download_chunk, key: key, range: range do
116
+ @blobs.get_range(key, range)
117
+ end
118
+ rescue DiscordStore::NotFoundError
119
+ raise ActiveStorage::FileNotFoundError
120
+ end
121
+
122
+ # @return [void]
123
+ def delete(key)
124
+ instrument :delete, key: key do
125
+ @blobs.delete(key)
126
+ end
127
+ end
128
+
129
+ # @return [void]
130
+ def delete_prefixed(prefix)
131
+ instrument :delete_prefixed, prefix: prefix do
132
+ @blobs.delete_prefix(prefix)
133
+ end
134
+ end
135
+
136
+ # @return [Boolean]
137
+ def exist?(key)
138
+ instrument :exist, key: key do |payload|
139
+ payload[:exist] = @blobs.exist?(key)
140
+ end
141
+ end
142
+
143
+ # Discord has no pre-signed upload endpoint, so a browser cannot PUT
144
+ # straight to it. Every byte goes through the application.
145
+ def url_for_direct_upload(*, **)
146
+ raise NotImplementedError,
147
+ "Discord has no direct-upload endpoint. Uploads must pass through your " \
148
+ "application, which also means they are bounded by your dyno's bandwidth " \
149
+ "and by a rate limit measured in single-digit megabytes per second."
150
+ end
151
+
152
+ def headers_for_direct_upload(*, **) = {}
153
+
154
+ private
155
+
156
+ # Redirect mode: hand back a freshly signed CDN link.
157
+ #
158
+ # Only viable for a single-chunk blob. Anything larger has no single URL,
159
+ # and there is no honest way to invent one — which is why proxy mode is
160
+ # the documented configuration.
161
+ def private_url(key, expires_in: nil, filename: nil, content_type: nil, disposition: nil, **)
162
+ @blobs.url(key)
163
+ rescue DiscordStore::Error => e
164
+ raise ActiveStorage::FileNotFoundError if e.is_a?(DiscordStore::NotFoundError)
165
+
166
+ raise e.class, <<~MSG
167
+ #{e.message}
168
+
169
+ Set config.active_storage.resolve_model_to_route = :rails_storage_proxy so reads
170
+ go through ActiveStorage::Blobs::ProxyController, which reassembles the chunks
171
+ server-side and never hands an expiring Discord link to a browser.
172
+ MSG
173
+ end
174
+
175
+ # Discord links are already time-limited by Discord, on Discord's schedule,
176
+ # and nothing here can lengthen or shorten that.
177
+ def public_url(key, **)
178
+ raise NotImplementedError,
179
+ "a Discord-backed service cannot be public: every CDN link Discord issues " \
180
+ "expires on its own schedule, so there is no stable public URL to publish."
181
+ end
182
+
183
+ # ActiveStorage's services all hand back 5 MB slices when streaming, and
184
+ # Rails' conformance suite asserts it exactly. Discord's chunks are sized
185
+ # by the guild's attachment ceiling instead -- 8 MiB to 100 MiB depending
186
+ # on boost tier -- which is storage geometry and no business of a caller
187
+ # iterating a download. Re-slice on the way out.
188
+ def stream(key)
189
+ buffer = (+"").force_encoding(Encoding::BINARY)
190
+
191
+ @blobs.download(key) do |bytes|
192
+ buffer << bytes
193
+ yield buffer.slice!(0, STREAM_CHUNK_SIZE) while buffer.bytesize >= STREAM_CHUNK_SIZE
194
+ end
195
+
196
+ yield buffer unless buffer.empty?
197
+ end
198
+
199
+ def build_configuration(options)
200
+ DiscordStore::Configuration.new.tap do |config|
201
+ apply_credentials(config, options)
202
+ apply_placement(config, options)
203
+ apply_behaviour(config, options)
204
+ end
205
+ end
206
+
207
+ def apply_credentials(config, options)
208
+ config.i_understand_this_violates_discord_tos =
209
+ options[:i_understand_this_violates_discord_tos]
210
+ config.token = options[:token]
211
+ config.application_id = options[:application_id]
212
+ config.guild_id = options[:guild_id]
213
+ config.secret_key = options[:secret_key]
214
+ config.cipher = options[:cipher]&.to_sym || :aes_256_gcm
215
+ end
216
+
217
+ def apply_placement(config, options)
218
+ config.blob_channel_ids = Array(options[:blob_channel_ids]).map(&:to_s)
219
+ config.manifest_channel_id = options[:manifest_channel_id]&.to_s
220
+ config.log_channel_ids = Array(options[:log_channel_ids]).map(&:to_s)
221
+ end
222
+
223
+ def apply_behaviour(config, options)
224
+ config.chunk_size = options[:chunk_size]
225
+ config.delete_policy = (options[:delete_policy] || :tombstone).to_sym
226
+ config.max_concurrent_transfers =
227
+ options[:max_concurrent_transfers] || config.max_concurrent_transfers
228
+ config.logger = options[:logger]
229
+ end
230
+ end
231
+ end
232
+ end