forked from wangziqi/ruoyi-vue-pro
feat(education): add content import persistence
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
package cn.iocoder.yudao.module.education.dal.dataobject.importjob;
|
||||||
|
|
||||||
|
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||||
|
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
@TableName("education_content_import_asset")
|
||||||
|
@KeySequence("education_content_import_asset_seq")
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class ContentImportAssetDO extends TenantBaseDO {
|
||||||
|
@TableId
|
||||||
|
private Long id;
|
||||||
|
private String objectKey;
|
||||||
|
private String fileName;
|
||||||
|
private String mimeType;
|
||||||
|
private Long fileSizeBytes;
|
||||||
|
private String checksumSha256;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package cn.iocoder.yudao.module.education.dal.dataobject.importjob;
|
||||||
|
|
||||||
|
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||||
|
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@TableName(value = "education_content_import_job", autoResultMap = true)
|
||||||
|
@KeySequence("education_content_import_job_seq")
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class ContentImportJobDO extends TenantBaseDO {
|
||||||
|
@TableId
|
||||||
|
private Long id;
|
||||||
|
private Long assetId;
|
||||||
|
private String previewKey;
|
||||||
|
private String importType;
|
||||||
|
private String status;
|
||||||
|
private Integer attemptCount;
|
||||||
|
private Integer maxAttempts;
|
||||||
|
private LocalDateTime nextAttemptAt;
|
||||||
|
private String lockedBy;
|
||||||
|
private String leaseToken;
|
||||||
|
private LocalDateTime leaseExpiresAt;
|
||||||
|
private LocalDateTime lastHeartbeatAt;
|
||||||
|
private LocalDateTime startedAt;
|
||||||
|
private LocalDateTime finishedAt;
|
||||||
|
private String errorMessage;
|
||||||
|
@TableField("preview_payload")
|
||||||
|
private String previewPayload;
|
||||||
|
@TableField("result_summary")
|
||||||
|
private String resultSummary;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package cn.iocoder.yudao.module.education.dal.mysql.importjob;
|
||||||
|
|
||||||
|
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||||
|
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportAssetDO;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ContentImportAssetMapper extends BaseMapperX<ContentImportAssetDO> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package cn.iocoder.yudao.module.education.dal.mysql.importjob;
|
||||||
|
|
||||||
|
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||||
|
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportJobDO;
|
||||||
|
import org.apache.ibatis.annotations.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ContentImportJobMapper extends BaseMapperX<ContentImportJobDO> {
|
||||||
|
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO education_content_import_job
|
||||||
|
(tenant_id, asset_id, preview_key, import_type, status, attempt_count, max_attempts,
|
||||||
|
preview_payload, creator, create_time, updater, update_time, deleted)
|
||||||
|
VALUES
|
||||||
|
(#{tenantId}, #{assetId}, #{previewKey}, #{importType}, #{status}, 0, #{maxAttempts},
|
||||||
|
CAST(#{previewPayload} AS JSONB), '', CURRENT_TIMESTAMP, '', CURRENT_TIMESTAMP, FALSE)
|
||||||
|
ON CONFLICT (tenant_id, preview_key) DO NOTHING
|
||||||
|
""")
|
||||||
|
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||||
|
int insertPreviewIgnore(ContentImportJobDO job);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE education_content_import_job
|
||||||
|
SET status = 'PENDING', next_attempt_at = CURRENT_TIMESTAMP, update_time = CURRENT_TIMESTAMP
|
||||||
|
WHERE tenant_id = #{tenantId} AND id = #{id} AND status = 'PREVIEW' AND deleted = FALSE
|
||||||
|
""")
|
||||||
|
int executePreview(@Param("tenantId") Long tenantId, @Param("id") Long id);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
WITH candidates AS (
|
||||||
|
SELECT id FROM education_content_import_job
|
||||||
|
WHERE deleted = FALSE AND attempt_count < max_attempts
|
||||||
|
AND ((status = 'PENDING' AND (next_attempt_at IS NULL OR next_attempt_at <= CURRENT_TIMESTAMP))
|
||||||
|
OR (status = 'PROCESSING' AND lease_expires_at <= CURRENT_TIMESTAMP))
|
||||||
|
ORDER BY CASE WHEN status = 'PROCESSING' THEN 0 ELSE 1 END,
|
||||||
|
COALESCE(lease_expires_at, next_attempt_at, create_time), id
|
||||||
|
LIMIT #{limit} FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
UPDATE education_content_import_job job
|
||||||
|
SET status = 'PROCESSING', locked_by = #{workerId}, lease_token = #{leaseToken},
|
||||||
|
lease_expires_at = CURRENT_TIMESTAMP + CAST(#{leaseSeconds} || ' seconds' AS INTERVAL),
|
||||||
|
last_heartbeat_at = CURRENT_TIMESTAMP, attempt_count = job.attempt_count + 1,
|
||||||
|
next_attempt_at = NULL, started_at = COALESCE(job.started_at, CURRENT_TIMESTAMP),
|
||||||
|
finished_at = NULL, error_message = NULL, update_time = CURRENT_TIMESTAMP
|
||||||
|
FROM candidates WHERE job.id = candidates.id
|
||||||
|
RETURNING job.*
|
||||||
|
""")
|
||||||
|
List<ContentImportJobDO> claimReady(@Param("workerId") String workerId,
|
||||||
|
@Param("leaseToken") String leaseToken,
|
||||||
|
@Param("leaseSeconds") long leaseSeconds,
|
||||||
|
@Param("limit") int limit);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE education_content_import_job
|
||||||
|
SET lease_expires_at = CURRENT_TIMESTAMP + CAST(#{leaseSeconds} || ' seconds' AS INTERVAL),
|
||||||
|
last_heartbeat_at = CURRENT_TIMESTAMP, update_time = CURRENT_TIMESTAMP
|
||||||
|
WHERE tenant_id = #{tenantId} AND id = #{id} AND status = 'PROCESSING'
|
||||||
|
AND lease_token = #{leaseToken} AND lease_expires_at > CURRENT_TIMESTAMP
|
||||||
|
""")
|
||||||
|
int heartbeat(@Param("tenantId") Long tenantId, @Param("id") Long id,
|
||||||
|
@Param("leaseToken") String leaseToken, @Param("leaseSeconds") long leaseSeconds);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
WITH expired AS (
|
||||||
|
SELECT id FROM education_content_import_job
|
||||||
|
WHERE status = 'PROCESSING' AND lease_expires_at <= CURRENT_TIMESTAMP
|
||||||
|
AND attempt_count < max_attempts
|
||||||
|
ORDER BY lease_expires_at, id
|
||||||
|
LIMIT #{limit} FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
UPDATE education_content_import_job job
|
||||||
|
SET status = 'PENDING', locked_by = NULL, lease_token = NULL, lease_expires_at = NULL,
|
||||||
|
last_heartbeat_at = NULL, next_attempt_at = CURRENT_TIMESTAMP, update_time = CURRENT_TIMESTAMP
|
||||||
|
FROM expired WHERE job.id = expired.id
|
||||||
|
""")
|
||||||
|
int recoverExpired(@Param("limit") int limit);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE education_content_import_job
|
||||||
|
SET status = 'COMPLETED', result_summary = CAST(#{resultSummary} AS JSONB),
|
||||||
|
locked_by = NULL, lease_token = NULL, lease_expires_at = NULL, last_heartbeat_at = NULL,
|
||||||
|
finished_at = CURRENT_TIMESTAMP, update_time = CURRENT_TIMESTAMP
|
||||||
|
WHERE tenant_id = #{tenantId} AND id = #{id} AND status = 'PROCESSING'
|
||||||
|
AND lease_token = #{leaseToken} AND lease_expires_at > CURRENT_TIMESTAMP
|
||||||
|
""")
|
||||||
|
int complete(@Param("tenantId") Long tenantId, @Param("id") Long id,
|
||||||
|
@Param("leaseToken") String leaseToken, @Param("resultSummary") String resultSummary);
|
||||||
|
|
||||||
|
@Update("UPDATE education_content_import_job SET lease_expires_at = CURRENT_TIMESTAMP - INTERVAL '1 second' WHERE id = #{id}")
|
||||||
|
int expireLeaseForTest(@Param("id") Long id);
|
||||||
|
}
|
||||||
@@ -126,26 +126,9 @@ public interface ErrorCodeConstants {
|
|||||||
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_INVALID = new ErrorCode(1_005_002_035, "题集成员必须是当前租户已发布题目");
|
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_INVALID = new ErrorCode(1_005_002_035, "题集成员必须是当前租户已发布题目");
|
||||||
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_TOO_LARGE = new ErrorCode(1_005_002_036, "题集成员数量不能超过 {}");
|
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_TOO_LARGE = new ErrorCode(1_005_002_036, "题集成员数量不能超过 {}");
|
||||||
|
|
||||||
// ========== 商业化与权益 1-005-004-000 ~ 1-005-004-019 ==========
|
// ========== 内容导入持久化 1-005-002-040 ~ 1-005-002-049 ==========
|
||||||
ErrorCode EDUCATION_RESOURCE_ACCESS_DENIED = new ErrorCode(1_005_004_000, "教育资源不存在或无权访问");
|
ErrorCode CONTENT_IMPORT_JOB_NOT_FOUND = new ErrorCode(1_005_002_040, "内容导入任务不存在或无权访问");
|
||||||
ErrorCode ENTITLEMENT_IDEMPOTENCY_CONFLICT = new ErrorCode(1_005_004_001, "权益事件幂等键已被不同请求使用");
|
ErrorCode CONTENT_IMPORT_JOB_NOT_READY = new ErrorCode(1_005_002_041, "内容导入任务当前状态不可执行");
|
||||||
ErrorCode ENTITLEMENT_EVENT_INVALID = new ErrorCode(1_005_004_002, "权益事件无效");
|
ErrorCode CONTENT_IMPORT_PREVIEW_DUPLICATE = new ErrorCode(1_005_002_042, "内容导入预览键已存在");
|
||||||
ErrorCode ENTITLEMENT_RESOURCE_NOT_FOUND = new ErrorCode(1_005_004_003, "权益资源不存在或不可发放");
|
ErrorCode CONTENT_IMPORT_LEASE_LOST = new ErrorCode(1_005_002_043, "内容导入任务租约已失效");
|
||||||
ErrorCode PRODUCT_BINDING_CONFLICT = new ErrorCode(1_005_004_004, "资源商品绑定冲突");
|
|
||||||
ErrorCode PRODUCT_NOT_AVAILABLE = new ErrorCode(1_005_004_005, "Mall 商品不存在或不可用");
|
|
||||||
// ========== 分类创作 1-005-002-040 ~ 1-005-002-049 ==========
|
|
||||||
ErrorCode CATEGORY_AUTHORING_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_002_040,
|
|
||||||
"当前题库数据源模式不支持分类创作:{}");
|
|
||||||
ErrorCode CATEGORY_AUTHORING_NOT_FOUND = new ErrorCode(1_005_002_041, "分类不存在或无权管理");
|
|
||||||
ErrorCode CATEGORY_AUTHORING_CONFLICT = new ErrorCode(1_005_002_042, "分类已变化,请刷新后重试");
|
|
||||||
ErrorCode CATEGORY_SUBJECT_UNAVAILABLE = new ErrorCode(1_005_002_043, "分类学科不存在或不可用");
|
|
||||||
|
|
||||||
// ========== 练习蓝图创作 1-005-002-050 ~ 1-005-002-059 ==========
|
|
||||||
ErrorCode PRACTICE_BLUEPRINT_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_002_050,
|
|
||||||
"当前题库数据源模式不支持练习蓝图创作:{}");
|
|
||||||
ErrorCode PRACTICE_BLUEPRINT_AUTHORING_NOT_FOUND = new ErrorCode(1_005_002_051, "练习蓝图不存在或无权管理");
|
|
||||||
ErrorCode PRACTICE_BLUEPRINT_AUTHORING_CONFLICT = new ErrorCode(1_005_002_052, "练习蓝图已变化,请刷新后重试");
|
|
||||||
ErrorCode PRACTICE_BLUEPRINT_TARGET_UNAVAILABLE = new ErrorCode(1_005_002_053, "练习蓝图目标不存在或不可用");
|
|
||||||
ErrorCode PRACTICE_BLUEPRINT_CONFIG_INVALID = new ErrorCode(1_005_002_054, "练习蓝图配置无效");
|
|
||||||
ErrorCode PRACTICE_BLUEPRINT_ELIGIBLE_INSUFFICIENT = new ErrorCode(1_005_002_055, "练习蓝图可用题目不足");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
-- EDU-011 persistence slice: tenant-owned import assets and durable preview/execute jobs.
|
||||||
|
|
||||||
|
CREATE TABLE education_content_import_asset (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id BIGINT NOT NULL,
|
||||||
|
object_key VARCHAR(512) NOT NULL,
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
mime_type VARCHAR(128) NOT NULL,
|
||||||
|
file_size_bytes BIGINT NOT NULL,
|
||||||
|
checksum_sha256 VARCHAR(64) NOT NULL,
|
||||||
|
creator VARCHAR(64) DEFAULT '',
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updater VARCHAR(64) DEFAULT '',
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
CONSTRAINT ck_education_content_import_asset_size CHECK (file_size_bytes >= 0),
|
||||||
|
CONSTRAINT uk_education_content_import_asset_tenant_id UNIQUE (tenant_id, id),
|
||||||
|
CONSTRAINT uk_education_content_import_asset_object UNIQUE (tenant_id, object_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE education_content_import_asset IS '教育内容导入源文件元数据;文件存储与扫描由 Infra 持有';
|
||||||
|
COMMENT ON COLUMN education_content_import_asset.object_key IS 'Infra 私有对象键,不存储公开 URL';
|
||||||
|
|
||||||
|
CREATE TABLE education_content_import_job (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id BIGINT NOT NULL,
|
||||||
|
asset_id BIGINT NOT NULL,
|
||||||
|
preview_key VARCHAR(128) NOT NULL,
|
||||||
|
import_type VARCHAR(32) NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'PREVIEW',
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||||
|
next_attempt_at TIMESTAMP,
|
||||||
|
locked_by VARCHAR(128),
|
||||||
|
lease_token VARCHAR(64),
|
||||||
|
lease_expires_at TIMESTAMP,
|
||||||
|
last_heartbeat_at TIMESTAMP,
|
||||||
|
started_at TIMESTAMP,
|
||||||
|
finished_at TIMESTAMP,
|
||||||
|
error_message VARCHAR(2000),
|
||||||
|
preview_payload JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||||
|
result_summary JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||||
|
creator VARCHAR(64) DEFAULT '',
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updater VARCHAR(64) DEFAULT '',
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
CONSTRAINT fk_education_content_import_job_asset_tenant
|
||||||
|
FOREIGN KEY (tenant_id, asset_id)
|
||||||
|
REFERENCES education_content_import_asset (tenant_id, id),
|
||||||
|
CONSTRAINT uk_education_content_import_job_preview UNIQUE (tenant_id, preview_key),
|
||||||
|
CONSTRAINT ck_education_content_import_job_status
|
||||||
|
CHECK (status IN ('PREVIEW', 'PENDING', 'PROCESSING', 'COMPLETED', 'FAILED')),
|
||||||
|
CONSTRAINT ck_education_content_import_job_attempts
|
||||||
|
CHECK (attempt_count >= 0 AND max_attempts > 0 AND attempt_count <= max_attempts),
|
||||||
|
CONSTRAINT ck_education_content_import_job_lease CHECK (
|
||||||
|
(status = 'PROCESSING' AND locked_by IS NOT NULL AND lease_token IS NOT NULL
|
||||||
|
AND lease_expires_at IS NOT NULL AND last_heartbeat_at IS NOT NULL)
|
||||||
|
OR
|
||||||
|
(status <> 'PROCESSING' AND locked_by IS NULL AND lease_token IS NULL
|
||||||
|
AND lease_expires_at IS NULL AND last_heartbeat_at IS NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE education_content_import_job IS '教育内容导入预览与执行作业';
|
||||||
|
COMMENT ON COLUMN education_content_import_job.preview_key IS '租户内重复预览幂等键';
|
||||||
|
COMMENT ON COLUMN education_content_import_job.lease_token IS '处理租约 fencing token';
|
||||||
|
|
||||||
|
CREATE INDEX idx_education_content_import_job_ready
|
||||||
|
ON education_content_import_job (next_attempt_at, create_time, id)
|
||||||
|
WHERE status = 'PENDING' AND deleted = false;
|
||||||
|
CREATE INDEX idx_education_content_import_job_expired
|
||||||
|
ON education_content_import_job (lease_expires_at, create_time, id)
|
||||||
|
WHERE status = 'PROCESSING' AND deleted = false;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package cn.iocoder.yudao.module.education.dal.mysql.importjob;
|
||||||
|
|
||||||
|
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||||
|
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportAssetDO;
|
||||||
|
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportJobDO;
|
||||||
|
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class ContentImportJobMapperPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest {
|
||||||
|
|
||||||
|
@Resource(name = "contentImportAssetMapper") private ContentImportAssetMapper assetMapper;
|
||||||
|
@Resource(name = "contentImportJobMapper") private ContentImportJobMapper jobMapper;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
TenantContextHolder.setTenantId(10L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
TenantContextHolder.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldEnforceTenantBoundJobLifecycleAndLeaseFencing() {
|
||||||
|
ContentImportAssetDO asset = asset(10L, "imports/10/questions.xlsx");
|
||||||
|
assetMapper.insert(asset);
|
||||||
|
|
||||||
|
ContentImportJobDO preview = preview(10L, asset.getId(), "preview-1");
|
||||||
|
assertEquals(1, jobMapper.insertPreviewIgnore(preview));
|
||||||
|
assertEquals(0, jobMapper.insertPreviewIgnore(preview(10L, asset.getId(), "preview-1")));
|
||||||
|
|
||||||
|
ContentImportAssetDO otherTenantAsset = asset(20L, "imports/20/questions.xlsx");
|
||||||
|
assetMapper.insert(otherTenantAsset);
|
||||||
|
assertThrows(DataIntegrityViolationException.class,
|
||||||
|
() -> jobMapper.insertPreviewIgnore(preview(10L, otherTenantAsset.getId(), "cross-tenant")));
|
||||||
|
|
||||||
|
assertEquals(1, jobMapper.executePreview(10L, preview.getId()));
|
||||||
|
assertEquals(0, jobMapper.executePreview(10L, preview.getId()));
|
||||||
|
|
||||||
|
List<ContentImportJobDO> claimed = jobMapper.claimReady("worker-a", "token-a", 60, 1);
|
||||||
|
assertEquals(1, claimed.size());
|
||||||
|
ContentImportJobDO job = claimed.getFirst();
|
||||||
|
assertEquals("PROCESSING", job.getStatus());
|
||||||
|
assertEquals(1, job.getAttemptCount());
|
||||||
|
assertEquals("token-a", job.getLeaseToken());
|
||||||
|
assertTrue(jobMapper.claimReady("worker-b", "token-b", 60, 1).isEmpty());
|
||||||
|
|
||||||
|
LocalDateTime firstExpiry = job.getLeaseExpiresAt();
|
||||||
|
assertEquals(1, jobMapper.heartbeat(10L, job.getId(), "token-a", 120));
|
||||||
|
assertTrue(jobMapper.selectById(job.getId()).getLeaseExpiresAt().isAfter(firstExpiry));
|
||||||
|
assertEquals(0, jobMapper.heartbeat(10L, job.getId(), "stale-token", 120));
|
||||||
|
assertEquals(0, jobMapper.complete(10L, job.getId(), "stale-token", "{}"));
|
||||||
|
|
||||||
|
jobMapper.expireLeaseForTest(job.getId());
|
||||||
|
assertEquals(1, jobMapper.recoverExpired(10));
|
||||||
|
assertEquals("PENDING", jobMapper.selectById(job.getId()).getStatus());
|
||||||
|
|
||||||
|
ContentImportJobDO reclaimed = jobMapper.claimReady("worker-b", "token-b", 60, 1).getFirst();
|
||||||
|
assertEquals(2, reclaimed.getAttemptCount());
|
||||||
|
assertEquals(0, jobMapper.complete(10L, job.getId(), "token-a", "{}"));
|
||||||
|
assertEquals(1, jobMapper.complete(10L, job.getId(), "token-b", "{\"inserted\":1}"));
|
||||||
|
assertEquals("COMPLETED", jobMapper.selectById(job.getId()).getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldAllowOnlyFiveJobStates() {
|
||||||
|
ContentImportAssetDO asset = asset(10L, "imports/10/states.xlsx");
|
||||||
|
assetMapper.insert(asset);
|
||||||
|
ContentImportJobDO job = preview(10L, asset.getId(), "states");
|
||||||
|
job.setStatus("REJECTED");
|
||||||
|
|
||||||
|
assertThrows(DataIntegrityViolationException.class, () -> jobMapper.insert(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ContentImportAssetDO asset(Long tenantId, String objectKey) {
|
||||||
|
ContentImportAssetDO asset = new ContentImportAssetDO();
|
||||||
|
asset.setTenantId(tenantId);
|
||||||
|
asset.setObjectKey(objectKey);
|
||||||
|
asset.setFileName("questions.xlsx");
|
||||||
|
asset.setMimeType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
asset.setFileSizeBytes(128L);
|
||||||
|
asset.setChecksumSha256("a".repeat(64));
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ContentImportJobDO preview(Long tenantId, Long assetId, String previewKey) {
|
||||||
|
ContentImportJobDO job = new ContentImportJobDO();
|
||||||
|
job.setTenantId(tenantId);
|
||||||
|
job.setAssetId(assetId);
|
||||||
|
job.setPreviewKey(previewKey);
|
||||||
|
job.setImportType("QUESTIONS");
|
||||||
|
job.setStatus("PREVIEW");
|
||||||
|
job.setMaxAttempts(3);
|
||||||
|
job.setPreviewPayload("{}");
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,7 +119,7 @@ class EducationFlywayMigrationIntegrationTest {
|
|||||||
|
|
||||||
assertThat(queryStrings(schema,
|
assertThat(queryStrings(schema,
|
||||||
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
|
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
|
||||||
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4140", "4150", "4160", "4170");
|
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4130", "4140", "4150", "4160", "4170");
|
||||||
assertThat(queryLong(schema,
|
assertThat(queryLong(schema,
|
||||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
|
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
|
||||||
"AND table_name = 'education_idempotency'"))
|
"AND table_name = 'education_idempotency'"))
|
||||||
@@ -211,7 +211,7 @@ class EducationFlywayMigrationIntegrationTest {
|
|||||||
|
|
||||||
assertThat(queryStrings(schema,
|
assertThat(queryStrings(schema,
|
||||||
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
|
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
|
||||||
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4140", "4150", "4160", "4170");
|
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4130", "4140", "4150", "4160", "4170");
|
||||||
assertThat(queryStrings(schema,
|
assertThat(queryStrings(schema,
|
||||||
"SELECT table_name FROM information_schema.tables " +
|
"SELECT table_name FROM information_schema.tables " +
|
||||||
"WHERE table_schema = current_schema() AND table_name IN (" +
|
"WHERE table_schema = current_schema() AND table_name IN (" +
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ TRUNCATE TABLE
|
|||||||
education_category_lifecycle_audit,
|
education_category_lifecycle_audit,
|
||||||
education_practice_blueprint,
|
education_practice_blueprint,
|
||||||
education_category,
|
education_category,
|
||||||
|
education_content_import_job,
|
||||||
|
education_content_import_asset,
|
||||||
education_question_collection_lifecycle_audit,
|
education_question_collection_lifecycle_audit,
|
||||||
education_content_node_lifecycle_audit,
|
education_content_node_lifecycle_audit,
|
||||||
education_question_lifecycle_transition_token,
|
education_question_lifecycle_transition_token,
|
||||||
|
|||||||
Reference in New Issue
Block a user