فهرست مقاله
پس از خطای نوشتن، بازکردنِ تراکنش تازه فقط یک مرزِ اجرایی تازه میسازد؛ محاسبهٔ قدیمیِ برنامه را خودکار تازه نمیکند. در این درس، شمارندهٔ انرژیِ کاملاً ساختگی داریم و دو اتصال، هرکدام افزایشی معلوم را ثبت میکنند. هدف این است که میان «دوباره فرستادنِ مقدارِ قبلی» و «دوباره ارزیابیکردنِ عملیاتِ موردنظر» فرق بگذاریم. هیچ حسگر، سرویس بیرونی یا بارِ همزمانِ واقعی در آزمایش وجود ندارد.
۱. تکرارِ دستور با تکرارِ تصمیم یکسان نیست

مقدار آغازین صد میکروژول است. اتصال A آن را میخواند و برای افزایشِ سهمیکروژولی، نتیجهٔ صدوسه را در متغیری نگه میدارد. سپس اتصال B هفت میکروژول میافزاید و صدوهفت را ثبت میکند. انتظارِ مستقلِ ما، در صورت پذیرش هر دو رویداد، صدوده است: صد بهاضافهٔ هفت بهاضافهٔ سه. این محاسبه به خروجیِ SQL وابسته نیست.
در حالت WAL، اگر A هنوز تراکنشِ خواندنِ قدیمی را نگه داشته باشد، ارتقای آن به نوشتن میتواند با خطای SQLITE_BUSY_SNAPSHOT روبهرو شود. شاهدِ ما کدِ توسعهیافتهٔ ۵۱۷ و نامِ همان خطا را ثبت میکند. درسِ مرزِ نمای خواندن در SQLite این رفتار را مفصل بررسی کرده است؛ مسئلهٔ تازهٔ اینجا، تصمیمی است که بعد از پایان آن نما باقی میماند.
A دستور ROLLBACK میدهد، تراکنشِ تازه باز میکند و همان مقدارِ ذخیرهشدهٔ صدوسه را مینویسد. این دستور میتواند موفق شود، اما هفت واحدِ ثبتشدهٔ B را از نتیجه حذف میکند. این بخش، ضدنمونهٔ عمدی در پایگاه دادهٔ دورریختنیِ ماست. پایگاه داده یک انتسابِ معتبر گرفته؛ قصدِ «افزودن سه به مقدار جاری» با مقدارِ مطلقِ قدیمی جایگزین شده است.
بنابراین پایانِ تراکنش، پایانِ عمرِ تمام فرضهای برنامه نیست. متغیر، تصمیمِ مجازبودن و نتیجهٔ محاسبهشده در حافظه باقی میمانند. بازیابی باید مشخص کند کدامیک هنوز معتبرند. تغییرِ نامِ دستور یا انتقال آن به تراکنشِ تازه، بهتنهایی شاهدِ حفظِ منطقِ مسئله نیست.
۲. قراردادِ شمارنده، رویداد و نسخهٔ ردیف

