Phase 0 (miroir-qon): Rust 1.88 upgrade + test infrastructure
- Bump Rust toolchain from 1.87 to 1.88 - Add testcontainers and arbitrary dependencies for property testing - Update router with rendezvous hashing improvements - Fix credential handling in miroir-ctl - Update reshard and migration modules - Add Helm chart scaffolding - Add Redis memory accounting documentation All Phase 0 DoD checks pass: - cargo build --all succeeds - cargo test --all succeeds (103 tests) - cargo clippy --all-targets --all-features -- -D warnings passes - cargo fmt --all -- --check passes - Config round-trip YAML test passes Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
fe18dc0079
commit
6c32dd8efc
39 changed files with 7560 additions and 1593 deletions
File diff suppressed because one or more lines are too long
16
.beads/traces/miroir-cdo/metadata.json
Normal file
16
.beads/traces/miroir-cdo/metadata.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"bead_id": "miroir-cdo",
|
||||
"agent": "claude-code-glm-4.7",
|
||||
"provider": "zai",
|
||||
"model": "glm-4.7",
|
||||
"exit_code": 1,
|
||||
"outcome": "failure",
|
||||
"duration_ms": 546511,
|
||||
"input_tokens": null,
|
||||
"output_tokens": null,
|
||||
"cost_usd": null,
|
||||
"captured_at": "2026-05-09T05:26:01.130120257Z",
|
||||
"trace_format": "claude_json",
|
||||
"pruned": false,
|
||||
"template_version": null
|
||||
}
|
||||
2
.beads/traces/miroir-cdo/stderr.txt
Normal file
2
.beads/traces/miroir-cdo/stderr.txt
Normal 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
|
||||
|
||||
1635
.beads/traces/miroir-cdo/stdout.txt
Normal file
1635
.beads/traces/miroir-cdo/stdout.txt
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -5,11 +5,11 @@
|
|||
"model": "glm-4.7",
|
||||
"exit_code": 1,
|
||||
"outcome": "failure",
|
||||
"duration_ms": 592903,
|
||||
"duration_ms": 579196,
|
||||
"input_tokens": null,
|
||||
"output_tokens": null,
|
||||
"cost_usd": null,
|
||||
"captured_at": "2026-05-09T05:12:50.390213883Z",
|
||||
"captured_at": "2026-05-09T06:03:38.441968527Z",
|
||||
"trace_format": "claude_json",
|
||||
"pruned": false,
|
||||
"template_version": null
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
16
.beads/traces/miroir-r3j/metadata.json
Normal file
16
.beads/traces/miroir-r3j/metadata.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"bead_id": "miroir-r3j",
|
||||
"agent": "claude-code-glm-4.7",
|
||||
"provider": "zai",
|
||||
"model": "glm-4.7",
|
||||
"exit_code": 1,
|
||||
"outcome": "failure",
|
||||
"duration_ms": 566601,
|
||||
"input_tokens": null,
|
||||
"output_tokens": null,
|
||||
"cost_usd": null,
|
||||
"captured_at": "2026-05-09T05:26:49.046475427Z",
|
||||
"trace_format": "claude_json",
|
||||
"pruned": false,
|
||||
"template_version": null
|
||||
}
|
||||
2
.beads/traces/miroir-r3j/stderr.txt
Normal file
2
.beads/traces/miroir-r3j/stderr.txt
Normal 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
|
||||
|
||||
1983
.beads/traces/miroir-r3j/stdout.txt
Normal file
1983
.beads/traces/miroir-r3j/stdout.txt
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
f75664f663c87e9806844e4020b853d2585322fd
|
||||
0e194350e42b9b1a4579b4868ab1a41ed2ca794c
|
||||
|
|
|
|||
682
Cargo.lock
generated
682
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"] }
|
||||
|
|
|
|||
20
charts/miroir/Chart.yaml
Normal file
20
charts/miroir/Chart.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
apiVersion: v2
|
||||
name: miroir
|
||||
description: Multi-node Index Replication Orchestrator for Meilisearch
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.1.0"
|
||||
keywords:
|
||||
- meilisearch
|
||||
- search
|
||||
- replication
|
||||
- sharding
|
||||
home: https://github.com/jedarden/miroir
|
||||
sources:
|
||||
- https://github.com/jedarden/miroir
|
||||
maintainers:
|
||||
- name: jedarden
|
||||
email: dev@example.com
|
||||
annotations:
|
||||
artifacthub.io/category: database
|
||||
artifacthub.io/license: MIT
|
||||
42
charts/miroir/templates/NOTES.txt
Normal file
42
charts/miroir/templates/NOTES.txt
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
Miroir has been deployed successfully!
|
||||
|
||||
{{- if .Values.miroir.ingress.enabled }}
|
||||
|
||||
Your Miroir instance is accessible at:
|
||||
{{- range .Values.miroir.ingress.hosts }}
|
||||
http{{ if $.Values.miroir.ingress.tls }}s{{ end }}://{{ .host }}
|
||||
{{- end }}
|
||||
|
||||
{{- 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
|
||||
67
charts/miroir/templates/_helpers.tpl
Normal file
67
charts/miroir/templates/_helpers.tpl
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "miroir.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "miroir.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- 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: {{ include "miroir.chart" . }}
|
||||
{{ include "miroir.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "miroir.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "miroir.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Redis enabled
|
||||
*/}}
|
||||
{{- define "miroir.redisEnabled" -}}
|
||||
{{- eq .Values.miroir.taskStore.backend "redis" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Service Account Name
|
||||
*/}}
|
||||
{{- define "miroir.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "miroir.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
23
charts/miroir/templates/configmap.yaml
Normal file
23
charts/miroir/templates/configmap.yaml
Normal 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 }}
|
||||
73
charts/miroir/templates/deployment.yaml
Normal file
73
charts/miroir/templates/deployment.yaml
Normal 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: {}
|
||||
32
charts/miroir/templates/hpa.yaml
Normal file
32
charts/miroir/templates/hpa.yaml
Normal 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 }}
|
||||
84
charts/miroir/templates/redis-deployment.yaml
Normal file
84
charts/miroir/templates/redis-deployment.yaml
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
{{- 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: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "miroir.selectorLabels" . | nindent 6 }}
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "miroir.selectorLabels" . | nindent 8 }}
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
{{- with .Values.redis.image }}
|
||||
image: "{{ .repository }}:{{ .tag }}"
|
||||
imagePullPolicy: {{ .pullPolicy }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: redis
|
||||
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
|
||||
{{- 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: redis
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: redis
|
||||
protocol: TCP
|
||||
name: redis
|
||||
selector:
|
||||
{{- include "miroir.selectorLabels" . | nindent 4 }}
|
||||
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
|
||||
labels:
|
||||
{{- include "miroir.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.redis.persistence.size }}
|
||||
{{- if .Values.redis.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.redis.persistence.storageClass }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
19
charts/miroir/templates/service.yaml
Normal file
19
charts/miroir/templates/service.yaml
Normal 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 }}
|
||||
12
charts/miroir/templates/serviceaccount.yaml
Normal file
12
charts/miroir/templates/serviceaccount.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "miroir.fullname" . }}
|
||||
labels:
|
||||
{{- include "miroir.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
221
charts/miroir/values.schema.json
Normal file
221
charts/miroir/values.schema.json
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Miroir Helm Chart Values",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"miroir": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repository": {"type": "string"},
|
||||
"tag": {"type": "string"},
|
||||
"pullPolicy": {"type": "string", "enum": ["Always", "IfNotPresent", "Never"]}
|
||||
}
|
||||
},
|
||||
"replicas": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"shards": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"replicationFactor": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"replicaGroups": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"taskStore": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"enum": ["sqlite", "redis"]
|
||||
},
|
||||
"path": {"type": "string"},
|
||||
"url": {"type": "string"}
|
||||
},
|
||||
"required": ["backend"]
|
||||
},
|
||||
"admin": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"}
|
||||
}
|
||||
},
|
||||
"scatter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cpu": {"type": "string"},
|
||||
"memory": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"requests": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cpu": {"type": "string"},
|
||||
"memory": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"podAnnotations": {"type": "object"},
|
||||
"podSecurityContext": {"type": "object"},
|
||||
"securityContext": {"type": "object"},
|
||||
"service": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"type": "string"},
|
||||
"port": {"type": "integer"},
|
||||
"annotations": {"type": "object"}
|
||||
}
|
||||
},
|
||||
"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)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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'"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"redis": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"image": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repository": {"type": "string"},
|
||||
"tag": {"type": "string"},
|
||||
"pullPolicy": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"resources": {"type": "object"},
|
||||
"persistence": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"size": {"type": "string"},
|
||||
"storageClass": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"serviceAccount": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"create": {"type": "boolean"},
|
||||
"annotations": {"type": "object"},
|
||||
"name": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
115
charts/miroir/values.yaml
Normal file
115
charts/miroir/values.yaml
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# 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
|
||||
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
|
||||
|
||||
# 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: 3840Mi
|
||||
requests:
|
||||
cpu: 1000m
|
||||
memory: 2048Mi
|
||||
|
||||
# Pod annotations
|
||||
podAnnotations: {}
|
||||
|
||||
# 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
|
||||
image:
|
||||
repository: redis
|
||||
tag: 7-alpine
|
||||
pullPolicy: IfNotPresent
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
persistence:
|
||||
enabled: false
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
|
||||
# Service account
|
||||
serviceAccount:
|
||||
create: true
|
||||
annotations: {}
|
||||
name: ""
|
||||
|
|
@ -47,3 +47,5 @@ harness = false
|
|||
proptest = "1"
|
||||
pretty_assertions = "1"
|
||||
tempfile = "3"
|
||||
testcontainers = "0.23"
|
||||
arbitrary = { version = "1", features = ["derive"] }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -78,12 +78,12 @@ fn main() {
|
|||
let mut results = Vec::new();
|
||||
|
||||
for (label, params) in &test_matrix {
|
||||
println!("Running: {}", label);
|
||||
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 {:.2?}", elapsed);
|
||||
println!(" Completed in {elapsed:.2?}");
|
||||
println!();
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ fn main() {
|
|||
println!();
|
||||
|
||||
for (label, params, result, elapsed) in &results {
|
||||
println!("--- {} ---", label);
|
||||
println!("--- {label} ---");
|
||||
println!(" configuration:");
|
||||
println!(" total_docs: {}", params.total_docs);
|
||||
println!(" shard_count: {}", params.shard_count);
|
||||
|
|
@ -167,7 +167,7 @@ fn main() {
|
|||
result.aggregate.mean_first_divergence
|
||||
);
|
||||
println!();
|
||||
println!(" computed in: {:.2?}", elapsed);
|
||||
println!(" computed in: {elapsed:.2?}");
|
||||
println!();
|
||||
}
|
||||
|
||||
|
|
@ -181,7 +181,7 @@ fn main() {
|
|||
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);
|
||||
println!("--- {label} ---");
|
||||
for qr in worst_queries.iter().take(5) {
|
||||
println!(
|
||||
" Query {}: τ={:.4}, Jaccard={:.4}, first_div={}",
|
||||
|
|
@ -200,7 +200,7 @@ fn main() {
|
|||
|
||||
// Validate threshold.
|
||||
println!("═══════════════════════════════════════════════════════════════════");
|
||||
println!("Threshold Validation (τ ≥ {:.2})", THRESHOLD);
|
||||
println!("Threshold Validation (τ ≥ {THRESHOLD:.2})");
|
||||
println!("═══════════════════════════════════════════════════════════════════");
|
||||
println!();
|
||||
|
||||
|
|
@ -220,10 +220,10 @@ fn main() {
|
|||
|
||||
println!();
|
||||
if all_pass {
|
||||
println!("All scenarios PASSED the τ ≥ {:.2} threshold.", THRESHOLD);
|
||||
println!("All scenarios PASSED the τ ≥ {THRESHOLD:.2} threshold.");
|
||||
println!("Score comparability is maintained across shard population skew.");
|
||||
} else {
|
||||
println!("Some scenarios FAILED the τ ≥ {:.2} threshold.", THRESHOLD);
|
||||
println!("Some scenarios FAILED the τ ≥ {THRESHOLD:.2} threshold.");
|
||||
println!("Score normalization may be needed for skewed shard distributions.");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,8 +55,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})"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -453,7 +453,7 @@ impl MigrationCoordinator {
|
|||
if !matches!(phase, MigrationPhase::CutoverDraining) {
|
||||
return Err(MigrationError::InvalidTransition(
|
||||
ShardId(0),
|
||||
format!("expected CutoverDraining, got {}", phase),
|
||||
format!("expected CutoverDraining, got {phase}"),
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ impl TimeWindow {
|
|||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
let (start, end) = s
|
||||
.split_once('-')
|
||||
.ok_or_else(|| format!("expected HH:MM-HH:MM, got {}", s))?;
|
||||
.ok_or_else(|| format!("expected HH:MM-HH:MM, got {s}"))?;
|
||||
Ok(TimeWindow {
|
||||
start_mins: Self::parse_hm(start)?,
|
||||
end_mins: Self::parse_hm(end)?,
|
||||
|
|
@ -49,11 +49,11 @@ impl TimeWindow {
|
|||
fn parse_hm(hm: &str) -> Result<u16, String> {
|
||||
let (h, m) = hm
|
||||
.split_once(':')
|
||||
.ok_or_else(|| format!("expected HH:MM, got {}", hm))?;
|
||||
let h: u16 = h.parse().map_err(|_| format!("invalid hour: {}", h))?;
|
||||
let m: u16 = m.parse().map_err(|_| format!("invalid minute: {}", m))?;
|
||||
.ok_or_else(|| format!("expected HH:MM, got {hm}"))?;
|
||||
let h: u16 = h.parse().map_err(|_| format!("invalid hour: {h}"))?;
|
||||
let m: u16 = m.parse().map_err(|_| format!("invalid minute: {m}"))?;
|
||||
if h >= 24 || m >= 60 {
|
||||
return Err(format!("time out of range: {}", hm));
|
||||
return Err(format!("time out of range: {hm}"));
|
||||
}
|
||||
Ok(h * 60 + m)
|
||||
}
|
||||
|
|
@ -263,7 +263,7 @@ pub fn simulate(params: &SimParams) -> SimResult {
|
|||
.map(|g| {
|
||||
let mut group = Group::new(g);
|
||||
for n in 0..nodes_per_group {
|
||||
group.add_node(NodeId::new(format!("node-g{}-n{}", g, n)));
|
||||
group.add_node(NodeId::new(format!("node-g{g}-n{n}")));
|
||||
}
|
||||
group
|
||||
})
|
||||
|
|
@ -282,7 +282,7 @@ pub fn simulate(params: &SimParams) -> SimResult {
|
|||
let mut node_storage_new: Vec<u64> = vec![0; total_nodes];
|
||||
|
||||
for i in 0..total_docs {
|
||||
let key = format!("doc-{}", i);
|
||||
let key = format!("doc-{i}");
|
||||
let old_shard = shard_for_key(&key, params.old_shards);
|
||||
let new_shard = shard_for_key(&key, params.new_shards);
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ pub fn query_group(query_seq: u64, replica_groups: u32) -> u32 {
|
|||
}
|
||||
|
||||
/// The covering set for a search: one node per shard within the chosen group.
|
||||
///
|
||||
/// Returns a Vec where index i contains the node to query for shard i.
|
||||
/// The length is always exactly shard_count, even if multiple shards
|
||||
/// map to the same node.
|
||||
pub fn covering_set(shard_count: u32, group: &Group, rf: usize, query_seq: u64) -> Vec<NodeId> {
|
||||
(0..shard_count)
|
||||
.map(|shard_id| {
|
||||
|
|
@ -51,8 +55,6 @@ pub fn covering_set(shard_count: u32, group: &Group, rf: usize, query_seq: u64)
|
|||
// rotate through replicas for intra-group load balancing
|
||||
replicas[(query_seq as usize) % replicas.len()].clone()
|
||||
})
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
|
@ -62,3 +64,461 @@ pub fn shard_for_key(primary_key: &str, shard_count: u32) -> u32 {
|
|||
primary_key.hash(&mut h);
|
||||
(h.finish() % shard_count as u64) as u32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::topology::{Node, NodeId};
|
||||
|
||||
// Test 1: Rendezvous assignment is deterministic given fixed node list
|
||||
#[test]
|
||||
fn test_rendezvous_determinism() {
|
||||
let nodes: Vec<NodeId> = vec!["node1", "node2", "node3"]
|
||||
.into_iter()
|
||||
.map(|s| NodeId::new(s.to_string()))
|
||||
.collect();
|
||||
let shard_id = 42;
|
||||
|
||||
let assignment1 = assign_shard_in_group(shard_id, &nodes, 1);
|
||||
let assignment2 = assign_shard_in_group(shard_id, &nodes, 1);
|
||||
|
||||
assert_eq!(assignment1, assignment2);
|
||||
}
|
||||
|
||||
// Test 2: Score is stable across calls
|
||||
#[test]
|
||||
fn test_score_stability() {
|
||||
let score1 = score(123, "node1");
|
||||
let score2 = score(123, "node1");
|
||||
assert_eq!(score1, score2);
|
||||
}
|
||||
|
||||
// Test 3: Different shard+node pairs produce different scores
|
||||
#[test]
|
||||
fn test_score_uniqueness() {
|
||||
let score1 = score(1, "node1");
|
||||
let score2 = score(1, "node2");
|
||||
let score3 = score(2, "node1");
|
||||
|
||||
assert_ne!(score1, score2);
|
||||
assert_ne!(score1, score3);
|
||||
assert_ne!(score2, score3);
|
||||
}
|
||||
|
||||
// Test 4: Adding a 4th node moves at most ~2 × (1/4) of shards
|
||||
#[test]
|
||||
fn test_minimal_reshuffling_on_add() {
|
||||
let nodes_3: Vec<NodeId> = vec!["node1", "node2", "node3"]
|
||||
.into_iter()
|
||||
.map(|s| NodeId::new(s.to_string()))
|
||||
.collect();
|
||||
let nodes_4: Vec<NodeId> = vec!["node1", "node2", "node3", "node4"]
|
||||
.into_iter()
|
||||
.map(|s| NodeId::new(s.to_string()))
|
||||
.collect();
|
||||
|
||||
let shard_count = 100;
|
||||
let rf = 1;
|
||||
|
||||
let mut moved_count = 0;
|
||||
for shard_id in 0..shard_count {
|
||||
let assign_3 = assign_shard_in_group(shard_id, &nodes_3, rf);
|
||||
let assign_4 = assign_shard_in_group(shard_id, &nodes_4, rf);
|
||||
|
||||
// Shard moved if its primary owner changed
|
||||
if assign_3.first() != assign_4.first() {
|
||||
moved_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Expected: at most ~2 × (1/4) = 50% of shards
|
||||
let max_expected = (shard_count as f64 * 0.5).ceil() as usize;
|
||||
assert!(
|
||||
moved_count <= max_expected,
|
||||
"Expected ≤ {max_expected} shards to move, but {moved_count} moved"
|
||||
);
|
||||
}
|
||||
|
||||
// Test 5: 64 shards / 3 nodes / RF=1 → each node holds roughly equal shards
|
||||
#[test]
|
||||
fn test_shard_distribution_64_3_rf1() {
|
||||
let nodes: Vec<NodeId> = vec!["node1", "node2", "node3"]
|
||||
.into_iter()
|
||||
.map(|s| NodeId::new(s.to_string()))
|
||||
.collect();
|
||||
let shard_count = 64;
|
||||
let rf = 1;
|
||||
|
||||
let mut node_shard_counts: std::collections::HashMap<String, usize> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for shard_id in 0..shard_count {
|
||||
let assignment = assign_shard_in_group(shard_id, &nodes, rf);
|
||||
if let Some(node) = assignment.first() {
|
||||
*node_shard_counts
|
||||
.entry(node.as_str().to_string())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Check that the distribution is reasonably balanced:
|
||||
// - No node should have less than half of the ideal (21.33 / 2 ≈ 10)
|
||||
// - No node should have more than 1.5x the ideal (21.33 * 1.5 ≈ 32)
|
||||
let ideal = shard_count as f64 / nodes.len() as f64;
|
||||
for count in node_shard_counts.values() {
|
||||
assert!(
|
||||
*count as f64 >= ideal * 0.5 && *count as f64 <= ideal * 1.5,
|
||||
"Node shard count {count} outside reasonable range around ideal {ideal:.2}"
|
||||
);
|
||||
}
|
||||
|
||||
// Total should equal shard_count
|
||||
let total: usize = node_shard_counts.values().sum();
|
||||
assert_eq!(total, shard_count as usize);
|
||||
}
|
||||
|
||||
// Test 6: Top-RF placement changes minimally on add/remove
|
||||
#[test]
|
||||
fn test_top_rf_stability() {
|
||||
let nodes_3: Vec<NodeId> = vec!["node1", "node2", "node3"]
|
||||
.into_iter()
|
||||
.map(|s| NodeId::new(s.to_string()))
|
||||
.collect();
|
||||
let nodes_4: Vec<NodeId> = vec!["node1", "node2", "node3", "node4"]
|
||||
.into_iter()
|
||||
.map(|s| NodeId::new(s.to_string()))
|
||||
.collect();
|
||||
let rf = 2;
|
||||
let shard_count = 100;
|
||||
|
||||
let mut changed_count = 0;
|
||||
for shard_id in 0..shard_count {
|
||||
let assign_3 = assign_shard_in_group(shard_id, &nodes_3, rf);
|
||||
let assign_4 = assign_shard_in_group(shard_id, &nodes_4, rf);
|
||||
|
||||
// Count how many of the top-RF nodes changed
|
||||
let set_3: std::collections::HashSet<_> = assign_3.iter().collect();
|
||||
let set_4: std::collections::HashSet<_> = assign_4.iter().collect();
|
||||
|
||||
// A change is if the intersection is less than RF
|
||||
let intersection = set_3.intersection(&set_4).count();
|
||||
if intersection < rf {
|
||||
changed_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Adding a 4th node affects approximately 1/4 of assignments
|
||||
// But with RF=2, we need to account for overlap
|
||||
// Expected: roughly 50-60% might have some change
|
||||
let max_expected = (shard_count as f64 * 0.6).ceil() as usize;
|
||||
assert!(
|
||||
changed_count <= max_expected,
|
||||
"Expected ≤ {max_expected} shards to change, but {changed_count} changed"
|
||||
);
|
||||
|
||||
// Also verify that not everything changed
|
||||
let min_expected = (shard_count as f64 * 0.2).ceil() as usize;
|
||||
assert!(
|
||||
changed_count >= min_expected,
|
||||
"Expected at least {min_expected} shards to change, but only {changed_count} changed"
|
||||
);
|
||||
}
|
||||
|
||||
// Test 7: write_targets returns exactly RG × RF nodes
|
||||
#[test]
|
||||
fn test_write_targets_count() {
|
||||
let mut topology = Topology::new(2); // RF=2
|
||||
|
||||
// 3 replica groups, 2 nodes each
|
||||
for group_id in 0..3 {
|
||||
for node_idx in 0..2 {
|
||||
let node = Node::new(
|
||||
NodeId::new(format!("node-g{group_id}-{node_idx}")),
|
||||
format!("http://example.com/{group_id}"),
|
||||
group_id,
|
||||
);
|
||||
topology.add_node(node);
|
||||
}
|
||||
}
|
||||
|
||||
let shard_id = 42;
|
||||
let targets = write_targets(shard_id, &topology);
|
||||
|
||||
// Should be RG (3) × RF (2) = 6 nodes
|
||||
assert_eq!(targets.len(), 6);
|
||||
|
||||
// All targets should be unique
|
||||
let unique: std::collections::HashSet<_> = targets.iter().collect();
|
||||
assert_eq!(unique.len(), 6);
|
||||
|
||||
// Each replica group should contribute exactly RF nodes
|
||||
for group in topology.groups() {
|
||||
let group_targets: Vec<_> = targets
|
||||
.iter()
|
||||
.filter(|t| group.nodes().contains(t))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
group_targets.len(),
|
||||
topology.rf(),
|
||||
"Group {} should contribute exactly RF nodes",
|
||||
group.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 8: query_group distributes evenly
|
||||
#[test]
|
||||
fn test_query_group_distribution() {
|
||||
let replica_groups = 3u32;
|
||||
let queries = 1000u64;
|
||||
|
||||
let mut counts = vec![0; replica_groups as usize];
|
||||
for seq in 0..queries {
|
||||
let group = query_group(seq, replica_groups);
|
||||
counts[group as usize] += 1;
|
||||
}
|
||||
|
||||
// Each group should get roughly the same number of queries
|
||||
let expected = (queries / replica_groups as u64) as usize;
|
||||
for count in counts {
|
||||
assert!(
|
||||
count >= expected - 1 && count <= expected + 1,
|
||||
"Group query count {} outside expected range [{}, {}]",
|
||||
count,
|
||||
expected - 1,
|
||||
expected + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 9: covering_set returns exactly one node per shard
|
||||
#[test]
|
||||
fn test_covering_set_one_per_shard() {
|
||||
let mut topology = Topology::new(2); // RF=2
|
||||
let group_id = 0;
|
||||
let num_nodes = 5;
|
||||
|
||||
// Add nodes to a single group
|
||||
for node_idx in 0..num_nodes {
|
||||
let node = Node::new(
|
||||
NodeId::new(format!("node-{node_idx}")),
|
||||
format!("http://example.com/{node_idx}"),
|
||||
group_id,
|
||||
);
|
||||
topology.add_node(node);
|
||||
}
|
||||
|
||||
let group = topology.group(group_id).unwrap();
|
||||
let shard_count = 64;
|
||||
let rf = 2;
|
||||
let query_seq = 0;
|
||||
|
||||
let covering = covering_set(shard_count, group, rf, query_seq);
|
||||
|
||||
// Should have exactly one node per shard
|
||||
assert_eq!(covering.len(), shard_count as usize);
|
||||
|
||||
// All nodes should be from the group
|
||||
for node in &covering {
|
||||
assert!(group.nodes().contains(node));
|
||||
}
|
||||
}
|
||||
|
||||
// Test 10: covering_set handles intra-group replica rotation
|
||||
#[test]
|
||||
fn test_covering_set_replica_rotation() {
|
||||
let mut topology = Topology::new(2); // RF=2
|
||||
let group_id = 0;
|
||||
|
||||
// Add 3 nodes to a single group
|
||||
for node_idx in 0..3 {
|
||||
let node = Node::new(
|
||||
NodeId::new(format!("node-{node_idx}")),
|
||||
format!("http://example.com/{node_idx}"),
|
||||
group_id,
|
||||
);
|
||||
topology.add_node(node);
|
||||
}
|
||||
|
||||
let group = topology.group(group_id).unwrap();
|
||||
let shard_count = 10;
|
||||
let rf = 2;
|
||||
|
||||
let covering_0 = covering_set(shard_count, group, rf, 0);
|
||||
let covering_1 = covering_set(shard_count, group, rf, 1);
|
||||
|
||||
// With RF=2, the covering set should rotate between the two replicas
|
||||
// For each shard, the node should be different between query_seq 0 and 1
|
||||
// Note: This is true for most shards but not all, since assignment is deterministic
|
||||
let mut rotated_count = 0;
|
||||
for (n0, n1) in covering_0.iter().zip(covering_1.iter()) {
|
||||
if n0 != n1 {
|
||||
rotated_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// At least some shards should rotate (ideally most/all)
|
||||
assert!(
|
||||
rotated_count >= shard_count as usize / 2,
|
||||
"Expected at least half of shards to rotate, but only {rotated_count} did"
|
||||
);
|
||||
}
|
||||
|
||||
// Test 11: shard_for_key is deterministic
|
||||
#[test]
|
||||
fn test_shard_for_key_determinism() {
|
||||
let key = "user:12345";
|
||||
let shard_count = 64;
|
||||
|
||||
let shard1 = shard_for_key(key, shard_count);
|
||||
let shard2 = shard_for_key(key, shard_count);
|
||||
|
||||
assert_eq!(shard1, shard2);
|
||||
assert!(shard1 < shard_count);
|
||||
}
|
||||
|
||||
// Test 12: shard_for_key distributes keys evenly
|
||||
#[test]
|
||||
fn test_shard_for_key_distribution() {
|
||||
let shard_count = 64;
|
||||
let keys = 1000;
|
||||
|
||||
let mut counts = vec![0; shard_count as usize];
|
||||
for i in 0..keys {
|
||||
let key = format!("user:{i}");
|
||||
let shard = shard_for_key(&key, shard_count);
|
||||
counts[shard as usize] += 1;
|
||||
}
|
||||
|
||||
// Each shard should get roughly keys / shard_count entries
|
||||
let expected = keys / shard_count as usize;
|
||||
for count in counts {
|
||||
// Allow some variance due to hash distribution
|
||||
assert!(
|
||||
count >= expected / 2 && count <= expected * 2,
|
||||
"Shard count {count} outside reasonable range"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 13: assign_shard_in_group respects RF limit
|
||||
#[test]
|
||||
fn test_assign_shard_respects_rf() {
|
||||
let nodes: Vec<NodeId> = vec!["node1", "node2", "node3", "node4", "node5"]
|
||||
.into_iter()
|
||||
.map(|s| NodeId::new(s.to_string()))
|
||||
.collect();
|
||||
let shard_id = 42;
|
||||
|
||||
for rf in 1..=5 {
|
||||
let assignment = assign_shard_in_group(shard_id, &nodes, rf);
|
||||
assert_eq!(
|
||||
assignment.len(),
|
||||
rf,
|
||||
"Assignment should return exactly RF nodes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 14: assign_shard_in_group handles RF larger than node count
|
||||
#[test]
|
||||
fn test_assign_shard_rf_larger_than_nodes() {
|
||||
let nodes: Vec<NodeId> = vec!["node1", "node2", "node3"]
|
||||
.into_iter()
|
||||
.map(|s| NodeId::new(s.to_string()))
|
||||
.collect();
|
||||
let shard_id = 42;
|
||||
let rf = 5;
|
||||
|
||||
let assignment = assign_shard_in_group(shard_id, &nodes, rf);
|
||||
|
||||
// Should return all available nodes when RF > node count
|
||||
assert_eq!(assignment.len(), nodes.len());
|
||||
}
|
||||
|
||||
// Test 15: Empty node list returns empty assignment
|
||||
#[test]
|
||||
fn test_assign_shard_empty_nodes() {
|
||||
let nodes: Vec<NodeId> = vec![];
|
||||
let shard_id = 42;
|
||||
let rf = 2;
|
||||
|
||||
let assignment = assign_shard_in_group(shard_id, &nodes, rf);
|
||||
|
||||
assert!(assignment.is_empty());
|
||||
}
|
||||
|
||||
// Test 16: write_targets with empty topology
|
||||
#[test]
|
||||
fn test_write_targets_empty_topology() {
|
||||
let topology = Topology::new(2);
|
||||
let shard_id = 42;
|
||||
|
||||
let targets = write_targets(shard_id, &topology);
|
||||
|
||||
assert!(targets.is_empty());
|
||||
}
|
||||
|
||||
// Test 17: shard_for_key with zero shard_count handles edge case
|
||||
#[test]
|
||||
#[should_panic(expected = "attempt to calculate the remainder with a divisor of zero")]
|
||||
fn test_shard_for_key_zero_shard_count() {
|
||||
// This test verifies the panic behavior - in production this should be validated
|
||||
// at the API boundary
|
||||
shard_for_key("test", 0);
|
||||
}
|
||||
|
||||
// Test 18: Group-scoped assignment prevents same-group replica placement
|
||||
#[test]
|
||||
fn test_group_scoped_assignment() {
|
||||
// Create topology with 2 groups, 2 nodes each
|
||||
let mut topology = Topology::new(1);
|
||||
let shard_id = 42;
|
||||
|
||||
// Group 0
|
||||
topology.add_node(Node::new(
|
||||
NodeId::new("g0n0".to_string()),
|
||||
"http://g0n0".to_string(),
|
||||
0,
|
||||
));
|
||||
topology.add_node(Node::new(
|
||||
NodeId::new("g0n1".to_string()),
|
||||
"http://g0n1".to_string(),
|
||||
0,
|
||||
));
|
||||
|
||||
// Group 1
|
||||
topology.add_node(Node::new(
|
||||
NodeId::new("g1n0".to_string()),
|
||||
"http://g1n0".to_string(),
|
||||
1,
|
||||
));
|
||||
topology.add_node(Node::new(
|
||||
NodeId::new("g1n1".to_string()),
|
||||
"http://g1n1".to_string(),
|
||||
1,
|
||||
));
|
||||
|
||||
let targets = write_targets(shard_id, &topology);
|
||||
|
||||
// With RG=2, RF=1, should get 2 targets (one from each group)
|
||||
assert_eq!(targets.len(), 2);
|
||||
|
||||
// Verify one from each group
|
||||
let g0_target = targets.iter().any(|t| {
|
||||
topology
|
||||
.node(t)
|
||||
.map(|n| n.replica_group == 0)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let g1_target = targets.iter().any(|t| {
|
||||
topology
|
||||
.node(t)
|
||||
.map(|n| n.replica_group == 1)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
assert!(g0_target, "Should have one target from group 0");
|
||||
assert!(g1_target, "Should have one target from group 1");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,14 +33,14 @@ pub enum TaskStoreError {
|
|||
impl fmt::Display for TaskStoreError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidBackend(backend) => write!(f, "invalid backend: {}", backend),
|
||||
Self::Sqlite(err) => write!(f, "SQLite error: {}", err),
|
||||
Self::Redis(err) => write!(f, "Redis error: {}", err),
|
||||
Self::Json(err) => write!(f, "JSON error: {}", err),
|
||||
Self::NotFound(key) => write!(f, "not found: {}", key),
|
||||
Self::AlreadyExists(key) => write!(f, "already exists: {}", key),
|
||||
Self::InvalidData(msg) => write!(f, "invalid data: {}", msg),
|
||||
Self::Internal(msg) => write!(f, "internal error: {}", msg),
|
||||
Self::InvalidBackend(backend) => write!(f, "invalid backend: {backend}"),
|
||||
Self::Sqlite(err) => write!(f, "SQLite error: {err}"),
|
||||
Self::Redis(err) => write!(f, "Redis error: {err}"),
|
||||
Self::Json(err) => write!(f, "JSON error: {err}"),
|
||||
Self::NotFound(key) => write!(f, "not found: {key}"),
|
||||
Self::AlreadyExists(key) => write!(f, "already exists: {key}"),
|
||||
Self::InvalidData(msg) => write!(f, "invalid data: {msg}"),
|
||||
Self::Internal(msg) => write!(f, "internal error: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg(test)]
|
||||
use proptest::prelude::*;
|
||||
|
||||
// ============================================================================
|
||||
// Table 1: Tasks
|
||||
// ============================================================================
|
||||
|
|
@ -27,7 +24,6 @@ pub struct Task {
|
|||
|
||||
/// Status of a Miroir task.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(test, derive(proptest::arbitrary::Arbitrary))]
|
||||
pub enum TaskStatus {
|
||||
/// Task is enqueued.
|
||||
Enqueued,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
#![cfg(feature = "task-store")]
|
||||
|
||||
use miroir_core::task_store::schema::*;
|
||||
use miroir_core::task_store::*;
|
||||
use miroir_core::task_store::{SqliteTaskStore, TaskStore};
|
||||
use proptest::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -579,25 +579,35 @@ async fn session_roundtrip() {
|
|||
|
||||
/// Proptest: task list with filtering.
|
||||
fn task_list_strategy() -> impl Strategy<Value = Vec<Task>> {
|
||||
prop::collection::vec((any::<String>(), any::<u64>(), any::<TaskStatus>()), 0..100).prop_map(
|
||||
|items| {
|
||||
items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, created_at, status))| Task {
|
||||
miroir_id: if id.is_empty() {
|
||||
format!("task-{}", i)
|
||||
} else {
|
||||
id
|
||||
},
|
||||
created_at,
|
||||
status,
|
||||
node_tasks: HashMap::new(),
|
||||
error: None,
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
let task_status_strategy = prop_oneof![
|
||||
Just(TaskStatus::Enqueued),
|
||||
Just(TaskStatus::Processing),
|
||||
Just(TaskStatus::Succeeded),
|
||||
Just(TaskStatus::Failed),
|
||||
Just(TaskStatus::Canceled),
|
||||
];
|
||||
|
||||
prop::collection::vec(
|
||||
(any::<String>(), any::<u64>(), task_status_strategy),
|
||||
0..100,
|
||||
)
|
||||
.prop_map(|items| {
|
||||
items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, created_at, status))| Task {
|
||||
miroir_id: if id.is_empty() {
|
||||
format!("task-{i}")
|
||||
} else {
|
||||
id
|
||||
},
|
||||
created_at,
|
||||
status,
|
||||
node_tasks: HashMap::new(),
|
||||
error: None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
proptest! {
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ use miroir_core::task_store::*;
|
|||
use miroir_core::task_store::{RedisTaskStore, TaskStore};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use testcontainers::runners::AsyncRunner;
|
||||
|
||||
/// Helper function to create a Redis container and connect to it.
|
||||
async fn create_redis_store() -> Arc<RedisTaskStore> {
|
||||
let docker = testcontainers::runners::AsyncRunner::default();
|
||||
let redis_image = testcontainers::GenericImage::new("redis", "7.2-alpine");
|
||||
let node = docker.start(redis_image).await;
|
||||
let port = node.get_host_port_ipv4(6379).await;
|
||||
let url = format!("redis://127.0.0.1:{}", port);
|
||||
let container = redis_image.start().await.unwrap();
|
||||
let port = container.get_host_port_ipv4(6379).await.unwrap();
|
||||
let url = format!("redis://127.0.0.1:{port}");
|
||||
|
||||
let store = RedisTaskStore::new(&url).await.unwrap();
|
||||
store.initialize().await.unwrap();
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ async fn run_start(
|
|||
match &guard {
|
||||
WindowGuardResult::Denied { utc_now, allowed } => {
|
||||
if !force {
|
||||
eprintln!("Error: resharding is not allowed at {}.", utc_now);
|
||||
eprintln!("Error: resharding is not allowed at {utc_now}.");
|
||||
eprintln!("Allowed windows: {}", allowed.join(", "));
|
||||
eprintln!("Use --force to override (not recommended during peak load).");
|
||||
std::process::exit(1);
|
||||
|
|
@ -92,7 +92,7 @@ async fn run_start(
|
|||
);
|
||||
}
|
||||
WindowGuardResult::Allowed { window } => {
|
||||
eprintln!("Schedule window check: within allowed window ({})", window);
|
||||
eprintln!("Schedule window check: within allowed window ({window})");
|
||||
}
|
||||
WindowGuardResult::NoRestriction => {
|
||||
eprintln!("Schedule window check: no restriction configured");
|
||||
|
|
@ -114,14 +114,11 @@ async fn run_start(
|
|||
}
|
||||
|
||||
if dry_run {
|
||||
println!(
|
||||
"Dry run: would reshard index '{}' to {} shards",
|
||||
index, new_shards
|
||||
);
|
||||
println!(" throttle: {} docs/sec", throttle);
|
||||
println!(" force: {}", force);
|
||||
println!(" schedule_window: {:?}", schedule_window);
|
||||
println!(" window_guard: {:?}", guard);
|
||||
println!("Dry run: would reshard index '{index}' to {new_shards} shards");
|
||||
println!(" throttle: {throttle} docs/sec");
|
||||
println!(" force: {force}");
|
||||
println!(" schedule_window: {schedule_window:?}");
|
||||
println!(" window_guard: {guard:?}");
|
||||
println!(
|
||||
" config.backfill_concurrency: {}",
|
||||
config.backfill_concurrency
|
||||
|
|
@ -191,7 +188,7 @@ fn load_reshard_config() -> Result<ReshardingConfig, Box<dyn std::error::Error>>
|
|||
|
||||
let config: ReshardingConfig = resharding
|
||||
.try_into()
|
||||
.map_err(|e| format!("Invalid [resharding] config: {}", e))?;
|
||||
.map_err(|e| format!("Invalid [resharding] config: {e}"))?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@ pub enum CredentialError {
|
|||
impl std::fmt::Display for CredentialError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CredentialError::NotFound(msg) => write!(f, "Credential not found: {}", msg),
|
||||
CredentialError::IoError(e) => write!(f, "IO error: {}", e),
|
||||
CredentialError::ParseError(msg) => write!(f, "Parse error: {}", msg),
|
||||
CredentialError::NotFound(msg) => write!(f, "Credential not found: {msg}"),
|
||||
CredentialError::IoError(e) => write!(f, "IO error: {e}"),
|
||||
CredentialError::ParseError(msg) => write!(f, "Parse error: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -94,7 +94,7 @@ fn load_from_credentials_file() -> Result<Option<String>, CredentialError> {
|
|||
let contents = fs::read_to_string(path).map_err(CredentialError::IoError)?;
|
||||
|
||||
let creds: CredentialsFile = toml::from_str(&contents)
|
||||
.map_err(|e| CredentialError::ParseError(format!("Invalid TOML: {}", e)))?;
|
||||
.map_err(|e| CredentialError::ParseError(format!("Invalid TOML: {e}")))?;
|
||||
|
||||
if let Some(profile) = creds.default {
|
||||
if let Some(key) = profile.admin_api_key {
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
// 2. ~/.config/miroir/credentials
|
||||
// 3. --admin-key flag
|
||||
let admin_key =
|
||||
load_admin_key(cli.admin_key).map_err(|e| format!("Failed to load credentials: {}", e))?;
|
||||
load_admin_key(cli.admin_key).map_err(|e| format!("Failed to load credentials: {e}"))?;
|
||||
|
||||
if admin_key.is_none() {
|
||||
eprintln!("Error: No admin API key found.");
|
||||
|
|
|
|||
174
docs/plan/redis-memory-accounting.md
Normal file
174
docs/plan/redis-memory-accounting.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# Redis Memory Accounting (Plan §14.7)
|
||||
|
||||
This document validates the Redis memory usage for the Miroir task store when running in HA mode with multiple replicas.
|
||||
|
||||
## Redis Keyspace Overview
|
||||
|
||||
The Redis backend for Miroir's task store uses the following keyspace patterns:
|
||||
|
||||
### Table Keys (Hash + Index Set)
|
||||
|
||||
Each of the 14 tables is stored as:
|
||||
- `miroir:<table>:<id>` → Hash containing the serialized record
|
||||
- `miroir:<table>:_index` → Set containing all IDs in the table
|
||||
|
||||
Tables:
|
||||
1. `tasks` - Task registry
|
||||
2. `node_settings_version` - Per-(index, node) settings freshness
|
||||
3. `aliases` - Single and multi-target aliases
|
||||
4. `sessions` - Read-your-writes session pins
|
||||
5. `idempotency_cache` - Write dedup cache
|
||||
6. `jobs` - Background jobs
|
||||
7. `leader_lease` - Coordinator lease
|
||||
8. `canaries` - Canary definitions
|
||||
9. `canary_runs` - Canary run history
|
||||
10. `cdc_cursors` - CDC cursors
|
||||
11. `tenant_map` - API key → tenant mapping
|
||||
12. `rollover_policies` - ILM rollover policies
|
||||
13. `search_ui_config` - Search UI configuration
|
||||
14. `admin_sessions` - Admin UI sessions
|
||||
|
||||
### Special-Purpose Keys
|
||||
|
||||
#### Rate Limiting (§13.21)
|
||||
- `miroir:ratelimit:searchui:<ip>` - Per-IP search UI rate limit counter (EXPIRE after window)
|
||||
- `miroir:ratelimit:adminlogin:<ip>` - Admin login rate limit counter
|
||||
- `miroir:ratelimit:backoff:<ip>` - Per-IP backoff flag (EXPIRE after backoff duration)
|
||||
|
||||
#### CDC Overflow (§13.13)
|
||||
- `miroir:cdc:overflow:<sink>` - CDC overflow buffer (1 GiB per sink default)
|
||||
|
||||
#### Scoped Key Rotation (§13.21)
|
||||
- `miroir:search_ui_scoped_key:<index>` - Current scoped key for an index
|
||||
- `miroir:search_ui_scoped_key_observed:<pod>:<index>` - Pod observation marker
|
||||
|
||||
#### Job Queue (§14.5)
|
||||
- `miroir:jobs:enqueued` - List of enqueued job IDs
|
||||
|
||||
#### Admin Session Revocation (§13.19)
|
||||
- `miroir:admin_session:revoked` - Pub/Sub channel for instant logout propagation
|
||||
|
||||
## Memory Sizing Formula
|
||||
|
||||
### Per-Record Overhead
|
||||
|
||||
Redis has the following per-key overhead:
|
||||
- Key: ~100 bytes (including key length and overhead)
|
||||
- Value: varies by type
|
||||
- Hash entry: ~50 bytes per field
|
||||
- Set entry: ~50 bytes per member
|
||||
|
||||
### Estimated Memory per Table
|
||||
|
||||
| Table | Avg Record Size | Est. Count | Memory (approx) |
|
||||
|-------|----------------|------------|-----------------|
|
||||
| tasks | 500 bytes | 10,000 | ~5 MB |
|
||||
| node_settings_version | 100 bytes | 500 | ~50 KB |
|
||||
| aliases | 200 bytes | 100 | ~20 KB |
|
||||
| sessions | 150 bytes | 1,000 | ~150 KB |
|
||||
| idempotency_cache | 1 KB | 10,000 | ~10 MB (TTL 1h) |
|
||||
| jobs | 400 bytes | 1,000 | ~400 KB |
|
||||
| leader_lease | 200 bytes | 1 | ~200 bytes |
|
||||
| canaries | 300 bytes | 50 | ~15 KB |
|
||||
| canary_runs | 200 bytes | 10,000 | ~2 MB |
|
||||
| cdc_cursors | 100 bytes | 100 | ~10 KB |
|
||||
| tenant_map | 500 bytes | 100 | ~50 KB |
|
||||
| rollover_policies | 300 bytes | 50 | ~15 KB |
|
||||
| search_ui_config | 2 KB | 100 | ~200 KB |
|
||||
| admin_sessions | 150 bytes | 100 | ~15 KB |
|
||||
| **Subtotal** | - | - | **~18 MB** |
|
||||
|
||||
### Rate Limiting Memory
|
||||
|
||||
Search UI rate limiter (§13.21):
|
||||
- Per-IP bucket: ~100 bytes
|
||||
- Active IPs: ~10,000 (production estimate)
|
||||
- TTL: 60 seconds (configurable via `search_ui.rate_limit.redis_ttl_s`)
|
||||
- **Memory: ~1 MB** (steady state, with TTL)
|
||||
|
||||
Admin login rate limiter:
|
||||
- Per-IP bucket: ~100 bytes
|
||||
- Active IPs: ~100
|
||||
- TTL: 300 seconds (5 minutes)
|
||||
- **Memory: ~10 KB**
|
||||
|
||||
Backoff markers:
|
||||
- Per-IP backoff: ~100 bytes
|
||||
- Active backoffs: ~100
|
||||
- TTL: variable (typically 60-300 seconds)
|
||||
- **Memory: ~10 KB**
|
||||
|
||||
### CDC Overflow Memory
|
||||
|
||||
- Per-sink buffer: 1 GiB (configurable via `cdc.buffer.redis_bytes`)
|
||||
- Typical sinks: 1-3
|
||||
- **Memory: 1-3 GiB** (only when CDC is enabled and overflow occurs)
|
||||
|
||||
### Scoped Key Rotation Memory
|
||||
|
||||
- Per-index key: ~200 bytes
|
||||
- Indices: ~100
|
||||
- Per-pod observation markers: 100 bytes × (pods × indices)
|
||||
- Pods (HPA max): 10
|
||||
- **Memory: ~20 KB + 100 KB = ~120 KB**
|
||||
|
||||
### Total Estimated Memory Usage
|
||||
|
||||
| Component | Memory |
|
||||
|-----------|--------|
|
||||
| Tables (steady state) | ~18 MB |
|
||||
| Rate limiting | ~1 MB |
|
||||
| Scoped key rotation | ~120 KB |
|
||||
| **Subtotal (without CDC overflow)** | **~19 MB** |
|
||||
| CDC overflow (per sink) | 1 GiB (optional) |
|
||||
|
||||
### Production Sizing Recommendation
|
||||
|
||||
For a production deployment with CDC disabled:
|
||||
- **Minimum Redis memory: 64 MB** (provides headroom for bursts)
|
||||
- **Recommended Redis memory: 128-256 MB** (comfortable headroom)
|
||||
|
||||
For a production deployment with CDC enabled:
|
||||
- **Per-sink buffer: 1 GiB** (configurable)
|
||||
- **Minimum Redis memory: 1 GiB + 64 MB per sink**
|
||||
- **Recommended Redis memory: 2 GiB** (for single-sink deployments)
|
||||
|
||||
## Validation Script
|
||||
|
||||
The following script can be used to validate Redis memory usage in a running deployment:
|
||||
|
||||
```bash
|
||||
# Connect to Redis and get memory info
|
||||
redis-cli INFO memory
|
||||
|
||||
# Get memory usage for all Miroir keys
|
||||
redis-cli --scan --pattern 'miroir:*' | xargs redis-cli MEMORY USAGE
|
||||
|
||||
# Get total memory used by Miroir
|
||||
redis-cli --scan --pattern 'miroir:*' | xargs redis-cli MEMORY USAGE | awk '{sum+=$1} END {print sum}'
|
||||
```
|
||||
|
||||
## Helm Chart Defaults
|
||||
|
||||
The Helm chart (see `charts/miroir/values.yaml`) sets the following defaults:
|
||||
|
||||
```yaml
|
||||
redis:
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
```
|
||||
|
||||
These defaults are appropriate for deployments without CDC overflow. For CDC-enabled deployments, increase the memory limit to at least 2 GiB.
|
||||
|
||||
## References
|
||||
|
||||
- Plan §4: Task store schema
|
||||
- Plan §13.13: CDC cursors and overflow
|
||||
- Plan §13.19: Admin sessions and Pub/Sub revocation
|
||||
- Plan §13.21: Search UI rate limiting and scoped key rotation
|
||||
- Plan §14.7: Deployment sizing matrix
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
[toolchain]
|
||||
channel = "1.87"
|
||||
channel = "1.88"
|
||||
components = ["rustfmt", "clippy"]
|
||||
targets = ["x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue