Migrating DemoBank Finance: Legacy Progress 4GL to Modern SQL Server


June 2026  |  20 min read  |  SQL Server, Migration, SSIS  |  views

DemoBank Finance database migration plan Progress to SQL Server

Part 4 of the DemoBank Finance case study — the full migration playbook: five phases, the SSIS pipeline, 90-day parallel run, cutover weekend, and what failed in Month 1


The previous three articles built the schema, explained the VISION file constraints, and cleaned the orphaned records. This article covers the migration itself: how DemoBank Finance moved 85,000 active contracts, 15 million payment records, and 20 years of history from Progress OpenEdge to SQL Server 2019 without losing a single direct debit payment or missing an FCA reporting deadline.

The migration ran across five phases over 18 months: discovery, schema build and initial load, 90-day parallel operation, cutover weekend, and a 30-day hypercare period. Each phase had defined entry criteria, exit criteria, and a specific rollback trigger. The rollback was never needed — but having it defined in writing was the single decision that most reduced stakeholder anxiety during the project.

The Five-Phase Migration Plan

PhaseDurationDatesKey DeliverableExit Criterion
1. Discovery 8 weeks Sep–Oct 2023 Schema inventory, row counts, data quality baseline, VISION file list All 18 tables documented; orphan audit baseline run
2. Schema Build & Initial Load 10 weeks Nov 2023 – Jan 2024 SQL Server schema created; SSIS pipeline built; full historical data loaded to staging Row counts match OpenEdge ±0.1%; financial totals reconcile to penny
3. Parallel Operation 12 weeks Mar–May 2024 Both systems running; daily reconciliation; orphan remediation; VISION export verified Zero reconciliation differences for 10 consecutive business days
4. Cutover Weekend 48 hours 7–9 Jun 2024 Final sync; 23 validation scripts; OpenEdge shut down; SQL Server opens to users All 23 scripts pass; no P1/P2 incidents within 4 hours of go-live
5. Hypercare 30 days Jun 2024 Elevated monitoring, daily DBA stand-up, fast-track incident resolution No recurring incidents; monthly payment run complete without manual intervention
Why no February? February was used as a buffer month — schema changes identified during Phase 2 testing were finalised, the application team completed their ODBC driver switch testing, and the FCA notification (required 30 days before a regulated system change) was submitted. The parallel run was intentionally not started until the application was stable against SQL Server — starting parallel operation with known application bugs would have created false reconciliation failures and eroded stakeholder confidence.

Phase 2: The SSIS Pipeline Architecture

The initial data load and the daily parallel-operation sync used the same SSIS package architecture. Four packages handled the full migration, each responsible for one domain.

PackageTablesStrategyApprox Runtime (full load)
PKG_REF_LOADREF_STATUS, REF_PROD_TYPE, ASST_TYPETruncate + full reload (small, stable)2 seconds
PKG_CUSTOMER_LOADCUST_MAST, CUST_ADDR, CUST_CONTACT, CUST_CREDITMERGE on primary key45 seconds
PKG_ASSET_LOADASST_MAST, ASST_INSUR, ASST_VALUATMERGE on primary key1 min 20 sec
PKG_CONTRACT_LOADCONT_HDR, CONT_SCHED, CONT_PAYMNT, CONT_STATUS, CONT_RATE_HIS, CONT_NOTEMERGE on primary key; CONT_PAYMNT incremental by CP_PMT_DATE18 min (full); 3 min (daily delta)

The Staging Schema Pattern

No package wrote directly to the live dbo schema. Every table had a stg counterpart (e.g. stg.CONT_HDR) with identical column definitions but no constraints and no indexes. The SSIS package truncated the staging table, bulk-loaded from OpenEdge via ODBC, then a T-SQL post-load script ran the MERGE into dbo. This kept the live tables available throughout the load — no locking, no blocking.

-- ============================================================
-- Post-load MERGE: stg.CONT_HDR → dbo.CONT_HDR
-- Runs after PKG_CONTRACT_LOAD finishes the staging load.
-- ============================================================

MERGE dbo.CONT_HDR AS target
USING stg.CONT_HDR AS source
    ON target.CH_CONT_ID = source.CH_CONT_ID
