Teknisk artikel

Progressiv PDF-intervallinläsning i Delphi med PDFlibPas

Ett 2 GB stort skannat arkiv ligger i en S3-bucket och användaren vill ha sida 900. PDFlibPas kan servera den sidan utan att ladda ner filen: LoadFromRangeSource bygger en skrivskyddad, sökbar ström över din egen återanropning för byte-intervall och lämnar den till TPDFDocument, så att tolken hämtar korsreferenstabellerna, en sidträds gren och en innehållsström

Transportsidan av det här är gammal och tråkig. HTTP-servrar har annonserat byte-intervall i årtionden, numera specificerat i RFC 9110 §14, och alla objektlager talar samma dialekt. PDF-sidan är lika färdig: ISO 32000-1 §7.5.8 definierar linjärisering just för att en läsare ska kunna rendera första sidan från filens början. Det som saknats i Delphi är biten i mitten, den som avgör vilka intervall som ska begäras, hur många som ska behållas och hur man undviker att fråga två gånger

Vad behöver LoadFromRangeSource från din transport?

Två saker, och ingen av dem är en ström. PDFlibPas begär en auktoritativ SourceSize och en synkron läsåteranropning av typen TPDFlibRangeReadEvent, deklarerad som function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Internt blir paret en TCallbackByteRangeSource som exponerar SourceSize och ReadRange, insvept i en ström vars ägarskap går över till dokumentet. Ditt återanropsmål och dess backend förblir ditt: dokumentet frigör omslaget vid close, clear eller reload, men rör aldrig transportobjektet bakom metodpekaren

Kontraktet är medvetet eftergivande i en riktning och strikt i den andra. En kort läsning är laglig och betyder bara att tolken frågar igen. En återanropning som kastar undantag omvandlas till en kort läsning och konvergerar via den normala sökvägen för inläsningsfel. En återanropning som påstår sig ha skrivit mer än Count byte kläms, eftersom en buggig leverantör inte får kunna köra över cachebufferten. Lösenordsförsök bygger en ny intervallström och ett nytt tolkningstillstånd över samma återanropskälla, så att ett misslyckat försök inte kan lämna kvar inaktuell position, fönster eller dekrypteringstillstånd

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
  { en blockerande 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 omslagsströmmen }
  Src.Free;  { din transport, din livstid }
end;

Hur mycket rymmer intervallcachen egentligen?

Som standard 4 MiB, fördelat över chunk-justerade fönster och utkastade enligt LRU. Den tidigare designen med ett enda fönster växte till den längd anroparen bad om, så en stor sekventiell läsning kunde spränga den nominella chunkstorleken medan ett slumpmässigt hopp kastade bort föregående fönster omedelbart. Nuvarande cache justerar varje källoffset mot ChunkSize, hämtar exakt en chunk per miss och driver en hård bytebudget över flera fönster. Varje uttrycklig budget du skickar höjs till minst en hel chunk, så en enda läsning går alltid fram chunk för chunk och toppbelastningen i cachen förblir förutsägbar. En ChunkSize under 4096 faller tillbaka på standardvärdet 64 KiB

Hur PDFlibPas betjänar en tolkläsning i Delphi utan att ladda ner PDF:en: den absoluta offseten justeras nedåt till chunkstorleken, betjänas från ett av flera LRU-fönster vid träff eller görs om till ett enda klämt återanropsanrop vid miss
Varje källoffset justeras mot chunkstorleken, så en miss hämtar exakt en chunk och toppbelastningen i cachen förblir förutsägbar

Redovisningen av upprepade läsningar är delen som är värd att koppla in i din telemetri. PDFlibPas identifierar en upprepning via justerad chunkstart och behåller ordnade sammanhängande intervall, vilket skiljer en verklig första hämtning från en nyhämtning efter utkastning samtidigt som bokföringen inte växer linjärt med filstorleken. GetRangeSourceCacheInfo returnerar hela bilden som JSON, SetRangeSourceCacheLimit ändrar budgetens storlek i drift och ClearRangeSourceCache släpper fönstren och nollställer statistiken tillsammans. Att krympa budgeten i drift behåller historiken och räknar budgetdrivna frisläppanden som utkastningar, så ett stigande repeatedReads mot ett platt hits är din signal om att arbetsmängden inte längre ryms

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;

