زمان تماشای واقعی از دل لاگ‌های پرنویز پلیر ویدیو

زمان تماشای واقعی از دل لاگ‌های پرنویز پلیر ویدیو

«این دانشجو واقعاً چقدر از این ویدیو را دیده؟» به نظر سؤالی است که جوابش یک خط است: پلیر می‌داند نشانگر کجاست،‏ ثانیه‌ها را جمع بزن. اما وقتی رویدادهای خام را باز می‌کنید،‏ آن یک خط از هم می‌پاشد. مکث‌هایی که هیچ‌وقت ثبت نشده‌اند،‏ تب‌هایی که وسط درس بسته شده‌اند،‏ گوشی‌هایی که ساعتشان چند دقیقه جلو یا عقب است،‏ کسانی که با سرعت دو برابر تماشا می‌کنند و کسانی که نوار پیشرفت را برای بار سوم عقب می‌کشند تا یک اثبات را دوباره ببینند.

برای یک پلتفرم بزرگ آموزش آنلاین فارسی‌زبان،‏ خط پردازشی را از نو ساختم که این داده‌ها را به اطلاعاتی قابل‌اتکا تبدیل می‌کند. برای هر دانشجو و هر ویدیو جواب می‌دهد: چند ثانیهٔ متمایز دیده شده،‏ کدام بخش‌ها دوباره دیده یا رد شده‌اند،‏ آیا ویدیو واقعاً تا آخر دیده شده و تماشا در طول روزها چطور پخش شده است. کار از یک نمونهٔ اولیه شروع شد که دو توسعه‌دهندهٔ دیگر در سال ۲۰۲۴ نوشته بودند. در سال ۲۰۲۶ سرویس دریافت رویدادها،‏ موتور تحلیل،‏ مدل ذخیره‌سازی و تست‌ها را بازنویسی کردم. این یادداشت دربارهٔ تصمیم‌هایی است که در عمل اهمیت داشتند.

پلیر چه چیزی می‌فرستد

پلیر وب با هر اتفاق یک رویداد کوچک JSON می‌فرستد: پخش،‏ مکث،‏ جابه‌جایی نشانگر (seek)،‏ تغییر سرعت یا کیفیت،‏ و یک ضربان دوره‌ای که می‌گوید «هنوز در حال پخش است». هر رویداد موقعیت در ویدیو،‏ زمان سمت کاربر و شناسهٔ یادگیرنده و ویدیو را دارد. خود فایل ویدیو هیچ‌وقت تحلیل نمی‌شود و هرچه در ادامه می‌آید از همین رویدادها استنتاج می‌شود.

دو ویژگی کل طراحی را شکل می‌دهد. رویدادها ارزان ارسال می‌شوند و حجمشان زیاد است،‏ پس دریافتشان هرگز نباید از سرعت پلیر بکاهد. و غیرقابل‌اعتماد بودن رویدادها امری عادی است،‏ نه استثنا؛‏ پس تحلیل باید دربارهٔ اتفاقی که احتمالاً بین دو رویداد افتاده استدلال کند،‏ نه این‌که به تک‌تک آن‌ها اعتماد کند.

دریافت: سریع بپذیر،‏ بعداً بنویس

سرویس دریافت یک Minimal API در ASP.NET Core روی .NET 10 است. مسیر درخواستش تقریباً هیچ کاری نمی‌کند: رویداد را در یک کانال حافظه‌ای با ظرفیت محدود می‌گذارد و 202 Accepted برمی‌گرداند. اگر کانال پر باشد،‏ پاسخ 503 با بدنهٔ ProblemDetails می‌دهد تا کلاینت عقب بنشیند و سرور از پا نیفتد. نسخهٔ ساده‌شدهٔ این اندپوینت:

app.MapPost("/events", (PlayerEvent e, EventBuffer buffer) =>
    buffer.TryEnqueue(e)
        ? Results.Accepted()
        : Results.Problem(title: "The event buffer is full.",
                          statusCode: StatusCodes.Status503ServiceUnavailable));

یک سرویس پس‌زمینه کانال را خالی می‌کند و هر وقت دسته پر شد یا زمان‌سنج به صدا درآمد،‏ هر کدام زودتر،‏ آن را می‌نویسد. هر نوشتن دسته را در جدول‌های موقت بار می‌کند و یک upsert مجموعه‌محور (set-based) را داخل یک تراکنش اجرا می‌کند؛‏ به این ترتیب SQL Server به‌جای هزاران عملیات کوچک،‏ چند عملیات بزرگ انجام می‌دهد.

