feat: add generic file conversion sdk example

This commit is contained in:
2026-09-20 00:07:24 +08:00
parent eb2b911379
commit 77abc0e2d9
9 changed files with 348 additions and 5 deletions

View File

@@ -14,7 +14,7 @@
- 结果日志的序号在独立命名空间流行锁下分配,与任务状态及原事件同事务提交;禁止用最大事件ID或时间戳推断提交水位。生产者不得锁订阅行。 - 结果日志的序号在独立命名空间流行锁下分配,与任务状态及原事件同事务提交;禁止用最大事件ID或时间戳推断提交水位。生产者不得锁订阅行。
- 创建订阅先锁队列再锁流行;消费按订阅→交付持锁,不能再取得任务、队列、额度或流行锁。ACK只验证最近代次及证明,过期未被替代的证明仍可确认;重复确认不能再次扣减pending_count。 - 创建订阅先锁队列再锁流行;消费按订阅→交付持锁,不能再取得任务、队列、额度或流行锁。ACK只验证最近代次及证明,过期未被替代的证明仍可确认;重复确认不能再次扣减pending_count。
- 可靠订阅已完成核心、HTTP/WSS、管理及消费SDK、独立业务Inbox示例、节点及消费者进程故障回归。批量受理已提供逐项幂等与未知结果分类。独立任务核查/人工重试已实现,任务查询已支持业务键、创建时间及直接来源(普通/人工重试/计划固定版本),业务事务Outbox独立示例已实现并通过全量链路回归;P3已完成原功能范围核对与全量阶段回归(docs/reports/phase-3.md),相对延迟及四种重试全停恢复一并验证;P4/P5功能范围另按阶段报告验收,A04已补精确故障证据;继续P6/P7的生产验证和实际文件转换示例等未完成项,不能把P3阶段验收当作全平台交付。 - 可靠订阅已完成核心、HTTP/WSS、管理及消费SDK、独立业务Inbox示例、节点及消费者进程故障回归。批量受理已提供逐项幂等与未知结果分类。独立任务核查/人工重试已实现,任务查询已支持业务键、创建时间及直接来源(普通/人工重试/计划固定版本),业务事务Outbox独立示例已实现并通过全量链路回归;P3已完成原功能范围核对与全量阶段回归(docs/reports/phase-3.md),相对延迟及四种重试全停恢复一并验证;P4/P5功能范围另按阶段报告验收,A04已补精确故障证据;通用CSV→JSON文件转换示例已补,继续P6/P7生产验证和发布交付,不能把P3阶段验收当作全平台交付。
- 批量受理最多32项、512 KiB;每项复用单项身份与事务,未知结果必须原键核对。任务提交按认证→应用额度→提交幂等身份→队列持锁,禁止恢复为先抢提交唯一键后等待额度锁;原回执仍须先于配额上限检查返回。 - 批量受理最多32项、512 KiB;每项复用单项身份与事务,未知结果必须原键核对。任务提交按认证→应用额度→提交幂等身份→队列持锁,禁止恢复为先抢提交唯一键后等待额度锁;原回执仍须先于配额上限检查返回。

View File

