Technischer Artikel

HotPDF RenderCacheFolder: Ein Disk-Seitencache in Delphi

HotPDF RenderCacheFolder macht aus dem In-Memory-Cache gerenderter Seiten der HotPDF Delphi Component einen persistenten Disk-Seitencache: Gerenderte Seiten werden als PNG-Dateien unter einem Ordner Ihrer Wahl geschrieben, und beim nächsten Öffnen derselben PDF-Quelle liest RenderLoadedPageToBitmapCached sie zurück, statt erneut zu rastern. Die Suchreihenfolge ist Speicher, dann Disk, dann der Renderer

Die Disk-Ebene ist seit v2.416.0 in der API, aber bis v2.770.140 hat sie für einen normalen LoadFromFile- oder LoadFromStream-Aufruf nie tatsächlich eine Seite geliefert. Der Fix zwang eine Frage, die jeder persistente Cache beantworten muss: Woher wissen Sie, dass die Datei, die Sie heute öffnen, das Dokument ist, das Sie gestern gerendert haben, und was passiert mit den gecachten Seiten, wenn nicht? Hier sind die Antworten, auf die sich HotPDF geeinigt hat, samt der Stellen, an denen es bewusst das Cachen verweigert

Wie funktioniert der HotPDF-Disk-Render-Cache?

Der HotPDF-Disk-Render-Cache ist eine zweite Ebene hinter dem Raster-Cache im Arbeitsspeicher, und er partizipiert nur, wenn RenderCacheFolder ein nicht leerer Pfad ist. Ein Aufruf von RenderLoadedPageToBitmapCached(PageIndex, DPI) scannt zuerst die In-Memory-Einträge, verschlüsselt nach Seitenindex, DPI und einer Render-Settings-Variante. Bei einem Fehlschlag fragt er die Disk-Ebene; ein Disk-Treffer dekodiert das PNG, befördert es zurück in den Speicher und liefert eine Kopie in Aufruferbesitz. Nur wenn beide Ebenen fehlgreifen, läuft die Seite durch den Content-Stream-Interpreter aus Eine geladene PDF-Seite in ein TBitmap rendern, und die frische Bitmap wird dann ebenfalls auf Disk geschrieben

HotPDF-Diagramm des Render-Cache-Lookups für RenderLoadedPageToBitmapCached: die im Speicher liegende, nach Seite, DPI und Render-Variante verschlüsselte Ebene wird zuerst geprüft, dann die RenderCacheFolder-Disk-Ebene aus PNG-Dateien mit atomarem Ersetzen, dann der Content-Stream-Interpreter, und jeder Treffer liefert eine Kopie in Aufruferbesitz
HotPDF schaut zuerst in den Speicher, dann auf die Disk und rastert erst danach; ein Disk-Treffer wird zurück in den Speicher befördert, und jeder Pfad gibt Ihnen eine Kopie, die Ihnen gehört und die Sie freigeben müssen

Auf der Disk ist das Layout absichtlich langweilig. Jedes Dokument bekommt einen Unterordner, benannt aus einem 16-Hexzeichen-Dokumentschlüssel plus einer 16-Hexzeichen-Render-Variante, jede Seite wird als <page>@<dpi>.png gespeichert, und eine index.txt an der Wurzel hält Dokumente in Most-Recently-Used-Reihenfolge hinter einem Schema-Tag. Ein Schema-Mismatch leert den Ordner bei der ersten Benutzung. Schreibvorgänge gehen zuerst in eine temporäre Datei und werden per atomarem Ersetzen an ihren Platz getauscht, ein Absturz mitten im Schreiben hinterlässt also entweder die alte Seite oder nichts, niemals ein halbes PNG. Ein PNG, das sich nicht dekodieren lässt, wird gelöscht und als Fehlschlag gezählt

