מאמר טכני

טעינת PDF טווח־אחר־טווח ב־Delphi עם PDFlibPas

ארכיון סרוק בן 2 GB יושב בדלי S3 והמשתמש רוצה את עמוד 900. PDFlibPas יכול לספק את העמוד הזה בלי להוריד את הקובץ: LoadFromRangeSource בונה זרם ניתן לחיפוש לקריאה בלבד מעל פונקציית הקריאה החוזרת שלכם לטווחי בייטים ומוסר אותו ל־TPDFDocument, כך שהמפענח שולף את טבלאות ההפניות הצולבות, ענף אחד של עץ העמודים וזרם תוכן אחד

צד התחבורה של זה ישן ומשעמם. שרתי HTTP מפרסמים טווחי בייטים כבר עשרות שנים, כיום במפרט RFC 9110 §14, וכל מאגר אובייקטים מדבר את אותו ניב. צד ה־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, עטוף בזרם שהבעלות עליו עוברת למסמך. יעד הפונקציה החוזרת והבק־אנד שלו נשארים שלכם: המסמך משחרר את העטיפה בסגירה, בניקוי או בטעינה מחדש, אבל לעולם לא נוגע באובייקט התחבורה שמאחורי מצביע המתודה

החוזה סלחני בכוונה בכיוון אחד וקפדני בשני. קריאה קצרה היא חוקית ופירושה פשוט שהמפענח שואל שוב. פונקציה חוזרת שמעלה חריגה מומרת לקריאה קצרה ומתכנסת דרך נתיב כשל הטעינה הרגיל. פונקציה חוזרת שטוענת שכתבה יותר מ־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;  { משחרר את זרם העטיפה }
  Src.Free;  { התחבורה שלכם, משך החיים שלכם }
end;

כמה בפועל מחזיק מטמון הטווחים?

4 MiB כברירת מחדל, מפוזר על חלונות מיושרים למקטעים ומפונה לפי LRU. התכנון הקודם של חלון יחיד גדל לכל אורך שהקורא ביקש, כך שקריאה סדרתית גדולה אחת יכלה לעבור את גודל המקטע הנומינלי בעוד קפיצה אקראית השליכה מיד את החלון הקודם. המטמון הנוכחי מיישר כל היסט מקור ל־ChunkSize, שולף בדיוק מקטע אחד לכל החטאה ואוכף תקציב בייטים נוקשה על פני כמה חלונות. כל תקציב מפורש שתעבירו מוגבה לפחות למקטע מלא אחד, כך שקריאה בודדת תמיד מתקדמת מקטע אחר מקטע ועומס השיא של המטמון נשאר צפוי. ChunkSize מתחת ל־4096 נופל בחזרה לברירת המחדל של 64 KiB

איך PDFlibPas משרת קריאת מפענח ב־Delphi בלי להוריד את ה־PDF: ההיסט המוחלט מיושר מטה לגודל המקטע, מוגש מאחד מכמה חלונות LRU בפגיעה, או הופך לקריאת פונקציה חוזרת אחת מוגבלת בהחטאה
כל היסט מקור מיושר לגודל המקטע, כך שהחטאה אחת שולפת בדיוק מקטע אחד ועומס השיא של המטמון נשאר צפוי

ההנהלה של קריאות חוזרות היא החלק ששווה לחבר לטלמטריה שלכם. PDFlibPas מזהה חזרה לפי התחלה מיושרת של מקטע ומחזיק מרווחים רצופים מסודרים, מה שמבדיל שליפה ראשונה אמיתית משליפה חוזרת אחרי פינוי ומונע מהניהול לגדול ליניארית עם גודל הקובץ. 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;

מה קורה כשכמה תהליכונים רוצים את אותו מקטע?

