সরাসরি পর্বে যান
পর্ব ৬২ · ৬৭-এর মধ্যে · মডিউল ৯

ক্যাপস্টোন — ডিজাইন থেকে deployment পর্যন্ত একটি সম্পূর্ণ ডেটাবেস

Capstone — A Clinic Appointment Database from Requirements, ER and Schema to Constraints, Queries, Concurrency, Indexing, Security, Operations and Deployment

Taught in Bangla, this capstone lesson builds a complete clinic appointment database: requirements and business rules, a conceptual ER model, a normalized logical schema with constraints, realistic sample data generated with recursive CTEs, the application's real queries for free slots, history, revenue and load, preventing double booking inside the database, index plans, roles, views and audit triggers, and backup, monitoring, migration and deployment.

এই পর্বে যা শিখবেন

  • ক্লিনিকের কথাবার্তা থেকে requirement আর ব্যবসার নিয়ম লেখা, আর সেখান থেকে entity ও ER ডায়াগ্রাম
  • সব constraint-সহ schema, referential action বাছাই, আর FD দিয়ে 3NF/BCNF যাচাই
  • অ্যাপের আসল কোয়েরি: খালি slot (anti-join), রোগীর ইতিহাস, দৈনিক আয়, no-show-এর হার আর window function-এ ব্যস্ততা
  • double booking ডেটাবেসেই আটকানো, আর দুজন receptionist একসাথে বুক করলে কী ঘটে — transaction আর isolation দিয়ে ব্যাখ্যা
  • index, role, audit, backup, migration, monitoring আর deployment — একটি release checklist-এ সব একসাথে

১ · প্রয়োজন আর ব্যবসার নিয়ম — ক্লিনিকের কথা শুনে লেখা

মিরপুরের একটি কাল্পনিক “ঢাকা কমিউনিটি হেলথ ক্লিনিক”. প্রতি সন্ধ্যায় ছয়জন ডাক্তার চেম্বারে বসেন — মেডিসিন, শিশু, গাইনি আর চর্মরোগ। এখন বুকিং হয় একটি বাঁধানো খাতায় আর ফোনে। সমস্যা প্রতিদিনের: একই সময়ে দুজন রোগীর নাম, কে টাকা দিলেন তার হিসাব মেলে না, আর পুরোনো prescription খুঁজতে খাতার পাতা ওল্টাতে হয়। ক্লিনিক চায় এমন একটি সিস্টেম, যেখানে দুজন receptionist আর একটি ওয়েবসাইট একসাথে বুকিং নিতে পারে।

ট্রেনের টিকিটের কথা ভাবুন। একটি আসন, একটি যাত্রা, একজন যাত্রী — কাউন্টার যত বেশিই থাকুক। কমলাপুরের দুই কাউন্টারের দুজন কর্মী একই মুহূর্তে শেষ আসনটি বিক্রি করতে চাইলে একজনকে “আসন নেই” শুনতেই হবে। ডাক্তারের প্রতিটি ১৫ মিনিটের slot ঠিক সেই আসন। এই পর্বের পুরো নকশা আসলে এই একটি প্রতিশ্রুতি রক্ষার গল্প।

পেশাদার কাজ শুরু হয় কোড দিয়ে নয়, কথা শুনে (requirements analysis, পর্ব ৭)। ক্লিনিকের ম্যানেজার, দুজন receptionist আর একজন ডাক্তারের সাথে কথা বলে নিচের ব্যবসার নিয়ম লেখা হলো। প্রতিটির একটি নম্বর — পরের প্রতিটি অংশে দেখবেন কোন নিয়ম কোথায় রক্ষা হলো:

নংনিয়মকোথায় রক্ষা হবে
R1একটি slot-এ বাতিল-নয় এমন appointment সর্বোচ্চ একটি — double booking নিষেধpartial UNIQUE index (অংশ ৬)
R2একজন রোগীর একই সময়ে দুটি সক্রিয় appointment থাকবে নাtrigger; PostgreSQL-এ exclusion constraint
R3বাতিল করলে slot আবার খালি হয়, কিন্তু বাতিলের সময়সহ ইতিহাস থাকেstatus + CHECK, সারি মোছা নয়
R4appointment-এর অবস্থা চারটির একটি: booked, completed, no_show, cancelledCHECK
R5ফি নগদ বা bKash-এ; bKash হলে লেনদেনের ID বাধ্যতামূলক আর অনন্যCHECK + UNIQUE
R6prescription মোছা যাবে না; প্রতিটি সংশোধনের হিসাব থাকবেaudit trigger (অংশ ৮)
R7ডাক্তার নিজের রোগীর তালিকা দেখবেন, টাকার হিসাব নয়view, role, privilege
R8প্রতিদিনের রিপোর্ট: আয়, no-show-এর হার, ডাক্তারের ব্যস্ততাকোয়েরি আর index (অংশ ৫, ৭)

সাথে কয়েকটি অ-কার্যকরী (non-functional) প্রয়োজন, যেগুলো পরে ডিজাইন ঠিক করে দেবে: একসাথে তিনটি জায়গা থেকে বুকিং; বিপর্যয়ে সর্বোচ্চ ৫ মিনিটের ডেটা হারানো চলবে আর ১ ঘণ্টার মধ্যে সেবা ফিরতে হবে; আর রোগীর তথ্য স্বাস্থ্য-সংক্রান্ত, তাই গোপনীয়তা সবচেয়ে কঠোর। নিচের stepper-এ দেখুন একটি বাক্য থেকে কীভাবে entity আর নিয়ম বেরিয়ে আসে:

  1. বর্ণনাম্যানেজারের কথা
  2. বিশেষ্যentity-র প্রার্থী
  3. Entityattribute-সহ
  4. ক্রিয়াrelationship
  5. নিয়মconstraint
ক্লিনিকের ম্যানেজারের বর্ণনালাল = এই ধাপের ফল

            
  1. বর্ণনা। ম্যানেজারের নিজের ভাষার একটি অনুচ্ছেদ দিয়ে শুরু — প্রযুক্তির কোনো শব্দ ছাড়া। ভালো প্রশ্ন করলে এমন অনুচ্ছেদই সবচেয়ে বেশি তথ্য দেয়: “বাতিল হলে কী হয়?”, “একজন রোগী একদিনে দুজন ডাক্তার দেখাতে পারেন?”
  2. বিশেষ্য। প্রতিটি বিশেষ্য একটি entity-র প্রার্থী (পর্ব ১১-এর কৌশল)। সবগুলো entity নয়: “সন্ধ্যা” slot-এর সময়ের অংশ, আর “নগদ” পেমেন্টের একটি মান — এগুলো attribute.
  3. Entity. আটটি entity আর তাদের মূল attribute. “ওষুধের লাইন”-এর নিজের পরিচয় নেই — কোন prescription-এর কত নম্বর লাইন, এটুকুই তার key. তাই এটি weak entity (পর্ব ৮)।
  4. ক্রিয়া। “নেন”, “লেখেন”, “দেন” — ক্রিয়াগুলো relationship. লক্ষ করুন slot আর appointment 1 থেকে N: বাতিল appointment-এর সারি থেকে যায়, তাই একই slot-এ সময়ের সাথে একাধিক সারি আসতে পারে।
  5. নিয়ম। “একটি slot-এ একটিই সক্রিয় appointment” ER-এর cardinality দিয়ে আঁকা যায় না — এটি সারির অবস্থার ওপর নির্ভর করে। এমন নিয়মগুলো আলাদা তালিকায় তুলে রাখা হলো, পরে constraint আর trigger-এ রূপ নেবে।

২ · ধারণাগত ER আর মূল সিদ্ধান্ত

পর্ব ৭–৮-এর চিহ্নে পুরো সিস্টেমের ER ডায়াগ্রাম। আয়তক্ষেত্র entity, দুই-রেখার আয়তক্ষেত্র weak entity, রেখার পাশে সংখ্যা cardinality:

ক্লিনিক appointment সিস্টেমের ER ডায়াগ্রাম

Eight entities; appointment links a slot and a patient; prescription items are a weak entity

বিভাগ (specialities) ডাক্তার (doctors) slot (slots) রোগী (patients) appointments পেমেন্ট (payments) prescriptions ওষুধের লাইন (items) 1N 1N 1N 1N 1N 10..1 1N খোলেন নেওয়া হয় (সক্রিয় ≤ ১) লেখা হয় ধারণ করে (চেনায়) কাজ করেন নেন পরিশোধ
কমলা বাক্সটি associative entity: slot আর রোগীকে জোড়ে। “সক্রিয় ≤ ১” ER-এর চিহ্ন নয় — এটি অংশ ৬-এর constraint.

ডায়াগ্রামের পেছনের পাঁচটি সিদ্ধান্ত, আর প্রতিটির বিকল্প কেন বাদ:

১. Slot আলাদা entity

বিকল্প ছিল appointment-এর ভেতরেই ডাক্তার আর সময় রাখা। কিন্তু তাহলে “কোন সময় খালি” প্রশ্নের উত্তর দেওয়ার মতো কোনো সারিই থাকে না — খালি সময়ের অস্তিত্ব নেই। আগে থেকে বানানো slot-এর তালিকা থাকলে খালি slot = যে slot-এর সক্রিয় appointment নেই, একটি anti-join.

২. Appointment = associative entity

slot আর রোগীর সম্পর্কের নিজের attribute আছে (অবস্থা, বুকিং আর বাতিলের সময়), তাই এটি নিজেই একটি entity — associative entity (পর্ব ৮)। বাতিল সারি মোছা হয় না, তাই slot থেকে appointment 1:N.

৩. ওষুধ আলাদা লাইনে

“Omeprazole, Domperidone” এক কলামে লিখলে কলামটি atomic থাকে না — 1NF ভাঙে (পর্ব ৩৬), আর “কতজনকে Omeprazole দেওয়া হলো” প্রশ্ন LIKE-এর অনুমানে নামে। তাই prescription_items, key (rx_id, line_no).

৪. পেমেন্ট 1:N, কলাম নয়

appointment-এ একটি “paid” কলাম সহজ, কিন্তু আংশিক পরিশোধ, পরে ফেরত (refund) বা দুই পদ্ধতিতে ভাগ করে দেওয়া — কোনোটিই ধরে না। প্রতিটি লেনদেন একটি সারি, যেমন bKash-এর নিজের খাতায়।

৫. Key আর সময়। প্রতিটি টেবিলে ছোট সংখ্যার surrogate key, আর বাস্তবের অনন্য পরিচয়গুলো — ডাক্তারের BMDC নিবন্ধন নম্বর, রোগীর ফোন, bKash-এর trx ID — আলাদা UNIQUE (পর্ব ৬)। ফোন নম্বর key বানাইনি, কারণ মানুষ নম্বর বদলান, আর পরিবারের কয়েকজন একটি নম্বর ভাগ করতে পারেন — সেটি এলে UNIQUE তুলে দেওয়া সহজ, primary key বদলানো নয়। সময় রাখা হয়েছে ঢাকার স্থানীয় সময়ে ISO-8601 টেক্সটে ('2026-11-05 17:30:00'), যাতে টেক্সটের ক্রম আর সময়ের ক্রম এক থাকে; PostgreSQL-এ একই কাজ timestamptz করে (পর্ব ১৩, ১৭)।

৩ · Logical schema — CREATE TABLE, constraint আর normalization

পর্ব ১০-এর mapping নিয়ম প্রয়োগ করলে আটটি টেবিল আসে: প্রতিটি strong entity একটি টেবিল; প্রতিটি 1:N relationship “N” দিকে একটি foreign key; weak entity-র primary key = owner-এর key + partial key. নিচে পুরো schema — এই পর্বের প্রতিটি লাইভ এডিটর এটি দিয়েই শুরু হয় (এডিটরের “নমুনা ডেটাবেস” বাক্সে নমুনা ডেটাসহ দেখতে পাবেন):

-- ঢাকা কমিউনিটি হেলথ ক্লিনিক: schema (পর্ব ৬২)
CREATE TABLE specialities (
  spec_id INTEGER PRIMARY KEY,
  name    TEXT NOT NULL UNIQUE
);
CREATE TABLE doctors (
  doctor_id INTEGER PRIMARY KEY,
  name      TEXT NOT NULL,
  spec_id   INTEGER NOT NULL REFERENCES specialities(spec_id) ON DELETE RESTRICT,
  reg_no    TEXT NOT NULL UNIQUE,              -- BMDC নিবন্ধন নম্বর
  fee       INTEGER NOT NULL CHECK (fee BETWEEN 100 AND 5000)   -- টাকা
);
CREATE TABLE patients (
  patient_id INTEGER PRIMARY KEY,
  name       TEXT NOT NULL,
  phone      TEXT NOT NULL UNIQUE CHECK (length(phone) = 11 AND phone LIKE '01%'),
  birth_year INTEGER CHECK (birth_year BETWEEN 1900 AND 2026)
);
CREATE TABLE slots (
  slot_id    INTEGER PRIMARY KEY,
  doctor_id  INTEGER NOT NULL REFERENCES doctors(doctor_id) ON DELETE RESTRICT,
  slot_start TEXT NOT NULL,                    -- 'YYYY-MM-DD HH:MM:SS', ঢাকার সময়
  slot_end   TEXT NOT NULL,
  CHECK (slot_end > slot_start),
  UNIQUE (doctor_id, slot_start)               -- একই ডাক্তারের একই সময়ে দুটি slot নয়
);
CREATE TABLE appointments (
  appt_id      INTEGER PRIMARY KEY,
  slot_id      INTEGER NOT NULL REFERENCES slots(slot_id) ON DELETE RESTRICT,
  patient_id   INTEGER NOT NULL REFERENCES patients(patient_id) ON DELETE RESTRICT,
  status       TEXT NOT NULL DEFAULT 'booked'
               CHECK (status IN ('booked', 'completed', 'no_show', 'cancelled')),
  booked_at    TEXT NOT NULL,
  cancelled_at TEXT,
  CHECK ((status = 'cancelled') = (cancelled_at IS NOT NULL))
);
-- নিয়ম ১: একটি slot-এ বাতিল-নয় এমন appointment সর্বোচ্চ একটি
CREATE UNIQUE INDEX appt_one_active_per_slot
  ON appointments(slot_id) WHERE status <> 'cancelled';