Drei Limits begrenzen den Ordner:

  • RenderCacheMaxDocuments (Default 20) begrenzt die Anzahl der Dokumentunterordner; der am längsten unbenutzte Ordner wird zuerst verdrängt
  • RenderCacheMaxBytes (Default 524288000, also 500 MB) begrenzt die Gesamtgröße aller PNG-Dateien unter der Wurzel
  • Jeder Dokumentordner hält höchstens 200 Seitenbilder; diese pro-Dokument-Grenze ist von THotPDF fest verdrahtet und keine veröffentlichte Property

RenderCacheCapacity (Default 8) ist ein separater Regler: Er setzt, wie viele gerenderte Seiten die Speicher-Ebene hält, und hat mit dem Disk-Fußabdruck nichts zu tun

uses
  SysUtils, Graphics, HPDFDoc;

procedure WarmThumbnails(const FileName: string);
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // Die Disk-Ebene konfigurieren, bevor das erste gecachte Render läuft:
    // Ordner und beide Limits werden gelesen, wenn die Stufe erstmals benutzt wird
    Pdf.RenderCacheFolder := IncludeTrailingPathDelimiter(
      GetEnvironmentVariable('LOCALAPPDATA')) + 'MyViewer\PageCache';
    Pdf.RenderCacheMaxDocuments := 50;
    Pdf.RenderCacheMaxBytes := Int64(1024) * 1024 * 1024; // 1 GiB
    Pdf.RenderCacheCapacity := 16;                        // Seiten im Speicher

    if Pdf.LoadFromFile(FileName) > 0 then
      for I := 0 to Pdf.LoadedPageCount - 1 do
      begin
        Bmp := Pdf.RenderLoadedPageToBitmapCached(I, 96);
        if Bmp <> nil then
        try
          // Die Kopie hier an die Thumbnail-Leiste weitergeben
        finally
          Bmp.Free; // der gecachte Aufruf liefert stets eine Kopie in Aufruferbesitz
        end;
      end;
  finally
    Pdf.Free; // seit v2.770.140 löscht dies die Disk-Einträge nicht mehr
  end;
end;

Führen Sie dieselbe Prozedur zweimal aus, rastert der zweite Durchlauf keine Seite mehr, die in den Cache passte. Das Disk-Cache-Objekt wird träge beim ersten gecachten Render erzeugt und lebt, bis die THotPDF-Instanz freigegeben wird, eine Änderung von RenderCacheFolder, RenderCacheMaxDocuments oder RenderCacheMaxBytes danach verschiebt oder vergrößert einen bereits geöffneten Cache also nicht. Seiten, die zu groß für die Aufnahmepolitik des Speichers sind (standardmäßig darf ein einzelner Eintrag 64 MiB an 32-Bit-Pixeln nicht übersteigen), werden ebenfalls nicht persistiert, und die Disk-Ebene wird nur konsultiert, solange RenderFallbackPolicy ihren Default rfpIgnore hält, denn Fallback-Diagnostik wird nicht neben dem PNG gespeichert

Warum hat RenderCacheFolder vor v2.770.140 nie funktioniert?

RenderCacheFolder hatte vor v2.770.140 keinen Effekt, weil die Disk-Ebene Dokumente über einen Hash der Quellbytes verschlüsselte, den gewöhnliche Ladevorgänge nie vorhielten. Der Dokumentschlüssel kam aus einem SHA-256 über eine interne Kopie der rohen PDF-Bytes, aber LoadFromFile und LoadFromStream parsen die Quelle an Ort und Stelle und behalten keine solche Kopie; das Feld wurde nur temporär auf einem Verschlüsselungs-Wiederherstellungspfad gefüllt und direkt danach wieder geleert. Ohne Bytes war der Schlüssel immer leer, und ein leerer Schlüssel bedeutet, dass die Disk-Ebene umgangen wird. Kein Fehler, keine Warnung, nur ein Ordner, der leer blieb