@@ -31,7 +31,7 @@
| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 已完成平台功能阶段验收:[原功能及W01–W17逐项核对](reports/phase-4-5.md)、[版本/暂停/全停回归](reports/phase-4-5-races.md)、[提交故障边界](reports/phase-6-fault-boundaries.md)。研发发布、固定版本、顺序/分支/有限并行、信号/时间等待、公共API/SDK及可运行示例均已交付;生产拓扑与发布兼容继续P6/P7 | | P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 已完成平台功能阶段验收:[原功能及W01–W17逐项核对](reports/phase-4-5.md)、[版本/暂停/全停回归](reports/phase-4-5-races.md)、[提交故障边界](reports/phase-6-fault-boundaries.md)。研发发布、固定版本、顺序/分支/有限并行、信号/时间等待、公共API/SDK及可运行示例均已交付;生产拓扑与发布兼容继续P6/P7 |
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 已完成平台功能阶段验收:[原功能核对](reports/phase-4-5.md)。有界查询、独立预算、暂停继续/取消、六类恢复、历史/筛选/事件/来源与SDK场景均已交付;真实竞争、COMMIT断线及进程全停保留原身份、截止和预算。运维、容量与首版发布继续P6/P7 | | P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 已完成平台功能阶段验收:[原功能核对](reports/phase-4-5.md)。有界查询、独立预算、暂停继续/取消、六类恢复、历史/筛选/事件/来源与SDK场景均已交付;真实竞争、COMMIT断线及进程全停保留原身份、截止和预算。运维、容量与首版发布继续P6/P7 |
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 进行中;已补[精确故障边界](reports/phase-6-fault-boundaries.md)和[进程观测](reports/phase-6-observability.md);继续业务积压/阶段延迟、凭据轮换及其余生产门槛 | | P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 进行中;已补[精确故障边界](reports/phase-6-fault-boundaries.md)和[进程观测](reports/phase-6-observability.md);继续业务积压/阶段延迟、凭据轮换及其余生产门槛 |
| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 待开发 | | P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 进行中;已补[通用文件转换示例](reports/phase-7-file-conversion.md),仍需 SDK 发布兼容、全量资源充足回归和交付清单 |
SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。 SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。
@@ -99,7 +99,7 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
| 场景 | 预期调用链 | 所属阶段 | | 场景 | 预期调用链 | 所属阶段 |
| --- | --- | --- | | --- | --- | --- |
| 健康检查 | 创建客户端 → 存活/就绪查询 | P0 | | 健康检查 | 创建客户端 → 存活/就绪查询 | P0 |
| 文件转换任务 | 提交幂等任务 → Worker实际转换 → 查询结果 | 当前仅有受理/取消示例;真实转换例子仍须在P7首版交付前补齐 | | 文件转换任务 | 提交幂等任务 → Worker实际转换 → 查询结果 | 已补通用CSV→JSON Worker示例;大文件对象引用、外部效果核查与生产容量仍按接入方和P6/P7验收 |
| 相对延迟执行 | 受理时按数据库时间等待 → Worker实际执行 → 历史时间核对 → 原键重报不重计时 | P3,已实现完整示例 | | 相对延迟执行 | 受理时按数据库时间等待 → Worker实际执行 → 历史时间核对 → 原键重报不重计时 | P3,已实现完整示例 |
| 有限安全重试 | 明确失败 → 持久化退避 → 新尝试 → 查询完整历史 | P3,已实现完整示例 | | 有限安全重试 | 明确失败 → 持久化退避 → 新尝试 → 查询完整历史 | P3,已实现完整示例 |
| 定期数据同步 | 发布计划 → 触发任务 → 暂停/恢复 → 查看触发记录 | P3,已实现通用快照示例 | | 定期数据同步 | 发布计划 → 触发任务 → 暂停/恢复 → 查看触发记录 | P3,已实现通用快照示例 |

View File

