Why NextWhy Next
सूची पर लौटें
💻 डेवलपमेंट13 मिनट में पढ़ें

Test हरे थे, पर notification एक बार भी नहीं गया था

Queue में job डालने वाला code पहले दिन से हर बार fail हो रहा था। error निगल लिया गया, और mock की बदौलत test हरे रहते थे। एक colon ने तीन feature चुपचाप मार दिए, यह उसी की कहानी है।

मुख्य सारांश

BullMQ custom jobId में colon की इजाजत नहीं देता। इस नियम को तोड़ने वाला enqueue पहले दिन से 100% fail हो रहा था, लेकिन failure को catch में निगल लिया गया था, इसलिए किसी को पता ही नहीं चला कि notification feature मरा पड़ा है। test queue की जगह शुद्ध mock इस्तेमाल करते थे और असली validation कभी चलाते ही नहीं थे, इसलिए हमेशा हरे रहे। यही defect दो और queue में मिला, और उनमें से एक 12 दिन तक बिना किसी लक्षण के चलता रहा। ठीक करते हुए दूसरा जाल भी सामने आया - fixed jobId तब re-enqueue को बिना error के ignore कर देता है जब completed job का hash बचा हो, और इस तरह retry के contract को चुपचाप मार देता है।

इस पेज पर

Slack पर 500 का alert आया। error message अनजाना था। Error: Custom Id cannot contain :। वह API मर रहा था जिससे admin user के सवाल पर जवाब register करता है, और stack trace BullMQ के Job.validateOptions की ओर इशारा कर रहा था।

यहाँ तक तो यह एक मामूली bug report है। कहानी मामूली रहना वहीं बंद हो जाती है जहाँ कारण मिल जाता है। यह error फेंकने वाला code उस दिन के deploy में बदला ही नहीं था। जिस दिन यह feature पहली बार आया, उसी दिन से वह हर बार, 100% fail हो रहा था। मतलब यह कि इस queue से जो push notification जाने चाहिए थे, वे आज तक एक बार भी नहीं भेजे गए। और इस पूरे दौरान सारे test हरे थे।

कारण था एक अक्षर

समस्या वाला code कुछ ऐसा दिखता था। सवाल पर जवाब आते ही user को push भेजने वाला job queue में डालने की जगह है यह।

await this.queue.add(ANSWER_NOTIFY_JOB_NAME, data, {
  jobId: `answer-notify:${data.questionId}`, // ← यही colon
});

एक ही सवाल पर job दो बार न घुसे, इसलिए custom jobId दिया गया था, लेकिन separator के तौर पर colon लिख दिया गया। BullMQ Redis key colon से जोड़ता है, इसलिए custom jobId में colon मना है। v5 में सटीक शर्त यह है: jobId में colon हो, और colon से बाँटने पर टुकड़े 3 न हों, तो error फेंका जाता है। 3 टुकड़ों वाला exception legacy repeatable job format के लिए रखा गया carve-out है, इसलिए answer-notify:123 जैसे 2 टुकड़ों पर हर हाल में Custom Id cannot contain : मिलेगा। और हाँ, शुद्ध numeric jobId भी Custom Id cannot be integers कहकर reject होता है, इसलिए वह prefix हटा देना भी कोई हल नहीं है।

Codebase की बाकी दर्जनों queue सब hyphen इस्तेमाल कर रही थीं। सिर्फ इसी file में colon था।

Bug पहले दिन से था, फिर आज ही क्यों फटा

ठीक करना एक line का काम है। colon को hyphen कर दो, बस। दिलचस्प सवाल दूसरा है। हमेशा fail होने वाला code अब तक चुप क्यों था, और आज अचानक 500 क्यों बन गया।

Feature जब पहली बार deploy हुआ, enqueue कुछ इस तरह लपेटा हुआ था।

try {
  await this.queue.add(/* ... */);
  return true;
} catch (err) {
  this.logger.error("enqueue failed", err);
  return false; // ← failure यहीं गायब हो जाता है
}

Failure को log करके false लौटा देता है। caller return value देखता ही नहीं। नतीजा यह कि हर बार फेंका जाने वाला exception हर बार निगल लिया गया, API 200 लौटाता रहा, और notification चुपचाप हवा होते रहे।

फिर उसी दिन merge हुए एक commit ने यह contract बदल दिया। मंशा यह थी कि enqueue fail हो तो निगलो मत, फेंको, ताकि transaction में लगाया गया "answered" का निशान वापस लिया जा सके और retry हो सके। दिशा बिल्कुल सही थी। और उसी पल हमेशा fail होने वाले enqueue ने पहली बार आवाज की। वह 500 उस commit का बनाया bug नहीं था, बल्कि पहले से मौजूद bug की पहली चीख थी।

