Logo
Nuevos usuarios de RPC: 35% de descuento el primer mesVer oferta
OnFinality Learn
Infraestructura y operaciones14 min de lectura

Solana getProgramAccounts: Cómo construir un indexador de conjuntos de cuentas reanudable

Convierte un escaneo puntual de getProgramAccounts en un índice reanudable y siempre correcto, con cursores de slot, relleno de huecos y reconciliación de eliminaciones.

TL;DR

Un indexador de Solana en producción no es una sola llamada a getProgramAccounts; es una instantánea tomada en un slot registrado más un stream incremental que mantiene esa instantánea al día, sin permitir que ambas discrepen en silencio. La identidad de la instantánea es el context.slot mínimo entre sus páginas, porque un escaneo reanudable sobre una cadena que avanza abarca un rango de slots. Un cursor de slot no es un cursor de altura de bloque: los slots pueden omitirse, por lo que el cursor debe ser un rango de slots recorrido con getBlocks, no un incremento entero. accountSubscribe comienza en el slot actual y no puede rellenar el hueco entre el slot de la instantánea y el slot de la suscripción, por lo que ese hueco debe cerrarse con un reescaneo acotado. La detección de eliminaciones requiere reconciliar por propiedad y vivacidad en lugar de por pertenencia al escaneo, porque una cuenta cerrada simplemente desaparece del siguiente escaneo. Este artículo asume que has leído la página hermana sobre semántica de filtros y escaneos reanudables por orden de clave y no repite ese material.

El contrato del indexador: instantánea más stream

Un escaneo completo de getProgramAccounts establece una instantánea del conjunto de cuentas de un programa en un slot registrado. Un stream incremental mantiene esa instantánea al día. El contrato es que estos dos nunca discrepen en silencio: cada escritura lleva una versión, y cada reinicio puede demostrar qué rango del stream debe reproducirse.

El contrato de solicitud y respuesta del escaneo es autoritativo en la documentación JSON-RPC de Solana para getProgramAccounts, incluido el slot de contexto de RpcResponse, el array de filtros dataSize/memcmp, dataSlice y el envoltorio withContext. La superficie de streaming está documentada en la documentación websocket JSON-RPC de Solana para accountSubscribe y logsSubscribe.

Este artículo deliberadamente no vuelve a enseñar la semántica de filtros, los offsets de memcmp, dataSlice como control de ancho de banda ni el escaneo reanudable por orden de clave. Esas primitivas pertenecen a la página de requisitos previos enlazada arriba y de nuevo en los siguientes pasos.

  • Instantánea: un escaneo completo cuyas páginas están indexadas por una clave de cuenta estable y selladas con un slot.
  • Stream: accountSubscribe para cambios por cuenta, comenzando en el slot actual.
  • Reconciliación: una pasada que distingue cuentas cerradas de cuentas meramente ausentes de una página.

El slot de contexto como identidad de la instantánea

getProgramAccounts devuelve un RpcResponse cuyo context.slot es el slot en el que se leyó la página. Una instantánea correcta registra el slot de contexto mínimo entre sus páginas, no el máximo, y no la hora del reloj. Un escaneo reanudable que pagina sobre una cadena que avanza abarca un rango de slots, por lo que el slot más temprano es el único valor que garantiza que no se pierda ningún cambio entre ese slot y la finalización de la instantánea.

Registrar el máximo omitiría en silencio los cambios que ocurrieron entre la primera página y la última. Registrar la hora del reloj es peor: no tiene una relación definida con el estado de la cadena y no puede reproducirse contra getBlocks.

  • Registra min(context.slot) entre todas las páginas como el slot de la instantánea.
  • Persiste el slot de la instantánea junto con el cursor para que un reinicio sepa dónde debe reanudarse el stream.
  • Nunca sustituyas la altura de bloque ni la hora del reloj por el slot de contexto.

Por qué un cursor de slot no es un cursor de altura de bloque

Los slots pueden omitirse, por lo que un cursor no puede ser un incremento entero. El cursor debe ser un rango de slots recorrido con getBlocks, que devuelve los bloques producidos en un rango. getBlockHeight es una cantidad diferente y no debe usarse como cursor, como se documenta en la documentación JSON-RPC de Solana para getSlot, getBlockHeight y getBlocks.

Un slot sin bloque producido no puede ser una posición de cursor. Si tu cursor es un incremento entero, un slot omitido atascará el indexador o hará que se salte estado real. Recorrer el rango con getBlocks hace explícitos los slots omitidos y permite que la lógica de relleno de huecos los trate correctamente.

  • Cursor = un rango de slots, no un solo entero.
  • Usa getBlocks para enumerar los bloques producidos en el rango.
  • Trata los slots omitidos como huecos que deben detectarse, no como errores que deben ignorarse.

Construir la instantánea con un cursor persistido

Enumera páginas, indexa cada página por una clave de cuenta estable y persiste pares (clave de cuenta, slot). Usa el slot registrado para decidir qué stream incremental debe reproducirse desde dónde al reiniciar. El escaneo en sí es el escaneo reanudable por orden de clave de la página de requisitos previos; aquí solo añadimos el sellado de slot y la persistencia.