WHEN MATCHED
    AND (   target.CH_STATUS       <> source.CH_STATUS
         OR target.CH_ARREARS_AMT  <> source.CH_ARREARS_AMT
         OR target.CH_TOTAL_PAID   <> source.CH_TOTAL_PAID
         OR target.CH_OUTSTANDING  <> source.CH_OUTSTANDING
         OR target.CH_MODIFIED_DATE <> source.CH_MODIFIED_DATE )
THEN UPDATE SET
    target.CH_STATUS        = source.CH_STATUS,
    target.CH_ARREARS_BAND  = source.CH_ARREARS_BAND,
    target.CH_ARREARS_AMT   = source.CH_ARREARS_AMT,
    target.CH_TOTAL_PAID    = source.CH_TOTAL_PAID,
    target.CH_OUTSTANDING   = source.CH_OUTSTANDING,
    target.CH_EARLY_SETT    = source.CH_EARLY_SETT,
    target.CH_SETT_METHOD   = source.CH_SETT_METHOD,
    target.CH_MODIFIED_DATE = source.CH_MODIFIED_DATE,
    target.CH_MODIFIED_BY   = source.CH_MODIFIED_BY
WHEN NOT MATCHED BY TARGET
THEN INSERT (
    CH_CONT_ID, CH_CONT_REF, CH_PROD_CODE, CH_CUST_ID, CH_ASSET_ID,
    CH_STATUS, CH_CURRENCY, CH_FINANCE_AMT, CH_DEPOSIT, CH_BALLOON_AMT,
    CH_RATE_TYPE, CH_INTEREST_RT, CH_APR, CH_TERM_MONTHS, CH_PAYMENT_FREQ,
    CH_PAYMENT_DAY, CH_FIRST_PMT_DT, CH_LAST_PMT_DT, CH_MATURITY_DT,
    CH_START_DATE, CH_ARREARS_BAND, CH_ARREARS_AMT, CH_TOTAL_PAID,
    CH_OUTSTANDING, CH_EARLY_SETT, CH_SETT_METHOD, CH_FCA_REGULATED,
    CH_INTRODUCER, CH_BRANCH_CODE, CH_COUNTRY_CODE,
    CH_CREATED_DATE, CH_CREATED_BY, CH_MODIFIED_DATE, CH_MODIFIED_BY
)
VALUES (
    source.CH_CONT_ID, source.CH_CONT_REF, source.CH_PROD_CODE,
    source.CH_CUST_ID, source.CH_ASSET_ID, source.CH_STATUS,
    source.CH_CURRENCY, source.CH_FINANCE_AMT, source.CH_DEPOSIT,
    source.CH_BALLOON_AMT, source.CH_RATE_TYPE, source.CH_INTEREST_RT,
    source.CH_APR, source.CH_TERM_MONTHS, source.CH_PAYMENT_FREQ,
    source.CH_PAYMENT_DAY, source.CH_FIRST_PMT_DT, source.CH_LAST_PMT_DT,
    source.CH_MATURITY_DT, source.CH_START_DATE, source.CH_ARREARS_BAND,
    source.CH_ARREARS_AMT, source.CH_TOTAL_PAID, source.CH_OUTSTANDING,
    source.CH_EARLY_SETT, source.CH_SETT_METHOD, source.CH_FCA_REGULATED,
    source.CH_INTRODUCER, source.CH_BRANCH_CODE, source.CH_COUNTRY_CODE,
    source.CH_CREATED_DATE, source.CH_CREATED_BY,
    source.CH_MODIFIED_DATE, source.CH_MODIFIED_BY
);

-- Log the merge result
INSERT INTO dbo.SYS_AUDIT_LOG
    (AL_LOG_ID, AL_TABLE_NAME, AL_ROW_ID, AL_ACTION,
     AL_FIELD_NAME, AL_OLD_VALUE, AL_NEW_VALUE,
     AL_CHANGED_BY, AL_CHANGE_DATE, AL_CHANGE_TIME, AL_SESSION_ID, AL_YEAR)
SELECT
    NEXT VALUE FOR dbo.sq_audit,  -- replaced SYS_SEQUENCE in Phase 2 for audit table
    'CONT_HDR           ',
    0, 'I',
    'MERGE_STATS        ',
    '                   ',
    'Rows merged: ' + CAST(@@ROWCOUNT AS VARCHAR(10)),
    'SSIS_PKG_CONT      ',
    CAST(FORMAT(GETDATE(),'yyyyMMdd') AS INT),
    FORMAT(GETDATE(),'HH:mm:ss'),
    'PARALLEL_RUN       ',
    YEAR(GETDATE());
