CI/CD و اتوماسیونمهندسانسطح متوسط

چرا همگام‌سازی JQL در Jira فقط ۵۰ ایشو برمی‌گرداند؟

اگر job همگام‌سازی Jira شما بدون خطا تمام می‌شود ولی فقط ۵۰ ایشو را می‌آورد یا در حلقه بی‌پایان گیر می‌کند، مشکل از JQL نیست؛ اندپوینت search به pagination مبتنی بر nextPageToken منتقل شده و startAt و total دیگر وجود ندارند.

تحریریه کلودیکپ ۸ دقیقه مطالعه
چرا همگام‌سازی JQL در Jira فقط ۵۰ ایشو برمی‌گرداند؟

شرح مسئله: job سبز است، ولی داده نصفه است - سرویس جیرا

یک job شبانه در پایپ‌لاین داریم که با یک JQL ایشوهای یک پروژه را از Jira می‌خواند و در دیتابیس داخلی upsert می‌کند. روی محیط تست که ۴۰ ایشو دارد همه‌چیز درست است؛ روی محیط production که بیش از ۳۰۰۰ ایشو دارد، job بدون هیچ خطایی تمام می‌شود، لاگ هم exit code 0 می‌دهد، اما جدول فقط ۵۰ ردیف دارد. نه هشداری، نه خطایی، نه retry‌ای.

اگر با اندپوینت قدیمی سرچ کار می‌کنید، خروجی چیزی شبیه این است:

$ curl -sS -u "$JIRA_EMAIL:$JIRA_API_TOKEN" \
    -G "$JIRA_BASE/rest/api/3/search" \
    --data-urlencode "jql=project = OPS" \
    --data-urlencode "maxResults=50" \
  | jq '{startAt, maxResults, total, count: (.issues | length)}'
{
  "startAt": 0,
  "maxResults": 50,
  "total": 3140,
  "count": 50
}

اسکریپت فقط صفحه اول را می‌خواند و چون total عدد بزرگی است، در ذهن توسعه‌دهنده یعنی «همه‌چیز اوکی است»، در حالی که count عدد ثابت ۵۰ را نشان می‌دهد.

نشانه دوم: حلقه‌ای که تمام نمی‌شود

وقتی کسی متوجه می‌شود اندپوینت قدیمی deprecated شده، سریع مسیر را به /rest/api/3/search/jql عوض می‌کند، اما منطق pagination را دست نمی‌زند و همان startAt و total را نگه می‌دارد. نتیجه، دو رفتار عجیب است:

$ curl -sS -o /dev/null -w '%{http_code}\n' -u "$JIRA_EMAIL:$JIRA_API_TOKEN" \
    -G "$JIRA_BASE/rest/api/3/search" \
    --data-urlencode 'jql=project = OPS'
410

یعنی اندپوینت قدیمی رسماً از دسترس خارج شده و پاسخ ۴۱۰ Gone برمی‌گرداند؛ بدنه پاسخ هم در errorMessages توضیح می‌دهد که این API حذف شده است. اما بدتر از ۴۱۰، حالت خاموش است:

$ curl -sS -u "$JIRA_EMAIL:$JIRA_API_TOKEN" \
    -X POST "$JIRA_BASE/rest/api/3/search/jql" \
    -H 'Content-Type: application/json' \
    --data '{"jql":"project = OPS ORDER BY created ASC","startAt":100,"maxResults":50}' \
  | jq '{startAt: .startAt, isLast: .isLast, first_keys: [.issues[0:3][].key]}'
{
  "startAt": null,
  "isLast": false,
  "first_keys": ["OPS-1", "OPS-2", "OPS-3"]
}

startAt در پاسخ وجود ندارد و ورودی‌اش هم نادیده گرفته شده؛ ما دقیقاً همان صفحه اول را با OPS-1 گرفتیم. حالا حلقه‌ای که منتظر total است، یا روی null می‌شکند یا تا ابد صفحه اول را می‌خواند:

