PostgreSQL
Dos destinos llevan los puntos fechados de ghchronicle a PostgreSQL: uno escribe SQL en un fichero para cargarlo después, el otro conecta e inserta.
sinks: sql: dialect: postgres path: /var/lib/ghchronicle/points.sql max_bytes: 67108864 keep: 5Sentencias INSERT en el dialecto de PostgreSQL, escritas a un fichero rotatorio
o, con path: "-", a la salida estándar.
ghchronicle -config config.yaml -once | psql "$DATABASE_URL"Esta es una de las dos maneras de llegar a PostgreSQL, y la otra se conecta:
sinks: postgres: dsn: ${DATABASE_URL} batch: 1000sinks.postgres escribe las mismas tablas en una base de datos que está
corriendo, en una ida y vuelta por lote, con los valores enviados como
parámetros en vez de renderizados dentro de la sentencia. Nada más cambia: el
esquema de abajo es el que escriben las dos, a propósito, porque los dashboards
que publica este proyecto consultan esas tablas y una segunda forma convertiría
a uno de los dos en una mentira.
Cuál quieres es una pregunta sobre cuándo ocurre la carga. El fichero es la respuesta cuando la base está donde esto no llega, cuando las sentencias se van a leer o a retener antes de ejecutarse, o cuando quien las lee no es PostgreSQL: son SQL corriente y otro motor puede tomarlas. La conexión es la respuesta cuando la base está ahí mismo, y te ahorra el temporizador que mete un fichero por psql.
Hay una diferencia más, y está en Grafana y no aquí. El sink que conecta sabe
cuál es el servidor porque lo marca, así que -publish-dashboard puede
construir el datasource a partir del DSN; el de fichero no puede, porque nunca
se conecta y en su configuración no hay host, ni puerto, ni usuario.
El esquema es el contrato
Sección titulada «El esquema es el contrato»- Una tabla por medida, con su nombre:
gh_repo,gh_traffic,gh_workflow_run. time TIMESTAMPTZ NOT NULL, la fecha en que ocurrió la cosa.- Una columna
TEXT NOT NULL DEFAULT ''por etiqueta, con cadena vacía donde el punto no tenía valor. - Una columna por campo, tipada a partir del valor:
BIGINTpara un entero,DOUBLE PRECISIONpara un flotante,BOOLEAN,TEXT, yTIMESTAMPTZpara un campo que es en sí una hora. PRIMARY KEY (time, <columnas de etiqueta en orden de nombre>).- Todo identificador va entre comillas dobles, porque
user,typeystateson aquí nombres de etiqueta y allí palabras reservadas.
Esa clave primaria es la clave de serie de InfluxDB escrita como restricción, y es lo que hace que una reescritura de la ventana de tráfico de catorce días converja en vez de acumular.
CREATE TABLE IF NOT EXISTS "gh_traffic" ("time" TIMESTAMPTZ NOT NULL, "full_name" TEXT NOT NULL DEFAULT '', "kind" TEXT NOT NULL DEFAULT '', "owner" TEXT NOT NULL DEFAULT '', "repo" TEXT NOT NULL DEFAULT '', PRIMARY KEY ("time", "full_name", "kind", "owner", "repo"));ALTER TABLE "gh_traffic" ADD COLUMN IF NOT EXISTS "count" BIGINT;ALTER TABLE "gh_traffic" ADD COLUMN IF NOT EXISTS "uniques" BIGINT;ALTER TABLE "gh_traffic" ADD COLUMN IF NOT EXISTS "url" TEXT;INSERT INTO "gh_traffic" ("time", "full_name", "kind", "owner", "repo", "count", "uniques", "url") VALUES ('2026-09-07T00:00:00Z'::timestamptz, 'acme/telemetry', 'views', 'acme', 'telemetry', 41, 12, 'https://github.com/acme/telemetry/graphs/traffic') ON CONFLICT ("time", "full_name", "kind", "owner", "repo") DO UPDATE SET "count" = EXCLUDED."count", "uniques" = EXCLUDED."uniques", "url" = EXCLUDED."url";Cómo llegan las declaraciones
Sección titulada «Cómo llegan las declaraciones»El CREATE TABLE IF NOT EXISTS se emite la primera vez que se ve una medida en
un fichero, o en el proceso de un sink que conecta, con el tiempo y las
etiquetas de las que está hecha la clave. Cada campo de la unión que lleva ese
lote lo sigue como ALTER TABLE ... ADD COLUMN IF NOT EXISTS, y lo mismo un
campo que aparece en un lote posterior. Los campos nunca van en el
CREATE TABLE, porque esa sentencia no le hace nada a una tabla que creó una
versión anterior: un campo que esa versión no escribía llegaría al INSERT
sin columna, y PostgreSQL rechaza la sentencia y el lote que la rodea. Añadir
la columna de un campo si falta significa lo mismo para una tabla nueva y para
una antigua. Un fichero rotado empieza otra vez sus declaraciones, así que
cualquier fichero se puede reproducir por su cuenta.
Así que un fichero reproducido en una base de datos que ya tiene sus tablas
hace que psql imprima un aviso por cada declaración que encuentra su trabajo
hecho, uno por el CREATE TABLE y uno por cada columna que la tabla ya tiene:
NOTICE: relation "gh_traffic" already exists, skippingNOTICE: column "count" of relation "gh_traffic" already exists, skippingEsos son de esperar. Una línea ERROR no lo es.
El sink que conecta no envía todos esos ALTER TABLE. Pregunta al catálogo qué
columnas tiene ya una tabla la primera vez que su proceso se encuentra con ella,
y añade solo las que le faltan. PostgreSQL toma el bloqueo exclusivo de un
ALTER TABLE antes de comprobar IF NOT EXISTS, así que uno por campo en cada
arranque esperaba a cada consulta de Grafana que leía la tabla y retenía todas
las que llegaban detrás. Medido contra PostgreSQL 18.6 con un lector que
mantenía una tabla abierta: CREATE TABLE IF NOT EXISTS respondió en menos de
un milisegundo, mientras que un ADD COLUMN IF NOT EXISTS de una columna que ya
existía esperó hasta que un lock_timeout de un segundo lo rechazó, y un
SELECT detrás de él esperó tres segundos. Una sentencia que el servidor
rechaza se envía otra vez en la escritura siguiente en vez de darse por hecha.
Una etiqueta es otra cosa, porque forma parte de la clave. Una vista por primera vez después de declarar la tabla, en el mismo fichero o en el mismo proceso, no puede unirse a la clave primaria sin reescribirla, así que se convierte en una columna normal. Eso solo ocurre cuando un colector cambia su conjunto de etiquetas entre pasadas.
Una tabla que creó una versión anterior es otro asunto, y una versión que cambia las etiquetas de una medida cambia lo que se carga en ella. Medido contra PostgreSQL 18.6 el 2026-09-27:
- Una etiqueta que añade una versión posterior no recibe columna de ninguno de
los dos destinos, así que cada
INSERTde esa medida se rechaza concolumn ... does not exist, y en el destino que conecta, con él las demás filas del mismo lote. - Una etiqueta que una versión posterior deja de escribir, como 2.6.1 dejó de
escribir
is_answerengh_discussion_comment, deja elON CONFLICTdel fichero nombrando una clave que la tabla no tiene. psql rechaza cada una de esas sentencias con “there is no unique or exclusion constraint matching the ON CONFLICT specification”, y el resto del fichero se carga. El destino que conecta lee del catálogo la clave propia de la tabla y choca contra esa, así que sigue escribiendo, y sus filas nuevas quedan junto a las viejas con la cadena vacía en la columna que ya no se escribe, las dos formas que guarda también InfluxDB.
La salida limpia de cualquiera de los dos casos es borrar la tabla y dejar que
las pasadas la vuelvan a llenar, y un relleno histórico lo anterior, que es lo
que hace -migrate -yes con los cambios que el binario conoce: mira más abajo.
Un colector con el destino que conecta sigue escribiendo tras el borrado. Su
primera escritura después se rechaza con relation ... does not exist, porque
el destino declara una tabla una vez por proceso; entonces olvida las tablas de
ese lote, las declara de nuevo y reenvía el lote una vez, lo que crea la tabla
de cero. Medido contra PostgreSQL 18.6: antes de esto, todas las
escrituras siguientes de la tabla, y el resto de su lote, se rechazaban hasta
reiniciar.
Cómo leer las tablas
enseña cómo reconstruir una clave a mano.
Qué hace aquí una migración
Sección titulada «Qué hace aquí una migración»Cuando una versión cambia aquello por lo que se identifican las filas de una
medida, -migrate pregunta a
la base de datos del destino que conecta si alguna fila tiene un valor en
la columna de la etiqueta antigua, en el esquema en el que escribe el destino,
y aplicar el cambio renombra esa única tabla en ese esquema, lo que conserva
todas sus filas, índices y claves:
ALTER TABLE "<esquema>"."gh_discussion_comment" RENAME TO "gh_discussion_comment-20261001T091004";El nombre es la medida, un guion y el instante en UTC, así que hay que ponerlo
entre comillas. El renombrado toma el mismo bloqueo exclusivo que un borrado,
que espera detrás de cada consulta de Grafana que lee la tabla, así que se
envía con un lock_timeout de 5 segundos, tres veces; una tabla que sigue
ocupada más tiempo deja el cambio pendiente con la razón del bloqueo. La
siguiente escritura del destino crea la tabla con el nombre antiguo y la clave
nueva, cuyo índice toma el nombre gh_discussion_comment_pkey1 junto al de la
copia. Medido contra PostgreSQL 18.6: el renombrado tardó 7 ms, no tocó
ningún otro esquema, y un destino que había escrito la tabla antes siguió
escribiendo después.
ghchronicle borra la copia cuando lleva 24 horas guardada: tras una pasada del
servicio, en el siguiente arranque de cualquier ejecución o en el siguiente
-migrate -yes, cada uno con su propio lock_timeout. -uninstall data la
lista con las demás tablas gh_. Hasta entonces, deshacer el cambio son dos
sentencias:
DROP TABLE "gh_discussion_comment";ALTER TABLE "gh_discussion_comment-20261001T091004" RENAME TO "gh_discussion_comment";Al fichero SQL no se le puede preguntar, así que decide el registro del fichero de estado de la versión que lo escribió primero, y aplicar el cambio escribe el borrado en el fichero, después de todo lo que ya tiene:
DROP TABLE IF EXISTS "gh_discussion_comment";Después el destino olvida la tabla, así que las siguientes filas de esa medida
la declaran de nuevo tras el borrado. Reproducido en orden, la base de datos
pierde la tabla con la forma antigua y gana la de la forma nueva. Medido con
psql contra PostgreSQL 18.6: sin el borrado, las filas nuevas se rechazan
contra la clave antigua; con él, el fichero carga entero. El borrado llega a
donde se reproduzca el fichero, donde no se aparta nada, así que un arranque
nunca lo aplica por su cuenta; -migrate -yes sí. Un fichero reproducido
solo, sin el que lleva el borrado, sigue encontrando la tabla antigua, y una
rotación que borra ese fichero antes de reproducirlo se lleva el borrado con
él.
Con path: "-" el borrado y las filas releídas van a la salida estándar, y
-migrate -yes imprime su plan y su informe en la salida de errores, así que
la salida estándar es solo el SQL y admite la misma tubería que -once:
ghchronicle -config config.yaml -migrate -yes | psql "$DATABASE_URL"Medido con psql contra PostgreSQL 18.6: por esa tubería, la tabla que hizo la 2.6.0 se borró y se volvió a crear con la clave nueva, con los comentarios releídos. Antes, el plan iba primero por el mismo flujo, psql leía su primera línea como el comienzo de una sentencia y perdía el borrado con ella, y el destino conservaba la tabla antigua mientras el fichero de estado registraba el cambio como aplicado. Ejecutado sin la tubería, el SQL va a la terminal y a ningún otro sitio, y el cambio igualmente se registra como aplicado.
TimescaleDB
Sección titulada «TimescaleDB»Convierte cada tabla en hypertable una vez exista. La clave primaria ya incluye
time, que es la única condición que le pone TimescaleDB.
SELECT create_hypertable('gh_traffic', 'time', if_not_exists => TRUE);SELECT create_hypertable('gh_workflow_run', 'time', if_not_exists => TRUE);Nada del dashboard cambia.
Cómo montarlo
Sección titulada «Cómo montarlo»-
Elige el destino. El que conecta necesita
sinks.postgres.dsny una base de datos a la que esta máquina llegue, y declara sus tablas en su primera escritura. El de fichero necesitasinks.sql.path, un fichero o-para la salida estándar. -
Con el destino de fichero, carga lo que escribió. El que conecta no tiene nada que cargar.
Ventana de terminal psql "$DATABASE_URL" -f /var/lib/ghchronicle/points.sqlO, para la disposición en flujo, ejecuta el colector con
-oncedesde un planificador y encadénalo directamente. -
Apunta el datasource de PostgreSQL de Grafana a la base de datos e importa
ghchronicle-postgres.json, o, con el destino que conecta, deja que-publish-dashboardcree el datasource a partir del DSN y publique el dashboard.SELECT time, "count" FROM gh_traffic WHERE kind = 'views' AND repo = $repo
dashboards/ghchronicle-postgres.json tiene los mismos 154 paneles que el de
InfluxDB, con cada consulta traducida a PostgreSQL contra este esquema.
La traducción compara el texto por sus bytes, como InfluxDB: cada columna de
texto por la que una consulta ordena, o de la que toma el menor o el mayor
valor, lleva COLLATE "C". Si se deja a la base de datos, el orden es el de su
intercalación, y en un PostgreSQL construido sobre glibc cuya base se creó con
una configuración regional como en_US.UTF-8 esa intercalación deja a un lado
mayúsculas y signos de puntuación en su primera pasada. Medido contra
PostgreSQL 18.6 en Debian, cuya base se crea como en_US.utf8: antes de
esto, “(ghost)” iba después de “alice” y “VALID” después de “unsigned”, y tres
paneles listaban sus filas en otro orden que el dashboard de InfluxDB. La
imagen Alpine coincidía solo porque musl compara bytes se llame como se llame
la configuración regional. En la base de datos no cambia nada: sus columnas
conservan la intercalación con que se crearon, y solo las consultas del
dashboard dicen cómo comparar.
Por dónde seguir
Sección titulada «Por dónde seguir»- Elegir almacén compara PostgreSQL con los demás, y lleva el registro de escrituras que todos comparten.
- Los dashboards dice cuál de los cinco se dibuja contra cada almacén, y en qué se convierte un panel que un almacén no puede responder.