insika 0.8.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "logger"
4
5
  require "time"
5
6
  require "async/semaphore"
7
+ require "zlib"
8
+ require "async/promise"
6
9
 
7
10
  module Insika
8
11
  module Stores
@@ -11,34 +14,89 @@ module Insika
11
14
  # writes in a transaction serialized by an Async::Semaphore.
12
15
  class SQLite
13
16
  include Store
17
+ include JsonValue
14
18
 
19
+ # Prepended per instance when slow_ms is set; see #initialize.
20
+ module SlowLog
21
+ %i[get set delete list entries recent scopes].each do |name|
22
+ define_method(name) do |*args, &blk|
23
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+ result = super(*args, &blk)
25
+ ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
26
+ report_slow(name, args.first, ms) if ms >= @slow_ms
27
+ result
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+
34
+ def report_slow(name, scope, ms)
35
+ # up to 3 frames of the caller chain (the store method alone rarely says
36
+ # which duty called it), innermost first
37
+ chain = caller_locations(2, 40).reject { |l| l.path.end_with?("stores/sqlite.rb") }
38
+ .select { |l| l.path.include?("/insika/") }.first(3)
39
+ @slow_logger.warn(format("slow store %s scope=%s %.1f ms at %s", name, scope, ms,
40
+ chain.map { |l| "#{File.basename(l.path)}:#{l.lineno}" }.join(" < ")))
41
+ end
42
+ end
43
+
44
+ # A rowid table plus a (scope, key) index, not WITHOUT ROWID: values run to
45
+ # tens of KB, and a WITHOUT ROWID table keeps each row inside the key
46
+ # b-tree, so every key scan (list, the prefix ranges) read the values too.
47
+ # The index holds only the keys; a value is read when it is asked for.
48
+ # SQLite's own guidance: WITHOUT ROWID is for small rows.
15
49
  DDL = <<~SQL
16
50
  CREATE TABLE IF NOT EXISTS kv (
17
51
  scope TEXT NOT NULL,
18
52
  key TEXT NOT NULL,
19
53
  value TEXT NOT NULL,
20
- updated_at TEXT NOT NULL,
21
- PRIMARY KEY (scope, key)
22
- ) WITHOUT ROWID;
54
+ updated_at TEXT NOT NULL
55
+ );
56
+ CREATE UNIQUE INDEX IF NOT EXISTS kv_scope_key ON kv (scope, key);
23
57
  SQL
24
58
 
25
- # JSON model types + Symbol (coerced to String on write).
26
- # Any other type is "garbage" and must be rejected. Identical to
27
- # Stores::Memory — both backends share the SAME type model
28
- # (the contract suite is honest).
29
- JSONABLE = [NilClass, TrueClass, FalseClass, Integer, Float,
30
- String, Symbol].freeze
31
- private_constant :JSONABLE
59
+ # The rebuild of a file still in the old layout: one transaction, so a crash
60
+ # leaves the old table whole. Rows go in by updated_at so their rowids follow
61
+ # write order, which #recent reads them in.
62
+ REBUILD = <<~SQL
63
+ CREATE TABLE kv_rowid (
64
+ scope TEXT NOT NULL,
65
+ key TEXT NOT NULL,
66
+ value TEXT NOT NULL,
67
+ updated_at TEXT NOT NULL
68
+ );
69
+ INSERT INTO kv_rowid (scope, key, value, updated_at)
70
+ SELECT scope, key, value, updated_at FROM kv ORDER BY scope, updated_at, key;
71
+ DROP TABLE kv;
72
+ ALTER TABLE kv_rowid RENAME TO kv;
73
+ SQL
32
74
 
33
75
  # lazy require: the core installs without the sqlite3 gem
34
76
  # when only Memory is used.
