Tehnički članak

Progresivno PDF range učitavanje u Delphiju s PDFlibPasom

Skenirani arhiv od 2 GB živi u S3 bucketu i korisnik želi stranicu 900. PDFlibPas može poslužiti tu stranicu bez preuzimanja datoteke: LoadFromRangeSource gradi tok samo za čitanje s pozicioniranjem nad vašim vlastitim byte-range povratnim pozivom i predaje ga TPDFDocument, pa parser povlači tablice unakrsnih referenci, jednu granu stabla stranica i jedan tok sadržaja

Transportna strana ovoga stara je i dosadna. HTTP serveri oglašavaju byte raspone desetljećima, sada specificirano u RFC 9110 §14, i svaki object store govori isti dijalekt. PDF strana jednako je sređena: ISO 32000-1 §7.5.8 definira linearizaciju upravo da čitač može prikazati prvu stranicu s početka datoteke. Ono što je u Delphiju nedostajalo jest komad u sredini, dio koji odlučuje koje raspone tražiti, koliko ih držati i kako izbjeći dvostruko traženje

Što LoadFromRangeSource treba od vašeg transporta?

Dvije stvari, i nijedna nije tok. PDFlibPas traži autoritativni SourceSize i sinkroni povratni poziv čitanja tipa TPDFlibRangeReadEvent, deklariran kao function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Izvodno par postaje TCallbackByteRangeSource koji izlaže SourceSize i ReadRange, omotan u tok čije vlasništvo prelazi na dokument. Vaš cilj povratnog poziva i njegova pozadina ostaju vaši: dokument oslobađa omot pri zatvaranju, brisanju ili ponovnom učitavanju, ali nikad ne dodiruje transportni objekt iza pokazivača metode

Ugovor je namjerno popustljiv u jednom smjeru i strog u drugom. Kraće čitanje legalno je i jednostavno znači da parser pita ponovno. Povratni poziv koji baci iznimku pretvara se u kraće čitanje i konvergira kroz normalni put neuspjeha učitavanja. Povratni poziv koji tvrdi da je napisao više od Count bajtova steže se, jer pokvaren davatelj ne smije moći pregaziti međuspremnik keša. Pokušaji lozinke ponovno grade svježi range tok i svježe stanje parsiranja nad istim izvorom povratnog poziva, pa neuspjeli pokušaj ne može ostaviti za sobom zastarjelu poziciju, prozor ili stanje dešifriranja

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 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;  { oslobađa omotni tok }
  Src.Free;  { vaš transport, vaš vijek trajanja }
end;

Koliko range keš stvarno drži?

Po zadanom 4 MiB, raspršeno po chunk-poravnatim prozorima i izbačeno LRU-om. Raniji dizajn s jednim prozorom rastao bi na koju duljinu pozivatelj zamoli, pa bi jedno veliko sekvencijalno čitanje moglo prijeći nominalnu veličinu chunka dok bi nasumični skok odmah bacio prethodni prozor. Trenutačni keš poravnava svaki izvorni ofset na ChunkSize, dohvaća točno jedan chunk po promašaju i provodi kruti bajtni budžet kroz više prozora. Svaki izričit budžet koji pošaljete podiže se na najmanje jedan puni chunk, pa jedno čitanje uvijek napreduje chunk po chunk i vršno opterećenje keša ostaje predvidivo. ChunkSize ispod 4096 vraća se na zadane 64 KiB

Kako PDFlibPas u Delphiju poslužuje čitanje parsera bez preuzimanja PDF-a: apsolutni ofset poravnava se dolje na veličinu chunka, poslužuje iz jednog od više LRU prozora pri pogotku, ili se pretvara u jedan stezni poziv povratnog poziva pri promašaju
Svaki izvorni ofset poravnava se na veličinu chunka, pa jedan promašaj dohvaća točno jedan chunk i vršno opterećenje keša ostaje predvidivo

