Teknisk artikkel

Trinnvis innlasting av PDF-ranges i Delphi med PDFlibPas

Et 2 GB skannet arkiv ligger i en S3-bøtte, og brukeren vil ha side 900. PDFlibPas kan levere den siden uten å laste ned filen: LoadFromRangeSource bygger en skrivebeskyttet, søkbar strøm over din egen byte-range-callback og gir den til TPDFDocument, slik at parseren henter kryssreferansetabellene, én sidetre-gren og én innholdsstrøm

Transportdelen av dette er gammel og kjedelig. HTTP-servere har annonsert byte-ranges i tiår, nå spesifisert i RFC 9110 §14, og hvert objektlager snakker samme dialekt. PDF-siden er like avklart: ISO 32000-1 §7.5.8 definerer linearisering presist slik at en leser kan gjengi første side fra filens fremre del. Det som har manglet i Delphi er brikken i midten, delen som avgjør hvilke ranges som skal etterspørres, hvor mange som skal beholdes, og hvordan man unngår å spørre to ganger

Hva krever LoadFromRangeSource fra transporten din?

To ting, og ingen av dem er en strøm. PDFlibPas krever en autoritativ SourceSize og en synkron lese-callback av typen TPDFlibRangeReadEvent, deklarert som function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Internt blir paret en TCallbackByteRangeSource som eksponerer SourceSize og ReadRange, pakket inn i en strøm hvis eierskap går over til dokumentet. Callback-målet ditt og dets bakpart forblir ditt: dokumentet frigjør wrapperen ved close, clear eller reload, men rører aldri transportobjektet bak metodepekeren

