Technisch artikel

HotPDF RenderCacheFolder: een pagecache op schijf in Delphi

HotPDF RenderCacheFolder maakt van de in-memory cache met gerenderde pagina's van de HotPDF Delphi-component een blijvende paginacache op schijf: gerenderde pagina's worden weggeschreven als PNG-bestanden onder een map naar keuze, en de volgende keer dat dezelfde PDF-bron wordt geopend, leest RenderLoadedPageToBitmapCached ze terug in plaats van opnieuw te rasteriseren. De opzoekvolgorde is geheugen, dan schijf, dan de renderer

De schijftier zit al sinds v2.416.0 in de API, maar tot v2.770.140 gaf hij voor een gewone LoadFromFile- of LoadFromStream-aanroep nooit echt een pagina terug. De fix dwong een vraag af die elke persistente cache moet beantwoorden: hoe weet u dat het bestand dat u vandaag opent het document is dat u gisteren hebt gerenderd, en wat gebeurt er met de gecachede pagina's als dat niet zo is? Hieronder staan de antwoorden die HotPDF heeft gekozen, inclusief de plekken waar hij beweigerd te cachen

Hoe werkt de HotPDF-schijf-rendercache?

De HotPDF-schijf-rendercache is een tweede tier achter de in-memory rastercache, en hij participeert alleen als RenderCacheFolder een niet-lege pad is. Een aanroep van RenderLoadedPageToBitmapCached(PageIndex, DPI) scant eerst de in-memory entries, gesleuteld op pagina-index, DPI en een render-settings-variant. Bij een miss vraagt hij het aan de schijftier; een schijfhit decodeert de PNG, promoveert hem terug naar het geheugen en geeft een kopie in aanroeper-bezit terug. Pas als beide tiers missen gaat de pagina door de content-stream-interpreter beschreven in het renderen van een geladen PDF-pagina naar een TBitmap, en de verse bitmap wordt daarna ook naar schijf geschreven

HotPDF-diagram van de rendercache-opzoek voor RenderLoadedPageToBitmapCached: eerst wordt de in-memory tier gesleuteld op pagina, DPI en render-variant gecontroleerd, dan de RenderCacheFolder-schijftier van PNG-bestanden met atomische vervanging, dan de content-stream-interpreter, en elke hit geeft een kopie in aanroeper-bezit terug
HotPDF kijkt eerst in het geheugen, dan op schijf, en pas dan rasteriseert hij; een schijfhit wordt terug gepromoveerd naar het geheugen en elk pad geeft u een kopie die van u is en die u moet vrijgeven

Op schijf is de layout opzettelijk saai. Elk document krijgt een submap benoemd vanuit een documentsleutel van 16 hexadecimale tekens plus een render-variant van 16 hexadecimale tekens, elke pagina wordt opgeslagen als <page>@<dpi>.png, en een index.txt in de root houdt documenten in meest-recent-gebruikt-volgorde bij achter een schema-tag. Een schemamismatch wist de map bij eerste gebruik. Schrijfacties gaan eerst naar een tijdelijk bestand en worden met een atomische vervanging op hun plek gewisseld, dus een crash midden in een schrijfactie laat ofwel de oude pagina ofwel niets achter, nooit een halve PNG. Een PNG die niet decodeert wordt verwijderd en als miss geteld

Drie limieten begrenzen de map:

  • RenderCacheMaxDocuments (default 20) plafonneert het aantal documentsubmappen; de minst recent gebruikte map gaat er eerst af
  • RenderCacheMaxBytes (default 524288000, oftewel 500 MB) plafonneert de totale grootte van alle PNG-bestanden onder de root
  • Elke documentmap houdt er ten hoogste 200 pagina-afbeeldingen bij; dat per-document-plafond is vastgelegd door THotPDF en is geen gepubliceerde property

RenderCacheCapacity (default 8) is een aparte knop: hij stelt in hoeveel gerenderde pagina's de in-memory tier bijhoudt, en hij heeft niets te maken met de schijfvoetafdruk