page=1 got=50 isLast=false token=eyJvZmZzZXQiOjUw
page=2 got=50 isLast=false token=eyJvZmZzZXQiOjUw
page=3 got=50 isLast=false token=eyJvZmZzZXQiOjUw
...

خروجی ثابت است چون پارامتر offset دیگر معنایی ندارد و توکن صفحه هم چون درخواست عوض نشده، همان توکن قبلی است.

علت ریشه‌ای: مهاجرت از offset pagination به cursor pagination

Jira Cloud اندپوینت‌های GET/POST /rest/api/3/search را کنار گذاشته و جایش /rest/api/3/search/jql را آورده است. این تغییر فقط تغییر آدرس نیست، مدل صفحه‌بندی عوض شده:

مفهوم

اندپوینت قدیمی (search)

اندپوینت جدید (search/jql)

شروع صفحه

startAt (offset عددی)

nextPageToken (رشته مبهم)

اندازه صفحه

maxResults (پیش‌فرض ۵۰)

maxResults (پیش‌فرض ۵۰)

تعداد کل

total

وجود ندارد

پایان نتایج

مقایسه startAt + maxResults با total

isLast یا خالی بودن توکن

پایداری نتیجه

ضعیف؛ با ویرایش همزمان، ایشو تکراری یا جاافتاده می‌شود

بهتر؛ توکن به مکان نتیجه گره خورده است

دلیل حذف total هم ساده است: محاسبه تعداد کل یک result set بزرگ در Jira گران است و برای اکثر کلاینت‌ها هم بی‌فایده. اما همین حذف، اسکریپت‌هایی را که شرط پایانشان startAt < total بود، به دو سرنوشت می‌کشاند: یا فقط صفحه اول را می‌خوانند، یا در حلقه بی‌پایان می‌مانند. نکته مهم این است که JQL شما سالم است؛ باگ در سمت کلاینت و در منطق صفحه‌بندی است.

هر پارامتری که در پاسخ نباشد ولی در کد به‌عنوان شرط حلقه استفاده شود، یک بمب ساعتی است. قانون طلایی: شرط پایان حلقه را از پاسخ API بگیر، نه از حدس خودت.

راه‌حل گام‌به‌گام

گام ۱: مطمئن شو کدام اندپوینت زنده است

با یک توکن API و یک پروژه کوچک شروع کن. اگر پاسخ ۴۱۰ گرفتی، اندپوینت قدیمی حذف شده و باید به search/jql بروی.

export JIRA_BASE="https://your-domain.atlassian.net"
export JIRA_EMAIL="you@example.com"
export JIRA_API_TOKEN="..."   # از id.atlassian.com/manage-profile/security/api-tokens

curl -sS --fail-with-body -u "$JIRA_EMAIL:$JIRA_API_TOKEN" \
  -X POST "$JIRA_BASE/rest/api/3/search/jql" \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  --data '{"jql":"project = OPS ORDER BY updated ASC","maxResults":2,"fields":["summary","status","updated"]}' \
| jq '{count: (.issues | length), isLast, nextPageToken, keys: [.issues[].key]}'

اگر nextPageToken و isLast را در بدنه پاسخ دیدی، در مسیر درستی هستی. key و id هر ایشو همیشه در پاسخ هستند و نیازی به درخواستشان در fields نیست.

گام ۲: اسکریپت همگام‌سازی با cursor

این اسکریپت تا وقتی isLast یا توکن تمام نشود جلو می‌رود و اگر توکن تکرار شد، عمداً fail می‌کند تا حلقه بی‌پایان شکل نگیرد:

#!/usr/bin/env bash
set -euo pipefail

: "${JIRA_BASE:?JIRA_BASE را ست کن، مثلا https://your-domain.atlassian.net}"
: "${JIRA_EMAIL:?}"
: "${JIRA_API_TOKEN:?}"

JQL="${JQL:-project = OPS ORDER BY updated ASC}"
MAX_RESULTS="${MAX_RESULTS:-100}"
OUT_DIR="${OUT_DIR:-./out}"
mkdir -p "$OUT_DIR"
: > "$OUT_DIR/issues.jsonl"