-- নিয়ম ২: একজন রোগীর একই সময়ে দুটি সক্রিয় appointment নয়
CREATE TRIGGER appt_no_patient_overlap
BEFORE INSERT ON appointments WHEN NEW.status <> 'cancelled'
BEGIN
  SELECT RAISE(ABORT, 'patient already has an appointment at this time')
  WHERE EXISTS (
    SELECT 1 FROM appointments a
    JOIN slots s  ON s.slot_id = a.slot_id
    JOIN slots ns ON ns.slot_id = NEW.slot_id
    WHERE a.patient_id = NEW.patient_id AND a.status <> 'cancelled'
      AND s.slot_start < ns.slot_end AND ns.slot_start < s.slot_end);
END;
CREATE TABLE prescriptions (
  rx_id      INTEGER PRIMARY KEY,
  appt_id    INTEGER NOT NULL UNIQUE REFERENCES appointments(appt_id) ON DELETE RESTRICT,
  diagnosis  TEXT NOT NULL,
  written_at TEXT NOT NULL
);
CREATE TABLE prescription_items (
  rx_id    INTEGER NOT NULL REFERENCES prescriptions(rx_id) ON DELETE CASCADE,
  line_no  INTEGER NOT NULL CHECK (line_no >= 1),
  medicine TEXT NOT NULL,
  dosage   TEXT NOT NULL,                      -- যেমন '1+0+1'
  days     INTEGER NOT NULL CHECK (days BETWEEN 1 AND 365),
  PRIMARY KEY (rx_id, line_no)
);
CREATE TABLE payments (
  pay_id  INTEGER PRIMARY KEY,
  appt_id INTEGER NOT NULL REFERENCES appointments(appt_id) ON DELETE RESTRICT,
  method  TEXT NOT NULL CHECK (method IN ('bkash', 'cash')),
  amount  INTEGER NOT NULL CHECK (amount > 0),
  trx_id  TEXT UNIQUE,                         -- bKash লেনদেনের ID
  paid_at TEXT NOT NULL,
  CHECK ((method = 'bkash') = (trx_id IS NOT NULL))
);

এখানে প্রতিটি constraint একটি নিয়মের পাহারাদার। কয়েকটি সিদ্ধান্ত বিশেষভাবে লক্ষ করুন:

  • দুই কলামের CHECK: (status = 'cancelled') = (cancelled_at IS NOT NULL) — বাতিল হলে সময় থাকতেই হবে, আর বাতিল না হলে সময় থাকা চলবে না (R3). একই কৌশলে bKash ⇔ trx ID (R5). দুই দিকের “যদি এবং কেবল যদি” একটি সমতায়।
  • Referential action: প্রায় সব foreign key ON DELETE RESTRICT. চিকিৎসার ইতিহাস কখনো নিঃশব্দে মুছে যাওয়া চলবে না — যে ডাক্তারের appointment আছে, তাঁকে মোছা যাবে না; চাকরি ছাড়লে একটি “inactive” চিহ্ন যথেষ্ট। একমাত্র ব্যতিক্রম prescription_items-এ ON DELETE CASCADE: ওষুধের লাইনের অস্তিত্ব prescription-এর ওপর নির্ভরশীল (পর্ব ২৩)।
  • টাকা INTEGER-এ: ফি পূর্ণ টাকায়, তাই INTEGER; পয়সা লাগলে পয়সায় পূর্ণসংখ্যা বা PostgreSQL-এ numeric(10,2) — কখনো floating point নয়।

এবার constraint-গুলো কাজে দেখুন। নতুন একজন রোগী খালি slot ৩৯-এ (Dr. Farhana Rahman, ৫ নভেম্বর সন্ধ্যা ৬:৩০) appointment নিলেন; তারপর receptionist bKash-এ টাকা নিয়ে লেনদেনের ID লিখতে ভুলে গেলেন:

-- নতুন রোগী আর একটি খালি slot-এ (39) তাঁর appointment
INSERT INTO patients VALUES (121, 'Ayesha Siddiqua', '01812345678', 1994);
INSERT INTO appointments (slot_id, patient_id, booked_at)
VALUES (39, 121, '2026-11-04 10:00:00');
SELECT a.appt_id, s.slot_start, d.name AS doctor, d.fee, a.status
FROM appointments a JOIN slots s ON s.slot_id = a.slot_id
JOIN doctors d ON d.doctor_id = s.doctor_id WHERE a.patient_id = 121;
-- bKash-এ টাকা নেওয়া হলো, কিন্তু লেনদেনের ID লেখা হয়নি
INSERT INTO payments (appt_id, method, amount, paid_at)
VALUES (178, 'bkash', 800, '2026-11-05 18:50:00');

প্রথম দুটি INSERT সফল; SELECT দেখায় 178 | 2026-11-05 18:30:00 | Dr. Farhana Rahman | 800 | booked — status লেখা হয়নি, তাই DEFAULT 'booked'. শেষ INSERT থামে: CHECK constraint failed — bKash অথচ trx_id NULL, তাই দুই দিকের সমতা মিথ্যা। অ্যাপের ফর্মে এই যাচাই থাকলেও ভালো, কিন্তু অন্য কোনো পথ (একটি স্ক্রিপ্ট, ভবিষ্যতের মোবাইল অ্যাপ) দিয়ে এলেও ডেটাবেস ভুলটি ঢুকতে দেবে না।

Normalization যাচাই — FD থেকে BCNF

পুরোনো খাতার একটি পাতা আসলে একটি চওড়া টেবিল: register(appt_no, slot_start, doctor, reg_no, speciality, fee, patient_phone, patient_name, diagnosis, medicines, method, trx_id, amount). পর্ব ৩৫-এর মতো functional dependency লিখি:

  • reg_no → doctor, speciality, fee  ·  patient_phone → patient_name
  • appt_no → slot_start, reg_no, patient_phone, diagnosis  ·  trx_id → appt_no, amount
  • (reg_no, slot_start) → appt_no — কিন্তু শুধু সক্রিয় appointment-এর জন্য

এই টেবিলের key appt_no (সাথে ওষুধের লাইন), অথচ reg_no → fee একটি transitive dependency: appt_no → reg_no → fee. তাই এটি 3NF নয় (পর্ব ৩৬) — ফি বদলালে শত সারি বদলাতে হয়, নতুন ডাক্তারের appointment না হওয়া পর্যন্ত তাঁকে লেখাই যায় না। প্রতিটি FD-র determinant-কে নিজের টেবিলের key বানিয়ে ভাঙলে ঠিক আমাদের আটটি টেবিল আসে। এবার প্রমাণ যে প্রতিটি টেবিল BCNF: সংজ্ঞা অনুযায়ী প্রতিটি অ-তুচ্ছ FD X → Y-এ X একটি superkey হতে হবে (পর্ব ৩৭)।

  • doctors: FD দুটি — doctor_id → সব, reg_no → সব। দুটি determinant-ই candidate key. BCNF.
  • slots: slot_id → সব, আর (doctor_id, slot_start) → সব (UNIQUE-এর কারণে)। দুটিই candidate key. BCNF.
  • appointments: appt_id → সব। slot_id → patient_id কি FD? না — slot ১৯৫-এ বাতিল আর নতুন, দুই রোগীর দুটি সারি আছে। তাই অন্য কোনো অ-তুচ্ছ FD নেই। BCNF.
  • payments: pay_id → সব, trx_id → সব (NULL বাদে অনন্য)। BCNF. বাকি টেবিলগুলোতেও প্রতিটি determinant একটি candidate key — যেমন patients-এ patient_id আর phone, prescriptions-এ rx_id আর appt_id, prescription_items-এ (rx_id, line_no); তাই সবগুলোই BCNF.

দুটি সূক্ষ্মতা। এক, “সক্রিয় হলে slot_id → appt_id” একটি শর্তসাপেক্ষ নির্ভরতা — প্রথাগত FD তত্ত্ব এটি প্রকাশ করতে পারে না, তাই এটি normalization নয়, constraint (partial index) দিয়ে রক্ষা হয়। দুই, payments.amount আর doctors.fee কি একই তথ্যের দুই কপি? না। ফি আজকের দাম; amount সেদিন আসলে যা নেওয়া হয়েছিল — ঐতিহাসিক ঘটনা। আগামী মাসে ফি বাড়লে পুরোনো আয়ের রিপোর্ট বদলানো উচিত নয়। এটি redundancy নয়, ভিন্ন অর্থের দুটি তথ্য (পর্ব ৩৮)।

৪ · নমুনা ডেটা — recursive CTE দিয়ে বাস্তবের মতো ডেটা

খালি টেবিলে কোয়েরি লেখা মানে অন্ধকারে লেখা। কোয়েরির ফল যাচাই, plan দেখা আর demo — সবকিছুর জন্য বাস্তবের মতো নমুনা ডেটা লাগে (seeding, পর্ব ৫০)। দুটি শর্ত: ডেটা নির্ধারিত (প্রতিবার হুবহু একই, যাতে ফল তুলনা করা যায়) আর constraint মানা। এজন্য random() নয়, সূত্র — আর সারি বানাতে পর্ব ২৫-এর recursive CTE. এই এডিটরে শুধু schema, বিভাগ, ছয়জন ডাক্তার আর ১২০ জন রোগী আছে; slot বানাবেন আপনি:

WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < 239)
INSERT INTO slots (doctor_id, slot_start, slot_end)
SELECT i / 40 + 1,
  datetime('2026-11-01 17:00', '+' || (i % 40 / 8) || ' days', '+' || (i % 8 * 15) || ' minutes'),
  datetime('2026-11-01 17:15', '+' || (i % 40 / 8) || ' days', '+' || (i % 8 * 15) || ' minutes')
FROM n;
-- যাচাই: প্রতি ডাক্তারের কয়টি slot, কোন দিনগুলোতে
SELECT doctor_id, COUNT(*) AS slots, MIN(slot_start) AS first_start,
       MAX(slot_end) AS last_end, COUNT(DISTINCT date(slot_start)) AS days
FROM slots GROUP BY doctor_id;
SELECT DISTINCT strftime('%w', slot_start) AS weekday FROM slots;

n টেবিলে ০ থেকে ২৩৯ — মোট ২৪০টি সংখ্যা। i / 40 + 1 প্রতি ৪০টিতে নতুন ডাক্তার; i % 40 / 8 দিন (০–৪); i % 8 × 15 মিনিট। প্রতিটি ডাক্তারের 40টি slot, 2026-11-01 17:00:00 থেকে 2026-11-05 19:00:00, 5 দিন। weekday-র ফল 0 থেকে 4 — রবি থেকে বৃহস্পতি, বাংলাদেশের কর্মসপ্তাহ। এডিটরে INSERT-টি আরেকবার যোগ করে চালিয়ে দেখুন: UNIQUE constraint failed: slots.doctor_id, slots.slot_start. seed স্ক্রিপ্ট দ্বিতীয়বার চালালে ভাঙবে — চালু ডেটাবেসে বারবার চালানোর স্ক্রিপ্টে তাই INSERT … ON CONFLICT DO NOTHING (পর্ব ১৪)।

বাকি নমুনা ডেটা (“নমুনা ডেটাবেস” বাক্সের clinic-seed অংশ) একই নীতিতে বানানো: যে slot-এর নম্বর ১০ দিয়ে ভাগ করলে ভাগশেষ ৩, ৭ বা ৯, সেগুলো খালি — বাকি ৭০% বুক; ১৩-এর গুণিতক বাতিল; ৫ নভেম্বরেরগুলো এখনো সামনে। ফল: ২৪০টি slot, ১৭৭টি appointment (১১৭ completed, ১৮ no_show, ২৯ booked, ১৩ cancelled), ১১৭টি prescription আর ১১৭টি পেমেন্ট, মোট ১,০৯,০০০ টাকা।

রোগী বাছাইয়ের সূত্রটি R2 মানার একটি ছোট প্রমাণ বহন করে। একটি সময়-বিন্দু t (দিন × ৮ + slot-এর ক্রম, ০ থেকে ৩৯) আর ডাক্তার d (১–৬)-এর রোগী = ১ + (৬t + d − ১) mod ১২০. একই t-তে ছয়জন ডাক্তারের জন্য ৬t, ৬t + ১, …, ৬t + ৫ — পরপর ছয়টি সংখ্যা, আর ১২০-এর চেয়ে কম ব্যবধানের সংখ্যার ভাগশেষ আলাদা। তাই একই সময়ে কোনো রোগী দুই ডাক্তারের কাছে নেই, আর trigger-টি seed-এর একটি সারিও আটকায় না। ডেটা বানানোর সময় নিয়ম মানা নিশ্চিত করুন — কিন্তু তবুও constraint রাখুন, যাতে সূত্রে ভুল হলে ডেটাবেস জানিয়ে দেয়।

