Articol tehnic

Încărcare PDF pe intervale în Delphi cu PDFlibPas

O arhivă scanată de 2 GB stă într-un bucket S3, iar utilizatorul vrea pagina 900. PDFlibPas poate livra pagina respectivă fără să descarce fișierul: LoadFromRangeSource construiește un flux seekabil doar în citire peste propriul dumneavoastră callback de intervale de octeți și îl predă lui TPDFDocument, astfel încât parserul trage doar tabelele de referințe încrucișate, o ramură din arborele de pagini și un flux de conținut

Partea de transport e veche și plictisitoare. Serverele HTTP publică intervale de octeți de decenii, acum specificate în RFC 9110 §14, iar orice object store vorbește același dialect. Partea PDF este la fel de așezată: ISO 32000-1 §7.5.8 definește liniarizarea tocmai pentru ca un cititor să poată reda prima pagină din fața fișierului. Ce a lipsit în Delphi este piesa din mijloc, partea care decide ce intervale cere, câte reține și cum evită să ceară de două ori

Ce cere LoadFromRangeSource de la transportul dumneavoastră?

Două lucruri, și niciunul nu este un flux. PDFlibPas cere un SourceSize autoritar și un callback sincron de citire de tipul TPDFlibRangeReadEvent, declarat ca function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Intern, perechea devine un TCallbackByteRangeSource care expune SourceSize și ReadRange, învelit într-un flux a cărui proprietate trece la document. Ținta callback-ului și backendul ei rămân ale dumneavoastră: documentul eliberează învelișul la close, clear sau reload, dar nu atinge niciodată obiectul de transport din spatele pointerului de metodă

