kestra_queue_soak.sh 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. #!/bin/sh
  2. set -eu
  3. compose_file=${COMPOSE_FILE:-deploy/docker/docker-compose.yml}
  4. duration=${KESTRA_SOAK_SECONDS:-1800}
  5. interval=${KESTRA_SOAK_INTERVAL_SECONDS:-15}
  6. base_url=${KESTRA_SOAK_BASE_URL:-http://127.0.0.1:18080/api/v1/main}
  7. username=${KESTRA_USERNAME:-admin@dataops.local}
  8. password=${KESTRA_PASSWORD:-DataOpsKestra1!}
  9. evidence=${KESTRA_SOAK_EVIDENCE:-/tmp/dataops-kestra-soak-$(date +%Y%m%dT%H%M%S).jsonl}
  10. namespace=dataops_validation
  11. flow_id=queue_restart_soak_$(date +%s)
  12. deadline=$(( $(date +%s) + duration ))
  13. restart_at=$(( $(date +%s) + duration / 3 ))
  14. restarted=false
  15. post_restart_since=
  16. soak_started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
  17. checks=0
  18. executions=0
  19. postgres_container=$(docker compose -f "$compose_file" ps -q postgres)
  20. kestra_container=$(docker compose -f "$compose_file" ps -q kestra)
  21. postgres_restart_count_before=$(docker inspect -f '{{.RestartCount}}' "$postgres_container")
  22. kestra_restart_count_before=$(docker inspect -f '{{.RestartCount}}' "$kestra_container")
  23. jq -cn \
  24. --arg started_at "$soak_started_at" \
  25. --argjson duration_seconds "$duration" \
  26. --argjson postgres_restart_count "$postgres_restart_count_before" \
  27. --argjson kestra_restart_count "$kestra_restart_count_before" \
  28. '{kind:"soak_start",started_at:$started_at,
  29. duration_seconds:$duration_seconds,
  30. postgres_restart_count:$postgres_restart_count,
  31. kestra_restart_count:$kestra_restart_count}' >"$evidence"
  32. flow_file=$(mktemp "${TMPDIR:-/tmp}/dataops-kestra-soak.XXXXXX.yml")
  33. trap 'rm -f "$flow_file"' EXIT
  34. printf '%s\n' \
  35. "id: ${flow_id}" \
  36. "namespace: ${namespace}" \
  37. "description: DataOps controlled queue and restart soak" \
  38. "disabled: false" \
  39. "tasks:" \
  40. " - id: queue_probe" \
  41. " type: io.kestra.plugin.core.log.Log" \
  42. " message: DataOps queue soak probe" >"$flow_file"
  43. api() {
  44. method=$1
  45. path=$2
  46. shift 2
  47. curl -fsS --user "${username}:${password}" \
  48. -X "$method" "${base_url}/${path}" "$@"
  49. }
  50. api POST flows -H 'Content-Type: application/x-yaml' \
  51. --data-binary "@${flow_file}" >/dev/null
  52. while [ "$(date +%s)" -lt "$deadline" ]; do
  53. sample_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)
  54. execution=$(api POST "executions/${namespace}/${flow_id}")
  55. execution_id=$(printf '%s' "$execution" | jq -er '.id')
  56. state=UNKNOWN
  57. poll=0
  58. while [ "$poll" -lt 30 ]; do
  59. state=$(api GET "executions/${execution_id}" |
  60. jq -r '.state.current // .state // "UNKNOWN"')
  61. case "$state" in
  62. SUCCESS|FAILED|KILLED|CANCELLED|WARNING) break ;;
  63. esac
  64. poll=$((poll + 1))
  65. sleep 1
  66. done
  67. [ "$state" = "SUCCESS" ] || {
  68. printf 'Kestra soak execution %s ended as %s\n' "$execution_id" "$state" >&2
  69. exit 1
  70. }
  71. executions=$((executions + 1))
  72. docker compose -f "$compose_file" exec -T kestra \
  73. curl -fsS http://127.0.0.1:8081/health >/dev/null
  74. health='{"status":"UP"}'
  75. jq -cn \
  76. --arg kind "sample" \
  77. --arg sampled_at "$sample_time" \
  78. --arg execution_id "$execution_id" \
  79. --arg state "$state" \
  80. --argjson health "$health" \
  81. --argjson postgres_restarted "$restarted" \
  82. '{kind:$kind,sampled_at:$sampled_at,execution_id:$execution_id,state:$state,
  83. health:$health,controlled_restart_completed:$postgres_restarted}' \
  84. >>"$evidence"
  85. checks=$((checks + 1))
  86. if [ "$restarted" = false ] && [ "$(date +%s)" -ge "$restart_at" ]; then
  87. docker compose -f "$compose_file" restart postgres
  88. ready_attempt=0
  89. until docker compose -f "$compose_file" exec -T postgres \
  90. pg_isready -U dataops -d dataops >/dev/null 2>&1; do
  91. ready_attempt=$((ready_attempt + 1))
  92. [ "$ready_attempt" -lt 30 ] || {
  93. echo "PostgreSQL did not recover during controlled restart" >&2
  94. exit 1
  95. }
  96. sleep 2
  97. done
  98. docker compose -f "$compose_file" run --rm -T kestra-db-init
  99. docker compose -f "$compose_file" restart kestra
  100. docker compose -f "$compose_file" up -d --wait kestra
  101. post_restart_since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
  102. restarted=true
  103. fi
  104. sleep "$interval"
  105. done
  106. [ "$restarted" = true ] || {
  107. echo "Controlled PostgreSQL/Kestra restart did not run" >&2
  108. exit 1
  109. }
  110. postgres_restart_count_after=$(docker inspect -f '{{.RestartCount}}' "$postgres_container")
  111. kestra_restart_count_after=$(docker inspect -f '{{.RestartCount}}' "$kestra_container")
  112. # Docker's explicit `docker restart` does not consume RestartCount; that field
  113. # counts daemon/restart-policy recoveries. Any delta therefore proves an
  114. # uncontrolled extra restart during the observation window.
  115. if [ "$postgres_restart_count_after" != "$postgres_restart_count_before" ] ||
  116. [ "$kestra_restart_count_after" != "$kestra_restart_count_before" ]; then
  117. echo "Unexpected automatic container restart during soak" >&2
  118. exit 1
  119. fi
  120. log_file=$(mktemp "${TMPDIR:-/tmp}/dataops-kestra-soak-logs.XXXXXX")
  121. docker compose -f "$compose_file" logs --since "$soak_started_at" kestra >"$log_file"
  122. if grep -Eiq 'outofmemory' "$log_file"; then
  123. echo "Kestra emitted a fatal database/queue signature during soak" >&2
  124. exit 1
  125. fi
  126. # PostgreSQL emits SQLSTATE 57P01 / "terminating connection due to
  127. # administrator command" for the one deliberate database restart above. It is
  128. # retained and counted as controlled evidence, while every other FATAL line
  129. # across the full observation window remains a release-gate failure.
  130. if grep -Ei '(^|[^a-z])fatal([^a-z]|$)' "$log_file" |
  131. grep -Eiv 'fatal: terminating connection due to administrator command' \
  132. >/dev/null; then
  133. echo "Kestra emitted an unexpected fatal database/queue signature" >&2
  134. exit 1
  135. fi
  136. known_restart_disconnects=$(grep -Eic \
  137. 'broken pipe|connection is closed|connection reset|hikaripool.*failed|terminating connection due to administrator command' \
  138. "$log_file" || true)
  139. soak_completed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
  140. jq -cn \
  141. --arg completed_at "$soak_completed_at" \
  142. --arg log_since "$soak_started_at" \
  143. --argjson executions "$executions" \
  144. --argjson samples "$checks" \
  145. --argjson known_restart_disconnects "$known_restart_disconnects" \
  146. --argjson postgres_restart_count "$postgres_restart_count_after" \
  147. --argjson kestra_restart_count "$kestra_restart_count_after" \
  148. '{kind:"soak_complete",completed_at:$completed_at,log_since:$log_since,
  149. executions:$executions,samples:$samples,
  150. known_controlled_restart_disconnects:$known_restart_disconnects,
  151. postgres_restart_count:$postgres_restart_count,
  152. kestra_restart_count:$kestra_restart_count}' >>"$evidence"
  153. rm -f "$log_file"
  154. printf 'Kestra soak passed: %s executions, %s samples, evidence=%s\n' \
  155. "$executions" "$checks" "$evidence"