GO
MERGE and the CONT_PAYMNT table: With 15 million payment rows, a full MERGE of CONT_PAYMNT every night took 22 minutes — too long for a nightly window. The solution was an incremental load: only payments with CP_PMT_DATE >= today - 7 were pulled from OpenEdge and merged. Historical payments were loaded once during Phase 2 and never re-synced — they do not change after posting. A separate weekly reconciliation job verified total payment amounts by contract against the OpenEdge totals to catch any drift.

The ODBC Source Configuration

The SSIS ODBC source component connected to OpenEdge using the Progress DataDirect ODBC driver. Three settings were critical:

; Progress ODBC DSN settings (odbc.ini excerpt)
[DemoBank FinanceOpenEdge]
Driver       = /opt/progress/odbc/lib/ddprog11.so
Host         = oe-prod-01.internal
Port         = 5162
Database     = DemoBank Finance
LogonID      = ssis_reader
Password     = *****
ArraySize    = 5000          ; fetch 5000 rows per round-trip (critical for performance)
WorkArounds  = 536870912     ; Progress-specific: disable cursor sensitivity
ProcedureParameters = 0      ; do not enumerate stored procedure parameters on connect
FetchTSWTZasTimestamp = 1    ; treat timestamp with timezone as plain timestamp

The ArraySize = 5000 setting was the single biggest performance tuning win. The default array size of 1 caused SSIS to make one network round-trip per row — at 85,000 contract rows that meant 85,000 individual ODBC fetches. With ArraySize = 5000 the contract header extract dropped from 14 minutes to 45 seconds.

Phase 3: The Daily Reconciliation Script

Every morning during the 90-day parallel run, a SQL Agent job ran the reconciliation procedure at 06:00 — after the nightly SSIS sync completed at ~05:30 and before the first users logged in at 08:00. Any difference above the defined tolerance triggered a Severity 1 alert to the on-call DBA.

-- ============================================================
-- Daily parallel-run reconciliation
-- Compares SQL Server totals against OpenEdge shadow table
-- populated by the SSIS sync job.
-- ============================================================

-- The SSIS package also populates a reconciliation summary table
-- from the OpenEdge source query before the MERGE runs:
CREATE TABLE dbo.RECON_SHADOW (
    RS_RUN_DATE       DATE         NOT NULL,
    RS_TABLE_NAME     CHAR(20)     NOT NULL,
    RS_OE_ROW_COUNT   INT          NOT NULL DEFAULT 0,
    RS_OE_TOTAL_1     DECIMAL(18,2)NOT NULL DEFAULT 0, -- primary financial total
    RS_OE_TOTAL_2     DECIMAL(18,2)NOT NULL DEFAULT 0, -- secondary financial total
    RS_NOTES          VARCHAR(100) NOT NULL DEFAULT '',
    CONSTRAINT PK_RECON_SHADOW PRIMARY KEY (RS_RUN_DATE, RS_TABLE_NAME)
);

GO

CREATE OR ALTER PROCEDURE dbo.usp_DailyReconciliation
    @RunDate DATE = NULL
