Ctrl K
ComunidadGuías…

src/infrastructure/state/state-mysql.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { randomUUID } from 'node:crypto';
import type { Pool } from 'mysql2/promise';
import type { AppConfig } from '../../core/config/config';
import { completeTraceEntry } from '../../core/runtime/trace-runtime';
import type { AuditEntry, Job } from '../../core/contracts/types';
import type { JobOperationalMetricsSnapshot, RuntimeStateStore } from '../../core/state/state-contracts';
import { dt, json, rowToJob } from '../../core/state/state-codec';
import { mysqlJobUpdatePlan } from './state-mysql-update-plan';
import { MysqlPoolOwner, type MysqlPoolResource } from '../mysql/pool-owner';
import type { ApprovalRequeueInput, ApprovalRequeueOutcome } from '../../core/contracts/approval-continuation';
import { approvalRequeuePlan } from '../../core/state/approval-continuation';
import { deliveryRequeuePlan } from '../../core/state/delivery-replay';
import { isOriginalExecutorDelivery, type DeliveryRequeueInput, type DeliveryRequeueOutcome } from '../../core/contracts/delivery-replay';
import { completionMatches, terminalNotifications } from '../../core/state/notification-outbox';
import { insertNotificationEvents, MysqlNotificationOutbox } from './notification-outbox-mysql';

/** mysql 后端:生产状态库。对应 sql/001_init_state.sql 的 bz_ 表。 */
export class MysqlStore implements RuntimeStateStore {
  private pool!: Pool;
  private readonly poolOwner: MysqlPoolResource;
  readonly notificationOutbox = new MysqlNotificationOutbox(() => this.pool);

  constructor(cfg: AppConfig['state']['mysql'], poolOwner?: MysqlPoolResource) {
    this.poolOwner = poolOwner ?? new MysqlPoolOwner(cfg);
  }

  /** Reuses the store's existing pool; readiness never opens a fallback connection. */
  get schemaReadinessDb(): Pool { return this.pool; }

  async init(): Promise<void> {
    this.pool = await this.poolOwner.get();
  }

  async close(): Promise<void> {
    await this.poolOwner.close();
  }

  async findByRequestId(requestId: string): Promise<Job | null> {
    const [rows] = await this.pool.query('SELECT * FROM bz_jobs WHERE request_id=? LIMIT 1', [requestId]);
    const found = (rows as Record<string, unknown>[])[0];
    return found ? rowToJob(found) : null;
  }

  async createJob(job: Job): Promise<void> {
    await this.pool.query(
      'INSERT INTO bz_jobs (job_id,request_id,status,target,profile,project,source,client_app_id,agent_session_id,on_behalf_of,thread_id,session_id,input_preview,input,dispatch,attempts,run_after,claimed_at,lease_until,report,result,raw_result,`usage`,error,metadata,callback_url,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
      [
        job.job_id, job.request_id, job.status, job.target ?? null, job.profile, job.project, job.source,
        job.client_app_id ?? null, job.agent_session_id ?? null, job.on_behalf_of ?? null, job.thread_id ?? null,
        job.session_id ?? null, job.input_preview, job.input ?? null, json(job.dispatch), job.attempts ?? 0,
        job.run_after ? dt(job.run_after) : null, job.claimed_at ? dt(job.claimed_at) : null, job.lease_until ? dt(job.lease_until) : null,
        json(job.report), json(job.result), job.raw_result ?? null,
        json(job.usage), job.error ?? null, json(job.metadata), job.callback_url ?? null,
        dt(job.created_at), dt(job.updated_at),
      ],
    );
  }

  async updateJob(jobId: string, patch: Partial<Job>): Promise<Job | null> {
    return this.writeJob(jobId, patch);
  }

  async completeJobWithNotifications(original: Job, patch: Partial<Job>, context?: { brandName?: string }): Promise<Job | null> {
    if (!['done', 'error', 'rejected'].includes(String(patch.status))) return null;
    return this.writeTerminalJob(original.job_id, patch, undefined, original, context);
  }

