Skip to content

feat(clickhouse): add the ClickHouse destination package - #46

Open
gjtorikian wants to merge 7 commits into
mainfrom
feat/sync-clickhouse
Open

gjtorikian wants to merge 7 commits into
mainfrom
feat/sync-clickhouse

Conversation

@gjtorikian

Copy link
Copy Markdown
Collaborator

Summary

  • Adds @workos/sync-clickhouse, a ClickHouse destination with the same CLI, container image and Destination semantics as the Postgres and MySQL packages, aimed at what ClickHouse is for: keeping the authentication.* sign-in log and the event archive past the 90-day retention, with the identity objects mirrored beside them for the joins.
  • Mirror streams are ReplacingMergeTree(_updated_at) tables with _deleted_at tombstones and generated _live views (FINAL, tombstones hidden); append streams are plain MergeTree. Projected columns are DEFAULT expressions over _raw (JSONExtract(... 'Nullable(T)'), parseDateTime64BestEffortOrNull), with data-skipping indexes where the catalog asks for one.
  • The lease, cursors, schema fingerprints and column records live in KeeperMap tables, the one place ClickHouse offers a compare-and-swap: strict insert-if-absent and conditional ALTER UPDATE as CAS, verified on a local 26.10 and on ClickHouse Cloud 26.6. That is the source of the package's one extra requirement, a Keeper plus keeper_map_path_prefix, which the README states at the technology level alongside the single-node config CI uses.
  • Reconcile diffs inside the database through per-run staging tables; the quarantine store is a ReplacingMergeTree cleared with lightweight DELETE; redact uses lightweight DELETE across the mirror, the sign-in log and the archive.
  • Two ClickHouse-specific repairs the tests surfaced: a column added to an existing table is MATERIALIZEd so old parts stop re-evaluating the expression on every read, and init --repair follows MODIFY COLUMN with an UPDATE ... WHERE 1, since MODIFY COLUMN alone does not recompute stored values.
  • Ports --exclude-streams and the oversized-stream reporting from feat: Abandon oversized reconcile streams, add excludeStreams #45 into this CLI.

Testing

164 tests run against a clickhouse/clickhouse-server:26.10 container started with the config in test/clickhouse/ (embedded Keeper, keeper_map_path_prefix); the pure tests run without one and the rest skip. CI gains a ClickHouse job, the Docker workflow gains the clickhouse matrix entry, and release-please gains the package. The image builds and streams runs from it.

Mirror streams are ReplacingMergeTree tables with tombstones and _live
views; append streams are plain MergeTree. The lease, cursors and schema
bookkeeping live in KeeperMap tables, the one compare-and-swap ClickHouse
offers, so a Keeper and keeper_map_path_prefix are required. Projected
columns are DEFAULT expressions over _raw, materialized when added to an
existing table and recomputed on repair.

Wires the package into CI (a ClickHouse container with an embedded
Keeper), the Docker matrix, release-please and the READMEs.
@greptile-apps

greptile-apps Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[High impact] The new destination can persist WorkOS identity and authentication history in ClickHouse.

The PR still needs a write lock that remains effective while an insert is in flight.

Findings

  1. P1 Concurrent writes undo deletions ▶
Fix with agent prompt
### Issue 1
packages/sync-clickhouse/src/db/upsert.ts:420-424
Backfill and refresh run alongside the tail without taking its lease. If both writers read a live row at T1, refresh can prepare a live version at T2 while `deleteRows` prepares a tombstone still versioned at T1. `ReplacingMergeTree` keeps the live T2 row, even if the delete finishes last. The deleted object remains visible.

The same read-before-write gap can lose fields when an event merges against an older snapshot. Serialize writes for each key across processes, or use a write model that preserves deletes and merged fields under overlap.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds @workos/sync-clickhouse with a CLI, database destination, tests, and release wiring.

  • ClickHouse keeps the latest WorkOS records beside long-lived event logs.
  • ClickHouse keeps sync progress and writer ownership in KeeperMap.
  • Reconciliation compares ClickHouse rows with WorkOS pages in the database.
  • Rejected events stay available for operators to inspect.
  • Operators can run the ClickHouse sync from a new package.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  API[WorkOS API] --> Engine[SyncEngine]
  Engine --> Lock[KeeperMap write lock]
  Lock --> Read[Read current rows]
  Read --> Decide[Choose next row state]
  Decide --> Write[Insert next versions]
  Write --> Tables[ReplacingMergeTree tables]
  Tables --> Views[FINAL reads and live views]
  Engine --> State[KeeperMap lease and cursors]
