261f1d38e9
- 按 DEVICE_DEVICE.DEVICE_COLLECT_ID 过滤 API 拉取任务 - 仅允许与 APP_SERVICE_DEVICE_COLLECT_ID 匹配的节点执行任务 - 在任务扫描和数据库租约获取阶段双重校验设备归属 - 设备迁移后原节点不执行 HTTP 请求且不推进 NEXT_RUN_AT - 保留数据库租约,避免同一探针的多实例重复拉取 - 微步 searchBySql 改为全量逐条日志查询 - 移除 net_data_type=attack/risk 固定过滤 - 支持采集 attack、action、risk 并原样写入 WAL/Kafka - 保留空 sql、完整字段和时间窗口查询 - 新增微步原始查询诊断接口 - 支持自定义 size、page、pageSize、cur_page 等查询字段 - 复用任务绑定的地址、凭据、TLS 和超时配置 - 诊断查询不写 WAL/Kafka、不推进 checkpoint - 响应隐藏 API Key、Secret、签名和认证字段 - 更新启动版本标识为 WEIBU_DIAGNOSTIC_V20260917_66 - 更新 v6 部署、验证和 curl 调用文档 - Docker 基础镜像调整为 eclipse-temurin:8-jre - 补充探针归属、全量查询、诊断透传和状态隔离测试 测试: - JDK 8 - Tests run: 165 - Failures: 0 - Errors: 0 - Skipped: 2 - git diff --check 通过
254 lines
11 KiB
Java
254 lines
11 KiB
Java
package com.haobang.apipull;
|
|
|
|
import com.common.mapper.DeviceCollectApiMapper;
|
|
import com.haobang.config.AppProperties;
|
|
import com.haobang.dispatch.DeviceContextResolver;
|
|
import com.haobang.apipull.persistence.DeviceCollectApiCheckpoint;
|
|
import com.haobang.apipull.persistence.DeviceCollectApiConfig;
|
|
import com.haobang.apipull.persistence.DeviceCollectApiRun;
|
|
import com.haobang.wal.SyslogWalService;
|
|
import java.net.URI;
|
|
import java.time.LocalDateTime;
|
|
import java.util.Set;
|
|
import java.util.UUID;
|
|
import java.util.concurrent.*;
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
import javax.annotation.PostConstruct;
|
|
import javax.annotation.PreDestroy;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
import org.springframework.scheduling.annotation.Scheduled;
|
|
import org.springframework.stereotype.Component;
|
|
|
|
/**
|
|
* API 拉取任务调度器和并发闸门。
|
|
*
|
|
* <p>并发限制分三层:同一 JVM 内同一任务只能运行一次、全局并发不超过线程池大小、同一设备 主机不超过 per-host 上限。跨实例互斥由达梦租约和单调递增的 fencing token
|
|
* 保证。
|
|
*/
|
|
@Component
|
|
public class ApiPullScheduler {
|
|
private static final Logger log = LoggerFactory.getLogger(ApiPullScheduler.class);
|
|
private final ApiPullProperties properties;
|
|
private final DeviceCollectApiMapper mapper;
|
|
private final ApiPullRunner runner;
|
|
private final SyslogWalService wal;
|
|
private final DeviceContextResolver deviceResolver;
|
|
private final AppProperties appProperties;
|
|
private ExecutorService executor;
|
|
private Semaphore global;
|
|
private final ConcurrentHashMap<String, Semaphore> hosts = new ConcurrentHashMap<>();
|
|
private final Set<Integer> localRunning = ConcurrentHashMap.newKeySet();
|
|
|
|
public ApiPullScheduler(
|
|
ApiPullProperties properties,
|
|
DeviceCollectApiMapper mapper,
|
|
ApiPullRunner runner,
|
|
SyslogWalService wal,
|
|
DeviceContextResolver deviceResolver,
|
|
AppProperties appProperties) {
|
|
this.properties = properties;
|
|
this.mapper = mapper;
|
|
this.runner = runner;
|
|
this.wal = wal;
|
|
this.deviceResolver = deviceResolver;
|
|
this.appProperties = appProperties;
|
|
}
|
|
|
|
@PostConstruct
|
|
public void init() {
|
|
properties.validate();
|
|
global = new Semaphore(properties.getGlobalConcurrency());
|
|
executor =
|
|
Executors.newFixedThreadPool(
|
|
properties.getGlobalConcurrency(),
|
|
r -> {
|
|
Thread t = new Thread(r, "device-api-pull");
|
|
t.setDaemon(true);
|
|
return t;
|
|
});
|
|
log.info(
|
|
"设备 API 拉取调度器配置: enabled={}, globalConcurrency={}, perHostConcurrency={}, leaseSeconds={},"
|
|
+ " maxPagesPerSlice={}, slowPageThresholdMs={}",
|
|
properties.isEnabled(),
|
|
properties.getGlobalConcurrency(),
|
|
properties.getPerHostConcurrency(),
|
|
properties.getLeaseSeconds(),
|
|
properties.getMaxPagesPerSlice(),
|
|
properties.getSlowPageThresholdMs());
|
|
}
|
|
|
|
/** 扫描到期任务;总开关关闭时不查询数据库,也不创建任何设备请求。 */
|
|
@Scheduled(fixedDelayString = "${device.api.scheduler.scan-interval-ms:5000}")
|
|
public void scan() {
|
|
if (!properties.isEnabled()) return;
|
|
for (DeviceCollectApiConfig c
|
|
: mapper.selectDueConfigs(appProperties.getAppServiceDeviceCollectId()))
|
|
try {
|
|
trySubmit(c, "SCHEDULED");
|
|
} catch (Throwable failure) {
|
|
log.warn("设备 API 调度提交失败: taskId={}, error={}", c.getCollectTaskId(), failure.toString());
|
|
}
|
|
}
|
|
|
|
/** 提交一次人工运行;手工操作不会绕过任务启用状态或资源保护。 */
|
|
public DeviceCollectApiRun trigger(Integer taskId) {
|
|
if (!properties.isEnabled())
|
|
throw new ApiPullException(
|
|
"API_PULL_DISABLED",
|
|
"设备 API 拉取总开关未开启",
|
|
org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE);
|
|
if (wal.getFatalFailure() != null || wal.isDiskCritical() || wal.shouldPauseTcp())
|
|
throw new ApiPullException(
|
|
"WAL_UNAVAILABLE",
|
|
"WAL 不可用或处于接入保护水位",
|
|
org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE);
|
|
DeviceCollectApiConfig c = mapper.selectConfig(taskId);
|
|
if (c == null)
|
|
throw new ApiPullException(
|
|
"TASK_NOT_FOUND", "API 拉取任务不存在", org.springframework.http.HttpStatus.NOT_FOUND);
|
|
if (!c.isEnabled())
|
|
throw new ApiPullException(
|
|
"TASK_NOT_PAUSED", "任务处于暂停状态,不能手工触发", org.springframework.http.HttpStatus.CONFLICT);
|
|
DeviceCollectApiRun run = trySubmit(c, "MANUAL");
|
|
if (run == null)
|
|
throw new ApiPullException(
|
|
"LEASE_CONFLICT", "任务正在运行或无可用并发槽", org.springframework.http.HttpStatus.CONFLICT);
|
|
return run;
|
|
}
|
|
|
|
private DeviceCollectApiRun trySubmit(DeviceCollectApiConfig c, String trigger) {
|
|
if (!c.isEnabled()) return null;
|
|
if (!requireApiCollectionMode(c, "MANUAL".equals(trigger))) return null;
|
|
if (wal.getFatalFailure() != null || wal.isDiskCritical() || wal.shouldPauseTcp()) {
|
|
mapper.scheduleNext(c.getCollectTaskId(), c.getConfigVersion(), c.getPollIntervalSeconds());
|
|
return null;
|
|
}
|
|
// 获取顺序固定为任务互斥 -> 全局许可 -> 主机许可 -> DB 租约,失败时按持有情况释放。
|
|
if (!localRunning.add(c.getCollectTaskId())) return null;
|
|
if (!global.tryAcquire()) {
|
|
localRunning.remove(c.getCollectTaskId());
|
|
return null;
|
|
}
|
|
String host;
|
|
try {
|
|
host = new URI(c.getBaseUrl()).getHost();
|
|
} catch (Exception e) {
|
|
releaseLocal(c.getCollectTaskId(), null);
|
|
return null;
|
|
}
|
|
Semaphore hostPermit =
|
|
hosts.computeIfAbsent(host, h -> new Semaphore(properties.getPerHostConcurrency()));
|
|
if (!hostPermit.tryAcquire()) {
|
|
releaseLocal(c.getCollectTaskId(), null);
|
|
return null;
|
|
}
|
|
DeviceCollectApiCheckpoint cp = null;
|
|
DeviceCollectApiRun submittedRun = null;
|
|
try {
|
|
// DB 租约是跨实例最终互斥;本地 semaphore 只用于资源控制,不能代替 fencing。
|
|
if (mapper.acquireLease(
|
|
c.getCollectTaskId(),
|
|
c.getConfigVersion(),
|
|
properties.getInstanceId(),
|
|
properties.getLeaseSeconds(),
|
|
appProperties.getAppServiceDeviceCollectId())
|
|
!= 1) {
|
|
releaseLocal(c.getCollectTaskId(), hostPermit);
|
|
return null;
|
|
}
|
|
cp = mapper.selectCheckpoint(c.getCollectTaskId());
|
|
DeviceCollectApiRun run = new DeviceCollectApiRun();
|
|
run.setRunId(UUID.randomUUID().toString());
|
|
run.setCollectTaskId(c.getCollectTaskId());
|
|
run.setDeviceId(c.getDeviceId());
|
|
run.setAdapterType(c.getAdapterType());
|
|
run.setAdapterVersion(c.getAdapterVersion());
|
|
run.setStreamKey(c.getStreamKey());
|
|
run.setInstanceId(properties.getInstanceId());
|
|
run.setConfigVersion(c.getConfigVersion());
|
|
run.setFencingToken(cp.getFencingToken());
|
|
run.setTriggerType(trigger);
|
|
run.setStatus("RUNNING");
|
|
run.setScheduledAt(LocalDateTime.now(ApiPullTime.DATABASE_ZONE));
|
|
mapper.insertRun(run);
|
|
submittedRun = run;
|
|
final DeviceCollectApiCheckpoint leasedCheckpoint = cp;
|
|
// 成功提交后,许可的所有权交给工作线程并由其 finally 统一释放。
|
|
executor.execute(
|
|
() -> {
|
|
try {
|
|
// 模式可能在排队期间变化;执行 HTTP 前再次校验,避免已提交线程绕过新策略。
|
|
if (requireApiCollectionMode(c, false)) {
|
|
runner.execute(c, leasedCheckpoint, run);
|
|
} else {
|
|
run.setStatus("FAILED");
|
|
run.setErrorCode("COLLECTION_MODE_DENIED");
|
|
run.setErrorSummary("设备采集模式不允许 API_PULL");
|
|
run.setFinishedAt(LocalDateTime.now(ApiPullTime.DATABASE_ZONE));
|
|
mapper.updateRun(run);
|
|
mapper.releaseLease(
|
|
c.getCollectTaskId(), properties.getInstanceId(), leasedCheckpoint.getFencingToken());
|
|
}
|
|
} finally {
|
|
releaseLocal(c.getCollectTaskId(), hostPermit);
|
|
}
|
|
});
|
|
return run;
|
|
} catch (Throwable e) {
|
|
if (submittedRun != null) {
|
|
submittedRun.setStatus("FAILED");
|
|
submittedRun.setErrorCode("SCHEDULER_REJECTED");
|
|
submittedRun.setErrorSummary("任务未能提交到本地执行器");
|
|
submittedRun.setFinishedAt(LocalDateTime.now(ApiPullTime.DATABASE_ZONE));
|
|
mapper.updateRun(submittedRun);
|
|
}
|
|
if (cp != null && cp.getFencingToken() != null)
|
|
mapper.releaseLease(c.getCollectTaskId(), properties.getInstanceId(), cp.getFencingToken());
|
|
releaseLocal(c.getCollectTaskId(), hostPermit);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 调度扫描遇到禁止 API 的设备时推进 next_run_at,避免同一到期任务按 5 秒扫描周期空转。
|
|
*/
|
|
private boolean requireApiCollectionMode(DeviceCollectApiConfig c, boolean failRequest) {
|
|
DeviceContextResolver.Resolution resolution = deviceResolver.resolveDeviceId(c.getDeviceId());
|
|
if (resolution.isRegistered()) return true;
|
|
// 非归属节点不能修改任务调度时间;设备归属可能在扫描后、执行前发生变化。
|
|
// 手工触发失败同样不应改变正常调度计划。
|
|
if (!failRequest && resolution.getType() != DeviceContextResolver.Type.NOT_ASSIGNED) {
|
|
mapper.scheduleNext(c.getCollectTaskId(), c.getConfigVersion(), c.getPollIntervalSeconds());
|
|
}
|
|
String reason = resolution.getReason() == null ? "设备不允许 API_PULL" : resolution.getReason();
|
|
log.warn("设备 API 拉取已被设备策略跳过: taskId={}, deviceId={}, type={}, reason={}",
|
|
c.getCollectTaskId(), c.getDeviceId(), resolution.getType(), reason);
|
|
if (failRequest) {
|
|
String code = resolution.getType() == DeviceContextResolver.Type.COLLECTION_MODE_DENIED
|
|
? "COLLECTION_MODE_DENIED" : "DEVICE_CONTEXT_DENIED";
|
|
throw new ApiPullException(code, reason, org.springframework.http.HttpStatus.CONFLICT);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private void releaseLocal(Integer taskId, Semaphore host) {
|
|
if (host != null) host.release();
|
|
global.release();
|
|
localRunning.remove(taskId);
|
|
}
|
|
|
|
@PreDestroy
|
|
public void shutdown() {
|
|
// 等待在途窗口正常收尾;超时中断时,未提交的 checkpoint 会使该窗口下次重新拉取。
|
|
if (executor == null) return;
|
|
executor.shutdown();
|
|
try {
|
|
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) executor.shutdownNow();
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
executor.shutdownNow();
|
|
}
|
|
}
|
|
}
|