<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Aadarsh Chaurasia on Medium]]></title>
        <description><![CDATA[Stories by Aadarsh Chaurasia on Medium]]></description>
        <link>https://medium.com/@aadarshchaurasia45?source=rss-c6100ea50204------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/0*Thg_R4xjg31oPbSX</url>
            <title>Stories by Aadarsh Chaurasia on Medium</title>
            <link>https://medium.com/@aadarshchaurasia45?source=rss-c6100ea50204------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 05 Nov 2026 00:50:55 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@aadarshchaurasia45/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Stop Scanning Ten Million Rows to Change Three HundredA complete, step-by-step guide to rebuilding…]]></title>
            <link>https://medium.com/@aadarshchaurasia45/stop-scanning-ten-million-rows-to-change-three-hundreda-complete-step-by-step-guide-to-rebuilding-239fa5d9cbe6?source=rss-c6100ea50204------2</link>
            <guid isPermaLink="false">https://medium.com/p/239fa5d9cbe6</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[batch-processing]]></category>
            <category><![CDATA[cronjob-scheduler]]></category>
            <category><![CDATA[performance-optimization]]></category>
            <category><![CDATA[software-development]]></category>
            <dc:creator><![CDATA[Aadarsh Chaurasia]]></dc:creator>
            <pubDate>Mon, 02 Nov 2026 07:01:40 GMT</pubDate>
            <atom:updated>2026-11-02T07:01:40.760Z</atom:updated>
            <content:encoded><![CDATA[<h3>Stop Scanning Ten Million Rows to Change Three Hundred<em>A complete, step-by-step guide to rebuilding a slow nightly batch job: what to measure, what to change, and why each change works.</em></h3><p>Every backend team has one. It is a scheduled job that recalculates something for every record in a big table: a risk score, an eligibility flag, a classification, a renewal date. It began as a simple loop. It worked beautifully when the table had 50,000 rows. Today the table has 10 million rows, the run takes hours, it occasionally dies halfway, and nobody wants to touch it.</p><p>We recently rebuilt exactly this kind of job, a nightly process that recomputes a derived status for every customer record. The rebuild turned a run that moved millions of messages and made thousands of cross-service calls into one that does a few hundred rows of real work and a handful of SQL statements per page.</p><p>The interesting part is that the speedup did not come from clever tricks. It came from a small set of patterns that apply to almost any batch scheduler. This article walks through the whole process in order, with the reasoning behind each step, so you can apply it to your own jobs.</p><h3>The process at a glance</h3><ol><li>Measure the problem with real numbers.</li><li>Decide whether the job should do less work at all.</li><li>Fix how the job reads data.</li><li>Fix how the job writes data.</li><li>Shorten and bound every transaction.</li><li>Make failures local instead of contagious.</li><li>Add safe concurrency, checkpoints and locking.</li><li>Make re-runs and retries harmless.</li><li>Bound memory.</li><li>Configure, observe and protect sensitive data.</li><li>Test and verify on real infrastructure.</li></ol><p>The order matters. Steps 1 and 2 usually deliver more than all the others combined, and they cost nothing to try on paper.</p><h3>Step 1: Size the problem with numbers</h3><p>Before you change a line of code, answer a short list of questions using the code and the database, not intuition. Engineers are consistently wrong about where a batch job spends its time.</p><p>Question</p><p>How to find the answer</p><p>How many rows does each run scan?</p><p>Table size and the query’s WHERE clause</p><p>How many rows actually change per run?</p><p>The business rule, plus a count from a real run</p><p>How many SQL statements does each row cost?</p><p>Count entity loads, eager associations, per-page COUNT queries, flushes and re-reads</p><p>How many external calls does each row cost?</p><p>HTTP or RPC calls, message-broker sends, decryption, file access</p><p>What is the longest transaction?</p><p>Find any transaction annotation that spans a loop</p><p>How many connections does the job hold at peak?</p><p>Workers, prefetchers and any nested transactions, compared with the pool size</p><p>Does the job need every row, or only rows that can change?</p><p>Is there a date or an event that predicts when a row’s result changes?</p><p>In our case the answers were embarrassing:</p><ul><li>The job scanned 10 million rows to change a few hundred.</li><li>Each row cost about 13 SQL statements, mostly because loading a fully mapped entity triggered around nine separate SELECTs through eager associations.</li><li>The job produced one message on a broker per row, and a separate service paged through the data over HTTP, about 20,000 calls per run.</li><li>Worse, the HTTP client had a fallback that returned an empty page when the call failed. A failed run therefore still reported SUCCESS, with most rows quietly missing.</li></ul><p>Write your own table down. If it shows the job is already cheap, stop here and leave it alone. Optimisation has a maintenance cost, and a cheap job does not deserve one.</p><h3>Step 2: Do less work, not the same work faster</h3><p>The biggest win in the entire project had nothing to do with SQL tuning. It came from asking one question: <strong>can we predict when a row’s result will next change?</strong></p><p>In our case the derived status depended on a person’s age, and age only crosses a category boundary on a known date. A customer who is 31 today will not change category for years. Scanning that row every night is pure waste.</p><h3>Store a “next due” date</h3><p>If you can compute when a row’s result will next change, store that date on the row. The daily run then reads only the rows whose date has arrived. A job that touched 10 million rows a day now touched a few hundred.</p><h3>The rules that make it safe</h3><p>A stored “next due” date is a cache, and caches go stale. These rules keep it honest:</p><ul><li><strong>Use a far-future sentinel for “never changes again.”</strong> For example 9999-12-31. Rows in their final state drop out of every future delta run permanently.</li><li><strong>Keep NULL to mean “not computed yet.”</strong> New rows and edited rows then get picked up automatically, because they have no date yet.</li><li><strong>Clear the date whenever the source data changes.</strong> If a user edits the field the result depends on, null out the stored date in the same code path, ideally inside the entity’s setter, so no caller can forget.</li><li><strong>Run a weekly full reconciliation.</strong> A full sweep is your safety net against drift, missed events, bugs and manual data fixes made directly in the database.</li><li><strong>Run an event-triggered full sweep when configuration changes.</strong> If thresholds or rules come from configuration, every stored date was computed under the old configuration. When a config input changes, recompute everything once.</li></ul><p>Three run modes fall out of this naturally: a cheap daily DELTA run, a weekly FULL run, and an on-demand FULL run after a configuration change. They share all the code below and differ only in which query selects the rows.</p><h3>Compute “today” exactly once</h3><p>A small trap hides inside date-driven jobs. If the job reads the current date in several places, a run that crosses midnight will classify a row using day D and then evaluate follow-up rules using day D+1, and the two will disagree.</p><p>The fix is simple: compute the run date once at the start, store it in a run context object, and pass it to every date calculation, including calls into other services. Overload your helper methods to accept the date explicitly rather than reading the clock.</p><h3>Run the job next to its data</h3><p>The old design fanned work out across service boundaries: one broker message per row, and an HTTP client paging through the data from another service. Every row paid for serialisation, a network hop and a retry policy, and the failure mode (the empty fallback page) was dangerous.</p><p>The rebuilt job runs inside the service that owns the data, reading its own table directly. The only thing left in the scheduling service is a configuration row that says “trigger this job at this time.” Moving the work to the data eliminated millions of messages and thousands of HTTP calls in one stroke.</p><h3>Step 3: Fix how the job reads data</h3><h3>Use keyset pagination, not OFFSET</h3><p>Paging with Page&lt;&gt; or OFFSET looks convenient and ships in every framework. It is also a quiet disaster at scale:</p><ul><li>It runs a COUNT(*) on every page, which on a large table is itself expensive.</li><li>OFFSET costs more with every page, so a full run is O(N²). Page 9,000 must walk and discard 9,000 pages’ worth of rows before returning anything.</li><li>If rows are inserted, deleted or updated while you scan, OFFSET skips some rows and repeats others. This is worst when the job updates the very column its query filters on.</li></ul><p>Keyset pagination fixes all three. Instead of “skip N rows,” you say “give me the next rows after the last id I saw”:</p><pre>-- first page: no cursor yet<br>SELECT id, encrypted_dob, next_review_date<br>FROM customers<br>WHERE status = &#39;ACTIVE&#39;<br>ORDER BY id<br>FETCH FIRST :n ROWS ONLY;</pre><pre>-- every page after that<br>SELECT id, encrypted_dob, next_review_date<br>FROM customers<br>WHERE status = &#39;ACTIVE&#39; AND id &gt; :lastId<br>ORDER BY id<br>FETCH FIRST :n ROWS ONLY;</pre><p>Write these as <strong>two separate queries</strong>, not one query with (:lastId IS NULL OR id &gt; :lastId). The OR form often defeats the optimiser and produces a poor plan. Two simple queries give the database one stable, index-friendly plan each.</p><p>The cursor also becomes your checkpoint. “Resume from id X” is meaningful and stable even when data changes underneath you, whereas “resume from page 4,200” is not.</p><p>For the delta run, the same shape applies with an extra predicate (next_review_date &lt;= :today OR next_review_date IS NULL), and an index on that column turns the daily query into a range scan.</p><h3>Read a narrow projection, not the entity</h3><p>Loading a fully mapped entity is the most expensive thing an ORM does. With eager associations, a single findById can issue around nine SELECTs, and the entity then becomes managed state that the persistence context must track and dirty-check.</p><p>For reads, ask only for the columns you need and map them into a plain value type. In Java that is a record filled by a constructor expression in the query:</p><pre>record ScanRow(UUID id, byte[] encryptedDob, LocalDate nextReview) {}</pre><p>One row, no associations, no managed state, no proxies. Prefer records over interface projections on the hot path, because interface projections are proxies and cost more per row.</p><h3>Load wider data only for rows that need it</h3><p>Often most rows in a page need nothing beyond the narrow projection, and a few need more: a join, an extra decrypted field. Do not make everyone pay for the few. Decide per row from the narrow data, collect the ids that need more, and fetch the wider data for just those ids with a single IN query.</p><h3>Kill the N+1 with one IN query per page</h3><p>If each row needs related rows (the latest flag, the latest status), do not query inside the loop. Collect the page’s ids, run <strong>one</strong> IN query for all related rows, then reduce in memory to the latest row per key. A page of 1,000 rows costs one extra query instead of 1,000.</p><h3>Respect the IN-list limit</h3><p>Some databases cap the number of elements in an IN list at 1,000 and fail hard when you exceed it. Clamp your page size to 1,000, and throw an explicit error if a caller asks for more. A clear error at startup beats a mysterious failure mid-run.</p><h3>Set a fetch size</h3><p>Database drivers fetch a small number of rows per network round trip by default, often 10. A 1,000-row page then takes about 100 round trips. Setting the fetch size to match your page size (via a query hint or the statement setting) collapses that to a handful. It is one line of configuration and one of the cheapest wins available.</p><h3>Check cheaply before loading expensively</h3><p>Some rows need an expensive step: a wide load, or decryption of several fields. Before paying for it, ask whether you can rule the row out using data you already have.</p><p>In our full sweeps after a configuration change, most customers could not possibly be affected by the new thresholds. A cheap pre-check on the narrow projection avoided millions of wide loads and tens of millions of decrypt operations.</p><p>There is one rule that makes this optimisation safe and one test that enforces it:</p><ul><li><strong>The pre-check must call the same helper methods as the full evaluation</strong>, so the two cannot disagree by construction.</li><li><strong>Write a parity test</strong> asserting preCheck(row) == (fullEvaluation(row) != null) across a large grid of boundary cases. Without it, the pre-check and the real logic will drift apart the first time someone edits one of them, and rows will silently stop being processed.</li></ul><h3>Decrypt only what you use, once per row</h3><p>If sensitive fields are encrypted at rest, decrypt only the fields the decision actually needs, and decrypt each at most once per row. Decryption is CPU-heavy and often involves a key service. In our job the main sweep decrypted a single field, and the multi-field decrypt ran only for the small number of rows that genuinely needed the wider object.</p><h3>Step 4: Fix how the job writes data</h3><h3>Skip unchanged rows completely</h3><p>Compare the new result with the state you already fetched. If nothing changed, do nothing: zero SQL statements, zero audit events, zero downstream triggers. In a daily job where almost everything is unchanged, this single rule removes the vast majority of write work.</p><h3>Write in bulk</h3><p>Stage every change for the page in memory, then write it in one go. With an ORM, that means one saveAll with JDBC batching switched on (batch size around 50, with inserts and updates ordered so statements can be grouped). Two details trip people up:</p><ul><li>Batching silently stops working with database-generated IDENTITY ids. Use application-generated UUIDs.</li><li>For plain bookkeeping columns (such as stamping a review date on thousands of rows), skip the ORM and use batchUpdate with a single parameterised statement.</li></ul><h3>Write managed entities, not detached ones</h3><p>If you built an object elsewhere and call save on it, the ORM treats it as detached and runs a merge, which issues a SELECT for every row first to see whether it exists. Instead, re-read the rows inside the write transaction. They become managed, the dirty check updates them in place, and the extra SELECTs disappear. The re-read is one IN query for the whole page.</p><h3>Keep bookkeeping consistent with the data it describes</h3><p>This is the subtlest rule in the write path. A stamp such as “processed” or “next due” must commit <strong>together with</strong> the data write it describes, or be written only after that write succeeds.</p><p>The failure case: the stamp commits, the data write fails. The row now says “done until next quarter” while holding stale data, and the daily run will never look at it again. Always make the stamp share the data write’s fate.</p><h3>Step 5: Keep transactions short and bounded</h3><p>This is where batch jobs most often go wrong, usually by accident.</p><h3>Never wrap the whole run in a transaction</h3><p>A transaction that spans a loop over millions of rows has three terrible properties. It pins a database connection for the entire run. It makes the persistence context grow without bound, which is a slow memory leak that ends in an out-of-memory error. And it holds row locks for hours, blocking online users.</p><p>Check for this explicitly: a class-level transactional annotation on a job, a launcher or a service loop is the classic form of this bug.</p><h3>Put only writes inside the transaction</h3><p>The pattern that works is a three-phase page:</p><ol><li><strong>Read</strong> the page outside any transaction.</li><li><strong>Compute</strong>: decrypt, evaluate rules, call any remote service, all still outside any transaction.</li><li><strong>Write</strong> inside one short programmatic transaction that contains only the re-read, the bulk save, outbox inserts and the bookkeeping stamps.</li></ol><p>A connection is then held only for the milliseconds of the write, not for decryption or network calls.</p><h3>Give every transaction a timeout</h3><p>Every transaction should have a timeout shorter than your connection pool’s leak-detection threshold. If the pool reports a suspected leak at 60 seconds, give the page transaction something like 30 seconds and a single-row fallback transaction around 5.</p><pre>TransactionTemplate pageTx = new TransactionTemplate(txManager);<br>pageTx.setTimeout(30);   // pool leak detection fires at 60s</pre><pre>TransactionTemplate rowTx = new TransactionTemplate(txManager);<br>rowTx.setTimeout(5);     // single-row fallback path</pre><p>The reasoning is that a stuck transaction should roll back and release its connection on its own. The checkpoint logic then retries the page. Without a timeout, the connection stays pinned and the pool begins logging “apparent connection leak” warnings, usually at 3 a.m.</p><h3>Know what joins your transaction</h3><p>Any method you call inside the page transaction with default propagation (REQUIRED) becomes part of it. That is exactly right for an outbox insert, which should commit or roll back with the data. It is exactly wrong for anything slow: an HTTP call, a broker send, heavy computation. Keep those outside.</p><h3>Side effects that must survive get their own transaction</h3><p>Some side effects should not be undone if the main write rolls back, for example recording that a follow-up review was triggered. Run those outside the page transaction, each in a short REQUIRES_NEW transaction. Do the programmatic form so the try/catch sits outside the transaction, which lets you catch a constraint violation that only appears at flush time.</p><h3>Avoid nested REQUIRES_NEW inside the page transaction</h3><p>Each nested REQUIRES_NEW takes a second connection while the first is still held. With four workers, each holding one connection and waiting for another, you can exhaust a pool of 20 faster than you would think, and in the worst case deadlock on the pool itself.</p><h3>Step 6: Make failures local</h3><p>Batching introduces a new risk: one poisoned row can sink 1,000 good ones. A good batch job must make sure a bad row fails only itself.</p><h3>Two-level writes with per-row fallback</h3><ol><li>Try the batched page write inside the short page transaction.</li><li>If it throws, roll back and redo the same work <strong>one row at a time</strong>, each row in its own short transaction with the tight timeout.</li><li>Merge counters only from transactions that actually committed, so the rolled-back batch attempt is not counted twice.</li></ol><p>The common case stays fast, and the rare bad page degrades gracefully into slow but correct processing. Only the genuinely bad row ends up failing.</p><h3>A failed row must stay eligible</h3><p>We found this bug in our own rebuild, and it is easy to miss. In the fallback path, a row whose write failed still received a future “next due” date, so the daily run dropped it until the weekly full sweep. The row was effectively lost for days.</p><p>The rule: any row that failed must be stamped “due today” (or left unstamped) so tomorrow’s run picks it up again. Failure must never advance the schedule.</p><h3>Isolate each row’s parse, decrypt and arithmetic</h3><p>Wrap each row’s parsing, decryption and date arithmetic in its own error handling. Count failures by type (invalid value, decrypt failure, missing data) so you can see what is going wrong. Date arithmetic deserves special care: adding a period to a date near the maximum representable date throws an exception, and one such row should not abort a page. Never log decrypted values, not even in error messages.</p><h3>Add a failure-ratio circuit breaker</h3><p>Bad data is normal. A broken dependency is not, and the two need different handling. Split failures into two kinds:</p><ul><li><strong>Data failures:</strong> a malformed value, a missing field. These are per-row, expected, and counted but never fatal.</li><li><strong>Systemic failures:</strong> database write errors, decrypt failures, downstream errors. These suggest something is broken.</li></ul><p>If systemic failures on a page exceed a threshold (we used 20%), abort the run with a resumable checkpoint rather than ploughing on. Without a breaker, a database outage produces a run that “completes” having processed nothing, and reports SUCCESS.</p><h3>Fail fast on bad configuration</h3><p>Validate every configuration value once, at the start of the run, and throw a non-retriable exception if something is missing or invalid. A missing threshold should stop the run immediately with a clear message, not surface as a NullPointerException at row 4 million, and a retry loop should never keep retrying something that cannot succeed.</p><h3>Step 7: Concurrency, checkpoints and locking</h3><h3>A sliding window of pages</h3><p>Parallelism without chaos comes from a small, fixed structure:</p><ul><li>A fixed pool of worker threads (we used 4).</li><li>A window of in-flight pages (we used 3), so workers always have something to do.</li><li>A separate single-thread executor that prefetches the next page while workers are busy.</li></ul><p>Budget your connections explicitly: workers, plus one for the prefetcher, plus the short side-effect transactions, kept well under the pool size. Then confirm the arithmetic against reality, not assumptions.</p><p>One small but important detail: thread-local context such as a security or request context does not follow work onto pool threads. Set it inside each worker task and clear it in a finally block, otherwise every row logs a warning about a missing context.</p><h3>Checkpoint at the low watermark</h3><p>Pages finish out of order. If you record “the last page that completed” as your checkpoint and then crash, you may have recorded page 12 while page 10 never finished, and on resume you will skip page 10 forever.</p><p>The fix is to drain results in strict FIFO order. Always wait on the <strong>front</strong> page of the window, and record only the front page’s cursor as the checkpoint. This is the low watermark: everything at or before the checkpoint is guaranteed finished. On resume, the run restarts from that cursor and at worst repeats some completed pages, which is harmless because re-running a page is a no-op (see Step 8).</p><h3>A durable lease with owner fencing</h3><p>Each run should hold a durable lease keyed by a unique run id, recording which instance owns it. Three operations matter: heartbeat, complete and release. All three must filter on the owner, so an instance that has lost the lease cannot affect the new owner’s progress.</p><p>The heartbeat must return a boolean, and the caller must <strong>stop the run when it returns false</strong>. We found exactly this bug in an older scheduler: a run that lost its lease kept running alongside the new owner, and two instances wrote the same rows concurrently, a zombie dual-writer that is very hard to debug after the fact.</p><h3>Renew the distributed lock on every heartbeat</h3><p>If a distributed lock stops two runs overlapping, it needs an expiry (so a crashed pod does not hold it forever) and it needs renewal (so a healthy long run does not lose it). Renew on every heartbeat using an atomic compare-and-extend script, so you only extend a lock you still own. Two supporting rules:</p><ul><li>Every job that writes the same rows must share one lock key. In our case, the daily, weekly and configuration-change sweeps all contend on the same key.</li><li>Decide deliberately what happens if the lock service is down. Failing open (running anyway) was our existing convention, because the lease and idempotent writes protect correctness.</li></ul><h3>Keep the run off the message-listener thread</h3><p>If a message from a broker triggers the job, never run the job on the listener thread. It blocks consumption, trips poll-timeout rebalances, and causes redelivery storms. Instead, hand the run to a launcher: a single-thread executor with a SynchronousQueue, which gives exactly one run per pod and rejects a second trigger instead of queueing it.</p><p>While the run is in progress, publish a RUNNING heartbeat every few minutes so an upstream watchdog (ours gave up after 30 minutes of silence) does not declare the run dead and send it to a dead-letter queue.</p><h3>Step 8: Make re-runs and retries harmless</h3><p>At-least-once execution is the reality of distributed batch work. Pages get retried, runs get resumed, triggers get delivered twice. Design so that none of it hurts.</p><ul><li><strong>Re-running a page is a no-op.</strong> A second pass over the same page must produce no writes, no audit events and no downstream triggers. This follows from skipping unchanged rows, and it is the property that makes checkpoint resume safe.</li><li><strong>Dedupe downstream triggers at the database.</strong> Put a unique index on the trigger table’s natural key. Even if application logic ever double-fires, the second insert fails harmlessly.</li><li><strong>Order side effects so a failure leaves the retry signal visible.</strong> In our case, a downstream review trigger is evaluated <em>before</em> the flag is written. If the page then fails, the stale flag still differs from the new result, so the next run re-detects the change and fires the trigger. Had we written the flag first and failed before the trigger, the difference would be gone and the trigger lost.</li><li><strong>Keep audit events small.</strong> Emit one event per changed row containing ids and changed values only. Never serialise the whole object graph into an audit record: it is slow, huge and often contains data that should not be there.</li><li><strong>Mind the outbox queue.</strong> If audit events flow through an outbox with a bounded callback queue, keep workers multiplied by page size within that queue’s capacity. Overflow can be designed to be safe (rows stay PENDING and a relay resends them), but it delays delivery, so size it deliberately.</li></ul><h3>Step 9: Bound memory</h3><p>A batch job’s memory should be a constant, not a function of table size. Make it so:</p><ul><li>Hold at most (window + 1) pages of small records at any time.</li><li>Use LongAdder or similar counters rather than lists of per-row results.</li><li>Keep no per-row result lists across pages.</li><li>Close the persistence context at every page commit so entities do not accumulate.</li><li>Build objects cheaply: records instead of proxies, and convert reusable values such as thresholds into Period objects once per run rather than once per row.</li></ul><p>If you can compute the maximum heap the job needs from its settings (window, page size, record size), you have done this right.</p><h3>Step 10: Configuration, observability and sensitive data</h3><h3>Put every knob in configuration, with a safe fallback</h3><p>Worker count, window size, page size, transaction timeouts, failure-ratio threshold: all of them belong in configuration with sensible defaults. Guard each value so that zero or missing means “use the default.” This matters because tests often construct the job outside the dependency-injection container, where injected fields are zero.</p><h3>Make the job observable</h3><ul><li>Counters and timers per run mode.</li><li>One summary INFO line every N pages, with scanned, changed, unchanged and failed counts.</li><li>Per-row logging only at DEBUG.</li><li>Final counts returned to whatever scheduled the job, so “SUCCESS” comes with numbers.</li></ul><p>A job that reports only “done” is a job you will not trust the first time it is wrong.</p><h3>Treat sensitive data deliberately</h3><p>Decrypt in memory only and never log decrypted values. Be careful with derived columns: a stored “next due” date that is computed from a date of birth effectively points back to the original, so it deserves the same scrutiny as the source. Exclude such columns from audit trails and serialisation, keep them out of search indexes, and get explicit sign-off from whoever owns compliance before shipping them.</p><h3>Migrations and code quality</h3><ul><li>Let your migration tool own indexes and schema, using idempotent guards so a re-run is safe. Do not let the ORM create indexes implicitly.</li><li>Check the next free migration version across unmerged branches too, because most migration tools refuse out-of-order versions.</li><li>Keep methods small. Batch code tends to produce one enormous method, and static analysis gates on cognitive complexity will punish it. Extract constants for repeated literals and delete unused code.</li></ul><h3>Step 11: Test and verify</h3><p>Batch jobs fail in odd places, so test at four levels.</p><p><strong>Rule tests.</strong> The pure date and threshold logic: boundary values at minus one, exact and plus one day; February 29 in non-leap years; month-and-day thresholds; the sentinel value.</p><p><strong>Page-processor tests.</strong></p><ul><li>An unchanged row makes zero writes and zero downstream calls.</li><li>A first-time row inserts but does not fire a trigger.</li><li>A real transition fires exactly once, in the right order.</li><li>A bad row is isolated and the rest of the page commits.</li><li>A failed row stays eligible for tomorrow.</li><li>Transaction timeouts are asserted by capturing the transaction definition passed to the transaction manager.</li></ul><p><strong>Job-level tests.</strong> The low-watermark checkpoint, resume from a saved cursor, the failure-ratio breaker, lock contention, a lost lease stopping the run, and bad configuration being reported as non-retriable.</p><p><strong>Parity tests</strong> for every pre-check, as described in Step 3.</p><p><strong>Verification on real infrastructure</strong>, which unit tests cannot replace:</p><ul><li>Count SQL statements per page with the ORM’s statistics. Expect a handful, not thousands.</li><li>Run the database’s explain-plan on the delta query and confirm an index range scan.</li><li>Load-test on at least a million rows.</li><li>Confirm there are no connection-pool leak warnings.</li><li>Confirm no backlog is building in the outbox.</li><li>Run the job twice and confirm the second run performs zero writes.</li></ul><h3>A reusable checklist</h3><p>Copy this into your own planning document and tick each line, writing “N/A” plus a reason when something does not apply.</p><p><strong>Workload</strong></p><ul><li>Measured scanned rows, changed rows, statements per row, external calls per row.</li><li>Considered a stored “next due” date, with sentinel and NULL semantics.</li><li>Run date computed once and passed everywhere.</li><li>Job runs next to its data.</li></ul><p><strong>Reading</strong></p><ul><li>Keyset pagination with separate first and after queries.</li><li>Narrow record projections.</li><li>One IN query per page for related rows; page size at or below the IN limit.</li><li>Fetch size set.</li><li>Cheap pre-check with a parity test.</li></ul><p><strong>Writing</strong></p><ul><li>Unchanged rows skipped completely.</li><li>Bulk writes with managed entities.</li><li>Bookkeeping stamps commit with their data.</li></ul><p><strong>Transactions</strong></p><ul><li>No run-wide transaction.</li><li>Only writes inside transactions; timeouts below the leak threshold.</li><li>No slow calls or nested REQUIRES_NEW inside the page transaction.</li></ul><p><strong>Failures</strong></p><ul><li>Batched write with per-row fallback.</li><li>Failed rows stay eligible.</li><li>Failure-ratio circuit breaker; config validated up front.</li></ul><p><strong>Concurrency</strong></p><ul><li>Worker pool, window and prefetch within the connection budget.</li><li>Low-watermark checkpoint in FIFO order.</li><li>Owner-fenced lease; stop on lost heartbeat; renewed distributed lock.</li><li>Run off the listener thread, with upstream heartbeats.</li></ul><p><strong>Safety</strong></p><ul><li>Re-runs are no-ops; unique indexes on triggers.</li><li>Side effects ordered for at-least-once delivery.</li><li>Small audit events; bounded memory.</li><li>Configurable, observable, no sensitive values in logs.</li></ul><h3>The short version</h3><p>If you take only five ideas away:</p><ol><li><strong>Store a “next due” date</strong> so daily runs read only rows that can actually change.</li><li><strong>Use keyset pagination and narrow projections</strong>, with one query per page for related data.</li><li><strong>Keep transactions short, per page, writes only, with timeouts</strong> below your pool’s leak threshold.</li><li><strong>Batch the writes, fall back to one row at a time</strong>, and never let a failed row look finished.</li><li><strong>Fence your leases and checkpoint at the low watermark</strong>, so crashes and concurrency cannot corrupt progress.</li></ol><p>The job we rebuilt went from millions of messages and thousands of cross-service calls per run to a few hundred rows of real work and a handful of SQL statements per page. None of it relied on exotic technology. It came from asking, at every step, the same question: does this need to happen at all?</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=239fa5d9cbe6" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>