@@ -49,6 +49,14 @@
- 异常:凭据失效、权限不足、内容冲突、版本竞争和未知网络结果,见 [任务接口](../api/tasks.md)。 - 异常:凭据失效、权限不足、内容冲突、版本竞争和未知网络结果,见 [任务接口](../api/tasks.md)。
- 清理:示例以取消结束;任务和审计历史保留。重复运行会生成独立示例任务。 - 清理:示例以取消结束;任务和审计历史保留。重复运行会生成独立示例任务。
## 已可运行:文件格式转换执行
- 源码:[CSV→JSON Worker 示例](../../sdk/go/examples/file-conversion/main.go),[完整说明](file-conversion.md)。
- 前置:专用 Worker 凭据、已启动服务和当前迁移;标准隔离回归会自动运行本例。
- 输入与步骤:提交带稳定操作键的两行 CSV 快照 → Worker 注册 `file.convert.csv-json` v1 → 领取、授权并转换 → 查询 JSON 结果 → 排空。
- 预期:`SUCCEEDED`、一次尝试、`records.json`、`rows=2`,重复提交仍沿用原任务身份。
- 边界:示例只把小文件正文放入任务快照;生产大文件应使用受控对象引用,业务侧仍需保存操作键并按外部效果核查规则处理未知结果。
## 已可运行:文本规范化执行 ## 已可运行:文本规范化执行
- 源码:[Worker 底层协议示例](../../sdk/go/examples/worker-protocol/main.go)。 - 源码:[Worker 底层协议示例](../../sdk/go/examples/worker-protocol/main.go)。
@@ -128,7 +136,7 @@
| 场景 | 输入与步骤 | 预期结果 | 必测异常 | 状态 | | 场景 | 输入与步骤 | 预期结果 | 必测异常 | 状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 文件转换 | 对象引用 → 提交任务 → Worker 转换 → 结果引用 | 同一操作获得一次业务结果,历史可查 | 重复提交、Worker 在结果上报前中断 | 受理与执行框架已实现,实际转换示例待补齐 | | 文件转换 | 小型 CSV 快照 → 提交任务 → Worker 转为 JSON → 查询结果 | 同一操作获得一次转换结果,历史可查 | 重复提交、Worker 在结果上报前中断 | CSV→JSON 通用示例已完成;大文件对象引用与外部效果仍由接入方实现 |
| 定期同步 | 发布计划 → 周期触发 → 暂停 → 恢复 | 触发身份唯一,漏跑策略可追溯 | 多节点竞争、停机后补跑上限 | P3 通用快照示例已完成,业务同步由接入方处理器实现 | | 定期同步 | 发布计划 → 周期触发 → 暂停 → 恢复 | 触发身份唯一,漏跑策略可追溯 | 多节点竞争、停机后补跑上限 | P3 通用快照示例已完成,业务同步由接入方处理器实现 |
| 并行生成 | 输入对象 → 并行计算两份结果 → 汇合 | 两支满足条件后只创建一份后继工作 | 一支失败、重复结果、汇合竞争 | P4 待开发 | | 并行生成 | 输入对象 → 并行计算两份结果 → 汇合 | 两支满足条件后只创建一份后继工作 | 一支失败、重复结果、汇合竞争 | P4 待开发 |
| 等待审核 | 校验 → 等待信号 → 发布结果 | 只接受授权、匹配当前操作的信号 | 早到、重复、过期与越权信号 | P4 待开发 | | 等待审核 | 校验 → 等待信号 → 发布结果 | 只接受授权、匹配当前操作的信号 | 早到、重复、过期与越权信号 | P4 待开发 |

View File

@@ -0,0 +1,57 @@
# 文件格式转换示例
本例使用独立 Go SDK 提交一份小型 CSV 文件快照,由真实 Worker 转换成 JSON 文件正文,再查询平台权威结果。它只演示通用的任务受理、处理器版本、执行授权和结果确认,不包含行业规则或接入方项目名称。
## 前置
需要当前迁移、已启动 Orbit 服务,以及具备任务提交/读取和 Worker 消费权限的专用凭据。凭据文件应只保存在本地受保护目录:
```sh
cd sdk/go
ORBIT_URL=http://127.0.0.1:8080 \
ORBIT_CREDENTIALS_FILE=../../.local/worker.credentials.json \
go run ./examples/file-conversion
```
## 调用链与预期
示例生成独立的运行身份和处理器能力,提交以下输入:
```json
{
"file_name": "records.csv",
"content": "name,amount\nalpha,2\nbeta,3\n",
"target_format": "json"
}
```
Worker 使用固定的 `file.convert.csv-json` 处理器版本完成转换。预期输出类似:
```text
input=records.csv output=records.json rows=2 state=SUCCEEDED worker_drained=true
```
任务结果中的 `content` 是:
```json
[{"amount":"2","name":"alpha"},{"amount":"3","name":"beta"}]
```
提交键由本次运行身份和场景组成;网络结果不明时必须复用相同提交键查询,不能生成随机新键。示例只执行一次处理器,Worker 结束前等待排空,任务和审计历史保留。
## 生产接入边界
示例将小文件正文放在任务 JSON 中,方便隔离运行和回归。生产大文件应由业务侧先保存到受控对象存储,再把不可变对象引用、格式和业务操作键放入任务;处理器需要使用上下文取消、对象版本和业务幂等键,不能把 `AttemptID` 当作跨尝试的业务幂等键。
如果处理器在外部写入后失去连接,普通错误只能报告未知效果,接入方应使用查询或证据核对;不能因为 HTTP 或 Worker 回执超时就再次生成文件。平台保存的是任务结果快照,不替代业务对象存储、病毒扫描、权限策略或归档策略。
## 回归
单元转换校验:
```sh
cd sdk/go
GOWORK=off go test ./examples/file-conversion
```
`make integration` 会在一次性 MySQL 和真实 Orbit HTTP 服务上运行该示例,确认提交、Worker 执行、结果查询和排空均通过。该场景不证明外部对象存储的持久性,也不替代生产容量、故障切换或 RPO/RTO 验收。