הם ממתינים לבקשה אחת, ולא לכמה. ל־TStream קלאסי יש סמן מיקום יחיד, ושני תהליכונים שכל אחד מהם נועל נכון עדיין יכולים למצוא את המיקום הזה נכתב מחדש בין Seek ל־Read, ולכן אובייקטים עצלים וקריאות מקוטעות ב־PDFlibPas משתמשים ב־ReadAt מוחלט שלא מזיז את הסמן. לכל מקטע מיושר יש בקשה בלתי גמורה אחת שכל קורא לאותו מקטע חולק, מקטעים סמוכים בתור ממוזגים לפני תחילת הקריאה מהמקור, וקריאה פיזית אחת תקועה בתקרה של 16 MiB, כך שהתפרצות של עבודת עמודים מקבילה אינה מתגברת לא לבקשות קטנות כפולות ולא לאחת גדולה הזויה. חלון המיזוג כברירת מחדל 2 ms וחל רק על המקטע החסר הראשון של כל ReadAt; Read ממוקם לעולם לא ממתין לו, והעברת אפס מסירה לגמרי את עיכוב האיסוף הראשוני, דבר שחשוב לסריקות סדרתיות ארוכות שאחרת היו צוברות את ההמתנה מקטע אחר מקטע. מיקום, מטא־דאטה של המטמון וקריאות מקור יושבים מאחורי שלוש נעילות נפרדות, ופונקציית הקריאה החוזרת של המקור עצמה מסודרת בטור, וזה מה שמאפשר להשתמש בלתי משונה במתאם מסד נתונים או מאגר אובייקטים בלי הגנת תהליכונים פנימית. הממתינים מקבלים עותק משלהם של הנתונים, כך שפינוי LRU מאוחר לא יכול לפסול חוצץ שכבר נמסר

מיזוג בקשות בטעינת טווחים של PDFlibPas עבור Delphi: שני תהליכונים המבקשים את אותו מקטע חולקים בקשה בלתי גמורה אחת, מקטעים סמוכים בתור ממוזגים בתוך חלון של שתי מילישניות, וקריאת מקור אחת מסודרת בטור משרתת את כולם
התפרצות של עבודת עמודים מקבילה מתקפלת לבקשה משותפת אחת לכל מקטע, וכל ממתין עדיין מקבל עותק משלו של הבייטים

אפשר לשאול אם עמוד 900 מוכן בלי לשלוף אותו?

כן, ובדיוק לשם כך קיימת פונקציית הקריאה החוזרת האופציונלית לזמינות. פונקציית קריאה חוזרת רגילה אינה יכולה להבדיל בייטים שכבר נחתו מבייטים שדורשים סבב חוסם, ובדיקה בקריאת ניסיון תפעיל בדיוק את ההורדה שאתם מנסים להימנע ממנה. TPDFlibRangeAvailabilityEvent עונה על שאלה אחת בלבד, האם טווח שלם יכול להיקרא מיד, ואסור לה לשלוף דבר; בייטים שהמטמון כבר מכסה נחשבים תמיד זמינים. GetRangeSourceDataAvailability ממפה אובייקטים עקיפים לטווחי האחסון הפיזיים הרשומים ברשומות ההפניה הצולבת, מפענח אובייקטים דחוסים למכל ה־object stream שלהם, מתקן עבור כותרת PDF מוסטת, ומפענח אובייקט רק אחרי שהטווח המלא עובר את הבדיקה הבלתי שולפת, כך שהנתיב החסר לעולם לא קורא לפונקציית הקריאה החוזרת שלכם

הסריקה ממוקדת בהיקף ולא ממצה. שאילתת עמוד הולכת רק על הענף של עץ העמודים שמכיל את עמוד היעד ואז מוסיפה תוכן עמוד, משאבים, הערות ותכונות עמוד שהורשו, מדלגה על קשתות חזרה של Parent ושל P כך שעמוד או ווידגט בודד לא יכולים להתפשט אחורה אל כל המסמך. גרף האובייקטים תחום ב־100000 אובייקטים מבוקשים ובעומק 256, אובייקטי זרם מפוענחים קודם כל המילון, ונפילה לפענוח מלא מותרת רק לאובייקטים שמורים עד 4 MiB. דוח ה־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
    { הדוח נושא את "missingBytes" ועוד את "missingRanges" הממוזגים }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { למשל: לקובץ אין AcroForm בכלל }
