Odborný článok

Progresívne načítanie PDF po rozsahoch v Delphi s PDFlibPas

2 GB naskenovaný archív leží v S3 buckete a používateľ chce stranu 900. PDFlibPas môže obslúžiť tú stranu bez stiahnutia súboru: LoadFromRangeSource stavia read-only seekovateľný stream nad váš vlastný byte-range callback a odovzdá ho TPDFDocument, takže parser potiahne cross-reference tabuľky, jednu vetvu stromu strán a jeden obsahový stream

Transportná strana je stará a nudná. HTTP servery reklamujú byte range desaťročia, teraz špecifikované v RFC 9110 §14, a každý object store hovorí ten istý dialekt. PDF strana je rovnako usadená: ISO 32000-1 §7.5.8 definuje linearizáciu presne preto, aby čítačka mohla renderovať prvú stranu z prednej časti súboru. To, čo v Delphi chýbalo, je diel uprostred, časť, ktorá rozhoduje, ktoré rozsahy si vyžiadať, koľko ich držať a ako sa vyhnúť dvojitej žiadosti

Čo LoadFromRangeSource potrebuje od vášho transportu?

Dve veci a ani jedna nie je stream. PDFlibPas si žiada autoritatívny SourceSize a synchrónny čítací callback typu TPDFlibRangeReadEvent, deklarovaný ako function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Interně sa pár stáva TCallbackByteRangeSource vystavujúcim SourceSize a ReadRange, zabaleným do streamu, ktorého vlastníctvo prechádza na dokument. Váš callback cieľ a jeho backend ostávajú vaše: dokument uvoľní obal pri zatvorení, vyčistení alebo opätovnom načítaní, ale nikdy sa nesiahne na transportný objekt za metódovým ukazovateľom

