Artículo técnico

Carga progresiva de PDF por rangos en Delphi con PDFlibPas

Un archivo escaneado de 2 GB vive en un bucket de S3 y el usuario quiere la página 900. PDFlibPas puede servir esa página sin descargar el archivo: LoadFromRangeSource construye un flujo seekable de solo lectura sobre tu propio callback de rangos de bytes y lo entrega a TPDFDocument, de modo que el parser tira de las tablas de referencias cruzadas, una rama del árbol de páginas y un flujo de contenido

El lado del transporte es viejo y aburrido. Los servidores HTTP llevan décadas anunciando rangos de bytes, hoy especificados en RFC 9110 §14, y todo object store habla el mismo dialecto. El lado PDF está igual de asentado: ISO 32000-1 §7.5.8 define la linearización precisamente para que un lector pueda renderizar la primera página desde el frente del archivo. Lo que faltaba en Delphi es la pieza del medio, la parte que decide qué rangos pedir, cuántos conservar y cómo no pedir dos veces

¿Qué necesita LoadFromRangeSource de tu transporte?

Dos cosas, y ninguna de las dos es un flujo. PDFlibPas pide un SourceSize fiable y un callback de lectura síncrono de tipo TPDFlibRangeReadEvent, declarado como function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Internamente la pareja se convierte en un TCallbackByteRangeSource que expone SourceSize y ReadRange, envuelto en un flujo cuya propiedad pasa al documento. Tu destino del callback y su backend siguen siendo tuyos: el documento libera el envoltorio al cerrar, limpiar o recargar, pero nunca toca el objeto de transporte detrás del puntero a método

