← Back to summary

Full technical write-up

Sluice

1 / 1

What it is

We built Sluice to extract patient and clinical records from an electronic health record database each night and load them into a data warehouse, so that reports and dashboards reflect current information every morning. It replaces an older process built in SSIS, a general-purpose Microsoft tool that was never designed for the particular demands of this job.

Why it mattered

The electronic health record system runs on a database technology called InterSystems IRIS, and at some point that database moved to run in the cloud rather than on premises. That change meant every single query now had to cross a VPN connection to reach it. IRIS also handles one query at a time reasonably well, but does not speed up much when several queries run at once the way a modern SQL Server database does.

The old job pulled each table down as a single query, one after another, over that same VPN link. The whole nightly load took as long as the slowest table allowed, with no real way to make it faster short of buying more bandwidth. That same shift made failures more expensive too, since resuming from a partial run had never been necessary back when the whole extraction ran quickly on premises. When it failed partway through, the usual fix was to rerun the entire job from the start, not just the piece that failed, and diagnosing what actually went wrong on a bad night often took longer than rerunning the job would have.

The idea behind the fix

Some database systems respond well to being asked several smaller questions in parallel rather than one large question at a time. IRIS is one of them. We designed Sluice to take each table, slice it into many smaller pieces, and ask for all the pieces at once instead of one after another. A table that used to download as one long sequential pull now downloads as, in some cases, hundreds of small pulls running side by side, each one crossing the VPN independently rather than waiting in line.

I wrote the requirements down before the first line of code, and kept them plain and practical:

Built like a set of locks and channels, on purpose

We built the codebase around a consistent naming theme drawn from civil engineering, the kind of vocabulary used to describe canals, locks, and water control structures. A single partition of a table download is called a Flume. The program that reads from the source database is the Intake. The program that writes into SQL Server is the Penstock, the term for a controlled pipe feeding a turbine. My idea was that anyone joining the project later could learn one clear vocabulary and understand both the software and the physical metaphor behind it at the same time. Some pieces of that original vocabulary were planned but never actually built, and we said so plainly in our own documentation rather than pretending the metaphor is more complete than it is.

More practically, I split the system into two separate running programs rather than one large one. The Orchestrator decides what needs to run and keeps a permanent record of every attempt. The Worker does the actual work of connecting to the source and pulling data, with one Worker process per table being downloaded. This split matters because the software driver used to talk to IRIS has, on occasion, crashed the entire program it runs inside, not just the one operation it was doing. With Workers and the Orchestrator as separate programs, a driver crash inside one Worker only takes down that one table’s download. The Orchestrator notices within a few seconds, cleans up, and restarts that table on its own. Nobody has to wake up and rerun anything by hand.

How the project actually grew, phase by phase

The first working version, which we built in early May, could do one thing: pull a table from IRIS and land it in the warehouse’s Bronze layer. Everything else came afterward, in a sequence that reflects what actually turned out to matter once real use began.

We added a web interface next, along with a way to monitor Worker processes while they ran. Then came Schema Drift, a feature for comparing what a table’s structure is supposed to be against what the live source database currently reports, since IRIS schemas can drift out of sync with what the warehouse expects without anyone noticing until a load fails. We redesigned Schema Drift more than once as the project matured, eventually scoping it narrowly to just that comparison rather than also trying to manage the warehouse table structure at the same time, since combining the two concerns had been creating confusing failures.

We followed that with a connection types and environment switching system, letting a table reference a logical role, such as one connection type for one part of the clinical system and a different one for another, rather than being pinned to one specific physical connection. I deliberately designed switching between environments, such as a live database and a sandbox copy, as a two-step confirmation with a full audit trail, rather than a single click, because column names and structure can differ between those environments and an accidental switch could make the system report false problems that were not real.

We added an index management screen with a built-in code editor so that performance-tuning scripts for the warehouse tables could be written and stored in one place, rather than run by hand outside the application. We built a priority-based scheduler to decide which tables should launch first when many are queued at once. And we added a full Performance Insights feature, then rebuilt it entirely, moving away from fixed rules of thumb about how many pieces to split a table into, toward a model based on actually measured run times from real history.

A scheduling design I proposed, argued against, and rejected

Not every idea I seriously considered made it into the final build, and one debate is worth describing because it shows the kind of judgment involved. The system needs to cap how many simultaneous connections to IRIS are allowed at once, shared across many Worker processes running independently. My original design used a simple shared counter to track this.

At one point, I worked out a more elaborate replacement in real detail: instead of a counter, track every live connection as its own row in a database table, each with an expiration time, so that if a Worker crashed without cleanly releasing its connection, the row would simply expire on its own and free up the slot automatically, healing itself without anyone noticing.

