Artículo técnico

Transmita PDF remotos en Delphi: fusión de rangos HotPDF

HotPDF carga un PDF desde cualquier fuente de acceso aleatorio que usted implemente, y THPDFCoalescingRandomAccessSource envuelve esa fuente para que las lecturas pequeñas y dispersas del analizador se conviertan en un conjunto acotado de rangos de bloques en caché con prefetch asíncrono. En un documento servido mediante solicitudes de rango HTTP, esa es la diferencia entre unos cientos de idas y vueltas y apenas unas docenas

Nada cambia en el analizador. Usted sigue llamando a LoadFromRandomAccessSource, obtiene el mismo objeto de documento de regreso, y la misma API de páginas funciona igual. Lo que cambia es el tráfico por debajo

¿Por qué el mismo PDF carga al instante en local y se arrastra por la red?

Porque un analizador de PDF no lee un archivo, lo navega. Busca el final para encontrar startxref, salta de vuelta a la tabla de referencias cruzadas, resuelve el diccionario trailer, sigue una referencia hasta el Catalog, luego hasta la raíz del árbol de páginas, luego hasta un nodo de página, luego hasta su diccionario de recursos. Cada uno de esos pasos lee decenas de bytes desde un offset distinto

En un archivo local ese patrón es casi gratuito: el sistema operativo ya tiene en caché la página de 4 KiB circundante, así que la segunda lectura solo cuesta un memcpy. Sobre un transporte de red no existe esa localidad. Cada lectura es una solicitud con su propia latencia, y 300 solicitudes secuenciales a 40 ms cada una son doce segundos gastados casi por completo esperando. La solución no es leer menos; el analizador necesita exactamente lo que pide. La solución es hacer que cada lectura física cubra más de lo que la siguiente lectura lógica querrá

Qué cambia la fusión de rangos

La fuente de fusión redondea cada lectura hacia arriba hasta un bloque y almacena ese bloque en caché. BlockSize tiene un valor predeterminado de 262,144 bytes y MaxCacheBytes de 2,097,152, de modo que hay ocho bloques residentes por defecto, y se desalojan por orden de uso menos reciente contra un presupuesto de bytes fijo. La lectura de 40 bytes que hace el analizador para una clave del trailer trae consigo los 256 KiB circundantes, y la próxima docena de lecturas en ese vecindario, que es donde vive la información de referencias cruzadas y del catálogo, se atienden desde memoria

Su propia fuente se mantiene simple. Implemente GetSize y ReadAt, sobrescriba ReadAtCancellable si su transporte puede abortar en pleno vuelo, y deje que el wrapper se encargue del caché, la fusión de rangos y el prefetch

type
  THttpRangeSource = class(THPDFRandomAccessSource)
  private
    FClient: TMyHttpClient;
    FUrl: string;
    FSize: Int64;
  public
    function GetSize: Int64; override;
    function ReadAt(Offset: Int64; var Buffer; Count: Longint): Longint; override;
    function ReadAtCancellable(Offset: Int64; var Buffer; Count: Longint;
      CancellationToken: THPDFCancellationToken): Longint; override;
  end;

var
  Raw: THttpRangeSource;
  Cached: THPDFCoalescingRandomAccessSource;
  Pdf: THotPDF;
begin
  Raw := THttpRangeSource.Create('https://files.example.com/contract.pdf');
  // OwnsSource=True: el wrapper libera Raw junto con él mismo
  Cached := THPDFCoalescingRandomAccessSource.Create(Raw, True, 262144, 8388608);
  Pdf := THotPDF.Create(nil);
  try
    Cached.AsyncPrefetchEnabled := True;
    Cached.AdaptiveReadAheadEnabled := True;
    Cached.MaxReadAheadBlocks := 8;

    if Pdf.LoadFromRandomAccessSource(Cached, True) = 1 then
      RenderFirstPage(Pdf);
  finally
    Pdf.Free;
  end;
end;

¿Qué tan adelante debería leer?

La lectura anticipada adaptativa responde esa pregunta por documento, en lugar de obligarlo a adivinar. Con AdaptiveReadAheadEnabled activado, la ventana crece a través de 1, 2, 4 y 8 bloques a medida que se acumulan lecturas secuenciales sostenidas hacia adelante, y nunca excede MaxReadAheadBlocks ni la capacidad de caché configurada. En el momento en que llega una lectura que no está aproximadamente donde terminó la anterior, la ventana colapsa y el prefetch se suprime

SequentialReadToleranceBytes, con valor predeterminado de 4,096, define ese "aproximadamente". Las lecturas que caen dentro de esa distancia del final de la lectura anterior aún cuentan como secuenciales, lo cual importa porque un analizador de PDF que recorre un content stream no produce offsets perfectamente contiguos; se salta un campo de longitud aquí, un diccionario en línea allá. Si fija la tolerancia demasiado baja, un recorrido normal hacia adelante se clasifica como aleatorio, y la lectura anticipada nunca se activa. Si la fija demasiado alta, un acceso verdaderamente aleatorio parece secuencial, y termina descargando megabytes que nadie quiere. El valor predeterminado está calibrado para el recorrido de content streams, y las estadísticas le dirán si su transporte no está de acuerdo

