Transactions और DB migrations की टेस्टिंग
दो तरह के डेटाबेस defects सामान्य functional testing से बच निकलते हैं। पहले तभी दिखते हैं जब चीज़ें बीच में फ़ेल हों या एक ही समय पर हों: items के बिना सेव हुआ ऑर्डर, स्टॉक का आख़िरी item दो बार बिकना। दूसरे तभी दिखते हैं जब असली डेटा के नीचे schema बदलता है: जो migration test database पर दो सेकंड में चला, वह production टेबल को बीस मिनट लॉक कर देता है, या चुपचाप ऐसी values बदल देता है जिन्हें उसे रखना था।
यह पेज दोनों को कवर करता है। यह टेस्टर के नज़रिए से transactions और isolation levels समझाता है — सटीक रूप से, यह भी कि PostgreSQL और MySQL कहाँ अलग हैं — दो SQL sessions से चलने वाले concurrency tests दिखाता है, और production तक पहुँचने से पहले schema और data migrations टेस्ट करने का तरीका और checklist देता है।
टेस्टर की नज़र से transactions और ACID
Transaction कई statements को ऐसे समूह में रखता है कि वे सब साथ लागू हों या कोई भी नहीं। यह BEGIN से शुरू होता है (MySQL में START TRANSACTION समकक्ष है), बदलाव रखने के लिए COMMIT या छोड़ने के लिए ROLLBACK पर ख़त्म होता है, और commit होने तक इसके बदलाव दूसरे sessions को नहीं दिखते। चार ACID गुण बताते हैं कि डेटाबेस क्या वादा करता है:
- Atomicity — सारे statements या कोई नहीं: ऑर्डर, उसके items, stock reservation और payment record साथ लिखे जाते हैं, या कुछ भी नहीं।
- Consistency — हर committed transaction डेटाबेस को एक valid state से दूसरी में ले जाता है: constraints टिके रहते हैं और totals अब भी मेल खाते हैं।
- Isolation — एक साथ चलते transactions एक-दूसरे का अधूरा काम नहीं देखते, उस हद तक जितना चुना गया isolation level वादा करता है।
- Durability — COMMIT लौटते ही बदलाव crash या restart के बाद भी बचा रहता है।
Atomicity की टेस्टिंग: commit, rollback और failure injection
Database engine की गारंटियाँ दोबारा टेस्ट करने की ज़रूरत नहीं। आप यह टेस्ट करते हैं कि ऐप्लिकेशन transactions सही इस्तेमाल करता है या नहीं: साथ रहने वाले statements एक ही transaction में हैं या नहीं, हर error path rollback चलाता है या नहीं, और transaction के बाहर की कोई चीज़ — ईमेल, दूसरी सर्विस को message, फ़ाइल — डेटा के सचमुच commit होने से पहले तो नहीं भेजी जाती।
तरीका है failure injection: पहले के steps चलने के बाद किसी step को फ़ेल कराएँ, फिर बचा-खुचा खोजें। यहाँ engines का अंतर मायने रखता है। PostgreSQL में transaction के अंदर error पूरे transaction को abort कर देता है, और ROLLBACK तक हर अगला statement फ़ेल होता है। MySQL InnoDB में ज़्यादातर errors सिर्फ़ फ़ेल हुए statement को rollback करते हैं और transaction खुला रहता है — इसलिए जो ऐप्लिकेशन कोड error पकड़कर फिर commit करता है, वह उससे पहले लिखी rows रख लेता है। Error paths उसी engine पर टेस्ट करें जिस पर आप ship करते हैं।
Transaction डेटाबेस की सीमा पर ख़त्म भी हो जाता है। जब ऑर्डर सर्विस commit करके payment सर्विस को call करती है और वह फ़ेल होती है, तो कोई database rollback committed ऑर्डर को वापस नहीं ले सकता; डिज़ाइन को compensating action (जैसे ऑर्डर रद्द करने वाली saga) या ऐसा outbox चाहिए जो events सिर्फ़ commit के बाद publish करे। उस compensation को अपने आप में एक फ़ीचर की तरह टेस्ट करें।
- कई items में से आख़िरी में invalid डेटा — ऑर्डर और पहले के items बचे नहीं रहने चाहिए।
- ऑर्डर लिखे जाने के बाद payment provider timeout — ऑर्डर परिभाषित state (pending या cancelled) में ख़त्म होता है, बिना payment के कभी “paid” नहीं।
- दो steps के बीच ऐप्लिकेशन process kill करना (test environment में) — restart पर कुछ भी आधा लिखा नहीं, और retries duplicate नहीं बनाते।
- Transaction के बीच unique-key violation — यूज़र को साफ़ error दिखता है और कोई partial rows नहीं बचतीं।
-- PostgreSQL test database: the order must not survive a failed item
BEGIN;
INSERT INTO orders (order_id, customer_id, status, total_amount, created_at)
VALUES (9001, 317, 'PENDING', 150.00, '2026-09-28 10:00:00');
INSERT INTO order_items (order_item_id, order_id, product_sku, quantity, unit_price)
VALUES (90011, 9001, 'SKU-100', 0, 150.00); -- violates CHECK (quantity > 0)
ROLLBACK;
SELECT COUNT(*) AS leftover FROM orders WHERE order_id = 9001; -- expect 0Concurrency anomalies जिन्हें आप टेस्ट कर सकते हैं
Isolation सही होने और throughput के बीच का समझौता है, इसलिए डेटाबेस transactions को दूसरों के कुछ असर देखने देते हैं। SQL मानक तीन phenomena के नाम देता है; PostgreSQL का documentation serialization anomalies जोड़ता है; और व्यवहार में टेस्टर जिससे सबसे ज़्यादा मिलते हैं, वह मानक की सूची में है ही नहीं: lost update।
- Dirty read — एक transaction ऐसा डेटा पढ़ता है जो दूसरे transaction ने लिखा है पर अभी commit नहीं किया (और जो rollback हो सकता है)।
- Non-repeatable read — एक transaction एक ही row दो बार पढ़ता है और अलग values पाता है, क्योंकि बीच में दूसरे transaction ने बदलाव commit कर दिया।
- Phantom read — एक transaction एक ही query दो बार चलाता है और rows का अलग सेट पाता है, क्योंकि दूसरे transaction ने मेल खाती rows insert या delete करके commit कर दीं।
- Serialization anomaly — committed concurrent transactions का नतीजा उन्हें एक-एक करके चलाने के किसी भी क्रम से मेल नहीं खाता (क्लासिक उदाहरण write skew: दो डॉक्टर on-call से हट जाते हैं क्योंकि हर एक ने दूसरे को अब भी on-call देखा)।
- Lost update — दो transactions एक ही value पढ़ते हैं, दोनों उससे नई value निकालते हैं, और दूसरी write चुपचाप पहली को मिटा देती है। नीचे की script इसे दो SQL sessions से दोहराती है।
-- Setup on a test database: one unit left
CREATE TABLE inventory (
sku VARCHAR(40) NOT NULL PRIMARY KEY,
stock INTEGER NOT NULL CHECK (stock >= 0)
);
INSERT INTO inventory (sku, stock) VALUES ('SKU-100', 1);
-- Step 1, session A
BEGIN;
SELECT stock FROM inventory WHERE sku = 'SKU-100'; -- reads 1
-- Step 2, session B
BEGIN;
SELECT stock FROM inventory WHERE sku = 'SKU-100'; -- reads 1
UPDATE inventory SET stock = 0 WHERE sku = 'SKU-100'; -- app computed 1 - 1
COMMIT;
-- Step 3, session A
UPDATE inventory SET stock = 0 WHERE sku = 'SKU-100'; -- app computed 1 - 1
COMMIT;
-- Two sales, one unit: B's update is lost.
-- PostgreSQL READ COMMITTED and MySQL InnoDB REPEATABLE READ: both commits succeed.
-- PostgreSQL REPEATABLE READ: step 3 fails with a serialization error; the app must retry.- Session A
inventorySKU-100, stock शुरू में 1- Session B
- 1 Session A से
inventoryतक:BEGIN, फिर stock पढ़ेंSELECT stock - 2
inventoryसे Session A तक: 1 पढ़ता है जवाब - 3 Session B से
inventoryतक:BEGIN, फिर stock पढ़ेंSELECT stock - 4
inventoryसे Session B तक: 1 पढ़ता है जवाब - 5 Session B से
inventoryतक: 0 लिखें औरCOMMITऐप ने 1 − 1 निकाला; अब stock 0 हैUPDATE … SET stock = 0 - 6 Session A से
inventoryतक: 0 लिखें औरCOMMITऐप ने यह भी 1 − 1 निकाला, A की क़दम 2 वाली read से, मौजूदा 0 से नहींUPDATE … SET stock = 0
परिणाम: PostgreSQL READ COMMITTED और MySQL InnoDB REPEATABLE READ: दोनों commit सफल होते हैं और B का update खो जाता है। PostgreSQL REPEATABLE READ: क़दम 6 serialization error से फ़ेल होता है; ऐप्लिकेशन को retry करना होगा।
Test डेटाबेस पर दो SQL sessions से दोहराया गया। चित्र के क़दम 1–2, 3–5 और 6 script के step 1, 2 और 3 हैं।
Isolation levels: हर एक क्या रोकता है और engines कैसे अलग हैं
SQL मानक चार levels इस आधार पर परिभाषित करता है कि उन्हें तीन phenomena में से कौन-से रोकने हैं। ये न्यूनतम गारंटियाँ हैं, और हर engine इन्हें अलग तरह लागू करता है, इसलिए हमेशा अपने engine और वर्ज़न का documentation देखें।
PostgreSQL का डिफ़ॉल्ट READ COMMITTED है और वह कभी dirty read नहीं होने देता — उसका READ UNCOMMITTED, READ COMMITTED जैसा व्यवहार करता है। उसका REPEATABLE READ हर transaction को BEGIN जैसे transaction-control commands को छोड़कर उसके पहले statement की शुरुआत पर लिया गया snapshot देता है और phantom reads भी नहीं होने देता, जो मानक से ज़्यादा सख़्त है; अगर ऐसा transaction ऐसी row अपडेट करने की कोशिश करे जिसे किसी दूसरे transaction ने उसके snapshot के बाद बदलकर commit किया, तो वह serialization error से फ़ेल होता है, जिसे ऐप्लिकेशन को पकड़कर retry करना होता है। SERIALIZABLE इसके अलावा serialization anomalies पहचानता है और शामिल transactions में से एक को abort करता है।
MySQL के InnoDB engine का डिफ़ॉल्ट REPEATABLE READ है। Transaction में सामान्य SELECT पहली read पर बने consistent snapshot से पढ़ते हैं; locking reads (SELECT … FOR UPDATE या FOR SHARE), UPDATE और DELETE सबसे नई committed rows पर काम करते हैं और locks लेते हैं, जिनमें next-key (gap) locks भी हैं जो scan की गई range में inserts रोकते हैं। PostgreSQL के विपरीत, किसी दूसरे committed transaction से बदली row का update फ़ेल नहीं होता — वह सबसे नए वर्ज़न पर लागू होता है — इसलिए ऐप्लिकेशन कोड में read-then-write तब तक update खो सकता है जब तक वह locking read या atomic statement इस्तेमाल न करे। SQLite isolation अपने तरीक़े से लागू करता है, इसलिए PostgreSQL या MySQL जैसा व्यवहार मानने के बजाय उसका documentation देखें।
- READ UNCOMMITTED — मानक इस level पर तीनों phenomena की अनुमति देता है: dirty read, non-repeatable read और phantom read।
- READ COMMITTED — dirty read रोकता है; non-repeatable read और phantom read संभव रहते हैं।
- REPEATABLE READ — dirty read और non-repeatable read रोकता है; मानक के अनुसार phantom read संभव है।
- SERIALIZABLE — तीनों phenomena रोकता है।
- ये SQL मानक की न्यूनतम गारंटियाँ हैं; engines ज़्यादा रोक सकते हैं: PostgreSQL कभी dirty read नहीं होने देता, और उसका REPEATABLE READ phantoms भी रोकता है। Lost update मानक की तालिका में नहीं है; ऊपर engine नोट्स देखें। नीचे की तालिका चारों levels का अंग्रेज़ी शब्दों में सारांश है (possible = संभव, prevented = रोका गया)।
Isolation level Dirty read Non-repeatable read Phantom read
READ UNCOMMITTED possible possible possible
READ COMMITTED prevented possible possible
REPEATABLE READ prevented prevented possible
SERIALIZABLE prevented prevented preventedConcurrency tests जो हाथ से चलें और automate हों
सबसे सरल concurrency test के लिए दो SQL sessions और steps का लिखित क्रम चाहिए, जैसा ऊपर की script में है। यह race को निश्चित रूप से दोहराता है, जो bug report के लिए बिलकुल सही है। फिर असली ऐप्लिकेशन टेस्ट करें: प्रतिस्पर्धी requests API से parallel भेजें, छोटी script से या k6 या JMeter जैसे load tool से कुछ virtual users के साथ, और run को इतनी बार दोहराएँ कि वह window पकड़ में आ जाए।
जहाँ सिर्फ़ एक सफल हो सकता है, वहाँ concurrency test का expected result कभी “दोनों सफल” नहीं होता। ठीक एक request जीतती है; दूसरी को परिभाषित response मिलता है (conflict, out-of-stock message, या पारदर्शी retry); और बाद में डेटा मेल खाता है — stock कभी ऋणात्मक नहीं, coupon अपनी सीमा से ज़्यादा इस्तेमाल नहीं, और transfer में balances का योग अपरिवर्तित। Engines deadlocks पहचानकर एक transaction abort करते हैं; ऐप्लिकेशन को उसे retry करना चाहिए, और यूज़र को कभी 500 नहीं दिखना चाहिए।
- स्टॉक का आख़िरी item दो ग्राहक एक ही पल में ख़रीदते हैं।
- Submit पर double-click, या timeout के बाद client retry — idempotency key के साथ दूसरा request दूसरा ऑर्डर बनाने की जगह पहले का नतीजा लौटाता है।
- दो admins एक ही record edit करते हैं — optimistic locking के साथ दूसरा save चुपचाप overwrite करने की जगह conflict से reject होता है।
- सीमित coupon जिसे बहुत-से यूज़र एक साथ redeem करते हैं।
- एक ही दो खातों के बीच उलटी दिशाओं में transfers — क्लासिक deadlock पैटर्न।
-- Option 1: lock the row while deciding (PostgreSQL, MySQL; not SQLite)
BEGIN;
SELECT stock FROM inventory WHERE sku = 'SKU-100' FOR UPDATE;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'SKU-100';
COMMIT;
-- Option 2: one conditional, atomic statement; the app checks rows affected = 1
UPDATE inventory
SET stock = stock - 1
WHERE sku = 'SKU-100' AND stock >= 1;
-- Option 3: optimistic locking with a version column; 0 rows affected = conflict
UPDATE orders
SET status = 'SHIPPED', version = version + 1
WHERE order_id = 1042 AND version = 7;Schema और data migrations: क्या गड़बड़ होता है
Schema migration ढाँचा बदलता है — टेबल, कॉलम, index या constraint जोड़ता है, कुछ rename या drop करता है। Data migration सामग्री बदलता है — नए कॉलम को backfill करता है, नाम फ़ील्ड को बाँटता है, currencies बदलता है। Flyway, Liquibase या framework के अपने migrations जैसे tools क्रमांकित scripts क्रम से लागू करते हैं और दर्ज करते हैं कि कौन-सी चलीं। Tool कोई भी हो, जोखिम वही हैं:
- Locks और table rewrites — कुछ operations, जैसे कॉलम का type बदलना या engine के online विकल्प के बिना index बनाना, टेबल को lock या rewrite करते हैं। Test database पर सेकंड production वॉल्यूम पर मिनट या घंटे बन सकते हैं। कोई operation कैसे व्यवहार करेगा, यह engine और उसके वर्ज़न पर निर्भर है।
- Data loss — कॉलम drop या संकरा करना, decimal scale घटाना, text को numbers में बदलना, या character set बदलना बिना error के values नष्ट या ख़राब कर सकता है।
- अधूरे backfills — script NULLs, असामान्य values, या अपने चलते समय बनी rows छोड़ देती है।
- Rollout के दौरान compatibility — rolling deployment में पुराना और नया ऐप्लिकेशन वर्ज़न एक ही schema पर एक साथ चलते हैं, इसलिए कॉलम rename करना पुराने वर्ज़न को तुरंत तोड़ देता है।
- Partial failure — MySQL DDL statements से पहले और बाद में implicit commit करता है, इसलिए बीच में फ़ेल हुआ multi-step migration schema को आधा बदला छोड़ देता है। PostgreSQL ज़्यादातर DDL transaction के अंदर चला सकता है, पर CREATE INDEX CONCURRENTLY जैसे कुछ statements transaction block के अंदर नहीं चल सकते।
- One-way steps — down script drop हुआ कॉलम फिर बना सकती है, पर उसमें रहा डेटा नहीं।
Migration कैसे टेस्ट करें
हर अहम migration का rehearsal production जैसे वॉल्यूम और आकार वाले डेटाबेस पर करें — वही engine और वर्ज़न, मिलती-जुलती row counts, मिलते-जुलते value distributions। इसे generated सिंथेटिक डेटा से बनाएँ, या production डेटा से सिर्फ़ ऐसी anonymisation प्रक्रिया के ज़रिए जिसे आपके data protection officer ने मंज़ूरी दी हो; सऊदी PDPL test environments में personal data पर भी लागू होता है। Migration का समय नापें और चलते समय blocked queries देखें, बेहतर हो कि उसी समय test traffic भी ऐप्लिकेशन पर आ रहा हो।
पहले और बाद में चलने वाली queries से पुष्टि करें: row counts, पैसे वाले कॉलमों के sums, status के हिसाब से distributions, और बदलाव से जुड़ी ख़ास जाँचें — backfill हुए कॉलम में कोई NULL न बचे, सिर्फ़ अनुमत values हों, constraints valid हों। “पहले” के नतीजे सेव करें ताकि तुलना सटीक हो।
Compatibility की योजना expand-and-contract पैटर्न (जिसे parallel change भी कहते हैं) से बनाएँ: पहले पुराना ढाँचा रखते हुए नया जोड़ें, दोनों में लिखें, backfill करें, reads को नए ढाँचे पर ले जाएँ, और पुराना ढाँचा सिर्फ़ बाद की release में हटाएँ। तब हर step backward-compatible रहता है, और आप नए schema पर पुराना ऐप्लिकेशन वर्ज़न टेस्ट कर सकते हैं। आख़िर में वापसी का रास्ता टेस्ट करें: rehearsal database पर down migration या restore प्रक्रिया चलाएँ, उसका समय नापें, और लिखें कि कौन-से steps one-way हैं।
-- Fingerprint: run before and after the migration and compare the results
SELECT 'orders' AS table_name, COUNT(*) AS row_count, SUM(total_amount) AS amount_sum FROM orders
UNION ALL
SELECT 'order_items', COUNT(*), SUM(quantity * unit_price) FROM order_items
UNION ALL
SELECT 'payments', COUNT(*), SUM(amount) FROM payments;
-- A distribution that must not change
SELECT status, COUNT(*) AS order_count
FROM orders
GROUP BY status
ORDER BY status;
-- Example: after adding orders.currency with a backfill to 'SAR'
SELECT COUNT(*) AS missing_currency FROM orders WHERE currency IS NULL;
SELECT currency, COUNT(*) AS order_count
FROM orders
GROUP BY currency
ORDER BY currency;Migration test checklist
यह checklist हर उस migration के ticket में कॉपी करें जो डेटा बदलता है या बड़ी टेबल को छूता है। यह जान-बूझकर छोटी है; हर लाइन एक सवाल है जिसका जवाब release से पहले किसी को “हाँ” में दे सकना चाहिए।
MIGRATION TEST CHECKLIST: <migration id>
पहले
[ ] बदलाव का वर्गीकरण: schema, data, या दोनों
[ ] विनाशकारी steps पहचाने गए (DROP, संकरा type या length, data rewrite)
[ ] production जैसे वॉल्यूम वाला rehearsal database (सिंथेटिक या मंज़ूर anonymised डेटा)
[ ] fingerprint queries चलाईं और नतीजे सेव किए (counts, sums, distributions)
[ ] backup या point-in-time restore की पुष्टि; restore का समय ज्ञात
Rehearsal के दौरान
[ ] समय नापा; locks और blocked queries देखीं
[ ] migration चलते समय ऐप्लिकेशन traffic चालू
[ ] बीच में failure simulate किया; उसके बाद की state समझी
बाद में
[ ] fingerprint queries मेल खाती हैं, या ठीक उतनी अलग हैं जितना इरादा था
[ ] नए कॉलम भरे हुए; कोई अनपेक्षित NULL नहीं; constraints valid
[ ] पुराना ऐप्लिकेशन वर्ज़न नए schema पर काम करता है
[ ] नया ऐप्लिकेशन वर्ज़न smoke और integrity checks पास करता है
[ ] down migration या restore टेस्ट किया; one-way steps दर्ज
[ ] मुख्य queries के plans अपरिवर्तित या बेहतरCI में transaction और migration tests का automation
डेटाबेस टेस्ट production वाले ही engine और major वर्ज़न पर चलाएँ, pipeline में नए सिरे से शुरू किए हुए — आम तौर पर container में। PostgreSQL या MySQL की जगह SQLite या in-memory डेटाबेस लगाना ठीक वही व्यवहार छिपा देता है जिसके बारे में यह पेज है: isolation, locking, transactions में DDL और constraint enforcement सब अलग हैं।
Pipeline को सारे migrations ख़ाली डेटाबेस पर लागू करने चाहिए, नए migrations पिछली released वर्ज़न पर seed किए डेटाबेस पर लागू करने चाहिए, concurrency cases सहित integration tests चलाने चाहिए, और read-only integrity queries के साथ ख़त्म होना चाहिए। बड़े वॉल्यूम वाले rehearsals ज़्यादा समय लेते हैं और आम तौर पर हर commit पर नहीं, बल्कि तय समय पर या release से पहले चलते हैं।
- शुरू से और पिछली release से, दोनों तरह के migrations सफल होते हैं।
- Down migrations, जहाँ टीम उन्हें सपोर्ट करती है, seed किए डेटा पर साफ़ चलते हैं।
- Concurrency tests सिर्फ़ response codes पर नहीं, अंतिम डेटा पर assert करते हैं।
- Suite के बाद integrity queries (orphans, duplicates, reconciliation) शून्य rows लौटाती हैं।
- Rehearsal में तय सीमा से ज़्यादा देर टेबल lock करने वाला migration release रोक देता है।
आज़माएँ: SQL INSERT जनरेटर आज़माएँ: बाउंड्री नंबर डेटासेट जनरेटर
अक्सर पूछे जाने वाले सवाल
हमारे ऐप्लिकेशन को कौन-सा isolation level इस्तेमाल करना चाहिए?
आम तौर पर engine का डिफ़ॉल्ट, और जहाँ कोई business rule एक ही डेटा को पढ़कर फिर लिखने पर निर्भर है वहाँ स्पष्ट सुरक्षा: locking read, atomic conditional update या optimistic locking। SERIALIZABLE जैसे सख़्त levels सही हैं पर ऐप्लिकेशन से aborted transactions retry करवाते हैं। यह डिज़ाइन का फ़ैसला है; टेस्टिंग दिखाती है कि चुना गया तरीका टिकता है या नहीं।
क्या PostgreSQL का REPEATABLE READ phantom reads होने देता है?
नहीं। SQL मानक REPEATABLE READ पर phantoms की अनुमति देता है, पर PostgreSQL का implementation ऐसा snapshot इस्तेमाल करता है जो उन्हें रोकता है। Serialization anomalies फिर भी संभव हैं, जिन्हें सिर्फ़ SERIALIZABLE रोकता है।
क्या हर migration rollback हो सकता है?
नहीं। ढाँचा जोड़ना आम तौर पर उलटा जा सकता है; कॉलम drop करना, types संकरे करना और डेटा rewrite करना नहीं, क्योंकि मूल values जा चुकी हैं। इन steps के लिए rollback योजना backup या point-in-time restore है, जिसे rehearsal में टेस्ट और time किया गया हो।
Migration rehearsal के लिए कितना डेटा चाहिए?
इतना कि production का व्यवहार दोहराया जा सके: migration जिन टेबलों को छूता है उनके लिए मिलती-जुलती row counts और वास्तविक value distributions, NULLs और edge cases सहित। दस rows सिर्फ़ यह साबित करती हैं कि script parse होती है।
बिना ख़ास tools के concurrency कैसे टेस्ट करें?
दो SQL sessions खोलें और race को ठीक-ठीक दोहराने के लिए steps का लिखित क्रम अपनाएँ। ऐप्लिकेशन के लिए छोटी script या कम users वाले load tool से parallel requests भेजें, run दोहराएँ, और अंतिम डेटा पर assert करें।
क्या CI में डेटाबेस टेस्ट के लिए SQLite काफ़ी है?
जो logic engine पर निर्भर नहीं, उसके लिए हो सकता है। Transactions, isolation, locking, migrations और constraint enforcement उसी engine पर टेस्ट करें जिस पर आप ship करते हैं — इनमें से हर क्षेत्र में SQLite, PostgreSQL और MySQL से अलग व्यवहार करता है।
संदर्भ
- Transactions — PostgreSQL Documentationtransaction क्या है: कई क़दम जो BEGIN, COMMIT और ROLLBACK से सब-या-कुछ-नहीं लागू होते हैं, commit होने तक दूसरों को नहीं दिखते।
- Transaction Isolation — PostgreSQL DocumentationSQL isolation levels और वे किन घटनाओं की अनुमति देते हैं — dirty read, non-repeatable read, phantom, serialization anomaly — PostgreSQL में जैसे लागू हैं।
- Transaction Isolation Levels — MySQL 8.4 Reference Manualचार SQL isolation levels जैसे InnoDB उन्हें लागू करता है, जिसमें REPEATABLE READ डिफ़ॉल्ट है।
- Transaction — SQLiteSQLite transactions कैसे शुरू, commit और rollback करता है, deferred, immediate और exclusive मोड सहित।
- Constraints — PostgreSQL Documentationcheck, not-null, unique, primary key, foreign key और exclusion constraints — वे नियम जो डेटाबेस लागू करता है और टेस्टर जाँचता है।
- ISO/IEC 9075-1:2023 — Information technology — Database languages SQL — Part 1: Framework (SQL/Framework)अंतरराष्ट्रीय SQL मानक का framework भाग, जिसे PostgreSQL, MySQL, SQLite आदि की बोलियाँ अलग-अलग हद तक लागू करती हैं।
- Personal Data Protection Law (English translation)व्यक्तिगत डेटा पर सऊदी कानून — टेस्ट वातावरण में असली ग्राहक रिकॉर्ड की प्रतियों के बजाय सिंथेटिक डेटा क्यों इस्तेमाल करना चाहिए।
अभ्यास
Gift card checkout और उसके migration का review करें
आप एक grocery delivery ऐप (काल्पनिक) की release 5.3 टेस्ट कर रहे हैं, जो ग्राहकों को ऑर्डर का भुगतान gift card से करने देती है। एक pull request दो बदलाव लाता है, जो नीचे के material में हैं: A, नया checkout handler (pseudo-code में), और B, migration V42, जो पहला 5.3 instance शुरू होने से पहले gift_cards टेबल बदलता है। Production, MySQL 8.0 पर InnoDB और उसके डिफ़ॉल्ट isolation level के साथ चलता है, और release rolling deployment से जाती है।
गाइड के साथ काग़ज़ पर काम करें: failure injection, दो sessions वाला step schedule, migration के जोखिम, fingerprint queries और migration test checklist। डेटाबेस ज़रूरी नहीं; अगर आपके पास MySQL 8.0 का test instance है, तो step 2 का schedule उस पर चला सकते हैं। हर finding वैसे लिखें जैसे review में उठाएँगे: line या statement, इस engine पर क्या गड़बड़ होता है, कौन-सा test उसे दिखाता है, और expected result। Balance वाले बदलाव के rehearsal डेटा के लिए decimal dataset generator काम आएगा, और text diff migration से पहले और बाद के सेव किए query नतीजों की तुलना करता है। इसके लिए 45–60 मिनट रखें।
Grocery delivery ऐप (काल्पनिक) - release 5.3
Gift cards पहले से मौजूद हैं: ग्राहक उन्हें ख़रीदते हैं, balance देखते हैं, और
release 4.8 से उन पर 3 decimals वाला cashback पाते हैं (जैसे 12.345)।
Release 5.3 ऑर्डर का भुगतान gift card से करने की सुविधा जोड़ती है।
सारे gift cards SAR में हैं।
Production: MySQL 8.0, InnoDB, डिफ़ॉल्ट isolation level (REPEATABLE READ),
लगभग 20 लाख gift cards। Test database: MySQL 8.0, 300 gift cards।
Rolling deployment: ऐप के चार instances एक-एक करके बदले जाते हैं, इसलिए
releases 5.2 और 5.3 लगभग 15 मिनट तक साथ-साथ traffic संभालते हैं।
A. Checkout handler, release 5.3 (pseudo-code; cart.total = cart के सभी items का योग)
1 placeOrder(cart, cardCode):
2 BEGIN
3 card = SELECT balance FROM gift_cards WHERE card_code = cardCode
4 if card.balance < cart.total:
5 ROLLBACK; return 409 "Insufficient gift card balance"
6 orderId = INSERT INTO orders (customer_id, status, total_amount)
7 VALUES (cart.customerId, 'PAID', cart.total)
8 for each item in cart.items:
9 try:
10 INSERT INTO order_items (order_id, product_sku, quantity, unit_price)
11 VALUES (orderId, item.sku, item.qty, item.price) -- FK to products(sku)
12 catch DatabaseError:
13 log.warn("item skipped", item.sku)
14 newBalance = card.balance - cart.total
15 UPDATE gift_cards SET balance = newBalance WHERE card_code = cardCode
16 sendEmail(cart.customerEmail, "Order confirmed", orderId)
17 COMMIT
18 return 201 { orderId }
B. Migration V42 - पहला 5.3 instance शुरू होने से पहले एक बार लागू होता है।
Test database पर यह 3 सेकंड में चला।
ALTER TABLE gift_cards ADD COLUMN currency CHAR(3) NULL;
UPDATE gift_cards SET currency = 'SAR' WHERE currency = '';
ALTER TABLE gift_cards MODIFY balance DECIMAL(10,2) NOT NULL; -- was DECIMAL(12,3)
ALTER TABLE gift_cards RENAME COLUMN code TO card_code;- Part A, atomicity: वे दो जगहें खोजें जहाँ handler ऐसा डेटा या side effects छोड़ सकता है जो commit हुई चीज़ से मेल नहीं खाते। हर एक के लिए failure-injection test लिखें: आप क्या फ़ेल कराते हैं, बाद में क्या जाँचते हैं, और expected result।
- Part A, concurrency: handler में race खोजें। दो sessions वाला step schedule लिखें जो इसे test database पर card
GC-T-001(balance 100.00) और 60.00 व 70.00 के दो ऑर्डरों से दोहराए — मौजूदा कोड जो नतीजा देता है और सही नतीजा, दोनों के साथ। फिर parallel API test बताएँ और यह कि वह किस पर assert करता है। - Part B: V42 को statement-दर-statement गाइड के migration जोखिमों से मिलाकर review करें: locks और table rewrites, data loss, अधूरे backfills, rolling deployment के दौरान compatibility, partial failure और one-way steps।
- V42 का rehearsal plan लिखें: rehearsal database, migration से पहले और बाद में चलने वाली fingerprint queries और वे नतीजे जो सही migration को देने चाहिए, और migration चलते समय आप क्या देखते हैं।
- तय करें कि V42 और handler जैसे लिखे हैं वैसे ship हो सकते हैं या नहीं। लिखें कि क्या बदलना ज़रूरी है, और developers व product owner से आप कौन-से सवाल पूछेंगे।
मॉडल उत्तर दिखाएँ
Atomicity, lines 8–13: निगला गया item error। MySQL InnoDB में foreign-key violation जैसा error सिर्फ़ फ़ेल हुए INSERT को rollback करता है; transaction खुला रहता है, इसलिए line 17 का COMMIT ऑर्डर, error से पहले लिखे items और घटा हुआ balance रख लेता है। ग्राहक पूरा cart.total चुकाता है, उस item समेत जो कभी सेव ही नहीं हुआ। Failure-injection test: cart में तीन items रखें, जिनमें तीसरा एक test product हो, और checkout से पहले उस product को products से हटा दें, मानो उसे catalogue से निकाल दिया गया हो (या तीसरा item ऐसे SKU के साथ भेजें जो मौजूद ही नहीं); फिर पर्याप्त balance वाले card से checkout करें। Expected: "आपके cart का एक item अब उपलब्ध नहीं है" जैसा साफ़ 4xx message, test customer के लिए orders या order_items में कोई नई row नहीं, card का balance अपरिवर्तित और कोई email नहीं। यह कोड 201 लौटाता है और पूरा total काटकर दो items वाला ऑर्डर छोड़ देता है। Retest के लिए fix: database का कोई भी error पूरा transaction rollback करे और वही message लौटाए। PostgreSQL पर यही कोड अलग तरह से फ़ेल होता है: error transaction को abort कर देता है और हर अगला statement फ़ेल होता है, इसलिए कुछ commit नहीं होता, पर request एक unhandled error पर ख़त्म होती है। इसीलिए गाइड कहती है कि error paths उसी engine पर टेस्ट करें जिस पर आप ship करते हैं।
Atomicity, lines 16–17: COMMIT से पहले email। Confirmation डेटा commit होने से पहले ही system से निकल जाता है, और कोई rollback उसे वापस नहीं बुला सकता; साथ ही, mail server के जवाब देने तक gift card की row lock रहती है। Test, test environment में: lines 16 और 17 के बीच, यानी email भेजे जाने के बाद और COMMIT से पहले, ऐप्लिकेशन को breakpoint पर रोकें और वहीं process को kill कर दें — या ऐसा fault-injection switch इस्तेमाल करें जो developers tests के लिए जोड़ें। Expected: कुछ भी आधा लिखा नहीं, और ऐसे ऑर्डर का कोई confirmation नहीं जो डेटाबेस में है ही नहीं। यह कोड "Order confirmed" ऐसे order id के साथ भेजता है जो मौजूद नहीं, क्योंकि connection बंद होने पर MySQL uncommitted transaction को rollback कर देता है। Retest के लिए fix: email COMMIT के बाद भेजें, या उसे transaction के अंदर outbox टेबल में लिखें और commit के बाद ही भेजें।
Concurrency, lines 3, 14 और 15: पहले पढ़ना, फिर ऐप्लिकेशन में निकाली गई value लिखना। Line 3 एक सामान्य SELECT है, इसलिए एक ही card वाले दो checkouts दोनों पुराना balance पढ़ सकते हैं, दोनों line 4 की जाँच पास कर सकते हैं, और दोनों उसी पुरानी value से निकाला balance लिखते हैं: lost update। MySQL InnoDB पर REPEATABLE READ में दूसरा UPDATE फ़ेल नहीं होता; वह row के सबसे नए वर्ज़न पर लागू होता है, इसलिए दोनों commits सफल होते हैं और card अपनी रक़म से ज़्यादा का भुगतान कर देता है। गाइड के सभी fixes यहाँ काम करते हैं: locking read (line 3 पर SELECT balance ... FOR UPDATE); या एक atomic conditional statement UPDATE gift_cards SET balance = balance - ? WHERE card_code = ? AND balance >= ?, जिसके बाद कोड जाँचे कि ठीक एक row affected हुई, वरना rollback करे और 409 लौटाए; या version कॉलम के साथ optimistic locking। चुनाव developers का है; आपके tests उनके चुने किसी भी तरीक़े पर पास होने चाहिए।
MySQL 8.0 test database पर दो sessions वाला schedule, GC-T-001 का balance 100.00। Step 1, session A: BEGIN; फिर SELECT balance FROM gift_cards WHERE card_code = 'GC-T-001'; — 100.00 पढ़ता है। Step 2, session B: BEGIN; और वही SELECT — यह भी 100.00 पढ़ता है। Step 3, session B, 60.00 का ऑर्डर: UPDATE gift_cards SET balance = 40.00 WHERE card_code = 'GC-T-001'; फिर COMMIT;। Step 4, session A, 70.00 का ऑर्डर: UPDATE gift_cards SET balance = 30.00 WHERE card_code = 'GC-T-001'; फिर COMMIT;। मौजूदा नतीजा: दोनों commits सफल और balance 30.00, जबकि 100.00 से 130.00 के ऑर्डर चुकाए गए; B का update खो गया। PostgreSQL पर REPEATABLE READ में step 4 इसके बजाय serialization error से फ़ेल होता, और उसके डिफ़ॉल्ट READ COMMITTED पर update वहाँ भी खो जाता। सही नतीजा: सिर्फ़ एक ऑर्डर का भुगतान हो। Line 3 पर FOR UPDATE के साथ step 2 में B की read इंतज़ार करती है; A आगे बढ़कर 70.00 चुकाता और commit करता है, फिर B 30.00 पढ़ता है और उसे 60.00 का ऑर्डर 409 के साथ ठुकराना होता है।
फिर ऐप्लिकेशन test: GC-T-001 वाले दोनों checkouts API से parallel भेजें — छोटी script से, या k6 या JMeter जैसे load tool से दो virtual users के साथ। Card को 100.00 पर reset करें और run कई बार दोहराएँ, क्योंकि race की window छोटी है। सिर्फ़ status codes पर नहीं, अंतिम डेटा पर assert करें: हर run में ठीक एक 201 और एक 409 "Insufficient gift card balance"; अंतिम balance 40.00 या 30.00 है और जीतने वाले ऑर्डर से मेल खाता है; वह कभी negative नहीं होता; और चुकाए गए ऑर्डरों का योग कभी 100.00 से ज़्यादा नहीं होता।
Migration V42, statements 1 और 2। Nullable कॉलम जोड़ना सिर्फ़ ढाँचा बदलता है और उलटा जा सकता है। Backfill ग़लत है: statement 1 के बाद हर मौजूदा row में currency NULL है, और NULL = '' कभी true नहीं होता, इसलिए WHERE currency = '' कोई row update नहीं करता; test database पर UPDATE 300 की जगह 0 affected rows बताता है। Condition WHERE currency IS NULL होनी चाहिए; 20 लाख rows पर यह UPDATE commit होने तक हर scan की गई row को lock रखता है, इसलिए rehearsal में इसका समय नापें और इसे batches में चलाएँ। तब भी, rollout के दौरान चल रहे 5.2 instances बिना currency के gift cards बनाते रहते हैं। या तो कॉलम को DEFAULT 'SAR' मिले, ताकि हर नए card को SAR मिले, चाहे उसे कोई भी वर्ज़न लिखे, या 5.3 SAR लिखे और आख़िरी 5.2 instance हटने के बाद backfill फिर चले। उसके बाद ही कोई बाद का migration कॉलम को NOT NULL बना सकता है।
Statement 3, यानी balance को DECIMAL(12,3) से DECIMAL(10,2) करना, schema बदलाव में छिपा data migration है। Data loss: तीसरे decimal वाला हर balance, जैसे cashback से आया 12.345, बिना error के वह अंक खो देता है, यानी ग्राहकों का पैसा बदल जाता है; और यह one-way step है, क्योंकि down migration खोया अंक वापस नहीं ला सकता, इसलिए वापसी का रास्ता backup से restore है। Precision भी 12 से 10 अंकों पर आ जाती है, इसलिए जाँचें कि सबसे बड़ा balance अब भी समाता है। Locks: कॉलम का type बदलने पर MySQL 8.0 पूरी टेबल copy करता है, और copy पूरी होने तक gift_cards पर writes इंतज़ार करते हैं; 300 rows के लिए 3 सेकंड, 20 लाख rows के बारे में कुछ नहीं बताते। Statement 4, यानी rename: V42 तब चलता है जब चारों instances अभी 5.2 पर हैं, इसलिए इसके चलने से लेकर आख़िरी 5.2 instance बदले जाने तक, 5.2 की हर query जो अब भी code इस्तेमाल करती है, फ़ेल होती है — जैसे balance दिखाना या cashback जोड़ना। Partial failure: MySQL हर DDL statement से पहले और बाद में implicit commit करता है, इसलिए अगर V42 statement 3 या 4 पर रुक जाए, तो पहले के statements लागू रहते हैं, और V42 दोबारा चलाने पर वह statement 1 पर ही फ़ेल होता है, क्योंकि currency पहले से मौजूद है।
Rehearsal plan। Database: production जैसा MySQL 8.0, लगभग 20 लाख gift cards के साथ — production जैसे आकार का generated सिंथेटिक डेटा (दो और तीन decimals वाले balances, शून्य balances, सबसे बड़ी वास्तविक values), या production डेटा सिर्फ़ ऐसी anonymisation प्रक्रिया से जिसे data protection officer ने मंज़ूरी दी हो, क्योंकि gift cards ग्राहकों के हैं और PDPL test environments में personal data पर भी लागू होता है। पहले: backup या point-in-time restore की पुष्टि करें, फिर SELECT COUNT(*) AS card_count, SUM(balance) AS balance_sum FROM gift_cards; और SELECT COUNT(*) AS three_decimals FROM gift_cards WHERE balance <> ROUND(balance, 2); चलाकर नतीजे सेव करें। दौरान: 5.2 का test traffic चालू रखें, क्योंकि V42 चलते समय live वर्ज़न 5.2 ही है, हर statement का समय नापें, blocked queries देखें, और एक run में migration को बीच में रोककर उसके बाद की state दर्ज करें। बाद में: card_count और balance_sum अपरिवर्तित (अगर पहले three_decimals 0 से ज़्यादा था, तो जैसा लिखा है वैसा V42 उतने balances बदल देता है, और balance_sum भी आम तौर पर बदल जाता है — यही अंकों में data loss है); SELECT COUNT(*) AS missing_currency FROM gift_cards WHERE currency IS NULL; 0 लौटाती है और currency के हिसाब से grouping में सिर्फ़ SAR दिखता है; 5.2 नए schema पर अब भी काम करता है; 5.3 smoke और integrity checks पास करता है; restore टेस्ट और time किया जा चुका है। सेव किए नतीजों की तुलना आँख से नहीं, text diff से करें।
फ़ैसला: दोनों में से कोई बदलाव जैसा लिखा है वैसा ship नहीं होता। रोकने वाली findings: partial ऑर्डर और lost update (ग्राहक ऐसे सामान के पैसे देते हैं जो उन्हें नहीं मिलता, या card अपनी रक़म से ज़्यादा चुकाता है), COMMIT से पहले email, कुछ भी update न करने वाला backfill, rollout के दौरान 5.2 को तोड़ने वाला rename, और ग्राहकों के balances में ऐसा बदलाव जिसका फ़ैसला किसी ने नहीं किया। Expand-and-contract योजना माँगें: V42 सिर्फ़ currency जोड़े; 5.3 नए cards के लिए SAR लिखे, या कॉलम को यही default मिले; batches में चलने वाला backfill WHERE currency IS NULL rollout के बाद चले और NOT NULL बाद की release में आए; 5.3 का handler नाम code ही इस्तेमाल करे, या card_code जोड़ा जाए, code के साथ उसमें भी लिखा जाए और उसे backfill किया जाए, reads उस पर ले जाए जाएँ, और code सिर्फ़ 5.3 के बाद की किसी release में drop हो; balance का बदलाव product owner के फ़ैसले का इंतज़ार करे और अपने अलग migration व rehearsal के साथ आए; हर migration में एक ही DDL बदलाव, ताकि failure एक ज्ञात state छोड़े। Product owner से सवाल: मौजूदा balances के तीसरे decimal का क्या होगा (कौन-सा rounding नियम, अंतरों की report, क्या ग्राहकों को बताया जाएगा)? Developers से: concurrency का कौन-सा fix, और क्या हारने वाली request हमेशा 409 पर ख़त्म होती है, कभी 500 या lock-wait timeout पर नहीं? क्या lock के समय की कोई तय सीमा है जो release रोकती है?
अगर आपकी सूची अलग है, तो हर finding को गाइड के सवालों से मिलाएँ। क्या handler साथ रहने वाली हर चीज़ एक transaction में रखता है, हर error पर rollback करता है, और COMMIT से पहले डेटाबेस के बाहर कुछ नहीं करता? क्या concurrency test अंतिम डेटा पर assert करता है? क्या हर migration step चल रहे वर्ज़न के साथ compatible रहता है, डेटा बचाता है, और उसका टेस्ट किया हुआ वापसी का रास्ता है? जो finding गाइड में नहीं है, वह भी ठीक है, अगर आप उससे होने वाला failure और उसे दिखाने वाला test बता सकें।
इस topic में आपकी प्रगति
प्रगति ट्रैक करने के लिए साइन इन करें
Topics पूर्ण चिह्नित करें, जहाँ छोड़ा था वहीं से जारी रखें और specialisation परीक्षाएँ खोलें।
QA रोडमैप का हिस्सा
इस गाइड में बताए गए टूल्स
किसी भी dialect के लिए वास्तविक जैसी टेस्ट पंक्तियों के साथ SQL seed स्क्रिप्ट (CREATE TABLE + INSERT) जनरेट करें।
SQL स्टेटमेंट में सिंटैक्स की समस्याएँ, असंतुलित कोष्ठक, बिना बंद की स्ट्रिंग और जोखिम भरे पैटर्न जाँचें।
SQL क्वेरी को एकसमान कीवर्ड केसिंग, इंडेंटेशन और लाइन ब्रेक के साथ फ़ॉर्मेट करें (MySQL, PostgreSQL, SQL Server, Oracle, SQLite)।
टेबल परिभाषा, नमूना पंक्तियों, CSV या JSON से INSERT स्टेटमेंट बनाएँ।
सीमाओं के आसपास की वैल्यू जनरेट करें: किसी भी चौड़ाई के पूर्णांकों के लिए min-1, min, min+1, max-1, max, max+1।