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

هر ردیف sample شامل شناسه، ts_ms و ولتاژ صحیح برحسب میلیولت است. زمانها از مبدأ دلخواهِ ساختگی و برحسب میلیثانیهاند؛ ساعت دیواری، UTC یا زمان دریافت شبکه نیستند. شناسهها یک تا ششاند. دو نمونهٔ نخست زمان صد، سومی صد و یک، چهارمی و پنجمی صد و دو و آخری صد و سه دارند. ترتیب درج عمداً متفاوت انتخاب شده است تا آن را با ترتیب خواندن اشتباه نگیریم.
خروجی با ORDER BY ts_ms,id صعودی مرتب میشود. شناسه یکتا و دو ستون کلید در دادهٔ ذخیرهشده غیرNULL هستند؛ بنابراین زمانِ مساوی با شناسه شکسته میشود. مستندات SELECT و ORDER BY توضیح میدهد که بدون ترتیب صریح، یا وقتی تمام عبارتهای ترتیب برابر باشند، ترتیب ردیفها معین نیست. نام یک ایندکس بهتنهایی قرارداد خروجی ایجاد نمیکند؛ ORDER BY در پرسوجو باقی میماند.
نشانگر این API داخلی، زوج دقیقِ timestamp_ms و id است؛ ترتیب اعضای آن با ترتیب ستونهای SELECT یکسان نیست، چون SELECT ابتدا id را برمیگرداند. برای شروع، None میدهیم. نوع و ترتیب نشانگر باید در مرز رابط روشن باشد؛ خواندن بیدقتِ آخرین ردیف و ساختن زوجِ وارونه، شرط ادامهٔ دیگری میسازد. این زوج، توکن شبکه یا مدرک مجوز دسترسی نیست.
۲. ادامهٔ سختگیرانه و یک ردیف برای تشخیص پایان

مقایسهٔ row value در SQLite مؤلفهها را از چپ به راست مقایسه میکند. شرط ما (ts_ms,id)>(?,?) است؛ با همان ترتیب صعودی، ردیف مرزی دوباره وارد نمیشود و نمونهٔ همزمان با شناسهٔ بزرگتر حفظ میشود. وجود NULL در مؤلفهٔ تعیینکننده میتواند مقایسه را نامعلوم کند؛ به همین دلیل برای این قرارداد، کلید nullable طراحی نکردهایم.
اندازهٔ صفحه فقط عدد صحیح دقیقِ یک تا سه است. برای اندازهٔ دو، حداکثر سه ردیف میخوانیم، دو ردیف اول را تحویل میدهیم و از وجود ردیف اضافه has_more میسازیم. next_cursor فقط وقتی ادامه داریم از آخرین ردیفِ تحویلی ساخته میشود. صفحهٔ پایانی ممکن است دقیقاً دو ردیف داشته باشد و بااینحال ادامه نداشته باشد؛ مصرفکننده نباید فقط از کاملبودن صفحه حدس بزند.
پاسخ دستی سه صفحه با اندازهٔ دو، شناسههای یک/دو، سه/چهار و پنج/شش است. نشانگرهای ادامه بهترتیب (۱۰۰،۲) و (۱۰۲،۴) هستند؛ صفحهٔ آخر next_cursor ندارد. اگر بهجای مقایسهٔ زوجی فقط ts_ms>۱۰۰ بگذاریم، پس از تحویل نمونهٔ یک، نمونهٔ دو جا میافتد. اگر شرط زوجی >= باشد، نمونهٔ مرزی دو تکرار میشود. کد هر دو خطای عمدی را نشان میدهد.
۳. نسخهٔ ثابت با کپیِ جدا، نه با خودِ نشانگر