35
- def initialize(path:, serializer: JSON)
77
+ # autocheckpoint: false when Litestream replicates this file — it owns the
78
+ # checkpoints, and the app's own (every 1000 pages, per connection, N
79
+ # workers) race it: the WAL grows past the database itself while Litestream's
80
+ # checkpoint fails "database is locked". litestream.io/tips.
81
+ # slow_ms: log every call that takes at least this long (nil = off, zero
82
+ # cost). A call holds the GVL for its whole duration, so under a fiber
83
+ # server a slow one stalls every turn in the process; the line names the
84
+ # method, scope and Insika caller so the offender is found, not guessed.
85
+ def initialize(path:, serializer: JSON, autocheckpoint: true, slow_ms: nil, logger: nil)
36
86
  require "sqlite3"
37
87
 
88
+ if slow_ms
89
+ @slow_ms = slow_ms
90
+ @slow_logger = logger || Logger.new($stderr)
91
+ singleton_class.prepend(SlowLog)
92
+ end
93
+
38
94
  @serializer = serializer
39
95
  @db = SQLite3::Database.new(path)
40
96
  @write_semaphore = Async::Semaphore.new(1)
41
97
  @tx_owner = nil
98
+ @pending_writes = []
99
+ @flushing = false
42
100
 
43
101
  # Multi-process boot (N Falcon workers opening the SAME file at the
44
102
  # same time): `PRAGMA journal_mode = WAL` on a new file needs an
@@ -58,10 +116,10 @@ module Insika
58
116
  with_busy_retry do
59
117
  @db.execute("PRAGMA journal_mode = WAL")
60
118
  @db.execute("PRAGMA synchronous = NORMAL")
119
+ @db.execute("PRAGMA wal_autocheckpoint = 0") unless autocheckpoint
120
+ rebuild_without_rowid!
61
121
  @db.execute_batch(DDL)
62
122
  end
63
- # The WITHOUT ROWID PRIMARY KEY (scope, key) is already the prefix index —
64
- # there is no extra index to create.
65
123
  rescue ::SQLite3::Exception => e
66
124
  raise Insika::StoreError, "failed to open #{path}: #{e.message}"
67
125
  end
@@ -86,6 +144,26 @@ module Insika
86
144
  end
87
145
  private :with_busy_retry
88
146
 
147
+ # Rebuilds a WITHOUT ROWID kv (the old layout) into the rowid one. Checked
148
+ # again under BEGIN IMMEDIATE: of N workers opening the file together only
149
+ # the first rebuilds; the rest find it done. On a big file this takes
150
+ # seconds, so a deployment should open the store once before its workers
151
+ # (deploy/entrypoint.sh does).
152
+ def rebuild_without_rowid!
153
+ return unless without_rowid?
154
+
155
+ @db.transaction(:immediate) do
156
+ @db.execute_batch(REBUILD) if without_rowid?
157
+ end
158
+ end
159
+ private :rebuild_without_rowid!
160
+
161
+ def without_rowid?
162
+ @db.get_first_value("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'kv'")
163
+ .to_s.include?("WITHOUT ROWID")
164
+ end
165
+ private :without_rowid?
166
+
89
167
  def close
90
168
  @db&.close
91
169
  nil
@@ -95,14 +173,14 @@ module Insika
95
173
  row = @db.get_first_value(
96
174
  "SELECT value FROM kv WHERE scope = ? AND key = ?", [scope, key]
97
175
  )
98
- row.nil? ? nil : @serializer.parse(row)
176
+ row.nil? ? nil : @serializer.parse(decode(row))
99
177
  rescue ::SQLite3::Exception => e
100
178
  raise Insika::StoreError, e.message
101
179
  end
102
180
 
103
181
  def set(scope, key, value)
104
182
  serialized = serialize(value) # fail-fast BEFORE writing
