Tehnički članak

Progresivno PDF range učitavanje u Delphi-ju uz PDFlibPas

Arhiva skeniranih dokumenata od 2 GB leži u S3 bucket-u i korisnik traži stranicu 900. PDFlibPas može poslužiti tu stranicu bez preuzimanja fajla: LoadFromRangeSource gradi read-only seekable stream preko vašeg sopstvenog byte-range callback-a i predaje ga TPDFDocument-u, pa parser povlači tabele unakrsnih referenci, jednu granu page tree-a i jedan content stream

Transportna strana priče stara je i dosadna. HTTP serveri oglašavaju byte range-ove decenijama, danas specificirane u RFC 9110 §14, i svaki object store govori isti dijalekat. PDF strana jednako je rešena: ISO 32000-1 §7.5.8 definiše linearizaciju upravo zato da čitač može renderovati prvu stranicu sa početka fajla. Ono što je u Delphi-ju nedostajalo je komad u sredini, deo koji odlučuje koje range-ove da traži, koliko da zadrži i kako da ne pita dvaput

Šta LoadFromRangeSource traži od vašeg transporta?

Dve stvari, i nijedna nije stream. PDFlibPas traži autoritativan SourceSize i sinhroni read callback tipa TPDFlibRangeReadEvent, deklarisan kao function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Iznutra par postaje TCallbackByteRangeSource koji izlaže SourceSize i ReadRange, umotan u stream čije vlasništvo prelazi na dokument. Vaš callback cilj i njegov backend ostaju vaši: dokument oslobađa omot pri close, clear ili reload, ali nikad ne dodiruje transportni objekat iza method pokazivača

