Separate database connections from file-backed host information and document handoff
Verify databases and deploy Aster VM / verify-and-deploy (push) Successful in 7m25s

This commit is contained in:
2026-10-19 02:07:36 +08:00
parent 18cd24a203
commit e52d2e1e4b
50 changed files with 935 additions and 442 deletions
+5 -7
View File
@@ -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
+1 -1
View File
@@ -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 或部署成功;按用户约定不主动跟踪状态。
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+8 -7
View File
@@ -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
View File
@@ -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)。
+1 -2
View File
@@ -11,8 +11,7 @@ path = "src/main.rs"
[dependencies]
core.workspace = true
storage.workspace = true
tokio.workspace = true
host.workspace = true
[lints]
workspace = true
+4 -4
View File
@@ -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)。
+2 -2
View File
@@ -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
View File
@@ -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()),
}
+16
View File
@@ -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
+11
View File
@@ -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)。
+8
View File
@@ -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)。
+4
View File
@@ -0,0 +1,4 @@
//! Host information stored in UTF-8 JSON files, independently of the VM and SQL.
mod metadata;
pub use metadata::MetadataStore;
+172
View File
@@ -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")))
}
}
+5
View File
@@ -0,0 +1,5 @@
# Host 文件信息检查
[metadata.rs](metadata.rs) 检查缺失读取不建目录、日文/中文/emoji 值和日文路径、重开与同键替换、非法键不越界、坏 JSON/版本/键不匹配不改原文件、并发替换可见完整记录,以及旧记录幂等导入和保留更新值。
检查使用临时目录,仅在指定远端环境运行。本地不执行文件行为测试;当前是否通过应以对应提交的 CI 为准。命令见 [验证](../../../docs/operations/validation.md),返回 [host](../README.md)。
+131
View File
@@ -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());
}
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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)。
+5 -6
View File
@@ -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)。
+7 -52
View File
@@ -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");
}
}
+5 -5
View File
@@ -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)。
+4 -7
View File
@@ -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
View File
@@ -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,
-4
View File
@@ -1,4 +0,0 @@
CREATE TABLE IF NOT EXISTS aster_metadata (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
)
+4 -4
View File
@@ -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)。
+48 -30
View File
@@ -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;
}
+87 -54
View File
@@ -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,
};
+3 -4
View File
@@ -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:
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)。
+5 -2
View File
@@ -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) 为准。
+36
View File
@@ -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
View File
@@ -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)。
+14 -8
View File
@@ -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、协议数字固定格式是设计方向,只有加载器实现后才能写成已实现行为。
+12 -11
View File
@@ -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)。
## 查看、停止与恢复
+47
View File
@@ -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)。
+4 -4
View File
@@ -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 快照已经实现。
+3 -1
View File
@@ -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)。
+36
View File
@@ -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)。
+1 -1
View File
@@ -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)。
+1 -1
View File
@@ -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)。
+29
View File
@@ -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
View File
@@ -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 临时选择端口后再启动宿主,存在小的端口竞争窗口,指定环境遇到冲突可重试。
+57 -10
View File
@@ -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
View File
@@ -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__":