مقاله فنی

لود تدریجی PDF با بازه در Delphi با PDFlibPas

یک آرشیو اسکن‌شده 2 گیگابایتی در یک باکت S3 زندگی می‌کند و کاربر صفحه 900 را می‌خواهد. PDFlibPas می‌تواند آن صفحه را بدون دانلود کل فایل سرو کند: LoadFromRangeSource روی کال‌بک بازه بایت خودتان یک استریم فقط‌خواندنی قابل seek می‌سازد و به TPDFDocument می‌دهد، تا تجزیه‌گر جداول ارجاع متقابل، یک شاخه درخت صفحه و یک استریم محتوا را بکشد

سمت حمل‌ونقل این ماجرا قدیمی و خسته‌کننده است. سرورهای HTTP دهه‌هاست بازه بایت را اعلان می‌کنند، اکنون در RFC 9110 §14 مشخص‌شده، و هر object store همین گویش را صحبت می‌کند. سمت PDF هم همین‌قدر جاافتاده است: ISO 32000-1 §7.5.8 خطی‌سازی را دقیقاً برای این تعریف کرده که خواننده بتواند صفحه اول را از ابتدای فایل رندر کند. آن‌چه در Delphi جا مانده بود قطعه وسط است؛ بخشی که تصمیم می‌گیرد کدام بازه‌ها درخواست شوند، چند تای آن‌ها نگه داشته شوند و چطور از درخواست دوباره پرهیز شود

‏LoadFromRangeSource از لایه حمل‌ونقل شما چه می‌خواهد؟

دو چیز، و هیچ‌کدام استریم نیست. PDFlibPas یک SourceSize معتبر و یک کال‌بک خواندن همگام از نوع TPDFlibRangeReadEvent می‌خواهد، اعلان‌شده به شکل function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. در داخل این جفت به یک TCallbackByteRangeSource تبدیل می‌شود که SourceSize و ReadRange را عرضه می‌کند، در یک استریم پیچیده شده که مالکیتش به سند می‌رسد. هدف کال‌بک شما و بک‌اندش مال خودتان می‌ماند: سند روی بستن، پاک کردن یا لود مجدد wrapper را آزاد می‌کند، اما هرگز به شیء حمل‌ونقل پشت اشاره‌گر متد دست نمی‌زند

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

type
  TObjectStoreSource = class
  private
    FClient: TRangeHttpClient;
    FSize: Int64;
  public
    function ReadRange(Sender: TObject; Offset: Int64;
      Buffer: Pointer; Count: LongInt): LongInt;
    function IsResident(Sender: TObject; Offset: Int64;
      Count: LongInt): Integer;
    property Size: Int64 read FSize;
  end;

function TObjectStoreSource.ReadRange(Sender: TObject; Offset: Int64;
  Buffer: Pointer; Count: LongInt): LongInt;
begin
  { یک GET مسدودکننده با Range: bytes=Offset-(Offset+Count-1) }
  Result := FClient.FetchInto(Offset, Count, Buffer);
end;

{ ... }
Lib := TPDFlib.Create;
Src := TObjectStoreSource.Create(BucketUrl);
try
  if Lib.LoadFromRangeSource(Src.Size, Src.ReadRange, '',
       65536, 8 * 1024 * 1024, 2, Src.IsResident) = 1 then
    Lib.SelectPage(900);
finally
  Lib.Free;  { استریم wrapper را آزاد می‌کند }
  Src.Free;  { لایه حمل‌ونقل شما، طول عمر شما }
end;

کش بازه واقعاً چقدر نگه می‌دارد؟

به‌طور پیش‌فرض 4 مگابایت، پخش‌شده روی پنجره‌های هم‌راستا با chunk و تخلیه‌شده به روش LRU. طراحی تک‌پنجره قبلی تا هر طولی که فراخوان می‌خواست رشد می‌کرد، پس یک خواندن ترتیبی بزرگ می‌توانست از اندازه اسمی chunk عبور کند در حالی که یک پرش تصادفی پنجره قبلی را فوراً دور می‌انداخت. کش فعلی هر آفست منبع را به ChunkSize هم‌راستا می‌کند، دقیقاً یک chunk به ازای هر miss می‌گیرد و بودجه بایتی سختی را روی چند پنجره اعمال می‌کند. هر بودجه صریحی که بدهید دست‌کم به یک chunk کامل بالا برده می‌شود، پس یک خواندن تنها همیشه chunk به chunk جلو می‌رود و اوج بار کش قابل پیش‌بینی می‌ماند. ChunkSize زیر 4096 به پیش‌فرض 64 کیلوبایت برمی‌گردد

