kintone REST API レコード一括取得(/k/v1/records.json)のoffset上限が10,000に制限されてもなんとかしてみる(既存処理にも対応)

2020年7月より、レコード一括取得APIの「offset」の上限が10,000に制限されます。kintone API レコード一括取得APIのoffsetの上限値制限について(2019/10/30更新)

なんで仕様が変わるのか

レコード一括取得APIでoffsetに大きな値を指定した場合、サーバーに非常に高い負荷がかかるのでkintoneのサービス全体の負荷を軽減し、対障害性や安定運営に寄与することを目的とした仕様変更とのことです。

レコード規模の大きい処理において、パフォーマンスの問題はつきまといます。
今回、「レコードID順の取得方式は高速である」ことを明示してもらえたことはこの問題に対しての非常に大きな回答だったと思います。

/k/v1/records.json がoffsetをパラメーターとして持てる時点で、kintone内でうまくやってもらえたら嬉しいところではありましたが、今後 /k/v1/records.json をどう使っていくべきか、をまとめてみたいと思います。

offsetが10,000を超えるということ

一意の条件で抽出対象レコードが10,000件を超えるときに起こり得ます。
現在のkintone使用規模を見ていったときに限定的な状況ではありません。

  • 数万件の顧客リストから独自条件でレコードを取得する
  • 集計処理

などで、レコード数によっては必要な処理です。

サイボウズが提示する回避策

offset の制限値を考慮したレコード一括取得について

  • 方法1: レコード ID を利用する方法
  • 方法2: カーソル API を利用する方法
  • 方法3: offset を利用する方法

が提案されています。
方法2の新たに提供された cursor.json については、既存のレコード一括取得処理に対する置き換えは難しい仕様です。(当記事では詳細を割愛します)
方法3は既存の方法です。「10,000件以上は取得しないことが許容される場合」にしか使えなくなる、という説明ですね。

「レコードが何件あっても取得できる処理」を前提にしたとき、方法1が実質的な、今後使用していくべきレコードの一括取得方法、ということになります。

10,000件以内で絶対に収まる処理については、既存通り方法3でoffsetを使えば良いことになりますが、後述するソートの問題や、9,999件のレコードの一括取得はどちらの方法を採るべきか、などを考えたときに、ID順の取得方式に統一しておくという考え方も一つだと思います。

レコードID順に取得するのでソートができない

書かれている通り、方法1は

この方法は、次のようなシナリオに適しています。

  • レコードのソート条件を必要としない場合(レコード ID 順で問題ない場合)
  • レコードID 順にレコードを取得した後、プログラムのロジックで別のソートができる場合

が前提条件です。

実は一番問題になるのはこのソートの部分です。

kintoneのソートとJavaScriptのソートは必ずしも一致しないので
いずれにしても、
「これまで10,000件以上のレコードにソート条件を付与して処理していた」ケースについては
これまでと完全に一致したソートをかけるのは不可能になることもある(cursor.json方式を採らない場合)、というのがもう一つの結論です。

あるいは、同一のアプリに対しての異なる処理下でソート条件を統一しなければならない場合、
一方が10,000件以内の処理であっても、他方が10,000件以上である時点で、
前者の処理についても取得後にJavaScript側でソートをかけ直す必要があるかもしれません。

じゃあどうしよう。

目指すところ、

  • レコードID順の方式で高速に取得して
  • 必要なら取得結果を任意にソートする

という方法をスタンダードにしないとなりません。
さらに、既に実装された仕様変更前の処理を置き換えなければならないケースも想定する必要があります。

ポイントは3点。

  • offsetが10,000までという縛りで10,000件以上のレコードをどのように取得するか
  • $idでソートするので、order by文でのソートをどうするか

そして

  • 並列処理をどう実行するか

です。

並列処理は使いたい=offsetは使いたい

数十万件のレコードを取得する際、特にブラウザ上の処理であれば、パフォーマンスの低下を無視できません。
この場合にはAPIリクエストを並列で実行する場合が多くあります。

方法1に対しての実装例は、「offsetを使わない」処理の例ですが、並列実行にも対応するなど汎用的にはoffsetを使いたい場面があります。
今後スタンダードになり得る汎用的な処理をどのように作るべきかまとめていきたいと思います。

var body = {
  'app': kintone.app.getId(),
  'query': '日付 = THIS_MONTH() order by 日付 desc, 金額 asc',
  'fields': []
};
getRecords(body).then(function(resp) {
  console.log(resp.records);//今月の日付降順、金額昇順のrecords
}).catch(function(error) {
  console.log(error);
});

