fix(lastleg): persist bounded test collection session budgets
This commit is contained in:
@@ -22,7 +22,7 @@
|
||||
|
||||
内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。
|
||||
|
||||
平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、稳定任务号、阶段进度/心跳和旧版替代时间;库存 run 增加每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。本批只交付文件与离线验证,不执行 SQL、不部署;真实采集模式默认 OFF。
|
||||
平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。
|
||||
|
||||
## 唯一入口与区域边界
|
||||
|
||||
|
||||
@@ -3990,6 +3990,8 @@ CREATE TABLE ll_selfop_sync_task (
|
||||
execution_purpose VARCHAR(16) NOT NULL COMMENT 'BUSINESS或VALIDATION;由服务端冻结用途,子任务继承',
|
||||
validation_root_task_id BIGINT NULL COMMENT '验证会话唯一预算根任务,根引用自身,正式任务为空',
|
||||
validation_budget_json JSON NULL COMMENT '验证根冻结硬预算、累计消耗、库存分仓页数、截止时刻及停止原因',
|
||||
business_root_task_id BIGINT NULL COMMENT '测试人工正式采集共享预算根任务;生产任务为空',
|
||||
business_budget_json JSON NULL COMMENT '仅测试正式采集根保存冻结请求上限、截止时间、消耗及停止原因',
|
||||
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
|
||||
task_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC',
|
||||
scope_id BIGINT NULL COMMENT '分仓任务必须引用分仓作用域;仅账号共享发现为空',
|
||||
@@ -4026,6 +4028,8 @@ CREATE TABLE ll_selfop_sync_task (
|
||||
KEY idx_selfop_task_apply (apply_state,id),
|
||||
KEY idx_selfop_task_scope_list (scope_id,created_at,id),
|
||||
KEY idx_selfop_task_account_list (source_account,task_role,created_at,id),
|
||||
KEY idx_selfop_task_business_root (business_root_task_id,status,id),
|
||||
CONSTRAINT chk_selfop_business_budget CHECK ((business_root_task_id IS NULL AND business_budget_json IS NULL) OR (execution_purpose='BUSINESS' AND business_root_task_id IS NOT NULL AND ((business_root_task_id=id AND business_budget_json IS NOT NULL) OR (business_root_task_id<>id AND business_budget_json IS NULL)))),
|
||||
CONSTRAINT chk_selfop_sync_task CHECK (attempt_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND ((execution_purpose = 'BUSINESS' AND validation_root_task_id IS NULL AND validation_budget_json IS NULL) OR (execution_purpose = 'VALIDATION' AND validation_root_task_id IS NOT NULL AND apply_state = 'NOT_APPLICABLE' AND result_published_run_id IS NULL AND status <> 'SUCCEEDED')) AND ((task_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL AND discovery_scope_id IS NOT NULL) OR (task_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL)))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='一次计划或人工请求';
|
||||
|
||||
|
||||
+4
@@ -5,6 +5,10 @@ public record SelfopRequestContext(String sourceAccount,String executionPurpose,
|
||||
String sessionId,String exclusiveGrantId,Long taskId,Long runId) {
|
||||
public static SelfopRequestContext forTask(SelfopSyncTask task,SelfopSyncRun run,SelfopAnalyticsProperties properties) {
|
||||
Long root="VALIDATION".equals(task.getExecutionPurpose())?task.getValidationRootTaskId():task.getId();
|
||||
if("BUSINESS".equals(task.getExecutionPurpose())&&"test".equals(properties.getEnvironment())) {
|
||||
root=task.getBusinessRootTaskId();
|
||||
SelfopSupport.require(root!=null,"BUSINESS_SESSION_ROOT_REQUIRED");
|
||||
}
|
||||
return new SelfopRequestContext(task.getSourceAccount(),task.getExecutionPurpose(),root,
|
||||
"selfop-"+root,properties.getExclusiveGrantId(),task.getId(),run.getId());
|
||||
}
|
||||
|
||||
+7
@@ -3,6 +3,8 @@ package com.baiyue.lastleg.selfoperated.analytics.model;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
@@ -15,6 +17,11 @@ public class SelfopSyncTask {
|
||||
private String executionPurpose;
|
||||
private Long validationRootTaskId;
|
||||
private String validationBudgetJson;
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private Long businessRootTaskId;
|
||||
// Only locked explicit budget SQL may update this counter; collector entities can be stale.
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private String businessBudgetJson;
|
||||
private Long resultPublishedRunId;
|
||||
private String progressJson;
|
||||
private LocalDateTime progressUpdatedAtUtc;
|
||||
|
||||
+20
-2
@@ -235,6 +235,9 @@ public class CollectionQueryService {
|
||||
String disabled=executionBlock(task.warehouseCode(),protection);if(disabled!=null)return disabled;
|
||||
if(!"BUSINESS".equals(config.getCollectionMode()))return "COLLECTION_MODE_NOT_BUSINESS";
|
||||
if("test".equals(config.getEnvironment())&&"SCHEDULED".equals(task.taskType()))return "TEST_MANUAL_TASK_REQUIRED";
|
||||
if("test".equals(config.getEnvironment())) {
|
||||
String budgetBlock=businessBudgetBlock(task);if(budgetBlock!=null)return budgetBlock;
|
||||
}
|
||||
if(!Set.of("DEAD","RETRY_WAIT").contains(task.status()))return "TASK_NOT_RETRYABLE";
|
||||
if(scope==null||!Objects.equals(scope.scopeHash(),task.scopeHash()))return "SCOPE_CHANGED";
|
||||
if(scope.leaseOwner()!=null&&scope.leaseUntilUtc()!=null&&clock.instant().isBefore(utc(scope.leaseUntilUtc())))return "SCOPE_LEASE_BUSY";
|
||||
@@ -242,6 +245,19 @@ public class CollectionQueryService {
|
||||
if(task.upstreamTaskId()!=null&&(parent==null||!"SUCCEEDED".equals(parent.status())))return "UPSTREAM_NOT_READY";
|
||||
return null;
|
||||
}
|
||||
private String businessBudgetBlock(Task task) {
|
||||
JsonNode budget=boundedObject(task.businessRootBudgetJson());
|
||||
Long maximum=number(budget,"maxHttp"),reserved=number(budget,"http"),confirmed=number(budget,"confirmedHttp");
|
||||
Instant started=budgetInstant(budget,"startedAtUtc"),deadline=budgetInstant(budget,"deadlineAtUtc");
|
||||
if(budget==null||maximum==null||maximum<=0||maximum>Integer.MAX_VALUE||reserved==null||reserved>maximum
|
||||
||confirmed==null||confirmed>reserved||started==null||deadline==null||!deadline.isAfter(started))
|
||||
return "BUSINESS_SESSION_BUDGET_MISSING";
|
||||
JsonNode stopped=budget.get("stopReason");
|
||||
if(stopped!=null&&!stopped.isNull())
|
||||
return "BUSINESS_SESSION_STOPPED";
|
||||
if(!clock.instant().isBefore(deadline)&&!Boolean.TRUE.equals(task.businessLocalWorkReady()))return "BUSINESS_SESSION_DEADLINE";
|
||||
return null;
|
||||
}
|
||||
private CollectionResponses.Attempt attemptView(Attempt run,Task task,boolean retryAllowed) {
|
||||
boolean terminal=TERMINAL.contains(run.status());
|
||||
boolean same=task!=null&&Objects.equals(run.attemptNo(),task.attemptCount());
|
||||
@@ -251,11 +267,13 @@ public class CollectionQueryService {
|
||||
String health=terminal?"TERMINATED":heartbeat==null||!clock.instant().isBefore(heartbeat.plusSeconds(config.getLeaseSeconds()))?"UNCONFIRMED":"HEALTHY";
|
||||
Long elapsed=started==null?null:Math.max(0,Duration.between(started,finished==null?clock.instant():finished).getSeconds());
|
||||
return new CollectionResponses.Attempt(run.id(),run.taskNo(),run.attemptNo(),run.status(),run.lastPhase(),run.executionPurpose(),
|
||||
started,finished,progressInstant(progress,"stageStartedAt"),updated,heartbeat,health,elapsed,run.errorCode(),safeMessage(run.errorMessage()),
|
||||
started,finished,progressInstant(progress,"stageStartedAt"),updated,heartbeat,health,elapsed,run.errorCode(),safeMessage(run.errorCode(),run.errorMessage()),
|
||||
retryAllowed&&same&&"BUSINESS".equals(run.executionPurpose())&&Set.of("FAILED","ABORTED","YIELDED").contains(run.status()),
|
||||
run.coverageStatus(),utc(run.coveredFromUtc()),utc(run.coveredUntilUtc()),utc(run.publishedAtUtc()),progress);
|
||||
}
|
||||
private String safeMessage(String error) {
|
||||
private String safeMessage(String code,String error) {
|
||||
if("BUSINESS_SESSION_HTTP_LIMIT".equals(code))return "本次正式采集会话的请求预算已耗尽,根任务及关联子任务已停止,不能恢复原任务";
|
||||
if("BUSINESS_SESSION_DEADLINE".equals(code))return "本次正式采集会话已到截止时间,根任务及关联子任务已停止,不能恢复原任务";
|
||||
if(error==null)return null;
|
||||
// Errors are already sanitized by the write side; avoid exposing arbitrary payloads here.
|
||||
return "采集尝试失败,请根据错误码查看受控诊断记录";
|
||||
|
||||
+17
-4
@@ -41,17 +41,30 @@ public interface CollectionReadMapper {
|
||||
+ " THEN JSON_UNQUOTE(JSON_EXTRACT(t.query_scope_json,'$.warehouses[0]')) ELSE t.warehouse_code END";
|
||||
String TASK_FAMILY = "CASE WHEN " + VALIDATION_ROOT + " THEN 'OUTBOUND' ELSE s.data_family END";
|
||||
String TASK_MEMBERSHIP = "((t.task_role='WAREHOUSE_SYNC' AND s.id IS NOT NULL) OR " + VALIDATION_ROOT + ")";
|
||||
String BUSINESS_LOCAL_WORK_READY = "CASE WHEN t.task_role='WAREHOUSE_SYNC' AND s.data_family='OUTBOUND'"
|
||||
+ " AND (t.task_type='REBUILD' OR t.apply_state='APPLIED' OR ("
|
||||
+ "JSON_EXTRACT(t.routing_manifest_json,'$.directoryComplete')=CAST('true' AS JSON)"
|
||||
+ " AND JSON_TYPE(JSON_EXTRACT(t.routing_manifest_json,'$.objects'))='OBJECT'"
|
||||
+ " AND NOT EXISTS (SELECT 1 FROM JSON_TABLE(t.routing_manifest_json,'$.objects.*'"
|
||||
+ " COLUMNS(entry JSON PATH '$')) manifest_item"
|
||||
+ " WHERE COALESCE(JSON_TYPE(JSON_EXTRACT(manifest_item.entry,'$.effectiveStageId')),'NULL') NOT IN ('INTEGER')"
|
||||
+ " OR (CAST(JSON_UNQUOTE(JSON_EXTRACT(manifest_item.entry,'$.effectiveStageId')) AS DECIMAL(65,0)) BETWEEN 1 AND 9223372036854775807) IS NOT TRUE)))"
|
||||
+ " THEN TRUE ELSE FALSE END";
|
||||
String TASK = "t.id,t.task_no,t.source_account," + TASK_WAREHOUSE + " AS warehouse_code," + TASK_FAMILY + " AS data_family,t.task_role,t.task_type,"
|
||||
+ "t.execution_purpose,t.scope_hash,t.status,t.apply_state,t.attempt_count,t.query_from_utc,t.query_until_utc,"
|
||||
+ "t.next_retry_at_utc,t.upstream_task_id,t.validation_root_task_id,t.result_published_run_id,"
|
||||
+ "t.progress_json,t.progress_updated_at_utc,t.validation_budget_json,t.created_at,t.updated_at";
|
||||
+ "t.progress_json,t.progress_updated_at_utc,t.validation_budget_json,b.business_budget_json AS business_root_budget_json,"
|
||||
+ BUSINESS_LOCAL_WORK_READY + " AS business_local_work_ready,t.created_at,t.updated_at";
|
||||
String ATTEMPT = "r.id,r.task_id,t.task_no,r.attempt_no,r.status,r.last_phase,r.execution_purpose,"
|
||||
+ "r.fetch_started_at_utc,r.fetch_finished_at_utc,r.build_started_at_utc,r.finished_at_utc,"
|
||||
+ "r.published_at_utc,r.heartbeat_at_utc,r.progress_snapshot_json,r.coverage_status,"
|
||||
+ "r.covered_from_utc,r.covered_until_utc,r.error_code,r.error_message,r.created_at,"
|
||||
+ "CASE WHEN " + VALIDATION_ROOT + " THEN " + TASK_WAREHOUSE + " ELSE r.warehouse_code END AS warehouse_code,r.scope_id,r.scope_hash";
|
||||
String BUSINESS_ROOT_JOIN = " LEFT JOIN ll_selfop_sync_task b ON b.id=t.business_root_task_id"
|
||||
+ " AND b.source_account=t.source_account AND t.execution_purpose='BUSINESS'"
|
||||
+ " AND b.execution_purpose='BUSINESS' AND b.business_root_task_id=b.id ";
|
||||
String TASK_FROM = " FROM ll_selfop_sync_task t LEFT JOIN ll_selfop_sync_scope s ON s.id=t.scope_id "
|
||||
+ "AND s.source_account=t.source_account AND s.warehouse_code=t.warehouse_code ";
|
||||
+ "AND s.source_account=t.source_account AND s.warehouse_code=t.warehouse_code " + BUSINESS_ROOT_JOIN;
|
||||
String RUN_FROM = " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id "
|
||||
+ "AND t.source_account=r.source_account AND r.execution_purpose=t.execution_purpose AND r.run_role=t.task_role"
|
||||
+ " AND (t.warehouse_code=r.warehouse_code OR (t.warehouse_code IS NULL AND r.warehouse_code IS NULL)) "
|
||||
@@ -96,8 +109,7 @@ public interface CollectionReadMapper {
|
||||
@Select("SELECT api_cooldown_until_utc,api_pause_reason,api_budget_json FROM ll_selfop_outbound_discovery_scope WHERE source_account=#{account} LIMIT 1")
|
||||
Protection protection(@Param("account") String account);
|
||||
|
||||
@Select("<script>SELECT " + TASK + " FROM ll_selfop_sync_task t LEFT JOIN ll_selfop_sync_scope s ON s.id=t.scope_id"
|
||||
+ " AND s.source_account=t.source_account AND s.warehouse_code=t.warehouse_code"
|
||||
@Select("<script>SELECT " + TASK + TASK_FROM
|
||||
+ " WHERE t.source_account=#{account} AND t.id IN <foreach collection='ids' item='id' open='(' close=')' separator=','>#{id}</foreach></script>")
|
||||
List<Task> tasksByIds(@Param("account") String account,@Param("ids") List<Long> ids);
|
||||
@Select("<script>SELECT " + ATTEMPT + " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id"
|
||||
@@ -132,6 +144,7 @@ public interface CollectionReadMapper {
|
||||
// The one account active task is separately related to warehouses by actual persisted windows.
|
||||
@Select("SELECT " + TASK + " FROM ll_selfop_outbound_discovery_scope d JOIN ll_selfop_sync_task t ON t.id=d.active_task_id"
|
||||
+ " AND t.source_account=d.source_account LEFT JOIN ll_selfop_sync_scope s ON s.id=t.scope_id"
|
||||
+ BUSINESS_ROOT_JOIN
|
||||
+ " WHERE d.source_account=#{account} AND t.task_role='ACCOUNT_DISCOVERY' LIMIT 1")
|
||||
Task sharedActiveAccount(@Param("account") String account);
|
||||
@Select("<script>SELECT DISTINCT s.warehouse_code FROM ll_selfop_sync_scope s WHERE s.source_account=#{account}"
|
||||
|
||||
+2
-1
@@ -20,7 +20,8 @@ public final class CollectionReadRows {
|
||||
String scopeHash, String status, String applyState, Integer attemptCount,
|
||||
LocalDateTime queryFromUtc, LocalDateTime queryUntilUtc, LocalDateTime nextRetryAtUtc,
|
||||
Long upstreamTaskId, Long validationRootTaskId, Long resultPublishedRunId,
|
||||
String progressJson, LocalDateTime progressUpdatedAtUtc, String validationBudgetJson,
|
||||
String progressJson, LocalDateTime progressUpdatedAtUtc, String validationBudgetJson, String businessRootBudgetJson,
|
||||
Boolean businessLocalWorkReady,
|
||||
LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||
public record Attempt(Long id, Long taskId, String taskNo, Integer attemptNo,
|
||||
String status, String lastPhase, String executionPurpose,
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.request;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** TEST manual BUSINESS session ceiling, frozen on its root and shared by descendants. */
|
||||
public final class SelfopBusinessBudget {
|
||||
public int maxHttp, http, confirmedHttp;
|
||||
public LocalDateTime startedAtUtc, deadlineAtUtc;
|
||||
public String stopReason;
|
||||
|
||||
public static String freeze(LocalDateTime now, int maxHttp, int seconds) {
|
||||
if (now == null || maxHttp < 1 || seconds < 1)
|
||||
throw new IllegalArgumentException("BUSINESS_SESSION_BUDGET_INVALID");
|
||||
var budget = new SelfopBusinessBudget();
|
||||
budget.maxHttp = maxHttp;
|
||||
budget.startedAtUtc = now;
|
||||
budget.deadlineAtUtc = now.plusSeconds(seconds);
|
||||
return SelfopSupport.json(budget);
|
||||
}
|
||||
|
||||
public void validate() {
|
||||
if (maxHttp < 1 || http < 0 || http > maxHttp || confirmedHttp < 0 || confirmedHttp > http
|
||||
|| startedAtUtc == null || deadlineAtUtc == null || !deadlineAtUtc.isAfter(startedAtUtc)
|
||||
|| stopReason != null && !stopReason.matches("[A-Z0-9_]{1,64}"))
|
||||
throw new EgressRejectedException("BUSINESS_SESSION_BUDGET_INVALID", false, null);
|
||||
}
|
||||
|
||||
public String limit(LocalDateTime now) {
|
||||
validate();
|
||||
if (stopReason != null) return stopReason;
|
||||
if (!now.isBefore(deadlineAtUtc)) return "BUSINESS_SESSION_DEADLINE";
|
||||
if (http >= maxHttp) return "BUSINESS_SESSION_HTTP_LIMIT";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -9,6 +9,8 @@ public interface SelfopEgressMapper {
|
||||
@Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE") SelfopSyncRun lockRun(Long id);
|
||||
@Select("SELECT COUNT(*) FROM ll_selfop_sync_scope WHERE id=#{scopeId} AND fence_token=#{fence} AND lease_until_utc>#{now}") int warehouseFence(@Param("scopeId") Long scopeId,@Param("fence") Long fence,@Param("taskId") Long taskId,@Param("now") LocalDateTime now);
|
||||
@Update("UPDATE ll_selfop_outbound_discovery_scope SET api_budget_json=#{apiBudgetJson},api_cooldown_until_utc=#{apiCooldownUntilUtc},api_pause_reason=#{apiPauseReason},updated_at=UTC_TIMESTAMP(3) WHERE id=#{id}") int saveAccount(SelfopOutboundDiscoveryScope row);
|
||||
@Update("UPDATE ll_selfop_sync_task SET validation_budget_json=#{validationBudgetJson},status=#{status},next_retry_at_utc=#{nextRetryAtUtc},updated_at=UTC_TIMESTAMP(3) WHERE id=#{id}") int saveRoot(SelfopSyncTask row);
|
||||
@Update("UPDATE ll_selfop_sync_task SET validation_budget_json=#{validationBudgetJson},business_budget_json=#{businessBudgetJson},status=#{status},next_retry_at_utc=#{nextRetryAtUtc},updated_at=UTC_TIMESTAMP(3) WHERE id=#{id}") int saveRoot(SelfopSyncTask row);
|
||||
@Update("UPDATE ll_selfop_sync_task SET status='DEAD',next_retry_at_utc=NULL,updated_at=UTC_TIMESTAMP(3) WHERE business_root_task_id=#{root} AND source_account=#{account} AND execution_purpose='BUSINESS' AND status IN ('WAITING_SOURCE','READY','RUNNING','RETRY_WAIT')") int terminateBusinessTasks(@Param("root") Long root,@Param("account") String account);
|
||||
@Update("UPDATE ll_selfop_sync_run SET status='FAILED',error_code=#{reason},finished_at_utc=#{now},progress_snapshot_json=(SELECT progress_json FROM ll_selfop_sync_task WHERE id=task_id) WHERE source_account=#{account} AND execution_purpose='BUSINESS' AND task_id IN (SELECT id FROM ll_selfop_sync_task WHERE business_root_task_id=#{root} AND source_account=#{account} AND execution_purpose='BUSINESS') AND status IN ('FETCHING','VALIDATED','YIELDED','READY','BUILDING')") int terminateBusinessRuns(@Param("root") Long root,@Param("account") String account,@Param("reason") String reason,@Param("now") LocalDateTime now);
|
||||
@Update("UPDATE ll_selfop_sync_run SET status=#{status},error_code=#{reason},finished_at_utc=#{now},progress_snapshot_json=(SELECT progress_json FROM ll_selfop_sync_task WHERE id=task_id) WHERE task_id IN (SELECT id FROM ll_selfop_sync_task WHERE validation_root_task_id=#{root}) AND status IN ('FETCHING','VALIDATED','YIELDED')") int terminateValidationRuns(@Param("root") Long root,@Param("status") String status,@Param("reason") String reason,@Param("now") LocalDateTime now);
|
||||
}
|
||||
|
||||
+30
-2
@@ -19,7 +19,10 @@ public class SelfopEgressService {
|
||||
LocalDateTime now=now();gate(context.sourceAccount(),context.executionPurpose(),context.exclusiveGrantId(),now);
|
||||
if(operation==null||requestHash==null||!requestHash.matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_INVALID");
|
||||
var account=account(context.sourceAccount());var budget=budget(account,now);save(account,budget);var root=root(context.rootTaskId(),context.executionPurpose(),context.sourceAccount());
|
||||
var task=mapper.lockTask(context.taskId());var run=mapper.lockRun(context.runId());verify(task,run,root,account,now);
|
||||
var task=mapper.lockTask(context.taskId());var run=mapper.lockRun(context.runId());
|
||||
SelfopBusinessBudget business=testBusiness(context.executionPurpose())?business(root):null;
|
||||
if(business!=null){verifyBusinessTask(task,root);String limit=business.limit(now);if(limit!=null){stopBusiness(root,business,limit,now);throw reject(limit);}}
|
||||
verify(task,run,root,account,now);
|
||||
checkAccount(account,budget,now);
|
||||
SelfopValidationBudget validation=null;
|
||||
if("VALIDATION".equals(context.executionPurpose())){
|
||||
@@ -41,8 +44,10 @@ public class SelfopEgressService {
|
||||
var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds())));
|
||||
if(pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc();
|
||||
if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc;
|
||||
if(business!=null&&pending.expiresAtUtc.isAfter(business.deadlineAtUtc))pending.expiresAtUtc=business.deadlineAtUtc;
|
||||
budget.pending=pending;budget.reservedHttp++;budget.lastRequestAtUtc=now;
|
||||
if(validation!=null){validation.consume(operation,warehouseCode,detailKey);root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);}
|
||||
if(business!=null){business.http++;saveBusiness(root,business);}
|
||||
save(account,budget);return outbound;
|
||||
}
|
||||
@Transactional(noRollbackFor=EgressRejectedException.class)
|
||||
@@ -50,7 +55,16 @@ public class SelfopEgressService {
|
||||
requireRequest(request);var c=request.context();var now=now();gate(c.sourceAccount(),c.executionPurpose(),c.exclusiveGrantId(),now);
|
||||
var account=account(c.sourceAccount());var budget=budget(account,now);var pending=pendingForAuthorization(budget,c,request.operation());
|
||||
var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount());
|
||||
var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId);verify(task,run,root,account,now);
|
||||
var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId);
|
||||
if(testBusiness(c.executionPurpose())){
|
||||
var b=business(root);verifyBusinessTask(task,root);
|
||||
// The last reservation already spent its HTTP allowance. Recheck time/stop,
|
||||
// not http>=maxHttp, or that paid reservation could never be authorized.
|
||||
String limit=b.stopReason!=null?b.stopReason:!now.isBefore(b.deadlineAtUtc)?"BUSINESS_SESSION_DEADLINE":null;
|
||||
if(limit!=null){stopBusiness(root,b,limit,now);throw reject(limit);}
|
||||
if(pending.expiresAtUtc.isAfter(b.deadlineAtUtc))pending.expiresAtUtc=b.deadlineAtUtc;
|
||||
}
|
||||
verify(task,run,root,account,now);
|
||||
if(pending.consumed||pending.completed||!now.isBefore(pending.expiresAtUtc))throw reject("EGRESS_PERMIT_REPLAY_OR_EXPIRED");
|
||||
if(account.getApiPauseReason()!=null||account.getApiCooldownUntilUtc()!=null&&now.isBefore(account.getApiCooldownUntilUtc()))throw reject("ACCOUNT_PAUSED");
|
||||
if("VALIDATION".equals(c.executionPurpose())){var v=validation(root);if(v.stopReason!=null||!now.isBefore(v.deadlineAtUtc)){stop(root,v,v.stopReason==null?"VALIDATION_DEADLINE":v.stopReason,false,now);throw new EgressRejectedException("VALIDATION_STOPPED",true,null);}}
|
||||
@@ -72,6 +86,7 @@ public class SelfopEgressService {
|
||||
var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount());
|
||||
SelfopValidationBudget validation="VALIDATION".equals(c.executionPurpose())?validation(root):null;
|
||||
if(validation!=null){validation.confirmedHttp+=request.actualHttpAttempts();root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);}
|
||||
if(testBusiness(c.executionPurpose())){var b=business(root);b.confirmedHttp+=request.actualHttpAttempts();b.validate();saveBusiness(root,b);}
|
||||
boolean auth=Integer.valueOf(401).equals(request.upstreamStatus())||Integer.valueOf(403).equals(request.upstreamStatus())
|
||||
||"LINGXING_WMS_AUTHORIZATION_REJECTED".equals(request.errorCode())||"LINGXING_WMS_SIGNATURE_REJECTED".equals(request.errorCode());
|
||||
boolean rate=!auth&&(Integer.valueOf(429).equals(request.upstreamStatus())||Integer.valueOf(31001).equals(request.sourceCode())
|
||||
@@ -146,12 +161,25 @@ public class SelfopEgressService {
|
||||
}
|
||||
private SelfopSyncTask root(Long id,String purpose,String account){var t=mapper.lockTask(id);if(t==null||!Objects.equals(t.getSourceAccount(),account)||!Objects.equals(t.getExecutionPurpose(),purpose))throw reject("EGRESS_ROOT_INVALID");if("VALIDATION".equals(purpose)&&!Objects.equals(id,t.getValidationRootTaskId()))throw reject("VALIDATION_ROOT_INVALID");return t;}
|
||||
private void verify(SelfopSyncTask task,SelfopSyncRun run,SelfopSyncTask root,SelfopOutboundDiscoveryScope account,LocalDateTime now){
|
||||
if(testBusiness(root.getExecutionPurpose())&&"DEAD".equals(root.getStatus()))throw reject("BUSINESS_SESSION_ROOT_STOPPED");
|
||||
if(task==null||run==null||!Objects.equals(run.getTaskId(),task.getId())||!Objects.equals(run.getSourceAccount(),root.getSourceAccount())||!Objects.equals(task.getSourceAccount(),root.getSourceAccount())||!Objects.equals(task.getExecutionPurpose(),root.getExecutionPurpose())||!Objects.equals(run.getExecutionPurpose(),task.getExecutionPurpose())||!"RUNNING".equals(task.getStatus())||!Set.of("FETCHING","VALIDATED").contains(run.getStatus()))throw reject("EGRESS_TASK_NOT_ACTIVE");
|
||||
if("VALIDATION".equals(root.getExecutionPurpose())&&(!Objects.equals(task.getValidationRootTaskId(),root.getId())||Set.of("DEAD","SAMPLE_DONE").contains(root.getStatus())))throw reject("VALIDATION_ROOT_STOPPED");
|
||||
if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())){if(!Objects.equals(task.getDiscoveryScopeId(),account.getId())||!Objects.equals(account.getFenceToken(),run.getFenceToken())||account.getLeaseUntilUtc()==null||!now.isBefore(account.getLeaseUntilUtc()))throw reject("EGRESS_FENCE_LOST");}
|
||||
else if(!Objects.equals(task.getScopeId(),run.getScopeId())||!Objects.equals(task.getWarehouseCode(),run.getWarehouseCode())||mapper.warehouseFence(task.getScopeId(),run.getFenceToken(),task.getId(),now)!=1)throw reject("EGRESS_FENCE_LOST");
|
||||
}
|
||||
private SelfopValidationBudget validation(SelfopSyncTask root){if(root==null||!"VALIDATION".equals(root.getExecutionPurpose())||!Objects.equals(root.getId(),root.getValidationRootTaskId())||root.getValidationBudgetJson()==null)throw reject("VALIDATION_ROOT_INVALID");var b=SelfopSupport.read(root.getValidationBudgetJson(),SelfopValidationBudget.class);b.validate();return b;}
|
||||
private boolean testBusiness(String purpose){return "test".equals(config.getEnvironment())&&"BUSINESS".equals(purpose);}
|
||||
private SelfopBusinessBudget business(SelfopSyncTask root){
|
||||
if(root==null||!"BUSINESS".equals(root.getExecutionPurpose())||!Objects.equals(root.getId(),root.getBusinessRootTaskId())||root.getBusinessBudgetJson()==null)throw reject("BUSINESS_SESSION_ROOT_INVALID");
|
||||
SelfopBusinessBudget b;try{b=SelfopSupport.read(root.getBusinessBudgetJson(),SelfopBusinessBudget.class);}catch(RuntimeException e){throw reject("BUSINESS_SESSION_BUDGET_INVALID");}
|
||||
if(b==null)throw reject("BUSINESS_SESSION_BUDGET_INVALID");b.validate();return b;
|
||||
}
|
||||
private void verifyBusinessTask(SelfopSyncTask task,SelfopSyncTask root){if(task==null||!Objects.equals(task.getBusinessRootTaskId(),root.getId())||!Objects.equals(task.getSourceAccount(),root.getSourceAccount())||!"BUSINESS".equals(task.getExecutionPurpose()))throw reject("BUSINESS_SESSION_ROOT_INVALID");}
|
||||
private void stopBusiness(SelfopSyncTask root,SelfopBusinessBudget b,String reason,LocalDateTime now){
|
||||
b.stopReason=reason;root.setStatus("DEAD");root.setNextRetryAtUtc(null);saveBusiness(root,b);
|
||||
mapper.terminateBusinessTasks(root.getId(),root.getSourceAccount());mapper.terminateBusinessRuns(root.getId(),root.getSourceAccount(),reason,now);
|
||||
}
|
||||
private void saveBusiness(SelfopSyncTask root,SelfopBusinessBudget b){root.setBusinessBudgetJson(SelfopSupport.json(b));if(mapper.saveRoot(root)!=1)throw new IllegalStateException("BUSINESS_SESSION_BUDGET_WRITE_FAILED");}
|
||||
private SelfopApiBudget.Pending pendingForAuthorization(SelfopApiBudget budget,EgressContext c,Operation op){var p=budget.pending;if(p==null||!Objects.equals(p.context,c)||p.operation!=op)throw reject("EGRESS_PERMIT_BINDING_INVALID");return p;}
|
||||
private SelfopApiBudget.Pending pending(SelfopApiBudget budget,EgressContext c,Operation op,String hash){var p=budget.pending;if(p==null||!Objects.equals(p.context,c)||p.operation!=op||!Objects.equals(p.requestHash,hash))throw reject("EGRESS_PERMIT_BINDING_INVALID");return p;}
|
||||
private void stop(SelfopSyncTask root,SelfopValidationBudget v,String reason,boolean failed,LocalDateTime now){if("DEAD".equals(root.getStatus())||"SAMPLE_DONE".equals(root.getStatus()))return;v.stopReason=reason;root.setValidationBudgetJson(SelfopSupport.json(v));root.setStatus(failed?"DEAD":"SAMPLE_DONE");root.setNextRetryAtUtc(null);mapper.saveRoot(root);mapper.terminateValidationRuns(root.getId(),failed?"FAILED":"SAMPLE_DONE",reason,now);}
|
||||
|
||||
+5
-1
@@ -22,7 +22,7 @@ public class SelfopCommandService {
|
||||
public SelfopCommandService(SelfopTaskService taskService,SelfopSyncScopeMapper scopes,SelfopOutboundDiscoveryScopeMapper accounts,SelfopAnalyticsProperties config,SelfopSyncTaskMapper tasks){
|
||||
this.taskService=taskService;this.scopes=scopes;this.accounts=accounts;this.config=config;this.tasks=tasks;
|
||||
}
|
||||
@Transactional
|
||||
@Transactional(noRollbackFor=SelfopTaskService.BusinessSessionStoppedException.class)
|
||||
public SelfopSyncTask request(Command c,String operator){
|
||||
try { return requestChecked(c,operator); }
|
||||
catch(IllegalStateException failure){
|
||||
@@ -103,6 +103,10 @@ public class SelfopCommandService {
|
||||
if(value==null||value.isBlank()||value.length()>max)throw new BizException(label+"不能为空且不能超过"+max+"个字符");
|
||||
}
|
||||
private static final Map<String,String> REJECTIONS=Map.ofEntries(
|
||||
Map.entry("BUSINESS_SESSION_ROOT_REQUIRED","该测试任务没有有效的会话预算,请新建有界任务"),
|
||||
Map.entry("BUSINESS_SESSION_ROOT_INVALID","测试采集会话关联无效,请核对任务记录"),
|
||||
Map.entry("BUSINESS_SESSION_IDENTITY_INVALID","测试采集会话不能重新指定预算或根任务"),
|
||||
Map.entry("BUSINESS_SESSION_BUDGET_INVALID","测试采集会话预算无效,请核对任务记录"),
|
||||
Map.entry("SELFOP_WAREHOUSE_UNCONFIGURED","该仓库尚未配置采集,暂不能提交任务"),
|
||||
Map.entry("SELFOP_TASK_SCOPE_MISMATCH","任务不存在或不属于当前账号和仓库"),
|
||||
Map.entry("SELFOP_TASK_MISSING","任务不存在,请刷新后重试"),
|
||||
|
||||
+36
-31
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException;
|
||||
@@ -53,7 +54,7 @@ public class SelfopDiscoveryService {
|
||||
require(page.total()!=null&&page.pages()!=null,"SELFOP_PAGE_METADATA_MISSING");
|
||||
if(manifest.getTotal()==null){manifest.setTotal(page.total());manifest.setPages(page.pages());}
|
||||
require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED");
|
||||
tx.executeWithoutResult(s->{leases.verify(lease);
|
||||
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);
|
||||
for(var summary:page.records()) {
|
||||
identity(summary.customerCode(),64);identity(summary.deliveryNo(),128);identity(summary.warehouseCode(),64);
|
||||
String key=hash(List.of(task.getSourceAccount(),summary.customerCode(),summary.deliveryNo()));String content=hash(summary);
|
||||
@@ -80,11 +81,11 @@ public class SelfopDiscoveryService {
|
||||
if(manifest.getScan()==1){manifest.setScan(2);manifest.setNextPage(1);}
|
||||
else{manifest.getObjects().keySet().removeIf(k->k.startsWith("seen:"));manifest.setDirectoryComplete(true);}
|
||||
}
|
||||
saveManifest(task,manifest);
|
||||
saveManifest(checkpoint.task(),checkpoint.run(),manifest);
|
||||
});
|
||||
}
|
||||
}
|
||||
route(task,manifest,lease,false);
|
||||
route(task,run,manifest,lease,false);
|
||||
for(var entry:manifest.getObjects().entrySet()) {
|
||||
var ref=entry.getValue();
|
||||
if(ref.getRefreshTaskId()!=null){
|
||||
@@ -97,7 +98,7 @@ public class SelfopDiscoveryService {
|
||||
var refreshed=read(refresh.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(entry.getKey());
|
||||
require(refreshed!=null&&refreshed.getEffectiveStageId()!=null,"SELFOP_REFRESH_REFERENCE_MISSING");
|
||||
ref.setEffectiveStageId(refreshed.getEffectiveStageId());ref.setWarehouseCode(refreshed.getWarehouseCode());
|
||||
tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);});
|
||||
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});
|
||||
}
|
||||
var latest=stages.selectOne(new QueryWrapper<SelfopSyncStage>().eq("object_type","OUTBOUND_ORDER")
|
||||
.eq("object_key_hash",entry.getKey()).eq("validation_status","VALID").isNotNull("observation_sequence").orderByDesc("observation_sequence").last("LIMIT 1"));
|
||||
@@ -128,11 +129,11 @@ public class SelfopDiscoveryService {
|
||||
var locateQuery=new Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId());relocate.setQueryScopeJson(json(locateQuery));relocate.setRequestHash(hash(relocate.getQueryScopeJson()));
|
||||
fetchTask=taskService.create(relocate);fetchRun=taskService.begin(fetchTask.getId(),lease);ref.setRefreshTaskId(fetchTask.getId());
|
||||
fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING");
|
||||
tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);});
|
||||
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});
|
||||
var located=new SelfopRoutingManifest();
|
||||
if(!collectLocated(fetchTask,fetchRun,lease,budget,locateQuery,located)){tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);});taskService.yield(task.getId(),run.getId(),lease);return;}
|
||||
if(!collectLocated(fetchTask,fetchRun,lease,budget,locateQuery,located)){tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});taskService.yield(task.getId(),run.getId(),lease);return;}
|
||||
ref.setWarehouseCode(located.getObjects().values().iterator().next().getWarehouseCode());
|
||||
tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);});
|
||||
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});
|
||||
if(!budget.take()){taskService.yield(fetchTask.getId(),fetchRun.getId(),lease);taskService.yield(task.getId(),run.getId(),lease);return;}
|
||||
observation=leases.nextObservation(lease);detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo());
|
||||
}
|
||||
@@ -141,7 +142,9 @@ public class SelfopDiscoveryService {
|
||||
require(detail.items()!=null&&detail.sourceStatus()!=null,"SELFOP_DETAIL_STRUCTURE_INVALID");
|
||||
identity(detail.warehouseCode(),64);
|
||||
var finalFetchTask=fetchTask;var finalFetchRun=fetchRun;var acceptedDetail=detail;long acceptedObservation=observation;
|
||||
tx.executeWithoutResult(s->{leases.verify(lease);
|
||||
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);
|
||||
var fetchCheckpoint=finalFetchTask.getId().equals(task.getId())?checkpoint:taskService.checkpoint(finalFetchTask,finalFetchRun,lease);
|
||||
var currentFetchTask=fetchCheckpoint.task();var currentFetchRun=fetchCheckpoint.run();
|
||||
var existing=stages.selectOne(new QueryWrapper<SelfopSyncStage>().eq("stage_batch_id",finalFetchTask.getId()).eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",entry.getKey()));
|
||||
SelfopSyncStage full;
|
||||
if(existing!=null&&"VALID".equals(existing.getValidationStatus())){
|
||||
@@ -150,29 +153,30 @@ public class SelfopDiscoveryService {
|
||||
full=stage(finalFetchTask,finalFetchRun,acceptedDetail,existing==null?1:existing.getFirstPageNo(),acceptedObservation,"VALID");
|
||||
if(existing!=null){full.setId(existing.getId());full.setRunId(existing.getRunId());full.setCreatedAt(existing.getCreatedAt());stages.updateById(full);}else stages.insert(full);
|
||||
}
|
||||
ref.setEffectiveStageId(full.getId());ref.setWarehouseCode(acceptedDetail.warehouseCode());saveManifest(task,manifest);
|
||||
if(!finalFetchTask.getId().equals(task.getId())){var refreshManifest=new SelfopRoutingManifest();refreshManifest.setDirectoryComplete(true);refreshManifest.setPages(1);refreshManifest.setTotal(1L);
|
||||
ref.setEffectiveStageId(full.getId());ref.setWarehouseCode(acceptedDetail.warehouseCode());saveManifest(checkpoint.task(),checkpoint.run(),manifest);
|
||||
if(!currentFetchTask.getId().equals(task.getId())){var refreshManifest=new SelfopRoutingManifest();refreshManifest.setDirectoryComplete(true);refreshManifest.setPages(1);refreshManifest.setTotal(1L);
|
||||
var refreshRef=new SelfopRoutingManifest.Entry();refreshRef.setCustomerCode(ref.getCustomerCode());refreshRef.setDeliveryNo(ref.getDeliveryNo());refreshRef.setWarehouseCode(ref.getWarehouseCode());refreshRef.setEffectiveStageId(full.getId());
|
||||
refreshManifest.getObjects().put(entry.getKey(),refreshRef);finalFetchTask.setRoutingManifestJson(json(refreshManifest));
|
||||
SelfopProgressProjection.freezeRun(finalFetchTask,finalFetchRun);finalFetchRun.setStatus("ROUTED");finalFetchRun.setFetchFinishedAtUtc(utcNow());finalFetchRun.setFinishedAtUtc(utcNow());finalFetchRun.setUpdatedAt(utcNow());runs.updateById(finalFetchRun);
|
||||
finalFetchTask.setStatus("SUCCEEDED");finalFetchTask.setUpdatedAt(utcNow());tasks.updateById(finalFetchTask);}
|
||||
refreshManifest.getObjects().put(entry.getKey(),refreshRef);currentFetchTask.setRoutingManifestJson(json(refreshManifest));
|
||||
SelfopProgressProjection.freezeRun(currentFetchTask,currentFetchRun);currentFetchRun.setStatus("ROUTED");currentFetchRun.setFetchFinishedAtUtc(utcNow());currentFetchRun.setFinishedAtUtc(utcNow());currentFetchRun.setUpdatedAt(utcNow());runs.updateById(currentFetchRun);
|
||||
currentFetchTask.setStatus("SUCCEEDED");currentFetchTask.setUpdatedAt(utcNow());tasks.updateById(currentFetchTask);}
|
||||
});
|
||||
route(task,manifest,lease,false);
|
||||
route(task,run,manifest,lease,false);
|
||||
}
|
||||
// A directory warehouse is provisional until every account detail (including resumed
|
||||
// revision checks) has resolved. An empty warehouse must not publish a false zero early.
|
||||
route(task,manifest,lease,true);
|
||||
tx.executeWithoutResult(s->{leases.verify(lease);var now=utcNow();
|
||||
SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);
|
||||
run.setStatus("ROUTED");run.setCoverageStatus("COMPLETE");run.setPageCount(manifest.getPages()==null?0:manifest.getPages()*2);
|
||||
run.setSourceTotalCount(manifest.getTotal());run.setUniqueObjectCount((long)manifest.getObjects().size());run.setFetchFinishedAtUtc(now);run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run);
|
||||
task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.updateById(task);
|
||||
var account=accounts.selectById(task.getDiscoveryScopeId());
|
||||
route(task,run,manifest,lease,true);
|
||||
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);var currentTask=checkpoint.task();var currentRun=checkpoint.run();var now=utcNow();
|
||||
SelfopCollectionPolicy.requireBusiness(currentTask);SelfopCollectionPolicy.requireBusiness(currentRun);SelfopProgressProjection.freezeRun(currentTask,currentRun);
|
||||
currentRun.setStatus("ROUTED");currentRun.setCoverageStatus("COMPLETE");currentRun.setPageCount(manifest.getPages()==null?0:manifest.getPages()*2);
|
||||
currentRun.setSourceTotalCount(manifest.getTotal());currentRun.setUniqueObjectCount((long)manifest.getObjects().size());currentRun.setFetchFinishedAtUtc(now);currentRun.setFinishedAtUtc(now);currentRun.setUpdatedAt(now);runs.updateById(currentRun);
|
||||
currentTask.setStatus("SUCCEEDED");currentTask.setUpdatedAt(now);tasks.updateById(currentTask);
|
||||
var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("id",task.getDiscoveryScopeId()).last("FOR UPDATE"));
|
||||
if(task.getQueryFromUtc()!=null&&task.getQueryUntilUtc()!=null&&
|
||||
(account.getCatalogUntilUtc()==null||!task.getQueryFromUtc().isAfter(account.getCatalogUntilUtc()))) {
|
||||
account.setCatalogFromUtc(account.getCatalogFromUtc()==null?task.getQueryFromUtc():account.getCatalogFromUtc());
|
||||
account.setCatalogUntilUtc(account.getCatalogUntilUtc()==null||task.getQueryUntilUtc().isAfter(account.getCatalogUntilUtc())?task.getQueryUntilUtc():account.getCatalogUntilUtc());
|
||||
account.setUpdatedAt(now);accounts.updateById(account);}
|
||||
account.setUpdatedAt(now);accounts.update(null,new UpdateWrapper<SelfopOutboundDiscoveryScope>().eq("id",account.getId())
|
||||
.set("catalog_from_utc",account.getCatalogFromUtc()).set("catalog_until_utc",account.getCatalogUntilUtc()).set("updated_at",now));}
|
||||
});
|
||||
}
|
||||
private boolean collectLocated(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,SelfopBudget budget,Query query,SelfopRoutingManifest manifest) {
|
||||
@@ -189,7 +193,7 @@ public class SelfopDiscoveryService {
|
||||
var e=new SelfopRoutingManifest.Entry();e.setCustomerCode(summary.customerCode());e.setDeliveryNo(summary.deliveryNo());e.setWarehouseCode(summary.warehouseCode());manifest.getObjects().put(key,e);}
|
||||
manifest.setNextPage(manifest.getNextPage()+1);if(page.pages()==0||manifest.getNextPage()>page.pages()){
|
||||
require(manifest.getObjects().size()==1,"SELFOP_LOCATE_UNRESOLVED");manifest.setDirectoryComplete(true);}
|
||||
tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);});
|
||||
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -199,17 +203,18 @@ public class SelfopDiscoveryService {
|
||||
s.setObjectKeyHash(hash(List.of(payload.sourceAccount(),payload.customerCode(),payload.deliveryNo())));s.setFirstPageNo(page);s.setObservedAtUtc(utcNow());
|
||||
s.setContentHash(SelfopOutboundFingerprint.order(payload));s.setPayloadJson(json(payload));s.setValidationStatus(status);s.setCreatedAt(utcNow());s.setUpdatedAt(utcNow());return s;
|
||||
}
|
||||
private void saveManifest(SelfopSyncTask task,SelfopRoutingManifest manifest){task.setRoutingManifestJson(json(manifest));task.setUpdatedAt(utcNow());
|
||||
var active=runs.selectOne(new QueryWrapper<SelfopSyncRun>().eq("task_id",task.getId()).eq("status","FETCHING").orderByDesc("attempt_no").last("LIMIT 1"));
|
||||
if(active!=null){SelfopProgressProjection.applyCheckpoint(task,active,manifest.isDirectoryComplete()?"DETAIL":"DIRECTORY",manifest.isDirectoryComplete()?"ORDER":"PAGE",
|
||||
private void saveManifest(SelfopSyncTask task,SelfopSyncRun active,SelfopRoutingManifest manifest){
|
||||
task.setRoutingManifestJson(json(manifest));task.setUpdatedAt(utcNow());
|
||||
SelfopProgressProjection.applyCheckpoint(task,active,manifest.isDirectoryComplete()?"DETAIL":"DIRECTORY",manifest.isDirectoryComplete()?"ORDER":"PAGE",
|
||||
manifest.isDirectoryComplete()?manifest.getObjects().values().stream().filter(e->e.getEffectiveStageId()!=null).count():Math.max(0,manifest.getNextPage()-1),
|
||||
manifest.isDirectoryComplete()?(long)manifest.getObjects().size():manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan());runs.updateById(active);}
|
||||
tasks.updateById(task);}
|
||||
private void route(SelfopSyncTask parent,SelfopRoutingManifest manifest,SelfopLeaseService.Lease lease,boolean detailsResolved){
|
||||
manifest.isDirectoryComplete()?(long)manifest.getObjects().size():manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan());
|
||||
runs.updateById(active);tasks.updateById(task);
|
||||
}
|
||||
private void route(SelfopSyncTask parent,SelfopSyncRun parentRun,SelfopRoutingManifest manifest,SelfopLeaseService.Lease lease,boolean detailsResolved){
|
||||
// Refresh commands only replace a parent manifest reference; never create an independent
|
||||
// fact writer that could publish around that parent's half-applied barrier.
|
||||
if(read(parent.getQueryScopeJson(),Query.class).parentTaskId()!=null)return;
|
||||
tx.executeWithoutResult(s->{leases.verify(lease);if(!manifest.isDirectoryComplete())return;
|
||||
tx.executeWithoutResult(s->{taskService.checkpoint(parent,parentRun,lease);if(!manifest.isDirectoryComplete())return;
|
||||
boolean accountDetailsReady=detailsResolved&&manifest.getObjects().values().stream().allMatch(e->e.getEffectiveStageId()!=null);
|
||||
for(var scope:scopes.selectList(new QueryWrapper<SelfopSyncScope>().eq("source_account",parent.getSourceAccount()).eq("data_family","OUTBOUND"))){
|
||||
if(!config.getWarehouses().containsKey(scope.getWarehouseCode()))continue;
|
||||
@@ -232,7 +237,7 @@ public class SelfopDiscoveryService {
|
||||
child.setRequestKey("route:"+parent.getId()+":"+scope.getWarehouseCode());child.setTaskType(parent.getTaskType());child.setQueryFromUtc(parent.getQueryFromUtc());child.setQueryUntilUtc(parent.getQueryUntilUtc());
|
||||
child.setQueryScopeJson(scope.getScopeJson());child.setScopeHash(scope.getScopeHash());child.setRequestHash(hash(List.of(parent.getId(),scope.getScopeHash())));child.setStatus(ready?"READY":"WAITING_SOURCE");child.setApplyState("NOT_STARTED");
|
||||
var saved=taskService.create(child);
|
||||
if(!"SUCCEEDED".equals(saved.getStatus())&& !Set.of("APPLYING","APPLIED","BLOCKED").contains(saved.getApplyState())){
|
||||
if(!Set.of("SUCCEEDED","DEAD","SAMPLE_DONE").contains(saved.getStatus())&& !Set.of("APPLYING","APPLIED","BLOCKED").contains(saved.getApplyState())){
|
||||
saved.setRoutingManifestJson(json(own));saved.setStatus(ready?"READY":"WAITING_SOURCE");saved.setUpdatedAt(utcNow());tasks.updateById(saved);}
|
||||
}
|
||||
});
|
||||
|
||||
+6
-6
@@ -34,7 +34,7 @@ public class SelfopInventoryCollector {
|
||||
require(page.total()!=null&&page.pages()!=null,"SELFOP_PAGE_METADATA_MISSING");
|
||||
if(manifest.getTotal()==null){manifest.setTotal(page.total());manifest.setPages(page.pages());}
|
||||
require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED");
|
||||
tx.executeWithoutResult(s->{leases.verify(lease);
|
||||
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);var currentTask=checkpoint.task();var currentRun=checkpoint.run();
|
||||
for(var row:page.records()){
|
||||
require(task.getSourceAccount().equals(row.sourceAccount())&&task.getWarehouseCode().equals(row.warehouseCode()),"SELFOP_STOCK_SCOPE_LEAK");
|
||||
identity(row.customerCode(),64);identity(row.skuCode(),128);require(row.productQuality()!=null,"SELFOP_STOCK_IDENTITY_MISSING");
|
||||
@@ -49,13 +49,13 @@ public class SelfopInventoryCollector {
|
||||
require(manifest.getFirstScan().size()==manifest.getTotal(),"SELFOP_STOCK_COUNT_MISMATCH");
|
||||
manifest.setDirectoryComplete(true);
|
||||
}
|
||||
task.setRoutingManifestJson(json(manifest));task.setUpdatedAt(utcNow());tasks.updateById(task);
|
||||
SelfopProgressProjection.applyCheckpoint(task,run,"INVENTORY","PAGE",Math.max(0,manifest.getNextPage()-1),manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan());tasks.updateById(task);runs.updateById(run);
|
||||
currentTask.setRoutingManifestJson(json(manifest));currentTask.setUpdatedAt(utcNow());tasks.updateById(currentTask);
|
||||
SelfopProgressProjection.applyCheckpoint(currentTask,currentRun,"INVENTORY","PAGE",Math.max(0,manifest.getNextPage()-1),manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan());tasks.updateById(currentTask);runs.updateById(currentRun);
|
||||
});
|
||||
}
|
||||
tx.executeWithoutResult(s->{leases.verify(lease);run.setStatus("VALIDATED");run.setLastPhase("VALIDATED");run.setCoverageStatus("COMPLETE");
|
||||
run.setPageCount(Math.max(1,manifest.getPages()));run.setSourceTotalCount(manifest.getTotal());run.setUniqueObjectCount((long)manifest.getFirstScan().size());
|
||||
run.setFetchFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run);
|
||||
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);var currentTask=checkpoint.task();var currentRun=checkpoint.run();currentRun.setStatus("VALIDATED");currentRun.setLastPhase("VALIDATED");currentRun.setCoverageStatus("COMPLETE");
|
||||
currentRun.setPageCount(Math.max(1,manifest.getPages()));currentRun.setSourceTotalCount(manifest.getTotal());currentRun.setUniqueObjectCount((long)manifest.getFirstScan().size());
|
||||
currentRun.setFetchFinishedAtUtc(utcNow());currentRun.setUpdatedAt(utcNow());runs.updateById(currentRun);
|
||||
});return true;
|
||||
}
|
||||
}
|
||||
|
||||
+118
-15
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
@@ -88,11 +89,11 @@ public class SelfopTaskService {
|
||||
}
|
||||
return account;
|
||||
}
|
||||
@Transactional
|
||||
@Transactional(noRollbackFor=BusinessSessionStoppedException.class)
|
||||
public SelfopSyncTask create(SelfopSyncTask task) {
|
||||
identity(task.getRequestKey(),128); var existing=tasks.selectOne(new QueryWrapper<SelfopSyncTask>()
|
||||
.eq("source_account",task.getSourceAccount()).eq("request_key",task.getRequestKey()));
|
||||
if(existing!=null){require(existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");return existing;}
|
||||
if(existing!=null){require(existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");businessGuard(existing,!localBusinessWork(existing));return existing;}
|
||||
if(task.getExecutionPurpose()==null)task.setExecutionPurpose(configuredPurpose(config));
|
||||
requireExecution(config,task.getExecutionPurpose());
|
||||
var now=utcNow();task.setId(IdWorker.getId());task.setAttemptCount(0);task.setCreatedAt(now);task.setUpdatedAt(now);
|
||||
@@ -107,6 +108,20 @@ public class SelfopTaskService {
|
||||
:com.baiyue.lastleg.selfoperated.analytics.request.SelfopValidationBudget.freeze(now));}
|
||||
task.setApplyState("NOT_APPLICABLE");
|
||||
}
|
||||
if(testBusiness(task)) {
|
||||
require(task.getBusinessRootTaskId()==null&&task.getBusinessBudgetJson()==null,"BUSINESS_SESSION_IDENTITY_INVALID");
|
||||
if(task.getRequestKey().startsWith("manual:")&&task.getUpstreamTaskId()==null) {
|
||||
require(controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
|
||||
task.setBusinessRootTaskId(task.getId());
|
||||
task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now,config.getRequestBudget(),config.getTimeBudgetSeconds()));
|
||||
}else {
|
||||
var parent=businessParent(task);
|
||||
require(parent!=null&&Objects.equals(parent.getSourceAccount(),task.getSourceAccount())&&controlledBusinessTask(parent,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
|
||||
if("MANUAL_RETRY".equals(task.getTaskType()))require("SUCCEEDED".equals(task.getStatus())&&task.getRequestKey().startsWith("retry:")&&task.getUpstreamTaskId()!=null,"SELFOP_TEST_MANUAL_TASK_REQUIRED");
|
||||
require("MANUAL_RETRY".equals(task.getTaskType())||controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
|
||||
businessGuard(parent,!"WAREHOUSE_SYNC".equals(task.getTaskRole()));task.setBusinessRootTaskId(parent.getBusinessRootTaskId());
|
||||
}
|
||||
}
|
||||
task.setStatus(task.getStatus()==null?"READY":task.getStatus());
|
||||
task.setApplyState(task.getApplyState()==null?"NOT_APPLICABLE":task.getApplyState());
|
||||
if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())) {
|
||||
@@ -115,12 +130,13 @@ public class SelfopTaskService {
|
||||
}
|
||||
try{tasks.insert(task);}catch(DuplicateKeyException ignored){existing=tasks.selectOne(new QueryWrapper<SelfopSyncTask>()
|
||||
.eq("source_account",task.getSourceAccount()).eq("request_key",task.getRequestKey()).last("FOR UPDATE"));
|
||||
require(existing!=null&&existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");return existing;}
|
||||
require(existing!=null&&existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");businessGuard(existing,!localBusinessWork(existing));return existing;}
|
||||
return task;
|
||||
}
|
||||
@Transactional
|
||||
@Transactional(noRollbackFor=BusinessSessionStoppedException.class)
|
||||
public SelfopSyncRun begin(long taskId,SelfopLeaseService.Lease lease) {
|
||||
leases.verify(lease);
|
||||
lockBusinessAccount(taskId);leases.verify(lease);
|
||||
if("test".equals(config.getEnvironment())){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));}
|
||||
var task=locks.lockTask(taskId);require(task!=null&&!"SUCCEEDED".equals(task.getStatus())&&!"DEAD".equals(task.getStatus()),"SELFOP_TASK_NOT_EXECUTABLE");
|
||||
checkTaskLease(task,lease);
|
||||
requireExecution(config,task.getExecutionPurpose());
|
||||
@@ -159,15 +175,17 @@ public class SelfopTaskService {
|
||||
String initialPhase=lease.account()?"DIRECTORY":("INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())?"INVENTORY":"MERGE");
|
||||
SelfopProgressProjection.applyCheckpoint(task,run,initialPhase,"PAGE",0,null,1);tasks.updateById(task);return run;
|
||||
}
|
||||
@Transactional
|
||||
@Transactional(noRollbackFor=BusinessSessionStoppedException.class)
|
||||
public void yield(long taskId,long runId,SelfopLeaseService.Lease lease) {
|
||||
leases.verify(lease);
|
||||
lockBusinessAccount(taskId);leases.verify(lease);
|
||||
guardBeforeTaskLock(taskId,true);
|
||||
var now=utcNow();var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);run.setStatus("YIELDED");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run);
|
||||
task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(now.plusSeconds(1));task.setUpdatedAt(now);tasks.updateById(task);
|
||||
}
|
||||
@Transactional
|
||||
@Transactional(noRollbackFor=BusinessSessionStoppedException.class)
|
||||
public void fail(long taskId,long runId,String code,boolean retryable,SelfopLeaseService.Lease lease) {
|
||||
leases.verify(lease);
|
||||
lockBusinessAccount(taskId);leases.verify(lease);
|
||||
try{guardBeforeTaskLock(taskId,true);}catch(BusinessSessionStoppedException stopped){return;}
|
||||
var now=utcNow();var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);run.setLastPhase(run.getStatus());run.setStatus("FAILED");run.setErrorCode(code);
|
||||
run.setErrorMessage("自营同步失败,需核对运行记录");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run);
|
||||
// A yielded execution is not a failed request; finite failures are counted separately.
|
||||
@@ -184,19 +202,21 @@ public class SelfopTaskService {
|
||||
}
|
||||
task.setNextRetryAtUtc(now.plusSeconds(Math.min(3600,5L<<Math.min(9,(int)failures))));task.setUpdatedAt(now);tasks.updateById(task);
|
||||
}
|
||||
@Transactional
|
||||
@Transactional(noRollbackFor=BusinessSessionStoppedException.class)
|
||||
public SelfopSyncTask retry(long id,String requestKey,String operator,String reason) {
|
||||
identity(reason,500);
|
||||
identity(requestKey,128);var preview=tasks.selectById(id);require(preview!=null,"SELFOP_TASK_MISSING");
|
||||
lockBusinessAccount(id);
|
||||
var commandQuery=new QueryWrapper<SelfopSyncTask>().eq("source_account",preview.getSourceAccount()).eq("request_key","retry:"+requestKey);
|
||||
var prior=tasks.selectOne(commandQuery);
|
||||
if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");return preview;}
|
||||
if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");businessGuard(preview,!localBusinessWork(preview));return preview;}
|
||||
// Match executor lock ordering: account/warehouse scope first, task second.
|
||||
String activeOwner;LocalDateTime activeUntil;
|
||||
if("ACCOUNT_DISCOVERY".equals(preview.getTaskRole())){var scope=locks.lockDiscovery(preview.getDiscoveryScopeId());require(scope!=null,"SELFOP_SCOPE_MISSING");activeOwner=scope.getLeaseOwner();activeUntil=scope.getLeaseUntilUtc();}
|
||||
else{var scope=locks.lockScope(preview.getScopeId());require(scope!=null,"SELFOP_SCOPE_MISSING");activeOwner=scope.getLeaseOwner();activeUntil=scope.getLeaseUntilUtc();}
|
||||
prior=tasks.selectOne(new QueryWrapper<SelfopSyncTask>().eq("source_account",preview.getSourceAccount()).eq("request_key","retry:"+requestKey).last("FOR UPDATE"));
|
||||
if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");return tasks.selectById(id);}
|
||||
businessGuard(preview,!localBusinessWork(preview));
|
||||
require(SelfopLeaseService.available(activeOwner,activeUntil,utcNow()),"SELFOP_RETRY_LEASE_BUSY");
|
||||
var original=locks.lockTask(id);require(original!=null,"SELFOP_TASK_MISSING");
|
||||
requireBusiness(original);requireExecution(config,original.getExecutionPurpose());
|
||||
@@ -212,21 +232,104 @@ public class SelfopTaskService {
|
||||
return original;
|
||||
}
|
||||
public List<SelfopSyncTask> due(int limit){return locks.due(utcNow(),limit,config.getCollectionMode());}
|
||||
@Transactional
|
||||
@Transactional(noRollbackFor=BusinessSessionStoppedException.class)
|
||||
public void candidateFinished(long taskId,long runId,SelfopLeaseService.Lease lease) {
|
||||
leases.verify(lease);var task=locks.lockTask(taskId);var run=locks.lockRun(runId);
|
||||
lockBusinessAccount(taskId);leases.verify(lease);guardBeforeTaskLock(taskId,false);var task=locks.lockTask(taskId);var run=locks.lockRun(runId);
|
||||
checkRunLease(task,run,lease);
|
||||
require("READY".equals(run.getStatus()),"SELFOP_CANDIDATE_NOT_READY");
|
||||
run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run);
|
||||
// Candidate readiness is not business success until a published result is linked.
|
||||
task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(utcNow().plusSeconds(config.getMinimumBuildIntervalSeconds()));task.setUpdatedAt(utcNow());tasks.updateById(task);
|
||||
}
|
||||
@Transactional
|
||||
@Transactional(noRollbackFor=BusinessSessionStoppedException.class)
|
||||
public void waitForBudget(long taskId,long runId,LocalDateTime until,SelfopLeaseService.Lease lease) {
|
||||
leases.verify(lease);var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);
|
||||
lockBusinessAccount(taskId);leases.verify(lease);if("test".equals(config.getEnvironment())){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));}var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);
|
||||
SelfopProgressProjection.freezeRun(task,run);run.setLastPhase(run.getStatus());run.setStatus("YIELDED");run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run);
|
||||
task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(until);task.setUpdatedAt(utcNow());tasks.updateById(task);
|
||||
}
|
||||
public record CollectionCheckpoint(SelfopSyncTask task,SelfopSyncRun run) { }
|
||||
/** Caller holds the checkpoint transaction through all stage/progress writes. Never trust response-era entities. */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public CollectionCheckpoint checkpoint(SelfopSyncTask observedTask,SelfopSyncRun observedRun,SelfopLeaseService.Lease lease) {
|
||||
long taskId=observedTask.getId();lockBusinessAccount(taskId);leases.verify(lease);
|
||||
if("test".equals(config.getEnvironment())) {
|
||||
var preview=tasks.selectById(taskId);require(preview!=null,"SELFOP_TASK_MISSING");businessGuard(preview,false,false);
|
||||
}
|
||||
var task=locks.lockTask(taskId);var run=locks.lockRun(observedRun.getId());checkRunLease(task,run,lease);
|
||||
requireBusiness(task);requireBusiness(run);
|
||||
require("RUNNING".equals(task.getStatus())&&"FETCHING".equals(run.getStatus()),"SELFOP_COLLECTION_TERMINATED");
|
||||
return new CollectionCheckpoint(task,run);
|
||||
}
|
||||
/** Only TEST manual collection has a session ceiling; production retains its resumable scheduler. */
|
||||
public static final class BusinessSessionStoppedException extends com.baiyue.common.core.exception.BizException {
|
||||
private final String reason;
|
||||
BusinessSessionStoppedException(String reason){super("本次测试采集会话已结束或预算无效,请核对记录后新建有界任务");this.reason=reason;}
|
||||
public String reason(){return reason;}
|
||||
}
|
||||
private boolean testBusiness(SelfopSyncTask task){return task!=null&&"test".equals(config.getEnvironment())&&"BUSINESS".equals(task.getExecutionPurpose());}
|
||||
private SelfopSyncTask businessParent(SelfopSyncTask task){
|
||||
Long id=task.getUpstreamTaskId();
|
||||
if(id==null&&task.getQueryScopeJson()!=null){var query=read(task.getQueryScopeJson(),Map.class);if(query.get("parentTaskId") instanceof Number n)id=n.longValue();}
|
||||
return id==null?null:tasks.selectById(id);
|
||||
}
|
||||
private void lockBusinessAccount(long id){
|
||||
if(!"test".equals(config.getEnvironment()))return;
|
||||
var task=tasks.selectById(id);if(!testBusiness(task))return;
|
||||
require(task.getBusinessRootTaskId()!=null,"BUSINESS_SESSION_ROOT_REQUIRED");
|
||||
var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",task.getSourceAccount()));
|
||||
require(account!=null,"SELFOP_SCOPE_MISSING");locks.lockDiscovery(account.getId());
|
||||
}
|
||||
private void guardBeforeTaskLock(long id,boolean checkDeadline){
|
||||
if(!"test".equals(config.getEnvironment()))return;
|
||||
var task=tasks.selectById(id);businessGuard(task,checkDeadline);
|
||||
}
|
||||
private boolean localBusinessWork(SelfopSyncTask task){
|
||||
if(!testBusiness(task)||!"WAREHOUSE_SYNC".equals(task.getTaskRole()))return false;
|
||||
var scope=scopes.selectById(task.getScopeId());
|
||||
if(scope==null||!"OUTBOUND".equals(scope.getDataFamily()))return false;
|
||||
if("REBUILD".equals(task.getTaskType())||"APPLIED".equals(task.getApplyState()))return true;
|
||||
if(task.getRoutingManifestJson()==null)return false;
|
||||
Map<?,?> manifest;
|
||||
try{manifest=read(task.getRoutingManifestJson(),Map.class);}catch(IllegalArgumentException invalid){return false;}
|
||||
if(manifest==null||!Boolean.TRUE.equals(manifest.get("directoryComplete"))||!(manifest.get("objects") instanceof Map<?,?> objects))return false;
|
||||
return objects.values().stream().allMatch(value->{
|
||||
if(!(value instanceof Map<?,?> entry))return false;
|
||||
Object id=entry.get("effectiveStageId");
|
||||
return (id instanceof Long||id instanceof Integer)&&((Number)id).longValue()>0;
|
||||
});
|
||||
}
|
||||
private void businessGuard(SelfopSyncTask task,boolean checkDeadline){businessGuard(task,checkDeadline,true);}
|
||||
private void businessGuard(SelfopSyncTask task,boolean checkDeadline,boolean terminate){
|
||||
if(!testBusiness(task))return;
|
||||
require(controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
|
||||
require(task.getBusinessRootTaskId()!=null,"BUSINESS_SESSION_ROOT_REQUIRED");
|
||||
var ancestor=task;
|
||||
while(!ancestor.getRequestKey().startsWith("manual:"))ancestor=businessParent(ancestor);
|
||||
require(Objects.equals(ancestor.getId(),task.getBusinessRootTaskId()),"BUSINESS_SESSION_ROOT_INVALID");
|
||||
// Serialize with egress in account -> root -> task order, before taking any task row lock.
|
||||
var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",task.getSourceAccount()));
|
||||
require(account!=null,"SELFOP_SCOPE_MISSING");locks.lockDiscovery(account.getId());
|
||||
var root=locks.lockTask(task.getBusinessRootTaskId());
|
||||
require(root!=null&&Objects.equals(root.getId(),root.getBusinessRootTaskId())&&Objects.equals(root.getSourceAccount(),task.getSourceAccount())
|
||||
&&root.getRequestKey()!=null&&root.getRequestKey().startsWith("manual:")&&controlledBusinessTask(root,tasks::selectById),"BUSINESS_SESSION_ROOT_INVALID");
|
||||
require(root.getBusinessBudgetJson()!=null,"BUSINESS_SESSION_BUDGET_INVALID");
|
||||
var budget=read(root.getBusinessBudgetJson(),SelfopBusinessBudget.class);budget.validate();
|
||||
// HTTP exhaustion is enforced at egress, allowing the final permitted response to finish local work.
|
||||
String reason=budget.stopReason;
|
||||
if(reason==null&&checkDeadline&&!utcNow().isBefore(budget.deadlineAtUtc))reason="BUSINESS_SESSION_DEADLINE";
|
||||
if(reason==null&&"DEAD".equals(root.getStatus()))reason="BUSINESS_SESSION_STOPPED";
|
||||
if(reason==null)return;
|
||||
if(!terminate)throw new BusinessSessionStoppedException(reason);
|
||||
var now=utcNow();budget.stopReason=reason;
|
||||
tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",root.getId()).set("business_budget_json",json(budget)).set("status","DEAD").set("next_retry_at_utc",null).set("updated_at",now));
|
||||
var members=tasks.selectList(new QueryWrapper<SelfopSyncTask>().eq("business_root_task_id",root.getId()).eq("source_account",root.getSourceAccount()).eq("execution_purpose","BUSINESS"));
|
||||
var ids=new ArrayList<Long>();for(var member:members)ids.add(member.getId());if(!ids.contains(root.getId()))ids.add(root.getId());
|
||||
runs.update(null,new UpdateWrapper<SelfopSyncRun>().in("task_id",ids).eq("source_account",root.getSourceAccount()).eq("execution_purpose","BUSINESS").in("status",List.of("FETCHING","VALIDATED","YIELDED","READY","BUILDING"))
|
||||
.set("status","FAILED").set("error_code",reason).set("error_message","测试采集会话已结束").set("finished_at_utc",now).set("updated_at",now));
|
||||
tasks.update(null,new UpdateWrapper<SelfopSyncTask>().in("id",ids).eq("source_account",root.getSourceAccount()).eq("execution_purpose","BUSINESS").in("status",List.of("WAITING_SOURCE","READY","RUNNING","RETRY_WAIT"))
|
||||
.set("status","DEAD").set("next_retry_at_utc",null).set("updated_at",now));
|
||||
throw new BusinessSessionStoppedException(reason);
|
||||
}
|
||||
private static void checkTaskLease(SelfopSyncTask task,SelfopLeaseService.Lease lease){
|
||||
require(task!=null&&(lease.account()?"ACCOUNT_DISCOVERY".equals(task.getTaskRole())&&Objects.equals(task.getDiscoveryScopeId(),lease.scopeId())
|
||||
:"WAREHOUSE_SYNC".equals(task.getTaskRole())&&Objects.equals(task.getScopeId(),lease.scopeId())),"SELFOP_TASK_LEASE_SCOPE_MISMATCH");
|
||||
|
||||
@@ -3703,6 +3703,8 @@ CREATE TABLE ll_selfop_sync_task (
|
||||
execution_purpose VARCHAR(16) NOT NULL COMMENT 'BUSINESS或VALIDATION;由服务端冻结用途,子任务继承',
|
||||
validation_root_task_id BIGINT NULL COMMENT '验证会话唯一预算根任务,根引用自身,正式任务为空',
|
||||
validation_budget_json JSON NULL COMMENT '验证根冻结硬预算、累计消耗、库存分仓页数、截止时刻及停止原因',
|
||||
business_root_task_id BIGINT NULL COMMENT '测试人工正式采集共享预算根任务;生产任务为空',
|
||||
business_budget_json JSON NULL COMMENT '仅测试正式采集根保存冻结请求上限、截止时间、消耗及停止原因',
|
||||
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
|
||||
task_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC',
|
||||
scope_id BIGINT NULL COMMENT '分仓任务必须引用分仓作用域;仅账号共享发现为空',
|
||||
@@ -3739,6 +3741,8 @@ CREATE TABLE ll_selfop_sync_task (
|
||||
KEY idx_selfop_task_apply (apply_state,id),
|
||||
KEY idx_selfop_task_scope_list (scope_id,created_at,id),
|
||||
KEY idx_selfop_task_account_list (source_account,task_role,created_at,id),
|
||||
KEY idx_selfop_task_business_root (business_root_task_id,status,id),
|
||||
CONSTRAINT chk_selfop_business_budget CHECK ((business_root_task_id IS NULL AND business_budget_json IS NULL) OR (execution_purpose='BUSINESS' AND business_root_task_id IS NOT NULL AND ((business_root_task_id=id AND business_budget_json IS NOT NULL) OR (business_root_task_id<>id AND business_budget_json IS NULL)))),
|
||||
CONSTRAINT chk_selfop_sync_task CHECK (attempt_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND ((execution_purpose = 'BUSINESS' AND validation_root_task_id IS NULL AND validation_budget_json IS NULL) OR (execution_purpose = 'VALIDATION' AND validation_root_task_id IS NOT NULL AND apply_state = 'NOT_APPLICABLE' AND result_published_run_id IS NULL AND status <> 'SUCCEEDED')) AND ((task_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL AND discovery_scope_id IS NOT NULL) OR (task_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL)))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='一次计划或人工请求';
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ paths:
|
||||
仅平台账号且须 platform:self-operated-analytics:sync 权限。命令先持久化,
|
||||
不在 HTTP 请求内运行历史采集。collectionMode 默认 OFF;VALIDATION 仅用于持久预算内的样本暂存,
|
||||
BUSINESS 允许 production 以及受控人工执行的 test,均要求真实出口取得服务端核验的人工独占许可。TEST 不自动计划、历史复查或创建两个月首采;仅执行人工命令及合法派生任务。
|
||||
TEST BUSINESS 人工根创建时冻结服务端 requestBudget/timeBudgetSeconds 为根会话请求总上限和截止时间;子任务及重试共用持久预算,续授权或重启不清零。
|
||||
请求上限或截止后不得继续出网,保留停止原因并终止未完成任务;不将截断结果作为完整版本发布。最后一个合法请求返回的完整数据允许完成纯本地校验和发布。
|
||||
出库来源时间及业务时间统一 Asia/Shanghai;首次正式目标固定为 E0 前两个滚动月。
|
||||
BACKFILL 单片不超过一天。RETRY 使用 taskNo 和非空 reason 恢复原分仓逻辑任务,
|
||||
保留半归并屏障及旧尝试,拒绝验证会话复活、账号共享任务伪装单仓、有效执行租约及上游未齐;
|
||||
|
||||
+3
-1
@@ -64,11 +64,13 @@ class SelfoperatedAnalyticsSchemaContractTest {
|
||||
var task = ddl.get("ll_selfop_sync_task");
|
||||
for (var column : new String[]{"apply_state", "affected_warehouse_codes_json", "observation_baseline",
|
||||
"routing_manifest_json", "request_hash", "apply_completed_at_utc", "task_no", "execution_purpose",
|
||||
"validation_root_task_id", "validation_budget_json", "result_published_run_id", "progress_json",
|
||||
"validation_root_task_id", "validation_budget_json", "business_root_task_id", "business_budget_json", "result_published_run_id", "progress_json",
|
||||
"progress_updated_at_utc"}) {
|
||||
assertTrue(task.contains(" " + column + " "), column);
|
||||
}
|
||||
assertTrue(task.contains("UNIQUE KEY uk_selfop_task_no (task_no)"));
|
||||
assertTrue(task.contains("KEY idx_selfop_task_business_root (business_root_task_id,status,id)"));
|
||||
assertTrue(task.contains("CONSTRAINT chk_selfop_business_budget CHECK"));
|
||||
var account = ddl.get("ll_selfop_outbound_discovery_scope");
|
||||
for (var column : new String[]{"api_budget_json", "api_cooldown_until_utc", "api_pause_reason"}) {
|
||||
assertTrue(account.contains(" " + column + " "), column);
|
||||
|
||||
+92
-4
@@ -43,7 +43,84 @@ class CollectionQueryServiceTest {
|
||||
}
|
||||
Task task(String purpose,String progress,int attempts) {
|
||||
return new Task(7L,"LXCOL-TASK","primary","A","OUTBOUND","WAREHOUSE_SYNC","SCHEDULED",purpose,
|
||||
"hash","DEAD","BLOCKED",attempts,now.minusDays(1),now,null,null,null,null,progress,now,null,now,now);
|
||||
"hash","DEAD","BLOCKED",attempts,now.minusDays(1),now,null,null,null,null,progress,now,null,null,false,now,now);
|
||||
}
|
||||
private void enableBusiness(String environment) {
|
||||
config.setCollectionMode("BUSINESS");config.setEnvironment(environment);config.setQuantityUnit("PCS");
|
||||
config.setExclusiveHandoffVerified(true);config.setPriorEgressClosed(true);config.setInflightDrained(true);config.setTransferredBudgetVerified(true);
|
||||
config.setExclusiveGrantExpiresAtUtc(now.plusHours(1));config.setDailyRequestBudget(100);
|
||||
}
|
||||
private Task manualBusinessTask(String budget) { return manualBusinessTask(budget,false); }
|
||||
private Task manualBusinessTask(String budget,boolean ready) {
|
||||
return new Task(7L,"LXCOL-TASK","primary","A","OUTBOUND","WAREHOUSE_SYNC","BACKFILL","BUSINESS",
|
||||
"hash","DEAD","BLOCKED",1,now.minusDays(1),now,null,null,null,null,null,now,null,budget,ready,now,now);
|
||||
}
|
||||
private String businessBudget(String deadline,String stopReason,int http) {
|
||||
return "{\"startedAtUtc\":\""+now.minusHours(1)+"\",\"maxHttp\":5,\"http\":"+http+",\"confirmedHttp\":"+http+",\"deadlineAtUtc\":\""+deadline+"\",\"stopReason\":"
|
||||
+(stopReason==null?"null":"\""+stopReason+"\"")+"}";
|
||||
}
|
||||
@Test void stoppedOrMissingTestBusinessRootBudgetCannotAdvertiseTaskOrAttemptRetry() {
|
||||
enableBusiness("test");
|
||||
for(String budget:Arrays.asList(null,"{}",businessBudget(now.plusHours(1).toString(),"BUSINESS_SESSION_HTTP_LIMIT",5))) {
|
||||
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(budget));
|
||||
when(mapper.latestAttempts(eq("primary"),anyList())).thenReturn(List.of(run(1,"FAILED",null)));
|
||||
var detail=service.taskDetail("LXCOL-TASK",q(null,null,null)).task();
|
||||
assertTrue(detail.allowedActions().isEmpty());assertFalse(detail.latestAttempt().retryable());
|
||||
assertEquals(budget==null||budget.equals("{}")?"BUSINESS_SESSION_BUDGET_MISSING":"BUSINESS_SESSION_STOPPED",detail.actionBlockReason());
|
||||
}
|
||||
}
|
||||
@Test void malformedBudgetBoundsAndAnyNonNullStopReasonCannotAdvertiseRetry() {
|
||||
enableBusiness("test");
|
||||
String valid=businessBudget(now.plusHours(1).toString(),null,5);
|
||||
for(String budget:List.of(
|
||||
valid.replace("\"startedAtUtc\"","\"missingStarted\""),
|
||||
valid.replace(now.minusHours(1).toString(),"invalid"),
|
||||
valid.replace(now.minusHours(1).toString(),now.plusHours(1).toString()),
|
||||
valid.replace(now.minusHours(1).toString(),now.plusHours(2).toString()),
|
||||
valid.replace("\"maxHttp\":5","\"maxHttp\":2147483648"),
|
||||
valid.replace("\"http\":5","\"http\":6"),
|
||||
valid.replace("\"confirmedHttp\":5","\"confirmedHttp\":6"))) {
|
||||
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(budget,true));
|
||||
var task=service.taskDetail("LXCOL-TASK",q(null,null,null)).task();
|
||||
assertTrue(task.allowedActions().isEmpty());assertEquals("BUSINESS_SESSION_BUDGET_MISSING",task.actionBlockReason());
|
||||
}
|
||||
for(String stop:List.of("\"\"","\" \"","false","0","{}")) {
|
||||
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(valid.replace("\"stopReason\":null","\"stopReason\":"+stop),true));
|
||||
var task=service.taskDetail("LXCOL-TASK",q(null,null,null)).task();
|
||||
assertTrue(task.allowedActions().isEmpty());assertEquals("BUSINESS_SESSION_STOPPED",task.actionBlockReason());
|
||||
}
|
||||
}
|
||||
@Test void validTestBusinessBudgetAtExactHttpCapStillAllowsLocalRetryAndProductionNeedsNoTestBudget() {
|
||||
enableBusiness("test");
|
||||
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(businessBudget(now.plusHours(1).toString(),null,5)));
|
||||
when(mapper.latestAttempts(eq("primary"),anyList())).thenReturn(List.of(run(1,"FAILED",null)));
|
||||
var detail=service.taskDetail("LXCOL-TASK",q(null,null,null)).task();
|
||||
assertEquals(List.of("RETRY"),detail.allowedActions());assertTrue(detail.latestAttempt().retryable());
|
||||
enableBusiness("production");when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(null));
|
||||
assertEquals(List.of("RETRY"),service.taskDetail("LXCOL-TASK",q(null,null,null)).task().allowedActions());
|
||||
}
|
||||
@Test void expiredBudgetOnlyAllowsProvenLocalWorkButStoppedBudgetNeverDoes() {
|
||||
enableBusiness("test");
|
||||
when(mapper.latestAttempts(eq("primary"),anyList())).thenReturn(List.of(run(1,"FAILED",null)));
|
||||
for(boolean ready:List.of(false,true)) {
|
||||
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(businessBudget(now.toString(),null,5),ready));
|
||||
var task=service.taskDetail("LXCOL-TASK",q(null,null,null)).task();
|
||||
assertEquals(ready,task.latestAttempt().retryable());
|
||||
assertEquals(ready?null:"BUSINESS_SESSION_DEADLINE",task.actionBlockReason());
|
||||
}
|
||||
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(businessBudget(now.toString(),"BUSINESS_SESSION_DEADLINE",5),true));
|
||||
assertEquals("BUSINESS_SESSION_STOPPED",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason());
|
||||
}
|
||||
@Test void businessBudgetTerminationCodesHaveSafeChineseAttemptMessagesEvenWithoutRawErrorText() {
|
||||
var upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper);
|
||||
for(String code:List.of("BUSINESS_SESSION_HTTP_LIMIT","BUSINESS_SESSION_DEADLINE")) {
|
||||
var attempt=new Attempt(90L,7L,"LXCOL-TASK",1,"FAILED","DETAILS","BUSINESS",now.minusMinutes(2),null,null,
|
||||
now,null,now,null,"PARTIAL",null,null,code,null,now.minusMinutes(2),"A",1L,"hash");
|
||||
when(mapper.runs(any(),eq(upper))).thenReturn(List.of(attempt));
|
||||
String message=service.runs(q(null,null,null)).records().get(0).failureSummary();
|
||||
assertTrue(message.contains(code.endsWith("HTTP_LIMIT")?"请求预算已耗尽":"已到截止时间"));
|
||||
assertTrue(message.contains("不能恢复原任务"));
|
||||
}
|
||||
}
|
||||
Attempt run(int attempt,String status,String snapshot) {
|
||||
return new Attempt(90L,7L,"LXCOL-TASK",attempt,status,"DETAILS","BUSINESS",now.minusHours(1),null,null,
|
||||
@@ -182,7 +259,7 @@ class CollectionQueryServiceTest {
|
||||
@Test void validationBudgetReadsActualFlatPersistedFieldsAndMasksDetails() {
|
||||
Task t=new Task(7L,"LXCOL-TASK","primary","A","OUTBOUND","WAREHOUSE_SYNC","VALIDATION","VALIDATION",
|
||||
"hash","SAMPLE_DONE","NOT_APPLICABLE",1,null,null,null,null,null,null,null,null,
|
||||
"{\"maxPages\":10,\"pages\":3,\"http\":5,\"confirmedHttp\":4,\"detailKeys\":[\"secret-order\"],\"deadlineAtUtc\":\"2026-10-12T04:05:00\"}",now,now);
|
||||
"{\"maxPages\":10,\"pages\":3,\"http\":5,\"confirmedHttp\":4,\"detailKeys\":[\"secret-order\"],\"deadlineAtUtc\":\"2026-10-12T04:05:00\"}",null,false,now,now);
|
||||
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(t);
|
||||
var result=service.taskDetail("LXCOL-TASK",q(null,null,null)).task().validation();
|
||||
assertEquals(10L,result.budgetLimits().get("maxPages"));assertEquals(3L,result.budgetConsumed().get("pages"));
|
||||
@@ -202,10 +279,21 @@ class CollectionQueryServiceTest {
|
||||
"publishedRuns","currentTasks","latestValidationTasks","lastSuccessfulTasks","sharedActiveAccount","barrierWarehouses",
|
||||
"inventoryDailySamples","inventoryAccumulationStarts")) {
|
||||
String sql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+"."+method).getBoundSql(parameters).getSql();
|
||||
assertFalse(sql.contains("routing_manifest_json"));assertFalse(sql.contains("ll_selfop_sku_daily_sales"));assertFalse(sql.contains("ll_selfop_inventory_snapshot"));
|
||||
assertFalse(sql.contains("AS routing_manifest_json"));assertFalse(sql.contains("ll_selfop_sku_daily_sales"));assertFalse(sql.contains("ll_selfop_inventory_snapshot"));
|
||||
assertFalse(sql.contains("ll_selfop_sync_stage"));
|
||||
}
|
||||
String runSql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+".runs").getBoundSql(parameters).getSql();
|
||||
assertFalse(runSql.contains("routing_manifest_json"));
|
||||
for(String method:List.of("tasks","tasksByIds","currentTasks","latestValidationTasks","lastSuccessfulTasks","sharedActiveAccount")) {
|
||||
String sql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+"."+method).getBoundSql(parameters).getSql();
|
||||
assertTrue(sql.contains("b.source_account=t.source_account"));
|
||||
assertTrue(sql.contains("b.execution_purpose='BUSINESS' AND b.business_root_task_id=b.id"));
|
||||
assertTrue(sql.contains("AS business_local_work_ready"));
|
||||
assertTrue(sql.contains("'$.objects'))='OBJECT'"));
|
||||
assertTrue(sql.contains("'$.objects.*'"));
|
||||
assertTrue(sql.contains("COALESCE(JSON_TYPE(JSON_EXTRACT(manifest_item.entry,'$.effectiveStageId')),'NULL')"));
|
||||
assertTrue(sql.contains("BETWEEN 1 AND 9223372036854775807) IS NOT TRUE"));
|
||||
}
|
||||
assertTrue(runSql.contains("r.created_at <"));assertTrue(runSql.contains("r.id <="));assertTrue(runSql.contains("LIMIT ? OFFSET ?"));
|
||||
String samplesSql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+".inventoryDailySamples").getBoundSql(parameters).getSql();
|
||||
assertTrue(samplesSql.contains("r.inventory_daily_sample_date >= ?"));
|
||||
@@ -250,7 +338,7 @@ class CollectionQueryServiceTest {
|
||||
private Task validationRoot(String warehouse,Long rootId) {
|
||||
return new Task(7L,"LXCOL-SAMPLE","primary",warehouse,"OUTBOUND","ACCOUNT_DISCOVERY","VALIDATION","VALIDATION",
|
||||
"sample-hash","SAMPLE_DONE","NOT_APPLICABLE",1,null,null,null,null,rootId,null,sampleProgress(),now,
|
||||
"{\"http\":23,\"detailHttp\":20,\"deadlineAtUtc\":\"2026-10-12T04:05:00\"}",now,now);
|
||||
"{\"http\":23,\"detailHttp\":20,\"deadlineAtUtc\":\"2026-10-12T04:05:00\"}",null,false,now,now);
|
||||
}
|
||||
private Attempt sampleAttempt() {
|
||||
return new Attempt(90L,7L,"LXCOL-SAMPLE",1,"SAMPLE_DONE","SAMPLE_DONE","VALIDATION",now.minusMinutes(2),now,null,
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.request;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncTaskMapper;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask;
|
||||
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Actual MyBatis Plus insert/update execution, on process-local H2 only. */
|
||||
class SelfopBusinessBudgetPersistenceTest {
|
||||
@Test void staleCollectorUpdateCannotResetFrozenRootIdentityConsumptionOrStopReason() throws Exception {
|
||||
var source=new UnpooledDataSource("org.h2.Driver","jdbc:h2:mem:business_budget_"+UUID.randomUUID()+";MODE=MySQL","sa","");
|
||||
var configuration=new MybatisConfiguration();configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(SelfopSyncTaskMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(source);factory.setConfiguration(configuration);
|
||||
try(var session=Objects.requireNonNull(factory.getObject()).openSession(true)){
|
||||
var connection=session.getConnection();
|
||||
try(var statement=connection.createStatement()){
|
||||
statement.execute("CREATE TABLE ll_selfop_sync_task(id BIGINT PRIMARY KEY,status VARCHAR(32),progress_json VARCHAR(2000),business_root_task_id BIGINT,business_budget_json VARCHAR(4000))");
|
||||
}
|
||||
var tasks=session.getMapper(SelfopSyncTaskMapper.class);
|
||||
var stale=new SelfopSyncTask();stale.setId(1L);stale.setStatus("RUNNING");stale.setBusinessRootTaskId(1L);
|
||||
stale.setBusinessBudgetJson(SelfopBusinessBudget.freeze(LocalDateTime.of(2026,10,13,0,0),2,120));
|
||||
assertEquals(1,tasks.insert(stale)); // NEVER updates must still allow initial freezing.
|
||||
var charged=SelfopSupport.read(stale.getBusinessBudgetJson(),SelfopBusinessBudget.class);
|
||||
charged.http=2;charged.confirmedHttp=1;
|
||||
String persisted=SelfopSupport.json(charged);
|
||||
assertEquals(1,tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",1L).set("business_budget_json",persisted)));
|
||||
|
||||
stale.setBusinessRootTaskId(999L);stale.setProgressJson("{\"phase\":\"DETAIL\"}");
|
||||
var sql=configuration.getMappedStatement(SelfopSyncTaskMapper.class.getName()+".updateById")
|
||||
.getBoundSql(Map.of("et",stale)).getSql();
|
||||
assertFalse(sql.contains("business_root_task_id"));assertFalse(sql.contains("business_budget_json"));
|
||||
assertEquals(1,tasks.updateById(stale));
|
||||
assertStored(connection,persisted,stale.getProgressJson());
|
||||
|
||||
charged.stopReason="BUSINESS_SESSION_HTTP_LIMIT";persisted=SelfopSupport.json(charged);
|
||||
assertEquals(1,tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",1L).set("business_budget_json",persisted)));
|
||||
stale.setProgressJson("{\"phase\":\"LOCAL_RESULT\"}");assertEquals(1,tasks.updateById(stale));
|
||||
assertStored(connection,persisted,stale.getProgressJson());
|
||||
}
|
||||
}
|
||||
private static void assertStored(java.sql.Connection connection,String budget,String progress) throws Exception {
|
||||
try(var statement=connection.createStatement();var result=statement.executeQuery("SELECT business_root_task_id,business_budget_json,progress_json FROM ll_selfop_sync_task WHERE id=1")){
|
||||
assertTrue(result.next());assertEquals(1L,result.getLong(1));assertEquals(budget,result.getString(2));assertEquals(progress,result.getString(3));assertFalse(result.next());
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.request;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
|
||||
import java.time.LocalDateTime;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SelfopBusinessBudgetTest {
|
||||
private final LocalDateTime start=LocalDateTime.of(2026,10,13,0,0);
|
||||
|
||||
@Test void serializedConsumptionAndDeadlineSurviveReload() {
|
||||
var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,2,60),SelfopBusinessBudget.class);
|
||||
budget.http=2;budget.confirmedHttp=1;
|
||||
var restored=SelfopSupport.read(SelfopSupport.json(budget),SelfopBusinessBudget.class);
|
||||
assertEquals(start.plusSeconds(60),restored.deadlineAtUtc);
|
||||
assertEquals(2,restored.http);assertEquals(1,restored.confirmedHttp);
|
||||
assertEquals("BUSINESS_SESSION_HTTP_LIMIT",restored.limit(start.plusSeconds(1)));
|
||||
assertEquals("BUSINESS_SESSION_DEADLINE",restored.limit(start.plusSeconds(60)));
|
||||
restored.stopReason="BUSINESS_SESSION_HTTP_LIMIT";
|
||||
assertEquals(restored.stopReason,restored.limit(start.plusDays(1)));
|
||||
}
|
||||
|
||||
@Test void noImplicitUnlimitedOrInvalidAccounting() {
|
||||
assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(start,0,60));
|
||||
assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(start,1,0));
|
||||
assertThrows(EgressRejectedException.class,()->new SelfopBusinessBudget().validate());
|
||||
var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,2,60),SelfopBusinessBudget.class);
|
||||
budget.confirmedHttp=1;
|
||||
assertThrows(EgressRejectedException.class,budget::validate);
|
||||
budget.confirmedHttp=0;budget.http=3;
|
||||
assertThrows(EgressRejectedException.class,budget::validate);
|
||||
}
|
||||
}
|
||||
+80
-1
@@ -35,9 +35,10 @@ class SelfopEgressServiceTest {
|
||||
cfg.setCollectionMode("VALIDATION");cfg.setPriorEgressClosed(false);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");verifyNoInteractions(mapper);
|
||||
}
|
||||
@Test void testBusinessStillChargesPersistentAccountAndRequiresLiveExclusiveGrant(){
|
||||
business();cfg.setEnvironment("test");
|
||||
testBusiness(5,300);
|
||||
var c=reserve(Operation.WAREHOUSES,1,null,null);success(c,Operation.WAREHOUSES);
|
||||
assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1);
|
||||
assertThat(businessBudget().http).isEqualTo(1);assertThat(businessBudget().confirmedHttp).isEqualTo(1);
|
||||
cfg.setExclusiveGrantExpiresAtUtc(now());
|
||||
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");
|
||||
assertThat(budget().reservedHttp).isEqualTo(1);
|
||||
@@ -147,6 +148,84 @@ class SelfopEgressServiceTest {
|
||||
}
|
||||
}
|
||||
private void business(){cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("production");task.setExecutionPurpose("BUSINESS");run.setExecutionPurpose("BUSINESS");}
|
||||
private void testBusiness(int http,int seconds){business();cfg.setEnvironment("test");task.setBusinessRootTaskId(1L);task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),http,seconds));}
|
||||
private SelfopBusinessBudget businessBudget(){return SelfopSupport.read(task.getBusinessBudgetJson(),SelfopBusinessBudget.class);}
|
||||
@Test void testBusinessLastReservedAttemptIsAllowedAndRestartCannotRenewSession(){
|
||||
testBusiness(1,300);var start=businessBudget().startedAtUtc;var deadline=businessBudget().deadlineAtUtc;
|
||||
var c=reserve(Operation.WAREHOUSES,1,null,null);assertThat(businessBudget().http).isEqualTo(1);
|
||||
service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
|
||||
var completion=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null);
|
||||
service.complete(completion);new SelfopEgressService(mapper,cfg,clock).complete(completion);
|
||||
assertThat(businessBudget().confirmedHttp).isEqualTo(1);clock.advance(10);
|
||||
cfg.setRequestBudget(1000);cfg.setTimeBudgetSeconds(3600);cfg.setExclusiveGrantId("renewed");
|
||||
var renewed=new SelfopRequestContext("synthetic","BUSINESS",1L,"selfop-1","renewed",1L,2L);
|
||||
assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(renewed,Operation.OUTBOUND_QUERY,1,null,null,INTERNAL))
|
||||
.hasMessage("BUSINESS_SESSION_HTTP_LIMIT").satisfies(e->{assertThat(((EgressRejectedException)e).sampleDone()).isFalse();assertThat(((EgressRejectedException)e).waitUntilUtc()).isNull();});
|
||||
assertThat(task.getStatus()).isEqualTo("DEAD");assertThat(task.getNextRetryAtUtc()).isNull();
|
||||
assertThat(businessBudget().startedAtUtc).isEqualTo(start);assertThat(businessBudget().deadlineAtUtc).isEqualTo(deadline);
|
||||
assertThat(businessBudget().maxHttp).isEqualTo(1);assertThat(budget().reservedHttp).isEqualTo(1);
|
||||
verify(mapper).terminateBusinessTasks(1L,"synthetic");verify(mapper).terminateBusinessRuns(eq(1L),eq("synthetic"),eq("BUSINESS_SESSION_HTTP_LIMIT"),any());
|
||||
verify(mapper,never()).terminateValidationRuns(any(),any(),any(),any());
|
||||
}
|
||||
@Test void testBusinessDeadlineClipsPermitAndAuthorizeRechecksBeforeHttp(){
|
||||
testBusiness(5,10);var c=reserve(Operation.WAREHOUSES,1,null,null);assertThat(budget().pending.expiresAtUtc).isEqualTo(businessBudget().deadlineAtUtc);
|
||||
clock.advance(9);var permit=service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
|
||||
assertThat(permit.expiresAt()).isEqualTo(businessBudget().deadlineAtUtc.toInstant(ZoneOffset.UTC));
|
||||
clock.advance(2);service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));
|
||||
assertThat(businessBudget().confirmedHttp).isEqualTo(1);
|
||||
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_DEADLINE");
|
||||
assertThat(businessBudget().stopReason).isEqualTo("BUSINESS_SESSION_DEADLINE");
|
||||
}
|
||||
@Test void testBusinessExpiredOrStoppedPreparedAttemptCannotAuthorize(){
|
||||
testBusiness(5,10);var c=reserve(Operation.WAREHOUSES,1,null,null);clock.advance(10);
|
||||
assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)))
|
||||
.hasMessage("BUSINESS_SESSION_DEADLINE").satisfies(e->assertThat(((EgressRejectedException)e).sampleDone()).isFalse());
|
||||
assertThat(budget().pending.consumed).isFalse();assertThat(businessBudget().http).isEqualTo(1);assertThat(businessBudget().confirmedHttp).isZero();
|
||||
assertThat(task.getStatus()).isEqualTo("DEAD");
|
||||
}
|
||||
@Test void testBusinessMissingBudgetOrWrongRootFailsClosedWithoutBackfill(){
|
||||
testBusiness(5,300);task.setBusinessBudgetJson(null);
|
||||
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID");
|
||||
assertThat(task.getBusinessBudgetJson()).isNull();assertThat(budget().reservedHttp).isZero();
|
||||
task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),5,300));task.setBusinessRootTaskId(999L);
|
||||
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID");
|
||||
assertThat(businessBudget().http).isZero();
|
||||
}
|
||||
@Test void testBusinessChildSharesFrozenRootAcrossYieldAndRejectsDifferentRoot(){
|
||||
testBusiness(2,300);
|
||||
var child=new SelfopSyncTask();child.setId(3L);child.setBusinessRootTaskId(1L);child.setTaskRole("WAREHOUSE_SYNC");child.setExecutionPurpose("BUSINESS");child.setSourceAccount("synthetic");child.setStatus("RUNNING");child.setScopeId(100L);child.setWarehouseCode("A");
|
||||
var attempt=new SelfopSyncRun();attempt.setId(4L);attempt.setTaskId(3L);attempt.setSourceAccount("synthetic");attempt.setExecutionPurpose("BUSINESS");attempt.setStatus("FETCHING");attempt.setFenceToken(7L);attempt.setScopeId(100L);attempt.setWarehouseCode("A");
|
||||
when(mapper.lockTask(3L)).thenReturn(child);when(mapper.lockRun(4L)).thenReturn(attempt);when(mapper.warehouseFence(eq(100L),eq(7L),eq(3L),any())).thenReturn(1);
|
||||
success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES);
|
||||
var context=new SelfopRequestContext("synthetic","BUSINESS",1L,"selfop-1","grant",3L,4L);
|
||||
var c=new SelfopEgressService(mapper,cfg,clock).reserve(context,Operation.PRODUCT_INVENTORY,1,"A",null,INTERNAL);success(c,Operation.PRODUCT_INVENTORY);
|
||||
assertThat(businessBudget().http).isEqualTo(2);assertThat(businessBudget().confirmedHttp).isEqualTo(2);assertThat(child.getBusinessBudgetJson()).isNull();
|
||||
child.setBusinessRootTaskId(999L);assertThatThrownBy(()->service.reserve(context,Operation.PRODUCT_INVENTORY,1,"A",null,INTERNAL)).hasMessage("BUSINESS_SESSION_ROOT_INVALID");
|
||||
child.setBusinessRootTaskId(1L);assertThatThrownBy(()->service.reserve(context,Operation.PRODUCT_INVENTORY,1,"A",null,INTERNAL)).hasMessage("BUSINESS_SESSION_HTTP_LIMIT");
|
||||
verify(mapper).terminateBusinessTasks(1L,"synthetic");
|
||||
}
|
||||
@Test void productionBusinessDoesNotRequireTestBudget(){
|
||||
business();success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES);
|
||||
assertThat(task.getBusinessBudgetJson()).isNull();assertThat(budget().confirmedHttp).isEqualTo(1);
|
||||
verify(mapper,never()).terminateBusinessTasks(any(),any());
|
||||
}
|
||||
@Test void testBusinessPreparedAttemptHonorsPersistedStopAndDoesNotReportValidationComplete(){
|
||||
testBusiness(5,300);var c=reserve(Operation.WAREHOUSES,1,null,null);
|
||||
var b=businessBudget();b.stopReason="BUSINESS_SESSION_HTTP_LIMIT";task.setBusinessBudgetJson(SelfopSupport.json(b));task.setStatus("DEAD");
|
||||
assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)))
|
||||
.hasMessage("BUSINESS_SESSION_HTTP_LIMIT").satisfies(e->assertThat(((EgressRejectedException)e).sampleDone()).isFalse());
|
||||
assertThat(budget().pending.consumed).isFalse();assertThat(businessBudget().confirmedHttp).isZero();
|
||||
}
|
||||
@Test void businessTerminationSqlIsRootScopedAndPreservesTerminalPublicationStates(){
|
||||
var configuration=new org.apache.ibatis.session.Configuration();configuration.addMapper(SelfopEgressMapper.class);
|
||||
var args=new HashMap<String,Object>();args.put("root",1L);args.put("account","synthetic");args.put("reason","BUSINESS_SESSION_HTTP_LIMIT");args.put("now",now());
|
||||
String prefix=SelfopEgressMapper.class.getName()+".";
|
||||
String tasks=configuration.getMappedStatement(prefix+"terminateBusinessTasks").getBoundSql(args).getSql();
|
||||
String runs=configuration.getMappedStatement(prefix+"terminateBusinessRuns").getBoundSql(args).getSql();
|
||||
assertThat(tasks).contains("business_root_task_id=? AND source_account=? AND execution_purpose='BUSINESS'", "next_retry_at_utc=NULL", "status IN ('WAITING_SOURCE','READY','RUNNING','RETRY_WAIT')");
|
||||
assertThat(runs).contains("business_root_task_id=? AND source_account=? AND execution_purpose='BUSINESS'", "error_code=?", "status IN ('FETCHING','VALIDATED','YIELDED','READY','BUILDING')");
|
||||
assertThat(tasks).doesNotContain("SUCCEEDED");assertThat(runs).doesNotContain("PUBLISHED","REUSED");
|
||||
}
|
||||
@Test void committedCompletionWithLostReplyStaysPausedAcrossIntegrationRestart(){
|
||||
business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
|
||||
service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));
|
||||
|
||||
+10
@@ -24,6 +24,16 @@ class SelfopCommandServiceTest {
|
||||
config.getWarehouses().put("WH-A", new SelfopAnalyticsProperties.Warehouse());
|
||||
}
|
||||
|
||||
@Test void stoppedBusinessSessionKeepsItsCommittedTerminationAcrossCommandBoundary() throws Exception {
|
||||
configure();var original=original("synthetic-account","WH-A");when(tasks.selectOne(any())).thenReturn(original);
|
||||
var stopped=new SelfopTaskService.BusinessSessionStoppedException("BUSINESS_SESSION_DEADLINE");
|
||||
when(taskService.retry(anyLong(),anyString(),anyString(),anyString())).thenThrow(stopped);
|
||||
assertThatThrownBy(()->service.request(retry(),"42")).isSameAs(stopped);
|
||||
var transaction=SelfopCommandService.class.getMethod("request",SelfopCommandService.Command.class,String.class)
|
||||
.getAnnotation(org.springframework.transaction.annotation.Transactional.class);
|
||||
assertThat(transaction.noRollbackFor()).contains(SelfopTaskService.BusinessSessionStoppedException.class);
|
||||
}
|
||||
|
||||
@Test void retryCannotResumeTaskBelongingToAnotherWarehouse() {
|
||||
configure();
|
||||
var original = original("synthetic-account", "WH-B");
|
||||
|
||||
+26
-1
@@ -42,10 +42,13 @@ class SelfopDiscoveryServiceTest {
|
||||
private SelfopOutboundDiscoveryScope account;
|
||||
|
||||
@BeforeEach void prepare() {
|
||||
when(taskService.checkpoint(any(SelfopSyncTask.class),any(SelfopSyncRun.class),any())).thenAnswer(inv ->
|
||||
new SelfopTaskService.CollectionCheckpoint(inv.getArgument(0),inv.getArgument(1)));
|
||||
|
||||
doAnswer(inv -> { Consumer<TransactionStatus> cb = inv.getArgument(0); cb.accept(mock(TransactionStatus.class)); return null; })
|
||||
.when(tx).executeWithoutResult(any());
|
||||
account = new SelfopOutboundDiscoveryScope(); account.setId(30L); account.setRequestBudget(10); account.setTimeBudgetSeconds(60);
|
||||
when(accounts.selectById(30L)).thenReturn(account);
|
||||
when(accounts.selectById(30L)).thenReturn(account);when(accounts.selectOne(any())).thenReturn(account);
|
||||
task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(1L); task.setSourceAccount("demo"); task.setDiscoveryScopeId(30L);
|
||||
task.setDiscoverySequence(17L); task.setObservationBaseline(90L); task.setTaskType("SCHEDULED"); task.setScopeHash("account-hash");
|
||||
task.setQueryFromUtc(from); task.setQueryUntilUtc(until); task.setStatus("RUNNING");
|
||||
@@ -76,6 +79,28 @@ class SelfopDiscoveryServiceTest {
|
||||
when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), anyString())).thenAnswer(inv -> detail(inv.getArgument(3), "2026-10-11 12:00:00"));
|
||||
service = new SelfopDiscoveryService(client, tasks, runs, stages, orders, scopes, accounts, taskService, leases, config, tx);
|
||||
}
|
||||
@Test void lateDirectoryResponseCannotPersistManifestOrStagesAfterSessionStops() {
|
||||
when(client.discover(any(SelfopRequestContext.class),eq(from),eq(until),eq(1),eq(2)))
|
||||
.thenReturn(page(1,2,1,1,summary("late","2026-10-11 12:00:00")));
|
||||
when(taskService.checkpoint(task,run,lease)).thenThrow(new SelfopTaskService.BusinessSessionStoppedException("BUSINESS_SESSION_DEADLINE"));
|
||||
assertThrows(SelfopTaskService.BusinessSessionStoppedException.class,()->service.collect(task,run,lease));
|
||||
assertTrue(stored.isEmpty());verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(runs,never()).updateById(any(SelfopSyncRun.class));
|
||||
}
|
||||
@Test void lateDetailCannotPromotePendingStageOrRouteWarehouseReady() {
|
||||
when(client.discover(any(SelfopRequestContext.class),eq(from),eq(until),eq(1),eq(2)))
|
||||
.thenReturn(page(1,2,1,1,summary("late","2026-10-11 12:00:00")));
|
||||
boolean[] stopped={false};
|
||||
when(client.detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("late"))).thenAnswer(inv->{stopped[0]=true;return detail("late","2026-10-11 12:00:00");});
|
||||
when(taskService.checkpoint(task,run,lease)).thenAnswer(inv->{
|
||||
if(stopped[0])throw new SelfopTaskService.BusinessSessionStoppedException("BUSINESS_SESSION_DEADLINE");
|
||||
return new SelfopTaskService.CollectionCheckpoint(task,run);
|
||||
});
|
||||
assertThrows(SelfopTaskService.BusinessSessionStoppedException.class,()->service.collect(task,run,lease));
|
||||
assertTrue(stored.values().stream().allMatch(row->"PENDING".equals(row.getValidationStatus())));
|
||||
assertTrue(children.values().stream().allMatch(row->"WAITING_SOURCE".equals(row.getStatus())));
|
||||
assertEquals("FETCHING",run.getStatus());assertEquals("RUNNING",task.getStatus());
|
||||
}
|
||||
|
||||
private LingxingAnalyticsClient.Summary summary(String no, String time) {
|
||||
return new LingxingAnalyticsClient.Summary("demo", "customer", "A", no, null, 100, null, time);
|
||||
}
|
||||
|
||||
+19
@@ -32,6 +32,9 @@ class SelfopInventoryCollectorTest {
|
||||
private SelfopSyncRun run;
|
||||
|
||||
@BeforeEach void prepare() {
|
||||
when(taskService.checkpoint(any(SelfopSyncTask.class),any(SelfopSyncRun.class),any())).thenAnswer(inv ->
|
||||
new SelfopTaskService.CollectionCheckpoint(inv.getArgument(0),inv.getArgument(1)));
|
||||
|
||||
doAnswer(invocation -> {
|
||||
Consumer<TransactionStatus> callback = invocation.getArgument(0);
|
||||
callback.accept(mock(TransactionStatus.class)); return null;
|
||||
@@ -42,6 +45,22 @@ class SelfopInventoryCollectorTest {
|
||||
run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setTaskId(task.getId()); run.setSourceAccount("demo"); run.setFenceToken(lease.fence()); run.setId(10L); run.setTaskId(20L); run.setStatus("FETCHING");
|
||||
run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}"); run.setFetchStartedAtUtc(utcNow());
|
||||
}
|
||||
@Test void lateInventoryResponseCannotWriteStagesOrReviveStoppedAttempt() {
|
||||
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(2))).thenReturn(page(1,1,1,stock("late",BigDecimal.ONE)));
|
||||
when(taskService.checkpoint(task,run,lease)).thenThrow(new SelfopTaskService.BusinessSessionStoppedException("BUSINESS_SESSION_DEADLINE"));
|
||||
assertThrows(SelfopTaskService.BusinessSessionStoppedException.class,()->collector.collect(task,run,lease));
|
||||
verify(stages,never()).insert(any(SelfopSyncStage.class));verify(tasks,never()).updateById(any(SelfopSyncTask.class));
|
||||
verify(runs,never()).updateById(any(SelfopSyncRun.class));
|
||||
}
|
||||
@Test void terminationBetweenLastPageAndFinalValidationCannotSetValidated() {
|
||||
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(2))).thenReturn(page(1,1,1,stock("last",BigDecimal.ONE)));
|
||||
when(taskService.checkpoint(task,run,lease)).thenReturn(new SelfopTaskService.CollectionCheckpoint(task,run))
|
||||
.thenThrow(new SelfopTaskService.BusinessSessionStoppedException("BUSINESS_SESSION_DEADLINE"));
|
||||
assertThrows(SelfopTaskService.BusinessSessionStoppedException.class,()->collector.collect(task,run,lease));
|
||||
assertEquals("FETCHING",run.getStatus());assertNull(run.getFetchFinishedAtUtc());
|
||||
verify(runs,times(1)).updateById(any(SelfopSyncRun.class));
|
||||
}
|
||||
|
||||
private SelfopPayloads.Stock stock(String sku, BigDecimal available) {
|
||||
return new SelfopPayloads.Stock("demo", "customer", "A", sku, 0, "Name",
|
||||
BigDecimal.TEN, available, BigDecimal.ZERO, BigDecimal.ZERO, null);
|
||||
|
||||
+1
@@ -90,6 +90,7 @@ class SelfopInventoryRecoveryTest {
|
||||
doAnswer(invocation -> {
|
||||
Consumer<TransactionStatus> callback = invocation.getArgument(0); callback.accept(mock(TransactionStatus.class)); return null;
|
||||
}).when(tx).executeWithoutResult(any());
|
||||
when(locks.lockRun(fresh.getId())).thenReturn(fresh);
|
||||
var collector = new SelfopInventoryCollector(client, stages, tasks, runs, leases, service, config, tx);
|
||||
assertTrue(collector.collect(task, fresh, lease));
|
||||
var saved = ArgumentCaptor.forClass(SelfopSyncStage.class); verify(stages).insert(saved.capture());
|
||||
|
||||
+124
@@ -366,6 +366,130 @@ class SelfopTaskServiceTest {
|
||||
verify(runs,never()).updateById(any(SelfopSyncRun.class));verify(runs,never()).selectCount(any());
|
||||
}
|
||||
|
||||
@Test void testManualRootFreezesBudgetAndContextWithoutAffectingProductionIdentity() {
|
||||
config.setEnvironment("test");config.setRequestBudget(7);config.setTimeBudgetSeconds(91);
|
||||
var root=task("READY");root.setRequestKey("manual:frozen");root.setRequestedBy("operator");
|
||||
service.create(root);
|
||||
var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class);
|
||||
assertThat(root.getBusinessRootTaskId()).isEqualTo(root.getId());assertThat(budget.maxHttp).isEqualTo(7);
|
||||
assertThat(budget.deadlineAtUtc).isEqualTo(budget.startedAtUtc.plusSeconds(91));
|
||||
var child=task("READY");child.setId(42L);child.setBusinessRootTaskId(root.getId());
|
||||
assertThat(SelfopRequestContext.forTask(child,run("FETCHING"),config).rootTaskId()).isEqualTo(root.getId());
|
||||
config.setEnvironment("production");
|
||||
assertThat(SelfopRequestContext.forTask(child,run("FETCHING"),config).rootTaskId()).isEqualTo(42L);
|
||||
}
|
||||
@Test void routedChildInheritsFrozenRootAndDoesNotGetAnIndependentBudget() {
|
||||
var root=boundedRoot(0,utcNow().plusMinutes(3));
|
||||
var child=task("READY");child.setUpstreamTaskId(1L);child.setRequestKey("route:1:WH-A");
|
||||
child.setId(null);service.create(child);
|
||||
assertThat(child.getBusinessRootTaskId()).isEqualTo(1L);assertThat(child.getBusinessBudgetJson()).isNull();
|
||||
assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isZero();
|
||||
}
|
||||
@Test void legacyTestTaskCannotAcquireAnImplicitUnlimitedBudget() {
|
||||
config.setEnvironment("test");var root=task("READY");root.setRequestKey("manual:legacy");root.setRequestedBy("operator");
|
||||
when(tasks.selectById(1L)).thenReturn(root);
|
||||
assertThatThrownBy(()->service.begin(1L,lease)).hasMessage("BUSINESS_SESSION_ROOT_REQUIRED");
|
||||
assertThatThrownBy(()->SelfopRequestContext.forTask(root,run("FETCHING"),config)).hasMessage("BUSINESS_SESSION_ROOT_REQUIRED");
|
||||
verify(runs,never()).insert(any(SelfopSyncRun.class));assertThat(root.getBusinessBudgetJson()).isNull();
|
||||
}
|
||||
@Test void exactLastHttpResponseMayContinuePureLocalMergeWithoutAnotherReservation() {
|
||||
var root=boundedRoot(7,utcNow().plusMinutes(3));when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND"));
|
||||
var manifest=new SelfopRoutingManifest();manifest.setDirectoryComplete(true);root.setRoutingManifestJson(json(manifest));
|
||||
var next=service.begin(1L,lease);
|
||||
assertThat(next.getStatus()).isEqualTo("FETCHING");
|
||||
assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isEqualTo(7);
|
||||
verify(runs).insert(next);
|
||||
}
|
||||
@Test void expiredFetchCannotBeginAndManualRetryCannotRestartItsSession() {
|
||||
var root=boundedRoot(2,utcNow().minusSeconds(1));
|
||||
assertThatThrownBy(()->service.begin(1L,lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class);
|
||||
assertThatThrownBy(()->service.retry(1L,"new-key","operator","retry"))
|
||||
.isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class);
|
||||
verify(runs,never()).insert(any(SelfopSyncRun.class));verify(tasks,never()).insert(any(SelfopSyncTask.class));
|
||||
assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isEqualTo(2);
|
||||
}
|
||||
@Test void expiredYieldAndBudgetWaitEndInsteadOfSchedulingAnotherDispatch() {
|
||||
boundedRoot(2,utcNow().minusSeconds(1));
|
||||
assertThatThrownBy(()->service.yield(1L,20L,lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class);
|
||||
assertThatThrownBy(()->service.waitForBudget(1L,20L,utcNow().plusDays(1),lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class);
|
||||
verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(runs,never()).updateById(any(SelfopSyncRun.class));
|
||||
}
|
||||
@Test void egressStopReasonSurvivesExecutorFailureHandling() {
|
||||
var root=boundedRoot(7,utcNow().plusMinutes(3));
|
||||
var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class);
|
||||
budget.stopReason="BUSINESS_SESSION_HTTP_LIMIT";root.setBusinessBudgetJson(json(budget));root.setStatus("DEAD");
|
||||
service.fail(1L,20L,"SELFOP_EXECUTION_FAILED",true,lease);
|
||||
verify(runs,never()).updateById(any(SelfopSyncRun.class));verify(tasks,never()).updateById(any(SelfopSyncTask.class));
|
||||
assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).stopReason).isEqualTo("BUSINESS_SESSION_HTTP_LIMIT");
|
||||
}
|
||||
@Test void manualRetryAuditRetainsOriginalRootAndCannotFreezeAnotherAllowance() {
|
||||
var root=boundedRoot(3,utcNow().plusMinutes(3));root.setStatus("RETRY_WAIT");String frozen=root.getBusinessBudgetJson();
|
||||
config.setRequestBudget(100);config.setTimeBudgetSeconds(900);
|
||||
assertThat(service.retry(1L,"retry-bounded","operator","retry failed attempt")).isSameAs(root);
|
||||
var saved=org.mockito.ArgumentCaptor.forClass(SelfopSyncTask.class);verify(tasks).insert(saved.capture());
|
||||
assertThat(saved.getValue().getTaskType()).isEqualTo("MANUAL_RETRY");assertThat(saved.getValue().getStatus()).isEqualTo("SUCCEEDED");
|
||||
assertThat(saved.getValue().getBusinessRootTaskId()).isEqualTo(1L);assertThat(saved.getValue().getBusinessBudgetJson()).isNull();
|
||||
assertThat(root.getBusinessBudgetJson()).isEqualTo(frozen);assertThat(root.getStatus()).isEqualTo("READY");
|
||||
}
|
||||
@Test void completeLocalRebuildCanFinishAfterCollectionDeadline() {
|
||||
var root=boundedRoot(7,utcNow().minusSeconds(1));root.setTaskType("REBUILD");
|
||||
when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND"));
|
||||
assertThat(service.begin(1L,lease).getStatus()).isEqualTo("FETCHING");
|
||||
assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isEqualTo(7);
|
||||
}
|
||||
@Test void guardLocksAccountBeforeWarehouseAndRoot() {
|
||||
boundedRoot(0,utcNow().plusMinutes(3));when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND"));
|
||||
service.begin(1L,lease);var order=inOrder(locks,leases);
|
||||
order.verify(locks).lockDiscovery(99L);order.verify(leases).verify(lease);order.verify(locks).lockDiscovery(99L);
|
||||
// This task is its own session root: guard locks the root, then begin locks the executable task.
|
||||
order.verify(locks,times(2)).lockTask(1L);
|
||||
}
|
||||
|
||||
@Test void incompleteManifestCannotUseLocalWorkExceptionAfterDeadline() {
|
||||
var root=boundedRoot(1,utcNow().minusSeconds(1));when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND"));
|
||||
for(String manifest:List.of("{\"directoryComplete\":true}","{\"directoryComplete\":true,\"objects\":[]}",
|
||||
"{\"directoryComplete\":true,\"objects\":{\"one\":{\"effectiveStageId\":\" \"}}}")) {
|
||||
root.setRoutingManifestJson(manifest);
|
||||
assertThatThrownBy(()->service.begin(1L,lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class);
|
||||
}
|
||||
verify(runs,never()).insert(any(SelfopSyncRun.class));
|
||||
}
|
||||
|
||||
@Test void checkpointRejectsLateResponseUsingLockedPersistedStateWithoutOverwritingReason() {
|
||||
var persisted=boundedRoot(7,utcNow().plusMinutes(1));persisted.setStatus("DEAD");
|
||||
var budget=read(persisted.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class);
|
||||
budget.stopReason="BUSINESS_SESSION_DEADLINE";persisted.setBusinessBudgetJson(json(budget));
|
||||
var failed=run("FAILED");failed.setErrorCode(budget.stopReason);when(locks.lockRun(20L)).thenReturn(failed);
|
||||
var responseTask=task("RUNNING");var responseRun=run("FETCHING");
|
||||
assertThatThrownBy(()->service.checkpoint(responseTask,responseRun,lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class);
|
||||
verify(tasks,never()).update(any(),any());verify(runs,never()).update(any(),any());
|
||||
assertThat(persisted.getStatus()).isEqualTo("DEAD");assertThat(failed.getStatus()).isEqualTo("FAILED");assertThat(failed.getErrorCode()).isEqualTo("BUSINESS_SESSION_DEADLINE");
|
||||
}
|
||||
@Test void checkpointReturnsLockedFreshEntitiesAndAllowsLastPermittedResponsePastDeadline() {
|
||||
var persisted=boundedRoot(7,utcNow().minusSeconds(1));persisted.setStatus("RUNNING");
|
||||
var active=run("FETCHING");when(locks.lockRun(20L)).thenReturn(active);
|
||||
var result=service.checkpoint(task("RUNNING"),run("FETCHING"),lease);
|
||||
assertThat(result.task()).isSameAs(persisted);assertThat(result.run()).isSameAs(active);
|
||||
assertThat(read(persisted.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isEqualTo(7);
|
||||
verify(tasks,never()).update(any(),any());verify(runs,never()).update(any(),any());
|
||||
}
|
||||
@Test void completeLocalBuildMayWaitPastCollectionDeadlineWithoutReopeningHttp() {
|
||||
var root=boundedRoot(7,utcNow().minusSeconds(1));root.setTaskType("REBUILD");root.setStatus("RUNNING");
|
||||
when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND"));when(locks.lockRun(20L)).thenReturn(run("BUILDING"));
|
||||
service.waitForBudget(1L,20L,utcNow().plusMinutes(30),lease);
|
||||
assertThat(root.getStatus()).isEqualTo("RETRY_WAIT");
|
||||
assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isEqualTo(7);
|
||||
}
|
||||
|
||||
private SelfopSyncTask boundedRoot(int used,LocalDateTime deadline) {
|
||||
config.setEnvironment("test");var root=task("READY");root.setRequestKey("manual:bounded");root.setRequestedBy("operator");root.setBusinessRootTaskId(1L);
|
||||
var budget=new com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget();
|
||||
budget.startedAtUtc=deadline.minusMinutes(5);budget.deadlineAtUtc=deadline;budget.maxHttp=7;budget.http=used;budget.confirmedHttp=used;
|
||||
root.setBusinessBudgetJson(json(budget));when(tasks.selectById(1L)).thenReturn(root);when(locks.lockTask(1L)).thenReturn(root);
|
||||
var account=new SelfopOutboundDiscoveryScope();account.setId(99L);when(accounts.selectOne(any())).thenReturn(account);
|
||||
when(tasks.selectList(any())).thenReturn(List.of(root));when(locks.lockScope(10L)).thenReturn(scope("OUTBOUND"));return root;
|
||||
}
|
||||
|
||||
private SelfopSyncTask task(String status) {
|
||||
var task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001");
|
||||
task.setId(1L); task.setScopeId(10L); task.setSourceAccount("synthetic-account");
|
||||
|
||||
Reference in New Issue
Block a user