uses
  SysUtils, Graphics, HPDFDoc;

procedure WarmThumbnails(const FileName: string);
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // Configureer de schijftier vóór de eerste cached render:
    // de map en beide limieten worden gelezen zodra de tier voor het eerst wordt gebruikt
    Pdf.RenderCacheFolder := IncludeTrailingPathDelimiter(
      GetEnvironmentVariable('LOCALAPPDATA')) + 'MyViewer\PageCache';
    Pdf.RenderCacheMaxDocuments := 50;
    Pdf.RenderCacheMaxBytes := Int64(1024) * 1024 * 1024; // 1 GiB
    Pdf.RenderCacheCapacity := 16;                        // pagina's in het geheugen

    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
          // Geef de kopie hier aan de thumbnailstrip
        finally
          Bmp.Free; // de cached call geeft altijd een kopie in aanroeper-bezit terug
        end;
      end;
  finally
    Pdf.Free; // sinds v2.770.140 verwijdert dit de schijfentries niet meer
  end;
end;

Draai dezelfde procedure twee keer en de tweede run rasteriseert nooit een pagina die in de cache paste. Het schijfcache-object wordt lazy aangemaakt bij de eerste cached render en leeft tot de THotPDF-instantie wordt vrijgegeven, dus RenderCacheFolder, RenderCacheMaxDocuments of RenderCacheMaxBytes veranderen na dat punt verplaatst of herschaalt een al geopende cache niet. Pagina's te groot voor het in-memory toelatingsbeleid (by default mag één entry niet meer dan 64 MiB aan 32-bit pixels beslaan) worden ook niet bewaard, en de schijftier wordt alleen geraadpleegd zolang RenderFallbackPolicy zijn default rfpIgnore houdt, want fallback-diagnostiek wordt niet naast de PNG bewaard

Waarom werkte RenderCacheFolder nooit vóór v2.770.140?

RenderCacheFolder had vóór v2.770.140 geen enkel effect omdat de schijftier documenten sleutelde op een hash van bronbytes die gewone loads nooit bijhielden. De documentsleutel kwam uit een SHA-256 over een interne kopie van de rauwe PDF-bytes, maar LoadFromFile en LoadFromStream parsen de bron ter plekke en houden zo'n kopie niet bij; het veld werd alleen tijdelijk gevuld op een pad voor encrypted-recovery en direct daarna weer gewist. Zonder bytes was de sleutel altijd leeg, en een lege sleutel betekent dat de schijftier wordt omzeild. Geen fout, geen waarschuwing, gewoon een map die leeg bleef

Het leeg maken van de sleutel legde een tweede bug bloot die zich achter de eerste had verstopt. De oude InvalidateRenderedPageCache verwijderde de schijfmap van het document, en InvalidateRenderedPageCache draait aan het begin van elke load, bij elke edit en binnen Free. Dus op het moment dat de sleutel zou werken, zou elke viewersessie zijn eigen cache bij het afsluiten hebben weggegooid, en de volgende sessie zou toch koud zijn begonnen. Erger nog: de sleutel werd na een edit opnieuw uit dezelfde bron berekend, dus renders van het bewerkte document zouden onder de sleutel van het originele bestand zijn opgeslagen en aan de volgende sessie die de ongewijzigde PDF opende zijn teruggegeven. v2.770.140 fixt identiteit en invalidatie samen; er maar één van fixen had ofwel een dode cache ofwel een liegende opgeleverd

Hoe HotPDF een PDF identificeert zonder het hele bestand te lezen

HotPDF identificeert een PDF geladen van een lokaal bestand via een vingerafdruk van zijn grootte, zijn last-write time en zijn eerste en laatste 64 KiB, en identificeert een stream- of random-access-bron via een SHA-256 van zijn volledige inhoud. Beide worden één keer vastgelegd, zodra een load slaagt, en de eerste 16 hexadecimale tekens van het SHA-256-digest (64 bits) worden de documentsleutel