page=0
token=""

while :; do
  payload=$(jq -n \
    --arg jql "$JQL" \
    --argjson mr "$MAX_RESULTS" \
    --arg token "$token" \
    '{jql: $jql,
      maxResults: $mr,
      fields: ["summary", "status", "updated", "project"]}
     + (if $token == "" then {} else {nextPageToken: $token} end)')

  resp=$(curl -sS --fail-with-body --retry 5 --retry-delay 2 \
    -u "$JIRA_EMAIL:$JIRA_API_TOKEN" \
    -X POST "$JIRA_BASE/rest/api/3/search/jql" \
    -H 'Accept: application/json' \
    -H 'Content-Type: application/json' \
    --data "$payload")

  page=$((page + 1))
  got=$(jq '.issues | length' <<<"$resp")
  jq -c '.issues[]' <<<"$resp" >> "$OUT_DIR/issues.jsonl"

  prev_token="$token"
  token=$(jq -r '.nextPageToken // empty' <<<"$resp")
  is_last=$(jq -r '.isLast // false' <<<"$resp")

  echo "page=$page got=$got isLast=$is_last token=${token:0:12}"

  [ "$got" -eq 0 ] && break
  [ "$is_last" = "true" ] && break
  [ -z "$token" ] && break

  if [ "$token" = "$prev_token" ]; then
    echo "nextPageToken عوض نشد؛ توقف برای جلوگیری از حلقه بی‌پایان" >&2
    exit 1
  fi
done

unique=$(jq -r '.key' "$OUT_DIR/issues.jsonl" | sort -u | wc -l)
echo "synced_issues=$unique"

چند نکته عملی:

  • ساخت بدنه JSON با jq -n انجام می‌شود تا مشکل escape شدن JQL و کوتیشن‌ها نداشته باشیم.

  • پارامتر nextPageToken فقط وقتی فرستاده می‌شود که مقدار داشته باشد؛ فرستادن رشته خالی رفتار نامشخصی می‌دهد.

  • --fail-with-body نیاز به curl نسخه ۷.۷۶ یا بالاتر دارد. برای خطاهای گذرا مثل ۴۲۹ و ۵xx، --retry خودش هدر Retry-After را در نظر می‌گیرد.

  • توکن صفحه‌بندی را هرگز parse یا دستکاری نکن؛ یک رشته مبهم است و ساختارش می‌تواند بدون اطلاع تغییر کند.

گام ۳: همگام‌سازی افزایشی

برای job‌های دوره‌ای، کل پروژه را نخوان. یک watermark نگه دار و با یک بازه همپوشان کوچک بخوان تا ایشوهایی که وسط اجرا ویرایش شده‌اند جا نیفتند، سپس بر اساس id ایشو upsert کن:

-- نمونه جدول مقصد
CREATE TABLE jira_issues (
  issue_id   text PRIMARY KEY,   -- از فیلد id، نه key
  issue_key  text NOT NULL,
  summary    text,
  status     text,
  updated_at timestamptz,
  synced_at  timestamptz NOT NULL DEFAULT now()
);

و JQL را با مرتب‌سازی صریح بفرست، مثل project = OPS AND updated >= "2025-01-01 00:00" ORDER BY updated ASC. مرتب‌سازی صریح باعث می‌شود صفحه‌بندی cursor قابل پیش‌بینی بماند.

بررسی اینکه مشکل واقعاً حل شده است

اولین کار این است که تعداد واقعی را از یک منبع مستقل بگیری. اندپوینت search/approximate-count در Jira Cloud دقیقاً برای همین ساخته شده و جای total را می‌گیرد (روی Data Center در دسترس نیست):

count_jql="${JQL% ORDER BY*}"

expected=$(curl -sS -u "$JIRA_EMAIL:$JIRA_API_TOKEN" \
  -X POST "$JIRA_BASE/rest/api/3/search/approximate-count" \
  -H 'Content-Type: application/json' \
  --data "$(jq -n --arg jql "$count_jql" '{jql: $jql}')" | jq '.count')

