【PL/SQL】ジョブ実行履歴の集中管理と異常検知(DBMS_SCHEDULER+ロギング)

【PL/SQL】ジョブ実行履歴の集中管理と異常検知(DBMS_SCHEDULER+ロギング) PL/SQL

ジョブを安定運用するうえで最も重要なのは「いつ・何が・どれくらい動いて・成功か失敗か・遅延や長時間化はないか」を一元的に把握できることだ。DBMS_SCHEDULERはデフォルトでも実行履歴ビューを提供するが、アプリ都合の識別子やSLA、入力パラメータ、ビジネス件数などを含めた“運用に効く粒度”での集中ロギングを別途設計しておくと、異常検知・原因追跡・リカバリが格段に速くなる。この記事では、ジョブ実行のラッピングと自治ロギング、ヘルスチェック用ハートビート、遅延・超過・未実行の検知クエリ、SLA監視ビュー化、再実行とサマリの自動化までを、一貫したコードで示す。

集中ロギングの土台を作る(メタ・実行・ハートビート)

ジョブ定義と運用メトリクスを分けて記録する。メタは期待スケジュールやSLA、実行は開始・終了・結果コードや件数、ハートビートは長時間ジョブの生存信号を残す。まずは最小のスキーマから始める。

CREATE TABLE job_meta (
  job_name        VARCHAR2(128) PRIMARY KEY,
  cron_expr       VARCHAR2(100),              -- 期待スケジュール(参考用)
  sla_seconds     NUMBER,                     -- 期待完了時間
  enabled_flag    CHAR(1) DEFAULT 'Y',        -- 運用ON/OFF
  notify_email    VARCHAR2(320)               -- 通知先(任意)
);

CREATE TABLE job_run (
  run_id          NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  job_name        VARCHAR2(128) NOT NULL,
  corr_id         VARCHAR2(64),               -- 相関ID(APIやバッチ全体で共有)
  started_at      TIMESTAMP DEFAULT SYSTIMESTAMP,
  finished_at     TIMESTAMP,
  status          VARCHAR2(30),               -- SUCCESS/ERROR/TIMEOUT/CANCELLED
  error_code      NUMBER,
  error_message   VARCHAR2(4000),
  input_args      CLOB,                       -- 文字列 or JSON
  output_stats    CLOB                        -- 処理件数などをJSONで
);

CREATE TABLE job_heartbeat (
  job_name        VARCHAR2(128) NOT NULL,
  run_id          NUMBER        NOT NULL,
  beat_at         TIMESTAMP     DEFAULT SYSTIMESTAMP,
  note            VARCHAR2(200),
  CONSTRAINT pk_job_heartbeat PRIMARY KEY(job_name, run_id, beat_at)
);

ロギングを一元化するパッケージ(自治トランザクションで確実に確定)

実ジョブの成功・失敗にかかわらず履歴が残るよう、記録系はAUTONOMOUS_TRANSACTIONで独立させる。開始・進捗・終了の三つを用意しておけば十分だ。

CREATE OR REPLACE PACKAGE job_log AS
  FUNCTION start_run(p_job VARCHAR2, p_corr VARCHAR2 := NULL, p_args CLOB := NULL) RETURN NUMBER;
  PROCEDURE beat(p_run_id NUMBER, p_job VARCHAR2, p_note VARCHAR2 := NULL);
  PROCEDURE finish_ok(p_run_id NUMBER, p_stats CLOB := NULL);
  PROCEDURE finish_err(p_run_id NUMBER, p_code NUMBER, p_msg VARCHAR2);
END job_log;
/
CREATE OR REPLACE PACKAGE BODY job_log AS
  FUNCTION start_run(p_job VARCHAR2, p_corr VARCHAR2, p_args CLOB) RETURN NUMBER IS
    PRAGMA AUTONOMOUS_TRANSACTION;
    v_id NUMBER;
  BEGIN
    INSERT INTO job_run(job_name, corr_id, input_args) VALUES(p_job, p_corr, p_args)
    RETURNING run_id INTO v_id;
    COMMIT;
    RETURN v_id;
  END;
  PROCEDURE beat(p_run_id NUMBER, p_job VARCHAR2, p_note VARCHAR2) IS
    PRAGMA AUTONOMOUS_TRANSACTION;
  BEGIN
    INSERT INTO job_heartbeat(job_name, run_id, note) VALUES(p_job, p_run_id, p_note);
    COMMIT;
  END;
  PROCEDURE finish_ok(p_run_id NUMBER, p_stats CLOB) IS
    PRAGMA AUTONOMOUS_TRANSACTION;
  BEGIN
    UPDATE job_run
       SET finished_at = SYSTIMESTAMP, status = 'SUCCESS', output_stats = p_stats
     WHERE run_id = p_run_id;
    COMMIT;
  END;
  PROCEDURE finish_err(p_run_id NUMBER, p_code NUMBER, p_msg VARCHAR2) IS
    PRAGMA AUTONOMOUS_TRANSACTION;
  BEGIN
    UPDATE job_run
       SET finished_at = SYSTIMESTAMP, status = 'ERROR', error_code = p_code,
           error_message = SUBSTR(p_msg,1,4000)
     WHERE run_id = p_run_id;
    COMMIT;
  END;
