Custom ETL services

No DAG, no manifest, and still a pipeline

A service that consumes a queue, batches, parses, maps and bulk-inserts, or a scheduled job that extracts, stages and loads. Its behaviour is spread across code, config files and a scheduler, and no platform underneath it describes the graph. This page covers how to investigate one by hand, and how Decim does it.

Why there is no platform to ask

Every data observability product assumes a platform underneath it. Bespoke services have none, and that is not an oversight — it is the definition of the category.

Monte Carlo, Bigeye, Metaplane and the rest attach to a warehouse and watch tables. Airflow's UI knows about tasks because Airflow scheduled them. dbt can test a model because dbt built it. In every case the tool has a privileged position: it either ran the thing or it owns the store the thing wrote to.

A hand-written loader offers no such foothold. There is no scheduler API to query, no manifest describing the graph, no lineage metadata emitted anywhere. The service is a process. It reads from somewhere, writes to somewhere, and the only durable trace it leaves is whatever the author happened to log — which is usually enough to reconstruct what happened, and almost never arranged so that anyone can.

This matters more than it sounds, because custom pipelines are where specific business logic lives: the partner who sends fixed-width files, the till system that speaks its own protocol, the reconciliation that needs more than SQL can express. Teams write them because no product does the job, and they are frequently close to revenue.

The reader may not be the author

That changes what a diagnosis has to do. It is not enough to be correct; it has to be demonstrable to someone on call who did not write this code and does not know which of its behaviours are deliberate. A claim like "the mapping is stale" is useless without the file, the commit that changed it, and the log lines from the rows it failed on. That constraint is why every conclusion Decim reaches carries its evidence with it.

Anatomy

The anatomy of a bespoke loader

Six shapes account for most of what these services do, and each has a characteristic way of losing rows quietly.

The queue consumer

A listener pulls messages and accumulates them until one of two conditions trips: a batch size or an elapsed time. That pair is the single most important thing to know about a continuously running pipeline, because it defines what a "run" even is. There is no daily job to point at — the batch id is the run, and its counts are the reconciliation. Anyone asking "did last night's load work?" is asking a question the system cannot answer in those terms.

Spool files and handoffs

Memory-mapped files, staging directories, a table called something like Staging_Incoming. These exist because someone needed to decouple two stages, and they are invisible to every diagram because they were never a design decision — they were a fix. When rows disappear between two stages that both report success, a handoff buffer is the first place to look.

The mapper

Lookup from an external code to an internal identifier. This is where the quiet failures concentrate, because the sensible-looking code is also the dangerous code: an unknown key is skipped and logged rather than thrown, so the batch completes and the run is green.

The bulk insert

SqlBulkCopy or its equivalent, with a batch size of its own that is unrelated to the intake batch size. The pattern to watch for is the recovery path: the batch fails, the code catches it and retries row-by-row so that one bad row cannot block the other 4,999, and each individual failure is logged and dropped. It is a reasonable design. It is also a silent data-loss machine the moment nobody reads the log.

Retry and dead letters

A retry worker, an error table, and a path back into the parser. Rarely drawn, frequently the cause. The failure mode is circular: rows that will never succeed cycle indefinitely, and because each attempt looks like normal activity, throughput metrics stay flat while real progress is zero.

Several workloads in one process

Covered in its own section below, because it is the one that most reliably produces a misdiagnosis.

Definition sites

Where the pipeline is actually defined

The instinct is to read the repository. The repository holds perhaps half of what decides how rows move.

This is the same problem that makes SQL Server and SSIS hard to reason about, arriving from a different direction. There, the definition sits in msdb and SSISDB. Here it is scattered across a config file, a control table, a scheduler entry and a compiled binary.

What decides behaviour, and whether source control knows about it
What Where it lives In git? Note
Batch size, parallelism, timeoutsappsettings.json per environmentSometimesRelease artefact or config server, not always the repo
Which table a row lands inMapping file or a control table in the databaseRarelyEdited in production more often than anyone admits
Transformation logicCompiled C#/Java/Python — plus stored proceduresPartlyThe proc has no commit history
ScheduleCron expression, Quartz, Hangfire, or systemd timerSometimesOften changed on the host during an incident
Which queues and tables are liveRuntime onlyNoVisible only at runtime, in logs and database state
Retry and dead-letter routingCode, plus whatever the operator did manuallyPartlyRequeues frequently leave no trace at all