৫ · অ্যাপের আসল কোয়েরি — খালি slot, ইতিহাস, আয় আর ব্যস্ততা

ক. একটি দিনে একজন ডাক্তারের খালি slot

রোগী ওয়েবসাইটে ডাক্তার আর তারিখ বাছলে প্রথম কোয়েরি: কোন slot খালি? খালি মানে সেই slot-এর এমন কোনো appointment নেই যা বাতিল নয় — একটি anti-join (পর্ব ২০, ২১)। তারিখের শর্ত half-open পরিসরে, যাতে index কাজে লাগে (পর্ব ১৭):

-- Dr. Sharmin Akter (doctor_id 5), ৫ নভেম্বর: খালি slot কোনগুলো?
SELECT s.slot_id, time(s.slot_start) AS starts
FROM slots s
WHERE s.doctor_id = 5
  AND s.slot_start >= '2026-11-05' AND s.slot_start < '2026-11-06'
  AND NOT EXISTS (SELECT 1 FROM appointments a
                  WHERE a.slot_id = s.slot_id AND a.status <> 'cancelled')
ORDER BY s.slot_start;
-- প্রচলিত ভুল: status না দেখে LEFT JOIN … IS NULL
SELECT s.slot_id, time(s.slot_start) AS starts
FROM slots s LEFT JOIN appointments a ON a.slot_id = s.slot_id
WHERE s.doctor_id = 5 AND s.slot_start >= '2026-11-05'
  AND s.slot_start < '2026-11-06' AND a.appt_id IS NULL;

সঠিক উত্তর চারটি: 193 | 17:00:00, 195 | 17:30:00, 197 | 18:00:00, 199 | 18:30:00. দ্বিতীয় কোয়েরি পায় তিনটি — slot ১৯৫ নেই। কারণ ১৯৫-এর একটি appointment ছিল, যা বাতিল হয়েছে; LEFT JOIN সেই বাতিল সারির সাথে মেলে, তাই a.appt_id IS NULL মিথ্যা। ফল: ক্লিনিক একটি খালি slot কখনো বিক্রিই করে না, অথচ কেউ টের পায় না — কোয়েরি চলে, শুধু ভুল উত্তর দেয়। LEFT JOIN-এ সমাধান হলো status-এর শর্তটি ON-এ রাখা (WHERE-এ নয়); NOT EXISTS-এ শর্তটি স্বাভাবিকভাবেই subquery-র ভেতরে থাকে।

খ. একজন রোগীর পুরো ইতিহাস

ডাক্তার রোগী ৭-এর ফাইল খুললেন। দরকার প্রতিটি appointment, রোগনির্ণয়, ওষুধ আর পরিশোধ — যেগুলোর prescription বা পেমেন্ট নেই, সেগুলোও। এখানে একটি ফাঁদ: prescription_items আর payments দুটিই “অনেক” দিক; দুটিকে একসাথে JOIN করলে সারি গুণ হয় — fan-out (পর্ব ১৯): দুটি ওষুধ × একটি পেমেন্ট = দুটি সারি, আর SUM দ্বিগুণ। তাই “অনেক” দিকগুলো আলাদা scalar subquery-তে:

-- রোগী ৭-এর পুরো ইতিহাস
SELECT date(s.slot_start) AS day, time(s.slot_start) AS at, d.name AS doctor,
       a.status, rx.diagnosis,
       (SELECT group_concat(i.medicine, ', ' ORDER BY i.line_no)
          FROM prescription_items i WHERE i.rx_id = rx.rx_id) AS medicines,
       (SELECT SUM(p.amount) FROM payments p WHERE p.appt_id = a.appt_id) AS paid
FROM appointments a
JOIN slots s ON s.slot_id = a.slot_id
JOIN doctors d ON d.doctor_id = s.doctor_id
LEFT JOIN prescriptions rx ON rx.appt_id = a.appt_id
WHERE a.patient_id = 7
ORDER BY s.slot_start;

দুটি সারি: 2026-11-01 | 17:15:00 | Dr. Farhana Rahman | completed | Gastritis | Omeprazole 20 mg, Domperidone 10 mg | 800, আর 2026-11-03 | 18:15:00 | Dr. Farhana Rahman | no_show | NULL | NULL | NULL. no-show সারিটি আছে LEFT JOIN-এর কারণে; INNER JOIN হলে নিঃশব্দে হারাত। ওষুধগুলো এক ঘরে, লাইনের ক্রমে — group_concat(… ORDER BY …) SQLite 3.44 থেকে; PostgreSQL-এ string_agg(medicine, ', ' ORDER BY line_no). পরিশোধ ৮০০, দ্বিগুণ নয়।

গ. দৈনিক আয় আর no-show-এর হার

ম্যানেজারের রিপোর্ট (R8): প্রতিদিন নগদ আর bKash আলাদা কলামে — conditional aggregation (পর্ব ১৮) — আর ডাক্তারপ্রতি no-show-এর হার। হারের হর নিয়ে সাবধান: বাতিল appointment বা যেগুলো এখনো সামনে, সেগুলো “এলেন কি না” প্রশ্নের অংশ নয়:

-- দৈনিক আয়: নগদ আর bKash আলাদা কলামে
SELECT date(paid_at) AS day,
       SUM(CASE WHEN method = 'cash'  THEN amount ELSE 0 END) AS cash,
       SUM(CASE WHEN method = 'bkash' THEN amount ELSE 0 END) AS bkash,
       SUM(amount) AS total
FROM payments GROUP BY date(paid_at) ORDER BY day;
-- no-show-এর হার: শুধু যেগুলোর ফল জানা (completed বা no_show)
SELECT d.name, SUM(a.status = 'no_show') AS no_shows, COUNT(*) AS decided,
       ROUND(100.0 * SUM(a.status = 'no_show') / COUNT(*), 1) AS no_show_pct
FROM appointments a JOIN slots s ON s.slot_id = a.slot_id
JOIN doctors d ON d.doctor_id = s.doctor_id
WHERE a.status IN ('completed', 'no_show')
GROUP BY d.doctor_id ORDER BY no_show_pct DESC, d.name;

দৈনিক আয়: ১ নভেম্বর 18800 | 11200 | 30000, ২ নভেম্বর 15300 | 12000 | 27300, ৩ নভেম্বর 12900 | 11900 | 24800, ৪ নভেম্বর 18700 | 8200 | 26900 — চার দিনে ১,০৯,০০০ টাকা, seed-এর মোটের সাথে মেলে। এই মিলিয়ে দেখাটাই reconciliation: দিনশেষে bKash merchant-এর বিবরণীর যোগফল এই কলামের সাথে মেলাতে হয়। no-show-এ সবার ওপরে Dr. Sharmin Akter | 4 | 23 | 17.4, সবার নিচে Dr. Farhana Rahman | 2 | 23 | 8.7. হর হিসেবে সব appointment নিলে হার কম দেখাত — বাতিল আর ভবিষ্যতের সারিগুলো হরে ঢুকে যেত।

ঘ. ডাক্তারের সময় কতটা কাজে লাগল — window function

“ব্যস্ততা” (utilisation) = যত slot-এ রোগী আসলে দেখা হলো ÷ মোট slot. সাথে দুটি তুলনা — সবার মধ্যে স্থান (RANK) আর নিজের বিভাগের গড় — যা GROUP BY একা দিতে পারে না, কারণ প্রতিটি সারির পাশে অন্য সারির হিসাব লাগে (পর্ব ২৬):

-- ১–৪ নভেম্বর: কোন ডাক্তারের সময় কতটা কাজে লেগেছে
WITH per_doc AS (
  SELECT s.doctor_id, COUNT(*) AS slots,
         SUM(a.status = 'completed') AS seen
  FROM slots s LEFT JOIN appointments a
    ON a.slot_id = s.slot_id AND a.status <> 'cancelled'
  WHERE s.slot_start < '2026-11-05'
  GROUP BY s.doctor_id)
SELECT d.name, p.seen, p.slots,
       ROUND(100.0 * p.seen / p.slots, 1) AS util_pct,
       RANK() OVER (ORDER BY 1.0 * p.seen / p.slots DESC) AS rnk,
       ROUND(AVG(100.0 * p.seen / p.slots) OVER (PARTITION BY d.spec_id), 1) AS spec_avg
FROM per_doc p JOIN doctors d ON d.doctor_id = p.doctor_id
ORDER BY rnk, d.name;

প্রথম Dr. Farhana Rahman | 21 | 32 | 65.6 | 1 | 62.5, তারপর Dr. Tanvir Ahmed | 20 | 32 | 62.5 | 2 | 60.9, আর বাকি চারজন 19 | 32 | 59.4, সবার স্থান 3 — RANK সমান মানে একই স্থান দেয় আর পরের স্থান লাফিয়ে যায় (এখানে ৪ নেই, পরের কেউ থাকলে হতো ৭)। মেডিসিনের গড় (৬৫.৬ + ৫৯.৪) / ২ = ৬২.৫. LEFT JOIN জরুরি: খালি slot-ও হরে থাকতে হবে। আর বাতিল সারি বাদ দেওয়ার শর্ত ON-এ, যাতে বাতিল আর নতুন বুকিং — দুই সারির slot দুবার না গোনা হয়।

৬ · কঠিনতম নিয়ম — double booking ডেটাবেসেই আটকানো

অ্যাপের সবচেয়ে স্বাভাবিক কোড: “আগে দেখো slot খালি কি না; খালি হলে INSERT”. একজন ব্যবহারকারী থাকলে এটি কাজ করে। দুজন একই মুহূর্তে হলে দুজনই “খালি” দেখেন, দুজনই INSERT করেন — check-then-act ধরনের race condition. দেখা আর লেখার মাঝের ফাঁকটি কোনো অ্যাপ-কোড বন্ধ করতে পারে না। বন্ধ করে ডেটাবেসের constraint, কারণ constraint-এর যাচাই আর লেখা ডেটাবেসের ভেতরে একসাথে, একটি অবিভাজ্য কাজে ঘটে।

R1-এর জন্য শুধু UNIQUE (slot_id) দেওয়া যেত না — তাহলে বাতিল slot আর কখনো বুক করা যেত না। দরকার শর্তসাপেক্ষ অনন্যতা: CREATE UNIQUE INDEX … ON appointments(slot_id) WHERE status <> 'cancelled' — একটি partial index, যা শুধু সক্রিয় সারির হিসাব রাখে:

-- slot 195 (Dr. Sharmin Akter, ৫ নভেম্বর ৫:৩০): আগের appointment বাতিল
SELECT appt_id, patient_id, status, cancelled_at FROM appointments WHERE slot_id = 195;
-- বাতিল slot আবার বুক করা যায় — partial index শুধু সক্রিয় সারি দেখে
INSERT INTO appointments (slot_id, patient_id, booked_at)
VALUES (195, 30, '2026-11-04 09:00:00');
SELECT appt_id, patient_id, status FROM appointments WHERE slot_id = 195;
-- দ্বিতীয় receptionist একই slot-এ আরেকজনকে বসাতে চাইলেন
INSERT INTO appointments (slot_id, patient_id, booked_at)
VALUES (195, 31, '2026-11-04 09:00:05');

slot ১৯৫-এ ছিল 137 | 89 | cancelled | 2026-11-04 17:30:00. রোগী ৩০-এর বুকিং সফল — বাতিল সারিটি partial index-এ নেই। এখন দুটি সারি: 137 | 89 | cancelled আর 178 | 30 | booked. রোগী ৩১-এর চেষ্টা থামে: UNIQUE constraint failed: appointments.slot_id. অ্যাপ এই নির্দিষ্ট error ধরে বলবে “এই সময়টি এইমাত্র অন্য কেউ নিয়েছেন — পরের খালি সময় দেখুন”. PostgreSQL-এ একই ভুলের SQLSTATE 23505 (unique_violation); অ্যাপ বার্তার লেখা নয়, এই কোড দেখে সিদ্ধান্ত নেয়।

R2 — একই রোগীর একই সময়ে দুটি appointment নয় — আরও কঠিন, কারণ সময় থাকে slots-এ আর রোগী appointments-এ: নিয়মটি দুই টেবিল জুড়ে। SQLite-এ এর জন্য একটি BEFORE INSERT trigger (পর্ব ২৯), যা দুটি সময়ের ছেদ খোঁজে। দুটি half-open পরিসর [s₁, e₁) আর [s₂, e₂) ছেদ করে ঠিক তখন, যখন s₁ < e₂ এবং s₂ < e₁. প্রমাণ: ছেদ করে না মানে একটি অন্যটির পুরো আগে শেষ, অর্থাৎ e₁ ≤ s₂ অথবা e₂ ≤ s₁; De Morgan-এর নিয়মে এর বিপরীতই শর্তটি। পরপর দুটি slot (৫:০০–৫:১৫ আর ৫:১৫–৫:৩০) ছোঁয়, কিন্তু ছেদ করে না:

-- রোগী ৫০: ৫ নভেম্বর বিকেল ৫টায় Dr. Tanvir Ahmed-এর slot 153-এ
INSERT INTO appointments (slot_id, patient_id, booked_at)
VALUES (153, 50, '2026-11-04 11:00:00');
-- overlap-এর শর্ত: a.start < b.end AND b.start < a.end
SELECT b.slot_id, b.doctor_id, b.slot_start, b.slot_end,
       a.slot_start < b.slot_end AND b.slot_start < a.slot_end AS overlaps