BronIdentiteitKostenVastgelegd wanneer
LoadFromFileGrootte + LastWriteTime + eerste en laatste 64 KiB, gehasht met SHA-256Hooguit 128 KiB lezen, onafhankelijk van bestandsgrootteElke geslaagde load, ook als RenderCacheFolder later wordt gezet
LoadFromStreamSHA-256 van de hele streamEén volledige passe over de bronAlleen als RenderCacheFolder vóór de load was gezet
LoadFromRandomAccessSourceSHA-256 van de hele bronEén volledige passe over de bronAlleen als de map eerst was gezet en het hele bereik beschikbaar is
Elke bron met een /Encrypt-entryGeenGeenNooit; de schijftier wordt omzeild
HotPDF-bronidentiteit-kaart voor de schijf-rendercache: LoadFromFile hasht grootte, LastWriteTime en de eerste en laatste 64 KiB, LoadFromStream en LoadFromRandomAccessSource hashen de volledige inhoud alleen als RenderCacheFolder eerst was gezet, en elke /Encrypt-trailer legt helemaal geen identiteit vast
Bestanden worden aan hun uiteinden gevingerdrukt omdat de header, de xref en de trailer daar huizen, streams betalen alleen voor een volledige hash als u eerst om de cache vroeg, en versleutelde documenten gaan nooit naar schijf

De bestandsvingerafdruk is een bewuste afweging. Een gescand archief van 400 MB bij elke open volledig hashen kan meer kosten dan de twee pagina's renderen die een gebruiker werkelijk bekijkt. De gesamplede regio's zijn niet willekeurig: de header zit aan het begin van het bestand, en de trailer en de laatste cross-reference-sectie zitten aan het einde (ISO 32000-1 §7.5). Een incrementele update voegt een nieuwe body, cross-reference-sectie en trailer toe (§7.5.6), dus ze verandert grootte en staart tegelijk. Een volledige herschrijving door welke normale tool dan ook verandert de last-write time. Voor bestanden tot 128 KiB dekken de twee samples elke byte, dus kleine documenten worden in feite volledig gehasht

Het rest risico is een gelijk-grote, ter-plekke-verandering in het midden van een groot bestand waarvan de schrijver daarna de originele tijdstempel herstelt. Daarvoor is een tool nodig die modificatietijden bewust bewaart terwijl hij inhoud edit, wat zeldzaam maar niet onmogelijk is, en in dat geval levert de cache verouderde pagina's. De keerzijde is goedaardig: een bestand op Windows kopiëren bewaart normaal zijn last-write time, dus een kopie van een document dat al in de cache zit raakt dezelfde entries, en dat is correct want de bytes zijn identiek

Streams hebben helemaal geen modificatietijd, dus de enige eerlijke identiteit is de inhoud. HotPDF betaalt die volledige SHA-256-passe alleen als u vóór het laden om een schijfcache heeft gevraagd; elke andere aanroeper van LoadFromStream merkt niets van extra kosten. Daarmee is de volgorde van property-toewijzingen dragend:

procedure OpenDownloadedPdf(Pdf: THotPDF; Data: TStream;
  const CacheRoot: string);
begin
  // Verkeerde volgorde voor streams: de content hash wordt alleen berekend als de
  // map al is gezet, dus dit document zou de schijftier omzeilen
  //   Pdf.LoadFromStream(Data);
  //   Pdf.RenderCacheFolder := CacheRoot;

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

Een random-access-bron die nog aan het downloaden is (sommige bereiken nog niet beschikbaar) krijgt geen identiteit in plaats van een hash van partiële inhoud, en als het berekenen van de identiteit om welke reden dan ook faalt, slaagt de load alsnog; het document rendert simpelweg zonder de schijftier

Wat maakt een HotPDF-schijfcache-entry ongeldig?

Een HotPDF-schijfcache-entry wordt nooit ongeldig verklaard door haar bij een edit te verwijderen; in plaats daarvan laat het bewerken van het geladen document de bronidentiteit vallen, dus de schijftier wordt voor de rest van die load omzeild en de opgeslagen pagina's blijven geldig voor de ongewijzigde bron. Entries verlaten de schijf alleen via de LRU- en bytelimieten, een corrupte PNG of een schemawijziging

