2 puntos por GN⁺ 5 일 전 | 1 comentarios | Compartir por WhatsApp
  • La extensión de SQLite y varios bindings de lenguaje permiten manejar pub/sub duradero, colas de trabajo y flujos de eventos dentro del mismo archivo .db, sin polling del cliente ni un daemon o broker aparte
  • notify(), stream() y queue() se registran dentro de la transacción de quien las llama, por lo que se confirman junto con las escrituras de negocio o se revierten juntas, reduciendo el problema de dual-write
  • La activación entre procesos funciona consultando PRAGMA **data_version** cada 1 ms, con el objetivo de lograr una latencia de pocos milisegundos y un costo de consulta muy bajo
  • La cola de trabajo incluye reintentos, prioridad, ejecución diferida, dead-letter, scheduler, named lock y rate limiting, y los streams admiten entrega at-least-once guardando offsets por consumidor
  • En entornos que usan SQLite como almacén principal, permite operar la aplicación y el procesamiento asíncrono en un solo archivo de base de datos, reduciendo la complejidad operativa; la API aún está en estado Experimental

Resumen general

  • Con una extensión de SQLite y varios bindings de lenguaje, agrega a SQLite el comportamiento NOTIFY/LISTEN al estilo Postgres, y permite procesar pub/sub duradero, colas de trabajo y flujos de eventos dentro del mismo archivo .db sin polling del cliente ni un daemon o broker aparte
  • A partir de un diseño on-disk definido una sola vez en Rust, los bindings de Python, Node, Bun, Ruby, Go, Elixir y C++ siguen una estructura en la que todos envuelven de forma ligera la misma loadable extension
  • Sustituye el polling a nivel de aplicación por una lectura de la base de datos cada 1 ms; el costo de consultar PRAGMA data_version está en el rango de pocos microsegundos y la entrega de notificaciones entre procesos en el rango de pocos milisegundos
  • Si SQLite se usa como almacén principal, las escrituras de negocio y el encolado de tareas pueden confirmarse o revertirse en la misma transacción, lo que reduce la necesidad de operar un datastore separado y el problema de dual-write
  • La API sigue en estado Experimental y puede cambiar
  • Aclara explícitamente que, si ya se opera con Postgres, puede ser más apropiado usar pg_notify, pg-boss u Oban

Funciones principales

  • Ofrece en un mismo archivo .db notify/listen entre procesos, una cola de trabajo con reintentos, prioridad, ejecución diferida y tabla dead-letter, y un stream duradero con offsets por consumidor
  • Todas las operaciones de envío pueden acoplarse de forma atómica con las escrituras de negocio, de modo que se confirman o revierten juntas
  • El tiempo de respuesta entre procesos está en el rango de pocos milisegundos, e incluye también handler timeout, reintentos con exponential backoff, delayed jobs, task expiration, named lock y rate limiting
  • También admite un scheduler basado en leader election, tareas periódicas estilo crontab y almacenamiento opt-in de resultados de tareas
    • enqueue devuelve un id, el worker guarda el valor de retorno y quien llama puede esperar el resultado con queue.wait_result(id)
  • Se ofrece en forma de SQLite loadable extension, por lo que cualquier cliente SQLite puede leer las mismas tablas
  • También funciona dentro de conexiones SQLite administradas por un ORM, y la guía de ORM cubre la integración con SQLAlchemy, SQLModel, Django, Drizzle, Kysely, sqlx, GORM, ActiveRecord y Ecto
  • También deja claro lo que deliberadamente queda fuera de alcance
    • no admite task pipeline, chain, group ni chord
    • no admite replicación multi-writer
    • no admite workflow orchestration basada en DAG