FROM slots a, slots b WHERE a.slot_id = 153 AND b.slot_id IN (193, 34);
-- একই সময়ে Dr. Sharmin Akter-এর খালি slot 193-এ একই রোগী
INSERT INTO appointments (slot_id, patient_id, booked_at)
VALUES (193, 50, '2026-11-04 11:05:00');

প্রথম INSERT সফল। শর্তের হিসাব: slot ৩৪ (৫:১৫–৫:৩০) overlaps = 0 — ১৭:০০ < ১৭:৩০ সত্য, কিন্তু ১৭:১৫ < ১৭:১৫ মিথ্যা; slot ১৯৩ (৫:০০–৫:১৫) overlaps = 1. তাই শেষ INSERT থামে trigger-এর নিজের বার্তায়: patient already has an appointment at this time. লক্ষ করুন, trigger শুধু INSERT পাহারা দেয়; বাতিল appointment আবার চালু করা বা slot বদলানোর UPDATE-এর জন্য আরেকটি trigger লাগবে (অনুশীলন ৩)। partial index-এর এই সমস্যা নেই — index INSERT আর UPDATE দুটিতেই নিজে থেকে যাচাই হয়।

দুজন receptionist, শেষ slot — concurrency-র গল্প

এবার সবচেয়ে কঠিন প্রশ্ন: সত্যিকারের একসাথে চলা transaction-এ কী ঘটে (পর্ব ৪৪–৪৭)? সার্ভারটি PostgreSQL, ডিফল্ট READ COMMITTED. ৫ নভেম্বর সন্ধ্যা ৬টা; ধরুন ডা. শারমিনের অন্য খালি slot-গুলো এর মধ্যেই বুক হয়ে গেছে, বাকি শুধু slot ১৯৯ (৬:৩০)। কাউন্টার A আর কাউন্টার B একই মুহূর্তে দুজন রোগীকে বসাতে চান:

PostgreSQL, READ COMMITTED, slot 199লাল = এই ধাপের মূল ঘটনা

            
অবস্থা
A
B
slot 199-এ সক্রিয়
  1. দুজনই খালি দেখেন। দুটি SELECT-ই সত্য বলছে — ঠিক সেই মুহূর্তে slot খালি। সমস্যা দেখায় নয়, দেখা আর লেখার মাঝের ফাঁকে। অ্যাপ-কোডের কোনো if এই ফাঁক বন্ধ করে না।
  2. A লেখে। A-র INSERT unique index-এ একটি entry রাখে, কিন্তু commit না হওয়া পর্যন্ত অন্য transaction সেটি দেখে না — READ COMMITTED-ও dirty read দেয় না (পর্ব ৪৭)।
  3. B অপেক্ষা করে। B-র INSERT index-এ একই key পায়, যার মালিক একটি চলমান transaction. PostgreSQL তখন B-কে থামিয়ে রাখে A-র ফল পর্যন্ত — কারণ A ব্যর্থ হলে B-র লেখাই বৈধ।
  4. A commit, B ব্যর্থ। A-র commit-এর পর B পায় 23505 unique_violation. A rollback করলে B সফল হতো। দুই ক্ষেত্রেই ফল একটিই appointment — R1 রক্ষা।
  5. অ্যাপের জবাব। B-র অ্যাপ নির্দিষ্ট এই কোডটি ধরে, transaction ফিরিয়ে নেয় আর রোগীকে পরের খালি সময় দেখায়। একই INSERT অন্ধভাবে আবার চালানো অর্থহীন — slot এখন সত্যিই নেই।
  6. শেষ অবস্থা। slot ১৯৯-এ একটিই সক্রিয় appointment. অ্যাপের SELECT শুধু ব্যবহারকারীকে খালি সময় দেখানোর জন্য; নিয়মের আসল পাহারাদার constraint, যা প্রতিটি লেখার সাথে অবিভাজ্যভাবে যাচাই হয়।

একই ঘটনা হাতে-কলমে, দুটি আলাদা connection দিয়ে — একটি ফাইলের SQLite ডেটাবেসে, দুই receptionist দুই connection. প্রথমবার unique index ছাড়া, দ্বিতীয়বার সহ:

import sqlite3, tempfile, os
def scenario(guard):
    path = os.path.join(tempfile.mkdtemp(), "clinic.db")
    with sqlite3.connect(path) as c:
        c.execute("CREATE TABLE appointments (appt_id INTEGER PRIMARY KEY, slot_id INT NOT NULL,"
                  " patient_id INT NOT NULL, status TEXT NOT NULL DEFAULT 'booked')")
        if guard:
            c.execute("CREATE UNIQUE INDEX one_active ON appointments(slot_id) WHERE status <> 'cancelled'")
    desk_a, desk_b = sqlite3.connect(path), sqlite3.connect(path)   # two receptionists
    free = "SELECT NOT EXISTS (SELECT 1 FROM appointments WHERE slot_id = 199 AND status <> 'cancelled')"
    print("A checks: free =", desk_a.execute(free).fetchone()[0] == 1)
    print("B checks: free =", desk_b.execute(free).fetchone()[0] == 1)
    for name, con, patient in (("A", desk_a, 11), ("B", desk_b, 12)):
        try:
            with con:                        # one transaction: commit or roll back
                con.execute("INSERT INTO appointments (slot_id, patient_id) VALUES (199, ?)", (patient,))
            print(f"{name} books patient {patient}: OK")
        except sqlite3.IntegrityError as e:
            print(f"{name} books patient {patient}: REJECTED ({e})")
    print("active bookings for slot 199:", desk_a.execute("SELECT COUNT(*) FROM appointments").fetchone()[0])
for guard in (False, True):
    print("--- unique index:", "yes" if guard else "no")
    scenario(guard)

আউটপুট:

--- unique index: no
A checks: free = True
B checks: free = True
A books patient 11: OK
B books patient 12: OK
active bookings for slot 199: 2
--- unique index: yes
A checks: free = True
B checks: free = True
A books patient 11: OK
B books patient 12: REJECTED (UNIQUE constraint failed: appointments.slot_id)
active bookings for slot 199: 1

দুই ক্ষেত্রেই অ্যাপের যাচাই হুবহু এক, আর দুবারই দুজন “খালি” দেখেছেন। পার্থক্য শুধু ডেটাবেসের index-এ: ছাড়া হলে একটি slot-এ দুজন রোগী, সহ হলে দ্বিতীয়টি প্রত্যাখ্যাত। এখানে ক্রমটি আমরা স্থির করে দিয়েছি (দুজনের দেখা, তারপর দুজনের লেখা) যাতে ফল প্রতিবার একই হয়; বাস্তবে এই ক্রম কখনো কখনো ঘটে — সপ্তাহে একবার হলেও ক্লিনিকের ঝগড়ার জন্য যথেষ্ট।

Trigger কেন যথেষ্ট নয় — PostgreSQL-এ। R2-এর trigger-টি নিজেও একটি check-then-act: “এই রোগীর ছেদ করা appointment আছে কি?” দেখে তারপর লেখা। SQLite-এ একসাথে একটিই লেখক (writer) চলে, তাই সেখানে এটি নিরাপদ। কিন্তু PostgreSQL-এ READ COMMITTED-এ দুটি transaction একই রোগীকে দুই ডাক্তারের কাছে একই সময়ে বসালে দুটি trigger-ই অন্যটির uncommitted সারি দেখে না — দুটিই পাস, নিয়ম ভাঙে। তিনটি সঠিক সমাধান: (১) SERIALIZABLE isolation — PostgreSQL-এর SSI একটি transaction-কে 40001 serialization failure দিয়ে ফেরায়, অ্যাপ retry করে; (২) রোগীর সারিতে আগে SELECT … FOR UPDATE — একই রোগীর বুকিংগুলো লাইনে দাঁড়ায় (পর্ব ৪৬); (৩) সবচেয়ে ভালো, নিয়মটিকে constraint বানানো — exclusion constraint:

CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE slots (
  slot_id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  doctor_id int NOT NULL REFERENCES doctors ON DELETE RESTRICT,
  during    tstzrange NOT NULL,              -- [শুরু, শেষ) — half-open
  UNIQUE (slot_id, during),
  EXCLUDE USING gist (doctor_id WITH =, during WITH &&)  -- একই ডাক্তারের slot ছেদ নয়
);
CREATE TABLE appointments (
  appt_id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  slot_id    bigint NOT NULL,
  during     tstzrange NOT NULL,             -- slot-এর সময়ের কপি…
  patient_id bigint NOT NULL REFERENCES patients,
  status     text NOT NULL DEFAULT 'booked',
  FOREIGN KEY (slot_id, during) REFERENCES slots (slot_id, during),  -- …যা ভুল হতে পারে না
  EXCLUDE USING gist (patient_id WITH =, during WITH &&) WHERE (status <> 'cancelled')
);
CREATE UNIQUE INDEX appt_one_active_per_slot ON appointments (slot_id) WHERE status <> 'cancelled';

&& হলো range-এর ছেদ অপারেটর; exclusion constraint নিশ্চিত করে কোনো দুটি সক্রিয় সারিতে একই রোগী আর ছেদ করা সময় একসাথে থাকবে না — আর unique index-এর মতোই একসাথে চলা transaction-এর মধ্যে সঠিক থাকে। দাম একটি ইচ্ছাকৃত denormalization: সময়ের কপি appointments-এ। কপিটি ভুল হতে পারে না, কারণ composite foreign key (slot_id, during) তাকে slots-এর আসল মানের সাথে বেঁধে রাখে। MySQL-এ partial index নেই; প্রচলিত বিকল্প একটি generated column (সক্রিয় হলে slot_id, নইলে NULL) আর তার ওপর UNIQUE — InnoDB-র UNIQUE একাধিক NULL মেনে নেয়।

৭ · Index — প্রধান কোয়েরির plan, আগে আর পরে

পর্ব ৪১-এর নিয়ম: index বানানো হয় কোয়েরির জন্য, কলামের জন্য নয়। আমাদের প্রধান কোয়েরি আর তাদের দরকার:

কোয়েরিশর্তindex
খালি slot (দিনে লাখোবার)doctor_id = ? AND slot_start-এর পরিসরUNIQUE (doctor_id, slot_start) — আগে থেকেই আছে
slot-এ সক্রিয় appointment আছে?slot_id = ? AND status <> 'cancelled'partial UNIQUE index — আগে থেকেই আছে
রোগীর ইতিহাস, R2-এর triggerpatient_id = ?নতুন: appointments(patient_id)
দৈনিক আয়paid_at-এর পরিসর, method, amountনতুন: payments(paid_at, method, amount) — covering
-- আগে: রোগীর ইতিহাস আর দৈনিক আয়
EXPLAIN QUERY PLAN SELECT appt_id, slot_id FROM appointments WHERE patient_id = 7;
EXPLAIN QUERY PLAN SELECT method, SUM(amount) FROM payments
WHERE paid_at >= '2026-11-03' AND paid_at < '2026-11-04' GROUP BY method;
-- দুটি index
CREATE INDEX appt_patient ON appointments(patient_id);
CREATE INDEX pay_day ON payments(paid_at, method, amount);
-- পরে: একই দুটি কোয়েরি
EXPLAIN QUERY PLAN SELECT appt_id, slot_id FROM appointments WHERE patient_id = 7;
EXPLAIN QUERY PLAN SELECT method, SUM(amount) FROM payments
WHERE paid_at >= '2026-11-03' AND paid_at < '2026-11-04' GROUP BY method;
-- খালি slot-এর কোয়েরি: index আগে থেকেই আছে
EXPLAIN QUERY PLAN SELECT s.slot_id FROM slots s
WHERE s.doctor_id = 5 AND s.slot_start >= '2026-11-05' AND s.slot_start < '2026-11-06'
  AND NOT EXISTS (SELECT 1 FROM appointments a WHERE a.slot_id = s.slot_id AND a.status <> 'cancelled');

আগে দুটিই SCAN appointments আর SCAN payments (সাথে USE TEMP B-TREE FOR GROUP BY) — প্রতিটি সারি পড়া। index-এর পরে SEARCH appointments USING INDEX appt_patient (patient_id=?) আর SEARCH payments USING COVERING INDEX pay_day (paid_at>? AND paid_at<?) — দ্বিতীয়টিতে টেবিলে যেতেই হয় না, দরকারি তিনটি কলামই index-এ (covering index). GROUP BY-র temp B-tree রয়ে গেছে, কিন্তু সেটি শুধু একদিনের সারি সাজায়। শেষ plan: SEARCH s USING COVERING INDEX sqlite_autoindex_slots_1 (doctor_id=? AND slot_start>? AND slot_start<?) আর NOT EXISTS-এর ভেতরে SEARCH a USING INDEX appt_one_active_per_slot (slot_id=?) — নিয়ম রক্ষার জন্য বানানো দুটি UNIQUE-ই সবচেয়ে ব্যস্ত কোয়েরিকে দ্রুত করছে। optimizer partial index নিতে পেরেছে, কারণ কোয়েরির status <> 'cancelled' হুবহু index-এর শর্ত।

খরচের হিসাব: n সারির টেবিলে SCAN মানে n সারি পড়া, O(n); B+ tree-তে SEARCH মানে গাছের উচ্চতা ধরে নামা আর k-টি মিল পড়া, O(log n + k) (পর্ব ৪০)। ১৭৭ সারিতে পার্থক্য চোখে পড়ে না; কিন্তু পাঁচ বছরে ক্লিনিকে কয়েক লাখ appointment হলে রোগীর ইতিহাস খোলায় লাখো সারি বনাম কয়েকটি page — আর R2-এর trigger প্রতিটি বুকিংয়ে এই একই খোঁজ চালায়, তাই appt_patient বুকিংকেও দ্রুত রাখে। উল্টো দিকে, প্রতিটি index প্রতিটি INSERT-এ একটি বাড়তি B+ tree লেখা। এখানে লেখা কম (দিনে কয়েকশো বুকিং), পড়া বেশি — দুটি নতুন index স্পষ্টতই লাভজনক।

