Teknisk artikel

Progressiv PDF range-indlæsning i Delphi med PDFlibPas

Et 2 GB scanned arkiv ligger i en S3-bucket, og brugeren vil have side 900. PDFlibPas kan levere den side uden at downloade filen: LoadFromRangeSource bygger en read-only søgbar stream over din egen byte-range-callback og giver den til TPDFDocument, så parseren trækker krydsreferencetabellerne, én sidetræ-gren og én content stream

Transportsiden af dette er gammel og kedelig. HTTP-servere har annonceret byte-ranges i årtier, nu specificeret i RFC 9110 §14, og hver objektbutik taler samme dialekt. PDF-siden er lige så afgjort: ISO 32000-1 §7.5.8 definerer linearisering præcis, så en reader kan rendere første side fra filens front. Det, der har manglet i Delphi, er brikken i midten, den del, der beslutter, hvilke ranges der spørges efter, hvor mange der beholdes, og hvordan man undgår at spørge to gange

Hvad behøver LoadFromRangeSource fra din transport?

To ting, og ingen af dem er en stream. PDFlibPas beder om en autoritativ SourceSize og en synkron read-callback af typen TPDFlibRangeReadEvent, deklareret som function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Internt bliver parret til en TCallbackByteRangeSource, der eksponerer SourceSize og ReadRange, pakket ind i en stream, hvis ejerskab går til dokumentet. Dit callback-mål og dets backend forbliver dit: dokumentet frigør wrapperen ved close, clear eller reload, men rører aldrig transportobjektet bag metodepointeren