Inicio rápido

  • Cola de Python

    • Puedes abrir la base de datos con honker.open("app.db") y obtener una cola con algo como db.queue("emails") para encolar y consumir trabajos.
    • Si dentro del bloque with db.transaction() as tx: haces el INSERT de un pedido y emails.enqueue(..., tx=tx) juntos, la escritura del pedido y el encolado de la tarea de correo quedan agrupados en la misma transacción.
    • El worker toma los trabajos uno por uno con una forma como async for job in emails.claim("worker-1"):; si tiene éxito, usa job.ack(), y si falla, lo procesa con job.retry(delay_s=60, error=str(e)).
    • claim() es un iterador asíncrono y, internamente, llama a claim_batch(worker_id, 1) en cada iteración.
    • Se despierta con cualquier commit de la base de datos, y solo vuelve a un paranoia poll de 5 segundos cuando el commit watcher no puede funcionar.
    • El procesamiento por lotes está separado para usar directamente claim_batch(worker_id, n) y queue.ack_batch(ids, worker_id); la visibilidad predeterminada es de 300 segundos.
  • Tareas de Python

    • Si usas el decorador @emails.task(retries=3, timeout_s=30), la llamada a la función se convierte directamente en un encolado y devuelve un TaskResult.
    • Quien llama puede usarlo como send_email("alice@example.com", "Hi") y esperar el resultado ejecutado por el worker con r.get(timeout=10).
    • El worker puede ejecutarse como un proceso separado o in-process, por ejemplo python -m honker worker myapp.tasks:db --queue=emails --concurrency=4.
    • El nombre automático es {module}.{qualname}, y en entornos de producción se recomienda un nombre explícito como @emails.task(name="...") para evitar que un cambio de nombre deje huérfanos los trabajos pendientes.
    • Las tareas periódicas usan una forma como @emails.periodic_task(crontab("0 3 * * *")).
    • Hay un ejemplo más detallado en packages/honker/examples/tasks.py.
  • Streams de Python

    • db.stream("user-events") ofrece pub/sub durable, y puedes ejecutar el UPDATE de negocio y stream.publish(..., tx=tx) dentro de la misma transacción.
    • Si te suscribes con async for event in stream.subscribe(consumer="dashboard"), primero reproduce las filas posteriores al offset guardado y luego cambia a entrega en tiempo real basada en commits.
    • El offset de cada consumer con nombre se guarda en la tabla _honker_stream_consumers.
    • El guardado automático del offset solo ocurre, por defecto, cada 1000 eventos o una vez por segundo, para no golpear en exceso el single-writer slot incluso con alto throughput.
    • Puede ajustarse con save_every_n= y save_every_s=; si ambos se dejan en 0, se desactiva el guardado automático y puedes llamar directamente a stream.save_offset(consumer, offset, tx=tx).
    • Si ocurre un crash, sigue un modelo at-least-once en el que los eventos en vuelo posteriores al último offset persistido se vuelven a entregar.
  • Notify de Python

    • Puedes suscribirte a pub/sub efímero con async for n in db.listen("orders"): y enviar una notificación dentro de una transacción con tx.notify("orders", {"id": 42}).
    • El listener se conecta actualmente desde el punto MAX(id), por lo que no reproduce historial anterior.
    • Si necesitas replay durable, debes usar db.stream().
    • La tabla de notifications no se limpia automáticamente, así que en una tarea programada debes llamar a db.prune_notifications(older_than_s=…, max_keep=…).
    • El payload de la tarea debe ser JSON válido, y un writer en Python y un reader en Node pueden compartir el mismo canal.
  • Node.js

    • En el binding de Node también se usan las mismas funciones con el patrón open('app.db'), db.transaction(), tx.notify(...), db.listen('orders').
    • La escritura de negocio y el notify quedan unidos al mismo commit, y listen se despierta con cualquier commit de la base de datos antes de filtrar por canal.
  • Extensión de SQLite

    • Después de .load ./libhonker_ext, puedes inicializar con SELECT honker_bootstrap(); y usar solo funciones SQL para cola, locks, rate limit, scheduler, streams y almacenamiento de resultados.
    • Se ofrecen funciones como honker_claim_batch, honker_ack_batch, honker_sweep_expired, honker_lock_acquire, honker_rate_limit_try, honker_scheduler_tick, honker_stream_publish, honker_stream_read_since, honker_result_save.
    • El binding de Python y la extensión comparten _honker_live, _honker_dead, _honker_notifications, así que un worker de Python puede tomar trabajos insertados por otro lenguaje mediante la extensión.
    • La compatibilidad de esquema está fijada en tests/test_extension_interop.py.