Kontrakt je zámerne zhovievavý jedným smerom a prísny druhým. Krátke čítanie je legálne a jednoducho znamená, že parser si znova požiada. Callback, ktorý vyvolá výnimku, sa prevedie na krátke čítanie a konverguje cez normálnu cestu zlyhania načítania. Callback, ktorý tvrdí, že zapísal viac než Count bajtov, sa svorkuje, pretože chybný provider nesmie pretekať cache buffer. Opakovania hesla prestavajú čerstvý range stream a čerstvý parse stav nad tým istým callback zdrojom, takže neúspešný pokus nemôže nechať za sebou zastaraný pozíciu, 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
  { jedno blokujúce 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;  { uvoľňuje obalový stream }
  Src.Free;  { váš transport, váš životný cyklus }
end;

Koľko cache rozsahov naozaj drží?

Štandardne 4 MiB, rozložené cez okná zarovnané na chunky a vysídľované LRU. Skorší návrh s jediným oknom rástol na akúkoľvek dĺžku, o ktorú volajúci požiadal, takže jedno veľké sekvenčné čítanie mohlo prefúkať nominálnu veľkosť chunku, zatiaľ čo náhodný skok zahodil predchádzajúce okno okamžite. Súčasná cache zarovnáva každý zdrojový offset na ChunkSize, fetchuje presne jeden chunk na každý miss a vynucuje pevný bajtový rozpočet naprieč viacerými oknami. Akýkoľvek explicitný rozpočet, ktorý pošlete, sa zdvíha na aspoň jeden celý chunk, takže jediné čítanie vždy postupuje chunk po chunku a špičková záťaž cache zostáva predvídateľná. ChunkSize pod 4096 pada späť na predvolených 64 KiB

Ako PDFlibPas obsluhuje čítanie parsera v Delphi bez stiahnutia PDF: absolútny offset sa zarovná nadol na veľkosť chunku, obslúži sa z jedného z viacerých LRU okien pri zásahu, alebo sa zmení na jediné svorkované volanie callbacku pri miss
Každý zdrojový offset sa zarovnáva na veľkosť chunku, takže jeden miss fetchuje presne jeden chunk a špičková záťaž cache zostáva predvídateľná

Účtovníctvo opakovaných čítaní je časť, ktorú stojí za to napojiť do vašej telemetrie. PDFlibPas identifikuje opakovanie zarovnaným začiatkom chunku a drží usporiadané súvislé intervaly, čo oddeľuje skutočný prvý fetch od refetchu po vysídlení, a pritom drží účtovníctvo od lineárneho rastu s veľkosťou súboru. GetRangeSourceCacheInfo vracia celý obraz ako JSON, SetRangeSourceCacheLimit mení rozpočet za behu a ClearRangeSourceCache zahodí okná a resetuje štatistiky spolu. Zmenšenie rozpočtu za behu drží históriu a počíta rozpočtom riadené uvoľnenia ako vysídlenia, takže rastúce repeatedReads proti plochému hits je váš signál, že pracovná množina sa už nezmestí

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;

Čo sa stane, keď viacero vlákien chce ten istý chunk?

Čakajú na jednu žiadosť, nie na viacero. Klasický TStream má jediný kurzor pozície a dve vlákna, ktoré sa každé správne zamknú, môžu mať stále tú pozíciu prepísanú medzi Seek a Read, takže lazy objekty a segmentované čítania v PDFlibPas používajú absolútne ReadAt, ktoré nikdy nepohnú kurzor. Každý zarovnaný chunk dostane jedinú in-flight žiadosť, ktorú zdieľa každý volajúci toho chunku, susedné frontované chunky sa zlúčia pred štartom zdrojového čítania a jedno fyzické čítanie sa svorkuje na 16 MiB, takže dávková paralelná práca so stranami sa zosilní ani do duplikovaných malých žiadostí, ani do jednej absurdne veľkej. Zlučovacie okno má predvolene 2 ms a aplikuje sa len na prvý chýbajúci chunk každého ReadAt; pozičné Read nikdy nečaká naň a posunutie nuly odstráni počiatočné oneskorenie zberu úplne, čo záleží pri dlhých sekvenčných skenoch, ktoré by inak akumulovali čakanie chunk po chunku. Pozícia, metadata cache a zdrojové čítania sedia za tromi samostatnými zámkami a samotný zdrojový callback je serializovaný, čo je to, čo dovoľuje databázovému alebo object-store adaptéru bez internej ochrany vlákien byť použitý nezmenený. Čakajúci dostanú svoju vlastnú kópiu dát, takže neskoršie LRU vysídlenie nemôže zneplatniť buffer, ktorý už bol odovzdaný

Zlučovanie žiadostí v PDFlibPas range načítaní pre Delphi: dve vlákna žiadajúce ten istý chunk zdieľajú jednu in-flight žiadosť, susedné frontované chunky sa zlúčia vo vnútri dvojmilisekundového okna a jedno serializované zdrojové čítanie obslúži všetky
Dávka paralelnej práce so stranami sa zrúti do jednej zdieľanej žiadosti na chunk a každý čakajúci aj tak dostane svoju vlastnú kópiu bajtov

Možete sa opýtať, či je strana 900 hotová, bez jej fetchu?

Áno a presne na to je voliteľný callback dostupnosti. Obyčajný čítací callback nedokáže odlíšiť bajty, ktoré už pristáli, od bajtov, ktoré vyžadujú blokujúci okruh, a prieskum skúšobným čítaním by spustil presne to stiahnutie, ktorému sa snažíte vyhnúť. TPDFlibRangeAvailabilityEvent odpovedá na jedinú otázku, či celý rozsah sa dá prečítať okamžite, a je zakázané fetchovať čokoľvek; bajty, ktoré cache už pokrýva, sa vždy počítajú ako dostupné. GetRangeSourceDataAvailability mapuje nepriame objekty na fyzické rozsahy úložiska zaznamenané v cross-reference položkách, rozlíši komprimované objekty na ich objektový stream kontajner, koriguje posunutú PDF hlavičku a parsuje objekt až po tom, ako celý rozsah prejde nefetchujúcim prieskumom, takže chýbajúca cesta nikdy nezavolá váš čítací callback

Prechod je šcopeovaný, nie vyčerpávajúci. Dotaz strany prechádza len vetvou stromu strán obsahujúcou cieľovú stranu a potom pridá obsah strany, zdroje, anotácie a zdedené atribúty strany, preskočí spätné hrany Parent a P, takže jediná strana alebo widget sa nedokáže roztiahnuť dozadu do celého dokumentu. Graf objektov je ohraničený na 100000 požadovaných objektov a hĺbku 256, streamové objekty sa parsujú najprv slovník a plný parse fallback je dovolený len pre uložené objekty do 4 MiB. JSON správa zlúči prekrývajúce sa a susedné intervaly pred počítaním, takže requiredBytes a missingBytes sa počítajú z zlúčených polí requiredRanges a missingRanges, ktorých end je inkluzívny koncový bod. Dotazovanie objektu, ktorý je už dostupný, môže naplniť cache rozsahov; dotazovanie chýbajúceho nechá štatistiky čítania nedotknuté

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 nesie "missingBytes" plus zlúčené "missingRanges" }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { napr. súbor vôbec nemá AcroForm }
end;