I rejected it, for reasons I wrote down plainly rather than glossing over. The proposed design would have added a database transaction before every single connection attempt, potentially hundreds of times a minute, which would have made a fast, simple operation into a much slower one. Worse, the expiration timer itself was a guess: set it too short, and a connection that was still legitimately in use could get treated as abandoned and have its slot handed to someone else, which is exactly the kind of bug the system exists to prevent. Set it long enough to avoid that risk, and the whole benefit of automatic healing disappears, since a genuinely crashed connection would then sit unclaimed for just as long as before. My existing simple counter, paired with the crash-detection system already built elsewhere, already recovered from a crash within ten to fifteen seconds in practice. The more complicated design was solving a problem I had already solved a simpler way, so I set it aside in favor of building a priority queue on top of the existing counter instead, which shipped and is the version running today.

Getting it fast enough to matter

A single concentrated stretch of work stands out as the point where the system became genuinely production-ready. Over two days, we ran a full audit of the system that turned up five defects severe enough to block launch, eight of lesser severity, and sixteen more minor issues, and we fixed and verified every one of them before that stretch of work ended. In the same push, we added a mode so the system could run against either the new warehouse or the older legacy database, depending on which one a given deployment needed, and we rewrote the connection scheduler so that both the number of simultaneous connections and the overall bandwidth budget were enforced together rather than as two separate, sometimes conflicting rules.

The result, measured against real validation runs, was a load that completed in 45 to 46 minutes with zero failures, down from roughly two hours under the old process. That is close to the two to three times speed-up I had hoped for in my original success criteria, and it came with something the old job never had: a session that fails partway now reports exactly which table failed and why, rather than leaving anyone to guess.

Handling a source database that behaves unpredictably

A meaningful share of our engineering effort went into resilience, not new features. IRIS connections can freeze without producing an error, appearing to be working while no data actually moves. We built a watchdog that runs inside each Worker and starts a stall timer only once the first row of data has actually arrived, so a query that is simply slow to start is not mistaken for a frozen one. If no progress happens for a set grace period, the watchdog kills that connection and lets the Orchestrator restart it clean.

There is also a subtler piece of resilience worth mentioning: when a database write and its confirmation get separated by a network hiccup, the system can end up unsure whether a write actually succeeded. Rather than risk writing the same data twice, or risk a fragile attempt to detect and delete a possible duplicate after the fact, we record a small marker as part of the very same transaction as the actual data write. On any retry, the code checks for that marker first. If it exists, the write already happened, and the retry reports success without repeating anything.

A real day inside the project

One entry from my own daily record gives a fair sense of what a serious day of this kind of work looks like. A long-planned review of the codebase came due, and it turned up dozens of individual findings across security, internal design, and a class of bug where two processes could interfere with each other’s work without either one crashing outright.

Two findings mattered most. The first was how the system should handle someone clicking a stop button in the middle of an active extraction, since simply telling the database to cancel was not safe and could leave a table in a partially written state. The second was that a small internal service, used by one part of the system to check the source database’s structure, turned out to be reachable over the network with no password of any kind, once we split the application and database servers onto separate machines. Neither was a small fix, and each required a real design decision.

For the stop button, I weighed a few approaches before settling on a database-driven flag that a running Worker checks on its own and shuts down cleanly when it sees it, rather than asking an outside process to guess what state a partial write was in. For the exposed internal service, we added a shared secret that both programs must present to each other before either will respond, on top of restricting which machines are even allowed to reach it at all. By the end of that one day, every item from the review had been closed, either fixed and confirmed working or found not to actually apply.

Timeline and effort

I worked on this across roughly 12 weeks. My own record, reconstructed from git commit history rather than logged in real time as I worked, shows 28 distinct working sessions in that period.

Technical reference

This section goes deeper than the narrative above, into the actual shape of the system, for anyone who wants to understand or rebuild something like it rather than just read about it.

Architecture

flowchart LR
    subgraph Source["Source system, across a VPN"]
        IRIS[("Clinical database")]
    end

    subgraph AppBox["Application server"]
        Web["Management UI<br/>Blazor Server"]
    end

    subgraph SqlBox["Database server"]
        Orch["Orchestrator<br/>Windows service"]
        W1["Worker<br/>one process per table"]
        CTL[("Control database")]
        TGT[("Target warehouse")]
    end

    IRIS ==>|"rows read, partition by partition"| W1
    W1 ==>|"bulk insert"| TGT
    W1 ==>|"attempt history written"| CTL
    CTL ==>|"pending work read each tick"| Orch
    Web ==>|"commands written as rows"| CTL

    Orch -.->|"spawns, monitors heartbeat, restarts"| W1

Reading the diagram. Thick arrows are data movement and point the way the data actually travels, so a read from the source points into the process doing the reading rather than out toward the thing being read. Dotted arrows are control, meaning one process starting or stopping another.

The one relationship worth noticing is that the management interface and the Orchestrator never talk to each other directly. Every command, a session launch, a kill request, a promotion, is written into the control database as a row or a flag, and the Orchestrator picks it up on its next tick, typically within a few seconds. That indirection is deliberate: either process can restart independently without losing track of what the other was doing.