  private async writeTerminalJob(jobId: string, patch: Partial<Job>, expectedStatuses?: Job['status'][], original?: Job,
    context?: { brandName?: string }): Promise<Job | null> {
    const connection = await this.pool.getConnection();
    try {
      await connection.beginTransaction();
      const [rows]: any = await connection.query('SELECT * FROM bz_jobs WHERE job_id=? FOR UPDATE', [jobId]);
      const previous = rows[0] ? rowToJob(rows[0]) : null;
      if (!previous || (expectedStatuses && !expectedStatuses.includes(previous.status)) || (original && !completionMatches(previous, original))) {
        await connection.commit(); return null;
      }
      const update = mysqlJobUpdatePlan(patch, new Date().toISOString());
      await connection.query(`UPDATE bz_jobs SET ${update.assignments.join(',')} WHERE job_id=?`, [...update.values, jobId]);
      const [updatedRows]: any = await connection.query('SELECT * FROM bz_jobs WHERE job_id=?', [jobId]);
      const updated = rowToJob(updatedRows[0]);
      await insertNotificationEvents(connection, terminalNotifications(previous, updated, context));
      await connection.commit();
      return updated;
    } catch (error) { await connection.rollback().catch(() => undefined); throw error; }
    finally { connection.release(); }
  }

  async requeueApprovedJobOnce(input: ApprovalRequeueInput): Promise<ApprovalRequeueOutcome> {
    const conn = await this.pool.getConnection();
    try {
      await conn.beginTransaction();
      const [rows] = await conn.query('SELECT * FROM bz_jobs WHERE job_id=? FOR UPDATE', [input.job_id]);
      const row = (rows as any[])[0];
      const plan = approvalRequeuePlan(row ? rowToJob(row) : null, input);
      if (plan.patch) {
        const [approvals] = await conn.query('SELECT status,used_at,job_id,request_id,decision_id FROM bz_tool_approvals WHERE id=? FOR UPDATE', [input.approval_id]);
        const approval = (approvals as any[])[0];
        if (!approval || approval.status !== 'approved' || approval.job_id !== input.job_id || approval.request_id !== input.request_id
          || (approval.decision_id ?? null) !== (input.decision_id ?? null)) { await conn.commit(); return 'conflict'; }
        if (approval.used_at != null) { await conn.commit(); return 'consumed'; }
        const update = mysqlJobUpdatePlan(plan.patch, new Date().toISOString());
        await conn.query(`UPDATE bz_jobs SET ${update.assignments.join(',')},approval_continuation_keys=? WHERE job_id=?`,
          [...update.values, json(plan.patch.approval_continuation_keys), input.job_id]);
      }
      await conn.commit();
      return plan.outcome;
    } catch (error) {
      await conn.rollback().catch(() => undefined);
      throw error;
    } finally { conn.release(); }
  }

  async updateJobIfStatus(jobId: string, expectedStatuses: Array<Job['status']>, patch: Partial<Job>): Promise<Job | null> {
    if (!expectedStatuses.length) return null;
    return this.writeJob(jobId, patch, expectedStatuses);
  }

  async requeueDeliveryJobOnce(input: DeliveryRequeueInput): Promise<DeliveryRequeueOutcome> {
    const conn = await this.pool.getConnection();
    try {
      await conn.beginTransaction();
      const [rows] = await conn.query('SELECT * FROM bz_jobs WHERE job_id=? FOR UPDATE', [input.job_id]);
      const row = (rows as any[])[0];
      const job = row ? rowToJob(row) : null;
      const plan = deliveryRequeuePlan(job, input);
      if (plan.patch) {
        const [dlqs] = await conn.query('SELECT * FROM bz_delivery_dlq WHERE id=? FOR UPDATE', [input.dlq_id]);
        const dlq = (dlqs as any[])[0];
        if (!dlq || dlq.resolved || dlq.executor_replay_key !== input.replay_key || !isOriginalExecutorDelivery(dlq, job)) {
          await conn.commit(); return 'conflict';
        }
        const update = mysqlJobUpdatePlan(plan.patch, new Date().toISOString());
        await conn.query(`UPDATE bz_jobs SET ${update.assignments.join(',')},delivery_replay_keys=? WHERE job_id=?`,
          [...update.values, json(plan.patch.delivery_replay_keys), input.job_id]);
      }
      await conn.commit();
      return plan.outcome;
    } catch (error) {
      await conn.rollback().catch(() => undefined); throw error;
    } finally { conn.release(); }
  }

