Technický článek

Progresivní načítání rozsahů PDF v Delphi s PDFlibPas

Naskenovaný archiv o 2 GB leží v kbelíku S3 a uživatel chce stránku 900. PDFlibPas ji dodá bez stahování celého souboru: LoadFromRangeSource postaví read-only vyhledávatelný proud nad vaším vlastním callbackem bajtových rozsahů a předá ho TPDFDocument, takže parser vytáhne tabulky křížových odkazů, jednu větev stromu stránek a jeden proud obsahu

Dopravná strana téhle stavby je stará a nudná. HTTP servery inzerují bajtové rozsahy celá desetiletí, dnes popsané v RFC 9110 §14, a každé objektové úložiště mluví tímtéž nářečím. Strana PDF je stejně vyřešená: ISO 32000-1 §7.5.8 definuje linearizaci přesně proto, aby čtenář mohl vykreslit první stránku z přední části souboru. Chybí-li v Delphi něco, je to kus uprostřed, část, která rozhoduje, které rozsahy si vyžádat, kolik jich držet a jak se neptat dvakrát

Co LoadFromRangeSource potřebuje od vaší dopravy?

Dvě věci a ani jedna není proud. PDFlibPas žádá autoritativní SourceSize a synchronní read callback typu TPDFlibRangeReadEvent, deklarovaný jako function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Uvnitř se dvojice stane TCallbackByteRangeSource vystavujícím SourceSize a ReadRange, zabaleným v proudu, jehož vlastnictví přechází na dokument. Cíl vašeho callbacku i jeho backend zůstávají vaše: dokument uvolní obálku při uzavření, vyčištění nebo znovunačtení, ale nikdy nesáhne na transportní objekt za ukazatelem metody

