Ctrl K
ComunidadGuías…

scripts/bash/reprocess_missing_feature_usage.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
#
# Finds raw events that are missing from the 'events' table and triggers the
# reprocess API for each batch of event IDs.
#
# Memory-safe: compares raw_events against 'events' (not feature_usage).
# The 'events' table has ORDER BY (tenant_id, environment_id, timestamp, id)
# so id lookups are efficient. Single ANTI JOIN query dumps all missing IDs
# to a file, then batches API calls from the file — no repeated joins.
#
# Prerequisites: clickhouse (client), curl, jq
#
# Usage:
#   source .env.backfill && ./reprocess_missing_feature_usage.sh
#
set -euo pipefail

###############################################################################
# Parameters
###############################################################################
TENANT_ID="${TENANT_ID:?TENANT_ID is required}"
ENVIRONMENT_ID="${ENVIRONMENT_ID:?ENVIRONMENT_ID is required}"
START_DATE="${START_DATE:?START_DATE is required (ISO-8601, e.g. 2026-02-01T00:00:00Z)}"
END_DATE="${END_DATE:?END_DATE is required (ISO-8601, e.g. 2026-02-06T00:00:00Z)}"

EXTERNAL_CUSTOMER_ID="${EXTERNAL_CUSTOMER_ID:-}"

API_KEY="${API_KEY:-flexprice-api-key}"
API_URL="${API_URL:-https://us.api.flexprice.io/v1/events/raw/reprocess/all}"
BATCH_SIZE="${BATCH_SIZE:-20000}"
API_CHUNK_SIZE="${API_CHUNK_SIZE:-5000}"
API_PARALLEL="${API_PARALLEL:-10}"
DRY_RUN="${DRY_RUN:-false}"
SLEEP_BETWEEN_BATCHES="${SLEEP_BETWEEN_BATCHES:-1}"

# ClickHouse memory safety — applied per query
CH_MAX_MEMORY="${CH_MAX_MEMORY:-8000000000}"  # 8 GB per query

# ClickHouse connection (matches .env.backfill)
CH_HOST="${CH_HOST:-127.0.0.1}"
CH_PORT="${CH_PORT:-9000}"
CH_USER="${CH_USER:-default}"
CH_PASSWORD="${CH_PASSWORD:-}"
CH_DB="${CH_DB:-flexprice}"

###############################################################################
# Helpers
###############################################################################
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }

ch() {
  clickhouse client \
    --host "$CH_HOST" --port "$CH_PORT" \
    --user "$CH_USER" --password "$CH_PASSWORD" \
    --database "$CH_DB" \
    --format TSV \
    "$@"
}

# Memory-capped ClickHouse query
ch_safe_query() {
  local query="$1"
  ch --query "
    SET max_memory_usage = ${CH_MAX_MEMORY};
    SET max_bytes_before_external_sort = $((CH_MAX_MEMORY / 2));
    SET max_bytes_before_external_group_by = $((CH_MAX_MEMORY / 2));
    ${query}
  "
}

# Sends one API chunk. Writes "ok" or "fail" to $RESULT_DIR/<chunk_num>.
send_chunk() {
  local chunk_num="$1" total_chunks="$2" chunk_count="$3" payload="$4"

  local api_tmp
  api_tmp=$(mktemp)
  local api_http
  api_http=$(curl -sS -o "$api_tmp" -w '%{http_code}' \
    --request POST \
    --url "$API_URL" \
    --header 'Content-Type: application/json' \
    --header "x-api-key: ${API_KEY}" \
    --max-time 120 \
    --data "$payload" 2>/dev/null) || api_http="000"
  local api_body
  api_body=$(cat "$api_tmp" 2>/dev/null || true)
  rm -f "$api_tmp"

  if [[ "$api_http" -ge 200 && "$api_http" -lt 300 ]]; then
    local wf_id
    wf_id=$(printf '%s' "$api_body" | jq -r '.workflow_id // empty' 2>/dev/null || true)
    log "  chunk ${chunk_num}/${total_chunks} (${chunk_count} IDs) -> OK (${api_http})  wf=${wf_id:-n/a}"
    echo "ok" > "${RESULT_DIR}/${chunk_num}"
  else
    log "  chunk ${chunk_num}/${total_chunks} (${chunk_count} IDs) -> FAIL (${api_http})"
    echo "fail" > "${RESULT_DIR}/${chunk_num}"
  fi
}