Den Schlüssel nicht leer zu machen, legte einen zweiten Bug frei, der hinter dem ersten versteckt gewesen war. Das alte InvalidateRenderedPageCache löschte den Disk-Ordner des Dokuments, und InvalidateRenderedPageCache läuft am Anfang jedes Ladens, bei jeder Änderung und innerhalb von Free. In dem Moment, in dem der Schlüssel funktioniert hätte, hätte also jede Viewer-Sitzung ihren eigenen Cache beim Verlassen zerstört, und die nächste Sitzung wäre ohnehin kalt gestartet. Schlimmer noch: Der Schlüssel wurde nach einer Änderung aus derselben Quelle neu berechnet, Rendern des editierten Dokuments wären also unter dem Schlüssel der Originaldatei gespeichert und der nächsten Sitzung serviert worden, die das unveränderte PDF öffnete. v2.770.140 fixt Identität und Invalidierung zusammen; nur einen von beiden zu fixen hätte entweder einen toten oder einen lügenden Cache geliefert

Wie HotPDF ein PDF identifiziert, ohne die ganze Datei zu lesen

HotPDF identifiziert ein aus einer lokalen Datei geladenes PDF über einen Fingerabdruck aus seiner Größe, seiner Last-Write-Zeit und seinem ersten und letzten 64 KiB, und eine Stream- oder Random-Access-Quelle über einen SHA-256 ihres gesamten Inhalts. Beide werden einmalig erfasst, wenn ein Laden gelingt, und die ersten 16 Hexzeichen des SHA-256-Digests (64 Bits) werden der Dokumentschlüssel

QuelleIdentitätKostenErfasst, wenn
LoadFromFileGröße + LastWriteTime + führende und abschließende 64 KiB, gehasht mit SHA-256Höchstens 128 KiB gelesen, unabhängig von der DateigrößeJeder erfolgreiche Ladevorgang, selbst wenn RenderCacheFolder erst später gesetzt wird
LoadFromStreamSHA-256 über den ganzen StreamEin voller Durchlauf über die QuelleNur wenn RenderCacheFolder vor dem Laden gesetzt war
LoadFromRandomAccessSourceSHA-256 über die ganze QuelleEin voller Durchlauf über die QuelleNur wenn der Ordner zuerst gesetzt war und die ganze Range verfügbar ist
Jede Quelle mit einem /Encrypt-EintragKeineKeineNie; die Disk-Ebene wird umgangen
HotPDF-Quellidentitäts-Map für den Disk-Render-Cache: LoadFromFile hasht Größe, LastWriteTime und das erste und letzte 64 KiB, LoadFromStream und LoadFromRandomAccessSource hashen den ganzen Inhalt nur, wenn RenderCacheFolder zuerst gesetzt war, und jeder /Encrypt-Trailer erfasst überhaupt keine Identität
Dateien werden an ihren Enden fingerabdruckt, weil Header, xref und Trailer dort leben, Streams zahlen nur für einen vollen Hash, wenn Sie den Cache zuerst angefragt haben, und verschlüsselte Dokumente werden nie auf die Disk geschrieben

Der Datei-Fingerabdruck ist ein bewusster Kompromiss. Eine 400-MB-Scanarchiv bei jedem Öffnen vollständig zu hashen kann mehr kosten, als die zwei Seiten zu rendern, die ein Benutzer tatsächlich ansieht. Die Stichprobenregionen sind nicht willkürlich: Der Header sitzt am Anfang der Datei, und Trailer und die letzte Cross-Reference-Sektion sitzen am Ende (ISO 32000-1 §7.5). Ein inkrementelles Update hängt einen neuen Body, eine neue Cross-Reference-Sektion und einen neuen Trailer an (§7.5.6), ändert also Größe und Ende auf einen Schlag. Ein vollständiges Umschreiben durch jedes normale Tool ändert die Last-Write-Zeit. Für Dateien bis 128 KiB decken die zwei Stichproben jedes Byte ab, kleine Dokumente werden also effektiv vollständig gehasht