Smlouva je úmyslně shovívavá jedním směrem a přísná druhým. Kratší čtení je legální a prostě znamená, že se parser zeptá znovu. Callback, který vyhodí výjimku, se převede na kratší čtení a konverguje normální cestou selhání načítání. Callback, který tvrdí, že zapsal víc než Count bajtů, se zalamuje, protože pokazený provider nesmí přetéct vyrovnávací buffer cache. Pokusy o heslo staví čerstvý rozsahový proud a čerstvý stav parsování nad tímtéž zdrojem callbacku, takže neúspěšný pokus nemůže zanechat zastaralou pozici, okno ani dešifrovací stav

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
  { jeden blokující GET s 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;  { uvolní obálkový proud }
  Src.Free;  { vaše doprava, váš životní cyklus }
end;

Kolik ve skutečnosti drží cache rozsahů?

Ve výchozím stavu 4 MiB, rozprostřené po oknech zarovnaných na bloky a vytěsněných LRU. Starší návrh s jedním oknem rostl na libovolnou délku, o kterou volající požádal, takže jedno velké sekvenční čtení mohlo přeletět nominální velikost bloku, zatímco náhodný skok ihned vyhodil předchozí okno. Současná cache zarovnává každý zdrojový offset na ChunkSize, stahuje při každém miss přesně jeden blok a vymáhá tvrdý bajtový rozpočet napříč několika okny. Jakýkoli explicitní rozpočet, který předáte, se zvedne alespoň na jeden celý blok, takže jediné čtení postupuje blok po bloku a špičková zátěž cache zůstává předvídatelná. ChunkSize pod 4096 padá zpět na výchozích 64 KiB

Jak PDFlibPas obslouží čtení parseru v Delphi bez stažení PDF: absolutní offset se zarovná dolů na velikost bloku, při hitu se obslouží z jednoho z několika LRU oken a při miss se promění v jediné zalamované volání callbacku
Každý zdrojový offset se zarovná na velikost bloku, takže jeden miss stáhne přesně jeden blok a špičková zátěž cache zůstává předvídatelná

Účtování opakovaných čtení je kus, který stojí za napojení na vaši telemetrii. PDFlibPas rozezná opakování podle zarovnaného začátku bloku a drží uspořádané souvislé intervaly, což odděluje skutečné první stažení od refetch po vytěsnění, aniž by účetnictví rostlo lineárně s velikostí souboru. GetRangeSourceCacheInfo vrací celý obraz jako JSON, SetRangeSourceCacheLimit mění rozpočet za běhu a ClearRangeSourceCache shodí okna a vynuluje statistiky dohromady. Zmenšení rozpočtu za běhu drží historii a počítá rozpočtem vynucená uvolnění jako vytěsnění, takže rostoucí repeatedReads při plochém hits je váš signál, že pracovní množina už se nevejde

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;

Co se stane, když několik vláken chce tentýž blok?

Čekají na jeden požadavek, ne na několik. Klasický TStream má jediný kurzor pozice a dvě vlákna, která se každé správně zamknou, si přesto mohou tu pozici přepsat mezi Seek a Read, takže líné objekty a segmentované čtení v PDFlibPas používají absolutní ReadAt, který kurzor nikdy nepohne. Každý zarovnaný blok dostane jedinou žádost v letu, o niž se dělí každý volající toho bloku, sousední frontované bloky se sloučí, než zdrojové čtení nastartuje, a jedno fyzické čtení se uzavírá na 16 MiB, takže závan paralelní práce na stránkách se nerozmnoží ani v duplicitní malé požadavky, ani v jeden absurdně velký. Slučovací okno má výchozích 2 ms a vztahuje se jen na první chybějící blok každého ReadAt; poziční Read na něj nikdy nečeká a nula odstraní počáteční sběrnou prodlevu úplně, což zajímá dlouhé sekvenční skeny, které by jinak hromadily čekání blok po bloku. Pozice, metadata cache i zdrojová čtení sedí za třemi oddělenými zámky a samotný zdrojový callback se serializuje, což dovolí použít adapter databáze či objektového úložiště bez vnitřní ochrany vláken beze změny. Čekající dostávají vlastní kopii dat, takže pozdější vytěsnění LRU nemůže zneplatnit buffer, který už byl předán

Slučování požadavků při rozsahovém načítání PDFlibPas pro Delphi: dvě vlákna žádající tentýž blok se dělí o jedinou žádost v letu, sousední frontované bloky se sloučí v okně dvou milisekund a jediné serializované zdrojové čtení obslouží všechny
Závan paralelní práce na stránkách se smrskne na jeden sdílený požadavek na blok a každý čekající i tak dostane vlastní kopii bajtů

Dá se zeptat, zda je stránka 900 hotová, aniž by se stahovala?

Ano a přesně k tomu slouží volitelný callback dostupnosti. Obyčejný read callback nerozezná bajty, které už doletěly, od bajtů, které vyžadují blokující okružní cestu, a píchnutí zkušebním čtením by spustilo přesně to stahování, kterému se snažíte vyhnout. TPDFlibRangeAvailabilityEvent odpovídá na jedinou otázku, zda lze celý rozsah přečíst okamžitě, a je zakázáno, aby cokoli stahovalo; bajty, které cache už kryje, se počítají jako dostupné vždy. GetRangeSourceDataAvailability mapuje nepřímé objekty na fyzické rozsahy úložiště zapsané v položkách křížových odkazů, rozliší komprimované objekty na jejich kontejner objektového proudu, opraví posunutou PDF hlavičku a parsuje objekt až poté, co celý rozsah projde ne-stahující sondou, takže chybějící cesta nikdy nezavolá váš read callback

Průchod je zúžený, ne vyčerpávající. Dotaz na stránku projde jen větví stromu stránek obsahující cílovou stránku a pak přidá obsah stránky, zdroje, anotace a děděné atributy stránky, s přeskočením zpětných hran Parent a P, takže jediná stránka ani widget se nerozexpanduje zpět do celého dokumentu. Graf objektů se uzavírá na 100000 vyžádaných objektů a hloubku 256, proudové objekty se parsují nejdřív po slovníku a plné parsování se dovoluje jen pro uložené objekty do 4 MiB. JSON hlášení sloučí překrývající se a sousedící intervaly před počítáním, takže requiredBytes a missingBytes se počítají ze sloučených polí requiredRanges a missingRanges, jejichž end je hraniční bod včetně. Dotaz na objekt, který už je dostupný, může naplnit rozsahovou cache; dotaz na chybějící nechá statistiky čtení nedotčené

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 nese "missingBytes" plus sloučené "missingRanges" }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { např. soubor nemá vůbec žádné AcroForm }
end;

