Merge remote-tracking branch 'origin/master'

# Conflicts:
#	.beads/issues.jsonl
#	.beads/traces/bf-5xqk/metadata.json
#	.beads/traces/bf-5xqk/stdout.txt
#	.beads/traces/miroir-9dj/metadata.json
#	.beads/traces/miroir-9dj/stdout.txt
#	.beads/traces/miroir-cdo/metadata.json
#	.beads/traces/miroir-cdo/stdout.txt
#	.beads/traces/miroir-mkk/metadata.json
#	.beads/traces/miroir-mkk/stdout.txt
#	.beads/traces/miroir-r3j/metadata.json
#	.beads/traces/miroir-r3j/stdout.txt
#	.beads/traces/miroir-uhj/metadata.json
#	.beads/traces/miroir-uhj/stdout.txt
#	.beads/traces/miroir-zc2.6/metadata.json
#	.beads/traces/miroir-zc2.6/stdout.txt
#	.needle-predispatch-sha
#	Cargo.lock
#	charts/miroir/Chart.yaml
#	charts/miroir/templates/NOTES.txt
#	charts/miroir/templates/_helpers.tpl
#	charts/miroir/templates/redis-deployment.yaml
#	charts/miroir/templates/serviceaccount.yaml
#	charts/miroir/tests/README.md
#	charts/miroir/values.schema.json
#	charts/miroir/values.yaml
#	crates/miroir-core/Cargo.toml
#	crates/miroir-core/src/config.rs
#	crates/miroir-core/src/hedging.rs
#	crates/miroir-core/src/lib.rs
#	crates/miroir-core/src/merger.rs
#	crates/miroir-core/src/query_planner.rs
#	crates/miroir-core/src/raft_proto/mod.rs
#	crates/miroir-core/src/replica_selection.rs
#	crates/miroir-core/src/router.rs
#	crates/miroir-core/src/scatter.rs
#	crates/miroir-core/src/task_store/mod.rs
#	crates/miroir-core/src/task_store/redis.rs
#	crates/miroir-core/src/task_store/sqlite.rs
#	crates/miroir-core/src/topology.rs
#	crates/miroir-ctl/src/credentials.rs
#	crates/miroir-proxy/Cargo.toml
#	crates/miroir-proxy/src/auth.rs
#	crates/miroir-proxy/src/client.rs
#	crates/miroir-proxy/src/lib.rs
#	crates/miroir-proxy/src/main.rs
#	crates/miroir-proxy/src/middleware.rs
#	crates/miroir-proxy/src/routes/admin.rs
#	crates/miroir-proxy/src/routes/documents.rs
#	crates/miroir-proxy/src/routes/indexes.rs
#	crates/miroir-proxy/src/routes/search.rs
#	crates/miroir-proxy/src/routes/settings.rs
#	crates/miroir-proxy/src/routes/tasks.rs
#	docs/research/score-normalization-at-scale.md
#	notes/miroir-cdo.md
#	notes/miroir-r3j-final-verification.md
#	notes/miroir-r3j-verification.md
#	notes/miroir-r3j.1.md
#	notes/miroir-r3j.md
#	notes/miroir-zc2.1.md
#	notes/miroir-zc2.3.md
#	notes/miroir-zc2.4.md
#	notes/miroir-zc2.5.md
This commit is contained in:
jedarden 2026-05-24 05:21:32 -04:00
commit 1f686c646b
216 changed files with 66821 additions and 9785 deletions

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-3gfw",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 136129,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-08T19:26:08.544156983Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-3lad",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 179981,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-10T11:06:39.907809756Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

@ -0,0 +1,2 @@
SessionEnd hook [/home/coding/.ccdash/hooks/session-end.sh] failed: /bin/sh: line 1: /home/coding/.ccdash/hooks/session-end.sh: cannot execute: required file not found

View file