Contractul este, în mod deliberat, îngăduitor într-o direcție și strict în cealaltă. O citire scurtă este legală și înseamnă pur și simplu că parserul cere din nou. Un callback care ridică o excepție este convertit într-o citire scurtă și converge pe calea obișnuită a eșecului de încărcare. Un callback care pretinde că a scris mai mult de Count octeți este limitat, pentru că un furnizor cu erori nu trebuie să poată depăși bufferul cache. Reîncercările de parolă reconstruiesc un flux de intervale nou și o stare de parsare nouă peste aceeași sursă de callback, astfel încât o tentativă eșuată nu poate lăsa în urmă poziție, fereastră sau stare de decriptare perimate

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
  { un singur GET blocant cu 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;  { eliberează fluxul-înveliș }
  Src.Free;  { transportul tău, durata ta de viață }
end;

Cât deține de fapt cache-ul de intervale?

În mod implicit 4 MiB, răspândite pe ferestre aliniate la chunk și eliminate LRU. Proiectul anterior cu o singură fereastră creștea la orice lungime cerea apelantul, astfel încât o citire secvențială mare putea depăși dimensiunea nominală a chunk-ului, în timp ce un salt aleator arunca imediat fereastra anterioară. Cache-ul actual aliniază fiecare offset al sursei la ChunkSize, aduce exact un chunk per miss și impune un buget riguros de octeți pe mai multe ferestre. Orice buget explicit pe care îl transmiteți este ridicat la cel puțin un chunk întreg, astfel încât o citire avansează întotdeauna chunk cu chunk, iar încărcarea de vârf a cache-ului rămâne previzibilă. Un ChunkSize sub 4096 revine la implicitul de 64 KiB

Cum livrează PDFlibPas o citire a parserului în Delphi fără să descarce PDF-ul: offsetul absolut este aliniat în jos la dimensiunea chunk-ului, livrat din una dintre ferestrele LRU la hit sau transformat într-un singur apel de callback limitat la miss
Fiecare offset al sursei este aliniat la dimensiunea chunk-ului, astfel încât un miss aduce exact un chunk, iar încărcarea de vârf a cache-ului rămâne previzibilă

Contabilizarea citirilor repetate este partea care merită legată de telemetria dumneavoastră. PDFlibPas identifică o repetare după începutul de chunk aliniat și păstrează intervale contigue ordonate, ceea ce separă o aducere genuină de la prima încercare de o readucere după eliminare, ținând contabilitatea să nu crească liniar cu dimensiunea fișierului. GetRangeSourceCacheInfo returnează imaginea completă ca JSON, SetRangeSourceCacheLimit redimensionează bugetul în timpul rulării, iar ClearRangeSourceCache aruncă ferestrele și resetează statisticile împreună. Reducerea bugetului în timpul rulării păstrează istoricul și contorizează eliberările dictate de buget ca eliminări, astfel încât un repeatedReads în creștere contra unui hits plat este semnalul că setul de lucru nu mai încape

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;

Ce se întâmplă când mai multe fire vor același chunk?

Așteaptă pe o singură cerere, nu pe mai multe. Un TStream clasic are un singur cursor de poziție, iar două fire care fiecare își iau corect blocada pot avea totuși poziția rescrisă între un Seek și un Read, de aceea obiectele leneșe și citirile segmentate din PDFlibPas folosesc un ReadAt absolut care nu mută niciodată cursorul. Fiecare chunk aliniat primește o singură cerere în curs, partajată de toți apelanții acelui chunk, chunk-urile alăturate aflate în coadă sunt contopite înainte să înceapă citirea sursei, iar o citire fizică este plafonată la 16 MiB, astfel încât o explozie de lucru paralel pe pagini nu se amplifică nici în cereri mici duplicate, nici într-una absurd de mare. Fereastra de contopire implicită este de 2 ms și se aplică doar primului chunk lipsă al fiecărui ReadAt; Read-ul pozițional nu așteaptă niciodată după ea, iar trecerea lui zero elimină complet întârzierea inițială de colectare, ceea ce contează pentru scanările secvențiale lungi care altfel ar acumula așteptarea chunk cu chunk. Poziția, metadatele cache și citirile din sursă stau în spatele a trei blocări separate, iar callback-ul sursei în sine este serializat, ceea ce permite ca un adaptor de bază de date sau object store fără protecție internă de fire să fie folosit neschimbat. Cei care așteaptă primesc propria copie a datelor, astfel încât o eliminare LRU ulterioară nu poate invalida un buffer deja predat

Contopirea cererilor în încărcarea pe intervale din PDFlibPas pentru Delphi: două fire care cer același chunk partajează o singură cerere în curs, chunk-urile alăturate din coadă sunt contopite într-o fereastră de două milisecunde, iar o singură citire serializată a sursei le deservește pe toate
O explozie de lucru paralel pe pagini se prăbușește într-o singură cerere partajată per chunk, iar fiecare așteptător primește în continuare propria copie a octeților

Puteți întreba dacă pagina 900 e gata fără să o aduceți?

Da, și exact pentru asta există callback-ul opțional de disponibilitate. Un callback de citire obișnuit nu poate distinge octeții care au aterizat deja de octeții care necesită un dus-întors blocant, iar o sondare printr-o citire de probă ar declanșa chiar descărcarea pe care încercați să o evitați. TPDFlibRangeAvailabilityEvent răspunde la o singură întrebare, dacă un interval complet poate fi citit imediat, și îi este interzis să aducă ceva; octeții acoperiți deja de cache contează întotdeauna ca disponibili. GetRangeSourceDataAvailability mapează obiectele indirecte la intervalele fizice de stocare consemnate în intrările de referințe încrucișate, rezolvă obiectele comprimate la containerul lor object stream, corectează un antet PDF deplasat și parsează un obiect doar după ce întregul interval trece de sondarea care nu aduce nimic, astfel încât calea pentru ce lipsește nu vă cheamă niciodată callback-ul de citire

Traversarea are scop delimitat, nu exhaustiv. O interogare de pagină parcurge doar ramura arborelui de pagini care conține pagina țintă și apoi adaugă conținutul paginii, resursele, adnotările și atributele de pagină moștenite, sărind muchiile înapoi Parent și P astfel încât o singură pagină sau un widget să nu se poată extinde înapoi în tot documentul. Graful de obiecte este limitat la 100000 de obiecte cerute și la o adâncime de 256, obiectele de tip stream sunt parsate întâi din dicționar, iar o repliere la parsare completă este permisă doar pentru obiecte stocate de până la 4 MiB. Raportul JSON contopește intervalele suprapuse și adiacente înainte de numărare, astfel încât requiredBytes și missingBytes sunt calculate din tablourile contopite requiredRanges și missingRanges, ai căror end este un capăt inclusiv. Interogarea unui obiect deja disponibil poate popula cache-ul de intervale; interogarea unuia care lipsește lasă statisticile de citire neatinse

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 poartă "missingBytes" plus "missingRanges" contopite }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { de ex. fișierul nu are deloc AcroForm }
end;