Config is the pipeline

When a JSON file decides which table a row lands in, that file is not configuration. It is the routing layer, deployed without review.

A representative appsettings.json for a queue-driven loader. Almost every number here changes the shape of a run, and none of them are visible to a monitor watching for errors.

appsettings.Production.json json
{
  "Intake": {
    "QueueName": "pos-transactions",
    "BatchSize": 500,
    "MaxBatchWaitMs": 2000,
    "PrefetchCount": 1000,
    "MaxDegreeOfParallelism": 8
  },
  "Load": {
    "ConnectionString": "Server=sql-prod-02;Database=Sales;Max Pool Size=100;Connect Timeout=15",
    "BulkCopyBatchSize": 5000,
    "BulkCopyTimeoutSeconds": 120,
    "RetryRowByRowOnFailure": true
  },
  "Sync": {
    "Enabled": true,
    "CronExpression": "0 2 * * *",
    "FullReload": true
  },
  "Mapping": {
    "Source": "File",
    "Path": "config/tender-map.json"
  }
}

Read it as a set of coupled decisions rather than a list of settings. BatchSize: 500 with MaxBatchWaitMs: 2000 means a quiet period produces small batches and a busy one produces large ones — so batch counts are not comparable across the day, and an average is meaningless. MaxDegreeOfParallelism: 8 against Max Pool Size=100 is fine in isolation and stops being fine the moment a second workload wants connections from the same pool.

RetryRowByRowOnFailure: true is the setting that converts a loud failure into a quiet one. And Mapping.Source: "File" is the detail that decides your whole investigation: if the mapping is a file, the deployed copy can differ from the one in the repository and from a control table in the database that somebody added later. All three can disagree, and only one of them is actually being used.

The mapper that fails politely

Here is the code that turns a missing mapping into a successful run. It is not badly written — it is the obvious thing to write, and something close to it exists in most of these services.

TransactionMapper.cs — the reject path csharp
foreach (var tx in batch)
{
    if (!_tenderMap.TryGetValue(tx.PaymentType, out var tenderId))
    {
        _rejects.Add(new RejectedTransaction(tx, "unmapped PaymentType"));
        _log.Warning("unmapped PaymentType {PaymentType} batch {BatchId}",
                     tx.PaymentType, batchId);
        continue;                       // the batch still completes normally
    }

    mapped.Add(tx.WithTender(tenderId));
}

await _bulk.WriteAsync(mapped, ct);     // writes only what survived the loop

_log.Information("batch {BatchId} loaded {Loaded} of {Received}",
                 batchId, mapped.Count, batch.Count);

Note what is and is not there. The rows are not lost — they are in RejectedTransactions, with a reason. The counts are not missing — the final log line carries both Loaded and Received. Every fact needed to detect this is already being recorded.

What is missing is anyone comparing the two numbers. The service does not, because it has no opinion about what the ratio should be. The monitor does not, because it is watching for exceptions and there were none. That gap — evidence present, comparison absent — is the characteristic failure of this whole category, and it is why detection tools and investigation are different problems.

Contention

One process, several workloads

A service that looks like one pipeline is usually four, running on different clocks and competing for the same resources.

A typical bespoke service accumulates responsibilities: continuous intake from a queue, a periodic aggregation, a nightly full sync with an upstream system, and some housekeeping. Each arrived in a different quarter, for a different reason, and they share a process, a connection pool and a thread pool.

They do not share a clock. The intake runs constantly; the sync runs at 02:00. And a full reload holding connections for eleven minutes will starve the loader that needs them continuously.

Application log — a stall with a schedule (illustrative) log
01:59:58 INF batch 88412 loaded 500 of 500 in 412ms
02:00:01 INF sync started full=true
02:00:04 WRN intake queue depth 1,240 (threshold 1,000)
02:03:52 WRN intake queue depth 9,880 (threshold 1,000)
02:07:19 ERR Timeout expired. The timeout period elapsed prior to obtaining a
             connection from the pool. Max Pool Size=100
02:07:19 INF batch 88413 loaded 0 of 500 in 15,004ms
02:11:36 INF sync completed rows=2,410,882 duration=00:11:35
02:11:37 INF batch 88414 loaded 500 of 500 in 398ms