AS
BEGIN
    SET NOCOUNT ON;
    IF @RunDate IS NULL SET @RunDate = CAST(GETDATE() AS DATE);

    DECLARE @Failures INT = 0;

    -- ── Contract header reconciliation ──────────────────────
    DECLARE @SS_ContCount   INT,          @OE_ContCount   INT;
    DECLARE @SS_Outstanding DECIMAL(18,2),@OE_Outstanding DECIMAL(18,2);
    DECLARE @SS_TotalPaid   DECIMAL(18,2),@OE_TotalPaid   DECIMAL(18,2);

    SELECT @SS_ContCount   = COUNT(*),
           @SS_Outstanding = SUM(CH_OUTSTANDING),
           @SS_TotalPaid   = SUM(CH_TOTAL_PAID)
    FROM   dbo.CONT_HDR
    WHERE  CH_STATUS IN ('AC','AR','SU');  -- active portfolio only

    SELECT @OE_ContCount   = RS_OE_ROW_COUNT,
           @OE_Outstanding = RS_OE_TOTAL_1,
           @OE_TotalPaid   = RS_OE_TOTAL_2
    FROM   dbo.RECON_SHADOW
    WHERE  RS_RUN_DATE   = @RunDate
      AND  RS_TABLE_NAME = 'CONT_HDR';

    IF ABS(@SS_ContCount - @OE_ContCount) > 0
    BEGIN
        PRINT 'FAIL: CONT_HDR active row count: SS=' + CAST(@SS_ContCount AS VARCHAR)
            + ' OE=' + CAST(@OE_ContCount AS VARCHAR);
        SET @Failures += 1;
    END

    -- Tolerance: outstanding balance within £100 (rounding differences in COMP-3 decode)
    IF ABS(@SS_Outstanding - @OE_Outstanding) > 100.00
    BEGIN
        PRINT 'FAIL: CONT_HDR outstanding balance: SS=' + CAST(@SS_Outstanding AS VARCHAR)
            + ' OE=' + CAST(@OE_Outstanding AS VARCHAR)
            + ' Diff=' + CAST(ABS(@SS_Outstanding - @OE_Outstanding) AS VARCHAR);
        SET @Failures += 1;
    END

    -- ── Payment reconciliation ───────────────────────────────
    DECLARE @SS_PmtCount INT, @OE_PmtCount INT;
    DECLARE @SS_PmtTotal DECIMAL(18,2), @OE_PmtTotal DECIMAL(18,2);

    SELECT @SS_PmtCount = COUNT(*), @SS_PmtTotal = SUM(CP_AMOUNT)
    FROM   dbo.CONT_PAYMNT
    WHERE  CP_PMT_DATE >= CAST(FORMAT(DATEADD(DAY,-7,@RunDate),'yyyyMMdd') AS INT)
      AND  CP_REVERSAL_FLG = 'N';

    SELECT @OE_PmtCount = RS_OE_ROW_COUNT, @OE_PmtTotal = RS_OE_TOTAL_1
    FROM   dbo.RECON_SHADOW
    WHERE  RS_RUN_DATE = @RunDate AND RS_TABLE_NAME = 'CONT_PAYMNT_7D';

    IF ABS(@SS_PmtCount - @OE_PmtCount) > 0
    BEGIN
        PRINT 'FAIL: CONT_PAYMNT 7-day count: SS=' + CAST(@SS_PmtCount AS VARCHAR)
            + ' OE=' + CAST(@OE_PmtCount AS VARCHAR);
        SET @Failures += 1;
    END

    IF ABS(@SS_PmtTotal - @OE_PmtTotal) > 0.01  -- zero tolerance on payment amounts
    BEGIN
        PRINT 'FAIL: CONT_PAYMNT 7-day total: SS=' + CAST(@SS_PmtTotal AS VARCHAR)
            + ' OE=' + CAST(@OE_PmtTotal AS VARCHAR);
        SET @Failures += 1;
    END

    -- ── Customer count ───────────────────────────────────────
    DECLARE @SS_CustCount INT, @OE_CustCount INT;
    SELECT @SS_CustCount = COUNT(*) FROM dbo.CUST_MAST WHERE CM_ACTIVE_FLAG = 'Y';
    SELECT @OE_CustCount = RS_OE_ROW_COUNT FROM dbo.RECON_SHADOW
    WHERE RS_RUN_DATE = @RunDate AND RS_TABLE_NAME = 'CUST_MAST';

    IF ABS(@SS_CustCount - @OE_CustCount) > 0
    BEGIN
        PRINT 'FAIL: CUST_MAST active count: SS=' + CAST(@SS_CustCount AS VARCHAR)
            + ' OE=' + CAST(@OE_CustCount AS VARCHAR);
        SET @Failures += 1;
    END

    -- ── Summary ──────────────────────────────────────────────
    IF @Failures = 0
        PRINT 'RECONCILIATION PASSED — ' + CAST(@RunDate AS VARCHAR) + ' — all checks clean.';
    ELSE
        PRINT 'RECONCILIATION FAILED — ' + CAST(@RunDate AS VARCHAR)
            + ' — ' + CAST(@Failures AS VARCHAR) + ' check(s) failed. Escalate to on-call DBA.';

    RETURN @Failures;
END;
GO
The £100 outstanding balance tolerance: This was not laziness. COBOL COMP-3 packed decimal encodes numbers in binary BCD with a sign nibble. When the SSIS ODBC driver decodes COMP-3 to a SQL Server DECIMAL it introduces sub-penny rounding in a small number of records — typically 3–8 contracts per day — where the COMP-3 value is exactly on a rounding boundary. The £100 tolerance was set after analysing three weeks of test data: the actual drift never exceeded £47. Payment totals had zero tolerance because those values are confirmed bank receipts — there is no legitimate rounding source.