対象件数が1万件を超える、という前提で、こちらを例にgetRecordsという汎用処理があったとします。
ここではGoogle Chromeを想定して、ブラウザの同時接続数を6とした並列処理数にしています。

これまでの処理

var LIMIT_PER_ONCE = 500;
var MAX_CONCURRENCY = 6;
function getRecords(body, data) {
  var done = false;
  if (!data) {
    var query = body.query.split(' limit ')[0];
    data = {
      condition: query,
      records: [],
      offset: 0
    };
  }
  var result, query, queries = [], currentOffset = data.offset;
  for (var i = 0; i < MAX_CONCURRENCY; i++) {
    if (i > 0) {
      //500ずつoffsetを加算
      currentOffset += LIMIT_PER_ONCE;
    }
    query = data.condition;
    query += ' limit ' + LIMIT_PER_ONCE + ' offset ' + currentOffset;
    queries.push(query);
  }
  return Promise.all(queries.map(function(query) {
    body.query = query;
    body.totalCount = true;
    return kintone.api(kintone.api.url('/k/v1/records', true), 'GET', body);
  })).then(function(resp) {
    resp.forEach(function(res) {
      if (res.records.length > 0) {
        data.records = data.records.concat(res.records);
        data.offset = currentOffset + LIMIT_PER_ONCE;
      }
      if (res.records.length < LIMIT_PER_ONCE) {
        done = true;
        res.records = data.records;
        result = res;
      }
    });
    return (!done) ? getRecords(body, data) : Promise.resolve(result);
  });
}

offsetは1万を超えるので、今後使えなくなる処理です。

まずは並列処理前提で取得できるところまで対応してみる(ソートは後で)


var LIMIT_PER_ONCE = 500;
var MAX_CONCURRENCY = 6;
function getRecords(body, data) {
  var done = false;
  if (!data) {
    var query = body.query.split(' limit ')[0];
    var tmp = query.split(' order by ');//condition, order by
    data = {
      condition: tmp[0],
      orderby: tmp[1],
      records: [],
      // offset: 0,//要らない
      lastId: null
    };
  }
  var result, query, queries = [],
    currentOffset = 0;//イテレーション毎にoffsetをリセット
  for (var i = 0; i < MAX_CONCURRENCY; i++) {
    if (i > 0) {
      //500ずつoffsetを加算、6件並列処理なので0〜2500で3000件ずつ取得。
      //offset値は3000に未満で取得し切れる
      currentOffset += LIMIT_PER_ONCE;
    }
    query = data.condition;
    if (data.lastId !== null) {
      //最後に取得したレコードID以上を省いて順次取得する。
      //これで、順次対象件数が減ってkintone側での処理負荷を軽減できるはず
      query = (query ? '(' + query + ') and ' : '') + '$id < ' + data.lastId;
    }
    query += ' limit ' + LIMIT_PER_ONCE + ' offset ' + currentOffset;
    queries.push(query);//order by を省略したのでデフォルトの $id desc
  }
  return Promise.all(queries.map(function(query) {
    body.query = query;
    body.totalCount = true;
    return kintone.api(kintone.api.url('/k/v1/records', true), 'GET', body);
  })).then(function(resp) {
    resp.forEach(function(res) {
      if (res.records.length > 0) {
        data.records = data.records.concat(res.records);
        //data.offset = currentOffset + LIMIT_PER_ONCE;//要らない
        //$id descの最終取得$id
        data.lastId = res.records[res.records.length - 1].$id.value;
      }
      if (res.records.length < LIMIT_PER_ONCE) {
        done = true;
        res.records = data.records;
        result = res;
      }
    });
    return (!done) ? getRecords(body, data) : Promise.resolve(result);
  });
}

このままですと不要であっても常に6リクエストを投げ続けることになりますので、レコード件数に応じた並列処理数の調整などを加えれば汎用的な処理として$id順(例は降順)にレコードの一括取得ができそうです。

残る問題は「ソート(ordre by文)」です。

ソート処理は考え方だけにとどめておきます(ずるい)

上のコードで

data.orderby

に保持した、元のクエリが持つソート指定を使って、最終的なrecordsの返却直前にソート処理をしてあげることで完成になります。
上のような汎用処理内に、各フィールドタイプに対応したソート処理を実装する想定です。

既に書いたとおり、kintoneの処理とまったく同一の結果を保証するJavaScriptでのソート処理を用意するのは難しいでしょう。
今後、上記のような処理をしていく前提で考えていくときに、設計側で十分に考慮する必要があるポイントです。

割愛したcursor.json方式については、また別の投稿でどうして「置き換えは難しい」のかを踏まえながら、使い所を模索してみたいと思います。

アップデートの進むkintoneとうまく付き合っていきたいですね。

同じカテゴリーの記事