Class: Insika::Stores::SQLite

Inherits:
Object
  • Object
show all
Includes:
Insika::Store
Defined in:
lib/insika/stores/sqlite.rb

Overview

SQLite backend — the production default. A single kv table; the domain lives in the scopes. One handle per process, writes in a transaction serialized by an Async::Semaphore.

Constant Summary collapse

DDL =
<<~SQL
  CREATE TABLE IF NOT EXISTS kv (
    scope      TEXT    NOT NULL,
    key        TEXT    NOT NULL,
    value      TEXT    NOT NULL,
    updated_at TEXT    NOT NULL,
    PRIMARY KEY (scope, key)
  ) WITHOUT ROWID;
SQL

Instance Method Summary collapse

Constructor Details

#initialize(path:, serializer: JSON) ⇒ SQLite

lazy require: the core installs without the sqlite3 gem when only Memory is used.



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/insika/stores/sqlite.rb', line 35

def initialize(path:, serializer: JSON)
  require "sqlite3"

  @serializer = serializer
  @db = SQLite3::Database.new(path)
  @write_semaphore = Async::Semaphore.new(1)
  @tx_owner = nil

  # Multi-process boot (N Falcon workers opening the SAME file at the
  # same time): `PRAGMA journal_mode = WAL` on a new file needs an
  # EXCLUSIVE lock and may return SQLITE_BUSY right then — the busy
  # timeout alone does NOT cover the journal-mode switch. Hence: timeout
  # FIRST (covers the DDL and the hot path) + retry with backoff around
  # the initialization (covers the WAL-switch race). Idempotent:
  # reopening already-in-WAL is a no-op.
  #
  # `busy_handler_timeout=`, NOT `busy_timeout=`: the C-level handler
  # sleeps holding the GVL, so a worker waiting on another PROCESS's
  # write lock would stall every fiber it is running for up to the full
  # timeout (measured: a waiter blocks the winner's own commit). The
  # Ruby-level handler sleeps in Ruby — the scheduler keeps the rest of
  # the worker breathing while this handle waits its turn.
  @db.busy_handler_timeout = 5_000
  with_busy_retry do
    @db.execute("PRAGMA journal_mode = WAL")
    @db.execute("PRAGMA synchronous = NORMAL")
    @db.execute_batch(DDL)
  end
  # The WITHOUT ROWID PRIMARY KEY (scope, key) is already the prefix index —
  # there is no extra index to create.
rescue ::SQLite3::Exception => e
  raise Insika::StoreError, "failed to open #{path}: #{e.message}"
end

Instance Method Details

#closeObject



89
90
91
92
# File 'lib/insika/stores/sqlite.rb', line 89

def close
  @db&.close
  nil
end

#delete(scope, key) ⇒ Object



115
116
117
118
119
120
# File 'lib/insika/stores/sqlite.rb', line 115

def delete(scope, key)
  transaction do
    @db.execute("DELETE FROM kv WHERE scope = ? AND key = ?", [scope, key])
    @db.changes.positive?
  end
end

#get(scope, key) ⇒ Object



94
95
96
97
98
99
100
101
# File 'lib/insika/stores/sqlite.rb', line 94

def get(scope, key)
  row = @db.get_first_value(
    "SELECT value FROM kv WHERE scope = ? AND key = ?", [scope, key]
  )
  row.nil? ? nil : @serializer.parse(row)
rescue ::SQLite3::Exception => e
  raise Insika::StoreError, e.message
end

#list(scope, prefix = nil) ⇒ Object



122
123
124
125
126
127
128
129
# File 'lib/insika/stores/sqlite.rb', line 122

def list(scope, prefix = nil)
  keys = @db.execute(
    "SELECT key FROM kv WHERE scope = ? ORDER BY key", [scope]
  ).map(&:first)
  prefix ? keys.select { |k| k.start_with?(prefix) } : keys
rescue ::SQLite3::Exception => e
  raise Insika::StoreError, e.message
end

#set(scope, key, value) ⇒ Object



103
104
105
106
107
108
109
110
111
112
113
# File 'lib/insika/stores/sqlite.rb', line 103

def set(scope, key, value)
  serialized = serialize(value)               # fail-fast BEFORE writing
  transaction do
    @db.execute(
      "INSERT OR REPLACE INTO kv (scope, key, value, updated_at) " \
      "VALUES (?, ?, ?, ?)",
      [scope, key, serialized, Time.now.utc.iso8601]
    )
  end
  value
end

#transaction(&blk) ⇒ Object

BEGIN IMMEDIATE ... COMMIT/ROLLBACK, serialized by the semaphore. A nested one reuses the outer transaction.



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/insika/stores/sqlite.rb', line 133

def transaction(&blk)
  return yield if @tx_owner == Fiber.current

  @write_semaphore.acquire do
    begin
      @db.transaction(:immediate)
      @tx_owner = Fiber.current
      result = yield
      @db.commit
      result
    rescue StandardError
      @db.rollback if @db.transaction_active?
      raise
    ensure
      @tx_owner = nil
    end
  end
rescue ::SQLite3::Exception => e
  raise Insika::StoreError, e.message
end