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/memory.rb
CHANGED
|
@@ -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
|
-
|
|
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
|