fix(db): /dev/shm боевого postgres — 64 МБ умолчания Docker → 1 ГБ #2819

Merged
lekss361 merged 1 commit from fix/2812-postgres-shm into main 2026-08-15 19:34:35 +00:00
Collaborator

WIP: в заголовке стоит намеренно — это замок от merge, а не «не доделано».
Правка готова и проверена. Мержить её должен владелец и в названное окно:
merge = ближайший деплой пересоздаёт контейнер боевой БД.

Что меняется

Одна строка у сервиса postgres в docker-compose.prod.yml:

shm_size: 1gb

Больше в сервисе не тронуто ничего (ports, volumes, restart, healthcheck, сети — как были).

Почему это простой, а не «просто деплой»

docker-compose.prod.yml — триггер deploy.yml, а shm_size это свойство создания контейнера.
Шаг docker compose -p gendesign -f docker-compose.prod.yml up -d (deploy.yml:421) захватывает и postgres,
поэтому первый же деплой после merge пересоздаст контейнер БД, когда бы этот деплой ни случился.
restart тут не поможет — свойство читается только при создании.

  • Простой: пересоздание postgres (по замеру #2761 в этом же файле — порядка 10 с; сам я его не мерил).
  • В эти секунды рвутся ВСЕ соединения: запросы в полёте, celery-таски, glitchtip.
  • Данные не трогаются: том postgres_data переживает пересоздание, down -v не выполняется.

Окно

По beat_schedule.py: плотная батч-полоса 03:00–07:00 МСК (гуще всего пн/вт/ср), ежедневная задача
в 09:00 МСК. Постоянно тикают только zombie-cleanup'ы (раз в минуту и раз в 2 минуты) — пропущенный тик
безвреден, следующий подберёт. Пользовательская нагрузка — рабочий день (сам отказ случился в 11:46 МСК).

Наименее плохое окно: 01:00–02:30 МСК (после людей, до батчей) либо 21:00–23:00 МСК.
Худшее — 03:00–07:00 МСК.

Обоснование 1 ГиБ — по замерам, не по рекомендации

dynamic_shared_memory_type = posix (прод, из postgresql.conf) → сегменты DSM параллельных планов
живут в /dev/shm. У контейнера там 64 МБ (умолчание Docker; ShmSize=67108864 в inspect).

Замеры на проде 2026-08-10:

Что Сколько
Постоянный расход (17 сегментов: control + DSA кумулятивной статистики pgstat) 9.75 МиБ — 15% лимита занято до первого запроса
Один параллельный запрос Объектива ~15.4 МиБ (3 одновременных: пик 55.9 МиБ; (55.9−9.75)/3)
Порог отказа 9.75 + 4×15.4 = 71.3 МиБ > 64 → 4-й одновременный не влезает

Арифметика сходится с инцидентом: два backend-процесса упёрлись, пока остальные держали память.

Нижняя граница. 128 МиБ хватает лишь на ~7–8 одновременных. Потолок конкурентности —
celery --concurrency=8 плюс request-path, то есть ~12 тяжёлых запросов → минимум 256 МиБ.
Локальный A/B это подтвердил: при 6 одновременных запросах реальный спрос 113 МиБ, то есть 128 МиБ
заполнены на 88% — впритык.

Верхняя граница. /dev/shm — tmpfs, страницы выделяются по факту: значение это потолок, а не резерв
(свежий контейнер с 1g: 1.0G 1.1M 1023M, занято 0). Платить приходится только за то, что реально
взято. На хосте 11 ГиБ RAM, свободно ~6 ГиБ, swap уже занят целиком (3/3 ГиБ) — то есть при росте
tmpfs вытеснять некуда, он пойдёт прямо из RAM. Поэтому выше 2 ГиБ на этом хосте я бы не шёл.

1 ГиБ = ~65 таких запросов, ~5× запаса над реалистичным пиком в 12, и место под медленно растущий
базовый расход pgstat. Абсолютный теоретический максимум им не покрыт (max_connections=100 × 15.4 МиБ
≈ 1.5 ГиБ), но 100 одновременных тяжёлых аналитических запросов — это уже другая авария.

Проверка без прода (зелёный CI тут ничего не доказывает)

postgres:16, GUC как на проде (work_mem=4MB, max_parallel_workers_per_gather=2,
dynamic_shared_memory_type=posix, shared_buffers=128MB), 600k строк шириной ~2 КБ.
Между прогонами различается только --shm-size. 6 одновременных запросов:

shm_size результат пик /dev/shm
64m 4 из 6 упали could not resize shared memory segment ... No space left on device 57.3 МиБ (упёрлись в лимит)
128m 0 отказов 113.3 МиБ (88% лимита)
256m 0 отказов 102 МиБ
1g 0 отказов 113.3 МиБ

Пик при 1g (113 МиБ) — это реальный спрос: 64 МиБ его резали почти вдвое.
План проверяется на параллельность в самом скрипте (plan_gather_nodes), cost-GUC не трогаются.

Что сейчас видит пользователь

Отказ ловится graceful-обёртками, но по-разному, и не везде видно:

Место Что делает Видно ли пользователю
special_indices §25 artificial_demand _unavailable(reason="ошибка расчёта (см. логи)") Да, честное «недоступно»
market_metrics._query_stock все счётчики → 0 (n_lots/n_sold/obj_count) Нет. Ноль неотличим от «в районе правда нет лотов». Производные (velocity/absorption) уходят в None по гейту n_lots > 0, confidence падает в low — это единственный намёк
market_metrics._query_sales_window rows = [] → продажи окна 0 Нет, так же
sales_series источник B return {} Нет вообще. Ряд продаж молча строится по одному источнику из двух

То есть худший случай из названных в issue подтверждается: тихая пустота, а не видимая ошибка.
Срочность от этого выше, чем кажется по «6 ошибок в мониторинге».

Альтернатива без простоя (если окно не сейчас)

ALTER DATABASE gendesign SET max_parallel_workers_per_gather = 0;

Контекст GUC — user, рестарт не нужен. Убирает параллельные планы → DSM запросами не берётся вовсе.

Цена (замерено на проде, прогоны чередованием, прогретый кэш — холодный первый прогон даёт
обратную картину и обманывает):

Форма запроса parallel=2 parallel=0 цена отказа от параллелизма
district-scoped (bitmap+sort, как у упавших) 622 / 652 мс 1390 / 1544 мс ×2.2, +~0.77 с на вызов
full-table (форма v_objective_lots_latest) 1880 / 1905 мс 1964 / 2258 мс ×1.05–1.15 (в пределах шума, упирается в I/O)

Такие district-вызовы идут в отчёте не по одному (market_metrics дёргается из 9 forecast/scoring-путей —
это по комментариям в коде, не мой замер), так что порядок эффекта — единицы–десяток секунд на отчёт.

⚠️ Оговорка: ALTER DATABASE действует на новые соединения. Пул SQLAlchemy держит открытые —
эффект наступит не мгновенно, а по мере их переоткрытия.

Выбор

простой скорость что чинит
A. shm_size: 1gb (этот PR) ~10 с на пересоздание БД без изменений причину
B. ALTER DATABASE ... = 0 нет −0.77 с на district-вызов, единицы–десяток секунд на отчёт симптом

B обратима одной командой (RESET) и годится как временная мера до окна. Это не взаимоисключающие
варианты: можно поставить B сегодня и снять её после того, как A уедет.

Откат A

Убрать строку и пересоздать контейнер (снова 64 МиБ). Том с данными не затрагивается.

Про trade-in

Дыра структурно та же и в tradein-postgres: /dev/shm тоже 64 МБ (ShmSize=67108864), тот же
dynamic_shared_memory_type=posix, тот же max_parallel_workers_per_gather=2, и планировщик на его
данных реально выбирает Parallel Hash (проверил EXPLAIN на listings ⋈ listing_sources).

Но по данным она не стреляет: за 9 минут живого трафика /dev/shm не сдвинулся с 1.03 МиБ ни на
байт, и в логах ноль таких ошибок. Оговорка честная: журнал глубиной всего 4 суток (journald для обоих
стеков появился 2026-08-06), так что «ноль» — это ноль за 4 дня, а не за всё время.

В этот PR я его не тащу: это отдельный контейнер, отдельный простой и отдельное решение, а весь
смысл этого PR — одна строка и одно решение. deploy-tradein.yml поднимает захардкоженный список
сервисов без postgres, так что сам он никогда не пересоздаётся — понадобится такое же ручное окно.
Готовая строка для будущего PR — shm_size: 1gb у postgres в tradein-mvp/docker-compose.prod.yml.

Опровергнутое по ходу

  1. «6 групп ошибок» — это не 6 проблем и не 6 инцидентов. Имя сегмента (/PostgreSQL.3812907982)
    попадает в заголовок, а он у GlitchTip участвует в фингерпринте → каждое событие завело свою группу,
    у всех count=1. Это один инцидент длиной 1.2 с (08:46:09.65–08:46:10.83) внутри одного разбора
    участка 66:41:0702017:131. На срочность влияет в обе стороны: проблем меньше, но и «6 в сутки» как
    частота — неверно, за 4 суток логов это единственный случай.
  2. Диагноз «дело в /dev/shm» подтверждён, но не тем доводом. Решает не то, что на диске свободно:
    temp_file_limit = -1 (то есть лимита на temp-файлы нет вовсе, упереться в него нельзя), а сортировки
    в этих планах и правда спиллят на диск — и спокойно, по 5.7 МБ на воркера. Отсекает именно связка
    dynamic_shared_memory_type=posix + имя сегмента /PostgreSQL.<handle> (так POSIX-DSM именует свои
    объекты в /dev/shm) + ERRCODE_DISK_FULL от ftruncate. Ни WAL, ни pg_temp таких имён не дают.
  3. Мой собственный первый замер цены варианта B был неверен и говорил обратное. Холодный прогон
    показал parallel=0 БЫСТРЕЕ (1515 мс против 3802 мс) — потому что первый прогон грел кэш для второго.
    С чередованием картина перевернулась. Если бы я остановился на первом замере, отключение параллелизма
    выглядело бы бесплатным улучшением.
  4. Локальное воспроизведение сначала не воспроизводило ничего и об этом стоит сказать прямо: первый
    вариант дал plan_gather_nodes=0 (плана-параллели нет) и 8 из 8 «успехов» — зелёный результат,
    который не проверял ничего. Второй дал параллельный план, но спрос 0.5 МиБ на запрос: 64 МиБ таким
    не исчерпать. Рабочим стал третий. Санити-проверка на параллельность плана оставлена в скрипте
    именно поэтому.
  5. _query_artificial_demand документирует graceful-поведение, которого в нём нет. Docstring обещает
    «Сбой → {n_sold:0,n_mortgage:0} (НЕ crash)», но try/except в функции отсутствует — ловит внешний
    _run в compute_special_indices, и результат другой: не нули, а _unavailable. На поведение сейчас
    не влияет (обёртка есть), но docstring врёт про контракт.

Refs #2812

> **WIP: в заголовке стоит намеренно — это замок от merge, а не «не доделано».** > Правка готова и проверена. Мержить её должен владелец и в названное окно: > **merge = ближайший деплой пересоздаёт контейнер боевой БД.** ## Что меняется Одна строка у сервиса `postgres` в `docker-compose.prod.yml`: ```yaml shm_size: 1gb ``` Больше в сервисе не тронуто ничего (`ports`, `volumes`, `restart`, healthcheck, сети — как были). ## Почему это простой, а не «просто деплой» `docker-compose.prod.yml` — триггер `deploy.yml`, а `shm_size` это свойство **создания** контейнера. Шаг `docker compose -p gendesign -f docker-compose.prod.yml up -d` (deploy.yml:421) захватывает и `postgres`, поэтому **первый же деплой после merge пересоздаст контейнер БД**, когда бы этот деплой ни случился. `restart` тут не поможет — свойство читается только при создании. - Простой: пересоздание postgres (по замеру #2761 в этом же файле — порядка 10 с; сам я его не мерил). - В эти секунды рвутся ВСЕ соединения: запросы в полёте, celery-таски, glitchtip. - Данные не трогаются: том `postgres_data` переживает пересоздание, `down -v` не выполняется. ### Окно По `beat_schedule.py`: плотная батч-полоса **03:00–07:00 МСК** (гуще всего пн/вт/ср), ежедневная задача в 09:00 МСК. Постоянно тикают только zombie-cleanup'ы (раз в минуту и раз в 2 минуты) — пропущенный тик безвреден, следующий подберёт. Пользовательская нагрузка — рабочий день (сам отказ случился в 11:46 МСК). Наименее плохое окно: **01:00–02:30 МСК** (после людей, до батчей) либо **21:00–23:00 МСК**. Худшее — 03:00–07:00 МСК. ## Обоснование 1 ГиБ — по замерам, не по рекомендации `dynamic_shared_memory_type = posix` (прод, из `postgresql.conf`) → сегменты DSM параллельных планов живут в `/dev/shm`. У контейнера там 64 МБ (умолчание Docker; `ShmSize=67108864` в inspect). Замеры на проде 2026-08-10: | Что | Сколько | |---|---| | Постоянный расход (17 сегментов: control + DSA кумулятивной статистики pgstat) | **9.75 МиБ** — 15% лимита занято до первого запроса | | Один параллельный запрос Объектива | **~15.4 МиБ** (3 одновременных: пик 55.9 МиБ; (55.9−9.75)/3) | | Порог отказа | 9.75 + 4×15.4 = **71.3 МиБ > 64** → 4-й одновременный не влезает | Арифметика сходится с инцидентом: два backend-процесса упёрлись, пока остальные держали память. **Нижняя граница.** 128 МиБ хватает лишь на ~7–8 одновременных. Потолок конкурентности — celery `--concurrency=8` плюс request-path, то есть ~12 тяжёлых запросов → **минимум 256 МиБ**. Локальный A/B это подтвердил: при 6 одновременных запросах реальный спрос 113 МиБ, то есть 128 МиБ заполнены на 88% — впритык. **Верхняя граница.** `/dev/shm` — tmpfs, страницы выделяются по факту: значение это **потолок, а не резерв** (свежий контейнер с `1g`: `1.0G 1.1M 1023M`, занято 0). Платить приходится только за то, что реально взято. На хосте 11 ГиБ RAM, свободно ~6 ГиБ, **swap уже занят целиком (3/3 ГиБ)** — то есть при росте tmpfs вытеснять некуда, он пойдёт прямо из RAM. Поэтому **выше 2 ГиБ на этом хосте я бы не шёл**. **1 ГиБ** = ~65 таких запросов, ~5× запаса над реалистичным пиком в 12, и место под медленно растущий базовый расход pgstat. Абсолютный теоретический максимум им не покрыт (`max_connections=100` × 15.4 МиБ ≈ 1.5 ГиБ), но 100 одновременных тяжёлых аналитических запросов — это уже другая авария. ## Проверка без прода (зелёный CI тут ничего не доказывает) `postgres:16`, GUC как на проде (`work_mem=4MB`, `max_parallel_workers_per_gather=2`, `dynamic_shared_memory_type=posix`, `shared_buffers=128MB`), 600k строк шириной ~2 КБ. Между прогонами различается **только** `--shm-size`. 6 одновременных запросов: | shm_size | результат | пик /dev/shm | |---|---|---| | `64m` | **4 из 6 упали** `could not resize shared memory segment ... No space left on device` | 57.3 МиБ (упёрлись в лимит) | | `128m` | 0 отказов | 113.3 МиБ (88% лимита) | | `256m` | 0 отказов | 102 МиБ | | `1g` | 0 отказов | 113.3 МиБ | Пик при `1g` (113 МиБ) — это реальный спрос: 64 МиБ его резали почти вдвое. План проверяется на параллельность в самом скрипте (`plan_gather_nodes`), cost-GUC не трогаются. ## Что сейчас видит пользователь Отказ ловится graceful-обёртками, но по-разному, и не везде видно: | Место | Что делает | Видно ли пользователю | |---|---|---| | `special_indices` §25 artificial_demand | `_unavailable(reason="ошибка расчёта (см. логи)")` | **Да**, честное «недоступно» | | `market_metrics._query_stock` | все счётчики → **0** (`n_lots/n_sold/obj_count`) | **Нет.** Ноль неотличим от «в районе правда нет лотов». Производные (velocity/absorption) уходят в None по гейту `n_lots > 0`, confidence падает в low — это единственный намёк | | `market_metrics._query_sales_window` | `rows = []` → продажи окна 0 | **Нет**, так же | | `sales_series` источник B | `return {}` | **Нет вообще.** Ряд продаж молча строится по одному источнику из двух | То есть худший случай из названных в issue подтверждается: **тихая пустота**, а не видимая ошибка. Срочность от этого выше, чем кажется по «6 ошибок в мониторинге». ## Альтернатива без простоя (если окно не сейчас) ```sql ALTER DATABASE gendesign SET max_parallel_workers_per_gather = 0; ``` Контекст GUC — `user`, рестарт не нужен. Убирает параллельные планы → DSM запросами не берётся вовсе. Цена (замерено на проде, прогоны чередованием, прогретый кэш — холодный первый прогон даёт **обратную** картину и обманывает): | Форма запроса | parallel=2 | parallel=0 | цена отказа от параллелизма | |---|---|---|---| | district-scoped (bitmap+sort, как у упавших) | 622 / 652 мс | 1390 / 1544 мс | **×2.2, +~0.77 с на вызов** | | full-table (форма `v_objective_lots_latest`) | 1880 / 1905 мс | 1964 / 2258 мс | ×1.05–1.15 (в пределах шума, упирается в I/O) | Такие district-вызовы идут в отчёте не по одному (`market_metrics` дёргается из 9 forecast/scoring-путей — это по комментариям в коде, не мой замер), так что порядок эффекта — **единицы–десяток секунд на отчёт**. ⚠️ Оговорка: `ALTER DATABASE` действует на **новые** соединения. Пул SQLAlchemy держит открытые — эффект наступит не мгновенно, а по мере их переоткрытия. ## Выбор | | простой | скорость | что чинит | |---|---|---|---| | **A. `shm_size: 1gb`** (этот PR) | ~10 с на пересоздание БД | без изменений | причину | | **B. `ALTER DATABASE ... = 0`** | нет | −0.77 с на district-вызов, единицы–десяток секунд на отчёт | симптом | B обратима одной командой (`RESET`) и годится как временная мера до окна. Это не взаимоисключающие варианты: можно поставить B сегодня и снять её после того, как A уедет. ## Откат A Убрать строку и пересоздать контейнер (снова 64 МиБ). Том с данными не затрагивается. ## Про trade-in Дыра **структурно та же и в `tradein-postgres`**: `/dev/shm` тоже 64 МБ (`ShmSize=67108864`), тот же `dynamic_shared_memory_type=posix`, тот же `max_parallel_workers_per_gather=2`, и планировщик на его данных **реально выбирает Parallel Hash** (проверил `EXPLAIN` на `listings ⋈ listing_sources`). Но по данным она **не стреляет**: за 9 минут живого трафика `/dev/shm` не сдвинулся с 1.03 МиБ ни на байт, и в логах ноль таких ошибок. Оговорка честная: журнал глубиной всего 4 суток (journald для обоих стеков появился 2026-08-06), так что «ноль» — это ноль за 4 дня, а не за всё время. **В этот PR я его не тащу**: это отдельный контейнер, отдельный простой и отдельное решение, а весь смысл этого PR — одна строка и одно решение. `deploy-tradein.yml` поднимает захардкоженный список сервисов без `postgres`, так что сам он никогда не пересоздаётся — понадобится такое же ручное окно. Готовая строка для будущего PR — `shm_size: 1gb` у `postgres` в `tradein-mvp/docker-compose.prod.yml`. ## Опровергнутое по ходу 1. **«6 групп ошибок» — это не 6 проблем и не 6 инцидентов.** Имя сегмента (`/PostgreSQL.3812907982`) попадает в заголовок, а он у GlitchTip участвует в фингерпринте → каждое событие завело свою группу, у всех `count=1`. Это **один инцидент длиной 1.2 с** (08:46:09.65–08:46:10.83) внутри одного разбора участка `66:41:0702017:131`. На срочность влияет в обе стороны: проблем меньше, но и «6 в сутки» как частота — неверно, за 4 суток логов это единственный случай. 2. **Диагноз «дело в `/dev/shm`» подтверждён, но не тем доводом.** Решает не то, что на диске свободно: `temp_file_limit = -1` (то есть лимита на temp-файлы нет вовсе, упереться в него нельзя), а сортировки в этих планах и правда спиллят на диск — и спокойно, по 5.7 МБ на воркера. Отсекает именно связка `dynamic_shared_memory_type=posix` + имя сегмента `/PostgreSQL.<handle>` (так POSIX-DSM именует свои объекты в `/dev/shm`) + `ERRCODE_DISK_FULL` от `ftruncate`. Ни WAL, ни `pg_temp` таких имён не дают. 3. **Мой собственный первый замер цены варианта B был неверен и говорил обратное.** Холодный прогон показал parallel=0 БЫСТРЕЕ (1515 мс против 3802 мс) — потому что первый прогон грел кэш для второго. С чередованием картина перевернулась. Если бы я остановился на первом замере, отключение параллелизма выглядело бы бесплатным улучшением. 4. **Локальное воспроизведение сначала не воспроизводило ничего** и об этом стоит сказать прямо: первый вариант дал `plan_gather_nodes=0` (плана-параллели нет) и 8 из 8 «успехов» — зелёный результат, который не проверял ничего. Второй дал параллельный план, но спрос 0.5 МиБ на запрос: 64 МиБ таким не исчерпать. Рабочим стал третий. Санити-проверка на параллельность плана оставлена в скрипте именно поэтому. 5. **`_query_artificial_demand` документирует graceful-поведение, которого в нём нет.** Docstring обещает «Сбой → {n_sold:0,n_mortgage:0} (НЕ crash)», но `try/except` в функции отсутствует — ловит внешний `_run` в `compute_special_indices`, и результат другой: не нули, а `_unavailable`. На поведение сейчас не влияет (обёртка есть), но docstring врёт про контракт. Refs #2812
bot-backend added 1 commit 2026-08-10 10:32:47 +00:00
fix(db): /dev/shm боевого postgres — 64 МБ умолчания Docker на 1 ГиБ
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
6ab359a127
Параллельные планы Постгреса размещают DSM-сегменты в /dev/shm
(dynamic_shared_memory_type=posix). Контейнеру БД никто не задавал shm_size,
поэтому там держалось умолчание Docker — 64 МБ, и запросы Объектива падали
с psycopg.errors.DiskFull «could not resize shared memory segment».
Текст ошибки называет НЕ тот ресурс: на разделе 28 ГБ свободно, кончался
именно /dev/shm.

Замеры на проде 2026-08-10 (не рекомендация из интернета):
  постоянный расход  ~9.8 МиБ (DSA кумулятивной статистики pgstat)
  один параллельный  ~15.4 МиБ (3 одновременных → 55.9 МиБ из 64)
  → 4-й одновременный запрос не влезает; ровно это и наблюдалось
    (6 отказов за 1.2 с из двух backend-процессов)

Нижняя граница: 128 МиБ покрывают лишь ~7-8 одновременных, а потолок
celery (--concurrency=8) плюс request-path даёт ~12 → минимум 256 МиБ.
Верхняя: /dev/shm это tmpfs, страницы выделяются по факту, поэтому
значение — потолок, а не резерв; на хосте 11 ГиБ RAM (свободно ~6),
2 ГиБ я бы не переходил. 1 ГиБ = ~65 таких запросов, ~5x запаса.

Локальная проверка A/B (postgres:16, GUC как на проде, различается только
--shm-size): 6 одновременных параллельных запросов
  64m → 4 отказа, пик 57.3 МиБ (упёрлись в лимит)
  1g  → 0 отказов, пик 113.3 МиБ (реальный спрос вдвое выше 64 МиБ)

Refs #2812
lekss361 changed title from WIP: fix(db): /dev/shm боевого postgres — 64 МБ умолчания Docker → 1 ГиБ (#2812) to fix(db): /dev/shm боевого postgres — 64 МБ умолчания Docker → 1 ГБ 2026-08-15 19:33:55 +00:00
lekss361 merged commit 9848f0b803 into main 2026-08-15 19:34:35 +00:00
lekss361 deleted branch fix/2812-postgres-shm 2026-08-15 19:34:36 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2819
No description provided.