Diseño

  • Este repositorio incluye juntos la extensión loadable de SQLite honker y bindings para Python, Node, Rust, Go, Ruby, Bun y Elixir.
  • Está orientado a aplicaciones que usan SQLite como almacenamiento principal, y se enfoca en mover la lógica del paquete a una extensión de SQLite para que pueda usarse de forma similar desde varios lenguajes y frameworks.
  • Hay tres primitivas principales.
    • notify(), que es pub/sub efímero.
    • stream(), que es pub/sub durable con offsets por consumidor.
    • queue(), que es una cola de trabajos at-least-once.
  • Estas tres primitivas se registran todas como INSERT dentro de la transacción del llamador, de modo que la entrega del trabajo y la escritura de negocio se confirman juntas o se revierten juntas.
  • El objetivo es implementar un comportamiento parecido a NOTIFY/LISTEN sin polling a nivel de aplicación, para lograr tiempos de respuesta rápidos.
  • Si se usa tal cual un archivo SQLite existente, cada commit de la base de datos despierta al worker, y la mayoría de los triggers pueden terminar leyendo mensajes o la cola y devolviendo un resultado vacío sin procesar nada.
  • Este overtriggering es un tradeoff intencional, elegido para lograr un comportamiento cercano a push y tiempos de respuesta rápidos.

Valores predeterminados recomendados para WAL

  • Los bindings de lenguaje usan por defecto journal_mode = WAL, lo que ofrece una estructura de múltiples lectores concurrentes y un solo escritor, batching eficiente de fsync y la configuración wal_autocheckpoint = 10000
  • Otros modos como DELETE, TRUNCATE y MEMORY también funcionan, y la detección de commits se basa en PRAGMA data_version, que aumenta en todos los modos de journal
  • Lo único que se pierde en el modo no WAL es la característica de escritura durante lectura concurrente; la corrección y el wake entre procesos no dependen de WAL
  • Todo el sistema consiste en un solo archivo .db, y si WAL está habilitado pueden agregarse los sidecars .db-wal y .db-shm
  • El claim se resuelve con un único UPDATE … RETURNING mediante un partial index, y el ack con un único DELETE
  • En cualquier journal mode solo puede haber un writer a la vez, y la ventaja de lectores concurrentes la aporta WAL
  • PRAGMA data_version aumenta en cada commit y checkpoint, por lo que maneja correctamente situaciones como truncado de WAL, creación y eliminación del archivo de journal y reutilización del mismo tamaño
  • SQLite no tiene wire protocol, así que no es posible hacer push desde el servidor; el consumidor debe iniciar la lectura por sí mismo
    • La señal de wake es el incremento del contador
    • Después, la consulta real se hace con SELECT
  • Como las transacciones son baratas, jobs, events y notifications se escriben dentro del bloque abierto with db.transaction() del llamador, como en el patrón outbox
  • En lugar de mirar el tamaño y mtime del archivo WAL con stat(2) o usar kernel watchers como FSEvents, inotify o kqueue, se usa PRAGMA data_version
    • data_version es un contador monotónico que SQLite incrementa ante el commit de cualquier conexión
    • Maneja correctamente WAL truncation, clock skew y transacciones revertidas
    • En macOS, los kernel watchers pueden perder escrituras del mismo proceso, y stat(2) basado en (size, mtime) puede perder commits cuando el WAL se trunca y luego vuelve a crecer al mismo tamaño
    • Funciona igual en Linux, macOS y Windows, y el costo de CPU con resolución de 1 ms es muy bajo
    • Se indica un costo de unas 3.5µs por consulta, o alrededor de 3.5ms/seg a 1kHz
  • El modelo de locks de SQLite asume single machine, single writer; si dos servidores escriben sobre el mismo .db en NFS, habrá corrupción
    • En ese caso se necesita sharding a nivel de archivo o cambiar a Postgres

