feat: multi-cell support — trigger once, run in every cell - #28
Open
rogercampos wants to merge 3 commits into
Open
feat: multi-cell support — trigger once, run in every cell#28rogercampos wants to merge 3 commits into
rogercampos wants to merge 3 commits into
Conversation
rogercampos
force-pushed
the
multi-cell-support
branch
from
July 31, 2026 14:41
3452776 to
7c46dd4
Compare
DataDrip can now coordinate the same backfill or script across several
independent deployments ("cells"). The cell whose UI created the run is
the coordinator: it saves its own local run, records one CellDispatch
per target cell, and a background job delivers each one to that cell's
new machine-to-machine Cell API, where an independent copy of the run
is created and executes on that cell's own queue against its own data.
Everything is opt-in: with DataDrip.current_cell_id / cell_transport
unset (the defaults), behavior is exactly as before. The new columns
are nullable and the Cell API rejects all requests until tokens are
configured, so hosts can upgrade and migrate with zero change.
- Config: current_cell_id, cell_ids, cell_transport, cell_api_tokens,
cell_ui_url (values or callables), plus a built-in HTTPS transport
(DataDrip::CellTransport::Http) and client (DataDrip::CellClient).
- Cell API: separate engine (DataDrip::CellApi::Engine) so hosts can
mount it outside their staff/admin gate; bearer-token auth with
constant-time compare, target-cell echo check (421 on misrouting),
idempotent creation per (group_uuid, cell_id) backed by a unique
index, group status snapshots, and stop / retry / delete endpoints
that re-apply the owner-only and history-preservation rules.
- Dispatch: GroupCreator freezes the payload per dispatch;
CellDispatcherJob marks failures visibly (422 waits for a human via
"Retry dispatch"; 5xx and network errors also raise so the queue
retries).
- UI: "Where to run" targeting on the new-run forms, origin/cell badges
in the lists, per-cell cards on the show pages with live status,
progress, errors and script output fetched from each cell on every
poll (unreachable cells render as such and never block), fan-out of
stop/delete, and per-cell retry of failed batches.
- Identity: runs snapshot backfiller_name (now also on script runs);
remote cells store the coordinator's backfiller_id verbatim without
requiring a local record (ids are assumed globally unique).
- Schema: group_uuid/cell_id/origin/origin_cell_id on both run tables,
data_drip_cell_dispatches, a data_drip:add_multi_cell upgrade
generator, and updated install templates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rogercampos
force-pushed
the
multi-cell-support
branch
from
September 7, 2026 08:59
7c46dd4 to
0913073
Compare
… a leg Review follow-ups on the multi-cell feature. Six of these are correctness or data-safety fixes; the rest bound the cost of the coordinator's UI. Report the group's status, not the coordinator's. A run's status row only ever described what happened in the cell that created it, so a group whose remote leg failed still read "completed" in the lists and the page header. The new MultiCellGroup owns the cross-cell questions — worst-of status, whether anything may still change, refreshing the legs — and the views ask it instead of the run. Never delete a run whose legs have not answered. `destroy` reported unreachable cells in a flash message and then destroyed the run and its dispatch records anyway, leaving a run enqueued in another cell with nothing anywhere able to see or stop it. Deletion now requires every cell to acknowledge; a leg that already ran (409) counts, since it will not run again either way. Let the executing cell manage a fanned-in run. Backfiller ids are cell-scoped, so a `remote` run matches no local backfiller and the ownership check made it unstoppable from the only cell that can actually stop it — with no break-glass if the coordinator is down. `manageable_by?` allows any operator in that cell, and leaves ownership deciding for local runs. Make the run and its dispatch records one fact. GroupCreator saved the run (and fired its `after_commit :enqueue`) before any dispatch row existed, so a crash in between left a run executing here with no trace that other cells were meant to run it. Both now commit together, and the jobs are enqueued after commit. Fan out concurrently against a shared deadline. Stop and delete applied to each cell in sequence with no deadline at all: at the preproduction topology one unreachable cell could hold a web request for minutes. The new CellFanout runs both the status reads and the actions on a bounded pool under one deadline, and the transport's default timeouts moved next to that deadline so work the fan-out abandoned is not left parked on a dead socket. Stop re-fetching what cannot change, and stop shipping whole logs. Each leg's last snapshot is cached on its dispatch row: settled legs are never polled again, an unreachable cell keeps rendering its last known state flagged as stale, and a finished group stays readable after its cells are decommissioned. Snapshots carry a 4KB tail of a script's log rather than up to a megabyte per cell per poll, and the run lists load every group's dispatches in one query instead of two per row. Also: refresh a dispatch's error message on later attempts instead of keeping the first one; scope every Cell API mutation to this cell's own fanned-in leg, so a valid token cannot reach a run created in this cell's UI; log auth rejections and cell-to-cell mutations; require ownership to retry batches, as stop, delete and the Cell API already did; and stop the pollers overlapping — `updates` can now take as long as a fan-out deadline, which a fixed interval turned into a pile of concurrent requests. Generated migration versions are real timestamps again: `strftime(...).to_i + n` rolls seconds past 59 (…120059 + 3 => …120062, not a datetime). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rogercampos
force-pushed
the
multi-cell-support
branch
from
September 7, 2026 09:56
263b853 to
d5e5353
Compare
Follow-up audit for a public gem: nothing here may assume Factorial's deployment, and the coordinator's pages must be safe for any host to serve. Rendering a page no longer queries other cells. Each dispatch record caches the last status its cell reported, the pages render from that cache, and a new CellStatusRefreshJob catches up whatever has gone stale. The previous version refreshed inline, which wrote to the database while serving a GET — hosts routinely route reads to a replica with writes forbidden, so that would have raised there. It also means a page returns immediately instead of waiting on a fan-out, and polling costs a cached read. Several viewers of the same run no longer each enqueue the same fan-out: one request claims the refresh per freshness window through Rails.cache, chosen over a database flag precisely because this runs on a read path. Without a shared cache store it just stops deduplicating. `manageable_by?` no longer reasons from cell-scoped ids. It asks whether the run has an owner *here* at all: a fanned-in run whose backfiller does not resolve locally is manageable by any operator who can reach the UI, and ownership still decides whenever there is an owner to ask about. Same outcome where ids are not reused across cells, and no blanket grant where they are. A status a cell reports that this version does not recognise (a cell on a newer release) now ranks just below an outright failure instead of defaulting to "completed" severity — version skew between cells must never make a group look finished. The tunables became settings rather than constants, following the gem's existing mattr_accessor convention, since 8 cells and a 5s deadline are guesses about somebody else's deployment: cell_fanout_concurrency, cell_fanout_deadline, cell_status_refresh_interval, script_output_tail_bytes. README gains a Requirements section stating what the gem asks of a host — chiefly that backfiller ids are not reused between cells, which the whole fan-out relies on and which was previously buried in a code comment — plus how refreshing works, the transport's error contract, and the delete and local-management rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rogercampos
marked this pull request as ready for review
September 7, 2026 10:29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
DataDrip can now coordinate the same backfill or script across several independent deployments ("cells"): a developer triggers it once, from any cell's UI, and DataDrip creates and executes an independent copy of the run in every cell — with the UI honestly reflecting that results differ per cell.
Fully opt-in and backwards compatible: with
DataDrip.current_cell_id/cell_transportunset (the defaults), behavior is exactly as today. New columns are nullable, the new table starts empty, and the Cell API rejects everything until tokens are configured — hosts can bump the gem and run the migrations with zero behavior change, then enable multi-cell purely via configuration.How it works
group_uuid; creation is idempotent per(group_uuid, cell_id)(unique index + find-or-return), so dispatch retries and duplicate deliveries are safe.GET(hosts commonly route reads to a replica where writing is forbidden).Requirements it asks of a host
Both hold in most cell architectures, and the README now states them up front:
AUTO_INCREMENToffsets, UUIDs or snowflake ids satisfy this; a shared sequence per cell does not. The record itself only has to exist in the coordinator's cell — others display the name snapshot taken at creation.DataDrip.queue_name.Changes
Config & transport (
lib/)DataDrip.current_cell_id,cell_ids,cell_transport,cell_api_tokens,cell_ui_url— plain values or callables;DataDrip.multi_cell?gates every new code path.cell_fanout_concurrency,cell_fanout_deadline,cell_status_refresh_interval,script_output_tail_bytes.DataDrip::CellTransport::Http— dependency-free HTTPS/JSON transport (configurableurl/query/headers); any object with the samecallcontract can replace it. Its error contract:CellTransport::Errormeans "this cell is unusable", covering unreachable and "not in my registry" / "I refuse to send a secret over plaintext" — errors raised by a host'surlcallable are converted, so a misconfigured cell is a retriable dispatch failure rather than a crash.DataDrip::CellClient— knows the Cell API paths and always sends the intendedtarget_cell_id.DataDrip::CellFanout— the one way anything talks to several cells: a bounded pool under a single shared deadline, so one hung cell costs the caller its deadline once rather than once per cell. Used by both the status refresh and the stop/delete fan-out.Cell API (second engine:
DataDrip::CellApi::Engine)421 Misdirected Requestif the routing layer delivered to the wrong cell.POST /v1/backfill_runs&/v1/script_runs(idempotent create),GET /v1/groups/:group_uuid(status snapshot), stop / retry_failed_batches / delete re-applying the existing owner-only and history-preservation rules.origin: :remote, thiscell_id): a valid token cannot reach a run somebody created in this cell's own UI. Rejected auth and every mutation leave a structured log line — a stopped or deleted run should have a local explanation.Group state
DataDrip::MultiCellGroupowns everything that spans cells. A run row only ever describes its own cell, so asking it alone reported a group as "completed" while another cell was still working or had failed outright; the lists and page headers now show the group's worst-of status. A dispatch that never landed counts as a failure of the group; a cell never reached leaves the group unfinished rather than lowering its status; a status this version does not recognise (a cell on a newer release) is surfaced as-is and never read as finished.Rails.cache(deliberately not a DB flag — this is a read path). No shared cache simply means no deduplication.Dispatch
GroupCreatorcommits the local run and its dispatch rows in one transaction, then enqueues. A run executing here with no record that other cells were meant to run it is worse than no run at all, and the run'safter_commit :enqueuenow fires only once the dispatches are durable.UI
N cellsbadge on coordinator runs,from <cell>badge on fanned-in runs.script_output_tail_bytes, 4KB) rather than up toScriptRun::OUTPUT_LIMITper cell per refresh, with a link into the owning cell for the rest.Identity
backfiller_namesnapshot now also on script runs; remote cells store the coordinator'sbackfiller_idverbatim without requiring a local record —belongs_toisoptionalwith a local-existence validation for local-origin runs only.Schema & generators
group_uuid/cell_id/origin/origin_cell_idon both run tables (+ unique(group_uuid, cell_id)), newdata_drip_cell_dispatchestable carrying each leg's cached state.rails generate data_drip:add_multi_cellfor existing installs; install templates updated for fresh ones.strftime(...).to_i + nrolls seconds past 59 (…120059 + 3→…120062, not a datetime). Fixed for all four generated migrations, not only the new one.Testing
CellFanout(shared deadline, real concurrency, the configured cap, falsy-vs-failure),MultiCellGroup(worst-of ordering, unknown statuses, settled caching, unreachable retention, single-query preload), the dispatcher and refresh jobs,GroupCreatorincluding transactional rollback, and controller specs for targeting, per-cell cards, refresh requests, retry-dispatch, and stop/delete fan-out.rake data_drip:css_checkgreen.Review notes
The commits are meant to be read in order: the feature, then a round of review fixes, then a host-agnosticism pass. The middle and last commits carry their reasoning in their messages, including two things worth a second opinion — treating an unacknowledged delete as a refusal, and letting any operator manage a fanned-in run.
🤖 Generated with Claude Code