برای ثابتبودن مجموعهٔ این آزمایش، از Connection.backup پایتون به یک اتصال حافظهایِ جدا کپی میگیریم. منبع هنگام شروع کپی در تراکنش فعال نیست و در این آزمایش، نویسندهٔ همزمان نداریم. کپی پیش از تغییرهای بعدی کامل میشود؛ سپس برنامه دادهٔ داخل همان کپی را تغییر نمیدهد. این روش، پیادهسازی نگهداری snapshot در یک سرویس چنددرخواستی نیست.
"""Original fixed-copy SQLite keyset acceptance harness, no HTTP or timing claims."""
import json
import math
import platform
import sqlite3
MAX_INT = 2**63 - 1
ROWS = [(4,102,4000),(1,100,1100),(6,103,6000),(3,101,3000),(2,100,2200),(5,102,5000)]
def fixture(rows=ROWS):
con=sqlite3.connect(':memory:',autocommit=True)
con.execute("CREATE TABLE sample(id INTEGER PRIMARY KEY CHECK(id>0),ts_ms INTEGER NOT NULL CHECK(typeof(ts_ms)='integer' AND ts_ms>=0),voltage_mv INTEGER NOT NULL)")
con.execute('CREATE INDEX sample_order ON sample(ts_ms,id)')
con.executemany('INSERT INTO sample VALUES(?,?,?)',rows)
return con
def integer(value,minimum,maximum):
if type(value)is not int:
raise TypeError('cursor/size components must be exact int, not bool or float')
if not minimum<=value<=maximum:
raise ValueError('integer outside authored bounds')
return value
class FixedCopyPager:
def __init__(self,source):
if source.in_transaction:
raise ValueError('authored capture requires source outside transaction')
self.snapshot=sqlite3.connect(':memory:',autocommit=True)
source.backup(self.snapshot)
self.snapshot.execute('PRAGMA query_only=ON')
def page(self,cursor=None,size=2):
size=integer(size,1,3)
if cursor is None:
rows=self.snapshot.execute('SELECT id,ts_ms,voltage_mv FROM sample ORDER BY ts_ms,id LIMIT ?',(size+1,)).fetchall()
else:
if type(cursor)is not tuple or len(cursor)!=2:
raise ValueError('cursor must be exact (timestamp_ms,id) tuple or None')
ts=integer(cursor[0],0,MAX_INT)
identifier=integer(cursor[1],1,MAX_INT)
rows=self.snapshot.execute('SELECT id,ts_ms,voltage_mv FROM sample WHERE (ts_ms,id)>(?,?) ORDER BY ts_ms,id LIMIT ?',
(ts,identifier,size+1)).fetchall()
more=len(rows)>size
result=rows[:size]
next_cursor=(result[-1][1],result[-1][0])if more else None
return {'rows':result,'has_more':more,'next_cursor':next_cursor}
def close(self):
self.snapshot.close()
def walk(pager,size):
result=[]
cursor=None
while True:
page=pager.page(cursor,size)
result.extend(page['rows'])
if not page['has_more']:
return result
assert page['next_cursor'] is not None and (cursor is None or page['next_cursor']>cursor)
cursor=page['next_cursor']
def _raises(expected,function,*args):
try:
function(*args)
except expected:
return True
return False
def run_example():
source=fixture()
pager=FixedCopyPager(source)
empty_source=fixture(())
empty_pager=FixedCopyPager(empty_source)
refreshed=None
try:
expected=sorted(ROWS,key=lambda row:(row[1],row[0]))
first=pager.page(None,2)
second=pager.page(first['next_cursor'],2)
final=pager.page(second['next_cursor'],2)
cursors=(None,(99,1),(100,1),(100,2),(101,3),(102,4),(102,5),(103,6),(999,9))
reference_cases=[]
for size in (1,2,3):
for cursor in cursors:
remaining=[r for r in expected if cursor is None or (r[1],r[0])>cursor]
ref_rows=remaining[:size]
more=len(remaining)>size
ref_cursor=(ref_rows[-1][1],ref_rows[-1][0])if more else None
reference_cases.append(pager.page(cursor,size)=={'rows':ref_rows,'has_more':more,'next_cursor':ref_cursor})
timestamp_only=pager.snapshot.execute('SELECT id FROM sample WHERE ts_ms>? ORDER BY ts_ms,id',(100,)).fetchall()
inclusive_bad=pager.snapshot.execute('SELECT id FROM sample WHERE (ts_ms,id)>=(?,?) ORDER BY ts_ms,id LIMIT 2',(100,2)).fetchall()
source.execute('INSERT INTO sample VALUES(?,?,?)',(7,100,7700))
after_insert=walk(pager,2)
source.execute('UPDATE sample SET ts_ms=? WHERE id=?',(99,3))
source.execute('DELETE FROM sample WHERE id=?',(5,))
after_update_delete=walk(pager,2)
live_rows=source.execute('SELECT id,ts_ms,voltage_mv FROM sample ORDER BY ts_ms,id').fetchall()
refreshed=FixedCopyPager(source)
readonly_rejected=_raises(sqlite3.OperationalError,pager.snapshot.execute,'DELETE FROM sample')
source.execute('BEGIN')
active_capture_rejected=_raises(ValueError,FixedCopyPager,source)
source.execute('ROLLBACK')
null_time_rejected=_raises(sqlite3.IntegrityError,source.execute,'INSERT INTO sample VALUES(?,?,?)',(99,None,9900))
duplicate_id_rejected=_raises(sqlite3.IntegrityError,source.execute,'INSERT INTO sample VALUES(?,?,?)',(1,999,9900))
checks={
'hand_full_order_is_timestamp_then_unique_id_not_insert_order': walk(pager,3)==expected and [r[0]for r in expected]==[1,2,3,4,5,6],
'hand_first_page_keeps_both_equal_timestamp_rows': first=={'rows':[(1,100,1100),(2,100,2200)],'has_more':True,'next_cursor':(100,2)},
'hand_second_page_cursor_splits_next_equal_timestamp_pair': second=={'rows':[(3,101,3000),(4,102,4000)],'has_more':True,'next_cursor':(102,4)},
'hand_final_full_page_reports_no_more_using_extra_row': final=={'rows':[(5,102,5000),(6,103,6000)],'has_more':False,'next_cursor':None},
'size_one_complete_walk_has_no_skip_or_duplicate': walk(pager,1)==expected and len({r[0]for r in walk(pager,1)})==6,
'independent_sorted_python_filter_slice_oracle_twenty_seven_cases': len(reference_cases)==27 and all(reference_cases),
'cursor_is_position_not_required_existing_record': pager.page((100,99),2)['rows']==[(3,101,3000),(4,102,4000)]
and pager.page((999,9),2)=={'rows':[],'has_more':False,'next_cursor':None},
'timestamp_only_predicate_demonstrably_skips_equal_time_id_two': timestamp_only==[(3,),(4,),(5,),(6,)],
'inclusive_composite_predicate_demonstrably_repeats_boundary': inclusive_bad==[(2,),(3,)],
'completed_copy_does_not_include_later_source_insert': after_insert==expected and all(r[0]!=7 for r in after_insert),
'completed_copy_does_not_move_or_delete_rows_when_source_changes': after_update_delete==expected and any(r[0]==5 for r in after_update_delete),
'fresh_copy_observes_changed_source_order_without_claiming_same_view': [r[0]for r in live_rows]==[3,1,2,7,4,6] and walk(refreshed,2)==live_rows and live_rows!=expected,
'query_only_rejects_write_in_owned_program_not_security_immutability': readonly_rejected and walk(pager,2)==expected,
'page_size_exact_integer_one_to_three_only': all(_raises((TypeError,ValueError),pager.page,None,x)for x in (True,0,-1,4,2.0,math.nan,None,'2')),
'cursor_tuple_shape_cannot_be_coerced': all(_raises(ValueError,pager.page,c,2)for c in ([100,2],(),(100,),(100,2,3),{'ts':100},'100,2')),
'cursor_components_reject_bool_float_null_and_nonfinite': all(_raises(TypeError,pager.page,c,2)for c in ((True,2),(100,False),(100.0,2),(100,None),(math.nan,2),(100,math.inf))),
'cursor_integer_ranges_match_nonnegative_time_positive_signed_int64_id': all(_raises(ValueError,pager.page,c,2)for c in ((-1,2),(100,0),(MAX_INT+1,2),(100,MAX_INT+1)))
and pager.page((MAX_INT,MAX_INT),2)=={'rows':[],'has_more':False,'next_cursor':None},
'capture_contract_empty_dataset_idle_source_and_nonnull_unique_keys': empty_pager.page(None,2)=={'rows':[],'has_more':False,'next_cursor':None}
and active_capture_rejected and null_time_rejected and duplicate_id_rejected,
}
assert len(checks)==18 and all(type(v)is bool and v for v in checks.values())
return {'runtime':{'python':platform.python_version(),'sqlite_runtime':sqlite3.sqlite_version,'storage':'source and completed backup copies all private :memory: connections'},
'contract':{'time_unit':'integer milliseconds from arbitrary synthetic origin, not wall-clock UTC','order':'ascending (ts_ms,id), keys non-NULL, id unique',
'cursor':'None start or exact tuple(ts_ms,id), each exact int, ts>=0/id>0, each<=2**63-1; not authenticated/opaque token',
'page_size_inclusive':[1,3],'end':'fetch size+1, has_more explicit, next_cursor only when more exists',
'copy_scope':'backup completes before later source mutations; owned copy not modified, query_only safeguard; no concurrent writers used'},
'metrics':{'initial_ordered_rows':expected,'first_page':first,'second_page':second,'final_full_page':final,
'timestamp_only_after_100_ids':timestamp_only,'bad_inclusive_cursor_ids':inclusive_bad,'copied_rows_after_source_changes':after_update_delete,
'fresh_copy_after_source_changes':live_rows,'independent_reference_cases':len(reference_cases)},
'checks':checks,
'limitations':['Offline fixed-copy harness only; no HTTP server, concurrent client, timestamp synchronization, benchmark, authorization, cryptographic cursor or durable snapshot storage.',
'Composite cursor alone does not freeze a live dataset or identify this copy across API requests; production snapshot identity/lifetime/filter/access policy needs separate design.',
'query_only is a program safeguard, not a truly immutable database or a security boundary; code owning the connection can change settings.',
'Index included for matching order but no speed/plan/complexity benchmark is claimed; all records and milliseconds are synthetic.']}
finally:
if source.in_transaction:source.execute('ROLLBACK')
if refreshed is not None:refreshed.close()
empty_pager.close();empty_source.close();pager.close();source.close()
if __name__=='__main__':
print(json.dumps(run_example(),ensure_ascii=False,indent=2))
روی کپی، PRAGMA query_only فعال میشود و تلاش آزمایشی برای DELETE رد میشود. مستندات صریحاً میگوید این گزینه پایگاه داده را واقعاً read-only نمیکند. آن را حفاظ برنامهای برای همین کد میدانیم، نه مرز امنیتی یا تضمین تغییرناپذیری؛ مالک اتصال میتواند تنظیم را عوض کند. ثبات خروجی این مثال از کپی مستقل و تغییرندادن آن ناشی میشود.
بعد از کپی، نمونهٔ هفت با زمان صد به منبع اضافه میشود؛ زمان نمونهٔ سه به نودونه تغییر و نمونهٔ پنج حذف میشود. پیمایش کپی قبلی همچنان شش ردیف آغازین را میدهد. کپی تازه از منبع، ترتیب سه، یک، دو، هفت، چهار و شش دارد. این دو مشاهدهٔ متفاوت را یک snapshot نمینامیم؛ نشانگر قبلی بهتنهایی تغییر دادهٔ زنده را خنثی نمیکند.
۴. اعتبارسنجی نشانگر و مرجع مستقلِ صفحهها