Arquitectura

  • Ruta de wake

    • Cada Database tiene un PRAGMA poll thread que consulta data_version cada 1 ms
    • Cuando cambia el contador, hace fan-out del tick al bounded channel de cada subscriber
    • Cada subscriber ejecuta SELECT … WHERE id > last_seen usando partial index, devuelve las filas nuevas y luego vuelve a esperar
    • Incluso con 100 subscribers, solo hace falta 1 poll thread
    • Un listener idle no ejecuta ninguna consulta SQL
    • El costo idle es solo una consulta PRAGMA data_version por base de datos cada 1 ms, y la cantidad de listeners escala casi gratis gracias al uso de la lectura del contador de SQLite
    • honker-core usa SharedWalWatcher como dueño del poll thread y hace fan-out mediante canales bounded SyncSender<()> por id de subscriber
    • Cada llamada a db.wal_events() registra un subscriber, y el handle devuelto se da de baja automáticamente cuando ocurre Drop
    • Cuando un listener se libera, el bridge thread recibe rx.recv() -> Err, hace limpieza y termina
  • Esquema de cola

    • _honker_live contiene filas en estado pending y processing
    • El partial index tiene la forma (queue, priority DESC, run_at, id) WHERE state IN ('pending','processing')
    • El claim se hace con un solo UPDATE … RETURNING sobre ese índice
    • El ack es un solo DELETE
    • Las filas que superan el límite de reintentos se mueven a _honker_dead y ya no se vuelven a escanear en la ruta de claim
    • Gracias al partial index sobre state, la ruta crítica de claim queda limitada por el tamaño del working set, no por el tamaño del historial completo
    • Aunque haya 100k dead rows, la velocidad de claim se mantiene igual que en una cola sin dead rows
  • Iterador de claim

    • async for job in q.claim(id) llama repetidamente a claim_batch(id, 1) y entrega los trabajos de a uno
    • Job.ack() es un único DELETE dentro de su propia transacción, y devuelve True si el claim sigue siendo válido, o False si la visibility window expiró y otro worker lo volvió a tomar
    • Se despierta con cualquier commit a la base de datos desde cualquier proceso, y un paranoia poll de 5 segundos es el único fallback
    • Para trabajo por lotes hay que usar directamente claim_batch(worker_id, n) y queue.ack_batch(ids, worker_id)
    • La librería no oculta el batch detrás del iterador, para permitir manejar con más claridad el costo transaccional y el comportamiento de visibilidad at-most-once
  • Acoplamiento con transacciones

    • notify() es una SQL scalar function que se registra en la conexión de escritura
    • Hace un INSERT en _honker_notifications dentro de la transacción abierta del llamador
    • queue.enqueue(…, tx=tx) y stream.publish(…, tx=tx) funcionan de la misma manera
    • Si ocurre un rollback, también desaparecen el job, el event y la notification
    • Esto equivale a un patrón transactional outbox incorporado, que permite procesar en la misma operación la escritura de negocio y el enqueue del side effect sin instalar una librería aparte
    • No hay dispatch table ni dispatcher process separado; la propia fila del side effect se convierte en la fila confirmada, y cualquier proceso que esté observando la base de datos puede recogerla en alrededor de 1 ms
  • Over-triggering más rápido que hacer polling

    • Un cambio en data_version despierta a todos los subscribers de ese Database; no despierta selectivamente solo a los canales con commits
    • El costo de un wake incorrecto se limita a un SELECT indexado, del orden de microsegundos
    • En cambio, perder un wake necesario lleva a un bug silencioso de corrección
    • El filtrado por canal se resuelve en la ruta de SELECT, no en la etapa de notificación del trigger
    • SQLite también puede manejar eficientemente el patrón de muchas consultas pequeñas
  • Política de retención

    • Los trabajos de cola permanecen hasta ser acked, y si superan el límite de reintentos se mueven a _honker_dead
    • Los eventos de stream se conservan y cada consumidor con nombre rastrea su propio offset
    • notify es fire-and-forget y no tiene limpieza automática
    • La política de retención la elige el llamador para cada primitive, y debe invocar directamente db.prune_notifications(older_than_s=…, max_keep=…)
    • El enfoque busca que la política de retention quede explícita en el código del llamador, en lugar de esconderse detrás de valores predeterminados de la librería