END job_log;
/

ビジネス処理をラップする実行テンプレート(例外を握らず記録して再送可能に)

運用に乗せるすべてのジョブは共通ラッパを通す。相関IDを発行し、開始→定期ハートビート→終了の流れを統一する。失敗時は例外をロギングして再送判断は上位へ返す。

CREATE OR REPLACE PROCEDURE run_monthly_agg(p_ym IN DATE) IS
  v_run   NUMBER;
  v_corr  VARCHAR2(64) := RAWTOHEX(SYS_GUID());
  v_done  NUMBER := 0;
BEGIN
  v_run := job_log.start_run('RUN_MONTHLY_AGG',
                             p_corr => v_corr,
                             p_args => JSON_OBJECT('ym' VALUE TO_CHAR(TRUNC(p_ym,'MM'),'YYYY-MM') RETURNING CLOB));
  DBMS_APPLICATION_INFO.SET_MODULE('JOB','RUN_MONTHLY_AGG');
  DBMS_SESSION.SET_IDENTIFIER(v_corr);

  -- 集計の例(チャンクごとにハートビート)
  FOR r IN (SELECT /*+ PARALLEL(4) */ cust_id FROM mv_sales_cust_m WHERE ym = TRUNC(p_ym,'MM')) LOOP
    -- ここで集計や書き戻しを行う(省略)
    v_done := v_done + 1;
    IF MOD(v_done, 10000)=0 THEN job_log.beat(v_run, 'RUN_MONTHLY_AGG', 'rows='||v_done); END IF;
  END LOOP;

  job_log.finish_ok(v_run, JSON_OBJECT('rows' VALUE v_done RETURNING CLOB));
EXCEPTION
  WHEN OTHERS THEN
    job_log.finish_err(v_run, SQLCODE, SQLERRM);
    RAISE;
END;
/

DBMS_SCHEDULERのジョブ定義と引数受け渡し(スケジュールとアドホック両方)

定期実行はスケジュールへ、臨時実行はジョブ引数で受ける。プロシージャ引数を使うパターンに統一すると履歴の比較が容易になる。

BEGIN
  DBMS_SCHEDULER.CREATE_JOB(
    job_name        => 'JOB_MONTHLY_AGG',
    job_type        => 'STORED_PROCEDURE',
    job_action      => 'RUN_MONTHLY_AGG',
    start_date      => SYSTIMESTAMP,
    repeat_interval => 'FREQ=DAILY;BYHOUR=2;BYMINUTE=0;BYSECOND=0',
    enabled         => TRUE);
END;
/
-- アドホック実行例(特定月のみ)
BEGIN
  RUN_MONTHLY_AGG(p_ym => DATE '2025-10-01');
END;
/

遅延・長時間化・未実行の異常検知(SLAを満たさないケースをSQLで抽出)

メタのSLAとrun履歴を結び、閾値超過や次回予定時刻からの遅延を検知する。最小限のビューを用意するとダッシュボード作成が容易になる。

CREATE OR REPLACE VIEW v_job_health AS
SELECT m.job_name,
       m.sla_seconds,
       (SELECT MAX(started_at) FROM job_run r WHERE r.job_name = m.job_name) AS last_start,
       (SELECT MAX(finished_at) FROM job_run r WHERE r.job_name = m.job_name) AS last_finish,
       (SELECT MAX(status) KEEP (DENSE_RANK LAST ORDER BY started_at)
          FROM job_run r WHERE r.job_name = m.job_name) AS last_status,
       CASE
         WHEN (SELECT MAX(finished_at) FROM job_run r WHERE r.job_name=m.job_name) IS NULL
           THEN NULL
         ELSE EXTRACT(SECOND FROM NUMTODSINTERVAL(
                (SELECT (MAX(finished_at)-MAX(started_at))*86400 FROM job_run r WHERE r.job_name=m.job_name),'SECOND'))
       END AS last_duration_sec
  FROM job_meta m;

-- 遅延(最後の開始が24h超空いている等、基準は用途に応じて調整)
SELECT job_name, last_start FROM v_job_health
 WHERE last_start IS NULL OR SYSTIMESTAMP - last_start > INTERVAL '1' DAY;

-- 長時間化(SLA超過の最新実行を抽出)
SELECT job_name, last_duration_sec, sla_seconds
  FROM v_job_health
 WHERE last_status = 'SUCCESS' AND last_duration_sec > sla_seconds;

