SQL Batch Processing — Applied Soft Deletes and Pagination

ADVBatch processingLogical deletionPaginationIncremental updates and monitoringWebApp APIPostgreSQL5 questions
1 / 5 · In progress 0 / 5 completed
QUESTION 6

Logical deletion — track deleted rows with deleted_at and retrieve only active data

UPDATEIS NULLLogical deletionsoft delete
Background

Logical deletion (soft delete) is a pattern that treats a row as deleted by setting a value in the deleted_at timestamp column instead of physically DELETEing it. Use it when an API or batch job needs to restore the pre-deletion state or retain deletion history.

-- Logical deletion: set the current time in deleted_at
UPDATE users
SET    deleted_at = TIMESTAMP '2024-06-01 06:30'
WHERE  user_id = 42;

-- Retrieve only active records: rows whose deleted_at is NULL
SELECT * FROM users
WHERE  deleted_at IS NULL;
COALESCE(value, default): returns the first non-NULL value. COALESCE(deleted_at, '9999-12-31') treats a NULL deleted_at as a date far in the future. Use it for date sorting instead of a logical-deletion flag.
Problem

Implement each of the following requirements as a separate query.

① In the users table, logically delete users whose last_login_at is at least 90 days old (deleted_at = TIMESTAMP '2024-06-01 06:30').

② Retrieve only active users that have not been logically deleted, sort by newest last_login_at first, and use COALESCE to put NULLs last.

Tables used
▸ users (reference date: 2024-06-01)
user_idnamelast_login_atdeleted_at (timestamp)
U01Alice2024-05-20NULL
U02Bob2024-01-10NULL
U03CarolNULLNULL
U04Dave2024-02-28NULL
U05Eve2024-05-012024-04-01
Expected Output

After UPDATE ① (U02 and U04 are logical-deletion targets):

user_idnamelast_login_atdeleted_at
U01Alice2024-05-20NULL
U02Bob2024-01-102024-06-01 06:30:00
U03CarolNULLNULL
U04Dave2024-02-282024-06-01 06:30:00
U05Eve2024-05-012024-04-01 00:00:00

SELECT result ② (U01 and U03 only; last_login descending, with NULL last):

user_idnamelast_login_at
U01Alice2024-05-20
U03CarolNULL
QUESTION 7

LAG-based change detection — Compare values with the previous batch run and extract only changed rows

LAGChange detectionDelta batchWindow function
Background

LAG(column, N) returns the value N rows earlier within the same partition. It is ideal for a delta batch that compares the previous batch value with the current value and extracts only changed rows.

LAG(column, 1) OVER (
  PARTITION BY group_key        -- Compare preceding and following values within each group
  ORDER BY     time_col         -- Return the value from the previous row after sorting chronologically
)                                -- The first row is NULL because there is no previous row

Delta-batch pattern: process only changed rows instead of retrieving everything to reduce external API calls and avoid cost and rate-limit problems.

Difference from LEAD:LAG returns the previous row's value, while LEAD returns the next row's value. Use LAG for change detection because it compares the previous and current values.
Problem

The stock_snapshots table records daily inventory snapshots. Extract only products whose quantity changed from the previous day and output the change amount (diff) as well.

Tables used
▸ stock_snapshots
snapshot_idproduct_idsnapshot_datequantity
1P012024-06-01100
2P012024-06-0285
3P012024-06-0385
4P022024-06-01200
5P022024-06-02200
6P022024-06-03215
Expected Output
product_idsnapshot_datequantityprev_quantitydiff
P012024-06-0285100-15
P022024-06-03215200+15
QUESTION 8

Cursor pagination — Fetch the next page while avoiding LIMIT/OFFSET problems

WHERELIMITPaginationAPI response
Background

When an API returns a large dataset, LIMIT/OFFSET pagination becomes slower as OFFSET grows because more rows must be scanned. Cursor-based pagination passes the last retrieved record's ID (the cursor) in the next request and can consistently use an index.

-- OFFSET method (problematic): the page around row 1,000,000 scans all skipped rows
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 990000; -- Slow

-- Cursor method (recommended): use LIMIT to take rows greater than the previous last id
SELECT * FROM orders
WHERE  id > :last_seen_id           -- Cursor: the last ID retrieved previously
ORDER BY id
LIMIT  10;                          -- Fast because it always uses the index
When cursor pagination is a good fit: Infinite scrolling that fetches the next page in sequence and chunking batch processing. OFFSET is useful when users must jump to an arbitrary page number, such as in an admin console.
Problem