The Seven Reconciliation Failures During Parallel Operation

Over 90 days of parallel operation, the reconciliation script failed on seven separate days. Each failure was resolved within the same business day. The causes are worth documenting because they are representative of what every 4GL-to-SQL migration team encounters.

#DateCheck FailedRoot CauseFix
1 Week 1, Day 3 CONT_HDR active row count off by 12 The SSIS query filtered WHERE CH_STATUS = 'AC' but OpenEdge also counted SU (Suspended) contracts as "active" for portfolio reporting. Status definition mismatch. Added SU to the SSIS source query and reconciliation script filter. Documented in status code mapping reference.
2 Week 2, Day 1 CONT_PAYMNT 7-day total off by £2,340.00 A batch of 6 cheque payments was manually posted in OpenEdge by the Finance team after the nightly SSIS extract window closed (posted at 04:55, extract runs 04:00–04:30). SQL Server missed them. Extract window extended to 05:15. Late-posted payments procedure documented for Finance team: cut-off is 04:00, post-cut payments will appear next day.
3 Week 3, Day 4 CUST_MAST active count off by 1 A new customer was created directly in OpenEdge by a developer testing the origination API — bypassing the application which writes to both systems simultaneously. The "both systems" write was not yet implemented for the customer creation path. Dual-write confirmed implemented for all customer creation paths. Developer test accounts must be created in a non-production environment.
4 Week 5, Day 2 CONT_HDR outstanding balance off by £18,420.38 An early settlement calculation updated CH_OUTSTANDING in OpenEdge via a stored procedure that was not yet ported to SQL Server. The SQL Server balance was not updated. Early settlement stored procedure ported and tested. This was the last unported business-critical procedure.
5 Week 7, Day 1 CONT_PAYMNT 7-day total off by £0.02 Two payments with amounts of £1,234.565 (three decimal places in OpenEdge) were truncated to £1,234.56 in SQL Server DECIMAL(13,2). The total was £0.01 per payment × 2 = £0.02. OpenEdge source queried the offending rows; both were data entry errors (the 4GL accepted the extra decimal digit and silently truncated it). The OpenEdge values were corrected. SQL Server DECIMAL(13,2) is correct — this was dirty source data.
6 Week 9, Day 3 CONT_HDR active row count off by 3 Three contracts were created during a network interruption. The dual-write to SQL Server timed out and the application silently swallowed the error. The contracts existed in OpenEdge but not SQL Server. Application error handling updated: SQL Server write timeout is now a hard failure — the OpenEdge write is rolled back if the SQL Server write fails. This was the only incident that required an application code change during parallel operation.
7 Week 11, Day 4 CONT_HDR active row count off by 47 A month-end portfolio status update ran in OpenEdge and batch-updated 47 contracts from AR (arrears) to AC (active) after successful payment. The nightly SSIS sync had already run. SQL Server still showed them as AR. Month-end batch jobs identified as requiring same-day sync, not next-day. A SQL Agent trigger was added: after the month-end batch completes in OpenEdge, a delta sync job runs for CONT_HDR within 30 minutes.

Failures 3 and 6 were the most significant: they revealed gaps in the dual-write implementation and an unhandled timeout scenario. Both required application code changes — the only application changes made during the entire parallel operation period. Identifying them in parallel operation, not in production, was the entire point of the 90-day run.

Phase 4: The Cutover Weekend Playbook

The cutover ran Friday 7 June 2024 at 21:00 to Sunday 9 June 2024 at 21:00. The window was chosen to avoid the mid-month direct debit run (15th) and the month-end batch (30th). Every step had a named owner, a start time, an expected duration, and a go/no-go decision point.

-- ============================================================
-- Cutover Validation Script Suite — 23 checks
-- All must return PASS before OpenEdge is decommissioned.
-- ============================================================

-- Check 1: Final row count reconciliation
SELECT
    'CONT_HDR'     AS TableName, COUNT(*) AS SqlServerCount FROM dbo.CONT_HDR   UNION ALL