How partitioning actually works

Each table’s JobTables row carries a PartitionMode of either Range or Modulo, a PartitionExpression, and a PartitionCount.

Range mode requires an indexed integer column. It divides that column’s value space into contiguous chunks, one per partition, and each Flume reads its assigned chunk. We prefer this mode when a suitable column exists, because it requires only one full pass over the data on the source side. It performs poorly on tables where the identifier column is skewed or has large gaps, since some partitions would end up covering far more rows than others.

Modulo mode is the fallback, and the default when no indexed integer column is viable. Every partition query wraps the base query with a generated clause of the form WHERE COALESCE((expression), 0) # count = partition, where # is IRIS’s own modulo operator and expression is typically a numeric cast of the table’s identifier column, for example cast(PATID as decimal(20,0)). Each partition number from zero up to PartitionCount - 1 gets its own query, and IRIS evaluates the modulo condition per row. This mode tolerates skewed or gapped identifiers at the cost of requiring IRIS to scan the full table once per partition rather than once overall.

FlumeBuilder.WrapInPartition is the pure function we wrote to generate this clause, and we kept it intentionally free of any side effects or dependencies, which makes it trivial to unit test in isolation from the rest of the pipeline.

What the Orchestrator actually does, tick by tick

The Orchestrator runs on a fixed tick, roughly every five seconds, and each tick performs several independent checks:

None of this relies on in-memory state that would be lost if the Orchestrator itself restarted. Every decision is derived fresh from what is currently in SluiceDB, which is what lets the Orchestrator crash and restart without losing track of an in-progress session.

The naming lexicon

A partial reference, the terms we actually implemented and that matter for understanding the codebase:

TermCivil engineering meaningWhat it is in Sluice
SluiceA structure that controls and splits water flowThe system name itself
HeadworksThe upstream control structure at a canal’s intakeIHeadworks / SqlHeadworks, resolves which table and connection to use
ChannelAn artificial waterway carrying flow from intake to outletOne Worker process, carrying one table’s data end to end
FlumeA narrow channel for conveying water at speedOne partition of one table’s download
IntakeWhere water enters the systemIIntake / OdbcIntake, the ODBC source reader
PenstockA controlled pipe feeding a turbineIPenstock / SqlPenstock, the SqlBulkCopy writer
WeirA low dam controlling flow rateThe component that throttles concurrent connections
SpillwayAn overflow channel for excess waterThe retry-with-backoff mechanism for a failed partition

Some planned terms from the same theme, SluiceGate, Raceway, Drawdown, Crest, were designs I sketched out but never implemented, and we keep them listed separately in our own documentation as a record of what was considered rather than pretending they exist in the running code.

The data model, in plain terms

The control database, SluiceDB, holds a small number of tables that between them describe everything about a run:

A table’s status as shown anywhere in the application, the dashboard, the monitor page, the run history, is read from one view we built that combines the session, the tracker, and the channel run state into a single answer, so that every screen agrees with every other screen about what actually happened.

Deployment topology

Every environment, development, test, and production, uses the same two-server split. One box runs IIS and the web application. A separate box runs every database plus the Orchestrator, running as a Windows Service, plus every Worker process the Orchestrator launches. The two communicate entirely through the database, never directly.

The system runs against exactly one target at a time, either the newer MedallionHub warehouse or the older legacy ODS database, chosen per environment and never mixed. The two modes use different table promotion mechanics under the hood, a schema-level rename in the legacy mode versus a schema transfer in the newer one, but we drive both with the same application code, treating the target as a parameter rather than two separate code paths.

Legacy schema mapping

The older system used a naming prefix convention that MedallionHub replaces with named schemas. This mapping matters for anyone tracing data from the old system into the new one:

Legacy prefixMedallionHub schemaRole
ZD_BronzeRaw data as landed, untransformed
ZP_Bronze stagingChange-capture staging
ZR_SilverValidated tables that reports read directly
ZO_ArchivePrevious Silver, kept for rollback
CVTGoldMaterialized aggregate outputs

Bugs we found and fixed along the way

We kept a running log of every real incident during development. Here is all of it, condensed to the essentials, grouped by the part of the system each one touches.

IRIS and ODBC quirks

Orchestrator, scheduling, and worker lifecycle

Data integrity and idempotency

Deployment and operations

Data modeling and query correctness

Design-level lessons

User interface bugs

Where it stands now

As of this writing, we have built, tested, and hardened Sluice, but it has not yet had its first real production night. We have a written cutover checklist covering exactly what remains: which database scripts need to run, which server the system will live on, and one remaining manual test of the stop button described above, since it is the one piece of logic we’ve reviewed thoroughly but not yet clicked for real.

Once it is live, Sluice will fully replace the old SSIS job. The validation numbers from that hardening push suggest the parallel extraction approach delivers on its original goal, and the resilience work we built alongside it gives us a level of visibility into failures, and a level of protection against a source database that does not always behave predictably, that the old job never had.