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,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DiscordStore
|
|
4
|
+
# Base class for every error this library raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the library is used before it has been configured, or when a
|
|
8
|
+
# configuration value is missing or nonsensical.
|
|
9
|
+
class ConfigurationError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when the caller has not acknowledged that this library operates in
|
|
12
|
+
# violation of the Discord Developer Terms of Service. See
|
|
13
|
+
# {DiscordStore::Configuration#i_understand_this_violates_discord_tos}.
|
|
14
|
+
class UnacknowledgedError < Error
|
|
15
|
+
DEFAULT_MESSAGE = <<~MSG
|
|
16
|
+
discord_store stores application data in Discord messages and attachments.
|
|
17
|
+
That is an explicit violation of the Discord Developer Terms of Service and
|
|
18
|
+
the Discord API Developer Policy, and it can get your bot token revoked and
|
|
19
|
+
your account actioned without warning.
|
|
20
|
+
|
|
21
|
+
This library will not talk to Discord until you say, in code, that you know
|
|
22
|
+
that:
|
|
23
|
+
|
|
24
|
+
DiscordStore.configure do |c|
|
|
25
|
+
c.i_understand_this_violates_discord_tos = true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Do not set this in an application you did not build for yourself.
|
|
29
|
+
MSG
|
|
30
|
+
|
|
31
|
+
def initialize(msg = DEFAULT_MESSAGE)
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Base class for anything that came back from Discord's REST API.
|
|
37
|
+
class APIError < Error
|
|
38
|
+
attr_reader :status, :code, :response_body
|
|
39
|
+
|
|
40
|
+
def initialize(message, status: nil, code: nil, response_body: nil)
|
|
41
|
+
@status = status
|
|
42
|
+
@code = code
|
|
43
|
+
@response_body = response_body
|
|
44
|
+
super(message)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# 401/403 — the token is wrong, expired, or lacks the required permission.
|
|
49
|
+
class AuthError < APIError; end
|
|
50
|
+
|
|
51
|
+
# 404 — the channel, message, or attachment is gone. In a store built on a
|
|
52
|
+
# chat app this is a routine occurrence, not an exceptional one: a human with
|
|
53
|
+
# Manage Messages can delete your data at any time from the client UI.
|
|
54
|
+
class NotFoundError < APIError; end
|
|
55
|
+
|
|
56
|
+
# 429 — rate limited. Carries the server-supplied backoff so callers can obey
|
|
57
|
+
# it rather than guessing.
|
|
58
|
+
class RateLimitedError < APIError
|
|
59
|
+
attr_reader :retry_after, :scope, :global
|
|
60
|
+
|
|
61
|
+
def initialize(message, retry_after:, scope: nil, global: false, **kwargs)
|
|
62
|
+
@retry_after = retry_after
|
|
63
|
+
@scope = scope
|
|
64
|
+
@global = global
|
|
65
|
+
super(message, **kwargs)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A request exhausted its retry budget.
|
|
70
|
+
class ExhaustedError < Error; end
|
|
71
|
+
|
|
72
|
+
# The local process could not acquire rate-limit quota within the configured
|
|
73
|
+
# timeout. This is the moral equivalent of ActiveRecord::ConnectionTimeoutError,
|
|
74
|
+
# but the exhausted resource is request budget, not sockets.
|
|
75
|
+
class QuotaTimeoutError < Error; end
|
|
76
|
+
|
|
77
|
+
# A stored payload could not be decrypted or did not authenticate.
|
|
78
|
+
class DecryptionError < Error; end
|
|
79
|
+
|
|
80
|
+
# A stored payload is structurally wrong — truncated, wrong version, or not
|
|
81
|
+
# something this library wrote.
|
|
82
|
+
class CorruptRecordError < Error; end
|
|
83
|
+
|
|
84
|
+
# A record was larger than anything this library can represent, even after
|
|
85
|
+
# spilling to an attachment.
|
|
86
|
+
class PayloadTooLargeError < Error; end
|
|
87
|
+
|
|
88
|
+
# Raised when a message that was expected to be one of ours turns out to have
|
|
89
|
+
# been written by somebody else. discord_store only ever reads its own bot's
|
|
90
|
+
# messages; see DiscordStore::Transport::REST#assert_own_message!
|
|
91
|
+
class ForeignMessageError < Error; end
|
|
92
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DiscordStore
|
|
4
|
+
# Discovers how large an attachment this guild will actually accept.
|
|
5
|
+
#
|
|
6
|
+
# Every previous library in this genre hardcoded the number, and every one of
|
|
7
|
+
# them broke, because Discord has moved it repeatedly and in both directions:
|
|
8
|
+
# 8 MiB for years, then 25 MB, then back to 10 MB, with boosted guilds on a
|
|
9
|
+
# different ladder again. Published write-ups from 2023 and 2026 disagree,
|
|
10
|
+
# and both were right when they were written.
|
|
11
|
+
#
|
|
12
|
+
# So the number is treated as a fact about the running system rather than a
|
|
13
|
+
# constant. The tier table below is a conservative starting point; {#probe!}
|
|
14
|
+
# measures the truth by trying.
|
|
15
|
+
class GuildLimits
|
|
16
|
+
MIB = 1024 * 1024
|
|
17
|
+
|
|
18
|
+
# Deliberately pessimistic. Being wrong low costs a few extra chunks; being
|
|
19
|
+
# wrong high costs a failed upload halfway through a large file.
|
|
20
|
+
TIER_LIMITS = {
|
|
21
|
+
0 => 10 * MIB,
|
|
22
|
+
1 => 10 * MIB,
|
|
23
|
+
2 => 50 * MIB,
|
|
24
|
+
3 => 100 * MIB
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
FALLBACK_LIMIT = 8 * MIB
|
|
28
|
+
|
|
29
|
+
# Room for multipart framing, the payload_json part, and headers, so that a
|
|
30
|
+
# chunk sized at exactly the ceiling does not push the request over it.
|
|
31
|
+
REQUEST_OVERHEAD = 16 * 1024
|
|
32
|
+
|
|
33
|
+
def initialize(rest:, config:)
|
|
34
|
+
@rest = rest
|
|
35
|
+
@config = config
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Largest attachment this guild accepts, in bytes.
|
|
40
|
+
#
|
|
41
|
+
# @param refresh [Boolean]
|
|
42
|
+
# @return [Integer]
|
|
43
|
+
def attachment_limit(refresh: false)
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
@attachment_limit = nil if refresh
|
|
46
|
+
@attachment_limit ||= discover_limit
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Bytes per blob chunk: the attachment ceiling less request overhead, unless
|
|
51
|
+
# the caller pinned it in configuration.
|
|
52
|
+
#
|
|
53
|
+
# @return [Integer]
|
|
54
|
+
def chunk_size
|
|
55
|
+
@config.chunk_size || (attachment_limit - REQUEST_OVERHEAD)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Measures the real ceiling by binary search, uploading and then deleting
|
|
59
|
+
# throwaway attachments.
|
|
60
|
+
#
|
|
61
|
+
# Costs a handful of requests and writes garbage into the channel briefly,
|
|
62
|
+
# so it is opt-in. Run it once at deploy time and pin the result in
|
|
63
|
+
# configuration rather than probing on every boot.
|
|
64
|
+
#
|
|
65
|
+
# @param channel_id [String] a channel safe to write junk into
|
|
66
|
+
# @param low [Integer] known-good size
|
|
67
|
+
# @param high [Integer] known-or-suspected-bad size
|
|
68
|
+
# @param precision [Integer] stop when the bracket is this narrow
|
|
69
|
+
# @return [Integer] the largest size that succeeded
|
|
70
|
+
def probe!(channel_id:, low: MIB, high: 100 * MIB, precision: MIB / 4)
|
|
71
|
+
raise ArgumentError, "low must be under high" unless low < high
|
|
72
|
+
|
|
73
|
+
best = nil
|
|
74
|
+
|
|
75
|
+
while high - low > precision
|
|
76
|
+
midpoint = low + ((high - low) / 2)
|
|
77
|
+
|
|
78
|
+
if upload_succeeds?(channel_id, midpoint)
|
|
79
|
+
best = midpoint
|
|
80
|
+
low = midpoint
|
|
81
|
+
else
|
|
82
|
+
high = midpoint
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
best ||= low
|
|
87
|
+
@mutex.synchronize { @attachment_limit = best }
|
|
88
|
+
best
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def discover_limit
|
|
94
|
+
return @config.chunk_size + REQUEST_OVERHEAD if @config.chunk_size
|
|
95
|
+
return FALLBACK_LIMIT unless @config.guild_id
|
|
96
|
+
|
|
97
|
+
guild = @rest.get_guild(@config.guild_id)
|
|
98
|
+
tier = guild["premium_tier"].to_i
|
|
99
|
+
TIER_LIMITS.fetch(tier, FALLBACK_LIMIT)
|
|
100
|
+
rescue APIError
|
|
101
|
+
# A store that cannot read its own guild can still write small chunks.
|
|
102
|
+
FALLBACK_LIMIT
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def upload_succeeds?(channel_id, size)
|
|
106
|
+
message = @rest.create_message(
|
|
107
|
+
channel_id,
|
|
108
|
+
content: "DS1 probe #{size}",
|
|
109
|
+
files: [{ filename: "probe.bin", content: "\0" * size, content_type: "application/octet-stream" }]
|
|
110
|
+
)
|
|
111
|
+
@rest.delete_message(channel_id, message["id"])
|
|
112
|
+
true
|
|
113
|
+
rescue APIError, ExhaustedError
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "bigdecimal"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module DiscordStore
|
|
8
|
+
# The write-ahead log behind the ActiveRecord adapter.
|
|
9
|
+
#
|
|
10
|
+
# Each entry is a SQL statement plus its bind values, which is logical
|
|
11
|
+
# replication in the plainest possible form: replaying the statements in order
|
|
12
|
+
# against an empty database reproduces the database. Postgres and MySQL both
|
|
13
|
+
# ship a version of this idea; the only novelty here is that the log segment
|
|
14
|
+
# is a Discord channel.
|
|
15
|
+
#
|
|
16
|
+
# Statements are journalled with their binds already materialised, because a
|
|
17
|
+
# prepared statement handle means nothing to a reader on another machine three
|
|
18
|
+
# months from now.
|
|
19
|
+
class Journal
|
|
20
|
+
STREAM = "wal"
|
|
21
|
+
CURSOR_TABLE = "discord_store_journal"
|
|
22
|
+
|
|
23
|
+
MODES = %i[sync async off].freeze
|
|
24
|
+
|
|
25
|
+
# Tags for values JSON cannot carry losslessly.
|
|
26
|
+
BINARY_TAG = "__b"
|
|
27
|
+
TIME_TAG = "__t"
|
|
28
|
+
DATE_TAG = "__D"
|
|
29
|
+
DECIMAL_TAG = "__d"
|
|
30
|
+
|
|
31
|
+
attr_reader :mode, :log
|
|
32
|
+
|
|
33
|
+
# @param config [Configuration]
|
|
34
|
+
# @param mode [Symbol] :sync, :async or :off
|
|
35
|
+
# @param flush_interval [Numeric] seconds, for :async
|
|
36
|
+
# @param max_buffer [Integer] entries, for :async
|
|
37
|
+
# @param http [#call, nil] injectable transport
|
|
38
|
+
def initialize(config:, mode: :sync, flush_interval: 1.0, max_buffer: 200, http: nil)
|
|
39
|
+
raise ConfigurationError, "journal_mode must be one of #{MODES.join(", ")}" unless MODES.include?(mode)
|
|
40
|
+
|
|
41
|
+
@mode = mode
|
|
42
|
+
@flush_interval = flush_interval
|
|
43
|
+
@max_buffer = max_buffer
|
|
44
|
+
@buffer = []
|
|
45
|
+
@mutex = Mutex.new
|
|
46
|
+
@local_cursor = nil
|
|
47
|
+
|
|
48
|
+
return if mode == :off
|
|
49
|
+
|
|
50
|
+
@client = Client.new(config: config, http: http)
|
|
51
|
+
@log = @client.log
|
|
52
|
+
start_flusher if mode == :async
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def recording? = @mode != :off
|
|
57
|
+
|
|
58
|
+
# Records one transaction.
|
|
59
|
+
#
|
|
60
|
+
# @param statements [Array<Hash>] each {"sql" =>, "binds" =>}
|
|
61
|
+
# @return [String, nil] the cursor the transaction landed at, if written now
|
|
62
|
+
def write(statements)
|
|
63
|
+
return nil unless recording?
|
|
64
|
+
return nil if statements.empty?
|
|
65
|
+
|
|
66
|
+
entry = { "tx" => statements, "at" => Time.now.utc.iso8601(3) }
|
|
67
|
+
|
|
68
|
+
case @mode
|
|
69
|
+
when :sync
|
|
70
|
+
append([entry]).last&.cursor
|
|
71
|
+
when :async
|
|
72
|
+
buffered = @mutex.synchronize do
|
|
73
|
+
@buffer << entry
|
|
74
|
+
@buffer.size
|
|
75
|
+
end
|
|
76
|
+
flush! if buffered >= @max_buffer
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Writes anything buffered.
|
|
82
|
+
#
|
|
83
|
+
# @return [Integer] entries written
|
|
84
|
+
def flush!
|
|
85
|
+
return 0 unless @mode == :async
|
|
86
|
+
|
|
87
|
+
pending = @mutex.synchronize do
|
|
88
|
+
drained = @buffer
|
|
89
|
+
@buffer = []
|
|
90
|
+
drained
|
|
91
|
+
end
|
|
92
|
+
return 0 if pending.empty?
|
|
93
|
+
|
|
94
|
+
append(pending)
|
|
95
|
+
pending.size
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Integer] entries waiting to be written
|
|
99
|
+
def pending_count = @mutex.synchronize { @buffer.size }
|
|
100
|
+
|
|
101
|
+
# @return [String, nil] cursor of the newest record in the channel
|
|
102
|
+
def tip
|
|
103
|
+
return nil unless recording?
|
|
104
|
+
|
|
105
|
+
@log.tip(stream: STREAM)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Reads transactions in order.
|
|
109
|
+
#
|
|
110
|
+
# @param from [String, nil] exclusive cursor
|
|
111
|
+
# @yieldparam entry [Hash] {"tx" =>, "at" =>}
|
|
112
|
+
# @yieldparam cursor [String]
|
|
113
|
+
# @return [void]
|
|
114
|
+
def each_transaction(from: nil)
|
|
115
|
+
# Deliberately checks for a log rather than for recording?: a replay reads
|
|
116
|
+
# while paused, and gating this on the write mode would make it a no-op
|
|
117
|
+
# exactly when it matters.
|
|
118
|
+
return if @log.nil?
|
|
119
|
+
|
|
120
|
+
@log.each(stream: STREAM, after: from) do |record|
|
|
121
|
+
data = record.data
|
|
122
|
+
next unless data.is_a?(Hash) && data["tx"]
|
|
123
|
+
|
|
124
|
+
yield data, record.cursor
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# How far the local database has been replayed.
|
|
129
|
+
#
|
|
130
|
+
# @return [String, nil]
|
|
131
|
+
attr_accessor :local_cursor
|
|
132
|
+
|
|
133
|
+
# Suspends writing for the duration of the block, leaving reads working.
|
|
134
|
+
#
|
|
135
|
+
# Replay uses this: applying a statement locally must not journal it back,
|
|
136
|
+
# or every replay would duplicate the log it just read.
|
|
137
|
+
#
|
|
138
|
+
# @return [Object] the block's value
|
|
139
|
+
def while_paused
|
|
140
|
+
previous = @mode
|
|
141
|
+
@mode = :off
|
|
142
|
+
yield
|
|
143
|
+
ensure
|
|
144
|
+
@mode = previous
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [void]
|
|
148
|
+
def close
|
|
149
|
+
flush!
|
|
150
|
+
@flusher&.kill
|
|
151
|
+
@client&.close
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# --- Bind serialisation ---------------------------------------------------
|
|
155
|
+
|
|
156
|
+
# Turns ActiveRecord bind parameters into something JSON can carry, with
|
|
157
|
+
# tags for the types it cannot.
|
|
158
|
+
#
|
|
159
|
+
# @param binds [Array]
|
|
160
|
+
# @return [Array]
|
|
161
|
+
def self.serialize_binds(binds)
|
|
162
|
+
Array(binds).map do |bind|
|
|
163
|
+
value = bind.respond_to?(:value_for_database) ? bind.value_for_database : bind
|
|
164
|
+
serialize_value(value)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @param value [Object]
|
|
169
|
+
# @return [Object]
|
|
170
|
+
def self.serialize_value(value)
|
|
171
|
+
case value
|
|
172
|
+
when nil, true, false, Integer, Float then value
|
|
173
|
+
when BigDecimal then { DECIMAL_TAG => value.to_s("F") }
|
|
174
|
+
when Time then { TIME_TAG => value.utc.iso8601(6) }
|
|
175
|
+
when DateTime then { TIME_TAG => value.to_time.utc.iso8601(6) }
|
|
176
|
+
when Date then { DATE_TAG => value.iso8601 }
|
|
177
|
+
when String
|
|
178
|
+
# A UTF-8 string rides as itself; anything else is bytes, and bytes do
|
|
179
|
+
# not survive JSON.
|
|
180
|
+
if value.encoding == Encoding::BINARY || !value.valid_encoding?
|
|
181
|
+
{ BINARY_TAG => Base64.strict_encode64(value) }
|
|
182
|
+
else
|
|
183
|
+
value
|
|
184
|
+
end
|
|
185
|
+
else
|
|
186
|
+
if value.respond_to?(:to_s) && value.class.name.to_s.include?("Binary")
|
|
187
|
+
{ BINARY_TAG => Base64.strict_encode64(value.to_s) }
|
|
188
|
+
else
|
|
189
|
+
value.to_s
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @param values [Array]
|
|
195
|
+
# @return [Array]
|
|
196
|
+
def self.deserialize_binds(values)
|
|
197
|
+
Array(values).map { |value| deserialize_value(value) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# @param value [Object]
|
|
201
|
+
# @return [Object]
|
|
202
|
+
def self.deserialize_value(value)
|
|
203
|
+
return value unless value.is_a?(Hash)
|
|
204
|
+
|
|
205
|
+
if value.key?(BINARY_TAG) then Base64.strict_decode64(value[BINARY_TAG])
|
|
206
|
+
elsif value.key?(TIME_TAG) then Time.iso8601(value[TIME_TAG])
|
|
207
|
+
elsif value.key?(DATE_TAG) then Date.iso8601(value[DATE_TAG])
|
|
208
|
+
elsif value.key?(DECIMAL_TAG) then BigDecimal(value[DECIMAL_TAG])
|
|
209
|
+
else value
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Builds a {Configuration} from a database.yml stanza.
|
|
214
|
+
#
|
|
215
|
+
# @param options [Hash]
|
|
216
|
+
# @return [Configuration]
|
|
217
|
+
def self.build_configuration(options)
|
|
218
|
+
Configuration.new.tap do |config|
|
|
219
|
+
config.i_understand_this_violates_discord_tos =
|
|
220
|
+
options[:i_understand_this_violates_discord_tos]
|
|
221
|
+
config.token = options[:token]
|
|
222
|
+
config.application_id = options[:application_id]
|
|
223
|
+
config.guild_id = options[:guild_id]
|
|
224
|
+
config.secret_key = options[:secret_key]
|
|
225
|
+
config.cipher = (options[:cipher] || :aes_256_gcm).to_sym
|
|
226
|
+
config.log_channel_ids = Array(options[:log_channel_ids]).map(&:to_s)
|
|
227
|
+
config.logger = options[:logger]
|
|
228
|
+
|
|
229
|
+
if config.log_channel_ids.size > 1
|
|
230
|
+
raise ConfigurationError,
|
|
231
|
+
"a write-ahead log needs one total order, and message IDs are only ordered " \
|
|
232
|
+
"within a channel. Configure exactly one log_channel_id for the adapter, or " \
|
|
233
|
+
"use DiscordStore::Log directly if per-stream ordering is enough."
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
def append(entries)
|
|
241
|
+
records = entries.map { |entry| Log::Record.new(stream: STREAM, data: entry) }
|
|
242
|
+
@log.append_all(records)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def start_flusher
|
|
246
|
+
@flusher = Thread.new do
|
|
247
|
+
Thread.current.name = "discord_store-journal-flusher"
|
|
248
|
+
loop do
|
|
249
|
+
sleep(@flush_interval)
|
|
250
|
+
begin
|
|
251
|
+
flush!
|
|
252
|
+
rescue StandardError => e
|
|
253
|
+
warn "discord_store: journal flush failed: #{e.class}: #{e.message}"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
@flusher.abort_on_exception = false
|
|
258
|
+
|
|
259
|
+
at_exit { flush! }
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|