View File

@@ -0,0 +1,34 @@
# P7:通用文件转换示例
验证日期:2026-09-20。本批补齐首版接入清单中“提交任务 → Worker 实际转换 → 查询结果”的通用场景,使用独立 Go SDK 和真实 Orbit HTTP/WebSocket 链路,不包含特定行业或接入方规则。
## 实现内容
- 新增 [`sdk/go/examples/file-conversion`](../../sdk/go/examples/file-conversion/main.go):提交两行 CSV 快照,由版本化 `file.convert.csv-json` Worker 转成 JSON 结果。
- 处理器只读取任务输入和执行上下文,使用稳定提交键;同一个运行身份重复提交不创建第二个任务。
- 结果包含输出文件名、格式、转换正文和行数;输入标题重复、行列数不匹配或格式不支持时返回固定 `INVALID_FILE`,不把原始异常正文写入结果。
- 示例结束前等待 Worker 排空,任务与审计历史保留;单元测试覆盖正常 CSV、重复标题和列数错误。
- 小文件正文仅为可复现演示。生产大文件应由接入方保存不可变对象引用,处理器使用对象版本和业务幂等键;平台不承担对象存储、病毒扫描或外部副作用核查。
## 验证证据
SDK 单元与静态检查使用 Docker Go 1.25 临时模块文件通过:
```text
go test -race ./examples/file-conversion PASS
go test ./... PASS
go vet ./... PASS
```
真实链路使用 macOS arm64 主机、Docker MySQL 8.4、当前迁移 28、现有 Orbit HTTP 服务,以及 Docker Go 1.25 交叉编译的 darwin/arm64 示例二进制。一次性命名空间和 Worker 凭据运行结果为:
```text
input=records.csv output=records.json rows=2 state=SUCCEEDED worker_drained=true
{"schema_version":28,"migration_count":28,"foreign_key_count":0,"required_index_count":6,"complete":true}
```
脚本语法、Markdown 本地链接(1242 个,错误 0)和 `git diff --check` 通过。标准 `scripts/test-mysql.sh` 已加入该示例;本次尝试用 Go 1.27.1 启动标准全量回归时,第一组集成测试进程被执行环境 `SIGKILL` 终止,未将其记为代码通过或失败,后续仍需在资源充足的环境完成完整回归。
## 验收边界
本批证明通用 SDK 示例能在真实平台链路完成一次小文件格式转换。它不证明对象存储持久性、恶意文件处理、外部业务副作用 exactly-once、生产容量、数据库切换、跨故障域可用性或 RPO/RTO;这些仍按 [生产验收门槛](../05-production-acceptance.md) 和 [持续开发计划](../08-development-plan.md) 单独验证。

View File

