バッチ処理などで大量のレコードを処理するときはカーソルベースページネーションを使う

バッチ処理などで大量のレコードを処理するときは、少しづつ取得して処理するためにページネーションを用いるのが一般的だが、このときオフセットベースページネーション(一般的なページネーション)を使ってはいけない。

たとえば以下のように有効期限の切れたファイルの情報を格納するレコードを削除するときを考える。

i = 0
n = 100
now = Time.zone.now
loop do
  records = UploadedFile.where('expired_at <= ?', now)
  records = records.limit(n).offset(n * i)
  records = records.to_a
  break if records.empty?
  records.each do |record|
    record.file.delete # S3等にアップロードしてあるファイルを削除する
    record.destroy # レコードの削除
  end
  i += 1
end

ページネーションにはオフセットベースページネーションを使用しているが、レコードを削除しているため次回ループ時にズレが生じる。

これを防ぐためには、カーソルベースページネーションを用いる。カーソルベースページネーションは以下のようなものである。

  • 一意かつソート可能な識別子をカーソルとする
  • カーソルで昇順(降順)ソート
  • 指定したカーソルより大きい(小さい)もののみをN件抽出

ページネーションにオフセットを用いないため、レコードの削除を行なってもズレが生じない。

cursor = nil
n = 100
now = Time.zone.now
loop do
  records = UploadedFile.where('expired_at <= ?', now)
  records = records.limit(n).sort(id: :asc) # 一意かつソート可能な識別子でソート(ここではauto incrementを用いたID)
  records = records.where('id > ?', cursor) if cursor.present? # 現在のカーソルより大きいもののみに絞る
  records = records.to_a
  break if records.empty?
  records.each do |record|
    record.file.delete # S3等にアップロードしてあるファイルを削除する
    record.destroy # レコードの削除
  end
  cursor = records.last.id # カーソルを更新
end

なお、ActiveRecord の場合は find_in_batches などがこのカーソルベースページネーションを使うので、ActiveRecord を使っている場合はこれを用いるのがよい。

このように、カーソルベースページネーションは一般的にはSNS等のタイムラインなどに使われることが多いが、ページネーション中に対象レコードの増減が起きても歯抜け・重複が起きないという特性がバッチ処理でも有効となる。

RubyのRegexp.escapeを使って簡易的なワイルドカードによるマッチングをする

例えばログ出力をテストしたい時があり、時刻などは無視したい場合があるが、これを正規表現を使ってマッチングしようとすると正規表現において特別な意味を持つ文字のエスケープが大変で見にくいのでワイルドカードのような簡易的な表現にしたい。

actual = <<~EOS
  [INFO] [2024-07-04T10:00:00Z] message1
  [ERROR] [2024-07-04T10:00:01Z] #<StandardError: error>
  /path/to/file1:10:in `initialize'
  /path/to/file2:3:in `hoge'
  /path/to/file3:5:in `foo'
  [INFO] [2024-07-04T10:00:02Z] message3
EOS

expected = Regexp.new(<<~EOS, Regexp::MULTILINE)
  \\[INFO\\] \\[.+\\] message1
  \\[ERROR\\] \\[.+\\] #<StandardError: error>
  .+
  \\[INFO\\] \\[.+\\] message3
EOS

expect(actual).to match(expected)

ここで Ruby には Regexp.escape1 という正規表現において特別な意味を持つ文字をエスケープしてくれるメソッドがあり、これを使うとエスケープしなくて済む。そしてエスケープするときに自身で定義したワイルドカード文字列(ここでは%ワイルドカードとする)を正規表現に置き換えればワイルドカードによる簡易的なマッチングできるようなる。

actual = <<~EOS
  [INFO] [2024-07-04T10:00:00Z] message1
  [ERROR] [2024-07-04T10:00:01Z] #<StandardError: error>
  /path/to/file1:10:in `initialize'
  /path/to/file2:3:in `hoge'
  /path/to/file3:5:in `foo'
  [INFO] [2024-07-04T10:00:02Z] message3
EOS

expected = Regexp.new(Regexp.escape(<<~EOS).gsub('%','.+'), Regexp::MULTILINE)
  [INFO] [%] message1
  [ERROR] [%] #<StandardError: error>
  %
  [INFO] [%] message3
EOS

expect(actual).to match(expected)

環境

TypeScriptではリテラルの型指定にはキャストではなくsatisfiese演算子を使う

よく以下のように、キャスト{} as Tを使ってリテラルに型指定をしているコードを見る。

type User = {
  id: string;
  name: string;
};

const value1 = {
  user: {} as User,
};

しかし、このコードは型安全ではなく、存在しないプロパティがある場合や必要なプロパティが不足している場合であってもエラーにならない。そのため嘘の型情報を付与してしまい、実質的に型安全でなくなる。