El código a continuación muestra el bucle de instantánea con captura del slot de contexto y una escritura de cursor. Asume el helper de paginación del artículo hermano y se centra en el ciclo de vida del indexador.

const { Connection, PublicKey } = require('@solana/web3.js');

async function snapshot(connection, programId, store) {
  let cursor = await store.getCursor();
  let minSlot = null;
  let page = 0;

  while (true) {
    const res = await connection.getProgramAccounts(new PublicKey(programId), {
      withContext: true,
      filters: [],
      dataSlice: { offset: 0, length: 0 },
      // pagination via key-order scan is handled by the sibling helper
      ...(cursor ? { before: cursor } : {})
    });

    const slot = res.context.slot;
    if (minSlot === null || slot < minSlot) minSlot = slot;

    for (const { pubkey, account } of res.value) {
      await store.upsert(pubkey.toBase58(), { slot, data: account.data });
    }

    if (res.value.length === 0) break;
    cursor = res.value[res.value.length - 1].pubkey.toBase58();
    await store.setCursor(cursor);
    page += 1;
  }

  await store.setSnapshotSlot(minSlot);
  return { minSlot, page };
}

module.exports = { snapshot };

Mantener el índice al día con accountSubscribe

accountSubscribe entrega cambios por cuenta, pero una suscripción comienza en el slot actual y por lo tanto no puede rellenar entre el slot de la instantánea y el slot de la suscripción. Ese hueco debe cerrarse con un reescaneo acotado. Acótalo registrando el slot de la suscripción y reescaneando el rango [snapshotSlot, subscriptionSlot] usando getBlocks para recorrer los bloques producidos.

El reescaneo acotado no es un segundo escaneo completo; es una reproducción dirigida del hueco. Su tamaño es la diferencia entre dos slots, que es medible y puede limitarse. Si el hueco supera tu límite, recurre a una instantánea nueva en lugar de dejar que el hueco crezca sin límite.

  • Registra el slot de la suscripción cuando el websocket lo confirme.
  • Reescanea [snapshotSlot, subscriptionSlot] con getBlocks para recorrer los bloques producidos.
  • Limita el hueco; si supera el límite, toma una instantánea nueva.

Detección de eliminaciones por propiedad y vivacidad

Una cuenta cerrada desaparece del escaneo, por lo que un indexador ingenuo que 'reemplaza mi tabla con lo que acabo de escanear' elimina en silencio datos vivos, y un indexador de fusión ingenuo conserva las cuentas eliminadas para siempre. El enfoque correcto reconcilia por propiedad y vivacidad: una cuenta que el escaneo ya no devuelve y que getAccountInfo informa como inexistente ha sido cerrada.

El modelo de cuenta y lo que significa cerrar una cuenta para sus lamports y su propietario están documentados en la documentación de Solana sobre propiedad de cuentas y el modelo de cuentas. Úsalo para distinguir una cuenta cerrada de una cuenta que simplemente no se devolvió porque una página dio error.

  • Cerrada: el escaneo ya no la devuelve y getAccountInfo informa que no existe.
  • No devuelta: la página dio error o se truncó; no elimines.
  • Reconcilia por propiedad y vivacidad, no por pertenencia al escaneo.

Escrituras idempotentes indexadas por clave de cuenta con versionado por slot

La misma cuenta llega tanto desde la instantánea como desde el stream, por lo que la ruta de escritura debe ser un upsert indexado por clave de cuenta con el slot como versión. Esto también hace que las reproducciones sean inofensivas: un mensaje de stream reproducido con un slot más antiguo se ignora, y un slot más nuevo sobrescribe.

El código a continuación muestra el upsert y la pasada de reconciliación de eliminaciones. Es intencionalmente pequeño para que pueda integrarse en un almacén existente.

async function upsert(store, accountKey, { slot, data }) {
  const existing = await store.get(accountKey);
  if (existing && existing.slot >= slot) return; // stale replay
  await store.put(accountKey, { slot, data });
}

async function reconcileDeletions(connection, store, programId) {
  const known = await store.allKeys();
  for (const key of known) {
    const info = await connection.getAccountInfo(new PublicKey(key));
    if (info === null) {
      await store.delete(key);
    } else if (!info.owner.equals(new PublicKey(programId))) {
      await store.delete(key); // ownership changed
    }
  }
}

module.exports = { upsert, reconcileDeletions };

Un indexador Node.js ejecutable con relleno de huecos y reconciliación

El bucle completo combina la instantánea, el cursor persistido, el stream de accountSubscribe, el reescaneo acotado de relleno de huecos y la pasada de reconciliación de eliminaciones. Ejecútalo contra tu propio endpoint y completa la tabla de resultados en la siguiente sección.

El manejador del stream escribe a través del mismo upsert, por lo que la instantánea y el stream convergen en un único almacén versionado. La pasada de reconciliación se ejecuta según una programación y después de cada reinicio.

