پرش به محتویات

عیب‌یابی

هر مورد پایین خرابی‌ای است که واقعاً در همین پروژه رخ داده، شکلی که داشته، و چیزی که از آب درآمده. نوشته شده‌اند چون نشانه به‌ندرت به علت اشاره می‌کرد.


ModuleNotFoundError: No module named 'telegram'

کجا دیده شد: یونیت systemd، بعد migrations/env.py، بعد uvicorn. سه بار، با ماه‌ها فاصله.

چه شکلی بود: وابستگی‌ای گم‌شده که کسی نخواسته بودش.

چه بود: دو بسته به نام kasbbook جواب می‌دادند — فعلی در src/، و ربات نسل اول در ریشهٔ مخزن. هرکدام در sys.path جلوتر بود بی‌صدا برنده می‌شد. قدیمی python-telegram-bot را وارد می‌کند که این پروژه دیگر به آن وابسته نیست، پس خطا اسم ماژولی را می‌برد که هیچ ربطی به چیزی که در حال اجرا بود نداشت.

مورد uvicorn ظریف‌ترین بود: PYTHONPATH=/opt/kasbbook/src درست تنظیم شده بود، اما WorkingDirectory ریشهٔ مخزن را جلوتر از PYTHONPATH می‌گذارد، پس بستهٔ ریشه باز هم برنده می‌شد.

درمان: نسل اول از کد حذف شد. پیش از آن، هر نقطه sys.path را صریح مرتب می‌کرد، با توضیحی که می‌گفت چرا.

اگر دوباره دیدی: چیزی ریشهٔ مخزن را جلوتر از src/ گذاشته. sys.path را در نقطهٔ خرابی چاپ کن؛ حدس نزن.


یک مهاجرت اجرا شد، چیزی نگفت، و اعمال نشد — برای سه استقرار

چه شکلی بود: استقرارها موفقیت گزارش می‌دادند. بعد ربات روی ستونی که وجود نداشت شکست می‌خورد.

چه بود: ALTER TABLE ... ADD COLUMN ... NOT NULL روی جدولی که از قبل سطر داشت. روی جدول خالی مشکلی نیست، روی SQLite هم نیست (که در حالت دسته‌ای جدول را بازمی‌سازد و پیش‌فرض سمت پایتون را اعمال می‌کند)، و روی پستگرس NotNullViolationError است — ترکیبی که فقط در محیط عملیاتی پیش می‌آید.

نامرئی ماند چون اسکریپت استقرار خروجی alembic را از grep -E "Running upgrade|ERROR" رد می‌کرد، و ردپای خطای پایتون هیچ‌کدام از این دو کلمه را ندارد.

درمان: سه چیز. ۱. server_default روی ستون، هم در مهاجرت و هم در مدل. ۲. اسکریپت استقرار دیگر خروجی را فیلتر نمی‌کند. این اولین توضیح داخل آن است. ۳. tests/test_migrations_postgres.py نسخه‌ها را یکی‌یکی جلو می‌برد و بعد از هر کدام یک سطر می‌کارد، که همان شکلی است که محیط عملیاتی موقع رسیدن مهاجرت بعدی واقعاً دارد.

اثباتش: با برداشتن server_default، SQLite پنج قبولی گزارش می‌دهد و تمرین پستگرس چهار مردودی.


خلاصهٔ روزانه هرگز نرسید، و خطایی هم نیامد

چه شکلی بود: سکوت. سرویس فعال بود، لاگ تمیز.

چه بود: TelegramAdapter.send_plain پارامترهایش را به‌صورت دیکشنری موضعی به _call می‌داد، در حالی که _call آرگومان کلیدواژه‌ای می‌گیرد. هر بار روی اولین خط بدنه‌اش TypeError می‌داد. send_stored_file — همان که رسید را نشان می‌دهد — دقیقاً همین ایراد را داشت.

هیچ‌کدام پوشش تست نداشتند. تست‌های یادآور از آداپتور جاسوس استفاده می‌کنند؛ تست‌های رسید روی شیء پاسخ ادعا می‌کنند بی‌آنکه هرگز آن را به تلگرام بدهند. پس تنها دو متد خروجی‌ای که هیچ چیز تمرینشان نمی‌کرد دقیقاً همان دوتایی بودند که خراب بودند.

درمان: هرکدام یک خط، به‌علاوهٔ جدولی از هر متد خروجی که واقعاً صدا زده می‌شود در برابر یک ترابری ساختگی، به‌علاوهٔ تستی که آن جدول را با کلاس مقایسه می‌کند و وقتی متدی مورد آزمون ندارد شکست می‌خورد.

