Upgrades @clickhouse/client from 0.2.10 to 1.23.1 in the root package.json and packages/civitai-clickhouse. apps/event-engine was already on 1.x, so the workspace now holds one version of the driver. A version upgrade, not a fix for the ClickHouse socket hang-ups. The pre-upgrade rate was recorded before this change so the post-deploy rate can be compared. - ResultSet.json<T>() returns T[] in 1.x: 13 call sites, 3 in src/ and 10 in apps/moderator, which the root typecheck does not cover. - host -> url; keep_alive.socket_ttl + retry_on_expired_socket -> idle_socket_ttl. - Three 1.x default changes held at their 0.2.x values: max_open_connections Infinity, request_timeout 300000, response compression on. - keep_alive.eagerly_destroy_stale_sockets: true is a deliberate non-default, more permissive than 0.2.x's retry, standing in for it. It confounds the before/after comparison. - 1.x adds ~1ms per request (await sleep(0)); not configurable. - apps/moderator ships on its own release. - New src/server/clickhouse/__tests__/client-config-pins.test.ts pins the values. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
metric-event-watcher → event-engine (monorepo migration)
This directory is the metric-event-watcher service relocated into the civitai monorepo as
apps/event-engine (renamed — see below), at its clean committed HEAD — none of the in-progress
outbox / delete-side-effects work (the feat/image-delete-side-effects branch) is included.
- Worktree/branch:
feat/metric-event-watcher-monorepo(branched offmain). - Source:
metric-event-watcher@ HEAD viagit archive(so no uncommitted local changes came across). - event-engine-common: vendored under
src/common/at its clean submodule HEAD (49b0d4f) — no longer a git submodule here. Not yet extracted into a shared package (deferred — see below); the standaloneevent-engine-commonsubmodule/repo will be examined separately.
Renamed to event-engine
The app now reflects its broader scope (metrics + signals + CDC side effects), not just metrics. Only the
packaging/deploy-facing name was changed — dir apps/event-engine, package @civitai/event-engine, and the
Dockerfile filter/paths. Runtime identifiers were intentionally left unchanged for continuity:
- Kafka consumer group default
metric-event-watcher(src/config/index.ts,scripts/reset-consumer-offsets.ts) — so the cutover resumes from existing offsets rather than reprocessing. - Prometheus
applabelmetric-event-watcher+ themew_metric prefix (src/metrics.ts) — so existing dashboards/alerts keep working. (The dashboard that keys on these now lives in the ops repo; the legacyk8s/grafana-dashboard.jsoncopy has since been deleted — see thek8s/bullet under "What changed vs. the standalone repo".) - Kafka
clientId(src/index.ts) — cosmetic; left as-is.
Rename these later as a deliberate, separately-planned step (with an offset seed + dashboard update) if desired.
Approach: lift-and-shift (deliberately minimal)
The goal was a faithful move, not a rewrite. So the app is self-contained: its own dependencies, its own
tsc && tsc-alias (CommonJS) build, its own tsconfig.json (@/* path aliases), and EEC vendored in-tree.
It drops into apps/* as a pnpm workspace and is auto-discovered — no pnpm-workspace.yaml / turbo.json /
root changes were needed.
What changed vs. the standalone repo
package.json: name →@civitai/event-engine,"private": true; dropped repo-level scripts (sync:submodule,install:hooks,release*). Deps/build/tsconfig kept as-is.- Removed
.gitmodules(submodule retired → vendored) andpackage-lock.json(npm → the monorepo is pnpm). Dockerfilerewritten to the monorepo pnpm-deploy pattern — DRAFT, unverified. (This bullet said "see below"; no section describing that pattern was ever written. Read theDockerfileitself.).github/was kept as legacy reference only and has since been DELETED (2026-09-15), together withscripts/release.mjs— the standalone repo's release machinery, both members. The reason the workflow was safe to delete is worth keeping: GitHub reads workflows only from the repo-root.github/workflows/, so a nested copy is structurally never scheduled — verified against the Actions API, which listed zero workflows underapps/. (Quote that zero, not a count of root entries: the registry carries rows that do not correspond to the default branch's tree at all — both for workflow files since deleted and for files that only ever existed on a branch — so the root number it returns is larger than the tree's, and a re-verifier will not reproduce it. Measured 2026-09-15: 6 root rows against 4 tracked root workflow files.) 🔴scripts/release.mjswas the dangerous half and was not inert: 437 lines, wired to nopackage.jsonscript and referenced by nothing, but executable. Run asnode scripts/release.mjsfromapps/event-engineit would check outrelease, rebase it ontomain, bump this app's version, tag a barev<version>and pushrelease— an unreviewed production deploy of whatevermainheld, plus a tag in the root release namespace. This app's real release path ispnpm release:event-engine:*→scripts/release-app.mjs, which tagsevent-engine-v*. ⚠ The cwd qualifier is load-bearing and an earlier draft of this bullet omitted it::264rangit add package.jsonrelative to the process cwd while the version write resolved the app's own manifest, so invoked from the repo root it staged the untouched root manifest and the commit exited non-zero. It stopped there — after anensureOnMain()checkout and a version write to the app's manifest, but before the switch torelease— so it left a dirty, version-bumped manifest behind and reached no tag and no push. The blob was also100644, so./…would not run it. Deleting it was right either way; the overstatement is corrected here rather than left for the next reader to re-derive and disbelieve.docker-compose.ymlis NOT legacy and NOT inert: it is the live local-dev Kafka/Debezium harness, driven byREADME.md,scripts/produce-comic-event.tsandscripts/setup-digitalocean.ts. Do not delete it as legacy.k8s/was kept on the same terms and has since been DELETED (2026-09-14). The relocation this section called for has happened: the Kafka/Debezium manifests, the Kafka UI and the app's own Deployment all live in the ops repo now and are what actually deploys. The copies here deployed nothing and had begun to read as the live source. One file outside this one referenced them —docs/plans/ci-cd.md, a task prompt for the CI/CD work item 7 below now records as done, which named the manifest with a\separator; it was deleted in the same PR. A sweep for references of this kind must match both path separators — ak8s/grep alone returns a confident zero.docs/reference/release-script-example.jsanddocs/reference/service-deploy-workflow.ymlwere referenced only by that deleted task prompt and are now orphaned. Kept deliberately as examples of the pre-monorepo release shape; they should not be read as live. 🔴 "Not wired to anything" is NOT why the first one is safe — the.github/+release.mjsbullet earlier in this list says in terms that unwired-but-executable is exactly the dangerous shape, and this file is a 446-line near-copy of thescripts/release.mjsjust deleted, sharing its branch constants and itscreateGitTag/pushReleasepath. What actually stops it is narrower and worth stating, because nothing else records it:getCurrentVersion()resolves<its dir>/../package.json— adocs/parent, which holds none — so it throwsENOENTin the version read, before any write. That read sits after anensureOnMain()checkout but before the release branch is touched, so it cannot reach a tag or a push. Two consequences for whoever reads this next: do not cite "unreferenced" as the reason it is retained, and do not conclude from the bullet above that the runnable-release-script class was cleared from this app — one copy remains, deliberately, and its safety rests on a path that does not exist. ⚠ Retaining it is an open question, not a settled decision: the same criterion in the.github/+release.mjsbullet deleted a near-identical file. Either delete this one too, or keep it and this paragraph is why.
Outbox reconciliation poller (ported in on top of the lift-and-shift)
A background OutboxPoller was ported in — a backstop that drains Outbox rows the live CDC path never
processed (created pre-connector, during downtime, or CDC misses), with retry/park/re-drive. Files:
src/services/outbox-poller.ts, wiring in src/services/event-processor.ts (processOutboxRecord +
start/stop), OUTBOX_POLL_*/OUTBOX_MAX_ATTEMPTS config, outboxPollerMetrics (mew_outbox_*), and
scripts/redrive-outbox.ts (pnpm redrive:outbox).
- Single-active across autoscaled pods via a Postgres two-int advisory lock
(
pg_try_advisory_lock(25974, 1)) — a separate key space from the main app's single-bigintpg_advisory_xact_lock(articleId), so it can never collide. Self-healing on pod death;FOR UPDATE SKIP LOCKED+ cursor paging are additional safety. - DEFAULT ON (opt-out). It requires an
attemptsint column on"Outbox", added bypackages/civitai-db-schema/prisma/migrations/20260720120000_add_outbox_table/— which also models the pre-existing Outbox table +OutboxEntityenum inschema.full.prisma(they existed in the DB but were not in the Prisma schema). That migration has been applied, so the poller now runs by default; setOUTBOX_POLL_ENABLED=falseto disable. (Sequence was: apply migration →pnpm db:generateto regenerate the derived typesmodels.ts/kysely/*— NOT hand-edited here → poller on.)
Security
.env.example shipped real-looking production credentials in the source repo; they were scrubbed to
placeholders here. The underlying Postgres/ClickHouse/Redis credentials were committed upstream — rotate
them as an ops follow-up.
Required before it builds
Run pnpm install at the repo root once, so pnpm-lock.yaml includes this new app. Until then the
Docker --frozen-lockfile install and any pnpm --filter build will fail. After that,
pnpm --filter @civitai/event-engine typecheck && ... build should pass (it builds clean as a
standalone repo today).
Deferred follow-ups (not done here)
These are intentionally out of scope for the lift-and-shift; they turn it monorepo-native and are the
workstreams from docs/plans/monorepo-migration.md (in the watcher repo):
- Root lockfile —
pnpm installto register the app (prerequisite for everything below). - Adopt
@civitai/*packages — replace this app's own Redis/ClickHouse/Axiom clients + env with@civitai/redis,@civitai/clickhouse,@civitai/axiom(recommend keeping rawpgrather than@civitai/db). Currently it ships its own clients. - Convert build to tsup/ESM — the siblings (
apps/orchestrator-gateway,apps/notifications) bundle with tsup and run ESM. This app is stilltsc/CJS. Convert when adopting@civitai/*. - Retire the submodule properly — fold
event-engine-commoninto a sharedpackages/*workspace consumed by BOTH this app and the monolith, then delete the vendoredsrc/commonhere and the monolith's rootevent-engine-commonsubmodule. Note the two EEC copies are at different commits today (this app49b0d4f; the monolith submodule7a0c4b0) — reconcile before sharing. - ClickHouse client — both this app and the monorepo root are on
@clickhouse/client1.x now, so there is no version split left to reconcile. What remains is that this app builds its own client withcreateClientrather than going through@civitai/clickhouse, so it takes the library's defaults where the shared client pins them. - Meilisearch — keep this app's own client, or factor a
@civitai/meilisearchpackage. DevOps (Zach): add a Tekton tag-webhook trigger + aDONE — the app, the Kafka/Debezium infra and the Kafka UI are all deployed from the ops repo. The legacyrelease-app.mjs/release:event-engineentry + the k8s Deployment/HPA/secret (port from the legacyk8s/09-metric-watcher-app.yml). The Kafka/Debezium k8s infra (k8s/02-*,03-*) stays as separate infra regardless.k8s/copies that this item said to port from have been deleted. Still true and still the reason the runtime identifiers above were left alone: the app runs on the same Kafka consumer group, so the cutover did not reprocess or drop offsets.- Node 20 → 22 — the monorepo standard is Node 22; bump
@types/nodeand validate when convenient. - Optional rename — if this becomes the general events/signals/CDC app, rename the package/dir.