跳到主要内容

自定义 AsyncTaskHandler

AsyncTaskHandler<P> 把任意供应商的异步协议适配为两个原子操作:创建一次远端任务,以及查询一次远端状态。循环、休眠、退避、截止时间和持久化由 Worker 与 Store 负责。

什么时候需要自定义 Handler

  • 接入 OCR、视频之外的异步供应商。
  • 需要为自定义请求类型定义持久化校验和供应商提交逻辑。
  • 查询除了任务 ID,还需要区域、批次类型、账号路由或动态游标。
  • 内置 OCR Handler 无法恢复某种供应商的特殊查询路由,例如 MinerU 本地文件批任务。

接口职责

java
public interface AsyncTaskHandler<P> {
    String getKey();
    @SuppressWarnings("unchecked")
    default Class<P> getSubmitParamsType() {
        // AsyncTaskHandlerTypes 是框架内部的泛型解析器。
        return (Class<P>) AsyncTaskHandlerTypes.resolve(getClass());
    }
    default void validateSubmitParams(P params) {}
    TaskSubmitResult submit(P params, TaskSubmitContext context);
    TaskQueryResult query(TaskQueryParams params, TaskQueryContext context);
}

query() 只执行一次请求,不应在 Handler 内循环或 Thread.sleep()

开发步骤

  1. 定义实现 Serializable 的稳定提交参数类型。
  2. validateSubmitParams() 中拒绝本地文件、流、二进制和其他无法跨节点恢复的数据。
  3. submit() 中创建一次供应商任务,并返回可持久化查询参数。
  4. query() 中只查询一次并映射统一状态。
  5. 使用稳定的 Handler Key 注册,并在所有 Worker 实例中保持一致。
  6. 分别测试参数拒绝、提交成功、同步完成、供应商失败、查询重试和游标更新。

submit()

submit() 接收业务 payload 和框架上下文,用于调用供应商创建任务:

java
@Override
public TaskSubmitResult submit(DocumentParseRequest params,
                               TaskSubmitContext context) {
    ProviderSubmitResponse response = client.createTask(
        params,
        context.getIdempotencyKey()
    );

    TaskSubmitResult result = new TaskSubmitResult();
    result.setStatus(AsyncTaskStatus.SUBMITTED);

    TaskQueryParams queryParams =
        new TaskQueryParams(response.getTaskId());
    queryParams.putProviderParam("region", response.getRegion());
    result.setQueryParams(queryParams);
    return result;
}

返回 SUBMITTEDRUNNING 时,必须提供带有效 externalTaskIdTaskQueryParams。供应商同步完成时,可以直接返回 SUCCEEDEDresult

TaskSubmitContext 提供:

字段说明
taskId框架内部任务 ID
idempotencyKey默认与 taskId 相同,应传给支持幂等的供应商
currentTimeMillisStore 权威时间
metadata业务扩展信息,不包含 payload

TaskQueryParams

只传一个供应商任务 ID 对简单接口足够,但区域、账号路由、批次类型或动态查询端点也可能是查询所必需的。将这些稳定字段放入 providerParams

java
TaskQueryParams params = new TaskQueryParams(externalTaskId);
params.putProviderParam("region", "cn-beijing");
params.putProviderParam("batch", true);

TaskQueryParams 会持久化。不要放 HTTP Client、凭证、打开的流或短期对象引用。

query()

java
@Override
public TaskQueryResult query(TaskQueryParams params,
                             TaskQueryContext context) {
    String region = (String) params.getProviderParams().get("region");
    ProviderTask task = client.getTask(
        region,
        params.getExternalTaskId()
    );

    TaskQueryResult result = new TaskQueryResult();
    result.setProviderStatus(task.getStatus());

    if (task.isSucceeded()) {
        result.setStatus(AsyncTaskStatus.SUCCEEDED);
        result.setResult(task.getOutput());
    } else if (task.isFailed()) {
        result.setStatus(AsyncTaskStatus.FAILED);
        result.setErrorCode(task.getErrorCode());
        result.setErrorMessage(task.getErrorMessage());
    } else {
        result.setStatus(AsyncTaskStatus.RUNNING);
    }
    return result;
}

TaskQueryContext 是本轮运行上下文,不包含 TaskQueryParams

字段说明
taskId框架任务 ID
queryCount本次调用前已完成的查询次数
consecutiveErrors本次调用前连续查询异常数
createdAt / deadlineAt任务时间边界
currentTimeMillisStore 权威时间
metadata只读业务信息

供应商在查询过程中更换游标、查询 token 或端点时,可以返回 nextQueryParams。Worker 会持久化它供下一轮使用;为空则继续使用原参数。

注册 Handler

java
public final class MyAsyncTaskHandler
    implements AsyncTaskHandler<DocumentParseRequest> {

    @Override
    public String getKey() {
        return "document:my-provider";
    }

    // submit() 和 query() 省略
}

registry.register(new MyAsyncTaskHandler());

键必须稳定且唯一。所有处理历史任务的 Worker 都必须注册同一版本兼容的 Handler。框架默认从 AsyncTaskHandler<DocumentParseRequest> 的泛型声明解析精确类型;直接实现接口、泛型抽象基类和多层泛型 继承通常都不需要覆盖 getSubmitParamsType()。Manager 按解析出的精确类型寻找候选,单一候选直接使用, 不要求业务代码再次传 key。