جدول فقط یک ردیف با شناسهٔ یک دارد. energy_uj انرژیِ ساختگی برحسب میکروژول و rev شمارهٔ بازنگریِ بیواحد است. هر تغییرِ قراردادی، rev را یک واحد افزایش میدهد؛ حتی اگر افزایشِ انرژی صفر باشد. این شماره، ساعت یا شمارِ نمونههای حسگر نیست. محدودیتِ SQL انرژی را میان صفر و یکمیلیون نگه میدارد. جدول STRICT نیست؛ CHECK بازه را میسنجد و نوعِ صحیحِ دقیق را تضمین نمیکند. ورودیها فقط ثابتهای صحیحِ محدود و تألیفیاند؛ تابعها رابطِ اعتبارسنجیِ ورودیِ خارجی نیستند.
دو اتصال با cache خصوصی، روی یک فایلِ تازه در پوشهٔ اختصاصی ساخته میشوند. read_uncommitted خاموش و WAL صریحاً درخواست و بررسی میشود. مرجعِ جداسازی SQLite رفتارِ اتصالهای جدا و نمای قدیمی را توضیح میدهد. ما ترتیبِ عملیات را خودمان تعیین میکنیم؛ رشتهٔ اجرایی، خواب، زمانسنج یا اندازهگیریِ سرعت نداریم.
autocommit در پایتون True است و BEGIN، COMMIT و ROLLBACK با SQL صریح اجرا میشوند. در قراردادِ کنترل تراکنش پایتون، روشهای commit و rollback اتصال در این حالت اثری ندارند؛ نباید آنها را بیبررسی جانشین دستورهای برنامه کرد. timeout صفر نیز برای حذفِ انتظارِ قراردادیِ قفل است، نه اثباتِ پاسخگوییِ بلادرنگ.
صفرکردنِ rev در تابع seed فقط آغازِ آزمایشِ مستقلِ بعدی است. در سامانهای که از نسخه برای کشفِ تغییر استفاده میکند، همهٔ نویسندگانِ مرتبط باید آن را مطابق قرارداد جلو ببرند. بازنشانی، سرریز یا نویسندهای که نسخه را تغییر ندهد، استدلالِ این نمونه را از بین میبرد. شرطِ نامنفیبودنِ rev محافظِ سرریز نیست؛ آزمونِ سرریز نداریم.
۳. کد کامل؛ شاهدِ بازنویسیِ کهنه و سه راهِ مقایسه

