insika 0.9.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 (257) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +237 -2
  3. data/README.md +5 -3
  4. data/bin/insika +3 -0
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +90 -7
  7. data/docs/ARCHITECTURE.md +12 -7
  8. data/docs/ARTIFACTS.md +33 -3
  9. data/docs/BENCHMARK.md +50 -7
  10. data/docs/CONTEXT.md +63 -0
  11. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  12. data/docs/DEPLOY.md +57 -11
  13. data/docs/EMBEDDING.md +10 -0
  14. data/docs/EVALS.md +33 -4
  15. data/docs/FACTS.md +8 -6
  16. data/docs/HARVEST.md +8 -3
  17. data/docs/KNOWLEDGE.md +7 -0
  18. data/docs/MEDIA.md +6 -2
  19. data/docs/OBSERVABILITY.md +143 -22
  20. data/docs/PERFORMANCE.md +110 -0
  21. data/docs/PLUGINS.md +4 -4
  22. data/docs/POLICY.md +22 -13
  23. data/docs/RELEASING.md +5 -0
  24. data/docs/ROUTER.md +7 -0
  25. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  26. data/docs/SECURITY.md +7 -0
  27. data/docs/SHARED-CONVERSATIONS.md +40 -0
  28. data/docs/SKILLS.md +5 -3
  29. data/docs/STUDIO.md +122 -0
  30. data/docs/TOOLS.md +111 -9
  31. data/docs/operate.md +3 -0
  32. data/lib/insika/agent_defaults.rb +140 -0
  33. data/lib/insika/agent_file_store.rb +12 -0
  34. data/lib/insika/agent_profile.rb +78 -8
  35. data/lib/insika/artifact_signing.rb +11 -0
  36. data/lib/insika/chat_builder.rb +79 -25
  37. data/lib/insika/checkpoint.rb +6 -1
  38. data/lib/insika/checkpoint_store.rb +59 -7
  39. data/lib/insika/commands/agent_payload.rb +3 -3
  40. data/lib/insika/commands/delete_kit.rb +26 -0
  41. data/lib/insika/commands/gate_harvest.rb +3 -0
  42. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  43. data/lib/insika/commands/run_harvest.rb +17 -1
  44. data/lib/insika/commands/seed_session.rb +11 -1
  45. data/lib/insika/commands/send_message.rb +9 -3
  46. data/lib/insika/commands/update_settings.rb +23 -1
  47. data/lib/insika/commands/write_kit.rb +27 -0
  48. data/lib/insika/compaction.rb +33 -7
  49. data/lib/insika/config_store.rb +87 -7
  50. data/lib/insika/context/priority.rb +3 -0
  51. data/lib/insika/context/provider.rb +2 -2
  52. data/lib/insika/context/providers/knowledge.rb +25 -2
  53. data/lib/insika/context/providers/memory.rb +53 -4
  54. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  55. data/lib/insika/context/providers/prompt.rb +17 -4
  56. data/lib/insika/context/providers/session.rb +39 -3
  57. data/lib/insika/context/reranker.rb +75 -0
  58. data/lib/insika/distill.rb +22 -13
  59. data/lib/insika/doctor.rb +34 -4
  60. data/lib/insika/dsl/runtime.rb +36 -4
  61. data/lib/insika/dsl/server_boot.rb +4 -12
  62. data/lib/insika/dsl.rb +13 -5
  63. data/lib/insika/edge_limiter.rb +23 -15
  64. data/lib/insika/egress_guard.rb +11 -3
  65. data/lib/insika/env_schema.rb +33 -73
  66. data/lib/insika/evals/assertions.rb +171 -16
  67. data/lib/insika/evals/golden.rb +141 -4
  68. data/lib/insika/evals/judge.rb +19 -10
  69. data/lib/insika/evals/moderator_compare.rb +47 -0
  70. data/lib/insika/evals/report.rb +61 -9
  71. data/lib/insika/evals/runner.rb +44 -4
  72. data/lib/insika/evals/simulator.rb +10 -1
  73. data/lib/insika/evals/transport.rb +200 -0
  74. data/lib/insika/event_stream.rb +63 -7
  75. data/lib/insika/evidence.rb +29 -8
  76. data/lib/insika/executor.rb +668 -141
  77. data/lib/insika/harvest.rb +26 -14
  78. data/lib/insika/harvest_engine.rb +1 -0
  79. data/lib/insika/harvest_store.rb +12 -0
  80. data/lib/insika/http_client.rb +87 -4
  81. data/lib/insika/kits.rb +54 -0
  82. data/lib/insika/knowledge.rb +34 -20
  83. data/lib/insika/llm_configurator.rb +46 -4
  84. data/lib/insika/llm_provider_store.rb +1 -3
  85. data/lib/insika/llm_trace_store.rb +54 -0
  86. data/lib/insika/mcp_client.rb +27 -63
  87. data/lib/insika/mcp_live_tool.rb +55 -9
  88. data/lib/insika/mcp_store.rb +32 -1
  89. data/lib/insika/mcp_tool_registry.rb +69 -26
  90. data/lib/insika/media.rb +38 -25
  91. data/lib/insika/memory_store.rb +4 -3
  92. data/lib/insika/memory_trim.rb +60 -0
  93. data/lib/insika/middleware.rb +12 -3
  94. data/lib/insika/model_metrics_store.rb +285 -0
  95. data/lib/insika/model_registry.rb +110 -0
  96. data/lib/insika/model_resolver.rb +1 -1
  97. data/lib/insika/model_selection.rb +14 -21
  98. data/lib/insika/model_visible.rb +17 -6
  99. data/lib/insika/onboarding.rb +1 -0
  100. data/lib/insika/outcome_store.rb +1 -1
  101. data/lib/insika/overlay_tool_registry.rb +25 -2
  102. data/lib/insika/pack_importer.rb +54 -9
  103. data/lib/insika/packaging.rb +14 -5
  104. data/lib/insika/pending_action_store.rb +45 -18
  105. data/lib/insika/plugin/loader.rb +4 -2
  106. data/lib/insika/profile_source.rb +4 -0
  107. data/lib/insika/provider_error_classifier.rb +18 -3
  108. data/lib/insika/queue_policy.rb +7 -2
  109. data/lib/insika/refinement/proposer.rb +42 -17
  110. data/lib/insika/reliability.rb +12 -3
  111. data/lib/insika/router/app.rb +14 -5
  112. data/lib/insika/router/session_key.rb +35 -5
  113. data/lib/insika/safety/config.rb +20 -3
  114. data/lib/insika/safety/corpus.rb +10 -3
  115. data/lib/insika/safety/factory.rb +39 -1
  116. data/lib/insika/safety/input_guardrail.rb +21 -1
  117. data/lib/insika/safety/judge_moderator.rb +70 -0
  118. data/lib/insika/safety/output_validator.rb +29 -5
  119. data/lib/insika/safety/prompt_echo.rb +89 -0
  120. data/lib/insika/schema_guard.rb +53 -76
  121. data/lib/insika/server/app.rb +17 -8
  122. data/lib/insika/server/responses.rb +19 -1
  123. data/lib/insika/session_actor.rb +58 -28
  124. data/lib/insika/session_store.rb +159 -10
  125. data/lib/insika/settings_store.rb +47 -1
  126. data/lib/insika/shared_conversations.rb +305 -0
  127. data/lib/insika/skill_catalog.rb +28 -2
  128. data/lib/insika/skill_store.rb +6 -0
  129. data/lib/insika/soak/runner.rb +1 -1
  130. data/lib/insika/steer_injector.rb +7 -48
  131. data/lib/insika/store.rb +29 -1
  132. data/lib/insika/stores/json_value.rb +37 -0
  133. data/lib/insika/stores/memory.rb +22 -35
  134. data/lib/insika/stores/postgres.rb +295 -0
  135. data/lib/insika/stores/sqlite.rb +257 -43
  136. data/lib/insika/stores/turn_counter.rb +25 -0
  137. data/lib/insika/studio/app.rb +1453 -161
  138. data/lib/insika/studio/assets/dist/application.css +1 -1
  139. data/lib/insika/studio/assets/dist/application.js +25 -25
  140. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  141. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  142. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  143. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  144. data/lib/insika/studio/forms.rb +171 -9
  145. data/lib/insika/studio/nav_icons.rb +3 -0
  146. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  147. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  148. data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
  149. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  150. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  151. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  152. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  153. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  154. data/lib/insika/studio/views/_agents_master.erb +8 -12
  155. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  156. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  157. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  158. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  159. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  160. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  161. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  162. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  163. data/lib/insika/studio/views/_chats_master.erb +66 -0
  164. data/lib/insika/studio/views/_customers_master.erb +37 -0
  165. data/lib/insika/studio/views/_empty.erb +15 -0
  166. data/lib/insika/studio/views/_facts_master.erb +42 -0
  167. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  168. data/lib/insika/studio/views/_identity.erb +16 -0
  169. data/lib/insika/studio/views/_kpi.erb +17 -0
  170. data/lib/insika/studio/views/_message.erb +40 -2
  171. data/lib/insika/studio/views/_model_chart.erb +82 -0
  172. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  173. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
  174. data/lib/insika/studio/views/_tool_users.erb +17 -0
  175. data/lib/insika/studio/views/_why.erb +53 -0
  176. data/lib/insika/studio/views/agent_detail.erb +17 -10
  177. data/lib/insika/studio/views/agents.erb +28 -16
  178. data/lib/insika/studio/views/approvals.erb +16 -11
  179. data/lib/insika/studio/views/artifact.erb +20 -7
  180. data/lib/insika/studio/views/artifacts.erb +19 -14
  181. data/lib/insika/studio/views/chats.erb +22 -29
  182. data/lib/insika/studio/views/customer.erb +88 -43
  183. data/lib/insika/studio/views/customers.erb +16 -26
  184. data/lib/insika/studio/views/evals.erb +49 -31
  185. data/lib/insika/studio/views/fact_detail.erb +114 -0
  186. data/lib/insika/studio/views/facts.erb +15 -116
  187. data/lib/insika/studio/views/followups.erb +63 -52
  188. data/lib/insika/studio/views/funnel.erb +104 -43
  189. data/lib/insika/studio/views/harvest.erb +85 -59
  190. data/lib/insika/studio/views/home.erb +138 -23
  191. data/lib/insika/studio/views/inbox.erb +36 -0
  192. data/lib/insika/studio/views/kits.erb +176 -0
  193. data/lib/insika/studio/views/knowledge.erb +92 -80
  194. data/lib/insika/studio/views/layout.erb +42 -11
  195. data/lib/insika/studio/views/live.erb +45 -0
  196. data/lib/insika/studio/views/mcp.erb +43 -13
  197. data/lib/insika/studio/views/models.erb +187 -0
  198. data/lib/insika/studio/views/parity.erb +38 -32
  199. data/lib/insika/studio/views/playground.erb +13 -9
  200. data/lib/insika/studio/views/refinement.erb +15 -224
  201. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  202. data/lib/insika/studio/views/session.erb +152 -117
  203. data/lib/insika/studio/views/settings.erb +86 -22
  204. data/lib/insika/studio/views/skills.erb +53 -19
  205. data/lib/insika/studio/views/system_files.erb +7 -7
  206. data/lib/insika/studio/views/task.erb +45 -15
  207. data/lib/insika/studio/views/tasks.erb +35 -18
  208. data/lib/insika/studio/views/tools.erb +62 -73
  209. data/lib/insika/task_actor.rb +4 -1
  210. data/lib/insika/task_store.rb +67 -1
  211. data/lib/insika/telemetry/pricing.rb +16 -14
  212. data/lib/insika/telemetry/recorder.rb +103 -9
  213. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  214. data/lib/insika/telemetry.rb +12 -2
  215. data/lib/insika/templates/daily-digest/README.md +13 -0
  216. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  217. data/lib/insika/testing/store_contract.rb +65 -0
  218. data/lib/insika/text_tool_call.rb +66 -0
  219. data/lib/insika/tick.rb +7 -1
  220. data/lib/insika/token_store.rb +5 -0
  221. data/lib/insika/tool_batch.rb +1 -1
  222. data/lib/insika/tool_catalog.rb +12 -2
  223. data/lib/insika/tool_definition.rb +10 -4
  224. data/lib/insika/tool_envelope.rb +165 -28
  225. data/lib/insika/tool_manifest.rb +1 -0
  226. data/lib/insika/tool_store.rb +3 -0
  227. data/lib/insika/tool_trace_store.rb +15 -8
  228. data/lib/insika/tool_usage_report.rb +2 -2
  229. data/lib/insika/tools/a2a_remote.rb +1 -1
  230. data/lib/insika/tools/agent_enum.rb +1 -1
  231. data/lib/insika/tools/confirm_pending.rb +112 -0
  232. data/lib/insika/tools/data_defined_tool.rb +23 -15
  233. data/lib/insika/tools/generate_image.rb +2 -2
  234. data/lib/insika/tools/load_knowledge.rb +7 -3
  235. data/lib/insika/tools/load_skill.rb +3 -3
  236. data/lib/insika/tools/present.rb +1 -8
  237. data/lib/insika/tools/remember.rb +7 -2
  238. data/lib/insika/tools/run_persona_eval.rb +3 -5
  239. data/lib/insika/tools/save_artifact.rb +16 -6
  240. data/lib/insika/tools/schedule_followup.rb +3 -3
  241. data/lib/insika/tools/stuck_signal.rb +3 -3
  242. data/lib/insika/tools/subagent.rb +5 -5
  243. data/lib/insika/tools/subagents.rb +3 -3
  244. data/lib/insika/tools/tool_search.rb +7 -5
  245. data/lib/insika/tools/tts.rb +2 -2
  246. data/lib/insika/tools/update_briefing.rb +4 -4
  247. data/lib/insika/turn_output.rb +2 -2
  248. data/lib/insika/turn_state.rb +18 -0
  249. data/lib/insika/turn_timing.rb +51 -14
  250. data/lib/insika/version.rb +1 -1
  251. data/lib/insika/wiring/graph.rb +81 -16
  252. data/lib/insika/workflow.rb +25 -64
  253. data/lib/insika.rb +19 -3
  254. metadata +69 -13
  255. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  256. data/lib/insika/mcp_http_client.rb +0 -67
  257. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -11,6 +11,7 @@ module Insika