Evidencija ponovljenih čitanja dio je koji vrijedi povezati s vašom telemetrijom. PDFlibPas prepoznaje ponavljanje po poravnatom početku chunka i drži uređene neprekinute intervale, što razdvaja pravi prvi dohvat od ponovnog dohvata nakon izbacivanja uz knjigovodstvo koje ne raste linearno s veličinom datoteke. GetRangeSourceCacheInfo vraća cijelu sliku kao JSON, SetRangeSourceCacheLimit mijenja budžet tijekom izvođenja, a ClearRangeSourceCache baca prozore i resetira statistiku zajedno. Smanjenje budžeta tijekom izvođenja čuva povijest i broji otpuštanja uzrokovana budžetom kao izbacivanja, pa rastući repeatedReads uz ravan hits signal je 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;

Što se dogodi kad nekoliko niti želi isti chunk?

Čekaju na jedan zahtjev, a ne na više njih. Klasični TStream ima jedan pokazivač pozicije, i dvije niti koje svaka zaključava ispravno i dalje mogu imati tu poziciju prepisanom između Seek i Read, pa lijeni objekti i segmentirana čitanja u PDFlibPasu koriste apsolutni ReadAt koji nikad ne pomjera pokazivač. Svaki poravnati chunk dobiva jedan zahtjev u letu koji svaki pozivatelj za taj chunk dijeli, susjedni chunkovi u redu čekanja spajaju se prije nego što počne izvorno čitanje, i jedno fizičko čitanje ograničeno je na 16 MiB, pa se nalet paralelnog rada na stranicama ne pojačava ni u dvostruke male zahtjeve ni u jedan apsurdno veliki. Spajajući prozor zadano je 2 ms i odnosi se samo na prvi nedostajući chunk svakog ReadAt-a; pozicijski Read nikad ne čeka na njega, a slanje nule uklanja početno kašnjenje sakupljanja sasvim, što je važno za duga sekvencijalna skeniranja koja bi inače nakupljala čekanje chunk po chunk. Pozicija, metapodaci keša i izvorna čitanja stoje iza tri odvojena ključa, a sam izvorni povratni poziv serijaliziran je, što omogućuje da se adapter baze podataka ili object storea bez unutarnje zaštite niti koristi nepromijenjen. Čekatelji primaju vlastitu kopiju podataka, pa kasnije LRU izbacivanje ne može poništiti međuspremnik koji je već predan

Spajanje zahtjeva u PDFlibPas range učitavanju za Delphi: dvije niti koje traže isti chunk dijele jedan zahtjev u letu, susjedni chunkovi u redu čekanja spajaju se unutar dvomilisekundnog prozora, i jedno serijalizirano izvorno čitanje poslužuje sve njih
Nalet paralelnog rada na stranicama sažme se u jedan dijeljeni zahtjev po chunku, i svaki čekatelj i dalje prima vlastitu kopiju bajtova

Možete li pitati je li stranica 900 spremna bez dohvaćanja?

Možete, i upravo za to služi neobavezan povratni poziv dostupnosti. Običan povratni poziv čitanja ne razlikuje bajtove koji su već stigli od bajtova koji traže blokirajući odlazak i povratak, i ispitivanje probnim čitanjem pokrenulo bi upravo preuzimanje kojemu pokušavate umaknuti. TPDFlibRangeAvailabilityEvent odgovara na jedno pitanje, može li se potpun raspon pročitati odmah, i zabranjeno mu je dohvaćati bilo što; bajtove koje keš već pokriva uvijek računa dostupnima. GetRangeSourceDataAvailability preslikava indirektne objekte na fizičke raspone pohrane zabilježene u stavkama unakrsnih referenci, razrješuje kompresirane objekte na njihov object stream spremnik, ispravlja pomaknuto PDF zaglavlje i parsira objekt tek nakon što puni raspon prođe nepovlačeću probu, pa nedostajući put nikad ne poziva vaš povratni poziv čitanja

Obilazak ima opseg, a nije iscrpan. Upit stranice prolazi samo granu stabla stranica koja sadrži ciljnu stranicu i zatim dodaje sadržaj stranice, resurse, anotacije i naslijeđene atribute stranice, preskačući Parent i P povratne grane da jedna stranica ili widget ne može unatrag proširiti se na cijeli dokument. Graf objekta ograničen je na 100000 traženih objekata i dubinu 256, tok objekti parsiraju se rječnikom najprije, a fallback potpunog parsiranja dopušten je samo za pohranjene objekte do 4 MiB. JSON izvještaj spaja preklapajuće i susjedne intervale prije brojanja, pa se requiredBytes i missingBytes računaju iz spojenih polja requiredRanges i missingRanges, čiji je end uključiva krajnja točka. Upit nad objektom koji je već dostupan može napuniti range keš; upit nad nedostajućim 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" uz spojene "missingRanges" }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { npr. datoteka uopće nema AcroForm }
end;