Ugovor je namerno popustljiv u jednom smeru i strog u drugom. Kraće čitanje je legalno i samo znači da parser pita ponovo. Callback koji baci izuzetak pretvara se u kraće čitanje i konvergira kroz normalan put greške pri učitavanju. Callback koji tvrdi da je upisao više od Count bajtova se steže, jer pokvaren provajder ne sme da prepiše preko keš bafera. Ponovni pokušaji lozinke grade svež range stream i sveže parse stanje nad istim callback izvorom, pa neuspešan pokušaj ne može za sobom da ostavi zastarelu poziciju, prozor ili stanje dešifrovanja

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
  { jedan blokirajući GET sa 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;  { oslobađa omotni stream }
  Src.Free;  { vaš transport, vaš vek trajanja }
end;

Koliko range keš zaista drži?

Podrazumevano 4 MiB, raspoređeno po chunk-poravnatim prozorima i izbačeno po LRU. Raniji dizajn sa jednim prozorom rastao je na dužinu koju je pozivalac tražio, pa je jedno veliko sekvencijalno čitanje moglo da pregazi nominalnu chunk veličinu dok je slučajan skok odmah bacao prethodni prozor. Trenutni keš poravnava svaki izvorni ofset na ChunkSize, dobavlja tačno jedan chunk po promašaju i sprovodi krut bajtni budžet kroz više prozora. Svaki eksplicitni budžet koji prosledite podiže se na najmanje jedan pun chunk, pa jedno čitanje uvek napreduje chunk po chunk i vršno opterećenje keša ostaje predvidivo. ChunkSize ispod 4096 vraća se na podrazumevanih 64 KiB

Kako PDFlibPas u Delphi-ju opslužuje čitanje parsera bez preuzimanja PDF-a: apsolutni ofset se poravnava nadole na chunk veličinu, služi se iz jednog od više LRU prozora pri pogotku, ili se pretvara u jedan stežen callback poziv pri promašaju
Svaki izvorni ofset poravnava se na chunk veličinu, pa jedan promašaj dobavlja tačno jedan chunk i vršno opterećenje keša ostaje predvidivo

Računovodstvo ponovljenih čitanja deo je koji vredi povezati sa vašom telemetrijom. PDFlibPas prepoznaje ponavljanje po poravnatom početku chunk-a i drži uređene kontigualne intervale, čime razdvaja pravo prvo dobavljanje od ponovnog posle izbacivanja, a da knjigovodstvo ne raste linearno sa veličinom fajla. GetRangeSourceCacheInfo vraća celu sliku kao JSON, SetRangeSourceCacheLimit menja budžet u toku rada, a ClearRangeSourceCache baca prozore i resetuje statistiku zajedno. Smanjenje budžeta u toku rada čuva istoriju i izbacivanja vođena budžetom broji kao evictions, pa rastući repeatedReads uz ravan hits je vaš signal da radni skup više ne staje

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;

Šta se dešava kada više niti želi isti chunk?

Čekaju na jedan zahtev, ne na više. Klasičan TStream ima jedan kursor pozicije, i dve niti koje svaka ispravno zaključavaju i dalje mogu da dožive prepisivanje te pozicije između Seek i Read, pa lazy objekti i segmentirana čitanja u PDFlibPas-u koriste apsolutni ReadAt koji nikad ne pomera kursor. Svaki poravnati chunk dobija jedan in-flight zahtev koji svi pozivaoci tog chunk-a dele, susedni zakazani chunk-ovi spajaju se pre početka izvornog čitanja, a jedno fizičko čitanje ograničeno je na 16 MiB, pa se nalet paralelnog rada na stranicama ne pojačava ni u duple male zahteve ni u jedan apsurdno veliki. Prozor spajanja podrazumeva 2 ms i odnosi se samo na prvi nedostajući chunk svakog ReadAt; pozicioni Read nikad ne čeka na njega, a nula potpuno uklanja početno kašnjenje sakupljanja, što je bitno za duga sekvencijalna skeniranja koja bi inače nagomilala čekanje chunk po chunk. Pozicija, metapodaci keša i izvorna čitanja stoje iza tri odvojena brava, a sam izvorni callback je serializovan, što omogućava da adapter baze podataka ili object store-a bez unutrašnje zaštite niti bude korišćen nepromenjen. Čekaoci primaju sopstveni primerak podataka, pa kasnije LRU izbacivanje ne može da poništi bafer koji je već isporučen

Spajanje zahteva u PDFlibPas range učitavanju za Delphi: dve niti koje traže isti chunk dele jedan in-flight zahtev, susedni zakazani chunk-ovi spajaju se unutar prozora od dva milisekunda, i jedno serializovano izvorno čitanje opslužuje sve njih
Nalet paralelnog rada na stranicama skuplja se u jedan deljeni zahtev po chunk-u, i svaki čekaoc i dalje prima sopstveni primerak bajtova

Možete li pitati da li je stranica 900 spremna, a da je ne dobavljate?

Da, i upravo za to služi opciona availability callback funkcija. Običan read callback ne može da razlikuje bajtove koji su već stigli od bajtova koji zahtevaju blokirajući odlazak i povratak, a ispitivanje probnim čitanjem okidalo bi baš ono preuzimanje koje pokušavate da izbegnete. TPDFlibRangeAvailabilityEvent odgovara na jedno pitanje, da li kompletan range može da se pročita odmah, i zabranjeno mu je da išta dobavlja; bajtove koje keš već pokriva uvek računa kao dostupne. GetRangeSourceDataAvailability mapira indirektne objekte na fizičke storage range-ove zabeležene u unosima unakrsnih referenci, razrešava kompresovane objekte na njihov object stream kontejner, ispravlja pomeren PDF header i parsira objekat tek kada ceo range prođe nedobavljajuću probu, pa put nedostajućeg nikad ne poziva vaš read callback

Obilazak je opsežan, a ne iscrpan. Upit stranice prelazi samo granu page tree-a koja sadrži ciljnu stranicu, a zatim dodaje sadržaj stranice, resurse, anotacije i nasleđene atribute stranice, preskačući Parent i P povratne grane, da jedna stranica ili vidžet ne može unazad da se proširi na ceo dokument. Graf objekata ograničen je na 100000 traženih objekata i dubinu 256, stream objekti parsiraju se rečnikom prvo, a fallback punog parsiranja dozvoljen je samo za uskladištene objekte do 4 MiB. JSON izveštaj spaja preklapajuće i susedne intervale pre brojanja, pa se requiredBytes i missingBytes računaju iz spojenih nizova requiredRanges i missingRanges, čiji je end uključiva krajnja tačka. Upit objekta koji je već dostupan može da napuni range keš; upit nedostajućeg ostavlja statistiku čitanja netaknutom

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 nosi "missingBytes" plus spojene "missingRanges" }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { npr. fajl uopšte nema AcroForm }
end;