-- 未実行(有効ジョブだが履歴ゼロ)
SELECT m.job_name
  FROM job_meta m
 WHERE m.enabled_flag='Y'
   AND NOT EXISTS (SELECT 1 FROM job_run r WHERE r.job_name=m.job_name);

ハングや停滞の検知(ハートビートが止まったらアラート)

実行中のrun_idで最新ビート時刻を見て、閾値を超えたら停止疑いとして扱う。ジョブが長尺でも小刻みにbeatを打てば誤検知を避けられる。

-- 実行中runの最新ビートを追跡
WITH running AS (
  SELECT r.run_id, r.job_name, r.started_at
    FROM job_run r
   WHERE r.finished_at IS NULL
),
last_beat AS (
  SELECT job_name, run_id, MAX(beat_at) AS last_beat_at
    FROM job_heartbeat GROUP BY job_name, run_id
)
SELECT x.job_name, x.run_id, NVL(b.last_beat_at, x.started_at) AS last_signal
  FROM running x LEFT JOIN last_beat b
    ON b.job_name = x.job_name AND b.run_id = x.run_id
 WHERE SYSTIMESTAMP - NVL(b.last_beat_at, x.started_at) > INTERVAL '5' MINUTE;

デフォルト履歴との突き合わせ(USER_SCHEDULER_*ビューと自前ログの整合)

DB標準履歴と自前ログの差を定期的に比較し、欠落や二重記録を検知する。ジョブ異常時の一次情報は標準ビューに残るため、相互参照を用意すると原因特定が速い。

-- 最新失敗の要約
SELECT job_name, status, additional_info, log_date
  FROM user_scheduler_job_run_details
 WHERE status <> 'SUCCEEDED'
 ORDER BY log_date DESC FETCH FIRST 20 ROWS ONLY;

-- 自前ログとの突き合わせ(近時刻の相関)
SELECT d.job_name, d.status, r.status AS custom_status, d.log_date, r.started_at
  FROM user_scheduler_job_run_details d
  JOIN job_run r
    ON r.job_name = d.job_name
   AND ABS((CAST(d.log_date AS DATE) - CAST(r.started_at AS DATE))*86400) < 60;

月次サマリと再実行支援(集計ビューと再送API)

運用レビュー用にジョブ別・日別の成功率と平均時間をまとめ、失敗runの再実行入口を用意する。再実行は冪等性前提で入力引数をそのまま渡す。

CREATE OR REPLACE VIEW v_job_daily AS
SELECT job_name,
       CAST(TRUNC(started_at) AS DATE) AS d,
       COUNT(*) AS runs,
       SUM(CASE WHEN status='SUCCESS' THEN 1 ELSE 0 END) AS ok,
       ROUND(AVG((finished_at - started_at)*86400)) AS avg_sec
  FROM job_run
 GROUP BY job_name, TRUNC(started_at);

-- 失敗runの再実行(引数を再利用)
CREATE OR REPLACE PROCEDURE retry_failed_run(p_run_id NUMBER) IS
  v_job  job_run.job_name%TYPE;
  v_args CLOB;
  v_ym   DATE;
BEGIN
  SELECT job_name, input_args INTO v_job, v_args FROM job_run WHERE run_id = p_run_id;
  IF v_job = 'RUN_MONTHLY_AGG' THEN
    SELECT TO_DATE(JSON_VALUE(v_args,'$.ym'), 'YYYY-MM') INTO v_ym FROM dual;
    RUN_MONTHLY_AGG(v_ym);
  ELSE
    RAISE_APPLICATION_ERROR(-20001, 'unsupported job for retry');
  END IF;
END;
/

保持期間とアーカイブ(テーブル肥大化を避けて軽量運用)

履歴はSLAに沿って保持し、古い行は週次アーカイブへ退避する。サイズと参照頻度のバランスで日次・月次に分けてもよい。最小の運用タスク例を示す。

BEGIN
  DELETE FROM job_heartbeat WHERE beat_at < SYSTIMESTAMP - INTERVAL '30' DAY;
  DELETE FROM job_run       WHERE started_at < SYSTIMESTAMP - INTERVAL '180' DAY;
  COMMIT;
END;
/

まとめ

DBMS_SCHEDULERが提供する標準履歴は強力だが、運用の意思決定にはドメイン依存のメタと相関ID、入力・件数、SLA準拠状況を一体で見える化する専用ロギングが不可欠になる。開始・ビート・終了を自治トランザクションで確実に刻み、遅延・超過・未実行・停止疑いをSQLだけで抽出できる形に整えれば、ダッシュボードや通知は薄く実装できる。再実行は冪等性を前提に引数を保存し、標準ビューと自前ログの突き合わせで一次情報と運用指標を両立させる。これらをテンプレート化してすべてのジョブに適用すれば、履歴の集中管理と異常検知は“仕組み”として回り続け、運用の暗黙知から確かなデータ駆動へと移行できる。