for cmd in clickhouse curl jq; do
  if ! command -v "$cmd" &>/dev/null; then
    log "ERROR: '$cmd' is required but not installed."
    exit 1
  fi
done

# Convert ISO-8601 timestamp to ClickHouse DateTime string
to_ch_ts() { printf '%s' "$1" | sed 's/T/ /;s/Z$//'; }

ch_start=$(to_ch_ts "$START_DATE")
ch_end=$(to_ch_ts "$END_DATE")

# Temp files
RESULT_DIR=$(mktemp -d)
MISSING_IDS_FILE=$(mktemp)
trap 'rm -rf "$RESULT_DIR" "$MISSING_IDS_FILE"' EXIT

###############################################################################
# Build customer filters
###############################################################################
# raw_events ORDER BY: (tenant_id, environment_id, external_customer_id, timestamp, id)
#   → external_customer_id at position 3, efficient in PREWHERE
raw_customer_prewhere=""
# events ORDER BY: (tenant_id, environment_id, timestamp, id)
#   → no external_customer_id in sort key, but has bloom filter index
events_customer_where=""

if [[ -n "$EXTERNAL_CUSTOMER_ID" ]]; then
  log "Customer filter: ${EXTERNAL_CUSTOMER_ID}"
  raw_customer_prewhere="AND external_customer_id = '${EXTERNAL_CUSTOMER_ID}'"
  events_customer_where="AND external_customer_id = '${EXTERNAL_CUSTOMER_ID}'"
fi