Zašto prefetch mora da iterira

Zato jedno čitanje trenutnih missingRanges ne čini stranicu dostupnom. Nedostajući čvor page tree-a ili object stream otkriva sledeći sloj zavisnosti tek kad stigne, pa PDFlibPas prefetch posao vrti petlju upit, dobavljanje, ponovni upit dok stranica, forma ili graf objekata ne bude potpuno dostupan ili dok ga bajtni ili prolazni limit ne zaustavi. Posao koristi sopstveni čitač i mali sekundarni keš čiji izvor podataka prosleđuje apsolutna čitanja originalnom range stream-u, čime je parse stanje izolovano od prednjeg TSmartPDFReader-a, dok bajtovi koje zaista preuzme ipak dospevaju u deljeni glavni keš. Po jedan radni thread postoji po range stream-u, u skladu sa serializacijom koju izvorni callback već zahteva, a red bira po četiri nivoa prioriteta pa po redu prijave unutar nivoa. MaxBytes se naplaćuje u fizičkim chunk bajtovima, pa parser koji traži jedan bajt unutar nekeširanog chunk-a i dalje plaća ceo chunk, dok chunk-ovi već u deljenom kešu posao ne koštaju ništa. Otkazivanje posla u redu dolazi do terminalnog stanja sa nula izvornih čitanja; posao u toku proverava se pre svakog prolaza zavisnosti i pre svakog izvornog chunk-a, a oslobađanje range stream-a čeka da in-flight callback vrati rezultat umesto da pokuša da ga prekine

PDFlibPas prefetch petlja u Delphi-ju: posao ispitiva dostupnost, dobavlja nedostajuće range-ove i ispitiva ponovo, jer svaki čvor page tree-a ili object stream koji stigne otkriva sledeći sloj zavisnosti, dok se graf ne dovrši ili ga limit ne zaustavi
Prefetch posao iterira jer nedostajući čvor imenuje sopstvenu decu tek kad stigne, a svaki prolaz naplaćuje u celim fizičkim chunk-ovima
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" i poslednji
    pun izveštaj dostupnosti, pa LIMIT_REACHED ostaje razlučiv
    od FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Gde se ovo degradira u preuzimanje celog fajla

Range učitavanje je opklada o rasporedu fajla, i neki fajlovi je ne poštuju. Linearizovan fajl po ISO 32000-1 §7.5.8 je dobar slučaj: sekcija prve stranice zagreva se pri otvaranju, ograničena i postojećim sigurnosnim pragom od 4 MiB i trenutnim keš budžetom, da zagrevanje ne može odmah da izbaci većinu samog sebe. Nelinearizovan fajl i dalje se razrešava kroz trailer i lanac unakrsnih referenci pri kraju, što košta par dodatnih odlazaka i povratka umesto katastrofe. Prava litica je oštećen fajl koji forsira put popravke, jer obnova tabele unakrsnih referenci znači skeniranje headera objekata kroz ceo dokument, a to je puno preuzimanje koje stiže chunk po chunk. Latencija je druga poštena granica: pri 60 ms po zahtevu, parsiranje slučajnim pristupom koje traži četrdeset nekeširanih chunk-ova provede preko dve sekunde u transportu ma koliko dobar keš bio, što je upravo ono što read-ahead argument i red sa prioritetima postoje da sakriju. Ista disciplina pokazuje se u pristupu spajanju i razdvajanju velikih PDF-ova direktnim pristupom, a ovaj keš leži ispod paralelnog renderovanja stranica i disk keša stranica pregledača

Range source API, upit dostupnosti i prefetch raspoređivač deo su standardne PDFlibPas Delphi PDF Library za Delphi, C++Builder i Free Pascal; stranica proizvoda nosi kompletnu referencu parametara za LoadFromRangeSource uz prefetch prioritete i konstante stanja