Class: AceMQ::AMQP::Patterns::SQLIdempotencyStore
- Inherits:
-
Object
- Object
- AceMQ::AMQP::Patterns::SQLIdempotencyStore
- Includes:
- AceMQ::AMQP::Patterns::SQL::Statements
- Defined in:
- lib/acemq/amqp/patterns/sql_idempotency.rb
Overview
An idempotency store in a database table, shared by every consumer that points at it.
store = Patterns::SQLIdempotencyStore.new(connection: db)
store.create_schema # development and tests only
mq.consume("orders.new", &Patterns.idempotent(store) do ||
payments.charge(.payload)
Ack.accept
end)
The one InMemoryIdempotencyStore cannot replace. An in-process store deduplicates within one process, which is enough when duplicates arrive seconds apart on the same worker and useless the moment there are three workers behind one queue: the redelivery lands on a different one, finds an empty hash, and does the work again. Charging a card twice is not a caching problem.
The failure this has and the in-memory one does not
A shared store outlives the process using it, and that cuts both ways. If a consumer takes a key and then dies mid-handler, the row stays in the table. Naively, every redelivery of that message — for ever — is discarded as a duplicate, and a crash that should have cost one retry has silently deleted a message instead. The in-memory store never had this problem only because a crash wiped it.
So a key here is held under a lease, not a lock. It expires after
claim_timeout, after which another consumer may take it over. That
makes the timeout a real decision: too short and two consumers work on
the same message at once while the first is still going; too long and a
crashed consumer stalls that message for the duration. It should
comfortably exceed the slowest handler, and the default is deliberately
generous.
Confirmations are kept for retention and then forgotten, because a
table remembering every identifier ever seen is a disk-space incident
waiting to happen. A duplicate arriving after retention is handled
again — retention is the window within which duplicates are actually
expected, not a permanent record.
What it does not do
This deduplicates the delivery, not the work. If the handler writes to a different database from this table, a crash between the write committing and the confirmation landing leaves the work done and unrecorded, and the redelivery repeats it. Putting this table in the same database as the handler's writes closes that gap, and doing so is the caller's decision, since only the caller knows what the handler touches.
Schedule #purge_expired — hourly is ample. Nothing on the message path deletes anything, because a store that tidies up on the hot path makes every message pay for it.
Constant Summary collapse
- DEFAULT_TABLE =
"acemq_idempotency"- DEFAULT_CLAIM_TIMEOUT =
Long enough for a slow handler, short enough that a crash is not a permanent stall.
300.0- DEFAULT_RETENTION =
How long a confirmed identifier is remembered.
24 * 3600.0
- CLAIMED =
Taken and being worked on.
"CLAIMED"- CONFIRMED =
Done, and remembered until retention runs out.
"CONFIRMED"
Instance Attribute Summary collapse
-
#table ⇒ Object
readonly
The table this store reads and writes.
-
#worker ⇒ Object
readonly
Which worker this store claims as.
Class Method Summary collapse
-
.ddl(table) ⇒ Object
Written here rather than in a .sql file, because the gem ships lib/**/*.rb and a schema missing from the package is a schema that works in the repository and nowhere after it.
Instance Method Summary collapse
-
#claim(key) ⇒ Symbol
Claims a message, or says why not.
-
#confirm(key) ⇒ Object
Records that the work is done, and starts the retention clock.
-
#confirmed?(key) ⇒ Boolean
Whether a key is recorded as done and still within retention.
-
#create_schema ⇒ Object
Creates the table if it is not already there.
-
#first_time?(key) ⇒ Boolean
Records a key, and says whether this caller is the one to handle it.
-
#forget(key) ⇒ Object
Gives up this store's own hold, so a message that failed can be redone.
-
#initialize(connection:, retention: DEFAULT_RETENTION, claim_timeout: DEFAULT_CLAIM_TIMEOUT, table: DEFAULT_TABLE) ⇒ SQLIdempotencyStore
constructor
A new instance of SQLIdempotencyStore.
-
#purge_expired ⇒ Integer
Deletes rows nobody is bound by any more: confirmations past their retention, and holds whose lease has run out.
-
#size ⇒ Object
How many identifiers the table holds, expired ones included.
- #to_s ⇒ Object
Constructor Details
#initialize(connection:, retention: DEFAULT_RETENTION, claim_timeout: DEFAULT_CLAIM_TIMEOUT, table: DEFAULT_TABLE) ⇒ SQLIdempotencyStore
Returns a new instance of SQLIdempotencyStore.
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/acemq/amqp/patterns/sql_idempotency.rb', line 125 def initialize(connection:, retention: DEFAULT_RETENTION, claim_timeout: DEFAULT_CLAIM_TIMEOUT, table: DEFAULT_TABLE) @source = connection @table = SQL.table_name!(table) raise ArgumentError, "retention must be positive" unless retention.to_f.positive? unless claim_timeout.to_f.positive? raise ArgumentError, "claim_timeout must be positive" end @retention = retention.to_f @claim_timeout = claim_timeout.to_f # Identifies this instance's holds, so {#forget} can only give up its # own. Without it, releasing a failed handler's key would also delete # a key another worker had legitimately taken over after the lease # expired, and both would then run. @worker = SecureRandom.uuid end |
Instance Attribute Details
#table ⇒ Object (readonly)
The table this store reads and writes.
144 145 146 |
# File 'lib/acemq/amqp/patterns/sql_idempotency.rb', line 144 def table @table end |
#worker ⇒ Object (readonly)
Which worker this store claims as. Two stores on one table have different ones, which is what makes a release specific.
148 149 150 |
# File 'lib/acemq/amqp/patterns/sql_idempotency.rb', line 148 def worker @worker end |
Class Method Details
.ddl(table) ⇒ Object
Written here rather than in a .sql file, because the gem ships lib/**/*.rb and a schema missing from the package is a schema that works in the repository and nowhere after it.
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/acemq/amqp/patterns/sql_idempotency.rb', line 99 def self.ddl(table) [ <<~SQL.chomp, CREATE TABLE IF NOT EXISTS #{table} ( message_id VARCHAR(255) NOT NULL PRIMARY KEY, state VARCHAR(16) NOT NULL, claimed_by VARCHAR(64), recorded_at TIMESTAMP WITH TIME ZONE NOT NULL, expires_at TIMESTAMP WITH TIME ZONE NOT NULL ) SQL # One column carries both deadlines because they are never both # meaningful: while a row is CLAIMED it holds the lease expiry, and # once CONFIRMED it holds the retention expiry. Both answer the same # question — is this row still binding? — so one index serves the # claim path and the purge. "CREATE INDEX IF NOT EXISTS #{table}_expiry ON #{table} (expires_at)" ] end |
Instance Method Details
#claim(key) ⇒ Symbol
Claims a message, or says why not.
Claim::CLAIMED when there was no row or its lease had run out; Claim::DUPLICATE when the row is confirmed; Claim::IN_PROGRESS when somebody holds a live, unconfirmed claim -- still working, or failed without releasing it. The difference between the last two is the difference between done and maybe-never-done, which is why AceMQ::AMQP::Patterns.idempotent accepts one and puts the other back.
181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/acemq/amqp/patterns/sql_idempotency.rb', line 181 def claim(key) # Every key that reaches a statement goes through SQL.key, and it is # not tidiness: a message id off the wire is ASCII-8BIT, sqlite3 binds # that as a blob, and SQLite never matches a blob against a text # value. Two strings that are == in Ruby were two rows in a table # whose primary key exists to make that impossible — so the same # message could be handled twice, which is the one failure this class # exists to prevent. key = SQL.key(key) now = Time.now.utc return Claim::CLAIMED if take(key, now) # Somebody already has a row. Taking it over is allowed only if their # hold has run out, and the guard lives in the WHERE clause so that # the check and the take-over are one statement — a select followed by # an update would let two consumers both pass the check and both # conclude they had won. return Claim::CLAIMED if taken_over?(key, now) # Only asked once the take-over lost, so it decides nothing about who # runs. No row means it was released or purged between the two # statements: in progress is the answer that cannot lose the message. state = run("SELECT state FROM #{@table} WHERE message_id = ?", [key]).rows.dig(0, 0) state == CONFIRMED ? Claim::DUPLICATE : Claim::IN_PROGRESS end |
#confirm(key) ⇒ Object
Records that the work is done, and starts the retention clock.
Called by AceMQ::AMQP::Patterns.idempotent after a handler accepts. Without it a lease would simply expire and the next duplicate would be handled again, which is the whole failure a shared store is for.
212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/acemq/amqp/patterns/sql_idempotency.rb', line 212 def confirm(key) key = SQL.key(key) now = Time.now.utc updated = run( "UPDATE #{@table} SET state = ?, claimed_by = NULL, recorded_at = ?, " \ "expires_at = ? WHERE message_id = ?", [CONFIRMED, SQL.at_utc(now), SQL.at_utc(now + @retention), key] ).affected # The row was purged, or the lease expired and somebody else took it. # The work still happened, so it is recorded either way: an unrecorded # confirmation is how the same charge gets made twice. insert_confirmed(key, now) if updated.to_i.zero? nil end |
#confirmed?(key) ⇒ Boolean
Whether a key is recorded as done and still within retention.
240 241 242 243 244 245 246 247 248 |
# File 'lib/acemq/amqp/patterns/sql_idempotency.rb', line 240 def confirmed?(key) rows = run("SELECT expires_at FROM #{@table} WHERE message_id = ? AND state = ?", [SQL.key(key), CONFIRMED]).rows return false if rows.empty? # An expired row is answered as false rather than deleted: a read that # writes turns every duplicate check into a write on a shared table. SQL.time(rows[0][0]) > Time.now 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.
154 155 156 157 |
# File 'lib/acemq/amqp/patterns/sql_idempotency.rb', line 154 def create_schema self.class.ddl(@table).each { |statement| connection.run(statement, []) } nil end |
#first_time?(key) ⇒ Boolean
Records a key, and says whether this caller is the one to handle it.
The insert is the claim. A primary key makes it atomic across every process using this table, which is the entire reason a shared store can be trusted: two consumers racing on the same identifier cannot both succeed, whatever else they are doing.
168 |
# File 'lib/acemq/amqp/patterns/sql_idempotency.rb', line 168 def first_time?(key) = claim(key) == Claim::CLAIMED |
#forget(key) ⇒ Object
Gives up this store's own hold, so a message that failed can be redone.
Only a live hold of this worker's, and never a confirmation. Deleting somebody else's would put two consumers on one message; deleting a confirmation would undo it.
233 234 235 236 237 |
# File 'lib/acemq/amqp/patterns/sql_idempotency.rb', line 233 def forget(key) run("DELETE FROM #{@table} WHERE message_id = ? AND state = ? AND claimed_by = ?", [SQL.key(key), CLAIMED, @worker]) nil end |
#purge_expired ⇒ Integer
Deletes rows nobody is bound by any more: confirmations past their retention, and holds whose lease has run out.
254 255 256 257 |
# File 'lib/acemq/amqp/patterns/sql_idempotency.rb', line 254 def purge_expired run("DELETE FROM #{@table} WHERE expires_at <= ?", [SQL.at_utc(Time.now)]) .affected.to_i end |
#size ⇒ Object
How many identifiers the table holds, expired ones included.
260 261 262 |
# File 'lib/acemq/amqp/patterns/sql_idempotency.rb', line 260 def size run("SELECT COUNT(*) FROM #{@table}").rows.dig(0, 0).to_i end |
#to_s ⇒ Object
264 |
# File 'lib/acemq/amqp/patterns/sql_idempotency.rb', line 264 def to_s = "SQLIdempotencyStore(#{@table})" |