نحوه سرو کردن یک خواندن تجزیه‌گر در PDFlibPas در Delphi بدون دانلود PDF: آفست مطلق به پایین به اندازه chunk هم‌راستا می‌شود، در برخورد از یکی از چند پنجره LRU سرو می‌شود، یا در miss به یک فراخوان کال‌بک تنها و بُریده تبدیل می‌شود
هر آفست منبع به اندازه chunk هم‌راستا می‌شود، پس یک miss دقیقاً یک chunk می‌گیرد و اوج بار کش قابل پیش‌بینی می‌ماند

حسابداری خواندن تکراری بخشی است که ارزش اتصال به تلمتری شما را دارد. PDFlibPas تکرار را با شروع chunk هم‌راستا شناسایی می‌کند و بازه‌های پیوسته مرتب نگه می‌دارد؛ این کار یک واکشی واقعاً اول را از واکشی مجدد پس از تخلیه جدا می‌کند در حالی که از رشد خطی دفترچه‌ها با اندازه فایل جلوگیری می‌کند. GetRangeSourceCacheInfo کل تصویر را به‌شکل JSON برمی‌گرداند، SetRangeSourceCacheLimit بودجه را در زمان اجرا تغییر می‌دهد و ClearRangeSourceCache پنجره‌ها و آمار را با هم صفر می‌کند. کوچک کردن بودجه در زمان اجرا تاریخچه را نگه می‌دارد و رهاسازی‌های ناشی از بودجه را به‌شکل تخلیه می‌شمارد، پس بالا رفتن repeatedReads در برابر hits ثابت سیگنال شماست که مجموعه کاری دیگر جا نمی‌شود

var
  Info: WideString;
begin
  Lib.SetRangeSourceCacheLimit(16 * 1024 * 1024);
  Lib.SelectPage(900);
  if Lib.GetRangeSourceCacheInfo(Info) = 1 then
    { "windowCount", "cacheLimitBytes", "cachedBytes", "hits", "misses",
      "evictions", "sourceReads", "sourceBytes", "repeatedReads",
      "coalescedRequests", "coalescedSourceReads" }
    LogRangeStats(Info);
end;

وقتی چند نخ همان chunk را می‌خواهند چه می‌شود؟

روی یک درخواست منتظر می‌مانند نه چند درخواست. یک TStream کلاسیک یک مکان‌نما دارد و دو نخ که هر کدام درست قفل کرده‌اند باز هم می‌توانند آن موقعیت را میان یک Seek و یک Read بازنویسی‌شده ببینند، پس اشیای تنبل و خواندن‌های قطعه‌قطعه در PDFlibPas از یک ReadAt مطلق استفاده می‌کنند که هرگز مکان‌نما را تکان نمی‌دهد. هر chunk هم‌راستا یک درخواست در جریان می‌گیرد که هر فراخوانی آن chunk آن را به اشتراک می‌گذارد، chunk‌های همسایه در صف پیش از شروع خواندن منبع ادغام می‌شوند و یک خواندن فیزیکی سقف 16 مگابایت دارد، پس موج موازی‌کاری صفحه نه به درخواست‌های کوچک تکراری می‌انجامد نه به یک درخواست پهناور مضحک. پنجره ادغام پیش‌فرض 2 میلی‌ثانیه است و فقط به اولین chunk جاافتاده هر ReadAt اعمال می‌شود؛ Read مبتنی بر موقعیت هرگز برایش منتظر نمی‌ماند و دادن صفر تاخیر جمع‌آوری اولیه را کاملاً حذف می‌کند که برای اسکن‌های ترتیبی بلند اهمیت دارد که در غیر این صورت انتظار را chunk به chunk انباشته می‌کنند. موقعیت، فراداده کش و خواندن‌های منبع پشت سه قفل جدا هستند و خود کال‌بک منبع سریالی می‌شود؛ همین چیزی است که اجازه می‌دهد یک آداپتور پایگاه داده یا object store بدون حفاظت داخلی نخ، بدون تغییر استفاده شود. منتظران نسخه خودشان از داده را می‌گیرند، پس یک تخلیه LRU بعدی نمی‌تواند بافری را که تحویل داده شده بی‌اعتبار کند

