🍊

Pleasanter のサーバスクリプトで自サイト・自レコードを更新するときに注意すること

2022/12/15に公開

2022 個人アドベントカレンダー の記事です。

伝えたいこと

  • ユーザの編集に連動して、サーバスクリプトでそのレコード自身のデータ加工をする場合には、基本的に「更新前」を使おうというと
  • 「更新後」の使いどころとその対応方法

課題

サーバスクリプトが発動する条件には

  • 作成前/更新前

  • 作成後/更新後

がある。(以下、更新だけで考える)

このとき、更新されたデータそのものを加工する処理を api_modelUpdate でやると、その処理自身がスクリプトをトリガーしてしまい、多重に処理を繰り返してしまっている。そして場合によってはそのことに気付かず負荷だけ発生させている可能性がある。

具体的に起こる問題

「日付 A と日付 B の差分が何分であるかを数値 A に入れる」処理を考えます。

この例は、日付の計算はプロセスや計算式では上手く求められないこと、数値に保持することで例えば何らかの対応にかかった時間を集計・分析するなどユースケースが考えられなくはないこと、から選定しています。

  • 更新前で処理する場合

次のように処理できます。

const timeSpanInMinutes = (begin, end) =>
  Math.floor((end.getTime() - begin.getTime()) / 1000 / 60);

try {
  const diff = timeSpanInMinutes(model.DateA, model.DateB);
  model.NumA = diff;
} catch (e) {
  context.Log(e.message);
  context.Log(e.stack);
}
  • 更新後で処理する場合

model の UpdateOnExit を使うと ↓

const timeSpanInMinutes = (begin, end) =>
  Math.floor((end.getTime() - begin.getTime()) / 1000 / 60);

try {
  const diff = timeSpanInMinutes(model.DateA, model.DateB);
  model.NumA = diff;
  model.UpdateOnExit = true;
} catch (e) {
  context.Log(e.message);
  context.Log(e.stack);
}

api_model.Update() を使うと ↓

const timeSpanInMinutes = (begin, end) =>
  Math.floor((end.getTime() - begin.getTime()) / 1000 / 60);

try {
  const api_model = items.Get(model.ResultId || model.IssueId)[0];
  const diff = timeSpanInMinutes(api_model.DateA, api_model.DateB);
  api_model.NumA = diff;
  api_model.Update();
} catch (e) {
  context.Log(e.message);
  context.Log(e.stack);
}

となります。

起こること

更新後で model.UpdateOnExit を利用した場合、画面が応答しなくなります。
これは、無限ループが発生しているからです。

正確に、無限かは分かりませんが例えば model.NumB += 1 のようなコードを付けてカウントさせると demo.pleasanter.org で 1300 程度まで加算されることを確認しています。
同じことを更新後で api_model.Update() のほうでもやるとこちらは、カウンターは 5 で止まります。

これは内部的に API を利用する繰り返しの処理が遮断されたのだと思いますが、本質的には両者は同じ問題を持っていて、更新をトリガーにしてスクリプト処理をすると、スクリプトが更新をトリガーするので、自分で自分を発動させる無限ループ構造が生じています。

特に api_model.Update() のものは、一定回数で中断させられるために diff のような繰り返しの処理において同じ値を代入すると、多重処理をしていることに気付かない危険があります。

このやりかたでスクリプトを作ると、自レコードの数値自身をインクリメントしたり、作成後に同じサイトの別レコードを作成するという構造的に自己参照性のある処理をしたときに、1 足すはずが 5 足している、1 レコード複製するはずが N レコード複製される、というバグっぽい挙動として提示されます。[1]

日付に関する副次的な問題

また api_items は JSON 文字列をパラメータとして受け取ること、サーバスクリプトでは日付項目のタイムゾーンが UTC となること(ユーザのプロファイルのタイムゾーンと異なる)ことから、日付を計算して、文字列化して書き戻す、ということをすると、特定の時刻に実行したときだけ日付がずれるといった、発生条件や原因を追求しづらいバグを生じかねません。

サーバスクリプトによるデータ加工方法

model の正しい理解

model はレコードのデータそのものを表現しています。
このため model を適切に加工すると、入出力されるデータを適切にハンドルできます。

このため、例えば画面表示の前や行表示の前といったデータを取り出す処理で model に代入すると、画面や API、 CSV でのエクスポートに一貫して動的な値(例えば、現在時点までの経過時間など)を、データベースにデータ保持することなく、アウトプットできます。
(なお、画面表示には使えますが、データ保持されないので検索ができないことにご注意ください)

また、作成前や更新前で model を加工すれば、加工後の値がスクリプト終了後にデータ記録されるので、データベースに加工した値がそのまま保持されます。

サーバスクリプトの条件の正しい理解

サーバスクリプトの条件は、イベントに対して発動する条件なので、イベントを作動させたのが、ユーザの画面操作であるのか、スクリプト処理であるのか、API 呼び出しであるのか、を問いません。[2]

条件を指定する際には、それがいつ発動するものなのか、フローチャートを想定して選定する必要があります。

たとえば「更新前」においても api_model.Update を自レコードに対して発動することは可能であり、この場合、更新しようとしたら、スクリプトの更新が走り、それをトリガーに更新前処理発動して、さらにスクリプトの更新が走り…といったループを形成する可能性があります。

更新前後の使い分け

自レコードにデータ加工する処理は、基本的には「更新前」で対応する必要があります。

コーナーケースとして考えるとしたら、

  • ID 値を利用した処理が必要な場合、「作成前」だと ID 値が採番されていないため正しく実行できません
  • データレコードの更新がエラーなどに妨げられずに完了したときのみ発動して欲しい処理は、性質上 "更新後" に実行する必要があります

こうしたケースでは context.UserData を利用して Update をかける前にフラグ値を埋めておき、スクリプト処理前にフラグ値がないことをもって、スクリプトが発動させたのでないことを識別して処理を実行するようなロジックを入れると良いかもしれません。

おわりに

ローカル環境ではじめて model を使って処理を書いたとき、保存が必要だよねと思って、「更新前」なのに model.UpdateOnExit をセットしたら、ブラウザは固まるし、パソコンが急に重くなるし、で焦った経験から共有してみました。

脚注
  1. この処理を print デバッグしようとして、context.Log をすると、思ったより多くのログが出る、というまた別のバグっぽい挙動を観測してしまう可能性もあります。 ↩︎

  2. これと異なる実装も可能であり、例えば Google Spreadsheet に関連付けられた Google Apps Script での OnEdit トリガーはユーザによる編集のときのみ発動します。 ↩︎

GitHubで編集を提案

Discussion