これを防ぐために以前はliteralという簡単なヘルパー関数を作って対応していた。

type User = {
  id: string;
  name: string;
};

function literal<T>(value: T): T {
  return value;
}

const value2 = {
  user: literal<User>({
    id: '1',
    name: 'name 2',
  }),
};

このようにすれば型安全になり、嘘の型情報を付与することがなくなる。

しかし、TypeScript 4.9 から satisfies演算子というものが導入されたので、これを使うと上記と同様のことができるようになった。

type User = {
  id: string;
  name: string;
};

const value3 = {
  user: {
    id: '1',
    name: 'name 3',
  } satisfies User,
};

そのため、TypeScript 4.9 以上を使用しているのであれば、リテラルへの型指定にはsatisfies演算子を使うとよい。


参考:

React Select を使ったコンポーネントのテストをする

react-select という複数選択やoptionにサムネイルを追加したりできる高機能なselectを提供するライブラリがある。

OptionSelector.tsx:

import { useCallback } from 'react';
import Select from 'react-select';

export type Option = {
  value: string;
  label: string;
};

export const options: Option[] = [
  { value: '1', label: 'Option 1' },
  { value: '2', label: 'Option 2' },
  { value: '3', label: 'Option 3' },
];

type Props = {
  value: Option | undefined;
  onChange: (option: Option | undefined) => void;
};

export default function OptionSelector({ value, onChange } : Props) {
  const onChangeSelect = useCallback((option: Option | null) => {
    onChange(option || undefined);
  }, [onChange]);

  return (
    <div data-testid="OptionSelector">
      <Select
        value={value}
        options={options}
        onChange={onChangeSelect}
      />
    </div>
  );
};

使う分にはいいが、このライブラリを使っているコンポーネントのテストをする時は少々やっかいで、たとえば、特定のオプションを選択してから何かをするようなテストを書く場合などである。

__tests__/OptionSelector.test.tsx:

import { render, screen } from '@testing-library/react';
import OptionSelector, { Option, options } from '../OptionSelector';

describe('<OptionSelector />', () => {
  it('calls onChange() prop with selected value', async () => {
    let option: Option | undefined = undefined;
    const onChange = jest.fn((newOption: Option | undefined) => {
      option = newOption;
    }));

    render(<OptionSelector value={option} onChange={onChange} />);

    // なんらかの値を選択したい

    expect(onChange).toHaveBeenCalledTimes(1);
    expect(option).toBe(options[1]);
  });
});

このような場合は、モックに置き換えるのが一般的だが、react-select-event というライブラリを使うとモックに置き換えることなく任意の値を選択することができる。

import React from 'react'
import Select from 'react-select'
import {render} from '@testing-library/react'
import selectEvent from 'react-select-event'

const {getByTestId, getByLabelText} = render(
  <form data-testid="form">
    <label htmlFor="food">Food</label>
    <Select options={OPTIONS} name="food" inputId="food" isMulti />
  </form>,
)
expect(getByTestId('form')).toHaveFormValues({food: ''}) // empty select

// select two values...
await selectEvent.select(getByLabelText('Food'), ['Strawberry', 'Mango'])
expect(getByTestId('form')).toHaveFormValues({food: ['strawberry', 'mango']})

// ...and add a third one
await selectEvent.select(getByLabelText('Food'), 'Chocolate')
expect(getByTestId('form')).toHaveFormValues({
  food: ['strawberry', 'mango', 'chocolate'],
})

react-select-event | Testing Library

上記のサンプルコードによると selectEvent.select(<react-select内部のinput要素>, <選択するオプション>) というAPIを使用することで選択できる模様。

ここで、react-select内部のinput要素 は、react-selectinputId を指定すると任意のIDを付与できるので、label要素のfor属性と同じ値を指定することで紐づけられる。そのため、getByLabelText(<labelに指定している文字列>) で取得できるようになる。

これにより、以下のようにテストが書けるようになる。

OptionSelector.tsx:

export default function OptionSelector({ value, onChange } : Props) {
  const onChangeSelect = useCallback<OnChangeSelect>((option) => {
    onChange(option || undefined);
  }, [onChange]);

  return (
    <div data-testid="OptionSelector">
      <label htmlFor="option_selector">option:</label>
      <Select
        value={value}
        options={options}
        onChange={onChangeSelect}
        inputId="option_selector"
      />
    </div>
  );
};

__tests__/OptionSelector.test.tsx:

import { render, screen } from '@testing-library/react';
import selectEvent from 'react-select-event';
import OptionSelector, { Option, options } from '../OptionSelector';