Das Restrisiko ist eine gleich große, an Ort und Stelle vorgenommene Änderung in der Mitte einer großen Datei, deren Schreiber danach den ursprünglichen Zeitstempel wiederherstellt. Das braucht ein Tool, das absichtlich Änderungszeiten bewahrt, während es Inhalte editiert, was selten, aber nicht unmöglich ist, und in dem Fall serviert der Cache veraltete Seiten. Die Kehrseite ist harmlos: Eine Datei unter Windows zu kopieren bewahrt normalerweise ihre Last-Write-Zeit, eine Kopie eines bereits im Cache liegenden Dokuments trifft also dieselben Einträge, was korrekt ist, weil die Bytes identisch sind

Streams haben überhaupt keine Änderungszeit, die einzige ehrliche Identität ist also der Inhalt. HotPDF zahlt diesen vollen SHA-256-Durchlauf nur, wenn Sie vor dem Laden einen Disk-Cache angefragt haben; jeder andere Aufrufer von LoadFromStream sieht keine Extra-Kosten. Damit ist die Reihenfolge der Property-Zuweisungen tragend:

procedure OpenDownloadedPdf(Pdf: THotPDF; Data: TStream;
  const CacheRoot: string);
begin
  // Falsche Reihenfolge für Streams: Der Content-Hash wird nur berechnet, wenn
  // der Ordner bereits gesetzt ist, dieses Dokument würde die Disk-Ebene also umgehen
  //   Pdf.LoadFromStream(Data);
  //   Pdf.RenderCacheFolder := CacheRoot;

  Pdf.RenderCacheFolder := CacheRoot; // zuerst setzen
  Data.Position := 0;
  if Pdf.LoadFromStream(Data) <= 0 then
    raise Exception.Create('The stream is not a loadable PDF');
end;

Eine Random-Access-Quelle, die noch lädt (manche Ranges noch nicht verfügbar), bekommt keine Identität statt eines Hashs partiellen Inhalts, und scheitert die Identitätsberechnung aus irgendeinem Grund, gelingt das Laden dennoch; das Dokument rendert einfach ohne die Disk-Ebene

Was macht einen HotPDF-Disk-Cache-Eintrag ungültig?

Ein HotPDF-Disk-Cache-Eintrag wird nie durch Löschen bei einer Änderung ungültig gemacht; stattdessen verwirft das Editieren des geladenen Dokuments die Dokumentidentität, die Disk-Ebene wird also für den Rest dieses Ladens umgangen, und die gespeicherten Seiten bleiben für die unveränderte Quelle gültig. Einträge verlassen die Disk nur über die LRU- und Byte-Limits, ein korruptes PNG oder eine Schema-Änderung

Der Schlüssel beschreibt eine Quelle auf der Disk, nicht den Objektgraphen im Speicher. Sobald Sie eine Seite stempeln oder eine Annotation ändern, passt das Dokument nicht mehr zu dieser Quelle, weder Lesen noch Schreiben unter seinem Schlüssel wäre also korrekt. Seit v2.770.140 löschen sowohl Dokument- als auch Seitenebene-Invalidierung die Identität, statt den Ordner anzufassen, und es gibt eine zweite Absicherung für Änderungen, die InvalidateRenderedPageCache nicht aufgerufen haben: Bevor die Disk-Ebene benutzt wird, prüft THotPDF, ob irgendein geladenes Objekt dirty ist, und behandelt ein dirty Dokument als identitätslos

Render-Einstellungen funktionieren umgekehrt. PageRenderBackend zu wechseln (oder UseNativeGDIRenderBackend aufzurufen) sowie ConfigureRenderICCWorkflow oder ClearRenderICCWorkflow aufzurufen, spült die Speicherseiten, behält aber die Identität, denn das Dokument passt weiterhin zu seiner Quelle. Diese Einstellungen ändern die Pixel, ohne Teil der In-Memory-Variante zu sein, der Disk-Schlüssel faltet also den Backend-Namen, das Black-Point-Compensation-Flag und SHA-256-Digests der ICC-Proof- und Output-Profile ein. Die Variante selbst deckt bereits Color Intent, Output-Dithering, Overprint-Preview, Luminosity-Mask-Modus, Fallback-Politik und die Sichtbarkeit jeder Optional-Content-Gruppe ab, ein Layer-Toggle rendert also in einen anderen Ordner, statt die Default-Ansicht zu überschreiben