جزئیاتی که در هر سیستم مشابهی تکرارش می‌کنم،‏ رفتار با دستهٔ معیوب است. اگر SQL Server دسته‌ای را با خطای داده رد کند (نقض قید،‏ مقدار بیش از حد بلند،‏ تبدیل ناموفق)،‏ سرویس همان دسته را ردیف‌به‌ردیف دوباره امتحان می‌کند،‏ ردیف‌هایی را که باز هم شکست می‌خورند در جدول خطاها می‌نویسد و ادامه می‌دهد. یک رویداد خراب نمی‌تواند هزاران رویداد سالمِ پشت سرش را متوقف کند. خطاهای زیرساختی فرق دارند: دسته در بافر می‌ماند و بعداً دوباره امتحان می‌شود،‏ چون داده سالم بود و پایگاه‌داده نه.

این مصالحه آگاهانه است و مستند شده: بافر حافظه‌ای اگر پروسه از کار بیفتد،‏ محتوایش را از دست می‌دهد. برای دادهٔ رفتاری،‏ این بهای قابل‌قبولی است در ازای مسیر درخواستی که زمانش با میلی‌ثانیه سنجیده می‌شود؛‏ برای پرداخت‌ها قابل‌قبول نیست. یک ناظر جداگانه هم وقتی مدتی طولانی‌تر از حد انتظار رویدادی نرسد به تیم هشدار می‌دهد،‏ که خرابی یک نسخهٔ پلیر را زودتر از هر داشبوردی نشان می‌دهد.

در یک بنچمارک با Locust روی یک ایستگاه کاری ۱۲ هسته‌ای،‏ با پخش دوبارهٔ ۱۰٬۰۰۰ رویداد و ۵۰ کاربر هم‌زمان،‏ همهٔ درخواست‌ها بدون خطا پذیرفته شدند؛‏ حدود ۲٬۴۵۰ درخواست در ثانیه،‏ میانهٔ تأخیر ۱۳ میلی‌ثانیه و صدک ۹۵ برابر ۲۱ میلی‌ثانیه. مصرف حافظهٔ سرویس هم در اوج حدود ۱۰۸ مگابایت بود.

از رویداد تا ثانیه‌های دیده‌شده

موتور تحلیل C# خالص و بدون وابستگی است و همین تست کامل آن را آسان کرد. برای هر جفت (یادگیرنده،‏ ویدیو)،‏ کل تاریخچهٔ رویدادها را به ترتیب زمان می‌خواند و جلسه‌های پخش را بازسازی می‌کند. بیشتر کار را چهار قاعده انجام می‌دهد.

۱. پیشرفت باید از نظر فیزیکی ممکن باشد

بین دو رویداد پشت‌سرهم،‏ نشانگر چند ثانیه از ویدیو جلو رفته و در همین مدت چند ثانیه از زمان واقعی گذشته است. اگر ویدیو بیش از آن جلو رفته باشد که بیشترین سرعت مجاز پخش (دو برابر) توضیحش بدهد،‏ آن بازه دیده‌شده حساب نمی‌شود؛‏ اتفاق دیگری افتاده،‏ معمولاً یک seek که دیر ثبت شده یا اصلاً ثبت نشده. بازه‌های بدون پیشرفت رو به جلو یا طولانی‌تر از خود ویدیو هم به همین دلیل کنار گذاشته می‌شوند.

static bool IsPlausible(double videoSeconds, TimeSpan wallClock,
                        double videoLength, double maxSpeed = 2.0) =>
    videoSeconds > 0
    && videoSeconds <= videoLength
    && videoSeconds <= maxSpeed * wallClock.TotalSeconds;

۲. دهم‌ثانیه‌ها را بشمار،‏ نه ثانیه‌ها را

پوشش با دقت ۰٫۱ ثانیه و با بازه‌های نیم‌باز دنبال می‌شود تا دو بازهٔ مجاور،‏ مرز مشترکشان را دو بار حساب نکنند. هر ثانیه از ویدیو فقط وقتی دیده‌شده به حساب می‌آید که دست‌کم نیمی از ده دهم‌ثانیه‌اش پوشش داده شده باشد. این قاعده از دو خطای متضاد جلوگیری می‌کند: اعتبار دادن یک ثانیهٔ کامل به seekی که روی ۱۲٫۹ فرود آمده،‏ و از دست دادن ثانیه‌ای که در دو تکهٔ کوتاه دیده شده است.