cursor غیرNone باید tuple دقیقِ دو عضوی باشد. هر عضو باید int دقیق باشد؛ bool، float، NaN، بینهایت و None درون زوج رد میشوند. زمان از صفر تا دو به توان شصتوسه منهای یک و شناسه از یک تا همان سقف مجاز است؛ با این سیاست، مقدار خارج محدوده به اتصال SQLite فرستاده نمیشود. شکلِ فهرست در خروجی JSON گزارش، قرارداد ورودی تابع را تغییر نمیدهد؛ در کد دوباره tuple میخواهیم.
نشانگر معتبر الزاماً ردیف موجودی را نام نمیبرد. زوج (۱۰۰،۹۹) موقعیتی پس از همهٔ ردیفهای زمان صد میسازد و ادامه از نمونهٔ سه است. این سیاستِ رابط، ادعای معتبر بودن سابقه یا مجوز کاربر نیست. در سرویس واقعی، شناسهٔ مجموعهٔ ثابت، عمر آن، فیلتر، کاربر و کنترل دسترسی نیازمند طراحی مستقلاند؛ این برنامه آنها را پیاده نمیکند.
{
"runtime": {
"python": "3.14.5",
"sqlite_runtime": "3.50.4",
"storage": "source and completed backup copies all private :memory: connections"
},
"contract": {
"time_unit": "integer milliseconds from arbitrary synthetic origin, not wall-clock UTC",
"order": "ascending (ts_ms,id), keys non-NULL, id unique",
"cursor": "None start or exact tuple(ts_ms,id), each exact int, ts>=0/id>0, each<=2**63-1; not authenticated/opaque token",
"page_size_inclusive": [
1,
3
],
"end": "fetch size+1, has_more explicit, next_cursor only when more exists",
"copy_scope": "backup completes before later source mutations; owned copy not modified, query_only safeguard; no concurrent writers used"
},
"metrics": {
"initial_ordered_rows": [
[
1,
100,
1100
],
[
2,
100,
2200
],
[
3,
101,
3000
],
[
4,
102,
4000
],
[
5,
102,
5000
],
[
6,
103,
6000
]
],
"first_page": {
"rows": [
[
1,
100,
1100
],
[
2,
100,
2200
]
],
"has_more": true,
"next_cursor": [
100,
2
]
},
"second_page": {
"rows": [
[
3,
101,
3000
],
[
4,
102,
4000
]
],
"has_more": true,
"next_cursor": [
102,
4
]
},
"final_full_page": {
"rows": [
[
5,
102,
5000
],
[
6,
103,
6000
]
],
"has_more": false,
"next_cursor": null
},
"timestamp_only_after_100_ids": [
[
3
],
[
4
],
[
5
],
[
6
]
],
"bad_inclusive_cursor_ids": [
[
2
],
[
3
]
],
"copied_rows_after_source_changes": [
[
1,
100,
1100
],
[
2,
100,
2200
],
[
3,
101,
3000
],
[
4,
102,
4000
],
[
5,
102,
5000
],
[
6,
103,
6000
]
],
"fresh_copy_after_source_changes": [
[
3,
99,
3000
],
[
1,
100,
1100
],
[
2,
100,
2200
],
[
7,
100,
7700
],
[
4,
102,
4000
],
[
6,
103,
6000
]
],
"independent_reference_cases": 27
},
"checks": {
"hand_full_order_is_timestamp_then_unique_id_not_insert_order": true,
"hand_first_page_keeps_both_equal_timestamp_rows": true,
"hand_second_page_cursor_splits_next_equal_timestamp_pair": true,
"hand_final_full_page_reports_no_more_using_extra_row": true,
"size_one_complete_walk_has_no_skip_or_duplicate": true,
"independent_sorted_python_filter_slice_oracle_twenty_seven_cases": true,
"cursor_is_position_not_required_existing_record": true,
"timestamp_only_predicate_demonstrably_skips_equal_time_id_two": true,
"inclusive_composite_predicate_demonstrably_repeats_boundary": true,
"completed_copy_does_not_include_later_source_insert": true,
"completed_copy_does_not_move_or_delete_rows_when_source_changes": true,
"fresh_copy_observes_changed_source_order_without_claiming_same_view": true,
"query_only_rejects_write_in_owned_program_not_security_immutability": true,
"page_size_exact_integer_one_to_three_only": true,
"cursor_tuple_shape_cannot_be_coerced": true,
"cursor_components_reject_bool_float_null_and_nonfinite": true,
"cursor_integer_ranges_match_nonnegative_time_positive_signed_int64_id": true,
"capture_contract_empty_dataset_idle_source_and_nonnull_unique_keys": true
},
"limitations": [
"Offline fixed-copy harness only; no HTTP server, concurrent client, timestamp synchronization, benchmark, authorization, cryptographic cursor or durable snapshot storage.",
"Composite cursor alone does not freeze a live dataset or identify this copy across API requests; production snapshot identity/lifetime/filter/access policy needs separate design.",
"query_only is a program safeguard, not a truly immutable database or a security boundary; code owning the connection can change settings.",
"Index included for matching order but no speed/plan/complexity benchmark is claimed; all records and milliseconds are synthetic."
]
}
برنامه با Python 3.14.5 و SQLite 3.50.4 اجرا شده است. مرجع مستقل، همان fixture را در پایتون مرتب میکند، زوجها را با مقایسهٔ پایتونی فیلتر و خروجی را برش میدهد. سه اندازه و نه موقعیت نشانگر، بیستوهفت مقایسه و یک گروه از هجده گروهاند. جدول خالی، صفحهٔ کامل نهایی، زمان برابر، خطاهای عمدی، تغییر منبع و ورودی نامعتبر نیز بررسی شدهاند؛ اجرای دوباره، گروه تازه نیست.
راهنمای انتخاب روش صفحهبندی در PostgreSQL زمینهٔ تصمیمگیری را میدهد؛ اینجا صرفاً آزمون اجراییِ SQLite را ساختیم. پردازش دستهای با مولد نیز دربارهٔ مصرف داده پرسش دیگری دارد. ایندکسِ نمونه با ترتیب هماهنگ است، ولی زمان، برنامهٔ اجرا یا برتری سرعت اندازهگیری نشدهاند. منابع در ۲۴ مهر ۱۴۰۵ خوانده شدند و نسخهٔ مستندات پایتون 3.14.8 از نسخهٔ اجرای واقعی جداست.
۵. چهار پرسش با پاسخ کامل