HotPDF-Invalidierungssemantik für den RenderCacheFolder-Disk-Cache: Das Editieren des geladenen Dokuments oder eines dirty Objekts verwirft die Quellidentität, die Stufe wird also umgangen, das Wechseln des Render-Backends oder des ICC-Workflows behält die Identität unter einem neuen Variantenschlüssel, und Speichern plus Neuladen verschlüsselt das Dokument neu
Eine Änderung löscht nie den gespeicherten Ordner, eine Einstellungsänderung rendert unter einem anderen Schlüssel, und nur Speichern plus Neuladen verschafft dem editierten Dokument eine frische Identität

Um ein editiertes Dokument zurück auf die Disk-Ebene zu bringen, geben Sie ihm eine neue Quellidentität, indem Sie es speichern und das Ergebnis laden:

procedure CommitEditsAndRekey(Pdf: THotPDF; const EditedFile: string);
begin
  // Nach dem Editieren des geladenen Dokuments: die Speicherseiten auffrischen.
  // Die Quellidentität ist bereits weg, aus dem Disk-Ordner des Originaldokuments
  // wird also nichts gelesen oder hineingeschrieben
  Pdf.InvalidateRenderedPageCache;

  // Eine gespeicherte Datei hat neue Größe und Last-Write-Zeit, also eine
  // neue Identität; Rendern nach diesem Laden werden unter dem neuen Schlüssel gecacht
  Pdf.SaveLoadedDocument(EditedFile);
  if Pdf.LoadFromFile(EditedFile) <= 0 then
    raise Exception.Create('Could not reload the edited document');
end;

Der Ordner des Originaldokuments bleibt in Ruhe und altert über RenderCacheMaxDocuments und RenderCacheMaxBytes hinaus wie jeder andere Eintrag. Öffnet der Benutzer das unbearbeitete Original erneut, sind seine Seiten noch da

Sicherheitsgrenzen: verschlüsselte Quellen und verlinkte Ordner

Der HotPDF-Disk-Render-Cache verweigert zwei Arten von Eingabe mit Absicht: Er schreibt nie Seiten eines verschlüsselten PDF auf die Disk, und er folgt nie einem Dokumentunterordner, der eine Junction oder ein anderer Reparse Point ist. Beide Regeln tauschen Cache-Treffer dagegen ein, keine Daten zu leaken oder die falschen Dateien zu löschen

Verschlüsselte PDFs werden nie auf die Disk gecacht

Eine gerenderte Seite ist entschlüsselter Inhalt. Sie als nacktes PNG in einen Cache-Ordner zu schreiben würde eine lesbare Kopie eines passwortgeschützten Dokuments auf der Disk hinterlassen, außerhalb des Schutzes, den der Autor gewählt hat (ISO 32000-1 §7.6). HotPDF erfasst deshalb für jede Quelle, deren Trailer einen /Encrypt-Eintrag trägt, keine Identität, eingeschlossen Dateien, die mit einem Passwort oder mit leerem Benutzerpasswort geöffnet wurden. Diese Dokumente nutzen weiterhin die Speicher-Ebene, die mit dem Prozess stirbt

Junction-Unterordner werden seit v2.770.173 abgewiesen