ادغام درخواست در لود بازه PDFlibPas برای Delphi: دو نخ که همان chunk را می‌خواهند یک درخواست در جریان را به اشتراک می‌گذارند، chunk‌های همسایه در صف داخل پنجره دو میلی‌ثانیه‌ای ادغام می‌شوند و یک خواندن سریالی منبع همه را سرو می‌کند
موج موازی‌کاری صفحه به یک درخواست مشترک به ازای هر chunk فرو می‌ریزد و هر منتظر همچنان نسخه خودش از بایت‌ها را می‌گیرد

می‌شود پرسید صفحه 900 آماده است بدون واکشی‌اش؟

بله، و دقیقاً برای همین کال‌بک در‌دسترس‌بودن اختیاری وجود دارد. یک کال‌بک خواندن ساده نمی‌تواند بایت‌هایی را که رسیده‌اند از بایت‌هایی که به رفت‌وبرگشت مسدودکننده نیاز دارند تفکیک کند و کاوش با یک خواندن آزمایشی همان دانلودی را راه می‌اندازد که می‌خواهید ازش پرهیز کنید. TPDFlibRangeAvailabilityEvent فقط به یک پرسش جواب می‌دهد، اینکه آیا یک بازه کامل فوراً خواندنی است، و از واکشی هر چیز منع است؛ بایت‌هایی که کش پوشش می‌دهد همیشه در‌دسترس حساب می‌شوند. GetRangeSourceDataAvailability اشیای غیرمستقیم را به بازه‌های ذخیره‌سازی فیزیکی ثبت‌شده در مدخل‌های ارجاع متقابل نگاشت می‌کند، اشیای فشرده را به ظرف object stream آن‌ها حل می‌کند، هدر PDF جابه‌جاشده را جبران می‌کند و یک شیء را فقط بعد از اینکه کل بازه کاوش غیرواکشی را پاس کرد تجزیه می‌کند، پس مسیر جاافتاده هرگز کال‌بک خواندن شما را صدا نمی‌زند

پیمایش محدوده‌دار است نه جامع. یک پرسش صفحه فقط شاخه درخت صفحه حاوی صفحه هدف را طی می‌کند و بعد محتوای صفحه، منابع، حاشیه‌نویسی‌ها و ویژگی‌های صفحات ارثی را اضافه می‌کند و یال‌های برگشتی Parent و P را می‌پرد تا یک صفحه یا ویجت تنها نتواند به عقب به کل سند باز شود. گراف شیء در 100000 شیء درخواستی و عمق 256 محدود است، اشیای استریم اول دیکشنری تجزیه می‌شوند و مسیر جایگزین تجزیه کامل فقط برای اشیای ذخیره‌شده تا 4 مگابایت مجاز است. گزارش JSON بازه‌های هم‌پوشان و مجاور را پیش از شمارش ادغام می‌کند، پس requiredBytes و missingBytes از آرایه‌های ادغام‌شده requiredRanges و missingRanges محاسبه می‌شوند که end آن‌ها نقطه پایانی شمولی است. پرسیدن از شیئی که از قبل در‌دسترس است ممکن است کش بازه را پُر کند؛ پرسیدن از شیء جاافتاده آمار خواندن را دست‌نخورده می‌گذارد

var
  Report: WideString;
  Status: Integer;
begin
  Status := Lib.GetRangeSourceDataAvailability(PDF_RANGE_DATA_PAGE, 900,
    Report);
  if Status = PDF_RANGE_DATA_AVAILABLE then
    RenderPageNow
  else if Status = PDF_RANGE_DATA_NOT_AVAILABLE then
    { Report شامل "missingBytes" و "missingRanges" ادغام‌شده است }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { مثلاً فایل اصلاً AcroForm ندارد }
end;

چرا prefetch باید تکرار کند