# Load event names to skip
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKIP_FILE="${EVENTS_TO_SKIP_FILE:-${SCRIPT_DIR}/events_to_skip.json}"
extra_event_filter=""
if [[ -f "$SKIP_FILE" ]]; then
  skip_count=$(jq '.events_to_skip | length' "$SKIP_FILE")
  if (( skip_count > 0 )); then
    skip_event_list=$(jq -r '.events_to_skip[]' "$SKIP_FILE" \
      | awk '{printf "%s'\''%s'\''", (NR>1 ? "," : ""), $0}')
    extra_event_filter="AND event_name NOT IN (${skip_event_list})"
  fi
  log "  -> ${skip_count} event names excluded (events_to_skip.json)"
fi

###############################################################################
# Banner
###############################################################################
log "================================================================"
log " Reprocess Missing Events (raw_events ANTI JOIN events)"
log "================================================================"
log " Tenant:       ${TENANT_ID}"
log " Environment:  ${ENVIRONMENT_ID}"
[[ -n "$EXTERNAL_CUSTOMER_ID" ]] && \
log " Customer:     ${EXTERNAL_CUSTOMER_ID}"
log " Time range:   ${START_DATE}  ->  ${END_DATE}"
log " CH batch:     ${BATCH_SIZE}"
log " API chunk:    ${API_CHUNK_SIZE}  (x${API_PARALLEL} parallel)"
log " Dry run:      ${DRY_RUN}"
log " API URL:      ${API_URL}"
log " ClickHouse:   ${CH_HOST}:${CH_PORT}/${CH_DB}"
log " CH max mem:   ${CH_MAX_MEMORY} bytes per query"
log " Compare tbl:  events (ORDER BY tenant, env, timestamp, id)"
log "================================================================"

###############################################################################
# Step 1: Dump ALL missing IDs in one query
#
# Compares raw_events against 'events' table (NOT feature_usage).
# The 'events' table:
#   - ORDER BY (tenant_id, environment_id, timestamp, id) → id at pos 4
#   - Lighter schema (fewer columns) → smaller hash table
#   - 1:1 mapping with raw events (no fan-out like feature_usage)
#
# Single query, result streamed to file. No repeated joins.
###############################################################################
log ""
log "Finding all missing event IDs (raw_events ANTI JOIN events) ..."

if [[ -n "$EXTERNAL_CUSTOMER_ID" ]]; then
  log "Customer filter: ${EXTERNAL_CUSTOMER_ID} (skipping missing-customer pre-fetch)"
  extra_where+=" AND external_customer_id = '${EXTERNAL_CUSTOMER_ID}'"
  extra_where_fu+=" AND external_customer_id = '${EXTERNAL_CUSTOMER_ID}'"
else
  log "Pre-fetching missing customers ..."
  missing_customers_raw=$(ch_query "
  SELECT external_customer_id
  FROM (
    SELECT external_customer_id
    FROM ${CH_DB}.raw_events
    PREWHERE tenant_id  = '${TENANT_ID}'
      AND environment_id = '${ENVIRONMENT_ID}'
      AND timestamp >= toDateTime64('${ch_start}', 3)
      AND timestamp <  toDateTime64('${ch_end}', 3)
    WHERE field4 = 'false'
    GROUP BY external_customer_id
  ) r
  ANTI JOIN (
    SELECT external_customer_id
    FROM ${CH_DB}.feature_usage
    WHERE tenant_id  = '${TENANT_ID}'
      AND environment_id = '${ENVIRONMENT_ID}'
    GROUP BY external_customer_id
  ) f USING (external_customer_id)
  ")

  missing_customer_list=""
  missing_customer_count=0
  while IFS= read -r line; do
    [[ -z "$line" ]] && continue
    missing_customer_count=$((missing_customer_count + 1))
    [[ -n "$missing_customer_list" ]] && missing_customer_list+=","
    missing_customer_list+="'${line}'"
  done <<< "$missing_customers_raw"
  log "  -> ${missing_customer_count} customers excluded (no feature_usage)"

  [[ -n "$missing_customer_list" ]] && \
    extra_where+=" AND external_customer_id NOT IN (${missing_customer_list})"
fi

# Load event names to skip from JSON file (if present)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKIP_FILE="${EVENTS_TO_SKIP_FILE:-${SCRIPT_DIR}/events_to_skip.json}"
if [[ -f "$SKIP_FILE" ]]; then
  skip_count=$(jq '.events_to_skip | length' "$SKIP_FILE")
  if (( skip_count > 0 )); then
    skip_event_list=$(jq -r '.events_to_skip[]' "$SKIP_FILE" \
      | awk '{printf "%s'\''%s'\''", (NR>1 ? "," : ""), $0}')
    extra_where+=" AND event_name NOT IN (${skip_event_list})"
  fi
  log "  -> ${skip_count} event names excluded (events_to_skip.json)"
fi

###############################################################################
# Step 2: Count total events
###############################################################################
log "Counting total events to reprocess ..."
total_count=$(ch_query "
SELECT uniqHLL12(cityHash64(id))
FROM (
  SELECT id
  FROM ${CH_DB}.raw_events
  PREWHERE tenant_id  = '${TENANT_ID}'
    AND environment_id = '${ENVIRONMENT_ID}'
    ${raw_customer_prewhere}
    AND timestamp >= toDateTime64('${ch_start}', 3)
    AND timestamp <  toDateTime64('${ch_end}', 3)
  WHERE field4 = 'false'
  AND field1 != 'custom-llm'
    ${extra_where}
) r
ANTI JOIN (
  SELECT id
  FROM ${CH_DB}.events
  PREWHERE tenant_id  = '${TENANT_ID}'
    AND environment_id = '${ENVIRONMENT_ID}'
    AND timestamp >= toDateTime64('${ch_start}', 3)
    AND timestamp <  toDateTime64('${ch_end}', 3)
  WHERE 1=1
    ${events_customer_where}
  GROUP BY id
) e USING (id)
"

log "  Left:  raw_events  PREWHERE tenant+env+customer+ts  WHERE field4='false'"
log "  Right: events      PREWHERE tenant+env+ts            WHERE customer  GROUP BY id"
log ""

q_start=$(date +%s)
ch_safe_query "$QUERY" > "$MISSING_IDS_FILE"
q_end=$(date +%s)
query_elapsed=$((q_end - q_start))

total_count=$(wc -l < "$MISSING_IDS_FILE" | tr -d '[:space:]')
log "  -> ${total_count} missing event IDs found in ${query_elapsed}s"

if [[ "$total_count" == "0" ]]; then
  log "Nothing to do. Exiting."
  exit 0
fi

###############################################################################
# Step 2: Stream the file in batches for API calls
#
# No more ClickHouse queries — just read IDs from the file.
###############################################################################
log ""
log "Sending ${total_count} event IDs to API in batches of ${BATCH_SIZE} ..."

batch_num=0
total_processed=0
total_api_ok=0
total_api_fail=0
start_epoch=$(date +%s)

while true; do
  batch_num=$((batch_num + 1))

  # Read next BATCH_SIZE lines from the file (sed avoids SIGPIPE from tail|head)
  offset=$((total_processed))
  batch_ids=$(sed -n "$((offset + 1)),$((offset + BATCH_SIZE))p" "$MISSING_IDS_FILE")

  if [[ -z "$batch_ids" ]]; then
    log "All IDs processed. Done."
    break
  fi

  batch_count=$(printf '%s\n' "$batch_ids" | wc -l)
  batch_count=$(echo "$batch_count" | tr -d '[:space:]')
  log "--- Batch ${batch_num}  (${total_processed}/${total_count}) — ${batch_count} IDs ---"

  # Load IDs into array
  all_ids=()
  while IFS= read -r _id; do
    [[ -n "$_id" ]] && all_ids+=("$_id")
  done <<< "$batch_ids"

  # Clear result dir for this batch
  rm -f "${RESULT_DIR}"/*

  total_ids=${#all_ids[@]}
  total_chunks=$(( (total_ids + API_CHUNK_SIZE - 1) / API_CHUNK_SIZE ))
  chunk_num=0
  in_flight=0

  log "Sending ${total_chunks} API chunks (${API_PARALLEL} parallel) ..."

  for (( i=0; i < total_ids; i += API_CHUNK_SIZE )); do
    chunk_num=$((chunk_num + 1))
    chunk_arr=("${all_ids[@]:i:API_CHUNK_SIZE}")
    chunk_count=${#chunk_arr[@]}

    chunk_json=$(printf '%s\n' "${chunk_arr[@]}" \
      | jq -R -s 'split("\n") | map(select(length > 0))')

    # Build external_customer_ids array if EXTERNAL_CUSTOMER_ID is set
    cust_ids_json="null"
    if [[ -n "${EXTERNAL_CUSTOMER_ID:-}" ]]; then
      cust_ids_json=$(jq -n --arg cid "$EXTERNAL_CUSTOMER_ID" '[$cid]')
    fi

    payload=$(jq -n \
      --arg start_date  "$START_DATE" \
      --arg end_date    "$END_DATE" \
      --argjson batch_size "$API_CHUNK_SIZE" \
      --argjson event_ids  "$chunk_json" \
      --argjson external_customer_ids "$cust_ids_json" \
      '{
        start_date:            $start_date,
        end_date:              $end_date,
        batch_size:            $batch_size,
        event_ids:             $event_ids,
        external_customer_ids: $external_customer_ids
      }')

    if [[ "$DRY_RUN" == "true" ]]; then
      log "  [DRY RUN] chunk ${chunk_num}/${total_chunks}: ${chunk_count} IDs"
    else
      send_chunk "$chunk_num" "$total_chunks" "$chunk_count" "$payload" &
      in_flight=$((in_flight + 1))

      if (( in_flight >= API_PARALLEL )); then
        wait
        in_flight=0
      fi
    fi
  done

  # Wait for any remaining in-flight requests
  if (( in_flight > 0 )); then
    wait
  fi

  # Tally results from this batch
  batch_ok=$( (grep -rl 'ok' "${RESULT_DIR}" 2>/dev/null || true) | wc -l | tr -d '[:space:]')
  batch_fail=$( (grep -rl 'fail' "${RESULT_DIR}" 2>/dev/null || true) | wc -l | tr -d '[:space:]')
  total_api_ok=$((total_api_ok + batch_ok))
  total_api_fail=$((total_api_fail + batch_fail))

  total_processed=$((total_processed + batch_count))

  # Calculate speed
  now_epoch=$(date +%s)
  elapsed=$((now_epoch - start_epoch))
  if (( elapsed > 0 )); then
    rate=$((total_processed * 60 / elapsed))
    remaining=$(( (total_count - total_processed) * elapsed / total_processed ))
    remaining_min=$((remaining / 60))
    log "Speed: ~${rate} events/min | ETA: ~${remaining_min}m | API: ${batch_ok} ok, ${batch_fail} fail"
  fi

  if [[ "$batch_count" -lt "$BATCH_SIZE" ]]; then
    log "Last batch. Done."
    break
  fi

  if [[ "$SLEEP_BETWEEN_BATCHES" -gt 0 ]]; then
    sleep "$SLEEP_BETWEEN_BATCHES"
  fi
done

###############################################################################
# Summary
###############################################################################
end_epoch=$(date +%s)
total_elapsed=$(( end_epoch - start_epoch ))
total_min=$(( total_elapsed / 60 ))
total_sec=$(( total_elapsed % 60 ))

log "================================================================"
log " Done!"
log "   Total events:    ${total_processed}"
log "   Batches:         ${batch_num}"
log "   Time:            ${total_min}m ${total_sec}s"
if [[ "$DRY_RUN" != "true" ]]; then
  log "   API successes:   ${total_api_ok}"
  log "   API failures:    ${total_api_fail}"
fi
log "================================================================"