৮ · নিরাপত্তা — role, view আর audit trigger

রোগীর রোগনির্ণয় আর ওষুধ ব্যক্তিগত তথ্যের মধ্যে সবচেয়ে সংবেদনশীলগুলোর একটি। নীতি পর্ব ৫১-এর least privilege: প্রতিটি ভূমিকা ঠিক ততটুকু পায়, যতটুকু তার কাজে লাগে।

ভূমিকাযা পারেনযা পারেন না
receptionistরোগী যোগ, appointment বুক ও বাতিল, পেমেন্ট লেখাprescription পড়া বা লেখা
doctorনিজের schedule দেখা (view দিয়ে), prescription লেখা ও সংশোধনপেমেন্ট দেখা, অন্য ডাক্তারের রোগী
admin (হিসাব)সব রিপোর্ট, পেমেন্টprescription বদলানো
app_migratorschema বদলানো — শুধু release-এর সময়প্রতিদিনের অ্যাপ এই ভূমিকায় চলে না
CREATE ROLE receptionist NOLOGIN;  CREATE ROLE doctor NOLOGIN;
GRANT SELECT, INSERT ON patients, appointments, payments TO receptionist;
GRANT UPDATE (status, cancelled_at) ON appointments TO receptionist;
GRANT SELECT ON doctor_schedule TO doctor;      -- view, মূল টেবিল নয়
GRANT SELECT, INSERT, UPDATE ON prescriptions, prescription_items TO doctor;
REVOKE ALL ON payments FROM doctor;
-- প্রতিটি ডাক্তার শুধু নিজের রোগীর prescription
ALTER TABLE prescriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY own_patients ON prescriptions TO doctor USING (appt_id IN (
  SELECT a.appt_id FROM appointments a JOIN slots s USING (slot_id)
  WHERE s.doctor_id = current_setting('app.doctor_id')::int));
CREATE ROLE dr_sharmin LOGIN PASSWORD '…' IN ROLE doctor;

এখানে role দলগত অনুমতির নাম, আর প্রতিটি মানুষের নিজস্ব login role তার সদস্য — কে কী করলেন, audit-এ আলাদা দেখা যায়। Row-level security সারির স্তরে ছাঁকে; app.doctor_id অ্যাপ প্রতিটি connection-এ বসায়। এবার SQLite-এ চালানো যায় এমন দুটি অংশ — ডাক্তারের view আর prescription-এর audit (R6, R7):

-- ডাক্তারদের জন্য view: রোগী আর সময় আছে, টাকা আর ফোন নেই
CREATE VIEW doctor_schedule AS
SELECT s.doctor_id, s.slot_start, a.appt_id, p.name AS patient, p.birth_year, a.status
FROM slots s JOIN appointments a ON a.slot_id = s.slot_id AND a.status <> 'cancelled'
JOIN patients p ON p.patient_id = a.patient_id;
SELECT time(slot_start) AS at, patient, birth_year, status FROM doctor_schedule
WHERE doctor_id = 5 AND slot_start >= '2026-11-05' ORDER BY slot_start;
-- prescription-এর প্রতিটি সংশোধন audit টেবিলে; মুছে ফেলা নিষেধ
CREATE TABLE rx_audit (audit_id INTEGER PRIMARY KEY, rx_id INTEGER NOT NULL,
  old_diagnosis TEXT, new_diagnosis TEXT, changed_at TEXT DEFAULT CURRENT_TIMESTAMP);
CREATE TRIGGER rx_audit_upd AFTER UPDATE ON prescriptions BEGIN
  INSERT INTO rx_audit (rx_id, old_diagnosis, new_diagnosis)
  VALUES (OLD.rx_id, OLD.diagnosis, NEW.diagnosis); END;
CREATE TRIGGER rx_no_delete BEFORE DELETE ON prescriptions BEGIN
  SELECT RAISE(ABORT, 'prescriptions are never deleted - write a correction'); END;
UPDATE prescriptions SET diagnosis = 'Peptic ulcer disease' WHERE rx_id = 2;
SELECT rx_id, old_diagnosis, new_diagnosis FROM rx_audit;
DELETE FROM prescriptions WHERE rx_id = 2;

view থেকে ডা. শারমিনের ৫ নভেম্বরের চারজন রোগী: 17:15:00 | Patient 83 | 1971 | booked থেকে 18:45:00 | Patient 119 | 2013 | booked — কোথাও ফোন বা টাকা নেই, আর বাতিল slot ১৯৫ বাদ। সংশোধনের পর rx_audit-এ 2 | Gastritis | Peptic ulcer disease; মোছার চেষ্টা থামে prescriptions are never deleted - write a correction বার্তায়। মনে রাখুন, view একা নিরাপত্তা নয় — মূল টেবিলের অনুমতি কেড়ে নিয়ে শুধু view-তে GRANT দিলে তবেই (ওপরের PostgreSQL ব্লক)। SQLite-এ user বা GRANT নেই; সেখানে অনুমতি দেয় ফাইলের অধিকার আর অ্যাপ। PostgreSQL-এর audit trigger-এ current_user আর now() লিখে রাখুন — কে আর কখন; পুরো সার্ভারের জন্য pgAudit extension.

বাকি স্তরগুলো (defense in depth): অ্যাপে সব কোয়েরি parameterised — রোগীর নাম বা ফোন কখনো SQL-এর লেখায় জোড়া হয় না (পর্ব ৪৯, ৫১); অ্যাপ আর ডেটাবেসের মাঝে TLS; ডিস্ক আর backup এনক্রিপ্টেড; staging-এ আসল রোগীর ডেটা নয়, মুখোশ পরানো (masked) বা নমুনা ডেটা; আর ডেটাবেসের port ইন্টারনেটে খোলা নয়, শুধু অ্যাপ সার্ভার থেকে।

৯ · পরিচালনা আর deployment — backup, monitoring, migration আর কোথায় চালাবেন

Backup আর PITR — RPO ৫ মিনিট, RTO ১ ঘণ্টা

অংশ ১-এর অ-কার্যকরী প্রয়োজন দুটি এখন সংখ্যা: RPO ≤ ৫ মিনিট, RTO ≤ ১ ঘণ্টা (পর্ব ৫২)। শুধু রাতের backup দিয়ে RPO হয় প্রায় ২৪ ঘণ্টা — সন্ধ্যা ৭টায় ডিস্ক নষ্ট হলে সেদিনের সব বুকিং আর পেমেন্ট হারায়। তাই পরিকল্পনা:

  • প্রতি রাতে base backup (pgBackRest বা managed সেবার স্বয়ংক্রিয় backup), ৩০ দিন রাখা, আরেকটি অঞ্চলে কপি — 3-2-1 নিয়ম।
  • ধারাবাহিক WAL archiving, archive_timeout = 60s: লেখা কম হলেও প্রতি মিনিটে WAL ফাইল archive-এ যায়। ফলে প্রায় যেকোনো মুহূর্তে ফেরা যায় — PITR (পর্ব ৪৮)। সবচেয়ে খারাপ ক্ষতি মোটামুটি শেষ archive-এর পরের ১ মিনিট, ৫ মিনিটের লক্ষ্যের মধ্যে।
  • RTO-র হিসাব: ধরা পড়া (১০ মিনিট, alert থেকে) + নতুন সার্ভারে base backup ফেরানো (ধরা যাক ২০ মিনিট) + WAL replay (১৫ মিনিট) + যাচাই আর অ্যাপ ঘোরানো (১০ মিনিট) ≈ ৫৫ মিনিট। সংখ্যাগুলো অনুমান নয় — প্রতি মাসে একবার সত্যিই restore করে মাপা হয়। আরও কম RTO চাইলে একটি hot standby replica আর failover.

Monitoring আর migration

ক্লিনিকের জন্য অর্থপূর্ণ মাপ (পর্ব ৫৩): বুকিংয়ের p95 latency; ঘণ্টায় কতগুলো 23505 (বেশি হলে একই slot নিয়ে প্রতিযোগিতা — হয়তো জনপ্রিয় ডাক্তারের আরও slot লাগবে); সবচেয়ে নতুন backup কত পুরোনো; WAL archive ব্যর্থ হচ্ছে কি না; ডিস্কের বৃদ্ধি; ব্যর্থ login. প্রতিটির একটি alert আর একটি করণীয়।

Schema কখনো হাতে বদলানো হয় না। প্রতিটি পরিবর্তন একটি সংস্করণ-নম্বরযুক্ত migration ফাইল (Flyway, Liquibase বা Alembic), Git-এ, review-এর পর (পর্ব ৫০)। ধরুন ক্লিনিক “ভিডিও কনসালটেশন” চালু করবে। expand–contract ধাপে: (১) ALTER TABLE slots ADD COLUMN mode text NOT NULL DEFAULT 'in_person' CHECK (mode IN ('in_person', 'video')) — পুরোনো কোড চলতেই থাকে; (২) নতুন অ্যাপ mode পড়ে আর লেখে; (৩) পরে, দরকার হলে পুরোনো ধারণা সরানো। প্রতিটি ধাপ আলাদা release, প্রতিটি ফিরিয়ে নেওয়া যায়।

কোথায় চালাবেন — managed PostgreSQL বনাম নিজের VPS

বিষয়Managed PostgreSQL (পর্ব ৬১)নিজের VPS-এ PostgreSQL
Backup, PITR, patchসেবার অংশ, কয়েকটি ক্লিকনিজে বসানো, নিজে যাচাই
Failoverবাড়তি দামে standby, স্বয়ংক্রিয়Patroni-র মতো টুল আর দক্ষতা লাগে
মাসিক খরচসাধারণত বেশিকম, কিন্তু প্রকৌশলীর সময় যোগ করুন
নিয়ন্ত্রণsuperuser নেই, extension সীমিতসম্পূর্ণ
অবস্থানপ্রদানকারীর region-এদেশের ডেটা সেন্টারেও সম্ভব

Region: বড় cloud প্রদানকারীদের নিকটতম region সাধারণত সিঙ্গাপুর বা ভারতে (মুম্বাই)। ঢাকা থেকে সেখানে প্রতিটি round trip কয়েক দশক মিলিসেকেন্ড — একটি বুকিংয়ে ৫–১০টি কোয়েরি হলে চোখে পড়ার মতো, তাই অ্যাপ সার্ভার আর ডেটাবেস একই region-এ রাখুন, আর কোয়েরির সংখ্যা কম। স্বাস্থ্য-তথ্য দেশের বাইরে রাখা যাবে কি না (data residency) — এ বিষয়ে বাংলাদেশের নিয়মকানুন বদলাচ্ছে; চালুর আগে সর্বশেষ আইন আর প্রয়োজনে আইনজীবীর পরামর্শ নিন, অনুমানে নয়। এক শাখার ছোট ক্লিনিকের জন্য একটি যুক্তিসংগত শুরু: একটি managed PostgreSQL, স্বয়ংক্রিয় backup আর PITR চালু, একটি ছোট instance — আর পরিস্থিতি বদলালে migration-এর পথ খোলা।

Release checklist — প্রতিটি পরিবর্তনের পথ

  1. MigrationGit, review
  2. Stagingআসল মাপে
  3. Backupযাচাই করা
  4. Productionশান্ত সময়ে
  5. Smoke testসব পাস?
  6. পর্যবেক্ষণফেরার পথ
V7__slot_mode.sqlলাল = এই ধাপের শর্ত

            
  1. Migration লেখা। পরিবর্তন একটি ফাইলে, সংস্করণ নম্বরসহ, review-এর পর। প্রধান প্রশ্ন: পুরোনো অ্যাপ এই schema-তে চলবে কি? DEFAULT-সহ নতুন কলাম পুরোনো INSERT-কে ভাঙে না — তাই এটি নিরাপদ expand ধাপ।
  2. Staging. production-এর মাপের ডেটায় (masked কপি) চালিয়ে সময় আর lock মাপা। PostgreSQL 11 থেকে স্থির DEFAULT-সহ ADD COLUMN পুরো টেবিল নতুন করে লেখে না, কিন্তু অল্প সময়ের জন্য কড়া lock নেয় — তাই মাপা জরুরি।
  3. Backup যাচাই। শেষ base backup আর WAL archive তাজা কি না, আর গত restore drill-এ RTO লক্ষ্যের মধ্যে ছিল কি না। ফেরার পথ জানা না থাকলে এগোনো নয়।
  4. Production. সবচেয়ে কম বুকিংয়ের সময়ে। lock_timeout দিয়ে নিশ্চিত করা যে migration কোনো লম্বা transaction-এর পেছনে আটকে বাকি সবাইকে লাইনে দাঁড় করাবে না।
  5. Smoke test. কয়েকটি দ্রুত পরীক্ষা: মূল নিয়মগুলো এখনো রক্ষা পাচ্ছে কি না, হিসাব মেলে কি না। একটিও FAIL হলে release থামে।
  6. পর্যবেক্ষণ। পরের দিন latency, error আর 23505-এর সংখ্যা দেখা। expand ধাপে পুরোনো অ্যাপ নতুন schema-তে চলে, তাই সমস্যা হলে অ্যাপ ফেরানোই যথেষ্ট — ডেটাবেস ফেরাতে হয় না।

