Ein 2-GB-Scanarchiv liegt in einem S3-Bucket und der Nutzer will Seite 900. PDFlibPas kann diese Seite liefern, ohne die Datei herunterzuladen: LoadFromRangeSource baut über Ihren eigenen Byte-Range-Callback einen nur lesbaren, seekbaren Stream und reicht ihn an TPDFDocument weiter, sodass der Parser die Cross-Reference-Tabellen, einen Seitenbaum-Ast und einen Content-Stream zieht
Die Transportseite davon ist alt und unspektakulär. HTTP-Server machen Byte Ranges seit Jahrzehnten an, heute spezifiziert in RFC 9110 §14, und jeder Object Store spricht denselben Dialekt. Die PDF-Seite ist genauso geklärt: ISO 32000-1 §7.5.8 definiert die Linearisierung genau dafür, dass ein Reader die erste Seite vom Dateianfang rendern kann. Was in Delphi fehlte, war das Stück in der Mitte, der Teil, der entscheidet, welche Ranges erfragt werden, wie viele behalten werden und wie man nicht doppelt fragt
Was braucht LoadFromRangeSource von Ihrem Transport?
Zwei Dinge, und keins davon ist ein Stream. PDFlibPas verlangt eine verbindliche SourceSize und einen synchronen Read-Callback vom Typ TPDFlibRangeReadEvent, deklariert als function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Intern wird das Paar zu einer TCallbackByteRangeSource, die SourceSize und ReadRange bereitstellt, verpackt in einem Stream, dessen Eigentum an das Dokument übergeht. Ihr Callback-Ziel und sein Backend bleiben Ihres: Das Dokument gibt den Wrapper beim Schließen, Leeren oder Neuladen frei, aber es fasst das Transportobjekt hinter dem Methodenzeiger nie an
Der Vertrag ist absichtlich in eine Richtung nachsichtig und in die andere streng. Ein kurzer Read ist legal und bedeutet schlicht, dass der Parser erneut fragt. Ein Callback, der eine Exception wirft, wird in einen kurzen Read umgewandelt und konvergiert über den normalen Lade-Fehlerpfad. Ein Callback, der behauptet, mehr als Count Bytes geschrieben zu haben, wird geklemmt, denn ein fehlerhafter Provider darf den Cache-Puffer nicht überschreiben können. Passwort-Wiederholungen bauen über derselben Callback-Quelle einen frischen Range-Stream und einen frischen Parse-Zustand auf, sodass ein fehlgeschlagener Versuch keine veraltete Position, kein altes Fenster und keinen alten Entschlüsselungszustand hinterlassen kann
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
{ ein blockierender GET mit 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; { gibt den Wrapper-Stream frei }
Src.Free; { Ihr Transport, Ihre Lebensdauer }
end;
Wie viel fasst der Range-Cache wirklich?
Standardmäßig 4 MiB, verteilt auf chunk-ausgerichtete Fenster und nach LRU verdrängt. Der frühere Einzelfenster-Entwurf wuchs auf jede vom Aufrufer gewünschte Länge, sodass ein großer sequenzieller Read über die nominale Chunk-Größe hinausgehen konnte, während ein zufälliger Sprung das vorherige Fenster sofort warf. Der aktuelle Cache richtet jeden Quell-Offset an ChunkSize aus, holt exakt einen Chunk pro Miss und erzwingt ein hartes Byte-Budget über mehrere Fenster. Jedes explizit übergebene Budget wird auf mindestens einen vollen Chunk angehoben, sodass ein einzelner Read immer Chunk für Chunk voranschreitet und die Spitzen-Cache-Last vorhersagbar bleibt. Ein ChunkSize unter 4096 fällt auf den 64-KiB-Standard zurück
Die Wiederholungs-Read-Buchhaltung ist der Teil, dessen Anbindung an Ihre Telemetrie sich lohnt. PDFlibPas erkennt eine Wiederholung am ausgerichteten Chunk-Anfang und führt geordnete, zusammenhängende Intervalle, was einen echten ersten Fetch von einem Refetch nach Verdrängung trennt, ohne dass die Buchhaltung linear mit der Dateigröße wächst. GetRangeSourceCacheInfo liefert das Gesamtbild als JSON, SetRangeSourceCacheLimit passt das Budget zur Laufzeit an, und ClearRangeSourceCache wirft die Fenster weg und setzt die Statistiken gemeinsam zurück. Wird das Budget zur Laufzeit verkleinert, bleibt die Historie erhalten und die budgetgetriebenen Freigaben zählen als Evictions, sodass steigende repeatedReads bei flachen hits Ihr Signal dafür ist, dass der Working Set nicht mehr passt
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;
Was passiert, wenn mehrere Threads denselben Chunk wollen?
Sie warten auf eine Anfrage, nicht auf mehrere. Ein klassischer TStream hat einen einzigen Positions-Cursor, und zwei Threads, die jeweils korrekt sperren, können dennoch erleben, dass diese Position zwischen einem Seek und einem Read umgeschrieben wird; darum verwenden Lazy Objects und segmentierte Reads in PDFlibPas ein absolutes ReadAt, das den Cursor nie bewegt. Jeder ausgerichtete Chunk bekommt eine einzige In-Flight-Anfrage, die jeder Aufrufer dieses Chunks teilt, benachbarte wartende Chunks werden zusammengeführt, bevor der Quell-Read startet, und ein physischer Read ist auf 16 MiB begrenzt, sodass eine Parallel-Seiten-Last weder in duplizierte kleine Anfragen noch in eine absurde große ausufern kann. Das Merge-Fenster beträgt standardmäßig 2 ms und gilt nur für den ersten fehlenden Chunk jedes ReadAt; der positionale Read wartet nie darauf, und der Wert null beseitigt die anfängliche Sammelverzögerung völlig, was bei langen sequenziellen Scans zählt, die sonst die Wartezeit Chunk für Chunk aufstapeln würden. Position, Cache-Metadaten und Quell-Reads liegen hinter drei getrennten Sperren, und der Quell-Callback selbst wird serialisiert, eben damit sich ein Datenbank- oder Object-Store-Adapter ohne eigenen Thread-Schutz unverändert verwenden lässt. Wartende erhalten ihre eigene Kopie der Daten, sodass eine spätere LRU-Verdrängung keinen bereits ausgehändigten Puffer entwerten kann
Lässt sich fragen, ob Seite 900 bereit ist, ohne sie zu holen?
Ja, und genau dafür ist der optionale Availability-Callback da. Ein gewöhnlicher Read-Callback kann Bytes, die schon gelandet sind, nicht von Bytes unterscheiden, die eine blockierende Runde erfordern, und ein Sondieren mit einem Test-Read würde genau den Download auslösen, den man vermeiden will. TPDFlibRangeAvailabilityEvent beantwortet nur eine Frage, ob sich ein vollständiger Range sofort lesen lässt, und darf nichts holen; Bytes, die der Cache bereits abdeckt, gelten immer als verfügbar. GetRangeSourceDataAvailability bildet indirekte Objekte auf die in den Cross-Reference-Einträgen verzeichneten physischen Speicher-Ranges ab, löst komprimierte Objekte auf ihren Object-Stream-Container auf, korrigiert einen verschobenen PDF-Header und parst ein Objekt erst, nachdem der volle Range die nicht-holende Sonde passiert hat, sodass der Fehlend-Pfad Ihren Read-Callback nie aufruft
Die Traversierung ist eingegrenzt statt ausufernd. Eine Seitenabfrage läuft nur den Ast des Seitenbaums, der die Zielseite enthält, und fügt dann Seiteninhalt, Ressourcen, Annotationen und geerbte Seitenattribute hinzu, wobei sie Parent- und P-Rückkanten überspringt, damit eine einzelne Seite oder ein Widget sich nicht rückwärts zum ganzen Dokument ausdehnen kann. Der Objektgraph ist auf 100000 angefragte Objekte und eine Tiefe von 256 begrenzt, Stream-Objekte werden Dictionary-zuerst geparst, und ein Full-Parse-Fallback ist nur für gespeicherte Objekte bis 4 MiB erlaubt. Der JSON-Bericht verschmilzt überlappende und benachbarte Intervalle vor dem Zählen, sodass requiredBytes und missingBytes aus den verschmolzenen Arrays requiredRanges und missingRanges berechnet werden, deren end ein inklusiver Endpunkt ist. Die Abfrage eines bereits verfügbaren Objekts kann den Range-Cache füllen; die Abfrage eines fehlenden lässt die Read-Statistiken unberührt
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 enthält "missingBytes" plus die verschmolzenen "missingRanges" }
ShowProgress(Report)
else if Status = PDF_RANGE_DATA_NOT_PRESENT then
ShowMissingFeature; { z. B. die Datei hat gar kein AcroForm }
end;
Warum Prefetch iterieren muss
Weil das einmalige Lesen der aktuellen missingRanges die Seite nicht verfügbar macht. Ein fehlender Seitenbaum-Knoten oder Object Stream enthüllt erst nach seiner Ankunft die nächste Schicht der Abhängigkeiten, darum läuft ein PDFlibPas-Prefetch-Job eine Abfrage-Fetch-Erneut-Abfragen-Schleife, bis Seite, Formular oder Objektgraph vollständig verfügbar sind oder ein Byte- oder Durchlauf-Limit stoppt. Der Job nutzt seinen eigenen Reader und einen kleinen Sekundär-Cache, dessen Datenquelle absolute Reads an den ursprünglichen Range-Stream weiterreicht; so bleibt der Parse-Zustand vom Vordergrund-TSmartPDFReader isoliert, während die wirklich heruntergeladenen Bytes im gemeinsamen Haupt-Cache landen. Pro Range-Stream existiert ein Worker-Thread, passend zur Serialisierung, die der Quell-Callback ohnehin verlangt, und die Warteschlange wählt nach vier Prioritätsstufen und dann nach Eingangsreihenfolge innerhalb einer Stufe. MaxBytes wird in physischen Chunk-Bytes verrechnet, sodass ein Parser, der ein einzelnes Byte in einem nicht gecachten Chunk anfordert, trotzdem den ganzen Chunk zahlt, während Chunks im gemeinsamen Cache den Job nichts kosten. Das Abbrechen eines wartenden Jobs erreicht einen Endzustand mit null Quell-Reads; ein laufender Job wird vor jedem Abhängigkeitsdurchlauf und jedem Quell-Chunk geprüft, und das Freigeben des Range-Streams wartet, bis ein laufender Callback zurückkehrt, statt zu versuchen, ihn zu unterbrechen
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" und der letzte
vollständige Availability-Bericht, damit LIMIT_REACHED von FAILED
unterscheidbar bleibt }
Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;
Wo das Ganze in einen Komplett-Download kippt
Range-Loading ist eine Wette aufs Dateilayout, und manche Dateien halten sich nicht daran. Eine linearisierte Datei nach ISO 32000-1 §7.5.8 ist der gute Fall: Der First-Page-Abschnitt wird beim Öffnen vorgewärmt, begrenzt sowohl von der bestehenden 4-MiB-Sicherheitsschwelle als auch vom aktuellen Cache-Budget, damit das Vorwärmen sich nicht sofort selbst größtenteils verdrängt. Eine nicht linearisierte Datei löst sich weiterhin über den Trailer und die Cross-Reference-Kette nahe dem Ende auf, was ein paar zusätzliche Runden kostet, kein Desaster ist. Die echte Klippe ist eine beschädigte Datei, die den Reparaturpfad erzwingt, denn eine Cross-Reference-Tabelle zu rekonstruieren bedeutet, das ganze Dokument nach Objektheadern zu durchsuchen, und das ist ein Komplett-Download, der Chunk für Chunk ankommt. Die Latenz ist die andere ehrliche Grenze: Bei 60 ms pro Anfrage verbringt ein Random-Access-Parse, der vierzig nicht gecachte Chunks braucht, über zwei Sekunden unterwegs, egal wie gut der Cache ist, und genau das sollen das Read-Ahead-Argument und die Prioritätswarteschlange verbergen. Denselben Anspruch zeigt der Artikel zum Direct-Access-Ansatz für Mergen und Splitten großer PDFs, und dieser Cache liegt unter paralleler Seitenwiedergabe wie unter dem Viewer-Festplatten-Seitencache
Die Range-Source-API, die Availability-Abfrage und der Prefetch-Scheduler sind Teil der Standard-PDFlibPas Delphi PDF Library für Delphi, C++Builder und Free Pascal; die Produktseite führt die komplette Parameter-Referenz für LoadFromRangeSource samt Prefetch-Prioritäts- und Zustandskonstanten