Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/base/migrations/migrations_008.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from tortoise import connections

# 高频查询字段索引。真实部署的 schema 全部由迁移建立
# (init_db 不调用 generate_schemas),因此索引必须写在迁移里;
# apps/base/models.py 中的 index/Meta 仅用于测试的 generate_schemas 路径。
#
# - filecodes(expired_at):过期扫描(delete_expire_files)与后台过期统计。
# - filecodes(created_at):后台默认倒序列表、首页最近记录、今日/昨日统计。
# - uploadchunk(upload_id, chunk_index):分片状态检查与合并;其前缀同时
# 覆盖仅按 upload_id 的删除/清理查询(原先没有对应索引)。
# 已知缺口:clean_incomplete_uploads 的 chunk_index=-1 AND created_at<? 用不上
# 该复合索引,待清理任务改为分批查询后再评估。


async def migrate():
conn = connections.get("default")
await conn.execute_script(
"""
CREATE INDEX IF NOT EXISTS idx_filecodes_expired_at
ON filecodes (expired_at);
CREATE INDEX IF NOT EXISTS idx_filecodes_created_at
ON filecodes (created_at);
CREATE INDEX IF NOT EXISTS idx_uploadchunk_upload_chunk
ON uploadchunk (upload_id, chunk_index);
"""
)
13 changes: 10 additions & 3 deletions apps/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ class FileCodes(models.Model):
file_path = fields.CharField(max_length=255, null=True)
size = fields.BigIntField(default=0)
text = fields.TextField(null=True)
expired_at = fields.DatetimeField(null=True)
# 过期扫描与后台过期统计的高频过滤列
expired_at = fields.DatetimeField(null=True, index=True)
# -1 = 时间式(看 expired_at),<=0 的 0 = 次数耗尽。默认 -1 而非 0:
# 0 语义是"次数已用完即过期",忘传该字段的创建路径会得到立即过期的记录。
expired_count = fields.IntField(default=-1)
used_count = fields.IntField(default=0)
created_at = fields.DatetimeField(auto_now_add=True)
# 后台默认按创建时间倒序、今日/昨日统计均落在该列
created_at = fields.DatetimeField(auto_now_add=True, index=True)
file_hash = fields.CharField(max_length=64, null=True)
is_chunked = fields.BooleanField(default=False)
upload_id = fields.CharField(max_length=36, null=True)
Expand All @@ -44,7 +46,9 @@ async def get_file_path(self):

class UploadChunk(models.Model):
id = fields.IntField(pk=True)
upload_id = fields.CharField(max_length=36, index=True)
# 上传会话/分片查询始终同时带 upload_id;复合索引前缀已覆盖仅按 upload_id
# 的删除与清理查询,单列索引会造成重复。
upload_id = fields.CharField(max_length=36)
chunk_index = fields.IntField()
chunk_hash = fields.CharField(max_length=64)
total_chunks = fields.IntField()
Expand All @@ -55,6 +59,9 @@ class UploadChunk(models.Model):
created_at = fields.DatetimeField(auto_now_add=True)
completed = fields.BooleanField(default=False)

class Meta:
indexes = [("upload_id", "chunk_index")]


class KeyValue(Model):
# 与 FileCodes 保持同一风格:类属性不写值注解(等号右侧的 Field 描述符
Expand Down
81 changes: 81 additions & 0 deletions tests/test_migration_indexes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""迁移 008 建立的索引存在且被查询计划实际选用。