নিচে একটি smoke test-এর ছোট রূপ: schema বানানো, recursive CTE-তে seed, double booking-এর চেষ্টা, বাতিল slot আবার বুক, আর আয়ের রিপোর্ট মেলানো। CI pipeline-এ প্রতিটি migration-এর পর এমন একটি স্ক্রিপ্ট চলে:

import sqlite3
db = sqlite3.connect(":memory:", autocommit=True); db.execute("PRAGMA foreign_keys = ON")
db.executescript("""
CREATE TABLE doctors (doctor_id INTEGER PRIMARY KEY, fee INT NOT NULL CHECK (fee > 0));
CREATE TABLE slots (slot_id INTEGER PRIMARY KEY, doctor_id INT NOT NULL REFERENCES doctors,
  slot_start TEXT NOT NULL, UNIQUE (doctor_id, slot_start));
CREATE TABLE appointments (appt_id INTEGER PRIMARY KEY, slot_id INT NOT NULL REFERENCES slots,
  patient_id INT NOT NULL, status TEXT NOT NULL DEFAULT 'booked');
CREATE UNIQUE INDEX one_active ON appointments(slot_id) WHERE status <> 'cancelled';
CREATE TABLE payments (appt_id INT NOT NULL REFERENCES appointments, amount INT NOT NULL,
  method TEXT NOT NULL CHECK (method IN ('bkash', 'cash')));
INSERT INTO doctors VALUES (1, 800), (2, 1000);
WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < 15)
INSERT INTO slots (doctor_id, slot_start)
SELECT i / 8 + 1, datetime('2026-11-01 17:00', '+' || (i % 8 * 15) || ' minutes') FROM n;
INSERT INTO appointments (slot_id, patient_id) SELECT slot_id, slot_id FROM slots WHERE slot_id % 3 <> 0;
""")
def rejected(sql):
    try: db.execute(sql); return False
    except sqlite3.IntegrityError: return True
checks = {"16 slots seeded": db.execute("SELECT COUNT(*) FROM slots").fetchone()[0] == 16,
          "double booking rejected": rejected("INSERT INTO appointments (slot_id, patient_id) VALUES (1, 99)")}
db.execute("UPDATE appointments SET status = 'cancelled' WHERE slot_id = 2")
checks["cancelled slot can be rebooked"] = not rejected("INSERT INTO appointments (slot_id, patient_id) VALUES (2, 77)")
db.execute("""INSERT INTO payments SELECT a.appt_id, d.fee, CASE a.appt_id % 2 WHEN 0 THEN 'bkash' ELSE 'cash' END
  FROM appointments a JOIN slots s USING (slot_id) JOIN doctors d USING (doctor_id) WHERE a.status <> 'cancelled'""")
report = db.execute("SELECT method, COUNT(*), SUM(amount) FROM payments GROUP BY method").fetchall()
checks["revenue = 6 x 800 + 5 x 1000"] = sum(r[2] for r in report) == 9800
for name, ok in checks.items(): print(f"[{'PASS' if ok else 'FAIL'}] {name}")
print("daily report 2026-11-01:", report)
print("all checks passed" if all(checks.values()) else "SOME CHECKS FAILED")

আউটপুট:

[PASS] 16 slots seeded
[PASS] double booking rejected
[PASS] cancelled slot can be rebooked
[PASS] revenue = 6 x 800 + 5 x 1000
daily report 2026-11-01: [('bkash', 5, 4400), ('cash', 6, 5400)]
all checks passed

দুজন ডাক্তারের ৮টি করে slot; ৩-এর গুণিতক বাদে বুক — প্রথম ডাক্তারের ৬টি, দ্বিতীয়জনের ৫টি। slot ২ বাতিল করে রোগী ৭৭-কে দেওয়ায় সংখ্যা বদলায় না, তাই প্রত্যাশিত আয় ৬ × ৮০০ + ৫ × ১,০০০ = ৯,৮০০, আর রিপোর্টের ৪,৪০০ + ৫,৪০০ ঠিক তাই। autocommit=True (Python 3.12 থেকে) প্রতিটি statement-কে নিজের transaction-এ রাখে, তাই ব্যর্থ INSERT পরের কাজে প্রভাব ফেলে না। প্রত্যাশিত মানগুলো হাতে হিসাব করে লেখা — কোড দিয়ে কোড যাচাই নয়।

১০ · AI-এর যুগে ডিজাইন — আর এরপর কোথায়

“একটি ক্লিনিক appointment সিস্টেমের schema দাও” — AI কয়েক সেকেন্ডে সুন্দর CREATE TABLE দেবে। সাধারণত সেটি চলে; প্রশ্ন হলো নিয়মগুলো রক্ষা করে কি না। এই ধরনের কাজে AI-এর প্রচলিত ভুল:

AI-এর ভুলফলকীভাবে ধরবেন
double booking শুধু অ্যাপের “আগে দেখো” কোডেএকসাথে দুটি বুকিংয়ে নিয়ম ভাঙেদুটি connection-এ race পরীক্ষা (অংশ ৬)
slot-এ সাধারণ UNIQUEবাতিল slot আর বুক করা যায় নাবাতিল করে আবার বুকের পরীক্ষা
LEFT JOIN … IS NULL, status ছাড়াবাতিল slot “ভরা” দেখায়বাতিল সারিসহ নমুনা ডেটায় চালানো
সব foreign key-তে ON DELETE CASCADEএকজন ডাক্তার মুছলে চিকিৎসার ইতিহাস উধাওপ্রতিটি FK-র action নিয়ে প্রশ্ন
টাকা FLOAT-এ, সময় টেক্সটে এলোমেলো ফরম্যাটেহিসাবে পয়সার ভুল, ভুল ক্রমটাইপ-তালিকা আলাদা করে পর্যালোচনা
নিজে বানানো কলাম বা টেবিলের নামকোয়েরি আপনার schema-তে চলে নাআসল schema দিন, চালিয়ে দেখুন

AI-কে নিয়ম দিন, শুধু বিষয় নয় — আর প্রতিটি নিয়মের প্রমাণ চান:

আমি একটি ক্লিনিক appointment সিস্টেমের PostgreSQL 16 schema বানাচ্ছি। টেবিল: doctors, slots, patients, appointments(status: booked/completed/no_show/cancelled), prescriptions, payments(bkash/cash). নিয়ম: R1 একটি slot-এ বাতিল-নয় এমন appointment সর্বোচ্চ একটি; R2 একজন রোগীর একই সময়ে দুটি সক্রিয় appointment নয়; R5 bKash হলে trx_id বাধ্যতামূলক ও অনন্য; R6 prescription মোছা নিষেধ, সংশোধনের audit. ১. প্রতিটি নিয়ম কোন constraint, index বা trigger রক্ষা করে — একটি তালিকায় দেখাও। ২. দুটি transaction একসাথে চললে (READ COMMITTED) প্রতিটি ব্যবস্থা টেকে কি না যুক্তি দাও; না টিকলে বিকল্প দাও। ৩. প্রতিটি foreign key-র ON DELETE কেন বেছেছ বলো। ৪. প্রতিটি নিয়ম ভাঙার একটি করে পরীক্ষামূলক INSERT দাও, যা ব্যর্থ হওয়া উচিত। ৫. আমার দেওয়া নামের বাইরে কোনো টেবিল বা কলাম বানাবে না; কিছু অস্পষ্ট হলে জিজ্ঞেস করো।

AI-এর schema চালুর আগে — চেকলিস্ট

এরপর কোথায় — মডিউল ১০ আর আপনার নিজের প্রজেক্ট

এই একটি সিস্টেমে কোর্সের প্রায় প্রতিটি মডিউল কাজে লেগেছে: ER আর mapping (মডিউল ২), SQL (৩–৪), FD আর normal form (৫), index (৬), transaction (৭), নিরাপত্তা আর পরিচালনা (৮), cloud (৯)। এখন পরীক্ষা আর ইন্টারভিউর জন্য একই জ্ঞানকে দ্রুত ব্যবহারের অনুশীলন:

নিজের প্রজেক্টের জন্য কয়েকটি বিস্তার: অপেক্ষমাণ তালিকা (waitlist) — slot বাতিল হলে প্রথমজনকে স্বয়ংক্রিয় প্রস্তাব; SMS reminder-এর জন্য outbox টেবিল; একাধিক শাখা — প্রতিটি টেবিলে branch_id আর row-level security; আর মাসিক রিপোর্টের জন্য একটি ছোট data warehouse (পর্ব ৫৫)। অথবা একই পদ্ধতিতে সম্পূর্ণ আলাদা একটি সিস্টেম — যেমন বাসের টিকিট বুকিং, যেখানে slot-এর জায়গায় “যাত্রা × আসন”.

আরও গভীরে: PostgreSQL-এ unique index-এর যাচাই কীভাবে একসাথে চলা transaction-এর মধ্যে সঠিক থাকে? INSERT-এর সময় B-tree-তে একই key-র কোনো entry পেলে, তার transaction এখনো চলমান হলে নতুন inserter সেই transaction-এর শেষ পর্যন্ত অপেক্ষা করে, তারপর আবার দেখে — তাই isolation level যা-ই হোক, অনন্যতা ভাঙে না। Exclusion constraint একই ধাঁচে GiST index-এ “ছেদ করা” entry খোঁজে। অন্যদিকে সাধারণ SELECT-ভিত্তিক trigger-কে নিরাপদ করতে SERIALIZABLE-এর SSI predicate lock নেয় আর rw-antidependency-র চক্র ধরলে একটি transaction বাতিল করে (পর্ব ৪৭)।

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • একটি বুকিং সিস্টেমে double booking কীভাবে ঠেকাবেন? — ডেটাবেসের constraint দিয়ে: সক্রিয় সারির ওপর partial unique index, সময়-পরিসরের জন্য exclusion constraint; অ্যাপের আগাম যাচাই শুধু UX.
  • “আগে দেখো, তারপর লেখো” কেন অনিরাপদ? — দুটি transaction দুজনই “খালি” দেখতে পারে; দেখা আর লেখা অবিভাজ্য নয় — check-then-act race.
  • বাতিল appointment মুছবেন না status বদলাবেন? — status বদলান: ইতিহাস, no-show-এর হিসাব আর audit থাকে; partial index বাতিল সারিকে অনন্যতার বাইরে রাখে।
  • RPO আর RTO-র পার্থক্য? — RPO কতটা ডেটা হারানো মেনে নেওয়া যায় (সময়ে), RTO সেবা ফিরতে কত সময়; প্রথমটি WAL archiving, দ্বিতীয়টি restore-এর গতি ও standby ঠিক করে।
  • একটি টেবিল BCNF কি না কীভাবে দেখাবেন? — সব অ-তুচ্ছ FD লিখে দেখান প্রতিটির বাঁ দিক superkey.

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১Ruby on Rails-এর নিজের নির্দেশিকা সতর্ক করে যে validates_uniqueness_of (অ্যাপের স্তরের যাচাই) দুটি একসাথে চলা অনুরোধে duplicate ঠেকাতে পারে না, আর ডেটাবেসে unique index রাখতে বলে। তাহলে অ্যাপের যাচাই রাখাই বা কেন — আর কোন নিয়ম ডেটাবেসে, কোনটি অ্যাপে?

Rails-এর uniqueness validation কাজ করে ঠিক অংশ ৬-এর মতো: আগে একটি SELECT চালিয়ে দেখে একই মান আছে কি না, না থাকলে INSERT. Rails Guides স্পষ্ট বলে, দুটি connection একই সময়ে এটি করলে দুটিই পাস করতে পারে — তাই ডেটাবেসে unique index দরকার। Django, Laravel আর অন্য framework-এর অভিজ্ঞতাও একই। এটি কোনো framework-এর দুর্বলতা নয়; দেখা আর লেখা আলাদা ধাপে হলে ফাঁক থাকবেই।

তবু অ্যাপের যাচাই মূল্যবান, কারণ দুটির কাজ আলাদা। অ্যাপের যাচাই ব্যবহারকারীর অভিজ্ঞতার জন্য: ফর্মে লাল দাগ দিয়ে বলা “এই ফোন নম্বরে আগেই নিবন্ধন আছে”, অনুবাদ করা বার্তা, একসাথে সব ভুল দেখানো। ডেটাবেসের constraint সত্যের জন্য: অ্যাপের যেকোনো সংস্করণ, যেকোনো স্ক্রিপ্ট, যেকোনো একসাথে চলা অনুরোধ — কেউই নিয়ম ভাঙতে পারবে না। প্রথমটি ৯৯% ক্ষেত্রে সুন্দরভাবে থামায়, দ্বিতীয়টি বাকি ১%-কে অসম্ভব করে।

কোন নিয়ম কোথায়? একটি কাজের মাপকাঠি: যে নিয়ম ভাঙলে ডেটা নিজেই মিথ্যা হয়ে যায় — একটি slot-এ দুজন, পেমেন্টের ID ছাড়া bKash, অস্তিত্বহীন রোগীর appointment — সেটি ডেটাবেসে। যে নিয়ম নীতি বা অভিজ্ঞতা, আর প্রায়ই বদলায় — “একজন রোগী সপ্তাহে তিনটির বেশি বুকিং করতে পারবেন না”, “৬ ঘণ্টার কম আগে বাতিল করলে জরিমানা” — সেটি অ্যাপে রাখা যুক্তিসংগত, যদিও প্রয়োজনে trigger-এও যেতে পারে। Jane Street-এর Yaron Minsky-র জনপ্রিয় বাক্য “make illegal states unrepresentable” প্রোগ্রামের টাইপের জন্য বলা, কিন্তু schema-তেও খাটে: যে অবস্থা কখনো সত্য হওয়া উচিত নয়, schema-কে এমনভাবে বানান যাতে সেটি লেখাই না যায়।