105
- transaction do
183
+ group_write(scope, serialized.bytesize) do
106
184
  @db.execute(
107
185
  "INSERT OR REPLACE INTO kv (scope, key, value, updated_at) " \
108
186
  "VALUES (?, ?, ?, ?)",
@@ -112,22 +190,96 @@ module Insika
112
190
  value
113
191
  end
114
192
 
193
+ # An UPDATE keeps the row's rowid, the write order #recent reads.
194
+ def amend(scope, key, value)
195
+ serialized = serialize(value)
196
+ updated = group_write(scope, serialized.bytesize) do
197
+ @db.execute("UPDATE kv SET value = ? WHERE scope = ? AND key = ?", [serialized, scope, key])
198
+ @db.changes.positive?
199
+ end
200
+ updated ? value : set(scope, key, value)
201
+ end
202
+
115
203
  def delete(scope, key)
116
- transaction do
204
+ group_write(scope, 0) do
117
205
  @db.execute("DELETE FROM kv WHERE scope = ? AND key = ?", [scope, key])
118
206
  @db.changes.positive?
119
207
  end
120
208
  end
121
209
 
210
+ # A prefix is a primary-key RANGE [prefix, next): SQLite seeks straight to
211
+ # it. Filtering the whole scope in Ruby read every row of it (values of tens
212
+ # of KB in a WITHOUT ROWID table) while holding the GVL, so its cost grew with
213
+ # the database and, called on every turn, starved the reactor.
214
+ LIST_PREFIX_SQL = "SELECT key FROM kv WHERE scope = ? AND key >= ? AND key < ? ORDER BY key"
215
+
122
216
  def list(scope, prefix = nil)
123
- keys = @db.execute(
124
- "SELECT key FROM kv WHERE scope = ? ORDER BY key", [scope]
125
- ).map(&:first)
217
+ upper = prefix && range_end(prefix)
218
+ return @db.execute(LIST_PREFIX_SQL, [scope, prefix, upper]).map(&:first) if upper
219
+
220
+ keys = @db.execute("SELECT key FROM kv WHERE scope = ? ORDER BY key", [scope]).map(&:first)
126
221
  prefix ? keys.select { |k| k.start_with?(prefix) } : keys
127
222
  rescue ::SQLite3::Exception => e
128
223
  raise Insika::StoreError, e.message
129
224
  end
130
225
 
226
+ # One query for the pairs: a caller listing and reading a whole scope pays
227
+ # one statement instead of one per key.
228
+ def entries(scope, prefix = nil)
229
+ upper = prefix && range_end(prefix)
230
+ rows =
231
+ if upper
232
+ @db.execute("SELECT key, value FROM kv WHERE scope = ? AND key >= ? AND key < ? ORDER BY key", [scope, prefix, upper])
233
+ else
234
+ @db.execute("SELECT key, value FROM kv WHERE scope = ? ORDER BY key", [scope])
235
+ end
236
+ rows = rows.select { |key, _| key.start_with?(prefix) } if prefix && !upper
237
+ rows.map { |key, raw| [key, @serializer.parse(decode(raw))] }
238
+ rescue ::SQLite3::Exception => e
239
+ raise Insika::StoreError, e.message
240
+ end
241
+
242
+ # Newest first by rowid: every write (INSERT OR REPLACE) takes a new, higher
243
+ # rowid, so rowid order IS write order — no extra column or index. Two steps:
244
+ # the page's keys come from the (scope, key) index alone, then only their
245
+ # values are read. Asking for key and value in one query made SQLite read
246
+ # every value of the range just to sort it (a temp B-tree over the rows).
247
+ RECENT_KEYS_PREFIX_SQL = "SELECT key FROM kv WHERE scope = ? AND key >= ? AND key < ? " \
248
+ "ORDER BY rowid DESC LIMIT ? OFFSET ?"
249
+ RECENT_KEYS_SQL = "SELECT key FROM kv WHERE scope = ? ORDER BY rowid DESC LIMIT ? OFFSET ?"
250
+ VALUES_BATCH = 500 # keys per IN (...) read, well under SQLite's variable limit
251
+
252
+ def recent(scope, prefix, limit, offset = 0)
253
+ upper = prefix && range_end(prefix)
254
+ if prefix && !upper
255
+ return @db.execute("SELECT key, value FROM kv WHERE scope = ? ORDER BY rowid DESC", [scope])
256
+ .select { |key, _| key.start_with?(prefix) }.drop(offset).first(limit)
257
+ .map { |key, raw| [key, @serializer.parse(decode(raw))] }
258
+ end
259
+
260
+ keys = (upper ? @db.execute(RECENT_KEYS_PREFIX_SQL, [scope, prefix, upper, limit, offset])
261
+ : @db.execute(RECENT_KEYS_SQL, [scope, limit, offset])).flatten
262
+ values = {}
263
+ keys.each_slice(VALUES_BATCH) do |batch|
264
+ @db.execute("SELECT key, value FROM kv WHERE scope = ? AND key IN (#{Array.new(batch.size, '?').join(',')})",
265
+ [scope, *batch]).each { |key, raw| values[key] = raw }
266
+ end
267
+ # a key deleted between the two reads simply drops out of the page
268
+ keys.filter_map { |key| (raw = values[key]) && [key, @serializer.parse(decode(raw))] }
269
+ rescue ::SQLite3::Exception => e
270
+ raise Insika::StoreError, e.message
271
+ end
272
+
273
+ # The smallest key above every key starting with `prefix`: its last byte + 1.
274
+ # ASCII only (every prefix the stores use), so the bound stays valid UTF-8 and
275
+ # compares byte-wise under BINARY; anything else falls back to the scan.
276
+ def range_end(prefix)
277
+ return nil if prefix.empty? || !prefix.ascii_only? || prefix.end_with?("\x7F")
278
+
279
+ prefix[0...-1] + (prefix[-1].ord + 1).chr
280
+ end
281
+ private :range_end
282
+
131
283
  def scopes(prefix = nil)
132
284
  names = @db.execute("SELECT DISTINCT scope FROM kv ORDER BY scope").map(&:first)
133
285
  names = names.select { |s| s.start_with?(prefix) } if prefix
@@ -143,7 +295,11 @@ module Insika
143
295
 
144
296
  @write_semaphore.acquire do
145
297
  begin
146
- @db.transaction(:immediate)
298
+ # N workers on one file: BEGIN IMMEDIATE can fail at once, with no
299
+ # busy-handler wait, when this shared connection has a read open
300
+ # (another fiber) and a sibling process just committed. Nothing is
301
+ # written yet, so retrying the BEGIN alone is safe (~5s ceiling).
302
+ with_busy_retry(attempts: 100, backoff: 0.05) { @db.transaction(:immediate) }
147
303
  @tx_owner = Fiber.current
148
304
  result = yield
149
305
  @db.commit
@@ -161,31 +317,89 @@ module Insika
161
317
 
162
318
  private
163
319
 
164
- # Enforces the contract's type model at the boundary:
165
- # Symbol/symbol-key become String; a type outside the JSON model ->
166
- # StoreError on WRITE (fail-fast; never writes garbage).
167
- #
168
- # Does NOT use `generate(strict: true)`: under json 2.7.1 (the version that
169
- # ships with ruby 3.3.5 pinned in Gemfile.lock) `strict` REJECTS Symbol,
170
- # which would violate the Symbol coercion. The explicit validation
171
- # is independent of the json version and gives the SAME semantics as Memory.
172
- def serialize(value)
173
- ensure_jsonable!(value)
174
- @serializer.generate(value)
175
- rescue JSON::GeneratorError => e
176
- raise Insika::StoreError, "value not serializable: #{e.message}"
177
- end
178
-
179
- def ensure_jsonable!(value)
180
- case value
181
- when *JSONABLE then nil
182
- when Array then value.each { |v| ensure_jsonable!(v) }
183
- when Hash then value.each { |k, v| ensure_jsonable!(k); ensure_jsonable!(v) }
184
- else
185
- raise Insika::StoreError,
186
- "value not serializable: #{value.class} not allowed in JSON"
320
+ # A BLOB is a value an earlier release stored deflated; JSON is always text.
321
+ def decode(raw)
322
+ raw.encoding == Encoding::BINARY ? Zlib::Inflate.inflate(raw).force_encoding(Encoding::UTF_8) : raw
323
+ end
324
+
325
+ # Group commit for the single-statement writes (set/delete). The first one
326
+ # becomes the leader and takes the write lock; every write that arrives while
327
+ # it waits (on the semaphore, or on another PROCESS holding the file) joins
328
+ # the same transaction and one COMMIT. Each caller still returns only after
329
+ # its write is committed, so durability and per-fiber order are unchanged;
330
+ # what drops is how often N workers fight over the file's one write lock.
331
+ # A statement that fails fails only its own caller; one that leaves no
332
+ # transaction behind (SQLite rolled it back) fails the whole batch. A leader
333
+ # stopped before it committed hands the queue back: each write re-queues and
334
+ # one of them leads.
335
+ LeaderStopped = Class.new(StandardError)
336
+ private_constant :LeaderStopped
337
+
338
+ def group_write(scope, bytes, &op)
339
+ return op.call if @tx_owner == Fiber.current # inside an explicit transaction
340
+
341
+ begin
342
+ write = [op, Async::Promise.new, scope, bytes]
343
+ @pending_writes << write
344
+ flush_writes unless @flushing
345
+ write[1].wait
346
+ rescue LeaderStopped
347
+ retry
348
+ end
349
+ end
350
+
351
+ def flush_writes
352
+ @flushing = true
353
+ until @pending_writes.empty?
354
+ batch = nil
355
+ results = nil
356
+ begin
357
+ clock = [monotonic]
358
+ transaction do
359
+ clock << monotonic # the lock is ours
360
+ batch = @pending_writes
361
+ @pending_writes = []
362
+ results = batch.map { |(op, _)| run_write(op) }
363
+ raise Insika::StoreError, "the batch's transaction was rolled back" unless @db.transaction_active?
364
+
365
+ clock << monotonic
366
+ end
367
+ clock << monotonic
368
+ report_slow_batch(batch, clock) if @slow_ms
369
+ batch.zip(results) { |(_, promise), (ok, value)| ok ? promise.resolve(value) : promise.reject(value) }
370
+ rescue StandardError => e
371
+ (batch || @pending_writes.slice!(0..)).each { |(_, promise)| promise.reject(e) unless promise.completed? }
372
+ end
373
+ end
374
+ ensure
375
+ @flushing = false
376
+ # only non-empty when the leader stopped mid-flush (e.g. its task was cancelled)
377
+ (Array(batch) + @pending_writes.slice!(0..)).each do |(_, promise)|
378
+ promise.reject(LeaderStopped.new) unless promise.completed?
187
379
  end
188
380
  end
381
+
382
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
383
+
384
+ # One line per slow batch: wait = until the write lock was ours (this
385
+ # process's semaphore and other processes holding the file), exec = the
386
+ # statements, commit = COMMIT. A long wait with a short commit means
387
+ # someone else holds the lock; a long commit means the batch itself is heavy.
388
+ def report_slow_batch(batch, clock)
389
+ start, locked, executed, committed = clock
390
+ return if (committed - start) * 1000 < @slow_ms
391
+
392
+ scopes = batch.map { |w| w[2].to_s.sub(/:.*/, ":*") }.tally.map { |name, n| "#{name}:#{n}" }.join(",")
393
+ @slow_logger.warn(format("slow store batch writes=%d bytes=%d wait=%.1f ms exec=%.1f ms commit=%.1f ms scopes=%s",
394
+ batch.size, batch.sum { |w| w[3] }, (locked - start) * 1000,
395
+ (executed - locked) * 1000, (committed - executed) * 1000, scopes))
396
+ end
397
+
398
+ def run_write(op)
399
+ [true, op.call]
400
+ rescue ::SQLite3::Exception => e
401
+ [false, Insika::StoreError.new(e.message)]
402
+ end
189
403
  end
190
404
  end
191
405
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Stores
5
+ # Counts every store call into the running turn's TurnTiming, so a turn's
6
+ # completion event says how many round trips it cost and to which scopes.
7
+ # Prepended to one backend instance; with no turn running it only forwards.
8
+ module TurnCounter
9
+ OPS = %i[get set delete list scopes entries recent transaction].freeze
10
+
11
+ OPS.each do |op|
12
+ define_method(op) do |*args, &blk|
13
+ Insika::TurnTiming.current&.count_store(op, args.first)
14
+ super(*args, &blk)
15
+ end
16
+ end
17
+
18
+ # -> the same backend, counting from now on (idempotent).
19
+ def self.attach(backend)
20
+ backend.singleton_class.prepend(self) unless backend.singleton_class.include?(self)
21
+ backend
22
+ end
23
+ end
24
+ end
25
+ end