11
11
  # operation.
12
12
  class Memory
13
13
  include Store
14
+ include JsonValue
14
15
 
15
16
  def initialize
16
17
  @data = new_store
@@ -25,7 +26,18 @@ module Insika
25
26
  JSON.parse(raw)
26
27
  end
27
28
 
29
+ # Re-inserted on every write, so the Hash's order is the write order (#recent).
28
30
  def set(scope, key, value)
31
+ raw = serialize(value)
32
+ @data[scope].delete(key)
33
+ @data[scope][key] = raw
34
+ value
35
+ end
36
+
37
+ # Assigning an existing Hash key keeps its position (the write order).
38
+ def amend(scope, key, value)
39
+ return set(scope, key, value) unless @data[scope].key?(key)
40
+
29
41
  @data[scope][key] = serialize(value)
30
42
  value
31
43
  end
@@ -39,6 +51,12 @@ module Insika
39
51
  prefix ? keys.select { |k| k.start_with?(prefix) } : keys
40
52
  end
41
53
 
54
+ def recent(scope, prefix, limit, offset = 0)
55
+ @data[scope].reverse_each.lazy
56
+ .select { |key, _| prefix.nil? || key.start_with?(prefix) }
57
+ .drop(offset).first(limit).map { |key, raw| [key, JSON.parse(raw)] }
58
+ end
59
+
42
60
  def scopes(prefix = nil)
