先日、夜の 20:00 ちょうどに複数のタスクが一斉に着火して、共有している /tmp の空き容量とレート上限を奪い合った結果、後から起動した処理が clone の途中で息切れした夜がありました。私自身、複数サイトの自動投稿を Cowork のスケジュールタスクに任せているのですが、一本ずつ追加していくうちに「気づけば夜の同じ時刻に何本も重なっていた」という状態に、いつの間にか陥っていたのです。
厄介なのは、個々のタスクは正常に見えることです。cron はそれぞれ意図どおりに動いています。問題は「全部を並べたとき、ある一分に何本が同時に走り出すか」という、俯瞰しないと見えない性質のほうにありました。以下では、その俯瞰を人間の勘ではなく cron 式から機械的に取り出し、収益に効くプレミアム枠は一切動かさずにピークだけを削る、という順序で、個人開発の現場から設計していきます。
cron を「時と分」に展開して、同じ分に着火する本数を数える
まず必要なのは「どのタスクが、1日のうちどの分に着火するか」を全タスクぶん展開することです。Cowork のスケジュールタスクは 分 時 日 月 曜日 の 5 フィールド cron で表現されるので、そこから着火分(0〜1439 の通し番号)を取り出します。
実運用の cron はほとんどが数値かカンマ列(例: 0 9,20 * * *)なので、まずはそこに絞ると読み間違いが減ります。範囲やステップを足すのは、この土台が正しく動いてからで十分です。
type Task = { id: string; cron: string; durationMin: number; protected: boolean };
// "0,30" や "9,20" のようなフィールドを数値配列に展開する
function parseField(field: string, min: number, max: number): number[] {
if (field === "*") {
const all: number[] = [];
for (let v = min; v <= max; v++) all.push(v);
return all;
}
return field.split(",").map((s) => {
const n = Number(s);
if (!Number.isInteger(n) || n < min || n > max) {
throw new Error(`cron フィールドの値が不正です: "${s}"`);
}
return n;
});
}
// 1日分の着火時刻(分の通し番号 0..1439)を返す。
// 日・月・曜日は「毎日走る」前提でここでは無視し、まず時と分だけで
// 日次の並行度を見る(多くの生成タスクが毎日か特定曜日のため)。
function fireMinutes(cron: string): number[] {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error(`cron のフィールド数が5ではありません: "${cron}"`);
}
const [mField, hField] = parts;
const minutes = parseField(mField, 0, 59);
const hours = parseField(hField, 0, 23);
const out: number[] = [];
for (const h of hours) for (const m of minutes) out.push(h * 60 + m);
return [...new Set(out)].sort((a, b) => a - b);
}これで各タスクの着火分が得られたので、「同じ分に何本着火するか」の地図を作ります。
// 着火分 -> その分に起動するタスク ID の一覧
function collisionMap(tasks: Task[]): Map<number, string[]> {
const map = new Map<number, string[]>();
for (const t of tasks) {
for (const fm of fireMinutes(t.cron)) {
const list = map.get(fm) ?? [];
list.push(t.id);
map.set(fm, list);
}
}
return map;
}
function toHHMM(minute: number): string {
const h = String(Math.floor(minute / 60)).padStart(2, "0");
const m = String(minute % 60).padStart(2, "0");
return `${h}:${m}`;
}collisionMap のうち値の長さが 2 以上の分だけを拾えば、「同じ分に着火するタスク群」が一覧になります。ここで私が最初につまずいたのは、この「同時着火の本数」を並行度そのものだと思い込んでいたことでした。実際にはこれは入口にすぎません。
「重なり」と「同時に走っている数」は別物です
着火が同じ分でなくても、実行に時間がかかるタスク同士は走行中に重なります。19:45 に始まる処理が 20 分かかれば、20:00 ちょうどに始まる別の処理と 20:00〜20:05 で並走します。つまり本当に見たいのは「着火の衝突」ではなく「ある瞬間に何本が同時に走っているか(並行度)」です。
各タスクの実行を [着火分, 着火分 + 実行分) の区間として並べ、掃引線(sweep line)で最大並行度を求めます。ここに一つ、見落としやすい落とし穴があります。同じ分で「終了」と「開始」が同時に起きたとき、それを重複と数えてしまうと並行度を過大評価します。隣り合っているだけの処理を、重なっていると誤検出してしまうのです。
type Interval = { start: number; end: number; id: string };
function runIntervals(tasks: Task[]): Interval[] {
const intervals: Interval[] = [];
for (const t of tasks) {
for (const fm of fireMinutes(t.cron)) {
intervals.push({ start: fm, end: fm + t.durationMin, id: t.id });
}
}
return intervals;
}
function peakConcurrency(intervals: Interval[]): { peak: number; atMinute: number } {
const events: { minute: number; delta: number }[] = [];
for (const iv of intervals) {
events.push({ minute: iv.start, delta: +1 });
events.push({ minute: iv.end, delta: -1 });
}
// 同一分では終了(-1)を開始(+1)より先に処理する。
// こうしないと「20:05 に終わる処理」と「20:05 に始まる処理」を
// 同時走行と誤って数えてしまう。
events.sort((a, b) => a.minute - b.minute || a.delta - b.delta);
let cur = 0;
let peak = 0;
let atMinute = 0;
for (const e of events) {
cur += e.delta;
if (cur > peak) {
peak = cur;
atMinute = e.minute;
}
}
return { peak, atMinute };
}delta の昇順ソートで -1 が +1 より先に来る点が肝です。区間を半開区間 [start, end) で扱っているのと辻褄が合い、隣接する処理を並走と数えません。この落とし穴を回避するには、必ず終了イベントを先に処理します。この一行の順序を間違えると、ピークが実態より 1〜2 本多く見えてしまい、必要のないタスクまで動かす判断につながります。本番運用に足す計測ほど、こうした境界条件で静かにズレていないかを疑う価値があります。