Separate database connections from file-backed host information and document handoff
Verify databases and deploy Aster VM / verify-and-deploy (push) Successful in 7m25s
Verify databases and deploy Aster VM / verify-and-deploy (push) Successful in 7m25s
This commit is contained in:
+5
-7
@@ -1,10 +1,8 @@
|
||||
# Docker Compose reads .env automatically; cargo programs read exported env only.
|
||||
# Docker Compose reads .env; programs read exported host settings.
|
||||
ASTER_PORT=8080
|
||||
# Local host settings (export explicitly when running cargo):
|
||||
ASTER_LISTEN=127.0.0.1:8080
|
||||
SQLITE_PATH=data/aster.db
|
||||
# Unset DATABASE_URL to use SQLite. Do not set it to an empty string.
|
||||
# DATABASE_URL=postgres://aster:password@127.0.0.1:5432/aster
|
||||
# PostgreSQL Compose only: replace with a random hex string, e.g. openssl rand -hex 24.
|
||||
# Hex avoids URL-encoding ambiguity when used in the Compose connection URL.
|
||||
# JSON host information, separate from databases:
|
||||
ASTER_STATE_DIR=data/host
|
||||
# PostgreSQL provisioning only: credentials are not consumed by the VM host.
|
||||
# Database-backed objects will receive an explicit DatabaseConfig.
|
||||
POSTGRES_PASSWORD=replace-with-a-random-hex-password
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[ci-deploy.yml](ci-deploy.yml) 是当前唯一工作流:main push 或 workflow_dispatch 触发,运行于 ces-control,生产并发组串行且不取消正在运行的发布,任务超时 60 分钟。
|
||||
|
||||
顺序为检出 → ci-ces-control.sh 验证 → 构建 SHA 生产镜像 → main 分支执行 deploy-ces-control.sh。设置 DOCKER_BUILDKIT=0 使用当前 builder 路径,检出不保留认证信息;工作流不更改共享 Runner。
|
||||
顺序为检出 → ci-ces-control.sh 验证 → 构建 SHA 生产镜像 → main 分支执行 deploy-ces-control.sh。设置 DOCKER_BUILDKIT=0 使用当前 builder 路径,检出不保留认证信息;工作流不更改共享 Runner。PostgreSQL 保留为预置资源,基础信息由文件保存;发布只为旧基础信息做一次只读导入,不再写入数据库。
|
||||
|
||||
脚本职责见 [scripts](../../scripts/README.md),完整发布、保留数据和回退规则见 [部署](../../docs/operations/deployment.md)。main 推送会进入现有发布链,不能把推送成功写成 CI 或部署成功;按用户约定不主动跟踪状态。
|
||||
|
||||
|
||||
@@ -28,6 +28,6 @@ jobs:
|
||||
- name: Build production image
|
||||
run: docker build --target runtime -t "endorphin-aster-vm:$ASTER_RELEASE_SHA" .
|
||||
|
||||
- name: Deploy persistent PostgreSQL host
|
||||
- name: Deploy VM host and provision PostgreSQL
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
run: sh scripts/deploy-ces-control.sh
|
||||
|
||||
Generated
+11
-3
@@ -131,8 +131,7 @@ name = "cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"core",
|
||||
"storage",
|
||||
"tokio",
|
||||
"host",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -468,6 +467,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
@@ -1065,8 +1073,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"core",
|
||||
"host",
|
||||
"serde",
|
||||
"storage",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["crates/core", "crates/storage", "crates/cli", "crates/server"]
|
||||
members = ["crates/core", "crates/storage", "crates/cli", "crates/server", "crates/host"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
@@ -11,6 +11,7 @@ license = "MIT"
|
||||
[workspace.dependencies]
|
||||
core = { path = "crates/core" }
|
||||
storage = { path = "crates/storage" }
|
||||
host = { path = "crates/host" }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
+2
-2
@@ -19,12 +19,12 @@ FROM debian:bookworm-slim AS runtime
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --uid 10001 --create-home aster \
|
||||
&& mkdir -p /data && chown aster:aster /data
|
||||
&& mkdir -p /data/host && chown -R aster:aster /data
|
||||
COPY --from=build /build/target/release/server /usr/local/bin/server
|
||||
COPY --from=build /build/target/release/cli /usr/local/bin/cli
|
||||
USER 10001
|
||||
WORKDIR /data
|
||||
ENV ASTER_LISTEN=0.0.0.0:8080 SQLITE_PATH=/data/aster.db
|
||||
ENV ASTER_LISTEN=0.0.0.0:8080 ASTER_STATE_DIR=/data/host
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=10s --timeout=6s --start-period=10s --retries=3 \
|
||||
CMD curl --fail --silent http://127.0.0.1:8080/health || exit 1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Rust 编写的 Endor 虚拟机实现。当前具备 EndorObject、稳定对象分配、代际句柄、引用计数、显式循环回收,以及可执行语言函数的小型 wordcode VM。数字统一为无损表示优先的 Number。
|
||||
|
||||
VM 从空堆启动;尚无源码 lexer/parser、Python type/元类体系、数值算术或完整异常传播。宿主数据库保存元数据,不保存 VM 堆。当前边界见 [系统架构](docs/architecture/README.md)。
|
||||
VM 从空堆启动;尚无源码 lexer/parser、Python type/元类体系、数值算术或完整异常传播。数据库连接仅为需要数据库的特殊类保留;普通宿主不连接数据库;基础信息改用 host 包的 UTF-8 JSON 文件,不保存 VM 堆。当前边界见 [系统架构](docs/architecture/README.md)。
|
||||
|
||||
## 从哪里读
|
||||
|
||||
@@ -12,6 +12,7 @@ VM 从空堆启动;尚无源码 lexer/parser、Python type/元类体系、
|
||||
| 看源码目录与 crate 边界 | [crates](crates/README.md) |
|
||||
| 看指令、代码对象、执行帧和执行循环 | [字节码 VM](docs/runtime/bytecode.md) |
|
||||
| 看 VM/帧/对象如何存活和销毁 | [生命周期](docs/runtime/lifecycle.md) |
|
||||
| 看基础信息文件和旧库迁移 | [宿主信息](docs/operations/host-information.md) |
|
||||
| 看配置、UTF-8 与 locale 边界 | [配置](docs/operations/configuration.md) |
|
||||
| 运行 CLI/HTTP 宿主 | [运行指南](docs/operations/README.md) |
|
||||
| 看 ces-control CI 与 PostgreSQL 部署 | [部署](docs/operations/deployment.md) |
|
||||
@@ -21,18 +22,18 @@ VM 从空堆启动;尚无源码 lexer/parser、Python type/元类体系、
|
||||
|
||||
| 文件或目录 | 职责 |
|
||||
| --- | --- |
|
||||
| Cargo.toml / Cargo.lock | 工作区、包依赖、Rust 版本要求及依赖锁定;包名与目录一致:core、storage、cli、server |
|
||||
| crates/ | core、storage、cli、server 四个包,每个目录的 README 解释本目录 |
|
||||
| Cargo.toml / Cargo.lock | 工作区、包依赖、Rust 版本要求及依赖锁定;包名与目录一致:core、storage、host、cli、server |
|
||||
| crates/ | core、storage、host、cli、server 五个包,每个目录的 README 解释本目录 |
|
||||
| docs/ | 跨目录的架构、运行时契约、配置、运行、部署与维护说明 |
|
||||
| scripts/ | 指定远端环境中的检查、冒烟、CI 与部署脚本 |
|
||||
| .gitea/ | Gitea Actions 工作流 |
|
||||
| Dockerfile | source、ci、build、runtime 多阶段镜像 |
|
||||
| docker-compose.yml | 独立 SQLite 运行方案 |
|
||||
| docker-compose.postgres.yml | 独立 PostgreSQL 运行方案 |
|
||||
| docker-compose.ces-control.yml | ces-control 正式 PostgreSQL 部署 |
|
||||
| docker-compose.yml | VM 宿主运行方案,保留原有数据卷 |
|
||||
| docker-compose.postgres.yml | VM 宿主与预置 PostgreSQL |
|
||||
| docker-compose.ces-control.yml | ces-control 宿主与 PostgreSQL 资源部署 |
|
||||
| .env.example / .gitignore / .dockerignore | 配置样例、Git 与 Docker 上下文排除规则 |
|
||||
| LICENSE | MIT 许可 |
|
||||
|
||||
本地不编译、不测试、不下载依赖;可做文档和静态文件检查。运行验证交给 ces-control;按用户约定不主动跟踪 CI/部署结果。命令和使用条件见 [验证](docs/operations/validation.md)。
|
||||
|
||||
文档随代码版本更新:就地解释放各目录 README,跨目录契约放 docs,对外阅读副本以仓库文档为准。忽略的 work、outputs、target、data 和 Git 内部目录不属于需要维护 README 的源码目录。
|
||||
文档随代码版本更新:就地解释放各目录 README,跨目录契约放 docs,对外阅读副本以仓库文档为准。忽略的 work、target、data 和 Git 内部目录不属于需要维护 README 的源码目录。
|
||||
|
||||
+8
-7
@@ -1,14 +1,15 @@
|
||||
# Cargo 工作区
|
||||
|
||||
本目录包含四个 crate。Cargo 包名与目录一致,均不带项目前缀;路径依赖定义在根 Cargo.toml。
|
||||
包名与目录一致,路径依赖定义在根 Cargo.toml。
|
||||
|
||||
| 目录 | 包名/产物 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| [core](core/README.md) | core | 零外部依赖的对象运行时、堆与字节码 VM |
|
||||
| [storage](storage/README.md) | storage | 宿主元数据与 SQLite/PostgreSQL 适配,不依赖 core |
|
||||
| [cli](cli/README.md) | cli,二进制 cli | 引导演示和 metadata 命令 |
|
||||
| [server](server/README.md) | server | 最小常驻 HTTP 宿主 |
|
||||
| [core](core/README.md) | core,库 endor_core | 零外部依赖的对象运行时、堆与字节码 VM |
|
||||
| [host](host/README.md) | host | UTF-8 JSON 文件基础信息,与数据库分离 |
|
||||
| [storage](storage/README.md) | storage | 为数据库特殊类保留的显式连接适配,不提供基础信息存储 |
|
||||
| [cli](cli/README.md) | cli,二进制 cli | 引导演示与文件信息命令,不使用数据库 |
|
||||
| [server](server/README.md) | server,二进制 server | HTTP 宿主与文件版本信息,不使用数据库 |
|
||||
|
||||
core 的 Rust 库目标明确命名为 endor_core,避免与 Rust 标准库 core 重名;storage 的引用名为 storage,两个二进制分别为 cli、server。Cargo 通过 [lib] 自定义库名,详见 [Cargo 目标说明](https://doc.rust-lang.org/cargo/reference/cargo-targets.html#the-name-field)。
|
||||
CLI 和 Server 依赖 Core 与 Host,不依赖 Storage;Core 与 Storage 也互不依赖。未来特殊类的宿主实现按需依赖 Storage,见 [数据库对象](../docs/runtime/database-objects.md)。
|
||||
|
||||
CLI/Server 依赖 Core 和 Storage,Core 与 Storage 互不依赖。网络、数据库、浏览器、进程配置和信号留在宿主。全局说明见 [架构](../docs/architecture/README.md),查功能位置从 [文档索引](../docs/README.md) 进入。返回 [项目入口](../README.md)。
|
||||
core 的 Rust 库名为 endor_core,避免与标准库 core 重名,见 [Cargo 目标说明](https://doc.rust-lang.org/cargo/reference/cargo-targets.html#the-name-field)。网络、数据库、进程配置和信号不进入 Core。全局说明见 [架构](../docs/architecture/README.md),返回 [项目入口](../README.md)。
|
||||
|
||||
@@ -11,8 +11,7 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
core.workspace = true
|
||||
storage.workspace = true
|
||||
tokio.workspace = true
|
||||
host.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# cli
|
||||
|
||||
命令行宿主。bootstrap 演示两个运行时对象并释放;metadata put/get 使用独立 Storage,不让数据库配置进入 Core。
|
||||
同步命令行宿主,提供 bootstrap、help 和文件 metadata 命令。bootstrap 演示两个运行时对象并释放,不读取数据库配置、不连接或写入数据库。
|
||||
|
||||
| 内容 | 职责 |
|
||||
| --- | --- |
|
||||
| Cargo.toml | CLI 包、cli 二进制名称、core/storage/Tokio 依赖 |
|
||||
| [src](src/README.md) | 参数路由、命令执行和退出码 |
|
||||
| Cargo.toml | cli 二进制与 core/host 依赖;无 storage 或 Tokio 依赖 |
|
||||
| [src](src/README.md) | 参数路由、引导演示和退出码 |
|
||||
|
||||
用法见 [运行指南](../../docs/operations/README.md),文本参数与环境边界见 [配置](../../docs/operations/configuration.md)。此 CLI 当前不是 Endor 源码解释命令。返回 [工作区](../README.md)。
|
||||
运行方式见 [运行指南](../../docs/operations/README.md),输入边界见 [配置](../../docs/operations/configuration.md)。当前不是 Endor 源码解释命令,metadata put/get 和只导入缺失项的 import 均使用文件,见 [宿主信息](../../docs/operations/host-information.md)。返回 [工作区](../README.md)。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# CLI 入口
|
||||
|
||||
[main.rs](main.rs) 是唯一入口:main 将 run 的结果转成进程退出码;run 解析 bootstrap、metadata put/get 和 help,错误打印到 stderr 并返回失败。
|
||||
[main.rs](main.rs) 是唯一入口:同步 main 将 run 的结果转成进程退出码;run 处理 bootstrap、help、文件 metadata put/get/import,错误打印到 stderr 并返回失败。
|
||||
|
||||
bootstrap 创建并显式释放两个对象,不打开数据库。metadata 每次读取 StorageConfig、连接、操作并关闭池。参数目前由 std::env::args 读取,非 Unicode 参数可能 panic,现状及后续边界见 [配置](../../../docs/operations/configuration.md)。
|
||||
bootstrap 创建并显式释放两个对象,不读取数据库环境、不打开数据库。未知命令报错;metadata 通过 host::MetadataStore 读写文件,import 从标准输入接收旧记录且不覆盖已有文件。参数目前由 std::env::args 读取,非 Unicode 参数可能 panic,边界见 [配置](../../../docs/operations/configuration.md)。
|
||||
|
||||
新增命令时更新用法、[运行指南](../../../docs/operations/README.md) 和 [smoke.py 的导航](../../../scripts/README.md)。返回 [cli](../README.md)。
|
||||
|
||||
+16
-14
@@ -2,13 +2,12 @@ use std::error::Error;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use endor_core::{Value, Vm};
|
||||
use storage::{Storage, StorageConfig};
|
||||
use host::MetadataStore;
|
||||
|
||||
const USAGE: &str = "Usage: cli [bootstrap | metadata put <key> <value> | metadata get <key>]";
|
||||
const USAGE: &str = "Usage: cli [bootstrap | metadata put <key> <value> | metadata get <key> | metadata import | help]";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
match run().await {
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("cli: {error}");
|
||||
@@ -17,7 +16,7 @@ async fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), Box<dyn Error>> {
|
||||
fn run() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
match args
|
||||
.iter()
|
||||
@@ -43,22 +42,25 @@ async fn run() -> Result<(), Box<dyn Error>> {
|
||||
vm.release(first)?;
|
||||
vm.release(second)?;
|
||||
println!("released heap_objects={}", vm.object_count());
|
||||
println!("bootstrap is independent of host storage");
|
||||
println!("bootstrap uses no database");
|
||||
}
|
||||
["help"] | ["--help"] | ["-h"] => println!("{USAGE}"),
|
||||
["metadata", "put", key, value] => {
|
||||
let store = Storage::connect(&StorageConfig::from_env()?).await?;
|
||||
store.put(key, value).await?;
|
||||
println!("stored ({})", store.backend_name());
|
||||
store.close().await;
|
||||
MetadataStore::from_env()?.put(key, value)?;
|
||||
println!("stored (file)");
|
||||
}
|
||||
["metadata", "get", key] => {
|
||||
let store = Storage::connect(&StorageConfig::from_env()?).await?;
|
||||
println!(
|
||||
"{}",
|
||||
store.get(key).await?.as_deref().unwrap_or("(missing)")
|
||||
MetadataStore::from_env()?
|
||||
.get(key)?
|
||||
.as_deref()
|
||||
.unwrap_or("(missing)")
|
||||
);
|
||||
store.close().await;
|
||||
}
|
||||
["metadata", "import"] => {
|
||||
let count = MetadataStore::from_env()?.import_missing(std::io::stdin().lock())?;
|
||||
println!("imported {count} missing entries (file)");
|
||||
}
|
||||
_ => return Err(USAGE.into()),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "host"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,11 @@
|
||||
# host
|
||||
|
||||
宿主基础信息的文件层,与 Core 和数据库 Storage 分开。CLI/Server 使用它保存 host.version、deployment.release 和其他命名信息;不打开数据库,不保存 VM 堆。
|
||||
|
||||
| 内容 | 职责 |
|
||||
| --- | --- |
|
||||
| Cargo.toml | host 包、Serde/JSON 依赖及远端检查依赖 |
|
||||
| [src](src/README.md) | MetadataStore、UTF-8 JSON 文件与迁移导入 |
|
||||
| [tests](tests/README.md) | 持久读取、替换、并发、坏记录、路径边界和幂等导入 |
|
||||
|
||||
ASTER_STATE_DIR 指定信息目录;默认 data/host,容器使用 /data/host。协议、生命周期及旧数据库一次性迁移见 [宿主信息](../../docs/operations/host-information.md)。返回 [工作区](../README.md)。
|
||||
@@ -0,0 +1,8 @@
|
||||
# Host 源码导航
|
||||
|
||||
| 文件 | 职责 |
|
||||
| --- | --- |
|
||||
| [lib.rs](lib.rs) | 导出 MetadataStore,明确文件层不依赖 VM 或 SQL |
|
||||
| [metadata.rs](metadata.rs) | 目录配置、键校验、逐键 JSON 读取/同步写入、原子发布、仅导入缺失项 |
|
||||
|
||||
文件数据以 UTF-8 JSON 保存,操作系统路径用 PathBuf;坏记录不自动覆盖。每个键独立发布,不提供多键事务。旧库导入不覆盖新文件,规则见 [宿主信息](../../../docs/operations/host-information.md)。返回 [host](../README.md)。
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Host information stored in UTF-8 JSON files, independently of the VM and SQL.
|
||||
mod metadata;
|
||||
|
||||
pub use metadata::MetadataStore;
|
||||
@@ -0,0 +1,172 @@
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||
const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LegacyRecord {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Record {
|
||||
format_version: u32,
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
/// One independently replaceable JSON file per ASCII metadata key. Clones
|
||||
/// share a directory, not a cached map; reads always observe the file system.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MetadataStore {
|
||||
directory: PathBuf,
|
||||
}
|
||||
|
||||
impl MetadataStore {
|
||||
/// Does not create the directory until the first write.
|
||||
pub fn new(directory: impl Into<PathBuf>) -> io::Result<Self> {
|
||||
let directory = directory.into();
|
||||
if directory.as_os_str().is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"metadata directory is empty",
|
||||
));
|
||||
}
|
||||
Ok(Self { directory })
|
||||
}
|
||||
|
||||
/// Preserve OS paths without requiring the environment value to be UTF-8.
|
||||
pub fn from_env() -> io::Result<Self> {
|
||||
Self::new(
|
||||
std::env::var_os("ASTER_STATE_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("data/host")),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn directory(&self) -> &Path {
|
||||
&self.directory
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> io::Result<Option<String>> {
|
||||
let path = self.path(key)?;
|
||||
let bytes = match fs::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let record: Record = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
if record.format_version != FORMAT_VERSION || record.key != key {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"metadata key or format version mismatch",
|
||||
));
|
||||
}
|
||||
Ok(Some(record.value))
|
||||
}
|
||||
|
||||
/// Writes a new sibling file, syncs it, then publishes it with rename.
|
||||
/// Different keys do not share a read/modify/write map. For the same key,
|
||||
/// the last successful rename wins. This is not a multi-key transaction.
|
||||
pub fn put(&self, key: &str, value: &str) -> io::Result<()> {
|
||||
self.store(key, value, true).map(|_| ())
|
||||
}
|
||||
|
||||
/// One-time migration input: a JSON array of key/value string records.
|
||||
/// Validate all keys first, and never overwrite an existing file record.
|
||||
pub fn import_missing(&self, reader: impl Read) -> io::Result<usize> {
|
||||
let records: Vec<LegacyRecord> = serde_json::from_reader(reader)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
for record in &records {
|
||||
self.path(&record.key)?;
|
||||
}
|
||||
let mut imported = 0;
|
||||
for record in records {
|
||||
if self.store(&record.key, &record.value, false)? {
|
||||
imported += 1;
|
||||
} else if self.get(&record.key)?.is_none() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"existing metadata disappeared during import",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
fn store(&self, key: &str, value: &str, replace: bool) -> io::Result<bool> {
|
||||
let destination = self.path(key)?;
|
||||
let mut contents = serde_json::to_vec_pretty(&Record {
|
||||
format_version: FORMAT_VERSION,
|
||||
key: key.into(),
|
||||
value: value.into(),
|
||||
})
|
||||
.map_err(io::Error::other)?;
|
||||
contents.push(b'\n');
|
||||
fs::create_dir_all(&self.directory)?;
|
||||
for _ in 0..32 {
|
||||
let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
|
||||
let temporary = self
|
||||
.directory
|
||||
.join(format!(".{key}.{}.{sequence}.tmp", std::process::id()));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = match options.open(&temporary) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let result = (|| {
|
||||
file.write_all(&contents)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
if replace {
|
||||
fs::rename(&temporary, &destination)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
// Atomic create-if-absent: a concurrent writer's record
|
||||
// cannot be replaced by the legacy import.
|
||||
match fs::hard_link(&temporary, &destination) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(false),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
})();
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return result;
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"cannot reserve a metadata temporary file",
|
||||
))
|
||||
}
|
||||
|
||||
fn path(&self, key: &str) -> io::Result<PathBuf> {
|
||||
if key.is_empty()
|
||||
|| key.len() > 128
|
||||
|| !key.as_bytes()[0].is_ascii_alphanumeric()
|
||||
|| !key
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"metadata keys must start with an ASCII letter/digit and contain at most 128 ASCII letters, digits, dots, underscores or hyphens",
|
||||
));
|
||||
}
|
||||
Ok(self.directory.join(format!("{key}.json")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Host 文件信息检查
|
||||
|
||||
[metadata.rs](metadata.rs) 检查缺失读取不建目录、日文/中文/emoji 值和日文路径、重开与同键替换、非法键不越界、坏 JSON/版本/键不匹配不改原文件、并发替换可见完整记录,以及旧记录幂等导入和保留更新值。
|
||||
|
||||
检查使用临时目录,仅在指定远端环境运行。本地不执行文件行为测试;当前是否通过应以对应提交的 CI 为准。命令见 [验证](../../../docs/operations/validation.md),返回 [host](../README.md)。
|
||||
@@ -0,0 +1,131 @@
|
||||
use host::MetadataStore;
|
||||
|
||||
#[test]
|
||||
fn missing_reads_do_not_create_state() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let directory = temporary.path().join("state");
|
||||
let store = MetadataStore::new(&directory).unwrap();
|
||||
assert_eq!(store.get("missing").unwrap(), None);
|
||||
assert!(!directory.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_values_survive_reopening_and_same_key_replacement() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let directory = temporary.path().join("日本語");
|
||||
let store = MetadataStore::new(&directory).unwrap();
|
||||
store.put("host.version", "0.1.0").unwrap();
|
||||
store.put("project.note", "日本語・中文・😀").unwrap();
|
||||
let reopened = MetadataStore::new(&directory).unwrap();
|
||||
assert_eq!(
|
||||
reopened.get("project.note").unwrap().as_deref(),
|
||||
Some("日本語・中文・😀")
|
||||
);
|
||||
reopened.put("host.version", "0.2.0").unwrap();
|
||||
assert_eq!(store.get("host.version").unwrap().as_deref(), Some("0.2.0"));
|
||||
assert_eq!(
|
||||
store.get("project.note").unwrap().as_deref(),
|
||||
Some("日本語・中文・😀")
|
||||
);
|
||||
let record: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(directory.join("project.note.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(record["format_version"], 1);
|
||||
assert_eq!(record["key"], "project.note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_cannot_escape_the_state_directory() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let directory = temporary.path().join("state");
|
||||
let store = MetadataStore::new(&directory).unwrap();
|
||||
for key in [
|
||||
"",
|
||||
"../outside",
|
||||
"/outside",
|
||||
"nested/key",
|
||||
"nested\\key",
|
||||
".hidden",
|
||||
"日本語",
|
||||
] {
|
||||
assert!(store.put(key, "invalid").is_err(), "{key}");
|
||||
assert!(store.get(key).is_err(), "{key}");
|
||||
}
|
||||
assert!(store.put(&"x".repeat(129), "invalid").is_err());
|
||||
assert!(!directory.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_or_mismatched_records_fail_without_being_rewritten() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let store = MetadataStore::new(temporary.path()).unwrap();
|
||||
let path = temporary.path().join("host.version.json");
|
||||
for bytes in [
|
||||
b"not-json".as_slice(),
|
||||
b"\xff".as_slice(),
|
||||
br#"{"format_version":2,"key":"host.version","value":"old"}"#.as_slice(),
|
||||
br#"{"format_version":1,"key":"wrong","value":"old"}"#.as_slice(),
|
||||
] {
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
assert!(store.get("host.version").is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_replacements_expose_complete_records_and_preserve_other_keys() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let store = MetadataStore::new(temporary.path()).unwrap();
|
||||
store.put("independent", "keep").unwrap();
|
||||
let a = "a".repeat(8192);
|
||||
let b = "b".repeat(8192);
|
||||
store.put("shared", &a).unwrap();
|
||||
std::thread::scope(|scope| {
|
||||
for value in [&a, &b] {
|
||||
let store = &store;
|
||||
scope.spawn(move || {
|
||||
for _ in 0..8 {
|
||||
store.put("shared", value).unwrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
for _ in 0..64 {
|
||||
let value = store.get("shared").unwrap().unwrap();
|
||||
assert!(value == a || value == b);
|
||||
}
|
||||
});
|
||||
assert_eq!(store.get("independent").unwrap().as_deref(), Some("keep"));
|
||||
assert!(std::fs::read_dir(temporary.path()).unwrap().all(|entry| {
|
||||
!entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.ends_with(".tmp")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_import_is_repeatable_and_preserves_newer_file_values() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let store = MetadataStore::new(temporary.path()).unwrap();
|
||||
store.put("host.version", "new").unwrap();
|
||||
let input =
|
||||
br#"[{"key":"host.version","value":"old"},{"key":"project.note","value":"legacy"}]"#;
|
||||
assert_eq!(store.import_missing(input.as_slice()).unwrap(), 1);
|
||||
assert_eq!(store.import_missing(input.as_slice()).unwrap(), 0);
|
||||
assert_eq!(store.get("host.version").unwrap().as_deref(), Some("new"));
|
||||
assert_eq!(
|
||||
store.get("project.note").unwrap().as_deref(),
|
||||
Some("legacy")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_import_keys_are_rejected_before_any_record_is_written() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let directory = temporary.path().join("state");
|
||||
let store = MetadataStore::new(&directory).unwrap();
|
||||
let input = br#"[{"key":"valid","value":"keep"},{"key":"../outside","value":"invalid"}]"#;
|
||||
assert!(store.import_missing(input.as_slice()).is_err());
|
||||
assert!(!directory.exists());
|
||||
}
|
||||
@@ -7,7 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
core.workspace = true
|
||||
storage.workspace = true
|
||||
host.workspace = true
|
||||
axum.workspace = true
|
||||
serde.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# server
|
||||
|
||||
最小常驻 HTTP 宿主,提供数据库健康检查和只读 VM 引导信息。它通过 Arc 共享 Vm,没有语言执行接口或可变 VM 请求上下文。
|
||||
最小常驻 HTTP 宿主,提供存活检查和只读 VM 信息。通过 Arc 共享 Vm,启动不读取数据库配置、不连接或写入数据库;通过 host 包把 host.version 写入文件。特殊类的数据库能力尚未接入 HTTP 宿主。
|
||||
|
||||
| 内容 | 职责 |
|
||||
| --- | --- |
|
||||
| Cargo.toml | core/storage、Axum、Tokio、Serde 依赖 |
|
||||
| [src](src/README.md) | 启动顺序、AppState、路由、信号与退出 |
|
||||
| Cargo.toml | core/host、Axum、Tokio、Serde 依赖;无 storage 依赖 |
|
||||
| [src](src/README.md) | AppState、启动、路由、信号与退出 |
|
||||
|
||||
HTTP 返回内容和运行方式见 [运行指南](../../docs/operations/README.md),变量见 [配置](../../docs/operations/configuration.md),生产端口/数据库见 [部署](../../docs/operations/deployment.md)。返回 [工作区](../README.md)。
|
||||
HTTP 返回内容见 [运行指南](../../docs/operations/README.md),监听变量见 [配置](../../docs/operations/configuration.md),预置 PostgreSQL 资源见 [部署](../../docs/operations/deployment.md)。没有语言执行接口或可变 VM 请求上下文。返回 [工作区](../README.md)。
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# Server 入口
|
||||
|
||||
[main.rs](main.rs) 包含当前所有宿主启动代码:
|
||||
[main.rs](main.rs) 包含所有宿主启动代码:
|
||||
|
||||
| 部分 | 职责 |
|
||||
| --- | --- |
|
||||
| AppState | 共享只读 Arc<Vm> 与可克隆的 Storage 连接池 |
|
||||
| main/run | 创建 VM、连接数据库、写 host.version、绑定地址、组装路由、服务结束后关闭存储 |
|
||||
| health | ping 数据库,返回 200/503 及后端标签 |
|
||||
| AppState | 共享只读 Arc<Vm> |
|
||||
| main/run | 创建空 VM、绑定地址、设置退出信号、写文件 host.version、组装路由;不创建数据库连接 |
|
||||
| health | 返回 200 和 status=ok,仅检查 HTTP 宿主可响应 |
|
||||
| bootstrap | 返回对象模型、type_system=false 和堆对象数量,不分配对象 |
|
||||
| shutdown_signal | Ctrl-C;Unix 上同时接受 SIGTERM,触发 Axum 优雅退出 |
|
||||
| tests | 关闭数据库池后健康状态变为 unavailable |
|
||||
|
||||
改变端点时同步 [运行指南](../../../docs/operations/README.md) 与 [smoke.py](../../../scripts/README.md);改变启动变量时同步 [配置](../../../docs/operations/configuration.md)。网络、信号和数据库逻辑不进入 Core。返回 [server](../README.md)。
|
||||
/health 不表示 PostgreSQL、特殊类查询或执行脚本正常;数据库检查应属于实际消费连接的对象。宿主独立于数据库的行为由远端 smoke.py 验证,见 [验证](../../../docs/operations/validation.md)。改变端点同步 [运行指南](../../../docs/operations/README.md) 和 [scripts](../../../scripts/README.md)。返回 [server](../README.md)。
|
||||
|
||||
@@ -3,21 +3,19 @@ use std::future::Future;
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Json, Router, extract::State, http::StatusCode, routing::get};
|
||||
use axum::{Json, Router, extract::State, routing::get};
|
||||
use endor_core::Vm;
|
||||
use host::MetadataStore;
|
||||
use serde::Serialize;
|
||||
use storage::{Storage, StorageConfig};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
vm: Arc<Vm>,
|
||||
storage: Storage,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Health {
|
||||
status: &'static str,
|
||||
storage: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -40,44 +38,23 @@ async fn main() -> ExitCode {
|
||||
|
||||
async fn run() -> Result<(), Box<dyn Error>> {
|
||||
let vm = Arc::new(Vm::new()?);
|
||||
let storage = Storage::connect(&StorageConfig::from_env()?).await?;
|
||||
storage
|
||||
.put("host.version", env!("CARGO_PKG_VERSION"))
|
||||
.await?;
|
||||
let address = std::env::var("ASTER_LISTEN").unwrap_or_else(|_| "127.0.0.1:8080".into());
|
||||
let listener = tokio::net::TcpListener::bind(&address).await?;
|
||||
let shutdown = shutdown_signal()?;
|
||||
MetadataStore::from_env()?.put("host.version", env!("CARGO_PKG_VERSION"))?;
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/bootstrap", get(bootstrap))
|
||||
.with_state(AppState {
|
||||
vm,
|
||||
storage: storage.clone(),
|
||||
});
|
||||
println!(
|
||||
"Endor host listening on {} (storage={})",
|
||||
listener.local_addr()?,
|
||||
storage.backend_name()
|
||||
);
|
||||
.with_state(AppState { vm });
|
||||
println!("Endor host listening on {}", listener.local_addr()?);
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown)
|
||||
.await?;
|
||||
storage.close().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health(State(state): State<AppState>) -> (StatusCode, Json<Health>) {
|
||||
let (code, status) = match state.storage.ping().await {
|
||||
Ok(()) => (StatusCode::OK, "ok"),
|
||||
Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "storage_unavailable"),
|
||||
};
|
||||
(
|
||||
code,
|
||||
Json(Health {
|
||||
status,
|
||||
storage: state.storage.backend_name(),
|
||||
}),
|
||||
)
|
||||
async fn health() -> Json<Health> {
|
||||
Json(Health { status: "ok" })
|
||||
}
|
||||
|
||||
async fn bootstrap(State(state): State<AppState>) -> Json<BootstrapInfo> {
|
||||
@@ -98,25 +75,3 @@ fn shutdown_signal() -> std::io::Result<impl Future<Output = ()>> {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_reports_unavailable_after_storage_closes() {
|
||||
let storage = Storage::connect(&StorageConfig::SqliteMemory)
|
||||
.await
|
||||
.unwrap();
|
||||
let state = AppState {
|
||||
vm: Arc::new(Vm::new().unwrap()),
|
||||
storage,
|
||||
};
|
||||
let (status, _) = health(State(state.clone())).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
state.storage.close().await;
|
||||
let (status, Json(body)) = health(State(state)).await;
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(body.status, "storage_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# storage
|
||||
|
||||
宿主元数据存储,依赖 SQLx/Tokio,不依赖 VM 内核。一个 Storage 选择 SQLite 或 PostgreSQL;明确配置 PostgreSQL 后失败不回退。
|
||||
按需数据库连接适配层,供将来需要数据库的特殊类或宿主适配器使用。依赖 SQLx/Tokio,不依赖 VM 内核;普通对象、CLI 和 Server 不连接这层。
|
||||
|
||||
| 内容 | 入口 |
|
||||
| --- | --- |
|
||||
| Cargo.toml | SQLx、Tokio 和检查所需依赖 |
|
||||
| [src](src/README.md) | 配置解析、连接池、元数据表和操作 |
|
||||
| [tests](tests/README.md) | 配置、持久化、隔离与专用 PostgreSQL 检查 |
|
||||
| Cargo.toml | SQLx、Tokio 与远端检查依赖 |
|
||||
| [src](src/README.md) | 显式 DatabaseConfig、连接池与关闭 |
|
||||
| [tests](tests/README.md) | 连接不建表、调用方 SQL、隔离/持久化、真实 PostgreSQL |
|
||||
|
||||
Storage 仅保存 key/value 元数据,不保存 EndorObject 或执行帧。运行与连接规则见 [运行指南](../../docs/operations/README.md),环境优先级与错误规则见 [配置](../../docs/operations/configuration.md),生产数据与迁移边界见 [部署](../../docs/operations/deployment.md)。返回 [工作区](../README.md)。
|
||||
没有默认数据库、自动环境变量读取、基础信息表或通用元数据 put/get。特殊类的名称、布局与查询接口尚未定义;当前只保留连接能力。设计边界见 [数据库对象](../../docs/runtime/database-objects.md),参数见 [配置](../../docs/operations/configuration.md)。返回 [工作区](../README.md)。
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
# Storage 源码导航
|
||||
# 数据库连接源码导航
|
||||
|
||||
| 文件 | 职责 |
|
||||
| --- | --- |
|
||||
| [lib.rs](lib.rs) | StorageConfig::from_values/from_env、StorageError、SQLite/PostgreSQL 连接池、schema 初始化、ping/put/get/close |
|
||||
| [schema.sql](schema.sql) | 当前 aster_metadata 文本 key/value 表,幂等建表 |
|
||||
[lib.rs](lib.rs) 是唯一实现文件:DatabaseConfig 显式选择 SQLite 文件/内存或 PostgreSQL;DatabaseConnection 提供 connect、sqlite_pool/postgres_pool、backend_name、ping 和 close;DatabaseError 对公开错误脱敏。
|
||||
|
||||
配置改动同步 [配置表](../../../docs/operations/configuration.md) 与 storage 测试。schema 改动须考虑已有数据库,当前没有版本化迁移;不得将 CREATE IF NOT EXISTS 当作已有结构升级。Storage 克隆共享连接池,close 影响所有克隆。
|
||||
connect 只建立连接池,不执行应用 DDL,不保存基础信息。没有 schema.sql、初始化表、put/get 或 from_env。业务表和查询由未来的数据库特殊类决定;本层不替它定义数据模型。
|
||||
|
||||
连接与持久化契约见 [运行指南](../../../docs/operations/README.md),测试定位见 [tests](../tests/README.md)。返回 [storage](../README.md)。
|
||||
克隆共享连接池,close 会关闭所有克隆;具体生命周期及限制见 [数据库对象](../../../docs/runtime/database-objects.md)。配置变动同步 [配置](../../../docs/operations/configuration.md) 和 [tests](../tests/README.md)。返回 [storage](../README.md)。
|
||||
|
||||
+41
-109
@@ -1,4 +1,5 @@
|
||||
//! Host metadata storage; deliberately has no dependency on core.
|
||||
//! Explicit database connections for database-backed objects.
|
||||
//! Connecting never creates application tables or stores VM/host metadata.
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
@@ -8,17 +9,15 @@ use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
||||
use sqlx::{PgPool, SqlitePool};
|
||||
|
||||
const SCHEMA: &str = include_str!("schema.sql");
|
||||
|
||||
/// An explicit PostgreSQL configuration never falls back to SQLite.
|
||||
#[derive(Clone)]
|
||||
pub enum StorageConfig {
|
||||
pub enum DatabaseConfig {
|
||||
SqliteFile(PathBuf),
|
||||
SqliteMemory,
|
||||
Postgres(String),
|
||||
}
|
||||
|
||||
impl fmt::Debug for StorageConfig {
|
||||
impl fmt::Debug for DatabaseConfig {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::SqliteFile(path) => f.debug_tuple("SqliteFile").field(path).finish(),
|
||||
@@ -28,57 +27,14 @@ impl fmt::Debug for StorageConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl StorageConfig {
|
||||
/// DATABASE_URL accepts PostgreSQL only; SQLITE_PATH selects file or :memory:.
|
||||
/// Empty/invalid explicit values are errors rather than default selection.
|
||||
pub fn from_values(
|
||||
database_url: Option<&str>,
|
||||
sqlite_path: Option<&str>,
|
||||
) -> Result<Self, StorageError> {
|
||||
if let Some(url) = database_url {
|
||||
if !(url.starts_with("postgres://") || url.starts_with("postgresql://")) {
|
||||
return Err(StorageError::Config(
|
||||
"DATABASE_URL must be a PostgreSQL URL",
|
||||
));
|
||||
}
|
||||
return Ok(Self::Postgres(url.into()));
|
||||
}
|
||||
match sqlite_path.unwrap_or("data/aster.db") {
|
||||
"" => Err(StorageError::Config("SQLITE_PATH cannot be empty")),
|
||||
":memory:" => Ok(Self::SqliteMemory),
|
||||
path => Ok(Self::SqliteFile(path.into())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env() -> Result<Self, StorageError> {
|
||||
let database_url = read_env("DATABASE_URL")?;
|
||||
// Irrelevant SQLite settings must not prevent PostgreSQL configuration.
|
||||
if database_url.is_some() {
|
||||
return Self::from_values(database_url.as_deref(), None);
|
||||
}
|
||||
let sqlite_path = read_env("SQLITE_PATH")?;
|
||||
Self::from_values(None, sqlite_path.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_env(name: &str) -> Result<Option<String>, StorageError> {
|
||||
match std::env::var(name) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(std::env::VarError::NotPresent) => Ok(None),
|
||||
Err(std::env::VarError::NotUnicode(_)) => Err(StorageError::Config(
|
||||
"storage environment must be valid Unicode",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StorageError {
|
||||
pub enum DatabaseError {
|
||||
Config(&'static str),
|
||||
Directory(std::io::Error),
|
||||
Database(sqlx::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for StorageError {
|
||||
impl fmt::Display for DatabaseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Config(message) => f.write_str(message),
|
||||
@@ -91,7 +47,7 @@ impl fmt::Display for StorageError {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StorageError {
|
||||
impl std::error::Error for DatabaseError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Config(_) => None,
|
||||
@@ -101,7 +57,7 @@ impl std::error::Error for StorageError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for StorageError {
|
||||
impl From<sqlx::Error> for DatabaseError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
Self::Database(error)
|
||||
}
|
||||
@@ -113,20 +69,26 @@ enum Backend {
|
||||
Postgres(PgPool),
|
||||
}
|
||||
|
||||
/// Clones share a pool. A memory SQLite store uses exactly one connection, so
|
||||
/// initialization and every operation see the same database for its lifetime.
|
||||
/// Created only on explicit request by an object or host adapter. Clones share
|
||||
/// a pool; close() closes it for all users. There is no automatic schema or data.
|
||||
/// In-memory SQLite uses one connection to preserve its database lifetime.
|
||||
#[derive(Clone)]
|
||||
pub struct Storage {
|
||||
pub struct DatabaseConnection {
|
||||
backend: Backend,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
/// Connects eagerly and initializes the v1 metadata schema before returning.
|
||||
pub async fn connect(config: &StorageConfig) -> Result<Self, StorageError> {
|
||||
impl DatabaseConnection {
|
||||
/// Connects eagerly to the explicit backend without executing application DDL.
|
||||
pub async fn connect(config: &DatabaseConfig) -> Result<Self, DatabaseError> {
|
||||
let backend = match config {
|
||||
StorageConfig::Postgres(url) => {
|
||||
DatabaseConfig::Postgres(url) => {
|
||||
if !(url.starts_with("postgres://") || url.starts_with("postgresql://")) {
|
||||
return Err(DatabaseError::Config(
|
||||
"expected a PostgreSQL connection URL",
|
||||
));
|
||||
}
|
||||
let options = PgConnectOptions::from_str(url)
|
||||
.map_err(|_| StorageError::Config("invalid PostgreSQL connection URL"))?;
|
||||
.map_err(|_| DatabaseError::Config("invalid PostgreSQL connection URL"))?;
|
||||
Backend::Postgres(
|
||||
PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
@@ -135,16 +97,16 @@ impl Storage {
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
StorageConfig::SqliteFile(path) => {
|
||||
DatabaseConfig::SqliteFile(path) => {
|
||||
if path.as_os_str().is_empty() || path == std::path::Path::new(":memory:") {
|
||||
return Err(StorageError::Config(
|
||||
"use SqliteMemory for an in-memory database",
|
||||
return Err(DatabaseError::Config(
|
||||
"expected a nonempty file path; use SqliteMemory for :memory:",
|
||||
));
|
||||
}
|
||||
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(StorageError::Directory)?;
|
||||
.map_err(DatabaseError::Directory)?;
|
||||
}
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
@@ -159,7 +121,7 @@ impl Storage {
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
StorageConfig::SqliteMemory => {
|
||||
DatabaseConfig::SqliteMemory => {
|
||||
let options = SqliteConnectOptions::new().in_memory(true);
|
||||
Backend::Sqlite(
|
||||
SqlitePoolOptions::new()
|
||||
@@ -172,21 +134,22 @@ impl Storage {
|
||||
)
|
||||
}
|
||||
};
|
||||
let storage = Self { backend };
|
||||
storage.initialize().await?;
|
||||
Ok(storage)
|
||||
Ok(Self { backend })
|
||||
}
|
||||
|
||||
async fn initialize(&self) -> Result<(), StorageError> {
|
||||
/// Backend-specific access for the database-backed object implementation.
|
||||
pub fn sqlite_pool(&self) -> Option<&SqlitePool> {
|
||||
match &self.backend {
|
||||
Backend::Sqlite(pool) => {
|
||||
sqlx::query(SCHEMA).execute(pool).await?;
|
||||
}
|
||||
Backend::Postgres(pool) => {
|
||||
sqlx::query(SCHEMA).execute(pool).await?;
|
||||
}
|
||||
Backend::Sqlite(pool) => Some(pool),
|
||||
Backend::Postgres(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn postgres_pool(&self) -> Option<&PgPool> {
|
||||
match &self.backend {
|
||||
Backend::Postgres(pool) => Some(pool),
|
||||
Backend::Sqlite(_) => None,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn backend_name(&self) -> &'static str {
|
||||
@@ -196,7 +159,7 @@ impl Storage {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ping(&self) -> Result<(), StorageError> {
|
||||
pub async fn ping(&self) -> Result<(), DatabaseError> {
|
||||
match &self.backend {
|
||||
Backend::Sqlite(pool) => {
|
||||
sqlx::query("SELECT 1").execute(pool).await?;
|
||||
@@ -208,38 +171,7 @@ impl Storage {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn put(&self, key: &str, value: &str) -> Result<(), StorageError> {
|
||||
match &self.backend {
|
||||
Backend::Sqlite(pool) => {
|
||||
sqlx::query("INSERT INTO aster_metadata (key, value) VALUES (?1, ?2) ON CONFLICT (key) DO UPDATE SET value = excluded.value")
|
||||
.bind(key).bind(value).execute(pool).await?;
|
||||
}
|
||||
Backend::Postgres(pool) => {
|
||||
sqlx::query("INSERT INTO aster_metadata (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = excluded.value")
|
||||
.bind(key).bind(value).execute(pool).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(&self, key: &str) -> Result<Option<String>, StorageError> {
|
||||
Ok(match &self.backend {
|
||||
Backend::Sqlite(pool) => {
|
||||
sqlx::query_scalar("SELECT value FROM aster_metadata WHERE key = ?1")
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
}
|
||||
Backend::Postgres(pool) => {
|
||||
sqlx::query_scalar("SELECT value FROM aster_metadata WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Explicitly closes the shared pool, including all Storage clones.
|
||||
/// Explicitly closes the shared pool, including all DatabaseConnection clones.
|
||||
pub async fn close(&self) {
|
||||
match &self.backend {
|
||||
Backend::Sqlite(pool) => pool.close().await,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS aster_metadata (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
# Storage 行为检查
|
||||
# 数据库连接检查
|
||||
|
||||
| 文件 | 覆盖与前提 |
|
||||
| --- | --- |
|
||||
| [storage.rs](storage.rs) | 配置默认值/优先级/错误、内存共享与实例隔离、临时文件重连、目录错误;不处理 PostgreSQL 协议的 socket 仅检查连接超时和不降级 |
|
||||
| [postgres.rs](postgres.rs) | 真实 PostgreSQL 写入/覆盖/缺失键/重连;默认 ignore,显式运行需 ASTER_TEST_POSTGRES_URL 指向专用测试库 |
|
||||
| [storage.rs](storage.rs) | 显式错误配置、凭据脱敏、SQLite 连接后无应用表、克隆共享与独立内存隔离、调用方自建数据跨文件重连、目录错误、PostgreSQL 失败不降级 |
|
||||
| [postgres.rs](postgres.rs) | 真实连接后 public 无表;测试在事务中创建临时 class_rows、参数绑定和读取,提交自动删除临时表,再连接;默认 ignore,需全新专用 ASTER_TEST_POSTGRES_URL |
|
||||
|
||||
SQLite 文件检查使用临时目录;PostgreSQL 成功路径不使用生产数据库。ces-control 脚本提供一次性的测试数据库,见 [验证](../../../docs/operations/validation.md) 和 [scripts](../../../scripts/README.md)。不在本地运行或为测试下载 SQLite 依赖。返回 [storage](../README.md)。
|
||||
所有业务表都由测试自己创建,连接层不得自动初始化基础信息表。临时 SQLite 文件和 PostgreSQL 测试库由远端 CI 隔离,不使用生产数据。检查命令见 [验证](../../../docs/operations/validation.md),原生连接能力边界见 [数据库对象](../../../docs/runtime/database-objects.md)。本地不运行测试或下载依赖。返回 [storage](../README.md)。
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
use storage::{Storage, StorageConfig};
|
||||
use storage::{DatabaseConfig, DatabaseConnection};
|
||||
|
||||
/// Run explicitly against a disposable database; never silently skip a missing URL.
|
||||
/// Run against a fresh disposable database, never a production or shared schema.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires ASTER_TEST_POSTGRES_URL pointing to a test PostgreSQL database"]
|
||||
async fn postgres_roundtrip_and_reconnect() {
|
||||
#[ignore = "requires ASTER_TEST_POSTGRES_URL pointing to a fresh disposable PostgreSQL database"]
|
||||
async fn postgres_connects_without_schema_and_executes_caller_owned_sql() {
|
||||
let url = std::env::var("ASTER_TEST_POSTGRES_URL")
|
||||
.expect("set ASTER_TEST_POSTGRES_URL to a disposable PostgreSQL database");
|
||||
let config = StorageConfig::from_values(Some(&url), None).unwrap();
|
||||
let store = Storage::connect(&config).await.unwrap();
|
||||
assert_eq!(store.backend_name(), "postgresql");
|
||||
store.ping().await.unwrap();
|
||||
let key = format!(
|
||||
"test.{}.{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
assert_eq!(store.get(&key).await.unwrap(), None);
|
||||
store.put(&key, "你好 ' ; $1").await.unwrap();
|
||||
assert_eq!(
|
||||
store.get(&key).await.unwrap().as_deref(),
|
||||
Some("你好 ' ; $1")
|
||||
);
|
||||
store.put(&key, "updated").await.unwrap();
|
||||
store.close().await;
|
||||
let reopened = Storage::connect(&config).await.unwrap();
|
||||
assert_eq!(
|
||||
reopened.get(&key).await.unwrap().as_deref(),
|
||||
Some("updated")
|
||||
);
|
||||
.expect("set ASTER_TEST_POSTGRES_URL to a fresh disposable PostgreSQL database");
|
||||
let config = DatabaseConfig::Postgres(url);
|
||||
let database = DatabaseConnection::connect(&config).await.unwrap();
|
||||
assert_eq!(database.backend_name(), "postgresql");
|
||||
assert!(database.sqlite_pool().is_none());
|
||||
database.ping().await.unwrap();
|
||||
let pool = database.postgres_pool().unwrap();
|
||||
let tables: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tables, 0, "connecting must not create application tables");
|
||||
let mut transaction = pool.begin().await.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TEMP TABLE class_rows (id INTEGER PRIMARY KEY, value TEXT NOT NULL) ON COMMIT DROP",
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO class_rows (id, value) VALUES ($1, $2)")
|
||||
.bind(1_i64)
|
||||
.bind("你好 ' ; $1")
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: String = sqlx::query_scalar("SELECT value FROM class_rows WHERE id = $1")
|
||||
.bind(1_i64)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(value, "你好 ' ; $1");
|
||||
transaction.commit().await.unwrap();
|
||||
database.close().await;
|
||||
let reopened = DatabaseConnection::connect(&config).await.unwrap();
|
||||
reopened.ping().await.unwrap();
|
||||
let tables: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'",
|
||||
)
|
||||
.fetch_one(reopened.postgres_pool().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tables, 0);
|
||||
reopened.close().await;
|
||||
}
|
||||
|
||||
@@ -1,69 +1,104 @@
|
||||
use storage::{Storage, StorageConfig};
|
||||
use storage::{DatabaseConfig, DatabaseConnection};
|
||||
|
||||
#[test]
|
||||
fn configuration_defaults_precedence_and_errors() {
|
||||
assert!(
|
||||
matches!(StorageConfig::from_values(None, None).unwrap(), StorageConfig::SqliteFile(p) if p.to_str() == Some("data/aster.db"))
|
||||
);
|
||||
assert!(matches!(
|
||||
StorageConfig::from_values(None, Some(":memory:")).unwrap(),
|
||||
StorageConfig::SqliteMemory
|
||||
));
|
||||
assert!(matches!(
|
||||
StorageConfig::from_values(Some("postgresql://localhost/aster"), Some("")).unwrap(),
|
||||
StorageConfig::Postgres(_)
|
||||
));
|
||||
assert!(StorageConfig::from_values(Some(""), None).is_err());
|
||||
assert!(StorageConfig::from_values(Some("mysql://localhost/aster"), None).is_err());
|
||||
assert!(StorageConfig::from_values(None, Some("")).is_err());
|
||||
let config = StorageConfig::Postgres("postgres://user:secret@localhost/db".into());
|
||||
fn postgres_configuration_debug_redacts_credentials() {
|
||||
let config = DatabaseConfig::Postgres("postgres://user:secret@localhost/db".into());
|
||||
assert!(!format!("{config:?}").contains("secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_store_is_shared_by_clones_but_isolated_between_stores() {
|
||||
let store = Storage::connect(&StorageConfig::SqliteMemory)
|
||||
async fn explicit_invalid_backends_fail_without_fallback() {
|
||||
for config in [
|
||||
DatabaseConfig::Postgres(String::new()),
|
||||
DatabaseConfig::Postgres("mysql://localhost/db".into()),
|
||||
DatabaseConfig::SqliteFile("".into()),
|
||||
DatabaseConfig::SqliteFile(":memory:".into()),
|
||||
] {
|
||||
assert!(DatabaseConnection::connect(&config).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_connections_start_without_tables_and_clones_share_the_pool() {
|
||||
let database = DatabaseConnection::connect(&DatabaseConfig::SqliteMemory)
|
||||
.await
|
||||
.unwrap();
|
||||
let other = Storage::connect(&StorageConfig::SqliteMemory)
|
||||
let other = DatabaseConnection::connect(&DatabaseConfig::SqliteMemory)
|
||||
.await
|
||||
.unwrap();
|
||||
store.ping().await.unwrap();
|
||||
assert_eq!(store.get("missing").await.unwrap(), None);
|
||||
store.put("message", "你好 ' ? $1 ;").await.unwrap();
|
||||
assert_eq!(
|
||||
store.clone().get("message").await.unwrap().as_deref(),
|
||||
Some("你好 ' ? $1 ;")
|
||||
);
|
||||
assert_eq!(other.get("message").await.unwrap(), None);
|
||||
store.put("message", "updated").await.unwrap();
|
||||
assert_eq!(
|
||||
store.get("message").await.unwrap().as_deref(),
|
||||
Some("updated")
|
||||
);
|
||||
store.close().await;
|
||||
assert!(database.postgres_pool().is_none());
|
||||
let pool = database.sqlite_pool().unwrap();
|
||||
let tables: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tables, 0, "connecting must not create application tables");
|
||||
// This schema belongs to the test's simulated database-backed object.
|
||||
sqlx::query("CREATE TABLE class_rows (id INTEGER PRIMARY KEY, value TEXT NOT NULL)")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO class_rows (id, value) VALUES (?, ?)")
|
||||
.bind(1_i64)
|
||||
.bind("你好 ' ? $1 ;")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let cloned = database.clone();
|
||||
let value: String = sqlx::query_scalar("SELECT value FROM class_rows WHERE id = ?")
|
||||
.bind(1_i64)
|
||||
.fetch_one(cloned.sqlite_pool().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(value, "你好 ' ? $1 ;");
|
||||
let other_tables: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'")
|
||||
.fetch_one(other.sqlite_pool().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(other_tables, 0);
|
||||
database.close().await;
|
||||
assert!(cloned.ping().await.is_err());
|
||||
other.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_file_creates_parents_and_survives_reopen() {
|
||||
async fn explicit_sqlite_file_preserves_only_caller_created_data_across_reconnects() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("nested/data/metadata.db");
|
||||
let config = StorageConfig::SqliteFile(path.clone());
|
||||
let store = Storage::connect(&config).await.unwrap();
|
||||
store.put("project", "Aster VM").await.unwrap();
|
||||
store.close().await;
|
||||
let path = dir.path().join("nested/data/class.db");
|
||||
let config = DatabaseConfig::SqliteFile(path.clone());
|
||||
let database = DatabaseConnection::connect(&config).await.unwrap();
|
||||
let pool = database.sqlite_pool().unwrap();
|
||||
let tables: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tables, 0);
|
||||
sqlx::query("CREATE TABLE class_rows (id INTEGER PRIMARY KEY, value TEXT NOT NULL)")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO class_rows (id, value) VALUES (?, ?)")
|
||||
.bind(1_i64)
|
||||
.bind("caller-owned data")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
database.close().await;
|
||||
assert!(path.is_file());
|
||||
let reopened = Storage::connect(&config).await.unwrap();
|
||||
assert_eq!(
|
||||
reopened.get("project").await.unwrap().as_deref(),
|
||||
Some("Aster VM")
|
||||
);
|
||||
reopened.put("project", "v2").await.unwrap();
|
||||
assert_eq!(
|
||||
reopened.get("project").await.unwrap().as_deref(),
|
||||
Some("v2")
|
||||
);
|
||||
let reopened = DatabaseConnection::connect(&config).await.unwrap();
|
||||
let value: String = sqlx::query_scalar("SELECT value FROM class_rows WHERE id = ?")
|
||||
.bind(1_i64)
|
||||
.fetch_one(reopened.sqlite_pool().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(value, "caller-owned data");
|
||||
let tables: Vec<String> =
|
||||
sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.fetch_all(reopened.sqlite_pool().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tables, vec!["class_rows"]);
|
||||
reopened.close().await;
|
||||
}
|
||||
|
||||
@@ -73,7 +108,7 @@ async fn invalid_sqlite_directory_reports_error() {
|
||||
let file = dir.path().join("file");
|
||||
std::fs::write(&file, "occupied").unwrap();
|
||||
assert!(
|
||||
Storage::connect(&StorageConfig::SqliteFile(file.join("aster.db")))
|
||||
DatabaseConnection::connect(&DatabaseConfig::SqliteFile(file.join("class.db")))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
@@ -81,14 +116,12 @@ async fn invalid_sqlite_directory_reports_error() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn postgres_connection_failure_is_an_error() {
|
||||
// Hold a TCP port without serving PostgreSQL: deterministic local failure.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!(
|
||||
"postgres://aster:secret@{}/aster?sslmode=disable",
|
||||
listener.local_addr().unwrap()
|
||||
);
|
||||
let result = Storage::connect(&StorageConfig::Postgres(url)).await;
|
||||
let error = match result {
|
||||
let error = match DatabaseConnection::connect(&DatabaseConfig::Postgres(url)).await {
|
||||
Ok(_) => panic!("unreachable PostgreSQL must fail"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
@@ -8,10 +8,9 @@ services:
|
||||
- "127.0.0.1:${ASTER_PORT:-18081}:8080"
|
||||
environment:
|
||||
ASTER_LISTEN: 0.0.0.0:8080
|
||||
DATABASE_URL: "postgres://aster:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/aster?sslmode=disable"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ASTER_STATE_DIR: /data/host
|
||||
volumes:
|
||||
- /var/lib/endorphin-aster-vm/host:/data/host
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 20s
|
||||
logging:
|
||||
|
||||
@@ -6,10 +6,9 @@ services:
|
||||
- "127.0.0.1:${ASTER_PORT:-8080}:8080"
|
||||
environment:
|
||||
ASTER_LISTEN: 0.0.0.0:8080
|
||||
DATABASE_URL: "postgres://aster:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/aster?sslmode=disable"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ASTER_STATE_DIR: /data/host
|
||||
volumes:
|
||||
- aster-host:/data/host
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
@@ -28,4 +27,5 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
aster-host:
|
||||
aster-postgres:
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ services:
|
||||
- "127.0.0.1:${ASTER_PORT:-8080}:8080"
|
||||
environment:
|
||||
ASTER_LISTEN: 0.0.0.0:8080
|
||||
SQLITE_PATH: /data/aster.db
|
||||
ASTER_STATE_DIR: /data/host
|
||||
volumes:
|
||||
- aster-sqlite:/data
|
||||
restart: unless-stopped
|
||||
|
||||
+7
-4
@@ -6,13 +6,16 @@
|
||||
|
||||
| 想知道什么 | 权威说明 | 代码入口 |
|
||||
| --- | --- | --- |
|
||||
| 四个 crate 如何分层,哪些能力已实现 | [架构](architecture/README.md) | [crates](../crates/README.md) |
|
||||
| 各个 crate 如何分层,哪些能力已实现 | [架构](architecture/README.md) | [crates](../crates/README.md) |
|
||||
| EndorObject、对象头、句柄、默认操作 | [对象模型](runtime/objects.md) | [core/src](../crates/core/src/README.md) |
|
||||
| Number、String、Dict、Cell、Function、异常数据 | [基本对象](runtime/builtins.md) | [core/src](../crates/core/src/README.md) |
|
||||
| 操作码、代码验证、局部槽、调用协议 | [字节码 VM](runtime/bytecode.md) | instruction.rs / code.rs / frame.rs / eval.rs;见 [文件表](../crates/core/src/README.md) |
|
||||
| 基础信息保存在哪里、旧库如何迁移 | [宿主文件信息](operations/host-information.md) | [host](../crates/host/README.md) |
|
||||
| UTF-8 在日本地区是否适用,其他编码怎样进入 VM | [文本编码](runtime/text-encoding.md) | [core](../crates/core/README.md)、[host](../crates/host/README.md) |
|
||||
| 数据库连接给谁用,是否自动建表或保存基础信息 | [数据库特殊类](runtime/database-objects.md) | [storage](../crates/storage/README.md) |
|
||||
| VM、对象与执行帧何时创建/释放,循环如何回收 | [生命周期](runtime/lifecycle.md) | heap.rs / eval.rs;见 [文件表](../crates/core/src/README.md) |
|
||||
| LC_CTYPE、UTF-8、环境变量和执行限额 | [配置](operations/configuration.md) | [core](../crates/core/README.md)、[storage](../crates/storage/README.md)、[server](../crates/server/README.md) |
|
||||
| CLI/HTTP 怎么运行,存储保存什么 | [运行](operations/README.md) | [cli](../crates/cli/README.md)、[server](../crates/server/README.md) |
|
||||
| LC_CTYPE、UTF-8、显式数据库配置和执行限额 | [配置](operations/configuration.md) | [core](../crates/core/README.md)、[storage](../crates/storage/README.md)、[server](../crates/server/README.md) |
|
||||
| CLI/HTTP 怎么运行,数据库在哪里使用 | [运行](operations/README.md) | [cli](../crates/cli/README.md)、[server](../crates/server/README.md) |
|
||||
| Docker、PostgreSQL、ces-control、回退与数据目录 | [部署](operations/deployment.md) | [scripts](../scripts/README.md)、[工作流](../.gitea/workflows/README.md) |
|
||||
| 检查覆盖什么,结果如何确认 | [验证](operations/validation.md) | [core/tests](../crates/core/tests/README.md)、[storage/tests](../crates/storage/tests/README.md) |
|
||||
| 改代码后文档写在哪里、怎样避免失效 | [维护规则](contributing/README.md) | 各目录 README 与本索引 |
|
||||
@@ -26,7 +29,7 @@
|
||||
| [operations](operations/README.md) | 运行、配置、部署和验证 |
|
||||
| [contributing](contributing/README.md) | 开发定位方法与随代码更新文档的规则 |
|
||||
|
||||
原 outputs/bytecode-vm.md 与 endor-builtins-design.md 已核对并融合到 runtime 下的对应主题;不再在 outputs 维护独立版本。原 docs 平铺文档已迁入这些主题。过时启动模型、旧测试通过数量和机器资源快照已移除;历史内容需要时查 Git 历史,不混入当前行为说明。
|
||||
旧 outputs 的设计正文已融合到 runtime,重复入口和 outputs 目录已移除;CPython 研究与早期报告保存在本地忽略的 work/research,不纳入 Git。原 docs 平铺文档已迁入这些主题。过时启动模型、旧测试通过数量和机器资源快照已移除;历史内容需要时查 Git 历史,不混入当前行为说明。
|
||||
|
||||
## 仓库内查询
|
||||
|
||||
|
||||
+19
-20
@@ -1,44 +1,43 @@
|
||||
# 系统架构
|
||||
|
||||
Endor 的目标是云原生语言;本仓库当前实现运行时和宿主演示,尚未提供源码编译器、云调度器或多租户执行沙箱。云原生方向在当前阶段落实为内核与进程配置分离、可预测的文本/数字表示、明确的数据持久化边界。
|
||||
Endor 的目标是云原生语言;本仓库当前实现运行时和宿主演示,尚无源码编译器、云调度器或多租户执行沙箱。当前侧重核心语义独立于进程环境,以及数据库能力按对象需求显式提供。
|
||||
|
||||
## 组件与依赖方向
|
||||
|
||||
```text
|
||||
CLI ────→ Core
|
||||
└─────→ Storage
|
||||
Server ─→ Core
|
||||
└─────→ Storage
|
||||
CLI ────→ Core + Host
|
||||
Server ─→ Core + Host
|
||||
未来数据库特殊类的宿主实现 ─→ Storage
|
||||
```
|
||||
|
||||
Core 与 Storage 互不依赖。HTTP、Tokio、SQLx、数据库、网络和后续浏览器能力属于宿主,不进入零外部依赖的 core。源码包导航见 [crates](../../crates/README.md)。
|
||||
CLI、Server 依赖 Host 保存文件信息,不依赖 Storage;Core 与 Storage 也互不依赖。HTTP、Tokio、SQLx、数据库、网络和浏览器能力不进入零依赖的 core。包与文件定位见 [crates](../../crates/README.md)。
|
||||
|
||||
| 组件 | 当前职责 | 全局契约 |
|
||||
| --- | --- | --- |
|
||||
| Core | EndorObject、对象堆、Number、wordcode、执行帧、求值器 | [运行时](../runtime/README.md) |
|
||||
| Storage | 一个确定后端上的宿主元数据读写 | [运行与存储](../operations/README.md) |
|
||||
| CLI | 对象引导示例、元数据 put/get | [CLI](../../crates/cli/README.md) |
|
||||
| Server | 数据库健康检查、只读运行时信息、进程退出 | [Server](../../crates/server/README.md) |
|
||||
| Gitea/Docker | 远端验证、镜像构建、PostgreSQL 持久部署 | [部署](../operations/deployment.md) |
|
||||
| Host | 基础信息的逐键 UTF-8 JSON 文件与旧库导入 | [宿主信息](../operations/host-information.md) |
|
||||
| Storage | 按显式配置建立 SQLite/PostgreSQL 连接池,不定义应用表 | [数据库特殊类](../runtime/database-objects.md) |
|
||||
| CLI | 对象引导演示与文件信息命令 | [CLI](../../crates/cli/README.md) |
|
||||
| Server | HTTP 存活检查、只读 VM 信息及文件版本记录 | [Server](../../crates/server/README.md) |
|
||||
| Gitea/Docker | 双数据库适配验证、镜像构建、宿主与 PostgreSQL 资源部署 | [部署](../operations/deployment.md) |
|
||||
|
||||
## 当前契约
|
||||
|
||||
- Vm 从空堆启动。EndorObject 是实现层公共结构,不是语言根类;当前没有 Python type、继承或元类。
|
||||
- 堆中对象地址稳定,公开句柄校验 VM 身份和槽位代数;句柄不是跨进程 ID。
|
||||
- Vm 从空堆启动。EndorObject 是实现层公共结构,不是语言根类;没有 Python type、继承或元类。
|
||||
- 对象地址稳定,公开句柄校验 VM 身份和槽位代数;句柄不是跨进程 ID。
|
||||
- 引用计数负责通常的释放,显式全堆追踪负责不可达循环;执行帧中的对象值也作为根。
|
||||
- 函数共享不可变代码对象,语言调用使用显式帧向量;字节码参考 CPython 的职责划分,不兼容其操作码 ABI。
|
||||
- Number 优先保存不同数字的无损表示;没有统一转 f64,也没有定义数值算术、相等和哈希。
|
||||
- 异常对象保存数据;执行错误使用 VmError,没有 raise/try/except 传播。
|
||||
- 内核不读取进程环境或修改 locale。宿主负责配置解析、I/O 和信号;详见 [配置](../operations/configuration.md)。
|
||||
- Number 优先无损表示;数值算术、相等和哈希尚未定义。异常对象保存数据,尚无 raise/try/except 传播。
|
||||
- 内核不读取环境或修改 locale。宿主处理监听和信号;数据库配置由真正消费它的对象适配层显式传入,见 [配置](../operations/configuration.md)。
|
||||
|
||||
## 数据与服务边界
|
||||
|
||||
Storage 仅保存 `aster_metadata(key, value)`;连接时幂等创建当前 schema。文件与数据库中的元数据可以跨进程保留,VM 堆、执行帧、句柄不会因此恢复。宿主重启会创建新的空 VM。
|
||||
数据库用于未来某种特殊类,不保存基础信息、版本、部署记录或普通对象。Storage 连接不建应用表,不提供统一 KV;实际数据布局和查询由特殊类定义。当前只有连接适配,还没有实现该类或把它接入 Vm。
|
||||
|
||||
HTTP 宿主通过 Arc 共享只读 Vm。GET /bootstrap 只读对象模型与堆数量,GET /health 实际探测数据库。当前没有语言执行端点、对象写入端点或认证协议;不能把现有服务称为远程执行平台。
|
||||
HTTP 宿主通过 Arc 共享只读 Vm。GET /bootstrap 只读对象模型和数量,GET /health 仅报告 HTTP 可响应。没有语言执行、对象写入或认证协议,不能称为远程执行平台。重启仍创建新空 VM,没有堆快照。
|
||||
|
||||
## 后续扩展边界
|
||||
部署保留既有 PostgreSQL 服务与数据目录,应用启动不依赖数据库连接。基础信息从数据库改为 Host 文件层;发布脚本一次性只读导入旧 PostgreSQL 记录,不删除原表或卷。脚本结果未主动跟踪,不能据此认定远端已经完成迁移。
|
||||
|
||||
源码前端、函数定义/迭代器指令、可变 VM 宿主回调、异常传播、自动 GC、内存配额、执行取消、能力隔离、VM 快照及语言类型体系都需要各自的设计。现有帧数/指令预算只是部分执行限额,不构成完整沙箱。
|
||||
## 后续边界
|
||||
|
||||
主题的详细规则只在对应文档维护,本页保留组件关系与摘要。返回 [文档索引](../README.md)。
|
||||
特殊类接口与异步数据库调用、源码前端、更多指令、异常传播、自动 GC、内存配额、取消和能力隔离等均需按实际需求设计。当前帧/指令限额不等于完整沙箱。返回 [文档索引](../README.md)。
|
||||
|
||||
@@ -15,9 +15,12 @@
|
||||
| 对象头、句柄、引用协议与 GC 算法 | [objects.md](../runtime/objects.md) | core/src 文件表 |
|
||||
| Number、String、Dict、Cell、函数环境和异常数据 | [builtins.md](../runtime/builtins.md) | core/src 与 tests 文件表 |
|
||||
| 指令编码、栈效果、调用协议与执行限制 | [bytecode.md](../runtime/bytecode.md) | instruction/code/frame/eval 文件职责 |
|
||||
| 宿主基础信息、JSON 文件和旧库导入 | [host-information.md](../operations/host-information.md) | host、cli、server 与脚本 README |
|
||||
| 地区与文本编码边界 | [text-encoding.md](../runtime/text-encoding.md) | 配置主题与实际 I/O 入口 |
|
||||
| 数据库特殊类、显式连接和数据模型归属 | [database-objects.md](../runtime/database-objects.md) | storage 的源码与检查 README |
|
||||
| VM/帧/对象的创建、错误退出与销毁 | [lifecycle.md](../runtime/lifecycle.md) | 对象与字节码主题链接 |
|
||||
| 环境变量、默认值、优先级、UTF-8、locale 和配置边界 | [configuration.md](../operations/configuration.md) | 相关 crate README 链接 |
|
||||
| 运行命令、端点和宿主存储行为 | [operations/README](../operations/README.md) | cli/server/storage README |
|
||||
| 运行命令、端点和数据库连接边界 | [operations/README](../operations/README.md) | cli/server/storage README |
|
||||
| 镜像、CI、部署、回退和数据目录 | [deployment.md](../operations/deployment.md) | scripts/工作流 README |
|
||||
| 检查入口、覆盖范围、结果边界 | [validation.md](../operations/validation.md) | 各 tests README |
|
||||
| 不可变研究表、聊天导出、临时脚本或构建产物 | 工作区外输出或忽略目录 | 不作为当前项目契约、不提交研究 HTML |
|
||||
@@ -44,6 +47,6 @@ Rust 实现依赖模块可见性、trait 和 rustfmt,不照搬 CPython C 符
|
||||
|
||||
## 导出与迁移
|
||||
|
||||
本轮把 outputs 的字节码及基本对象说明核对后融合入 runtime 主题。以后只维护仓库正文;需要分享时导出副本,副本注明仓库权威位置,避免继续平行编辑。CPython 284 项研究 HTML/Markdown 仍是独立研究材料,不上传 Git。
|
||||
旧 outputs 正文已融合入 runtime,输出目录已移除。以后只维护仓库正文,不保留同文入口文件;CPython 284 项研究 HTML/Markdown 和早期报告位于本地忽略的 work/research,不上传 Git。交接入口见 [handoff.md](handoff.md)。
|
||||
|
||||
旧平铺路径 docs/architecture.md、development.md、endor-object.md、endor-builtins.md、bytecode-vm.md、validation.md 已由新的多层主题替代;当前入口以 [文档索引](../README.md) 为准。
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Aster VM 后续开发交接
|
||||
|
||||
从 [文档索引](../README.md) 查询当前实现,再按目录 README 找文件。不要把旧聊天、早期报告或未查询的 CI 状态当成当前契约。
|
||||
|
||||
## 当前项目与命名
|
||||
|
||||
仓库为 endorphin-aster-vm,归属 ProjectEndorphin。包与目录为 core、storage、host、cli、server;core 的 Rust 库名为 endor_core,命令为 cli/server。旧部署服务、数据库和路径保留既有标识以保护数据。
|
||||
|
||||
## 已实现
|
||||
|
||||
- EndorObject、稳定 Box、代际句柄、显式强引用及手动循环回收;VM 从空堆启动,没有 Python type/元类或语言根类。
|
||||
- Function、NativeFunction、Dict、Cell、异常数据、UTF-8 String 和无损表示优先的 Number。
|
||||
- 小型 wordcode、共享代码对象、操作数/控制流/栈校验、显式执行帧及 execute/call。对象返回值持有根,用后 release_value;借用读取不自动 retain。
|
||||
- Host 文件信息独立于数据库:host.version、deployment.release 与 metadata put/get 使用 UTF-8 JSON。旧 PostgreSQL 基础信息只读导入缺失文件,不删除原库,见 [文件信息](../operations/host-information.md)。
|
||||
- Storage 只提供显式 DatabaseConfig/DatabaseConnection;没有默认数据库、自动应用建表或基础信息 KV。特殊类的名称/接口尚未定义,没有将连接接入 VM。
|
||||
- CLI 引导与文件信息,Server HTTP 存活/只读 VM 信息;生产 PostgreSQL 作为未来特殊类的预置资源保留。
|
||||
|
||||
## 资料位置
|
||||
|
||||
正式说明在 docs,多层 README 负责导航;源码目录 README 描述本目录。outputs 已移除,CPython 3.14 的 284 项对象研究表、生成脚本及早期历史报告保留在本地忽略的 work/research,不上传 Git。
|
||||
|
||||
## 尚未实现
|
||||
|
||||
源码 lexer/parser、MAKE_FUNCTION 等扩展指令、数值算术/相等/哈希、完整异常传播、可变 VM/异步数据库回调、生成器、自动 GC、堆快照、内存配额、取消及不可信代码沙箱。宿主回调 panic 后的执行根恢复也未完成。不能把未来设计写成已有能力。
|
||||
|
||||
## 工作约定
|
||||
|
||||
本地不构建、不运行测试/冒烟,不下载依赖;只允许静态阅读、格式化、语法/链接检查。运行验证交给 ces-control 的 SQLite、真实 PostgreSQL 与宿主流程。按用户要求不主动跟踪 CI/部署,结果未查询就明确写未核实。
|
||||
|
||||
数据库、卷和已有资料保留;不得改其他项目或共享 Runner。研究材料与 AGENTS.md 不提交。Git 身份按本地工作约定使用专用 codex-bot,不能借用仓库所有者凭据。
|
||||
|
||||
所有本项目对话标题包含 Aster VM。文档与代码同版本维护,不重新建立 outputs 或重复正文目录。开始下一项任务前先看 git status/最新提交,读取本地 AGENTS.md;本页不代替实时状态。
|
||||
|
||||
## 下一步
|
||||
|
||||
用户下一步尚未指定。先完成交接确认,不自动启动解析器、改数据库类接口、运行测试或跟踪 CI。需要继续哪个方向时由用户决定。
|
||||
+21
-31
@@ -1,26 +1,22 @@
|
||||
# 运行与宿主存储
|
||||
# 运行宿主
|
||||
|
||||
这里说明跨 CLI、Server 和 Storage 的运行流程。当前本机禁止构建、测试、冒烟与依赖下载;以下命令供用户指定的远端环境执行。环境变量见 [配置](configuration.md),容器和生产服务见 [部署](deployment.md),检查见 [验证](validation.md)。
|
||||
CLI 和 Server 运行 VM 引导、状态与文件基础信息,不连接数据库。Storage 为未来数据库特殊类保留显式连接能力,见 [数据库对象](../runtime/database-objects.md)。以下构建和运行命令仅供用户指定的远端环境,本地不执行。
|
||||
|
||||
## 构建与 CLI
|
||||
## CLI
|
||||
|
||||
工作区要求 Rust 1.91 或兼容更高版本;构建镜像固定 rust:1.91.1-bookworm,依赖由 Cargo.lock 锁定。
|
||||
工作区要求 Rust 1.91 或兼容更高版本,镜像固定 rust:1.91.1-bookworm,依赖由 Cargo.lock 锁定。
|
||||
|
||||
```sh
|
||||
cargo build --workspace --locked
|
||||
cargo run -p cli -- bootstrap
|
||||
cargo run -p cli -- help
|
||||
```
|
||||
|
||||
bootstrap 创建两个 Record,输出 runtime=EndorObject、type_system=disabled、objects_distinct=true 和 heap_objects=2,显式释放后输出 released heap_objects=0。它不读取数据库配置,因此无效 DATABASE_URL 也不影响该命令。
|
||||
bootstrap 创建两个 Record,输出 runtime=EndorObject、type_system=disabled、objects_distinct=true 和 heap_objects=2,显式释放后输出 released heap_objects=0。默认无参数执行同一示例。
|
||||
|
||||
```sh
|
||||
cargo run -p cli -- metadata put project 'Aster VM'
|
||||
cargo run -p cli -- metadata get project
|
||||
```
|
||||
CLI 不读取数据库环境,不创建基础信息数据库表。metadata put/get 通过 host 包写 UTF-8 JSON 文件,metadata import 仅导入缺失记录;示例、配置和协议见 [宿主信息](host-information.md)。输入与退出码见 [cli/src](../../crates/cli/src/README.md)。
|
||||
|
||||
每次 metadata 命令读取宿主存储配置、连接、操作并关闭连接池;缺失键显示 (missing)。命令和退出码的源码位置见 [cli/src](../../crates/cli/src/README.md)。
|
||||
|
||||
## HTTP 宿主
|
||||
## HTTP
|
||||
|
||||
```sh
|
||||
cargo run -p server
|
||||
@@ -28,34 +24,28 @@ curl --fail http://127.0.0.1:8080/health
|
||||
curl --fail http://127.0.0.1:8080/bootstrap
|
||||
```
|
||||
|
||||
Server 创建空 VM、连接存储、初始化元数据表并写入 host.version,随后绑定监听地址。连接、初始化或绑定失败会退出,不降级换库。默认地址为 127.0.0.1:8080。
|
||||
Server 创建空 VM 并绑定监听地址,默认 127.0.0.1:8080;通过 host 包写入文件 host.version,不读取数据库 URL、不建表。
|
||||
|
||||
| 端点 | 行为 |
|
||||
| --- | --- |
|
||||
| GET /health | 实际 ping 数据库;成功返回 200,失联返回 503,含 status 和 storage 后端名 |
|
||||
| GET /bootstrap | 返回 object_model=EndorObject、type_system=false、heap_objects=0;只读,不分配对象 |
|
||||
| GET /health | 返回 200 和 status=ok;仅表明 HTTP 宿主存活,不包含数据库后端字段 |
|
||||
| GET /bootstrap | 返回 object_model=EndorObject、type_system=false、heap_objects=0;只读、不分配对象 |
|
||||
|
||||
当前未提供语言执行、对象写入或认证 API。Unix 上 Ctrl-C/SIGTERM 触发 Axum 优雅退出,等待服务结束后关闭共享 Storage;最后的 Arc 释放时销毁 Vm。没有执行中脚本的取消协议。
|
||||
没有语言执行、对象写入或认证 API。Unix 上 Ctrl-C/SIGTERM 触发 Axum 优雅退出,最后的 Arc 释放时销毁 Vm;没有数据库池需要在此处关闭,也没有执行中脚本的取消协议。
|
||||
|
||||
## 存储契约
|
||||
## 数据库能力的使用位置
|
||||
|
||||
Storage 不依赖 Core,只保存宿主元数据。连接时执行 schema.sql:aster_metadata 的 key 为非空文本主键,value 为非空文本。put 使用参数绑定并更新重复键,get 对不存在的键返回 None。当前只有幂等建表,没有版本化迁移;CREATE IF NOT EXISTS 不能升级已有列结构。
|
||||
当前应用不把数据库作为启动依赖;即使进程带有失效 DATABASE_URL 或 SQLITE_PATH,也不会因此读取它们。特殊类将来需要连接时,宿主适配实现显式传入 DatabaseConfig,详见 [配置](configuration.md)。
|
||||
|
||||
| 后端 | 当前行为 |
|
||||
| --- | --- |
|
||||
| SQLite 文件 | 默认 data/aster.db,相对当前工作目录;创建父目录和文件,开启 WAL,最多 5 个连接,busy timeout 5 秒 |
|
||||
| SQLite 内存 | SQLITE_PATH=:memory:;单连接、不做空闲回收和最大存活轮换;不同 Storage 实例隔离 |
|
||||
| PostgreSQL | DATABASE_URL 显式选择;最多 5 个连接,连接池获取超时 5 秒,启动即真实连接;失败不回退 SQLite |
|
||||
Storage 支持 SQLite 文件/内存和 PostgreSQL,不自带应用 schema 或 key/value 数据模型。旧数据库数据保留原位,发布时只读导入到文件信息;新的普通宿主不会继续写入数据库;数据持久化也不自动保存 VM 堆、句柄或帧。
|
||||
|
||||
Storage 克隆共享连接池,close 会关闭所有克隆使用的池。元数据可跨进程保存;VM 堆、句柄、全局对象和执行帧不持久化。重启服务创建新的空 VM。
|
||||
|
||||
## 出错时先查哪里
|
||||
## 定位问题
|
||||
|
||||
| 现象 | 定位 |
|
||||
| --- | --- |
|
||||
| bootstrap 正常,metadata/服务启动失败 | [存储配置](configuration.md)、[storage/src](../../crates/storage/src/README.md) |
|
||||
| /health 返回 503 | 当前数据库连接;该端点不检测语言执行能力 |
|
||||
| 容器替换后元数据丢失 | [卷与生产数据路径](deployment.md),先确认是否使用内存库 |
|
||||
| 重启后找不到原 VM 对象 | 当前未实现 VM 快照,见 [生命周期](../runtime/lifecycle.md) |
|
||||
| Server 无法绑定地址 | ASTER_LISTEN 与端口占用,见 [配置](configuration.md) |
|
||||
| /health 正常但数据库不可用 | health 不检测数据库;当前没有使用数据库的语言类 |
|
||||
| 重启后 VM 对象消失 | 当前未实现 VM 快照,见 [生命周期](../runtime/lifecycle.md) |
|
||||
| 需要连接或查询数据库 | [storage](../../crates/storage/README.md) 和 [数据库对象](../runtime/database-objects.md);先定义实际特殊类接口 |
|
||||
|
||||
返回 [文档索引](../README.md)。
|
||||
容器与预置 PostgreSQL 见 [部署](deployment.md),检查范围见 [验证](validation.md)。返回 [文档索引](../README.md)。
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
## 当前选择
|
||||
|
||||
当前先明确少量稳定边界,不引入 PyPreConfig/PyConfig 式两阶段初始化,也不新增 VM 配置文件、环境变量前缀或配置框架。Vm::new 从空堆启动;需要环境、数据库或网络的是宿主。
|
||||
当前先明确少量稳定边界,不引入 PyPreConfig/PyConfig 式两阶段初始化,也不新增 VM 配置文件、环境变量前缀或配置框架。Vm::new 从空堆启动;监听与网络由宿主处理,数据库连接仅由真正消费它的对象显式请求。
|
||||
|
||||
| 配置层 | 当前入口 | 责任 |
|
||||
| --- | --- | --- |
|
||||
| 内核 | Vm::new、显式创建对象/函数;call_with_limits/execute_with_limits | 可预测的运行时语义,不读取环境变量、修改 locale、注册信号或加载 .env |
|
||||
| 宿主 | StorageConfig::from_env、Server 启动代码 | 将环境变量转成存储配置和监听地址,再启动服务 |
|
||||
| 宿主 | Server 启动代码与 host::MetadataStore | 读取监听/文件信息目录;基础信息存 JSON 文件,不读取数据库配置 |
|
||||
| 数据库对象适配 | DatabaseConnection::connect(&DatabaseConfig) | 显式选择后端后连接,不预设应用表;特殊类尚未接入 |
|
||||
| 部署 | Compose 与 ces-control 脚本 | 端口映射、数据库密码、持久卷和镜像版本 |
|
||||
| 构建/CI | Cargo.toml、Cargo.lock、Dockerfile、工作流 | 工具链、依赖锁定与验证环境;不作为语言运行语义配置 |
|
||||
|
||||
@@ -28,12 +29,13 @@ LC_CTYPE 处理字符分类、大小写和多字节字符相关约定;小数
|
||||
- 内核没有 setlocale 调用;不会为了启动 VM 修改 LANG、LC_ALL 或 LC_CTYPE。外部库、终端、字体或下游工具自身的行为不在这项保证之内。
|
||||
- 尚无源码加载器、标识符语法、通用字节串对象或文件系统 API,因此不能宣称已完成源码编码、路径编码和全部 I/O 策略。
|
||||
|
||||
## 已有宿主环境变量
|
||||
日文与外部 Shift_JIS/CP932 的处理见 [文本编码](../runtime/text-encoding.md);内部 UTF-8 不因地区切换。
|
||||
|
||||
## 已有进程与部署变量
|
||||
|
||||
| 名称 | 使用位置与默认值 | 错误和优先级 |
|
||||
| --- | --- | --- |
|
||||
| DATABASE_URL | StorageConfig;未设置时才选择 SQLite | 仅 postgres:// 或 postgresql://;显式空值、错误协议和非 Unicode 值报错;连接失败不降级 |
|
||||
| SQLITE_PATH | 无 DATABASE_URL 时使用;默认 data/aster.db | 空值和非 Unicode 值报错;:memory: 为临时库;设置 DATABASE_URL 后忽略此项 |
|
||||
| ASTER_STATE_DIR | Host 文件信息目录;默认 data/host,容器 /data/host | 使用 var_os 保留操作系统路径;显式空值报错,不影响数据库选择 |
|
||||
| ASTER_LISTEN | Server;默认 127.0.0.1:8080 | 当前 var(...).unwrap_or_else:缺失或非 Unicode 都用默认值;有效 Unicode 的错误地址在绑定时失败 |
|
||||
| ASTER_PORT | Compose 宿主端口映射 | 独立方案默认 8080,ces-control 默认 18081;不是 Vm 配置 |
|
||||
| POSTGRES_PASSWORD | PostgreSQL Compose/生产环境文件 | Compose 必填;示例使用随机十六进制以避免 URL 转义歧义;不写入仓库 |
|
||||
@@ -41,9 +43,13 @@ LC_CTYPE 处理字符分类、大小写和多字节字符相关约定;小数
|
||||
| ASTER_RELEASE_SHA | CI 与部署脚本 | 必须为 40 位小写十六进制提交 SHA;用于镜像和版本目录 |
|
||||
| ASTER_TEST_POSTGRES_URL | 指定环境显式运行 PostgreSQL 集成测试 | 专用测试库 URL;不是生产数据库配置 |
|
||||
|
||||
程序本身不自动加载 .env,Cargo 运行时须显式导出变量。Compose 会按其规则读取 .env;显式 --env-file 的生产文件由部署脚本管理。模板见 [.env.example](../../.env.example),执行链见 [部署](deployment.md)。
|
||||
文件信息命令及 Server 通过 host 包读取 ASTER_STATE_DIR,Server 还读取 ASTER_LISTEN;不会把 DATABASE_URL/SQLITE_PATH 作为基础信息存储配置。程序不自动加载 .env,Cargo 运行时须显式导出监听变量。Compose 会按其规则读取 .env;显式 --env-file 的生产文件由部署脚本管理。模板见 [.env.example](../../.env.example),执行链见 [部署](deployment.md)。
|
||||
|
||||
数据库 URL 可能包含密码;当前 StorageConfig 的 Debug 脱敏,公开数据库错误不回显 URL。底层错误 source 仍保留给宿主,不应不加处理地作为公开日志输出。
|
||||
## 显式数据库配置
|
||||
|
||||
DatabaseConfig 没有默认值和 from_env,仅允许显式构造:Postgres(url)、SqliteFile(path)、SqliteMemory。没有默认 data/aster.db,也没有应用启动时的全局连接。connect 不执行应用 DDL,不保存基础信息;基础信息另由 [host 文件层](host-information.md) 保存;未来的特殊类负责消费连接和定义数据,见 [数据库对象](../runtime/database-objects.md)。
|
||||
|
||||
SQLite 文件显式连接时创建父目录与文件、启用 WAL,最多 5 个连接、busy timeout 5 秒;内存库单连接、不因池轮换销毁。PostgreSQL 最多 5 个连接、获取超时 5 秒,错误不降级。数据库 URL 的 Debug 和公开错误脱敏;底层错误 source 保留,不应不加处理地公开输出。
|
||||
|
||||
## 已有执行限额
|
||||
|
||||
@@ -58,7 +64,7 @@ call_with_limits/execute_with_limits 使用自定义值;嵌套语言调用
|
||||
|
||||
## 保留的入口缺口与以后再做的内容
|
||||
|
||||
CLI 当前使用 std::env::args,遇到非 Unicode 命令行参数可能 panic,并没有统一转换成友好的配置错误。[Rust args 文档](https://doc.rust-lang.org/std/env/fn.args.html#panics)。Server 的非 Unicode ASTER_LISTEN 也尚未像存储变量一样严格报错;宿主输入错误接口尚未统一。
|
||||
CLI 当前使用 std::env::args,遇到非 Unicode 命令行参数可能 panic,并没有统一转换成友好的配置错误。[Rust args 文档](https://doc.rust-lang.org/std/env/fn.args.html#panics)。Server 的非 Unicode ASTER_LISTEN 也尚未严格报错;宿主输入错误接口尚未统一。
|
||||
|
||||
以后增加宿主输入时,应明确使用 OsString/PathBuf 保留系统路径,或显式拒绝不符合文本协议的输入;不要通过修改整个进程 locale 让隐式解码暂时成功。未来源码文本默认 UTF-8、协议数字固定格式是设计方向,只有加载器实现后才能写成已实现行为。
|
||||
|
||||
|
||||
@@ -1,56 +1,57 @@
|
||||
# Docker 与 ces-control 部署
|
||||
|
||||
这里描述仓库当前工作流和脚本行为,不代表正在运行的服务器状态。正式持久部署选择 PostgreSQL,SQLite Compose 保留为独立运行选项。配置字段见 [配置](configuration.md),CI 检查细节见 [验证](validation.md)。
|
||||
这里描述仓库当前工作流和脚本行为,不代表正在运行的服务器状态。生产保留预置 PostgreSQL 与既有数据。当前 VM 宿主不消费数据库,数据库连接仅为未来特殊类保留。配置字段见 [配置](configuration.md),CI 检查细节见 [验证](validation.md)。
|
||||
|
||||
## 镜像与独立运行
|
||||
|
||||
Dockerfile 的 source 阶段复制 Cargo 工作区及脚本;ci 阶段安装远端检查工具,build 阶段构建 server 和 cli,runtime 阶段使用 Debian slim、UID 10001、/data 工作目录及 HTTP 健康检查。
|
||||
|
||||
指定环境的 SQLite 方案:
|
||||
指定环境的独立宿主方案(沿用原 SQLite 卷,基础信息写入其中的 host 目录):
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
docker compose up --build -d
|
||||
curl --fail http://127.0.0.1:8080/health
|
||||
docker compose exec aster cli metadata put project 'Aster VM'
|
||||
docker compose exec aster cli bootstrap
|
||||
```
|
||||
|
||||
默认 docker-compose.yml 只向应用提供 SQLITE_PATH,使用 aster-sqlite 命名卷;不透传 DATABASE_URL。down 保留数据,down -v 会删除卷。
|
||||
默认 docker-compose.yml 提供监听地址和 ASTER_STATE_DIR,保留 aster-sqlite 卷挂载;只写基础信息 JSON,不自动打开卷中的数据库。down 保留数据,down -v 会删除卷。
|
||||
|
||||
指定环境的 PostgreSQL 独立替代方案:先在 .env 中将 POSTGRES_PASSWORD 改为随机十六进制字符串,例如 openssl rand -hex 24,再运行:
|
||||
指定环境中同时预置 PostgreSQL 的独立方案:先在 .env 中将 POSTGRES_PASSWORD 改为随机十六进制字符串,例如 openssl rand -hex 24,再运行:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.postgres.yml up --build -d
|
||||
docker compose -f docker-compose.postgres.yml exec aster cli metadata get project
|
||||
docker compose -f docker-compose.postgres.yml exec aster cli bootstrap
|
||||
```
|
||||
|
||||
仅使用该 Compose 文件,不与默认 SQLite 文件叠加。此方案使用 aster-postgres 命名卷、PostgreSQL 17;数据库不发布宿主端口。两个独立方案不要同时占用相同应用端口。内部示例连接禁用 TLS,跨不可信网络使用 PostgreSQL TLS 验证。
|
||||
仅使用该 Compose 文件,不与默认文件叠加。此方案使用 aster-postgres 命名卷、PostgreSQL 17,以及独立的 aster-host 文件信息卷;数据库不发布宿主端口。两个独立方案不要同时占用相同应用端口。未来特殊类跨不可信网络连接数据库时,需要明确配置 PostgreSQL TLS 验证。
|
||||
|
||||
## CI 到正式发布
|
||||
|
||||
[工作流](../../.gitea/workflows/README.md) 响应 main push 和 workflow_dispatch,在 ces-control 上串行运行:
|
||||
|
||||
1. 检出代码,调用 ci-ces-control.sh;在 Docker 中检查 workspace、SQLite/HTTP 冒烟及专用真实 PostgreSQL。
|
||||
1. 检出代码,调用 ci-ces-control.sh;在 Docker 中检查 workspace(含 SQLite 适配)、独立 HTTP 冒烟及专用真实 PostgreSQL。
|
||||
2. 检查成功后构建 endorphin-aster-vm:<sha> 生产镜像。
|
||||
3. main 上调用 deploy-ces-control.sh 更新 PostgreSQL 宿主。
|
||||
3. main 上调用 deploy-ces-control.sh 更新 VM 宿主并保留预置 PostgreSQL。
|
||||
|
||||
临时检查容器与网络退出时清理,Cargo 下载与构建缓存保留在专属远端卷。生产镜像、已有发布目录与数据库数据保留;没有自动 image prune 或数据库卷清理。工作流不改动其他项目或共享 Runner 配置。
|
||||
|
||||
## 生产目录与数据
|
||||
|
||||
Cargo 包名为 core/storage/cli/server,core 的 Rust 库名为 endor_core,可执行文件为 cli/server。Compose 应用服务名 aster、数据库名、卷和生产路径是已有部署资源标识,运行命令为该服务中的 cli;包改名无需迁移这些数据或重建数据库。
|
||||
Cargo 包名为 core/storage/host/cli/server,core 的 Rust 库名为 endor_core,可执行文件为 cli/server。Compose 应用服务名 aster、数据库名、卷和生产路径是已有部署资源标识,运行命令为该服务中的 cli。应用不接收 DATABASE_URL、不依赖数据库健康才能启动;Compose 的整体 --wait 仍检查所预置 PostgreSQL 的就绪状态。
|
||||
|
||||
| 路径 | 用途 |
|
||||
| --- | --- |
|
||||
| /opt/endorphin-aster-vm/releases/<sha>/ | 固定版本的 compose.yml 与 release.env |
|
||||
| /opt/endorphin-aster-vm/current | 最近通过部署检查的版本符号链接 |
|
||||
| /etc/endorphin-aster-vm/production.env | 首次生成的数据库密码和默认端口,权限 0600 |
|
||||
| /var/lib/endorphin-aster-vm/host | 文件基础信息,挂载到 /data/host,目录由部署准备并交给 UID 10001 |
|
||||
| /var/lib/endorphin-aster-vm/postgres | 独立的 PostgreSQL 持久数据目录 |
|
||||
| /var/lock/endorphin-aster-vm-deploy.lock | 避免并行切换生产版本的文件锁 |
|
||||
|
||||
生产 Compose 固定项目名 endorphin-aster-vm,默认应用入口仅绑定 127.0.0.1:18081;数据库不发布端口,两个服务使用 unless-stopped 自动重启和日志轮换。端口绑定回环地址不等于已实现应用认证。
|
||||
|
||||
部署脚本只接受已构建镜像。启动容器、健康检查、真实元数据 put/get 及 PostgreSQL 后端确认都成功后才切换 current。失败时保留数据并尝试恢复上一个健康版本;首次部署没有旧版本可恢复。脚本内容导航见 [scripts](../../scripts/README.md)。
|
||||
部署脚本只接受已构建镜像。容器就绪、HTTP status=ok 和 CLI 对象引导/释放检查都成功后才切换 current;发布记录改写到 deployment.release.json;首次发布一次性只读导入旧基础信息,再保留完成标记,不删除数据库原记录。详情见 [宿主信息](host-information.md)。镜像 SHA、release.env 与 current 链接负责标识版本。失败时保留数据并尝试恢复上一个健康版本;首次部署没有旧版本可恢复。脚本内容导航见 [scripts](../../scripts/README.md)。
|
||||
|
||||
## 查看、停止与恢复
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 宿主基础信息:文件保存
|
||||
|
||||
基础信息不使用数据库。host 包保存逐键 UTF-8 JSON,CLI 提供读写,Server 启动写 host.version,部署检查通过后写 deployment.release。Storage 的数据库连接仅给未来特殊类使用。
|
||||
|
||||
## 文件与配置
|
||||
|
||||
ASTER_STATE_DIR 使用操作系统路径,默认 data/host;显式空值报错。读取一个不存在的键返回 None,不创建目录;写入时创建目录。容器为 /data/host;生产挂载到 /var/lib/endorphin-aster-vm/host,独立 PostgreSQL Compose 使用 aster-host 卷。
|
||||
|
||||
例如 host.version 对应 host.version.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"format_version": 1,
|
||||
"key": "host.version",
|
||||
"value": "0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
控制键以 ASCII 字母或数字开头,最长 128 字节,其余只能是字母、数字、点、下划线或连字符。它是信息命名协议,不是文本编码限制;值支持日文、中文、emoji 等 Unicode 文本。用户文本不应被当作文件路径或控制键。
|
||||
|
||||
文件必须是有效 UTF-8 JSON,版本和 key 必须匹配请求。损坏记录返回错误,不清空、不用默认值偷偷替换。文件不属于 VM 堆快照,也不存数据库凭据。
|
||||
|
||||
## 读写与生命周期
|
||||
|
||||
CLI 的 metadata put/get 通过 host::MetadataStore 访问文件,不连接 SQL:
|
||||
|
||||
```sh
|
||||
cli metadata put project.note '日本語・中文・😀'
|
||||
cli metadata get project.note
|
||||
cli metadata get host.version
|
||||
```
|
||||
|
||||
MetadataStore::clone 只共享目录路径,没有缓存;每次读取重新打开文件。写入先建立同目录唯一临时文件,写完并 sync_all,再通过 rename 发布;Unix 新文件权限 0600。不同键分别写文件,不会因整张 map 的读改写丢失其他键;同键并发是最后一次成功发布生效。
|
||||
|
||||
没有跨键事务、分布式锁或共享网络文件系统一致性保证;文件同步与原子发布也不等于数据库的完整断电持久性协议。异常进程终止可能留下隐藏临时文件,正常失败会清理自己的临时文件。
|
||||
|
||||
## 旧基础信息的一次性迁移
|
||||
|
||||
ces-control 发布时,在 host/.legacy-imported 标记缺失的情况下,只读检查 PostgreSQL 原 public.aster_metadata 表。存在时,使用 UTF-8 客户端导出 key/value JSON,再经 cli metadata import 的标准输入导入。该命令只创建缺失文件,不覆盖 Server 刚写入的版本或其他已有文件;临时导出文件以私有权限创建并清理,不打印数据内容。
|
||||
|
||||
只导入缺失项通过同目录临时文件的原子 hard_link 实现,因此运行目录应使用支持该操作的本地文件系统。导入可重复执行;只有导入成功或源表不存在才写完成标记。损坏记录、不支持的控制键或 I/O 错误会停止发布,保留原库数据,不能当作迁移成功。
|
||||
|
||||
本迁移针对已配置的 PostgreSQL 生产路径,不自动扫描或删除其他 SQLite 文件。原数据库表与数据不会被修改/删除。此处说明发布脚本行为,不代表远端已执行成功;按约定不主动跟踪 CI 或服务器。
|
||||
|
||||
部署记录仍以版本镜像、release.env 与 current 链接为发布来源;deployment.release.json 是文件信息记录,不是数据库迁移表。脚本见 [scripts](../../scripts/README.md)。
|
||||
|
||||
返回 [运行指南](README.md) 或 [文档索引](../README.md)。
|
||||
@@ -18,7 +18,7 @@ python3 scripts/smoke.py
|
||||
|
||||
check.sh 执行 cargo fmt --all --check、cargo test --workspace --locked 和 cargo clippy --workspace --all-targets --locked -- -D warnings。workspace 测试也包含 rustdoc 示例;显式忽略的 PostgreSQL 测试需要单独执行。
|
||||
|
||||
ci-ces-control.sh 负责准备专用临时 PostgreSQL 和测试 URL;成功路径使用真实数据库,不用模拟 socket 代替。测试库与生产数据不共享挂载。没有 ASTER_TEST_POSTGRES_URL 时,显式执行 PostgreSQL 测试会失败,不标记为通过。
|
||||
ci-ces-control.sh 负责准备全新专用临时 PostgreSQL 和测试 URL;成功路径使用真实数据库,不用模拟 socket 代替。测试库与生产数据不共享挂载。没有 ASTER_TEST_POSTGRES_URL 时,显式执行 PostgreSQL 测试会失败,不标记为通过。
|
||||
|
||||
## 覆盖范围与文件位置
|
||||
|
||||
@@ -29,9 +29,9 @@ ci-ces-control.sh 负责准备专用临时 PostgreSQL 和测试 URL;成功路
|
||||
| 基本对象 | Number 表示、字符串、Dict/Cell 引用与更新、函数环境、异常数据 | 同目录 builtins.rs |
|
||||
| 字节码 | 编解码、非法指令/控制流、跳转、调用、闭包、结果存活、失败清理与限额 | 同目录 execution.rs |
|
||||
| 公开示例 | 对象操作、手写指令执行与释放 | [core/src](../../crates/core/src/README.md) 的 lib.rs rustdoc |
|
||||
| 存储 | 配置优先级、内存隔离/共享、文件重连、失败不降级、真实 PostgreSQL | [storage/tests](../../crates/storage/tests/README.md) |
|
||||
| HTTP 状态 | 数据库关闭后 health 返回 unavailable | [server/src](../../crates/server/src/README.md) 的单元测试 |
|
||||
| 端到端宿主 | CLI 跨进程元数据、独立 bootstrap、错误配置、HTTP 与 SIGTERM | [scripts](../../scripts/README.md) 的 smoke.py |
|
||||
| 宿主文件信息 | UTF-8 值/路径、持久读取、同键替换、并发、坏记录、幂等迁移与保留新值 | [host/tests](../../crates/host/tests/README.md) |
|
||||
| 数据库连接 | 不自动建表、显式配置、隔离/共享、调用方数据重连、失败不降级、真实 PostgreSQL | [storage/tests](../../crates/storage/tests/README.md) |
|
||||
| 端到端宿主 | 文件 metadata 跨进程读写、日文值、Server 文件版本;忽略失效数据库变量且不创建 SQLite 文件、HTTP 与 SIGTERM | [scripts](../../scripts/README.md) 的 smoke.py |
|
||||
|
||||
测试源码说明验证意图,不能证明已通过;现有测试也不代表完整 Python 兼容、panic 恢复、云沙箱或 VM 快照已经实现。
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
| [对象模型](objects.md) | EndorObject/ObjectHeader、代际句柄、操作协议、托管引用与循环回收 |
|
||||
| [基本对象与 Number](builtins.md) | 函数、原生函数、字典、Cell、异常数据、字符串与无损数字 |
|
||||
| [字节码 VM](bytecode.md) | 指令编码、代码校验、局部槽、执行帧、调用/返回与边界 |
|
||||
| [文本编码](text-encoding.md) | UTF-8、日文、外部 Shift_JIS/CP932 与宿主解码边界 |
|
||||
| [数据库特殊类](database-objects.md) | 显式连接、调用方数据模型、未来类接入与资源生命周期 |
|
||||
| [生命周期](lifecycle.md) | 宿主持有 Vm、帧执行根、结果所有权、错误退出和整体销毁 |
|
||||
|
||||
文本/环境约束和执行限额集中在 [配置](../operations/configuration.md);四个组件之间的关系见 [系统架构](../architecture/README.md)。返回 [文档索引](../README.md)。
|
||||
文本/环境约束和执行限额集中在 [配置](../operations/configuration.md);组件之间的关系见 [系统架构](../architecture/README.md)。返回 [文档索引](../README.md)。
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# 数据库特殊类与连接边界
|
||||
|
||||
数据库连接用于某类明确需要数据库的特殊对象,不用于 VM 版本、部署版本、普通对象信息或统一 key/value 元数据。当前尚未定义该特殊类的名字、载荷布局或语言接口,本页说明已保留的连接能力及接入边界。
|
||||
|
||||
## 当前分层
|
||||
|
||||
- Core 负责对象、堆和执行,不依赖 SQLx 或数据库。
|
||||
- Storage 仅暴露 DatabaseConfig、DatabaseConnection 和 DatabaseError。
|
||||
- CLI/Server 不依赖 Storage,启动、bootstrap 和 health 不访问数据库;基础信息由独立 host 文件层处理。
|
||||
- 特殊类以后由宿主实现按需取得数据库连接;尚未自动挂入 Vm 或对象工厂。
|
||||
|
||||
## 显式建立连接
|
||||
|
||||
调用方先构造 DatabaseConfig::Postgres(url)、SqliteFile(path) 或 SqliteMemory,再 await DatabaseConnection::connect。没有 from_env、默认 SQLite 路径或应用启动时的全局数据库。
|
||||
|
||||
connect 会建立真实连接,并可在显式请求 SQLite 文件时创建父目录和文件,但不创建任何应用表。SQLite WAL、连接协商等驱动自身操作不等于应用 schema 初始化。不会创建基础信息表,也不会写入运行或发布信息。
|
||||
|
||||
sqlite_pool/postgres_pool 返回匹配后端的 SQLx 池借用,另一后端返回 None。特殊类以后可在自己的适配实现里执行参数化查询、事务及由该类定义的数据结构;本层不预设通用 KV、ORM、迁移或序列化协议。现有 SQLx API 见 [Pool 文档](https://docs.rs/sqlx/0.8.6/sqlx/struct.Pool.html)。
|
||||
|
||||
## 生命周期
|
||||
|
||||
DatabaseConnection::clone 共享连接池;close().await 显式关闭所有克隆使用的池,并等待已借出连接归还。取出的 SQLx 池还能被调用方克隆,因此单个包装值销毁不保证所有使用者立即结束。
|
||||
|
||||
未来将连接放进原生载荷时,必须另行定义异步关闭、共享所有权和错误传播;当前 VM 的同步 Drop/Native 回调不等于已经完成异步数据库类。连接跨进程不能复用,也不代表 VM 快照。
|
||||
|
||||
## 既有数据和部署
|
||||
|
||||
原先的基础信息存储已从代码中移除。已有数据库表、卷、密码和服务器数据未执行删除或迁移;它们是否存在、有哪些记录不由本次代码修改推断。
|
||||
|
||||
生产继续保留预置 PostgreSQL 服务与既有数据目录,等待实际类的消费接口。应用容器不注入数据库连接变量,当前不消费该服务。部署版本通过镜像标签、release.env 和 current 链接识别,基础信息写入文件;发布时仅为旧数据做一次只读导入,不继续写数据库。详见 [宿主信息](../operations/host-information.md)。
|
||||
|
||||
## 验证
|
||||
|
||||
SQLite 检查先确认新连接没有应用表,再由测试自己创建 class_rows 验证查询和重连。真实 PostgreSQL 使用全新临时测试库,检查连接不建表,并在事务中创建提交后自动删除的临时表。这些是适配层测试,不代表特殊类已实现。
|
||||
|
||||
文件定位见 [storage](../../crates/storage/README.md),运行与生产资源见 [部署](../operations/deployment.md)。返回 [运行时索引](README.md)。
|
||||
@@ -30,6 +30,6 @@ Rust panic 尚无专门的捕获恢复或执行根 RAII 清理机制。宿主捕
|
||||
|
||||
Vm 没有独立后台线程、暂停状态或显式 shutdown 状态机。Rust 所有权销毁它所拥有的堆和剩余载荷,包括没有先做循环回收的对象;原生共享资源继续遵循各自的 Rust 所有权。外部 ObjectRef 不保活 Vm。
|
||||
|
||||
Server 的 Tokio 运行时、监听器、信号处理和 Storage 由宿主管理;优雅退出见 [运行](../operations/README.md)。数据库持久化只保留宿主元数据,不能延长或恢复 VM 对象生命周期。
|
||||
Server 的 Tokio 运行时、监听器、信号处理和 Host 文件信息由宿主管理;优雅退出见 [运行](../operations/README.md)。当前宿主不持有数据库连接。数据库能力只为未来的特殊类按需提供,异步连接关闭与原生载荷生命周期仍需单独接入,见 [数据库特殊类](database-objects.md)。数据库不自动保存或恢复 VM 对象。
|
||||
|
||||
源码定位:[core/src](../../crates/core/src/README.md) 中的 vm.rs、heap.rs、frame.rs、eval.rs。返回 [运行时索引](README.md)。
|
||||
|
||||
@@ -100,6 +100,6 @@ compare 的反向规则是 Lt 对应 Gt、Le 对应 Ge,Eq/Ne 保持不变。
|
||||
|
||||
已实现:统一对象结构、稳定地址、代际句柄、身份、显式引用计数、托管属性、原生操作覆盖、比较与哈希、原生调用、资源清理和显式循环回收。
|
||||
|
||||
尚未实现:Python type 系统、语言根类、继承和描述符、源码解析与编译器、可变 VM 调用上下文、自动/分代/增量 GC、语言终结器/复活、完整资源限额和 VM 快照;执行器已有帧数和指令数限额。数据库仍然只保存宿主元数据。
|
||||
尚未实现:Python type 系统、语言根类、继承和描述符、源码解析与编译器、可变 VM 调用上下文、自动/分代/增量 GC、语言终结器/复活、完整资源限额和 VM 快照;执行器已有帧数和指令数限额。数据库仅为未来特殊类提供按需连接,普通对象或宿主基础信息不自动持久化,详见 [数据库特殊类](database-objects.md)。
|
||||
|
||||
验证范围与执行约定见 [验证](../operations/validation.md)。
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 文本编码与地区边界
|
||||
|
||||
## UTF-8 不限定地区
|
||||
|
||||
Value::Text 与 EndorStringObject 使用 Rust String,始终是有效 UTF-8。日文假名、汉字、中文和 emoji 都可以保存在同一字符串中;运行在日本地区并不要求 VM 改成另一套内部编码。[Rust String](https://doc.rust-lang.org/std/string/struct.String.html#utf-8)。
|
||||
|
||||
地区、编码、字体和语言规则是不同层面:LC_CTYPE 不决定已有 Rust String 里字符的含义;字体缺失会影响显示,不会把 UTF-8 内容变成另一种文本。
|
||||
|
||||
## 不同编码的外部数据
|
||||
|
||||
日本的旧系统或文件可能使用 Shift_JIS/Windows CP932,也可能使用 UTF-8;不能仅根据所在地推断文件编码。Windows 的代码页表中列有日文 932,见 [Microsoft 代码页说明](https://learn.microsoft.com/en-us/windows/win32/intl/code-page-identifiers)。
|
||||
|
||||
| 输入或输出 | 边界处理 |
|
||||
| --- | --- |
|
||||
| 已是 Rust String/明确 UTF-8 JSON | 保持 UTF-8,可直接传入当前 VM |
|
||||
| Shift_JIS/CP932 等外部字节 | 由宿主 I/O 适配器按明确编码解码成 Unicode,再交给 VM |
|
||||
| 非法 UTF-8 却声明为 UTF-8 | 使用严格解码返回错误,不默默替换字符 |
|
||||
| 必须与旧系统交换文本 | 宿主显式选择目标编码;遇到目标编码不能表示的字符时明确报错或由业务另行选择策略 |
|
||||
| 操作系统路径 | 使用 PathBuf/OsString 保留系统表示,不先强转成 VM 文本 |
|
||||
|
||||
String::from_utf8 验证字节;它不会自动识别 Shift_JIS,也不会自动转换编码。某些错误编码字节可能恰好仍是合法 UTF-8,因此“能通过 UTF-8 校验”也不能代替输入协议。[from_utf8 文档](https://doc.rust-lang.org/std/string/struct.String.html#method.from_utf8)。
|
||||
|
||||
当前尚无通用文件/网络文本加载器、Shift_JIS 解码器或源码 parser;以上非 UTF-8 转换是未来宿主入口应遵守的边界,不是已经提供的内置转码能力。无需修改全局 locale 来处理单个文件。
|
||||
|
||||
## 其他日文文本差异
|
||||
|
||||
当前按 Unicode 标量值访问字符,不按人眼字形簇。预组合的“ガ”和“カ”加组合浊点可显示相似,但标量数量和序列不同;当前没有自动规范化或全角/半角转换。字符串和字典按当前内容语义处理,不为了所在地悄悄改写数据。源码标识符规则尚未定义。
|
||||
|
||||
宿主基础信息以 UTF-8 JSON 写入;控制键的 ASCII 命名限制属于文件信息协议,值可保存日文,详见 [宿主信息](../operations/host-information.md)。配置与 locale 见 [配置](../operations/configuration.md),返回 [运行时索引](README.md)。
|
||||
+2
-2
@@ -5,9 +5,9 @@
|
||||
| 文件 | 入口与副作用 |
|
||||
| --- | --- |
|
||||
| [check.sh](check.sh) | fmt 检查、workspace 测试、严格 Clippy;会使用 Cargo 依赖和构建产物 |
|
||||
| [smoke.py](smoke.py) | 标准库 Python 脚本;构建后用 Cargo metadata 定位 cli/server 的 debug 二进制,支持 CARGO_TARGET_DIR;临时 SQLite 数据、HTTP 探测和 SIGTERM 退出,不改生产配置 |
|
||||
| [smoke.py](smoke.py) | 标准库 Python 脚本;构建后用 Cargo metadata 定位 cli/server 的 debug 二进制,支持 CARGO_TARGET_DIR;文件 metadata 跨进程与日文值;失效数据库变量下独立启动、不创建 SQLite 文件、HTTP 与 SIGTERM,不改生产配置 |
|
||||
| [ci-ces-control.sh](ci-ces-control.sh) | 需要 ASTER_RELEASE_SHA;构建 ci 镜像、创建临时网络/PostgreSQL/检查容器,运行全部检查;退出清理临时容器和网络,保留专属远端 Cargo 缓存卷 |
|
||||
| [deploy-ces-control.sh](deploy-ces-control.sh) | ces-control root 执行;校验已构建镜像、锁定发布、保留密码和数据、验证新服务后切换 current,失败尝试恢复旧版;保留镜像;应用服务 aster 内调用 cli |
|
||||
| [deploy-ces-control.sh](deploy-ces-control.sh) | ces-control root 执行;校验已构建镜像、锁定发布、保留密码和数据、验证 HTTP 与无数据库 CLI 引导后切换 current,失败尝试恢复旧版;保留镜像;应用服务 aster 内调用 cli bootstrap,基础信息改写文件;一次性只读导入旧基础信息,不覆盖新文件或删除原库 |
|
||||
|
||||
CI/部署使用的镜像与路径仅属于本项目,禁止改动其他项目和共享 Runner。smoke.py 临时选择端口后再启动宿主,存在小的端口竞争窗口,指定环境遇到冲突可重试。
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ umask 077
|
||||
root=/opt/endorphin-aster-vm
|
||||
config=/etc/endorphin-aster-vm/production.env
|
||||
release="$root/releases/$release_sha"
|
||||
mkdir -p "$release" /etc/endorphin-aster-vm /var/lib/endorphin-aster-vm/postgres
|
||||
mkdir -p "$release" /etc/endorphin-aster-vm /var/lib/endorphin-aster-vm/postgres /var/lib/endorphin-aster-vm/host
|
||||
chown 10001:10001 /var/lib/endorphin-aster-vm/host
|
||||
chmod 700 /var/lib/endorphin-aster-vm/host
|
||||
exec 9>/var/lock/endorphin-aster-vm-deploy.lock
|
||||
flock 9
|
||||
|
||||
@@ -26,6 +28,11 @@ chmod 600 "$config"
|
||||
cp docker-compose.ces-control.yml "$release/compose.yml"
|
||||
printf 'ASTER_IMAGE=%s\n' "$image" > "$release/release.env"
|
||||
previous=$(readlink -f "$root/current" 2>/dev/null || true)
|
||||
legacy_export=
|
||||
cleanup_export() {
|
||||
if [ -n "$legacy_export" ]; then rm -f "$legacy_export"; fi
|
||||
}
|
||||
trap cleanup_export EXIT
|
||||
|
||||
compose_at() {
|
||||
location=$1
|
||||
@@ -45,6 +52,54 @@ if ! compose_at "$release" up -d --no-build --wait --wait-timeout 180; then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if ! health=$(compose_at "$release" exec -T aster curl --fail --silent http://127.0.0.1:8080/health); then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$health" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d == {"status":"ok"}' || {
|
||||
rollback
|
||||
exit 1
|
||||
}
|
||||
if ! bootstrap=$(compose_at "$release" exec -T aster cli bootstrap); then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$bootstrap" | python3 -c 'import sys; s=sys.stdin.read(); assert "runtime=EndorObject type_system=disabled" in s; assert "released heap_objects=0" in s' || {
|
||||
rollback
|
||||
exit 1
|
||||
}
|
||||
# Import legacy basic information once, without changing database records.
|
||||
# Files are authoritative; cli import only creates records that are missing.
|
||||
if [ ! -f /var/lib/endorphin-aster-vm/host/.legacy-imported ]; then
|
||||
if ! has_legacy=$(compose_at "$release" exec -T postgres env PGCLIENTENCODING=UTF8 psql -X -U aster -d aster -A -t -v ON_ERROR_STOP=1 \
|
||||
-c "SELECT to_regclass('public.aster_metadata') IS NOT NULL"); then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if [ "$has_legacy" = t ]; then
|
||||
legacy_export=$(mktemp)
|
||||
if ! compose_at "$release" exec -T postgres env PGCLIENTENCODING=UTF8 psql -X -U aster -d aster -A -t -v ON_ERROR_STOP=1 \
|
||||
-c "SELECT COALESCE(json_agg(json_build_object('key', key, 'value', value)), '[]'::json) FROM public.aster_metadata" > "$legacy_export"; then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if ! compose_at "$release" exec -T aster cli metadata import < "$legacy_export"; then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$legacy_export"
|
||||
legacy_export=
|
||||
elif [ "$has_legacy" != f ]; then
|
||||
echo "Cannot determine legacy information schema" >&2
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if ! touch /var/lib/endorphin-aster-vm/host/.legacy-imported; then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# File-backed host information; no SQL table or database write.
|
||||
if ! compose_at "$release" exec -T aster cli metadata put deployment.release "$release_sha"; then
|
||||
rollback
|
||||
exit 1
|
||||
@@ -57,16 +112,8 @@ if [ "$actual" != "$release_sha" ]; then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if ! health=$(compose_at "$release" exec -T aster curl --fail --silent http://127.0.0.1:8080/health); then
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$health" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d == {"status":"ok", "storage":"postgresql"}' || {
|
||||
rollback
|
||||
exit 1
|
||||
}
|
||||
ln -sfn "$release" "$root/current.next"
|
||||
mv -Tf "$root/current.next" "$root/current"
|
||||
compose_at "$release" ps
|
||||
echo "Deployed release=$release_sha backend=postgresql endpoint=127.0.0.1:18081"
|
||||
echo "Deployed release=$release_sha postgres=provisioned endpoint=127.0.0.1:18081"
|
||||
echo "Persistent PostgreSQL data: /var/lib/endorphin-aster-vm/postgres"
|
||||
|
||||
+19
-11
@@ -19,7 +19,6 @@ def run(*args, env):
|
||||
|
||||
def main():
|
||||
env = os.environ.copy()
|
||||
env.pop("DATABASE_URL", None)
|
||||
run("cargo", "build", "--workspace", "--locked", env=env)
|
||||
# Cargo may use a configured CARGO_TARGET_DIR; discover the actual binaries.
|
||||
metadata = json.loads(run("cargo", "metadata", "--no-deps", "--format-version", "1", env=env))
|
||||
@@ -27,18 +26,23 @@ def main():
|
||||
cli = str(binary_dir / "cli")
|
||||
server = str(binary_dir / "server")
|
||||
with tempfile.TemporaryDirectory(prefix="aster-smoke-") as directory:
|
||||
env["ASTER_STATE_DIR"] = str(Path(directory) / "host-info")
|
||||
env["SQLITE_PATH"] = str(Path(directory) / "nested" / "aster.db")
|
||||
# This must succeed even when an explicit database URL is invalid.
|
||||
isolated_env = {**env, "DATABASE_URL": "invalid-database-url"}
|
||||
bootstrap = run(cli, "bootstrap", env=isolated_env)
|
||||
# Neither executable may use database settings for basic VM information.
|
||||
env["DATABASE_URL"] = "invalid-database-url"
|
||||
bootstrap = run(cli, "bootstrap", env=env)
|
||||
assert "runtime=EndorObject type_system=disabled" in bootstrap, bootstrap
|
||||
assert "objects_distinct=true heap_objects=2" in bootstrap, bootstrap
|
||||
assert "released heap_objects=0" in bootstrap, bootstrap
|
||||
assert not Path(env["SQLITE_PATH"]).exists(), "bootstrap must not open storage"
|
||||
assert "sqlite" in run(cli, "metadata", "put", "smoke", "持久化成功", env=env)
|
||||
assert run(cli, "metadata", "get", "smoke", env=env) == "持久化成功"
|
||||
failed = subprocess.run([cli, "metadata", "get", "smoke"], cwd=ROOT, env=isolated_env, capture_output=True)
|
||||
assert failed.returncode != 0, "invalid explicit DB config must fail"
|
||||
assert "stored (file)" in run(cli, "metadata", "put", "project.note", "日本語・中文・😀", env=env)
|
||||
assert run(cli, "metadata", "get", "project.note", env=env) == "日本語・中文・😀"
|
||||
legacy = json.dumps([{"key": "project.note", "value": "old"},
|
||||
{"key": "legacy.note", "value": "preserved"}])
|
||||
subprocess.run([cli, "metadata", "import"], input=legacy, cwd=ROOT, env=env,
|
||||
check=True, text=True, capture_output=True)
|
||||
assert run(cli, "metadata", "get", "project.note", env=env) == "日本語・中文・😀"
|
||||
assert run(cli, "metadata", "get", "legacy.note", env=env) == "preserved"
|
||||
assert not Path(env["SQLITE_PATH"]).exists(), "CLI must not open a database"
|
||||
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
@@ -58,7 +62,7 @@ def main():
|
||||
log.seek(0)
|
||||
raise AssertionError(f"server did not become healthy: {log.read()}")
|
||||
time.sleep(0.1)
|
||||
assert health == {"status": "ok", "storage": "sqlite"}, health
|
||||
assert health == {"status": "ok"}, health
|
||||
for _ in range(2):
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/bootstrap", timeout=2) as response:
|
||||
info = json.load(response)
|
||||
@@ -72,8 +76,12 @@ def main():
|
||||
process.wait()
|
||||
raise AssertionError("server did not shut down gracefully")
|
||||
assert process.returncode == 0, process.returncode
|
||||
assert not Path(env["SQLITE_PATH"]).exists(), "Server must not open a database"
|
||||
assert run(cli, "metadata", "get", "host.version", env=env) == "0.1.0"
|
||||
print("PASS: bootstrap isolation; SQLite persistence; config failure; HTTP health/bootstrap; SIGTERM shutdown")
|
||||
record = json.loads((Path(env["ASTER_STATE_DIR"]) / "host.version.json").read_text(encoding="utf-8"))
|
||||
assert record == {"format_version": 1, "key": "host.version", "value": "0.1.0"}
|
||||
assert run(cli, "metadata", "get", "project.note", env=env) == "日本語・中文・😀"
|
||||
print("PASS: no implicit database access; file metadata persistence; HTTP health/bootstrap; SIGTERM shutdown")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user