Class: DiscordStore::Journal
- Inherits:
-
Object
- Object
- DiscordStore::Journal
- Defined in:
- lib/discord_store/journal.rb
Overview
The write-ahead log behind the ActiveRecord adapter.
Each entry is a SQL statement plus its bind values, which is logical replication in the plainest possible form: replaying the statements in order against an empty database reproduces the database. Postgres and MySQL both ship a version of this idea; the only novelty here is that the log segment is a Discord channel.
Statements are journalled with their binds already materialised, because a prepared statement handle means nothing to a reader on another machine three months from now.
Constant Summary collapse
- STREAM =
"wal"- CURSOR_TABLE =
"discord_store_journal"- MODES =
%i[sync async off].freeze
- BINARY_TAG =
Tags for values JSON cannot carry losslessly.
"__b"- TIME_TAG =
"__t"- DATE_TAG =
"__D"- DECIMAL_TAG =
"__d"
Instance Attribute Summary collapse
-
#local_cursor ⇒ String?
How far the local database has been replayed.
-
#log ⇒ Object
readonly
Returns the value of attribute log.
-
#mode ⇒ Object
readonly
Returns the value of attribute mode.
Class Method Summary collapse
-
.build_configuration(options) ⇒ Configuration
Builds a Configuration from a database.yml stanza.
- .deserialize_binds(values) ⇒ Array
- .deserialize_value(value) ⇒ Object
-
.serialize_binds(binds) ⇒ Array
Turns ActiveRecord bind parameters into something JSON can carry, with tags for the types it cannot.
- .serialize_value(value) ⇒ Object
Instance Method Summary collapse
- #close ⇒ void
-
#each_transaction(from: nil) {|entry, cursor| ... } ⇒ void
Reads transactions in order.
-
#flush! ⇒ Integer
Writes anything buffered.
-
#initialize(config:, mode: :sync, flush_interval: 1.0, max_buffer: 200, http: nil) ⇒ Journal
constructor
A new instance of Journal.
-
#pending_count ⇒ Integer
Entries waiting to be written.
- #recording? ⇒ Boolean
-
#tip ⇒ String?
Cursor of the newest record in the channel.
-
#while_paused ⇒ Object
Suspends writing for the duration of the block, leaving reads working.
-
#write(statements) ⇒ String?
Records one transaction.
Constructor Details
#initialize(config:, mode: :sync, flush_interval: 1.0, max_buffer: 200, http: nil) ⇒ Journal
Returns a new instance of Journal.
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/discord_store/journal.rb', line 38 def initialize(config:, mode: :sync, flush_interval: 1.0, max_buffer: 200, http: nil) raise ConfigurationError, "journal_mode must be one of #{MODES.join(", ")}" unless MODES.include?(mode) @mode = mode @flush_interval = flush_interval @max_buffer = max_buffer @buffer = [] @mutex = Mutex.new @local_cursor = nil return if mode == :off @client = Client.new(config: config, http: http) @log = @client.log start_flusher if mode == :async end |
Instance Attribute Details
#local_cursor ⇒ String?
How far the local database has been replayed.
131 132 133 |
# File 'lib/discord_store/journal.rb', line 131 def local_cursor @local_cursor end |
#log ⇒ Object (readonly)
Returns the value of attribute log.
31 32 33 |
# File 'lib/discord_store/journal.rb', line 31 def log @log end |
#mode ⇒ Object (readonly)
Returns the value of attribute mode.
31 32 33 |
# File 'lib/discord_store/journal.rb', line 31 def mode @mode end |
Class Method Details
.build_configuration(options) ⇒ Configuration
Builds a Configuration from a database.yml stanza.
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 |
# File 'lib/discord_store/journal.rb', line 217 def self.build_configuration() Configuration.new.tap do |config| config.i_understand_this_violates_discord_tos = [:i_understand_this_violates_discord_tos] config.token = [:token] config.application_id = [:application_id] config.guild_id = [:guild_id] config.secret_key = [:secret_key] config.cipher = ([:cipher] || :aes_256_gcm).to_sym config.log_channel_ids = Array([:log_channel_ids]).map(&:to_s) config.logger = [:logger] if config.log_channel_ids.size > 1 raise ConfigurationError, "a write-ahead log needs one total order, and message IDs are only ordered " \ "within a channel. Configure exactly one log_channel_id for the adapter, or " \ "use DiscordStore::Log directly if per-stream ordering is enough." end end end |
.deserialize_binds(values) ⇒ Array
196 197 198 |
# File 'lib/discord_store/journal.rb', line 196 def self.deserialize_binds(values) Array(values).map { |value| deserialize_value(value) } end |
.deserialize_value(value) ⇒ Object
202 203 204 205 206 207 208 209 210 211 |
# File 'lib/discord_store/journal.rb', line 202 def self.deserialize_value(value) return value unless value.is_a?(Hash) if value.key?(BINARY_TAG) then Base64.strict_decode64(value[BINARY_TAG]) elsif value.key?(TIME_TAG) then Time.iso8601(value[TIME_TAG]) elsif value.key?(DATE_TAG) then Date.iso8601(value[DATE_TAG]) elsif value.key?(DECIMAL_TAG) then BigDecimal(value[DECIMAL_TAG]) else value end end |
.serialize_binds(binds) ⇒ Array
Turns ActiveRecord bind parameters into something JSON can carry, with tags for the types it cannot.
161 162 163 164 165 166 |
# File 'lib/discord_store/journal.rb', line 161 def self.serialize_binds(binds) Array(binds).map do |bind| value = bind.respond_to?(:value_for_database) ? bind.value_for_database : bind serialize_value(value) end end |
.serialize_value(value) ⇒ Object
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/discord_store/journal.rb', line 170 def self.serialize_value(value) case value when nil, true, false, Integer, Float then value when BigDecimal then { DECIMAL_TAG => value.to_s("F") } when Time then { TIME_TAG => value.utc.iso8601(6) } when DateTime then { TIME_TAG => value.to_time.utc.iso8601(6) } when Date then { DATE_TAG => value.iso8601 } when String # A UTF-8 string rides as itself; anything else is bytes, and bytes do # not survive JSON. if value.encoding == Encoding::BINARY || !value.valid_encoding? { BINARY_TAG => Base64.strict_encode64(value) } else value end else if value.respond_to?(:to_s) && value.class.name.to_s.include?("Binary") { BINARY_TAG => Base64.strict_encode64(value.to_s) } else value.to_s end end end |
Instance Method Details
#close ⇒ void
This method returns an undefined value.
148 149 150 151 152 |
# File 'lib/discord_store/journal.rb', line 148 def close flush! @flusher&.kill @client&.close end |
#each_transaction(from: nil) {|entry, cursor| ... } ⇒ void
This method returns an undefined value.
Reads transactions in order.
114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/discord_store/journal.rb', line 114 def each_transaction(from: nil) # Deliberately checks for a log rather than for recording?: a replay reads # while paused, and gating this on the write mode would make it a no-op # exactly when it matters. return if @log.nil? @log.each(stream: STREAM, after: from) do |record| data = record.data next unless data.is_a?(Hash) && data["tx"] yield data, record.cursor end end |
#flush! ⇒ Integer
Writes anything buffered.
84 85 86 87 88 89 90 91 92 93 94 95 96 |
# File 'lib/discord_store/journal.rb', line 84 def flush! return 0 unless @mode == :async pending = @mutex.synchronize do drained = @buffer @buffer = [] drained end return 0 if pending.empty? append(pending) pending.size end |
#pending_count ⇒ Integer
Returns entries waiting to be written.
99 |
# File 'lib/discord_store/journal.rb', line 99 def pending_count = @mutex.synchronize { @buffer.size } |
#recording? ⇒ Boolean
56 |
# File 'lib/discord_store/journal.rb', line 56 def recording? = @mode != :off |
#tip ⇒ String?
Returns cursor of the newest record in the channel.
102 103 104 105 106 |
# File 'lib/discord_store/journal.rb', line 102 def tip return nil unless recording? @log.tip(stream: STREAM) end |
#while_paused ⇒ Object
Suspends writing for the duration of the block, leaving reads working.
Replay uses this: applying a statement locally must not journal it back, or every replay would duplicate the log it just read.
139 140 141 142 143 144 145 |
# File 'lib/discord_store/journal.rb', line 139 def while_paused previous = @mode @mode = :off yield ensure @mode = previous end |
#write(statements) ⇒ String?
Records one transaction.
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
# File 'lib/discord_store/journal.rb', line 62 def write(statements) return nil unless recording? return nil if statements.empty? entry = { "tx" => statements, "at" => Time.now.utc.iso8601(3) } case @mode when :sync append([entry]).last&.cursor when :async buffered = @mutex.synchronize do @buffer << entry @buffer.size end flush! if buffered >= @max_buffer nil end end |