پرسش اول: بعد از نمونهٔ یک، شرط زمان بزرگتر از صد کافی است؟ پاسخ: خیر؛ نمونهٔ دو هم زمان صد دارد و جا میافتد. شرط زوجیِ سختگیرانه با شناسه، ادامهٔ درست همین ترتیب را نگه میدارد.
پرسش دوم: دو ردیف در آخرین صفحه یعنی صفحهٔ دیگری داریم؟ پاسخ: خیر؛ یک ردیف اضافه فقط برای تشخیص ادامه خوانده میشود. صفحهٔ پنج/شش کامل است ولی has_more نادرست و next_cursor برابر None دارد.
پرسش سوم: تغییر منبع، کپی قبلی را تغییر میدهد؟ پاسخ: در این آزمایش خیر؛ کپی مستقل پیشتر کامل شده است. کپی تازه دادهٔ تغییرکرده را میبیند و همان مشاهدهٔ قبلی نیست؛ cursor بهتنهایی نسخهٔ ثابت ایجاد نمیکند.
پرسش چهارم: زوج عددیِ معتبر، اجازهٔ خواندن داده است؟ پاسخ: خیر؛ این فقط موقعیت مرتبسازی است. احراز هویت، مجوز، اتصال نشانگر به snapshot و عمر آن در این تمرین وجود ندارند و باید جدا طراحی شوند.
منابع و مطالعهٔ بیشتر
- SQLite؛ مقایسهٔ زوجها و پنجرهٔ پیمایش
- SQLite SELECT؛ ترتیب چندستونی و LIMIT
- Python 3.14؛ Connection.backup و کپی حافظهای SQLite
- SQLite؛ مرز محدودِ PRAGMA query_only
بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