درسی که ارزش نگه‌داشتن دارد: حفره‌های پوشش خوشه‌ای‌اند. پیش از اینکه بپرسی کدام متدها مشکوک به نظر می‌رسند، بپرس کدام‌ها اصلاً تست ندارند.


تشخیص سرقت توکن تازه‌سازی اجرا شد، لاگ کرد، و کار خودش را خنثی کرد

چه شکلی بود: بازپخش توکن مصرف‌شده درست رد می‌شد، اما توکن تازه‌ترِ دارندهٔ قانونی هنوز کار می‌کرد. قرار بود کل خانواده مرده باشد.

چه بود: سرویس هنگام تشخیص استفادهٔ مجدد، کل خانواده را باطل می‌کرد و خطا می‌داد. خطا دادن باعث می‌شد نشستِ محدود به درخواست برگردد به عقب — از جمله همان ابطالی که تازه نوشته بود.

درمان: ابطال پیش از پرتاب خطا کامیت می‌شود. این تنها جایی است که یک سرویس کار خودش را کامیت می‌کند، و توضیح همان‌جا می‌گوید چرا.


تولید خودکار می‌خواست سی جدول دست‌نخورده را بازنویسی کند

چه شکلی بود: هر alembic revision --autogenerate برای هر ستون پولی در شِما یک ALTER COLUMN ... TYPE NUMERIC(28,4) می‌ساخت.

چه بود: Money یک TypeDecorator روی Numeric(28, 4) است. Alembic کلاس دکوراتور را با NUMERICی که از دیتابیس خوانده بود مقایسه کرد، دو کلاس متفاوت دید، و تغییر گزارش داد.

روی پستگرس هرکدام از آن بازنویسی‌ها کل جدول را به همان نوعی که دارد بازنویسی می‌کند.

تست انحراف موجود نمی‌توانست بگیردش: با compare_type=False اجرا می‌شد، که یعنی هرگز نمی‌توانست تغییر نوع واقعی را هم بگیرد.

درمان: migrations/comparators.py یک قلاب compare_type دارد که می‌داند Money چیست. ماژول جدایی دارد چون env.py فقط زیر Alembic وارد می‌شود، و قاعده‌ای به این پیامد باید مستقیم قابل آزمون باشد.


BadRequest به‌عنوان خطای شبکه بلعیده می‌شد

چه شکلی بود: هنوز هیچ. یک تست اول گرفتش.

چه بود: در python-telegram-bot، BadRequest زیرکلاس NetworkError است. بررسی خطای گذرا که به شکل isinstance(err, NetworkError) نوشته شده باشد، ردهای واقعی API را بی‌صدا و تا ابد تکرار می‌کرد.

درمان: type(err) is NetworkError.

درس: سلسله‌مراتب کلاس هر استثنایی را که گسترده می‌گیری بررسی کن.


یک پیشوند کال‌بک دو بار مسیریابی شده بود

چه شکلی بود: سه تست بی‌ربط روی تجزیهٔ UUID شکست می‌خوردند.

چه بود: rc: هم یعنی «گزارش CSV» و هم «تکرارشونده». اولین شاخهٔ منطبق برنده می‌شد، و دکمه‌های آن یکی امکان، کلیدواژهٔ گزارش را به‌عنوان UUID تجزیه می‌کردند.

درمان: تکرارشونده شد rr: — و حالا دو تست نگهبان روی AST هست: هیچ پیشوندی دو بار مسیریابی نمی‌شود، و هیچ پیشوندی که صفحه‌ای منتشر می‌کند بی‌مسیر نمی‌ماند. این‌ها رده را می‌گیرند، نه نمونه را.


تست‌هایی که امروز قبول می‌شدند و فردا مردود

چه بود: چند تست date.today() می‌خواندند. همان روزی که نوشته شدند قبول شدند.

درمان: تاریخ‌ها میخ‌کوب شده‌اند — DAY = date(2026, 8, 24). تستی که ساعت واقعی را می‌خواند تستی است که یک سه‌شنبهٔ آینده به دلایلی مردود می‌شود که هیچ‌کس به کد ربطشان نمی‌دهد.


dubious ownership in repository

چه شکلی بود: git pull وسط استقرار از اجرا سر باز می‌زد.

چه بود: گیت روی پوشه‌ای که مالکش کاربر دیگری است کار نمی‌کند. چک‌اوت مال root است؛ استقرار زیر sudo اجرا می‌شود که درست است، اما نشست ساده‌ی ssh روت نیست.

درمان: اسکریپت‌ها git config --global --add safe.directory را دقیقاً برای همان مسیر صدا می‌زنند، و need_root زود شکست می‌خورد با پیامی که می‌گوید از sudo استفاده کن، به‌جای اینکه بگذارد گیت پیام گیج‌کننده‌ای بدهد.


