qwc-services による Web GIS の構築 #6 MapCache 再び

QGIS Server と QGIS Web Client 2 (QWC2) を中核とする統合的 Web GIS エコ・システムである qwc-services によって Web GIS を構築する作業記録の第6回です。

第1回では、`qwc-docker` によって `qwc-services` をインストールし、`MapCache` とも連携させて、QGIS のプロジェクトを Web 地図として表示するところまでやりました。

- [qwc-services による Web GIS の構築 #1 qwc-docker](https://qiita.com/softark/items/bb179315d404fdd3cf9f)

第2回では、ユーザの認証と権限管理について調べて、地図やレイヤを閲覧できるユーザを限定したり、ビューワの機能を使用できるユーザを限定したりする方法を確認しました。

- [qwc-services による Web GIS の構築 #2 認証と権限](https://qiita.com/softark/items/e307645aebcd5366d16b)

第3回では、qwc-services によって実現される「編集」関連の機能に不可欠なデータベース環境である `PostGIS` を使えるようにしました。

- [qwc-services による Web GIS の構築 #3 PostGIS](https://qiita.com/softark/items/74861df6a6ebfd359eaa)

第4回は、ちょっと寄り道をして、`QWC2 Viewer` とは独立したページを `Web GIS` に追加する方法として、`Python` のマイクロ・ウェブ・フレームワークである `Flask` の使用を検討しました。

- [qwc-services による Web GIS の構築 #4 独自ページを追加](https://qiita.com/softark/items/410fca12a8f5ad8ee129)

前回は、`qwc-services` と `PostGIS` によって実現される編集機能について、必要となる前提条件および具体的な使用方法を説明しました。

- [qwc-services による Web GIS の構築 #5 編集機能](https://qiita.com/softark/items/ad9d590366d61a891e40)

今回は地図画像キャッシュの問題を再び扱います。

## 1. 編集機能と地図画像キャッシュの問題

WebGIS で編集機能が使えるようになると、地物データと地図画像キャッシュとの間に生じるズレが問題となります。

地物データが更新されたのに古い地図画像キャッシュが残っていると、

- 追加した地物が表示されない
- 削除した地物がまだ表示されている
- 位置や形状を変更した地物が古い位置や形状で表示される
- 変更した文字ラベルが古いまま表示される

というようなことが生じるのです。

## 2. 地図画像キャッシュの無効化

上記のズレの問題を解消するためには、地物データに更新があるたびに影響を受ける範囲の地図画像キャッシュを無効化（削除）する必要があります。

キャッシュ・サーバは当該領域の地図画像データを要求されたときに、有ればそのキャッシュを返しますが、無ければ `WMS` サーバに要求して地図画像データを取得し、キャッシュを再作成して返します。（on-the-fly シーディング）

従って、旧くなったキャッシュを無効化（削除）してやりさえすれば良い、ということになりますが、リアルタイムで働くキャッシュ無効化の仕組みを作るとなると、少し工夫が必要になります。

ここでは、キャッシュ無効化の処理の流れを次のようにします。

1. `PostGIS` 上で地物が更新される
    1. 更新に影響される範囲をログ・テーブルに記録する
    2. キャッシュ無効化サービスに更新を通知する
2. キャッシュ無効化サービスが更新の通知を受ける
    1. ログ・テーブルから未処理レコードを読みとる
    2. 更新に影響される範囲のキャッシュを無効化する
    3. 処理済みフラグを立ててログ・テーブルを更新する

### 2-1. PostGIS 側の処理

#### 2-1-1. キャッシュ無効化ログ・テーブル

次のようなキャッシュ無効化ログ・テーブル `cache_invalidation_log` を作成します。

```sql:cache_invalidation_log
CREATE TABLE cache_invalidation_log (
    id         serial PRIMARY KEY,
    layer_name text NOT NULL,
    bbox_xmin  double precision NOT NULL,
    bbox_ymin  double precision NOT NULL,
    bbox_xmax  double precision NOT NULL,
    bbox_ymax  double precision NOT NULL,
    event_time timestamptz DEFAULT now(),
    processed  boolean NOT NULL DEFAULT false
);
```

- layer_name ... レイヤ名（タイル名）
- bbox_xmin, bbox_ymin, bbox_xmax, bbox_ymax ... 無効化する範囲（CRS = EPSG:3857, 単位 = m）
- event_time ... 更新イベント発生日時
- processed ... 処理済みフラグ

:::note
テーブルの作成等には `psql` を使用して `SQL` を実行するのがお奨めです。

まず、`psql` を対話モードで起動します。
```bash
psql -U db_user db_name
```

`Docker` コンテナ内にある `PostgreSQL` サーバであれば、`docker exec` を使って、`psql` を起動します。
```bash
docker exec -it <container_id> psql -U db_user db_name
```
:::

:::note warn
`pgAdmin4` の `GUI` からでもテーブル等の作成は可能ですが、設定可能なオプションが多すぎて、却って使いづらく感じます。

いったん作成したものを修正するのは、`pgAdmi4` の方が楽ですけれど。
:::

#### 2-1-2. キャッシュ設定テーブル

レイヤごとのキャッシュ設定データを保持するテーブル `cache_config` を次のように作成します。

```sql:cache_config
CREATE TABLE cache_config (
    layer_name       text PRIMARY KEY,
    buff_east_west   double precision NOT NULL,
    buff_north_south double precision NOT NULL
);
```

- buff_east_west ... 東西方向のバッファ（単位 m）
- buff_north_south ... 南北方向のバッファ（単位 m）

そして、次のように必要なデータを登録しておきます。

| layer_name | buff_east_west | buff_north_south |
|:----|---:|---:|
| spots | 300 | 100 |
| tanada | 10 | 10 |
| bld | 10 | 10 |

`spots` は `Point` 型のデータですが、地名・建物名などのラベルを表示する地物であるため、特に左右方向のバッファをかなり大きく取っています。`tanada` と `bld` は `Polygon` 型の地物で、現状では文字ラベルを表示していませんので、それほど大きなバッファは必要ありません。

#### 2-1-3. 共通トリガ関数

各レイヤのテーブルからトリガで呼ばれてキャッシュ無効化ログを記録するトリガ関数 `log_cache_invalidation()` を作成します。

```sql:log_cache_invalidation()
CREATE OR REPLACE FUNCTION log_cache_invalidation()
    RETURNS trigger
    LANGUAGE 'plpgsql'
AS $BODY$
DECLARE
  bbox geometry;
  layer text := TG_ARGV[0];  -- トリガ実行時に渡される
  buff_x double precision;
  buff_y double precision;
  bbox_3857_old geometry := NULL;
  bbox_3857_new geometry := NULL;
BEGIN
  -- キャッシュ設定を取得
  SELECT buff_east_west, buff_north_south
  INTO buff_x, buff_y
  FROM cache_config WHERE layer_name = layer;

  IF buff_x IS NULL THEN
    RAISE WARNING 'No cache config for layer %, using default values', layer;
    buff_x := 10;
	buff_y := 10;
  END IF;

  -- bbox を取得 /（EPSG:6673）→ EPSG:3857 に変換 / バッファ処理
  IF TG_OP = 'DELETE' THEN
	  bbox_3857_old := ST_Transform(OLD.geom, 3857);
	  bbox_3857_old := ST_Expand(bbox_3857_old, buff_x, buff_y);
  ELSEIF TG_OP = 'INSERT' THEN
	  bbox_3857_new := ST_Transform(NEW.geom, 3857);
	  bbox_3857_new := ST_Expand(bbox_3857_new, buff_x, buff_y);
  ELSE
	  bbox_3857_old := ST_Transform(OLD.geom, 3857);
	  bbox_3857_old := ST_Expand(bbox_3857_old, buff_x, buff_y);
	  bbox_3857_new := ST_Transform(NEW.geom, 3857);
	  bbox_3857_new := ST_Expand(bbox_3857_new, buff_x, buff_y);
	  IF ST_Distance(bbox_3857_old, bbox_3857_new) < 1.0 THEN
	     bbox_3857_old := ST_Union(bbox_3857_old, bbox_3857_new);
		 bbox_3857_new := NULL;
	  END IF;
  END IF;
	
  -- BBOX を記録
  IF bbox_3857_old IS NOT NULL THEN
    INSERT INTO cache_invalidation_log (
      layer_name,
      bbox_xmin, bbox_ymin, bbox_xmax, bbox_ymax
    )
    SELECT
      layer,
      ST_XMin(bbox_3857_old),
      ST_YMin(bbox_3857_old),
      ST_XMax(bbox_3857_old),
      ST_YMax(bbox_3857_old);
  END IF;

  IF bbox_3857_new IS NOT NULL THEN
    INSERT INTO cache_invalidation_log (
      layer_name,
      bbox_xmin, bbox_ymin, bbox_xmax, bbox_ymax
    )
    SELECT
      layer,
      ST_XMin(bbox_3857_new),
      ST_YMin(bbox_3857_new),
      ST_XMax(bbox_3857_new),
      ST_YMax(bbox_3857_new);
  END IF;

  PERFORM pg_notify('cache_invalidation', layer);

  RETURN NEW;
END;
$BODY$;
```

1. `cache_config` テーブルからレイヤのキャッシュ設定を取得
    - テーブルに設定が無い場合はデフォルト値を設定
2. 更新された地物の `geometry` の `bbox` を取得
    - トリガ起動操作別の処理
        - `DELETE` ... 旧いレコードの `bbox` を取得
        - `INSERT` ... 新しいレコードの `bbox` を取得
        - `UPDATE` ... 旧いレコードと新しいレコードの `bbox` を取得
            - 距離が近ければ結合して旧い方の `bbox` だけにまとめる
    - 共通の処理
        - レイヤの `CRS` から `MapCache` 用の `EPSG:3857` に変換
        - バッファ分だけ `bbox` を拡大
3. ログにレイヤ名、`bbox` を記録
    - 旧いレコードの分、または、新しいレコードの分、または、まれに新旧両方
4. `cache_invalidation` 通知を発行
    - 以後の処理はキャッシュ無効化サービスにゆだねる 

#### 2-1-4. トリガ定義

各レイヤのテーブルに次のようにトリガを定義します。

```sql:trigger
CREATE TRIGGER trg_cache_log_xxxxxx
AFTER INSERT OR UPDATE OR DELETE ON xxxxxx
FOR EACH ROW
EXECUTE FUNCTION log_cache_invalidation('xxxxxx');
```

`xxxxxx` の所には `spots`, `tanada`, `bld` などのテーブル名（レイヤ名）が入ります。

#### 2-1-5. 動作確認

`psql` で次のような `SQL` を実行してみます。

```sql
UPDATE xxxxxx SET geom = geom WHERE id = 1;
```

`cache_invalidation_log` テーブルに新しいログが記録されたら、正常に動作していると見て良いでしょう。

### 2-2. キャッシュ無効化サービス

`Python` でキャッシュ無効化サービスのスクリプトを作成します。

#### 2-2-1. 準備作業

##### 2-2-1-1. psycopg2-binary

作成するスクリプトが依存するライブラリ `psycopg2-binary` をインストールします。

```bash
pip install psycopg2-binary
```

:::note warn
この作業の前提条件として `python3` と `python3-pip` を `dnf` でインストールしておく必要がある場合もあります。

```bash
sudo dnf install python3 python3-pip
```
:::


##### 2-2-1-2. /etc/sudoers

`/etc/sudoers` の末尾に以下の行を追加します。

```config:/etc/sudoers
## Allows people in group wheel to execute /usr/bin/mapcache_seed without password
%wheel ALL=(apache) NOPASSWD: /usr/bin/mapcache_seed
```

これによって、常用するユーザ（e.g. `my-accout`）が `sudo -u apache mapcache_seed ...` をパスワード無しで実行出来るようになります。

#### 2-2-2. /usr/local/bin/cache_invalidate.py

次の内容で `/usr/local/bin/cache_invalidate.py` を作成します。

```python:cache_invalidate.py
#!/usr/bin/env python3

import psycopg2
import psycopg2.extensions
import select
import subprocess
import time
import logging

# 設定
DB_NAME = "gisdb"
DB_USER = "gisdb"
DB_PASSWORD = "xxxxxxxx"
DB_HOST = "192.168.20.11"
DB_PORT = 5432
MAPCACHE_CONFIG = "/etc/mapcache.xml"

# ログ設定
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)

def invalidate_cache(layer_name, conn):
    with conn.cursor() as cur:
        cur.execute("""
            SELECT id, bbox_xmin, bbox_ymin, bbox_xmax, bbox_ymax
            FROM cache_invalidation_log
            WHERE processed = false AND layer_name = %s
        """, (layer_name,))
        rows = cur.fetchall()

        for row in rows:
            id, xmin, ymin, xmax, ymax = row
            logger.info(f"Invalidating {layer_name} (id={id})")

            cmd = [
                "sudo", "-u", "apache", "mapcache_seed",
                "-c", MAPCACHE_CONFIG,
                "-t", layer_name,
                "-e", f"{xmin},{ymin},{xmax},{ymax}",
                "-m", "delete"
            ]

            try:
                subprocess.run(cmd, check=True)
                cur.execute("""
                    UPDATE cache_invalidation_log
                    SET processed = true
                    WHERE id = %s
                """, (id,))
                conn.commit()
                logger.info(f"Successfully processed id={id}")
            except subprocess.CalledProcessError as e:
                logger.error(f"mapcache_seed failed for id={id}: {e}")

def main():
    while True:
        try:
            conn = psycopg2.connect(
                dbname=DB_NAME,
                user=DB_USER,
                password=DB_PASSWORD,
                host=DB_HOST,
                port=DB_PORT
            )
            break
        except psycopg2.OperationalError as e:
            logger.warning("DB 接続失敗。10秒後に再試行します…")
            time.sleep(10)
    conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
    cur = conn.cursor()

    cur.execute("LISTEN cache_invalidation;")
    logger.info("Listening for cache_invalidation notifications...")

    try:
        while True:
            if select.select([conn], [], [], 10) == ([], [], []):
                continue
            conn.poll()
            while conn.notifies:
                notify = conn.notifies.pop(0)
                layer_name = notify.payload.strip()
                logger.info(f"Received notification for layer: {layer_name}")
                invalidate_cache(layer_name, conn)
    finally:
        cur.close()
        conn.close()

def outer_loop():
    try:
        while True:
            try:
                main()
            except psycopg2.OperationalError as e:
                logger.warning(f"DB 接続失敗または切断: {e}. 10秒後に再接続します…")
                time.sleep(10)
    except KeyboardInterrupt:
        logger.info("Exiting by user interrupt.")

if __name__ == "__main__":
    outer_loop()

```

やっていることは、`PostGIS` サーバとの接続を開いて、`cache_invalidation` 通知チャンネルを LISTEN し、

1. 通知を受信したら、レイヤ名を通知から取得
2. レイヤ名に該当する未処理のログ・データを取得
3. `mapcache_seed -m delete` でキャッシュを削除
4. ログ・データの処理済みフラグを立てる
5. 最初に戻って通知を待つ

という仕事です。

なお、`PostGIS` サーバとの接続が確立出来なかったり、途中で切断された場合の再試行を行っているため、若干、構造が複雑になっています。

スクリプトのパーミッションを実行可能に設定しておきます。
```bash
chmod 775 /usr/local/bin/cache_invalidate.py
```

#### 2-2-3. 動作確認

スクリプトを起動して、地物の編集を行って、想定通りにキャッシュが無効化されることを確認して下さい。

```bash
/usr/local/bin/cache_invalidate.py
```

スクリプトは `Ctrl + C` で中止できます。

#### 2-2-4. 自動起動・常駐サービス化

以下の内容で `/etc/systemd/system/cache_invalidator.service` を作成します。

```ini:cache_invalidator.service
[Unit]
Description=PostGIS → MapCache Cache Invalidator
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/cache_invalidate.py
Restart=always
RestartSec=5
User=my-account
Group=my-group
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target
```

そして、サービスを有効化し、起動します。

```bash
sudo systemctl daemon-reexec
sudo systemctl daemon-reload
sudo systemctl enable cache_invalidator
sudo systemctl start cache_invalidator
```

## 3. cron によるシーディング

ここまでの作業で、地物の編集によって必要になるキャッシュの無効化が実現されています。

しかし、無効化（＝削除）されたキャッシュの再作成は完全には終っていないのが普通です。

と言うのは、ある領域のキャッシュを無効化すると、全ての縮尺においてその領域のキャッシュが削除されるのですが、削除されたキャッシュが全て再作成されるためには、その領域の全ての縮尺のデータを `MapCache` に要求する必要があるからです。ユーザが WebGIS でアクセスしなかった縮尺のキャッシュは再作成されずに残ります。

そこで、深夜、丑三つ時に、ひっそりと補完的かつ包括的なシーディングを行うことにします。

### 3-1. /usr/local/bin/nigthly_seed.sh

次の内容で `/usr/local/bin/nightly_seed.sh` を作成します。

```bash:nigthly_seed.sh
#!/bin/bash

# Layer list
LAYERS=("tanada" "spots" "bld")

# MapCache config path
CONFIG="/etc/mapcache.xml"

for LAYER in "${LAYERS[@]}"; do
  echo "Seeding layer: $LAYER"
  sudo -u apache mapcache_seed -c "$CONFIG" -t "$LAYER"
done
```

`LAYERS` は必要に応じて追加・修正して下さい。

このスクリプトを実行すると、指定されたレイヤの全ての縮尺において、未作成のキャッシュが作成されます。（作成済みの所はスキップされます。）

### 3-2. crontab への登録

```bash
sudo crontab -e
```

以下を追加します。

```
30 3 * * * /usr/local/bin/nightly_seed.sh >> /var/log/mapcache_seed.log 2>&1
```

- 3:30 AM に実行
- 実行ユーザは `root`
- ログは `/var/log/mapcache_seed.log`

以上で編集機能と地図画像キャッシュとのズレの問題は解消されます。

## And so, what's next?

`qwc-services` による Web GIS 構築に関する一連の記事はこれで一通り完了しました。次の予定はありません。

### 追記

`MapCache` のグリッドの設計に関して、Web GIS のズーム・レベルの問題と併せて考察した記事を書きました。自分で言うのは変かも知れませんが、`MapCache` の実際の運用において非常に役に立つ記事ですので、ご参照ください。

- [Web GIS のズーム・レベルとタイル・キャッシュの設計](https://qiita.com/softark/items/31829ddfb5feeadb73b2)