۳. مکث‌هایی را که کسی ثبت نکرده استنتاج کن

وقتی ویدیو پخش می‌شود،‏ پلیر در فاصله‌های منظم ضربان می‌فرستد. همین سکوت را معنادار می‌کند. اگر از آخرین رویداد خیلی بیشتر از آن گذشته باشد که پخش با کمترین سرعت مجاز،‏ به‌اضافهٔ یک حاشیهٔ تحمل،‏ توضیحش بدهد،‏ تقریباً قطعی است که یادگیرنده دست از تماشا کشیده و رویداد مکثی به سرور نرسیده است: تب بسته شده،‏ اتصال قطع شده،‏ درِ لپ‌تاپ بسته شده. موتور به‌جای این‌که کل این فاصله را دیده‌شده حساب کند،‏ در آخرین موقعیتی که می‌شود به آن اعتماد کرد یک مکث مجازی می‌گذارد.

همین بازسازی بقیهٔ حالت‌های روزمره را هم پوشش می‌دهد. seek پخش را از موقعیت مقصد ادامه می‌دهد. تغییر تنظیمات نمی‌تواند جلسه‌ای را که بسته شده دوباره باز کند. جلسه‌ای که در پایان تاریخچه هنوز باز مانده،‏ در آخرین موقعیت شناخته‌شده بسته می‌شود.

۴. دوباره‌دیدن باید شبیه دوباره‌دیدن باشد

ثانیه‌هایی که بیش از یک بار دیده شده‌اند برای مدرس‌ها جالب‌اند،‏ چون بخش‌های سخت درس را نشان می‌دهند. اما یادگیرنده‌ای که دو ثانیه عقب می‌رود تا یک کلمه را بشنود،‏ چیزی را دوباره ندیده است. موتور فقط وقتی یک بازه را تکراری گزارش می‌کند که هر ثانیهٔ آن دست‌کم دو بار دیده شده باشد و طول آن دست‌کم چهار ثانیه باشد؛‏ یک حفرهٔ یک‌ثانیه‌ای داخل بازه هم پذیرفته می‌شود. بازه‌های ردشده هم از همین خط زمانی،‏ این بار در جهت مخالف،‏ به دست می‌آیند.

زمان سخت‌تر از چیزی است که به نظر می‌رسد

دو تصمیم کوچک‌تر دسته‌های کاملی از نتیجه‌های عجیب را از بین برد. اولی دربارهٔ ساعت کاربر است: زمانی که خود رویداد گزارش می‌کند فقط وقتی استفاده می‌شود که حداکثر ۳۰ دقیقه با لحظهٔ دریافت در سرور فاصله داشته باشد؛‏ در غیر این صورت زمان سرور ملاک است. گوشی‌ای که تاریخش اشتباه تنظیم شده،‏ دیگر نمی‌تواند درس خواندن کسی را به ماه بعد منتقل کند.

دومی نیمه‌شب است. پوشش روزانه برای تحلیل یادگیری مهم است («هر روز کمی درس خوانده؟»)،‏ پس بازه‌ای که از نیمه‌شب عبور می‌کند تقسیم می‌شود. پیشرفت داخل بازه یکنواخت فرض می‌شود و هر ثانیه از ویدیو به روزی نسبت داده می‌شود که نقطهٔ میانی آن ثانیه در آن روز است. بُعد تاریخ هم فیلدهای تقویم شمسی دارد،‏ چون کسانی که گزارش‌ها را می‌خوانند با ماه‌های شمسی فکر می‌کنند.

ذخیرهٔ نتیجه‌ها

نتیجه‌ها در یک طرح‌وارهٔ ستاره‌ای کوچک در SQL Server ذخیره می‌شوند: بُعدهای تاریخ و ویدیو،‏ به‌اضافهٔ جدول‌های واقعیت برای تماشای هر جفت،‏ پوشش روزانه،‏ کنش‌های هر ثانیه،‏ بازه‌های ردشده و ثانیه‌های تکراری. دسته‌ها با SqlBulkCopy نوشته می‌شوند،‏ روی اتصالی که عمداً Multiple Active Result Sets (MARS) در آن خاموش است؛‏ کپی‌های انبوهِ حجیم و ناهم‌زمان در کنار MARS روی یک اتصال،‏ ترکیبی است که بهتر است از آن پرهیز کرد.