  private async writeJob(jobId: string, patch: Partial<Job>, expectedStatuses?: Array<Job['status']>): Promise<Job | null> {
    if (['done', 'error', 'rejected'].includes(String(patch.status))) return this.writeTerminalJob(jobId, patch, expectedStatuses);
    const plan = mysqlJobUpdatePlan(patch, new Date().toISOString());
    const whereStatus = expectedStatuses?.length ? ` AND status IN (${expectedStatuses.map(() => '?').join(',')})` : '';
    const [r]: any = await this.pool.query(
      `UPDATE bz_jobs SET ${plan.assignments.join(',')} WHERE job_id=?${whereStatus}`,
      [...plan.values, jobId, ...(expectedStatuses ?? [])],
    );
    if (r?.affectedRows !== 1) return null;
    return this.getJob(jobId);
  }

  async getJob(jobId: string): Promise<Job | null> {
    const [rows] = await this.pool.query('SELECT * FROM bz_jobs WHERE job_id=? LIMIT 1', [jobId]);
    const found = (rows as Record<string, unknown>[])[0];
    return found ? rowToJob(found) : null;
  }

  async claimNextJob(targets: string[], executorId: string, leaseMs: number): Promise<Job | null> {
    if (!targets.length) return null;
    return await this.claimQueued(targets, {
      status: 'dispatched',
      owner: executorId,
      setDispatchedAt: true,
      leaseMs,
    });
  }

  async claimNextInhubJob(targets: string[], workerId: string, leaseMs: number): Promise<Job | null> {
    if (!targets.length) return null;
    return await this.claimQueued(targets, {
      status: 'running',
      owner: workerId,
      setDispatchedAt: false,
      leaseMs,
    });
  }

  private async claimQueued(
    targets: string[],
    opts: { status: 'running' | 'dispatched'; owner: string; setDispatchedAt: boolean; leaseMs: number },
  ): Promise<Job | null> {
    const conn = await this.pool.getConnection();
    const token = randomUUID();
    const nowIso = new Date().toISOString();
    const nowS = dt(nowIso);
    const leaseUntilS = dt(new Date(Date.now() + Math.max(1, opts.leaseMs)).toISOString());
    const placeholders = targets.map(() => '?').join(',');
    try {
      await conn.beginTransaction();
      const [rows] = await conn.query(
        `SELECT * FROM bz_jobs j
          WHERE j.status='queued'
            AND j.target IN (${placeholders})
            AND (j.run_after IS NULL OR j.run_after <= ?)
            AND (
              j.thread_id IS NULL OR NOT EXISTS (
                SELECT 1 FROM bz_jobs inflight
                WHERE inflight.thread_id=j.thread_id
                  AND inflight.job_id<>j.job_id
                  AND (
                    inflight.status IN ('running','dispatched')
                    OR (
                      inflight.status='queued'
                      AND (inflight.run_after IS NULL OR inflight.run_after <= ?)
                      AND (
                        inflight.created_at < j.created_at
                        OR (inflight.created_at = j.created_at AND inflight.job_id < j.job_id)
                      )
                    )
                  )
              )
            )
          ORDER BY j.created_at ASC, j.job_id ASC LIMIT 1 FOR UPDATE SKIP LOCKED`,
        [...targets, nowS, nowS],
      );
      const row = (rows as any[])[0];
      if (!row) {
        await conn.commit();
        return null;
      }
      await conn.query(
        "UPDATE bz_jobs SET status=?, executor_id=?, claimed_at=?, dispatched_at=?, lease_until=?, claim_token=?, run_after=NULL, updated_at=? WHERE job_id=? AND status='queued'",
        [opts.status, opts.owner, nowS, opts.setDispatchedAt ? nowS : null, leaseUntilS, token, nowS, row.job_id],
      );
      const [claimedRows] = await conn.query('SELECT * FROM bz_jobs WHERE job_id=? LIMIT 1', [row.job_id]);
      await conn.commit();
      return (claimedRows as any[])[0] ? rowToJob((claimedRows as any[])[0]) : null;
    } catch (e: any) {
      await conn.rollback().catch(() => undefined);
      if (e?.code === 'ER_PARSE_ERROR' || e?.code === 'ER_NOT_SUPPORTED_YET') {
        return await this.claimQueuedFallback(targets, opts, token, nowS);
      }
      throw e;
    } finally {
      conn.release();
    }
  }