describe('<OptionSelector />', () => {
  it('calls onChange() prop with selected value', async () => {
    let option: Option | undefined = undefined;
    const onChange = jest.fn((newOption: Option | undefined) => {
      option = newOption;
    }));

    render(<OptionSelector value={option} onChange={onChange} />);

    await selectEvent.select(screen.getByLabelText('option:'), [options[1].label]);

    expect(onChange).toHaveBeenCalledTimes(1);
    expect(option).toBe(options[1]);
  });
});

ここで、以下にあげるように、いくつかの問題がある。

  1. IDを指定しているのでこのコンポーネントを一つの画面で複数使用している場合うまくいかない
  2. label要素がない場合は自分でコンポーネント内部のIDをもとにdocument.getElementById()などで取得する必要がある

まず 1. の問題についてだが、ジェネレーターなどを使ってコンポーネント生成毎に異なるIDを生成することで解決できる。

OptionSelector.tsx:

function* inputIdGenerator(): Generator<string, string, string> {
  let id = 1;
  while (true) {
    yield `option_selector_${id++}`;
  }
}
const inputIdIterator = inputIdGenerator();

export default function OptionSelector({ value, onChange } : Props) {
  const onChangeSelect = useCallback<OnChangeSelect>((option) => {
    onChange(option || undefined);
  }, [onChange]);

  const inputId = useMemo(() => inputIdIterator.next().value, []);

  return (
    <div data-testid="OptionSelector">
      <label htmlFor={inputId}>option:</label>
      <Select
        value={value}
        options={options}
        onChange={onChangeSelect}
        inputId={inputId}
      />
    </div>
  );
};

そして次に 2. の問題についてだが、コンポーネントdata-inputid 属性を追加してここにIDを指定して、テスト時に参照することで解決できる。

OptionSelector.tsx:

export default function OptionSelector({ value, onChange } : Props) {
  const onChangeSelect = useCallback<OnChangeSelect>((option) => {
    onChange(option || undefined);
  }, [onChange]);

  const inputId = useMemo(() => inputIdIterator.next().value, []);

  return (
    <div data-testid="OptionSelector" data-inputid={inputId}>
      <label htmlFor={inputId}>option:</label>
      <Select
        value={value}
        options={options}
        onChange={onChangeSelect}
        inputId={inputId}
      />
    </div>
  );
};

__tests__/OptionSelector.test.tsx:

describe('<OptionSelector />', () => {
  it('calls onChange() prop with selected value', async () => {
    ...
    await selectEvent.select(
      document.getElementById(screen.getByTestId('OptionSelector').dataset.inputid!)!,
      [options[1].label]
    );
    ...
  });
});

さらにヘルパー関数を作成しておくとよい。

OptionSelector.tsx:

export function getReactSelectInputElement(el: HTMLElement | undefined | null): HTMLInputElement {
  if (!el) throw new Error('Element is null');
  if (el.dataset.testid !== 'OptionSelector') throw new Error('Element is not OptionSelector');
  if (typeof el.dataset.inputid === 'undefined') throw new Error('InputId is empty');
  const input = el.querySelector<HTMLInputElement>(`#${el.dataset.inputid}`);
  if (!input) throw new Error('input element not found');
  return input;
}

__tests__/OptionSelector.test.tsx:

describe('<OptionSelector />', () => {
  it('calls onChange() prop with selected value', async () => {
    ...
    await selectEvent.select(
      getReactSelectInputElement(screen.getByTestId('OptionSelector')),
      [options[1].label]
    );
    ...
  });
});

以上で、react-select を使ったコンポーネントもテストできるようになった。また、上記の方針で実装したサンプルコードは sandbox/ts-react-select にある。

環境:

  • React: 18.2.0
  • react-select: 5.8.0
  • react-select-event: 5.5.1

Docker 25.0 でヘルスチェックに start-interval オプションが追加された

Docker にはヘルスチェック機能があるが、25.0start-interval オプションが追加された。

The options that can appear before CMD are:

  • --interval=DURATION (default: 30s)
  • --timeout=DURATION (default: 30s)
  • --start-period=DURATION (default: 0s)
  • --start-interval=DURATION (default: 5s)
  • --retries=N (default: 3)

...

start interval is the time between health checks during the start period. This option requires Docker Engine version 25.0 or later.

HEALTHCHECK - Dockerfile reference | Docker Docs

いままでは、以下のように interval 間隔でヘルスチェックがされていた。 このとき start-period を指定していると、コンテナ開始から指定した時間が経過するまでは失敗しても無視されるという動作をする。

 |<------- start-period(10s) --------->|
 |<- interval(5s) ->|<- interval(5s) ->|<- interval(5s) ->|<- interval(5s) ->|
 |
 +------------------+------------------+------------------+------------------+
 ^                  ^                  ^                                           
 |                  |                  |                                            
失敗               失敗               成功                                            