Loading

Reviews (5) · Last reviewed commit: "fix(clickhouse): re-create live views af..." · Reviewed by Greptile

Comment on lines +355 to +359
return {
key: row.key,
raw: merge(stored.raw, row.data),
updatedAt: stored.updatedAt,
deletedAt: later(stored.deletedAt, row.updatedAt),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concurrent writes undo deletions

Backfill and refresh run alongside the tail without taking its lease. If both writers read a live row at T1, refresh can prepare a live version at T2 while deleteRows prepares a tombstone still versioned at T1. ReplacingMergeTree keeps the live T2 row, even if the delete finishes last. The deleted object remains visible.

The same read-before-write gap can lose fields when an event merges against an older snapshot. Serialize writes for each key across processes, or use a write model that preserves deletes and merged fields under overlap.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/sync-clickhouse/src/db/upsert.ts
Line: 355-359

Comment:
**Concurrent writes undo deletions**

Backfill and refresh run alongside the tail without taking its lease. If both writers read a live row at T1, refresh can prepare a live version at T2 while `deleteRows` prepares a tombstone still versioned at T1. `ReplacingMergeTree` keeps the live T2 row, even if the delete finishes last. The deleted object remains visible.

The same read-before-write gap can lose fields when an event merges against an older snapshot. Serialize writes for each key across processes, or use a write model that preserves deletes and merged fields under overlap.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread release-please-config.json Resolved
…ckpoint races

Every mirror write is read-decide-write, and ClickHouse has no row lock
to serialize it, so two writers on one key (the tail beside a backfill,
or a poll outliving its lease) judged against the state before the
other's insert landed: a merge could drop fields, a tombstone could lose
a version tie, an append-only event could land twice. Each batch now
holds a per-stream lock in a `_locks` KeeperMap table: strict-mode
INSERT to take it, DELETE to release, conditional UPDATE to take over
one a crashed process left behind once it expires. A lock wait that
runs out is a transient error, so the engine retries rather than
quarantines.

`migrate` left a view alone whenever it had every column; a foreign
definition with every column now goes through `applyLiveView` and is
refused, or replaced under `--repair`. A checkpoint that Keeper refused
over a version race (the holder's own renewal is enough) re-reads and
retries instead of reporting a moved cursor and replaying the poll.

Also: release-please calls the publish workflow for this package, and
the test server config loses the `--` XML forbids inside a comment, which
kept CI's ClickHouse from starting.
…ones at deletion time

A batch that stalled past the lock's TTL could still insert rows judged
against state another writer has since changed. It now checks it still
holds the lock right before writing and fails with LOCK_LOST, a transient
error the engine retries from a fresh read.

An object delete's tombstone takes the deletion time as its version
instead of the row's last update time, so it outranks any older snapshot
on the version itself rather than on which insert landed last. The
deletion came after anything a listing could still show of the row, so
nothing legitimate is outranked.
# Conflicts:
#	.github/workflows/release-please.yml
… out-of-range offsets

The same three fixes Postgres (#49) and MySQL (#50) got. npm installs the
bin as a symlink named after the command, which the old argv check did
not recognise, so `npx workos-sync-clickhouse` imported the CLI and did
nothing. Duplicate keys in a batch are now judged by the instant each
timestamp names rather than by its text, so `01:00:00+01:00` no longer
outranks `00:30:00Z`. And the timestamp projection range-checks the
clock and the offset ahead of the best-effort parser, which read
`24:00` as the next midnight and `+00:99` as a zone where the other
destinations project NULL.
…ate append rows

`init --repair` redefines a column, and its `_live` view keeps the type it
declared when it was created: a query through the view then returns the
old type or fails to convert. The views of the streams a repair touched
are now re-created after it, and the check that leaves an up-to-date
view alone compares column types as well as names.

Append-only tables are a ReplacingMergeTree keyed by the event id, so a
second copy of an event collapses at the next merge, and under FINAL
before then. The write path's existence check under the stream lock
still keeps copies out in the first place; the table is the backstop
for a poll that outlived its lease.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant