feat: keep RGB-D captures only on workstation
This commit is contained in:
@@ -4,5 +4,6 @@
|
|||||||
`manifest.json`、`READY`。数据来自机器人 Nvidia 上独立的项目录制服务,不影响厂家
|
`manifest.json`、`READY`。数据来自机器人 Nvidia 上独立的项目录制服务,不影响厂家
|
||||||
`record_bag_node`。运行数据、临时同步目录及 `sync_status.json` 均不会提交到 Git。
|
`record_bag_node`。运行数据、临时同步目录及 `sync_status.json` 均不会提交到 Git。
|
||||||
|
|
||||||
只有通过大小和 SHA-256 校验的目录才会从 `.incoming` 原子移动到这里。机器人端保留
|
只有通过大小和 SHA-256 校验、fsync 和原子发布的目录才会从 `.incoming` 进入这里,
|
||||||
ready 副本,传输中断后会自动续传,不要把 `.incoming` 当作完整数据集。
|
并写入持久化 `VERIFIED` 凭据。之后才按 episode ID 与 manifest SHA 精确删除机器人端
|
||||||
|
对应 `ready` 副本。传输或删除确认中断会自动重试,不要把 `.incoming` 当作完整数据集。
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -12,7 +12,7 @@
|
|||||||
- `tg3_local_teleop/`:部署到机器人 Nvidia;直接接收 OmniSocket 数据,发布双臂、
|
- `tg3_local_teleop/`:部署到机器人 Nvidia;直接接收 OmniSocket 数据,发布双臂、
|
||||||
双手及 `/hric/robot/cmd_vel`,提供限速回 Home,并独立录制控制数据 MCAP。
|
双手及 `/hric/robot/cmd_vel`,提供限速回 Home,并独立录制控制数据 MCAP。
|
||||||
- `tg3_data_collection/`:部署在本机;通过本地 SSH/rsync 拉取已完整收尾并校验的
|
- `tg3_data_collection/`:部署在本机;通过本地 SSH/rsync 拉取已完整收尾并校验的
|
||||||
episode,原子保存到 `Data_Get/`。不会删除机器人上的备份。
|
episode,原子保存到 `Data_Get/`;持久化验真凭据后自动回收机器人上的对应副本。
|
||||||
- `Data_Get/`:最终数采目录。实际 MCAP、清单和同步状态默认不提交 Git。
|
- `Data_Get/`:最终数采目录。实际 MCAP、清单和同步状态默认不提交 Git。
|
||||||
- `docs/`:可提交的跨机器人迁移步骤。含实测帧和现场拓扑的汇报/证据文档只保留在
|
- `docs/`:可提交的跨机器人迁移步骤。含实测帧和现场拓扑的汇报/证据文档只保留在
|
||||||
当前本地工作副本,不同步到匿名可读的远端仓库。
|
当前本地工作副本,不同步到匿名可读的远端仓库。
|
||||||
@@ -54,8 +54,9 @@ TOML 解析检查。若还要核对外置 OmniSocketGo 版本,可设置任务
|
|||||||
|
|
||||||
Nvidia 独立服务录制明确白名单内的机器人实测状态、双臂/双手命令与反馈、行走、
|
Nvidia 独立服务录制明确白名单内的机器人实测状态、双臂/双手命令与反馈、行走、
|
||||||
IMU、电源状态、xTELE 应用帧以及头/腰 Orbbec 的 RGB-D。RGB 使用标准 JPEG 压缩流,
|
IMU、电源状态、xTELE 应用帧以及头/腰 Orbbec 的 RGB-D。RGB 使用标准 JPEG 压缩流,
|
||||||
16 位深度使用无损 compressedDepth PNG,并同时保存相机内参与静态 TF;腰部相机是
|
16 位深度使用无损 compressedDepth PNG,并同时保存相机内参与静态 TF。头部和腰部
|
||||||
必采,头部相机未启动时不阻塞,之后启动即可被同一录制自动发现。它不接管或停止厂家
|
相机都是可选流:服务运行就自动采集,未运行不阻塞核心 episode;manifest 会分别标记
|
||||||
|
`absent/healthy/partial/low_rate`。它不接管或停止厂家
|
||||||
`/record_bag_node`,也不录点云。正常结束后先校验 MCAP、生成 SHA-256
|
`/record_bag_node`,也不录点云。正常结束后先校验 MCAP、生成 SHA-256
|
||||||
`manifest.json` 和 `READY`,再由
|
`manifest.json` 和 `READY`,再由
|
||||||
本机服务拉取到:
|
本机服务拉取到:
|
||||||
@@ -64,8 +65,11 @@ IMU、电源状态、xTELE 应用帧以及头/腰 Orbbec 的 RGB-D。RGB 使用
|
|||||||
/home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop/Data_Get/<episode_id>/
|
/home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop/Data_Get/<episode_id>/
|
||||||
```
|
```
|
||||||
|
|
||||||
传输中断只会留在 `Data_Get/.incoming/`,不会显示为完成 episode;机器人端 ready
|
传输中断只会留在 `Data_Get/.incoming/`,不会显示为完成 episode。机器人在录制期间
|
||||||
副本不会自动删除。部署、状态检查和恢复步骤见 `tg3_data_collection/README.md`。
|
必须临时写 `active/ready`;只有本机完成全文件 SHA-256、fsync、原子发布并写入
|
||||||
|
`VERIFIED` 后,才会自动删除机器人上同 ID、同 manifest 哈希的 `ready` 目录。EAI
|
||||||
|
从不保存 MCAP 或图像 payload。部署、状态检查和恢复步骤见
|
||||||
|
`tg3_data_collection/README.md`。
|
||||||
|
|
||||||
## 迁移前必须修改
|
## 迁移前必须修改
|
||||||
|
|
||||||
|
|||||||
@@ -1031,6 +1031,7 @@ tg3_local_teleop.py
|
|||||||
data_collection.py
|
data_collection.py
|
||||||
data_recorder_protocol.py
|
data_recorder_protocol.py
|
||||||
data_recorder_node.py
|
data_recorder_node.py
|
||||||
|
delete_ready_episode.py
|
||||||
config.toml
|
config.toml
|
||||||
run.sh
|
run.sh
|
||||||
run_data_recorder.sh
|
run_data_recorder.sh
|
||||||
@@ -1061,11 +1062,13 @@ rosbag 加载 `RobotState/ArmCtrl` 时出现 undefined symbol。`run.sh` 仍需
|
|||||||
迁移时在 `config.toml [data_collection]` 核对 `base_directory`、100 GiB 余量、30 分钟
|
迁移时在 `config.toml [data_collection]` 核对 `base_directory`、100 GiB 余量、30 分钟
|
||||||
上限、明确 topic 白名单和 required topics。不要改成 `ros2 bag record -a`,点云需另行
|
上限、明确 topic 白名单和 required topics。不要改成 `ros2 bag record -a`,点云需另行
|
||||||
估算带宽和磁盘后再加入。当前白名单已经包含头/腰 Orbbec 的 JPEG RGB、无损 PNG 深度、
|
估算带宽和磁盘后再加入。当前白名单已经包含头/腰 Orbbec 的 JPEG RGB、无损 PNG 深度、
|
||||||
CameraInfo、Metadata 和 `/tf_static`;腰部 RGB-D/Metadata/静态 TF 为 required,两路
|
CameraInfo、Metadata 和 `/tf_static`。头、腰相机都是 optional:服务已启动时 rosbag 自动
|
||||||
图像收尾时还要求各自平均频率至少 `20 Hz`。头部未启动时是 optional,以后启动
|
发现并采集;未启动时不影响核心 episode 开始或进入 READY。收尾后 manifest 会分别把
|
||||||
`orbbec_head.service` 即可由正在录制的 rosbag 自动发现。
|
两台相机标成 `absent`、`healthy`、`partial` 或 `low_rate`,后两种附带质量警告但不阻塞
|
||||||
|
核心数据保存。
|
||||||
|
|
||||||
新机器人先确认相机服务和发布者。相机必须在按 L3 开始 episode 之前启动:
|
需要采集某台相机的完整 episode 时,先确认相机服务和发布者;不需要相机时可保持服务
|
||||||
|
停止,L3 数采仍可正常启动:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl is-active orbbec_waist.service orbbec_head.service
|
systemctl is-active orbbec_waist.service orbbec_head.service
|
||||||
@@ -1077,7 +1080,8 @@ ros2 topic info /ob_camera_waist/color/image_raw/compressed
|
|||||||
ros2 topic info /ob_camera_waist/depth/image_raw/compressedDepth
|
ros2 topic info /ob_camera_waist/depth/image_raw/compressedDepth
|
||||||
```
|
```
|
||||||
|
|
||||||
两条腰部话题都必须显示 `Publisher count: 1`。原始 1280x720 RGB+深度约
|
希望采集腰部图像时,两条腰部话题应显示 `Publisher count: 1`;头部同理。原始
|
||||||
|
1280x720 RGB+深度约
|
||||||
`8.3 GB/min/相机`,所以默认不重复录 raw,而是录标准 image_transport 压缩流;现场
|
`8.3 GB/min/相机`,所以默认不重复录 raw,而是录标准 image_transport 压缩流;现场
|
||||||
单相机约 `1.3–1.5 GB/min`,实际随画面变化。头部和腰部都运行时应按约两倍预留空间与
|
单相机约 `1.3–1.5 GB/min`,实际随画面变化。头部和腰部都运行时应按约两倍预留空间与
|
||||||
同步时间。`minimum_free_gib=100` 是停止下限,不是整条 episode 的容量预算;双相机
|
同步时间。`minimum_free_gib=100` 是停止下限,不是整条 episode 的容量预算;双相机
|
||||||
@@ -1113,12 +1117,17 @@ systemctl --user restart tg3-data-get-sync.service
|
|||||||
2. 左摇杆按下(L3)连续 1 秒开始数采;该键是 `button_joystick.left`,不是 X/Y/Z;
|
2. 左摇杆按下(L3)连续 1 秒开始数采;该键是 `button_joystick.left`,不是 X/Y/Z;
|
||||||
3. 松开 L3 至少 0.5 秒,再长按 1 秒结束;或正常 Z+C 结束遥操自动收尾;
|
3. 松开 L3 至少 0.5 秒,再长按 1 秒结束;或正常 Z+C 结束遥操自动收尾;
|
||||||
4. 查看 Nvidia `/tg3/data_collection/status`,必须先到 `ready`,不能从 `active/` 取数据;
|
4. 查看 Nvidia `/tg3/data_collection/status`,必须先到 `ready`,不能从 `active/` 取数据;
|
||||||
recorder 会同时要求 required topics 有实时发布者且最终消息计数大于零;
|
recorder 会要求核心 required topics 有实时发布者且最终消息计数大于零;相机质量看
|
||||||
|
manifest 的 `optional_topic_groups`,相机未启动显示 `absent`,不会阻塞;
|
||||||
5. 查看本机 `Data_Get/sync_status.json`,最终目录必须有 `READY`、`manifest.json`、
|
5. 查看本机 `Data_Get/sync_status.json`,最终目录必须有 `READY`、`manifest.json`、
|
||||||
`bag/metadata.yaml` 和非空 `*.mcap`;
|
`VERIFIED`、`bag/metadata.yaml` 和非空 `*.mcap`;
|
||||||
6. 执行 `ros2 bag info <episode>/bag` 并确认所需 topic 有消息;
|
6. 执行 `ros2 bag info <episode>/bag` 并确认所需 topic 有消息;
|
||||||
7. 断开 PS 网络再采一条,确认 Nvidia 保留数据;恢复网络后应续传且 SHA-256 通过。
|
7. 断开 PS 网络再采一条,确认 Nvidia 暂存 `ready/`;恢复网络后应续传,完成 SHA-256、
|
||||||
|
fsync 和 `VERIFIED` 后,只删除机器人上 manifest 完全匹配的该 episode。
|
||||||
|
|
||||||
每个新会话先要求 L3 稳定松开 0.5 秒,同一次持续按压只切换一次。数采故障不得解除
|
每个新会话先要求 L3 稳定松开 0.5 秒,同一次持续按压只切换一次。数采故障不得解除
|
||||||
遥操或延迟 STOP/Home;桥心跳中断超过 3 秒时 recorder 会自行 SIGINT 收尾。机器人
|
遥操或延迟 STOP/Home;桥心跳中断超过 3 秒时 recorder 会自行 SIGINT 收尾。当前架构在
|
||||||
`ready/` 不自动删除,确认本机和外部备份后才按具体 episode 清理。
|
录制期间必须先写 Nvidia 的项目专用 `active/`,PS 离线时也会暂存在 `ready/`;它不是
|
||||||
|
长期副本。本机完成全文件验真并持久化 `VERIFIED` 后,固定根目录删除助手才会原子移走并
|
||||||
|
删除该 exact episode,不能触碰 `active/`、`failed/` 或根目录。`retain_failed_episodes=false`
|
||||||
|
时失败录包也不在机器人保留。EAI 只转发控制数据,不保存 MCAP 或相机 payload。
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
机器人只在 `/home/nvidia/tg3_data_collection/ready` 暴露已经收到 SIGINT、写完
|
机器人只在 `/home/nvidia/tg3_data_collection/ready` 暴露已经收到 SIGINT、写完
|
||||||
`metadata.yaml`、通过 `ros2 bag info` 并生成 SHA-256 清单的 episode。本机服务用免密 SSH/rsync 复制到
|
`metadata.yaml`、通过 `ros2 bag info` 并生成 SHA-256 清单的 episode。本机服务用免密 SSH/rsync 复制到
|
||||||
`Data_Get/.incoming`,逐文件校验后再原子改名为 `Data_Get/<episode_id>`。中断的复制不会
|
`Data_Get/.incoming`,逐文件校验并 fsync 后再原子改名为 `Data_Get/<episode_id>`。本机
|
||||||
显示成完成数据,也不会删除机器人上的备份。
|
`VERIFIED` 收据持久化成功后,正式服务会再次完整哈希本机 payload,再让 Nvidia 侧固定根目录
|
||||||
|
helper 比对原始 manifest SHA-256,原子移入 `deleting/` 后清理。因此完整数据最终只保留在 PS 本机,Nvidia 只是临时
|
||||||
|
staging;中断、哈希错误或本机落盘失败都不会触发远端删除。
|
||||||
|
|
||||||
数据只走本机到 Nvidia 的 SSH 链路,与公网或 EAI 本地 OmniSocket Hub 无关。当前默认:
|
数据只走本机到 Nvidia 的 SSH 链路,与公网或 EAI 本地 OmniSocket Hub 无关。当前默认:
|
||||||
|
|
||||||
@@ -12,6 +14,8 @@ nvidia@192.168.41.2:/home/nvidia/tg3_data_collection/ready/
|
|||||||
-> /home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop/Data_Get/
|
-> /home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop/Data_Get/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
EAI 不运行 recorder/sync,也不保存 MCAP 或图像数据。
|
||||||
|
|
||||||
先确认免密与依赖:
|
先确认免密与依赖:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -20,6 +24,18 @@ command -v rsync
|
|||||||
ssh nvidia@192.168.41.2 command -v rsync
|
ssh nvidia@192.168.41.2 command -v rsync
|
||||||
```
|
```
|
||||||
|
|
||||||
|
先把固定根目录删除 helper 部署到 Nvidia。它的 CLI 只接受安全 episode ID 和 64 位
|
||||||
|
manifest SHA-256,不接受 root 参数,也不会枚举或触碰 `active/`、`failed/`。helper 在
|
||||||
|
`deleting/` 树之外的 `delete_ledger/` 先持久化独立删除授权;即使递归删除中断且 tombstone
|
||||||
|
里的 READY/manifest 已消失,仍能凭该授权安全续删,最后才删除授权文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp tg3_local_teleop/delete_ready_episode.py \
|
||||||
|
nvidia@192.168.41.2:/home/nvidia/tg3_local_teleop/
|
||||||
|
ssh nvidia@192.168.41.2 \
|
||||||
|
chmod 755 /home/nvidia/tg3_local_teleop/delete_ready_episode.py
|
||||||
|
```
|
||||||
|
|
||||||
安装本机用户服务:
|
安装本机用户服务:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -39,10 +55,20 @@ python3 -m json.tool Data_Get/sync_status.json
|
|||||||
也可以只执行一次,便于首次部署验收:
|
也可以只执行一次,便于首次部署验收:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 tg3_data_collection/data_get_sync.py --once
|
systemctl --user stop tg3-data-get-sync.service
|
||||||
|
python3 tg3_data_collection/data_get_sync.py --once --delete-remote-after-sync
|
||||||
|
systemctl --user start tg3-data-get-sync.service
|
||||||
```
|
```
|
||||||
|
|
||||||
|
同步器使用 `Data_Get/.data_get_sync.lock` 做 flock;正式服务运行时,另一个 `--once` 会明确
|
||||||
|
报“已有同步进程”,不会与服务同时复制或删除。不带 `--delete-remote-after-sync` 的手动命令
|
||||||
|
只复制、验真并保留远端,正式 unit 已显式启用自动回收。
|
||||||
|
|
||||||
日常轮询不会每 2 秒重新读取并哈希全部历史 MCAP;首次原子发布时已经完成深度校验。
|
日常轮询不会每 2 秒重新读取并哈希全部历史 MCAP;首次原子发布时已经完成深度校验。
|
||||||
|
如果进程在远端删除 ACK 返回前退出,`VERIFIED` 会保持 `pending`。每次真正调用 helper
|
||||||
|
之前都会重新完整哈希本机 payload;失败后从 30 秒开始指数退避,最大 15 分钟,进程重启也
|
||||||
|
遵守持久化的 `next_retry_unix_s`,避免每 2 秒重读大型 MCAP。helper 使用独立 600 秒超时,
|
||||||
|
能继续清理同摘要 tombstone/ledger,也会把已经不存在的同一目标视为成功。
|
||||||
需要定期审计已有本地数据时单独执行(可能耗时较长):
|
需要定期审计已有本地数据时单独执行(可能耗时较长):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -56,6 +82,7 @@ python3 tg3_data_collection/data_get_sync.py --once --verify-existing
|
|||||||
```text
|
```text
|
||||||
<episode_id>/
|
<episode_id>/
|
||||||
READY
|
READY
|
||||||
|
VERIFIED
|
||||||
manifest.json
|
manifest.json
|
||||||
bag/metadata.yaml
|
bag/metadata.yaml
|
||||||
bag/*.mcap
|
bag/*.mcap
|
||||||
@@ -64,10 +91,32 @@ python3 tg3_data_collection/data_get_sync.py --once --verify-existing
|
|||||||
```
|
```
|
||||||
|
|
||||||
同步端会再次核对 `READY`、episode ID、每个 MCAP/metadata 的长度和 SHA-256;远端
|
同步端会再次核对 `READY`、episode ID、每个 MCAP/metadata 的长度和 SHA-256;远端
|
||||||
manifest 在传输中发生变化也会拒绝发布最终目录。`.incoming`、`sync_status.json` 和全部
|
manifest 在传输中发生变化也会拒绝发布最终目录。随后 fsync 所有复制文件和目录、以
|
||||||
episode 已由 `Data_Get/.gitignore` 排除。服务绝不自动删除机器人端 `ready/` 数据;确认
|
NOREPLACE 语义原子发布、fsync 父目录,最后写入并 fsync `VERIFIED`。收据绑定 episode、
|
||||||
本机备份后如需清理,必须由操作者明确指定具体 episode,不能删除整个项目目录。
|
远端身份、原始 manifest SHA-256、已校验文件数和字节数,并记录 `pending/deleted`。
|
||||||
|
`.incoming`、`sync_status.json` 和全部 episode 已由 `Data_Get/.gitignore` 排除。
|
||||||
|
|
||||||
迁移后需要同步修改 service 中的本机项目路径、机器人 SSH 地址和机器人 ready 路径。
|
临时目录按 `<episode>.<manifest_sha256>.partial` 隔离,rsync 同时使用删除同步;发布前还会
|
||||||
如果 Nvidia 的 `192.168.41.2` 改变,只改该 unit 的 `--remote`;它不在机器人
|
拒绝 manifest 未列出的任何 `*.mcap` 或 `metadata.yaml`,避免旧中断文件混入新 episode。
|
||||||
`config.toml` 或 OmniSocket Peer 设置中。
|
|
||||||
|
`sync_status.json` 的 `state=delete_pending` 表示本机数据已完整发布,但远端回收尚未收到
|
||||||
|
成功 ACK;`pending_remote_cleanup`、`last_delete_error`、`delete_count` 和
|
||||||
|
`last_deleted_episode` 可用于排查。此状态下不要手工删除本机数据,服务会在下一轮重试。
|
||||||
|
|
||||||
|
已有本机同名目录不会直接授权删除:没有可信 `VERIFIED` 时必须重新做完整哈希,并要求
|
||||||
|
本机与当前远端 manifest 原始摘要一致,之后才补收据并回收远端。同名碰撞、损坏、符号
|
||||||
|
链接、错误 root 或摘要不符都保留远端并报错。
|
||||||
|
|
||||||
|
历史 `retained/deleted` 收据可以来自旧机器人 IP,不会阻塞新地址的同步;仍为 `pending` 的
|
||||||
|
跨 IP 收据不会静默授权新机器人删除,只会逐项报错且不影响其他新 episode 继续同步。
|
||||||
|
|
||||||
|
自动回收 helper 严格只覆盖 `ready/`,录制中的 `active/` 永远不能由 PS 同步器处理,
|
||||||
|
也不能对 `failed/` 使用 READY 凭据。当前正式配置为
|
||||||
|
`retain_failed_episodes=false`:录制器只把失败原因写入状态和 journal,随后精确删除自己
|
||||||
|
刚创建的失败 payload,因此机器人不会长期保留失败 MCAP。若以后改回 `true` 以保留故障
|
||||||
|
诊断,必须另建独立验真与回收协议,不能整目录清空。
|
||||||
|
|
||||||
|
迁移后需要同步修改 service 中的本机项目路径和机器人 SSH 地址。如果 Nvidia 的
|
||||||
|
`192.168.41.2` 改变,只改 unit 的 `--remote` 并重新部署 helper;它不在机器人
|
||||||
|
`config.toml` 或 OmniSocket Peer 设置中。启用自动删除时远端路径必须保持固定的
|
||||||
|
`/home/nvidia/tg3_data_collection/ready`,同步器会拒绝对其他 root 启用删除。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,12 +3,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from data_get_sync import (
|
from data_get_sync import (
|
||||||
DataGetSync,
|
DataGetSync,
|
||||||
|
FIXED_REMOTE_READY,
|
||||||
|
ManifestDocument,
|
||||||
|
VERIFIED_RECEIPT_NAME,
|
||||||
|
fsync_episode_tree,
|
||||||
parse_manifest,
|
parse_manifest,
|
||||||
safe_episode_name,
|
safe_episode_name,
|
||||||
safe_relative_path,
|
safe_relative_path,
|
||||||
@@ -16,6 +24,99 @@ from data_get_sync import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalRsyncRunner:
|
||||||
|
def __init__(self, remote_root: Path) -> None:
|
||||||
|
self.remote_root = remote_root
|
||||||
|
self.mode = "normal"
|
||||||
|
self.rsync_calls = 0
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self, command: list[str], *, timeout: float
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
del timeout
|
||||||
|
if not command or command[0] != "rsync":
|
||||||
|
return subprocess.CompletedProcess(command, 1, "", "unexpected command")
|
||||||
|
self.rsync_calls += 1
|
||||||
|
remote_source = command[-2].rstrip("/")
|
||||||
|
episode = remote_source.rsplit("/", 1)[-1]
|
||||||
|
destination = Path(command[-1])
|
||||||
|
shutil.copytree(
|
||||||
|
self.remote_root / episode,
|
||||||
|
destination,
|
||||||
|
dirs_exist_ok=True,
|
||||||
|
symlinks=True,
|
||||||
|
)
|
||||||
|
if self.mode == "truncate":
|
||||||
|
(destination / "bag" / "bag_0.mcap").write_bytes(b"truncated")
|
||||||
|
elif self.mode == "manifest_changed":
|
||||||
|
manifest = destination / "manifest.json"
|
||||||
|
manifest.write_bytes(manifest.read_bytes() + b"\n")
|
||||||
|
elif self.mode == "manifest_symlink":
|
||||||
|
manifest = destination / "manifest.json"
|
||||||
|
manifest.unlink()
|
||||||
|
manifest.symlink_to("bag/metadata.yaml")
|
||||||
|
return subprocess.CompletedProcess(command, 0, "", "")
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSync(DataGetSync):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
remote_root: Path,
|
||||||
|
destination: Path,
|
||||||
|
runner: LocalRsyncRunner,
|
||||||
|
delete: bool = True,
|
||||||
|
remote: str = "nvidia@test",
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
remote=remote,
|
||||||
|
remote_ready=FIXED_REMOTE_READY,
|
||||||
|
destination=destination,
|
||||||
|
status_file=destination / "sync_status.json",
|
||||||
|
runner=runner,
|
||||||
|
reserve_bytes=0,
|
||||||
|
delete_remote_after_sync=delete,
|
||||||
|
remote_delete_helper="/fixed/delete_ready_episode.py",
|
||||||
|
)
|
||||||
|
self.remote_root = remote_root
|
||||||
|
self.delete_calls: list[tuple[str, str]] = []
|
||||||
|
self.delete_outcomes: list[str] = []
|
||||||
|
|
||||||
|
def list_remote_episodes(self) -> list[str]:
|
||||||
|
return sorted(
|
||||||
|
path.name
|
||||||
|
for path in self.remote_root.iterdir()
|
||||||
|
if path.is_dir() and not path.is_symlink()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_remote_manifest(self, episode: str) -> ManifestDocument:
|
||||||
|
raw = (self.remote_root / episode / "manifest.json").read_bytes()
|
||||||
|
return ManifestDocument.from_bytes(raw, episode)
|
||||||
|
|
||||||
|
def _invoke_delete_helper(
|
||||||
|
self, episode: str, manifest_sha256: str
|
||||||
|
) -> dict[str, object]:
|
||||||
|
self.delete_calls.append((episode, manifest_sha256))
|
||||||
|
outcome = self.delete_outcomes.pop(0) if self.delete_outcomes else "success"
|
||||||
|
target = self.remote_root / episode
|
||||||
|
if outcome == "failure":
|
||||||
|
raise RuntimeError("injected delete failure")
|
||||||
|
if outcome == "ack_lost_after_delete":
|
||||||
|
if target.exists():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
raise RuntimeError("injected lost acknowledgement")
|
||||||
|
if target.exists():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
state = "deleted"
|
||||||
|
else:
|
||||||
|
state = "already_absent"
|
||||||
|
return {
|
||||||
|
"state": state,
|
||||||
|
"episode_id": episode,
|
||||||
|
"manifest_sha256": manifest_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DataGetSyncTests(unittest.TestCase):
|
class DataGetSyncTests(unittest.TestCase):
|
||||||
def test_safe_episode_name(self) -> None:
|
def test_safe_episode_name(self) -> None:
|
||||||
self.assertTrue(safe_episode_name("episode_20260810T120000000_deadbeef"))
|
self.assertTrue(safe_episode_name("episode_20260810T120000000_deadbeef"))
|
||||||
@@ -93,28 +194,377 @@ class DataGetSyncTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
validate_episode_dir(episode, manifest)
|
validate_episode_dir(episode, manifest)
|
||||||
|
|
||||||
def test_existing_episode_skips_expensive_hash_unless_requested(self) -> None:
|
def test_fsync_episode_tree_rejects_intermediate_symlink(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
root = Path(temporary)
|
root = Path(temporary)
|
||||||
name = "episode_20260810T120000000_deadbeef"
|
episode = root / "episode"
|
||||||
episode, _manifest = self._episode(root, name)
|
episode.mkdir()
|
||||||
(episode / "bag" / "bag_0.mcap").write_bytes(b"changed")
|
outside = root / "outside"
|
||||||
fast = DataGetSync(
|
outside.mkdir()
|
||||||
remote="unused",
|
(outside / "file").write_bytes(b"sentinel")
|
||||||
remote_ready="/unused",
|
(episode / "linked").symlink_to(outside, target_is_directory=True)
|
||||||
destination=root,
|
|
||||||
status_file=root / "status.json",
|
|
||||||
)
|
|
||||||
self.assertFalse(fast.sync_episode(name))
|
|
||||||
deep = DataGetSync(
|
|
||||||
remote="unused",
|
|
||||||
remote_ready="/unused",
|
|
||||||
destination=root,
|
|
||||||
status_file=root / "status.json",
|
|
||||||
verify_existing=True,
|
|
||||||
)
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
deep.sync_episode(name)
|
fsync_episode_tree(episode)
|
||||||
|
|
||||||
|
def _local_sync(
|
||||||
|
self, root: Path, name: str = "episode_20260810T120000000_deadbeef"
|
||||||
|
) -> tuple[LocalSync, LocalRsyncRunner, Path, Path]:
|
||||||
|
remote = root / "remote_ready"
|
||||||
|
destination = root / "Data_Get"
|
||||||
|
remote.mkdir()
|
||||||
|
destination.mkdir()
|
||||||
|
self._episode(remote, name)
|
||||||
|
runner = LocalRsyncRunner(remote)
|
||||||
|
syncer = LocalSync(
|
||||||
|
remote_root=remote,
|
||||||
|
destination=destination,
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
return syncer, runner, remote, destination
|
||||||
|
|
||||||
|
def test_copy_is_verified_receipted_then_remote_is_deleted(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
name = "episode_20260810T120000000_deadbeef"
|
||||||
|
syncer, _runner, remote, destination = self._local_sync(
|
||||||
|
Path(temporary), name
|
||||||
|
)
|
||||||
|
self.assertEqual(syncer.run_once(), 1)
|
||||||
|
final = destination / name
|
||||||
|
receipt = json.loads((final / VERIFIED_RECEIPT_NAME).read_text())
|
||||||
|
self.assertEqual(receipt["state"], "VERIFIED")
|
||||||
|
self.assertEqual(receipt["remote_identity"], syncer.remote_identity)
|
||||||
|
self.assertEqual(receipt["verified_files"], 2)
|
||||||
|
self.assertGreater(receipt["verified_bytes"], 0)
|
||||||
|
self.assertEqual(receipt["remote_delete"]["state"], "deleted")
|
||||||
|
self.assertFalse((remote / name).exists())
|
||||||
|
self.assertEqual(syncer.delete_count, 1)
|
||||||
|
status = json.loads((destination / "sync_status.json").read_text())
|
||||||
|
self.assertEqual(status["state"], "idle")
|
||||||
|
self.assertEqual(status["pending_remote_cleanup"], 0)
|
||||||
|
|
||||||
|
def test_truncated_copy_never_calls_delete(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
syncer, runner, remote, _destination = self._local_sync(Path(temporary))
|
||||||
|
runner.mode = "truncate"
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
syncer.run_once()
|
||||||
|
self.assertEqual(syncer.delete_calls, [])
|
||||||
|
self.assertEqual(len(list(remote.iterdir())), 1)
|
||||||
|
|
||||||
|
def test_manifest_change_during_copy_never_calls_delete(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
syncer, runner, remote, _destination = self._local_sync(Path(temporary))
|
||||||
|
runner.mode = "manifest_changed"
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
syncer.run_once()
|
||||||
|
self.assertEqual(syncer.delete_calls, [])
|
||||||
|
self.assertEqual(len(list(remote.iterdir())), 1)
|
||||||
|
|
||||||
|
def test_copied_manifest_symlink_never_calls_delete(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
syncer, runner, remote, _destination = self._local_sync(Path(temporary))
|
||||||
|
runner.mode = "manifest_symlink"
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
syncer.run_once()
|
||||||
|
self.assertEqual(syncer.delete_calls, [])
|
||||||
|
self.assertEqual(len(list(remote.iterdir())), 1)
|
||||||
|
|
||||||
|
def test_delete_failure_is_pending_and_retried(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
name = "episode_20260810T120000000_deadbeef"
|
||||||
|
syncer, _runner, remote, destination = self._local_sync(
|
||||||
|
Path(temporary), name
|
||||||
|
)
|
||||||
|
syncer.delete_outcomes = ["failure", "success"]
|
||||||
|
self.assertEqual(syncer.run_once(), 1)
|
||||||
|
receipt_path = destination / name / VERIFIED_RECEIPT_NAME
|
||||||
|
receipt = json.loads(receipt_path.read_text())
|
||||||
|
self.assertEqual(receipt["remote_delete"]["state"], "pending")
|
||||||
|
self.assertTrue((remote / name).exists())
|
||||||
|
status = json.loads((destination / "sync_status.json").read_text())
|
||||||
|
self.assertEqual(status["state"], "delete_pending")
|
||||||
|
self.assertEqual(status["pending_remote_cleanup"], 1)
|
||||||
|
|
||||||
|
# Persistent backoff prevents a 2-second poll loop from repeatedly
|
||||||
|
# hashing a large MCAP after a network/helper failure.
|
||||||
|
self.assertEqual(syncer.run_once(), 0)
|
||||||
|
self.assertEqual(len(syncer.delete_calls), 1)
|
||||||
|
receipt = json.loads(receipt_path.read_text())
|
||||||
|
receipt["remote_delete"]["next_retry_unix_s"] = 0.0
|
||||||
|
receipt_path.write_text(json.dumps(receipt), encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertEqual(syncer.run_once(), 0)
|
||||||
|
receipt = json.loads(receipt_path.read_text())
|
||||||
|
self.assertEqual(receipt["remote_delete"]["state"], "deleted")
|
||||||
|
self.assertFalse((remote / name).exists())
|
||||||
|
self.assertEqual(len(syncer.delete_calls), 2)
|
||||||
|
|
||||||
|
def test_lost_delete_ack_is_idempotent_after_process_restart(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
name = "episode_20260810T120000000_deadbeef"
|
||||||
|
root = Path(temporary)
|
||||||
|
first, runner, remote, destination = self._local_sync(root, name)
|
||||||
|
first.delete_outcomes = ["ack_lost_after_delete"]
|
||||||
|
self.assertEqual(first.run_once(), 1)
|
||||||
|
self.assertFalse((remote / name).exists())
|
||||||
|
receipt_path = destination / name / VERIFIED_RECEIPT_NAME
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(receipt_path.read_text())["remote_delete"]["state"],
|
||||||
|
"pending",
|
||||||
|
)
|
||||||
|
|
||||||
|
restarted = LocalSync(
|
||||||
|
remote_root=remote,
|
||||||
|
destination=destination,
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
with mock.patch(
|
||||||
|
"data_get_sync.validate_episode_dir",
|
||||||
|
wraps=validate_episode_dir,
|
||||||
|
) as deep_validator:
|
||||||
|
self.assertEqual(restarted.run_once(), 0)
|
||||||
|
self.assertEqual(deep_validator.call_count, 0)
|
||||||
|
self.assertEqual(restarted.delete_calls, [])
|
||||||
|
|
||||||
|
receipt = json.loads(receipt_path.read_text())
|
||||||
|
receipt["remote_delete"]["next_retry_unix_s"] = 0.0
|
||||||
|
receipt_path.write_text(json.dumps(receipt), encoding="utf-8")
|
||||||
|
self.assertEqual(restarted.run_once(), 0)
|
||||||
|
self.assertGreaterEqual(deep_validator.call_count, 1)
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(receipt_path.read_text())["remote_delete"]["state"],
|
||||||
|
"deleted",
|
||||||
|
)
|
||||||
|
self.assertEqual(restarted.delete_calls[0][0], name)
|
||||||
|
|
||||||
|
def test_retry_rehashes_and_refuses_delete_after_local_tamper(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
name = "episode_20260810T120000000_deadbeef"
|
||||||
|
syncer, _runner, remote, destination = self._local_sync(
|
||||||
|
Path(temporary), name
|
||||||
|
)
|
||||||
|
syncer.delete_outcomes = ["failure", "success"]
|
||||||
|
self.assertEqual(syncer.run_once(), 1)
|
||||||
|
(destination / name / "bag" / "bag_0.mcap").write_bytes(b"tampered")
|
||||||
|
receipt_path = destination / name / VERIFIED_RECEIPT_NAME
|
||||||
|
receipt = json.loads(receipt_path.read_text())
|
||||||
|
receipt["remote_delete"]["next_retry_unix_s"] = 0.0
|
||||||
|
receipt_path.write_text(json.dumps(receipt), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
syncer.run_once()
|
||||||
|
self.assertEqual(len(syncer.delete_calls), 1)
|
||||||
|
self.assertTrue((remote / name).exists())
|
||||||
|
|
||||||
|
def test_unlisted_stale_mcap_is_never_published_or_deleted(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
name = "episode_20260810T120000000_deadbeef"
|
||||||
|
syncer, _runner, remote, destination = self._local_sync(
|
||||||
|
Path(temporary), name
|
||||||
|
)
|
||||||
|
(remote / name / "bag" / "stale_old_payload.mcap").write_bytes(
|
||||||
|
b"stale"
|
||||||
|
)
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
syncer.run_once()
|
||||||
|
self.assertFalse((destination / name).exists())
|
||||||
|
self.assertEqual(syncer.delete_calls, [])
|
||||||
|
self.assertTrue((remote / name).exists())
|
||||||
|
|
||||||
|
def test_reused_hash_staging_cannot_smuggle_old_mcap(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
name = "episode_20260810T120000000_deadbeef"
|
||||||
|
syncer, _runner, remote, destination = self._local_sync(
|
||||||
|
Path(temporary), name
|
||||||
|
)
|
||||||
|
document = syncer.get_remote_manifest(name)
|
||||||
|
staging = (
|
||||||
|
destination
|
||||||
|
/ ".incoming"
|
||||||
|
/ f"{name}.{document.sha256}.partial"
|
||||||
|
/ "bag"
|
||||||
|
)
|
||||||
|
staging.mkdir(parents=True)
|
||||||
|
(staging / "stale_old_payload.mcap").write_bytes(b"old interrupted bag")
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
syncer.run_once()
|
||||||
|
self.assertFalse((destination / name).exists())
|
||||||
|
self.assertEqual(syncer.delete_calls, [])
|
||||||
|
self.assertTrue((remote / name).exists())
|
||||||
|
|
||||||
|
def test_deleted_receipt_from_old_ip_does_not_block_new_ip(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
name = "episode_20260810T120000000_deadbeef"
|
||||||
|
root = Path(temporary)
|
||||||
|
first, runner, remote, destination = self._local_sync(root, name)
|
||||||
|
self.assertEqual(first.run_once(), 1)
|
||||||
|
receipt_path = destination / name / VERIFIED_RECEIPT_NAME
|
||||||
|
receipt = json.loads(receipt_path.read_text())
|
||||||
|
receipt["remote"] = "nvidia@old-ip"
|
||||||
|
receipt["remote_identity"] = (
|
||||||
|
f"nvidia@old-ip:{FIXED_REMOTE_READY}"
|
||||||
|
)
|
||||||
|
receipt_path.write_text(json.dumps(receipt), encoding="utf-8")
|
||||||
|
|
||||||
|
migrated = LocalSync(
|
||||||
|
remote_root=remote,
|
||||||
|
destination=destination,
|
||||||
|
runner=runner,
|
||||||
|
remote="nvidia@new-ip",
|
||||||
|
)
|
||||||
|
self.assertEqual(migrated.run_once(), 0)
|
||||||
|
status = json.loads((destination / "sync_status.json").read_text())
|
||||||
|
self.assertEqual(status["state"], "idle")
|
||||||
|
self.assertEqual(status["pending_remote_cleanup"], 0)
|
||||||
|
|
||||||
|
def test_bad_pending_old_ip_does_not_starve_new_episode(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
old_name = "episode_old"
|
||||||
|
first, _runner, _old_remote, destination = self._local_sync(
|
||||||
|
root, old_name
|
||||||
|
)
|
||||||
|
first.delete_outcomes = ["failure"]
|
||||||
|
self.assertEqual(first.run_once(), 1)
|
||||||
|
|
||||||
|
new_remote = root / "new_remote_ready"
|
||||||
|
new_remote.mkdir()
|
||||||
|
new_name = "episode_new"
|
||||||
|
self._episode(new_remote, new_name)
|
||||||
|
new_runner = LocalRsyncRunner(new_remote)
|
||||||
|
migrated = LocalSync(
|
||||||
|
remote_root=new_remote,
|
||||||
|
destination=destination,
|
||||||
|
runner=new_runner,
|
||||||
|
remote="nvidia@new-ip",
|
||||||
|
)
|
||||||
|
self.assertEqual(migrated.run_once(), 1)
|
||||||
|
self.assertFalse((new_remote / new_name).exists())
|
||||||
|
self.assertTrue((destination / new_name).is_dir())
|
||||||
|
status = json.loads((destination / "sync_status.json").read_text())
|
||||||
|
self.assertEqual(status["state"], "delete_pending")
|
||||||
|
self.assertEqual(status["pending_remote_cleanup"], 1)
|
||||||
|
self.assertIn("remote_identity", status["last_delete_error"])
|
||||||
|
|
||||||
|
def test_delete_helper_uses_independent_long_timeout(self) -> None:
|
||||||
|
class AckRunner:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.timeouts: list[float] = []
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self, command: list[str], *, timeout: float
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
self.timeouts.append(timeout)
|
||||||
|
payload = {
|
||||||
|
"state": "already_absent",
|
||||||
|
"episode_id": "episode_timeout",
|
||||||
|
"manifest_sha256": "0" * 64,
|
||||||
|
}
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
command, 0, json.dumps(payload) + "\n", ""
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
runner = AckRunner()
|
||||||
|
syncer = DataGetSync(
|
||||||
|
remote="nvidia@test",
|
||||||
|
remote_ready=FIXED_REMOTE_READY,
|
||||||
|
destination=Path(temporary),
|
||||||
|
status_file=Path(temporary) / "status.json",
|
||||||
|
runner=runner,
|
||||||
|
delete_remote_after_sync=True,
|
||||||
|
remote_delete_timeout_s=600.0,
|
||||||
|
)
|
||||||
|
syncer._invoke_delete_helper("episode_timeout", "0" * 64)
|
||||||
|
self.assertEqual(runner.timeouts, [600.0])
|
||||||
|
|
||||||
|
def test_existing_final_without_receipt_is_deep_verified_before_delete(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
name = "episode_20260810T120000000_deadbeef"
|
||||||
|
root = Path(temporary)
|
||||||
|
syncer, _runner, remote, destination = self._local_sync(root, name)
|
||||||
|
shutil.copytree(remote / name, destination / name)
|
||||||
|
with mock.patch(
|
||||||
|
"data_get_sync.validate_episode_dir",
|
||||||
|
wraps=validate_episode_dir,
|
||||||
|
) as deep_validator:
|
||||||
|
self.assertEqual(syncer.run_once(), 0)
|
||||||
|
self.assertGreaterEqual(deep_validator.call_count, 1)
|
||||||
|
self.assertTrue((destination / name / VERIFIED_RECEIPT_NAME).is_file())
|
||||||
|
self.assertFalse((remote / name).exists())
|
||||||
|
|
||||||
|
def test_existing_corrupt_final_without_receipt_never_deletes_remote(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
name = "episode_20260810T120000000_deadbeef"
|
||||||
|
root = Path(temporary)
|
||||||
|
syncer, _runner, remote, destination = self._local_sync(root, name)
|
||||||
|
shutil.copytree(remote / name, destination / name)
|
||||||
|
(destination / name / "bag" / "bag_0.mcap").write_bytes(b"corrupt")
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
syncer.run_once()
|
||||||
|
self.assertEqual(syncer.delete_calls, [])
|
||||||
|
self.assertTrue((remote / name).exists())
|
||||||
|
|
||||||
|
def test_existing_manifest_collision_never_deletes_remote(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
name = "episode_20260810T120000000_deadbeef"
|
||||||
|
root = Path(temporary)
|
||||||
|
syncer, _runner, remote, destination = self._local_sync(root, name)
|
||||||
|
self._episode(destination, name)
|
||||||
|
local_manifest = destination / name / "manifest.json"
|
||||||
|
local_manifest.write_bytes(local_manifest.read_bytes() + b"\n")
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
syncer.run_once()
|
||||||
|
self.assertEqual(syncer.delete_calls, [])
|
||||||
|
self.assertTrue((remote / name).exists())
|
||||||
|
|
||||||
|
def test_receipt_write_failure_never_calls_delete(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
syncer, _runner, remote, _destination = self._local_sync(Path(temporary))
|
||||||
|
with mock.patch(
|
||||||
|
"data_get_sync.atomic_write_json",
|
||||||
|
side_effect=RuntimeError("injected receipt fsync failure"),
|
||||||
|
):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
syncer.sync_episode(next(remote.iterdir()).name)
|
||||||
|
self.assertEqual(syncer.delete_calls, [])
|
||||||
|
self.assertEqual(len(list(remote.iterdir())), 1)
|
||||||
|
|
||||||
|
def test_sync_flock_rejects_concurrent_once(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
first, runner, remote, destination = self._local_sync(root)
|
||||||
|
second = LocalSync(
|
||||||
|
remote_root=remote,
|
||||||
|
destination=destination,
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
with first.process_lock():
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "already holds"):
|
||||||
|
second.run_once()
|
||||||
|
|
||||||
|
def test_symlink_lock_is_refused(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
syncer, _runner, _remote, destination = self._local_sync(root)
|
||||||
|
sentinel = root / "sentinel"
|
||||||
|
sentinel.write_text("do not touch", encoding="utf-8")
|
||||||
|
(destination / ".data_get_sync.lock").symlink_to(sentinel)
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
syncer.run_once()
|
||||||
|
self.assertEqual(sentinel.read_text(encoding="utf-8"), "do not touch")
|
||||||
|
|
||||||
|
def test_delete_mode_rejects_nonfixed_remote_ready(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
DataGetSync(
|
||||||
|
remote="nvidia@test",
|
||||||
|
remote_ready="/tmp/not-ready",
|
||||||
|
destination=root,
|
||||||
|
status_file=root / "status.json",
|
||||||
|
delete_remote_after_sync=True,
|
||||||
|
)
|
||||||
|
|
||||||
def test_bad_old_episode_does_not_starve_newer_episode(self) -> None:
|
def test_bad_old_episode_does_not_starve_newer_episode(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Description=TG3 completed data episode sync to Data_Get
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=/home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop
|
WorkingDirectory=/home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop
|
||||||
ExecStart=/usr/bin/python3 /home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop/tg3_data_collection/data_get_sync.py --remote nvidia@192.168.41.2 --remote-ready /home/nvidia/tg3_data_collection/ready --destination /home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop/Data_Get --poll-seconds 2
|
ExecStart=/usr/bin/python3 /home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop/tg3_data_collection/data_get_sync.py --remote nvidia@192.168.41.2 --remote-ready /home/nvidia/tg3_data_collection/ready --destination /home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop/Data_Get --delete-remote-after-sync --poll-seconds 2
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
KillSignal=SIGINT
|
KillSignal=SIGINT
|
||||||
|
|||||||
@@ -167,21 +167,19 @@ OmniSocket 进程;在此之前桥会保持最后一帧目标。未 START 或
|
|||||||
为避免 1280x720 原始 RGB+深度每台相机约 `8.3 GB/min` 的持续写盘负载,默认录制
|
为避免 1280x720 原始 RGB+深度每台相机约 `8.3 GB/min` 的持续写盘负载,默认录制
|
||||||
`color/image_raw/compressed`(JPEG)和 `depth/image_raw/compressedDepth`(16UC1
|
`color/image_raw/compressed`(JPEG)和 `depth/image_raw/compressedDepth`(16UC1
|
||||||
无损 PNG)。现场单台腰部相机实测合计约 `22–25 MB/s`(约 `1.3–1.5 GB/min`,随
|
无损 PNG)。现场单台腰部相机实测合计约 `22–25 MB/s`(约 `1.3–1.5 GB/min`,随
|
||||||
场景变化)。腰部图像、内参、Metadata 和 `/tf_static` 是 required topics;两路图像还
|
场景变化)。头、腰两组相机都不是 required topics:服务在运行就由 rosbag 自动发现并
|
||||||
必须在整条 bag 内分别达到至少 `20 Hz` 平均频率。相机服务未发布或图像严重掉流时,
|
采集,未运行不阻塞 L3 或核心 READY。manifest 对每组记录 `absent`、`healthy`、
|
||||||
L3 启动或最终校验会失败,不会生成缺图像却标记 READY 的 episode。头部话题在白名单
|
`partial` 或 `low_rate`,并保存各话题消息数和图像平均频率;相机异常会产生明确质量
|
||||||
中但不是 required:当前
|
警告,但不会丢弃其余机器人控制数据。静态 TF 也尽力采集,不作为启动门槛。
|
||||||
`orbbec_head.service` 未启动不影响腰部数采;将来在 episode 开始前启动后,rosbag 会
|
|
||||||
自动发现并采集,无需再改配置。静态 TF 会被保存用于相机外参,但当前收尾只验证该
|
|
||||||
Topic 非空,不解析每条 TF 的 frame 语义。
|
|
||||||
|
|
||||||
机器人暂存目录:
|
机器人暂存目录:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/home/nvidia/tg3_data_collection/
|
/home/nvidia/tg3_data_collection/
|
||||||
active/ # 尚未完成,不能取走
|
active/ # 录制期间的临时数据,不能取走
|
||||||
ready/ # 已正常收尾、校验并生成 READY
|
ready/ # 等待 PS 本机完成持久化验真;随后自动回收
|
||||||
failed/ # 启动、磁盘、进程或校验失败,保留供诊断
|
failed/ # 当前策略不保留失败 payload
|
||||||
|
deleting/ # 固定根删除助手的短暂原子隔离目录
|
||||||
```
|
```
|
||||||
|
|
||||||
MCAP 使用 `zstd_fast`、64 MiB cache、每 300 秒分片;每条 episode 最长 30 分钟,
|
MCAP 使用 `zstd_fast`、64 MiB cache、每 300 秒分片;每条 episode 最长 30 分钟,
|
||||||
@@ -191,6 +189,12 @@ MCAP 使用 `zstd_fast`、64 MiB cache、每 300 秒分片;每条 episode 最
|
|||||||
`manifest.json` 和 `READY`,最后才原子进入 `ready/`。Nvidia 需已安装 `python3-yaml`
|
`manifest.json` 和 `READY`,最后才原子进入 `ready/`。Nvidia 需已安装 `python3-yaml`
|
||||||
(现场已验证 PyYAML 6.0.1)。
|
(现场已验证 PyYAML 6.0.1)。
|
||||||
|
|
||||||
|
当前 `retain_failed_episodes=false`:失败原因仍进入 recorder 状态和 journal,但失败
|
||||||
|
MCAP 不留在机器人磁盘。成功 episode 必须先在 PS 本机完成逐文件 SHA-256、fsync、
|
||||||
|
原子发布并生成 `VERIFIED`,然后由固定根目录删除助手按 episode ID + manifest SHA
|
||||||
|
精确回收;它不能访问 `active/`、`failed/` 或数据根目录。PS 断线时 `ready/` 会临时保留,
|
||||||
|
避免尚未落到本机的数据丢失,恢复后自动续传和回收。
|
||||||
|
|
||||||
相机由厂家系统服务管理,本项目只订阅、不自动启停。录制前检查或按需启动:
|
相机由厂家系统服务管理,本项目只订阅、不自动启停。录制前检查或按需启动:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -224,7 +228,8 @@ find /home/nvidia/tg3_data_collection/ready -mindepth 1 -maxdepth 1 -type d
|
|||||||
```
|
```
|
||||||
|
|
||||||
完成 episode 由 PS 本机服务校验后保存到项目 `Data_Get`,详见
|
完成 episode 由 PS 本机服务校验后保存到项目 `Data_Get`,详见
|
||||||
`../tg3_data_collection/README.md`。机器人 `ready/` 中的副本不会自动删除。
|
`../tg3_data_collection/README.md`。本机 `VERIFIED` 持久化后,机器人对应 `ready/`
|
||||||
|
副本会自动删除;EAI 不保存数采 payload。
|
||||||
|
|
||||||
## 完整重启顺序
|
## 完整重启顺序
|
||||||
|
|
||||||
|
|||||||
@@ -75,20 +75,17 @@ control_topic = "/tg3/data_collection/control"
|
|||||||
status_topic = "/tg3/data_collection/status"
|
status_topic = "/tg3/data_collection/status"
|
||||||
iarm_frame_topic = "/tg3/data_collection/iarm_frame"
|
iarm_frame_topic = "/tg3/data_collection/iarm_frame"
|
||||||
base_directory = "/home/nvidia/tg3_data_collection"
|
base_directory = "/home/nvidia/tg3_data_collection"
|
||||||
|
# Failed/incomplete payloads are discarded after the error is reported through
|
||||||
|
# recorder status and journal. Completed READY episodes remain only until the
|
||||||
|
# PS sync has durably verified its local copy and requests exact-episode removal.
|
||||||
|
retain_failed_episodes = false
|
||||||
minimum_free_gib = 100.0
|
minimum_free_gib = 100.0
|
||||||
max_duration_seconds = 1800.0
|
max_duration_seconds = 1800.0
|
||||||
# A camera endpoint merely existing is insufficient: completed episodes must
|
|
||||||
# also sustain this conservative average rate across the rosbag data span.
|
|
||||||
# The driver targets 30 Hz; 20 Hz tolerates startup/discovery jitter while
|
|
||||||
# rejecting a stream that only delivered a few frames or stopped mid-episode.
|
|
||||||
minimum_topic_rates_hz."/ob_camera_waist/color/image_raw/compressed" = 20.0
|
|
||||||
minimum_topic_rates_hz."/ob_camera_waist/depth/image_raw/compressedDepth" = 20.0
|
|
||||||
|
|
||||||
# Explicit data whitelist; never replace this with `-a` on the live robot.
|
# Explicit data whitelist; never replace this with `-a` on the live robot.
|
||||||
# RGB is recorded through image_transport JPEG and 16UC1 depth through its
|
# RGB is recorded through image_transport JPEG and 16UC1 depth through its
|
||||||
# lossless compressedDepth PNG transport. Waist RGB-D is required because that
|
# lossless compressedDepth PNG transport. Both RGB-D cameras are optional:
|
||||||
# camera is enabled on this robot. Head RGB-D stays optional: rosbag discovers
|
# rosbag discovers and records either one whenever its service is running.
|
||||||
# and records it automatically whenever orbbec_head.service is running.
|
|
||||||
# /robot_state is the authoritative measured robot state.
|
# /robot_state is the authoritative measured robot state.
|
||||||
topics = [
|
topics = [
|
||||||
"/robot_state",
|
"/robot_state",
|
||||||
@@ -137,18 +134,43 @@ required_topics = [
|
|||||||
"/left_hand/motor_status",
|
"/left_hand/motor_status",
|
||||||
"/right_hand/motor_status",
|
"/right_hand/motor_status",
|
||||||
"/hric/robot/rl_state",
|
"/hric/robot/rl_state",
|
||||||
|
"/tg3/data_collection/control",
|
||||||
|
"/tg3/data_collection/status",
|
||||||
|
"/tg3/data_collection/iarm_frame",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Optional sensor groups never block capture or core READY. Their message
|
||||||
|
# counts and whole-episode average rates are recorded in manifest.json as
|
||||||
|
# absent/healthy/partial/low_rate quality observations. The Orbbec driver
|
||||||
|
# targets 30 Hz; 20 Hz leaves room for discovery and compression jitter.
|
||||||
|
[data_collection.optional_topic_groups.head_rgbd]
|
||||||
|
topics = [
|
||||||
|
"/ob_camera_head/color/image_raw/compressed",
|
||||||
|
"/ob_camera_head/color/camera_info",
|
||||||
|
"/ob_camera_head/color/metadata",
|
||||||
|
"/ob_camera_head/depth/image_raw/compressedDepth",
|
||||||
|
"/ob_camera_head/depth/camera_info",
|
||||||
|
"/ob_camera_head/depth/metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[data_collection.optional_topic_groups.head_rgbd.minimum_topic_rates_hz]
|
||||||
|
"/ob_camera_head/color/image_raw/compressed" = 20.0
|
||||||
|
"/ob_camera_head/depth/image_raw/compressedDepth" = 20.0
|
||||||
|
|
||||||
|
[data_collection.optional_topic_groups.waist_rgbd]
|
||||||
|
topics = [
|
||||||
"/ob_camera_waist/color/image_raw/compressed",
|
"/ob_camera_waist/color/image_raw/compressed",
|
||||||
"/ob_camera_waist/color/camera_info",
|
"/ob_camera_waist/color/camera_info",
|
||||||
"/ob_camera_waist/color/metadata",
|
"/ob_camera_waist/color/metadata",
|
||||||
"/ob_camera_waist/depth/image_raw/compressedDepth",
|
"/ob_camera_waist/depth/image_raw/compressedDepth",
|
||||||
"/ob_camera_waist/depth/camera_info",
|
"/ob_camera_waist/depth/camera_info",
|
||||||
"/ob_camera_waist/depth/metadata",
|
"/ob_camera_waist/depth/metadata",
|
||||||
"/tf_static",
|
|
||||||
"/tg3/data_collection/control",
|
|
||||||
"/tg3/data_collection/status",
|
|
||||||
"/tg3/data_collection/iarm_frame",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[data_collection.optional_topic_groups.waist_rgbd.minimum_topic_rates_hz]
|
||||||
|
"/ob_camera_waist/color/image_raw/compressed" = 20.0
|
||||||
|
"/ob_camera_waist/depth/image_raw/compressedDepth" = 20.0
|
||||||
|
|
||||||
[locomotion]
|
[locomotion]
|
||||||
# The two original immediate bindings are independent: right C + left-stick
|
# The two original immediate bindings are independent: right C + left-stick
|
||||||
# vertical controls translation; left Z + right-stick horizontal controls
|
# vertical controls translation; left Z + right-stick horizontal controls
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ import yaml
|
|||||||
ToggleAction = Literal["start", "stop"]
|
ToggleAction = Literal["start", "stop"]
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_ros_topic(topic: Any) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(topic, str)
|
||||||
|
and topic.startswith("/")
|
||||||
|
and topic.strip() == topic
|
||||||
|
and not any(character.isspace() for character in topic)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def left_joystick_pressed(data: Mapping[str, Any]) -> bool | None:
|
def left_joystick_pressed(data: Mapping[str, Any]) -> bool | None:
|
||||||
"""Strictly parse the live-frame ``button_joystick.left`` value.
|
"""Strictly parse the live-frame ``button_joystick.left`` value.
|
||||||
|
|
||||||
@@ -195,6 +204,55 @@ class RecordingToggleGate:
|
|||||||
return "active" if self.active else "idle"
|
return "active" if self.active else "idle"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OptionalTopicGroupConfig:
|
||||||
|
"""Non-fatal completeness and rate checks for an optional sensor group."""
|
||||||
|
|
||||||
|
topics: Sequence[str]
|
||||||
|
minimum_topic_rates_hz: Mapping[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
topics = (
|
||||||
|
tuple(self.topics)
|
||||||
|
if isinstance(self.topics, Sequence)
|
||||||
|
and not isinstance(self.topics, str)
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
if not topics:
|
||||||
|
raise ValueError("optional topic group must contain at least one topic")
|
||||||
|
if any(not _valid_ros_topic(topic) for topic in topics):
|
||||||
|
raise ValueError(
|
||||||
|
"optional topic group topics must be absolute ROS topic names"
|
||||||
|
)
|
||||||
|
if len(set(topics)) != len(topics):
|
||||||
|
raise ValueError("optional topic group topics must not contain duplicates")
|
||||||
|
if not isinstance(self.minimum_topic_rates_hz, Mapping):
|
||||||
|
raise ValueError("optional minimum topic rates must be a mapping")
|
||||||
|
rates: dict[str, float] = {}
|
||||||
|
for topic, rate in self.minimum_topic_rates_hz.items():
|
||||||
|
if topic not in topics:
|
||||||
|
raise ValueError(
|
||||||
|
"optional minimum-rate topics must be a subset of group topics"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
isinstance(rate, bool)
|
||||||
|
or not isinstance(rate, (int, float))
|
||||||
|
or not math.isfinite(rate)
|
||||||
|
or rate <= 0.0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"optional minimum topic rate for {topic!r} must be "
|
||||||
|
"positive and finite"
|
||||||
|
)
|
||||||
|
rates[topic] = float(rate)
|
||||||
|
object.__setattr__(self, "topics", topics)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"minimum_topic_rates_hz",
|
||||||
|
MappingProxyType(rates),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RecorderConfig:
|
class RecorderConfig:
|
||||||
"""Static configuration for :class:`DataRecorderManager`."""
|
"""Static configuration for :class:`DataRecorderManager`."""
|
||||||
@@ -203,6 +261,10 @@ class RecorderConfig:
|
|||||||
topics: Sequence[str] = ()
|
topics: Sequence[str] = ()
|
||||||
required_topics: Sequence[str] = ()
|
required_topics: Sequence[str] = ()
|
||||||
minimum_topic_rates_hz: Mapping[str, float] = field(default_factory=dict)
|
minimum_topic_rates_hz: Mapping[str, float] = field(default_factory=dict)
|
||||||
|
optional_topic_groups: Mapping[str, OptionalTopicGroupConfig] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
retain_failed_episodes: bool = True
|
||||||
minimum_free_bytes: int = 5 * 1024**3
|
minimum_free_bytes: int = 5 * 1024**3
|
||||||
max_duration_seconds: float = 30 * 60.0
|
max_duration_seconds: float = 30 * 60.0
|
||||||
poll_interval_seconds: float = 0.1
|
poll_interval_seconds: float = 0.1
|
||||||
@@ -219,25 +281,13 @@ class RecorderConfig:
|
|||||||
minimum_topic_rates: dict[str, float] = {}
|
minimum_topic_rates: dict[str, float] = {}
|
||||||
if not topics:
|
if not topics:
|
||||||
raise ValueError("at least one recording topic is required")
|
raise ValueError("at least one recording topic is required")
|
||||||
if any(
|
if any(not _valid_ros_topic(topic) for topic in topics):
|
||||||
not isinstance(topic, str)
|
|
||||||
or not topic.startswith("/")
|
|
||||||
or topic.strip() != topic
|
|
||||||
or any(character.isspace() for character in topic)
|
|
||||||
for topic in topics
|
|
||||||
):
|
|
||||||
raise ValueError("recording topics must be absolute ROS topic names")
|
raise ValueError("recording topics must be absolute ROS topic names")
|
||||||
if len(set(topics)) != len(topics):
|
if len(set(topics)) != len(topics):
|
||||||
raise ValueError("recording topics must not contain duplicates")
|
raise ValueError("recording topics must not contain duplicates")
|
||||||
if len(set(required_topics)) != len(required_topics):
|
if len(set(required_topics)) != len(required_topics):
|
||||||
raise ValueError("required topics must not contain duplicates")
|
raise ValueError("required topics must not contain duplicates")
|
||||||
if any(
|
if any(not _valid_ros_topic(topic) for topic in required_topics):
|
||||||
not isinstance(topic, str)
|
|
||||||
or not topic.startswith("/")
|
|
||||||
or topic.strip() != topic
|
|
||||||
or any(character.isspace() for character in topic)
|
|
||||||
for topic in required_topics
|
|
||||||
):
|
|
||||||
raise ValueError("required topics must be absolute ROS topic names")
|
raise ValueError("required topics must be absolute ROS topic names")
|
||||||
if not set(required_topics).issubset(topics):
|
if not set(required_topics).issubset(topics):
|
||||||
raise ValueError("required topics must be a subset of recording topics")
|
raise ValueError("required topics must be a subset of recording topics")
|
||||||
@@ -258,6 +308,45 @@ class RecorderConfig:
|
|||||||
f"minimum topic rate for {topic!r} must be positive and finite"
|
f"minimum topic rate for {topic!r} must be positive and finite"
|
||||||
)
|
)
|
||||||
minimum_topic_rates[topic] = float(rate)
|
minimum_topic_rates[topic] = float(rate)
|
||||||
|
if not isinstance(self.optional_topic_groups, Mapping):
|
||||||
|
raise ValueError("optional topic groups must be a mapping")
|
||||||
|
optional_groups: dict[str, OptionalTopicGroupConfig] = {}
|
||||||
|
grouped_topics: set[str] = set()
|
||||||
|
for name, group in self.optional_topic_groups.items():
|
||||||
|
if (
|
||||||
|
not isinstance(name, str)
|
||||||
|
or not name
|
||||||
|
or len(name) > 64
|
||||||
|
or any(
|
||||||
|
character
|
||||||
|
not in "-_0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
for character in name
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"optional topic group names must contain 1-64 safe characters"
|
||||||
|
)
|
||||||
|
if not isinstance(group, OptionalTopicGroupConfig):
|
||||||
|
raise ValueError(
|
||||||
|
f"optional topic group {name!r} has an invalid configuration"
|
||||||
|
)
|
||||||
|
group_topics = set(group.topics)
|
||||||
|
if not group_topics.issubset(topics):
|
||||||
|
raise ValueError(
|
||||||
|
f"optional topic group {name!r} must be a subset of recording topics"
|
||||||
|
)
|
||||||
|
if group_topics.intersection(required_topics):
|
||||||
|
raise ValueError(
|
||||||
|
f"optional topic group {name!r} must be disjoint from required topics"
|
||||||
|
)
|
||||||
|
overlap = group_topics.intersection(grouped_topics)
|
||||||
|
if overlap:
|
||||||
|
raise ValueError(
|
||||||
|
"optional topic groups must be disjoint; repeated topics: "
|
||||||
|
+ ", ".join(sorted(overlap))
|
||||||
|
)
|
||||||
|
grouped_topics.update(group_topics)
|
||||||
|
optional_groups[name] = group
|
||||||
if (
|
if (
|
||||||
isinstance(self.minimum_free_bytes, bool)
|
isinstance(self.minimum_free_bytes, bool)
|
||||||
or not isinstance(self.minimum_free_bytes, int)
|
or not isinstance(self.minimum_free_bytes, int)
|
||||||
@@ -277,6 +366,8 @@ class RecorderConfig:
|
|||||||
raise ValueError("ROS 2 executable must be a non-empty string")
|
raise ValueError("ROS 2 executable must be a non-empty string")
|
||||||
if type(self.validate_bag_info) is not bool:
|
if type(self.validate_bag_info) is not bool:
|
||||||
raise ValueError("validate bag info must be a bool")
|
raise ValueError("validate bag info must be a bool")
|
||||||
|
if type(self.retain_failed_episodes) is not bool:
|
||||||
|
raise ValueError("retain failed episodes must be a bool")
|
||||||
object.__setattr__(self, "base_directory", base)
|
object.__setattr__(self, "base_directory", base)
|
||||||
object.__setattr__(self, "topics", topics)
|
object.__setattr__(self, "topics", topics)
|
||||||
object.__setattr__(self, "required_topics", required_topics)
|
object.__setattr__(self, "required_topics", required_topics)
|
||||||
@@ -285,6 +376,11 @@ class RecorderConfig:
|
|||||||
"minimum_topic_rates_hz",
|
"minimum_topic_rates_hz",
|
||||||
MappingProxyType(minimum_topic_rates),
|
MappingProxyType(minimum_topic_rates),
|
||||||
)
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"optional_topic_groups",
|
||||||
|
MappingProxyType(optional_groups),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RecorderProcess(Protocol):
|
class RecorderProcess(Protocol):
|
||||||
@@ -325,6 +421,8 @@ class _Episode:
|
|||||||
required_topic_message_counts: dict[str, int] | None = None
|
required_topic_message_counts: dict[str, int] | None = None
|
||||||
minimum_topic_message_counts: dict[str, int] | None = None
|
minimum_topic_message_counts: dict[str, int] | None = None
|
||||||
observed_topic_rates_hz: dict[str, float] | None = None
|
observed_topic_rates_hz: dict[str, float] | None = None
|
||||||
|
optional_topic_groups: dict[str, Any] | None = None
|
||||||
|
data_quality_warnings: list[str] | None = None
|
||||||
metadata_duration_nanoseconds: int | None = None
|
metadata_duration_nanoseconds: int | None = None
|
||||||
process: RecorderProcess | None = None
|
process: RecorderProcess | None = None
|
||||||
stdout_stream: Any = None
|
stdout_stream: Any = None
|
||||||
@@ -767,6 +865,8 @@ class DataRecorderManager:
|
|||||||
episode.minimum_topic_message_counts
|
episode.minimum_topic_message_counts
|
||||||
),
|
),
|
||||||
"observed_topic_rates_hz": episode.observed_topic_rates_hz,
|
"observed_topic_rates_hz": episode.observed_topic_rates_hz,
|
||||||
|
"optional_topic_groups": episode.optional_topic_groups,
|
||||||
|
"data_quality_warnings": episode.data_quality_warnings,
|
||||||
"metadata_duration_nanoseconds": (
|
"metadata_duration_nanoseconds": (
|
||||||
episode.metadata_duration_nanoseconds
|
episode.metadata_duration_nanoseconds
|
||||||
),
|
),
|
||||||
@@ -859,6 +959,7 @@ class DataRecorderManager:
|
|||||||
topic: all_counts.get(topic, 0) for topic in self.config.topics
|
topic: all_counts.get(topic, 0) for topic in self.config.topics
|
||||||
}
|
}
|
||||||
episode.required_topic_message_counts = required_counts
|
episode.required_topic_message_counts = required_counts
|
||||||
|
self._observe_optional_topic_groups(episode, information, all_counts)
|
||||||
missing = [
|
missing = [
|
||||||
topic for topic in self.config.required_topics if topic not in all_counts
|
topic for topic in self.config.required_topics if topic not in all_counts
|
||||||
]
|
]
|
||||||
@@ -877,6 +978,101 @@ class DataRecorderManager:
|
|||||||
self._validate_minimum_topic_rates(episode, information, all_counts)
|
self._validate_minimum_topic_rates(episode, information, all_counts)
|
||||||
return required_counts
|
return required_counts
|
||||||
|
|
||||||
|
def _observe_optional_topic_groups(
|
||||||
|
self,
|
||||||
|
episode: _Episode,
|
||||||
|
information: Mapping[str, Any],
|
||||||
|
all_counts: Mapping[str, int],
|
||||||
|
) -> None:
|
||||||
|
"""Classify optional sensor quality without invalidating core data."""
|
||||||
|
|
||||||
|
duration_nanoseconds: int | None = None
|
||||||
|
duration = information.get("duration")
|
||||||
|
if isinstance(duration, Mapping):
|
||||||
|
candidate = duration.get("nanoseconds")
|
||||||
|
if (
|
||||||
|
type(candidate) is int
|
||||||
|
and candidate > 0
|
||||||
|
):
|
||||||
|
duration_nanoseconds = candidate
|
||||||
|
episode.metadata_duration_nanoseconds = candidate
|
||||||
|
duration_seconds = (
|
||||||
|
None
|
||||||
|
if duration_nanoseconds is None
|
||||||
|
else duration_nanoseconds / 1_000_000_000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
observations: dict[str, Any] = {}
|
||||||
|
warnings: list[str] = []
|
||||||
|
for name, group in self.config.optional_topic_groups.items():
|
||||||
|
counts = {
|
||||||
|
topic: int(all_counts.get(topic, 0)) for topic in group.topics
|
||||||
|
}
|
||||||
|
empty_topics = [
|
||||||
|
topic for topic, count in counts.items() if count <= 0
|
||||||
|
]
|
||||||
|
all_absent = len(empty_topics) == len(group.topics)
|
||||||
|
minimum_counts: dict[str, int | None] = {}
|
||||||
|
observed_rates: dict[str, float | None] = {}
|
||||||
|
below_rate: list[str] = []
|
||||||
|
for topic, minimum_rate in group.minimum_topic_rates_hz.items():
|
||||||
|
if duration_seconds is None:
|
||||||
|
minimum_counts[topic] = None
|
||||||
|
observed_rates[topic] = None
|
||||||
|
if not all_absent:
|
||||||
|
below_rate.append(topic)
|
||||||
|
continue
|
||||||
|
minimum_count = math.floor(duration_seconds * minimum_rate)
|
||||||
|
observed_rate = counts[topic] / duration_seconds
|
||||||
|
minimum_counts[topic] = minimum_count
|
||||||
|
observed_rates[topic] = observed_rate
|
||||||
|
if not all_absent and counts[topic] < minimum_count:
|
||||||
|
below_rate.append(topic)
|
||||||
|
|
||||||
|
if all_absent:
|
||||||
|
state = "absent"
|
||||||
|
elif empty_topics:
|
||||||
|
state = "partial"
|
||||||
|
warnings.append(
|
||||||
|
f"optional topic group {name!r} is partial; zero-message "
|
||||||
|
"topics: " + ", ".join(empty_topics)
|
||||||
|
)
|
||||||
|
elif below_rate:
|
||||||
|
state = "low_rate"
|
||||||
|
if duration_seconds is None:
|
||||||
|
warnings.append(
|
||||||
|
f"optional topic group {name!r} rate could not be "
|
||||||
|
"validated because bag duration is unavailable"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
details = [
|
||||||
|
f"{topic}={observed_rates[topic]:.3f}Hz<"
|
||||||
|
f"{group.minimum_topic_rates_hz[topic]:.3f}Hz"
|
||||||
|
for topic in below_rate
|
||||||
|
]
|
||||||
|
warnings.append(
|
||||||
|
f"optional topic group {name!r} is below its observed "
|
||||||
|
"minimum rate: " + "; ".join(details)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
state = "healthy"
|
||||||
|
|
||||||
|
observations[name] = {
|
||||||
|
"state": state,
|
||||||
|
"topics": list(group.topics),
|
||||||
|
"topic_message_counts": counts,
|
||||||
|
"minimum_topic_rates_hz": dict(
|
||||||
|
group.minimum_topic_rates_hz
|
||||||
|
),
|
||||||
|
"minimum_topic_message_counts": minimum_counts,
|
||||||
|
"observed_topic_rates_hz": observed_rates,
|
||||||
|
"zero_message_topics": empty_topics,
|
||||||
|
"below_minimum_rate_topics": below_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
episode.optional_topic_groups = observations
|
||||||
|
episode.data_quality_warnings = warnings
|
||||||
|
|
||||||
def _validate_minimum_topic_rates(
|
def _validate_minimum_topic_rates(
|
||||||
self,
|
self,
|
||||||
episode: _Episode,
|
episode: _Episode,
|
||||||
@@ -1036,6 +1232,31 @@ class DataRecorderManager:
|
|||||||
) -> tuple[Path | None, str]:
|
) -> tuple[Path | None, str]:
|
||||||
if not episode.active_directory.exists():
|
if not episode.active_directory.exists():
|
||||||
return None, "active episode directory is missing"
|
return None, "active episode directory is missing"
|
||||||
|
if not self.config.retain_failed_episodes:
|
||||||
|
try:
|
||||||
|
active_root = Path(self.config.base_directory) / "active"
|
||||||
|
target = episode.active_directory
|
||||||
|
if (
|
||||||
|
target.parent != active_root
|
||||||
|
or target.name != episode.request.episode_id
|
||||||
|
or target.is_symlink()
|
||||||
|
or active_root.is_symlink()
|
||||||
|
):
|
||||||
|
raise RecordingError(
|
||||||
|
"refusing to discard a failed episode outside its "
|
||||||
|
"fixed active root"
|
||||||
|
)
|
||||||
|
shutil.rmtree(target)
|
||||||
|
try:
|
||||||
|
self._sync_directory(active_root)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None, ""
|
||||||
|
except Exception as discard_error:
|
||||||
|
return None, (
|
||||||
|
"failed to discard project-owned failed episode: "
|
||||||
|
f"{type(discard_error).__name__}: {discard_error}"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
ready_marker = episode.active_directory / "READY"
|
ready_marker = episode.active_directory / "READY"
|
||||||
if ready_marker.exists():
|
if ready_marker.exists():
|
||||||
@@ -1063,6 +1284,8 @@ class DataRecorderManager:
|
|||||||
episode.minimum_topic_message_counts
|
episode.minimum_topic_message_counts
|
||||||
),
|
),
|
||||||
"observed_topic_rates_hz": episode.observed_topic_rates_hz,
|
"observed_topic_rates_hz": episode.observed_topic_rates_hz,
|
||||||
|
"optional_topic_groups": episode.optional_topic_groups,
|
||||||
|
"data_quality_warnings": episode.data_quality_warnings,
|
||||||
"metadata_duration_nanoseconds": (
|
"metadata_duration_nanoseconds": (
|
||||||
episode.metadata_duration_nanoseconds
|
episode.metadata_duration_nanoseconds
|
||||||
),
|
),
|
||||||
@@ -1242,6 +1465,7 @@ class DataRecorderManager:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DataRecorderManager",
|
"DataRecorderManager",
|
||||||
|
"OptionalTopicGroupConfig",
|
||||||
"RecorderConfig",
|
"RecorderConfig",
|
||||||
"RecordingError",
|
"RecordingError",
|
||||||
"RecordingToggleGate",
|
"RecordingToggleGate",
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ from rclpy.qos import (
|
|||||||
from rclpy.utilities import remove_ros_args
|
from rclpy.utilities import remove_ros_args
|
||||||
from std_msgs.msg import String
|
from std_msgs.msg import String
|
||||||
|
|
||||||
from data_collection import DataRecorderManager, RecorderConfig
|
from data_collection import (
|
||||||
|
DataRecorderManager,
|
||||||
|
OptionalTopicGroupConfig,
|
||||||
|
RecorderConfig,
|
||||||
|
)
|
||||||
from data_recorder_protocol import (
|
from data_recorder_protocol import (
|
||||||
RecorderControlProtocol,
|
RecorderControlProtocol,
|
||||||
topics_without_publishers,
|
topics_without_publishers,
|
||||||
@@ -102,6 +106,41 @@ def _minimum_topic_rates(value: Any) -> dict[str, float]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_topic_groups(
|
||||||
|
value: Any,
|
||||||
|
) -> dict[str, OptionalTopicGroupConfig]:
|
||||||
|
name = "optional_topic_groups"
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(f"data_collection.{name} must be a TOML table")
|
||||||
|
result: dict[str, OptionalTopicGroupConfig] = {}
|
||||||
|
for group_name, raw_group in value.items():
|
||||||
|
if not isinstance(raw_group, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"data_collection.{name}.{group_name} must be a TOML table"
|
||||||
|
)
|
||||||
|
unknown = sorted(
|
||||||
|
set(raw_group) - {"topics", "minimum_topic_rates_hz"}
|
||||||
|
)
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"data_collection.{name}.{group_name} has unknown keys: "
|
||||||
|
+ ", ".join(unknown)
|
||||||
|
)
|
||||||
|
topics = _topic_list(
|
||||||
|
raw_group.get("topics", []),
|
||||||
|
f"{name}.{group_name}.topics",
|
||||||
|
allow_empty=False,
|
||||||
|
)
|
||||||
|
rates = _minimum_topic_rates(
|
||||||
|
raw_group.get("minimum_topic_rates_hz", {})
|
||||||
|
)
|
||||||
|
result[group_name] = OptionalTopicGroupConfig(
|
||||||
|
topics=topics,
|
||||||
|
minimum_topic_rates_hz=rates,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SupervisorSettings:
|
class SupervisorSettings:
|
||||||
enabled: bool
|
enabled: bool
|
||||||
@@ -150,6 +189,9 @@ def load_settings(path: Path | str) -> SupervisorSettings:
|
|||||||
minimum_topic_rates = _minimum_topic_rates(
|
minimum_topic_rates = _minimum_topic_rates(
|
||||||
section.get("minimum_topic_rates_hz", {})
|
section.get("minimum_topic_rates_hz", {})
|
||||||
)
|
)
|
||||||
|
optional_topic_groups = _optional_topic_groups(
|
||||||
|
section.get("optional_topic_groups", {})
|
||||||
|
)
|
||||||
unknown_rate_topics = sorted(
|
unknown_rate_topics = sorted(
|
||||||
set(minimum_topic_rates) - set(required_topics)
|
set(minimum_topic_rates) - set(required_topics)
|
||||||
)
|
)
|
||||||
@@ -187,6 +229,11 @@ def load_settings(path: Path | str) -> SupervisorSettings:
|
|||||||
topics=topics,
|
topics=topics,
|
||||||
required_topics=required_topics,
|
required_topics=required_topics,
|
||||||
minimum_topic_rates_hz=minimum_topic_rates,
|
minimum_topic_rates_hz=minimum_topic_rates,
|
||||||
|
optional_topic_groups=optional_topic_groups,
|
||||||
|
retain_failed_episodes=_boolean(
|
||||||
|
section.get("retain_failed_episodes", True),
|
||||||
|
"retain_failed_episodes",
|
||||||
|
),
|
||||||
minimum_free_bytes=int(minimum_free_gib * 1024**3),
|
minimum_free_bytes=int(minimum_free_gib * 1024**3),
|
||||||
max_duration_seconds=max_duration,
|
max_duration_seconds=max_duration,
|
||||||
poll_interval_seconds=0.1,
|
poll_interval_seconds=0.1,
|
||||||
|
|||||||
537
tg3_local_teleop/delete_ready_episode.py
Executable file
537
tg3_local_teleop/delete_ready_episode.py
Executable file
@@ -0,0 +1,537 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Safely and idempotently remove one verified recorder ``ready`` episode.
|
||||||
|
|
||||||
|
This helper intentionally has no command-line option for either root directory.
|
||||||
|
It can only move a direct child of the fixed recorder ``ready`` directory into
|
||||||
|
the fixed ``deleting`` staging directory and remove that tombstone. The caller
|
||||||
|
must provide both the episode id and the SHA-256 of the exact manifest that was
|
||||||
|
verified and durably published on the PS workstation.
|
||||||
|
|
||||||
|
Before recursive removal, an authorization ledger is fsynced in the fixed
|
||||||
|
``delete_ledger`` sibling directory. It survives partial tombstone deletion and
|
||||||
|
is removed only after the tree cleanup itself is durable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
BASE_ROOT = Path("/home/nvidia/tg3_data_collection")
|
||||||
|
READY_ROOT = BASE_ROOT / "ready"
|
||||||
|
DELETING_ROOT = BASE_ROOT / "deleting"
|
||||||
|
LEDGER_ROOT = BASE_ROOT / "delete_ledger"
|
||||||
|
SAFE_EPISODE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
||||||
|
SAFE_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
MAX_MANIFEST_BYTES = 16 * 1024 * 1024
|
||||||
|
MAX_LEDGER_BYTES = 64 * 1024
|
||||||
|
LEDGER_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteSafetyError(RuntimeError):
|
||||||
|
"""The requested deletion did not satisfy the fixed safety contract."""
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteCollisionError(DeleteSafetyError):
|
||||||
|
"""A conflicting ready/deleting episode exists and was left untouched."""
|
||||||
|
|
||||||
|
|
||||||
|
def safe_episode_name(value: str) -> bool:
|
||||||
|
return bool(SAFE_EPISODE_NAME.fullmatch(value)) and value not in (".", "..")
|
||||||
|
|
||||||
|
|
||||||
|
def _open_directory(path: Path) -> int:
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(path, flags)
|
||||||
|
except OSError as exc:
|
||||||
|
raise DeleteSafetyError(f"unsafe or missing directory: {path}: {exc}") from exc
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISDIR(metadata.st_mode):
|
||||||
|
raise DeleteSafetyError(f"not a directory: {path}")
|
||||||
|
return descriptor
|
||||||
|
except Exception:
|
||||||
|
os.close(descriptor)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _open_child_directory(parent_fd: int, name: str) -> int:
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(name, flags, dir_fd=parent_fd)
|
||||||
|
except OSError as exc:
|
||||||
|
raise DeleteSafetyError(f"unsafe or missing episode directory: {name}: {exc}") from exc
|
||||||
|
try:
|
||||||
|
if not stat.S_ISDIR(os.fstat(descriptor).st_mode):
|
||||||
|
raise DeleteSafetyError(f"episode is not a directory: {name}")
|
||||||
|
return descriptor
|
||||||
|
except Exception:
|
||||||
|
os.close(descriptor)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _read_regular_file(parent_fd: int, name: str, *, max_bytes: int) -> bytes:
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(name, flags, dir_fd=parent_fd)
|
||||||
|
except OSError as exc:
|
||||||
|
raise DeleteSafetyError(f"missing or unsafe regular file {name!r}: {exc}") from exc
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise DeleteSafetyError(f"not a regular file: {name}")
|
||||||
|
if metadata.st_size > max_bytes:
|
||||||
|
raise DeleteSafetyError(f"file is too large: {name}")
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
remaining = max_bytes + 1
|
||||||
|
while remaining:
|
||||||
|
block = os.read(descriptor, min(1024 * 1024, remaining))
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
chunks.append(block)
|
||||||
|
remaining -= len(block)
|
||||||
|
payload = b"".join(chunks)
|
||||||
|
if len(payload) > max_bytes:
|
||||||
|
raise DeleteSafetyError(f"file is too large: {name}")
|
||||||
|
return payload
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_tree_entries(directory_fd: int) -> None:
|
||||||
|
"""Require the quarantined recorder tree to contain only dirs/files."""
|
||||||
|
|
||||||
|
for child in os.listdir(directory_fd):
|
||||||
|
if child in (".", "..") or "/" in child or "\x00" in child:
|
||||||
|
raise DeleteSafetyError(f"unsafe directory entry: {child!r}")
|
||||||
|
metadata = os.stat(child, dir_fd=directory_fd, follow_symlinks=False)
|
||||||
|
if stat.S_ISDIR(metadata.st_mode):
|
||||||
|
child_fd = _open_child_directory(directory_fd, child)
|
||||||
|
try:
|
||||||
|
_validate_tree_entries(child_fd)
|
||||||
|
finally:
|
||||||
|
os.close(child_fd)
|
||||||
|
elif not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise DeleteSafetyError(f"episode contains a symlink/special entry: {child}")
|
||||||
|
|
||||||
|
|
||||||
|
def _ledger_name(episode: str, manifest_sha256: str) -> str:
|
||||||
|
return f"{episode}.{manifest_sha256}.authorized.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _ledger_payload(
|
||||||
|
episode: str, manifest_sha256: str, tombstone: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": LEDGER_SCHEMA_VERSION,
|
||||||
|
"state": "AUTHORIZED_DELETE",
|
||||||
|
"episode_id": episode,
|
||||||
|
"manifest_sha256": manifest_sha256,
|
||||||
|
"tombstone": tombstone,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_ledger(
|
||||||
|
ledger_fd: int,
|
||||||
|
ledger_name: str,
|
||||||
|
*,
|
||||||
|
episode: str,
|
||||||
|
manifest_sha256: str,
|
||||||
|
tombstone: str,
|
||||||
|
) -> None:
|
||||||
|
raw = _read_regular_file(ledger_fd, ledger_name, max_bytes=MAX_LEDGER_BYTES)
|
||||||
|
try:
|
||||||
|
payload: Any = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise DeleteSafetyError(f"invalid deletion authorization ledger: {exc}") from exc
|
||||||
|
if payload != _ledger_payload(episode, manifest_sha256, tombstone):
|
||||||
|
raise DeleteSafetyError("deletion authorization ledger identity mismatch")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_ledger(
|
||||||
|
ledger_fd: int,
|
||||||
|
ledger_name: str,
|
||||||
|
*,
|
||||||
|
episode: str,
|
||||||
|
manifest_sha256: str,
|
||||||
|
tombstone: str,
|
||||||
|
) -> None:
|
||||||
|
payload = (
|
||||||
|
json.dumps(
|
||||||
|
_ledger_payload(episode, manifest_sha256, tombstone),
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
temporary = f".{ledger_name}.{uuid.uuid4().hex}.tmp"
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
descriptor = os.open(temporary, flags, 0o600, dir_fd=ledger_fd)
|
||||||
|
try:
|
||||||
|
view = memoryview(payload)
|
||||||
|
while view:
|
||||||
|
written = os.write(descriptor, view)
|
||||||
|
if written <= 0:
|
||||||
|
raise OSError("short write while creating deletion ledger")
|
||||||
|
view = view[written:]
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
try:
|
||||||
|
# A hard-link publish is atomic and refuses to replace any existing
|
||||||
|
# sidecar. The helper flock serializes legitimate writers as well.
|
||||||
|
os.link(
|
||||||
|
temporary,
|
||||||
|
ledger_name,
|
||||||
|
src_dir_fd=ledger_fd,
|
||||||
|
dst_dir_fd=ledger_fd,
|
||||||
|
follow_symlinks=False,
|
||||||
|
)
|
||||||
|
os.fsync(ledger_fd)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(temporary, dir_fd=ledger_fd)
|
||||||
|
os.fsync(ledger_fd)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_episode(
|
||||||
|
parent_fd: int,
|
||||||
|
directory_name: str,
|
||||||
|
*,
|
||||||
|
episode: str,
|
||||||
|
expected_manifest_sha256: str,
|
||||||
|
) -> None:
|
||||||
|
episode_fd = _open_child_directory(parent_fd, directory_name)
|
||||||
|
try:
|
||||||
|
# READY must be a real regular file. Its contents are deliberately not
|
||||||
|
# trusted as a deletion credential; the exact manifest digest is.
|
||||||
|
_read_regular_file(episode_fd, "READY", max_bytes=4096)
|
||||||
|
raw_manifest = _read_regular_file(
|
||||||
|
episode_fd, "manifest.json", max_bytes=MAX_MANIFEST_BYTES
|
||||||
|
)
|
||||||
|
actual_digest = hashlib.sha256(raw_manifest).hexdigest()
|
||||||
|
if actual_digest != expected_manifest_sha256:
|
||||||
|
raise DeleteSafetyError(
|
||||||
|
"manifest SHA-256 mismatch: "
|
||||||
|
f"expected {expected_manifest_sha256}, got {actual_digest}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
manifest: Any = json.loads(raw_manifest.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise DeleteSafetyError(f"invalid manifest JSON: {exc}") from exc
|
||||||
|
if not isinstance(manifest, dict):
|
||||||
|
raise DeleteSafetyError("manifest root is not an object")
|
||||||
|
if manifest.get("state") != "complete":
|
||||||
|
raise DeleteSafetyError("manifest state is not complete")
|
||||||
|
if manifest.get("episode_id") != episode:
|
||||||
|
raise DeleteSafetyError("manifest episode_id does not match target")
|
||||||
|
_validate_tree_entries(episode_fd)
|
||||||
|
finally:
|
||||||
|
os.close(episode_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_tree_at(parent_fd: int, name: str) -> None:
|
||||||
|
"""Remove one child tree without following any symbolic link."""
|
||||||
|
|
||||||
|
directory_fd = _open_child_directory(parent_fd, name)
|
||||||
|
try:
|
||||||
|
for child in os.listdir(directory_fd):
|
||||||
|
if child in (".", "..") or "/" in child or "\x00" in child:
|
||||||
|
raise DeleteSafetyError(f"unsafe directory entry: {child!r}")
|
||||||
|
metadata = os.stat(child, dir_fd=directory_fd, follow_symlinks=False)
|
||||||
|
if stat.S_ISDIR(metadata.st_mode):
|
||||||
|
_remove_tree_at(directory_fd, child)
|
||||||
|
else:
|
||||||
|
# Symlinks and all non-directory entries are unlinked, never
|
||||||
|
# followed. Recorder output is expected to contain only files.
|
||||||
|
os.unlink(child, dir_fd=directory_fd)
|
||||||
|
os.fsync(directory_fd)
|
||||||
|
finally:
|
||||||
|
os.close(directory_fd)
|
||||||
|
os.rmdir(name, dir_fd=parent_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def _child_kind(parent_fd: int, name: str) -> str:
|
||||||
|
try:
|
||||||
|
metadata = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "absent"
|
||||||
|
if stat.S_ISDIR(metadata.st_mode):
|
||||||
|
return "directory"
|
||||||
|
if stat.S_ISLNK(metadata.st_mode):
|
||||||
|
return "symlink"
|
||||||
|
if stat.S_ISREG(metadata.st_mode):
|
||||||
|
return "regular"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def delete_ready_episode(
|
||||||
|
episode: str,
|
||||||
|
manifest_sha256: str,
|
||||||
|
*,
|
||||||
|
ready_root: Path = READY_ROOT,
|
||||||
|
deleting_root: Path = DELETING_ROOT,
|
||||||
|
ledger_root: Path | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Compare, atomically quarantine, and remove exactly one ready episode.
|
||||||
|
|
||||||
|
Root arguments are injectable only for unit tests. The CLI below always
|
||||||
|
uses the immutable production constants, including a ledger directory that
|
||||||
|
is outside the recursively removed ``deleting`` tree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not safe_episode_name(episode):
|
||||||
|
raise DeleteSafetyError(f"unsafe episode id: {episode!r}")
|
||||||
|
if not SAFE_SHA256.fullmatch(manifest_sha256):
|
||||||
|
raise DeleteSafetyError("manifest SHA-256 must be 64 lowercase hex characters")
|
||||||
|
|
||||||
|
ready_root = Path(ready_root)
|
||||||
|
deleting_root = Path(deleting_root)
|
||||||
|
ledger_root = Path(ledger_root or (deleting_root.parent / LEDGER_ROOT.name))
|
||||||
|
if not (
|
||||||
|
ready_root.parent == deleting_root.parent == ledger_root.parent
|
||||||
|
):
|
||||||
|
raise DeleteSafetyError("ready, deleting, and ledger roots must be siblings")
|
||||||
|
base_root = ready_root.parent
|
||||||
|
|
||||||
|
# Refuse a symlinked/wrong parent even in tests. resolve(strict=True) also
|
||||||
|
# proves that all existing parent components resolve to this exact path.
|
||||||
|
try:
|
||||||
|
if base_root.resolve(strict=True) != base_root.absolute():
|
||||||
|
raise DeleteSafetyError("base root must not contain symlinks")
|
||||||
|
if ready_root.resolve(strict=True) != ready_root.absolute():
|
||||||
|
raise DeleteSafetyError("ready root must not contain symlinks")
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise DeleteSafetyError(f"fixed recorder root is missing: {exc}") from exc
|
||||||
|
|
||||||
|
base_fd = _open_directory(base_root)
|
||||||
|
ready_fd = _open_directory(ready_root)
|
||||||
|
deleting_fd: int | None = None
|
||||||
|
ledger_fd: int | None = None
|
||||||
|
lock_fd: int | None = None
|
||||||
|
try:
|
||||||
|
base_stat = os.fstat(base_fd)
|
||||||
|
ready_stat = os.fstat(ready_fd)
|
||||||
|
if base_stat.st_dev != ready_stat.st_dev:
|
||||||
|
raise DeleteSafetyError("ready root is not on the recorder filesystem")
|
||||||
|
|
||||||
|
deleting_kind = _child_kind(base_fd, deleting_root.name)
|
||||||
|
if deleting_kind == "absent":
|
||||||
|
os.mkdir(deleting_root.name, mode=0o750, dir_fd=base_fd)
|
||||||
|
os.fsync(base_fd)
|
||||||
|
elif deleting_kind != "directory":
|
||||||
|
raise DeleteSafetyError("deleting root is not a real directory")
|
||||||
|
deleting_fd = _open_directory(deleting_root)
|
||||||
|
if os.fstat(deleting_fd).st_dev != ready_stat.st_dev:
|
||||||
|
raise DeleteSafetyError("deleting root is not on the ready filesystem")
|
||||||
|
|
||||||
|
ledger_kind = _child_kind(base_fd, ledger_root.name)
|
||||||
|
if ledger_kind == "absent":
|
||||||
|
os.mkdir(ledger_root.name, mode=0o750, dir_fd=base_fd)
|
||||||
|
os.fsync(base_fd)
|
||||||
|
elif ledger_kind != "directory":
|
||||||
|
raise DeleteSafetyError("delete ledger root is not a real directory")
|
||||||
|
ledger_fd = _open_directory(ledger_root)
|
||||||
|
if os.fstat(ledger_fd).st_dev != ready_stat.st_dev:
|
||||||
|
raise DeleteSafetyError("delete ledger is not on the ready filesystem")
|
||||||
|
|
||||||
|
lock_flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
lock_fd = os.open(".delete_ready.lock", lock_flags, 0o600, dir_fd=deleting_fd)
|
||||||
|
if not stat.S_ISREG(os.fstat(lock_fd).st_mode):
|
||||||
|
raise DeleteSafetyError("deletion lock is not a regular file")
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||||
|
|
||||||
|
tombstone = f"{episode}.{manifest_sha256}.deleting"
|
||||||
|
authorization = _ledger_name(episode, manifest_sha256)
|
||||||
|
ready_kind = _child_kind(ready_fd, episode)
|
||||||
|
tombstone_kind = _child_kind(deleting_fd, tombstone)
|
||||||
|
authorization_kind = _child_kind(ledger_fd, authorization)
|
||||||
|
|
||||||
|
# Any other tombstone for this episode is a manifest-identity collision.
|
||||||
|
prefix = f"{episode}."
|
||||||
|
collisions = [
|
||||||
|
name
|
||||||
|
for name in os.listdir(deleting_fd)
|
||||||
|
if name.startswith(prefix)
|
||||||
|
and name.endswith(".deleting")
|
||||||
|
and name != tombstone
|
||||||
|
]
|
||||||
|
if collisions:
|
||||||
|
raise DeleteCollisionError(
|
||||||
|
f"conflicting deleting tombstone(s) exist: {sorted(collisions)!r}"
|
||||||
|
)
|
||||||
|
ledger_collisions = [
|
||||||
|
name
|
||||||
|
for name in os.listdir(ledger_fd)
|
||||||
|
if name.startswith(prefix)
|
||||||
|
and name.endswith(".authorized.json")
|
||||||
|
and name != authorization
|
||||||
|
]
|
||||||
|
if ledger_collisions:
|
||||||
|
raise DeleteCollisionError(
|
||||||
|
"conflicting deletion authorization(s) exist: "
|
||||||
|
f"{sorted(ledger_collisions)!r}"
|
||||||
|
)
|
||||||
|
if ready_kind not in ("absent", "directory"):
|
||||||
|
raise DeleteSafetyError(f"ready target is unsafe: {ready_kind}")
|
||||||
|
if tombstone_kind not in ("absent", "directory"):
|
||||||
|
raise DeleteSafetyError(f"deleting target is unsafe: {tombstone_kind}")
|
||||||
|
if authorization_kind not in ("absent", "regular"):
|
||||||
|
raise DeleteSafetyError(
|
||||||
|
f"deletion authorization is unsafe: {authorization_kind}"
|
||||||
|
)
|
||||||
|
if ready_kind == "directory" and tombstone_kind == "directory":
|
||||||
|
raise DeleteCollisionError("ready target and deleting tombstone both exist")
|
||||||
|
if ready_kind == "directory" and authorization_kind == "regular":
|
||||||
|
raise DeleteCollisionError(
|
||||||
|
"ready target exists alongside a stale deletion authorization"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ready_kind == "directory":
|
||||||
|
_validate_episode(
|
||||||
|
ready_fd,
|
||||||
|
episode,
|
||||||
|
episode=episode,
|
||||||
|
expected_manifest_sha256=manifest_sha256,
|
||||||
|
)
|
||||||
|
os.rename(
|
||||||
|
episode,
|
||||||
|
tombstone,
|
||||||
|
src_dir_fd=ready_fd,
|
||||||
|
dst_dir_fd=deleting_fd,
|
||||||
|
)
|
||||||
|
os.fsync(ready_fd)
|
||||||
|
os.fsync(deleting_fd)
|
||||||
|
# Re-validate after the rename. If the source name was exchanged
|
||||||
|
# between the first comparison and rename, the unexpected tree is
|
||||||
|
# quarantined but is never unlinked.
|
||||||
|
_validate_episode(
|
||||||
|
deleting_fd,
|
||||||
|
tombstone,
|
||||||
|
episode=episode,
|
||||||
|
expected_manifest_sha256=manifest_sha256,
|
||||||
|
)
|
||||||
|
_write_ledger(
|
||||||
|
ledger_fd,
|
||||||
|
authorization,
|
||||||
|
episode=episode,
|
||||||
|
manifest_sha256=manifest_sha256,
|
||||||
|
tombstone=tombstone,
|
||||||
|
)
|
||||||
|
authorization_kind = "regular"
|
||||||
|
result_state = "deleted"
|
||||||
|
elif tombstone_kind == "directory":
|
||||||
|
if authorization_kind == "regular":
|
||||||
|
# The durable out-of-tree ledger remains usable even if an
|
||||||
|
# interrupted recursive removal already removed READY and the
|
||||||
|
# manifest from inside the tombstone.
|
||||||
|
_validate_ledger(
|
||||||
|
ledger_fd,
|
||||||
|
authorization,
|
||||||
|
episode=episode,
|
||||||
|
manifest_sha256=manifest_sha256,
|
||||||
|
tombstone=tombstone,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# A crash between rename and ledger publication is recoverable
|
||||||
|
# only while the tombstone still proves its original identity.
|
||||||
|
_validate_episode(
|
||||||
|
deleting_fd,
|
||||||
|
tombstone,
|
||||||
|
episode=episode,
|
||||||
|
expected_manifest_sha256=manifest_sha256,
|
||||||
|
)
|
||||||
|
_write_ledger(
|
||||||
|
ledger_fd,
|
||||||
|
authorization,
|
||||||
|
episode=episode,
|
||||||
|
manifest_sha256=manifest_sha256,
|
||||||
|
tombstone=tombstone,
|
||||||
|
)
|
||||||
|
authorization_kind = "regular"
|
||||||
|
result_state = "resumed_delete"
|
||||||
|
else:
|
||||||
|
if authorization_kind == "regular":
|
||||||
|
# The tree was fully removed and only the final ledger cleanup
|
||||||
|
# (or its ACK) was interrupted.
|
||||||
|
_validate_ledger(
|
||||||
|
ledger_fd,
|
||||||
|
authorization,
|
||||||
|
episode=episode,
|
||||||
|
manifest_sha256=manifest_sha256,
|
||||||
|
tombstone=tombstone,
|
||||||
|
)
|
||||||
|
os.unlink(authorization, dir_fd=ledger_fd)
|
||||||
|
os.fsync(ledger_fd)
|
||||||
|
return {
|
||||||
|
"state": "already_absent",
|
||||||
|
"episode_id": episode,
|
||||||
|
"manifest_sha256": manifest_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
_remove_tree_at(deleting_fd, tombstone)
|
||||||
|
os.fsync(deleting_fd)
|
||||||
|
# Delete authorization is deliberately the final artifact removed.
|
||||||
|
# If this unlink/ACK is lost, the next invocation validates it and
|
||||||
|
# safely returns already_absent.
|
||||||
|
_validate_ledger(
|
||||||
|
ledger_fd,
|
||||||
|
authorization,
|
||||||
|
episode=episode,
|
||||||
|
manifest_sha256=manifest_sha256,
|
||||||
|
tombstone=tombstone,
|
||||||
|
)
|
||||||
|
os.unlink(authorization, dir_fd=ledger_fd)
|
||||||
|
os.fsync(ledger_fd)
|
||||||
|
return {
|
||||||
|
"state": result_state,
|
||||||
|
"episode_id": episode,
|
||||||
|
"manifest_sha256": manifest_sha256,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
if lock_fd is not None:
|
||||||
|
os.close(lock_fd)
|
||||||
|
if deleting_fd is not None:
|
||||||
|
os.close(deleting_fd)
|
||||||
|
if ledger_fd is not None:
|
||||||
|
os.close(ledger_fd)
|
||||||
|
os.close(ready_fd)
|
||||||
|
os.close(base_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("episode_id")
|
||||||
|
parser.add_argument("manifest_sha256")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
try:
|
||||||
|
result = delete_ready_episode(args.episode_id, args.manifest_sha256)
|
||||||
|
except DeleteCollisionError as exc:
|
||||||
|
print(json.dumps({"state": "collision", "error": str(exc)}))
|
||||||
|
return 3
|
||||||
|
except (DeleteSafetyError, OSError) as exc:
|
||||||
|
print(json.dumps({"state": "refused", "error": str(exc)}))
|
||||||
|
return 2
|
||||||
|
print(json.dumps(result, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -15,12 +15,45 @@ from typing import Any
|
|||||||
|
|
||||||
from data_collection import (
|
from data_collection import (
|
||||||
DataRecorderManager,
|
DataRecorderManager,
|
||||||
|
OptionalTopicGroupConfig,
|
||||||
RecorderConfig,
|
RecorderConfig,
|
||||||
RecordingToggleGate,
|
RecordingToggleGate,
|
||||||
left_joystick_pressed,
|
left_joystick_pressed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
HEAD_CAMERA_TOPICS = (
|
||||||
|
"/ob_camera_head/color/image_raw/compressed",
|
||||||
|
"/ob_camera_head/color/camera_info",
|
||||||
|
"/ob_camera_head/color/metadata",
|
||||||
|
"/ob_camera_head/depth/image_raw/compressedDepth",
|
||||||
|
"/ob_camera_head/depth/camera_info",
|
||||||
|
"/ob_camera_head/depth/metadata",
|
||||||
|
)
|
||||||
|
WAIST_CAMERA_TOPICS = tuple(
|
||||||
|
topic.replace("_head", "_waist") for topic in HEAD_CAMERA_TOPICS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def optional_camera_groups() -> dict[str, OptionalTopicGroupConfig]:
|
||||||
|
return {
|
||||||
|
"head_rgbd": OptionalTopicGroupConfig(
|
||||||
|
topics=HEAD_CAMERA_TOPICS,
|
||||||
|
minimum_topic_rates_hz={
|
||||||
|
HEAD_CAMERA_TOPICS[0]: 20.0,
|
||||||
|
HEAD_CAMERA_TOPICS[3]: 20.0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"waist_rgbd": OptionalTopicGroupConfig(
|
||||||
|
topics=WAIST_CAMERA_TOPICS,
|
||||||
|
minimum_topic_rates_hz={
|
||||||
|
WAIST_CAMERA_TOPICS[0]: 20.0,
|
||||||
|
WAIST_CAMERA_TOPICS[3]: 20.0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class LeftJoystickParserTest(unittest.TestCase):
|
class LeftJoystickParserTest(unittest.TestCase):
|
||||||
def test_accepts_only_live_bool_or_binary_integer(self) -> None:
|
def test_accepts_only_live_bool_or_binary_integer(self) -> None:
|
||||||
self.assertIs(
|
self.assertIs(
|
||||||
@@ -291,6 +324,10 @@ class DataRecorderManagerTest(unittest.TestCase):
|
|||||||
topics: Sequence[str] = ("/joint_states", "/tf"),
|
topics: Sequence[str] = ("/joint_states", "/tf"),
|
||||||
required_topics: Sequence[str] = ("/joint_states", "/tf"),
|
required_topics: Sequence[str] = ("/joint_states", "/tf"),
|
||||||
minimum_topic_rates_hz: Mapping[str, float] | None = None,
|
minimum_topic_rates_hz: Mapping[str, float] | None = None,
|
||||||
|
optional_topic_groups: Mapping[
|
||||||
|
str, OptionalTopicGroupConfig
|
||||||
|
] | None = None,
|
||||||
|
retain_failed_episodes: bool = True,
|
||||||
) -> tuple[DataRecorderManager, Any]:
|
) -> tuple[DataRecorderManager, Any]:
|
||||||
if factory is None:
|
if factory is None:
|
||||||
factory = FakeProcessFactory(**(process_options or {}))
|
factory = FakeProcessFactory(**(process_options or {}))
|
||||||
@@ -305,6 +342,8 @@ class DataRecorderManagerTest(unittest.TestCase):
|
|||||||
topics=topics,
|
topics=topics,
|
||||||
required_topics=required_topics,
|
required_topics=required_topics,
|
||||||
minimum_topic_rates_hz=minimum_topic_rates_hz or {},
|
minimum_topic_rates_hz=minimum_topic_rates_hz or {},
|
||||||
|
optional_topic_groups=optional_topic_groups or {},
|
||||||
|
retain_failed_episodes=retain_failed_episodes,
|
||||||
minimum_free_bytes=minimum_free,
|
minimum_free_bytes=minimum_free,
|
||||||
max_duration_seconds=max_duration,
|
max_duration_seconds=max_duration,
|
||||||
poll_interval_seconds=0.005,
|
poll_interval_seconds=0.005,
|
||||||
@@ -330,6 +369,36 @@ class DataRecorderManagerTest(unittest.TestCase):
|
|||||||
self.fail("condition did not become true before timeout")
|
self.fail("condition did not become true before timeout")
|
||||||
time.sleep(0.002)
|
time.sleep(0.002)
|
||||||
|
|
||||||
|
def record_with_optional_cameras(
|
||||||
|
self, topic_counts: Mapping[str, int]
|
||||||
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
topics = (
|
||||||
|
"/joint_states",
|
||||||
|
"/tf",
|
||||||
|
*HEAD_CAMERA_TOPICS,
|
||||||
|
*WAIST_CAMERA_TOPICS,
|
||||||
|
)
|
||||||
|
manager, _ = self.make_manager(
|
||||||
|
topics=topics,
|
||||||
|
required_topics=("/joint_states", "/tf"),
|
||||||
|
optional_topic_groups=optional_camera_groups(),
|
||||||
|
process_options={
|
||||||
|
"topic_counts": topic_counts,
|
||||||
|
"duration_nanoseconds": 1_000_000_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertTrue(manager.request_start("ca" * 16))
|
||||||
|
self.wait_for(lambda: manager.status()["recording"])
|
||||||
|
self.assertTrue(manager.request_stop())
|
||||||
|
self.assertTrue(manager.wait_until_idle(2.0))
|
||||||
|
status = manager.status()
|
||||||
|
manifest = json.loads(
|
||||||
|
(
|
||||||
|
Path(status["last_episode_directory"]) / "manifest.json"
|
||||||
|
).read_text()
|
||||||
|
)
|
||||||
|
return status, manifest
|
||||||
|
|
||||||
def test_manual_stop_uses_sigint_and_atomically_creates_ready_manifest(
|
def test_manual_stop_uses_sigint_and_atomically_creates_ready_manifest(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -627,6 +696,103 @@ class DataRecorderManagerTest(unittest.TestCase):
|
|||||||
0 if head_count is None else head_count,
|
0 if head_count is None else head_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_optional_cameras_absent_do_not_block_core_ready(self) -> None:
|
||||||
|
status, manifest = self.record_with_optional_cameras(
|
||||||
|
{"/joint_states": 25, "/tf": 50}
|
||||||
|
)
|
||||||
|
self.assertEqual(status["last_result"], "ready")
|
||||||
|
self.assertEqual(manifest["data_quality_warnings"], [])
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
name: observation["state"]
|
||||||
|
for name, observation in manifest[
|
||||||
|
"optional_topic_groups"
|
||||||
|
].items()
|
||||||
|
},
|
||||||
|
{"head_rgbd": "absent", "waist_rgbd": "absent"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
manifest["optional_topic_groups"]["head_rgbd"][
|
||||||
|
"below_minimum_rate_topics"
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
self.assertTrue((Path(status["last_episode_directory"]) / "READY").exists())
|
||||||
|
|
||||||
|
def test_each_active_optional_camera_is_observed_as_healthy(self) -> None:
|
||||||
|
for active_name, active_topics in (
|
||||||
|
("head_rgbd", HEAD_CAMERA_TOPICS),
|
||||||
|
("waist_rgbd", WAIST_CAMERA_TOPICS),
|
||||||
|
):
|
||||||
|
with self.subTest(active_name=active_name):
|
||||||
|
counts = {"/joint_states": 25, "/tf": 50}
|
||||||
|
counts.update({topic: 1 for topic in active_topics})
|
||||||
|
counts[active_topics[0]] = 25
|
||||||
|
counts[active_topics[3]] = 25
|
||||||
|
status, manifest = self.record_with_optional_cameras(counts)
|
||||||
|
self.assertEqual(status["last_result"], "ready")
|
||||||
|
observation = manifest["optional_topic_groups"][active_name]
|
||||||
|
self.assertEqual(observation["state"], "healthy")
|
||||||
|
self.assertEqual(
|
||||||
|
observation["observed_topic_rates_hz"],
|
||||||
|
{active_topics[0]: 25.0, active_topics[3]: 25.0},
|
||||||
|
)
|
||||||
|
inactive_name = (
|
||||||
|
"waist_rgbd"
|
||||||
|
if active_name == "head_rgbd"
|
||||||
|
else "head_rgbd"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
manifest["optional_topic_groups"][inactive_name]["state"],
|
||||||
|
"absent",
|
||||||
|
)
|
||||||
|
self.assertEqual(manifest["data_quality_warnings"], [])
|
||||||
|
|
||||||
|
def test_partial_optional_camera_warns_but_remains_ready(self) -> None:
|
||||||
|
counts = {"/joint_states": 25, "/tf": 50}
|
||||||
|
counts.update(
|
||||||
|
{
|
||||||
|
HEAD_CAMERA_TOPICS[0]: 25,
|
||||||
|
HEAD_CAMERA_TOPICS[1]: 1,
|
||||||
|
HEAD_CAMERA_TOPICS[2]: 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
status, manifest = self.record_with_optional_cameras(counts)
|
||||||
|
self.assertEqual(status["last_result"], "ready")
|
||||||
|
observation = manifest["optional_topic_groups"]["head_rgbd"]
|
||||||
|
self.assertEqual(observation["state"], "partial")
|
||||||
|
self.assertEqual(
|
||||||
|
observation["zero_message_topics"],
|
||||||
|
list(HEAD_CAMERA_TOPICS[3:]),
|
||||||
|
)
|
||||||
|
self.assertTrue(manifest["data_quality_warnings"])
|
||||||
|
self.assertIn("is partial", manifest["data_quality_warnings"][0])
|
||||||
|
self.assertTrue((Path(status["last_episode_directory"]) / "READY").exists())
|
||||||
|
|
||||||
|
def test_low_rate_optional_camera_warns_but_remains_ready(self) -> None:
|
||||||
|
counts = {"/joint_states": 25, "/tf": 50}
|
||||||
|
counts.update({topic: 1 for topic in WAIST_CAMERA_TOPICS})
|
||||||
|
counts[WAIST_CAMERA_TOPICS[0]] = 5
|
||||||
|
counts[WAIST_CAMERA_TOPICS[3]] = 10
|
||||||
|
status, manifest = self.record_with_optional_cameras(counts)
|
||||||
|
self.assertEqual(status["last_result"], "ready")
|
||||||
|
observation = manifest["optional_topic_groups"]["waist_rgbd"]
|
||||||
|
self.assertEqual(observation["state"], "low_rate")
|
||||||
|
self.assertEqual(
|
||||||
|
observation["observed_topic_rates_hz"],
|
||||||
|
{
|
||||||
|
WAIST_CAMERA_TOPICS[0]: 5.0,
|
||||||
|
WAIST_CAMERA_TOPICS[3]: 10.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
observation["below_minimum_rate_topics"],
|
||||||
|
[WAIST_CAMERA_TOPICS[0], WAIST_CAMERA_TOPICS[3]],
|
||||||
|
)
|
||||||
|
self.assertTrue(manifest["data_quality_warnings"])
|
||||||
|
self.assertIn("below", manifest["data_quality_warnings"][0])
|
||||||
|
self.assertTrue((Path(status["last_episode_directory"]) / "READY").exists())
|
||||||
|
|
||||||
def test_topic_that_stops_mid_episode_fails_average_rate(self) -> None:
|
def test_topic_that_stops_mid_episode_fails_average_rate(self) -> None:
|
||||||
bag_info = FakeBagInfoRunner()
|
bag_info = FakeBagInfoRunner()
|
||||||
manager, _ = self.make_manager(
|
manager, _ = self.make_manager(
|
||||||
@@ -779,6 +945,20 @@ class DataRecorderManagerTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(failure["status"], "failed")
|
self.assertEqual(failure["status"], "failed")
|
||||||
|
|
||||||
|
def test_failed_payload_is_discarded_when_retention_is_disabled(self) -> None:
|
||||||
|
manager, factory = self.make_manager(
|
||||||
|
free_bytes=lambda _path: 0,
|
||||||
|
retain_failed_episodes=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(manager.request_start("d0" * 16))
|
||||||
|
self.assertTrue(manager.wait_until_idle(2.0))
|
||||||
|
status = manager.status()
|
||||||
|
self.assertEqual(status["last_result"], "failed")
|
||||||
|
self.assertIsNone(status["last_episode_directory"])
|
||||||
|
self.assertEqual(factory.processes, [])
|
||||||
|
self.assertEqual(list((self.base / "active").iterdir()), [])
|
||||||
|
self.assertEqual(list((self.base / "failed").iterdir()), [])
|
||||||
|
|
||||||
def test_configuration_rejects_unsafe_or_ambiguous_values(self) -> None:
|
def test_configuration_rejects_unsafe_or_ambiguous_values(self) -> None:
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
RecorderConfig(self.base, ())
|
RecorderConfig(self.base, ())
|
||||||
@@ -790,6 +970,8 @@ class DataRecorderManagerTest(unittest.TestCase):
|
|||||||
RecorderConfig(self.base, ("/ok",), minimum_free_bytes=-1)
|
RecorderConfig(self.base, ("/ok",), minimum_free_bytes=-1)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
RecorderConfig(self.base, ("/ok",), validate_bag_info=1)
|
RecorderConfig(self.base, ("/ok",), validate_bag_info=1)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
RecorderConfig(self.base, ("/ok",), retain_failed_episodes=1)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
RecorderConfig(self.base, ("/ok",), bag_info_timeout_seconds=0)
|
RecorderConfig(self.base, ("/ok",), bag_info_timeout_seconds=0)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
@@ -827,6 +1009,45 @@ class DataRecorderManagerTest(unittest.TestCase):
|
|||||||
required_topics=("/recorded",),
|
required_topics=("/recorded",),
|
||||||
minimum_topic_rates_hz=[], # type: ignore[arg-type]
|
minimum_topic_rates_hz=[], # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
OptionalTopicGroupConfig(topics=())
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
OptionalTopicGroupConfig(
|
||||||
|
topics=("/camera/image",),
|
||||||
|
minimum_topic_rates_hz={"/other": 20.0},
|
||||||
|
)
|
||||||
|
camera_group = OptionalTopicGroupConfig(
|
||||||
|
topics=("/camera/image",),
|
||||||
|
minimum_topic_rates_hz={"/camera/image": 20.0},
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
RecorderConfig(
|
||||||
|
self.base,
|
||||||
|
("/recorded",),
|
||||||
|
optional_topic_groups={"camera": camera_group},
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
RecorderConfig(
|
||||||
|
self.base,
|
||||||
|
("/recorded", "/camera/image"),
|
||||||
|
required_topics=("/camera/image",),
|
||||||
|
optional_topic_groups={"camera": camera_group},
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
RecorderConfig(
|
||||||
|
self.base,
|
||||||
|
("/recorded", "/camera/image"),
|
||||||
|
optional_topic_groups={
|
||||||
|
"camera_a": camera_group,
|
||||||
|
"camera_b": camera_group,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
RecorderConfig(
|
||||||
|
self.base,
|
||||||
|
("/recorded", "/camera/image"),
|
||||||
|
optional_topic_groups={"bad name": camera_group},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
376
tg3_local_teleop/test_delete_ready_episode.py
Executable file
376
tg3_local_teleop/test_delete_ready_episode.py
Executable file
@@ -0,0 +1,376 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from delete_ready_episode import (
|
||||||
|
DeleteCollisionError,
|
||||||
|
DeleteSafetyError,
|
||||||
|
delete_ready_episode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteReadyEpisodeTests(unittest.TestCase):
|
||||||
|
def _roots(self, root: Path) -> tuple[Path, Path, Path]:
|
||||||
|
base = root / "tg3_data_collection"
|
||||||
|
ready = base / "ready"
|
||||||
|
deleting = base / "deleting"
|
||||||
|
active = base / "active"
|
||||||
|
failed = base / "failed"
|
||||||
|
for path in (ready, active, failed):
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
(active / "ACTIVE_SENTINEL").write_text("active", encoding="utf-8")
|
||||||
|
(failed / "FAILED_SENTINEL").write_text("failed", encoding="utf-8")
|
||||||
|
return ready, deleting, base
|
||||||
|
|
||||||
|
def _episode(
|
||||||
|
self,
|
||||||
|
ready: Path,
|
||||||
|
name: str = "episode_20260810T120000000_deadbeef",
|
||||||
|
*,
|
||||||
|
state: str = "complete",
|
||||||
|
) -> tuple[Path, str]:
|
||||||
|
episode = ready / name
|
||||||
|
(episode / "bag").mkdir(parents=True)
|
||||||
|
(episode / "bag" / "bag_0.mcap").write_bytes(b"mcap")
|
||||||
|
manifest = {"state": state, "episode_id": name, "files": []}
|
||||||
|
raw = (json.dumps(manifest, sort_keys=True) + "\n").encode("utf-8")
|
||||||
|
(episode / "manifest.json").write_bytes(raw)
|
||||||
|
(episode / "READY").write_text("ready\n", encoding="ascii")
|
||||||
|
return episode, hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
def _authorization(
|
||||||
|
self,
|
||||||
|
base: Path,
|
||||||
|
episode: str,
|
||||||
|
digest: str,
|
||||||
|
*,
|
||||||
|
payload_digest: str | None = None,
|
||||||
|
) -> Path:
|
||||||
|
ledger = base / "delete_ledger"
|
||||||
|
ledger.mkdir(exist_ok=True)
|
||||||
|
tombstone = f"{episode}.{digest}.deleting"
|
||||||
|
path = ledger / f"{episode}.{digest}.authorized.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"state": "AUTHORIZED_DELETE",
|
||||||
|
"episode_id": episode,
|
||||||
|
"manifest_sha256": payload_digest or digest,
|
||||||
|
"tombstone": tombstone,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_compare_rename_delete_and_absent_retry_are_idempotent(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
ready, deleting, base = self._roots(Path(temporary))
|
||||||
|
episode, digest = self._episode(ready)
|
||||||
|
name = episode.name
|
||||||
|
result = delete_ready_episode(
|
||||||
|
name,
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["state"], "deleted")
|
||||||
|
self.assertFalse(episode.exists())
|
||||||
|
self.assertEqual(
|
||||||
|
delete_ready_episode(
|
||||||
|
name,
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)["state"],
|
||||||
|
"already_absent",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(base / "active" / "ACTIVE_SENTINEL").read_text(), "active"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(base / "failed" / "FAILED_SENTINEL").read_text(), "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resume_matching_tombstone_after_lost_ack(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
ready, deleting, _base = self._roots(Path(temporary))
|
||||||
|
episode, digest = self._episode(ready)
|
||||||
|
deleting.mkdir()
|
||||||
|
tombstone = deleting / f"{episode.name}.{digest}.deleting"
|
||||||
|
episode.rename(tombstone)
|
||||||
|
result = delete_ready_episode(
|
||||||
|
"episode_20260810T120000000_deadbeef",
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["state"], "resumed_delete")
|
||||||
|
self.assertFalse(tombstone.exists())
|
||||||
|
|
||||||
|
def test_partial_tree_without_manifest_resumes_from_durable_ledger(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
ready, deleting, base = self._roots(Path(temporary))
|
||||||
|
episode, digest = self._episode(ready)
|
||||||
|
tombstone = deleting / f"{episode.name}.{digest}.deleting"
|
||||||
|
ledger = (
|
||||||
|
base
|
||||||
|
/ "delete_ledger"
|
||||||
|
/ f"{episode.name}.{digest}.authorized.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
def interrupt_after_control_files_removed(
|
||||||
|
_parent_fd: int, _name: str
|
||||||
|
) -> None:
|
||||||
|
(tombstone / "READY").unlink()
|
||||||
|
(tombstone / "manifest.json").unlink()
|
||||||
|
raise RuntimeError("injected power loss during recursive delete")
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"delete_ready_episode._remove_tree_at",
|
||||||
|
side_effect=interrupt_after_control_files_removed,
|
||||||
|
):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
delete_ready_episode(
|
||||||
|
episode.name,
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertTrue(tombstone.is_dir())
|
||||||
|
self.assertFalse((tombstone / "manifest.json").exists())
|
||||||
|
self.assertTrue(ledger.is_file())
|
||||||
|
|
||||||
|
result = delete_ready_episode(
|
||||||
|
episode.name,
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["state"], "resumed_delete")
|
||||||
|
self.assertFalse(tombstone.exists())
|
||||||
|
self.assertFalse(ledger.exists())
|
||||||
|
|
||||||
|
def test_partial_tombstone_without_ledger_is_refused(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
ready, deleting, _base = self._roots(Path(temporary))
|
||||||
|
episode, digest = self._episode(ready)
|
||||||
|
deleting.mkdir()
|
||||||
|
tombstone = deleting / f"{episode.name}.{digest}.deleting"
|
||||||
|
episode.rename(tombstone)
|
||||||
|
(tombstone / "manifest.json").unlink()
|
||||||
|
with self.assertRaises(DeleteSafetyError):
|
||||||
|
delete_ready_episode(
|
||||||
|
episode.name,
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertTrue(tombstone.exists())
|
||||||
|
|
||||||
|
def test_wrong_digest_or_symlink_ledger_is_refused(self) -> None:
|
||||||
|
for mode in ("wrong_digest", "symlink"):
|
||||||
|
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
ready, deleting, base = self._roots(root)
|
||||||
|
episode, digest = self._episode(ready)
|
||||||
|
deleting.mkdir()
|
||||||
|
tombstone = deleting / f"{episode.name}.{digest}.deleting"
|
||||||
|
episode.rename(tombstone)
|
||||||
|
(tombstone / "manifest.json").unlink()
|
||||||
|
if mode == "wrong_digest":
|
||||||
|
ledger = self._authorization(
|
||||||
|
base, episode.name, digest, payload_digest="f" * 64
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ledger_root = base / "delete_ledger"
|
||||||
|
ledger_root.mkdir()
|
||||||
|
ledger = (
|
||||||
|
ledger_root
|
||||||
|
/ f"{episode.name}.{digest}.authorized.json"
|
||||||
|
)
|
||||||
|
outside = root / "outside"
|
||||||
|
outside.write_text("keep", encoding="utf-8")
|
||||||
|
ledger.symlink_to(outside)
|
||||||
|
with self.assertRaises(DeleteSafetyError):
|
||||||
|
delete_ready_episode(
|
||||||
|
episode.name,
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertTrue(tombstone.exists())
|
||||||
|
self.assertTrue(ledger.exists())
|
||||||
|
|
||||||
|
def test_ledger_only_after_tree_removal_is_finalized_idempotently(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
ready, deleting, base = self._roots(Path(temporary))
|
||||||
|
deleting.mkdir()
|
||||||
|
episode = "episode_20260810T120000000_deadbeef"
|
||||||
|
digest = "a" * 64
|
||||||
|
ledger = self._authorization(base, episode, digest)
|
||||||
|
result = delete_ready_episode(
|
||||||
|
episode,
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["state"], "already_absent")
|
||||||
|
self.assertFalse(ledger.exists())
|
||||||
|
|
||||||
|
def test_conflicting_ledger_digest_refuses_ready_target(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
ready, deleting, base = self._roots(Path(temporary))
|
||||||
|
episode, digest = self._episode(ready)
|
||||||
|
collision = self._authorization(base, episode.name, "b" * 64)
|
||||||
|
with self.assertRaises(DeleteCollisionError):
|
||||||
|
delete_ready_episode(
|
||||||
|
episode.name,
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertTrue(episode.exists())
|
||||||
|
self.assertTrue(collision.exists())
|
||||||
|
|
||||||
|
def test_wrong_manifest_digest_refuses_and_preserves_target(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
ready, deleting, _base = self._roots(Path(temporary))
|
||||||
|
episode, _digest = self._episode(ready)
|
||||||
|
with self.assertRaises(DeleteSafetyError):
|
||||||
|
delete_ready_episode(
|
||||||
|
episode.name,
|
||||||
|
"0" * 64,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertTrue(episode.exists())
|
||||||
|
|
||||||
|
def test_noncomplete_manifest_refuses_and_preserves_target(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
ready, deleting, _base = self._roots(Path(temporary))
|
||||||
|
episode, digest = self._episode(ready, state="failed")
|
||||||
|
with self.assertRaises(DeleteSafetyError):
|
||||||
|
delete_ready_episode(
|
||||||
|
episode.name,
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertTrue(episode.exists())
|
||||||
|
|
||||||
|
def test_unsafe_episode_and_digest_are_rejected(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
ready, deleting, _base = self._roots(Path(temporary))
|
||||||
|
for episode, digest in (("../active", "0" * 64), ("safe", "BAD")):
|
||||||
|
with self.subTest(episode=episode, digest=digest):
|
||||||
|
with self.assertRaises(DeleteSafetyError):
|
||||||
|
delete_ready_episode(
|
||||||
|
episode,
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_symlink_episode_is_refused_without_touching_outside(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
ready, deleting, _base = self._roots(root)
|
||||||
|
outside = root / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
sentinel = outside / "sentinel"
|
||||||
|
sentinel.write_text("keep", encoding="utf-8")
|
||||||
|
name = "episode_20260810T120000000_deadbeef"
|
||||||
|
(ready / name).symlink_to(outside, target_is_directory=True)
|
||||||
|
with self.assertRaises(DeleteSafetyError):
|
||||||
|
delete_ready_episode(
|
||||||
|
name,
|
||||||
|
"0" * 64,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertEqual(sentinel.read_text(), "keep")
|
||||||
|
|
||||||
|
def test_symlink_manifest_ready_or_nested_entry_is_refused(self) -> None:
|
||||||
|
for target in ("manifest", "ready", "nested"):
|
||||||
|
with self.subTest(target=target), tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
ready, deleting, _base = self._roots(root)
|
||||||
|
episode, digest = self._episode(ready)
|
||||||
|
outside = root / "outside"
|
||||||
|
outside.write_text("keep", encoding="utf-8")
|
||||||
|
if target == "manifest":
|
||||||
|
(episode / "manifest.json").unlink()
|
||||||
|
(episode / "manifest.json").symlink_to(outside)
|
||||||
|
elif target == "ready":
|
||||||
|
(episode / "READY").unlink()
|
||||||
|
(episode / "READY").symlink_to(outside)
|
||||||
|
else:
|
||||||
|
(episode / "nested_link").symlink_to(outside)
|
||||||
|
with self.assertRaises(DeleteSafetyError):
|
||||||
|
delete_ready_episode(
|
||||||
|
episode.name,
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertEqual(outside.read_text(), "keep")
|
||||||
|
|
||||||
|
def test_conflicting_tombstone_refuses_without_moving_ready(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
ready, deleting, _base = self._roots(Path(temporary))
|
||||||
|
episode, digest = self._episode(ready)
|
||||||
|
deleting.mkdir()
|
||||||
|
collision = deleting / f"{episode.name}.{'1' * 64}.deleting"
|
||||||
|
collision.mkdir()
|
||||||
|
with self.assertRaises(DeleteCollisionError):
|
||||||
|
delete_ready_episode(
|
||||||
|
episode.name,
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertTrue(episode.exists())
|
||||||
|
self.assertTrue(collision.exists())
|
||||||
|
|
||||||
|
def test_ready_and_same_tombstone_collision_refuses_both(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
ready, deleting, _base = self._roots(Path(temporary))
|
||||||
|
episode, digest = self._episode(ready)
|
||||||
|
deleting.mkdir()
|
||||||
|
tombstone = deleting / f"{episode.name}.{digest}.deleting"
|
||||||
|
tombstone.mkdir()
|
||||||
|
with self.assertRaises(DeleteCollisionError):
|
||||||
|
delete_ready_episode(
|
||||||
|
episode.name,
|
||||||
|
digest,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=deleting,
|
||||||
|
)
|
||||||
|
self.assertTrue(episode.exists())
|
||||||
|
self.assertTrue(tombstone.exists())
|
||||||
|
|
||||||
|
def test_wrong_parent_relationship_is_refused(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
ready, _deleting, _base = self._roots(root)
|
||||||
|
elsewhere = root / "elsewhere" / "deleting"
|
||||||
|
elsewhere.mkdir(parents=True)
|
||||||
|
with self.assertRaises(DeleteSafetyError):
|
||||||
|
delete_ready_episode(
|
||||||
|
"episode_safe",
|
||||||
|
"0" * 64,
|
||||||
|
ready_root=ready,
|
||||||
|
deleting_root=elsewhere,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
29
verify.sh
29
verify.sh
@@ -8,6 +8,7 @@ for executable in \
|
|||||||
"$repo_dir/tg3_omnisocket_transport/omnisocket_xtele_sender.py" \
|
"$repo_dir/tg3_omnisocket_transport/omnisocket_xtele_sender.py" \
|
||||||
"$repo_dir/tg3_local_teleop/tg3_local_teleop.py" \
|
"$repo_dir/tg3_local_teleop/tg3_local_teleop.py" \
|
||||||
"$repo_dir/tg3_local_teleop/data_recorder_node.py" \
|
"$repo_dir/tg3_local_teleop/data_recorder_node.py" \
|
||||||
|
"$repo_dir/tg3_local_teleop/delete_ready_episode.py" \
|
||||||
"$repo_dir/tg3_local_teleop/run.sh" \
|
"$repo_dir/tg3_local_teleop/run.sh" \
|
||||||
"$repo_dir/tg3_local_teleop/run_data_recorder.sh" \
|
"$repo_dir/tg3_local_teleop/run_data_recorder.sh" \
|
||||||
"$repo_dir/tg3_local_teleop/wait_ros_ready.sh" \
|
"$repo_dir/tg3_local_teleop/wait_ros_ready.sh" \
|
||||||
@@ -27,6 +28,7 @@ python3 -m py_compile \
|
|||||||
"$repo_dir/tg3_local_teleop/data_collection.py" \
|
"$repo_dir/tg3_local_teleop/data_collection.py" \
|
||||||
"$repo_dir/tg3_local_teleop/data_recorder_protocol.py" \
|
"$repo_dir/tg3_local_teleop/data_recorder_protocol.py" \
|
||||||
"$repo_dir/tg3_local_teleop/data_recorder_node.py" \
|
"$repo_dir/tg3_local_teleop/data_recorder_node.py" \
|
||||||
|
"$repo_dir/tg3_local_teleop/delete_ready_episode.py" \
|
||||||
"$repo_dir/tg3_data_collection/data_get_sync.py"
|
"$repo_dir/tg3_data_collection/data_get_sync.py"
|
||||||
python3 "$repo_dir/tg3_omnisocket_transport/test_session_gate.py"
|
python3 "$repo_dir/tg3_omnisocket_transport/test_session_gate.py"
|
||||||
python3 "$repo_dir/tg3_local_teleop/test_session_gate.py"
|
python3 "$repo_dir/tg3_local_teleop/test_session_gate.py"
|
||||||
@@ -34,6 +36,7 @@ python3 "$repo_dir/tg3_local_teleop/test_gesture_toggle.py"
|
|||||||
python3 "$repo_dir/tg3_local_teleop/test_idle_session_refresh.py"
|
python3 "$repo_dir/tg3_local_teleop/test_idle_session_refresh.py"
|
||||||
python3 "$repo_dir/tg3_local_teleop/test_data_collection.py"
|
python3 "$repo_dir/tg3_local_teleop/test_data_collection.py"
|
||||||
python3 "$repo_dir/tg3_local_teleop/test_data_recorder_protocol.py"
|
python3 "$repo_dir/tg3_local_teleop/test_data_recorder_protocol.py"
|
||||||
|
python3 "$repo_dir/tg3_local_teleop/test_delete_ready_episode.py"
|
||||||
python3 "$repo_dir/tg3_data_collection/test_data_get_sync.py"
|
python3 "$repo_dir/tg3_data_collection/test_data_get_sync.py"
|
||||||
python3 - "$repo_dir/tg3_local_teleop/config.toml" <<'PY'
|
python3 - "$repo_dir/tg3_local_teleop/config.toml" <<'PY'
|
||||||
import sys
|
import sys
|
||||||
@@ -47,29 +50,39 @@ required = set(section["required_topics"])
|
|||||||
head_rgbd = {
|
head_rgbd = {
|
||||||
"/ob_camera_head/color/image_raw/compressed",
|
"/ob_camera_head/color/image_raw/compressed",
|
||||||
"/ob_camera_head/color/camera_info",
|
"/ob_camera_head/color/camera_info",
|
||||||
|
"/ob_camera_head/color/metadata",
|
||||||
"/ob_camera_head/depth/image_raw/compressedDepth",
|
"/ob_camera_head/depth/image_raw/compressedDepth",
|
||||||
"/ob_camera_head/depth/camera_info",
|
"/ob_camera_head/depth/camera_info",
|
||||||
|
"/ob_camera_head/depth/metadata",
|
||||||
}
|
}
|
||||||
waist_rgbd = {
|
waist_rgbd = {
|
||||||
"/ob_camera_waist/color/image_raw/compressed",
|
"/ob_camera_waist/color/image_raw/compressed",
|
||||||
"/ob_camera_waist/color/camera_info",
|
"/ob_camera_waist/color/camera_info",
|
||||||
|
"/ob_camera_waist/color/metadata",
|
||||||
"/ob_camera_waist/depth/image_raw/compressedDepth",
|
"/ob_camera_waist/depth/image_raw/compressedDepth",
|
||||||
"/ob_camera_waist/depth/camera_info",
|
"/ob_camera_waist/depth/camera_info",
|
||||||
|
"/ob_camera_waist/depth/metadata",
|
||||||
}
|
}
|
||||||
assert head_rgbd <= topics, "head RGB-D topics must stay in the recording whitelist"
|
assert head_rgbd <= topics, "head RGB-D topics must stay in the recording whitelist"
|
||||||
assert waist_rgbd <= topics, "waist RGB-D topics must stay in the recording whitelist"
|
assert waist_rgbd <= topics, "waist RGB-D topics must stay in the recording whitelist"
|
||||||
assert waist_rgbd <= required, "live waist RGB-D topics must stay required"
|
assert head_rgbd.isdisjoint(required), "head RGB-D must not block recording"
|
||||||
assert head_rgbd.isdisjoint(required), "inactive head RGB-D must not block recording"
|
assert waist_rgbd.isdisjoint(required), "waist RGB-D must not block recording"
|
||||||
assert {
|
assert "/tf_static" in topics and "/tf_static" not in required
|
||||||
"/ob_camera_waist/color/metadata",
|
assert section.get("minimum_topic_rates_hz", {}) == {}
|
||||||
"/ob_camera_waist/depth/metadata",
|
groups = section["optional_topic_groups"]
|
||||||
"/tf_static",
|
assert set(groups) == {"head_rgbd", "waist_rgbd"}
|
||||||
} <= required
|
assert set(groups["head_rgbd"]["topics"]) == head_rgbd
|
||||||
assert section["minimum_topic_rates_hz"] == {
|
assert set(groups["waist_rgbd"]["topics"]) == waist_rgbd
|
||||||
|
assert groups["head_rgbd"]["minimum_topic_rates_hz"] == {
|
||||||
|
"/ob_camera_head/color/image_raw/compressed": 20.0,
|
||||||
|
"/ob_camera_head/depth/image_raw/compressedDepth": 20.0,
|
||||||
|
}
|
||||||
|
assert groups["waist_rgbd"]["minimum_topic_rates_hz"] == {
|
||||||
"/ob_camera_waist/color/image_raw/compressed": 20.0,
|
"/ob_camera_waist/color/image_raw/compressed": 20.0,
|
||||||
"/ob_camera_waist/depth/image_raw/compressedDepth": 20.0,
|
"/ob_camera_waist/depth/image_raw/compressedDepth": 20.0,
|
||||||
}
|
}
|
||||||
assert section["minimum_free_gib"] >= 100.0
|
assert section["minimum_free_gib"] >= 100.0
|
||||||
|
assert section["retain_failed_episodes"] is False
|
||||||
print("config.toml parse passed")
|
print("config.toml parse passed")
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user