start-interval を指定すると、start-period の時のヘルスチェック間隔を設定できる。使い道としては、Docker Compose で depends_onservice_healthy を指定する時に起動が完了するまで待ち、立ち上がったら素早くhealthy状態に移行できるように高頻度でヘルスチェックしつつ、通常のヘルスチェックを頻繁にしなくするということができる。

なお、start-period 期間内でヘルスチェックを通ると指定した時間が経過していなくても、通常のヘルスチェックに移行する。

- SI: start-interval

 |<-------- start-period(3s) ---------->|
 |<- SI(1s) ->|<- SI(1s) ->|<- SI(1s) ->|<---------------- interval(10s) ---------------->|
 |
 +------------+------------+------------+-------------------------------------------------+
 ^            ^            ^            ^                                           
 |            |            |            |                                            
失敗         失敗         失敗         成功                                            

Docker Compose では compose.yaml で以下のように指定できる。

compose.yaml:

services:
  app:
    ...
    depends_on:
      db:
        condition: service_healthy
  db:
    image: mysql:8
    volumes:
      - type: volume
        source: mysql_data
        target: /var/lib/mysql
    ports:
      - target: 3306
        published: 3306
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: 1
    healthcheck:
      test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
      start_period: 10s
      start_interval: 1s
      interval: 600s
      timeout: 30s
      retries: 0
volumes:
  mysql_data:

RubyのENV.fetch()で空文字列の時もデフォルト値を使うようにする

Rubyには、指定された環境変数の値を読み込み、存在しなければデフォルト値を返すENV.fetch()というメソッドがある。Railsなどでは設定ファイルで環境変数から値を読み込むのによく使う。

config/database.yml:

default: &default
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

しかし、たとえば指定された環境変数が空文字列だったりすると、デフォルト値ではなく空文字列が返されてしまう。

HOGE='' ruby -e 'puts ENV.fetch("HOGE"){1}' #=> 

これは direnv の .envrc や Docker Compose の compose.yaml環境変数を指定していたりすると起きがちである。

.envrc:

export HOGE=

compose.yaml:

services:
  app:
    environment:
      HOGE: ${HOGE}

なので、空文字列の時もデフォルト値が参照されるようにENVをモンキーパッチして、新たにENV.fetch2()というENV.fetch()とほぼ同様の動作をするメソッドを実装する。

lib/env.rb:

class << ENV
  # 基本的な動作は`ENV.fetch()`と同様だが、空文字列のときもデフォルト値を返すようにする
  def fetch2(*args, &block)
    key, default = args
    value = fetch(*args, &block)
    return value if value != ''

    raise KeyError, format('key not found: "%s"', key) if args.size == 1 && !block
    warn('block supersedes default value argument', uplevel: 1) if args.size == 2 && block
    block ? yield(key) : default
  end
end
HOGE='' ruby -r ./lib/env.rb -e 'puts ENV.fetch2("HOGE"){1}' #=> 1

これで問題を解決できた。なお、空文字列の時はデフォルト値ではなく空文字列が返ってきてほしい時があるので、ENV.fetch() をオーバーライドするのはやめた方がよい。

Gist: https://gist.github.com/mrk21/3c6f29aa91659dcaf45eefba11f866a3

環境

年齢の算出

年齢の算出は簡単にできるかと思いきや、少々面倒臭い。これは、閏年というものが存在するためである。そのため以下のような単純な時間のdiffで算出しようとすると年を重ねる日時が1日ほどズレる場合がある。

const today = new Date(2010, 2, 10); // 2010-03-10
const birthday = new Date(2000, 2, 10); // 2000-03-10
const diff = today.getTime() - birthday.getTime();
const age = Math.floor(diff / (1000*3600*24*365.25));
console.log(age); // 9

また、誕生日が閏日(02-29)の人は、閏年以外は うるう年をめぐる法令|参議院法制局 によると、03-01 が誕生日となるのでこれも考慮すると以下のようになる。だいたいの日付オブジェクトでは閏年以外で 02-29 を指定すると 03-01 と解釈されるので、閏日の時に条件分けする必要はない。

// const today = new Date(2010, 1, 29); // 2010-03-01, 閏年以外で 02-29 を指定すると 03-01 と解釈される
const today = new Date(2010, 2, 10); // 2010-03-10
const birthday = new Date(2000, 2, 10); // 2000-03-10

let age = today.getFullYear() - birthday.getFullYear();
if (today.getMonth() < birthday.getMonth()) {
  age--;
}
else if (today.getMonth() === birthday.getMonth() && today.getDate() < birthday.getDate()) {
  age--;
}
console.log(age); // 10

ここで、上記で参照した うるう年をめぐる法令|参議院法制局 によると道路交通法では閏日の人は 02-28 が誕生日と解釈されるので道路交通法関連で年齢を算出するときは注意が必要となる。また日本国外では別のルールに基づく可能性があるので合わせて注意が必要となる。