فایل زیر با Python 3.14.5 و SQLite 3.50.4 اجرا شده است؛ شمارهٔ کتابخانه از sqlite3.sqlite_version خوانده میشود. صفحهٔ پایتون هنگام دسترسی نسخهٔ مستندات 3.14.8 را نشان میدهد. هیچ بستهای نصب نمیشود. تمام cursorها کامل مصرف یا بسته میشوند و پاکسازی فقط سه نامِ فایلِ شناختهشده را در پوشهٔ کنترلشده حذف میکند.
مقادیر با پارامتر به SQL وصل میشوند. UPDATEهای شرطیِ این مثال سادهاند؛ CTE، trigger و RETURNING ندارند. طبق قرارداد rowcount، پس از اجرای کاملِ این دستورها، شمارِ ردیفِ تغییرکرده را میخوانیم. عدد صفر را موفقیتِ اعمالِ رویداد حساب نمیکنیم. کد کامل و خروجیِ همان اجرای ثبتشده در ادامه آمدهاند.
"""Own finite SQL decision-recomputation experiment; no generic retry loop."""
import json
import platform
import sqlite3
import sys
import tempfile
from pathlib import Path
def sql(con, statement, values=()):
cursor = con.execute(statement, values)
try:
return cursor.rowcount
finally:
cursor.close()
def query(con, statement):
cursor = con.execute(statement)
try:
return cursor.fetchall()
finally:
cursor.close()
def state(con):
rows = query(con, "SELECT energy_uj,rev FROM meter WHERE id=1")
assert len(rows) == 1
return rows[0]
def seed(a, b, initial):
assert not a.in_transaction and not b.in_transaction
sql(b, "UPDATE meter SET energy_uj=?,rev=0 WHERE id=1", (initial,))
def add(con, delta):
return sql(con, "UPDATE meter SET energy_uj=energy_uj+?,rev=rev+1 WHERE id=1", (delta,))
def cas(con, desired, revision):
return sql(con, "UPDATE meter SET energy_uj=?,rev=rev+1 WHERE id=1 AND rev=?",
(desired, revision))
def compare_mode(a, b, initial, other_delta, own_delta, mode):
# This helper is used only with the finite authored fixture table below.
seed(a, b, initial)
stale = state(a)
add(b, other_delta) # A completed autocommit statement from the other session.
sql(a, "BEGIN IMMEDIATE")
if mode == "fresh":
fresh = state(a)
sql(a, "UPDATE meter SET energy_uj=?,rev=rev+1 WHERE id=1",
(fresh[0] + own_delta,))
elif mode == "atomic":
add(a, own_delta)
elif mode == "cas":
assert cas(a, stale[0] + own_delta, stale[1]) == 0
fresh = state(a)
assert cas(a, fresh[0] + own_delta, fresh[1]) == 1
else:
raise ValueError("unknown authored comparison mode")
sql(a, "COMMIT")
return state(b)
def run_example():
if sys.version_info < (3, 12):
raise RuntimeError("explicit autocommit keyword requires Python3.12+")
parent = Path(__file__).resolve().parent
folder = Path(tempfile.mkdtemp(prefix="sqlite-decision-", dir=parent)).resolve()
database = (folder / "fixture.sqlite").resolve()
if folder.parent != parent or database.parent != folder or database.exists():
raise RuntimeError("owned fresh local fixture required")
a = b = None
cleaned = False
try:
uri = database.as_uri() + "?cache=private"
b = sqlite3.connect(uri, uri=True, timeout=0, autocommit=True)
if query(b, "PRAGMA journal_mode=WAL") != [("wal",)]:
raise RuntimeError("WAL unavailable")
a = sqlite3.connect(uri, uri=True, timeout=0, autocommit=True)
for con in (a, b):
sql(con, "PRAGMA read_uncommitted=OFF")
setup = a is not b and all(
con.autocommit is True
and query(con, "PRAGMA journal_mode") == [("wal",)]
and query(con, "PRAGMA read_uncommitted") == [(0,)]
and query(con, "PRAGMA busy_timeout") == [(0,)]
and Path(query(con, "PRAGMA database_list")[0][2]).resolve() == database
for con in (a, b))
sql(b, "CREATE TABLE meter(id INTEGER PRIMARY KEY CHECK(id=1),"
"energy_uj INTEGER NOT NULL CHECK(energy_uj BETWEEN 0 AND 1000000),"
"rev INTEGER NOT NULL CHECK(rev>=0))")
sql(b, "INSERT INTO meter VALUES(1,100,0)")
# A deliberate unsafe counterexample, entirely inside the disposable DB.
sql(a, "BEGIN")
old = state(a)
stale_desired = old[0] + 3
add(b, 7)
failure = None
try:
sql(a, "UPDATE meter SET energy_uj=?,rev=rev+1 WHERE id=1", (stale_desired,))
except sqlite3.OperationalError as error:
failure = {"code": error.sqlite_errorcode, "name": error.sqlite_errorname,
"primary_code": error.sqlite_errorcode & 255}
active_after_failure = a.in_transaction
old_after_failure, committed_other = state(a), state(b)
sql(a, "ROLLBACK")
sql(a, "BEGIN IMMEDIATE")
sql(a, "UPDATE meter SET energy_uj=?,rev=rev+1 WHERE id=1", (stale_desired,))
sql(a, "COMMIT")
blind = state(b)
fresh = compare_mode(a, b, 100, 7, 3, "fresh")
atomic = compare_mode(a, b, 100, 7, 3, "atomic")
seed(a, b, 100)
cached = state(a)
add(b, 7)
sql(a, "BEGIN IMMEDIATE")
rejected = cas(a, cached[0] + 3, cached[1])
rejected_state = state(a)
sql(a, "COMMIT")
sql(a, "BEGIN IMMEDIATE")
reread = state(a)
accepted = cas(a, reread[0] + 3, reread[1])
sql(a, "COMMIT")
accepted_state = state(b)
seed(a, b, 100)
before_aba = state(a)
add(b, 7)
add(b, -7) # Two committed changes restore the value, not its revision.
aba_current = state(b)
sql(a, "BEGIN IMMEDIATE")
aba_rejected = cas(a, before_aba[0] + 3, before_aba[1])
value_only_accepted = sql(a,
"UPDATE meter SET energy_uj=?,rev=rev+1 WHERE id=1 AND energy_uj=?",
(before_aba[0] + 3, before_aba[0]))
sql(a, "COMMIT")
seed(a, b, 100)
prior_allowed = state(a)[0] + 3 <= 108
add(b, 7)
sql(a, "BEGIN IMMEDIATE")
now_allowed = state(a)[0] + 3 <= 108
if now_allowed:
add(a, 3)
sql(a, "COMMIT")
declined_state = state(b)
guarded = []
for ceiling in (108, 110):
seed(a, b, 100)
add(b, 7)
sql(a, "BEGIN IMMEDIATE")
count = sql(a, "UPDATE meter SET energy_uj=energy_uj+?,rev=rev+1 "
"WHERE id=1 AND energy_uj+?<=?", (3, 3, ceiling))
sql(a, "COMMIT")
guarded.append((ceiling, count, state(b)))
seed(a, b, 100)
add(b, 7)
sql(a, "BEGIN IMMEDIATE")
add(a, 3)
provisional = state(a)
sql(a, "ROLLBACK")
rolled_back = state(b)
sql(a, "BEGIN IMMEDIATE")
add(a, 3)
sql(a, "COMMIT")
sql(a, "BEGIN IMMEDIATE")
add(a, 3) # A second committed delivery is an additional operation.
sql(a, "COMMIT")
replayed = state(b)
family_ok = True
cases = 0
for initial in (0, 100, 999):
for other_delta in (0, 7):
for own_delta in (0, 3):
# Independent arithmetic event oracle: no SQL, state(), CAS,
# transaction logic, or output from another comparison mode.
expected = (sum((initial, other_delta, own_delta)), 2)
for mode in ("fresh", "atomic", "cas"):
observed = compare_mode(a, b, initial, other_delta, own_delta, mode)
family_ok = family_ok and observed == expected
cases += 1
checks = {
"owned_private_WAL_fixture_and_cleanup": bool(setup),
"stale_upgrade_records_extended_snapshot_error": failure == {
"code": 517, "name": "SQLITE_BUSY_SNAPSHOT", "primary_code": 5},
"failed_upgrade_keeps_old_view_and_other_commit": active_after_failure
and old == old_after_failure == (100, 0) and committed_other == (107, 1),
"blind_absolute_assignment_after_rollback_loses_increment": blind == (103, 2),
"fresh_read_recomputes_intended_increment": fresh == (110, 2),
"atomic_addition_uses_current_database_value": atomic == (110, 2),
"stale_revision_CAS_affects_zero_and_preserves_state": rejected == 0
and rejected_state == (107, 1),
"fresh_revision_CAS_recomputes_and_affects_one": reread == (107, 1)
and accepted == 1 and accepted_state == (110, 2),
"revision_detects_ABA_that_value_only_predicate_accepts": before_aba == (100, 0)
and aba_current == (100, 2) and aba_rejected == 0 and value_only_accepted == 1,
"fresh_limit_decision_can_decline_stale_allowed_intent": prior_allowed
and not now_allowed and declined_state == (107, 1),
"guarded_statement_rejects_or_accepts_current_limit": guarded == [
(108, 0, (107, 1)), (110, 1, (110, 2))],
"rollback_discards_own_change_not_other_commit": provisional == (110, 2)
and rolled_back == (107, 1),
"repeated_committed_atomic_add_is_not_idempotent": replayed == (113, 3),
"independent_arithmetic_event_oracle_36_cases": family_ok and cases == 36,
}
result = {"python": platform.python_version(), "sqlite": sqlite3.sqlite_version,
"fixture": {"unit": "synthetic integer microjoules", "initial": 100,
"other_increment": 7, "own_increment": 3,
"old_view": old, "stale_upgrade_error": failure,
"other_committed": committed_other, "blind_absolute": blind,
"fresh_recompute": fresh, "atomic_add": atomic,
"CAS_stale_rows": rejected, "CAS_fresh_rows": accepted,
"CAS_final": accepted_state, "ABA_current": aba_current,
"ABA_revision_rows": aba_rejected,
"ABA_value_only_rows": value_only_accepted,
"fresh_limit108_allowed": now_allowed, "guarded_limits": guarded,
"own_rollback": rolled_back, "replayed_increment": replayed},
"oracle_cases": cases, "meaningful_named_groups": 14, "checks": checks,
"scope": "Sequential two-connection owned local WAL fixture; timeout0; "
"no sleeps, threads, timing, network, unknown-commit recovery or external effects."}
finally:
for con in (a, b):
if con is not None:
if con.in_transaction:
sql(con, "ROLLBACK")
con.close()
if folder.parent != parent or database.parent != folder:
raise RuntimeError("cleanup scope mismatch")
for name in ("fixture.sqlite", "fixture.sqlite-wal", "fixture.sqlite-shm"):
target = (folder / name).resolve()
if target.parent != folder:
raise RuntimeError("cleanup file scope mismatch")
if target.exists():
target.unlink()
folder.rmdir() # No recursive deletion; unexpected files fail visibly.
cleaned = not folder.exists()
result["checks"]["owned_private_WAL_fixture_and_cleanup"] &= cleaned
assert len(result["checks"]) == 14
assert all(type(value) is bool and value for value in result["checks"].values())
return result
if __name__ == "__main__":
print(json.dumps(run_example(), ensure_ascii=False, indent=2))
{
"python": "3.14.5",
"sqlite": "3.50.4",
"fixture": {
"unit": "synthetic integer microjoules",
"initial": 100,
"other_increment": 7,
"own_increment": 3,
"old_view": [
100,
0
],
"stale_upgrade_error": {
"code": 517,
"name": "SQLITE_BUSY_SNAPSHOT",
"primary_code": 5
},
"other_committed": [
107,
1
],
"blind_absolute": [
103,
2
],
"fresh_recompute": [
110,
2
],
"atomic_add": [
110,
2
],
"CAS_stale_rows": 0,
"CAS_fresh_rows": 1,
"CAS_final": [
110,
2
],
"ABA_current": [
100,
2
],
"ABA_revision_rows": 0,
"ABA_value_only_rows": 1,
"fresh_limit108_allowed": false,
"guarded_limits": [
[
108,
0,
[
107,
1
]
],
[
110,
1,
[
110,
2
]
]
],
"own_rollback": [
107,
1
],
"replayed_increment": [
113,
3
]
},
"oracle_cases": 36,
"meaningful_named_groups": 14,
"checks": {
"owned_private_WAL_fixture_and_cleanup": true,
"stale_upgrade_records_extended_snapshot_error": true,
"failed_upgrade_keeps_old_view_and_other_commit": true,
"blind_absolute_assignment_after_rollback_loses_increment": true,
"fresh_read_recomputes_intended_increment": true,
"atomic_addition_uses_current_database_value": true,
"stale_revision_CAS_affects_zero_and_preserves_state": true,
"fresh_revision_CAS_recomputes_and_affects_one": true,
"revision_detects_ABA_that_value_only_predicate_accepts": true,
"fresh_limit_decision_can_decline_stale_allowed_intent": true,
"guarded_statement_rejects_or_accepts_current_limit": true,
"rollback_discards_own_change_not_other_commit": true,
"repeated_committed_atomic_add_is_not_idempotent": true,
"independent_arithmetic_event_oracle_36_cases": true
},
"scope": "Sequential two-connection owned local WAL fixture; timeout0; no sleeps, threads, timing, network, unknown-commit recovery or external effects."
}
۴. محاسبهٔ تازه، جمعِ اتمی و شرطِ نسخه را بسنجیم