Recuperación ante fallos

  • un rollback elimina jobs, events y notifications junto con las escrituras de negocio, de acuerdo con las propiedades ACID de SQLite
  • es seguro incluso si ocurre un SIGKILL durante la transacción, y en la siguiente apertura el rollback de atomic commit de SQLite no deja estado obsoleto
    • el uso de WAL o rollback journal depende del journal mode
    • la verificación se realizó en tests/test_crash_recovery.py, donde se finaliza el subprocess antes de COMMIT y luego se comprueba PRAGMA integrity_check == 'ok' y un nuevo flujo de notify
  • si un worker muere mientras procesa una tarea, otro worker la vuelve a claim cuando pasa visibility_timeout_s
    • el valor predeterminado es 300 segundos
    • attempts aumenta
    • si supera el valor predeterminado de max_attempts, que es 3, la fila se mueve a _honker_dead
  • si un listener estaba offline durante un prune, se perderá los eventos eliminados; si se necesita replay durable, se debe usar db.stream() guardando offsets por consumidor

Integración con frameworks web

  • no se ofrecen plugins para frameworks; como la API es pequeña, se opta por conectarla con unas pocas líneas de glue code
  • en FastAPI, se incluye un ejemplo donde se inicia un worker loop al arrancar y, durante el manejo de requests, se ejecutan juntas dentro de una transacción la escritura de negocio y la inserción en la cola
  • un endpoint SSE puede armarse en unas 30 líneas sobre db.listen(channel) o db.stream(name).subscribe(...), con una forma como async def stream(...): yield f"data: ...\n\n"
  • en Django y Flask, se recomienda una configuración donde el worker se ejecute como un proceso CLI separado, siguiendo un patrón similar a Celery o RQ

Uso con ORM

  • si se carga libhonker_ext en la conexión del ORM y se llaman funciones SQL dentro de la propia transacción del ORM, el enqueue se confirma de forma atómica junto con las escrituras de negocio
  • en el ejemplo con SQLAlchemy, se carga la extensión en el evento de conexión y se ejecuta SELECT honker_bootstrap(); luego, dentro de la transacción s.begin(), se llaman juntos el INSERT del modelo y SELECT honker_enqueue(...)
  • el worker se ejecuta como un proceso separado usando honker.open("app.db"), y el commit watcher despierta ante el commit de cualquier conexión sobre el mismo archivo
  • la guía Using with an ORM incluye integraciones con Django, SQLModel, Drizzle, Kysely, sqlx, GORM, ActiveRecord y Ecto, además del patrón de wrapper TypedQueue[T] para SQLModel/Pydantic y caveats relacionados con Prisma

Rendimiento

  • se indica que puede procesar miles de mensajes por segundo en una laptop moderna
  • la latencia de wake entre procesos está limitada por una poll cadence de 1 ms, y se especifica que en M-series la mediana es de alrededor de 1 a 2 ms
  • las mediciones en hardware real pueden realizarse con bench/wake_latency_bench.py y bench/real_bench.py