چون خواندن یک بارِ missingRanges فعلی صفحه را در‌دسترس نمی‌کند. یک گره درخت صفحه جاافتاده یا یک object stream فقط پس از رسیدنش لایه بعدی وابستگی‌ها را نشان می‌دهد، پس یک کار prefetch در PDFlibPas حلقه پرسش، واکشی، پرسش مجدد را تا کامل شدن در‌دسترس‌بودن صفحه، فرم یا گراف شیء یا رسیدن به سقف بایت یا گذر اجرا می‌کند. کار از خواننده خودش و یک کش دوم کوچک استفاده می‌کند که منبع داده‌اش خواندن‌های مطلق را به استریم بازه اصلی می‌فرستد؛ این حالت تجزیه را از TSmartPDFReader پیش‌زمینه جدا نگه می‌دارد در حالی که بایت‌هایی که واقعاً دانلود می‌کند در کش اصلی مشترک فرود می‌آیند. به ازای هر استریم بازه یک نخ کارگر وجود دارد، هم‌خوان با سریال‌سازی‌ای که کال‌بک منبع از قبل می‌طلبد، و صف اول با چهار سطح اولویت و بعد با ترتیب ارسال داخل سطح برمی‌دارد. MaxBytes به بایت‌های فیزیکی chunk محاسبه می‌شود، پس تجزیه‌گری که یک بایت از داخل یک chunk کش‌نشده بخواهد کل chunk را می‌پردازد، در حالی که chunk‌های موجود در کش مشترک برای کار هیچ خرجی ندارند. لغو یک کار در صف به حالت پایانی با صفر خواندن منبع می‌رسد؛ کار در حال اجرا پیش از هر گذر وابستگی و هر chunk منبع بررسی می‌شود و آزاد کردن استریم بازه منتظر بازگشت یک کال‌بک در جریان می‌ماند به‌جای تلاش برای وقفه

حلقه prefetch در PDFlibPas در Delphi: یک کار در‌دسترس‌بودن را می‌پرسد، بازه‌های جاافتاده را واکشی و دوباره می‌پرسد، چون هر گره درخت صفحه یا object stream رسیدنی لایه بعدی وابستگی‌ها را نشان می‌دهد، تا گراف کامل شود یا یک حد متوقفش کند
کار prefetch تکرار می‌کند چون یک گره جاافتاده فقط وقتی می‌رسد فرزندانش را نام می‌برد و هر گذر را به chunkهای فیزیکی کامل محاسبه می‌کند
var
  Job: Integer;
  Info: WideString;
begin
  Job := Lib.StartRangeSourcePrefetch(PDF_RANGE_DATA_PAGE, 901,
    PDF_RANGE_PREFETCH_PRIORITY_HIGH, 8 * 1024 * 1024, 65536);
  if Lib.WaitForRangeSourcePrefetch(Job, 5000) =
       PDF_RANGE_PREFETCH_STATE_COMPLETED then
    PrepareNextPage
  else
    Lib.CancelRangeSourcePrefetch(Job);
  { "passes"، "plannedRanges"، "sourceReads"، "fetchedBytes" و آخرین
    گزارش کامل در‌دسترس‌بودن، تا LIMIT_REACHED از FAILED
    تفکیک‌پذیر بماند }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

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

لود بازه شرط‌بندی روی چیدمان فایل است و بعضی فایل‌ها به آن وفادار نیستند. یک فایل خطی‌شده طبق ISO 32000-1 §7.5.8 حالت خوب است: بخش صفحه اول هنگام باز کردن گرم می‌شود، هم با آستانه ایمنی 4 مگابایتی موجود و هم با بودجه کش فعلی محدود، تا گرم‌کردن نتواند فوراً بیشتر خودش را تخلیه کند. یک فایل غیرخطی همچنان از طریق trailer و زنجیره ارجاع متقابل نزدیک انتها حل می‌شود که هزینه‌اش چند رفت‌وبرگشت اضافی است نه یک فاجعه. صخره واقعی فایل آسیب‌دیده‌ای است که مسیر تعمیر را اجباری می‌کند، چون بازسازی جدول ارجاع متقابل یعنی جست‌وجوی هدرهای شیء روی کل سند، و آن یک دانلود کامل است که chunk به chunk می‌رسد. تاخیر حد صادقانه دیگر است: با 60 میلی‌ثانیه به ازای هر درخواست، یک تجزیه دسترسی‌تصادفی که به چهل chunk کش‌نشده نیاز دارد فارغ از کیفیت کش بیش از دو ثانیه در انتظار می‌گذراند، دقیقاً همان چیزی که استدلال read-ahead و صف اولویت برای پنهان کردنش وجود دارند. همین نظم در رویکرد دسترسی مستقیم برای ادغام و تقسیم PDFهای بزرگ هم دیده می‌شود، و این کش زیر رندر موازی صفحه و کش صفحه دیسک نمایشگر یکسان می‌نشیند

API منبع بازه، پرسش در‌دسترس‌بودن و زمان‌بند prefetch بخشی از PDFlibPas Delphi PDF Library استاندارد برای Delphi، C++Builder و Free Pascal هستند؛ صفحه محصول مرجع کامل پارامترهای LoadFromRangeSource را همراه ثابت‌های اولویت و حالت prefetch حمل می‌کند