De sleutel beschrijft een bron op schijf, niet de objectgraaf in het geheugen. Zodra u een pagina van een stempel voorziet of een annotatie verandert, matcht het document die bron niet meer, dus noch lezen noch schrijven onder haar sleutel zou correct zijn. Sinds v2.770.140 wist zowel documentniveau- als paginaniveau-invalidation de identiteit in plaats van de map aan te raken, en er is een tweede vangnet voor edits die InvalidateRenderedPageCache niet aanriepen: vóór het gebruik van de schijftier controleert THotPDF of een geladen object dirty is en behandelt een dirty document als identiteitsloos

Renderinstellingen werken precies andersom. Het wisselen van PageRenderBackend (of het aanroepen van UseNativeGDIRenderBackend), en het aanroepen van ConfigureRenderICCWorkflow of ClearRenderICCWorkflow, spoelen de in-memory pagina's door maar houden de identiteit vast, want het document matcht nog steeds zijn bron. Die instellingen veranderen de pixels zonder deel uit te maken van de in-memory variant, dus de schijfsleutel vouwt de backend-naam, de black-point compensation-vlag en SHA-256-digests van de ICC-proof- en outputprofielen in. De variant dekt al de kleurintentie, outputdithering, overprint-preview, luminosity-mask-modus, fallback-beleid en de zichtbaarheid van elke optionele content group, dus een laag omschakelen rendert in een andere map in plaats van de defaultweergave te overschrijven

HotPDF-invalidatie-semantiek voor de RenderCacheFolder-schijfcache: het bewerken van het geladen document of een dirty object laat de bronidentiteit vallen zodat de tier wordt omzeild, het wisselen van render-backend of ICC-workflow houdt identiteit onder een nieuwe variant-sleutel, en opslaan plus opnieuw laden geeft het document een nieuwe sleutel
Een edit verwijdert nooit de opgeslagen map, een instellingswijziging rendert onder een andere sleutel, en alleen opslaan plus opnieuw laden verdient het bewerkte document een verse identiteit

Om een bewerkte document terug op de schijftier te krijgen, geeft u het een nieuwe bronidentiteit door het op te slaan en het resultaat te laden:

procedure CommitEditsAndRekey(Pdf: THotPDF; const EditedFile: string);
begin
  // Na het bewerken van het geladen document: verfris de pagina's in het geheugen.
  // De bronidentiteit is al weg, dus er wordt niets uit de schijfmap van het
  // originele document gelezen of ernaar geschreven
  Pdf.InvalidateRenderedPageCache;

  // Een opgeslagen bestand heeft een nieuwe grootte en last-write time, dus een
  // nieuwe identiteit; renders na deze load worden onder de nieuwe key gecached
  Pdf.SaveLoadedDocument(EditedFile);
  if Pdf.LoadFromFile(EditedFile) <= 0 then
    raise Exception.Create('Could not reload the edited document');
end;

De map van het originele document wordt met rust gelaten en veroudert via RenderCacheMaxDocuments en RenderCacheMaxBytes weg zoals elke andere entry. Opent de gebruiker het onbewerkte origineel opnieuw, dan staan zijn pagina's er nog

Beveiligingsgrenzen: versleutelde bronnen en gekoppelde mappen

De HotPDF-schijf-rendercache weigert met opzet twee soorten invoer: ze schrijft nooit pagina's van een versleutelde PDF naar schijf, en ze volgt nooit een documentsubmap die een junction of ander reparse point is. Beide regels ruilen cachehits in tegen geen data lekken of de verkeerde bestanden verwijderen

Versleutelde PDF's worden nooit op schijf gecached

Een gerenderde pagina is gedecrypte inhoud. Haar als platte PNG naar een cachemap schrijven zou een leesbare kopie van een met wachtwoord beveiligd document op schijf achterlaten, buiten de bescherming die de auteur koos (ISO 32000-1 §7.6). HotPDF legt daarom voor elke bron waarvan de trailer een /Encrypt-entry voert geen identiteit vast, inclusief bestanden geopend met een wachtwoord of met een leeg gebruikerswachtwoord. Die documenten blijven gewoon de in-memory tier gebruiken, die met het proces sterft