SELECT 'CUST_MAST',  COUNT(*) FROM dbo.CUST_MAST   UNION ALL
SELECT 'ASST_MAST',  COUNT(*) FROM dbo.ASST_MAST   UNION ALL
SELECT 'CONT_SCHED', COUNT(*) FROM dbo.CONT_SCHED  UNION ALL
SELECT 'CONT_PAYMNT',COUNT(*) FROM dbo.CONT_PAYMNT;
-- DBA manually compares against OpenEdge counts printed in the go/no-go pack

-- Check 2: No active contracts with zero outstanding balance
SELECT COUNT(*) AS BadContracts
FROM   dbo.CONT_HDR
WHERE  CH_STATUS = 'AC' AND CH_OUTSTANDING = 0 AND CH_MATURITY_DT > 20240607;
-- Expected: 0

-- Check 3: No schedule rows in PENDING status past their due date
DECLARE @CutoverDateInt INT = 20240607;
SELECT COUNT(*) AS OverdueSchedules
FROM   dbo.CONT_SCHED
WHERE  CS_STATUS = 'PE' AND CS_DUE_DATE < @CutoverDateInt;
-- Expected: 0 (all should be OV=Overdue, not PE=Pending)

-- Check 4: SYS_SEQUENCE values are ahead of all current max IDs
SELECT
    sq.SQ_TABLE_NAME,
    sq.SQ_NEXT_VAL          AS SequenceNextVal,
    mx.MaxCurrentID,
    CASE WHEN sq.SQ_NEXT_VAL > mx.MaxCurrentID THEN 'PASS' ELSE 'FAIL' END AS Check
FROM dbo.SYS_SEQUENCE sq
JOIN (
    SELECT 'CONT_HDR'    t, MAX(CH_CONT_ID)   v FROM dbo.CONT_HDR   UNION ALL
    SELECT 'CONT_SCHED',    MAX(CS_SCHED_ID)    FROM dbo.CONT_SCHED  UNION ALL
    SELECT 'CONT_PAYMNT',   MAX(CP_PMT_ID)      FROM dbo.CONT_PAYMNT UNION ALL
    SELECT 'CUST_MAST',     MAX(CM_CUST_ID)     FROM dbo.CUST_MAST   UNION ALL
    SELECT 'ASST_MAST',     MAX(AM_ASSET_ID)    FROM dbo.ASST_MAST
) mx ON mx.t = RTRIM(sq.SQ_TABLE_NAME);
-- Expected: all PASS

-- Check 5: VISION layout validation (from Part 2)
EXEC dbo.usp_ValidateVisionLayout;
-- Expected: all rows show 'OK'

-- Check 6: No orphaned records remain (from Part 3)
SELECT COUNT(*) AS OrphansRemaining
FROM   dbo.ORPHAN_AUDIT
WHERE  CAST(OA_RUN_DATE AS DATE) = '2024-06-06';  -- last pre-cutover audit run
-- Expected: 0

-- Check 7: All indexes in a healthy state (no disabled indexes)
SELECT i.name, t.name AS TableName, i.is_disabled
FROM   sys.indexes i
JOIN   sys.tables  t ON t.object_id = i.object_id
WHERE  i.is_disabled = 1;
-- Expected: 0 rows

-- Check 8: Active contract financial totals within tolerance
SELECT
    SUM(CH_OUTSTANDING) AS TotalOutstanding,
    SUM(CH_TOTAL_PAID)  AS TotalPaid,
    SUM(CH_FINANCE_AMT) AS TotalFinanced,
    COUNT(*)            AS ActiveContracts
FROM dbo.CONT_HDR
WHERE CH_STATUS IN ('AC','AR','SU');
-- Compare against figures in the go/no-go pack (tolerance: outstanding ±£500)

-- Check 9: Direct debit file can be generated (dry run)
EXEC dbo.usp_GenerateDirectDebitFile
    @RunDate    = 20240615,  -- next scheduled DD date
    @DryRun     = 1;         -- does not write file, just returns row count
-- Expected: row count matches OpenEdge DD file from parallel run Week 12

-- Check 10: FCA regulated contract count (must match FCA notification figure)
SELECT COUNT(*) AS FCAContracts
FROM   dbo.CONT_HDR
WHERE  CH_FCA_REGULATED = 'Y' AND CH_STATUS IN ('AC','AR','SU');
-- Expected: within 1% of figure submitted in FCA notification