De ce prefetch-ul trebuie să itereze

Pentru că citirea o singură dată a missingRanges curente nu face pagina disponibilă. Un nod lipsă din arborele de pagini sau un object stream dezvăluie următorul strat de dependențe abia după ce sosește, de aceea un job de prefetch PDFlibPas rulează o buclă de interogare, aducere, reinterogare până când pagina, formularul sau graful de obiecte este complet disponibil sau o limită de octeți ori de treceri îl oprește. Jobul folosește propriul cititor și un cache secundar mic a cărui sursă de date redirecționează citirile absolute către fluxul original de intervale, ceea ce ține starea de parsare izolată de TSmartPDFReader din prim-plan, în timp ce octeții pe care îi descarcă cu adevărat aterizează tot în cache-ul principal partajat. Există un singur fir de lucru per flux de intervale, potrivind serializarea pe care callback-ul sursei o cere oricum, iar coada alege după patru niveluri de prioritate și apoi după ordinea de trimitere în cadrul unui nivel. MaxBytes se taxează în octeți fizici de chunk, astfel încât un parser care cere un singur octet dintr-un chunk necache-uit plătește oricum tot chunk-ul, în timp ce chunk-urile aflate deja în cache-ul partajat nu costă jobul nimic. Anularea unui job din coadă ajunge într-o stare terminală cu zero citiri din sursă; un job în rulare este verificat înainte de fiecare trecere de dependențe și de fiecare chunk din sursă, iar eliberarea fluxului de intervale așteaptă ca un callback în curs să se întoarcă în loc să încerce să-l întrerupă

Bucla de prefetch PDFlibPas în Delphi: un job interoghează disponibilitatea, aduce intervalele care lipsesc și interoghează din nou, pentru că fiecare nod de arbore de pagini sau object stream care sosește dezvăluie următorul strat de dependențe, până când graful se completează sau o limită îl oprește
Un job de prefetch iterează pentru că un nod lipsă își numește copiii abia când sosește, iar el taxează fiecare trecere în chunk-uri fizice întregi
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 ultimul
    raport complet de disponibilitate, astfel încât LIMIT_REACHED
    rămâne distingebil de FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Unde se degradează asta într-o descărcare a întregului fișier

Încărcarea pe intervale este un pariu pe aranjarea fișierului, iar unele fișiere nu îl onorează. Un fișier liniarizat conform ISO 32000-1 §7.5.8 este cazul bun: secțiunea primei pagini este încălzită la deschidere, delimitată atât de pragul de siguranță existent de 4 MiB, cât și de bugetul actual de cache, astfel încât încălzirea să nu se poată elimina imediat pe sine în cea mai mare parte. Un fișier neliniarizat se rezolvă totuși prin trailer și lanțul de referințe încrucișate aflat aproape de sfârșit, ceea ce costă câteva dus-întorsuri în plus, nu un dezastru. Prăpastia reală este un fișier deteriorat care forțează calea de reparare, pentru că reconstruirea unui tabel de referințe încrucișate înseamnă scanarea antetelor de obiecte în tot documentul, iar asta este o descărcare completă care sosește câte un chunk. Latența este cealaltă limită onestă: la 60 ms per cerere, o parsare cu acces aleator care are nevoie de patruzeci de chunk-uri necache-uite petrece peste două secunde în tranzit, indiferent cât de bun este cache-ul, iar tocmai pentru a ascunde asta există argumentul read-ahead și coada de priorități. Aceeași disciplină apare în abordarea cu acces direct pentru fuziunea și împărțirea PDF-urilor mari, iar acest cache stă la bază atât sub randarea paralelă de pagini, cât și sub cache-ul de pagini pe disc al vizualizatorului

API-ul de sursă pe intervale, interogarea de disponibilitate și planificatorul de prefetch fac parte din PDFlibPas Delphi PDF Library standard pentru Delphi, C++Builder și Free Pascal; pagina produsului poartă referința completă de parametri pentru LoadFromRangeSource împreună cu constantele de prioritate și stare ale prefetch-ului