El contrato es deliberadamente indulgente en una dirección y estricto en la otra. Una lectura corta es legal y simplemente significa que el parser vuelve a pedir. Un callback que lanza una excepción se convierte en lectura corta y converge por la ruta normal de fallo de carga. Un callback que dice haber escrito más de Count bytes se recorta, porque un proveedor con errores no debe poder desbordar el búfer de caché. Los reintentos de contraseña reconstruyen un flujo de rangos nuevo y un estado de parseo nuevo sobre la misma fuente del callback, así que un intento fallido no puede dejar atrás posición, ventana ni estado de descifrado rancios

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
  { un GET bloqueante con 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;  { libera el flujo envoltorio }
  Src.Free;  { tu transporte, tu ciclo de vida }
end;

¿Cuánto retiene de verdad la caché de rangos?

Por defecto 4 MiB, repartidos en ventanas alineadas a chunks y desalojados por LRU. El diseño anterior de ventana única crecía hasta la longitud que pidiera el llamante, así que una gran lectura secuencial podía rebasar el tamaño nominal de chunk mientras que un salto aleatorio tiraba la ventana anterior de inmediato. La caché actual alinea cada offset de origen a ChunkSize, trae exactamente un chunk por fallo y aplica un presupuesto duro de bytes sobre varias ventanas. Cualquier presupuesto explícito que pases se eleva al menos a un chunk completo, de modo que una lectura avanza siempre chunk a chunk y la carga máxima de la caché se mantiene predecible. Un ChunkSize por debajo de 4096 cae al valor por defecto de 64 KiB

Cómo PDFlibPas sirve una lectura del parser en Delphi sin descargar el PDF: el offset absoluto se alinea hacia abajo al tamaño de chunk, se sirve desde una de varias ventanas LRU si acierta, o se convierte en una única llamada de callback recortada si falla
Cada offset de origen se alinea al tamaño de chunk, así que un fallo trae exactamente un chunk y la carga máxima de la caché se mantiene predecible

La contabilidad de lecturas repetidas es la parte que merece conectar a tu telemetría. PDFlibPas identifica una repetición por el inicio de chunk alineado y conserva intervalos contiguos ordenados, lo que separa una primera traída genuina de una re-traída tras un desalojo sin que la contabilidad crezca linealmente con el tamaño del archivo. GetRangeSourceCacheInfo devuelve el cuadro completo como JSON, SetRangeSourceCacheLimit redimensiona el presupuesto en tiempo de ejecución y ClearRangeSourceCache suelta las ventanas y reinicia las estadísticas juntas. Encoger el presupuesto en tiempo de ejecución conserva el historial y cuenta las liberaciones por presupuesto como desalojos, así que un repeatedReads en ascenso frente a un hits plano es tu señal de que el working set ya no cabe

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;

¿Qué pasa cuando varios hilos quieren el mismo chunk?

Esperan sobre una petición, no sobre varias. Un TStream clásico tiene un único cursor de posición, y dos hilos que bloquean correctamente cada uno por su lado pueden ver esa posición reescrita entre un Seek y un Read, así que los objetos lazy y las lecturas segmentadas de PDFlibPas usan un ReadAt absoluto que nunca mueve el cursor. Cada chunk alineado tiene una única petición en vuelo que comparten todos los llamantes de ese chunk, los chunks adyacentes en cola se fusionan antes de que arranque la lectura del origen, y una lectura física se limita a 16 MiB, de modo que una ráfaga de trabajo de páginas en paralelo no se amplifica ni en peticiones pequeñas duplicadas ni en una absurda gigante. La ventana de fusión es por defecto de 2 ms y solo aplica al primer chunk ausente de cada ReadAt; el Read posicional nunca la espera, y pasar cero elimina por completo el retraso inicial de recogida, algo que importa en escaneos secuenciales largos que acumularían la espera chunk a chunk. La posición, los metadatos de caché y las lecturas del origen viven tras tres bloqueos separados, y el propio callback del origen se serializa, que es lo que permite usar sin cambios un adaptador de base de datos u object store sin protección interna de hilos. Los que esperan reciben su propia copia de los datos, así que un desalojo LRU posterior no puede invalidar un búfer que ya se entregó

Fusión de peticiones en la carga por rangos de PDFlibPas para Delphi: dos hilos que piden el mismo chunk comparten una petición en vuelo, los chunks adyacentes en cola se fusionan dentro de una ventana de dos milisegundos y una única lectura serializada del origen sirve a todos
Una ráfaga de trabajo de páginas en paralelo colapsa en una petición compartida por chunk, y cada esperante recibe aún su propia copia de los bytes

¿Se puede preguntar si la página 900 está lista sin traerla?

Sí, y para eso está justo el callback opcional de disponibilidad. Un callback de lectura corriente no distingue los bytes que ya aterrizaron de los que exigen un viaje de ida y vuelta bloqueante, y sondear con una lectura de prueba dispararía la misma descarga que intentas evitar. TPDFlibRangeAvailabilityEvent responde a una sola pregunta, si un rango completo se puede leer de inmediato, y tiene prohibido traer nada; los bytes que la caché ya cubren cuentan siempre como disponibles. GetRangeSourceDataAvailability mapea los objetos indirectos a los rangos de almacenamiento físico registrados en las entradas de referencias cruzadas, resuelve los objetos comprimidos a su contenedor de object stream, corrige una cabecera PDF desplazada y parsea un objeto solo después de que el rango completo pase el sondeo sin traída, de modo que la ruta de lo ausente nunca llama a tu callback de lectura

El recorrido está acotado, no es exhaustivo. Una consulta de página recorre solo la rama del árbol de páginas que contiene la página objetivo y después añade contenido de página, recursos, anotaciones y atributos de página heredados, saltándose las aristas de vuelta Parent y P para que una sola página o widget no pueda expandirse hacia atrás hasta el documento entero. El grafo de objetos se limita a 100000 objetos solicitados y una profundidad de 256, los objetos de flujo se parsean primero por diccionario, y un fallback de parseo completo solo se permite para objetos almacenados de hasta 4 MiB. El informe JSON fusiona los intervalos solapados y adyacentes antes de contar, así que requiredBytes y missingBytes se calculan desde los arrays fusionados requiredRanges y missingRanges, cuyo end es un extremo inclusivo. Consultar un objeto ya disponible puede poblar la caché de rangos; consultar uno ausente deja las estadísticas de lectura intactas

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 lleva "missingBytes" más los "missingRanges" fusionados }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { p. ej. el archivo no tiene AcroForm alguno }
end;