Zašto prefetch mora iterirati

Jer jedno čitanje trenutačnih missingRanges ne čini stranicu dostupnom. Nedostajući čvor stabla stranica ili object stream otkriva sljedeći sloj ovisnosti tek kad stigne, pa PDFlibPas prefetch posao izvodi petlju upit, dohvat, ponovni upit dok stranica, form ili graf objekta nije potpuno dostupan ili dok ga bajtno ili prolazno ograničenje ne zaustavi. Posao koristi vlastiti čitač i mali sekundarni keš čiji izvor podataka prosljeđuje apsolutna čitanja izvornom range toku, što stanje parsiranja drži izoliranim od prednjeg TSmartPDFReader-a dok bajtovi koje stvarno preuzme i dalje padaju u dijeljeni glavni keš. Jedna radna nit postoji po range toku, u skladu sa serijalizacijom koju izvorni povratni poziv već traži, i red biranja ide po četiri razine prioriteta pa po redoslijedu predaje unutar razine. MaxBytes naplaćuje se u fizičkim bajtovima chunka, pa parser koji traži jedan bajt unutar nekeširanog chunka i dalje plaća cijeli chunk, dok chunkovi već u dijeljenom kešu posao ne koštaju ništa. Otkazivanje posla u redu čekanja dolazi do završnog stanja s nula izvornih čitanja; posao u tijeku provjerava se prije svakog prolaza ovisnosti i svakog izvornog chunka, a oslobađanje range toka čeka da povratni poziv u letu vrati umjesto da pokuša prekinuti

PDFlibPas prefetch petlja u Delphiju: posao ispituje dostupnost, dohvaća nedostajuće raspone i ponovno ispituje, jer svaki čvor stabla stranica ili object stream koji stigne otkriva sljedeći sloj ovisnosti, dok se graf ne dovrši ili ograničenje ne zaustavi
Prefetch posao iterira jer nedostajući čvor imenuje vlastitu djecu tek kad stigne, i naplaćuje svaki prolaz u cijelim fizičkim chunkovima
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 zadnji
    potpuni izvještaj dostupnosti, pa LIMIT_REACHED ostaje razlučit
    od FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Gdje se to degradira u preuzimanje cijele datoteke

Range učitavanje kladion je na raspored datoteke, i neke datoteke ga ne poštuju. Linearizirana datoteka po ISO 32000-1 §7.5.8 dobar je slučaj: odjeljak prve stranice ugrijava se pri otvaranju, ograničen i postojećim 4 MiB sigurnosnim pragom i trenutačnim keš budžetom da zagrijavanje ne može odmah izbaciti većinu samog sebe. Nelinearizirana datoteka i dalje se razrješuje kroz trailer i lanac unakrsnih referenci kraj kraja, što košta par dodatnih odlazaka i povratka, a ne katastrofu. Prava litica je oštećena datoteka koja forsira put popravka, jer obnova tablice unakrsnih referenci znači skeniranje zaglavlja objekata preko cijelog dokumenta, a to je potpuno preuzimanje koje stiže jedan chunk po chunk. Latencija je druga poštena granica: pri 60 ms po zahtjevu, parsiranje nasumičnog pristupa koje treba četrdeset nekeširanih chunkova provesti će preko dvije sekunde u prijenosu koljegod keš dobar bio, i upravo to argument read-ahead i prioritetni red postoje sakriti. Ista disciplina pojavljuje se u pristupu izravnog pristupa spajanju i dijeljenju velikih PDF-ova, i ovaj keš leži ispod paralelnog prikazivanja stranica i diskovnog keša stranica preglednika jednako

API izvora raspona, upit dostupnosti i prefetch planer dio su standardne PDFlibPas Delphi PDF Library za Delphi, C++Builder i Free Pascal; stranica proizvoda nosi potpunu referencu parametara za LoadFromRangeSource uz prefetch prioritetne i stanje konstante