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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +237 -2
- data/README.md +5 -3
- data/bin/insika +3 -0
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +90 -7
- data/docs/ARCHITECTURE.md +12 -7
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CONTEXT.md +63 -0
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +57 -11
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +33 -4
- data/docs/FACTS.md +8 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +143 -22
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +22 -13
- data/docs/RELEASING.md +5 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +7 -0
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +111 -9
- data/docs/operate.md +3 -0
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +78 -8
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/chat_builder.rb +79 -25
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +11 -1
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +2 -2
- data/lib/insika/context/providers/knowledge.rb +25 -2
- data/lib/insika/context/providers/memory.rb +53 -4
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +39 -3
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +34 -4
- data/lib/insika/dsl/runtime.rb +36 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +13 -5
- data/lib/insika/edge_limiter.rb +23 -15
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/evals/assertions.rb +171 -16
- data/lib/insika/evals/golden.rb +141 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +44 -4
- data/lib/insika/evals/simulator.rb +10 -1
- data/lib/insika/evals/transport.rb +200 -0
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +29 -8
- data/lib/insika/executor.rb +668 -141
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +32 -1
- data/lib/insika/mcp_tool_registry.rb +69 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +4 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +25 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/profile_source.rb +4 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +17 -8
- data/lib/insika/server/responses.rb +19 -1
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +159 -10
- data/lib/insika/settings_store.rb +47 -1
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1453 -161
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +171 -9
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +152 -117
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +103 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +10 -4
- data/lib/insika/tool_envelope.rb +165 -28
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +3 -0
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +2 -2
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +23 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +1 -8
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +3 -5
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +7 -5
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +18 -0
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +81 -16
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +19 -3
- metadata +69 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
data/lib/insika/stores/sqlite.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|