Files
SkinbaseNova/docs/optimization-m5-metric-snapshot-retention.md
T
klevze 8a80aae21e Ship production optimization M1-M12.5A: queues, metrics, HTTP observability, and vector search reliability.
Keep similar-ai from tripping the global circuit on a lone URL 502, clamp Qdrant search to 100, and add Server-Timing plus slow-request logging. Studio shared props, Academy S3 exists caching, heat chunking, and Redis/scheduler hygiene stay in this rollout.
2026-08-25 07:58:47 +02:00

330 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M5 — Artwork Metric Snapshot Retention & Growth
```text
STATUS: COMPLETE (local implementation; not deployed)
PRODUCTION WRITES: none
PRODUCTION DELETES: none
OPTIMIZE TABLE: not run
PARTITIONING: not added
NEW ANALYTICS TABLE: not created
```
---
## Verdict
```text
Pruning needed: YES
Recommended retention: 30 days
Downsampling needed: NO
Partitioning needed: NO
Indexes to add: none
Indexes to remove: optional later (redundant idx_artwork_bucket)
Expected storage vs unlimited: large reduction
Expected storage vs current 7d prune: INCREASE (~4.3×) if retention is raised to 30d
```
Hourly snapshots are already pruned daily with `--keep-days=7` (unbounded single `DELETE`). That window is **too short** for monthly leaderboards and Studio 30d views. M5 makes prune batched + configurable and sets default retention to **30 days**.
---
## 1. Table schema (production)
```sql
CREATE TABLE `artwork_metric_snapshots_hourly` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`artwork_id` bigint unsigned NOT NULL,
`bucket_hour` datetime NOT NULL,
`views_count` bigint unsigned NOT NULL DEFAULT '0',
`downloads_count` bigint unsigned NOT NULL DEFAULT '0',
`favourites_count` bigint unsigned NOT NULL DEFAULT '0',
`comments_count` bigint unsigned NOT NULL DEFAULT '0',
`shares_count` bigint unsigned NOT NULL DEFAULT '0',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_artwork_bucket` (`artwork_id`,`bucket_hour`),
KEY `idx_bucket_hour` (`bucket_hour`),
KEY `idx_artwork_bucket` (`artwork_id`,`bucket_hour`),
KEY `idx_bucket_artwork` (`bucket_hour`,`artwork_id`),
CONSTRAINT `artwork_metric_snapshots_hourly_artwork_id_foreign`
FOREIGN KEY (`artwork_id`) REFERENCES `artworks` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB
```
Not partitioned (`CREATE_OPTIONS` empty; `PARTITIONS.PARTITION_NAME` NULL).
Duplicates: **none** (unique `(artwork_id, bucket_hour)`). `has_duplicate_groups=0`.
---
## 2. Production size & range (read-only, 2026-08-23 15:07 UTC)
| Metric | Value |
| ------ | ----- |
| Exact `COUNT(*)` | **8,918,000** |
| `information_schema` est. | 8,622,217 |
| Data | 740.03 MB |
| Index | 1115.47 MB |
| Total | **1855.50 MB** |
| InnoDB `DATA_FREE` | 121.00 MB |
| Distinct artworks | 49,822 |
| Distinct hours | 179 |
| Oldest `bucket_hour` | 2026-08-16 05:00:00 |
| Newest `bucket_hour` | 2026-08-23 15:00:00 |
| Rows per hour (stable) | **49,822** |
| Rows / day | **1,195,728** |
| AUTO_INCREMENT | 153,018,248 (historical insert churn after prune) |
Rows older than 7 days at query time: 548,031 (~11 extra hours until the next 04:00 prune). Oldest row is 05:00 on the day after the last 04:00 cutoff — **daily prune is running**.
---
## 3. Growth estimates
Assumes current write rate stays ~49,822 rows/hour and size scales linearly (~249 MB/day from 1855.5 MB / 7.45 days).
| Horizon | Rows | Approx size |
| ------- | ----: | ----------: |
| Current (~7.5 d) | 8.9M | 1.86 GB |
| 24 hours | 1.2M | ~0.25 GB |
| 7 days | 8.4M | ~1.75 GB |
| **30 days** | **35.9M** | **~7.5 GB** |
| 90 days | 107.6M | ~22.4 GB |
| 365 days | 436.4M | ~91 GB |
| Unlimited 1y of this rate | same as 365d | ~91 GB |
MB/day ≈ **249 MB/day** (data+index) at current density.
---
## 4. Writers (exact)
| Writer | When | What |
| ------ | ---- | ---- |
| `nova:metrics-snapshot-hourly` (`MetricsSnapshotHourlyCommand`) | Scheduler `hourlyAt(2)` | Upsert one row per eligible artwork for `now()->startOfHour()` |
| Eligibility | `--days=60` default | `artworks.created_at` last 60 days **OR** `artwork_stats.ranking_score > 0`; approved, not deleted |
Chunk 1000. Unique key makes reruns idempotent. **No other writers** (no jobs, no HTTP, no observers).
That eligibility (ranking_score>0) explains ~50k IDs/hour, not only last-60-day artworks.
---
## 5. Readers (exact)
| Consumer | Lookback | Notes |
| -------- | -------- | ----- |
| `RecalculateHeatCommand` (`nova:recalculate-heat` every 15 min) | **24h** | DISTINCT artwork_id + load window; writes `artwork_stats.heat_score` |
| `HomepageService::risingRecentActivitySubquery` | **24h** | MAX-MIN deltas |
| `DiscoverController` rising | **24h** | same pattern |
| `DiscoverFeedController` (RSS) | **24h** | same pattern |
| `LeaderboardService` daily/weekly/**monthly** | **1 / 7 / ~30 days** | `MAX-MIN` cumulative counts grouped by artwork; all-time uses `artwork_stats`, not this table |
| `StudioMetricsService::getDashboardKpis` | **30 days** | `SUM(views_count)` — **semantically wrong** (views_count is cumulative totals, not hourly increments). Falls back to lifetime stats if 0. |
| `CreatorJourneyService::biggestDownloadSpike` | **all retained hours** for the creator’s public artworks | No time filter; bounded only by prune |
| `nova:prune-metric-snapshots` | cutoff | writer of deletes |
Frontend/API do not query this table directly; they read heat/leaderboard/studio payloads derived from it.
No daily/weekly/monthly artwork metric aggregate table exists (only collection daily stats elsewhere).
---
## 6. Required historical retention (not assumed)
| Need | Depth |
| ---- | ----- |
| Heat / rising homepage / discover / RSS | 24 hours |
| Weekly leaderboards | 7 days |
| Monthly leaderboards | **~30 days** |
| Studio “views 30d” query | 30 days (even though the SUM is a bad formula) |
| Journey download spike | prefers longer; not a product requirement for unlimited |
| Rec similar-art serving | **none** (M4) |
**Required: 30 days** of hourly rows if monthly leaderboards and Studio 30d stay on this table.
Unlimited / 1 year / 90 days: **not required** by application code.
Current production `--keep-days=7` **under-retains** monthly leaderboard snapshot deltas.
---
## 7. Production query plans (EXPLAIN)
Heat DISTINCT 24h:
```text
type=range key=idx_bucket_artwork rows~2.46M
Using where; Using index; Using temporary
```
Rising GROUP BY 24h:
```text
type=range key=idx_bucket_artwork rows~2.46M
Using index condition; Using temporary
```
Studio 30d for `user_id=1`:
```text
artworks: ref artworks_user_id_index (~449 rows)
snapshots: ref uq_artwork_bucket (artwork_id) ~411 rows, Using index condition
```
Leaderboard monthly GROUP BY (full table, 30d > retained 7d so it scans retained set):
```text
type=index key=uq_artwork_bucket rows~8.87M Using where
```
Prune count `bucket_hour < now()-7d`:
```text
type=range key=idx_bucket_artwork rows~1.1M Using where; Using index
```
Indexes are used. 24h range still estimates millions of rows because ~50k artworks × 24 hours ≈ 1.2M (optimizer overestimate 2.46M). Acceptable for hourly/15-min jobs; leaderboard monthly on 30d would scan ~36M rows if retention grows — still one scheduled hourly job, not request path.
---
## 8. Pruning / downsample / partition / archive
### Pruning: YES
Already scheduled. Replace unbounded `DELETE WHERE bucket_hour < cutoff` with PK-id batches.
### Downsampling: NO
No existing daily artwork snapshot table. Monthly scores only need MIN/MAX of cumulative counters in the window; hourly rows are convenient. A second table is more operational cost than 30d hourly until size is painful (~7.5 GB still fits RAM buffer pool 10 GB). Revisit if table exceeds ~15–20 GB.
### Archive: NO
Nothing reads off-box history.
### Partitioning: NO
Rolling 7–30 day window + batched DELETE is simpler than `PARTITION BY RANGE (TO_DAYS(bucket_hour))` + monthly `ALTER`/`DROP PARTITION`. Not justified at 1.86 GB (or 7.5 GB).
---
## 9. Delete vs InnoDB free space vs filesystem
- `DELETE` removes rows; InnoDB keeps pages in the tablespace (`DATA_FREE` today 121 MB).
- Disk file (`ibd`) typically **does not shrink**.
- Filesystem reclaim needs `OPTIMIZE TABLE` / `ALTER TABLE ... ENGINE=InnoDB` (rebuild) — **do not run automatically on production**.
- After raising retention, size grows; after later lowering it, expect logical free space, not a smaller `.ibd` until a rebuild is planned in a maintenance window.
---
## 10. Implementation (local)
- Config `config/metrics.php` + env `ARTWORK_METRIC_HOURLY_RETENTION_DAYS` default **30**.
- `PruneMetricSnapshotsCommand`: count eligible → loop `SELECT id … LIMIT chunk` → `DELETE WHERE id IN (…)`, sleep, logs per batch, `--dry-run`, `--max-batches`.
- Scheduler: `nova:prune-metric-snapshots` **without** hardcoded `--keep-days=7`.
- Tests for keep-days, batches, idempotence, dry-run, config default, max-batches.
### Production-safe properties
| Requirement | How |
| ----------- | --- |
| Bounded batches | `--chunk` default 5000 |
| No giant DELETE | one `whereIn` per batch |
| Avoid long locks | small PK deletes + `--sleep-ms=50` |
| Scheduler-safe | `withoutOverlapping`; daily 04:00 |
| Idempotent | re-run deletes 0 extra rows |
| Observable | info logs per batch + completed |
| Configurable | env + `--keep-days` override |
---
## 11. Indexes
Keep:
- `PRIMARY (id)` — batched prune
- `uq_artwork_bucket (artwork_id, bucket_hour)` — upsert + per-artwork time
- `idx_bucket_artwork (bucket_hour, artwork_id)` — heat/rising/prune range
Optional later (not in this milestone):
- Drop `idx_artwork_bucket` — duplicate of unique key
- Drop `idx_bucket_hour` — prefix of `idx_bucket_artwork`
Do not add indexes. Do not drop on production in M5 (online DDL on 1.85 GB).
---
## 12. Expected storage after M5 deploy
If `ARTWORK_METRIC_HOURLY_RETENTION_DAYS=30` on production:
- Table grows from ~1.86 GB toward **~7.5 GB** over ~3 weeks.
- That is **correctness**, not reduction.
If production must stay small, set `ARTWORK_METRIC_HOURLY_RETENTION_DAYS=7` and accept monthly leaderboard snapshot windows of only 7 days. **Do not** leave the scheduler at 7 days while believing Studio/monthly boards have 30d of hourly history.
Storage reduction vs never pruning: **~91 GB/year avoided**.
---
## 13. Deployment (later milestone — not M5)
1. Ship code + `config/metrics.php`.
2. Set env on server: `ARTWORK_METRIC_HOURLY_RETENTION_DAYS=30` (or 7 if size is prioritized).
3. `php artisan config:cache` as the deploy already does.
4. Do **not** run prune by hand on first deploy unless a dry-run is wanted: `php artisan nova:prune-metric-snapshots --dry-run`.
5. Do **not** `OPTIMIZE TABLE`.
6. Watch next 04:00 run logs for batch counts.
If raising 7 → 30: prune deletes almost nothing until the table ages to 30 days.
---
## 14. Rollback
- Restore previous command (single DELETE) and scheduler `--keep-days=7`.
- Or set env back to `7` with the new command (preferred).
- No schema change to roll back.
---
## 15. Tests
- `tests/Feature/PruneMetricSnapshotsCommandTest.php` (new)
- Existing `tests/Feature/RisingEngineTest.php` prune example still valid with `--keep-days=7`
---
## 16. Files changed
```text
config/metrics.php (new)
app/Console/Commands/PruneMetricSnapshotsCommand.php (batched prune)
routes/console.php (no hardcoded 7)
.env.example
docs/cli-reference.md
docs/optimization-m5-metric-snapshot-retention.md (new)
tests/Feature/PruneMetricSnapshotsCommandTest.php (new)
```
---
## 17. Risks
- Raising retention to 30d **increases** MySQL size and leaderboard monthly scan cost.
- Studio 30d `SUM(views_count)` remains an incorrect metric (cumulative summed across hours). Out of M5 scope.
- Journey spike still limited to retained hours.
- Batched prune of ~1.2M rows/day at chunk 5000 ≈ 240 batches; 50ms sleep ≈ 12s extra plus delete time. Fine for 04:00.
- Redundant indexes still cost ~index-heavy 1.1 GB; dropping them is a later DDL decision.
---
## 18. What was not done
- No production SQL writes/deletes
- No `OPTIMIZE TABLE`
- No partitioning
- No new aggregate table
- No change to snapshot writer eligibility (`--days=60` + ranking_score)
- M1–M4 still undeployed