リビジョンによる楽観的排他制御
kintone を業務で本格的に活用していると、「複数のユーザーが同じレコードを同時に更新してしまい、一方の変更が消えてしまった」という問題に遭遇することがあります。
kintone REST API には**リビジョン(revision)という仕組みが用意されており、これを活用することでデータの競合を検知し、意図しない上書きを防ぐことができます。この仕組みを楽観的排他制御(楽観ロック)**と呼びます。
この記事では、revision の仕組みと、競合を安全に処理する実装パターンを解説します。
リビジョン(revision)とは
リビジョンは、レコードのバージョン番号です。レコードが更新されるたびに、リビジョン番号が 1 ずつ増加します。
{
"$revision": {
"type": "__REVISION__",
"value": "5"
}
}
- レコード作成時: リビジョンは
1 - レコード更新のたびに: リビジョンが
+1される - 削除済みレコード: リビジョンは参照不可
リビジョンはレコード単位で管理されます。アプリ全体のバージョンではなく、個々のレコードごとに独立したリビジョン番号を持ちます。
楽観的排他制御の仕組み
楽観的排他制御は、以下の流れで動作します。
- レコードを取得する(このとき リビジョン
5を記憶) - ユーザーがレコードを編集する
- 更新リクエストを送信する際に
revision: 5を指定する - kintone 側で現在のリビジョンと照合する
- 現在のリビジョンが
5の場合 → 更新成功(リビジョンが6になる) - 現在のリビジョンが
6以上の場合 → エラー(他のユーザーが先に更新した)
- 現在のリビジョンが
(() => {
'use strict';
/**
* リビジョンを指定してレコードを更新する(楽観的排他制御)
* @param { Object } params
* @param { string | number } params.app - アプリID
* @param { string | number } params.id - レコードID
* @param { Record<string, any> } params.record - 更新するフィールド
* @param { string | number } params.revision - リビジョン番号
* @returns { Promise<{ revision: string }> }
*/
const updateRecord = (params) => {
const { app, id, record, revision } = params;
return kintone.api(kintone.api.url('/k/v1/record.json', true), 'PUT', {
app,
id,
record,
revision, // リビジョンを指定
});
};
kintone.events.on(['app.record.detail.show'], async (event) => {
const appId = kintone.app.getId();
const recordId = event.recordId;
// Step 1: 現在のレコードを取得(リビジョンを記憶)
const { record } = await kintone.api(
kintone.api.url('/k/v1/record.json', true),
'GET',
{ app: appId, id: recordId }
);
const currentRevision = record['$revision'].value;
console.log('現在のリビジョン:', currentRevision);
// Step 2: リビジョンを指定して更新(他のユーザーが更新済みならエラーになる)
try {
const result = await updateRecord({
app: appId,
id: recordId,
record: {
ステータス: { value: '確認済み' },
},
revision: currentRevision,
});
console.log('更新成功。新しいリビジョン:', result.revision);
} catch (error) {
if (error.code === 'GAIA_CO02') {
console.error('競合が発生しました。他のユーザーがレコードを更新しています。');
} else {
console.error('更新に失敗しました:', error);
}
}
return event;
});
})();
revision を指定しない場合の動作
revision パラメータを省略、または -1 を指定すると、リビジョンのチェックをスキップして強制的に更新します。
// リビジョンチェックなし(強制更新)
await kintone.api(kintone.api.url('/k/v1/record.json', true), 'PUT', {
app: appId,
id: recordId,
record: {
ステータス: { value: '完了' },
},
// revision を省略、または revision: -1 を指定
});
revision
を省略すると、他のユーザーの変更が上書きされる可能性があります。複数ユーザーが同時に操作する可能性があるアプリでは、明示的にリビジョンを指定して競合を検知することをお勧めします。
revision を指定すべきケース / しなくてよいケース
| ケース | revision の指定 | 理由 |
|---|---|---|
| ユーザーの操作に基づくレコード更新 | ✅ 指定する | 他のユーザーとの競合を防ぐため |
| バッチ処理での一括更新 | ⚠️ 状況次第 | 排他制御の要件に応じて判断 |
| システムによるログ書き込み | ❌ 省略可 | 常に最新値に追記する形であれば競合の問題が少ない |
| ステータスのみの更新(後勝ちで問題ない) | ❌ 省略可 | 最後の更新が正となる仕様であれば不要 |
競合時のリトライ処理
競合が発生した場合に、最新のレコードを再取得してリトライする実装パターンです。
/**
* 競合時にリトライする更新関数
* @param { Object } params
* @param { string | number } params.app - アプリID
* @param { string | number } params.id - レコードID
* @param { (record: Record<string, any>) => Record<string, any> } params.updateFn - 更新内容を返す関数
* @param { number } [params.maxRetries=3] - 最大リトライ回数
* @returns { Promise<{ revision: string }> }
*/
const updateWithRetry = async ({ app, id, updateFn, maxRetries = 3 }) => {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// 最新のレコードを取得
const { record } = await kintone.api(
kintone.api.url('/k/v1/record.json', true),
'GET',
{ app, id }
);
const revision = record['$revision'].value;
const updatedFields = updateFn(record);
try {
// リビジョンを指定して更新を試行
const result = await kintone.api(
kintone.api.url('/k/v1/record.json', true),
'PUT',
{ app, id, record: updatedFields, revision }
);
return result;
} catch (error) {
if (error.code === 'GAIA_CO02' && attempt < maxRetries) {
console.warn(`競合を検知しました。リトライします(${attempt + 1}/${maxRetries})`);
continue;
}
throw error;
}
}
};
使用例
(() => {
'use strict';
kintone.events.on(['app.record.detail.show'], async (event) => {
const appId = kintone.app.getId();
const recordId = event.recordId;
try {
const result = await updateWithRetry({
app: appId,
id: recordId,
updateFn: (record) => {
// 現在の値に基づいて更新内容を決定
const currentCount = Number(record['閲覧回数'].value) || 0;
return {
閲覧回数: { value: String(currentCount + 1) },
};
},
maxRetries: 3,
});
console.log('更新成功:', result);
} catch (error) {
console.error('更新に失敗しました:', error);
}
return event;
});
})();
リトライ処理では、updateFn
に「現在のレコード値を受け取り、更新内容を返す関数」を渡すことで、リトライのたびに最新の値に基づいた更新内容を再計算できます。これにより、競合後のリトライでも正しい値を設定できます。
複数レコードの更新における revision
records.json(複数件更新)でも、レコードごとに revision を指定できます。
/**
* 複数レコードをリビジョン付きで更新する
* @param { Object } params
* @param { string | number } params.app - アプリID
* @param { Array<{ id: string | number, record: Record<string, any>, revision: string | number }> } params.records
* @returns { Promise<{ records: Array<{ id: string, revision: string }> }> }
*/
const updateRecords = (params) => {
return kintone.api(kintone.api.url('/k/v1/records.json', true), 'PUT', {
app: params.app,
records: params.records.map(({ id, record, revision }) => ({
id,
record,
revision,
})),
});
};
// 使用例: 既存のレコードを取得し、リビジョン付きで一括更新
const updateAllStatus = async (appId, query, newStatus) => {
const { records } = await kintone.api(
kintone.api.url('/k/v1/records.json', true),
'GET',
{ app: appId, query, fields: ['$id', '$revision'] }
);
if (records.length === 0) return;
await updateRecords({
app: appId,
records: records.map((record) => ({
id: record['$id'].value,
revision: record['$revision'].value,
record: { ステータス: { value: newStatus } },
})),
});
};
複数件更新で 1 件でもリビジョン不一致があると、リクエスト全体がエラーになります。一部だけ更新に成功するということはありません。
まとめ
- **リビジョン(revision)**はレコードのバージョン番号で、更新のたびに 1 ずつ増加する
- 更新リクエストで
revisionを指定することで、楽観的排他制御(競合検知)が実現できる - 競合が発生すると
GAIA_CO02エラーが返されるため、最新レコードを再取得してリトライする処理が有効 revisionを省略または-1を指定すると、リビジョンチェックをスキップして強制更新される- 複数レコード更新(
records.json)でもレコードごとにリビジョンを指定でき、1 件でも不一致があればリクエスト全体がエラーになる