অধ্যায় 3 · প্রোডাকশন FastAPI
SQLAlchemy ও Alembic দিয়ে ডেটাবেস
- পৃষ্ঠা 10 / 22
- 27 মিনিট পড়া
এতদিন Prompt Library তার ডেটা রাখত একটা Python ডিকশনারিতে, যা প্রসেস রিস্টার্ট হলেই হারিয়ে যায় এবং ওয়ার্কারদের মধ্যে ভাগ হয় না। একটা সত্যিকারের AI ব্যাকএন্ড প্রম্পট, ব্যবহারকারী, কনভারসেশন, ইভ্যালুয়েশনের ফল ও ব্যবহারের রেকর্ড রাখে ডেটাবেসে, আর প্রোডাক্ট চালু থাকা অবস্থাতেই প্রতি কয়েক সপ্তাহে সেই ডেটাবেসের গড়ন বদলায়। এই পাতায় আপনি FastAPI-কে PostgreSQL-এর সাথে যুক্ত করবেন SQLAlchemy 2 দিয়ে (যে ORM-এর সাথে AI-এর জন্য SQL ও ডেটা হ্যান্ডলিং: অ্যাডভান্সড-এ পরিচয় হয়েছে), আর স্কিমা বদল সামলাবেন Alembic দিয়ে, একই নির্মাতাদের মাইগ্রেশন টুল।
প্রম্পট সার্ভিসটা আপনি নতুন করে বানাবেন ডেটাবেসের ওপর: রিলেশনশিপ ও কনস্ট্রেইন্টসহ মডেল, প্রতি রিকোয়েস্টে একটা সেশন, একটা রিপোজিটরি, প্রয়োগ ও ফেরত নেওয়ার মতো মাইগ্রেশন, যথাযথ এরর হ্যান্ডলিং (নাম ডুপ্লিকেট হলে 409, ক্র্যাশ নয়), পেজিনেশন ও ফিল্টারিং, এবং আলাদা একটা টেস্ট ডেটাবেস।
যা শিখবেন
- ফরেন কী, রিলেশনশিপ ও ডেটাবেস কনস্ট্রেইন্টসহ SQLAlchemy 2 মডেল, এবং "আগে দেখো, তারপর ঢোকাও" পদ্ধতির চেয়ে কেন কনস্ট্রেইন্ট ভালো।
- প্রসেসপ্রতি একটা ইঞ্জিন, রিকোয়েস্টপ্রতি একটা সেশন (ডিপেন্ডেন্সি), এবং ট্রানজ্যাকশনের সীমানা সামলানো রিপোজিটরি ও সার্ভিস।
- Alembic:
init,revision --autogenerate,upgrade,downgrade, আর অটোজেনারেট যা দেখতে পায় না। - রোলব্যাক,
IntegrityErrorথেকে409, N+1 কোয়েরি সমস্যা, কিসেট পেজিনেশন, ফিল্টারিং, কানেকশন পুলিং, সিঙ্ক বনাম অ্যাসিঙ্ক অ্যাক্সেস। - আলাদা টেস্ট ডেটাবেস, ডিপ্লয়মেন্টে PostgreSQL, এবং SQL ইনজেকশন ও ব্যাকআপের সংক্ষিপ্ত পুনরালোচনা।
সেটআপ: একটা প্রজেক্ট ও দুটো ডেটাবেস
এই পাতায় PostgreSQL ব্যবহার হচ্ছে, কারণ বেশিরভাগ প্রোডাকশন AI ব্যাকএন্ড এটাই চালায় (আর Docker Compose পাতাও এটাই ব্যবহার করে)। নিচের প্রতিটা উদাহরণ সত্যিকারের সার্ভারের বিরুদ্ধে চলে। আপনার কাছে না থাকলে Docker দিয়ে একটা চালু করুন:
docker run -d --name bitbyte-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:18এরপর প্রজেক্ট বানান। psycopg হলো PostgreSQL ড্রাইভার (ভার্সন ৩); [binary] এক্সট্রা তার দরকারি C লাইব্রেরি সাথে এনে দেয়:
mkdir -p ~/projects/prompt-db/app ~/projects/prompt-db/tests
cd ~/projects/prompt-db
uv init --bare -q
uv add -q "fastapi[standard]" sqlalchemy alembic "psycopg[binary]"
uv add -q --dev pytest httpx2
# with pip: python -m venv .venv && source .venv/bin/activate && pip install -q "fastapi[standard]" sqlalchemy alembic "psycopg[binary]" pytest httpx2
touch app/__init__.py
cat >> pyproject.toml <<'EOF'
[tool.fastapi]
entrypoint = "app.main:app"
EOF
export DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/p10_prompts
echo okokশেষ লাইনটা কানেকশন স্ট্রিং এক্সপোর্ট করে, যা অ্যাপ পড়বে (postgresql+psycopg:// SQLAlchemy ডায়ালেক্ট আর psycopg 3 ড্রাইভার বেছে নেয়)। Windows PowerShell-এ লিখুন $env:DATABASE_URL = "..."। আপনার দুটো ডেটাবেস লাগবে: একটা ডেভেলপমেন্টের জন্য, আরেকটা টেস্টের জন্য, যেটা টেস্ট নষ্ট করে ফেললেও ক্ষতি নেই। স্ক্রিপ্টটা দুটোকেই ড্রপ করে আবার বানায়, যাতে আপনি পাতাটা শুরু থেকে আবার চালাতে পারেন (ডেটাবেসের নাম প্যারামিটার হিসেবে বাইন্ড করা যায় না, তাই স্ক্রিপ্ট নামগুলো সরাসরি SQL-এ বসায়; এটা নিরাপদ শুধু এ কারণে যে নামগুলো আপনার নিজের কোডের ধ্রুবক):
import psycopg
with psycopg.connect("postgresql://postgres:postgres@localhost:5432/postgres", autocommit=True) as conn:
for name in ("p10_prompts", "p10_prompts_test"):
conn.execute(f"DROP DATABASE IF EXISTS {name} WITH (FORCE)")
conn.execute(f"CREATE DATABASE {name}")
rows = conn.execute("SELECT datname FROM pg_database WHERE datname LIKE 'p10%' ORDER BY 1")
print([row[0] for row in rows])['p10_prompts', 'p10_prompts_test']autocommit=True লাগে কারণ CREATE DATABASE ট্রানজ্যাকশনের ভেতরে চলে না।
ইঞ্জিন, সেশন ও মডেল
তিনটা অবজেক্ট কাজ করে। ইঞ্জিন (engine) কানেকশন পুলের মালিক এবং প্রসেসে একবারই তৈরি হয়। সেশন (session) একটা স্বল্পায়ু কাজের একক: সে একটা কানেকশন ধার নেয়, আপনার লোড ও বদলানো অবজেক্টগুলোর হিসাব রাখে, আর ফ্লাশ বা কমিট করলে SQL পাঠায়। মডেল Python ক্লাসকে টেবিলের সাথে মেলায়।
# file: app/db.py
"""Engine, session factory and the declarative base."""
import os
from collections.abc import Iterator
from sqlalchemy import MetaData, create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
DATABASE_URL = os.environ["DATABASE_URL"] # no default: a missing setting should fail loudly
# Named constraints make migrations predictable and let us map errors to messages.
NAMING = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_N_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING)
engine = create_engine(DATABASE_URL, pool_size=5, max_overflow=5, pool_pre_ping=True)
SessionLocal = sessionmaker(engine, expire_on_commit=False)
def get_session() -> Iterator[Session]:
"""FastAPI dependency: one session per request, always closed, rolled back if not committed."""
with SessionLocal() as session:
yield session- URL-এর গঠন
dialect+driver://user:password@host:port/database। এটা এনভায়রনমেন্ট থেকে পড়ুন, যাতে একই কোড আপনার ল্যাপটপে, CI-তে ও প্রোডাকশনে চলে (আগের পাতার Settings ক্লাসই এর গোছানো জায়গা)। expire_on_commit=Falseকমিটের পরেও লোড করা অবজেক্ট ব্যবহারযোগ্য রাখে। ডিফল্টে SQLAlchemy সেগুলোকে মেয়াদোত্তীর্ণ করে দেয়, ফলে পরের অ্যাট্রিবিউট অ্যাক্সেসে চুপচাপ আরেকটা কোয়েরি চলে, যা রেসপন্স মডেলের ভেতরে অপ্রত্যাশিত।get_sessionএকটাyieldডিপেন্ডেন্সি, ঠিক ডিপেন্ডেন্সি, লাইফস্প্যান, মিডলওয়্যার ও সেটিংস পাতার ডিপেন্ডেন্সিগুলোর মতো:withব্লক থেকে বেরোলে সেশন বন্ধ হয় এবং কানেকশন পুলে ফিরে যায়। তখন কমিট না-হওয়া ট্রানজ্যাকশন রোলব্যাক হয়, তাই কমিট ভুলে গেলে ডেটা হারায় কিন্তু অর্ধেক-হওয়া পরিবর্তন পড়ে থাকে না।
এবার ডেটা মডেল। একটা প্রম্পটের অনেকগুলো ভার্সন থাকে, আর প্রতিটা ভার্সন ঠিক একটা প্রম্পটের:
# file: app/models.py
"""Tables: prompts (one row per name) and prompt_versions (one row per saved version)."""
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
class Prompt(Base):
__tablename__ = "prompts"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(60), unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
versions: Mapped[list["PromptVersion"]] = relationship(
back_populates="prompt", order_by="PromptVersion.version",
cascade="all, delete-orphan", passive_deletes=True,
)
class PromptVersion(Base):
__tablename__ = "prompt_versions"
__table_args__ = (
UniqueConstraint("prompt_id", "version"),
CheckConstraint("temperature BETWEEN 0 AND 2", name="temperature_range"),
)
id: Mapped[int] = mapped_column(primary_key=True)
prompt_id: Mapped[int] = mapped_column(ForeignKey("prompts.id", ondelete="CASCADE"), index=True)
version: Mapped[int]
template: Mapped[str] = mapped_column(Text)
temperature: Mapped[float] = mapped_column(server_default="0.2")
prompt: Mapped[Prompt] = relationship(back_populates="versions")সিদ্ধান্তগুলো নিয়ে কিছু কথা:
Mapped[int]মানেNOT NULL;Mapped[int | None]হলে কলাম null হতে পারত। অ্যানোটেশনই স্কিমা।- ফরেন কী (foreign key)
prompt_idডেটাবেসকে এমন ভার্সন নিতে দেয় না, যা না-থাকা প্রম্পটকে নির্দেশ করে।ondelete="CASCADE"PostgreSQL-কে প্রম্পটের সাথে তার ভার্সনগুলোও মুছতে দেয়, আরpassive_deletes=TrueSQLAlchemy-কে বলে প্রতিটা ভার্সন আগে লোড না করে এর ওপর ভরসা করতে। - PostgreSQL ফরেন কী নিজে থেকে ইনডেক্স করে না;
index=Trueসেটা করে, ফলে "প্রম্পট ৭-এর সব ভার্সন" দ্রুত আসে। UNIQUEওCHECKকনস্ট্রেইন্ট যে কোডই ডেটা লিখুক, সবসময় প্রযোজ্য থাকে: আপনার API, একটা স্ক্রিপ্ট, বাpsql-এ বসা সহকর্মী।
SQLModel Pydantic ও SQLAlchemy একটা ক্লাসে মেলায়। এই কোর্স ORM মডেল (স্টোরেজ) আর Pydantic স্কিমা (API চুক্তি) আলাদা রাখে: দুটো আলাদা কারণে বদলায়, আর API যেন কখনো ভুল করে কোনো কলাম প্রকাশ না করে।
Alembic: কোড হিসেবে স্কিমা
Base.metadata.create_all() না-থাকা টেবিল বানায়, কিন্তু আগে থেকে থাকা টেবিল কখনো বদলায় না। প্রথমবার একটা কলাম যোগ করতে গেলেই আপনার লাগবে মাইগ্রেশন (migration): ছোট, ক্রমানুসারে সাজানো, ভার্সন করা স্ক্রিপ্ট, যা ডেটাবেসকে এক স্কিমা থেকে পরেরটায় নিয়ে যায় এবং কোডের পাশেই Git-এ থাকে। Alembic বর্তমান ভার্সন লিখে রাখে alembic_version নামের একটা টেবিলে।
uv run alembic init migrations > /dev/null
ls
ls migrationsalembic.ini
app
migrations
pyproject.toml
tests
uv.lock
README
env.py
script.py.mako
versionsশুধু migrations/env.py বদলান। তাকে আপনার মডেল চিনতে হবে (যাতে অটোজেনারেট সেগুলোকে ডেটাবেসের সাথে তুলনা করতে পারে) এবং আপনার ডেটাবেস URL জানতে হবে। এর বিষয়বস্তু এই ছোট সংস্করণ দিয়ে বদলে দিন:
# file: migrations/env.py
from logging.config import fileConfig
from alembic import context
from sqlalchemy import create_engine
from sqlalchemy.pool import NullPool
from app import models # noqa: F401 (importing registers the tables on Base.metadata)
from app.db import DATABASE_URL, Base
config = context.config
fileConfig(config.config_file_name, disable_existing_loggers=False)
engine = create_engine(DATABASE_URL, poolclass=NullPool)
with engine.connect() as connection:
context.configure(connection=connection, target_metadata=Base.metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()এবার (খালি) ডেটাবেস আর আপনার মডেল তুলনা করে প্রথম মাইগ্রেশন বানান:
export QUIET='Context impl|transactional DDL|autogenerate plugin|Detected sequence'
uv run alembic revision --autogenerate -m "create prompts and versions" --rev-id 0001 2>&1 | grep -vE "$QUIET"
sed -n '/^def upgrade/,/^def downgrade/{/^def downgrade/!p}' migrations/versions/0001_*.pyINFO [alembic.autogenerate.compare.tables] Detected added table 'prompts'
INFO [alembic.autogenerate.compare.tables] Detected added table 'prompt_versions'
INFO [alembic.autogenerate.compare.constraints] Detected added index 'ix_prompt_versions_prompt_id' on '('prompt_id',)'
Generating /home/nadia/projects/prompt-db/migrations/versions/0001_create_prompts_and_versions.py ... done
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('prompts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=60), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_prompts')),
sa.UniqueConstraint('name', name=op.f('uq_prompts_name'))
)
op.create_table('prompt_versions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('prompt_id', sa.Integer(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('template', sa.Text(), nullable=False),
sa.Column('temperature', sa.Double(), server_default='0.2', nullable=False),
sa.CheckConstraint('temperature BETWEEN 0 AND 2', name=op.f('ck_prompt_versions_temperature_range')),
sa.ForeignKeyConstraint(['prompt_id'], ['prompts.id'], name=op.f('fk_prompt_versions_prompt_id_prompts'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_prompt_versions')),
sa.UniqueConstraint('prompt_id', 'version', name=op.f('uq_prompt_versions_prompt_id_version'))
)
op.create_index(op.f('ix_prompt_versions_prompt_id'), 'prompt_versions', ['prompt_id'], unique=False)
# ### end Alembic commands ###Alembic Python-এর লগিং মডিউল দিয়ে stderr-এ লগ করে, তাই কমান্ডে 2>&1 আছে; grep শুধু শুরুর সেই বাড়তি বার্তা লুকায়, যা Alembic-এর ভার্সনভেদে আলাদা হয়। জেনারেট হওয়া ফাইল চালানোর আগে সবসময় পড়ে নিন। অটোজেনারেট একটা প্রোগ্রামের লেখা খসড়া, কোনো প্রতিশ্রুতি নয়। সে যোগ ও বাদ যাওয়া টেবিল-কলাম, বদলানো টাইপ (compare_type=True-এর কারণে), nullability, কনস্ট্রেইন্ট ও ইনডেক্স ধরতে পারে। কিন্তু নাম-বদল করা কলাম বা টেবিল সে ধরতে পারে না (সে দেখে একটা ড্রপ আর একটা অ্যাড, যা ডেটা নষ্ট করবে), আর ডেটা সম্পর্কে সে কিছুই জানে না। এসবের জন্য মাইগ্রেশন হাতে এডিট করুন (op.alter_column(..., new_column_name=...), op.execute("UPDATE ..."))।
uv run alembic upgrade head 2>&1 | grep -vE "$QUIET"
uv run alembic current 2>&1 | grep -vE "$QUIET"INFO [alembic.runtime.migration] Running upgrade -> 0001, create prompts and versions
0001 (head)স্কিমা বদলায়। প্রম্পটে একটা owner কলাম যোগ করুন, সার্ভার ডিফল্টসহ, যাতে আগের সারিগুলো বৈধ থাকে, আর Alembic-কে টের পেতে দিন:
sed -i '/created_at: Mapped/a\ owner: Mapped[str] = mapped_column(String(60), server_default="team")' app/models.py
uv run alembic revision --autogenerate -m "add prompt owner" --rev-id 0002 2>&1 | grep -vE "$QUIET"
grep -h 'op\.' migrations/versions/0002_*.pyINFO [alembic.autogenerate.compare.tables] Detected added column 'prompts.owner'
Generating /home/nadia/projects/prompt-db/migrations/versions/0002_add_prompt_owner.py ... done
op.add_column('prompts', sa.Column('owner', sa.String(length=60), server_default='team', nullable=False))
op.drop_column('prompts', 'owner')মাইগ্রেশন ফেরত নেওয়া যায়। এটা প্রয়োগ করুন, এক ধাপ পিছিয়ে যান, চারপাশ দেখুন, আবার সামনে যান:
uv run alembic upgrade head 2>&1 | grep -vE "$QUIET"
uv run alembic downgrade -1 2>&1 | grep -vE "$QUIET"
uv run alembic current 2>&1 | grep -vE "$QUIET"
uv run alembic upgrade head 2>&1 | grep -vE "$QUIET"
uv run alembic history 2>&1 | grep -vE "$QUIET"INFO [alembic.runtime.migration] Running upgrade 0001 -> 0002, add prompt owner
INFO [alembic.runtime.migration] Running downgrade 0002 -> 0001, add prompt owner
0001
INFO [alembic.runtime.migration] Running upgrade 0001 -> 0002, add prompt owner
0001 -> 0002 (head), add prompt owner
<base> -> 0001, create prompts and versionsপ্রতিদিনের কমান্ডগুলো: revision --autogenerate -m "..." খসড়া লেখে, upgrade head বাকি সবকিছু প্রয়োগ করে (প্রতিটা ডিপ্লয়ে চালান), downgrade -1 শেষ ধাপ ফেরত নেয়, current ও history দেখায় আপনি কোথায়, আর upgrade head --sql না চালিয়ে রিভিউয়ের জন্য SQL ছাপে (এর জন্য env.py-এ একটা অফলাইন শাখা লাগে)।
যে নিয়মগুলো চাকরি বাঁচায়: যে মাইগ্রেশন কোথাও শেয়ার্ড পরিবেশে প্রয়োগ হয়ে গেছে তা কখনো এডিট করবেন না (নতুন একটা যোগ করুন); যে মডেল-বদলের জন্য মাইগ্রেশন দরকার, সেই বদলের সাথেই মাইগ্রেশন কমিট করুন; আর downgrade-কে শুধু ডেভেলপমেন্টের সুবিধা ভাবুন, কারণ প্রোডাকশনে যে রোলব্যাক কলাম ড্রপ করে, সে তার ডেটাও ফেলে দেয়। বড় টেবিলে NOT NULL কলাম যোগ করতে ডিফল্ট বা বহুধাপের মাইগ্রেশন লাগে (আগে nullable যোগ করুন, ডেটা ভরুন, তারপর NOT NULL দিন)।
স্কিমা, রিপোজিটরি ও সার্ভিস
API চুক্তি Pydantic-এই থাকে, যেমন FastAPI আর্কিটেকচার: রাউটার, স্কিমা ও এরর হ্যান্ডলিং-এ ছিল। একটা from_prompt কনস্ট্রাক্টর ORM অবজেক্টকে পাবলিক রূপে বদলে দেয়:
# file: app/schemas.py
from pydantic import BaseModel, ConfigDict, Field
from app.models import Prompt
class PromptIn(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
name: str = Field(min_length=3, max_length=60, pattern=r"^[a-z0-9-]+$")
template: str = Field(min_length=1, max_length=4000)
temperature: float = Field(default=0.2, ge=0, le=2)
class PromptOut(BaseModel):
id: int
name: str
version: int
template: str
temperature: float
@classmethod
def from_prompt(cls, prompt: Prompt) -> "PromptOut":
latest = prompt.versions[-1]
return cls(id=prompt.id, name=prompt.name, version=latest.version,
template=latest.template, temperature=latest.temperature)
class PromptPage(BaseModel):
items: list[PromptOut]
next_cursor: int | Noneরিপোজিটরি (repository) একমাত্র কোড, যা কোয়েরি লেখে। সে কখনো কমিট করে না: সে flush() দিয়ে পরিবর্তন সাজিয়ে রাখে, যা SQL পাঠায় (তাই কনস্ট্রেইন্টের এরর এখনই আসে, আর তৈরি হওয়া id বসে যায়) কিন্তু ট্রানজ্যাকশন খোলা রাখে:
# file: app/repository.py
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models import Prompt, PromptVersion
class PromptRepository:
def __init__(self, session: Session) -> None:
self.session = session
def get(self, prompt_id: int) -> Prompt | None:
return self.session.get(Prompt, prompt_id)
def add(self, name: str, template: str, temperature: float) -> Prompt:
prompt = Prompt(name=name, versions=[PromptVersion(version=1, template=template, temperature=temperature)])
self.session.add(prompt)
self.session.flush()
return prompt
def add_version(self, prompt: Prompt, template: str, temperature: float) -> PromptVersion:
version = PromptVersion(version=prompt.versions[-1].version + 1, template=template, temperature=temperature)
prompt.versions.append(version)
self.session.flush()
return version
def list(self, *, q: str | None = None, after_id: int = 0, limit: int = 20) -> Sequence[Prompt]:
stmt = (select(Prompt).options(selectinload(Prompt.versions))
.where(Prompt.id > after_id).order_by(Prompt.id).limit(limit))
if q:
stmt = stmt.where(Prompt.name.icontains(q, autoescape=True))
return self.session.scalars(stmt).all()
সার্ভিস ট্রানজ্যাকশনের মালিক: কখন commit() করতে হবে এবং ডেটাবেস "না" বললে কী করতে হবে, সেটা সে ঠিক করে। সে আর্কিটেকচার পাতার মতো AppError ছোঁড়ে, এখানে সংক্ষেপে সংজ্ঞায়িত:
# file: app/service.py
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models import Prompt
from app.repository import PromptRepository
from app.schemas import PromptIn
class AppError(Exception):
status_code = 400
code = "bad_request"
class NotFound(AppError):
status_code = 404
code = "not_found"
class Conflict(AppError):
status_code = 409
code = "conflict"
CONFLICTS = {
"uq_prompts_name": "A prompt with this name already exists",
"uq_prompt_versions_prompt_id_version": "Another request saved a new version first; reload and try again",
}
def conflict_message(exc: IntegrityError) -> str:
constraint = getattr(getattr(exc.orig, "diag", None), "constraint_name", None)
return CONFLICTS.get(constraint, "The change conflicts with existing data")
class PromptService:
def __init__(self, session: Session) -> None:
self.session = session
self.repo = PromptRepository(session)
def get(self, prompt_id: int) -> Prompt:
prompt = self.repo.get(prompt_id)
if prompt is None:
raise NotFound(f"Prompt {prompt_id} not found")
return prompt
def create(self, data: PromptIn) -> Prompt:
try:
prompt = self.repo.add(data.name, data.template, data.temperature)
self.session.commit()
except IntegrityError as exc:
self.session.rollback()
raise Conflict(conflict_message(exc)) from exc
return prompt
def new_version(self, prompt_id: int, data: PromptIn) -> Prompt:
prompt = self.get(prompt_id)
if data.name != prompt.name:
raise Conflict("The name of a prompt cannot change; create a new prompt instead")
try:
self.repo.add_version(prompt, data.template, data.temperature)
self.session.commit()
except IntegrityError as exc:
self.session.rollback()
raise Conflict(conflict_message(exc)) from exc
return prompt
def list(self, *, q: str | None, cursor: int, limit: int) -> tuple[list[Prompt], int | None]:
rows = list(self.repo.list(q=q, after_id=cursor, limit=limit + 1))
page = rows[:limit]
return page, (page[-1].id if len(rows) > limit else None)ক্রমটা খেয়াল করুন: লেখার চেষ্টা করুন, ডেটাবেসকে প্রত্যাখ্যান করতে দিন, এররটা অনুবাদ করুন। লোভনীয় বিকল্প, "নামটা আছে কি না দেখো, তারপর ঢোকাও", তাতে রেস আছে: দুটো রিকোয়েস্ট দুটোই দেখতে পারে "এই নাম নেই" আর দুটোই ঢোকাতে পারে। শুধু ইউনিক কনস্ট্রেইন্টই রেস-প্রুফ, তাই কনস্ট্রেইন্টটা রাখুন, আর অ্যাপ্লিকেশন-স্তরের যেকোনো পরীক্ষাকে শুধু সৌজন্য ভাবুন।
সবশেষে রুটগুলো, আগের মতোই পাতলা। সার্ভিস আসে এমন একটা ডিপেন্ডেন্সি থেকে, যা সেশন ডিপেন্ডেন্সির ওপর নির্ভর করে, ফলে পুরো চেইন প্রতি রিকোয়েস্টে তৈরি হয় এবং টেস্টে বদলে দেওয়া যায়:
# file: app/main.py
from typing import Annotated
from fastapi import Depends, FastAPI, Path, Query, Request, status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app.db import get_session
from app.schemas import PromptIn, PromptOut, PromptPage
from app.service import AppError, PromptService
SessionDep = Annotated[Session, Depends(get_session)]
def get_service(session: SessionDep) -> PromptService:
return PromptService(session)
ServiceDep = Annotated[PromptService, Depends(get_service)]
PromptId = Annotated[int, Path(ge=1)]
def create_app() -> FastAPI:
app = FastAPI(title="BitByte Prompt Library (database edition)")
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
body = {"error": {"code": exc.code, "message": str(exc), "details": None}}
return JSONResponse(body, status_code=exc.status_code)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/v1/prompts")
def list_prompts(
service: ServiceDep,
q: Annotated[str | None, Query(max_length=60, description="Name contains this text")] = None,
cursor: Annotated[int, Query(ge=0, description="next_cursor from the previous page")] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
) -> PromptPage:
rows, next_cursor = service.list(q=q, cursor=cursor, limit=limit)
return PromptPage(items=[PromptOut.from_prompt(row) for row in rows], next_cursor=next_cursor)
@app.post("/v1/prompts", status_code=status.HTTP_201_CREATED)
def create_prompt(data: PromptIn, service: ServiceDep) -> PromptOut:
return PromptOut.from_prompt(service.create(data))
@app.get("/v1/prompts/{prompt_id}")
def get_prompt(prompt_id: PromptId, service: ServiceDep) -> PromptOut:
return PromptOut.from_prompt(service.get(prompt_id))
@app.put("/v1/prompts/{prompt_id}", summary="Save a new version of a prompt")
def update_prompt(prompt_id: PromptId, data: PromptIn, service: ServiceDep) -> PromptOut:
return PromptOut.from_prompt(service.new_version(prompt_id, data))
return app
app = create_app()রুট ফাংশনগুলো সাধারণ def: SQLAlchemy-র সিঙ্ক্রোনাস API PostgreSQL-এর জবাবের অপেক্ষায় ব্লক করে, তাই FastAPI সেগুলোকে থ্রেড পুলে চালায়, যা এখানে ঠিক কাজ (সিঙ্ক বনাম অ্যাসিঙ্ক অংশে আরও আছে)।
ট্রানজ্যাকশন ও রোলব্যাক
ট্রানজ্যাকশন (transaction) কয়েকটা স্টেটমেন্টকে একসাথে সফল বা ব্যর্থ করে। সেশন ডেটাবেসের সাথে প্রথম কথা বললেই নিজে থেকে একটা শুরু করে; commit() পরিবর্তন স্থায়ী করে আর rollback() ফেলে দেয়। দুটো আলাদা সেশন দিয়ে দেখুন, কারণ অন্য কানেকশন শুধু কমিট হওয়া ডেটা দেখতে পায়:
from sqlalchemy import func, select
from app.db import SessionLocal
from app.models import Prompt, PromptVersion
def count(session):
return session.scalar(select(func.count()).select_from(Prompt))
with SessionLocal() as session, SessionLocal() as observer:
session.add(Prompt(name="draft", versions=[PromptVersion(version=1, template="Draft: {input}")]))
session.flush() # INSERT is sent, the transaction is still open
print("writer sees:", count(session), "| observer sees:", count(observer))
session.rollback()
print("after rollback:", count(session))writer sees: 1 | observer sees: 0
after rollback: 0এই "সব নয়তো কিছুই না" গুণের জন্যই একটা প্রম্পট আর তার প্রথম ভার্সন একসাথে সেভ হয়। এবার ইচ্ছা করে দ্বিতীয় অর্ধেকটা ব্যর্থ করুন, ডেটাবেসের CHECK ভাঙা একটা temperature দিয়ে, আর দেখুন প্রথম অর্ধেকও উধাও:
from sqlalchemy.exc import IntegrityError
with SessionLocal() as session:
try:
session.add(Prompt(name="atomic", versions=[
PromptVersion(version=1, template="fine {input}", temperature=0.2),
PromptVersion(version=2, template="broken {input}", temperature=9),
]))
session.commit()
except IntegrityError as exc:
session.rollback()
print(type(exc.orig).__name__, "-", exc.orig.diag.constraint_name)
print("prompts saved:", count(session))CheckViolation - ck_prompt_versions_temperature_range
prompts saved: 0- এরর হলে সেশন ব্যর্থ অবস্থায় থাকে: আবার ব্যবহারের আগে
rollback()করুন।withব্লক বেরোনোর সময় সেটা করে, আর সার্ভিস আবার ছোঁড়ার আগে স্পষ্টভাবে করে। - ট্রানজ্যাকশন ছোট রাখুন। প্রথম লেখা আর কমিটের মাঝখানে কখনো LLM বা বাইরের API ডাকবেন না: আপনি কয়েক সেকেন্ড ধরে রো লক ও একটা পুলের কানেকশন আটকে রাখবেন।
with session.begin():সফল হলে কমিট করে, এক্সেপশন হলে রোলব্যাক করে;session.begin_nested()একটা সেভপয়েন্ট বানায়, যাতে একটা অংশ একা ব্যর্থ হতে পারে।- ব্যর্থ ইনসার্টও সিকোয়েন্স নম্বর খরচ করে। id ইউনিক, কিন্তু ফাঁকহীন নয়; একে কখনো মোট সংখ্যা ভাববেন না, আর ক্লায়েন্টকে ধারাবাহিক হওয়ার প্রতিশ্রুতি দেবেন না।
কনস্ট্রেইন্ট ও 409
Pydantic যাচাই করে API যা পায়; ডেটাবেস কনস্ট্রেইন্ট ডেটাকে বাঁচায় প্রতিটা লেখকের হাত থেকে এবং রেস থেকে। দুটোই ব্যবহার করুন। এখানে ডুপ্লিকেট নাম UNIQUE-এ ধাক্কা খায়, আর পরের ভার্সন সেভ করতে ছুটে আসা দুই লেখক (prompt_id, version) কনস্ট্রেইন্টে ধাক্কা খায়:
with SessionLocal() as session:
first = Prompt(name="support-answer", versions=[PromptVersion(version=1, template="Answer: {input}")])
second = Prompt(name="summarise", versions=[PromptVersion(version=1, template="Summarise: {input}")])
session.add_all([first, second])
session.commit()
prompt_id = first.id
with SessionLocal() as session:
session.add(Prompt(name="support-answer"))
try:
session.commit()
except IntegrityError as exc:
session.rollback()
print("duplicate name:", exc.orig.sqlstate, exc.orig.diag.constraint_name)
with SessionLocal() as a, SessionLocal() as b:
next_a = len(a.get(Prompt, prompt_id).versions) + 1 # both writers read "version 1 is the latest"
next_b = len(b.get(Prompt, prompt_id).versions) + 1
a.add(PromptVersion(prompt_id=prompt_id, version=next_a, template="A saves first {input}"))
a.commit()
b.add(PromptVersion(prompt_id=prompt_id, version=next_b, template="B arrives late {input}"))
try:
b.commit()
except IntegrityError as exc:
b.rollback()
print("second writer:", exc.orig.sqlstate, exc.orig.diag.constraint_name)duplicate name: 23505 uq_prompts_name
second writer: 23505 uq_prompt_versions_prompt_id_versionSQLSTATE 23505 মানে "unique violation", 23503 "foreign key violation", 23502 "not null violation" আর 23514 "check violation": মেসেজের টেক্সটের মতো নয়, এগুলো স্থির কোড, যার ওপর ভিত্তি করে কোডে শাখা (branch) করা যায়। সার্ভিস কনস্ট্রেইন্টের নামকে বন্ধুসুলভ বার্তায় বদলায় এবং Conflict ছোঁড়ে, যাকে হ্যান্ডলার 409 বানায়। 409 Conflict মানে "আপনার রিকোয়েস্ট বৈধ, কিন্তু বর্তমান অবস্থার সাথে সংঘর্ষ হচ্ছে"। ভুল গঠনের ইনপুট থাকে 422 (Pydantic থেকে), না-থাকা সারি 404; যে IntegrityError আপনি আশা করেননি, তা লগ হওয়া 500 হওয়া উচিত, ডেটাবেসের বার্তা ফাঁস করে নয়।
রিলেশনশিপ ও N+1 সমস্যা
prompt.versions দেখতে সাধারণ লিস্টের মতো, কিন্তু প্রথম অ্যাক্সেসেই একটা কোয়েরি চলে (লেজি লোডিং, lazy loading)। ৫০টা প্রম্পটের ওপর লুপ চালিয়ে প্রতিটার .versions ছুঁলে আপনি প্রম্পটের জন্য ১টা কোয়েরি আর আরও ৫০টা চালান: এটাই N+1 সমস্যা, ORM-এর সবচেয়ে সাধারণ পারফরম্যান্স বাগ। ইভেন্ট লিসেনার দিয়ে স্টেটমেন্ট গুনুন:
from sqlalchemy import event
from sqlalchemy.orm import selectinload
from app.db import engine
statements = []
@event.listens_for(engine, "before_cursor_execute")
def record(conn, cursor, statement, parameters, context, executemany):
statements.append(statement)
def load(*options):
statements.clear()
with SessionLocal() as session:
prompts = session.scalars(select(Prompt).options(*options)).all()
versions = sum(len(prompt.versions) for prompt in prompts)
return f"{len(prompts)} prompts, {versions} versions, {len(statements)} statements"
print("lazy: ", load())
print("selectinload:", load(selectinload(Prompt.versions)))lazy: 2 prompts, 3 versions, 3 statements
selectinload: 2 prompts, 3 versions, 2 statementsselectinload লোড হওয়া সব প্রম্পটের ভার্সন একটা বাড়তি কোয়েরিতে আনে (WHERE prompt_id IN (...)), তাই প্রম্পট যতগুলোই হোক কোয়েরির সংখ্যা ২-ই থাকে। রিপোজিটরির list ইতিমধ্যে এটা ব্যবহার করে। many-to-one সংযোগে (একটা ভার্সনের প্রম্পট) joinedload একই কাজ করে JOIN দিয়ে। লোডিং কৌশল ঠিক করুন রিপোজিটরির কোয়েরিতে, যেখানে আপনি জানেন কলার কী চায়; সিরিয়ালাইজারে দৈবাৎ নয়।
চালিয়ে দেখুন
uv run fastapi run --port 8000 > server.log 2>&1 &
export SERVER_PID=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health{"status":"ok"}support-answer আর summarise ওপরের স্ক্রিপ্টগুলো বানিয়েছে, তাই সার্ভার সেগুলো আগে থেকেই দেখতে পায়। আরেকটা বানান, তারপর এররগুলো ঘটান (-w অপশন প্রতিটা বডির পরে স্ট্যাটাস কোড ছাপে):
curl -s -w ' %{http_code}\n' -X POST http://localhost:8000/v1/prompts -H 'Content-Type: application/json' \
-d '{"name": "classify-ticket", "template": "Classify this ticket: {input}", "temperature": 0}'
curl -s -w ' %{http_code}\n' -X POST http://localhost:8000/v1/prompts -H 'Content-Type: application/json' \
-d '{"name": "summarise", "template": "Another summariser"}'
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8000/v1/prompts -H 'Content-Type: application/json' \
-d '{"name": "bad-temp", "template": "x", "temperature": 5}'
curl -s -w ' %{http_code}\n' -X PUT http://localhost:8000/v1/prompts/3 -H 'Content-Type: application/json' \
-d '{"name": "support-answer", "template": "Answer politely: {input}"}'
curl -s -w ' %{http_code}\n' http://localhost:8000/v1/prompts/999{"id":6,"name":"classify-ticket","version":1,"template":"Classify this ticket: {input}","temperature":0.0} 201
{"error":{"code":"conflict","message":"A prompt with this name already exists","details":null}} 409
422
{"id":3,"name":"support-answer","version":3,"template":"Answer politely: {input}","temperature":0.2} 200
{"error":{"code":"not_found","message":"Prompt 999 not found","details":null}} 404PUT-এ ভার্সন ৩ হয়েছে, কারণ ওপরের রেস ডেমো আগেই ভার্সন ২ সেভ করেছিল। classify-ticket-এর id দেখুন: আগের ব্যর্থ ও রোলব্যাক হওয়া ইনসার্টগুলো id খরচ করে ফেলেছে, তাই support-answer ৩ আর নতুন প্রম্পট ৬। আরও খেয়াল করুন, সীমার বাইরের temperature ডেটাবেসে পৌঁছানোর আগেই Pydantic (422) আটকে দিয়েছে। বাকি সব লেখকের জন্য CHECK কনস্ট্রেইন্টই দ্বিতীয় প্রতিরক্ষা।
পেজিনেশন ও ফিল্টারিং
LIMIT 20 OFFSET 100000 PostgreSQL-কে ১,০০,০০০ সারি পড়ে ফেলে দিতে বাধ্য করে, আর সারি ঢুকলেই জায়গা সরে যায়। রুটটা বরং কিসেট (কার্সর) পেজিনেশন ব্যবহার করে, যা মজবুত API ডিজাইন: পেজিনেশন, রেট লিমিট, রিট্রাই ও ভার্সনিং পাতার ভালো নকশা: WHERE id > :cursor ORDER BY id LIMIT :n প্রাইমারি কী ইনডেক্স ব্যবহার করে সোজা সঠিক জায়গায় লাফ দেয়। limit + 1 সারি আনলে আলাদা COUNT কোয়েরি ছাড়াই বোঝা যায় আরেকটা পাতা আছে কি না। ফিল্টার হলো একই স্টেটমেন্টে যোগ করা ঐচ্ছিক WHERE ক্লজ:
curl -s 'http://localhost:8000/v1/prompts?limit=2' | python -m json.tool | grep -E '"(name|version|next_cursor)"'
echo ---
curl -s 'http://localhost:8000/v1/prompts?limit=2&cursor=4' | python -m json.tool | grep -E '"(name|version|next_cursor)"'
echo ---
curl -s 'http://localhost:8000/v1/prompts?q=SUM' | python -c "import sys, json; print([p['name'] for p in json.load(sys.stdin)['items']])" "name": "support-answer",
"version": 3,
"name": "summarise",
"version": 1,
"next_cursor": 4
---
"name": "classify-ticket",
"version": 1,
"next_cursor": null
---
['summarise']নামের ফিল্টারটা কেস-ইনসেনসিটিভ (icontains হয়ে যায় ILIKE) এবং autoescape=True ব্যবহার করে, তাই কেউ % বা _ লিখলে ওয়াইল্ডকার্ড না হয়ে ওই অক্ষরগুলোই খোঁজা হয়। id ছাড়া অন্য কিছু দিয়ে (সবচেয়ে নতুন আগে, একটা স্কোর) পেজ করতে চাইলে সেই কলামের সাথে id-কে টাই-ব্রেকার ধরে সাজান এবং দুটোকেই এমন একটা কার্সরে, যার ভেতরটা ক্লায়েন্টের কাছে অর্থহীন (opaque), এনকোড করুন।
kill "$SERVER_PID"কানেকশন পুলিং
PostgreSQL কানেকশন খুলতে কয়েক মিলিসেকেন্ড আর একটা সার্ভার প্রসেস লাগে; প্রতি রিকোয়েস্টে এটা করলে ধীর হতো এবং সার্ভার ফুরিয়ে যেত। ইঞ্জিন খোলা কানেকশনের একটা পুল (pool) রাখে এবং প্রতিটা সেশনকে একটা ধার দেয়। তিনটা সেটিং গুরুত্বপূর্ণ: pool_size (খোলা রাখা কানেকশন), max_overflow (চাপের সময় অনুমোদিত বাড়তি কানেকশন) আর pool_timeout (ফাঁকা কানেকশনের জন্য রিকোয়েস্ট কতক্ষণ অপেক্ষা করবে)। ছোট একটা পুল দিয়ে সীমাটা চোখে দেখুন:
from sqlalchemy import create_engine, text
from sqlalchemy.exc import TimeoutError as PoolTimeout
from app.db import DATABASE_URL
small = create_engine(DATABASE_URL, pool_size=2, max_overflow=0, pool_timeout=1)
first, second = small.connect(), small.connect()
print(small.pool.status())
try:
small.connect()
except PoolTimeout as exc:
print(str(exc).split(" (")[0])
first.close()
with small.connect() as connection: # a connection was returned, so this works
print(connection.execute(text("SELECT 'served'")).scalar())
second.close()
small.dispose()Pool size: 2 Connections in pool: 0 Current Overflow: 0 Current Checked out connections: 2
QueuePool limit of size 2 overflow 0 reached, connection timed out, timeout 1.00
served- PostgreSQL সীমিত সংখ্যক কানেকশন অনুমোদন করে (ডিফল্টে ১০০)। প্রতিটা API প্রসেসের নিজস্ব পুল আছে, তাই সব ইনস্ট্যান্স মিলিয়ে সবচেয়ে খারাপ অবস্থা
workers x (pool_size + max_overflow)। এটাকে সার্ভারের সীমার নিচে রাখুন, নয়তো সামনে PgBouncer বসান। pool_pre_ping=Trueকানেকশন ধার দেওয়ার আগে পরীক্ষা করে, ফলে ডেটাবেস রিস্টার্ট হলে প্রতিটা পুলের কানেকশনে একটা করে এরর হয় না।pool_recycle=1800সেই কানেকশন বদলে দেয়, যা ফায়ারওয়াল চুপচাপ বন্ধ করে দেয়।
সিঙ্ক নাকি অ্যাসিঙ্ক?
ওপরের সবকিছু সিঙ্ক্রোনাস, এবং সেটাই যুক্তিসংগত ডিফল্ট: সহজ, ভালো ডকুমেন্টেড এবং বেশিরভাগ সার্ভিসের জন্য যথেষ্ট দ্রুত। রুট যখন অন্য কিছুরও অপেক্ষা করে (LLM কল, HTTP ক্লায়েন্ট, Redis) এবং একটা প্রসেসকে হাজার হাজার অপেক্ষমাণ রিকোয়েস্ট ধরে রাখতে হয়, তখন অ্যাসিঙ্ক SQLAlchemy বেছে নিন। psycopg 3 দিয়ে একই URL দুটোতেই চলে; create_async_engine অ্যাসিঙ্ক রূপটা বেছে নেয়:
import asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
async def main() -> None:
async_engine = create_async_engine(DATABASE_URL)
async_session = async_sessionmaker(async_engine, expire_on_commit=False)
async with async_session() as session:
stmt = select(Prompt).options(selectinload(Prompt.versions)).order_by(Prompt.id)
prompts = (await session.scalars(stmt)).all()
print([(prompt.name, len(prompt.versions)) for prompt in prompts])
await async_engine.dispose()
asyncio.run(main())[('support-answer', 3), ('summarise', 1), ('classify-ticket', 1)]এটা প্রতিটা প্রম্পটের নাম ও তার ভার্সন সংখ্যা ছাপে। অ্যাসিঙ্ক SQLAlchemy-র জন্য greenlet প্যাকেজ লাগে, তাই চেষ্টা করতে "sqlalchemy[asyncio]" ইনস্টল করুন। নিয়ম বদলায় তিনভাবে। প্রতিটা ডেটাবেস কলে await লাগে। লেজি লোডিং অসম্ভব (অ্যাট্রিবিউট অ্যাক্সেসে await করা যায় না), তাই রিলেশনশিপ আগেই লোড করুন selectinload দিয়ে, যেমন ওপরে, নইলে SQLAlchemy MissingGreenlet ছোঁড়ে। আর async def রুটের ভেতরে কখনো সিঙ্ক API চালাবেন না, কারণ তাতে ইভেন্ট লুপ সবার জন্য আটকে যায়। একই সার্ভিসে দুই ধরন মেশাবেন না: সিঙ্ক ইঞ্জিনের সাথে def রুট, অথবা অ্যাসিঙ্ক ইঞ্জিনের সাথে async def রুট।
আলাদা টেস্ট ডেটাবেস
যে টেস্ট সারি মোছে আর নতুন করে বানায়, সে কখনো আপনার ডেভেলপমেন্ট ডেটায় হাত দেবে না, প্রোডাকশনে তো নয়ই। দ্বিতীয় একটা ডেটাবেস ব্যবহার করুন (p10_prompts_test আপনি ওপরে বানিয়েছেন), pytest চালু করার সময় DATABASE_URL এনভায়রনমেন্ট ভেরিয়েবল দিয়ে অ্যাপকে সেদিকে নির্দেশ করুন, আসল মাইগ্রেশন দিয়ে তার স্কিমা বানান (তাতে মাইগ্রেশনও টেস্ট হয়), এবং প্রতিটা টেস্টের পর টেবিল খালি করুন। একটা পাহারাদার যোগ করুন, যা _test নামহীন কিছুর বিরুদ্ধে চলতে অস্বীকার করে:
# file: tests/conftest.py
import os
import pytest
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient
from sqlalchemy import text
from app.db import DATABASE_URL, engine
from app.main import create_app
assert DATABASE_URL.endswith("_test"), "refusing to run tests against a non-test database"
@pytest.fixture(scope="session", autouse=True)
def schema():
command.upgrade(Config("alembic.ini"), "head") # the real migrations build the test schema
yield
engine.dispose()
@pytest.fixture(autouse=True)
def empty_tables():
yield
with engine.begin() as connection:
connection.execute(text("TRUNCATE prompts RESTART IDENTITY CASCADE"))
@pytest.fixture
def client():
with TestClient(create_app()) as test_client:
yield test_client# file: tests/test_prompts.py
BODY = {"name": "support-answer", "template": "Answer: {input}"}
def test_duplicate_name_is_a_409(client):
assert client.post("/v1/prompts", json=BODY).status_code == 201
duplicate = client.post("/v1/prompts", json=BODY)
assert duplicate.status_code == 409
assert duplicate.json()["error"]["code"] == "conflict"
def test_cursor_pagination_and_clean_state(client):
for number in range(3):
client.post("/v1/prompts", json={**BODY, "name": f"prompt-{number}"})
page = client.get("/v1/prompts?limit=2").json()
assert [item["id"] for item in page["items"]] == [1, 2] # ids restart: the tables were emptied
assert page["next_cursor"] == 2
assert client.get("/v1/prompts?limit=2&cursor=2").json()["next_cursor"] is NoneDATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/p10_prompts_test uv run python -m pytest -q 2>&1 | tail -3.. [100%]
2 passed in 0.21sপ্রতি টেস্টের পর টেবিল ট্রাঙ্কেট করা সহজ, কিন্তু বড় স্যুটের জন্য ধীর। পেশাদার API টেস্ট করা পাতা দ্রুততর পদ্ধতি দেখায়: প্রতিটা টেস্ট একটা ট্রানজ্যাকশনের (বা সেভপয়েন্টের) ভেতরে চালিয়ে রোলব্যাক করুন, আর get_session-কে app.dependency_overrides দিয়ে বদলে দিন।
SQL ইনজেকশন: সংক্ষিপ্ত পুনরালোচনা
ORM ও Core কোয়েরি বানায় বাইন্ড করা প্যারামিটার দিয়ে, তাই ব্যবহারকারীের ইনপুট সবসময় ডেটা, কখনো SQL নয়। বিপদ ফিরে আসে যখন আপনি নিজে SQL টেক্সট বানান। ডেভেলপমেন্ট ডেটাবেসে এখন থাকা সারিগুলোর বিরুদ্ধে একটা ক্ষতিকর (hostile) সার্চ টার্ম তুলনা করুন:
from sqlalchemy import text
evil = "x' OR '1'='1"
with engine.connect() as connection:
unsafe = connection.execute(text(f"SELECT name FROM prompts WHERE name = '{evil}'")).all()
safe = connection.execute(text("SELECT name FROM prompts WHERE name = :name"), {"name": evil}).all()
orm = connection.execute(select(Prompt.name).where(Prompt.name == evil)).all()
print("f-string:", len(unsafe), "rows | bound parameter:", len(safe), "rows | ORM:", len(orm), "rows")f-string: 3 rows | bound parameter: 0 rows | ORM: 0 rowsf-string সংস্করণ প্রতিটা প্রম্পট ফেরত দিয়েছে: কোট চিহ্নটা স্ট্রিং বন্ধ করে দিয়েছে আর OR '1'='1' কোয়েরির অংশ হয়ে গেছে। নিয়ম: ব্যবহারকারীের ইনপুট কখনো text(), execute() বা কাঁচা SQL-এ ফরম্যাট করে বসাবেন না (:name প্যারামিটার ব্যবহার করুন); টেবিল বা কলামের নামের মতো আইডেন্টিফায়ার বাইন্ড করা যায় না, তাই সেগুলো অ্যালাউ-লিস্ট থেকে বাছুন; আর অ্যাপ্লিকেশনের ডেটাবেস ব্যবহারকারীকে শুধু যতটুকু অধিকার দরকার ততটুকুই দিন। AI-এর জন্য SQL ও ডেটা হ্যান্ডলিং: অ্যাডভান্সড এটা বিস্তারিত দেখিয়েছে, আর API নিরাপত্তা ও OWASP API Top 10 আবার এতে ফিরবে।
ডিপ্লয়মেন্টে PostgreSQL
SQLite একক-প্রসেসের স্ক্রিপ্টের জন্য চমৎকার। একাধিক ওয়ার্কার বা কন্টেইনারে ডিপ্লয় করা API-র দরকার সত্যিকারের সার্ভার, আর এতক্ষণ এখানে আপনি যেটা ব্যবহার করেছেন, ডিপ্লয়ের জন্যও সেটাই ঠিক:
| বিষয় | কী করবেন |
|---|---|
| কানেকশন স্ট্রিং | হোস্টের সিক্রেট স্টোর থেকে একটা DATABASE_URL এনভায়রনমেন্ট ভেরিয়েবল। হোস্ট প্রায়ই postgres://... বা postgresql://... দেয়; SQLAlchemy-র দরকার postgresql+psycopg://..., তাই সেটিংস কোডে প্রিফিক্সটা বদলে নিন। |
| মাইগ্রেশন | প্রতি ডিপ্লয়ে একবার alembic upgrade head চালান, রিলিজ ধাপ হিসেবে বা নতুন ভার্সন শুরুর আগের একবার চালানোর মতো (one-off) কন্টেইনারে; কখনো স্টার্টআপে প্রতিটা ওয়ার্কার থেকে নয় (তারা রেস করবে)। |
| পুলের আকার | প্রতিটা প্রসেসের পুল যোগ করুন এবং মোট max_connections-এর নিচে রাখুন; ছোট ম্যানেজড প্ল্যানে মাত্র কয়েক ডজন অনুমোদিত। |
| সামঞ্জস্য | এমন মাইগ্রেশন পাঠান, যার বিরুদ্ধে পুরোনো কোডও চলতে পারে (ব্যবহারের আগে কলাম যোগ করুন, এক রিলিজ পরে বাদ দিন), যাতে রোলিং ডিপ্লয় কখনো না ভাঙে। |
| ফ্রি টিয়ার | বেশ কয়েকটা হোস্টে ফ্রি ম্যানেজড Postgres সময়সীমিত (Render-এরটা ৩০ দিন পরে শেষ হয়)। বর্তমান সীমা দেখে নিন। |
Docker Compose পাতা এই API-কে একটা PostgreSQL কন্টেইনারের পাশে চালায়, হেলথ-চেক করা স্টার্ট ক্রমসহ, আর ডিপ্লয়মেন্ট পাতা ডিপ্লয়ের সময় মাইগ্রেশন প্রয়োগ করে।
ব্যাকআপ: সংক্ষিপ্ত পুনরালোচনা
রিস্টোর পরীক্ষা না করা ডেটাবেস শুধু আশা, ব্যাকআপ নয়। AI-এর জন্য SQL ও ডেটা হ্যান্ডলিং: অ্যাডভান্সড-এর টুলগুলো হুবহু প্রযোজ্য: ছোট ডেটাবেস ও সার্ভার বদলের জন্য pg_dump দিয়ে লজিক্যাল ডাম্প, আর প্রোডাকশনের জন্য হোস্টের স্বয়ংক্রিয় স্ন্যাপশট ও পয়েন্ট-ইন-টাইম রিকভারি।
pg_dump --format=custom --file=prompts.dump "$DATABASE_URL" # back up (use a plain postgresql:// URL here)
createdb prompts_restore_check
pg_restore --dbname=prompts_restore_check prompts.dump # restore into a scratch database, then look insideএটা শিডিউল করুন, কপি রাখুন ডেটাবেস সার্ভার থেকে দূরে, আর রিস্টোর অনুশীলন করুন।
AI-এর কাজে এটা কোথায় আসে
- কনভারসেশন ও ব্যবহারের স্টোরেজ: ব্যবহারকারীপ্রতি মেসেজ, টোকেন সংখ্যা ও খরচ এ ধরনের টেবিলের সারি, ব্যবহারকারীের দিকে ফরেন কী আর যে কলামে ফিল্টার করেন তার ইনডেক্সসহ।
- ইভ্যালুয়েশনের ফল:
(prompt_version_id, dataset_id)-তে একটা ইউনিক কনস্ট্রেইন্ট মানে ইভ্যালুয়েশন আবার চালালে দ্বিগুণ গণনা হতে পারে না। - জব ও ইনজেশনের অবস্থা: "ডকুমেন্ট ৪২ এমবেড হয়েছে" PostgreSQL-এ থাকে, আর
pgvectorএক্সটেনশন দিয়ে ভেক্টরগুলোও থাকতে পারে।
সাধারণ ভুল
- সব রিকোয়েস্টে ভাগ করা একটাই গ্লোবাল সেশন। সেশন থ্রেড-সেফ নয় এবং ট্রানজ্যাকশন চিরকাল খোলা রাখে। ডিপেন্ডেন্সির মাধ্যমে প্রতি রিকোয়েস্টে একটা বানান।
- কনস্ট্রেইন্টের বদলে "দেখো তারপর ঢোকাও"। একসাথে আসা দুটো রিকোয়েস্টই পরীক্ষায় পাস করে।
UNIQUEকনস্ট্রেইন্ট যোগ করুন এবংIntegrityErrorসামলান। - হাতে ডেটাবেস বদলানো, বা প্রোডাকশনে
create_all()ডাকা। পরিবেশগুলো আলাদা হয়ে যায়। মডেল বদলান, মাইগ্রেশন বানান, রিভিউ করুন, প্রয়োগ করুন। - ট্রানজ্যাকশনের ভেতরে LLM কল। লক ও কানেকশন কয়েক সেকেন্ড আটকে থাকে আর পুল শুকিয়ে যায়। আগে মডেল ডাকুন, তারপর ফল লিখুন একটা ছোট ট্রানজ্যাকশনে।
- N+1 কোয়েরি আর সীমাহীন লিস্ট। রিলেশনশিপ লোড করুন
selectinloadদিয়ে, সবসময় সর্বোচ্চlimitসহ পেজিনেট করুন, আর যে কলামে ফিল্টার ও জয়েন করেন তা ইনডেক্স করুন।
নিজে চেষ্টা করুন
- সহজ: একটা SQLAlchemy কোয়েরি দিয়ে প্রতিটা প্রম্পটের নাম আর তার ভার্সন সংখ্যা ছাপুন (
JOINওGROUP BYব্যবহার করুন)। - মাঝারি: একটা সার্ভিস মেথড
restore(prompt_id, version)যোগ করুন, যা পুরোনো ভার্সনের টেমপ্লেটকে নতুন সর্বশেষ ভার্সন হিসেবে সেভ করে। সেই ভার্সন না থাকলেNotFoundছুঁড়ুন। ছোট একটা Python স্ক্রিপ্টে পরীক্ষা করুন। - কঠিন: এমন একটা টেস্ট লিখুন, যা
?q=%25(আক্ষরিক%) পাঠায় এবং প্রমাণ করে ফিল্টার একে ওয়াইল্ডকার্ড ধরে না, তারপর টেস্ট ডেটাবেসে চালান।
উত্তর
# 1. Easy
with SessionLocal() as session:
stmt = (select(Prompt.name, func.count(PromptVersion.id))
.join(PromptVersion).group_by(Prompt.name).order_by(Prompt.name))
for name, versions in session.execute(stmt):
print(name, versions)
# 2. Medium
from app.schemas import PromptIn
from app.service import NotFound, PromptService
def restore(service: PromptService, prompt_id: int, version: int):
prompt = service.get(prompt_id)
old = next((v for v in prompt.versions if v.version == version), None)
if old is None:
raise NotFound(f"Prompt {prompt_id} has no version {version}")
return service.new_version(prompt_id, PromptIn(name=prompt.name, template=old.template,
temperature=old.temperature))
with SessionLocal() as session:
service = PromptService(session)
restored = restore(service, prompt_id, 1)
print([(v.version, v.template) for v in restored.versions])
try:
restore(service, prompt_id, 9)
except NotFound as exc:
print("NotFound:", exc)classify-ticket 1
summarise 1
support-answer 3
[(1, 'Answer: {input}'), (2, 'A saves first {input}'), (3, 'Answer politely: {input}'), (4, 'Answer: {input}')]
NotFound: Prompt 3 has no version 9# file: tests/test_filter.py
def test_percent_is_not_a_wildcard(client):
client.post("/v1/prompts", json={"name": "support-answer", "template": "x {input}"})
assert client.get("/v1/prompts?q=%25").json()["items"] == []
assert len(client.get("/v1/prompts?q=SUPPORT").json()["items"]) == 1DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/p10_prompts_test uv run python -m pytest -q 2>&1 | tail -3... [100%]
3 passed in 0.40sকাজ শেষ হলে অনুশীলনের ডেটাবেসগুলো মুছুন DROP DATABASE p10_prompts WITH (FORCE) দিয়ে (p10_prompts_test-এর জন্যও একই)।
সারসংক্ষেপ
- প্রসেসপ্রতি একটা ইঞ্জিন আর রিকোয়েস্টপ্রতি একটা সেশন বানান
yieldডিপেন্ডেন্সি দিয়ে; কোয়েরি রাখুন রিপোজিটরিতে আর কমিট/রোলব্যাকের সিদ্ধান্ত সার্ভিসে। - রিলেশনশিপ মডেল করুন ফরেন কী দিয়ে এবং
UNIQUE,CHECK,NOT NULLডেটাবেসকেই বলবৎ করতে দিন; "দেখো তারপর ঢোকাও"-র বদলেIntegrityError-কে409-এ অনুবাদ করুন। - স্কিমা বদল সামলান Alembic দিয়ে: অটোজেনারেট করুন, পড়ুন, প্রতিটা ডিপ্লয়ে
upgrade headদিয়ে প্রয়োগ করুন, আর মনে রাখুন নাম-বদল সে দেখে না। selectinloadদিয়ে N+1 এড়ান, কিসেট কার্সরে পেজ করুন, ফিল্টার এস্কেপ করুন, পুলের আকার সার্ভারের কানেকশন সীমার সাথে মিলিয়ে ঠিক করুন, আর প্রতি সার্ভিসে এক ধরন (সিঙ্ক বা অ্যাসিঙ্ক) রাখুন।- আসল মাইগ্রেশনে বানানো আলাদা ডেটাবেসে টেস্ট করুন, সবসময় বাইন্ড করা প্যারামিটার ব্যবহার করুন, আর ব্যাকআপ রিস্টোরের অনুশীলন করুন।
এরপর: অথেন্টিকেশন ও অথরাইজেশন এই ডেটাবেসে ব্যবহারকারী যোগ করে: পাসওয়ার্ড হ্যাশিং, JWT অ্যাক্সেস ও রিফ্রেশ টোকেন, সুরক্ষিত রুট ও পারমিশন।