synced=$(jq -r '.key' "$OUT_DIR/issues.jsonl" | sort -u | wc -l)
echo "expected=$expected synced=$synced"

سپس این چهار بررسی را انجام بده:

  1. تعداد یکتا برابر تعداد مورد انتظار باشد. اگر synced کمتر است، احتمالاً هنوز صفحه‌ای جا می‌افتد؛ اگر بیشتر است، ایشو تکراری در خروجی داری و باید با sort -u روی id یکتاسازی کنی.

  2. اجرای دوم job باید تقریباً صفر تغییر ثبت کند. اگر هر بار همان ایشوها را می‌نویسد، watermark یا کلید یکتای upsert ایراد دارد.

  3. تعداد صفحات را لاگ کن. برای ۳۰۰۰ ایشو با maxResults=100 باید حدود ۳۱ صفحه ببینی؛ نه یک صفحه، نه بی‌نهایت.

  4. خروجی را با UI مقایسه کن. یک export ساده CSV از همان فیلتر بگیر و تعداد ردیف‌ها را چک کن. اختلاف‌های کوچک معمولاً مربوط به permission خود کاربر API است، نه صفحه‌بندی.

پیشگیری و بهترین روش‌ها

  • یک تست قراردادی با دیتای بزرگتر از یک صفحه داشته باش. یک پروژه تستی با ۱۲۰ ایشو بساز و در CI هفتگی برو آن را بخوان. این تنها راه گرفتن باگ‌های صفحه‌بندی قبل از production است.

  • هرگز به نبودن فیلد در پاسخ تکیه نکن. اگر total وجود ندارد، jq '.total // 0' نباید شرط حلقه شود.

  • شرط پایان را دوگانه کن. هم isLast را چک کن، هم خالی بودن توکن، هم صفر بودن تعداد نتایج همان صفحه را.

  • محافظ حلقه بگذار. یک سقف مثل «حداکثر ۵۰۰ صفحه» و یک alert روی «تعداد صفحات بیشتر از حد انتظار» عملاً هر باگ صفحه‌بندی را لو می‌دهد.

  • فیلدها را صریح بخواه. درخواست همه فیلدها روی هزاران ایشو، هم کند است هم حجم پاسخ را بی‌دلیل بالا می‌برد.

  • برای رخدادهای نزدیک‌به‌واقعی از webhook استفاده کن، ولی idempotent بمان. webhook ممکن است تکرار شود؛ پس پردازش را بر اساس id ایشو و شناسه رویداد یکتاسازی کن.

  • تغییرات API را رصد کن. این مهاجرت نمونه روشنی از تغییرات شکست‌دهنده در Jira Cloud است؛ نسخه API را در کد pin کن و changelog را دنبال کن.

پرسش‌های پرتکرار

هنوز می‌توانم از /rest/api/3/search استفاده کنم؟

روی Jira Cloud این اندپوینت deprecated شده و پاسخ ۴۱۰ Gone می‌دهد، پس چاره‌ای جز مهاجرت به /rest/api/3/search/jql نداری. روی Data Center و Server وضعیت نسخه‌به‌نسخه متفاوت است؛ مستندات نسخه خودت را چک کن و کدی بنویس که با مدل cursor هم کار کند تا مهاجرت بعدی دردناک نباشد.

حالا که total حذف شده، چطور تعداد کل ایشوها را بگیرم؟

در Jira Cloud از POST /rest/api/3/search/approximate-count با همان JQL استفاده کن؛ در پاسخ یک فیلد count می‌گیری. به یاد داشته باش که این عدد تقریبی است و برای مقایسه و پایش مناسب است، نه برای شرط پایان حلقه. شرط پایان باید همیشه خود پاسخ صفحه‌بندی باشد.

چرا اسکریپت من در حلقه بی‌پایان می‌افتد؟

