feat: 完成 issue #31 ② MinIO 对象存储桶与生命周期策略

This commit is contained in:
2026-08-05 02:13:55 +08:00
parent 169492a6db
commit 2947dae434
5 changed files with 342 additions and 0 deletions
+1
View File
@@ -11,6 +11,7 @@ topic / 时序库表 / 关系表 / 对象桶:换行业只改模板资产(模
|------|------|
| `templating.py` | 模板命名推导:Kafka topic + 分区策略、TDengine 超级表/子表、PostgreSQL schema/表、MinIO 桶/对象键 |
| `kafka_naming.py` | Kafka topic 命名/分区模板化组件(issue #28):配置外置、点位维度覆盖、按 (topic,partition) 生产路由 |
| `minio_lifecycle.py` | MinIO 对象桶 + 生命周期策略模板化(issue #31):桶/对象键命名 + `mc mb`/`mc ilm` 落地命令 + S3 兼容生命周期策略 JSON |
| `tdengine_schema.py` | 时序库 schema 自动生成(超级表 + 每测点子表,**依据点位字典**)+ 批量 INSERT SQL |
| `postgres_schema.py` | 关系库 schema(模板 / 模型 / 用户 / 权限)+ 角色授权语句 |
| `batch_writer.py` | 批量写入缓冲:批量聚合(默认 5000 条/0.1s,对齐 PRD 5.2「5k/100ms」基线)、幂等去重、失败重试 —— **数据不丢不重** |
+3
View File
@@ -11,6 +11,7 @@ topic / 时序库表 / 关系表 / 对象桶,换行业只改模板资产(YAM
- kafka_naming Kafka topic 命名/分区模板化组件(配置外置 + 点位维度覆盖 + 生产路由);
- tdengine_schema 时序超级表/子表 DDL + 批量 INSERT SQL 生成;
- postgres_schema 关系库 schema(模板/模型/用户/权限)+ 授权语句;
- minio_lifecycle MinIO 对象桶 + 生命周期策略模板化(mc mb/ilm + S3 兼容策略 JSON);
- batch_writer 批量写入缓冲(StoreSink 抽象 / MemorySink / TdengineSink),
批量聚合 + 幂等去重 + 失败重试(不丢不重)。
@@ -26,6 +27,7 @@ from .batch_writer import (
TdengineSink,
)
from .kafka_naming import KafkaTopicNaming
from .minio_lifecycle import MinioLifecycle
from .postgres_schema import generate_grant_ddl, generate_schema_ddl
from .tdengine_schema import (
PointSpec,
@@ -42,6 +44,7 @@ __all__ = [
"sanitize",
"sanitize_sql",
"KafkaTopicNaming",
"MinioLifecycle",
"PointSpec",
"generate_supertable_ddl",
"generate_subtable_ddls",
+27
View File
@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# 模板「MinIO 对象存储桶 + 生命周期策略」配置资产示例:ti-cl4(氯化车间/海绵钛,Template-Ti 一期)。
#
# 说明(issue #31 / PRD 5.2「② 数据总线 + 时序库」):
# - 桶命名:`{template}-{bucket_suffix}`(S3 桶名允许 `-`,与 templating.bucket() 一致);
# 对象键前缀:特征快照 `features/...`、模型 artifact `models/...`(templating 派生);
# - lifecycle:按前缀声明对象过期天数(S3 LifecycleConfiguration 兼容,MinIO 支持),
# 与数据保留/归档策略(#33)联动,避免对象无限堆积;
# - 换行业只改本文件(template / bucket_suffix / lifecycle),内核零改动。
template: ti-cl4
version: 1.0.0
minio:
# 桶后缀(缺省 artifacts → 桶名 ti-cl4-artifacts)
bucket_suffix: artifacts
# mc 命令远端别名(联调 local / 生产 cluster;mc alias set 时定义)
mc_alias: local
# 生命周期规则(prefix 必填;expiration_days 过期天数,≥ 1)
lifecycle:
# 特征快照:供训练/回溯,保留 180 天自动过期
- id: features-expire
prefix: features/
expiration_days: 180
# 模型 artifact:随版本长期保留(3 年),出清历史版本前不自动删
- id: models-retain
prefix: models/
expiration_days: 1095
+160
View File
@@ -0,0 +1,160 @@
# -*- coding: utf-8 -*-
"""MinIO 对象存储桶 + 生命周期策略模板化 —— issue #31 / PRD 5.2。
在 #4(EPIC)templating 的 MinIO 命名(桶/对象键)之上,交付**完整的
MinIO 对象存储组件**:
- **桶**:`{template}-artifacts`(S3 桶名允许 `-`,无需 SQL 清洗),
对象键沿用 templating:特征快照 `features/{model_id}/{date}/{seq}.jsonl`、
模型 artifact `models/{model_id}/{version}/model.bin`;
- **生命周期策略**:按前缀配置对象过期天数(S3 LifecycleConfiguration 兼容,
MinIO 支持 JSON 形式),如特征快照保留 180 天、模型 artifact 长期保留,
与数据保留/归档策略(#33)联动,避免对象无限堆积;
- **落地方式**:只产出**配置文本**(`mc mb` / `mc ilm` 命令 + 生命周期
策略 JSON),不依赖 minio SDK,供模板配置台预览 / 运维执行;
- **换行业只改配置**:模板 YAML(`config/minio.template.yaml`)驱动,
内核代码零改动。
验收口径(issue #31 / EPIC #4):
- 桶命名模板化(`{template}-{suffix}`),配置可覆盖;
- 生命周期规则按前缀声明(ID / 前缀 / 过期天数),生成可执行命令与策略 JSON;
- 缺省配置覆盖特征快照 + 模型 artifact 两类对象。
"""
from __future__ import annotations
import json
import os
from typing import Dict, List, Optional
from data_bus.templating import TemplateNaming
#: 默认配置资产路径(相对本模块)
DEFAULT_CONFIG_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "config", "minio.template.yaml")
def _normalize_rules(lifecycle: Optional[List[dict]]) -> List[dict]:
"""规范化生命周期规则:过滤非法行,缺省 ID 自动生成。
每条规则形如 ``{"id": ..., "prefix": ..., "expiration_days": ...}``;
- prefix 必填(生命周期按对象键前缀匹配);
- expiration_days 为过期天数(≥ 1);
- 缺省 id 用前缀派生(保证 S3 Rule ID 唯一且稳定)。
"""
rules: List[dict] = []
for r in (lifecycle or []):
prefix = str(r.get("prefix") or "").strip()
days = int(r.get("expiration_days") or 0)
if not prefix or days < 1:
continue
rule_id = str(r.get("id") or "").strip() or f"{prefix}expire"
rules.append({
"id": rule_id,
"prefix": prefix,
"expiration_days": days,
})
return rules
class MinioLifecycle:
"""MinIO 桶 + 生命周期策略模板化(模板 + 前缀维度)。"""
def __init__(
self,
template: str,
bucket_suffix: str = "artifacts",
lifecycle: Optional[List[dict]] = None,
mc_alias: str = "local",
) -> None:
"""
Args:
template: 行业模板名(如 ti-cl4 / resin);
bucket_suffix: 桶后缀(缺省 artifacts → `{template}-artifacts`);
lifecycle: 生命周期规则 `[{"prefix": "features/", "expiration_days": 180}]`;
mc_alias: `mc` 命令的远端别名(联调 local / 生产 cluster)。
"""
self._tpl = TemplateNaming(template, bucket_suffix=bucket_suffix)
self.bucket_suffix = self._tpl.bucket_suffix
self.mc_alias = mc_alias
self._rules = _normalize_rules(lifecycle)
# ------------------------------------------------------------------
@classmethod
def from_template_config(cls, path: str = DEFAULT_CONFIG_PATH) -> "MinioLifecycle":
"""从模板配置资产加载(config/minio.template.yaml)。"""
import yaml
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
m = raw.get("minio", {}) or {}
return cls(
template=str(raw.get("template", "default")),
bucket_suffix=str(m.get("bucket_suffix", "artifacts")),
lifecycle=m.get("lifecycle", []),
mc_alias=str(m.get("mc_alias", "local")),
)
# ------------------------------------------------------------------
# 命名:桶 / 对象键(复用 templating,与 #4 命名一致)
# ------------------------------------------------------------------
def bucket(self) -> str:
"""对象存储桶:`{template}-{bucket_suffix}`(S3 桶名允许 `-`)。"""
return self._tpl.bucket()
def snapshot_key(self, model_id: str, date: str, seq: int) -> str:
"""特征快照对象键:`features/{model_id}/{date}/{seq:06d}.jsonl`。"""
return self._tpl.snapshot_key(model_id, date, seq)
def model_artifact_key(self, model_id: str, version: str) -> str:
"""模型 artifact 对象键:`models/{model_id}/{version}/model.bin`。"""
return self._tpl.model_artifact_key(model_id, version)
# ------------------------------------------------------------------
# 落地:mc 命令 + 生命周期策略 JSON(只产配置文本,不依赖 SDK)
# ------------------------------------------------------------------
def bucket_create_statements(self) -> List[str]:
"""桶创建命令(`mc mb`,--ignore-existing 幂等)。"""
return [f"mc mb --ignore-existing {self.mc_alias}/{self.bucket()}"]
def lifecycle_cli_statements(self) -> List[str]:
"""生命周期规则落地命令(`mc ilm add`,幂等可重跑)。
示例:`mc ilm add --expiry-days 180 --prefix "features/" local/ti-cl4-artifacts`
"""
bucket = self.bucket()
return [
f'mc ilm add --expiry-days {r["expiration_days"]} '
f'--prefix "{r["prefix"]}" {self.mc_alias}/{bucket}'
for r in self._rules
]
def lifecycle_policy_json(self, indent: int = 2) -> str:
"""S3 LifecycleConfiguration 兼容的 JSON 策略(MinIO 支持)。
返回结构:``{"Rules": [{"ID", "Status", "Filter": {"Prefix"},
"Expiration": {"Days"}}]}``,可直接用于
``PutBucketLifecycleConfiguration`` 或配置台预览。
"""
rules = [
{
"ID": r["id"],
"Status": "Enabled",
"Filter": {"Prefix": r["prefix"]},
"Expiration": {"Days": r["expiration_days"]},
}
for r in self._rules
]
return json.dumps({"Rules": rules}, indent=indent, ensure_ascii=False)
def brief(self) -> dict:
"""配置摘要(部署/巡检用)。"""
return {
"template": self._tpl.template,
"bucket": self.bucket(),
"mc_alias": self.mc_alias,
"lifecycle_rules": list(self._rules),
}
@property
def rules(self) -> List[dict]:
"""规范化后的生命周期规则(只读视图)。"""
return list(self._rules)
+151
View File
@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
"""MinIO 对象存储桶 + 生命周期策略模板化测试(issue #31)。
覆盖:
1. 模板配置资产加载(config/minio.template.yaml,含生命周期规则);
2. 桶命名:`{template}-{bucket_suffix}`(S3 桶名允许 `-`);
3. 对象键:特征快照 / 模型 artifact(与 templating 命名一致);
4. 落地命令:`mc mb` 幂等建桶、`mc ilm add` 生命周期规则命令;
5. 生命周期策略 JSON(S3 LifecycleConfiguration 兼容,MinIO 支持);
6. 规则规范化:缺省 id 派生、非法行过滤。
"""
import json
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _bootstrap # noqa: F401
from data_bus.minio_lifecycle import ( # noqa: E402
DEFAULT_CONFIG_PATH,
MinioLifecycle,
)
CONFIG = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"config", "minio.template.yaml",
)
class TestConfigLoad(unittest.TestCase):
"""模板配置资产加载与摘要。"""
def test_from_template_config(self):
life = MinioLifecycle.from_template_config(CONFIG)
self.assertEqual(life._tpl.template, "ti-cl4")
self.assertEqual(life.bucket(), "ti-cl4-artifacts")
self.assertEqual(life.mc_alias, "local")
brief = life.brief()
self.assertEqual(brief["bucket"], "ti-cl4-artifacts")
prefixes = [r["prefix"] for r in brief["lifecycle_rules"]]
self.assertIn("features/", prefixes)
self.assertIn("models/", prefixes)
def test_default_config_path_exists(self):
self.assertTrue(os.path.isfile(DEFAULT_CONFIG_PATH))
class TestBucketNaming(unittest.TestCase):
"""桶命名与对象键(复用 templating 命名,与 #4 一致)。"""
def setUp(self):
self.life = MinioLifecycle(template="ti-cl4")
def test_bucket_format(self):
self.assertEqual(self.life.bucket(), "ti-cl4-artifacts")
def test_bucket_suffix_custom(self):
self.assertEqual(
MinioLifecycle(template="resin", bucket_suffix="store").bucket(),
"resin-store")
def test_snapshot_key(self):
# model_id 经 sanitize_sql 清洗(`-` → `_`,与 templating 一致)
self.assertEqual(
self.life.snapshot_key("quality-forecast", "2026-08-04", 3),
"features/quality_forecast/2026-08-04/000003.jsonl")
def test_model_artifact_key(self):
self.assertEqual(
self.life.model_artifact_key("quality-forecast", "v1.2"),
"models/quality_forecast/v1.2/model.bin")
class TestBucketCreateStatements(unittest.TestCase):
"""mc mb 幂等建桶命令。"""
def test_create_statement(self):
life = MinioLifecycle(template="ti-cl4")
self.assertEqual(
life.bucket_create_statements(),
["mc mb --ignore-existing local/ti-cl4-artifacts"])
def test_create_statement_custom_alias(self):
life = MinioLifecycle(template="ti-cl4", mc_alias="cluster")
self.assertEqual(
life.bucket_create_statements(),
["mc mb --ignore-existing cluster/ti-cl4-artifacts"])
class TestLifecycleCli(unittest.TestCase):
"""mc ilm add 生命周期命令(按前缀 + 过期天数)。"""
def test_cli_statements(self):
life = MinioLifecycle(template="ti-cl4", lifecycle=[
{"prefix": "features/", "expiration_days": 180},
{"prefix": "models/", "expiration_days": 1095},
])
cmds = life.lifecycle_cli_statements()
self.assertEqual(len(cmds), 2)
self.assertIn('mc ilm add --expiry-days 180 --prefix "features/" local/ti-cl4-artifacts', cmds)
self.assertIn('mc ilm add --expiry-days 1095 --prefix "models/" local/ti-cl4-artifacts', cmds)
def test_no_rules_no_commands(self):
life = MinioLifecycle(template="ti-cl4", lifecycle=[])
self.assertEqual(life.lifecycle_cli_statements(), [])
class TestLifecyclePolicyJson(unittest.TestCase):
"""S3 LifecycleConfiguration 兼容 JSON 策略(MinIO 支持)。"""
def test_policy_json_structure(self):
life = MinioLifecycle(template="ti-cl4", lifecycle=[
{"id": "features-expire", "prefix": "features/", "expiration_days": 180},
])
policy = json.loads(life.lifecycle_policy_json())
rules = policy["Rules"]
self.assertEqual(len(rules), 1)
rule = rules[0]
self.assertEqual(rule["ID"], "features-expire")
self.assertEqual(rule["Status"], "Enabled")
self.assertEqual(rule["Filter"]["Prefix"], "features/")
self.assertEqual(rule["Expiration"]["Days"], 180)
def test_policy_json_roundtrip(self):
life = MinioLifecycle.from_template_config(CONFIG)
policy = json.loads(life.lifecycle_policy_json())
self.assertEqual(len(policy["Rules"]), 2)
class TestRuleNormalization(unittest.TestCase):
"""规则规范化:缺省 id 派生、非法行过滤。"""
def test_default_id_from_prefix(self):
life = MinioLifecycle(template="ti-cl4", lifecycle=[
{"prefix": "features/", "expiration_days": 180},
])
self.assertEqual(life.rules[0]["id"], "features/expire")
def test_invalid_rows_dropped(self):
life = MinioLifecycle(template="ti-cl4", lifecycle=[
{"prefix": "", "expiration_days": 180}, # 无前缀 → 丢弃
{"prefix": "models/", "expiration_days": 0}, # 过期天数非法 → 丢弃
{"prefix": "logs/", "expiration_days": 30}, # 合法
])
self.assertEqual(len(life.rules), 1)
self.assertEqual(life.rules[0]["prefix"], "logs/")
if __name__ == "__main__":
unittest.main()