跑真实迁移链(不 generate_schemas),与部署升级形态一致;用
EXPLAIN QUERY PLAN 断言索引名出现在执行计划中,避免"建了索引但查询用不上"。
"""
import pytest
from tortoise import Tortoise

import apps.base.config as config_module
from core.database import execute_migrations
from tests.helpers import MEMORY_DB_CONFIG, close_db

INDEXES = {
"idx_filecodes_expired_at",
"idx_filecodes_created_at",
"idx_uploadchunk_upload_chunk",
}


@pytest.fixture
async def migrated_db():
config_module._config_cached_until = 0.0
if Tortoise._inited:
await Tortoise.close_connections()
await Tortoise.init(config=MEMORY_DB_CONFIG)
conn = Tortoise.get_connection("default")
await conn.execute_script(
"CREATE TABLE IF NOT EXISTS migrates ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"migration_file VARCHAR(255) NOT NULL UNIQUE, "
"executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
)
await execute_migrations()
try:
yield conn
finally:
await close_db()


async def _plan(conn, sql: str) -> str:
_, rows = await conn.execute_query(f"EXPLAIN QUERY PLAN {sql}")
return " | ".join(str(row["detail"]) for row in rows)


@pytest.mark.asyncio
class TestMigrationIndexes:
async def test_indexes_exist(self, migrated_db):
_, rows = await migrated_db.execute_query(
"SELECT name FROM sqlite_master WHERE type='index'"
)
names = {row["name"] for row in rows}
assert INDEXES <= names

async def test_expiry_scan_uses_index(self, migrated_db):
plan = await _plan(
migrated_db,
"SELECT id FROM filecodes WHERE expired_at < '2024-01-01 00:00:00'",
)
assert "idx_filecodes_expired_at" in plan

async def test_recent_listing_uses_created_at_index(self, migrated_db):
plan = await _plan(
migrated_db,
"SELECT id FROM filecodes ORDER BY created_at DESC LIMIT 8",
)
assert "idx_filecodes_created_at" in plan

async def test_chunk_lookup_uses_composite_index(self, migrated_db):
plan = await _plan(
migrated_db,
"SELECT chunk_index FROM uploadchunk "
"WHERE upload_id = 'abc' AND chunk_index = 3",
)
assert "idx_uploadchunk_upload_chunk" in plan

async def test_chunk_cleanup_uses_upload_id_prefix(self, migrated_db):
plan = await _plan(
migrated_db,
"DELETE FROM uploadchunk WHERE upload_id = 'abc'",
)
assert "idx_uploadchunk_upload_chunk" in plan
18 changes: 9 additions & 9 deletions tests/test_migration_runner.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Migration runner coverage: discovery order, registration, idempotency,
and failure propagation. Runs the REAL migrations (001-007) against an
and failure propagation. Runs the REAL migrations (001-008) against an
in-memory database — the same code path a deployment upgrade takes.
"""
import pytest
Expand All @@ -8,7 +8,7 @@
from core.database import execute_migrations
from tests.helpers import MEMORY_DB_CONFIG, close_db

EXPECTED = [f"migrations_{i:03d}.py" for i in range(1, 8)]
EXPECTED = [f"migrations_{i:03d}.py" for i in range(1, 9)]


@pytest.fixture
Expand Down Expand Up @@ -60,15 +60,15 @@ async def test_pre_registered_subset_skips_those(self, fresh_db):
"""部分已登记(模拟中途升级):只执行缺口,顺序保持。"""
conn = Tortoise.get_connection("default")
await conn.execute_query("DELETE FROM migrates")
# 预登记 006/007(模拟已执行过全部迁移的库),验证 runner 只跑缺口
# 001-005 且跳过已登记的 006/007(跳过语义 = 不再执行其 DDL)
for name in EXPECTED[-2:]:
# 预登记 006/007/008(模拟已执行过末尾迁移的库),验证 runner 只跑缺口
# 001-005 且跳过已登记的 006/007/008(跳过语义 = 不再执行其 DDL)
for name in EXPECTED[-3:]:
await conn.execute_query(
"INSERT INTO migrates (migration_file) VALUES (?)", [name]
)
await execute_migrations()
registered = await _registered()
# 预登记的 006/007 占据登记序前列——断言用集合:7 个全登记、无重复
# 预登记的 006/007/008 占据登记序前列——断言用集合:全部登记、无重复
assert sorted(registered) == sorted(EXPECTED)
assert len(registered) == len(EXPECTED)
tables = {
Expand Down Expand Up @@ -107,7 +107,7 @@ async def test_failure_propagates_and_halts(self, fresh_db, monkeypatch):
"""迁移执行失败必须向上抛(启动失败可见),不能静默吞掉。"""
import importlib

# 构造一个会失败的执行缺口:007 未登记,且其模块 migrate 抛错
# 构造一个会失败的执行缺口:008 未登记,且其模块 migrate 抛错
conn = Tortoise.get_connection("default")
await conn.execute_query("DELETE FROM migrates")
for name in EXPECTED[:-1]:
Expand All @@ -117,7 +117,7 @@ async def test_failure_propagates_and_halts(self, fresh_db, monkeypatch):
real_import = importlib.import_module

def fake_import(name):
if name.endswith("migrations_007"):
if name.endswith("migrations_008"):
class FakeMod:
@staticmethod
async def migrate():
Expand All @@ -130,4 +130,4 @@ async def migrate():
with pytest.raises(RuntimeError, match="boom"):
await execute_migrations()
# 失败的迁移不得登记
assert "migrations_007.py" not in await _registered()
assert "migrations_008.py" not in await _registered()