@ -0,0 +1,16 @@
{"type":"system","subtype":"hook_started","hook_id":"dbd28456-ae67-4d0e-ba6e-b266ec7f3554","hook_name":"SessionStart:startup","hook_event":"SessionStart","uuid":"294724a5-ddba-4153-b31a-63c3e6dd7165","session_id":"9201791e-959e-468c-9f35-3f363274e896"}
{"type":"system","subtype":"hook_response","hook_id":"dbd28456-ae67-4d0e-ba6e-b266ec7f3554","hook_name":"SessionStart:startup","hook_event":"SessionStart","output":"/bin/sh: line 1: /home/coding/.ccdash/hooks/session-start.sh: cannot execute: required file not found\n","stdout":"","stderr":"/bin/sh: line 1: /home/coding/.ccdash/hooks/session-start.sh: cannot execute: required file not found\n","exit_code":127,"outcome":"error","uuid":"1d83198e-a1da-4c42-9927-bf6a32a933a1","session_id":"9201791e-959e-468c-9f35-3f363274e896"}
{"type":"system","subtype":"init","cwd":"/home/coding/miroir","session_id":"9201791e-959e-468c-9f35-3f363274e896","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","NotebookEdit","Read","ScheduleWakeup","Skill","TaskOutput","TaskStop","TodoWrite","WebFetch","WebSearch","Write","mcp__claude_ai_Alphavantage__TOOL_CALL","mcp__claude_ai_Alphavantage__TOOL_GET","mcp__claude_ai_Alphavantage__TOOL_LIST","mcp__claude_ai_Gmail__authenticate","mcp__claude_ai_Gmail__complete_authentication","mcp__claude_ai_Google_Calendar__authenticate","mcp__claude_ai_Google_Calendar__complete_authentication","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Google_Drive__complete_authentication"],"mcp_servers":[{"name":"claude.ai Alphavantage","status":"connected"},{"name":"claude.ai Google Calendar","status":"needs-auth"},{"name":"claude.ai Gmail","status":"needs-auth"},{"name":"claude.ai Google Drive","status":"needs-auth"}],"model":"glm-4.7","permissionMode":"bypassPermissions","slash_commands":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","claude-api","clear","compact","context","heapdump","init","review","security-review","usage","insights","team-onboarding"],"apiKeySource":"none","claude_code_version":"2.1.138","output_style":"default","agents":["Explore","general-purpose","Plan","statusline-setup"],"skills":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","claude-api"],"plugins":[],"analytics_disabled":true,"uuid":"bfbb2d9f-0887-4905-b028-428b1d67bbf5","memory_paths":{"auto":"/home/coding/.claude/projects/-home-coding-miroir/memory/"},"fast_mode_state":"off"}
{"type":"system","subtype":"status","status":"requesting","uuid":"0b91a995-2e43-47c2-b2b1-945e1f011223","session_id":"9201791e-959e-468c-9f35-3f363274e896"}
{"type":"system","subtype":"api_retry","attempt":1,"max_retries":10,"retry_delay_ms":594.126935420076,"error_status":null,"error":"unknown","session_id":"9201791e-959e-468c-9f35-3f363274e896","uuid":"fed130e2-257c-417a-9d75-251ee41c3777"}
{"type":"system","subtype":"api_retry","attempt":2,"max_retries":10,"retry_delay_ms":1062.0535439076032,"error_status":null,"error":"unknown","session_id":"9201791e-959e-468c-9f35-3f363274e896","uuid":"2f18d2e7-ba94-4f58-9232-f92c3779369c"}
{"type":"system","subtype":"api_retry","attempt":3,"max_retries":10,"retry_delay_ms":2132.401783390468,"error_status":null,"error":"unknown","session_id":"9201791e-959e-468c-9f35-3f363274e896","uuid":"7aa17d8b-7096-474b-bd69-3e68c9bc5ce6"}
{"type":"system","subtype":"api_retry","attempt":4,"max_retries":10,"retry_delay_ms":4599.899047965057,"error_status":null,"error":"unknown","session_id":"9201791e-959e-468c-9f35-3f363274e896","uuid":"52844996-cbbe-4f91-baa8-6ea1a4d1f2d5"}
{"type":"system","subtype":"api_retry","attempt":5,"max_retries":10,"retry_delay_ms":8296.40748059694,"error_status":null,"error":"unknown","session_id":"9201791e-959e-468c-9f35-3f363274e896","uuid":"48603337-d945-4ac7-9908-716cde4409e0"}
{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":16239.273788222305,"error_status":null,"error":"unknown","session_id":"9201791e-959e-468c-9f35-3f363274e896","uuid":"6774c915-9d6c-4e10-b1d1-50ed6dab8f6f"}
{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":33102.89590756301,"error_status":null,"error":"unknown","session_id":"9201791e-959e-468c-9f35-3f363274e896","uuid":"18848581-52df-4195-a49a-f781939e459d"}
{"type":"system","subtype":"api_retry","attempt":8,"max_retries":10,"retry_delay_ms":36914.271258730005,"error_status":null,"error":"unknown","session_id":"9201791e-959e-468c-9f35-3f363274e896","uuid":"0cdb2c68-07cb-4f9f-86b4-158ba01043be"}
{"type":"system","subtype":"api_retry","attempt":9,"max_retries":10,"retry_delay_ms":39458.44410609604,"error_status":null,"error":"unknown","session_id":"9201791e-959e-468c-9f35-3f363274e896","uuid":"0eea1ce9-4387-4d7d-945d-9796dd4e0de5"}
{"type":"system","subtype":"api_retry","attempt":10,"max_retries":10,"retry_delay_ms":35524.58115524215,"error_status":null,"error":"unknown","session_id":"9201791e-959e-468c-9f35-3f363274e896","uuid":"5f35a325-f987-4128-9740-b3e7d7f794b4"}
{"type":"assistant","message":{"id":"80acd61b-b207-4c22-bd26-5adbd06b6265","container":null,"model":"<synthetic>","role":"assistant","stop_details":null,"stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null},"content":[{"type":"text","text":"API Error: Unable to connect to API (ConnectionRefused)"}],"context_management":null},"parent_tool_use_id":null,"session_id":"9201791e-959e-468c-9f35-3f363274e896","uuid":"e087ccb5-c868-47f0-b3b1-c696f75835a7","error":"unknown"}
{"type":"result","subtype":"success","is_error":true,"api_error_status":null,"duration_ms":178916,"duration_api_ms":0,"num_turns":1,"result":"API Error: Unable to connect to API (ConnectionRefused)","stop_reason":"stop_sequence","session_id":"9201791e-959e-468c-9f35-3f363274e896","total_cost_usd":0,"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"3da61640-0410-4b86-875f-de2f79054ff1"}

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-4d9a",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 0,
"outcome": "success",
"duration_ms": 379153,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-08T19:29:59.500038883Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-4w08",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 174434,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-10T11:03:38.699815594Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

@ -0,0 +1,2 @@
SessionEnd hook [/home/coding/.ccdash/hooks/session-end.sh] failed: /bin/sh: line 1: /home/coding/.ccdash/hooks/session-end.sh: cannot execute: required file not found

View file

@ -0,0 +1,16 @@
{"type":"system","subtype":"hook_started","hook_id":"228dd5de-7520-4719-b873-270d6be8b095","hook_name":"SessionStart:startup","hook_event":"SessionStart","uuid":"5abf2738-6684-4a85-a917-1bf4de839cef","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d"}
{"type":"system","subtype":"hook_response","hook_id":"228dd5de-7520-4719-b873-270d6be8b095","hook_name":"SessionStart:startup","hook_event":"SessionStart","output":"/bin/sh: line 1: /home/coding/.ccdash/hooks/session-start.sh: cannot execute: required file not found\n","stdout":"","stderr":"/bin/sh: line 1: /home/coding/.ccdash/hooks/session-start.sh: cannot execute: required file not found\n","exit_code":127,"outcome":"error","uuid":"2dca8d9c-3e57-4e5b-8814-122a65f31ba5","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d"}
{"type":"system","subtype":"init","cwd":"/home/coding/miroir","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","NotebookEdit","Read","ScheduleWakeup","Skill","TaskOutput","TaskStop","TodoWrite","WebFetch","WebSearch","Write","mcp__claude_ai_Alphavantage__TOOL_CALL","mcp__claude_ai_Alphavantage__TOOL_GET","mcp__claude_ai_Alphavantage__TOOL_LIST","mcp__claude_ai_Gmail__authenticate","mcp__claude_ai_Gmail__complete_authentication","mcp__claude_ai_Google_Calendar__authenticate","mcp__claude_ai_Google_Calendar__complete_authentication","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Google_Drive__complete_authentication"],"mcp_servers":[{"name":"claude.ai Alphavantage","status":"connected"},{"name":"claude.ai Google Calendar","status":"needs-auth"},{"name":"claude.ai Gmail","status":"needs-auth"},{"name":"claude.ai Google Drive","status":"needs-auth"}],"model":"glm-4.7","permissionMode":"bypassPermissions","slash_commands":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","claude-api","clear","compact","context","heapdump","init","review","security-review","usage","insights","team-onboarding"],"apiKeySource":"none","claude_code_version":"2.1.138","output_style":"default","agents":["Explore","general-purpose","Plan","statusline-setup"],"skills":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","claude-api"],"plugins":[],"analytics_disabled":true,"uuid":"2d0b42a2-63b3-4acb-8a37-72c57262e5df","memory_paths":{"auto":"/home/coding/.claude/projects/-home-coding-miroir/memory/"},"fast_mode_state":"off"}
{"type":"system","subtype":"status","status":"requesting","uuid":"ec8b206b-0d1f-4f0c-8f3a-ed709d9ef5bd","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d"}
{"type":"system","subtype":"api_retry","attempt":1,"max_retries":10,"retry_delay_ms":585.065406262977,"error_status":null,"error":"unknown","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d","uuid":"fe366dff-9dc9-44cc-8a59-2229fd12cc53"}
{"type":"system","subtype":"api_retry","attempt":2,"max_retries":10,"retry_delay_ms":1231.1220782947023,"error_status":null,"error":"unknown","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d","uuid":"82a7f0dd-1f08-4242-80c3-47a187702c77"}
{"type":"system","subtype":"api_retry","attempt":3,"max_retries":10,"retry_delay_ms":2291.0281267998225,"error_status":null,"error":"unknown","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d","uuid":"e4c372c0-6b87-422e-8a47-f41484e64765"}
{"type":"system","subtype":"api_retry","attempt":4,"max_retries":10,"retry_delay_ms":4821.756914640311,"error_status":null,"error":"unknown","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d","uuid":"230ba3a2-6846-429d-b51c-a50e6d8330b9"}
{"type":"system","subtype":"api_retry","attempt":5,"max_retries":10,"retry_delay_ms":9915.794402170704,"error_status":null,"error":"unknown","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d","uuid":"f1189243-1005-4ee8-91f8-36c035f1fdab"}
{"type":"system","subtype":"api_retry","attempt":6,"max_retries":10,"retry_delay_ms":19728.754838667675,"error_status":null,"error":"unknown","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d","uuid":"a8c97b8a-2d9c-429f-9c19-6c2feecdb337"}
{"type":"system","subtype":"api_retry","attempt":7,"max_retries":10,"retry_delay_ms":32056.507439193858,"error_status":null,"error":"unknown","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d","uuid":"9136a235-a9c5-42a3-b1de-e58cca544533"}
{"type":"system","subtype":"api_retry","attempt":8,"max_retries":10,"retry_delay_ms":32813.34022624039,"error_status":null,"error":"unknown","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d","uuid":"7b28c2ae-3344-4e7c-b4a9-aa14c1b3db5d"}
{"type":"system","subtype":"api_retry","attempt":9,"max_retries":10,"retry_delay_ms":33948.93778320631,"error_status":null,"error":"unknown","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d","uuid":"99928feb-6c01-41cd-9fa4-43d3d287c07c"}
{"type":"system","subtype":"api_retry","attempt":10,"max_retries":10,"retry_delay_ms":34912.970971536546,"error_status":null,"error":"unknown","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d","uuid":"8c9dc41b-3083-4fe1-a314-81f9939e2314"}
{"type":"assistant","message":{"id":"338ba0ae-2ab4-4de3-8738-5108f330b6ae","container":null,"model":"<synthetic>","role":"assistant","stop_details":null,"stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null},"content":[{"type":"text","text":"API Error: Unable to connect to API (ConnectionRefused)"}],"context_management":null},"parent_tool_use_id":null,"session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d","uuid":"9898b089-6288-4643-92e1-8804d4564f4a","error":"unknown"}
{"type":"result","subtype":"success","is_error":true,"api_error_status":null,"duration_ms":173380,"duration_api_ms":0,"num_turns":1,"result":"API Error: Unable to connect to API (ConnectionRefused)","stop_reason":"stop_sequence","session_id":"6ab1d9c1-4fe7-4b8a-a764-9589e5d21e7d","total_cost_usd":0,"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"2a9e3f72-dc99-40ef-a9e8-a4f9042d9ccd"}

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-5gej",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 8808,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-08T19:23:58.154835111Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

View file

@ -0,0 +1,4 @@
{"type":"system","subtype":"init","cwd":"/home/coding/miroir","session_id":"97bb21d6-ffa1-4fbc-9edf-4bd9141c63d8","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","NotebookEdit","Read","ScheduleWakeup","Skill","TaskOutput","TaskStop","TodoWrite","WebFetch","WebSearch","Write"],"mcp_servers":[],"model":"glm-4.7","permissionMode":"bypassPermissions","slash_commands":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","claude-api","clear","compact","context","heapdump","init","review","security-review","usage","insights","team-onboarding"],"apiKeySource":"none","claude_code_version":"2.1.133","output_style":"default","agents":["Explore","general-purpose","Plan","statusline-setup"],"skills":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","claude-api"],"plugins":[],"analytics_disabled":true,"uuid":"96fb59e9-4d4f-43c4-8d23-e275b9ef2ab4","memory_paths":{"auto":"/home/coding/.claude/projects/-home-coding-miroir/memory/"},"fast_mode_state":"off"}
{"type":"system","subtype":"status","status":"requesting","uuid":"4c2cdaca-3bbd-44d4-8dc3-283b82cb5acc","session_id":"97bb21d6-ffa1-4fbc-9edf-4bd9141c63d8"}
{"type":"assistant","message":{"id":"29d19128-e199-4f9b-a8d2-443ababf2fc8","container":null,"model":"<synthetic>","role":"assistant","stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null},"content":[{"type":"text","text":"API Error: API returned an empty or malformed response (HTTP 200) — check for a proxy or gateway intercepting the request"}],"context_management":null},"parent_tool_use_id":null,"session_id":"97bb21d6-ffa1-4fbc-9edf-4bd9141c63d8","uuid":"f4d6edd6-e4b3-42b1-9577-b1e90d5504aa","error":"unknown"}
{"type":"result","subtype":"success","is_error":true,"api_error_status":null,"duration_ms":8547,"duration_api_ms":1508,"num_turns":1,"result":"API Error: API returned an empty or malformed response (HTTP 200) — check for a proxy or gateway intercepting the request","stop_reason":"stop_sequence","session_id":"97bb21d6-ffa1-4fbc-9edf-4bd9141c63d8","total_cost_usd":0.003705,"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{"glm-4.7":{"inputTokens":641,"outputTokens":20,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.003705,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"f4536a1e-22a1-4333-9e29-49bbb230eac6"}

View file

@ -0,0 +1,2 @@
SessionEnd hook [/home/coding/.ccdash/hooks/session-end.sh] failed: /bin/sh: line 1: /home/coding/.ccdash/hooks/session-end.sh: cannot execute: required file not found

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-5xs1",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 0,
"outcome": "success",
"duration_ms": 180918,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-08T19:26:44.263084349Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-dijm",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 176225,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-08T19:26:42.714634080Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-jap1",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 0,
"outcome": "success",
"duration_ms": 117752,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-08T19:25:53.105343822Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "miroir-cdo.1",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 202124,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-09T14:44:26.162212833Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

@ -0,0 +1,2 @@
SessionEnd hook [/home/coding/.ccdash/hooks/session-end.sh] failed: /bin/sh: line 1: /home/coding/.ccdash/hooks/session-end.sh: cannot execute: required file not found

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "miroir-cdo.2",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 204006,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-09T14:45:05.629528275Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

@ -0,0 +1,2 @@
SessionEnd hook [/home/coding/.ccdash/hooks/session-end.sh] failed: /bin/sh: line 1: /home/coding/.ccdash/hooks/session-end.sh: cannot execute: required file not found

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "miroir-cdo.4",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 196499,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-09T15:01:23.134387857Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

@ -0,0 +1,2 @@
SessionEnd hook [/home/coding/.ccdash/hooks/session-end.sh] failed: /bin/sh: line 1: /home/coding/.ccdash/hooks/session-end.sh: cannot execute: required file not found

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
SessionEnd hook [/home/coding/.ccdash/hooks/session-end.sh] failed: /bin/sh: line 1: /home/coding/.ccdash/hooks/session-end.sh: cannot execute: required file not found

View file

@ -0,0 +1,16 @@
{
"bead_id": "miroir-qon",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 253396,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-09T14:13:32.501636903Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

@ -0,0 +1,2 @@
SessionEnd hook [/home/coding/.ccdash/hooks/session-end.sh] failed: /bin/sh: line 1: /home/coding/.ccdash/hooks/session-end.sh: cannot execute: required file not found

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "miroir-r3j.1",
"agent": "claude-code-glm-5-1",
"provider": "zai",
"model": "glm-5.1",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 459142,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-13T23:12:06.004848388Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

@ -0,0 +1,2 @@
SessionEnd hook [/home/coding/.ccdash/hooks/session-end.sh] failed: /bin/sh: line 1: /home/coding/.ccdash/hooks/session-end.sh: cannot execute: required file not found

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "miroir-r3j.5",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 198584,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-09T09:45:59.659417710Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

@ -0,0 +1,2 @@
SessionEnd hook [/home/coding/.ccdash/hooks/session-end.sh] failed: /bin/sh: line 1: /home/coding/.ccdash/hooks/session-end.sh: cannot execute: required file not found

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
SessionEnd hook [/home/coding/.ccdash/hooks/session-end.sh] failed: /bin/sh: line 1: /home/coding/.ccdash/hooks/session-end.sh: cannot execute: required file not found

View file

@ -0,0 +1,2 @@
SessionEnd hook [/home/coding/.ccdash/hooks/session-end.sh] failed: /bin/sh: line 1: /home/coding/.ccdash/hooks/session-end.sh: cannot execute: required file not found

View file

@ -0,0 +1,16 @@
{
"bead_id": "miroir-zc2.3",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 157670,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-08T19:27:37.057396903Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "miroir-zc2.4",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 285213,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-08T19:30:38.669397737Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "miroir-zc2.5",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 208889,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-08T19:29:37.846094488Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "miroir-zc2",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 0,
"outcome": "success",
"duration_ms": 106390,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-08T19:24:58.966495904Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

553
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@ version = "0.1.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/jedarden/miroir"
rust-version = "1.87"
rust-version = "1.88"
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }

View file

@ -1,34 +1,20 @@
apiVersion: v2
name: miroir
description: Multi-node Index Replication Orchestrator for Meilisearch
type: application
version: 0.1.0
appVersion: 0.1.0
description: RAID-like sharding and HA for Meilisearch Community Edition
appVersion: "0.1.0"
keywords:
- search
- meilisearch
- search
- replication
- sharding
- kubernetes
home: https://github.com/jedarden/miroir
sources:
- https://github.com/jedarden/miroir
maintainers:
- name: jedarden
url: https://github.com/jedarden
icon: https://raw.githubusercontent.com/meilisearch/meilisearch/main/assets/logo.svg
email: dev@example.com
annotations:
artifacthub.io/category: database
artifacthub.io/license: MIT
artifacthub.io/links: |
- name: Documentation
url: https://github.com/jedarden/miroir
artifacthub.io/operator: "false"
artifacthub.io/prerelease: "false"
kubeVersion: ">=1.25.0-0"
# Prometheus Adapter dependency (plan §14.4)
# Required for HPA custom metrics: miroir_requests_in_flight, miroir_background_queue_depth
dependencies:
- name: prometheus-adapter
version: "4.x"
repository: "https://prometheus-community.github.io/helm-charts"
condition: prometheusAdapter.enabled
alias: prometheusAdapter

View file

@ -1,25 +1,42 @@
Miroir has been installed!
Miroir has been deployed successfully!
Get the service URL:
export POD_NAME=$(kubectl get pods -n {{ .Release.Namespace }} -l "app.kubernetes.io/instance={{ .Release.Name }},app.kubernetes.io/component=miroir" -o jsonpath="{.items[0].metadata.name}")
kubectl port-forward -n {{ .Release.Namespace }} "$POD_NAME" 7700:7700
{{- if .Values.miroir.ingress.enabled }}
Then visit http://localhost:7700/health to verify the proxy is running.
Components deployed:
- Miroir proxy ({{ .Values.miroir.replicas }} replica(s))
- Meilisearch ({{ .Values.meilisearch.replicas }} node(s))
{{- if eq (include "miroir.redisEnabled" .) "true" }}
- Redis (task store)
{{- else }}
- SQLite task store (embedded)
Your Miroir instance is accessible at:
{{- range .Values.miroir.ingress.hosts }}
http{{ if $.Values.miroir.ingress.tls }}s{{ end }}://{{ .host }}
{{- end }}
!!! PRODUCTION UPGRADE PATH !!!
These defaults are for dev/CI (single-pod evaluation). For production, override:
miroir.replicas=2+
miroir.replicationFactor=2
miroir.replicaGroups=2
taskStore.backend=redis
redis.enabled=true
hpa.enabled=true
{{- else if contains "NodePort" .Values.miroir.service.type }}
Get the NodePort by running:
export NODE_PORT=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "miroir.fullname" . }} -o jsonpath='{.spec.ports[0].nodePort}')
echo "URL: http://$NODE_PORT"
{{- else if contains "LoadBalancer" .Values.miroir.service.type }}
Get the LoadBalancer IP by running:
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "miroir.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo "URL: http://$SERVICE_IP:{{ .Values.miroir.service.port }}"
{{- else if contains "ClusterIP" .Values.miroir.service.type }}
Get the application URL by running these commands in the same shell:
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "miroir.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
echo "Visit http://127.0.0.1:7700 to use your application"
kubectl port-forward --namespace {{ .Release.Namespace }} $POD_NAME 7700:7700
{{- end }}
{{- if eq .Values.miroir.taskStore.backend "redis" }}
Redis is deployed and accessible at:
{{ include "miroir.fullname" . }}-redis:6379
{{- end }}
To verify the deployment, run:
kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "miroir.name" . }},app.kubernetes.io/instance={{ .Release.Name }}"
For more information, see the documentation at:
https://github.com/jedarden/miroir

View file

@ -21,11 +21,18 @@ Create a default fully qualified app name.
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "miroir.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "miroir.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
helm.sh/chart: {{ include "miroir.chart" . }}
{{ include "miroir.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
@ -42,7 +49,14 @@ app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
ServiceAccount name
Redis enabled
*/}}
{{- define "miroir.redisEnabled" -}}
{{- eq .Values.miroir.taskStore.backend "redis" }}
{{- end }}
{{/*
Service Account Name
*/}}
{{- define "miroir.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
@ -51,228 +65,3 @@ ServiceAccount name
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Secret name
*/}}
{{- define "miroir.secretName" -}}
{{- if .Values.miroir.existingSecret }}
{{- .Values.miroir.existingSecret }}
{{- else }}
{{- include "miroir.fullname" . }}-secret
{{- end }}
{{- end }}
{{/*
Generate the full DNS address for a Meilisearch node.
Usage:
{{ include "miroir.meilisearchNodeAddress" (dict "release" .Release "namespace" .Namespace "nodeIndex" 0) }}
Returns:
http://release-name-meili-0.release-name-meili-headless.namespace.svc.cluster.local:7700
*/}}
{{- define "miroir.meilisearchNodeAddress" -}}
{{- $ns := .namespace | default "default" -}}
http://{{ .release.Name }}-meili-{{ .nodeIndex }}.{{ .release.Name }}-meili-headless.{{ $ns }}.svc.cluster.local:7700
{{- end -}}
{{/*
Generate the list of Meilisearch node addresses for the ConfigMap.
Usage:
{{ include "miroir.meilisearchNodeList" $ }}
Returns a YAML-formatted list of node entries for the miroir config.
*/}}
{{- define "miroir.meilisearchNodeList" -}}
{{- $meiliReplicas := .Values.meilisearch.replicas | default 2 | int -}}
{{- $nodesPerGroup := .Values.meilisearch.nodesPerGroup | default 2 | int -}}
{{- $replicaGroups := .Values.miroir.replicaGroups | default 1 | int -}}
{{- range $group := until $replicaGroups -}}
{{- range $node := until $nodesPerGroup -}}
{{- $nodeIndex := add (mul $group $nodesPerGroup) $node }}
- id: "meili-{{ $nodeIndex }}"
address: {{ include "miroir.meilisearchNodeAddress" (dict "release" $.Release "namespace" $.Namespace "nodeIndex" $nodeIndex) }}
replica_group: {{ $group }}
{{- end -}}
{{- end -}}
{{- end -}}
{{/*
Generate the miroir YAML config for the ConfigMap.
Usage:
{{ include "miroir.config" $ }}
*/}}
{{- define "miroir.config" -}}
shards: {{ .Values.miroir.shards | default 64 }}
replication_factor: {{ .Values.miroir.replicationFactor | default 1 }}
replica_groups: {{ .Values.miroir.replicaGroups | default 1 }}
nodes:
{{ include "miroir.meilisearchNodeList" . | indent 2 }}
task_store:
backend: {{ .Values.taskStore.backend | default "sqlite" }}
path: {{ .Values.taskStore.path | default "/data/miroir-tasks.db" }}
{{- if eq (include "miroir.redisEnabled" .) "true" }}
url: redis://{{ .Release.Name }}-redis.{{ .Release.Namespace | default "default" }}.svc.cluster.local:6379
{{- end }}
health:
interval_ms: 5000
timeout_ms: 2000
unhealthy_threshold: 3
recovery_threshold: 2
scatter:
node_timeout_ms: 5000
retry_on_timeout: true
unavailable_shard_policy: partial
rebalancer:
auto_rebalance_on_recovery: true
max_concurrent_migrations: 4
migration_timeout_s: 3600
server:
port: 7700
bind: "0.0.0.0"
max_body_bytes: {{ .Values.miroir.server.max_body_bytes | default 104857600 }}
max_concurrent_requests: {{ .Values.miroir.server.max_concurrent_requests | default 500 }}
request_timeout_ms: {{ .Values.miroir.server.request_timeout_ms | default 30000 }}
connection_pool_per_node:
max_idle: {{ .Values.miroir.connection_pool_per_node.max_idle | default 32 }}
max_total: {{ .Values.miroir.connection_pool_per_node.max_total | default 128 }}
idle_timeout_s: {{ .Values.miroir.connection_pool_per_node.idle_timeout_s | default 60 }}
task_registry:
cache_size: {{ .Values.miroir.task_registry.cache_size | default 10000 }}
redis_pool_max: {{ .Values.miroir.task_registry.redis_pool_max | default 50 }}
ttl_seconds: {{ .Values.miroir.task_registry.ttl_seconds | default 604800 }}
prune_interval_s: {{ .Values.miroir.task_registry.prune_interval_s | default 300 }}
prune_batch_size: {{ .Values.miroir.task_registry.prune_batch_size | default 10000 }}
idempotency:
enabled: {{ .Values.miroir.idempotency.enabled | default true }}
max_cached_keys: {{ .Values.miroir.idempotency.max_cached_keys | default 1000000 }}
ttl_seconds: {{ .Values.miroir.idempotency.ttl_seconds | default 86400 }}
session_pinning:
enabled: {{ .Values.miroir.session_pinning.enabled | default true }}
ttl_seconds: {{ .Values.miroir.session_pinning.ttl_seconds | default 900 }}
max_sessions: {{ .Values.miroir.session_pinning.max_sessions | default 100000 }}
wait_strategy: {{ .Values.miroir.session_pinning.wait_strategy | default "block" }}
max_wait_ms: {{ .Values.miroir.session_pinning.max_wait_ms | default 5000 }}
query_coalescing:
enabled: {{ .Values.miroir.query_coalescing.enabled | default true }}
window_ms: {{ .Values.miroir.query_coalescing.window_ms | default 50 }}
max_subscribers: {{ .Values.miroir.query_coalescing.max_subscribers | default 1000 }}
max_pending_queries: {{ .Values.miroir.query_coalescing.max_pending_queries | default 10000 }}
anti_entropy:
enabled: {{ .Values.miroir.anti_entropy.enabled | default true }}
schedule: {{ .Values.miroir.anti_entropy.schedule | default "every 6h" }}
shards_per_pass: {{ .Values.miroir.anti_entropy.shards_per_pass | default 0 }}
max_read_concurrency: {{ .Values.miroir.anti_entropy.max_read_concurrency | default 2 }}
fingerprint_batch_size: {{ .Values.miroir.anti_entropy.fingerprint_batch_size | default 1000 }}
auto_repair: {{ .Values.miroir.anti_entropy.auto_repair | default true }}
updated_at_field: {{ .Values.miroir.anti_entropy.updated_at_field | default "_miroir_updated_at" }}
resharding:
enabled: {{ .Values.miroir.resharding.enabled | default true }}
backfill_concurrency: {{ .Values.miroir.resharding.backfill_concurrency | default 4 }}
backfill_batch_size: {{ .Values.miroir.resharding.backfill_batch_size | default 1000 }}
throttle_docs_per_sec: {{ .Values.miroir.resharding.throttle_docs_per_sec | default 0 }}
verify_before_swap: {{ .Values.miroir.resharding.verify_before_swap | default true }}
retain_old_index_hours: {{ .Values.miroir.resharding.retain_old_index_hours | default 48 }}
allowed_windows: {{ .Values.miroir.resharding.allowed_windows | default list | toJson }}
peer_discovery:
service_name: {{ .Values.miroir.peer_discovery.service_name | default (printf "%s-headless" (include "miroir.fullname" .)) }}
refresh_interval_s: {{ .Values.miroir.peer_discovery.refresh_interval_s | default 15 }}
leader_election:
enabled: {{ .Values.miroir.leader_election.enabled | default true }}
lease_ttl_s: {{ .Values.miroir.leader_election.lease_ttl_s | default 10 }}
renew_interval_s: {{ .Values.miroir.leader_election.renew_interval_s | default 3 }}
hpa:
enabled: {{ .Values.hpa.enabled | default false }}
tracing:
enabled: {{ .Values.tracing.enabled | default false }}
endpoint: {{ .Values.tracing.endpoint | default "http://tempo.monitoring.svc:4317" }}
service_name: {{ .Values.tracing.serviceName | default "miroir" }}
sample_rate: {{ .Values.tracing.sampleRate | default 0.1 }}
search_ui:
enabled: {{ .Values.search_ui.enabled | default true }}
scoped_key_max_age_days: {{ .Values.search_ui.scoped_key_max_age_days | default 60 }}
scoped_key_rotate_before_expiry_days: {{ .Values.search_ui.scoped_key_rotate_before_expiry_days | default 30 }}
scoped_key_rotation_drain_s: {{ .Values.search_ui.scoped_key_rotation_drain_s | default 120 }}
admin_ui:
enabled: {{ .Values.admin_ui.enabled | default true }}
path: {{ .Values.admin_ui.path | default "/_miroir/admin" }}
auth: {{ .Values.admin_ui.auth | default "key" }}
session_ttl_s: {{ .Values.admin_ui.session_ttl_s | default 3600 }}
read_only_mode: {{ .Values.admin_ui.read_only_mode | default false }}
allowed_origins: {{ .Values.admin_ui.allowed_origins | default list "same-origin" | toJson }}
cors_allowed_origins: {{ .Values.admin_ui.cors_allowed_origins | default list | toJson }}
rate_limit:
per_ip: {{ .Values.admin_ui.rate_limit.per_ip | default "10/minute" }}
backend: {{ .Values.admin_ui.rate_limit.backend | default "redis" }}
redis_key_prefix: {{ .Values.admin_ui.rate_limit.redis_key_prefix | default "miroir:ratelimit:adminlogin:" }}
redis_ttl_s: {{ .Values.admin_ui.rate_limit.redis_ttl_s | default 60 }}
failed_attempt_threshold: {{ .Values.admin_ui.rate_limit.failed_attempt_threshold | default 5 }}
backoff_start_minutes: {{ .Values.admin_ui.rate_limit.backoff_start_minutes | default 10 }}
backoff_max_hours: {{ .Values.admin_ui.rate_limit.backoff_max_hours | default 24 }}
{{- if .Values.miroir.cdc }}
cdc:
enabled: {{ .Values.miroir.cdc.enabled | default true }}
emit_ttl_deletes: {{ .Values.miroir.cdc.emit_ttl_deletes | default false }}
emit_internal_writes: {{ .Values.miroir.cdc.emit_internal_writes | default false }}
buffer:
primary: {{ .Values.miroir.cdc.buffer.primary | default "memory" }}
memory_bytes: {{ .Values.miroir.cdc.buffer.memory_bytes | default 67108864 }}
overflow: {{ .Values.miroir.cdc.buffer.overflow | default "drop" }}
{{- if eq (include "miroir.redisEnabled" .) "true" }}
redis_bytes: {{ .Values.miroir.cdc.buffer.redis_bytes | default 1073741824 }}
{{- end }}
{{- if eq (include "miroir.cdcPvcEnabled" .) "true" }}
pvc_path: /data/cdc
{{- end }}
{{- end }}
{{- end -}}
{{/*
Return "true" if Redis is enabled, "false" otherwise.
*/}}
{{- define "miroir.redisEnabled" -}}
{{- if .Values.redis.enabled -}}true{{- else -}}false{{- end -}}
{{- end -}}
{{/*
Redis auth secret name.
*/}}
{{- define "miroir.redisSecretName" -}}
{{- if .Values.redis.auth.existingSecret -}}
{{- .Values.redis.auth.existingSecret -}}
{{- else -}}
{{- include "miroir.fullname" . }}-redis-secret
{{- end -}}
{{- end -}}
{{/*
Return "true" if CDC PVC should be created, "false" otherwise.
PVC is rendered when cdc.buffer.primary=="pvc" or cdc.buffer.overflow=="pvc".
*/}}
{{- define "miroir.cdcPvcEnabled" -}}
{{- if and .Values.miroir.cdc (or (eq .Values.miroir.cdc.buffer.primary "pvc") (eq .Values.miroir.cdc.buffer.overflow "pvc")) -}}true{{- else -}}false{{- end -}}
{{- end -}}
{{/*
Cross-field validations that JSON Schema draft-7 cannot express.
Rendered as an empty ConfigMap; fails template rendering on invalid config.
*/}}
{{- define "miroir.validate.values" -}}
{{- if .Values.search_ui -}}
{{- if and (hasKey .Values.search_ui "scoped_key_rotate_before_expiry_days") (hasKey .Values.search_ui "scoped_key_max_age_days") -}}
{{- if ge (int .Values.search_ui.scoped_key_rotate_before_expiry_days) (int .Values.search_ui.scoped_key_max_age_days) -}}
{{- fail (printf "search_ui.scoped_key_rotate_before_expiry_days (%d) must be strictly less than scoped_key_max_age_days (%d); otherwise rotation fires before/at key issuance, producing a continuous rotation loop" (int .Values.search_ui.scoped_key_rotate_before_expiry_days) (int .Values.search_ui.scoped_key_max_age_days)) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- if .Values.miroir.leader_election -}}
{{- if and (hasKey .Values.miroir.leader_election "lease_ttl_s") (hasKey .Values.miroir.leader_election "renew_interval_s") -}}
{{- if le (int .Values.miroir.leader_election.lease_ttl_s) (int .Values.miroir.leader_election.renew_interval_s) -}}
{{- fail (printf "leader_election.lease_ttl_s (%d) must be greater than leader_election.renew_interval_s (%d); otherwise the lease expires before it can be renewed" (int .Values.miroir.leader_election.lease_ttl_s) (int .Values.miroir.leader_election.renew_interval_s)) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}

View file

@ -0,0 +1,23 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "miroir.fullname" . }}-config
labels:
{{- include "miroir.labels" . | nindent 4 }}
data:
config.yaml: |
miroir:
shards: {{ .Values.miroir.shards }}
replication_factor: {{ .Values.miroir.replicationFactor }}
replica_groups: {{ .Values.miroir.replicaGroups }}
scatter:
unavailable_shard_policy: {{ .Values.miroir.scatter.unavailableShardPolicy }}
task_store:
backend: {{ .Values.miroir.taskStore.backend }}
{{- if eq .Values.miroir.taskStore.backend "sqlite" }}
path: {{ .Values.miroir.taskStore.path }}
{{- else if eq .Values.miroir.taskStore.backend "redis" }}
url: {{ .Values.miroir.taskStore.url }}
{{- end }}
admin:
enabled: {{ .Values.miroir.admin.enabled }}

View file

@ -0,0 +1,73 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "miroir.fullname" . }}
labels:
{{- include "miroir.labels" . | nindent 4 }}
spec:
{{- if not .Values.miroir.hpa.enabled }}
replicas: {{ .Values.miroir.replicas }}
{{- end }}
selector:
matchLabels:
{{- include "miroir.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
{{- with .Values.miroir.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "miroir.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.miroir.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "miroir.serviceAccountName" . }}
containers:
- name: miroir
{{- with .Values.miroir.image }}
image: "{{ .repository }}:{{ .tag | default $.Chart.AppVersion }}"
imagePullPolicy: {{ .pullPolicy }}
{{- end }}
ports:
- name: http
containerPort: 7700
protocol: TCP
env:
- name: MIROIR_SHARDS
value: {{ .Values.miroir.shards | quote }}
- name: MIROIR_REPLICATION_FACTOR
value: {{ .Values.miroir.replicationFactor | quote }}
- name: MIROIR_REPLICA_GROUPS
value: {{ .Values.miroir.replicaGroups | quote }}
- name: MIROIR_TASK_STORE_BACKEND
value: {{ .Values.miroir.taskStore.backend }}
{{- if eq .Values.miroir.taskStore.backend "sqlite" }}
- name: MIROIR_TASK_STORE_PATH
value: {{ .Values.miroir.taskStore.path | quote }}
{{- else if eq .Values.miroir.taskStore.backend "redis" }}
- name: MIROIR_TASK_STORE_URL
value: {{ .Values.miroir.taskStore.url | quote }}
{{- end }}
{{- if .Values.miroir.existingSecret }}
envFrom:
- secretRef:
name: {{ .Values.miroir.existingSecret }}
{{- end }}
{{- with .Values.miroir.resources }}
resources:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- with .Values.miroir.securityContext }}
securityContext:
{{- toYaml . | nindent 10 }}
{{- end }}
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
emptyDir: {}

View file

@ -0,0 +1,32 @@
{{- if .Values.miroir.hpa.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "miroir.fullname" . }}
labels:
{{- include "miroir.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "miroir.fullname" . }}
minReplicas: {{ .Values.miroir.hpa.minReplicas }}
maxReplicas: {{ .Values.miroir.hpa.maxReplicas }}
metrics:
{{- if .Values.miroir.hpa.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.miroir.hpa.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.miroir.hpa.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.miroir.hpa.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}

View file

@ -1,138 +1,82 @@
{{/*
Redis Deployment (only when redis.enabled=true)
*/}}
{{- if eq (include "miroir.redisEnabled" .) "true" }}
{{- if and (include "miroir.redisEnabled" .) .Values.redis.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "miroir.fullname" . }}-redis
labels:
{{- include "miroir.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
app: redis
spec:
replicas: {{ .Values.redis.replicas }}
replicas: 1
selector:
matchLabels:
{{- include "miroir.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: redis
app: redis
template:
metadata:
annotations:
{{- with .Values.redis.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "miroir.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: redis
{{- with .Values.redis.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
app: redis
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
fsGroup: 999
containers:
- name: redis
{{- with .Values.redis.image }}
image: "{{ .repository }}:{{ .tag }}"
imagePullPolicy: {{ .pullPolicy }}
{{- end }}
ports:
- name: redis
image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}"
imagePullPolicy: {{ .Values.redis.image.pullPolicy }}
ports:
- name: redis
containerPort: 6379
protocol: TCP
{{- if .Values.redis.auth.enabled }}
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "miroir.redisSecretName" . }}
key: redis-password
{{- end }}
args:
{{- if .Values.redis.auth.enabled }}
- --requirepass
- $(REDIS_PASSWORD)
{{- end }}
{{- if .Values.redis.persistence.enabled }}
volumeMounts:
- name: data
mountPath: /data
{{- end }}
livenessProbe:
exec:
command:
- redis-cli
{{- if .Values.redis.auth.enabled }}
- -a
- $(REDIS_PASSWORD)
{{- end }}
- ping
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
exec:
command:
- redis-cli
{{- if .Values.redis.auth.enabled }}
- -a
- $(REDIS_PASSWORD)
{{- end }}
- ping
initialDelaySeconds: 3
periodSeconds: 5
resources:
{{- toYaml .Values.redis.resources | nindent 12 }}
containerPort: 6379
protocol: TCP
{{- with .Values.redis.resources }}
resources:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- if .Values.redis.persistence.enabled }}
volumeMounts:
- name: data
mountPath: /data
{{- end }}
{{- if .Values.redis.persistence.enabled }}
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ include "miroir.fullname" . }}-redis-data
{{- end }}
{{- with .Values.redis.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.redis.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.redis.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
- name: data
persistentVolumeClaim:
claimName: {{ include "miroir.fullname" . }}-redis
{{- end }}
---
{{- if and (include "miroir.redisEnabled" .) .Values.redis.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "miroir.fullname" . }}-redis
labels:
{{- include "miroir.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
app: redis
spec:
type: {{ .Values.redis.service.type | default "ClusterIP" }}
type: ClusterIP
ports:
- port: {{ .Values.redis.service.port | default 6379 }}
targetPort: redis
protocol: TCP
name: redis
- port: 6379
targetPort: redis
protocol: TCP
name: redis
selector:
{{- include "miroir.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: redis
{{- if .Values.redis.persistence.enabled }}
app: redis
{{- end }}
---
{{- if and (include "miroir.redisEnabled" .) .Values.redis.enabled .Values.redis.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "miroir.fullname" . }}-redis-data
name: {{ include "miroir.fullname" . }}-redis
labels:
{{- include "miroir.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.redis.persistence.size | default "1Gi" }}
storage: {{ .Values.redis.persistence.size }}
{{- if .Values.redis.persistence.storageClass }}
storageClassName: {{ .Values.redis.persistence.storageClass }}
{{- end }}

View file

@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "miroir.fullname" . }}
labels:
{{- include "miroir.labels" . | nindent 4 }}
{{- with .Values.miroir.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.miroir.service.type }}
ports:
- port: {{ .Values.miroir.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "miroir.selectorLabels" . | nindent 4 }}

View file

@ -1,11 +1,8 @@
{{/*
ServiceAccount
*/}}
{{- if .Values.serviceAccount.create }}
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "miroir.serviceAccountName" . }}
name: {{ include "miroir.fullname" . }}
labels:
{{- include "miroir.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}

View file

@ -1,40 +1,33 @@
# Helm Chart Validation Tests
# Miroir Helm Chart Tests
## Running All Tests
This directory contains test cases and validation scripts for the Miroir Helm chart.
## Schema Validation Tests
The `test_schema.py` script validates that the `values.schema.json` constraints are working correctly.
### Test Cases
| Test File | Description | Expected Result |
|-----------|-------------|-----------------|
| `replicas-1-sqlite.yaml` | Single replica with SQLite | PASS |
| `replicas-2-sqlite.yaml` | Multiple replicas with SQLite | FAIL (error: backend must be redis) |
| `replicas-2-redis.yaml` | Multiple replicas with Redis | PASS |
### Running Tests
```bash
./charts/miroir/tests/run-tests.sh
# Using Python (works without helm installed)
python3 tests/test_schema.py
# Using helm lint (requires helm)
helm lint --strict -f tests/replicas-1-sqlite.yaml .
helm lint --strict -f tests/replicas-2-sqlite.yaml . # Should fail
helm lint --strict -f tests/replicas-2-redis.yaml .
```
This runs both `helm lint --strict` (schema rules) and `helm template` (render-time rules) for all test cases.
### Constraint Details
## Test Cases
The values.schema.json enforces that when `miroir.replicas > 1`, the `taskStore.backend` must be `"redis"`. SQLite is a single-writer database and cannot be shared across multiple pods.
### Schema rejection tests (`helm lint --strict`)
| File | Rule | Description |
|------|------|-------------|
| `invalid-multi-replica-sqlite.yaml` | 1 | `replicas>1` with `taskStore.backend: sqlite` — SQLite cannot be shared across pods |
| `bad-hpa-no-redis.yaml` | 2a | `hpa.enabled: true` with `taskStore.backend: sqlite` — autoscaling requires Redis |
| `bad-hpa-single-replica.yaml` | 2b | `hpa.enabled: true` with `replicas: 1` — HPA requires `replicas >= 2` |
| `bad-search-ui-rate-limit-local-multi.yaml` | 3 | `search_ui.rate_limit.backend: local` with `replicas>1` — per-pod limits don't share state |
| `bad-admin-login-rate-limit-local-multi.yaml` | 4 | `admin_ui.login_rate_limit.backend: local` with `replicas>1` — per-pod limits don't share state |
### Template rejection tests (`helm template`)
| File | Rule | Description |
|------|------|-------------|
| `bad-scoped-key-rotate-gte-max.yaml` | 5a | `rotate_before_expiry >= max_age` — rotation fires at/before issuance |
| `bad-scoped-key-rotate-gt-max.yaml` | 5b | `rotate_before_expiry > max_age` — negative rotation window |
Rule 5 uses template-level `fail()` because JSON Schema draft-7 cannot compare sibling property values.
### Positive tests
| File | Description |
|------|-------------|
| `valid-single-replica-sqlite.yaml` | Single replica with SQLite (dev default) |
| `valid-single-pod-oversized.yaml` | Single-pod oversized mode (4 vCPU / 8 GB) for dev/small deployments |
| `valid-multi-replica-redis.yaml` | Multi-replica with Redis |
| `good-production.yaml` | Full production config with HPA, Redis rate limiting, and scoped keys |
| `good-dev-no-ui.yaml` | Minimal dev defaults |
See values.schema.json lines 142-161 for the constraint implementation.

View file

@ -0,0 +1,7 @@
# Test case: replicas: 1, backend: sqlite -> should PASS
# Single replica can use SQLite (single-writer is fine)
miroir:
replicas: 1
taskStore:
backend: sqlite
path: /data/miroir-tasks.db

View file

@ -0,0 +1,7 @@
# Test case: replicas: 2, backend: redis -> should PASS
# Multiple replicas MUST use Redis (shared backend)
miroir:
replicas: 2
taskStore:
backend: redis
url: redis://redis:6379

View file

@ -0,0 +1,7 @@
# Test case: replicas: 2, backend: sqlite -> should FAIL
# Multiple replicas CANNOT use SQLite (single-writer cannot be shared)
miroir:
replicas: 2
taskStore:
backend: sqlite
path: /data/miroir-tasks.db

View file

@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Test values.schema.json validation constraints.
Tests the SQLite + multiple replicas rejection rule:
- replicas: 1 + sqlite -> PASS
- replicas: 2 + sqlite -> FAIL
- replicas: 2 + redis -> PASS
Usage:
python3 test_schema.py # Run tests
helm lint --strict -f tests/replicas-2-sqlite.yaml . # Run with helm
"""
import json
import sys
from pathlib import Path
def load_json(path: Path) -> dict:
with open(path) as f:
return json.load(f)
def evaluate_condition(instance, if_cond):
"""Evaluate a JSON Schema if condition against an instance."""
if "properties" in if_cond:
for prop_path, schema in if_cond["properties"].items():
parts = prop_path.split(".")
value = instance
for part in parts:
if not isinstance(value, dict):
return False
if part not in value:
return False
value = value[part]
# Check the constraint
if "const" in schema:
if value != schema["const"]:
return False
elif "minimum" in schema:
if not isinstance(value, (int, float)):
return False
if value < schema["minimum"]:
return False
elif "type" in schema:
if schema["type"] == "boolean":
if value != schema.get(True, False):
return False
if "required" in if_cond:
for req in if_cond["required"]:
if req not in instance:
return False
return True
def validate_schema(schema: dict, instance: dict, path: str = "") -> list:
"""Validate instance against schema, return list of errors."""
errors = []
# Check allOf constraints
if "allOf" in schema:
for constraint in schema["allOf"]:
if "if" in constraint and "then" in constraint:
if evaluate_condition(instance, constraint["if"]):
# The 'if' condition is true, check 'then' constraint
then_schema = constraint["then"]
# Check nested properties in 'then'
if "properties" in then_schema:
for prop, prop_schema in then_schema["properties"].items():
if prop in instance:
if "properties" in prop_schema:
for nested, nested_schema in prop_schema["properties"].items():
if nested in instance[prop]:
actual = instance[prop][nested]
if "const" in nested_schema:
if actual != nested_schema["const"]:
msg = f"{path}{prop}.{nested}: expected {nested_schema['const']}, got {actual}"
if "errorMessage" in constraint:
msg = constraint["errorMessage"]
errors.append(msg)
# Check required fields in 'then'
if "required" in then_schema:
for req in then_schema["required"]:
if req not in instance:
errors.append(f"{path}{req} is required")
return errors
def test_schema_constraints():
chart_dir = Path(__file__).parent.parent
schema_path = chart_dir / "values.schema.json"
tests_dir = Path(__file__).parent
schema = load_json(schema_path)
test_cases = [
# (replicas, backend, should_pass, description)
(1, "sqlite", True, "replicas: 1 + sqlite should PASS"),
(2, "sqlite", False, "replicas: 2 + sqlite should FAIL"),
(2, "redis", True, "replicas: 2 + redis should PASS"),
]
passed = 0
failed = 0
for replicas, backend, should_pass, description in test_cases:
instance = {
"replicas": replicas,
"taskStore": {"backend": backend}
}
miroir_schema = schema["properties"]["miroir"]
errors = validate_schema(miroir_schema, instance)
is_valid = len(errors) == 0
if is_valid == should_pass:
print(f"{description}")
passed += 1
else:
print(f"{description}")
for err in errors:
print(f" Error: {err}")
failed += 1
print(f"\n{passed} passed, {failed} failed")
return failed == 0
if __name__ == "__main__":
success = test_schema_constraints()
sys.exit(0 if success else 1)

View file

@ -1,5 +1,5 @@
{
"$schema": "http://json-schema.org/draft-7/schema#",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Miroir Helm Chart Values",
"type": "object",
"properties": {
@ -9,9 +9,9 @@
"image": {
"type": "object",
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" },
"pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }
"repository": {"type": "string"},
"tag": {"type": "string"},
"pullPolicy": {"type": "string", "enum": ["Always", "IfNotPresent", "Never"]}
}
},
"replicas": {
@ -30,496 +30,192 @@
"type": "integer",
"minimum": 1
},
"existingSecret": { "type": "string" },
"logLevel": { "type": "string", "enum": ["trace", "debug", "info", "warn", "error"] },
"podAnnotations": { "type": "object" },
"podLabels": { "type": "object" },
"resources": {
"taskStore": {
"type": "object",
"properties": {
"limits": { "type": "object" },
"requests": { "type": "object" }
"backend": {
"type": "string",
"enum": ["sqlite", "redis"]
},
"path": {"type": "string"},
"url": {"type": "string"}
},
"required": ["backend"]
},
"admin": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"}
}
},
"nodeSelector": { "type": "object" },
"tolerations": { "type": "array" },
"affinity": { "type": "object" },
"cdc": {
"scatter": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"emit_ttl_deletes": { "type": "boolean" },
"emit_internal_writes": { "type": "boolean" },
"buffer": {
"type": "object",
"properties": {
"primary": { "type": "string", "enum": ["memory", "pvc", "redis"] },
"overflow": { "type": "string", "enum": ["drop", "pvc", "redis"] },
"pvc_size": { "type": "string" },
"pvc_storage_class": { "type": "string" },
"memory_bytes": { "type": "integer", "minimum": 0 },
"redis_bytes": { "type": "integer", "minimum": 0 }
"unavailableShardPolicy": {
"type": "string",
"enum": ["partial", "error"]
}
}
},
"hpa": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"minReplicas": {"type": "integer", "minimum": 1},
"maxReplicas": {"type": "integer", "minimum": 1},
"targetCPUUtilizationPercentage": {"type": "integer", "minimum": 1, "maximum": 100},
"targetMemoryUtilizationPercentage": {"type": "integer", "minimum": 1, "maximum": 100}
}
},
"ingress": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"className": {"type": "string"},
"annotations": {"type": "object"},
"hosts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"host": {"type": "string"},
"paths": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {"type": "string"},
"pathType": {"type": "string"}
},
"required": ["path", "pathType"]
}
}
},
"required": ["host"]
}
},
"tls": {
"type": "array",
"items": {
"type": "object",
"properties": {
"secretName": {"type": "string"},
"hosts": {"type": "array", "items": {"type": "string"}}
}
}
}
}
},
"server": {
"resources": {
"type": "object",
"properties": {
"max_body_bytes": { "type": "integer", "minimum": 0 },
"max_concurrent_requests": { "type": "integer", "minimum": 1 },
"request_timeout_ms": { "type": "integer", "minimum": 0 }
"limits": {
"type": "object",
"properties": {
"cpu": {"type": "string"},
"memory": {"type": "string"}
}
},
"requests": {
"type": "object",
"properties": {
"cpu": {"type": "string"},
"memory": {"type": "string"}
}
}
}
},
"connection_pool_per_node": {
"podAnnotations": {"type": "object"},
"podSecurityContext": {"type": "object"},
"securityContext": {"type": "object"},
"service": {
"type": "object",
"properties": {
"max_idle": { "type": "integer", "minimum": 0 },
"max_total": { "type": "integer", "minimum": 1 },
"idle_timeout_s": { "type": "integer", "minimum": 1 }
"type": {"type": "string"},
"port": {"type": "integer"},
"annotations": {"type": "object"}
}
},
"task_registry": {
"type": "object",
"properties": {
"cache_size": { "type": "integer", "minimum": 0 },
"redis_pool_max": { "type": "integer", "minimum": 1 },
"ttl_seconds": { "type": "integer", "minimum": 1 },
"prune_interval_s": { "type": "integer", "minimum": 1 },
"prune_batch_size": { "type": "integer", "minimum": 1 }
"existingSecret": {"type": "string"}
},
"required": ["replicas", "shards", "replicationFactor", "replicaGroups", "taskStore"],
"allOf": [
{
"if": {
"properties": {
"replicas": {"minimum": 2}
},
"required": ["replicas"]
},
"then": {
"properties": {
"taskStore": {
"properties": {
"backend": {"const": "redis"}
},
"required": ["backend"]
}
},
"errorMessage": "taskStore.backend must be 'redis' when replicas > 1 (SQLite is single-writer and cannot be shared across pods)"
}
},
"idempotency": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"max_cached_keys": { "type": "integer", "minimum": 0 },
"ttl_seconds": { "type": "integer", "minimum": 1 }
}
},
"session_pinning": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"ttl_seconds": { "type": "integer", "minimum": 1 },
"max_sessions": { "type": "integer", "minimum": 0 },
"wait_strategy": { "type": "string", "enum": ["block", "route_pin"] },
"max_wait_ms": { "type": "integer", "minimum": 0 }
}
},
"query_coalescing": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"window_ms": { "type": "integer", "minimum": 0 },
"max_subscribers": { "type": "integer", "minimum": 1 },
"max_pending_queries": { "type": "integer", "minimum": 1 }
}
},
"anti_entropy": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"schedule": { "type": "string" },
"shards_per_pass": { "type": "integer", "minimum": 0 },
"max_read_concurrency": { "type": "integer", "minimum": 1 },
"fingerprint_batch_size": { "type": "integer", "minimum": 1 },
"auto_repair": { "type": "boolean" },
"updated_at_field": { "type": "string" }
}
},
"resharding": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"backfill_concurrency": { "type": "integer", "minimum": 1 },
"backfill_batch_size": { "type": "integer", "minimum": 1 },
"throttle_docs_per_sec": { "type": "integer", "minimum": 0 },
"verify_before_swap": { "type": "boolean" },
"retain_old_index_hours": { "type": "integer", "minimum": 0 },
"allowed_windows": { "type": "array", "items": { "type": "string" } }
}
},
"peer_discovery": {
"type": "object",
"properties": {
"service_name": { "type": "string" },
"refresh_interval_s": { "type": "integer", "minimum": 1 }
}
},
"leader_election": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"lease_ttl_s": { "type": "integer", "minimum": 1 },
"renew_interval_s": { "type": "integer", "minimum": 1 }
{
"if": {
"properties": {
"hpa": {
"properties": {
"enabled": true
},
"required": ["enabled"]
}
},
"required": ["hpa"]
},
"then": {
"properties": {
"replicas": {"minimum": 2},
"taskStore": {
"properties": {
"backend": {"const": "redis"}
},
"required": ["backend"]
}
},
"errorMessage": "HPA requires replicas >= 2 and taskStore.backend='redis'"
}
}
}
]
},
"taskStore": {
"redis": {
"type": "object",
"properties": {
"backend": {
"type": "string",
"enum": ["sqlite", "redis"]
"enabled": {"type": "boolean"},
"image": {
"type": "object",
"properties": {
"repository": {"type": "string"},
"tag": {"type": "string"},
"pullPolicy": {"type": "string"}
}
},
"path": { "type": "string" },
"url": { "type": "string" }
},
"required": ["backend"]
},
"hpa": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"minReplicas": { "type": "integer", "minimum": 1 },
"maxReplicas": { "type": "integer", "minimum": 1 },
"targetCPUUtilizationPercentage": { "type": "integer", "minimum": 1, "maximum": 100 },
"targetMemoryUtilizationPercentage": { "type": "integer", "minimum": 1, "maximum": 100 },
"targetRequestsInFlight": { "type": "string" },
"targetBackgroundQueueDepth": { "type": "string" },
"annotations": { "type": "object" },
"behavior": { "type": "object" }
}
},
"tracing": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"endpoint": { "type": "string" },
"serviceName": { "type": "string" },
"sampleRate": { "type": "number", "minimum": 0, "maximum": 1 }
"resources": {"type": "object"},
"persistence": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"size": {"type": "string"},
"storageClass": {"type": "string"}
}
}
}
},
"serviceAccount": {
"type": "object",
"properties": {
"create": { "type": "boolean" },
"name": { "type": "string" },
"annotations": { "type": "object" }
}
},
"service": {
"type": "object",
"properties": {
"type": { "type": "string" },
"annotations": { "type": "object" },
"ports": {
"type": "object",
"properties": {
"http": { "type": "integer", "minimum": 1, "maximum": 65535 },
"metrics": { "type": "integer", "minimum": 1, "maximum": 65535 }
}
}
}
},
"serviceMonitor": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"interval": { "type": "string" },
"annotations": { "type": "object" }
}
},
"dashboards": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"annotations": { "type": "object" }
}
},
"prometheusRule": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"annotations": { "type": "object" }
}
},
"headless": {
"type": "object",
"properties": {
"annotations": { "type": "object" }
}
},
"meilisearch": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"image": {
"type": "object",
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" },
"pullPolicy": { "type": "string" }
}
},
"replicas": { "type": "integer", "minimum": 1 },
"nodesPerGroup": { "type": "integer", "minimum": 1 },
"podAnnotations": { "type": "object" },
"podLabels": { "type": "object" },
"resources": { "type": "object" },
"nodeSelector": { "type": "object" },
"tolerations": { "type": "array" },
"affinity": { "type": "object" },
"persistence": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"size": { "type": "string" },
"storageClass": { "type": "string" }
}
},
"env": { "type": "array" },
"masterKey": { "type": "string" }
}
},
"redis": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"image": {
"type": "object",
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" },
"pullPolicy": { "type": "string" }
}
},
"replicas": { "type": "integer", "minimum": 1 },
"podAnnotations": { "type": "object" },
"podLabels": { "type": "object" },
"resources": { "type": "object" },
"nodeSelector": { "type": "object" },
"tolerations": { "type": "array" },
"affinity": { "type": "object" },
"persistence": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"size": { "type": "string" },
"storageClass": { "type": "string" }
}
},
"service": {
"type": "object",
"properties": {
"type": { "type": "string" },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 }
}
},
"auth": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"existingSecret": { "type": "string" }
}
}
}
},
"search_ui": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"scoped_key_max_age_days": { "type": "integer", "minimum": 2 },
"scoped_key_rotate_before_expiry_days": { "type": "integer", "minimum": 1 },
"scoped_key_rotation_drain_s": { "type": "integer", "minimum": 10 },
"rate_limit": {
"type": "object",
"properties": {
"backend": { "type": "string", "enum": ["local", "redis"] }
}
}
}
},
"admin_ui": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"rate_limit": {
"type": "object",
"properties": {
"per_ip": { "type": "string" },
"backend": { "type": "string", "enum": ["local", "redis"] },
"failed_attempt_threshold": { "type": "integer", "minimum": 1 },
"backoff_start_minutes": { "type": "integer", "minimum": 1 },
"backoff_max_hours": { "type": "integer", "minimum": 1 }
}
}
}
},
"prometheusAdapter": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"customMetrics": { "type": "array" }
"create": {"type": "boolean"},
"annotations": {"type": "object"},
"name": {"type": "string"}
}
}
},
"allOf": [
{
"description": "Rule 0: taskStore.backend: redis requires miroir.replicas > 1 (HA mode requires multiple replicas)",
"if": {
"properties": {
"taskStore": {
"properties": {
"backend": { "const": "redis" }
},
"required": ["backend"]
}
},
"required": ["taskStore"]
},
"then": {
"properties": {
"miroir": {
"properties": {
"replicas": { "type": "integer", "exclusiveMinimum": 1 }
},
"required": ["replicas"]
}
},
"required": ["miroir"]
}
},
{
"description": "Rule 1: miroir.replicas > 1 requires taskStore.backend: redis",
"if": {
"properties": {
"miroir": {
"properties": {
"replicas": { "type": "integer", "exclusiveMinimum": 1 }
},
"required": ["replicas"]
}
},
"required": ["miroir"]
},
"then": {
"properties": {
"taskStore": {
"properties": {
"backend": {
"const": "redis"
}
},
"required": ["backend"]
}
},
"required": ["taskStore"]
}
},
{
"description": "Rule 2: hpa.enabled requires replicas >= 2 AND taskStore.backend: redis",
"if": {
"properties": {
"hpa": {
"properties": {
"enabled": { "const": true }
},
"required": ["enabled"]
}
},
"required": ["hpa"]
},
"then": {
"allOf": [
{
"properties": {
"miroir": {
"properties": {
"replicas": {
"type": "integer",
"minimum": 2
}
},
"required": ["replicas"]
}
},
"required": ["miroir"]
},
{
"properties": {
"taskStore": {
"properties": {
"backend": {
"const": "redis"
}
},
"required": ["backend"]
}
},
"required": ["taskStore"]
}
]
}
},
{
"description": "Rule 3: search_ui.rate_limit.backend must be redis when miroir.replicas > 1 (local is per-pod, not shared)",
"if": {
"properties": {
"miroir": {
"properties": {
"replicas": { "type": "integer", "exclusiveMinimum": 1 }
},
"required": ["replicas"]
}
},
"required": ["miroir"]
},
"then": {
"properties": {
"search_ui": {
"properties": {
"rate_limit": {
"properties": {
"backend": {
"enum": ["redis"]
}
}
}
}
}
}
}
},
{
"description": "Rule 4: admin_ui.rate_limit.backend must be redis when miroir.replicas > 1 (local is per-pod, not shared)",
"if": {
"properties": {
"miroir": {
"properties": {
"replicas": { "type": "integer", "exclusiveMinimum": 1 }
},
"required": ["replicas"]
}
},
"required": ["miroir"]
},
"then": {
"properties": {
"admin_ui": {
"properties": {
"rate_limit": {
"properties": {
"backend": {
"const": "redis"
}
},
"required": ["backend"]
}
},
"required": ["rate_limit"]
}
},
"required": ["admin_ui"]
}
},
{
"description": "Rule 5: scoped_key_rotate_before_expiry_days must be strictly less than scoped_key_max_age_days (enforced at render time via _helpers.tpl since JSON Schema draft-7 cannot compare sibling properties)"
},
{
"description": "Rule 6: leader_election.lease_ttl_s must be greater than leader_election.renew_interval_s (enforced at render time via _helpers.tpl since JSON Schema draft-7 cannot compare sibling properties)"
}
]
}
}

View file

@ -1,313 +1,115 @@
# Miroir Helm Chart Values
# These defaults boot a working single-pod install for evaluation and CI.
# For production, override to: replicas=2+, replicationFactor=2, replicaGroups=2,
# taskStore.backend=redis, redis.enabled=true, hpa.enabled=true
#
# SECRET INVENTORY (plan §9):
# ┌──────────────────────────┬───────────────────────────────┬─────────────────────┐
# │ Secret │ Managed by │ Rotation │
# ├──────────────────────────┼───────────────────────────────┼─────────────────────┤
# │ masterKey │ Helm / ESO │ Manual key change │
# │ nodeMasterKey │ Helm / ESO │ Zero-downtime (§9) │
# │ MEILI_MASTER_KEY │ Helm / ESO │ Pod restart only │
# │ adminApiKey │ Helm / ESO │ Manual + restart │
# │ adminSessionSealKey │ Helm / ESO │ Manual + restart │
# │ searchUiJwtSecret │ Helm / ESO │ Zero-downtime (§9) │
# │ searchUiJwtSecretPrev │ Helm / ESO │ Overlap window │
# │ searchUiSharedKey │ Helm / ESO │ Manual + restart │
# │ redis_password │ Helm / ESO │ Manual + restart │
# │ ghcr_credentials │ Argo Workflows (CI only) │ Manual │
# │ github_token │ Argo Workflows (CI only) │ Manual │
# └──────────────────────────┴───────────────────────────────┴─────────────────────┘
# ghcr_credentials and github_token are NOT in this chart — they belong to the
# Argo Workflows service account in the iad-ci cluster (see CLAUDE.md CI/CD section).
# MEILI_MASTER_KEY is set at Meilisearch process start and cannot be rotated
# without a pod restart (documented in the Meilisearch docs).
# Default values for miroir.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
miroir:
image:
repository: ghcr.io/jedarden/miroir
tag: "" # defaults to Chart.appVersion
tag: "" # Defaults to Chart.appVersion
pullPolicy: IfNotPresent
replicas: 1 # dev default: override to 2+ in production (requires taskStore.backend=redis)
# Sharding configuration
shards: 64
replicationFactor: 1 # dev default: override to 2 in production
replicaGroups: 1 # dev default: override to 2 in production
masterKey: "" # auto-generated if empty; ignored when existingSecret is set
nodeMasterKey: "" # auto-generated if empty; admin-scoped key for Meilisearch nodes (plan §9 Model B)
adminApiKey: "" # auto-generated if empty; ignored when existingSecret is set
adminSessionSealKey: "" # 64-byte base64 key for sealing admin session cookies; auto-generated if empty (plan §9)
searchUiJwtSecret: "" # HMAC secret for search UI JWTs; auto-generated when search_ui.enabled=true (plan §9)
searchUiJwtSecretPrevious: "" # Previous JWT secret during rotation overlap window (plan §9)
searchUiSharedKey: "" # Shared key for X-Search-UI-Key header when search_ui.auth.mode=shared_key (plan §9)
logLevel: info # RUST_LOG: trace, debug, info, warn, error
existingSecret: "" # name of K8s Secret with masterKey, nodeMasterKey, adminApiKey
podAnnotations: {}
podLabels: {}
# Task store backend (plan §4)
# IMPORTANT: When replicas > 1, backend MUST be "redis" (enforced by values.schema.json)
taskStore:
backend: sqlite # sqlite | redis
path: /data/miroir-tasks.db
url: "" # for redis: redis://host:6379
# Admin UI
admin:
enabled: true
# Scatter policy for unavailable shards
scatter:
unavailableShardPolicy: partial # partial | error
# Horizontal Pod Autoscaler
hpa:
enabled: false # dev default; production: true (requires taskStore.backend=redis)
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
# Ingress configuration
ingress:
enabled: false
className: "nginx"
annotations: {}
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
hosts:
- host: search.example.com
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: search-tls
# hosts:
# - search.example.com
# Resource limits (plan §14.2: 2 vCPU / 3.75 GB per pod)
resources:
limits:
cpu: 2000m
memory: 3584Mi # 3.5 GiB (leaves headroom under 3.75 GB envelope)
memory: 3840Mi
requests:
cpu: 500m
memory: 1Gi
nodeSelector: {}
tolerations: []
affinity: {}
cdc:
enabled: true
emit_ttl_deletes: false
emit_internal_writes: false
buffer:
primary: memory # memory | pvc | redis
overflow: drop # drop | pvc | redis
pvc_size: 10Gi
pvc_storage_class: ""
memory_bytes: 67108864
# §14.8 Resource-aware configuration defaults
# All defaults sized for 2 vCPU / 3.75 GB envelope
server:
max_body_bytes: 104857600 # 100 MiB per request
max_concurrent_requests: 500
request_timeout_ms: 30000
connection_pool_per_node:
max_idle: 32
max_total: 128
idle_timeout_s: 60
task_registry:
cache_size: 10000
redis_pool_max: 50
ttl_seconds: 604800 # 7 days
prune_interval_s: 300
prune_batch_size: 10000
idempotency:
enabled: true
max_cached_keys: 1000000 # ~100 MB
ttl_seconds: 86400
session_pinning:
enabled: true
ttl_seconds: 900
max_sessions: 100000 # ~50 MB
wait_strategy: block # block | route_pin
max_wait_ms: 5000
query_coalescing:
enabled: true
window_ms: 50
max_subscribers: 1000
max_pending_queries: 10000
anti_entropy:
enabled: true
schedule: every 6h
shards_per_pass: 0 # 0 = all shards
max_read_concurrency: 2
fingerprint_batch_size: 1000
auto_repair: true
updated_at_field: _miroir_updated_at
resharding:
enabled: true
backfill_concurrency: 4
backfill_batch_size: 1000
throttle_docs_per_sec: 0 # 0 = no throttle
verify_before_swap: true
retain_old_index_hours: 48
allowed_windows: [] # e.g., ["22:00-04:00 UTC"]
peer_discovery:
service_name: "" # default: "<fullname>-headless" (auto-derived from release name)
refresh_interval_s: 15
leader_election:
enabled: true # auto-true when replicas > 1
lease_ttl_s: 10
renew_interval_s: 3
cpu: 1000m
memory: 2048Mi
search_ui:
enabled: true
scoped_key_max_age_days: 60 # Maximum lifetime of a scoped Meilisearch key (days)
scoped_key_rotate_before_expiry_days: 30 # Rotate this many days before max_age (must be < max_age)
scoped_key_rotation_drain_s: 120 # Seconds to wait for all pods to observe new key before revoking old
admin_ui:
enabled: true
path: "/_miroir/admin"
auth: key # key | oauth (future) | none (dev only)
session_ttl_s: 3600 # 1 hour
read_only_mode: false
allowed_origins:
- "same-origin"
cors_allowed_origins: []
rate_limit:
per_ip: "10/minute" # Rate limit per source IP
backend: redis # redis | local (schema enforces redis when replicas > 1)
redis_key_prefix: "miroir:ratelimit:adminlogin:"
redis_ttl_s: 60
failed_attempt_threshold: 5 # Consecutive failures before exponential backoff
backoff_start_minutes: 10 # Initial backoff after threshold
backoff_max_hours: 24 # Maximum backoff cap
taskStore:
backend: sqlite # sqlite | redis
path: /data/miroir-tasks.db
url: "" # for redis: redis://host:6379
# Horizontal Pod Autoscaler (disabled by default for dev)
# Per plan §14.4, requires prometheus-adapter when custom metrics are enabled
# When HPA is enabled, prometheus-adapter is auto-enabled as a dependency
hpa:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 75
targetRequestsInFlight: "500" # type: Pods AverageValue (per-pod metric)
targetBackgroundQueueDepth: "10" # type: External Value (global metric)
annotations: {}
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 2
periodSeconds: 60
selectPolicy: Max
# ServiceAccount
serviceAccount:
create: true
name: "" # defaults to release name
annotations: {}
# Services
service:
type: ClusterIP
annotations: {}
ports:
http: 7700
metrics: 9090
headless:
annotations: {}
# Meilisearch StatefulSet
meilisearch:
enabled: true
image:
repository: getmeilisearch/meilisearch
tag: v1.12
pullPolicy: IfNotPresent
replicas: 2 # 1 group × 2 nodes (dev default)
nodesPerGroup: 2 # nodes per replica group
# Pod annotations
podAnnotations: {}
podLabels: {}
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 500m
memory: 1Gi
nodeSelector: {}
tolerations: []
affinity: {}
persistence:
enabled: true
size: 10Gi
storageClass: "" # uses default storage class
env: []
masterKey: "" # defaults to auto-generated; NOT zero-downtime rotatable (fixed at process start)
# Redis deployment (only when taskStore.backend=redis)
# Pod security context
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
# Container security context
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
# Service configuration
service:
type: ClusterIP
port: 7700
annotations: {}
# Existing secret with keys: masterKey, nodeMasterKey, adminApiKey
existingSecret: ""
# Redis deployment (when taskStore.backend=redis)
redis:
enabled: false # dev default: enable for production
enabled: false
image:
repository: redis
tag: 7.4-alpine
tag: 7-alpine
pullPolicy: IfNotPresent
replicas: 1
podAnnotations: {}
podLabels: {}
resources:
limits:
cpu: 500m
memory: 512Mi
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
nodeSelector: {}
tolerations: []
affinity: {}
persistence:
enabled: true
size: 5Gi
enabled: false
size: 1Gi
storageClass: ""
service:
type: ClusterIP
port: 6379
auth:
enabled: true
existingSecret: "" # auto-generated as <release>-redis-secret when empty; password comes from K8s Secret or ESO
# Prometheus Operator integration (plan §10 + §14.9)
serviceMonitor:
enabled: false # requires prometheus-operator in cluster
interval: 30s
# Service account
serviceAccount:
create: true
annotations: {}
prometheusRule:
enabled: false # requires prometheus-operator in cluster
annotations: {}
# Grafana dashboard ConfigMap (requires grafana-dashboard sidecar)
dashboards:
enabled: false # creates a ConfigMap labeled grafana_dashboard=1
annotations: {}
# OpenTelemetry tracing (plan §10)
tracing:
enabled: false # disabled by default for zero overhead
endpoint: "http://tempo.monitoring.svc:4317" # OTLP gRPC endpoint
serviceName: "miroir" # service name for trace identification
sampleRate: 0.1 # head-based sampling: 0.1 = ~10% of requests traced
# External Secrets Operator integration (plan §6 + §9)
# When enabled, ESO pulls secrets from OpenBao via ClusterSecretStore.
# Set miroir.existingSecret to "<release>-secret" to use the ESO-managed Secret.
eso:
enabled: false # disabled by default; enable for production with OpenBao
refreshInterval: "15m" # how often ESO re-syncs from the backend
secretStoreRef:
name: "openbao-backend" # ClusterSecretStore name in the cluster
kind: "ClusterSecretStore"
secretPath: "kv/search/miroir" # OpenBao KV v2 path
includePreviousJwt: false # set true during JWT rotation overlap window
includeSharedKey: false # set true when search_ui.auth.mode=shared_key
includeRedisPassword: false # set true when redis.auth.enabled=true
# Prometheus Adapter (plan §14.4)
# Required for HPA custom metrics (miroir_requests_in_flight, miroir_background_queue_depth).
# When HPA is enabled, prometheus-adapter is automatically installed as a dependency.
prometheusAdapter:
enabled: false # auto-enabled when hpa.enabled=true
# Prometheus adapter configuration (plan §14.4)
# Custom metrics rules for Miroir HPA
customMetrics:
# Per-pod metric: miroir_requests_in_flight (type: Pods AverageValue)
- name: miroir_requests_in_flight
metricsQuery: |
sum(mirol_requests_in_flight{<<.LabelMatchers>>}) by (<<.GroupBy>>)
type: Pods
resource:
name: miroir_requests_in_flight
container: miroir
# Global metric: miroir_background_queue_depth (type: External Value)
- name: miroir_background_queue_depth
metricsQuery: |
sum(miroir_background_queue_depth{<<.LabelMatchers>>})
type: Value
name: ""

99
coverage/html/control.js Normal file
View file

@ -0,0 +1,99 @@
function next_uncovered(selector, reverse, scroll_selector) {
function visit_element(element) {
element.classList.add("seen");
element.classList.add("selected");
if (!scroll_selector) {
scroll_selector = "tr:has(.selected) td.line-number"
}
const scroll_to = document.querySelector(scroll_selector);
if (scroll_to) {
scroll_to.scrollIntoView({behavior: "smooth", block: "center", inline: "end"});
}
}
function select_one() {
if (!reverse) {
const previously_selected = document.querySelector(".selected");
if (previously_selected) {
previously_selected.classList.remove("selected");
}
return document.querySelector(selector + ":not(.seen)");
} else {
const previously_selected = document.querySelector(".selected");
if (previously_selected) {
previously_selected.classList.remove("selected");
previously_selected.classList.remove("seen");
}
const nodes = document.querySelectorAll(selector + ".seen");
if (nodes) {
const last = nodes[nodes.length - 1]; // last
return last;
} else {
return undefined;
}
}
}
function reset_all() {
if (!reverse) {
const all_seen = document.querySelectorAll(selector + ".seen");
if (all_seen) {
all_seen.forEach(e => e.classList.remove("seen"));
}
} else {
const all_seen = document.querySelectorAll(selector + ":not(.seen)");
if (all_seen) {
all_seen.forEach(e => e.classList.add("seen"));
}
}
}
const uncovered = select_one();
if (uncovered) {
visit_element(uncovered);
} else {
reset_all();
const uncovered = select_one();
if (uncovered) {
visit_element(uncovered);
}
}
}
function next_line(reverse) {
next_uncovered("td.uncovered-line", reverse)
}
function next_region(reverse) {
next_uncovered("span.red.region", reverse);
}
function next_branch(reverse) {
next_uncovered("span.red.branch", reverse);
}
document.addEventListener("keypress", function(event) {
const reverse = event.shiftKey;
if (event.code == "KeyL") {
next_line(reverse);
}
if (event.code == "KeyB") {
next_branch(reverse);
}
if (event.code == "KeyR") {
next_region(reverse);
}
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
coverage/html/index.html Normal file

File diff suppressed because one or more lines are too long

194
coverage/html/style.css Normal file
View file

@ -0,0 +1,194 @@
.red {
background-color: #f004;
}
.cyan {
background-color: cyan;
}
html {
scroll-behavior: smooth;
}
body {
font-family: -apple-system, sans-serif;
}
pre {
margin-top: 0px !important;
margin-bottom: 0px !important;
}
.source-name-title {
padding: 5px 10px;
border-bottom: 1px solid #8888;
background-color: #0002;
line-height: 35px;
}
.centered {
display: table;
margin-left: left;
margin-right: auto;
border: 1px solid #8888;
border-radius: 3px;
}
.expansion-view {
margin-left: 0px;
margin-top: 5px;
margin-right: 5px;
margin-bottom: 5px;
border: 1px solid #8888;
border-radius: 3px;
}
table {
border-collapse: collapse;
}
.light-row {
border: 1px solid #8888;
border-left: none;
border-right: none;
}
.light-row-bold {
border: 1px solid #8888;
border-left: none;
border-right: none;
font-weight: bold;
}
.column-entry {
text-align: left;
}
.column-entry-bold {
font-weight: bold;
text-align: left;
}
.column-entry-yellow {
text-align: left;
background-color: #ff06;
}
.column-entry-red {
text-align: left;
background-color: #f004;
}
.column-entry-gray {
text-align: left;
background-color: #fff4;
}
.column-entry-green {
text-align: left;
background-color: #0f04;
}
.line-number {
text-align: right;
}
.covered-line {
text-align: right;
color: #06d;
}
.uncovered-line {
text-align: right;
color: #d00;
}
.uncovered-line.selected {
color: #f00;
font-weight: bold;
}
.region.red.selected {
background-color: #f008;
font-weight: bold;
}
.branch.red.selected {
background-color: #f008;
font-weight: bold;
}
.tooltip {
position: relative;
display: inline;
background-color: #bef;
text-decoration: none;
}
.tooltip span.tooltip-content {
position: absolute;
width: 100px;
margin-left: -50px;
color: #FFFFFF;
background: #000000;
height: 30px;
line-height: 30px;
text-align: center;
visibility: hidden;
border-radius: 6px;
}
.tooltip span.tooltip-content:after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -8px;
width: 0; height: 0;
border-top: 8px solid #000000;
border-right: 8px solid transparent;
border-left: 8px solid transparent;
}
:hover.tooltip span.tooltip-content {
visibility: visible;
opacity: 0.8;
bottom: 30px;
left: 50%;
z-index: 999;
}
th, td {
vertical-align: top;
padding: 2px 8px;
border-collapse: collapse;
border-right: 1px solid #8888;
border-left: 1px solid #8888;
text-align: left;
}
td pre {
display: inline-block;
text-decoration: inherit;
}
td:first-child {
border-left: none;
}
td:last-child {
border-right: none;
}
tr:hover {
background-color: #eee;
}
tr:last-child {
border-bottom: none;
}
tr:has(> td >a:target), tr:has(> td.uncovered-line.selected) {
background-color: #8884;
}
a {
color: inherit;
}
.control {
position: fixed;
top: 0em;
right: 0em;
padding: 1em;
background: #FFF8;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #222;
color: whitesmoke;
}
tr:hover {
background-color: #111;
}
.covered-line {
color: #39f;
}
.uncovered-line {
color: #f55;
}
.tooltip {
background-color: #068;
}
.control {
background: #2228;
}
tr:has(> td >a:target), tr:has(> td.uncovered-line.selected) {
background-color: #8884;
}
}

4593
coverage/lcov.info Normal file

File diff suppressed because it is too large Load diff

View file

@ -7,30 +7,49 @@ repository.workspace = true
autobenches = false
[dependencies]
# Core serialization
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = "0.9"
twox-hash = "2"
# Error handling and logging
thiserror = { workspace = true }
tracing = { workspace = true }
# UUIDs
uuid = { version = "1", features = ["v4", "serde"] }
# Configuration
config = "0.14"
rusqlite = { workspace = true }
futures-util = "0.3"
# Redis support (optional — enable via `redis-store` feature)
redis = { version = "0.27", features = ["aio", "tokio-comp", "connection-manager"], optional = true }
# Crypto
rand = "0.8"
sha2 = "0.10"
hex = "0.4"
# Task store backends
rusqlite = { workspace = true }
redis = { version = "0.27", features = ["aio", "tokio-comp", "connection-manager"], optional = true }
# Async runtime
tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "macros", "fs", "io-util"] }
async-trait = "0.1"
rand = "0.8"
futures-util = "0.3"
# HTTP
reqwest = { version = "0.12", features = ["json"], default-features = false }
urlencoding = "2"
sha2 = "0.10"
# Utilities
indexmap = "2"
chrono = { version = "0.4", features = ["serde"] }
regex = "1"
# DNS peer discovery (optional)
trust-dns-resolver = { version = "0.12", optional = true }
# Axum integration (optional — enable via `axum` feature)
# Axum integration (optional)
axum = { version = "0.7", optional = true }
# Raft prototype (P12.OP2 research) — not for production use
@ -45,13 +64,18 @@ redis-store = ["redis"]
axum = ["dep:axum"]
peer-discovery = ["trust-dns-resolver"]
test-helpers = []
# Enable when openraft compiles on stable Rust:
# raft-full = ["openraft", "bincode"]
# (openraft dep removed from manifest — restore when upstream fixes let_chains on stable)
[[bin]]
name = "bench-reshard-load"
path = "benches/reshard_load.rs"
[[bench]]
name = "reshard_load"
harness = false
[[bench]]
name = "score_comparability"
harness = false
[[bench]]
name = "merger_bench"
@ -68,9 +92,10 @@ harness = false
[dev-dependencies]
tempfile = "3"
proptest = "1"
criterion = "0.5"
criterion = { workspace = true }
tokio = { version = "1", features = ["rt", "macros", "time"] }
testcontainers = "0.23"
testcontainers-modules = { version = "0.11", features = ["redis"] }
mockall = "0.13"
meilisearch-sdk = { workspace = true }
arbitrary = { version = "1", features = ["derive"] }

View file

@ -101,7 +101,7 @@ fn main() {
result.old_shard_cv,
result.new_shard_cv,
);
println!(" (computed in {:.2?})", elapsed);
println!(" (computed in {elapsed:.2?})");
}
println!();
@ -111,7 +111,7 @@ fn main() {
println!();
for (label, params, result, _elapsed) in &results {
println!("--- {} ---", label);
println!("--- {label} ---");
println!(" doc_size: {} bytes", params.doc_size_bytes);
println!(
" corpus: {} bytes ({:.2} GB)",
@ -203,7 +203,7 @@ fn main() {
let cv_ok = result.old_shard_cv < 0.05 && result.new_shard_cv < 0.05;
let status = |ok| if ok { "PASS" } else { "FAIL" };
println!(" {}:", label);
println!(" {label}:");
println!(
" storage amplification == 2.0×: {} ({:.4}×)",
status(storage_ok),

View file

@ -0,0 +1,253 @@
//! Score comparability benchmark: Plan §15 OP#4 validation.
//!
//! Runs a matrix of tests to validate that `_rankingScore` values remain
//! comparable across shards with very different document-count distributions.
//!
//! For each configuration, we measure Kendall Tau correlation between
//! sharded and ground-truth (single-index) result orderings across many
//! random queries.
use miroir_core::score_comparability::{simulate, SimParams};
const THRESHOLD: f64 = 0.95;
fn main() {
println!("╔══════════════════════════════════════════════════════════════════╗");
println!("║ Score Comparability Benchmark — Plan §15 OP#4 Validation ║");
println!("╚══════════════════════════════════════════════════════════════════╝");
println!();
let test_matrix: Vec<(&str, SimParams)> = vec![
(
"Baseline: uniform distribution, small corpus",
SimParams {
total_docs: 10_000,
shard_count: 8,
skew_factor: 1.0, // Uniform
num_queries: 1000,
top_k: 20,
seed: 42,
},
),
(
"Moderate skew (10×), medium corpus",
SimParams {
total_docs: 100_000,
shard_count: 16,
skew_factor: 10.0,
num_queries: 1000,
top_k: 20,
seed: 42,
},
),
(
"High skew (100×), large corpus",
SimParams {
total_docs: 1_000_000,
shard_count: 32,
skew_factor: 100.0,
num_queries: 1000,
top_k: 20,
seed: 42,
},
),
(
"Extreme skew (1000×), many shards",
SimParams {
total_docs: 500_000,
shard_count: 64,
skew_factor: 1000.0,
num_queries: 1000,
top_k: 20,
seed: 42,
},
),
(
"Worst case: tiny sparse shards (0.01× median)",
SimParams {
total_docs: 200_000,
shard_count: 32,
skew_factor: 10000.0,
num_queries: 1000,
top_k: 20,
seed: 42,
},
),
];
let mut results = Vec::new();
for (label, params) in &test_matrix {
println!("Running: {label}");
let start = std::time::Instant::now();
let result = simulate(params);
let elapsed = start.elapsed();
results.push((label, params.clone(), result, elapsed));
println!(" Completed in {elapsed:.2?}");
println!();
}
// Print summary table.
println!("═══════════════════════════════════════════════════════════════════");
println!("Summary Table");
println!("═══════════════════════════════════════════════════════════════════");
println!();
println!(
"{:<45} {:>10} {:>10} {:>8} {:>8} {:>8} {:>8} {:>10}",
"Scenario", "Docs", "Shards", "Skew", "PopCV", "Meanτ", "Stdτ", "%≥0.95"
);
println!("{}", "-".repeat(120));
for (label, params, result, _elapsed) in &results {
let docs_str = format!("{:.1}M", params.total_docs as f64 / 1_000_000.0);
let skew_str = format!("{:.0}×", params.skew_factor);
let cv_str = format!("{:.2}%", result.aggregate.shard_pop_cv * 100.0);
println!(
"{:<45} {:>10} {:>10} {:>8} {:>8} {:>8.3} {:>8.3} {:>10.1}%",
label,
docs_str,
params.shard_count,
skew_str,
cv_str,
result.aggregate.mean_kendall_tau,
result.aggregate.std_kendall_tau,
result.aggregate.percent_above_threshold
);
}
println!();
println!("═══════════════════════════════════════════════════════════════════");
println!("Detailed Results (JSON)");
println!("═══════════════════════════════════════════════════════════════════");
println!();
for (label, params, result, elapsed) in &results {
println!("--- {label} ---");
println!(" configuration:");
println!(" total_docs: {}", params.total_docs);
println!(" shard_count: {}", params.shard_count);
println!(" skew_factor: {:.0}×", params.skew_factor);
println!(" num_queries: {}", params.num_queries);
println!(" top_k: {}", params.top_k);
println!();
println!(" shard population:");
println!(
" cv: {:.4} ({:.2}%)",
result.aggregate.shard_pop_cv,
result.aggregate.shard_pop_cv * 100.0
);
println!(
" max/median ratio: {:.2}×",
result.aggregate.shard_pop_ratio
);
println!(" per-shard counts: {:?}", result.shard_doc_counts);
println!();
println!(" kendall tau:");
println!(
" mean: {:.4} ± {:.4}",
result.aggregate.mean_kendall_tau, result.aggregate.std_kendall_tau
);
println!(
" min: {:.4}, max: {:.4}",
result.aggregate.min_kendall_tau, result.aggregate.max_kendall_tau
);
println!(
" ≥ {}: {:.1}%",
THRESHOLD, result.aggregate.percent_above_threshold
);
println!();
println!(" jaccard similarity:");
println!(" mean: {:.4}", result.aggregate.mean_jaccard);
println!();
println!(" first divergence:");
println!(
" mean position: {:.1}",
result.aggregate.mean_first_divergence
);
println!();
println!(" computed in: {elapsed:.2?}");
println!();
}
// Print worst-case queries (those with lowest Kendall Tau).
println!("═══════════════════════════════════════════════════════════════════");
println!("Worst-Case Queries (lowest τ)");
println!("═══════════════════════════════════════════════════════════════════");
println!();
for (label, _params, result, _elapsed) in &results {
let mut worst_queries: Vec<_> = result.query_results.iter().collect();
worst_queries.sort_by(|a, b| a.kendall_tau.partial_cmp(&b.kendall_tau).unwrap());
println!("--- {label} ---");
for qr in worst_queries.iter().take(5) {
println!(
" Query {}: τ={:.4}, Jaccard={:.4}, first_div={}",
qr.query_id, qr.kendall_tau, qr.jaccard_similarity, qr.first_divergence_position
);
println!(" Shard stats:");
for stat in &qr.shard_score_stats {
println!(
" Shard {}: {} docs, {} hits, score range [{:.3}, {:.3}]",
stat.shard_id, stat.doc_count, stat.hit_count, stat.min_score, stat.max_score
);
}
}
println!();
}
// Validate threshold.
println!("═══════════════════════════════════════════════════════════════════");
println!("Threshold Validation (τ ≥ {THRESHOLD:.2})");
println!("═══════════════════════════════════════════════════════════════════");
println!();
let mut all_pass = true;
for (label, _params, result, _) in &results {
let passes = result.aggregate.mean_kendall_tau >= THRESHOLD;
let status = if passes { "PASS" } else { "FAIL" };
println!(
" [{}] {}: mean τ = {:.4}",
status, label, result.aggregate.mean_kendall_tau
);
if !passes {
all_pass = false;
}
}
println!();
if all_pass {
println!("All scenarios PASSED the τ ≥ {THRESHOLD:.2} threshold.");
println!("Score comparability is maintained across shard population skew.");
} else {
println!("Some scenarios FAILED the τ ≥ {THRESHOLD:.2} threshold.");
println!("Score normalization may be needed for skewed shard distributions.");
}
// Generate JSON output for programmatic consumption.
println!();
println!("═══════════════════════════════════════════════════════════════════");
println!("JSON Output");
println!("═══════════════════════════════════════════════════════════════════");
println!();
let json_output: Vec<serde_json::Value> = results
.iter()
.map(|(label, _params, result, _elapsed)| {
serde_json::json!({
"scenario": label,
"aggregate": result.aggregate,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&json_output).unwrap());
// Exit with appropriate code.
if !all_pass {
std::process::exit(1);
}
}

View file

@ -69,3 +69,127 @@ pub fn from_yaml(yaml: &str) -> Result<MiroirConfig, ConfigError> {
cfg.validate()?;
Ok(cfg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_yaml_valid_config() {
let yaml = r#"
shards: 32
replication_factor: 1
cdc:
enabled: false
search_ui:
rate_limit:
backend: local
nodes: []
"#;
let cfg = from_yaml(yaml).expect("should parse valid config");
assert_eq!(cfg.shards, 32);
assert_eq!(cfg.replication_factor, 1);
}
#[test]
fn test_from_yaml_with_nodes() {
let yaml = r#"
shards: 64
replication_factor: 1
replica_groups: 2
task_store:
backend: redis
url: "redis://localhost:6379"
nodes:
- id: "node1"
address: "http://node1:7700"
replica_group: 0
- id: "node2"
address: "http://node2:7700"
replica_group: 1
"#;
let cfg = from_yaml(yaml).expect("should parse config with nodes");
assert_eq!(cfg.nodes.len(), 2);
assert_eq!(cfg.nodes[0].id, "node1");
assert_eq!(cfg.nodes[1].replica_group, 1);
}
#[test]
fn test_from_yaml_invalid_yaml_fails() {
let yaml = r#"
shards: 32
replication_factor: invalid
nodes: []
"#;
let result = from_yaml(yaml);
assert!(result.is_err(), "should fail on invalid YAML");
}
#[test]
fn test_from_yaml_validation_fails_on_ha_with_sqlite() {
let yaml = r#"
shards: 64
replication_factor: 2
nodes: []
"#;
let result = from_yaml(yaml);
assert!(result.is_err(), "should fail validation: RF=2 requires redis");
}
#[test]
fn test_from_yaml_validation_fails_on_zero_shards() {
let yaml = r#"
shards: 0
replication_factor: 1
nodes: []
"#;
let result = from_yaml(yaml);
assert!(result.is_err(), "should fail validation: zero shards");
}
#[test]
fn test_from_yaml_with_all_sections() {
let yaml = r#"
shards: 64
replication_factor: 1
replica_groups: 2
master_key: "test-key"
node_master_key: "node-key"
nodes:
- id: "node1"
address: "http://node1:7700"
replica_group: 0
task_store:
backend: redis
url: "redis://localhost:6379"
admin:
enabled: true
api_key: "admin-key"
health:
interval_ms: 5000
timeout_ms: 2000
unhealthy_threshold: 3
recovery_threshold: 2
scatter:
node_timeout_ms: 5000
retry_on_timeout: true
unavailable_shard_policy: partial
rebalancer:
auto_rebalance_on_recovery: true
max_concurrent_migrations: 4
migration_timeout_s: 3600
server:
port: 7700
bind: "0.0.0.0"
max_body_bytes: 104857600
leader_election:
enabled: true
"#;
let cfg = from_yaml(yaml).expect("should parse full config");
assert_eq!(cfg.shards, 64);
assert_eq!(cfg.master_key, "test-key");
assert_eq!(cfg.admin.api_key, "admin-key");
assert_eq!(cfg.health.interval_ms, 5000);
assert_eq!(cfg.scatter.node_timeout_ms, 5000);
}
}

View file

@ -8,14 +8,6 @@ pub fn validate(cfg: &MiroirConfig) -> Result<(), ConfigError> {
));
}
// replica_groups > 1 requires redis backend
if cfg.replica_groups > 1 && cfg.task_store.backend == "sqlite" {
return Err(ConfigError::Validation(
"replica_groups > 1 requires task_store.backend = 'redis' (SQLite is single-writer)"
.into(),
));
}
// Nodes must belong to a valid replica group
if cfg.replica_groups > 0 {
for node in &cfg.nodes {
@ -55,8 +47,7 @@ pub fn validate(cfg: &MiroirConfig) -> Result<(), ConfigError> {
let rotate_before = cfg.search_ui.scoped_key_rotate_before_expiry_days;
if rotate_before >= max_age {
return Err(ConfigError::Validation(format!(
"search_ui.scoped_key_rotate_before_expiry_days ({}) must be strictly less than scoped_key_max_age_days ({})",
rotate_before, max_age
"search_ui.scoped_key_rotate_before_expiry_days ({rotate_before}) must be strictly less than scoped_key_max_age_days ({max_age})"
)));
}
}
@ -78,11 +69,18 @@ pub fn validate(cfg: &MiroirConfig) -> Result<(), ConfigError> {
));
}
// Leader election should be enabled when replica_groups > 1
if cfg.replica_groups > 1 && !cfg.leader_election.enabled {
return Err(ConfigError::Validation(
"leader_election.enabled must be true when replica_groups > 1".into(),
));
// replica_groups > 1 requires both redis backend and leader election
if cfg.replica_groups > 1 {
if cfg.task_store.backend != "redis" {
return Err(ConfigError::Validation(
"replica_groups > 1 requires task_store.backend = 'redis' (SQLite is single-writer)".into(),
));
}
if !cfg.leader_election.enabled {
return Err(ConfigError::Validation(
"leader_election.enabled must be true when replica_groups > 1".into(),
));
}
}
// Tenant affinity dedicated_groups must be within valid range

View file

@ -194,6 +194,11 @@ impl HedgingManager {
pub fn should_hedge(&self, elapsed: Duration, deadline: Duration) -> bool {
elapsed >= deadline
}
/// Get configuration.
pub fn config(&self) -> &HedgingConfig {
&self.config
}
}
impl Default for HedgingManager {
@ -216,7 +221,6 @@ pub enum HedgeOutcome {
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_config_default() {

View file

@ -44,6 +44,7 @@ pub mod router;
pub mod scatter;
pub mod schema_migrations;
pub mod scoped_key_rotation;
pub mod score_comparability;
pub mod session_pinning;
pub mod settings;
pub mod shadow;
@ -55,8 +56,9 @@ pub mod tenant;
pub mod topology;
pub mod ttl;
#[cfg(feature = "raft-proto")]
pub mod raft_proto;
// Raft prototype temporarily disabled (openraft 0.9.22 fails on Rust 1.87)
// #[cfg(feature = "raft-proto")]
// pub mod raft_proto;
// Public re-exports
pub use api_error::{ErrorType, MeilisearchError, MiroirCode};

Some files were not shown because too many files have changed in this diff Show more