Die Cache-Wurzel ist Ihre Wahl, und auf eine Junction zu zeigen ist erlaubt. Die Dokumentunterordner darunter sind eine andere Sache: Der Cache erzeugt, liest, berührt und löscht sie selbstständig, bei der Startwiederherstellung (die übrig gebliebene temporäre Dateien entfernt), beim Lookup (das Zeitstempel aktualisiert), beim Speichern, bei der Invalidierung und bei den drei Verdrängungsgrenzen. Ersetzt jemand mit Schreibzugriff auf die Cache-Wurzel einen Dokumentordner durch eine Junction in ein anderes Verzeichnis, würde jeder dieser Pfade ihm folgen, und die Verdrängung würde Dateien löschen, die der Cache nie besessen hat. Seit v2.770.173 prüft jeder dieser Einstiegspunkte das Reparse-Point-Attribut und überspringt einen verlinkten Dokumentordner: Ein Lookup zählt einen Fehlschlag, ein Speichern zählt einen Schreibfehler, und die Verdrängung lässt ihn in Ruhe

Unicode-Pfade und geteilte Wurzeln

Zwei verwandte Fixes zählen, wenn Sie in Benutzerprofile ausliefern. Vor v2.770.135 war RenderCacheFolder ein AnsiString, ein Ordner außerhalb der System-Codepage (ein chinesischer Benutzername auf einer englischen Windows-Installation zum Beispiel) wurde also verlustbehaftet konvertiert, bevor der Cache ihn sah; die Property ist jetzt ein Unicode-string, und das atomare Ersetzen nutzt die Wide-Windows-API. Seit v2.770.52 teilen sich mehrere THotPDF-Instanzen in einem Prozess, die auf dieselbe Wurzel zeigen (nach Pfadexpansion, case-insensitiv verglichen), einen einzigen referenzgezählten Index und Lock. Vorher überschrieb jede Instanz die index.txt mit ihrer eigenen Kopie und erzwang die Limits gegen ihre Teilansicht, der Ordner konnte also sein Budget mehrfach übersteigen

Dieses Teilen endet an der Prozessgrenze. Zwei getrennte Prozesse auf derselben Wurzel halten weiterhin getrennte In-Memory-Indizes, geben Sie jeder gleichzeitig laufenden Anwendung also ihre eigene Cache-Wurzel. Viewer, die auf Worker-Threads rendern, sind innerhalb eines Prozesses fein: PrefetchLoadedPages und die Queue aus Hintergrund-Rendering mit einer Request-Queue laufen beide durch denselben gecachten Pfad und denselben Lock

Kurzreferenz: RenderCacheFolder-Checkliste

  • RenderCacheFolder, RenderCacheMaxDocuments und RenderCacheMaxBytes vor dem ersten Aufruf von RenderLoadedPageToBitmapCached setzen; bei Stream- und Random-Access-Laden den Ordner vor dem Laden setzen
  • Auf v2.770.140 oder später aktualisieren, wenn Sie sich auf die Disk-Ebene verlassen; frühere Versionen akzeptieren die Property, liefern aber nie eine Seite von der Disk für normale Ladevorgänge
  • Rechnen Sie mit keinem Disk-Caching für verschlüsselte PDFs, für nach dem Laden editierte Dokumente oder solange RenderFallbackPolicy nicht rfpIgnore ist
  • Geben Sie die THotPDF-Instanz normal frei; seit v2.770.140 löscht weder Free noch InvalidateRenderedPageCache Disk-Einträge
  • Das Wechseln von PageRenderBackend oder des ICC-Workflows hält das Dokument unter einem anderen Schlüssel auf der Disk-Ebene
  • Eine Cache-Wurzel pro laufender Anwendung benutzen; Instanzen innerhalb eines Prozesses teilen den Index seit v2.770.52
  • Die Cache-Wurzel an einem benutzerlokalen Ort halten; Dokumentunterordner, die Junctions sind, werden seit v2.770.173 übersprungen

Ein persistenter Seitencache zahlt sich am meisten in einem Viewer aus, der den ganzen Tag dieselben Dokumente wiederöffnet — genau die Form der Custom-PDF-Viewer-Architektur in Delphi, die anderswo in diesem Blog beschrieben wird. RenderCacheFolder, der In-Memory-Raster-Cache und der Seitenrenderer kommen mit der HotPDF Delphi PDF component für Delphi und C++Builder