Proč prefetch musí iterovat

Protože jednorázové přečtení aktuálních missingRanges stránku dostupnou neučiní. Chybějící uzel stromu stránek nebo objektový proud odhalí další vrstvu závislostí až po svém příchodu, takže prefetch job PDFlibPas běží ve smyčce dotaz–stažení–dotaz, dokud není stránka, formulář nebo graf objektů kompletně dostupný, nebo dokud ho nezastaví limit bajtů či průchodů. Job používá vlastního čtenáře a malou sekundární cache, jejíž zdroj dat přeposílá absolutní čtení původnímu rozsahovému proudu, což drží stav parsování izolovaný od popředního TSmartPDFReader, zatímco bajty, které skutečně stáhne, dopadnou do sdílené hlavní cache. Na každý rozsahový proud připadá jedno pracovní vlákno, v souladu se serializací, kterou už zdrojový callback vyžaduje, a fronta vybírá podle čtyř prioritních úrovní a pak podle pořadí podání v rámci úrovně. MaxBytes se účtuje ve fyzických bajtech bloků, takže parser, který si řekne o jediný bajt uvnitř necachovaného bloku, zaplatí za celý blok, zatímco bloky už sedící ve sdílené cache joba nic nestojí. Zrušení frontovaného jobu dospěje do koncového stavu s nulou zdrojových čtení; běžící job se kontroluje před každým průchodem závislostmi a každým zdrojovým blokem a uvolnění rozsahového proudu čeká, až žádost v letu vrátí, místo aby se ji pokoušelo přerušit

Prefetch smyčka PDFlibPas v Delphi: job se dotáže dostupnosti, stáhne chybějící rozsahy a dotáže se znovu, protože každý příchozí uzel stromu stránek nebo objektový proud odhalí další vrstvu závislostí, dokud se graf nedokončí nebo limit nezastaví
Prefetch job iteruje, protože chybějící uzel pojmenuje vlastní děti až po příchodu, a každý průchod účtuje v celých fyzických blocích
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" a poslední
    plné hlášení dostupnosti, takže LIMIT_REACHED zůstane rozeznatelné
    od FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Kde se tohle degraduje na stažení celého souboru

Rozsahové načítání je sázka na uspořádání souboru a některé soubory ji nesplatí. Linearizovaný soubor podle ISO 32000-1 §7.5.8 je dobrý případ: sekce první stránky se ohřeje při otevření, ohraničená jak stávajícím 4 MiB bezpečnostním prahem, tak aktuálním rozpočtem cache, takže ohřev nemůže hned vytěsnit sám sebe. Nelinearizovaný soubor se pořád rozliší přes trailer a řetěz křížových odkazů poblíž konce, což stojí pár okružních cest navíc, ne katastrofu. Skutečný útes je poškozený soubor, který vnutí cestu opravy, protože rekonstrukce tabulky křížových odkazů znamená skenovat hlavičky objektů napříč celým dokumentem, a to je stažení celého souboru, které přichází po jednom bloku. Latence je druhá čestná mez: při 60 ms na požadavek stráví náhodně přistupující parse potřeba čtyřiceti necachovaných bloků přes dvě sekundy v tranzitu, ať je cache sebelepší, a přesně proto tu jsou argument předčítání a prioritní fronta. Tatáž disciplína se ukazuje v přístupu s přímým přístupem ke slučování a dělení velkých PDF a tahle cache sedí pod paralelním vykreslováním stránek i pod diskovou cache stránek prohlížeče

Rozsahové zdrojové API, dotaz dostupnosti i plánovač prefetch jsou součástí standardní PDFlibPas Delphi PDF Library pro Delphi, C++Builder a Free Pascal; produktová stránka nese kompletní referenci parametrů LoadFromRangeSource spolu s konstantami priorit a stavů prefetch