Kontrakten er med vilje tilgivende i én retning og streng i den andre. En kort lesing er lovlig og betyr bare at parseren spør igjen. En callback som utløser et unntak konverteres til en kort lesing og konvergerer gjennom den normale innlastingsfeilbanen. En callback som hevder å ha skrevet mer enn Count byte blir begrenset, for en feilende leverandør må ikke kunne overskrive hurtigbufferen. Passordforsøk bygger en fersk range-strøm og en fersk analysetilstand over samme callback-kilde, så et mislykket forsøk ikke kan etterlate foreldet posisjon, vindu eller dekrypteringstilstand

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
  { én blokkerende GET med 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;  { frigjør wrapper-strømmen }
  Src.Free;  { din transport, din levetid }
end;

Hvor mye holder range-hurtigbufferen egentlig?

Som standard 4 MiB, spredt over chunk-justerte vinduer og tømt etter LRU-prinsippet. Det tidligere designet med ett vindu vokste til så lang lesing kalleren ba om, så én stor sekvensiell lesing kunne skyte seg forbi den nominelle chunk-størrelsen, mens et tilfeldig hopp kastet det forrige vinduet med en gang. Dagens hurtigbuffer justerer hver kildeoffset til ChunkSize, henter nøyaktig én chunk per bom, og håndhever et hardt bytebudsjett på tvers av flere vinduer. Ethvert eksplisitt budsjett du sender inn, heves til minst én full chunk, så en enkelt lesing alltid går fremover chunk for chunk, og toppbelastningen i hurtigbufferen forblir forutsigbar. En ChunkSize under 4096 faller tilbake til 64 KiB standardverdien

Hvordan PDFlibPas betjener en parserlesing i Delphi uten å laste ned PDF-en: den absolutte offseten justeres ned til chunk-størrelsen, betjenes fra ett av flere LRU-vinduer ved treff, eller gjøres om til ett begrenset callback-kall ved bom
Hver kildeoffset justeres til chunk-størrelsen, så én bom henter nøyaktig én chunk og toppbelastningen i hurtigbufferen forblir forutsigbar

Bokføring av gjentatte lesinger er delen som er verdt å koble til telemetrien din. PDFlibPas identifiserer en gjentagelse ved justert chunk-start og fører ordnede sammenhengende intervaller, noe som skiller en ekte første henting fra en ny henting etter tømming, samtidig som bokholderiet holdes fra å vokse lineært med filstørrelsen. GetRangeSourceCacheInfo returnerer hele bildet som JSON, SetRangeSourceCacheLimit endrer budsjettet under kjøring, og ClearRangeSourceCache forkaster vinduene og nullstiller statistikken samtidig. Å krympe budsjettet under kjøring beholder historikken og teller budsjettdrevne frigivelser som tømminger, så en stigende repeatedReads mot en flat hits er signalet ditt på at arbeidssettet ikke lenger får plass

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;

Hva skjer når flere tråder vil ha samme chunk?

De venter på én forespørsel, ikke flere. En klassisk TStream har én enkelt posisjonsmarkør, og to tråder som hver låser riktig kan likevel få posisjonen omskrevet mellom en Seek og en Read, så late objekter og segmenterte lesinger i PDFlibPas bruker en absolutt ReadAt som aldri flytter markøren. Hver justerte chunk får én enkelt pågående forespørsel som alle kallere for den chunken deler, tilstøtende kølagte chunker slås sammen før kildelesingen starter, og én fysisk lesing er begrenset til 16 MiB, så en burst av parallelt sidearbeid forsterkes verken til dupliserte små forespørsler eller én absurd stor. Sammenslåingsvinduet er som standard 2 ms og gjelder bare den første manglende chunken i hver ReadAt; posisjonell Read venter aldri på det, og null fjerner den innledende samlingsforsinkelsen helt, noe som betyr noe for lange sekvensielle skanninger som ellers akkumulerer ventetiden chunk for chunk. Posisjon, hurtigbuffermetadata og kildelesinger ligger bak tre separate låser, og kilde-callbacken selv serialiseres, noe som er det som lar en database- eller objektlageradapter uten intern trådbeskyttelse brukes uendret. Ventere mottar sin egen kopi av dataene, så en senere LRU-tømming ikke kan ugyldiggjøre en buffer som allerede er utlevert

Forespørselssammenslåing i PDFlibPas range-innlasting for Delphi: to tråder som ber om samme chunk deler én pågående forespørsel, tilstøtende kølagte chunker slås sammen innenfor et vindu på to millisekunder, og én serialisert kildelesing betjener alle
En burst av parallelt sidearbeid kollapser til én delt forespørsel per chunk, og hver venter mottar fremdeles sin egen kopi av bytene

Kan du spørre om side 900 er klar uten å hente den?

Ja, og det er nøyaktig hva den valgfrie tilgjengelighets-callbacken er til. En vanlig lese-callback kan ikke skille bytes som allerede er landet fra bytes som krever en blokkerende tur frem og tilbake, og å sonde med en prøvelesing ville utløse selve nedlastingen du prøver å unngå. TPDFlibRangeAvailabilityEvent svarer på bare ett spørsmål, om et komplett range kan leses umiddelbart, og er forbudt å hente noe; bytes hurtigbufferen allerede dekker teller alltid som tilgjengelige. GetRangeSourceDataAvailability mapper indirekte objekter til de fysiske lagringsrangene som er registrert i kryssreferanseoppføringene, løser komprimerte objekter til deres objektstrøm-container, korrigerer for en forskjøvet PDF-header, og parser et objekt først etter at hele rangen består den ikke-hentende sondringen, så den manglende banen aldri kaller lese-callbacken din

Traverseringen er avgrenset snarere enn uttømmende. En sideforespørsel går bare gjennom grenen av sidetreet som inneholder målsiden, og legger deretter til sideinnhold, ressurser, annotasjoner og arvede sideattributter, og hopper over Parent- og P-bakkantene slik at en enkelt side eller widget ikke kan ekspandere baklengs til hele dokumentet. Objektgrafen er begrenset til 100000 etterspurte objekter og en dybde på 256, strømobjekter parses ordbok-først, og en fullparsingsreserve tillates bare for lagrede objekter opptil 4 MiB. JSON-rapporten slår sammen overlappende og tilstøtende intervaller før tellingen, så requiredBytes og missingBytes beregnes fra de sammenslåtte requiredRanges- og missingRanges-matrisene, hvis end er et inklusivt endepunkt. Å spørre om et objekt som allerede er tilgjengelig kan fylle range-hurtigbufferen; å spørre om et manglende lar lesestatistikken urørt

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 inneholder "missingBytes" pluss de sammenslåtte "missingRanges" }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { f.eks. har filen ingen AcroForm i det hele tatt }
end;