  private async claimQueuedFallback(
    targets: string[],
    opts: { status: 'running' | 'dispatched'; owner: string; setDispatchedAt: boolean; leaseMs: number },
    token: string,
    nowS: string,
  ): Promise<Job | null> {
    const placeholders = targets.map(() => '?').join(',');
    const leaseUntilS = dt(new Date(Date.now() + Math.max(1, opts.leaseMs)).toISOString());
    const [candidates] = await this.pool.query(
      `SELECT * FROM bz_jobs j
        WHERE j.status='queued'
          AND j.target IN (${placeholders})
          AND (j.run_after IS NULL OR j.run_after <= ?)
          AND (
            j.thread_id IS NULL OR NOT EXISTS (
              SELECT 1 FROM bz_jobs inflight
              WHERE inflight.thread_id=j.thread_id
                AND inflight.job_id<>j.job_id
                AND (
                  inflight.status IN ('running','dispatched')
                  OR (
                    inflight.status='queued'
                    AND (inflight.run_after IS NULL OR inflight.run_after <= ?)
                    AND (
                      inflight.created_at < j.created_at
                      OR (inflight.created_at = j.created_at AND inflight.job_id < j.job_id)
                    )
                  )
                )
            )
          )
        ORDER BY j.created_at ASC, j.job_id ASC LIMIT 1`,
      [...targets, nowS, nowS],
    );
    const row = (candidates as any[])[0];
    if (!row) return null;
    const [r]: any = await this.pool.query(
      "UPDATE bz_jobs SET status=?, executor_id=?, claimed_at=?, dispatched_at=?, lease_until=?, claim_token=?, run_after=NULL, updated_at=? WHERE job_id=? AND status='queued'",
      [opts.status, opts.owner, nowS, opts.setDispatchedAt ? nowS : null, leaseUntilS, token, nowS, row.job_id],
    );
    if (!r || r.affectedRows !== 1) return null;
    const [rows] = await this.pool.query('SELECT * FROM bz_jobs WHERE claim_token=? LIMIT 1', [token]);
    return (rows as any[])[0] ? rowToJob((rows as any[])[0]) : null;
  }

  async extendExecutorLeases(executorId: string, leaseMs: number): Promise<number> {
    const nowS = dt(new Date().toISOString());
    const leaseUntilS = dt(new Date(Date.now() + Math.max(1, leaseMs)).toISOString());
    const [r]: any = await this.pool.query(
      "UPDATE bz_jobs SET lease_until=?, updated_at=? WHERE status='dispatched' AND executor_id=?",
      [leaseUntilS, nowS, executorId],
    );
    return r?.affectedRows ?? 0;
  }

  async requeueStaleDispatched(deadAfterMs: number, hardCapMs: number): Promise<number> {
    const nowS = dt(new Date().toISOString());
    const deadCutoff = dt(new Date(Date.now() - deadAfterMs).toISOString());
    const hardCutoff = dt(new Date(Date.now() - hardCapMs).toISOString());
    const [r]: any = await this.pool.query(
      "UPDATE bz_jobs j LEFT JOIN bz_executors e ON e.executor_id=j.executor_id " +
        "SET j.status='queued', j.executor_id=NULL, j.claimed_at=NULL, j.lease_until=NULL, j.dispatched_at=NULL, j.claim_token=NULL, j.updated_at=? " +
        "WHERE j.status='dispatched' AND (" +
        "  (j.lease_until IS NOT NULL AND j.lease_until < ?) " +
        "  OR (j.lease_until IS NULL AND (e.last_seen_at IS NULL OR e.last_seen_at < ?)) " +
        "  OR j.dispatched_at < ?" +
        ")",
      [nowS, nowS, deadCutoff, hardCutoff],
    );
    return r?.affectedRows ?? 0;
  }