راه نخست، خواندن و محاسبهٔ دوباره در یک تراکنشِ تازهٔ BEGIN IMMEDIATE است. A مقدارِ صدوهفت را میخواند و سه را به آن میافزاید؛ نتیجه صدوده و نسخه دو میشود. طبق مرجعِ تراکنش، IMMEDIATE از آغاز جایگاه نویسنده را میگیرد و خودِ آغاز میتواند مشغول باشد. این انتخاب، تضمینِ نبودِ هر خطا یا مناسببودنِ رزروِ طولانی نیست.
راه دوم، جمع در خودِ UPDATE است: سمت راستِ انتساب از مقدارِ جاریِ ردیف استفاده میکند. معنای SET و WHERE در SQLite مبنای این رفتار است. این مقایسه برای عملیاتِ بیشرطِ «سه واحد اضافه کن» معنا دارد. اگر اجازهٔ تغییر به تصمیمی دربارهٔ وضعیت وابسته باشد، صرفاً اتمیبودنِ جمع، آن تصمیمِ قدیمی را معتبر نمیکند.
راه سوم، انتسابِ شرطی با نسخه است؛ آن را اینجا CAS مینامیم. شرطِ نسخهٔ صفر پس از تغییر B، صفر ردیف را تغییر میدهد و صدوهفت باقی میماند. در ردیفِ ثابتِ این آزمایش، این نتیجه تعارض را آشکار میکند. سپس A وضعیت و نسخهٔ تازه را میخواند، صدوده را محاسبه میکند و با شرطِ نسخهٔ یک، یک ردیف را تغییر میدهد. این مسیر، حلقهٔ تلاشِ نامحدود ندارد.
در شاهد ABA، B ابتدا هفت میافزاید و سپس هفت کم میکند؛ انرژی دوباره صد است، ولی نسخه دو شده است. شرطِ مقدارِ صد پذیرفته میشود، درحالیکه شرطِ نسخهٔ صفر رد میشود. برابریِ مقدار بهتنهایی، نبودِ تغییرِ میانی را ثابت نمیکند؛ البته این شاهد نیز نمیگوید هر تغییرِ میانی برای هر مسئلهای نامجاز است.
اگر سقفِ تصمیم صدوهشت باشد، تصمیمِ قدیمی برای افزودن سه مجاز و تصمیمِ تازه نامجاز است. برنامه پس از بازخوانی تغییر را انجام نمیدهد. UPDATE دارای شرطِ جمعِ جاری نیز برای سقف صدوهشت صفر ردیف و برای صدوده یک ردیف تغییر میدهد. چهارده گروه بررسی و خانوادهٔ سیوششمقایسهای با مرجعِ جمعِ مستقل موفقاند؛ آن خانواده یک گروه است، نه سیوشش گروهِ تازه.
۵. چهار پرسش دربارهٔ تعارض و مرزِ بازیابی