Error निगलने वाले code की कीमत यही है। signal गायब हो जाता है, और bug वैसा का वैसा बैठा रहकर पूरा एक feature साथ ले जाता है।

Test हरे क्यों थे

इस queue के test थे। enqueue call करने पर queue.add सही argument के साथ बुलाया गया या नहीं, यह जाँचने वाला आम किस्म का spec। और उस test का queue.add एक ऐसा mock था जो हर हाल में succeed करता था।

असली BullMQ का add jobId validate करता है और colon reject करता है। mock का add कुछ भी ले लेता है। mock जितना असली contract से ज्यादा उदार होता है, test उतना ही ऐसे input को हरा करके पास कर देता है जो असलियत में कभी पास हो ही नहीं सकता। mock असली contract से अलग हो, तो हरा test जिंदा code की नहीं, मरे हुए code की गारंटी देता है।

ठीक करते समय सबसे पहले यही छेद भरा। queue mock के add को बदला ताकि वह BullMQ का असली Job.validateOptions चलाए। यह function Redis के बिना चलने वाला शुद्ध validation है, इसलिए test पर सीधे चढ़ाया जा सकता है। फिर mutation test से पक्का किया कि guard सचमुच guard है। colon वापस लगाकर देखा, test ठीक उसी जगह लाल हुआ, और उसके बाद ही commit किया। bug वापस लाने पर भी test हरा रहे, तो वह test न होने के बराबर है।

मामला एक नहीं था

यहीं रुक जाते तो आधा ही ठीक होता। एक जगह जो गलती है, उसके दूसरी जगहों पर भी होने की संभावना ज्यादा होती है। defect की "class" define की - colon वाला custom jobId - और पूरे codebase की तलाशी ली, ऊपर से कई review agent लगाकर cross-check भी करवाया। दो मामले और निकले।

एक थी वह queue जो user की upload की हुई आवाज को text में transcribe करती है। jobId: "stt:${id}"। यहाँ error अब भी निगला जा रहा था, इसलिए 500 तक नहीं आया। जाँचा तो पता चला कि 12 दिन से transcription का एक भी job queue में नहीं घुसा था। audio file ठीक से save हो रही थी, failure सिर्फ log में दर्ज हो रहा था, और user की screen पर text हमेशा के लिए खाली। पूरी तरह लक्षणहीन।

दूसरी थी scheduled event की सूचना वाली email queue। jobId colon से 4 टुकड़ों में बँटा था, इसलिए reminder और cancellation notice, दोनों queue में घुसते ही नहीं थे।

तीनों queue के producer को एक साझा jobId builder पर ला दिया। यह function hyphen से join करता है, colon मिल जाए तो बदल देता है, और शुद्ध numeric को reject करता है। दोबारा न हो, इसके लिए एक lint rule भी जोड़ा जो ठीक उसी pattern को पकड़ता है जिसमें colon literal jobId में जाता है। मौजूदा hyphen वाले 50 के करीब jobId को जबरदस्ती नए builder पर migrate नहीं किया। repeatable job का jobId बदल जाए तो cron के duplicate register होने का खतरा है, इसलिए सिर्फ defect class को निशाना बनाने का रास्ता चुना।

दूसरा जाल: fixed jobId retry को चुपचाप मार देता है

कहानी यहीं खत्म होती तो अच्छा था, पर review verification के दौरान इससे भी बुनियादी समस्या निकली। colon ठीक करते ही answer-notify-123 जैसा fixed jobId जिंदा हो जाता है, और यह fixed jobId खुद उसी दिन बनाए गए retry contract से टकराता था।

BullMQ का नियम है: उसी jobId का job hash अगर Redis में बचा हो, तो नया add बिना error के ignore होता है और पुराना jobId लौटा दिया जाता है। verifier ने BullMQ की Lua script तक पढ़कर यह behavior confirm किया। और इस queue में removeOnComplete: { count: 100 } था, यानी अभी-अभी complete हुए job का hash भी वहीं पड़ा रहता है। दोनों को जोड़ें तो scenario यह बनता है। notification job एक बार fail होता है। contract के मुताबिक "answered" का निशान वापस लिया जाता है, admin retry करता है। नया enqueue बचे हुए job hash से टकराकर कुछ नहीं करता और success जैसा लौट आता है। notification कभी नहीं जाता, और इस बार न error है, न DLQ।