Por qué el prefetch tiene que iterar

Porque leer los missingRanges actuales una vez no hace disponible la página. Un nodo del árbol de páginas o un object stream ausente solo revela la siguiente capa de dependencias cuando llega, así que un trabajo de prefetch de PDFlibPas ejecuta un bucle de consulta, traída y re-consulta hasta que la página, el formulario o el grafo de objetos está completamente disponible o un límite de bytes o de pasadas lo detiene. El trabajo usa su propio lector y una pequeña caché secundaria cuya fuente de datos reenvía las lecturas absolutas al flujo de rangos original, lo que mantiene el estado de parseo aislado del TSmartPDFReader de primer plano mientras que los bytes que de verdad descarga siguen cayendo en la caché principal compartida. Hay un hilo de trabajo por cada flujo de rangos, en línea con la serialización que ya exige el callback del origen, y la cola elige por cuatro niveles de prioridad y luego por orden de envío dentro de un nivel. MaxBytes se cobra en bytes de chunk físico, así que un parser que pide un solo byte dentro de un chunk sin cachear paga igualmente el chunk entero, mientras que los chunks ya en la caché compartida no le cuestan nada al trabajo. Cancelar un trabajo en cola alcanza un estado terminal con cero lecturas del origen; un trabajo en marcha se comprueba antes de cada pasada de dependencias y de cada chunk del origen, y liberar el flujo de rangos espera a que un callback en vuelo retorne en vez de intentar interrumpirlo

El bucle de prefetch de PDFlibPas en Delphi: un trabajo consulta la disponibilidad, trae los rangos ausentes y vuelve a consultar, porque cada nodo del árbol de páginas u object stream que llega revela la siguiente capa de dependencias, hasta que el grafo se completa o un límite lo detiene
Un trabajo de prefetch itera porque un nodo ausente solo nombra a sus hijos cuando llega, y cobra cada pasada en chunks físicos enteros
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" y el último
    informe completo de disponibilidad, para que LIMIT_REACHED siga siendo
    distinguible de FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Dónde esto degrada a una descarga del archivo entero

La carga por rangos es una apuesta a la disposición del archivo, y algunos archivos no la respetan. Un archivo linealizado según ISO 32000-1 §7.5.8 es el caso bueno: la sección de primera página se calienta al abrir, acotada a la vez por el umbral de seguridad de 4 MiB existente y por el presupuesto actual de caché, de modo que el calentamiento no puede desalojar de inmediato la mayor parte de sí mismo. Un archivo no linealizado aún se resuelve por el trailer y la cadena de referencias cruzadas cerca del final, que cuesta un par de viajes extra en vez de un desastre. El verdadero acantilado es un archivo dañado que fuerza la ruta de reparación, porque reconstruir una tabla de referencias cruzadas significa rastrear cabeceras de objeto por todo el documento, y eso es una descarga completa llegando de un chunk en un chunk. La latencia es el otro límite honesto: a 60 ms por petición, un parseo de acceso aleatorio que necesita cuarenta chunks sin cachear pasa más de dos segundos en tránsito sin importar lo buena que sea la caché, que es precisamente lo que el argumento de read-ahead y la cola de prioridad existen para esconder. La misma disciplina aparece en el enfoque de acceso directo para fusionar y dividir PDF grandes, y esta caché se sitúa bajo el renderizado de páginas en paralelo y la caché de páginas en disco del visor por igual

La API de fuente de rangos, la consulta de disponibilidad y el planificador de prefetch forman parte de la PDFlibPas Delphi PDF Library estándar para Delphi, C++Builder y Free Pascal; la página de producto lleva la referencia completa de parámetros de LoadFromRangeSource junto a las constantes de prioridad y estado del prefetch