43
61
  names = @data.keys
44
62
  names = names.select { |s| s.start_with?(prefix) } if prefix
@@ -71,46 +89,12 @@ module Insika
71
89
  end
72
90
  end
73
91
 
74
- # JSON model types + Symbol (coerced to String on write).
75
- # Any other type is "garbage" and must be rejected.
76
- JSONABLE = [NilClass, TrueClass, FalseClass, Integer, Float,
77
- String, Symbol].freeze
78
- private_constant :JSONABLE
79
-
80
92
  private
81
93
 
82
94
  def new_store
83
95
  Hash.new { |h, scope| h[scope] = {} }
84
96
  end
85
97
 
86
- # Enforces the contract's type model at the boundary:
87
- # Symbol/symbol-key become String; a type outside the JSON model ->
88
- # StoreError on WRITE (fail-fast; never writes garbage).
89
- #
90
- # Does not use `JSON.generate(strict: true)`: under json 2.7.1 (the pinned
91
- # version) `strict` rejects Symbol, which would violate the Symbol coercion.
92
- # The explicit validation is independent of the json version and gives the
93
- # SAME semantics as SQLite.
94
- # The transaction block's exception (from the caller) propagates without
95
- # wrapping; only a backend error becomes StoreError.
96
- def serialize(value)
97
- ensure_jsonable!(value)
98
- JSON.generate(value)
99
- rescue JSON::GeneratorError => e
100
- raise Insika::StoreError, "value not serializable: #{e.message}"
101
- end
102
-
103
- def ensure_jsonable!(value)
104
- case value
105
- when *JSONABLE then nil
106
- when Array then value.each { |v| ensure_jsonable!(v) }
107
- when Hash then value.each { |k, v| ensure_jsonable!(k); ensure_jsonable!(v) }
108
- else
109
- raise Insika::StoreError,
110
- "value not serializable: #{value.class} not allowed in JSON"
111
- end
112
- end
113
-
114
98
  # Deep dup: the values are already JSON strings (immutable in practice),
