Custom Managerの時は止まる

この記事は [ハンズラボ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/handslab) 12日目の記事です。

お疲れさまです。@naokiurです。
今年も担当させて頂きます。

Django REST FrameworkとVue.jsで、
内製システムのマスタ管理系アプリケーションの開発に携わっております。

つい先日、初歩的なところでやらかしてしまったので、
その話を記述させて頂きたいと思います。

# 要約
## 推察
* Custom Managerは、Modelのクラス変数として定義するため、`サーバ起動時`の一度のみインスタンス化/メモリ展開される
    * 以後は同じインスタンスが使い回される
    * `時は止まる`

## 教訓
* 言語仕様を理解しよう
* Webアプリケーションフレームワーク内で、時刻を取得する処理を入れるときは、入れる場所を気をつけよう

# 内容
今回は実際に起きたことをフィクションを織り交ぜながら、
お話しさせて頂きたいと思います。
大体はフィクションですが、起きてしまった事象としては、事実です。
本当にご迷惑をおかけしました…。

## プロローグ

ある日私は、 ~~プロデューサー~~ 上司より、
`ラジオ番組のマスタ管理アプリケーション` を依頼されました。
数あるラジオ番組をデータベース管理し、
変更がある場合はブラウザ経由でメンテナンスをできるようにしたいそうです。

そこで私は、

* Elastic Beanstalkにアプリケーションを構築する
* データベースはAmazon Aurora
* WebアプリケーションフレームワークにDjango

を活用することにしました。

また、上司から以下のような要望がありました。

* ラジオ番組は途中で名前が変わる場合がある[^1]
    * 変わらない場合もある
* メンテナンスを事前に投入できるようにしたい
* メンテナンスした履歴も管理したい


## テーブル設計

テーブル設計に悩んでいましたが、
会社の先輩に相談しつつ、
`履歴型テーブル` を用いることにしました。

```履歴管理していない.
radio_programsテーブル

|id  |code  |title          |
|----|------|--------------|
|1   |153   |ゆずらないラジオ | ← メンテナンスのたびにこの行を更新する。
```

```履歴管理.
radio_programsテーブル

|id  |code  |
|----|------|
|1   |153   |

radio_program_snapshotsテーブル

|id  |radio_program_id(FK)|activated_started_on | title                         |
|----|--------------------|---------------------|-----------------------------|
|1   |1                   |2018-04-01           |ゆずらないラジオ                |
|2   |1                   |2019-07-01           |ゆずラジ                       |
|3   |1                   |2019-10-01           |超ゆずラジ                     | ← メンテナンスのたびに行を追加する。
```
また、会社の先輩から、
`有効終了日はWindow関数を利用することで、算出することができるため、SQLで、その日に有効なレコードを取得することができる`
ということを教えて頂きました。

```SQL
SELECT
	id,
    radio_program_id,
    title,
    activated_started_on,
	CAST (
		COALESCE(
			LEAD(
				activated_started_on,
				1,
				NULL
			) OVER (
				PARTITION BY
					radio_program_id
				ORDER BY
					activated_started_on
			) - INTERVAL '1 days',
			'9999-12-31'
		) AS DATE
	) AS activated_ended_on
FROM
	radio_program_snapshots
;


|id  |radio_program_id|activated_started_on |activated_ended_on | title                       |
|----|----------------|---------------------|---------------------|-----------------------------|
|1   |1               |2018-04-01           |2019-06-30           |ゆずらないラジオ                |
|2   |1               |2019-07-01           |2019-09-30           |ゆずラジ                       | 
|3   |1               |2019-10-01           |9999-12-31           |超ゆずラジ                     |
```

## Djangoで現在有効なレコードを取得する

画面に、現在有効な情報を表示するときは、
上記SQLを元に、
DjangoのQueryset#extraを活用することにしました。

```views.py
today = datetime.now().date()
queryset = RadioProgramSnapshot.objects.filter(
    activated_started_on__lte=today
).extra(
    where=["""
    EXISTS(
        SELECT
            1
        FROM
            (
            SELECT
                id,
                CAST (
                    COALESCE(
                        LEAD(
                            activated_started_on,
                            1,
                            NULL
                        ) OVER (
                            PARTITION BY
                                radio_program_id
                            ORDER BY
                                activated_started_on
                        ) - INTERVAL '1 days',
                        '9999-12-31'
                    ) AS DATE
                ) AS activated_ended_on
            FROM
                radio_program_snapshots
            ) calculated
        WHERE
            calculated.activated_ended_on >= %s
            AND calculated.id = radio_program_snapshots.id
    )
    """], params=[today]).all()

print(queryset[0].title)
# >> 超ゆずラジ
```

## Custom Managerの導入

機能が追加されるにつれ、
`現在有効なラジオ番組情報`を使用するタイミングが増えてきました。

そのときに、上記のようなextraを活用していたのですが、
なかなか大変なコード量になってしまいました。

そこで私は、
`DjangoのCustom Managerを活用すれば、extraを書く場所を固定できるのでは？`
と考え、
現在有効な情報を取得するManager,
LatestManagerを作成しました。