如果 Handler 使用了运行时仍未绑定的类型变量,或者代理框架没有保留泛型签名,注册时会立即失败并提示 显式覆盖类型方法。此时可以把具体类型写清楚:

java
@Override
public Class<DocumentParseRequest> getSubmitParamsType() {
    return DocumentParseRequest.class;
}

框架不会在无法解析时退化成 Object.class,从而避免无关 Handler 被错误匹配。

P 必须最终解析为一个具体 ClassAsyncTaskHandler<List<Request>> 这类参数化容器无法参与当前的精确 类型路由,建议定义明确的 BatchRequest DTO;原始类型 AsyncTaskHandler 和运行时仍未绑定的 P 也会 在注册时失败。

同一个参数类型注册多个 Handler 时,在 Manager 构造器中配置 AsyncTaskHandlerSelector。也可以通过 AsyncTaskOptions.handlerKey 为某一次提交强制路由;显式 key 优先于 selector。无论采用哪种方式,选择 结果都会持久化到任务,Worker 不会在后续处理或恢复时重新选择。

Handler Selector

Selector 只在“未指定 options.handlerKey 且同一请求类型匹配多个 Handler”时执行。选择上下文包含:

字段语义
request当前提交对象;用于读取租户、区域或模型等路由信息
optionsAsyncTaskOptions 的独立快照;Selector 修改它不会改变最终持久化选项
candidatesRegistry 提供的稳定顺序、不可修改且 Key 唯一的候选快照;内存 Registry 按 Handler Key 排序

Selector 必须返回 candidates 中的一个实例;返回 null 或外部 Handler 会被 Manager 拒绝。内置工厂:

工厂行为与约束
roundRobin()按稳定候选顺序轮询;不同候选 Key 集合分别维护序号
random()从当前候选中均匀随机选择,不保证短周期比例
weighted(weights)按正整数权重循环;权重表可以覆盖多个候选组,但当前每个候选 Key 都必须配置
consistentHash(extractor)使用 Rendezvous Hash;提取器必须返回非空稳定业务键,候选增删时只迁移必要业务键
leastActive(provider)每轮读取每个候选一次活动数,选择最小值;活动数不能为负,平局选稳定顺序中的第一个

例如一份全局权重表可以同时包含多个能力的 Handler:

java
Map<String, Integer> weights = new HashMap<>();
weights.put("ocr:gitee", 3);
weights.put("ocr:baidu", 1);
weights.put("video:aliyun", 2);

AsyncTaskManager manager = new AsyncTaskManager(
    store,
    registry,
    AsyncTaskHandlerSelectors.weighted(weights)
);

OCR 候选组只使用 OCR 权重,视频候选组维护自己的权重周期。权重表中的其他已知 Key 不会导致当前候选组 失败,但当前候选漏配权重会立即抛出异常。

扩展内置 OCR Handler

简单 OCR 模型可以直接使用 OcrAsyncTaskHandler。供应商查询还需要批次或区域时,可覆盖两个扩展点:

java
public final class RegionalOcrHandler extends OcrAsyncTaskHandler {
    public RegionalOcrHandler(String key, OcrModel model) {
        super(key, model);
    }

    @Override
    protected TaskQueryParams createQueryParams(OcrResponse response,
                                                OcrRequest request) {
        TaskQueryParams params =
            new TaskQueryParams(response.getTaskId());
        params.putProviderParam("region", "cn-beijing");
        return params;
    }

    @Override
    protected OcrResponse queryModel(TaskQueryParams params,
                                     TaskQueryContext context) {
        String region =
            (String) params.getProviderParams().get("region");
        return queryRegionalModel(region, params.getExternalTaskId());
    }
}

createQueryParams()queryModel() 的字段约定必须向后兼容,否则 Store 中的历史任务可能无法恢复。

实现约束

  • submit() 返回 SUBMITTEDRUNNING 时必须包含有效查询参数。
  • submit() 抛出异常后 Worker 会把任务记为 SUBMIT_UNKNOWN,不会使用查询重试策略自动重提。
  • query() 只能返回可继续查询状态或终态,不能返回 PENDING_SUBMITSUBMITTING
  • 查询异常应抛出运行时异常交给 RetryPolicy,不要在 Handler 内自行无限重试。
  • result、metadata 和 providerParams 必须能被 Store 序列化。
  • 不要把 API Key 放入任务持久化字段;凭证应由运行时配置或账号路由加载。
  • 供应商支持幂等键时,应传递 context.getIdempotencyKey()

测试建议

自定义 Handler 至少应覆盖:

场景预期
本地文件、流或二进制输入在写入 Store 前抛出明确异常并提示改用 URL
提交返回任务 ID生成 TaskQueryParams
供应商同步完成直接返回 SUCCEEDED 和结果
查询仍在运行返回 RUNNING,不在 Handler 内循环
查询成功或失败正确映射终态、结果和错误信息
查询参数发生变化使用 nextQueryParams 保存下一轮游标
提交网络异常抛出异常,任务进入 SUBMIT_UNKNOWN,不得假设可以安全重提
查询网络异常抛出异常,由 Worker 按 RetryPolicy 退避重试
历史任务恢复旧版 DTO 和 providerParams 仍可反序列化

下一步