به‌روزرسانی موفقیت گزارش داد و API را خاموش گذاشت

چه شکلی بود: update.sh نوشت ✓ kasbbook-api: active, log clean, 0 restarts و با صفر بیرون آمد. API روی ModuleNotFoundError: No module named 'kasbbook.api' در حلقهٔ خرابی بود، و گامی که جلویش را می‌گرفت — pip install -e . — آشکارا در همان اسکریپتی هست که تازه چک‌اوت شده بود.

چه بود: اسکریپت وسط اجرا خودش را بازنویسی کرد. bash اسکریپت را تکه‌تکه می‌خواند و یک آفست بایتی در فایل نگه می‌دارد؛ git reset --hard چند خط پایین‌تر update.sh را سرجایش عوض کرد، و bash از همان آفست در محتوای جدید به خواندن ادامه داد. آنچه اجرا شد پیوند دو نسخه بود.

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

درمان: update.sh حالا پیش از دست‌زدن به درخت کار، خودش و lib.sh را به پوشه‌ای موقت کپی می‌کند و از آنجا exec می‌شود، با یک متغیر محیطی که جلوی بازگشت بی‌نهایت را می‌گیرد.

و بررسی سلامت سخت‌تر فریب می‌خورد:

  • NRestarts را پیش و پس از انتظار مقایسه می‌کند، چون Restart=always سرویسِ در حلقهٔ خرابی را تا ابد active نگه می‌دارد و این کلمه به‌تنهایی خیلی کم می‌گوید؛
  • --since "-10s" جای --since "10s ago" نشست، که شکل بدون ابهام است؛
  • API علاوه‌بر آن باید به /readyz جواب بدهد. پروسه‌ای که نپکیده با پروسه‌ای که سرویس می‌دهد یکی نیست.

درس: هر اسکریپتی که درختی را که خودش در آن زندگی می‌کند به‌روز می‌کند، نباید از همان درخت اجرا شود. این هم دربارهٔ update.sh صدق می‌کند و هم دربارهٔ هر چیزی به همین شکل.

ثبتی که بعد از نیمه‌شب انجام شد، تاریخ روز قبل خورد

چه شکلی بود: چیزی که ساعت یک بامداد به وقت تهران ثبت شد، تاریخ روز قبل را گرفت.

چه بود: سرور روی UTC است، و «امروز» از date.today() می‌آمد — یعنی تاریخ خود سرور. تهران سه‌ونیم ساعت جلوتر است، پس در سه‌ونیم ساعت اول هر روز ایرانی، سرور هنوز فکر می‌کرد دیروز است، و record() ثبت‌های بی‌تاریخ را با همان مهر می‌زد.

دیده نشد چون هیچ جای ربات ثبت‌ها را روز به روز نشان نمی‌داد. موقع ساختن همان یک صفحه‌ای پیدا شد که نشان می‌دهد — جایی که ثبتی غایب از لیستِ روزی که انجام شده، اولین چیزی است که هر کسی می‌بیند.

درمان: jalali.today_in(timezone) جواب می‌دهد «برای این آدم امروز چه روزی است»، و record() ثبت بی‌تاریخ را با روزِ دفترش مهر می‌زند. لیست روزانه و ثبت تکیِ تاریخ‌دار از روزِ خود آدم استفاده می‌کنند.

هنوز درست نشده: حدود بیست جای دیگر هنوز «امروز» را از سرور می‌پرسند — ماه بودجه، قواعد تکرارشونده، شروع وام، تاریخ‌های حقوق. هرکدام چیز متفاوتی را تصمیم می‌گیرند و هرکدام نگاه خودش را لازم دارد، نه یک جایگزینی کلی.


بررسی‌های روزمره

# واقعاً در حال اجراست، یا فقط استارت خورده؟
systemctl status kasbbook-bot kasbbook-api
journalctl -u kasbbook-bot -n 50 --no-pager

# آیا API فکر می‌کند به دیتابیس می‌رسد؟
curl -s http://127.0.0.1:8210/readyz

# شمای دیتابیس همان‌جاست که کد انتظار دارد؟
cd /opt/kasbbook && ./venv/bin/alembic current

# آخرین به‌روزرسانی چه کرد؟
git -C /opt/kasbbook log --oneline -5

تعداد راه‌اندازی مجدد بالای صفر روی سرویسی که کسی دستش نزده یعنی در حلقهٔ خرابی است. systemctl is-active باز هم می‌گوید active، چون Restart=always مدام برش می‌گرداند — و به همین دلیل است که اسکریپت‌های نصب و به‌روزرسانی به‌جای اعتماد به آن کلمه، ژورنال را دنبال ردپای خطا می‌گردند.