Junction-submappen worden geweigerd sinds v2.770.173

De cacheroot kiest u zelf, en haar naar een junction richten is toegestaan. De documentsubmappen eronder zijn een andere zaak: de cache maakt, leest, raakt aan en verwijdert ze in eigen beheer, tijdens opstartherstel (dat achtergebleven tijdelijke bestanden verwijdert), opzoeken (dat tijdstempels bijwerkt), opslaan, invalidatie en de drie eviction-limieten. Vervangt iemand met schrijfrechten op de cacheroot een documentmap door een junction naar een andere directory, dan zou elk van die paden haar volgen, en zou eviction bestanden verwijderen op een plek die de cache nooit in bezit had. Sinds v2.770.173 controleert elk van die ingangspunten het reparse-point-attribuut en slaat een gekoppelde documentmap over: een opzoeking telt een miss, een opslag telt een schrijffaling, en eviction laat haar met rust

Unicode-paden en gedeelde roots

Twee verwante fixes doen ertoe als u uitrolt naar gebruikersprofielen. Vóór v2.770.135 was RenderCacheFolder een AnsiString, dus een map buiten de systeemcodepagina (een Chinese gebruikersnaam op een Engelse Windows-installatie bijvoorbeeld) werd met verlies omgezet voordat de cache haar zag; de property is nu een Unicode string, en de atomische vervanging gebruikt de wide Windows API. Sinds v2.770.52 delen meerdere THotPDF-instanties in één proces die naar dezelfde root wijzen (na padexpansie, case-insensitief vergeleken) één referentiegetelde index en lock. Daarvoor overschreef elke instantie index.txt met zijn eigen kopie en handhaafde hij de limieten tegen zijn partiale blik, dus de map kon meerdere keren voorbij zijn budget groeien

Delen stopt aan de procesgrens. Twee aparte processen op dezelfde root houden nog steeds aparte in-memory indexen, dus geef elke gelijktijdig draaiende applicatie zijn eigen cacheroot. Viewers die op werkerthreads renderen zitten binnen één proces goed: PrefetchLoadedPages en de wachtrij behandeld in achtergrondrendering met een verzoekwachtrij lopen allebei via hetzelfde gecachede pad en dezelfde lock

Snelnaslag: RenderCacheFolder-checklist

  • Zet RenderCacheFolder, RenderCacheMaxDocuments en RenderCacheMaxBytes vóór de eerste aanroep van RenderLoadedPageToBitmapCached; zet bij stream- en random-access-loads de map vóór het laden
  • Upgrade naar v2.770.140 of later als u op de schijftier leunt; oudere versies accepteren de property maar serveren nooit een pagina vanaf schijf bij normale loads
  • Verwacht geen schijfcaching voor versleutelde PDF's, voor documenten die na de load zijn bewerkt, of zolang RenderFallbackPolicy niet rfpIgnore is
  • Geef de THotPDF-instantie normaal vrij; sinds v2.770.140 verwijdert noch Free noch InvalidateRenderedPageCache schijfentries
  • Het wisselen van PageRenderBackend of de ICC-workflow houdt het document op de schijftier onder een andere sleutel
  • Gebruik één cacheroot per draaiende applicatie; instanties binnen één proces delen de index sinds v2.770.52
  • Houd de cacheroot op een per-gebruiker locatie; documentsubmappen die junctions zijn worden sinds v2.770.173 overgeslagen

Een persistente paginacache betaalt zich het beste terug in een viewer die dezelfde documenten de hele dag opnieuw opent, en dat is precies de vorm van de custom PDF-viewerarchitectuur in Delphi die elders op deze blog wordt beschreven. RenderCacheFolder, de in-memory rastercache en de paginarender worden geleverd met de HotPDF Delphi PDF-component voor Delphi en C++Builder