Vad händer när flera trådar vill ha samma chunk?

De väntar på en begäran, inte flera. En klassisk TStream har en enda positionskursor, och två trådar som var för sig låser korrekt kan ändå få den positionen omskriven mellan en Seek och en Read, så lata objekt och segmenterade läsningar i PDFlibPas använder en absolut ReadAt som aldrig flyttar kursorn. Varje justerad chunk får en enda pågående begäran som alla anropare för den chunken delar, angränsande köade chunks slås samman innan källäsningen startar, och en fysisk läsning är toppbegränsad till 16 MiB, så ett utbrott av parallellt sidarbete förstärks inte till vare sig dubblerade små begäranden eller en ensam absurd stor. Sammanslagningsfönstret är som standard 2 ms och gäller bara den första saknade chunken i varje ReadAt; positionell Read väntar aldrig på den, och att skicka noll tar bort den initiala insamlingsfördröjningen helt, vilket spelar roll för långa sekventiella genomsök som annars skulle ackumulera väntan chunk för chunk. Position, cache-metadata och källäsningar ligger bakom tre separata lås, och källåteranropningen i sig serialiseras, vilket är det som låter en databas- eller objektlageradapter utan internt trådskydd användas oförändrad. Väntande får en egen kopia av data, så en senare LRU-utkastning kan inte ogiltigförklara en buffert som redan lämnats ut

Begäransammanslagning i PDFlibPas intervallinläsning för Delphi: två trådar som ber om samma chunk delar en pågående begäran, angränsande köade chunks slås samman inom ett fönster på två millisekunder och en serialiserad källäsning betjänar dem alla
Ett utbrott av parallellt sidarbete kollapsar till en delad begäran per chunk, och varje väntande får ändå en egen kopia av bytena

Kan du fråga om sida 900 är klar utan att hämta den?

Ja, och det är exakt vad den valfria tillgänglighetsåteranropningen är till för. En enkel läsåteranropning kan inte skilja byte som redan landat från byte som kräver en blockerande rundresa, och att sondera med en provläsning skulle utlösa just den nedladdning du försöker undvika. TPDFlibRangeAvailabilityEvent svarar på en enda fråga, huruvida ett komplett intervall kan läsas omedelbart, och är förbjuden att hämta något; byte som cachen redan täcker räknas alltid som tillgängliga. GetRangeSourceDataAvailability mappar indirekta objekt till de fysiska lagringsintervall som registrerats i korsreferensposterna, löser komprimerade objekt till sin objektströmbehållare, korrigerar för ett förskjutet PDF-huvud och tolkar ett objekt först efter att hela intervallet passerat den icke-hämtande sonderingen, så den saknade vägen anropar aldrig din läsåteranropning

Genomgången är avgränsad i stället för uttömmande. En sidfråga går bara igenom den gren av sidträdet som innehåller målsidan och lägger sedan till sidinnehåll, resurser, anteckningar och ärvda sidattribut, och hoppar över bakåtkanterna Parent och P så att en enda sida eller widget inte kan expandera bakåt till hela dokumentet. Objektgrafen begränsas till 100000 efterfrågade objekt och ett djup av 256, strömobjekt tolkas ordlistan först, och en reservväg med full tolkning tillåts bara för lagrade objekt upp till 4 MiB. JSON-rapporten slår samman överlappande och angränsande intervall före räkningen, så requiredBytes och missingBytes beräknas från de sammanslagna arrayerna requiredRanges och missingRanges, vars end är en inkluderande ändpunkt. Att fråga om ett objekt som redan är tillgängligt kan fylla intervallcachen; att fråga om ett saknat lämnar lässtatistiken orörd

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 innehåller "missingBytes" plus de sammanslagna "missingRanges" }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { t.ex. filen har ingen AcroForm alls }
end;