Checks 11–23 covered: index fragmentation levels, TempDB configuration, SQL Agent job status, backup configuration verification, read-only standby availability, VISION export dry run, arrears band distribution match, and the application team's end-to-end smoke test of 10 key user journeys against the SQL Server database.

The Go/No-Go Decision Point

The go/no-go decision was taken at 15:00 on Saturday — 18 hours into the cutover window. This gave the team 30 hours of remaining window to roll back to OpenEdge if needed. The rollback trigger was defined in writing before the cutover started:

Rollback trigger: Rollback to OpenEdge if, at the 15:00 Saturday checkpoint: (a) any of the 23 validation checks fail and cannot be resolved within 2 hours, OR (b) the application smoke test fails on more than 2 of the 10 user journeys.

At 15:00 on Saturday all 23 checks passed and the smoke test passed 10/10. The rollback was not needed. OpenEdge was shut down at 19:00 Saturday — four hours ahead of schedule.

Phase 5: The Three Month-1 Incidents

Hypercare ran through June 2024. Three incidents required DBA intervention in the first 30 days. None were data integrity incidents — all were performance and configuration issues that the pre-production environment had not exposed.

Incident 1: Parameter Sniffing on the Payment Allocation Query (Day 3)

The payment allocation stored procedure usp_AllocatePayment ran in under 1 second during UAT. In production on Day 3 it started timing out at 30 seconds. Root cause: the procedure had a @ContID INT parameter. The first execution with a high-volume contract (10,000+ schedule rows) compiled a plan optimised for that contract. Subsequent executions with standard contracts (48 rows) used the same plan — a nested loops scan that was terrible for small contracts but great for the sniffed large one.

-- Fix: add OPTION(OPTIMIZE FOR UNKNOWN) to prevent sniffing
-- on the variable-row-count contract schedule join
CREATE OR ALTER PROCEDURE dbo.usp_AllocatePayment
    @ContID  INT,
    @Amount  DECIMAL(13,2),
    @PmtDate INT
AS
BEGIN
    SET NOCOUNT ON;

    SELECT
        CS_SCHED_ID,
        CS_DUE_DATE,
        CS_TOTAL_AMT - CS_PAID_AMT AS AmountDue
    FROM   dbo.CONT_SCHED
    WHERE  CS_CONT_ID = @ContID
      AND  CS_STATUS  = 'PE'
    ORDER  BY CS_DUE_DATE
    OPTION (OPTIMIZE FOR (@ContID UNKNOWN));  -- <-- fix
END;
GO

Incident 2: TempDB Contention During Month-End Batch (Day 25)

The month-end arrears calculation ran 85,000 individual contract updates — each one opening a transaction, recalculating arrears bands, and updating CONT_HDR. TempDB hit PAGELATCH_EX waits on the PFS (Page Free Space) pages within minutes. Production had 1 TempDB data file; the recommendation is one per logical CPU core (up to 8).

-- Fix: add TempDB files to match logical CPU count
-- Run in the brief window between batch completion and next business day start
-- (requires brief SQL Server restart or ALTER DATABASE ... ADD FILE)

-- Check current TempDB file count
SELECT name, physical_name, size * 8 / 1024 AS SizeMB
FROM   tempdb.sys.database_files;

-- Add 7 more files (server has 8 logical CPUs — want 1 file per CPU)
ALTER DATABASE tempdb ADD FILE (
    NAME = 'tempdev2', FILENAME = 'T:\MSSQL\DATA\tempdb2.ndf', SIZE = 1024MB, FILEGROWTH = 256MB);
ALTER DATABASE tempdb ADD FILE (
    NAME = 'tempdev3', FILENAME = 'T:\MSSQL\DATA\tempdb3.ndf', SIZE = 1024MB, FILEGROWTH = 256MB);
-- (repeat for tempdev4 through tempdev8)

-- Also: batch the arrears update to avoid long-running transaction
-- Process 1000 contracts per batch instead of all 85,000 in one transaction
DECLARE @BatchSize INT = 1000, @Offset INT = 0, @TotalRows INT;
SELECT @TotalRows = COUNT(*) FROM dbo.CONT_HDR WHERE CH_STATUS IN ('AC','AR');

