feat: add generic file conversion sdk example
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
|
||||
- 结果日志的序号在独立命名空间流行锁下分配,与任务状态及原事件同事务提交;禁止用最大事件ID或时间戳推断提交水位。生产者不得锁订阅行。
|
||||
- 创建订阅先锁队列再锁流行;消费按订阅→交付持锁,不能再取得任务、队列、额度或流行锁。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;每项复用单项身份与事务,未知结果必须原键核对。任务提交按认证→应用额度→提交幂等身份→队列持锁,禁止恢复为先抢提交唯一键后等待额度锁;原回执仍须先于配额上限检查返回。
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| 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);继续业务积压/阶段延迟、凭据轮换及其余生产门槛 |
|
||||
| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 待开发 |
|
||||
| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 进行中;已补[通用文件转换示例](reports/phase-7-file-conversion.md),仍需 SDK 发布兼容、全量资源充足回归和交付清单 |
|
||||
|
||||
SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。
|
||||
|
||||
@@ -99,7 +99,7 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
|
||||
| 场景 | 预期调用链 | 所属阶段 |
|
||||
| --- | --- | --- |
|
||||
| 健康检查 | 创建客户端 → 存活/就绪查询 | P0 |
|
||||
| 文件转换任务 | 提交幂等任务 → Worker实际转换 → 查询结果 | 当前仅有受理/取消示例;真实转换例子仍须在P7首版交付前补齐 |
|
||||
| 文件转换任务 | 提交幂等任务 → Worker实际转换 → 查询结果 | 已补通用CSV→JSON Worker示例;大文件对象引用、外部效果核查与生产容量仍按接入方和P6/P7验收 |
|
||||
| 相对延迟执行 | 受理时按数据库时间等待 → Worker实际执行 → 历史时间核对 → 原键重报不重计时 | P3,已实现完整示例 |
|
||||
| 有限安全重试 | 明确失败 → 持久化退避 → 新尝试 → 查询完整历史 | P3,已实现完整示例 |
|
||||
| 定期数据同步 | 发布计划 → 触发任务 → 暂停/恢复 → 查看触发记录 | P3,已实现通用快照示例 |
|
||||
|
||||
@@ -49,6 +49,14 @@
|
||||
- 异常:凭据失效、权限不足、内容冲突、版本竞争和未知网络结果,见 [任务接口](../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)。
|
||||
@@ -128,7 +136,7 @@
|
||||
|
||||
| 场景 | 输入与步骤 | 预期结果 | 必测异常 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 文件转换 | 对象引用 → 提交任务 → Worker 转换 → 结果引用 | 同一操作获得一次业务结果,历史可查 | 重复提交、Worker 在结果上报前中断 | 受理与执行框架已实现,实际转换示例待补齐 |
|
||||
| 文件转换 | 小型 CSV 快照 → 提交任务 → Worker 转为 JSON → 查询结果 | 同一操作获得一次转换结果,历史可查 | 重复提交、Worker 在结果上报前中断 | CSV→JSON 通用示例已完成;大文件对象引用与外部效果仍由接入方实现 |
|
||||
| 定期同步 | 发布计划 → 周期触发 → 暂停 → 恢复 | 触发身份唯一,漏跑策略可追溯 | 多节点竞争、停机后补跑上限 | P3 通用快照示例已完成,业务同步由接入方处理器实现 |
|
||||
| 并行生成 | 输入对象 → 并行计算两份结果 → 汇合 | 两支满足条件后只创建一份后继工作 | 一支失败、重复结果、汇合竞争 | P4 待开发 |
|
||||
| 等待审核 | 校验 → 等待信号 → 发布结果 | 只接受授权、匹配当前操作的信号 | 早到、重复、过期与越权信号 | P4 待开发 |
|
||||
|
||||
57
docs/examples/file-conversion.md
Normal file
57
docs/examples/file-conversion.md
Normal 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 验收。
|
||||
34
docs/reports/phase-7-file-conversion.md
Normal file
34
docs/reports/phase-7-file-conversion.md
Normal 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) 单独验证。
|
||||
@@ -40,7 +40,7 @@ source_kind=DIRECT
|
||||
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/tasks/main.go):提交两次同一身份、获取当前状态、列表、带版本取消,再核对直接来源。
|
||||
|
||||
@@ -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_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/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/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)
|
||||
|
||||
218
sdk/go/examples/file-conversion/main.go
Normal file
218
sdk/go/examples/file-conversion/main.go
Normal 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
|
||||
}
|
||||
25
sdk/go/examples/file-conversion/main_test.go
Normal file
25
sdk/go/examples/file-conversion/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user