Read from the top, this is unambiguous. The sync starts at 02:00, queue depth climbs, the loader can no longer get a connection from the pool, and batch 88413 loads 0 of 500. When the sync finishes at 02:11 the next batch succeeds normally and every metric returns to baseline.

Nothing here alerts. The service never crashed, the pool timeout was caught and logged, and by the time anyone looks at a dashboard the graph is flat again. The only durable evidence is 500 rows that were received and never loaded — and the log line that says so, four minutes deep in a file nobody opens.

Why this misdiagnoses so reliably

An investigation that does not know about the second workload has to explain a stall with only the loader's own behaviour available, so it reaches for the plausible: a slow upstream, a bad batch, network flap. Each of those is consistent with the evidence in front of it. All of them are wrong, and each sends someone to instrument the wrong component.

The tell is periodicity. A cause that recurs at exactly the same minute is a schedule, and schedules belong to something else. This is why Decim's discovery defines a pipeline by its trigger rather than by its process: a service with a queue consumer and a recurring 02:00 sync job is discovered as two pipelines in the same repository, each with its own steps and code lines. The 02:00 sync appears as a pipeline of its own, with its schedule and the code behind it, instead of hiding inside the loader.

Diagnosing a partial load by hand

The order an investigation should follow, by hand or in Decim. Worth knowing regardless: if these queries return nothing useful on your system, that itself tells you which evidence you are missing.

  1. 1

    Establish what arrived

    Before anything else, find the number of rows the service believes it received. If there is an intake or batch table, that is your denominator. If there is not — and often there is not — the only witness is a log line, which is why the log's retention window silently becomes your investigation window.

    Serilog / NLog — the line that carries both numbers
    batch 88413 loaded 0 of 500 in 15,004ms
  2. 2

    Reconcile received against loaded and rejected

    The arithmetic is the whole diagnosis: loaded + rejected = received, or rows went somewhere nobody has written down. Run this across a day of batches rather than the one that was reported — a slow leak looks like nothing in a single run.

    Reconciliation by batch
    -- Reconcile intake against load and rejects, one batch at a time.
    -- A batch is only complete when loaded + rejected = received. Anything else
    -- is a row that entered the service and left no record of where it went.
    SELECT
        b.BatchId,
        b.ReceivedAtUtc,
        b.ReceivedCount,
        COUNT(DISTINCT t.TransactionId)         AS LoadedCount,
        COUNT(DISTINCT r.RejectedTransactionId) AS RejectedCount,
        b.ReceivedCount
          - COUNT(DISTINCT t.TransactionId)
          - COUNT(DISTINCT r.RejectedTransactionId) AS Unaccounted
    FROM dbo.IntakeBatch                AS b
    LEFT JOIN dbo.[Transaction]         AS t ON t.BatchId = b.BatchId
    LEFT JOIN dbo.RejectedTransactions  AS r ON r.BatchId = b.BatchId
    WHERE b.ReceivedAtUtc >= DATEADD(day, -1, SYSUTCDATETIME())
    GROUP BY b.BatchId, b.ReceivedAtUtc, b.ReceivedCount
    HAVING b.ReceivedCount
           - COUNT(DISTINCT t.TransactionId)
           - COUNT(DISTINCT r.RejectedTransactionId) <> 0
    ORDER BY b.ReceivedAtUtc DESC;
  3. 3

    Group the rejects by reason and hour

    A reject table with a steady trickle is normal. A reason that was rare last week and dominates today is a mapping or upstream-format change, and the first hour it appears is the interval to correlate against deployments.

    Reject profile over two weeks
    -- If there is no IntakeBatch table, the reject table is the next best
    -- witness. Group by reason and hour: a silent mapping failure shows up as a
    -- reason that was rare last week and dominant today.
    SELECT
        CONVERT(date, RejectedAtUtc)                AS [day],
        DATEPART(hour, RejectedAtUtc)               AS [hour],
        Reason,
        COUNT(*)                                    AS Rows,
        MIN(RejectedAtUtc)                          AS FirstSeen,
        MAX(RejectedAtUtc)                          AS LastSeen
    FROM dbo.RejectedTransactions
    WHERE RejectedAtUtc >= DATEADD(day, -14, SYSUTCDATETIME())
    GROUP BY CONVERT(date, RejectedAtUtc), DATEPART(hour, RejectedAtUtc), Reason
    ORDER BY [day] DESC, Rows DESC;
  4. 4

    Check what else was running

    A bespoke service almost never does one thing. If the stall has a clock — the same minutes every night — the cause is a neighbour, not the loader. Look for pool timeouts and blocking during the window.

    Live waits and blockers
    -- What the loader is waiting on right now. Run it during the stall,
    -- not after: sys.dm_exec_requests only shows requests currently executing.
    SELECT
        r.session_id,
        r.blocking_session_id,
        r.wait_type,
        r.wait_time / 1000.0            AS wait_seconds,
        DB_NAME(r.database_id)          AS [database],
        SUBSTRING(t.text, (r.statement_start_offset / 2) + 1,
            ((CASE r.statement_end_offset
                  WHEN -1 THEN DATALENGTH(t.text)
                  ELSE r.statement_end_offset
              END - r.statement_start_offset) / 2) + 1) AS running_statement
    FROM sys.dm_exec_requests AS r
    CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
    WHERE r.session_id <> @@SPID
      AND (r.blocking_session_id <> 0 OR r.wait_type IS NOT NULL)
    ORDER BY r.wait_time DESC;
  5. 5

    Correlate with the deployment record

    Only once you have the first bad batch and its timestamp is it worth looking at commits. Working forwards from a release list produces plausible stories; working backwards from a timestamp produces a cause. This ordering is the difference between a diagnosis and a guess.

    Bound the search to the interval you proved
    git log --since="2026-08-05 01:50" --until="2026-08-05 02:15" \
           --name-only --pretty=format:"%h %an %ad %s"