محاسبهٔ دوباره از اساس idempotent طراحی شده است. وقتی برای یک جفت رویداد تازه می‌رسد،‏ موتور کل تاریخچهٔ آن جفت را از نو محاسبه می‌کند و یک تصویر تازه با برچسب کاری که آن را ساخته می‌نویسد و بعد تصویرهای قدیمی‌ترِ همان جفت‌ها را پاک می‌کند. اجرای دوبارهٔ یک کار،‏ یا اجرای هم‌زمان دو کار که هم‌پوشانی دارند،‏ به همان وضعیت می‌رسد و هیچ چیزی دو بار شمرده نمی‌شود. خود کارها هم ردیف‌هایی در یک جدول صف با یک watermark روی لاگ رویدادها هستند؛‏ پس رویدادهای تازه به‌صورت افزایشی پردازش می‌شوند و اجرایی که قطع شده از همان جایی که ایستاده ادامه پیدا می‌کند.

تست موتوری که جواب درستش را نمی‌دانید

سخت‌ترین بخش تست چنین موتوری،‏ پیدا کردن جواب درست است: برای یک تاریخچهٔ درهم‌ریخته،‏ جواب صحیح چیست؟ روشی که انتخاب کردم این بود که مقدارهای مورد انتظار هر تاریخچهٔ تست را مستقل و پیش از اجرای موتور به دست بیاورم و فقط بعد از آن مقایسه کنم. مجموعهٔ تست‌ها به ۲۸۲ تست موفق رسید: تاریخچه‌هایی که برای الگوهای مشخص انتخاب شده‌اند (فاصله‌ها،‏ seekها،‏ ضربان‌ها،‏ نیمه‌شب،‏ تغییر سرعت)،‏ تاریخچه‌های نمونه‌گیری‌شدهٔ تصادفی،‏ و سناریوهای کوچک دست‌ساز برای هر قاعدهٔ بالا.

علاوه بر تست‌های واحد،‏ یک ممیزی هم در کار است. پیاده‌سازی جداگانه‌ای از همین قاعده‌ها به زبان پایتون،‏ رویدادهای خام یک نمونهٔ تصادفی از جفت‌ها را می‌خواند،‏ هر پنج جدول واقعیت را از نو حساب می‌کند و با چیزی که خط پردازش C# ذخیره کرده مقایسه می‌کند. در آخرین ممیزی همهٔ جفت‌های نمونه در همهٔ جدول‌ها منطبق بودند. وقتی دو پیاده‌سازی به دو زبان مختلف روی داده‌ای درهم‌ریخته به یک جواب می‌رسند،‏ این نشانهٔ خیلی قوی‌تری است از این‌که هر کدام تست‌های خودش را پاس کند.

قدم‌های بعدی

  • تحلیل جفت‌ها را یکی‌یکی در یک حلقه پردازش می‌کند. ساده و درست است،‏ اما کار ذاتاً موازی‌پذیر است و تقسیم جفت‌ها بین چند پردازشگر قدم بعدی بدیهی است.
  • تبدیل اشیای نتیجه به DataTable برای SqlBulkCopy با reflection انجام می‌شود. یک IDataReader نوع‌دار تخصیص حافظه را در دسته‌های بزرگ کم می‌کند.
  • بافر دریافت و ناظر عدم فعالیت عمداً تک‌نمونه‌ای‌اند. در مقیاس فعلی مشکلی نیست و اگر سرویس روزی روی بیش از یک گره اجرا شود،‏ اولین چیزی است که باید بازبینی شود.

اگر تعامل کاربران را از روی رویدادهای پلیر اندازه می‌گیرید و برای قاعده‌ها یا خط پردازش یک نگاه دوم لازم دارید،‏ این دقیقاً از جنس کارهای بک‌اند و داده‌ای است که انجام می‌دهم؛‏ صفحهٔ همکاری را ببینید.

این یادداشت به انگلیسی هم منتشر شده است: نسخهٔ انگلیسی.

بازگشت به همهٔ یادداشت‌ها