Kontrakten er bevidst eftergivende i én retning og streng i den anden. En kort læsning er lovlig og betyder blot, at parseren spørger igen. En callback, der rejser, konverteres til en kort læsning og konvergerer gennem den normale load-fejlsti. En callback, der hævder at have skrevet mere end Count bytes, klemmes, fordi en buggy provider ikke må kunne løbe cache-bufferen over. Adgangskodeforsøg genopbygger en frisk range-stream og frisk parsetilstand over samme callback-kilde, så et mislykket forsøg ikke kan efterlade forældet position, vindue 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
  { ét blokerende 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;  { frigør wrapper-streamen }
  Src.Free;  { din transport, din levetid }
end;

Hvor meget holder range-cachen reelt?

Som standard 4 MiB, fordelt over chunk-justerede vinduer og evicteret LRU. Det tidligere enkeltvindue-design voksede til den længde, kalderen bad om, så én stor sekventiel læsning kunne skyde forbi den nominelle chunk-størrelse, mens et tilfældigt hop kastede det forrige vindue ud straks. Den nuværende cache justerer hver kildeoffset til ChunkSize, henter præcis én chunk pr. miss og håndhæver et hårdt byte-budget på tværs af flere vinduer. Ethvert eksplicit budget, du giver, hæves til mindst én fuld chunk, så en enkelt læsning altid skrider frem chunk for chunk, og peak cache-belastning forbliver forudsigelig. En ChunkSize under 4096 falder tilbage til 64 KiB-standardens

Hvordan PDFlibPas betjener en parser-læsning i Delphi uden at downloade PDF'en: den absolutte offset justeres ned til chunk-størrelsen, betjenes fra ét af flere LRU-vinduer ved hit, eller omdannes til et enkelt klemt callback-kald ved miss
Hver kildeoffset justeres til chunk-størrelsen, så ét miss henter præcis én chunk, og peak cache-belastningen forbliver forudsigelig

Gentag-læsningsregnskabet er den del, der er værd at tilslutte din telemetri. PDFlibPas identificerer en gentagelse ved justeret chunk-start og holder ordnede sammenhængende intervaller, hvilket adskiller en ægte første hentning fra en genhentning efter eviction, samtidig med at bogholderiet ikke vokser lineært med filstørrelsen. GetRangeSourceCacheInfo returnerer hele billedet som JSON, SetRangeSourceCacheLimit ændrer budgettet ved runtime, og ClearRangeSourceCache dropper vinduerne og nulstiller statistikken sammen. At skrumpe budgettet ved runtime bevarer historikken og tæller de budgetdrevne frigørelser som evictions, så en stigende repeatedReads mod en flad hits er dit signal om, at working set ikke længere er med

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;

Hvad sker der, når flere tråde vil have samme chunk?

De venter på én anmodning, ikke flere. En klassisk TStream har én positionscursor, og to tråde, der hver låser korrekt, kan stadig have den position omskrevet mellem en Seek og en Read, så lazy objects og segmenterede læsninger i PDFlibPas bruger en absolut ReadAt, der aldrig flytter cursoren. Hver justeret chunk får én enkelt in-flight-anmodning, som enhver kalder for den chunk deler, tilstødende queuede chunks lægges sammen, før kildelæsningen starter, og én fysisk læsning er begrænset til 16 MiB, så et burst af parallelt sidearbejde forstærkes til hverken duplikerede små anmodninger eller én absurd stor. Merge-vinduet er som standard 2 ms og gælder kun den første manglende chunk i hver ReadAt; positionel Read venter aldrig på den, og at give nul fjerner den indledende samlingsforsinkelse helt, hvilket betyder noget for lange sekventielle scanninger, der ellers akkumulerer ventetiden chunk for chunk. Position, cache-metadata og kildelæsninger ligger bag tre separate låse, og selve kildce-callbacken er serialiseret, hvilket er det, der gør, at en database- eller objektbutik-adapter uden intern trådbeskyttelse kan bruges uændret. Ventere modtager deres egen kopi af dataene, så en senere LRU-eviction ikke kan invalidere en buffer, der allerede er udleveret

Request-sammenlægning i PDFlibPas range-indlæsning til Delphi: to tråde, der beder om samme chunk, deler én in-flight-anmodning, tilstødende queuede chunks lægges sammen inde i et to millisekunders vindue, og én serialiseret kildelæsning betjener dem alle
Et burst af parallelt sidearbejde kollapser til én delt anmodning pr. chunk, og hver ventende modtager stadig sin egen kopi af bytes

Kan du spørge, om side 900 er klar, uden at hente den?

Ja, og det er præcis, hvad den valgfrie availability-callback er til. En almindelig read-callback kan ikke skelne bytes, der allerede er landet, fra bytes, der kræver en blokerende rundtur, og at probe med en prøvelæsning ville udløse selve den download, du forsøger at undgå. TPDFlibRangeAvailabilityEvent besvarer kun ét spørgsmål, om et komplet interval kan læses straks, og er forbudt at hente noget; bytes, cachen allerede dækker, tæller altid som tilgængelige. GetRangeSourceDataAvailability mapper indirekte objekter til de fysiske lagringsintervaller, der er registreret i krydsreferenceindgangene, opløser komprimerede objekter til deres object stream-container, korrigerer for en forskudt PDF-header og parser et objekt først, efter hele intervallet består den ikke-hentende probe, så den manglende sti aldrig kalder din read-callback

Traverseringen er afgrænset snarere end udtømmende. En sideforespørgsel går kun den gren af sidetræet, der indeholder målsiden, og tilføjer derefter sideindhold, ressourcer, annotationer og nedarvede sideattributter, idet Parent- og P-tilbagekanter springes over, så en enkelt side eller widget ikke kan ekspandere baglæns ind i hele dokumentet. Objektgrafen er begrænset til 100000 anmodede objekter og en dybde på 256, stream-objekter parses ordbog-først, og en full-parse-fallback tillades kun for lagrede objekter op til 4 MiB. JSON-rapporten lægger overlappende og tilstødende intervaller sammen før optælling, så requiredBytes og missingBytes beregnes fra de sammenlagte requiredRanges- og missingRanges-arrays, hvis end er et inklusivt endepunkt. At forespørge et objekt, der allerede er tilgængeligt, kan befolke range-cachen; at forespørge et manglende efterlader læsestatistikken 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
    { Reporten bærer "missingBytes" plus de sammenlagte "missingRanges" }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { fx har filen slet ingen AcroForm }
end;

Hvorfor prefetch er nødt til at iterere

Fordi at læse de aktuelle missingRanges én gang ikke gør siden tilgængelig. En manglende sidetræknude eller object stream afslører først det næste lag af afhængigheder, efter den ankommer, så et PDFlibPas-prefetch-job kører en forespørgsels-, hentnings-, forespørgsels-løkke, indtil siden, formularen eller objektgrafen er helt tilgængelig, eller en byte- eller gennemløbsgrænse stopper den. Jobbet bruger sin egen reader og en lille sekundær cache, hvis datakilde videresender absolutte læsninger til den oprindelige range-stream, hvilket holder parsetilstanden isoleret fra forgrunds-TSmartPDFReader, mens de bytes, den reelt downloader, stadig lander i den delte hovedcache. Én workertråd findes pr. range-stream, matchende den serialisering, kildce-callbacken allerede kræver, og køen vælger efter fire prioritetsniveauer og derefter efter indsendelsesrækkefølge inden for et niveau. MaxBytes belastes i fysiske chunk-bytes, så en parser, der beder om en enkelt byte inde i en uncached chunk, stadig betaler for hele chunken, mens chunks allerede i den delte cache koster jobbet intet. At annullere et kø-job når en terminaltilstand med nul kildelæsninger; et kørende job tjekkes før hvert afhængighedsgennemløb og hver kildechunk, og at frigøre range-streamen venter på, at en in-flight-callback returnerer i stedet for at forsøge at afbryde den

PDFlibPas-prefetch-løkken i Delphi: et job forespørger availability, henter de manglende ranges og forespørger igen, fordi hver ankomne sidetræknude eller object stream afslører det næste lag af afhængigheder, indtil grafen fuldendes eller en grænse stopper den
Et prefetch-job itererer, fordi en manglende knude først nævner sine egne børn, når den ankommer, og den belaster hvert gennemløb i hele fysiske chunks
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 sidste
    fulde availability-rapport, så LIMIT_REACHED forbliver adskillelig
    fra FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Hvor dette degraderer til en hel-fil-download

Range-indlæsning er et væddemål om fillayout, og nogle filer ærer det ikke. En lineariseret fil efter ISO 32000-1 §7.5.8 er det gode tilfælde: første-sidesektionen varmes ved åbning, afgrænset af både den eksisterende 4 MiB sikkerhedstærskel og det aktuelle cache-budget, så opvarmningen ikke straks evicter det meste af sig selv. En ikke-lineariseret fil opløses stadig gennem traileren og krydsreferencekæden nær slutningen, hvilket koster et par ekstra rundturer snarere end en katastrofe. Den reelle klippe er en beskadiget fil, der tvinger repareringsstien frem, fordi rekonstruktion af en krydsreferencetabel betyder at scanne efter objekthoveder på tværs af hele dokumentet, og det er en fuld download, der ankommer én chunk ad gangen. Latenstid er den anden ærlige grænse: ved 60 ms pr. anmodning bruger en random-access-parse, der behøver fyrre uncached chunks, over to sekunder i transit, uanset hvor god cachen er, hvilket præcis er, hvad read-ahead-argumentet og prioritetskøen findes for at skjule. Samme disciplin viser sig i direct-access-tilgangen til fletning og split af store PDF'er, og denne cache ligger under både parallell side-rendering og viewer-disk-sidecachen

Range source-API'en, availability-forespørgslen og prefetch-scheduleren er en del af standard-PDFlibPas Delphi PDF Library til Delphi, C++Builder og Free Pascal; produktsiden bærer den fulde parameterreference for LoadFromRangeSource sammen med prefetch-prioritets- og tilstandskonstanterne