@@ -40,7 +40,7 @@ source_kind=DIRECT
task=<32位任务ID> duplicate=true listed=1 final_state=CANCELLED revision=2 task=<32位任务ID> duplicate=true listed=1 final_state=CANCELLED revision=2
``` ```
重复运行示例会新建一个独立任务并取消它,listed 数量可能增加,最多显示当前页 10 条。数据和审计保留,不执行硬删除。示例只演示受理闭环,尚不执行文件转换。 重复运行示例会新建一个独立任务并取消它,listed 数量可能增加,最多显示当前页 10 条。数据和审计保留,不执行硬删除。任务受理示例不启动 Worker;真实文件转换见[CSV→JSON场景](../examples/file-conversion.md)。
- [健康示例源码](../../sdk/go/examples/health/main.go):只读,无业务资源写入。 - [健康示例源码](../../sdk/go/examples/health/main.go):只读,无业务资源写入。
- [任务示例源码](../../sdk/go/examples/tasks/main.go):提交两次同一身份、获取当前状态、列表、带版本取消,再核对直接来源。 - [任务示例源码](../../sdk/go/examples/tasks/main.go):提交两次同一身份、获取当前状态、列表、带版本取消,再核对直接来源。

View File

@@ -73,6 +73,7 @@ ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap -
(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationEvent(Connection|Consumer)' -v .) (cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationEvent(Connection|Consumer)' -v .)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker-protocol) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker-protocol)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/worker)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/file-conversion)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/history) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/history)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/retry) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/retry)
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/delayed-task) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/worker.credentials.json" "$TASK_GO" run ./examples/delayed-task)

View File

