Class: AceMQ::AMQP::Patterns::SQLOutboxStore
- Inherits:
-
Object
- Object
- AceMQ::AMQP::Patterns::SQLOutboxStore
- Includes:
- AceMQ::AMQP::Patterns::SQL::Statements
- Defined in:
- lib/acemq/amqp/patterns/sql_outbox.rb
Overview
An outbox in a database table, in the same database as the work it accompanies.
store = Patterns::SQLOutboxStore.new(connection: db)
store.create_schema # development and tests only
db.transaction do
orders.insert(order)
store.add(Patterns.record(mq, event, to: "order.placed"), connection: db)
end
The connection handed to add is the point of the whole class.
InMemoryOutboxStore is criticised in its own comment for not sharing a
transaction with anybody's database, and a store that opened its own
connection would have exactly that flaw while looking as though it had
been dealt with: the insert would commit on its own, and a business
write that rolled back afterwards would leave a message queued for
something that never happened.
So the insert goes on the caller's connection, inside the caller's transaction, and this class neither commits it nor closes it. The message becomes durable exactly when the work does, and not before.
Two sources of connections
The two halves of this class run in different worlds. add belongs to
the caller's transaction. Everything the relay does — claiming, marking,
counting — happens on a background thread with no ambient transaction,
so it wants a connection of its own. Under a connection pool relay:
has to be a different connection from the one a request is using, or the
relay's thread and the request's thread are writing down the same
socket. With one connection — SQLite, a single-threaded service — the
same one for both is correct and is the default.
Claiming is a lease, not a lock
A held lock lasts as long as its transaction, so a relay that dies mid-batch either strands its rows until the database notices the connection has gone or holds a transaction open across a network publish. Neither is acceptable. A lease is a timestamp: it expires on its own, however the holder died, and the rows return to circulation with nobody intervening.
The claim is taken in two statements, and the second is what makes it safe. Candidates are selected, then each is updated with the lease still conditional on being unclaimed. Two relays that select the same candidates both try to update them; row locking serialises the updates, the loser re-evaluates its condition against the committed row and matches nothing. The claim is decided by the update's row count, never by the select.
Constant Summary collapse
- DEFAULT_TABLE =
"acemq_outbox"- DEFAULT_MAX_ATTEMPTS =
How many failures a record may accumulate before it stops being claimed and stays for somebody to look at.
10- DEFAULT_LEASE =
How long a relay holds a record before another may take it over. Comfortably longer than a publish and a confirm, and short enough that a relay that died does not strand a batch for long.
60.0- MAX_BATCH =
More than this in one sweep is a relay that has stopped being a relay.
10_000- COLUMNS =
Positional, because two drivers disagree about hash keys and neither disagrees about order.
"id, exchange_name, routing_key, body, body_encoding, content_type, " \ "headers, created_at, attempts, last_error"
Instance Attribute Summary collapse
-
#table ⇒ Object
readonly
The table this store reads and writes.
Class Method Summary collapse
-
.ddl(table) ⇒ Object
The table, as the two statements that make it.
Instance Method Summary collapse
-
#add(record, connection: nil) ⇒ Object
Records a message to be published, on the caller's connection.
-
#create_schema ⇒ Object
Creates the table if it is not already there.
-
#initialize(connection:, relay: nil, table: DEFAULT_TABLE, max_attempts: DEFAULT_MAX_ATTEMPTS, lease: DEFAULT_LEASE) ⇒ SQLOutboxStore
constructor
A new instance of SQLOutboxStore.
-
#mark_failed(id, reason) ⇒ Object
Records that publishing a record failed, and gives up its lease.
-
#mark_published(id) ⇒ Object
Removes a record the broker has confirmed.
-
#pending(limit = 0) ⇒ Array<OutboxRecord>
The records waiting, oldest first, claimed under a lease.
-
#pending_count ⇒ Object
(also: #size)
How many records are waiting.
-
#purge_published(older_than) ⇒ Integer
Removes published records older than
older_thanseconds. - #to_s ⇒ Object
Constructor Details
#initialize(connection:, relay: nil, table: DEFAULT_TABLE, max_attempts: DEFAULT_MAX_ATTEMPTS, lease: DEFAULT_LEASE) ⇒ SQLOutboxStore
Returns a new instance of SQLOutboxStore.
140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/acemq/amqp/patterns/sql_outbox.rb', line 140 def initialize(connection:, relay: nil, table: DEFAULT_TABLE, max_attempts: DEFAULT_MAX_ATTEMPTS, lease: DEFAULT_LEASE) @source = connection @relay_source = relay || connection @table = SQL.table_name!(table) raise ArgumentError, "max_attempts must be at least 1" if max_attempts.to_i < 1 raise ArgumentError, "a lease must be positive" unless lease.to_f.positive? @max_attempts = max_attempts.to_i @lease = lease.to_f end |
Instance Attribute Details
#table ⇒ Object (readonly)
The table this store reads and writes.
153 154 155 |
# File 'lib/acemq/amqp/patterns/sql_outbox.rb', line 153 def table @table end |
Class Method Details
.ddl(table) ⇒ Object
The table, as the two statements that make it.
Written here rather than read from a .sql file because the gem ships lib/**/*.rb and nothing else, and a schema that is missing from the package is a schema that works in the repository and nowhere after it. Keeping it as separate statements also avoids splitting a file on semicolons, which goes wrong the first time a comment contains one.
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/acemq/amqp/patterns/sql_outbox.rb', line 105 def self.ddl(table) [ <<~SQL.chomp, CREATE TABLE IF NOT EXISTS #{table} ( id VARCHAR(64) NOT NULL PRIMARY KEY, exchange_name VARCHAR(255) NOT NULL, routing_key VARCHAR(255) NOT NULL, body TEXT NOT NULL, body_encoding VARCHAR(16) NOT NULL, content_type VARCHAR(255) NOT NULL, headers TEXT NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL, published_at TIMESTAMP WITH TIME ZONE, attempts INTEGER NOT NULL DEFAULT 0, last_error VARCHAR(1000), locked_by VARCHAR(64), locked_until TIMESTAMP WITH TIME ZONE ) SQL # The relay's only query is "oldest unpublished first", so that is # what is indexed. "CREATE INDEX IF NOT EXISTS #{table}_pending ON #{table} (published_at, created_at)" ] end |
Instance Method Details
#add(record, connection: nil) ⇒ Object
Records a message to be published, on the caller's connection.
Neither committed nor closed here. The connection belongs to the caller's transaction, and this insert becomes durable exactly when that transaction does — which is the guarantee the whole pattern rests on.
Adding the same record twice is not an error, because the caller may be retrying its own transaction, but it must not become two messages.
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# File 'lib/acemq/amqp/patterns/sql_outbox.rb', line 180 def add(record, connection: nil) raise ArgumentError, "an outbox record needs an id" if record.id.to_s.empty? body, encoding = encoded(record.body) sql = "INSERT INTO #{@table} (#{COLUMNS}, published_at, locked_by, locked_until) " \ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)" # SQL.key rather than to_s: an id that came off the wire is # ASCII-8BIT, sqlite3 binds that as a blob, and a blob never matches # the text the same id was written as. Here that would turn "adding # the same record twice is not an error" into two rows and two # published messages. See SQL.key. run_unless_taken(sql, [SQL.key(record.id), record.exchange.to_s, record.routing_key.to_s, body, encoding, record.content_type.to_s, JSON.generate(record.headers || {}), SQL.at_utc(record.created_at || Time.now), 0, nil], on: connection_for(connection)) nil end |
#create_schema ⇒ Object
Creates the table if it is not already there.
For development and tests. In production the table belongs in the migration tool that owns the rest of the schema, alongside the business tables it commits with — a library that creates tables at start-up has taken a decision about when your database changes that is not its to take.
162 163 164 165 |
# File 'lib/acemq/amqp/patterns/sql_outbox.rb', line 162 def create_schema self.class.ddl(@table).each { |statement| connection.run(statement, []) } nil end |
#mark_failed(id, reason) ⇒ Object
Records that publishing a record failed, and gives up its lease.
Counting the attempt is what eventually stops a record nothing can publish from being claimed on every sweep for ever, and releasing the lease is what lets the next sweep try it rather than waiting out a minute for nothing.
240 241 242 243 244 245 246 247 248 249 250 251 |
# File 'lib/acemq/amqp/patterns/sql_outbox.rb', line 240 def mark_failed(id, reason) run("UPDATE #{@table} SET attempts = attempts + 1, last_error = ?, " \ "locked_by = NULL, locked_until = NULL WHERE id = ?", [truncate(reason), SQL.key(id)]) nil rescue StandardError # Swallowed on purpose. This is already the failure path, and a relay # that died here would leave the record leased rather than free; # letting the lease expire is the gentler outcome, and the message is # still in the table either way. nil end |
#mark_published(id) ⇒ Object
Removes a record the broker has confirmed.
The lease is cleared along with the mark, so the row reads plainly afterwards: a published row still showing a lock confuses whoever is reading the table during an incident.
228 229 230 231 232 |
# File 'lib/acemq/amqp/patterns/sql_outbox.rb', line 228 def mark_published(id) run("UPDATE #{@table} SET published_at = ?, locked_by = NULL, locked_until = NULL " \ "WHERE id = ?", [SQL.at_utc(Time.now), SQL.key(id)]) nil end |
#pending(limit = 0) ⇒ Array<OutboxRecord>
The records waiting, oldest first, claimed under a lease.
Claiming rather than merely reading, because two relays that both read the same batch both publish it, and the duplicate is the one thing a relay can avoid without any help from the consumer. What comes back is what this call won, which may be fewer than were asked for and may be none.
210 211 212 213 214 215 216 217 218 219 220 221 |
# File 'lib/acemq/amqp/patterns/sql_outbox.rb', line 210 def pending(limit = 0) token = SecureRandom.uuid now = Time.now.utc candidates = candidate_ids(limit_of(limit), now) return [] if candidates.empty? # Every candidate went to another relay between the select and the # update. Not an error, and not worth a second round trip: the next # sweep will find more. return [] if take_lease(candidates, token, now).zero? claimed(token) end |
#pending_count ⇒ Object Also known as: size
How many records are waiting.
254 255 256 257 |
# File 'lib/acemq/amqp/patterns/sql_outbox.rb', line 254 def pending_count result = run("SELECT COUNT(*) FROM #{@table} WHERE published_at IS NULL", []) result.rows.dig(0, 0).to_i end |
#purge_published(older_than) ⇒ Integer
Removes published records older than older_than seconds.
Nothing deletes them otherwise, and an outbox table that only grows eventually makes the relay's own query slow. How long to keep them is a judgement about auditing rather than about messaging, so it is the caller's to make.
268 269 270 271 272 |
# File 'lib/acemq/amqp/patterns/sql_outbox.rb', line 268 def purge_published(older_than) cutoff = SQL.at_utc(Time.now - older_than.to_f) run("DELETE FROM #{@table} WHERE published_at IS NOT NULL AND published_at < ?", [cutoff]).affected.to_i end |
#to_s ⇒ Object
274 |
# File 'lib/acemq/amqp/patterns/sql_outbox.rb', line 274 def to_s = "SQLOutboxStore(#{@table})" |