const { Connection, PublicKey } = require('@solana/web3.js');
const { snapshot } = require('./snapshot');
const { upsert, reconcileDeletions } = require('./store');

async function run(connection, programId, store) {
  const { minSlot } = await snapshot(connection, programId, store);

  const subId = connection.onProgramAccountChange(
    new PublicKey(programId),
    async (keyedAccountInfo, context) => {
      await upsert(store, keyedAccountInfo.accountId.toBase58(), {
        slot: context.slot,
        data: keyedAccountInfo.accountInfo.data
      });
    },
    'confirmed'
  );

  const subscriptionSlot = await connection.getSlot('confirmed');
  const gap = await connection.getBlocks(minSlot, subscriptionSlot);
  for (const blockSlot of gap) {
    // bounded re-scan of the gap range
    await snapshotRange(connection, programId, store, blockSlot);
  }

  setInterval(() => reconcileDeletions(connection, store, programId), 60_000);
  return subId;
}

module.exports = { run };

Tabla de resultados para completar contra tu propio endpoint

Mide contra tu propio endpoint y registra los valores a continuación. No te bases en cifras publicadas; el objetivo es caracterizar el comportamiento de tu endpoint bajo tu carga de trabajo.

Ejecuta el indexador durante una ventana fija y luego completa cada fila. El span de slots de la instantánea es la diferencia entre los slots de contexto máximo y mínimo entre páginas. El rango de relleno de huecos es la diferencia entre el slot de la suscripción y el slot de la instantánea.

  • Span de slots de la instantánea: max(context.slot) - min(context.slot) entre páginas.
  • Número de páginas: número de páginas de getProgramAccounts en la instantánea.
  • Retraso del stream: slot de la suscripción menos el slot de la última escritura transmitida.
  • Rango de relleno de huecos: slot de la suscripción menos slot de la instantánea.
  • Eliminaciones reconciliadas: número de cuentas eliminadas por la pasada de reconciliación.

Modos de fallo y solución de problemas

Un escaneo demasiado grande para el límite de respuesta de un endpoint dará error o se truncará. Un endpoint que trunca el escaneo sin dar error es el caso más peligroso, porque la instantánea parece completa pero no lo está. Detéctalo comparando los recuentos de páginas con una línea base conocida como buena y comprobando que la última página esté vacía.

Las páginas sobre una cadena en movimiento son esperadas; el slot de contexto mínimo se encarga de ellas. Las caídas de suscripción deben detectarse y el relleno de huecos debe volver a ejecutarse. La presión de límite de tasa de un escaneo que se encuentra con un stream sobre la misma clave se cubre en Límites de tasa de RPC de Solana y errores 429.

  • Límite de respuesta: reduce el tamaño de página o usa dataSlice para reducir los payloads.
  • Truncamiento silencioso: verifica que la última página esté vacía y que los recuentos de páginas coincidan con la línea base.
  • Caídas de suscripción: vuelve a ejecutar el relleno de huecos acotado desde el último slot registrado.
  • Límites de tasa: escalona el escaneo y el stream, y retrocede ante un 429.

Limitaciones, compensaciones y modelo de costes

Este diseño es eventualmente consistente por construcción: la instantánea y el stream convergen, pero siempre hay una ventana en la que el índice va por detrás de la cadena. No es un reemplazo de un indexador hecho a medida para conjuntos de cuentas de programa muy grandes, donde un escaneo completo es prohibitivamente caro y se justifica una canalización de ingesta dedicada.

El modelo de costes está dominado por el escaneo completo y la pasada de reconciliación. El stream es comparativamente barato. Usa Precios de RPC para estimar, y considera el servicio de API para acceso gestionado. Para contexto de red, consulta Solana.

  • Eventualmente consistente: aceptable para la mayoría de rutas de lectura, no para consistencia estricta.
  • No es un reemplazo de indexadores hechos a medida en conjuntos de cuentas muy grandes.
  • El coste está dominado por el escaneo completo y la reconciliación; el stream es barato.

Siguientes pasos y lecturas relacionadas

Comienza con el requisito previo Solana getProgramAccounts: dataSlice, filtros y paginación segura, luego amplía a Paginación de Solana getSignaturesForAddress y Solana getBlocks, slots omitidos y detección de huecos del indexador.

Para lecturas de cuentas y rent, consulta Leer cuentas de Solana: rent y cuentas de token. Para el comportamiento del endpoint, consulta la Guía de la API RPC de Solana (RPC Assistant) y el centro de aprendizaje de OnFinality.

  • Requisito previo: semántica de filtros y escaneos reanudables por orden de clave.
  • Relacionado: paginación de firmas, detección de huecos por slots omitidos, lecturas de cuentas/rent.
  • Operativo: límites de tasa, precios de RPC, servicio de API, página de red de Solana.

Nunca te preocupes por la infraestructura nuevamente

OnFinality elimina la carga pesada de DevOps para que puedas construir de forma más inteligente y rápida.

Comenzar