Honker - Extensión que agrega semántica NOTIFY/LISTEN de Postgres a SQLite
(github.com/russellromney)- 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()yqueue()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/LISTENal estilo Postgres, y permite procesar pub/sub duradero, colas de trabajo y flujos de eventos dentro del mismo archivo.dbsin 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_versionestá 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
.dbnotify/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
enqueuedevuelve un id, el worker guarda el valor de retorno y quien llama puede esperar el resultado conqueue.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 comodb.queue("emails")para encolar y consumir trabajos. - Si dentro del bloque
with db.transaction() as tx:haces el INSERT de un pedido yemails.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, usajob.ack(), y si falla, lo procesa conjob.retry(delay_s=60, error=str(e)). claim()es un iterador asíncrono y, internamente, llama aclaim_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)yqueue.ack_batch(ids, worker_id); la visibilidad predeterminada es de 300 segundos.
- Puedes abrir la base de datos con
-
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 unTaskResult. - Quien llama puede usarlo como
send_email("alice@example.com", "Hi")y esperar el resultado ejecutado por el worker conr.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.
- Si usas el decorador
-
Streams de Python
db.stream("user-events")ofrece pub/sub durable, y puedes ejecutar el UPDATE de negocio ystream.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=ysave_every_s=; si ambos se dejan en 0, se desactiva el guardado automático y puedes llamar directamente astream.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 contx.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.
- Puedes suscribirte a pub/sub efímero con
-
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
listense despierta con cualquier commit de la base de datos antes de filtrar por canal.
- En el binding de Node también se usan las mismas funciones con el patrón
-
Extensión de SQLite
- Después de
.load ./libhonker_ext, puedes inicializar conSELECT 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.
- Después de
Diseño
- Este repositorio incluye juntos la extensión loadable de SQLite
honkery 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/LISTENsin 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 defsyncy la configuraciónwal_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-waly.db-shm - El claim se resuelve con un único
UPDATE … RETURNINGmediante un partial index, y el ack con un únicoDELETE - En cualquier journal mode solo puede haber un writer a la vez, y la ventaja de lectores concurrentes la aporta WAL
PRAGMA data_versionaumenta 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
mtimedel archivo WAL constat(2)o usar kernel watchers comoFSEvents,inotifyokqueue, se usaPRAGMA data_versiondata_versiones 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
.dben NFS, habrá corrupción- En ese caso se necesita sharding a nivel de archivo o cambiar a Postgres
Arquitectura
-
Ruta de wake
- Cada
Databasetiene un PRAGMA poll thread que consultadata_versioncada 1 ms - Cuando cambia el contador, hace fan-out del tick al bounded channel de cada subscriber
- Cada subscriber ejecuta
SELECT … WHERE id > last_seenusando 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_versionpor 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-coreusaSharedWalWatchercomo dueño del poll thread y hace fan-out mediante canales boundedSyncSender<()>por id de subscriber- Cada llamada a
db.wal_events()registra un subscriber, y el handle devuelto se da de baja automáticamente cuando ocurreDrop - Cuando un listener se libera, el bridge thread recibe
rx.recv() -> Err, hace limpieza y termina
- Cada
-
Esquema de cola
_honker_livecontiene 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 … RETURNINGsobre ese índice - El ack es un solo
DELETE - Las filas que superan el límite de reintentos se mueven a
_honker_deady 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 aclaim_batch(id, 1)y entrega los trabajos de a unoJob.ack()es un únicoDELETEdentro de su propia transacción, y devuelveTruesi el claim sigue siendo válido, oFalsesi 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)yqueue.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_notificationsdentro de la transacción abierta del llamador queue.enqueue(…, tx=tx)ystream.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_versiondespierta a todos los subscribers de eseDatabase; no despierta selectivamente solo a los canales con commits - El costo de un wake incorrecto se limita a un
SELECTindexado, 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
- Un cambio en
-
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
notifyes 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
- Los trabajos de cola permanecen hasta ser acked, y si superan el límite de reintentos se mueven 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
SIGKILLdurante 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 deCOMMITy luego se compruebaPRAGMA 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
attemptsaumenta- 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)odb.stream(name).subscribe(...), con una forma comoasync 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_exten 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óns.begin(), se llaman juntos el INSERT del modelo ySELECT 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.pyybench/real_bench.py
Configuración de desarrollo
-
Estructura del repositorio
honker-core/:rlibde Rust compartido por todos los bindings; se incluye in-tree y también se publica en crates.iohonker-extension/:cdylibpara la loadable extension de SQLite; se incluye in-tree y también se publica en crates.iopackages/honker/: paquete de Python que incluye elcdylibde PyO3 y Queue, Stream, Outbox y Schedulerpackages/honker-node/: binding para Node.js; es un git submodulepackages/honker-rs/: wrapper ergonómico para Rust; es un git submodulepackages/honker-go/: binding para Go; es un git submodulepackages/honker-ruby/: binding para Ruby; es un git submodulepackages/honker-bun/: binding para Bun; es un git submodulepackages/honker-ex/: binding para Elixir; es un git submodulepackages/honker-cpp/: binding para C++; es un git submoduletests/: directorio de pruebas de integración cross-packagebench/: directorio de benchmarkssite/: sitiohonker.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-coreyhonker-extensionestá incluida directamente en este repositorio - al clonar, se necesita
git clone --recursiveogit submodule update --init --recursive
Pruebas y cobertura
make testejecuta por defecto las pruebas de Rust, Python y Node, y en la ruta rápida tarda unos 10 segundosmake test-python-slowincluye pruebas de soak y cron en tiempo real, y tarda unos 2 minutosmake test-allejecuta la suite completa, incluidas las marcas lentasmake buildrealizamaturin developde 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 instalacoverage.pyycargo-llvm-cov make coveragegenera dos reportes HTML encoverage/, ymake coverage-pythongenera el reporte de la ruta de Python, mientras quemake coverage-rustgenera el reporte basado en las pruebas unitarias Rust dehonker-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 dehonker_ops.rsse 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
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 lenguajeEncima 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
.dbde una app existente, así que se pueden confirmar atómicamente junto con las escrituras del negocio, y si hay rollback ambos desaparecen juntosOriginalmente era litenotify/joblite, pero había comprado
honker.devmedio 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 vigenteEn 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
stat()cada 1ms es sorprendentemente muy baratoEn 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%
PRAGMA data_versionsería mejor questat(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_VERSIONhttps://sqlite.org/c3ref/c_fcntl_begin_atomic_write.html#sqlitefcntldataversion
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
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 hacerEvents INNER JOIN Subscribersy despertar solo a los subscribers que realmente hagan matchGracias 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_versioncada 1ms,statcada 100ms y reconexión en caso de errorstatde cambios en size/mtime usandoPRAGMA data_versioncada 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íaProbando vi que
SQLITE_FCNTL_DATA_VERSIONde 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ícitamentedata_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 subscribersstatpara 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_versionsigue el fd abierto, así que si cambia el archivo seguiría viendo el inode original y no detectaría esoGracias 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
Me pregunto si no se podría observar cambios del WAL con inotify o algún wrapper multiplataforma en vez de hacer polling
statsimplemente funciona en todos ladosLo 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 eventosAntes 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
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
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
statse ve feo, pero al final parece ser lo que realmente funciona en todas partesTambié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
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
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
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 enPRAGMA, es muy barata. En otro comentario ya se mencionó questat(2)anda alrededor de 1µsMuy 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