تقریباً همیشه یکی از این دو دلیل است: یا پارامتر startAt را به اندپوینت جدید می‌فرستی که نادیده گرفته می‌شود و همیشه صفحه اول را می‌گیری، یا متغیر توکن را در هر تکرار به‌روز نمی‌کنی. راه‌حل این است که در هر تکرار nextPageToken را از پاسخ بخوانی، در بدنه درخواست بعدی بگذاری و اگر توکن با توکن قبلی یکی بود، اسکریپت را با خطا متوقف کنی.

اشتراک‌گذاری:

نویسنده

تحریریه کلودیکپ

تیم محتوای کلودیکپ

تیم فنی و محتوای کلودیکپ؛ مهندسانی که هر روز با DevOps، Kubernetes و زیرساخت ابری کار می‌کنند و تجربه‌هایشان را اینجا می‌نویسند.

سوالات متداول

سوالات متداول این مقاله

روی Jira Cloud این اندپوینت deprecated شده و پاسخ ۴۱۰ Gone برمی‌گرداند، پس باید به /rest/api/3/search/jql مهاجرت کنی. روی Data Center و Server وضعیت نسخه‌به‌نسخه متفاوت است؛ مستندات نسخه خودت را چک کن.

در Jira Cloud از POST /rest/api/3/search/approximate-count با همان JQL استفاده کن؛ فیلد count را برمی‌گرداند. این عدد تقریبی است و برای مقایسه و پایش مناسب است، نه برای شرط پایان حلقه.

یا پارامتر startAt را به اندپوینت جدید می‌فرستی که نادیده گرفته می‌شود و همیشه صفحه اول را می‌گیری، یا متغیر nextPageToken را در هر تکرار به‌روز نمی‌کنی. توکن را از پاسخ بخوان، در درخواست بعدی بگذار و اگر تکرار شد اجرا را با خطا متوقف کن.

ادامه مطالعه

مقالات مرتبط

حلقه بی‌پایان Jira Automation: وقتی قانون خودش را صدا می‌زند
CI/CD و اتوماسیونمتوسط

حلقه بی‌پایان Jira Automation: وقتی قانون خودش را صدا می‌زند

اگر trigger و action یک قانون Jira Automation روی یک رویداد بیفتند، قانون خودش را دوباره اجرا می‌کند و issue با صدها کامنت تکراری پر می‌شود. این مقاله علت، پاک‌سازی و راه‌حل پایدار را نشان می‌دهد.

تحریریه کلودیکپ ۷ دقیقه مطالعه
قفل state ترافورم گیر کرده؛ آزادسازی امن بعد از کیل شدن apply
زیرساخت ابری و IaCمتوسط

قفل state ترافورم گیر کرده؛ آزادسازی امن بعد از کیل شدن apply

وقتی پایپلاین CI وسط اجرای apply کشته می‌شود، قفل state ترافورم در جدول DynamoDB باقی می‌ماند و اجرای بعدی با خطای Error acquiring the state lock متوقف می‌شود. در این مقاله علت ریشه‌ای، روش آزادسازی امن و راه‌های پیشگیری را بررسی می‌کنیم.

تحریریه کلودیکپ ۶ دقیقه مطالعه
قطع شدن WebSocket در HAProxy هر ۵۰ ثانیه؛ علت و راه‌حل
Linux و مدیریت سرورمتوسط

قطع شدن WebSocket در HAProxy هر ۵۰ ثانیه؛ علت و راه‌حل

اگر WebSocket یا SSE پشت HAProxy هر ۵۰ ثانیه قطع می‌شود، مقصر تایماوت‌های پیش‌فرض فایل نمونه است. در این مقاله علت ریشه‌ای و پیکربندی درست timeout tunnel را بررسی می‌کنیم.

تحریریه کلودیکپ ۷ دقیقه مطالعه

در پیاده‌سازی به کمک نیاز دارید؟

تیم کلودیکپ همین کار را هر روز برای تیم‌های دیگر انجام می‌دهد. اگر جایی گیر کرده‌اید، با ما صحبت کنید.