115
99
  # so a per-scope dup is enough — there is no nested mutable structure.
116
100
  def deep_snapshot
@@ -125,5 +109,8 @@ module Insika
125
109
  @snapshot.each { |scope, kv| @data[scope] = kv }
126
110
  end
127
111
  end
128
- end
112
+
113
+ # Survives a restart? Every backend except Memory does.
114
+ def self.durable?(backend) = !backend.is_a?(Memory)
115
+ end
129
116
  end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Stores
5
+ # Postgres backend — opt-in (INSIKA_DATABASE_URL). Same single kv shape as
6
+ # SQLite: one table, the domain lives in the scopes. Unlike SQLite there is no
7
+ # file-wide write lock: concurrent writers only meet on the keys they share,
8
+ # which is what lets N worker processes write without queueing behind each other.
9
+ #
10
+ # Values stay JSON text (not jsonb): what was written is what is read, byte for
11
+ # byte, and the server never parses them. COLLATE "C" makes list/scopes sort
12
+ # byte-wise, like Ruby's sort and SQLite's BINARY.
13
+ class Postgres
14
+ include Store
15
+ include JsonValue
16
+
17
+ DDL = <<~SQL
18
+ CREATE TABLE IF NOT EXISTS insika_kv (
19
+ scope text COLLATE "C" NOT NULL,
20
+ key text COLLATE "C" NOT NULL,
21
+ value text NOT NULL,
22
+ updated_at timestamptz NOT NULL DEFAULT now(),
23
+ PRIMARY KEY (scope, key)
24
+ );
25
+ CREATE INDEX IF NOT EXISTS insika_kv_recent ON insika_kv (scope, updated_at DESC)
26
+ SQL
27
+
28
+ # Serializes the DDL across processes booting together (CREATE TABLE IF NOT
29
+ # EXISTS races on the catalog otherwise). Any constant works; this one spells "insika".
30
+ DDL_LOCK = 0x696e73696b61
31
+
32
+ GET = "SELECT value FROM insika_kv WHERE scope = $1 AND key = $2"
33
+
34
+ # Inside a transaction a read first takes the key's advisory lock (held to
35
+ # COMMIT/ROLLBACK), in its OWN statement: under READ COMMITTED a statement
36
+ # reads the snapshot it started with, so a SELECT that waited on the lock
37
+ # would still see the value from before the holder committed. The read that
38
+ # follows starts after the lock is ours and sees the committed value.
39
+ KEY_LOCK = "SELECT pg_advisory_xact_lock(hashtextextended($1 || ' ' || $2, 0))"
40
+
41
+ # Outside a transaction a write takes the same key lock in its own statement,
42
+ # so it waits for any transaction that read the key, as SQLite queued every
43
+ # write behind an open transaction. Released when the statement ends.
44
+ LOCKED_UPSERT = "WITH l AS (SELECT pg_advisory_xact_lock(hashtextextended($1 || ' ' || $2, 0))) " \
45
+ "INSERT INTO insika_kv (scope, key, value, updated_at) SELECT $1, $2, $3, clock_timestamp() FROM l " \
46
+ "ON CONFLICT (scope, key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at"
47
+ LOCKED_AMEND = "WITH l AS (SELECT pg_advisory_xact_lock(hashtextextended($1 || ' ' || $2, 0))) " \
48
+ "UPDATE insika_kv SET value = $3 WHERE scope = $1 AND key = $2 AND EXISTS (SELECT 1 FROM l)"
49
+ LOCKED_DELETE = "WITH l AS (SELECT pg_advisory_xact_lock(hashtextextended($1 || ' ' || $2, 0))) " \
50
+ "DELETE FROM insika_kv WHERE scope = $1 AND key = $2 AND EXISTS (SELECT 1 FROM l)"
51
+
52
+ # A transaction the server aborted as a deadlock is run again: it was rolled
53
+ # back whole, so re-running is SQLite's wait-then-run. Blocks only touch the
54
+ # store (side effects happen after the commit), so a re-run is safe.
55
+ DEADLOCK_ATTEMPTS = 3
56
+
57
+ # Waits on a lock or a connect give up like SQLite's busy timeout did.
58
+ CONNECT_OPTIONS = { connect_timeout: 5, options: "-c lock_timeout=5000" }.freeze
59
+
60
+ # pool: connections this process may open (lazily, on first use). A
61
+ # transaction holds its connection across several round trips while its
62
+ # fiber yields to others, so under many concurrent turns a small pool is
63
+ # where calls queue.
64
+ def initialize(url:, pool: 10)
65
+ require "pg" # lazy: the core installs without libpq
66
+ raise ArgumentError, "pool must be at least 1 (got #{pool.inspect})" unless pool.is_a?(Integer) && pool >= 1
67
+
68
+ @url = url
69
+ @closed = false
70
+ @pool = Thread::Queue.new
71
+ pool.times { @pool << nil } # nil = a slot not connected yet
72
+ @tx = {} # Fiber => connection pinned to its open transaction
73
+ migrate
74
+ end
75
+
76
+ def get(scope, key)
77
+ row = with_conn do |c|
78
+ c.exec_params(KEY_LOCK, [scope, key]) if @tx[Fiber.current]
79
+ c.exec_params(GET, [scope, key]).first
80
+ end
81
+ row && JSON.parse(row["value"])
82
+ end
83
+
84
+ def set(scope, key, value)
85
+ serialized = serialize(value) # fail-fast BEFORE writing
86
+ # One statement in or out of a transaction: the key lock and the write
87
+ # together (inside a transaction the lock is still held to COMMIT).
88
+ with_conn { |c| c.exec_params(LOCKED_UPSERT, [scope, key, serialized]) }
89
+ value
90
+ end
91
+
92
+ # updated_at is left alone: it is the write order #recent reads.
93
+ def amend(scope, key, value)
94
+ serialized = serialize(value)
95
+ updated = with_conn { |c| c.exec_params(LOCKED_AMEND, [scope, key, serialized]).cmd_tuples.positive? }
96
+ updated ? value : set(scope, key, value)
97
+ end
98
+
99
+ def delete(scope, key)
100
+ with_conn { |c| c.exec_params(LOCKED_DELETE, [scope, key]).cmd_tuples.positive? }
101
+ end
102
+
103
+ # A prefix is a start_with? on the key, never a LIKE pattern (no escaping of
104
+ # % or _ to get wrong). An ASCII prefix is a primary-key RANGE [prefix, next):
105
+ # an index seek, like SQLite's (the stores list key prefixes on every turn).
106
+ # Anything else compares left(key, n): still exact, scanned within the scope.
107
+ def list(scope, prefix = nil)
108
+ upper = prefix && range_end(prefix)
109
+ rows = with_conn do |c|
110
+ if upper
111
+ c.exec_params("SELECT key FROM insika_kv WHERE scope = $1 AND key >= $2 AND key < $3 ORDER BY key",
112
+ [scope, prefix, upper])
113
+ elsif prefix
114
+ c.exec_params("SELECT key FROM insika_kv WHERE scope = $1 AND left(key, length($2)) = $2 ORDER BY key",
115
+ [scope, prefix])
116
+ else
117
+ c.exec_params("SELECT key FROM insika_kv WHERE scope = $1 ORDER BY key", [scope])
118
+ end
119
+ end
120
+ rows.map { |r| r["key"] }
121
+ end
122
+
123
+ # One query for the pairs (see Store#entries): one round trip instead of one
124
+ # per key. No lock, like #list.
125
+ def entries(scope, prefix = nil)
126
+ upper = prefix && range_end(prefix)
127
+ rows = with_conn do |c|
128
+ if upper
129
+ c.exec_params("SELECT key, value FROM insika_kv WHERE scope = $1 AND key >= $2 AND key < $3 ORDER BY key",
130
+ [scope, prefix, upper])
131
+ elsif prefix
132
+ c.exec_params("SELECT key, value FROM insika_kv WHERE scope = $1 AND left(key, length($2)) = $2 ORDER BY key",
133
+ [scope, prefix])
134
+ else
135
+ c.exec_params("SELECT key, value FROM insika_kv WHERE scope = $1 ORDER BY key", [scope])
136
+ end
137
+ end
138
+ rows.map { |r| [r["key"], JSON.parse(r["value"])] }
139
+ end
140
+
141
+ # Newest first by the write time (updated_at, indexed with the scope); key
142
+ # breaks a tie. Only the top `limit` values travel.
143
+ def recent(scope, prefix, limit, offset = 0)
144
+ upper = prefix && range_end(prefix)
145
+ rows = with_conn do |c|
146
+ if upper
147
+ c.exec_params("SELECT key, value FROM insika_kv WHERE scope = $1 AND key >= $2 AND key < $3 " \
148
+ "ORDER BY updated_at DESC, key DESC LIMIT $4 OFFSET $5", [scope, prefix, upper, limit, offset])
149
+ elsif prefix
150
+ c.exec_params("SELECT key, value FROM insika_kv WHERE scope = $1 AND left(key, length($2)) = $2 " \
151
+ "ORDER BY updated_at DESC, key DESC LIMIT $3 OFFSET $4", [scope, prefix, limit, offset])
152
+ else
153
+ c.exec_params("SELECT key, value FROM insika_kv WHERE scope = $1 ORDER BY updated_at DESC, key DESC LIMIT $2 OFFSET $3",
154
+ [scope, limit, offset])
155
+ end
156
+ end
157
+ rows.map { |r| [r["key"], JSON.parse(r["value"])] }
158
+ end
159
+
160
+ def scopes(prefix = nil)
161
+ names = with_conn { |c| c.exec("SELECT DISTINCT scope FROM insika_kv ORDER BY scope") }.map { |r| r["scope"] }
162
+ prefix ? names.select { |s| s.start_with?(prefix) } : names
163
+ end
164
+
165
+ # Read-check-write is atomic: every key READ inside the transaction is locked
166
+ # first (see KEY_LOCK), so a second transaction reading the same key waits —
167
+ # the role BEGIN IMMEDIATE plays on SQLite, but per key instead of per file.
168
+ # It covers keys that do not exist yet, which a row lock cannot. Different
169
+ # keys never wait on each other; a hash collision only over-serializes.
170
+ # A nested transaction reuses the outer one.
171
+ def transaction(&blk)
172
+ return yield if @tx[Fiber.current]
173
+
174
+ attempts = 0
175
+ begin
176
+ attempts += 1
177
+ run_transaction(&blk)
178
+ rescue PG::TRDeadlockDetected, Insika::StoreError => e
179
+ deadlock = e.is_a?(PG::TRDeadlockDetected) || e.cause.is_a?(PG::TRDeadlockDetected)
180
+ retry if deadlock && attempts < DEADLOCK_ATTEMPTS
181
+ raise
182
+ end
183
+ rescue PG::Error => e
184
+ raise Insika::StoreError, e.message
185
+ end
186
+
187
+ # Closes the idle connections; a call after close raises instead of waiting.
188
+ def close
189
+ @closed = true
190
+ until @pool.empty?
191
+ conn = @pool.pop(true)
192
+ conn&.close
193
+ end
194
+ nil
195
+ end
196
+
197
+ private
198
+
199
+ # One attempt: BEGIN, the block, COMMIT. Anything that leaves without the
200
+ # COMMIT — an exception, a stopped fiber, or a `return`/`break` out of the
201
+ # block — rolls back, so a connection never goes back to the pool inside an
202
+ # open transaction (holding its key locks).
203
+ def run_transaction
204
+ conn = checkout
205
+ committed = false
206
+ begin
207
+ conn.exec("BEGIN")
208
+ @tx[Fiber.current] = conn
209
+ result = yield
210
+ conn.exec("COMMIT")
211
+ committed = true
212
+ result
213
+ ensure
214
+ @tx.delete(Fiber.current)
215
+ rollback(conn) unless committed
216
+ checkin(conn)
217
+ end
218
+ end
219
+
220
+ def rollback(conn)
221
+ conn.exec("ROLLBACK") if conn.status == PG::CONNECTION_OK && conn.transaction_status != PG::PQTRANS_IDLE
222
+ rescue PG::Error
223
+ nil # a dead connection was rolled back by the server; keep the original error
224
+ end
225
+
226
+ # The smallest key above every key starting with `prefix` (last byte + 1).
227
+ # ASCII only, so the bound stays valid text and compares byte-wise under "C".
228
+ def range_end(prefix)
229
+ return nil if prefix.empty? || !prefix.ascii_only? || prefix.end_with?("\x7F")
230
+
231
+ prefix[0...-1] + (prefix[-1].ord + 1).chr
232
+ end
233
+
234
+ # A connection for one call: the fiber's pinned one inside a transaction,
235
+ # otherwise one borrowed from the pool and returned after. Thread::Queue#pop
236
+ # parks only the calling fiber under a fiber scheduler.
237
+ def with_conn
238
+ pinned = @tx[Fiber.current]
239
+ return yield(pinned) if pinned
240
+
241
+ conn = checkout
242
+ begin
243
+ yield conn
244
+ ensure
245
+ checkin(conn)
246
+ end
247
+ rescue PG::Error => e
248
+ raise Insika::StoreError, e.message
249
+ end
250
+
251
+ # A borrower whose connect fails gives the empty slot back: otherwise every
252
+ # failed reconnect during an outage would shrink the pool until calls wait
253
+ # forever.
254
+ def checkout
255
+ raise Insika::StoreError, "store closed" if @closed
256
+
257
+ @pool.pop || begin
258
+ connect
259
+ rescue StandardError
260
+ @pool << nil
261
+ raise
262
+ end
263
+ end
264
+
265
+ # A connection the server dropped is not returned: its slot comes back empty
266
+ # and the next borrower connects afresh.
267
+ def checkin(conn)
268
+ if conn && conn.status == PG::CONNECTION_OK
269
+ @pool << conn
270
+ else
271
+ conn&.close
272
+ @pool << nil
273
+ end
274
+ end
275
+
276
+ def connect = PG.connect(@url, **CONNECT_OPTIONS)
277
+
278
+ # Its own short-lived connection, closed before returning: a server forks
279
+ # its workers after the app is built, and an inherited socket would be
280
+ # shared by every child.
281
+ def migrate
282
+ conn = connect
283
+ conn.exec("SET client_min_messages TO warning") # no "already exists, skipping" on every boot
284
+ conn.transaction do
285
+ conn.exec_params("SELECT pg_advisory_xact_lock($1)", [DDL_LOCK])
286
+ conn.exec(DDL)
287
+ end
288
+ rescue PG::Error => e
289
+ raise Insika::StoreError, "failed to prepare insika_kv: #{e.message}"
290
+ ensure
291
+ conn&.close
292
+ end
293
+ end
294
+ end
295
+ end