Files
appwrite/dev/test-distributed-lock.sh
T
Prem Palanisamy 77982f4b90 test: concurrency proof for distributedLockOrFail pilot
Two complementary tests for the lost-update bug the lock fixes, plus
the test-Client patches needed to make Swoole-coroutine HTTP work.

- tests/e2e/Services/Project/ServicesBase.php :: testConcurrentTogglesAllPersist
  Fires N parallel PATCHes via Swoole\Coroutine\run + SWOOLE_HOOK_CURL,
  then refetches the project and asserts (successCount == enabledCount).
  With the lock disabled (_APP_LOCKING_ENABLED=disabled) sparse
  updateDocument() calls overwrite each other and the assertion fails —
  proving the test detects the bug.

- dev/test-distributed-lock.sh
  Same proof via curl + bash background jobs. Runnable outside the
  PHPUnit suite for manual verification or load-style sweeps. Reads
  APPWRITE_ENDPOINT / APPWRITE_PROJECT_ID / APPWRITE_API_KEY.

- tests/e2e/Client.php (test util):
    * Skip CURLOPT_PATH_AS_IS (option 234) when SWOOLE_HOOK_CURL is
      active. Swoole's emulated cURL doesn't support it; setting it
      fatal-errors as soon as any test enables the cURL hook.
    * Don't redundantly set CURLOPT_NOBODY=false on non-HEAD requests
      (false is cURL's default). Under Swoole's hooked cURL this
      strips the body of PATCH/PUT requests, hitting the framework's
      404 fallback instead of the intended route.
  Both changes preserve native (non-hooked) cURL behavior unchanged.
  They unblock any future test that wants real parallel HTTP via
  Swoole\Coroutine\run + the cURL hook.

Both follow the same proof-of-bug pattern: run with locking enabled
(must pass) AND with it disabled (must fail). Verified locally against
the running stack:
  _APP_LOCKING_ENABLED=enabled  -> PASS  (15 assertions)
  _APP_LOCKING_ENABLED=disabled -> FAIL  (successCount=5 enabledCount=4)
2026-04-27 17:25:15 +01:00

158 lines
5.0 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Manual smoke test for the distributed-lock pilot on `updateProjectService`.
#
# Fires N concurrent PATCH /project/services/:serviceId requests against the
# same project, each toggling a different service to "enabled=true". Then
# refetches the project and counts how many of the targeted services actually
# persisted.
#
# Usage:
# APPWRITE_ENDPOINT=http://localhost \
# APPWRITE_PROJECT_ID=<id> \
# APPWRITE_API_KEY=<key with project.write scope> \
# ./dev/test-distributed-lock.sh
#
# Required scopes on the API key:
# - project.write (to toggle services)
# - projects.read (to refetch project state via GET /v1/projects/:id)
#
# To prove the lock fixes the bug, run twice:
# 1. With `_APP_LOCKING_ENABLED=enabled` (default): expect successes == enabled
# 2. With `_APP_LOCKING_ENABLED=disabled` : expect successes > enabled (lost updates)
#
# Set `_APP_LOCKING_ENABLED` in `.env` and `docker compose up -d --force-recreate`
# between runs. Use `--parallelism N` to tune the concurrency level.
set -eu
# --- Configuration ---------------------------------------------------------
ENDPOINT="${APPWRITE_ENDPOINT:?set APPWRITE_ENDPOINT, e.g. http://localhost}"
PROJECT_ID="${APPWRITE_PROJECT_ID:?set APPWRITE_PROJECT_ID}"
API_KEY="${APPWRITE_API_KEY:?set APPWRITE_API_KEY}"
PARALLELISM="${PARALLELISM:-5}"
# Services to toggle concurrently — must be in the optional-services list of
# the project. These are the same set used by the e2e ServicesBase trait.
SERVICES=(teams storage functions sites messaging)
# Trim or extend SERVICES to match PARALLELISM.
SERVICES=("${SERVICES[@]:0:$PARALLELISM}")
if [ "${#SERVICES[@]}" -lt 2 ]; then
echo "ERROR: PARALLELISM must be >= 2 to detect contention" >&2
exit 1
fi
# --- Helpers ---------------------------------------------------------------
curl_appwrite() {
local method="$1"
local path="$2"
shift 2
curl -sS -o /tmp/lock-smoke-body.$$ -w '%{http_code}' \
-X "$method" \
-H "Content-Type: application/json" \
-H "X-Appwrite-Project: $PROJECT_ID" \
-H "X-Appwrite-Key: $API_KEY" \
"$ENDPOINT/v1$path" \
"$@"
}
toggle_service() {
local service="$1"
local enabled="$2"
local code
code=$(curl_appwrite PATCH "/project/services/$service" \
-d "{\"enabled\": $enabled}")
echo "$code"
}
get_project_state() {
curl -sS \
-H "Content-Type: application/json" \
-H "X-Appwrite-Project: console" \
-H "X-Appwrite-Key: $API_KEY" \
"$ENDPOINT/v1/projects/$PROJECT_ID"
}
# --- Run -------------------------------------------------------------------
echo "==> Distributed-lock smoke test"
echo " endpoint: $ENDPOINT"
echo " project: $PROJECT_ID"
echo " parallelism: $PARALLELISM"
echo " services: ${SERVICES[*]}"
echo
# 1. Baseline — disable all targeted services sequentially.
echo "==> Baseline: disabling ${SERVICES[*]}"
for svc in "${SERVICES[@]}"; do
code=$(toggle_service "$svc" false)
if [ "$code" != "200" ]; then
echo " WARN: baseline disable of $svc returned $code (expected 200)"
fi
done
# 2. Fire concurrent toggles to enabled=true. Capture each child's HTTP status.
echo
echo "==> Firing ${#SERVICES[@]} concurrent toggle requests..."
RESULTS_FILE=$(mktemp -t lock-smoke-results.XXXXXX)
for svc in "${SERVICES[@]}"; do
(
code=$(toggle_service "$svc" true)
printf '%s %s\n' "$svc" "$code" >> "$RESULTS_FILE"
) &
done
wait
# 3. Tally responses.
SUCCESS_COUNT=$(awk '$2 == 200' "$RESULTS_FILE" | wc -l | tr -d ' ')
CONFLICT_COUNT=$(awk '$2 == 409' "$RESULTS_FILE" | wc -l | tr -d ' ')
OTHER_COUNT=$(awk '$2 != 200 && $2 != 409' "$RESULTS_FILE" | wc -l | tr -d ' ')
echo
echo "==> Child responses:"
sort "$RESULTS_FILE"
echo
echo " successes (200): $SUCCESS_COUNT"
echo " conflicts (409): $CONFLICT_COUNT"
echo " other: $OTHER_COUNT"
rm -f "$RESULTS_FILE"
# 4. Refetch project; count how many targeted services are enabled.
PROJECT_JSON=$(get_project_state)
ENABLED_COUNT=0
for svc in "${SERVICES[@]}"; do
# Capitalize first letter to form serviceStatusFor<Svc> key.
Cap="$(echo "$svc" | awk '{print toupper(substr($1,1,1)) substr($1,2)}')"
val=$(echo "$PROJECT_JSON" | sed -nE "s/.*\"serviceStatusFor${Cap}\":[[:space:]]*(true|false).*/\1/p" | head -n1)
if [ "$val" = "true" ]; then
ENABLED_COUNT=$((ENABLED_COUNT + 1))
fi
done
echo " enabled in project state: $ENABLED_COUNT"
echo
# 5. Verdict.
if [ "$SUCCESS_COUNT" -eq "$ENABLED_COUNT" ]; then
echo "PASS: every successful toggle persisted (no lost updates)."
EXIT=0
else
echo "FAIL: lost updates detected. successes=$SUCCESS_COUNT enabled=$ENABLED_COUNT"
echo " Locking is either disabled or not effective on this endpoint."
EXIT=1
fi
# 6. Cleanup — re-enable all targeted services.
echo
echo "==> Cleanup: re-enabling ${SERVICES[*]}"
for svc in "${SERVICES[@]}"; do
toggle_service "$svc" true >/dev/null || true
done
rm -f /tmp/lock-smoke-body.$$
exit "$EXIT"