Failure modes in bespoke ETL, and the evidence that distinguishes them
Failure mode What it looks like What proves it
Unmapped lookup keyRows diverted to a reject table, run reports successReject-table counts grouped by reason and hour
Bulk insert partial failureBatch rolls back, retries row-by-row, loses the failures to a log fileLoaded vs received counts per batch id
Connection pool exhaustionLoader stalls while another workload holds every connectionPool timeout errors correlated with the other workload's window
Config driftDeployed mapping file disagrees with the database control tableFile hash and modified time against the table's rows
Silent schema changeA new column or widened type upstream, quietly truncated on writeColumn metadata history against the first bad batch
Requeue loopThe same rows cycle through retry forever without alertingRepeat counts by correlation id across runs
Decim

How Decim investigates a custom service

Code from GitHub, logs from Cloud Logging or the optional Windows agent, the ticket, and checks for anything that lives in a database.

What it reads

Install the GitHub App on the repositories that hold the service. Decim reads the code, the commit history and the pull requests, and finds the pipelines in each repository by their triggers. For the loader on this page that means the queue consumer, the recurring sync job and any file-arrival or batch-command entry points, each discovered as a separate pipeline. There is no .NET, Java or Python parser behind this; Decim reads code in whatever language it is written, and a committed appsettings.json or mapping file is read along with it.

For each pipeline it builds a step graph: the queue as the source, then parse, map and bulk insert, the Transaction table as the destination, and the reject branch into RejectedTransactions. Every step carries the file and line range it came from, each connection is marked direct or inferred, and the places where pipelines tend to break are flagged as points of interest. A mapper that skips unknown keys and carries on, like the one above, is that kind of place.

Logs reach the investigation in one of two ways today. If the service ships to Google Cloud Logging, you define named log sources as filters, such as one matching the loader's SourceContext, under a read-only logging.read grant. If the logs are files on a Windows host, the optional Windows agent lists the log directory and reads files in byte ranges, and it can read the Windows Event Log, where a crashing .NET service leaves its unhandled exceptions. The agent polls outbound for tasks and does nothing else: no SQL, no process inspection.

What the investigation would look like on the stall above

Say the incident reads “batches from the POS queue are going missing overnight”, with a linked Jira ticket from the finance team. In the locate phase Decim ranks the discovered pipelines against that description and the ticket, picks the queue consumer, and cites the code that made the match. If nothing matches well enough it asks for more context rather than guessing.