  async listJobsByStatus(statuses: Array<Job['status']>, olderThanMs?: number): Promise<Job[]> {
    if (!statuses.length) return [];
    const placeholders = statuses.map(() => '?').join(',');
    const params: unknown[] = [...statuses];
    let sql = `SELECT * FROM bz_jobs WHERE status IN (${placeholders})`;
    if (olderThanMs !== undefined) {
      sql += ' AND updated_at < ?';
      params.push(dt(new Date(Date.now() - olderThanMs).toISOString()));
    }
    sql += ' ORDER BY created_at ASC LIMIT 500';
    const [rows] = await this.pool.query(sql, params);
    return (rows as any[]).map((r) => rowToJob(r));
  }

  async listExpiredLeases(statuses: Array<Job['status']>, fallbackOlderThanMs: number): Promise<Job[]> {
    if (!statuses.length) return [];
    const placeholders = statuses.map(() => '?').join(',');
    const nowS = dt(new Date().toISOString());
    const fallbackCutoff = dt(new Date(Date.now() - fallbackOlderThanMs).toISOString());
    const [rows] = await this.pool.query(
      `SELECT * FROM bz_jobs WHERE status IN (${placeholders})
        AND (
          (lease_until IS NOT NULL AND lease_until < ?)
          OR (lease_until IS NULL AND updated_at < ?)
        )
        ORDER BY created_at ASC LIMIT 500`,
      [...statuses, nowS, fallbackCutoff],
    );
    return (rows as any[]).map((r) => rowToJob(r));
  }

  async expireStaleQueued(ttlMs: number): Promise<number> {
    const cutoff = dt(new Date(Date.now() - ttlMs).toISOString());
    const [rows]: any = await this.pool.query("SELECT job_id FROM bz_jobs WHERE status='queued' AND created_at<? ORDER BY created_at LIMIT 500", [cutoff]);
    let count = 0;
    for (const row of rows) if (await this.updateJobIfStatus(row.job_id, ['queued'], {
      status: 'error', error: '排队超时:执行器长时间不可用,任务已过期(避免恢复后陈旧任务全量重放)',
    })) count++;
    return count;
  }

  async countInflightByThread(threadId: number): Promise<number> {
    const [rows] = await this.pool.query("SELECT COUNT(*) AS n FROM bz_jobs WHERE thread_id=? AND status IN ('queued','running','dispatched')", [threadId]);
    return Number((rows as any[])[0]?.n ?? 0);
  }

