Files
vigilcare-clinical/scripts/run-kafka-outbox-tests.sh
voltsrage 2a3ef62a7d
CI / frontend (push) Failing after 57s
CI / backend (push) Failing after 6m27s
Add deployment
2026-08-05 00:26:20 +08:00

367 lines
13 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="${COMPOSE_FILE:-${SCRIPT_DIR}/../docker-compose.yml}"
BASE_URL="${BASE_URL:-http://localhost:5270}"
PGHOST="${PGHOST:-localhost}"
PGPORT="${PGPORT:-5436}"
PGDATABASE="${PGDATABASE:-vigilcare}"
PGUSER="${PGUSER:-postgres}"
PGPASSWORD="${PGPASSWORD:-password}"
TOPIC_OBSERVATION="${TOPIC_OBSERVATION:-observation.recorded}"
TOPIC_ENCOUNTER="${TOPIC_ENCOUNTER:-encounter.status.changed}"
RELAY_WAIT_SECS="${RELAY_WAIT_SECS:-45}"
KAFKA_READY_WAIT_SECS="${KAFKA_READY_WAIT_SECS:-30}"
RECORDED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
SCRIPT_RUN_ID="$(date -u +"%Y%m%d%H%M%S")"
TMP_FILES=()
cleanup() {
local f
for f in "${TMP_FILES[@]}"; do
rm -f "${f}" "${f}.status" 2>/dev/null || true
done
}
trap cleanup EXIT
if ! command -v curl >/dev/null 2>&1; then
echo "Missing dependency: curl"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "Missing dependency: jq"
exit 1
fi
if ! command -v docker >/dev/null 2>&1 || [[ ! -f "${COMPOSE_FILE}" ]]; then
echo "Missing dependency: docker compose (${COMPOSE_FILE})"
exit 1
fi
compose() {
docker compose -f "${COMPOSE_FILE}" "$@"
}
kafka_exec() {
compose exec -T kafka "$@"
}
psql_cmd() {
local sql="$1"
if command -v psql >/dev/null 2>&1; then
PGPASSWORD="${PGPASSWORD}" psql -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
else
compose exec -T postgres psql -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
fi
}
request() {
local method="$1"
local url="$2"
local body="${3:-}"
local tmp_body
tmp_body="$(mktemp)"
TMP_FILES+=("${tmp_body}")
local status
if [[ -n "${body}" ]]; then
status="$(curl -sS -o "${tmp_body}" -w "%{http_code}" -X "${method}" "${url}" \
-H "Content-Type: application/json" -d "${body}")"
else
status="$(curl -sS -o "${tmp_body}" -w "%{http_code}" -X "${method}" "${url}")"
fi
echo "${status}" > "${tmp_body}.status"
echo "${tmp_body}"
}
assert_status() {
local expected="$1"
local body_file="$2"
local status
status="$(cat "${body_file}.status")"
if [[ "${status}" != "${expected}" ]]; then
echo "Expected HTTP ${expected}, got ${status}"
echo "Response body:"
cat "${body_file}"
echo
return 1
fi
}
wait_for_kafka() {
local elapsed=0
while (( elapsed < KAFKA_READY_WAIT_SECS )); do
if kafka_exec /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1; then
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
echo "Kafka did not become ready within ${KAFKA_READY_WAIT_SECS}s"
return 1
}
wait_for_outbox_processed() {
local outbox_id="$1"
local elapsed=0
while (( elapsed < RELAY_WAIT_SECS )); do
local processed
processed="$(psql_cmd "SELECT processed_at IS NOT NULL FROM outbox_events WHERE id = '${outbox_id}'")"
if [[ "${processed}" == "t" ]]; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "Outbox row ${outbox_id} was not processed within ${RELAY_WAIT_SECS}s"
return 1
}
wait_for_pending_outbox_count() {
local expected="$1"
local elapsed=0
while (( elapsed < RELAY_WAIT_SECS )); do
local pending
pending="$(psql_cmd "SELECT COUNT(*) FROM outbox_events WHERE processed_at IS NULL")"
if [[ "${pending}" == "${expected}" ]]; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "Expected ${expected} pending outbox row(s), timed out after ${RELAY_WAIT_SECS}s"
return 1
}
consume_topic() {
local topic="$1"
local group="$2"
local max_messages="$3"
local tmp_out
tmp_out="$(mktemp)"
TMP_FILES+=("${tmp_out}")
kafka_exec /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server localhost:9092 \
--topic "${topic}" \
--group "${group}" \
--from-beginning \
--property print.key=true \
--property print.partition=true \
--timeout-ms 15000 \
--max-messages "${max_messages}" > "${tmp_out}" 2>/dev/null || true
echo "${tmp_out}"
}
TOTAL_STEPS=11
echo "Running Kafka + outbox verification against ${BASE_URL}"
echo "Script run id: ${SCRIPT_RUN_ID}"
echo ""
echo "[0/${TOTAL_STEPS}] Preflight — API, Postgres, and Kafka reachable"
preflight_status="$(curl -sS -o /dev/null -w "%{http_code}" "${BASE_URL}/api/v1/alert-thresholds" || true)"
if [[ "${preflight_status}" != "200" ]]; then
echo "API not reachable at ${BASE_URL} (HTTP ${preflight_status})."
echo "Start the API with: dotnet run --project VigilCareClinicalAPI"
exit 1
fi
if ! psql_cmd "SELECT 1" >/dev/null 2>&1; then
echo "Postgres not reachable on ${PGHOST}:${PGPORT}."
echo "Start the stack with: docker compose up -d"
exit 1
fi
if ! wait_for_kafka; then
exit 1
fi
echo "OK: API, Postgres, and Kafka are up"
echo ""
echo "[1/${TOTAL_STEPS}] Verifying Kafka topics were provisioned"
topic_list="$(kafka_exec /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list)"
for topic in alert.generated alert.acknowledged encounter.status.changed observation.recorded; do
if ! grep -qx "${topic}" <<< "${topic_list}"; then
echo "Missing Kafka topic: ${topic}"
echo "Topics found:"
echo "${topic_list}"
exit 1
fi
done
echo "OK: observation.recorded, alert.generated, alert.acknowledged, encounter.status.changed exist"
echo ""
echo "[2/${TOTAL_STEPS}] Creating patient and two active encounters"
patient_payload='{"firstName":"Kafka","lastName":"Verifier","dateOfBirth":"1990-06-01","gender":"M"}'
resp="$(request POST "${BASE_URL}/api/v1/patients" "${patient_payload}")"
assert_status "201" "${resp}"
patient_id="$(jq -r '.data.id' "${resp}")"
enc_payload_a='{"encounterType":"Inpatient","department":"ICU","attendingPhysician":"Dr. Kafka"}'
resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${enc_payload_a}")"
assert_status "201" "${resp}"
encounter_a="$(jq -r '.data.id' "${resp}")"
enc_payload_b='{"encounterType":"Outpatient","department":"Clinic","attendingPhysician":"Dr. Kafka"}'
resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${enc_payload_b}")"
assert_status "201" "${resp}"
encounter_b="$(jq -r '.data.id' "${resp}")"
enc_payload_discharge='{"encounterType":"Emergency","department":"ED","attendingPhysician":"Dr. Kafka"}'
resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${enc_payload_discharge}")"
assert_status "201" "${resp}"
encounter_discharge="$(jq -r '.data.id' "${resp}")"
echo "OK: encounters ${encounter_a}, ${encounter_b}, ${encounter_discharge}"
echo ""
echo "[3/${TOTAL_STEPS}] Ingesting observations for partition-key verification"
for encounter_id in "${encounter_a}" "${encounter_b}"; do
for i in 1 2; do
obs_payload="$(jq -nc \
--arg recordedAt "${RECORDED_AT}" \
--arg key "kafka-script-${SCRIPT_RUN_ID}-${encounter_id}-${i}" \
'{observations:[{observationCode:"HEART_RATE",value:80,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt,idempotencyKey:$key}]}')"
resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${obs_payload}")"
assert_status "201" "${resp}"
done
done
echo "OK: four observation.recorded outbox rows written"
echo ""
echo "[4/${TOTAL_STEPS}] Waiting for relay to publish observation events"
latest_outbox="$(psql_cmd "SELECT id FROM outbox_events WHERE topic = '${TOPIC_OBSERVATION}' AND partition_key = '${encounter_a}' ORDER BY created_at DESC LIMIT 1")"
if [[ -z "${latest_outbox}" ]]; then
echo "Could not find outbox row for encounter ${encounter_a}"
exit 1
fi
wait_for_outbox_processed "${latest_outbox}"
echo "OK: relay marked observation outbox rows processed"
echo ""
echo "[5/${TOTAL_STEPS}] Verifying partition ordering (same encounter key → same partition)"
consumer_out="$(consume_topic "${TOPIC_OBSERVATION}" "partition-check-${SCRIPT_RUN_ID}" 50)"
for encounter_id in "${encounter_a}" "${encounter_b}"; do
partitions="$(grep -F "${encounter_id}" "${consumer_out}" | sed -n 's/^Partition:\([0-9]*\).*/\1/p' | sort -u)"
partition_count="$(echo "${partitions}" | grep -c . || true)"
if [[ "${partition_count}" -lt 1 ]]; then
echo "No Kafka messages found for encounter ${encounter_id}"
exit 1
fi
if [[ "${partition_count}" -gt 1 ]]; then
echo "Encounter ${encounter_id} landed on multiple partitions: ${partitions}"
exit 1
fi
done
echo "OK: each encounter's messages share a single partition"
echo ""
echo "[6/${TOTAL_STEPS}] Verifying consumer group independence"
group_a="es-indexer-${SCRIPT_RUN_ID}"
group_b="sepsis-engine-${SCRIPT_RUN_ID}"
out_a="$(consume_topic "${TOPIC_OBSERVATION}" "${group_a}" 20)"
out_b="$(consume_topic "${TOPIC_OBSERVATION}" "${group_b}" 20)"
count_a="$(grep -c '^Partition:' "${out_a}" || true)"
count_b="$(grep -c '^Partition:' "${out_b}" || true)"
if [[ "${count_a}" -lt 4 || "${count_b}" -lt 4 ]]; then
echo "Expected each consumer group to read at least 4 messages, got ${count_a} and ${count_b}"
exit 1
fi
offset_a="$(kafka_exec /opt/kafka/bin/kafka-consumer-groups.sh \
--bootstrap-server localhost:9092 \
--describe --group "${group_a}" 2>/dev/null | awk '/observation.recorded/ {sum += $4} END {print sum+0}')"
offset_b="$(kafka_exec /opt/kafka/bin/kafka-consumer-groups.sh \
--bootstrap-server localhost:9092 \
--describe --group "${group_b}" 2>/dev/null | awk '/observation.recorded/ {sum += $4} END {print sum+0}')"
if [[ "${offset_a}" -lt 4 || "${offset_b}" -lt 4 ]]; then
echo "Consumer groups did not commit expected offsets (es-indexer=${offset_a}, sepsis-engine=${offset_b})"
exit 1
fi
echo "OK: two consumer groups read independently (offsets es-indexer=${offset_a}, sepsis-engine=${offset_b})"
echo ""
echo "[7/${TOTAL_STEPS}] Verifying encounter.status.changed outbox write and Kafka publish"
discharge_payload='{"status":"Discharged"}'
resp="$(request PATCH "${BASE_URL}/api/v1/encounters/${encounter_discharge}/status" "${discharge_payload}")"
assert_status "200" "${resp}"
outbox_status="$(psql_cmd "SELECT payload->>'newStatus' FROM outbox_events WHERE topic = '${TOPIC_ENCOUNTER}' AND partition_key = '${encounter_discharge}' ORDER BY created_at DESC LIMIT 1")"
if [[ "${outbox_status}" != "DISCHARGED" ]]; then
echo "Expected encounter.status.changed outbox row with newStatus=DISCHARGED, got '${outbox_status}'"
exit 1
fi
discharge_outbox_id="$(psql_cmd "SELECT id FROM outbox_events WHERE topic = '${TOPIC_ENCOUNTER}' AND partition_key = '${encounter_discharge}' ORDER BY created_at DESC LIMIT 1")"
wait_for_outbox_processed "${discharge_outbox_id}"
encounter_consumer_out="$(consume_topic "${TOPIC_ENCOUNTER}" "encounter-check-${SCRIPT_RUN_ID}" 5)"
if ! grep -Fq "${encounter_discharge}" "${encounter_consumer_out}"; then
echo "encounter.status.changed message not found on Kafka for ${encounter_discharge}"
exit 1
fi
echo "OK: discharge produced outbox row and Kafka message"
echo ""
echo "[8/${TOTAL_STEPS}] Verifying relay survives Kafka restart (no rows lost)"
compose stop kafka >/dev/null
restart_obs_payload="$(jq -nc \
--arg recordedAt "${RECORDED_AT}" \
--arg key "kafka-restart-${SCRIPT_RUN_ID}" \
'{observations:[{observationCode:"HEART_RATE",value:83,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt,idempotencyKey:$key}]}')"
resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_a}/observations" "${restart_obs_payload}")"
assert_status "201" "${resp}"
restart_outbox_id="$(psql_cmd "SELECT id FROM outbox_events WHERE topic = '${TOPIC_OBSERVATION}' AND partition_key = '${encounter_a}' ORDER BY created_at DESC LIMIT 1")"
restart_pending="$(psql_cmd "SELECT processed_at IS NULL FROM outbox_events WHERE id = '${restart_outbox_id}'")"
if [[ "${restart_pending}" != "t" ]]; then
echo "Expected outbox row ${restart_outbox_id} to remain pending while Kafka is down"
exit 1
fi
echo "OK: outbox row pending while Kafka is stopped"
compose start kafka >/dev/null
wait_for_kafka
wait_for_outbox_processed "${restart_outbox_id}"
echo "OK: relay caught up after Kafka restart"
echo ""
echo "[9/${TOTAL_STEPS}] Verifying partial index and relay poll plan"
index_count="$(psql_cmd "SELECT COUNT(*) FROM pg_indexes WHERE tablename = 'outbox_events' AND indexdef LIKE '%processed_at IS NULL%'")"
if [[ "${index_count}" -lt 1 ]]; then
echo "Expected partial index on outbox_events (processed_at IS NULL), found ${index_count}"
exit 1
fi
psql_cmd "ANALYZE outbox_events" >/dev/null
explain_out="$(psql_cmd "BEGIN; SET LOCAL enable_seqscan = off; EXPLAIN SELECT id, topic, payload, partition_key FROM outbox_events WHERE processed_at IS NULL ORDER BY created_at ASC LIMIT 100 FOR UPDATE SKIP LOCKED; ROLLBACK;")"
if ! grep -qi 'Index Scan' <<< "${explain_out}"; then
echo "Expected Index Scan when seqscan is disabled, got:"
echo "${explain_out}"
exit 1
fi
echo "OK: partial index exists and backs the relay poll query"
echo ""
echo "[10/${TOTAL_STEPS}] Verifying partition_key is set on clinical outbox rows"
missing_keys="$(psql_cmd "SELECT COUNT(*) FROM outbox_events WHERE created_at >= NOW() - INTERVAL '10 minutes' AND topic IN ('${TOPIC_OBSERVATION}', '${TOPIC_ENCOUNTER}', 'alert.generated', 'alert.acknowledged') AND (partition_key IS NULL OR partition_key = '')")"
if [[ "${missing_keys}" != "0" ]]; then
echo "Found ${missing_keys} clinical outbox row(s) without partition_key"
exit 1
fi
echo "OK: all clinical outbox rows have partition_key"
echo ""
echo "All ${TOTAL_STEPS} Kafka + outbox checks passed."