Class: AceMQ::AMQP::Patterns::SQLSchemaRegistry
- Inherits:
-
Object
- Object
- AceMQ::AMQP::Patterns::SQLSchemaRegistry
- Includes:
- AceMQ::AMQP::Patterns::SQL::Statements
- Defined in:
- lib/acemq/amqp/patterns/sql_schema.rb
Overview
A schema registry in a database table.
registry = Patterns::SQLSchemaRegistry.new(connection: db)
registry.create_schema # development and tests only
schema = registry.register("order.placed", "avro", definition)
registry.by_id(schema.id)
InMemorySchemaRegistry is fine for a test and useless for anything else: identifiers must be stable for ever, because a message published today may be read next year by a consumer looking up the shape it was written with. A registry that hands out fresh identifiers on restart makes every message written before the restart unreadable, and does it silently.
This is the smallest thing that fixes that. It is not Confluent's registry and does not try to be: no compatibility checking, no versioning interface, no HTTP. What it does is remember which integer stands for which schema, across restarts and across processes.
Answers are cached in memory and never invalidated, because neither can change: an identifier stands for one schema for ever, and a schema keeps the identifier it was given.
As with InMemorySchemaRegistry, nothing here puts anything on the wire. Which header carries a schema identifier is a cross-language contract and not one AceMQ has agreed yet.
Constant Summary collapse
- DEFAULT_TABLE =
"acemq_schema_registry"- REGISTRATION_ATTEMPTS =
Two is enough to settle the fingerprint race; the rest are for a database having a day.
5- COLUMNS =
Positional, because two drivers disagree about hash keys and neither disagrees about order.
"id, subject, schema_version, format, definition, fingerprint, registered_at"
Instance Attribute Summary collapse
-
#table ⇒ Object
readonly
The table this registry reads and writes.
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
-
#by_id(id) ⇒ Object
The schema an identifier names.
-
#create_schema ⇒ Object
Creates the tables if they are not already there, and seeds the counter so the first registration has something to lock rather than something to create.
-
#initialize(connection:, table: DEFAULT_TABLE) ⇒ SQLSchemaRegistry
constructor
A new instance of SQLSchemaRegistry.
-
#latest(subject) ⇒ Object
The newest version of a subject.
-
#register(subject, format, definition) ⇒ SchemaDefinition
Records a schema and returns it with an identifier.
-
#size ⇒ Object
How many schemas this registry holds.
- #to_s ⇒ Object
-
#versions(subject) ⇒ Object
Every version of a subject, oldest first.
Constructor Details
#initialize(connection:, table: DEFAULT_TABLE) ⇒ SQLSchemaRegistry
Returns a new instance of SQLSchemaRegistry.
110 111 112 113 114 115 |
# File 'lib/acemq/amqp/patterns/sql_schema.rb', line 110 def initialize(connection:, table: DEFAULT_TABLE) @source = connection @table = SQL.table_name!(table) @lock = Mutex.new @by_id = {} end |
Instance Attribute Details
#table ⇒ Object (readonly)
The table this registry reads and writes.
118 119 120 |
# File 'lib/acemq/amqp/patterns/sql_schema.rb', line 118 def table @table 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.
TEXT rather than the CLOB the Java schema uses: PostgreSQL has no
CLOB, and a definition is text everywhere that does.
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 |
# File 'lib/acemq/amqp/patterns/sql_schema.rb', line 69 def self.ddl(table) [ <<~SQL.chomp, CREATE TABLE IF NOT EXISTS #{table} ( id INTEGER NOT NULL PRIMARY KEY, subject VARCHAR(255) NOT NULL, schema_version INTEGER NOT NULL, format VARCHAR(32) NOT NULL, definition TEXT NOT NULL, fingerprint VARCHAR(64) NOT NULL, registered_at TIMESTAMP WITH TIME ZONE NOT NULL ) SQL # The fingerprint is what makes registration idempotent: the same # definition offered twice must come back with the same id, from any # process, for ever. Unique rather than merely indexed, so two # processes racing to register the same schema end with one row and # one loser that re-reads instead of a second id for the same bytes. "CREATE UNIQUE INDEX IF NOT EXISTS #{table}_fingerprint " \ "ON #{table} (subject, fingerprint)", # One row, holding the last identifier handed out. # # A sequence would be the obvious tool and is spelled differently on # every database this has to run on. Taking the highest id and # adding one needs no sequence and is wrong under load: two writers # registering two different schemas at the same moment compute the # same next id, and one of them loses a race it cannot win by # retrying, because the writer it lost to is doing the same # arithmetic. Updating this row takes a row lock, so writers queue # for an instant and every one of them gets a number. <<~SQL.chomp CREATE TABLE IF NOT EXISTS #{table}_seq ( only_row INTEGER NOT NULL PRIMARY KEY, last_id INTEGER NOT NULL ) SQL ] end |
Instance Method Details
#by_id(id) ⇒ Object
The schema an identifier names.
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/acemq/amqp/patterns/sql_schema.rb', line 169 def by_id(id) cached = @lock.synchronize { @by_id[id.to_i] } return cached if cached rows = run("SELECT #{COLUMNS} FROM #{@table} WHERE id = ?", [id.to_i]).rows if rows.empty? # A message naming a schema this registry has never held is usually # one environment reading another's messages, which is worth saying # plainly: the alternative guess, a corrupt message, sends people # looking in the wrong place. raise SchemaNotFound, "no schema with id #{id}. Either a message was written against a schema " \ "registered somewhere else, or this is not the registry that wrote it." end remember(schema_from(rows[0])) end |
#create_schema ⇒ Object
Creates the tables if they are not already there, and seeds the counter so the first registration has something to lock rather than something to create.
123 124 125 126 127 |
# File 'lib/acemq/amqp/patterns/sql_schema.rb', line 123 def create_schema self.class.ddl(@table).each { |statement| connection.run(statement, []) } seed_counter nil end |
#latest(subject) ⇒ Object
The newest version of a subject.
190 191 192 193 194 195 196 |
# File 'lib/acemq/amqp/patterns/sql_schema.rb', line 190 def latest(subject) rows = run("SELECT #{COLUMNS} FROM #{@table} WHERE subject = ? " \ "ORDER BY schema_version DESC LIMIT 1", [subject.to_s]).rows raise SchemaNotFound, "no schema for subject #{subject.inspect}" if rows.empty? remember(schema_from(rows[0])) end |
#register(subject, format, definition) ⇒ SchemaDefinition
Records a schema and returns it with an identifier.
The same definition registered twice returns the same identifier rather than making a second version. Without that, a service that registers its schemas on every start adds a version per restart, and a week later the subject has three hundred identical versions.
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'lib/acemq/amqp/patterns/sql_schema.rb', line 141 def register(subject, format, definition) if subject.to_s.empty? || definition.to_s.empty? raise ArgumentError, "a schema needs a subject and a definition" end fingerprint = Patterns.fingerprint(definition) # The loop is for one race: two writers registering the same schema at # the same moment. The unique index lets exactly one of them insert, # and the loser comes back round to read the winner's row rather than # inventing a second identifier for the same bytes. REGISTRATION_ATTEMPTS.times do found = by_fingerprint(subject.to_s, fingerprint) return remember(found) if found next unless insert_new(subject.to_s, format.to_s, definition.to_s, fingerprint) return remember(by_fingerprint(subject.to_s, fingerprint)) end raise FatalError, "could not register #{subject.inspect} after #{REGISTRATION_ATTEMPTS} " \ "attempts. Every insert was refused for breaking a constraint and no " \ "matching fingerprint was there afterwards, so #{@table} has a constraint " \ "this registry does not know about -- check what a migration added to it." end |
#size ⇒ Object
How many schemas this registry holds.
209 |
# File 'lib/acemq/amqp/patterns/sql_schema.rb', line 209 def size = run("SELECT COUNT(*) FROM #{@table}").rows.dig(0, 0).to_i |
#to_s ⇒ Object
211 |
# File 'lib/acemq/amqp/patterns/sql_schema.rb', line 211 def to_s = "SQLSchemaRegistry(#{@table})" |
#versions(subject) ⇒ Object
Every version of a subject, oldest first.
Empty when there are none, because "what versions are there" has a sensible answer for a subject nobody has registered and "which schema is this" does not.
203 204 205 206 |
# File 'lib/acemq/amqp/patterns/sql_schema.rb', line 203 def versions(subject) run("SELECT #{COLUMNS} FROM #{@table} WHERE subject = ? ORDER BY schema_version", [subject.to_s]).rows.map { |row| remember(schema_from(row)) } end |