একটি বাস্তব সতর্কতা: constraint-এর error অ্যাপকে ধরতেই হবে। নইলে ব্যবহারকারী দেখেন “Internal Server Error”. ভালো অ্যাপ নির্দিষ্ট SQLSTATE (23505, 23514) চেনে আর তাকে মানুষের ভাষার বার্তায় বদলায়।

মূল উপলব্ধি: অ্যাপের যাচাই ভদ্র দারোয়ান, ডেটাবেসের constraint তালা। দারোয়ান বেশিরভাগ ভুল আগেই থামায়; তালা ছাড়া দরজা কখনো বন্ধ নয়।

প্র ০২বাংলাদেশ ২০০৯ সালের জুনে daylight saving time চালু করে ঘড়ি এক ঘণ্টা এগিয়ে দিয়েছিল, আর সে বছরের শেষে তা তুলে নেয়। এমন ঘটনা ভবিষ্যতের appointment-এর সময় রাখার নকশায় কী শিক্ষা দেয় — UTC-তে রাখবেন, না স্থানীয় সময়ে?

প্রচলিত পরামর্শ: “সব সময় UTC-তে রাখুন, দেখানোর সময় স্থানীয় সময়ে বদলান।” অতীতের ঘটনার জন্য — পেমেন্ট কখন হলো, prescription কখন লেখা — এটি সঠিক। মুহূর্তটি ঘটে গেছে, পৃথিবীর সব ঘড়িতে তার একটিই অবস্থান। PostgreSQL-এর timestamptz ঠিক এটিই করে: ভেতরে UTC, বাইরে session-এর time zone.

কিন্তু ভবিষ্যতের appointment আলাদা। রোগীর কাছে প্রতিশ্রুতিটি হলো “৫ নভেম্বর সন্ধ্যা ৫:৩০, ঢাকার ঘড়িতে” — UTC-র কোনো মুহূর্ত নয়। ২০০৯ সালে বাংলাদেশ সরকার অল্প সময়ের নোটিশে ১৯ জুন থেকে ঘড়ি এক ঘণ্টা এগিয়ে দেয়, আর বছরের শেষে আবার পিছিয়ে আনে। ধরুন মে মাসে কেউ জুলাইয়ের একটি appointment নিয়েছিলেন, আর সিস্টেম সেটি তখনকার নিয়মে UTC-তে বদলে রেখেছিল। নিয়ম বদলের পর সেই UTC মুহূর্ত ঢাকার ঘড়িতে আর ৫:৩০ নয়, ৬:৩০ — ডাক্তার আর রোগী দুজনেই ভুল সময়ে। স্থানীয় সময় আর time zone-এর নাম (Asia/Dhaka) রাখলে সমস্যা নেই: নিয়ম বদলালে IANA tz database-এর হালনাগাদ এলে রূপান্তর নিজেই ঠিক হয়।

তাই পরিণত নকশা দুই ধরনের সময়কে আলাদা করে: ঘটে যাওয়া মুহূর্ত timestamptz-এ (UTC), আর ভবিষ্যতের “দেয়ালঘড়ির সময়” স্থানীয় সময় + zone-এর নাম হিসেবে, প্রয়োজনে UTC-র একটি গণিত কপিসহ, যা নিয়ম বদলালে নতুন করে হিসাব হয়। আমাদের SQLite নকশা স্থানীয় ঢাকা সময় রেখেছে — এক শহরের ক্লিনিকের জন্য সরল আর যুক্তিসংগত — কিন্তু সেটি নথিতে লেখা থাকা দরকার, যাতে পরে কেউ ধরে না নেন এটি UTC.

আরেকটি শিক্ষা: সার্ভারের ঘড়ির ওপর নির্ভর করা কোড (now(), CURRENT_TIMESTAMP) সার্ভারের time zone সেটিং-এর ওপরও নির্ভর করে। cloud-এ সার্ভার প্রায়ই UTC-তে চলে, তাই “আজকের appointment” কোয়েরি ঢাকার মধ্যরাত আর UTC-র মধ্যরাতের ছয় ঘণ্টার পার্থক্যে ভুল দিন ধরতে পারে।

মূল উপলব্ধি: সময় দুই রকম — ঘটে যাওয়া মুহূর্ত আর ভবিষ্যতের প্রতিশ্রুতি। প্রথমটি UTC-তে, দ্বিতীয়টি স্থানীয় সময় আর zone-এর নামে; আর যে নিয়মই বাছুন, schema-র নথিতে লিখুন।

প্র ০৩Airline-গুলো জেনেশুনে আসনের চেয়ে বেশি টিকিট বিক্রি করে (overbooking), কারণ কিছু যাত্রী আসেন না। ২০১৭ সালের এপ্রিলে United Express-এর একটি ফ্লাইট থেকে একজন যাত্রীকে টেনে নামানোর ভিডিও বিশ্বজুড়ে আলোচনার জন্ম দেয়। আমাদের ক্লিনিকে no-show প্রায় ১৩% — R1 কি ঢিলে করা উচিত?

প্রশ্নটি প্রযুক্তির নয়, নীতির — আর ঠিক সেজন্যই ডেটাবেস ডিজাইনারের প্রশ্ন। Airline-এর হিসাব সরল: ১০০ আসনের ফ্লাইটে গড়ে ১০ জন না এলে ১০৫টি টিকিট বিক্রি করলে আয় বাড়ে, আর মাঝে মাঝে যখন সবাই আসেন, তখন কাউকে টাকা বা ভাউচার দিয়ে পরের ফ্লাইটে পাঠানো হয়। ২০১৭-র ঘটনায় একটি পূর্ণ ফ্লাইট থেকে কর্মীদের জায়গা করতে যাত্রী নামানো হচ্ছিল; একজন রাজি না হওয়ায় নিরাপত্তাকর্মীরা জোর করে নামান। পরে airline-টি নীতি বদলায় আর স্বেচ্ছায় আসন ছাড়ার ক্ষতিপূরণ অনেক বাড়ায়। শিক্ষা: overbooking-এর হিসাব গড়ে লাভজনক, কিন্তু খারাপ দিনের মানবিক দাম আগে থেকে পরিকল্পনা করতে হয়।

ক্লিনিকে কী হতে পারে? একজন ডাক্তারের ১৫ মিনিটে দুজন রোগী এলে কাউকে অপেক্ষা করতে হয় — অসুস্থ মানুষের জন্য সেটি টিকিট বদলের চেয়ে কঠিন। তাই চুপচাপ R1 তুলে দেওয়া ভুল উত্তর। constraint তুলে দিলে শুধু overbooking নয়, ভুলবশত double booking-ও আর ধরা পড়বে না — দুটিকে আলাদা করা যাবে না।

ভালো নকশা নীতিটিকে স্পষ্ট করে: slots-এ একটি capacity কলাম (সাধারণত ১, নির্দিষ্ট slot-এ ২), আর নিয়ম “সক্রিয় appointment ≤ capacity” — এটি আর সাধারণ unique index নয়, তাই trigger বা slot সারিতে lock নিয়ে গোনা লাগবে। অথবা আলাদা একটি standby অবস্থা: অপেক্ষমাণ রোগী, যিনি no-show হলে জায়গা পান — R1 অক্ষত থাকে, শুধু একটি waitlist যোগ হয়। আর no-show কমানোর সস্তা পথ প্রায়ই প্রযুক্তির বাইরে: আগের দিন SMS reminder, বা bKash-এ অল্প অগ্রিম।

এখানেই ডেটা কাজে লাগে: অংশ ৫-এর no-show-এর হার ডাক্তার, দিন আর সময় ধরে দেখলে বোঝা যায় overbooking আদৌ দরকার কি না, আর কোথায়।

মূল উপলব্ধি: constraint একটি নীতির লিখিত রূপ। নীতি বদলাতে হলে constraint মুছে নয়, নতুন নীতিটিকে স্পষ্টভাবে schema-তে লিখে বদলান — যাতে ইচ্ছাকৃত ব্যতিক্রম আর দুর্ঘটনা আলাদা থাকে।

প্র ০৪২০১৭ সালের ৩১ জানুয়ারি GitLab.com-এর একজন প্রকৌশলী ক্লান্ত অবস্থায় ভুল সার্ভারে ডেটাবেসের ফোল্ডার মুছে ফেলেন; পরে দেখা যায় তাদের কয়েকটি backup পদ্ধতির কোনোটিই ঠিকমতো কাজ করছিল না, আর প্রায় ছয় ঘণ্টার ডেটা হারায়। “backup আছে” আর “restore করা যায়” — এই দুইয়ের ফাঁক কীভাবে বন্ধ করবেন?

GitLab ঘটনাটি অস্বাভাবিক খোলামেলাভাবে প্রকাশ করেছিল — উদ্ধারের কাজ সরাসরি সম্প্রচারও করা হয়েছিল। প্রকাশিত বিবরণে ছিল: replication-এর একটি সমস্যা সারানোর সময় একজন প্রকৌশলী মনে করেছিলেন তিনি secondary সার্ভারে আছেন, আসলে ছিলেন primary-তে। তারপর backup খুঁজতে গিয়ে দেখা যায় নিয়মিত pg_dump সংস্করণের অমিলে নিঃশব্দে ব্যর্থ হচ্ছিল, আর ব্যর্থতার ইমেইল কারও কাছে পৌঁছাচ্ছিল না। শেষে উদ্ধার হয় একটি staging-এর জন্য প্রায় ছয় ঘণ্টা আগে নেওয়া কপি থেকে — অনেকটা ভাগ্যক্রমে।

শিক্ষাগুলো সরাসরি আমাদের পরিকল্পনায় যায়। প্রথম, backup-এর সাফল্য মাপুন, ব্যর্থতার ইমেইল নয়: “সবচেয়ে নতুন সফল backup কত ঘণ্টা পুরোনো” একটি metric, আর তার ওপর alert. নীরব ব্যর্থতা ধরা পড়ে শুধু ইতিবাচক সংকেত না আসায়। দ্বিতীয়, restore নিয়মিত অনুশীলন করুন: মাসে একবার একটি আলাদা সার্ভারে আসল backup থেকে ফেরানো, সময় মাপা (এটিই আপনার আসল RTO), আর ফেরানো ডেটাবেসে কয়েকটি কোয়েরি চালিয়ে মেলানো — যেমন অংশ ৫-এর দৈনিক আয়। যাচাই না করা backup আসলে একটি আশা।

তৃতীয়, ভুলের পথ কঠিন করুন: প্রতিটি সার্ভারের prompt-এ স্পষ্ট নাম আর রং (production লাল), ধ্বংসাত্মক কাজের আগে দ্বিতীয়জনের নিশ্চিতকরণ, আর মানুষের হাতে কাজ কমিয়ে স্বয়ংক্রিয় runbook. চতুর্থ, একাধিক স্বাধীন স্তর: replica মোছার বিরুদ্ধে রক্ষা করে না — primary-তে DROP হলে replica-তেও হয়। ভুল থেকে রক্ষা করে PITR, delayed replica আর অন্য অ্যাকাউন্টে immutable backup.

একটি ক্লিনিকের জন্য এর অর্থ: managed সেবার স্বয়ংক্রিয় backup চালু করাই শেষ নয়; restore-এর পরীক্ষা, backup-এর বয়সের alert আর একটি লিখিত “কী করব” নথিও প্রকল্পের অংশ।

মূল উপলব্ধি: backup-এর মূল্য শুধু restore-এর মুহূর্তে প্রমাণ হয়। তাই সেই মুহূর্তটি বিপর্যয়ের দিনে প্রথমবার নয় — প্রতি মাসে, শান্ত দিনে, ঘড়ি ধরে অনুশীলন করুন।

প্র ০৫একজন সহকর্মী বলেন, “prescription তো একটি document — ওষুধের তালিকা, নোট, পরীক্ষা — MongoDB-তে রাখলে সহজ হতো।” MongoDB 4.0 (২০১৮) থেকে বহু-document transaction আছে, আর partial unique index-ও আছে। তাহলে এই ক্লিনিকের জন্য relational নকশা বেছে নেওয়ার যুক্তি কী — বা কখন ভুল?

সহকর্মীর কথার একটি অংশ ঠিক। একটি prescription সত্যিই প্রায়ই একসাথে পড়া আর লেখা হয়: রোগনির্ণয়, কয়েকটি ওষুধ, পরীক্ষার পরামর্শ, পরের সাক্ষাতের তারিখ। document মডেলে এটি একটি nested document, এক পড়ায় পুরোটা (পর্ব ৫৬–৫৭)। আর ক্ষেত্রগুলো ঘন ঘন বদলায় — নতুন পরীক্ষার ধরন, নতুন নোট — schema না বদলেই।

কিন্তু এই সিস্টেমের কেন্দ্রে prescription নয়, বুকিং — আর বুকিং সম্পর্কে ভরা: slot, ডাক্তার, রোগী, পেমেন্ট। R1 আর R2 একাধিক collection জুড়ে নিয়ম; MongoDB-তে partial unique index (partialFilterExpression) দিয়ে R1 করা যায়, কিন্তু R2-এর মতো “সময়ের ছেদ” নিয়মের জন্য কোনো exclusion constraint নেই — অ্যাপের transaction-এ সাবধানে লেখা কোড লাগে। ম্যানেজারের রিপোর্ট — দৈনিক আয়, ব্যস্ততা, no-show — join আর aggregation-এ ভরা, যেখানে SQL স্বাভাবিকভাবেই শক্তিশালী। foreign key-র referential integrity-ও document ডেটাবেস নিজে দেয় না।