Hvorfor prefetch må iterere

Fordi å lese dagens missingRanges én gang ikke gjør siden tilgjengelig. En manglende sidetrenode eller objektstrøm avslører først neste lag av avhengigheter etter at den ankommer, så en PDFlibPas prefetch-jobb kjører en spør-, hent-, spør-igjen-løkke til siden, skjemaet eller objektgrafen er fullstendig tilgjengelig eller en byte- eller gjennomgangsgrense stopper den. Jobben bruker sin egen leser og en liten sekundær hurtigbuffer hvis datakilde videresender absolutte lesinger til den opprinnelige range-strømmen, noe som holder analysetilstanden isolert fra forgrunns-TSmartPDFReader mens bytene den faktisk laster ned fortsatt lander i den delte hovedbufferen. Én arbeidertråd finnes per range-strøm, i tråd med serialiseringen kilde-callbacken allerede krever, og køen velger etter fire prioritetsnivåer og deretter etter innsendingsrekkefølge innenfor et nivå. MaxBytes belastes i fysiske chunk-byte, så en parser som ber om én enkelt byte inne i en ucachet chunk betaler fortsatt for hele chunken, mens chucker allerede i den delte bufferen koster jobben ingenting. Å avbryte en kølagt jobb når en terminaltilstand med null kildelesinger; en kjørende jobb sjekkes før hver avhengighetsgjennomgang og hver kildechunk, og å frigjøre range-strømmen venter på at en pågående callback returnerer i stedet for å prøve å avbryte den

PDFlibPas prefetch-løkken i Delphi: en jobb spør om tilgjengelighet, henter de manglende rangene og spør igjen, fordi hver ankomende sidetrenode eller objektstrøm avslører neste lag av avhengigheter, til grafen fullføres eller en grense stopper den
En prefetch-jobb itererer fordi en manglende node først navngir sine egne barn når den ankommer, og den belaster hver gjennomgang i hele fysiske chucker
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" og den siste
    fullstendige tilgjengelighetsrapporten, slik at LIMIT_REACHED forblir
    skillbar fra FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Der dette forfaller til nedlasting av hele filen

Range-innlasting er et veddemål på filoppbygging, og noen filer ærer det ikke. En linearisert fil etter ISO 32000-1 §7.5.8 er det gode tilfellet: førstesideseksjonen varmes opp ved åpning, avgrenset av både den eksisterende 4 MiB sikkerhetsterskelen og dagens hurtigbufferbudsjett, så oppvarmingen ikke umiddelbart kan tømme det meste av seg selv. En ikke-linearisert fil løses fortsatt gjennom traileren og kryssreferansekjeden mot slutten, noe som koster et par ekstra turer frem og tilbake snarere enn en katastrofe. Den virkelige klippen er en skadet fil som tvinger frem reparasjonsbanen, for å rekonstruere en kryssreferansetabell betyr å skanne etter objekthoder på tvers av hele dokumentet, og det er en full nedlasting som ankommer én chunk om gangen. Latens er den andre ærlige grensen: ved 60 ms per forespørsel bruker en parsing med tilfeldig tilgang som trenger førti ucachete chucker over to sekunder i transitt uansett hvor god bufferen er, noe som er presis det lese-foran-argumentet og prioritetskøen finnes for å skjule. Denne disiplinen viser seg også i metoden med direkte tilgang for å flette og splitte store PDF-er, og denne bufferen ligger under både parallell sidengjengivelse og viserens disksidebuffer

Range-kilde-API-et, tilgjengelighetsforespørselen og prefetch-planleggeren er del av det standard PDFlibPas Delphi PDF Library for Delphi, C++Builder og Free Pascal; produktsiden fører den fullstendige parameterreferansen for LoadFromRangeSource sammen med prefetch-prioritetene og tilstandskonstantene