end;

למה אחזור מקדים חייב לחזור על עצמו

כי קריאה אחת של missingRanges הנוכחי לא הופכת את העמוד לזמין. צומת עץ עמודים חסר או object stream חסר חושף את שכבת התלויות הבאה רק אחרי שהוא מגיע, ולכן משימת אחזור מקדים של PDFlibPas מריצה לולאת שאילתה, שליפה ושאילתה חוזרת עד שהעמוד, הטופס או גרף האובייקטים זמינים לחלוטין או שמגבלת בייטים או מעברים עוצרת אותה. המשימה משתמשת בקורא משלה ובמטמון משני קטן שמקור הנתונים שלו מעביר קריאות מוחלטות לזרם הטווחים המקורי, מה ששומר על מצב הפענוח מנותק מה־TSmartPDFReader שבחזית בזמן שהבייטים שהוא באמת מוריד עדיין נוחתים במטמון הראשי המשותף. לכל זרם טווחים קיים תהליכון עבודה אחד, בהתאמה לסידור בטור שפונקציית הקריאה החוזרת של המקור כבר דורשת, והתור בוחר לפי ארבע רמות עדיפות ואז לפי סדר הגשה בתוך רמה. MaxBytes נחייב בבייטים של מקטעים פיזיים, כך שמפענח שמבקש בייט בודד בתוך מקטע שאינו במטמון עדיין משלם על המקטע המלא, בעוד שמקטעים שכבר במטמון המשותף לא עולים למשימה דבר. ביטול משימה בתור מגיע למצב סופי עם אפס קריאות מקור; משימה פעילה נבדקת לפני כל מעבר תלויות וכל מקטע מקור, ושחרור זרם הטווחים ממתין לפונקציה חוזרת בלתי גמורה לחזור במקום לנסות להפריע לה

לולאת האחזור המקדים של PDFlibPas ב־Delphi: משימה שואלת זמינות, שולפת את הטווחים החסרים ושואלת שוב, כי כל צומת עץ עמודים או object stream שמגיע חושף את שכבת התלויות הבאה, עד שהגרף הושלם או שמגבלה עוצרת
משימת אחזור מקדים חוזרת על עצמה כי צומת חסר מציין את הצאצאים שלו רק כשהוא מגיע, והיא מחייבת כל מעבר במקטעים פיזיים שלמים
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 MiB וגם בתקציב המטמון הנוכחי, כך שהחימום לא יכול לפנות מיד את רוב עצמו. קובץ שאינו מליניארי עדיין מתפענח דרך הטריילר ושרשרת ההפניות הצולבות ליד הסוף, וזה עולה כמה סבבים נוספים ולא אסון. הצוק האמיתי הוא קובץ פגום שכופה את נתיב התיקון, כי בנייה מחדש של טבלת הפניות צולבת פירושה סריקה אחר כותרות אובייקטים על פני כל המסמך, וזו הורדה מלאה שמגיעה מקטע אחר מקטע. השהיה היא המגבלה הכנה השנייה: ב־60 ms לבקשה, פענוח גישה אקראית שזקוק לארבעים מקטעים שאינם במטמון מבלה יותר משתי שניות בדרכים ללא קשר לאיכות המטמון, ובדיוק להסתרת זה נועדו ארגומנט הקריאה המקדימה והתור בעדיפויות. אותה משמעת מופיעה במאמר על הגישה הישירה למיזוג ופיצול PDF גדולים, והמטמון הזה יושב מתחת ל־עיבוד עמודים מקבילי ול־מטמון עמודים בדיסק של הצופה כאחד

ממשק ה־range source, שאילתת הזמינות ומתזמן האחזור המקדים הם חלק מה־PDFlibPas Delphi PDF Library הרגילה עבור Delphi, C++Builder ו־Free Pascal; עמוד המוצר נושא את ההפניה המלאה לפרמטרים של LoadFromRangeSource ועמו את קבועי העדיפות והמצב של האחזור המקדים