Failure निगलने वाला catch हटाया, तो library का dedupe वही कुर्सी सँभाल लेता है। आखिर में इस queue से custom jobId पूरी तरह निकाल दिया। duplicate भेजने से रोकने का काम consumer की तरफ ले गए - processor "notification sent" का निशान conditional update से atomically पहले हथिया लेता है। job दो बार भी आ जाए, push वही एक भेजता है जिसने निशान पहले पकड़ा।

अगर आपके design में custom jobId से duplicate रोका जा रहा है और साथ ही failure पर re-enqueue भी है, तो यह जाँच लेना ठीक रहेगा कि दोनों mechanism एक-दूसरे को मार तो नहीं रहे। हो सकता है वह भी हमारी तरह "retry चुपचाप no-op बन जाता है" वाला combination हो।

नुकसान कितना हुआ

12 दिन मरा पड़ा feature सुनकर लगता है नुकसान बड़ा होगा, पर नापने पर तस्वीर अलग निकली। answer notification वाला feature production में अभी असल इस्तेमाल में आया ही नहीं था, इसलिए loss शून्य। email queue में भी सिर्फ बीत चुके event थे, दोबारा भेजने लायक कुछ नहीं। असल में recover करने लायक थीं transcribe न हुई 2 audio। बची हुई audio file से दोबारा transcribe करके recover कर लिया।

किस्मत अच्छी थी। और यह किस्मत दोहराई नहीं जाती। feature असल इस्तेमाल में होता तो 12 दिन के notification और transcription सीधे उड़ जाते, और job queue में बना ही नहीं, इसलिए deploy से कुछ भी वापस नहीं आता। backfill script लिखना गौण काम था। असली बात यह ठीक करना था कि मरा हुआ feature 12 दिन तक मरा रहा और किसी को पता नहीं चला। इसलिए transcription failure अब सिर्फ log में नहीं रहता, alert channel तक पहुँचता है, और answer notification का रास्ता transaction के साथ commit होने वाले outbox पर ले गए, ताकि "DB में success दर्ज है पर job है ही नहीं" वाली स्थिति जड़ से खत्म हो जाए।

जो बचा रह गया

इस घटना से code से ज्यादा देर तक साथ रहने वाली तीन बातें।

पहली, catch में सिर्फ log करके normal return करने वाला code वह रास्ता 100% fail होने पर भी किसी को पता नहीं चलने देता। निगलना ही है तो कम से कम failure गिनने वाला metric या alert साथ हो, और वह न हो तो फेंक देना बेहतर है। फेंका गया error शोर करता है पर ठीक हो जाता है, निगला गया error 12 दिन जीता है।

दूसरी, mock असली contract जितना सख्त होना चाहिए। हर हाल में succeed करने वाले mock पर कोई input validate नहीं होता। library में शुद्ध validation function हो तो उसे mock में चलाओ, और bug वापस लाकर test लाल होते देखो - तब जाकर वह guard है।

तीसरी, defect को class के तौर पर पकड़ो। एक ही codebase में एक ही गलती सिर्फ एक बार नहीं होती। एक मिल गई तो class define करके पूरी तलाशी लो, और lint rule हो या साझा builder, वह class दोबारा न घुस पाए, वहाँ तक जाओ - तभी एक खोज अपनी पूरी कीमत वसूल करती है।

अक्सर पूछे जाने वाले सवाल

BullMQ में 'Custom Id cannot contain :' error आ रहा है। ऐसा क्यों होता है?

BullMQ v5 custom jobId में colon (:) की इजाजत नहीं देता, क्योंकि colon वह separator है जिससे Redis key जोड़ी जाती हैं। सटीक शर्त यह है: jobId में colon हो और colon से बाँटने पर टुकड़े ठीक 3 न हों, तो error फेंका जाता है। 3 टुकड़ों वाला exception legacy repeatable job format के लिए है। शुद्ध numeric jobId भी 'Custom Id cannot be integers' कहकर reject होता है। separator के तौर पर hyphen इस्तेमाल करें।

Mock इस्तेमाल करने वाले test ऐसे bug क्यों नहीं पकड़ पाते?

अगर mock असली implementation के contract से अलग बर्ताव करे, तो test असल में मरे हुए code को हरा दिखाकर उसकी गारंटी दे देता है। queue के add को हमेशा succeed करने वाले mock से बदल दें, तो library का input validation चलता ही नहीं, और हमेशा reject होने वाला jobId भी test पास कर जाता है। mock को कम से कम असली library का validation function जरूर चलाना चाहिए, और bug को वापस लाकर यह भी देखना चाहिए कि test लाल होता है या नहीं (mutation testing) - तभी वह असली guard बनता है।

संबंधित लेख

0 टिप्पणियाँ