Esta asimetría es deliberada: el crecimiento es gradual, el colapso es inmediato. Sobre-descargar en una carga de trabajo de acceso aleatorio cuesta ancho de banda real y dinero real en transportes medidos, así que se prefiere el error barato antes que el costoso

Cancelación que realmente detiene la transferencia

La clase base declara ReadAtCancellable, y la fuente de fusión la respeta de principio a fin. Cuando llega una lectura en primer plano para un rango que un prefetch en curso no está atendiendo, ese prefetch se cancela en lugar de dejarlo terminar, de modo que la solicitud de página del usuario no queda encolada detrás de tráfico especulativo. La implementación predeterminada en THPDFRandomAccessSource recae en un simple ReadAt, lo que significa que la funcionalidad es opcional por transporte: los clientes HTTP que admiten abortar solicitudes obtienen cancelación genuina, y las fuentes más simples siguen funcionando sin cambios

Combine eso con un token de cancelación propagado a través de su UI, y un usuario que cierra un documento realmente detiene el tráfico de red en lugar de esperar a que se agote. El mismo modelo de token sustenta la cola descrita en renderizado en segundo plano con una cola de solicitudes, de modo que un solo token puede cubrir todo el camino desde el viewport hasta el socket

Cómo leer las estadísticas de la caché de rangos

GetStatistics llena un registro THPDFRangeCacheStatistics que separa lo que hizo su transporte de lo que hizo la caché. SourceReadCount y SourceBytesRead son tráfico físico. CacheHitCount y CacheMissCount son tráfico lógico. SequentialReadCount y RandomReadCount muestran cómo se clasificó el patrón de acceso, CurrentReadAheadBlocks y PeakReadAheadBlocks muestran qué tan lejos se abrió la ventana, y PrefetchRequestCount, PrefetchCompletedCount, PrefetchCancelledCount y SuppressedPrefetchCount muestran si la especulación valió la pena

var
  S: THPDFRangeCacheStatistics;
begin
  Cached.GetStatistics(S);
  Log(Format('physical %d reads / %d bytes, hits %d, misses %d',
    [S.SourceReadCount, S.SourceBytesRead, S.CacheHitCount, S.CacheMissCount]));
  Log(Format('pattern: %d sequential, %d random, peak window %d blocks',
    [S.SequentialReadCount, S.RandomReadCount, S.PeakReadAheadBlocks]));
  Log(Format('prefetch: %d issued, %d completed, %d cancelled, %d suppressed',
    [S.PrefetchRequestCount, S.PrefetchCompletedCount,
     S.PrefetchCancelledCount, S.SuppressedPrefetchCount]));
end;

Tres lecturas de estas cifras le dicen qué cambiar. Muchos prefetch cancelados junto con un alto conteo de lecturas aleatorias significa que el documento se está accediendo fuera de orden, así que baje MaxReadAheadBlocks y deje de pagar por ancho de banda que descarta. Muchos fallos con una ventana pico que se queda en 1 significa que la tolerancia está rechazando un patrón que en la práctica es secuencial, así que suba SequentialReadToleranceBytes. Y bytes leídos muy por encima del tamaño del archivo significa que la caché está en thrashing, así que suba MaxCacheBytes antes de tocar cualquier otra cosa

Los archivos linealizados cambian la aritmética

Si usted controla al productor, linealizar el documento cambia el problema en lugar de optimizarlo. Un PDF linealizado coloca los objetos de la primera página y una tabla de sugerencias al frente del archivo, de modo que un visor puede renderizar la página uno desde el primer megabyte sin ver el resto. HotPDF expone esa ruta directamente mediante GetProgressiveLinearizedLoadInfo y ReadProgressiveLinearizedFirstPageSection, y el lado de la escritura se cubre en generación de PDF linealizados con tablas de sugerencias

Ambas técnicas se combinan bien. La fusión de rangos hace tolerable cualquier documento sobre un enlace lento; la linealización hace que la primera página llegue rápido en los documentos que usted mismo produce. Para archivos que residen en un disco local pero son demasiado grandes para caber en memoria, las rutas de archivo mapeado y stream perezoso descritas en el flujo de trabajo de la API de archivo directo suelen ser la mejor herramienta, ya que ahí no hay latencia de ida y vuelta que amortizar en primer lugar

HotPDF es un componente VCL nativo para Delphi y C++Builder, sin DLL externa para el analizador y con código fuente completo disponible. La API de fuente de acceso aleatorio, el wrapper de fusión de rangos y los puntos de entrada de carga progresiva están documentados en la página del componente HotPDF Delphi PDF