  async operationalMetricsSnapshot(nowMs = Date.now()): Promise<JobOperationalMetricsSnapshot> {
    const nowS = dt(new Date(nowMs).toISOString());
    const terminalCutoffS = dt(new Date(nowMs - 15 * 60_000).toISOString());
    const [rows] = await this.pool.query(
      `SELECT
        COALESCE(SUM(j.status='queued'),0) AS queued,
        COALESCE(SUM(j.status='running'),0) AS running,
        COALESCE(SUM(j.status='dispatched'),0) AS dispatched,
        COALESCE(SUM(j.status='done'),0) AS done,
        COALESCE(SUM(j.status='error'),0) AS error,
        COALESCE(SUM(j.status='rejected'),0) AS rejected,
        COALESCE(SUM(j.status='done' AND j.updated_at>=?),0) AS done_15m,
        COALESCE(SUM(j.status='error' AND j.updated_at>=?),0) AS error_15m,
        COALESCE(SUM(j.status='rejected' AND j.updated_at>=?),0) AS rejected_15m,
        MIN(CASE WHEN j.status='queued' AND COALESCE(j.source,'')<>'monitor' THEN j.created_at END) AS oldest_queued_at,
        COALESCE(SUM(j.status='queued' AND j.run_after IS NOT NULL AND j.run_after>?),0) AS delayed_queued,
        COALESCE(SUM(j.status IN ('running','dispatched') AND j.lease_until IS NOT NULL AND j.lease_until<?),0) AS expired_leases,
        (
          SELECT COUNT(DISTINCT q.thread_id)
          FROM bz_jobs q
          JOIN bz_jobs i ON i.thread_id=q.thread_id AND i.status IN ('running','dispatched')
          WHERE q.status='queued' AND q.thread_id IS NOT NULL
        ) AS blocked_threads
      FROM bz_jobs j`,
      [terminalCutoffS, terminalCutoffS, terminalCutoffS, nowS, nowS],
    );
    const row = (rows as any[])[0] ?? {};
    const count = (value: unknown): number => Math.max(Number(value) || 0, 0);
    const oldestQueuedMs = row.oldest_queued_at ? new Date(row.oldest_queued_at).getTime() : Number.NaN;
    return {
      byStatus: {
        queued: count(row.queued),
        running: count(row.running),
        dispatched: count(row.dispatched),
        done: count(row.done),
        error: count(row.error),
        rejected: count(row.rejected),
      },
      terminalLast15m: {
        done: count(row.done_15m),
        error: count(row.error_15m),
        rejected: count(row.rejected_15m),
      },
      oldestQueuedAgeSeconds: Number.isFinite(oldestQueuedMs) ? Math.max((nowMs - oldestQueuedMs) / 1000, 0) : 0,
      delayedQueuedJobs: count(row.delayed_queued),
      expiredLeases: count(row.expired_leases),
      blockedThreads: count(row.blocked_threads),
    };
  }

  async acquireRuntimeLock(lockKey: string, owner: string, ttlMs: number): Promise<boolean> {
    const nowS = dt(new Date().toISOString());
    const expiresS = dt(new Date(Date.now() + Math.max(1, ttlMs)).toISOString());
    const [inserted]: any = await this.pool.query(
      'INSERT IGNORE INTO bz_runtime_locks (lock_key,owner,expires_at,updated_at) VALUES (?,?,?,?)',
      [lockKey, owner, expiresS, nowS],
    );
    if ((inserted?.affectedRows ?? 0) === 1) return true;

    const [updated]: any = await this.pool.query(
      'UPDATE bz_runtime_locks SET owner=?, expires_at=?, updated_at=? WHERE lock_key=? AND (owner=? OR expires_at<=?)',
      [owner, expiresS, nowS, lockKey, owner, nowS],
    );
    if ((updated?.affectedRows ?? 0) === 1) return true;

    // 同一秒内续租可能没有物理变更(affectedRows=0),回读 owner 区分“已持有”与“被别人持有”。
    const [rows] = await this.pool.query('SELECT owner FROM bz_runtime_locks WHERE lock_key=? LIMIT 1', [lockKey]);
    return ((rows as any[])[0]?.owner ?? '') === owner;
  }

  async releaseRuntimeLock(lockKey: string, owner: string): Promise<void> {
    await this.pool.query('DELETE FROM bz_runtime_locks WHERE lock_key=? AND owner=?', [lockKey, owner]);
  }

  async appendAudit(entry: AuditEntry): Promise<void> {
    const e = completeTraceEntry(entry);
    await this.pool.query(
      'INSERT INTO bz_audit (ts,job_id,request_id,event,stage,severity,title,summary,detail) VALUES (?,?,?,?,?,?,?,?,?)',
      [dt(e.ts), e.job_id, e.request_id, e.event, e.stage, e.severity, e.title, e.summary, json(e.detail)],
    );
  }

  async pruneAuditOlderThan(cutoffIso: string): Promise<number> {
    const [r]: any = await this.pool.query('DELETE FROM bz_audit WHERE ts < ?', [dt(cutoffIso)]);
    return r?.affectedRows ?? 0;
  }
}