মাঝামাঝি পথও আছে, আর প্রায়ই সেটিই সেরা: PostgreSQL-এ prescription-এর মূল অংশ relational (কোন appointment, কোন ডাক্তার, কখন), আর পরিবর্তনশীল অংশ — পরীক্ষার ফলাফল, মুক্ত নোট — একটি jsonb কলামে, প্রয়োজনে GIN index-সহ (পর্ব ৩০)। একটি ডেটাবেস, এক transaction, এক backup. তবে ওষুধের লাইন আলাদা টেবিলে রাখার যুক্তি থেকেই যায়, কারণ “কতজনকে কোন ওষুধ” প্রশ্নটি ক্লিনিক সত্যিই করবে।

কখন সহকর্মী ঠিক? যদি সিস্টেমটি মূলত document সংগ্রহ হতো — যেমন হাজারো ধরনের চিকিৎসা-ফর্ম, প্রতিটির আলাদা গঠন, কদাচিৎ join — বা বিশাল মাপে বহু অঞ্চলে লেখার দরকার হতো। প্রযুক্তি বাছাইয়ের প্রশ্ন তাই “কোনটি আধুনিক” নয়, “আমার সবচেয়ে কঠিন নিয়ম আর সবচেয়ে ঘন ঘন প্রশ্ন কোন মডেলে সবচেয়ে সহজে আর নিরাপদে প্রকাশ হয়”.

মূল উপলব্ধি: ডেটার আকৃতি নয়, নিয়ম আর প্রশ্ন ডেটাবেস বাছায়। এখানে কঠিনতম নিয়ম সম্পর্কের ওপর, তাই relational কেন্দ্র — আর পরিবর্তনশীল অংশের জন্য jsonb.

অনুশীলন

একটি কোয়েরি লেখা, একটি ক্রম হাতে বিচার করা, একটি trigger লেখা, একটি backup-এর হিসাব।

  1. কোয়েরি লিখুন: ৫ নভেম্বর ২০২৬-এ প্রতিটি ডাক্তারের প্রথম খালি slot-এর সময় আর মোট খালি slot-এর সংখ্যা বের করুন — নাম, সময়, সংখ্যা। অংশ ৫-এর যেকোনো এডিটরে চালাতে পারেন।
    SELECT d.name, MIN(time(s.slot_start)) AS first_free, COUNT(*) AS free_slots
    FROM slots s JOIN doctors d ON d.doctor_id = s.doctor_id
    WHERE s.slot_start >= '2026-11-05' AND s.slot_start < '2026-11-06'
      AND NOT EXISTS (SELECT 1 FROM appointments a
                      WHERE a.slot_id = s.slot_id AND a.status <> 'cancelled')
    GROUP BY d.doctor_id ORDER BY first_free, d.name;

    ছয়জনেরই প্রথম খালি সময় 17:00:00; ডা. শারমিনের 4টি, বাকিদের 3টি করে খালি (তাঁর বাতিল slot ১৯৫-এর কারণে একটি বেশি)। anti-join আগে প্রতিটি খালি slot ছাঁকে, তারপর GROUP BY ডাক্তার ধরে MIN আর COUNT. JOIN-টি “এক” দিকের (প্রতিটি slot-এর একজনই ডাক্তার), তাই fan-out নেই।

  2. হাতে বিচার করুন (trace): appointments(appt_id, slot_id, patient_id, status) টেবিল খালি, আর CREATE UNIQUE INDEX one_active ON appointments(slot_id) WHERE status <> 'cancelled' আছে। ক্রমে চলে: (ক) slot ৫০, রোগী ১, booked; (খ) slot ৫০, রোগী ২, booked; (গ) UPDATE … SET status = 'cancelled' WHERE patient_id = 1; (ঘ) slot ৫০, রোগী ২, booked; (ঙ) UPDATE … SET status = 'booked' WHERE patient_id = 1. প্রতিটি সফল না ব্যর্থ?

    (ক) সফল — index-এ slot ৫০ প্রথমবার। (খ) ব্যর্থ — সক্রিয় সারিতে slot ৫০ আগে থেকে আছে। (গ) সফল — রোগী ১-এর সারি index থেকে বেরিয়ে যায়। (ঘ) সফল — এখন slot ৫০-এর কোনো সক্রিয় সারি নেই। (ঙ) ব্যর্থ — রোগী ১-এর সারি আবার সক্রিয় হলে index-এ ঢুকতে চায়, কিন্তু রোগী ২ সেখানে। অর্থাৎ partial index UPDATE-ও পাহারা দেয়; “বাতিল ফিরিয়ে আনা” double booking তৈরি করতে পারে না। শেষে দুটি সারি: রোগী ১ cancelled, রোগী ২ booked.

  3. Trigger লিখুন: অংশ ৬-এর R2 trigger শুধু INSERT দেখে। একটি UPDATE trigger লিখুন, যা কোনো appointment-এর slot_id বা status বদলানোর পর সেটি সক্রিয় থাকলে একই রোগীর অন্য কোনো সক্রিয় appointment-এর সাথে সময় ছেদ করলে বদলটি থামায়।
    CREATE TRIGGER appt_no_patient_overlap_upd
    BEFORE UPDATE OF slot_id, status, patient_id ON appointments
    WHEN NEW.status <> 'cancelled'
    BEGIN
      SELECT RAISE(ABORT, 'patient already has an appointment at this time')
      WHERE EXISTS (
        SELECT 1 FROM appointments a
        JOIN slots s  ON s.slot_id = a.slot_id
        JOIN slots ns ON ns.slot_id = NEW.slot_id
        WHERE a.patient_id = NEW.patient_id AND a.status <> 'cancelled'
          AND a.appt_id <> NEW.appt_id
          AND s.slot_start < ns.slot_end AND ns.slot_start < s.slot_end);
    END;

    মূল পার্থক্য a.appt_id <> NEW.appt_id: BEFORE UPDATE-এ টেবিলে এখনো পুরোনো সারিটি আছে, আর নিজের সাথে নিজের ছেদ সবসময় সত্য — এই শর্ত ছাড়া প্রতিটি UPDATE থেমে যেত। UPDATE OF তালিকায় patient_id-ও আছে, কারণ appointment অন্য রোগীর নামে সরালেও নিয়মটি প্রযোজ্য। PostgreSQL-এ একই কাজ exclusion constraint নিজে থেকে করে।

  4. Backup-এর হিসাব করুন: একটি ক্লিনিক প্রতি রাত ২টায় full backup নেয়, WAL archiving নেই। বুধবার সন্ধ্যা ৭টায় ডিস্ক নষ্ট হলো। (ক) সবচেয়ে খারাপ ক্ষেত্রে কত ঘণ্টার ডেটা হারায়, আর এই ঘটনায় কত? (খ) archive_timeout = 60s সহ WAL archiving চালু থাকলে? (গ) restore-এ ২৫ মিনিট, WAL replay-এ ২০ মিনিট, ধরা পড়তে ১৫ মিনিট আর যাচাইয়ে ১০ মিনিট লাগলে RTO ১ ঘণ্টার লক্ষ্য পূরণ হয়?
    • (ক) সবচেয়ে খারাপ ক্ষেত্রে প্রায় ২৪ ঘণ্টা (backup-এর ঠিক আগে নষ্ট হলে)। এই ঘটনায় রাত ২টা থেকে সন্ধ্যা ৭টা — ১৭ ঘণ্টা, অর্থাৎ পুরো দিনের বুকিং আর পেমেন্ট।
    • (খ) WAL প্রতি মিনিটে archive-এ গেলে হারায় মোটামুটি শেষ archive-এর পরের অংশ — প্রায় ১ মিনিট (সাথে ঠিক যে ফাইলটি পাঠানো হচ্ছিল)। synchronous replica থাকলে commit হওয়া কোনো লেনদেনই হারায় না।
    • (গ) ১৫ + ২৫ + ২০ + ১০ = ৭০ মিনিট — লক্ষ্য পূরণ হয় না। সবচেয়ে সস্তা উন্নতি দ্রুত ধরা পড়া (ভালো alert), আর base backup ঘন ঘন নেওয়া, যাতে replay ছোট হয়; আরও দ্রুত চাইলে hot standby-তে failover.

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি নতুন ডেটাবেস প্রকল্পের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. requirement আর ব্যবসার নিয়ম লেখা
  2. ER ডায়াগ্রাম আর মূল সিদ্ধান্ত
  3. constraint-সহ schema আর normalization যাচাই
  4. নমুনা ডেটায় কোয়েরি, constraint আর index পরীক্ষা
  5. backup, monitoring আর smoke test-সহ deployment

একটি বুকিং অ্যাপ আগে SELECT দিয়ে দেখে slot খালি কি না, খালি হলে INSERT করে। দুজন ব্যবহারকারী একই মুহূর্তে একই slot চাইলে double booking নিশ্চিতভাবে কী ঠেকায়?

  • অ্যাপে SELECT-এর পরে আরেকবার SELECT
  • ডেটাবেস সার্ভার আরও দ্রুত করা
  • সক্রিয় সারির ওপর UNIQUE (partial) index
  • SELECT আর INSERT একটি READ COMMITTED transaction-এ রাখা

slots(slot_id, …) আর appointments(slot_id, status, …) টেবিলে বাতিল appointment-এর সারি মোছা হয় না। “খালি slot” বের করতে কেউ লিখলেন slots LEFT JOIN appointments … WHERE appt_id IS NULL, status না দেখে। কোন slot ভুলভাবে বাদ পড়ে?

  • যে slot-এ কখনো কোনো appointment হয়নি
  • যে slot-এর একমাত্র appointment বাতিল হয়েছে
  • যে slot-এ একটি booked appointment আছে
  • যে slot-এ একটি বাতিল আর একটি নতুন booked appointment আছে

slots(slot_id, doctor_id, slot_start, slot_end)-এ slot_id primary key আর UNIQUE (doctor_id, slot_start). FD দুটি: slot_id → বাকি সব, (doctor_id, slot_start) → বাকি সব। টেবিলটি সর্বোচ্চ কোন normal form-এ?

  • BCNF
  • শুধু 3NF
  • শুধু 2NF
  • শুধু 1NF, কারণ দুটি candidate key

দুটি half-open সময়-পরিসর [s1, e1) আর [s2, e2) কখন ছেদ করে (overlap)?

  • s1 = s2
  • s1 <= s2 AND e2 <= e1
  • s1 <= e2 AND s2 <= e1
  • s1 < e2 AND s2 < e1

একটি ক্লিনিক প্রতি রাতে একবার full backup নেয়, WAL archiving বা replica নেই। সবচেয়ে খারাপ ক্ষেত্রে RPO মোটামুটি কত?

  • শূন্য
  • প্রায় ২৪ ঘণ্টা
  • প্রায় ১ ঘণ্টা
  • restore-এ যত সময় লাগে, তত

PostgreSQL-এ ডাক্তারদের জন্য একটি view বানানো হলো, যাতে পেমেন্টের কলাম নেই, আর doctor role-কে view-তে SELECT দেওয়া হলো। ডাক্তাররা যাতে পেমেন্ট দেখতে না পারেন, তার জন্য আর কী অবশ্যই নিশ্চিত করতে হবে?

  • view-টিকে materialized view বানানো
  • payments টেবিলে index বানানো
  • doctor role-এর payments টেবিলে সরাসরি কোনো অধিকার না থাকা
  • view-এর নাম গোপন রাখা

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. নিয়ম আগে: requirement থেকে নম্বরযুক্ত ব্যবসার নিয়ম, আর প্রতিটির একটি নির্দিষ্ট রক্ষক — CHECK, UNIQUE, FK, partial index বা trigger.
  2. ডিজাইনের সিদ্ধান্ত: slot আলাদা entity (খালি সময়ের অস্তিত্ব), appointment associative entity, বাতিল মানে status, ওষুধ আলাদা লাইনে, ইতিহাসে RESTRICT. FD লিখে প্রতিটি টেবিলের BCNF প্রমাণ।
  3. Double booking: “আগে দেখো, তারপর লেখো” পাহারাদার নয়। partial UNIQUE index আর PostgreSQL-এ exclusion constraint একসাথে চলা transaction-এও টেকে; trigger-ভিত্তিক যাচাই READ COMMITTED-এ নয়।
  4. কোয়েরি আর index: anti-join-এ status ভুলবেন না, fan-out এড়াতে “অনেক” দিক subquery-তে, হারের হর সঠিক; index কোয়েরি ধরে, EXPLAIN-এ আগে-পরে মেপে।
  5. চালানো: least privilege, view-এর সাথে মূল টেবিলের অনুমতি কাড়া, audit; RPO-র জন্য WAL archiving, RTO-র জন্য restore drill; migration expand–contract-এ; release-এর শেষে smoke test.

পরের পর্বে: মডিউল ৯ শেষ, সাথে কোর্সের শেখার অংশও। পর্ব ৬৩-এ মডিউল ১০ শুরু — পরীক্ষা আর ইন্টারভিউর প্রস্তুতি: একটি কোয়েরি না চালিয়েই তার ফল হাতে বের করা।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান