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 stream posicionable de solo lectura sobre su propio callback de rangos de bytes y lo entrega a TPDFDocument, de modo que el parser obtiene las tablas de referencias cruzadas, una rama del árbol de páginas y un stream de contenido
El lado del transporte de esto es viejo y aburrido. Los servidores HTTP anuncian rangos de bytes desde hace décadas, hoy especificados en RFC 9110 §14, y cada object store habla el mismo dialecto. El lado del PDF está igual de resuelto: 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 evitar pedir dos veces
¿Qué necesita LoadFromRangeSource de su transporte?
Dos cosas, y ninguna es un stream. PDFlibPas pide un SourceSize autoritativo y un callback de lectura sincrónico del 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 stream cuya propiedad pasa al documento. Su objetivo de callback y su backend siguen siendo suyos: 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 pregunta de nuevo. Un callback que lanza una excepción se convierte en una lectura corta y converge por el camino normal de falla 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 stream de rangos nuevo y un estado de parseo nuevo sobre la misma fuente de callback, así que un intento fallido no puede dejar atrás posición, ventana ni estado de descifrado obsoletos
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 stream envoltorio }
Src.Free; { su transporte, su ciclo de vida }
end;
¿Cuánto guarda realmente la caché de rangos?
Por defecto 4 MiB, repartidos en ventanas alineadas a chunks y expulsados por LRU. El diseño anterior de ventana única crecía hasta la longitud que pidiera el llamador, así que una lectura secuencial grande podía pasar del tamaño nominal de chunk, mientras que un salto aleatorio descartaba la ventana anterior de inmediato. La caché actual alinea cada offset de origen a ChunkSize, obtiene exactamente un chunk por cada miss y hace cumplir un presupuesto duro de bytes entre varias ventanas. Cualquier presupuesto explícito que pasen se eleva a por lo menos un chunk completo, de modo que una sola lectura siempre avanza chunk a chunk y la carga pico de la caché se mantiene predecible. Un ChunkSize por debajo de 4096 vuelve al valor por defecto de 64 KiB
La contabilidad de lecturas repetidas es la parte que vale la pena conectar a su telemetría. PDFlibPas identifica una repetición por el inicio de chunk alineado y conserva intervalos contiguos ordenados, lo que separa una primera obtención genuina de una reobtención tras una expulsión, a la vez que evita que la contabilidad crezca linealmente con el tamaño del archivo. GetRangeSourceCacheInfo devuelve todo el panorama como JSON, SetRangeSourceCacheLimit redimensiona el presupuesto en tiempo de ejecución, y ClearRangeSourceCache suelta las ventanas y reinicia las estadísticas juntas. Reducir el presupuesto en tiempo de ejecución conserva el historial y cuenta las liberaciones por presupuesto como expulsiones, así que un repeatedReads en ascenso frente a unos hits planos es su señal de que el conjunto de trabajo 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 por una sola petición, no por varias. Un TStream clásico tiene un único cursor de posición, y dos hilos que cada uno bloquea correctamente pueden aun así tener esa posición reescrita entre un Seek y un Read, así que los objetos perezosos y las lecturas segmentadas en PDFlibPas usan un ReadAt absoluto que nunca mueve el cursor. Cada chunk alineado recibe una única petición en vuelo que todos los llamadores de ese chunk comparten, los chunks adyacentes en cola se fusionan antes de que empiece 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 absurdamente grande. La ventana de fusión por defecto es de 2 ms y aplica solo al primer chunk faltante de cada ReadAt; el Read posicional nunca espera por ella, y pasar cero elimina por completo el retraso de recolección inicial, lo que importa para escaneos secuenciales largos que de otro modo acumularían la espera chunk a chunk. La posición, los metadatos de caché y las lecturas del origen están detrás de tres candados separados, y el callback del origen mismo está serializado, que es lo que permite usar sin cambios un adaptador de base de datos o de object store sin protección interna de hilos. Los que esperan reciben su propia copia de los datos, así que una expulsión LRU posterior no puede invalidar un búfer que ya fue entregado
¿Se puede preguntar si la página 900 está lista sin obtenerla?
Sí, y para eso existe justamente el callback opcional de disponibilidad. Un callback de lectura corriente no puede distinguir los bytes que ya aterrizaron de los bytes que requieren un viaje de ida y vuelta bloqueante, y sondear con una lectura de prueba dispararía justamente la descarga que se quiere evitar. TPDFlibRangeAvailabilityEvent responde una sola pregunta, si un rango completo se puede leer de inmediato, y tiene prohibido obtener nada; los bytes que la caché ya cubre 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 un encabezado PDF desplazado y parsea un objeto solo después de que el rango completo pase el sondeo sin obtención, así que el camino de lo faltante nunca llama a su callback de lectura
El recorrido está acotado y no es exhaustivo. Una consulta de página camina solo la rama del árbol de páginas que contiene la página objetivo y luego agrega contenido de página, recursos, anotaciones y atributos de página heredados, saltándose las aristas de retorno 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 acota a 100000 objetos solicitados y una profundidad de 256, los objetos stream se parsean primero por diccionario, y un fallback de parseo completo solo se permite para objetos almacenados de hasta 4 MiB. El reporte JSON fusiona los intervalos que se traslapan y los adyacentes antes de contar, así que requiredBytes y missingBytes se calculan a partir de los arreglos fusionados requiredRanges y missingRanges, cuyo end es un extremo inclusivo. Consultar un objeto ya disponible puede poblar la caché de rangos; consultar uno faltante 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 trae "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 en absoluto }
end;
Por qué el prefetch tiene que iterar
Porque leer los missingRanges actuales una vez no hace disponible la página. Un nodo faltante del árbol de páginas o un object stream solo revela la siguiente capa de dependencias después de llegar, así que un trabajo de prefetch de PDFlibPas corre un bucle de consulta, obtención y reconsulta 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 stream de rangos original, lo que mantiene el estado de parseo aislado del TSmartPDFReader de primer plano mientras los bytes que genuinamente descarga aterrizan igualmente en la caché principal compartida. Existe un hilo de trabajo por stream de rangos, en línea con la serialización que el callback del origen ya exige, 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 físicos de chunk, así que un parser que pide un solo byte dentro de un chunk no cacheado igual paga el chunk completo, mientras que los chunks ya en la caché compartida no le cuestan nada al trabajo. Cancelar un trabajo en cola llega a un estado terminal con cero lecturas del origen; un trabajo en ejecución se verifica antes de cada pasada de dependencias y de cada chunk del origen, y liberar el stream de rangos espera a que un callback en vuelo regrese en lugar de intentar interrumpirlo
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
reporte completo de disponibilidad, para que LIMIT_REACHED siga siendo
distinguible de FAILED }
Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;
Dónde esto se degrada a una descarga del archivo completo
La carga por rangos es una apuesta por la disposición del archivo, y algunos archivos no la honran. Un archivo linearizado según ISO 32000-1 §7.5.8 es el caso bueno: la sección de primera página se precalienta al abrir, acotada tanto por el umbral de seguridad existente de 4 MiB como por el presupuesto actual de caché, de modo que el precalentamiento no puede expulsar de inmediato la mayor parte de sí mismo. Un archivo no linearizado igual se resuelve por el trailer y la cadena de referencias cruzadas cerca del final, lo que cuesta un par de viajes de ida y vuelta extra y no un desastre. El verdadero precipicio es un archivo dañado que fuerza el camino de reparación, porque reconstruir una tabla de referencias cruzadas significa escanear encabezados de objeto por todo el documento, y eso es una descarga completa llegando un chunk a la vez. La latencia es el otro límite honesto: a 60 ms por petición, un parseo de acceso aleatorio que necesita cuarenta chunks sin caché pasa más de dos segundos en tránsito sin importar qué tan buena 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 PDFs grandes, y esta caché queda por debajo tanto de el renderizado de páginas en paralelo como de la caché de páginas en disco del visor
La API de fuente de rangos, la consulta de disponibilidad y el programador de prefetch son 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 con las constantes de prioridad y estado del prefetch