پرسش اول: آیا rollback، افزایشِ ثبتشدهٔ اتصال دیگر را حذف میکند؟ پاسخ: خیر. در شاهدِ مستقل، B هفت را ثبت کرده و A سه را موقتاً افزوده است؛ rollback خودِ A مقدار را به صدوهفت برمیگرداند. تغییرِ ثبتشدهٔ B همچنان باقی است.
پرسش دوم: صفر بودن rowcount یعنی همان خطای ۵۱۷؟ پاسخ: خیر. در CAS تازهٔ ما، دستور اجرا شده ولی شرطِ نسخه برقرار نیست. خطای ۵۱۷ مربوط به ارتقای نمای قدیمی است. در جدولِ عمومی، ردیفِ حذفشده نیز میتواند صفر ردیف بسازد؛ تشخیصِ علت، قراردادِ مستقلی میخواهد.
پرسش سوم: جمعِ اتمی، تکرارِ بیاثر هم هست؟ پاسخ: خیر. پس از ثبتِ هر دو افزایش و رسیدن به صدوده، اجرای ثبتشدهٔ دوبارهٔ افزایشِ سه، صدوسیزده میدهد. اگر نتیجهٔ COMMIT نامعلوم باشد یا اثرِ بیرونی داشته باشیم، این مثال مجوزِ بازپخشِ کور نیست؛ هویتِ عملیات و بازیابی باید جدا طراحی شوند.
پرسش چهارم: این آزمون جای کدام بررسیها را نمیگیرد؟ پاسخ: خرابی، قطع برق، checkpoint هملحظه و فشارِ تولید بررسی نشدهاند. مرجع WAL، بخش خطای WAL-reset نسخههای اصلاحشده را جدا توضیح میدهد؛ نسخهٔ اجراشدهٔ ما توصیهٔ استقرار نیست. آزمون تراکنشِ دادهٔ حسگر نیز اتمیبودنِ دسته و هویتِ بسته را دنبال میکند، نه اعتبارِ دوبارهٔ تصمیمِ این درس. منابع در ۲۷ مهر ۱۴۰۵ خوانده شدند و تصاویر همراه، مفهومیاند.
منابع و مطالعهٔ بیشتر
- SQLite؛ جداسازیِ اتصالها و نمای قدیمی
- SQLite؛ تراکنش، IMMEDIATE و پایانِ صریح
- SQLite؛ معنای UPDATE، SET و شرط WHERE
- SQLite؛ کد توسعهیافتهٔ BUSY_SNAPSHOT
- SQLite؛ WAL و محدودیتِ نسخهٔ اجراشده
- Python3.14؛ اتصال، کنترل تراکنش و rowcount
بررسی منابع: ۲۷ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