WHILE @Offset < @TotalRows
BEGIN
    UPDATE c
    SET    c.CH_ARREARS_BAND = dbo.fn_CalcArrearsBand(c.CH_CONT_ID),
           c.CH_ARREARS_AMT  = dbo.fn_CalcArrearsAmt(c.CH_CONT_ID)
    FROM   dbo.CONT_HDR c
    JOIN (
        SELECT CH_CONT_ID FROM dbo.CONT_HDR
        WHERE  CH_STATUS IN ('AC','AR')
        ORDER  BY CH_CONT_ID
        OFFSET @Offset ROWS FETCH NEXT @BatchSize ROWS ONLY
    ) batch ON batch.CH_CONT_ID = c.CH_CONT_ID;

    SET @Offset += @BatchSize;
    CHECKPOINT;  -- flush dirty pages regularly, keeps log lean
END;

Incident 3: SYS_SEQUENCE Contention at Peak Origination Time (Day 18)

At 10:00 on Day 18 — the mid-morning origination peak when 12 brokers simultaneously submitted new contract applications — the sequence fetch procedure caused blocking chains up to 8 seconds long. This was the exact race condition documented in Part 1. The two-statement version of usp_GetNextID was still in production. The OUTPUT clause fix was deployed in a 10-minute maintenance slot at lunchtime.

-- Fix deployed on Day 18 lunch — see Part 1 for full explanation
CREATE OR ALTER PROCEDURE dbo.usp_GetNextID
    @TableName CHAR(20),
    @NextID    INT OUTPUT
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @T TABLE (NewID INT);

    UPDATE dbo.SYS_SEQUENCE
    SET    SQ_NEXT_VAL     = SQ_NEXT_VAL + SQ_INCREMENT,
           SQ_LAST_UPDATED = CAST(FORMAT(GETDATE(),'yyyyMMdd') AS INT)
    OUTPUT INSERTED.SQ_NEXT_VAL INTO @T(NewID)
    WHERE  SQ_TABLE_NAME = @TableName;

    SELECT @NextID = NewID FROM @T;
END;
GO
-- Blocking duration immediately dropped from 8s to <50ms after deployment.
Why wasn't this fixed before go-live? The OUTPUT clause version was tested in the performance environment at 4 concurrent sessions. Production during peak origination was 12 concurrent sessions — three times the tested load. The original version worked fine at 4 sessions; the race only became reproducible at 8+. This is a common pattern: performance issues that require a specific concurrency level to manifest will survive UAT unless the load test is designed to match production peak concurrency exactly.

What's Next in the DemoBank Finance Series

The SSIS package architecture, reconciliation procedure, cutover validation scripts, and batch-update pattern from this article are directly reusable on any 4GL migration project. The seven reconciliation failures are representative — expect similar categories on your own project, not necessarily the same causes but the same domains: status definition mismatches, timing windows, dual-write gaps, unported procedures, dirty source data, timeout handling, and batch job scheduling conflicts.

Frequently Asked Questions

How was the dual-write implemented during parallel operation?

The application's data access layer was modified to write to both OpenEdge (via the 4GL ODBC driver) and SQL Server (via the SQL Server ODBC driver) within the same application-level transaction. The OpenEdge write was the primary: if it failed, the transaction aborted and the SQL Server write was rolled back. The SQL Server write was secondary: if it failed, the transaction also aborted (after Incident 6 in Week 11 — before that fix, SQL Server failures were swallowed silently). This approach meant OpenEdge remained the system of record until cutover day.

Was there a dry run of the cutover weekend before the real one?

Yes — two dress rehearsals. The first rehearsal in April used a clone of the production database and ran all 23 validation scripts against it. Seven scripts failed due to data differences between the clone and the real production state. The scripts were tuned and a second rehearsal in May passed all 23. Dress rehearsals are non-negotiable on a regulated system: a failed validation on a real cutover night with stakeholders watching is a very different experience from a failed validation in a rehearsal.

What did the FCA notification process involve?

Under FCA SYSC (Senior Management Arrangements, Systems and Controls) rules, DemoBank Finance was required to notify the FCA at least 30 days before making a material change to a regulated system. The notification described the change (database platform migration), the risk mitigations (parallel operation, rollback plan, data integrity audit), and the testing evidence (90-day reconciliation results). The FCA acknowledged the notification within 14 days with no objections. The FCA compliance team also required that the contract counts in the SQL Server database matched the figures in the notification — which is why Check 10 in the cutover validation suite verifies the FCA contract count within 1%.

Stay Updated

Get weekly SQL Server and database articles in your inbox.