Configuración de desarrollo

  • Estructura del repositorio

    • honker-core/: rlib de Rust compartido por todos los bindings; se incluye in-tree y también se publica en crates.io
    • honker-extension/: cdylib para la loadable extension de SQLite; se incluye in-tree y también se publica en crates.io
    • packages/honker/: paquete de Python que incluye el cdylib de PyO3 y Queue, Stream, Outbox y Scheduler
    • packages/honker-node/: binding para Node.js; es un git submodule
    • packages/honker-rs/: wrapper ergonómico para Rust; es un git submodule
    • packages/honker-go/: binding para Go; es un git submodule
    • packages/honker-ruby/: binding para Ruby; es un git submodule
    • packages/honker-bun/: binding para Bun; es un git submodule
    • packages/honker-ex/: binding para Elixir; es un git submodule
    • packages/honker-cpp/: binding para C++; es un git submodule
    • tests/: directorio de pruebas de integración cross-package
    • bench/: directorio de benchmarks
    • site/: sitio honker.dev, basado en Astro y como git submodule
    • cada repositorio de bindings se publica por separado en PyPI, npm, crates.io, Hex, RubyGems, etc., mientras que la base común honker-core y honker-extension está incluida directamente en este repositorio
    • al clonar, se necesita git clone --recursive o git submodule update --init --recursive

Pruebas y cobertura

  • make test ejecuta por defecto las pruebas de Rust, Python y Node, y en la ruta rápida tarda unos 10 segundos
  • make test-python-slow incluye pruebas de soak y cron en tiempo real, y tarda unos 2 minutos
  • make test-all ejecuta la suite completa, incluidas las marcas lentas
  • make build realiza maturin develop de PyO3 y la compilación de la loadable extension
  • los benchmarks pueden ejecutarse con python bench/wake_latency_bench.py --samples 500, python bench/real_bench.py --workers 4 --enqueuers 2 --seconds 15, python bench/ext_bench.py
  • para instalar las herramientas de cobertura se usa make install-coverage-deps, que instala coverage.py y cargo-llvm-cov
  • make coverage genera dos reportes HTML en coverage/, y make coverage-python genera el reporte de la ruta de Python, mientras que make coverage-rust genera el reporte basado en las pruebas unitarias Rust de honker-core
  • se indica que la cobertura de Python es de alrededor del 92% para packages/honker/
  • la cobertura de Rust solo refleja cargo test; varias rutas de honker_ops.rs se ejecutan únicamente desde la suite de pruebas de Python, por lo que no aparecen en el reporte de Rust
  • combinar la cross-language coverage mediante la fusión de datos de perfilado LLVM a través del límite de PyO3 es difícil y sigue postergado

Licencia

  • usa la licencia Apache 2.0
  • más detalles en LICENSE