Varför prefetch måste iterera

För att en läsning av nuvarande missingRanges en gång inte gör sidan tillgänglig. En saknad sidträdsnod eller objektström avslöjar först nästa lager av beroenden efter att den anlänt, så ett PDFlibPas-prefetchjobb kör en loop av fråga, hämtning, omfråga tills sidan, formuläret eller objektgrafen är helt tillgänglig eller en byte- eller omgångsgräns stoppar den. Jobbet använder sin egen läsare och en liten sekundär cache vars datakälla vidarebefordrar absoluta läsningar till den ursprungliga intervallströmmen, vilket håller tolkningstillståndet isolerat från förgrundens TSmartPDFReader medan de byte den verkligen laddar ner ändå landar i den delade huvudcachen. En arbetstråd finns per intervallström, i linje med den serialisering källåteranropningen redan kräver, och kön väljer efter fyra prioritetsnivåer och därefter efter anmälningsordning inom en nivå. MaxBytes debiteras i fysiska chunkbyte, så en tolk som ber om en enda byte i en cachemissad chunk betalar ändå för hela chunken, medan chunks som redan ligger i den delade cachen inte kostar jobbet något. Att avbryta ett köat jobb når ett sluttillstånd med noll källäsningar; ett pågående jobb kontrolleras före varje beroendegenomgång och varje källchunk, och att frigöra intervallströmmen väntar på att en pågående återanropning returnerar i stället för att försöka avbryta den

PDFlibPas prefetchloop i Delphi: ett jobb frågar om tillgänglighet, hämtar de saknade intervallen och frågar igen, eftersom varje anländande sidträdsnod eller objektström avslöjar nästa lager av beroenden, tills grafen blir komplett eller en gräns stoppar den
Ett prefetchjobb itererar eftersom en saknad nod först namnger sina egna barn när den anländer, och det debiterar varje omgång i hela fysiska 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" och den senaste
    fullständiga tillgänglighetsrapporten, så LIMIT_REACHED förblir åtskild
    från FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

När detta försämras till en hel filnedladdning

Intervallinläsning är ett spel på filens layout, och vissa filer lever inte upp till den. En linjäriserad fil enligt ISO 32000-1 §7.5.8 är det goda fallet: förstasidessektionen värms upp vid öppning, avgränsad av både den befintliga säkerhetströskeln på 4 MiB och den aktuella cachebudgeten så att uppvärmningen inte omedelbart kan kasta ut det mesta av sig själv. En icke-linjäriserad fil löses fortfarande via trailern och korsreferenskedjan nära slutet, vilket kostar några extra rundresor snarare än en katastrof. Den verkliga branten är en skadad fil som tvingar fram reparationsvägen, eftersom att återskapa en korsreferenstabell betyder att söka efter objekthuvuden över hela dokumentet, och det är en fullständig nedladdning som anländer en chunk i taget. Latensen är den andra ärliga gränsen: vid 60 ms per begäran lägger en slumpåtkomsttolkning som behöver fyrtio cachemissade chunks över två sekunder på transport oavsett hur bra cachen är, vilket är precis vad argumentet för read-ahead och prioritetskön finns för att dölja. Samma disciplin syns i den direkta åtkomstmetoden för att slå ihop och dela stora PDF-filer, och cachen ligger under både parallell sidrendering och visarens diskcache för sidor

API:et för intervallkällor, tillgänglighetsfrågan och prefetchschemaläggaren ingår i den vanliga PDFlibPas Delphi PDF Library för Delphi, C++Builder och Free Pascal; produktsidan har den fullständiga parameterreferensen för LoadFromRangeSource tillsammans med prioritets- och tillståndskonstanterna för prefetch