Write a query that retrieves the next page from orders, five rows per page using a cursor (the last order_id).

Conditions: status = 'pending', order_id ascending, and the previous last order_id is 1003 (parameter :last_id). Also output the cursor for the next page (the last order_id).

Tables used
▸ orders
order_idcustomer_idamountstatus
1001C015000pending
1002C023000completed
1003C037000pending
1004C042500pending
1005C058000pending
1006C064500pending
1007C016000cancelled
1008C029000pending
Expected Output
order_idcustomer_idamountstatusnext_cursor
1004C042500pending1008
1005C058000pending1008
1006C064500pending1008
1008C029000pending1008
QUESTION 9

Incremental update batches — Efficiently process only rows changed since the previous run using updated_at

WHEREIncremental updatesIncremental batchupdated_at
Background

Instead of retrieving and processing every row, filter with updated_at >= the previous batch run time and process only changed records. This incremental (delta) batch pattern is common in production for API data synchronization, aggregate-table updates, and notifications.

-- Pass the previous batch run time as :last_run_at
SELECT *
FROM   orders
WHERE  updated_at >= :last_run_at   -- Only rows changed since the previous run
  AND  updated_at <  TIMESTAMP '2024-06-01 06:30';        -- Before the current time (exclude changes during this run)

indexing updated_at makes WHERE filtering fast. In production, it is important to include updated_at from the table-design stage.

Managing batch run times: Store the previous run time (last_run_at) in a control table such as batch_jobs. After the batch completes, overwrite it with “this run's start time” as the reference for the next run.
Problem

Using the orders and batch_checkpoints tables, implement the following.

① Retrieve the previous batch run time from batch_checkpoints.

② Extract orders updated since the previous run and aggregate order count, total amount, and latest update time by customer_id.

③ Update batch_checkpoints with this batch’s start time (NOW()).

Tables used
▸ orders
order_idcustomer_idamountstatusupdated_at
1C015000completed2024-06-01 01:00
2C023000pending2024-06-01 02:00
3C017000completed2024-06-01 03:00
4C032500cancelled2024-05-31 12:00
5C028000completed2024-06-01 04:00
▸ batch_checkpoints
job_namelast_run_at
order_sync2024-06-01 00:00:00
Expected Output
customer_idorder_counttotal_amountlast_updated_at
C012120002024-06-01 03:00
C022110002024-06-01 04:00
QUESTION 10

Batch job monitoring — Monitor duration, stalls, and consecutive failures in one SQL query

EXTRACTCOUNTJob monitoringBatch operations
Background

This pattern monitors execution time, stalls, and consecutive failures in a batch-job history table with one query. It is useful for API health-check endpoints and monitoring dashboards.

-- Calculate duration: convert the timestamp difference to seconds
EXTRACT(EPOCH FROM (ended_at - started_at)) AS duration_sec
-- EXTRACT(EPOCH FROM interval): convert an interval to seconds

-- Consecutive-failure count: failures among the latest N runs
COUNT(*) FILTER (WHERE status = 'failed')  -- Count only matching rows
EXTRACT(EPOCH FROM interval): Converts the difference between two timestamps (an INTERVAL) to a number of seconds. Because (ended_at - started_at) returns an INTERVAL, converting it to seconds calculates the execution time.
Problem

Using the job_runs table, output the following summary for each job in one query.

  • successes, failures, and average execution time (seconds) among the latest 5 runs
  • jobs currently running for at least 30 minutes (stall detection)
  • job names with at least 3 failures in the latest 5 runs (alert targets)
Tables used
▸ job_runs
run_idjob_namestatusstarted_atended_at
1order_syncsuccess2024-06-01 02:002024-06-01 02:05
2order_syncfailed2024-06-01 03:002024-06-01 03:02
3email_batchsuccess2024-06-01 02:002024-06-01 02:30
4order_syncfailed2024-06-01 04:002024-06-01 04:01
5email_batchfailed2024-06-01 03:002024-06-01 03:05
6order_syncrunning2024-06-01 05:00NULL
7email_batchfailed2024-06-01 04:002024-06-01 04:08
8order_syncfailed2024-06-01 06:002024-06-01 06:03
9email_batchfailed2024-06-01 05:002024-06-01 05:06
Expected Output
job_namesuccess_countfail_countavg_secis_stalledalert
email_batch13735.0false! ALERT
order_sync13165.0true! ALERT