1 comentarios

 
GN⁺ 5 일 전
Opiniones de Hacker News
  • Yo hice esto. Honker agrega NOTIFY/LISTEN entre procesos a SQLite, para ofrecer entrega de eventos estilo push con latencia de un solo dígito en ms usando solo el archivo SQLite existente, sin daemons ni brokers
    Como SQLite no tiene un servidor como Postgres, la clave fue mover la fuente de polling desde consultas periódicas a un stat(2) ligero sobre el archivo WAL. SQLite sigue siendo eficiente incluso con muchas consultas pequeñas (https://www.sqlite.org/np1queryprob.html), así que no diría que sea una mejora enorme, pero me parece interesante que solo con observar el WAL y llamar funciones de SQLite ya sea independiente del lenguaje
    Encima de eso también monté pub/sub efímero, una cola de trabajo durable con reintentos y dead-letter, y un stream de eventos con offsets por consumidor. Los tres son filas dentro del archivo .db de una app existente, así que se pueden confirmar atómicamente junto con las escrituras del negocio, y si hay rollback ambos desaparecen juntos
    Originalmente era litenotify/joblite, pero había comprado honker.dev medio en broma y, viendo que nombres como Oban, pg-boss, Huey, RabbitMQ, Celery y Sidekiq también son medio ridículos, me quedé con ese. Ojalá sea útil o al menos dé risa, y la advertencia de que es software alfa sigue totalmente vigente

    • Parece estar pensado sobre todo para lenguajes donde es más fácil manejar solo concurrencia basada en procesos
      En cosas como Java/Go/Clojure/C#, como SQLite de todos modos es single writer, se ve más simple y limpio que la aplicación administre ese writer y, con una cola concurrente del lenguaje, sepa qué escrituras ocurrieron y despierte solo a los hilos relacionados
      Aun así, es divertido ver el WAL usado de una forma tan creativa, y en lenguajes como Python/JS/TS/Ruby, donde la concurrencia basada en procesos es común, parece encajar bastante bien como mecanismo de notify
    • Esta vez aprendí que hacer stat() cada 1ms es sorprendentemente muy barato
      En mi hardware tarda menos de 1μs por llamada, así que con ese nivel de polling el uso de CPU ni llega a 0.1%
    • Capaz se me escapa algo, pero me parece que PRAGMA data_version sería mejor que stat(2)
      https://sqlite.org/pragma.html#pragma_data_version
      Y si estás en la API de C, también está el más directo SQLITE_FCNTL_DATA_VERSION
      https://sqlite.org/c3ref/c_fcntl_begin_atomic_write.html#sqlitefcntldataversion
    • Está bastante bueno. Yo también hice algo parecido a medias
      Me pregunto si esto también serviría como stream de mensajes persistente, tipo Kafka liviano. También me da curiosidad si se podrían tener semánticas de replay completo de mensajes pasados + en tiempo real desde cierto timestamp para un topic específico
      Supongo que se podría imitar con polling como en pub/sub, pero como dijiste, probablemente no sea lo óptimo
    • Podría quedar aún mejor si también se guarda el estado del subscriber
      Si guardas cosas como posición de lectura, nombre de la cola y filtros, en vez de despertar todos los hilos de subscription cada vez que cambia stat(2) para que cada uno haga su SELECT con N=1, el hilo de polling podría hacer Events INNER JOIN Subscribers y despertar solo a los subscribers que realmente hagan match
  • Gracias por el feedback. Ya subí un PR incorporando las sugerencias
    https://github.com/russellromney/honker/pulls/1
    Ahora cambió a una estructura de polling de 3 capas: PRAGMA data_version cada 1ms, stat cada 100ms y reconexión en caso de error

    1. Reemplacé la detección previa con stat de cambios en size/mtime usando PRAGMA data_version cada 1ms. Como es el commit counter del propio SQLite, es monotónico, no le afecta el clock skew y maneja bien truncación del WAL y rollback. Es una query nonblocking de unos 3µs, y el cambio fue por correctitud, no por rendimiento. De hecho es apenas más lenta. El riesgo de truncación resultó ser más realista de lo que parecía
      Probando vi que SQLITE_FCNTL_DATA_VERSION de la API de C no funcionaba entre conexiones. Así que por ahora sigo pagando el costo de pasar por la capa VFS y aceptando ese tradeoff explícitamente
    2. Si falla la query de data_version, intento reconectar asumiendo casos como errores temporales de disco, un hiccup de NFS o corrupción de la conexión, y por prevención también despierto a los subscribers
    3. Cada 100ms uso stat para comparar (dev, ino) con los valores del arranque y detectar reemplazo de archivo. Eso cubre casos como atomic rename, restore con litestream o remount del volumen; data_version sigue el fd abierto, así que si cambia el archivo seguiría viendo el inode original y no detectaría eso
      Gracias a esto Honker mejoró y yo también aprendí bastante
  • Un poco de autopromoción: en el próximo PostgreSQL 19, LISTEN/NOTIFY fue optimizado para escalar mucho mejor en signaling selectivo
    Es un parche pensado para casos con muchos backends escuchando canales distintos
    https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=282b1cde9

    • Buena promoción, y además muy bien alineada con el tema
  • Me pregunto si no se podría observar cambios del WAL con inotify o algún wrapper multiplataforma en vez de hacer polling

    • Se rompe lo multiplataforma. Sobre todo en Mac, donde a veces se lo traga silenciosamente, así que cuesta confiar en eso
      stat simplemente funciona en todos lados
  • Lo atractivo frente a IPC separado es que queda confirmado atómicamente con los datos del negocio
    La mensajería externa siempre tiene el problema de “la notificación salió pero la transacción hizo rollback”, y eso se ensucia rápido
    Algo que sí me pregunto es el checkpoint del WAL. No sé si el polling con stat() maneja bien cuando SQLite vuelve a truncar el WAL a 0. Siento que podría haber una ventana en la que se pierdan eventos

    • La atomicidad es prácticamente todo
      Antes sufrí con una combinación de Postgres+SQS porque un trigger enviaba el enqueue antes de que el commit fuera visible en otra conexión. Le agregamos lógica de retry, polling del lado del worker y al final terminamos metiendo el enqueue dentro de la transacción; y llegado a ese punto básicamente era rehacer lo que hace Honker, pero con más moving parts
      Los bugs tipo “la notificación salió pero la fila todavía no está confirmada” suelen ser silenciosos y dependientes del timing, así que son realmente horribles de rastrear
    • El archivo WAL sigue ahí y solo se trunca, así que en sí eso sí aparece como un update
      Igual todavía no tengo tests para esa parte, así que necesito verificarlo mejor. Buen punto, lo voy a revisar
  • Gracias
    Han aumentado mucho las apps pequeñas basadas en SQLite, y casi todas necesitan cola y scheduler
    Yo mismo probé algunas cosas, pero siempre extrañé la elegancia de las soluciones del ecosistema Postgres
    A esto sí le voy a dar una prueba de inmediato

    • La expresión proliferación pequeña describe perfecto el grupo de side projects que formaron mis hábitos
      Si te topas con algún problema, estaría buenísimo que dejaras un PR o un issue en el repo
  • Aquí dan ganas de usar kqueue/FSEvents, pero tenía entendido que Darwin descarta notificaciones del mismo proceso
    Si publisher y listener están en el mismo proceso, a veces el listener ni siquiera se despierta, y rastrear eso se vuelve bastante sucio. El polling con stat se ve feo, pero al final parece ser lo que realmente funciona en todas partes
    También me pregunto si cuando el archivo vuelve a achicarse en un checkpoint del WAL eso dispara un wakeup, o si el poller filtra las disminuciones de tamaño

    • Este comentario está completamente equivocado
      Los eventos VNODE de kqueue se entregan siempre que el proceso tenga permisos de acceso al archivo, y no hay ningún filtro que los descarte por ser del mismo proceso
    • Esto sí necesita una prueba real
      Lo voy a revisar y luego comento
  • Está muy bueno. Me da curiosidad si, bajo carga, el cuello de botella principal es el write throughput de SQLite o la capa de notificaciones del WAL

    • El cuello de botella está del lado de las escrituras y del flujo de claim/ack
      También cambia bastante según el journal mode y el synchronous mode
      La notificación, ya sea con el viejo enfoque de stat(2) o con el nuevo basado en PRAGMA, es muy barata. En otro comentario ya se mencionó que stat(2) anda alrededor de 1µs
  • Muy buen proyecto. Yo también estoy construyendo algo que empuja SQLite mucho más allá de su uso típico
    Da ánimo ver a más gente explorando hasta dónde puede llegar SQLite realmente

  • Me pregunto si también se puede integrar en casos donde se usa SQLAlchemy
    Por cómo se ve ahora, parece que intenta crear por su cuenta la conexión a la base de datos