Class: Tina4::SessionHandlers::DatabaseHandler
- Inherits:
-
Object
- Object
- Tina4::SessionHandlers::DatabaseHandler
- Defined in:
- lib/tina4/session_handlers/database_handler.rb
Constant Summary collapse
- TABLE_NAME =
"tina4_session"- CREATE_TABLE_SQL =
CREATE TABLE per engine. This is the ONLY genuinely per-engine SQL in this file - every other statement is written once with
?placeholders and rewritten for the driver by the Database layer.The COLUMNS are identical on every engine (session_id, data, expires_at) because the table is a CROSS-FRAMEWORK CONTRACT: a tina4_session table written by tina4-python must be readable by tina4-ruby. Only the type spellings and the "create it only if absent" idiom differ. Ported from the Node reference under ADR-0004 (tina4-nodejs/packages/core/src/sessionHandlers/databaseHandler.ts).
WHAT WAS WRONG. This used to be ONE generic string for every engine, opening with CREATE TABLE IF NOT EXISTS. That clause is not T-SQL, so the whole statement was a SYNTAX ERROR on SQL Server and the database session backend did not work on MSSQL AT ALL - the identical defect measured and fixed in PHP. Firebird rejected the same string twice over: it has no IF NOT EXISTS clause AND no TEXT type.
{ # Behaviour-identical to the generic DDL this replaces, so an EXISTING # deployment is untouched. IF NOT EXISTS makes the statement a no-op # against a table that is already there, so a live tina4_session keeps # exactly the columns it was created with; and on a FRESH database the # storage classes are the same ones the old spelling produced, because # SQLite resolves VARCHAR(255) to TEXT affinity and DOUBLE PRECISION to # REAL affinity. Only the declared type string in sqlite_master # changes, and nothing reads that. "sqlite" => <<~SQL, "postgres" => <<~SQL, "mysql" => <<~SQL, CREATE TABLE IF NOT EXISTS #{TABLE_NAME} ( session_id VARCHAR(255) PRIMARY KEY, data TEXT NOT NULL, expires_at DOUBLE NOT NULL ) SQL # T-SQL has no CREATE TABLE IF NOT EXISTS. Node guards this statement # with `IF OBJECT_ID(N'tina4_session', N'U') IS NULL`; that guard is # deliberately NOT carried over, because it is CHECK-THEN-ACT and has a # window - two connections can both see NULL and both CREATE, and the # loser gets `Msg 2714: There is already an object named # 'tina4_session'`, measured on live SQL Server. This takes Node's # TYPES and pairs them with PHP's RESCUE in ensure_table below, which # is strictly better than either alone: Node's types make the statement # legal on this engine, and PHP's rescue closes the window Node's # catalog check leaves open. "mssql" => <<~SQL, CREATE TABLE #{TABLE_NAME} ( session_id NVARCHAR(255) NOT NULL PRIMARY KEY, data NVARCHAR(MAX) NOT NULL, expires_at FLOAT NOT NULL ) SQL # Firebird has neither IF NOT EXISTS nor a TEXT type, so the catalog # check goes in an EXECUTE BLOCK and the payload is a VARCHAR. That # caps a session payload at 8191 characters ON THIS ENGINE ALONE, which # is the deliberate price of not using BLOB SUB_TYPE TEXT: a driver # hands a blob back as a reader rather than a string, which the read # path here would not understand. # # VERIFIED AT THE SQL LEVEL ONLY (Firebird 5.0.4, via isql, measured on # the lab container): CREATE TABLE IF NOT EXISTS fails -104 "Token # unknown ... NOT", a TEXT column fails -607 "Specified domain or # source column TEXT does not exist", DOUBLE PRECISION is accepted, and # this EXECUTE BLOCK creates the table and is idempotent on a second # run. That idempotence is check-then-act inside one block, so it is # NOT a race guard - a bare CREATE with the table present gives # SQLSTATE 42S01, so the rescue below is required here exactly as on # mssql. "firebird" => <<~SQL EXECUTE BLOCK AS BEGIN IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = '#{TABLE_NAME.upcase}')) THEN EXECUTE STATEMENT 'CREATE TABLE #{TABLE_NAME.upcase} (SESSION_ID VARCHAR(255) NOT NULL PRIMARY KEY, DATA VARCHAR(8191) NOT NULL, EXPIRES_AT DOUBLE PRECISION NOT NULL)'; END SQL }.freeze
Instance Method Summary collapse
- #cleanup ⇒ Object
- #destroy(session_id) ⇒ Object
-
#gc(max_age) ⇒ Object
Garbage-collect expired sessions.
-
#initialize(options = {}) ⇒ DatabaseHandler
constructor
NO NETWORK I/O IN A CONSTRUCTOR (ADR-0021, session_contract.json #4).
- #read(session_id) ⇒ Object
-
#write(session_id, data, ttl = 0) ⇒ Object
Write session data.
Constructor Details
#initialize(options = {}) ⇒ DatabaseHandler
NO NETWORK I/O IN A CONSTRUCTOR (ADR-0021, session_contract.json #4). Both lines this constructor used to run were real traffic:
Tina4::Database.new(...) - #initialize ends in `connect` for a
single-connection database (database.rb:405), so the driver DIALS.
MEASURED against a real counting TCP listener: 1 accepted connection.
ensure_table - a CREATE TABLE IF NOT EXISTS, real DDL on
that connection.
And because the request path builds a Session per request, that ran on EVERY request. Both sat OUTSIDE the log-loud-and-degrade policy, so an unreachable database took the app down at construction instead of degrading per request as designed. Connection and table are now resolved on FIRST USE, inside that policy.
112 113 114 115 116 117 118 119 |
# File 'lib/tina4/session_handlers/database_handler.rb', line 112 def initialize( = {}) # TINA4_SESSION_TTL reaches every backend (ADR-0024); was a hard-coded # 86400. 3600 matches Python (the master), PHP and Node. @ttl = ([:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i @db_option = [:db] @db = nil @table_ready = false end |
Instance Method Details
#cleanup ⇒ Object
162 163 164 165 |
# File 'lib/tina4/session_handlers/database_handler.rb', line 162 def cleanup ensure_table db.execute("DELETE FROM #{TABLE_NAME} WHERE expires_at > 0 AND expires_at < ?", [Time.now.to_f]) end |
#destroy(session_id) ⇒ Object
157 158 159 160 |
# File 'lib/tina4/session_handlers/database_handler.rb', line 157 def destroy(session_id) ensure_table db.execute("DELETE FROM #{TABLE_NAME} WHERE session_id = ?", [session_id]) end |
#gc(max_age) ⇒ Object
Garbage-collect expired sessions. Matches the Python interface.
169 170 171 172 |
# File 'lib/tina4/session_handlers/database_handler.rb', line 169 def gc(max_age) ensure_table db.execute("DELETE FROM #{TABLE_NAME} WHERE expires_at > 0 AND expires_at < ?", [Time.now.to_f]) end |
#read(session_id) ⇒ Object
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/tina4/session_handlers/database_handler.rb', line 121 def read(session_id) ensure_table row = db.fetch_one("SELECT data, expires_at FROM #{TABLE_NAME} WHERE session_id = ?", [session_id]) return nil unless row expires_at = (row[:expires_at] || row["expires_at"]).to_f if expires_at > 0 && expires_at < Time.now.to_f destroy(session_id) return nil end JSON.parse(row[:data] || row["data"]) rescue JSON::ParserError nil end |
#write(session_id, data, ttl = 0) ⇒ Object
Write session data. A per-call ttl WINS over the handler default; 0 means
never expires and is stored as the 0 that read guards out.
143 144 145 146 147 148 149 150 151 152 153 154 155 |
# File 'lib/tina4/session_handlers/database_handler.rb', line 143 def write(session_id, data, ttl = 0) ensure_table effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl expires_at = effective_ttl.positive? ? Time.now.to_f + effective_ttl : 0.0 json_data = JSON.generate(data) existing = db.fetch_one("SELECT session_id FROM #{TABLE_NAME} WHERE session_id = ?", [session_id]) if existing db.execute("UPDATE #{TABLE_NAME} SET data = ?, expires_at = ? WHERE session_id = ?", [json_data, expires_at, session_id]) else db.execute("INSERT INTO #{TABLE_NAME} (session_id, data, expires_at) VALUES (?, ?, ?)", [session_id, json_data, expires_at]) end end |