Prečo musí prefetch iterovať

Pretože prečítanie aktuálnych missingRanges raz neurobí stranu dostupnou. Chýbajúci uzol stromu strán alebo objektový stream odhalí až po svojom príchode ďalšiu vrstvu závislostí, takže prefetch práca PDFlibPas beží vo vzore dotaz, fetch, znovu-dopyt, až kým strana, formulár alebo graf objektov nie je úplne dostupný, alebo bajt či priebehový limit nezastaví. Práca používa vlastného čítačku a malú sekundárnu cache, ktorej dátový zdroj preposiela absolútne čítania pôvodnému range streamu, čo drží parse stav izolovaný od popredného TSmartPDFReader, zatiaľ čo bajty, ktoré skutočne stiahne, pristanú v zdieľanej hlavnej cache. Jedno robotnícke vlákno existuje na range stream, zodpovedajúc serializácii, ktorú zdrojový callback už vyžaduje, a front vyberá podľa štyroch úrovní priority a potom podľa poradia odoslania v rámci úrovne. MaxBytes sa účtuje vo fyzických bajtoch chunkov, takže parser, ktorý si žiada jediný bajt vo vnútri neuloženého chunku, aj tak zaplatí celý chunk, zatiaľ čo chunky už v zdieľanej cache stoja prácu nič. Zrušenie frontovanej práce dosiahne terminálny stav s nulovými zdrojovými čítaniami; bežiaca práca sa kontroluje pred každou závislostnou passou a každým zdrojovým chunkom a uvoľnenie range streamu čaká na in-flight callback, aby sa vrátil, namiesto pokusu o prerušenie

Prefetch slučka PDFlibPas v Delphi: práca sa pýta na dostupnosť, fetchuje chýbajúce rozsahy a znova sa pýta, pretože každý prichádzajúci uzol stromu strán alebo objektový stream odhalí ďalšiu vrstvu závislostí, až kým sa graf nedokončí alebo limit nezastaví
Prefetch práca iteruje, pretože chýbajúci uzol pomenuje svoje deti až po príchode, a účtuje každú passu v celých fyzických chunkoch
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á správa dostupnosti, takže LIMIT_REACHED ostáva odlíšiteľný
    od FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Kde sa to degraduje na stiahnutie celého súboru

Range načítanie je stávka na usporiadanie súboru a niektoré súbory si ju nevážia. Linearizovaný súbor podľa ISO 32000-1 §7.5.8 je dobrý prípad: sekcia prvej strany sa zohreje pri otvorení, ohraničená jak existujúcim bezpečnostným prahom 4 MiB, tak aktuálnym cache rozpočtom, takže zahrievanie nemôže okamžite vysídiť väčšinu seba samého. Nelinearizovaný súbor sa aj tak rozlíši cez trailer a reťaz cross-referencií pri konci, čo stojí pár extra okruhov namiesto katastrofy. Skutočný útes je poškodený súbor, ktorý vynúti cestu opravy, pretože rekonštrukcia cross-reference tabuľky znamená skenovanie hlavičiek objektov naprieč celým dokumentom a to je plné stiahnutie prichádzajúce jeden chunk po chunku. Latencia je druhý úprimný limit: pri 60 ms na žiadosť strávi náhodný prístup parse potrebujúci štyridsať neuložených chunkov viac než dve sekundy v tranzite, nezáleží, aká dobrá je cache, čo je presne to, čo argument read-ahead a prioritný front existujú skryť. Rovnaká disciplína sa objavuje v prístupe priameho prístupu k zlučovaniu a deleniu veľkých PDF a táto cache sedí pod paralelným renderovaním strán aj diskovou cache strán prehliadača

API zdroja rozsahov, dotaz dostupnosti a prefetch plánovač sú časťou štandardnej PDFlibPas Delphi PDF Library pre Delphi, C++Builder a Free Pascal; produktová stránka nesie kompletnú referenciu parametrov LoadFromRangeSource spolu s konstantami priority a stavu prefetchu