It then reads the logs step by step, most suspicious first, within fixed limits on requests, bytes and model calls. The bulk-insert step would end as found something, citing the pool timeout and the loaded 0 of 500 line by byte offset, excerpt and hash. The comparison with successful runs from up to 30 days earlier is what exposes a schedule: a stall that recurs in the same minutes on other nights is visible in the baseline, and one that does not is a different problem. Steps with nothing to read end as “no logs” or “not enough logs” instead of being skipped silently.

In the weigh phase it holds up to five competing explanations, here a neighbouring workload exhausting the pool, a slow upstream, and a malformed batch, each with the evidence for and against it, the code involved and the test that would settle it. An independent critique pass challenges them, and each revision is numbered.

To settle it, Decim may need a fact from the database, such as whether batches outside the 02:00 window reconcile. It writes a check: a read-only query like the reconciliation above, validated against a read-only allowlist, for your team to run. A check can also ask for a file, for example the deployed appsettings.Production.json if production config is not in the repository. The result goes back in and triggers the next revision.

It concludes only with corroborating evidence: cause found, likely cause, cause unclear, or needs log access when the logs are too thin to decide. A fix arrives as a repository patch, such as moving the sync to its own connection pool, checked to apply cleanly and opened as a draft pull request. It is never built or run, and nothing is merged automatically.

What makes it work better

One log line per batch carrying the batch id and the received, loaded and rejected counts turns the reconciliation into a lookup, for you and for Decim. Structured logs help, but plain text is fine: the counts in it are still evidence, and every finding cites the exact lines it used.

What it does not do yet

Decim does not connect to your database, read cron, Quartz, Hangfire or systemd configuration on a host, or inspect a running process. Pipeline discovery sees what is in the repository; a mapping held only in a control table is reached through a check. The agent runs on Windows x64 only, so logs on a Linux host need to reach Cloud Logging or be attached to the incident. There is no redaction today, and evidence is processed by a third-party language-model provider. Database and scheduler connectors, a Linux agent and redaction are planned; see custom services and integrations.

For the method behind each phase, see how investigations work.

Common questions

Do you need access to our source code?
Yes, today. Decim discovers pipelines from code in GitHub, its only source-control provider so far, and the step graph it investigates comes from there. You choose which repositories the GitHub App can read. A mapping file, control table or scheduler entry that is not in git is not seen by discovery; the investigation asks for it through a check when it matters. GitLab, Azure Repos and Bitbucket are planned.
Our service is .NET Framework 4.7 on a Windows VM. Is that a problem?
No. Decim reads the code, not the running process, so the runtime version does not matter and nothing needs rebuilding. If the logs are files on that VM, the optional Windows agent can list and read them and read the Windows Event Log. It runs on Windows x64 and only polls outbound.
We have no structured logging, just text files. Does that work?
Yes. Plain text is still evidence, and every finding cites the exact lines it relied on, by byte offset, excerpt and hash. What you lose without structure is the correlation id that ties a log line to a batch. When the logs are too thin to decide, a step ends as “not enough logs” and the incident can conclude as “needs log access” instead of a guess.
How is this different from adding data quality tests?
Tests assert an expectation you already thought of, and they run on a schedule you chose. That catches the failure modes you have already met. Investigation starts after something unexpected happened and works backwards from evidence, which is why a good test suite and this are complements, not substitutes.
Can it tell us the batch size and parallelism to use?
Not as a tuning service. If an investigation concludes that a setting caused the incident, the fix can be a patch to that setting, opened as a draft pull request with the evidence attached. Decim never changes a production service itself, and nothing is merged automatically.
What does it do about connection strings and secrets in our config?
Nothing automatic today: there is no redaction, and evidence is processed by a third-party language-model provider. You control what Decim can see by choosing the repositories, the Cloud Logging filters and the hosts and paths the agent is pointed at. Keeping secrets out of committed config is good practice regardless. Redaction is planned; the current detail is on the security page.

Related: SQL Server & SSIS for the same problem where the definitions live in msdb; Airflow & dbt for why a green DAG and a wrong row count are not mutually exclusive; and all solutions by stack, role and failure mode.

Get started

Bring us a pipeline that broke last week

The fastest way to evaluate this is a real incident you already know the answer to. If Decim gets it wrong, that is a far more useful demo than one where it doesn't.