```custom_manager.py
class LatestManager(Manager):

    def __init__(self, **kwargs):
        super().__init__()

    @property
    def where(self):
        return """
            EXISTS(
                SELECT
                    1
                FROM
                    (
                    SELECT
                        id,
                        CAST (
                            COALESCE(
                                LEAD(
                                    activated_started_on,
                                    1,
                                    NULL
                                ) OVER (
                                    PARTITION BY
                                        radio_program_id
                                    ORDER BY
                                        activated_started_on
                                ) - INTERVAL '1 days',
                                '9999-12-31'
                            ) AS DATE
                        ) AS activated_ended_on
                    FROM
                        radio_program_snapshots
                    ) calculated
                WHERE
                    calculated.activated_ended_on >= %s
                    AND calculated.id = radio_program_snapshots.id
            )
        """

    def latest(self) -> QuerySet:
        today = datetime.now().date()

        return self.get_queryset().filter(
                activated_started_on__lte=today
        ).extra(
                where=[self.where], params=[today]
        )

```

```radio_program_snapshot.py
class RadioProgramSnapshot(models.Model):
    title = models.CharField(max_length=30)
    radio_program = models.ForeignKey(RadioProgram, db_column='radio_program_id', on_delete=models.PROTECT)
    activated_started_on = models.DateField(verbose_name='有効開始日', help_text='有効開始日')

    objects = LatestManager()

    class Meta:
        db_table = 'radio_program_snapshots'

```
```views.py
print(RadioProgramSnapshot.objects.latest()[0].title)
# >> 超ゆずラジ
```

これによって、
`現在有効な情報`を取得しやすくなりました。

一通りの機能を作成し、
Elastic Beanstalkにアプリケーションを構築、
`2019-11-01`から、上司に利用して頂くことができました。

めでたしめでたし。

## めでたしじゃなかった

あるとき、（コラボ企画が終わったため）再度番組名が変更となるという話があがりました。
上司は構築した `ラジオ番組管理システム` を活用し、
`2020-02-01`にメンテナンス実施、`有効開始日を2020-03-01` で投入しました。

これで当日になれば、
また番組名が変わった状態で、画面に表示されるはずです。

```

radio_program_snapshotsテーブル

|id  |radio_program_id|activated_started_on | title                         |
|----|----------------|---------------------|-----------------------------|
|1   |1               |2018-04-01           |ゆずらないラジオ                |
|2   |1               |2019-07-01           |ゆずラジ                       |
|3   |1               |2019-10-01           |超ゆずラジ                     |
|3   |1               |2020-03-01           |ゆずラジ                      | ← 上司が追加した行
```

…はずだったのですが、
`2020-03-01`、画面に表示される番組名は、
なぜか `超ゆずラジ` のまま。

パーソナリティにいじられる上司を横目に、
私はとても焦りました。
なぜならば、上司のオペレーションに問題はなく、
保持するデータ的にも問題ない状態です。
つまりは、アプリケーションに問題があります。

本番環境のデータベースをダンプしてローカルで試しましたが、
再現しません。
DjangoのQuerysetによって生成されたSQLを確認してみても、
問題があるようには見えません。
なぜ…。

## 原因判明

RDSのパフォーマンスインサイトで実際に発行されたSQLを確認しました。

```sql
SELECT
    "radio_program_snapshots"."id",
    "radio_program_snapshots"."title",
    "radio_program_snapshots"."radio_program_id",
    "radio_program_snapshots"."activated_started_on"
FROM
    "radio_program_snapshots"
WHERE (
    "radio_program_snapshots"."activated_started_on" <= '2019-11-01'::date -- ← ！！？？
    AND (
        EXISTS(
            SELECT
                1
            FROM
                (
                SELECT
                    id,
                    CAST (
                        COALESCE(
                            LEAD(
                                activated_started_on,
                                1,
                                NULL
                            ) OVER (
                                PARTITION BY
                                    radio_program_id
                                ORDER BY
                                    activated_started_on
                            ) - INTERVAL '1 days',
                            '9999-12-31'
                        ) AS DATE
                    ) AS activated_ended_on
                FROM
                    radio_program_snapshots
                ) calculated
            WHERE
                calculated.activated_ended_on >= '2019-11-01'::date -- ← ！！？？
                AND calculated.id = radio_program_snapshots.id
        )
    )) ;
```

日付が…サーバーでアプリケーション稼働した日付になっている…！

`時が…止まっている…！`

## 推測

Modelのクラス変数として定義したLatestManagerクラス、
そしてLatestManagerクラスのpropertyとして定義した `where`、
これら、インスタンス化されるタイミングがどうやら、
`manage.py runserver`によって、アプリケーションが実行したときのみ、
と考えられ、
その後は、 `そのインスタンスが使い回される` ようです。

なので、

```manager.py
def latest(self) -> QuerySet:
    today = datetime.now().date()
```

は、リクエストによってQuerysetが実行される度に実行されるわけではなく、
`日付が固定` されてしまったようです…。

## エピローグ

私は上司に平謝り、
日付取得の処理をPython側でなくDB側（`CURRENT_DATE`）に任せることで、
無事に番組名は `ゆずラジ` になりました…。
今後も、もし `セカンドシーズン` というような名前が付いたとしても、
問題なく番組名のメンテナンス・表示ができることでしょう…。


# 最後に

以上、フィクションでした。
ここまでお読み頂き、ありがとうございます。

[ハンズラボ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/handslab) 明日の13日目は、 @daisukeArk さんです！

[^1]: 番組名が変わると `新しい番組` と認識されるようなので、 `code`も変えるべきなのかもしれません。そう考えると、このテーブル設計はあまり適切じゃないかもしれません。