@@ -0,0 +1,218 @@
// file-conversion 演示一个通用的文件格式转换任务:CSV 内容由 Worker 转为 JSON。
// 示例把小文件内容放进任务快照;生产大文件应传对象引用,并在业务侧保存稳定操作键。
package main
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
type conversionInput struct {
// FileName 是输入文件名,仅用于结果追踪,不参与路径访问。
FileName string `json:"file_name"`
// Content 是示例中的小型文件正文;生产场景应替换为受控对象引用。
Content string `json:"content"`
// TargetFormat 固定为 json,演示处理器版本化能力。
TargetFormat string `json:"target_format"`
}
type conversionOutput struct {
// FileName 是转换后文件名。
FileName string `json:"file_name"`
// Format 是输出格式。
Format string `json:"format"`
// Content 是转换后的 JSON 文件正文。
Content string `json:"content"`
// Rows 是转换的记录数。
Rows int `json:"rows"`
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
token, queue := os.Getenv("ORBIT_TOKEN"), os.Getenv("ORBIT_QUEUE_ID")
if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); path != "" {
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read private credentials: %w", err)
}
var credentials struct {
Token string `json:"token"`
QueueID string `json:"queue_id"`
}
if err := json.Unmarshal(raw, &credentials); err != nil {
return errors.New("invalid credentials file")
}
if token == "" {
token = credentials.Token
}
if queue == "" {
queue = credentials.QueueID
}
}
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
if err != nil {
return err
}
runID, err := orbit.NewWorkerMessageID()
if err != nil {
return err
}
handler := "file.convert.csv-json." + runID
worker, err := orbit.NewWorker(client, orbit.WorkerOptions{
QueueID: queue,
Capacity: 1,
ShutdownTimeout: 5 * time.Second,
Handlers: []orbit.WorkerHandler{{
Capability: orbit.WorkerCapability{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"},
Handle: convertCSV,
}},
})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
payload, err := json.Marshal(conversionInput{
FileName: "records.csv",
Content: "name,amount\nalpha,2\nbeta,3\n",
TargetFormat: "json",
})
if err != nil {
return err
}
key := "file-conversion-" + runID
accepted, err := client.SubmitTask(ctx, key, orbit.SubmitTaskRequest{
QueueID: queue,
Handler: handler,
HandlerVersion: "v1",
PayloadVersion: "v1",
Payload: payload,
TaskTimeoutMS: 60000,
Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 10000},
RecoverySafety: orbit.RecoveryReconcileOnly,
})
if err != nil {
return err
}
workerCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- worker.Run(workerCtx) }()
waited := false
defer func() {
stop()
if !waited {
<-done
}
}()
for {
current, err := client.GetTask(ctx, accepted.Task.ID)
if err != nil {
return err
}
switch current.State {
case orbit.TaskSucceeded:
var output conversionOutput
if err := json.Unmarshal(current.Result, &output); err != nil {
return fmt.Errorf("decode conversion result: %w", err)
}
if output.FileName != "records.json" || output.Format != "json" || output.Rows != 2 || output.Content != `[{"amount":"2","name":"alpha"},{"amount":"3","name":"beta"}]` {
return fmt.Errorf("unexpected conversion result: %+v", output)
}
stop()
err = <-done
waited = true
if err != nil {
return err
}
stats := worker.Stats()
if stats.Started != 1 || stats.ReportsConfirmed != 1 || stats.Running != 0 {
return fmt.Errorf("unexpected Worker completion counts: %+v", stats)
}
fmt.Printf("input=%s output=%s rows=%d state=%s worker_drained=true\n", "records.csv", output.FileName, output.Rows, current.State)
return nil
case orbit.TaskFailed, orbit.TaskNeedsReview:
return fmt.Errorf("conversion task requires attention: %s", current.State)
}
select {
case err := <-done:
waited = true
return fmt.Errorf("Worker stopped before conversion completed: %v", err)
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
}
func convertCSV(ctx context.Context, execution orbit.WorkerExecution) (json.RawMessage, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
var in conversionInput
if err := json.Unmarshal(execution.Task.Payload, &in); err != nil {
return nil, &orbit.WorkerFailure{Code: "INVALID_FILE"}
}
out, err := convert(in)
if err != nil {
return nil, &orbit.WorkerFailure{Code: "INVALID_FILE"}
}
return json.Marshal(out)
}
func convert(in conversionInput) (conversionOutput, error) {
var out conversionOutput
if strings.TrimSpace(in.FileName) == "" || in.TargetFormat != "json" || in.Content == "" {
return out, errors.New("invalid conversion input")
}
reader := csv.NewReader(strings.NewReader(in.Content))
header, err := reader.Read()
if err != nil || len(header) == 0 {
return out, errors.New("CSV header is required")
}
seen := make(map[string]struct{}, len(header))
for i := range header {
header[i] = strings.TrimSpace(header[i])
if header[i] == "" {
return out, errors.New("CSV header is empty")
}
if _, ok := seen[header[i]]; ok {
return out, errors.New("CSV header is duplicated")
}
seen[header[i]] = struct{}{}
}
rows := make([]map[string]string, 0, 16)
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil || len(record) != len(header) {
return out, errors.New("CSV row does not match header")
}
row := make(map[string]string, len(header))
for i, value := range record {
row[header[i]] = value
}
rows = append(rows, row)
}
encoded, err := json.Marshal(rows)
if err != nil {
return out, err
}
return conversionOutput{FileName: strings.TrimSuffix(in.FileName, ".csv") + ".json", Format: "json", Content: string(encoded), Rows: len(rows)}, nil
}

View File

@@ -0,0 +1,25 @@
package main
import "testing"
func TestConvertCSV(t *testing.T) {
out, err := convert(conversionInput{FileName: "records.csv", Content: "name,amount\nalpha,2\nbeta,3\n", TargetFormat: "json"})
if err != nil {
t.Fatal(err)
}
if out.FileName != "records.json" || out.Format != "json" || out.Rows != 2 || out.Content != `[{"amount":"2","name":"alpha"},{"amount":"3","name":"beta"}]` {
t.Fatalf("unexpected output: %+v", out)
}
}
func TestConvertRejectsMalformedCSV(t *testing.T) {
for _, in := range []conversionInput{
{FileName: "records.csv", Content: "name,amount\nalpha\n", TargetFormat: "json"},
{FileName: "records.csv", Content: "name,name\nalpha,beta\n", TargetFormat: "json"},
{FileName: "records.csv", Content: "name,amount\nalpha,2\n", TargetFormat: "xml"},
} {
if _, err := convert(in); err == nil {
t.Fatalf("malformed input accepted: %+v", in)
}
}
}