Add persistent million-row discovery scale profile
This commit is contained in:
parent
b639db157c
commit
ebaaeaf517
|
|
@ -1695,11 +1695,12 @@ export const Hooks = {
|
||||||
|
|
||||||
RequestDiscovery: {
|
RequestDiscovery: {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.storageKey = "wnh:request-discovery-view"
|
this.storageKey = this.el.dataset.storageKey || "wnh:request-discovery-view"
|
||||||
|
this.mapTarget = this.el.dataset.mapTarget || "#request-map"
|
||||||
this.allowedViews = ["list", "split", "map"]
|
this.allowedViews = ["list", "split", "map"]
|
||||||
|
|
||||||
this.resizeMap = () => {
|
this.resizeMap = () => {
|
||||||
document.querySelector("#request-map")?._aidMap?.resize()
|
document.querySelector(this.mapTarget)?._aidMap?.resize()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.persistView = () => {
|
this.persistView = () => {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,32 @@
|
||||||
# Optional measurement-only override. It constrains CPU visibility for each
|
# Optional measurement-only override. Every service is pinned to the same
|
||||||
# long-running BEAM container so scheduler-count and memory behavior can be
|
# explicit host CPU set so the isolated stack can reproduce contention on a
|
||||||
# reproduced on a many-core development host. Values are experiment inputs,
|
# small CPU budget without constraining the external load generator. The BEAM
|
||||||
# not production recommendations or minimum requirements.
|
# services additionally use explicit scheduler counts. Values are experiment
|
||||||
|
# inputs, not production recommendations or minimum requirements.
|
||||||
services:
|
services:
|
||||||
|
docker-api-proxy:
|
||||||
|
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
|
||||||
|
|
||||||
|
db:
|
||||||
|
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
|
||||||
|
|
||||||
web:
|
web:
|
||||||
|
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
|
||||||
cpus: ${CPU_REPLAY_WEB_CPUS:?Set CPU_REPLAY_WEB_CPUS for this experiment}
|
cpus: ${CPU_REPLAY_WEB_CPUS:?Set CPU_REPLAY_WEB_CPUS for this experiment}
|
||||||
environment:
|
environment:
|
||||||
ERL_ZFLAGS: "+Q ${ERLANG_PORT_LIMIT:-65536} +S ${CPU_REPLAY_WEB_SCHEDULERS:?Set CPU_REPLAY_WEB_SCHEDULERS}:${CPU_REPLAY_WEB_SCHEDULERS:?Set CPU_REPLAY_WEB_SCHEDULERS}"
|
ERL_ZFLAGS: "+Q ${ERLANG_PORT_LIMIT:-65536} +S ${CPU_REPLAY_WEB_SCHEDULERS:?Set CPU_REPLAY_WEB_SCHEDULERS}:${CPU_REPLAY_WEB_SCHEDULERS:?Set CPU_REPLAY_WEB_SCHEDULERS}"
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
|
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
|
||||||
cpus: ${CPU_REPLAY_WORKER_CPUS:?Set CPU_REPLAY_WORKER_CPUS for this experiment}
|
cpus: ${CPU_REPLAY_WORKER_CPUS:?Set CPU_REPLAY_WORKER_CPUS for this experiment}
|
||||||
environment:
|
environment:
|
||||||
ERL_ZFLAGS: "+Q ${ERLANG_PORT_LIMIT:-65536} +S ${CPU_REPLAY_WORKER_SCHEDULERS:?Set CPU_REPLAY_WORKER_SCHEDULERS}:${CPU_REPLAY_WORKER_SCHEDULERS:?Set CPU_REPLAY_WORKER_SCHEDULERS}"
|
ERL_ZFLAGS: "+Q ${ERLANG_PORT_LIMIT:-65536} +S ${CPU_REPLAY_WORKER_SCHEDULERS:?Set CPU_REPLAY_WORKER_SCHEDULERS}:${CPU_REPLAY_WORKER_SCHEDULERS:?Set CPU_REPLAY_WORKER_SCHEDULERS}"
|
||||||
|
|
|
||||||
12
compose.scale.yaml
Normal file
12
compose.scale.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
services:
|
||||||
|
proxy:
|
||||||
|
ports:
|
||||||
|
- "${SCALE_HTTPS_BIND_ADDRESS:-127.0.0.1}:${SCALE_HTTPS_PORT:-4121}:443"
|
||||||
|
|
||||||
|
web:
|
||||||
|
labels:
|
||||||
|
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.rule=${TRAEFIK_ROUTER_RULE}
|
||||||
|
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.entrypoints=websecure
|
||||||
|
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.service=${TRAEFIK_APP_NAME}
|
||||||
|
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.middlewares=${TRAEFIK_APP_NAME}-forwarded,${TRAEFIK_APP_NAME}-retry
|
||||||
|
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.tls=true
|
||||||
|
|
@ -193,21 +193,43 @@ startup and rolling-verification scripts check the effective value in every
|
||||||
running web and worker VM.
|
running web and worker VM.
|
||||||
|
|
||||||
On a host with more CPUs than the intended server, the optional
|
On a host with more CPUs than the intended server, the optional
|
||||||
`compose.cpu-replay.yaml` override can constrain the CPUs visible to each
|
`compose.cpu-replay.yaml` override can pin the application, worker, database,
|
||||||
long-running BEAM container:
|
proxy, and supporting services to the same explicit host CPU set. The external
|
||||||
|
load generator remains outside that set:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
CPU_REPLAY_CPUSET=0 \
|
||||||
CPU_REPLAY_WEB_CPUS=1 CPU_REPLAY_WORKER_CPUS=1 \
|
CPU_REPLAY_WEB_CPUS=1 CPU_REPLAY_WORKER_CPUS=1 \
|
||||||
CPU_REPLAY_WEB_SCHEDULERS=1 CPU_REPLAY_WORKER_SCHEDULERS=1 \
|
CPU_REPLAY_WEB_SCHEDULERS=1 CPU_REPLAY_WORKER_SCHEDULERS=1 \
|
||||||
docker compose -f compose.yaml -f compose.cpu-replay.yaml up -d --wait
|
docker compose -f compose.yaml -f compose.cpu-replay.yaml up -d --wait
|
||||||
```
|
```
|
||||||
|
|
||||||
All four values are explicit experiment inputs. The override does not set a memory
|
All five values are explicit experiment inputs. Pinning every stack service to
|
||||||
limit or represent a whole-server CPU budget. The scheduler inputs make total
|
the same one-element CPU set reproduces CPU contention, but does not reproduce
|
||||||
and online BEAM scheduler counts match the intended replay instead of leaving
|
an unknown server's memory, storage, network, kernel, or neighbouring
|
||||||
offline scheduler threads sized from the development host. None of these
|
workloads. The scheduler inputs make total and online BEAM scheduler counts
|
||||||
inputs is a production sizing recommendation. Running ordinary
|
match the intended replay instead of leaving offline scheduler threads sized
|
||||||
`docker compose up` without the override removes the replay constraints.
|
from the development host. None of these inputs is a production sizing
|
||||||
|
recommendation. Running ordinary `docker compose up` without the override
|
||||||
|
removes the replay constraints.
|
||||||
|
|
||||||
|
The isolated lifecycle accepts the same override without touching the ordinary
|
||||||
|
Compose project. The explicit VU and replica values below are experiment
|
||||||
|
inputs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
WNH_LOAD_CPU_REPLAY=true \
|
||||||
|
CPU_REPLAY_CPUSET=0 \
|
||||||
|
CPU_REPLAY_WEB_CPUS=1 CPU_REPLAY_WORKER_CPUS=1 \
|
||||||
|
CPU_REPLAY_WEB_SCHEDULERS=1 CPU_REPLAY_WORKER_SCHEDULERS=1 \
|
||||||
|
WNH_LOAD_WEB_REPLICAS_OVERRIDE=1 \
|
||||||
|
WNH_LOAD_WORKER_REPLICAS_OVERRIDE=1 \
|
||||||
|
WNH_LOAD_HTTP_VUS_OVERRIDE=5 \
|
||||||
|
WNH_LOAD_WS_VUS_OVERRIDE=5 \
|
||||||
|
WNH_LOAD_AUTH_VUS_OVERRIDE=1 \
|
||||||
|
LOAD_DURATION_OVERRIDE=30s \
|
||||||
|
./scripts/load-cycle.sh one-cpu-baseline load
|
||||||
|
```
|
||||||
|
|
||||||
After the Helm rollout, the unchanged kind control-plane container measured
|
After the Helm rollout, the unchanged kind control-plane container measured
|
||||||
1.796 GiB, a reduction of 7.952 GiB (81.6%). Its four application pod cgroups
|
1.796 GiB, a reduction of 7.952 GiB (81.6%). Its four application pod cgroups
|
||||||
|
|
@ -726,6 +748,114 @@ The stop script reads the actual `LOAD_PROJECT`, refuses the staging project
|
||||||
name, and does not delete the volume. Volume deletion is intentionally not
|
name, and does not delete the volume. Volume deletion is intentionally not
|
||||||
automated.
|
automated.
|
||||||
|
|
||||||
|
## Persistent million-row discovery profile
|
||||||
|
|
||||||
|
The write-capable load lifecycle above intentionally cleans its fixtures. For
|
||||||
|
repeatable map, clustering, pagination, and responsive-UI work at high
|
||||||
|
cardinality, a second profile keeps its synthetic database:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/scale-stack-up.sh
|
||||||
|
./scripts/scale-seed.sh
|
||||||
|
./scripts/scale-verify.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The profile is pinned to Compose project `who_need_help_scale`, database
|
||||||
|
`who_need_help_scale`, and named volume
|
||||||
|
`who_need_help_scale_postgres_data`. Its generated credentials live only in
|
||||||
|
ignored `output/runtime/scale.env`. Fixture mutation additionally requires the
|
||||||
|
exact database name and `WNH_SCALE_FIXTURE_CONFIRM=persistent-local-scale`.
|
||||||
|
The seed task refuses a partially populated database instead of mixing data
|
||||||
|
sets. `scale-stack-stop.sh` stops only this Compose project and deliberately
|
||||||
|
preserves the named volume.
|
||||||
|
|
||||||
|
The canonical local fixture contains 1,000,000 requests, 1,000,000 activities,
|
||||||
|
1,000 synthetic request owners, and one scale viewer. Records are distributed
|
||||||
|
across 20 cities and include varied categories, states, urgency, hidden
|
||||||
|
locations, exact public points, and privacy radii. The recorded database size
|
||||||
|
was 1,237,898,943 bytes: 709,197,824 bytes for requests and 505,962,496 bytes
|
||||||
|
for activities including their indexes and TOAST data.
|
||||||
|
|
||||||
|
Every scale service is pinned to CPU `0` in this experiment. Three warm
|
||||||
|
application-level samples recorded the following medians:
|
||||||
|
|
||||||
|
| Viewport | Request list / map | Activity list / map | Map rows / represented records |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| Kyiv | 171.139 / 297.860 ms | 265.442 / 700.397 ms | 1 / 49,485 |
|
||||||
|
| Europe | 23.072 / 2,302.602 ms | 1,544.453 / 4,130.415 ms | 8 / 395,878 |
|
||||||
|
| World | 56.182 / 3,975.605 ms | 3,910.191 / 7,416.714 ms | 10 / 791,753 |
|
||||||
|
|
||||||
|
Server clustering kept each measured map payload between 135 and 1,463 bytes
|
||||||
|
instead of returning hundreds of thousands of points. The broad low-zoom map
|
||||||
|
queries remain the measured optimization target: clustering bounds the
|
||||||
|
network/browser payload, but PostgreSQL still reads and groups the matching
|
||||||
|
records.
|
||||||
|
|
||||||
|
A headed Chrome check used the same persistent profile. It verified 24-card
|
||||||
|
initial pages, duplicate-free 48-card pagination, List/Map/Together layouts,
|
||||||
|
390-by-844 mobile rendering, cluster zoom, and request/activity switching.
|
||||||
|
That check exposed a functional defect: grid-cell coordinates could pan to an
|
||||||
|
empty area after selecting a cluster. Cluster coordinates now use the average
|
||||||
|
of the public points in that group while the snapped cell remains only the
|
||||||
|
grouping and stable-ID key. The full suite then exposed a second privacy
|
||||||
|
regression: an activity close to a narrow viewport could pass the expanded
|
||||||
|
index candidate envelope even when its rounded public point was outside that
|
||||||
|
viewport. The query now retains the indexed candidate filter and also requires
|
||||||
|
an exact intersection against the rounded public point. The final headed check
|
||||||
|
confirmed cluster zoom, an explicit `Search this area` refresh, 390-by-844
|
||||||
|
mobile rendering without horizontal overflow, and no application or map
|
||||||
|
console errors. The only console warning was the expected inability to
|
||||||
|
register a service worker under the local self-signed TLS certificate.
|
||||||
|
|
||||||
|
The first authenticated HTML response now renders an honest loading shell and
|
||||||
|
starts discovery only after LiveView connects. The discovery runs
|
||||||
|
asynchronously, so a disconnected client no longer leaves its LiveView blocked
|
||||||
|
inside a broad spatial query. During the headed verification, reconnects
|
||||||
|
initially caused three identical logical world queries to overlap (nine active
|
||||||
|
PostgreSQL worker processes). The application now coalesces an identical
|
||||||
|
in-flight discovery key within each BEAM node and reuses a completed result for
|
||||||
|
10 seconds, which covers the observed 4.5- and 6.5-second local reconnect
|
||||||
|
intervals. Each key includes the viewer, filters, view mode, and viewport;
|
||||||
|
PubSub mutations invalidate the corresponding key. This is a node-local
|
||||||
|
single-flight optimization, not a cluster-wide cache or a capacity guarantee.
|
||||||
|
|
||||||
|
The final headed request and activity map checks each represented 791,753
|
||||||
|
records as 10 clusters. No fresh application `500`/`503`, database pool timeout,
|
||||||
|
discovery error, or container restart was observed. Screenshots are retained
|
||||||
|
in ignored local evidence:
|
||||||
|
|
||||||
|
- `output/playwright/scale-million-request-map-desktop-stable.png`
|
||||||
|
- `output/playwright/scale-million-activity-map-desktop-stable.png`
|
||||||
|
|
||||||
|
Authenticated HTTP load can reuse a restricted browser state without a login
|
||||||
|
or fixture write:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
WNH_SCALE_READONLY_VUS=<chosen-count> \
|
||||||
|
WNH_SCALE_READONLY_DURATION=<chosen-duration> \
|
||||||
|
WNH_SCALE_READONLY_THINK_SECONDS=<chosen-seconds> \
|
||||||
|
WNH_SCALE_READONLY_LABEL=<unique-label> \
|
||||||
|
./scripts/scale-readonly-load.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The k6 file has a compiled four-route GET allowlist for the request/activity
|
||||||
|
static LiveView shells in Europe and the world. It intentionally does not claim
|
||||||
|
to measure connected LiveView discovery: `scripts/scale-verify.sh` measures the
|
||||||
|
actual list and clustering queries separately. The runner verifies the exact
|
||||||
|
Compose project and database, uses the stack's internal network, and compares
|
||||||
|
counts for users, tokens, requests, activities, assignments, participants,
|
||||||
|
messages, and rate-limit buckets before and after.
|
||||||
|
|
||||||
|
The final 2026-07-28 one-VU, 30-second, one-CPU static-shell observation
|
||||||
|
completed 60 authenticated GETs with 0 HTTP failures and 120/120 checks.
|
||||||
|
Overall HTTP duration was 2.566 ms median, 3.196 ms average, and 8.709 ms
|
||||||
|
maximum. Every tracked table count was identical before and after. The
|
||||||
|
discovery-query medians are the independently measured values in the table
|
||||||
|
above. These are measurements of one local CPU-contention replay, not a
|
||||||
|
production SLO, capacity claim, or minimum resource requirement. Ignored
|
||||||
|
evidence is retained under `output/scale/verification.json` and
|
||||||
|
`output/performance/scale-million-onecpu-20260728-async-shell/`.
|
||||||
|
|
||||||
## Isolated database scale and cursor plans
|
## Isolated database scale and cursor plans
|
||||||
|
|
||||||
Run the database-only before/after measurement with:
|
Run the database-only before/after measurement with:
|
||||||
|
|
|
||||||
|
|
@ -376,15 +376,14 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
SELECT id FROM help_requests
|
SELECT id FROM help_requests
|
||||||
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
||||||
AND location IS NOT NULL
|
AND location IS NOT NULL
|
||||||
AND ST_DWithin(
|
AND location && ST_MakeEnvelope(30.4755, 50.3720, 30.5325, 50.4280, 4326)
|
||||||
location::geography,
|
AND (
|
||||||
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
ST_Intersects(location, ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326))
|
||||||
2000
|
OR ST_DWithin(
|
||||||
)
|
location::geography,
|
||||||
AND ST_DWithin(
|
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
||||||
location::geography,
|
COALESCE(location_radius_meters, 0)
|
||||||
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
)
|
||||||
COALESCE(location_radius_meters, 0)
|
|
||||||
)
|
)
|
||||||
AND requester_id NOT IN (
|
AND requester_id NOT IN (
|
||||||
SELECT blocked_id FROM blocks WHERE blocker_id = #{viewer}
|
SELECT blocked_id FROM blocks WHERE blocker_id = #{viewer}
|
||||||
|
|
@ -397,20 +396,15 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
"help_discovery_clusters" => """
|
"help_discovery_clusters" => """
|
||||||
SELECT
|
SELECT
|
||||||
count(id),
|
count(id),
|
||||||
ST_X(ST_Centroid(ST_Collect(ST_Transform(location, 3857)))),
|
avg(ST_X(location)),
|
||||||
ST_Y(ST_Centroid(ST_Collect(ST_Transform(location, 3857))))
|
avg(ST_Y(location))
|
||||||
FROM help_requests
|
FROM help_requests
|
||||||
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
||||||
AND location IS NOT NULL
|
AND location IS NOT NULL
|
||||||
AND ST_DWithin(
|
AND location && ST_MakeEnvelope(30.4985, 50.3850, 30.5095, 50.4150, 4326)
|
||||||
location::geography,
|
AND ST_Intersects(
|
||||||
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
location,
|
||||||
2000
|
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)
|
||||||
)
|
|
||||||
AND ST_DWithin(
|
|
||||||
location::geography,
|
|
||||||
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
|
||||||
COALESCE(location_radius_meters, 0)
|
|
||||||
)
|
)
|
||||||
GROUP BY ST_SnapToGrid(ST_Transform(location, 3857), 200.0)
|
GROUP BY ST_SnapToGrid(ST_Transform(location, 3857), 200.0)
|
||||||
""",
|
""",
|
||||||
|
|
@ -425,6 +419,37 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
AND starts_at > now() AND join_deadline > now()
|
AND starts_at > now() AND join_deadline > now()
|
||||||
ORDER BY starts_at ASC, id ASC LIMIT 25
|
ORDER BY starts_at ASC, id ASC LIMIT 25
|
||||||
""",
|
""",
|
||||||
|
"activity_discovery_viewport" => """
|
||||||
|
SELECT id FROM activities
|
||||||
|
WHERE status = 'open' AND hidden_at IS NULL
|
||||||
|
AND starts_at > now() AND join_deadline > now()
|
||||||
|
AND location_visibility = 'approximate_public'
|
||||||
|
AND location && ST_MakeEnvelope(30.4985, 50.3850, 30.5095, 50.4150, 4326)
|
||||||
|
ORDER BY starts_at ASC, id ASC LIMIT 25
|
||||||
|
""",
|
||||||
|
"activity_discovery_clusters" => """
|
||||||
|
SELECT
|
||||||
|
count(id),
|
||||||
|
avg(ST_X(public_location)),
|
||||||
|
avg(ST_Y(public_location))
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_MakePoint(
|
||||||
|
round(ST_X(location)::numeric, 2)::double precision,
|
||||||
|
round(ST_Y(location)::numeric, 2)::double precision
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
) AS public_location
|
||||||
|
FROM activities
|
||||||
|
WHERE status = 'open' AND hidden_at IS NULL
|
||||||
|
AND starts_at > now() AND join_deadline > now()
|
||||||
|
AND location_visibility = 'approximate_public'
|
||||||
|
AND location && ST_MakeEnvelope(30.4985, 50.3850, 30.5095, 50.4150, 4326)
|
||||||
|
) AS visible_activities
|
||||||
|
GROUP BY ST_SnapToGrid(ST_Transform(public_location, 3857), 200.0)
|
||||||
|
""",
|
||||||
"activity_participant" => """
|
"activity_participant" => """
|
||||||
SELECT activities.id
|
SELECT activities.id
|
||||||
FROM activities
|
FROM activities
|
||||||
|
|
@ -561,6 +586,8 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
"help_discovery_clusters" => "help_requests_location_geography_index",
|
"help_discovery_clusters" => "help_requests_location_geography_index",
|
||||||
"help_requester" => "help_requests_requester_cursor_index",
|
"help_requester" => "help_requests_requester_cursor_index",
|
||||||
"activity_discovery" => "activities_discovery_cursor_index",
|
"activity_discovery" => "activities_discovery_cursor_index",
|
||||||
|
"activity_discovery_viewport" => "activities_location_index",
|
||||||
|
"activity_discovery_clusters" => "activities_location_index",
|
||||||
"activity_participant" => "activity_participants_user_id_status_index",
|
"activity_participant" => "activity_participants_user_id_status_index",
|
||||||
"messages" => "messages_assignment_cursor_index",
|
"messages" => "messages_assignment_cursor_index",
|
||||||
"activity_messages" => "activity_messages_activity_cursor_index",
|
"activity_messages" => "activity_messages_activity_cursor_index",
|
||||||
|
|
@ -637,15 +664,10 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
SELECT count(*) FROM help_requests
|
SELECT count(*) FROM help_requests
|
||||||
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
||||||
AND location IS NOT NULL
|
AND location IS NOT NULL
|
||||||
AND ST_DWithin(
|
AND location && ST_MakeEnvelope(30.4985, 50.3850, 30.5095, 50.4150, 4326)
|
||||||
location::geography,
|
AND ST_Intersects(
|
||||||
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
location,
|
||||||
2000
|
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)
|
||||||
)
|
|
||||||
AND ST_DWithin(
|
|
||||||
location::geography,
|
|
||||||
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
|
||||||
COALESCE(location_radius_meters, 0)
|
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
@ -655,15 +677,10 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
SELECT 1 FROM help_requests
|
SELECT 1 FROM help_requests
|
||||||
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
||||||
AND location IS NOT NULL
|
AND location IS NOT NULL
|
||||||
AND ST_DWithin(
|
AND location && ST_MakeEnvelope(30.4985, 50.3850, 30.5095, 50.4150, 4326)
|
||||||
location::geography,
|
AND ST_Intersects(
|
||||||
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
location,
|
||||||
2000
|
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)
|
||||||
)
|
|
||||||
AND ST_DWithin(
|
|
||||||
location::geography,
|
|
||||||
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
|
||||||
COALESCE(location_radius_meters, 0)
|
|
||||||
)
|
)
|
||||||
GROUP BY ST_SnapToGrid(ST_Transform(location, 3857), 200.0)
|
GROUP BY ST_SnapToGrid(ST_Transform(location, 3857), 200.0)
|
||||||
) AS viewport_clusters
|
) AS viewport_clusters
|
||||||
|
|
|
||||||
519
lib/mix/tasks/wnh.scale_fixtures.ex
Normal file
519
lib/mix/tasks/wnh.scale_fixtures.ex
Normal file
|
|
@ -0,0 +1,519 @@
|
||||||
|
defmodule Mix.Tasks.Wnh.ScaleFixtures do
|
||||||
|
use Mix.Task
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Accounts.{Scope, User}
|
||||||
|
alias WhoNeedHelp.{Activities, Catalog, Help, Repo}
|
||||||
|
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||||
|
|
||||||
|
@shortdoc "Prepares or verifies a persistent, isolated local discovery-scale data set"
|
||||||
|
@confirmation "persistent-local-scale"
|
||||||
|
@default_rows 1_000_000
|
||||||
|
@synthetic_users 1_000
|
||||||
|
@viewer_email "scale-viewer@example.invalid"
|
||||||
|
|
||||||
|
@impl Mix.Task
|
||||||
|
def run(arguments) do
|
||||||
|
Mix.Task.run("app.start")
|
||||||
|
|
||||||
|
{options, rest, invalid} =
|
||||||
|
OptionParser.parse(arguments,
|
||||||
|
strict: [requests: :integer, activities: :integer, samples: :integer, output: :string]
|
||||||
|
)
|
||||||
|
|
||||||
|
if invalid != [], do: Mix.raise("invalid options: #{inspect(invalid)}")
|
||||||
|
|
||||||
|
action =
|
||||||
|
case rest do
|
||||||
|
[value] when value in ["prepare", "verify"] -> value
|
||||||
|
_other -> Mix.raise(usage())
|
||||||
|
end
|
||||||
|
|
||||||
|
request_count = positive_count!(options, :requests)
|
||||||
|
activity_count = positive_count!(options, :activities)
|
||||||
|
samples = positive_count!(options, :samples, 3)
|
||||||
|
output = Keyword.get(options, :output) || Mix.raise("--output PATH is required")
|
||||||
|
context = verified_context!(output)
|
||||||
|
|
||||||
|
case action do
|
||||||
|
"prepare" -> prepare!(context, request_count, activity_count)
|
||||||
|
"verify" -> verify!(context, request_count, activity_count, samples)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp usage do
|
||||||
|
"usage: mix wnh.scale_fixtures prepare|verify " <>
|
||||||
|
"[--requests COUNT] [--activities COUNT] [--samples COUNT] --output /output/FILE.json"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp positive_count!(options, key, default \\ @default_rows) do
|
||||||
|
value = Keyword.get(options, key, default)
|
||||||
|
if is_integer(value) and value > 0, do: value, else: Mix.raise("--#{key} must be positive")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verified_context!(output) do
|
||||||
|
expected_database = required_env!("WNH_SCALE_EXPECTED_DATABASE")
|
||||||
|
output = Path.expand(output)
|
||||||
|
|
||||||
|
unless System.get_env("WNH_SCALE_FIXTURE_CONFIRM") == @confirmation do
|
||||||
|
Mix.raise("WNH_SCALE_FIXTURE_CONFIRM must equal #{@confirmation}")
|
||||||
|
end
|
||||||
|
|
||||||
|
unless String.starts_with?(output, "/output/") do
|
||||||
|
Mix.raise("--output must resolve below /output")
|
||||||
|
end
|
||||||
|
|
||||||
|
%Postgrex.Result{rows: [[actual_database]]} =
|
||||||
|
Repo.query!("SELECT current_database()", [], log: false)
|
||||||
|
|
||||||
|
unless actual_database == expected_database do
|
||||||
|
Mix.raise(
|
||||||
|
"refusing scale mutation: expected database #{inspect(expected_database)}, " <>
|
||||||
|
"observed #{inspect(actual_database)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
%{database: actual_database, output: output}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prepare!(context, request_count, activity_count) do
|
||||||
|
counts = discovery_counts()
|
||||||
|
|
||||||
|
cond do
|
||||||
|
counts.help_requests == request_count and counts.activities == activity_count ->
|
||||||
|
ensure_viewer!()
|
||||||
|
write_summary!(context, "prepared", request_count, activity_count, %{})
|
||||||
|
|
||||||
|
counts.help_requests != 0 or counts.activities != 0 ->
|
||||||
|
Mix.raise(
|
||||||
|
"the isolated scale database already contains #{counts.help_requests} help requests " <>
|
||||||
|
"and #{counts.activities} activities; refusing to mix data sets"
|
||||||
|
)
|
||||||
|
|
||||||
|
true ->
|
||||||
|
categories = seed_categories!()
|
||||||
|
ensure_viewer!()
|
||||||
|
|
||||||
|
Repo.transaction(
|
||||||
|
fn ->
|
||||||
|
seed_users!()
|
||||||
|
seed_requests!(request_count, categories.help)
|
||||||
|
seed_activities!(activity_count, categories.activity)
|
||||||
|
end,
|
||||||
|
timeout: :infinity
|
||||||
|
)
|
||||||
|
|
||||||
|
Repo.query!(
|
||||||
|
"ANALYZE users, categories, help_requests, activities",
|
||||||
|
[],
|
||||||
|
timeout: :infinity,
|
||||||
|
log: false
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_counts!(request_count, activity_count)
|
||||||
|
write_summary!(context, "prepared", request_count, activity_count, %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
Mix.shell().info(
|
||||||
|
"persistent scale data is ready in #{context.database}: " <>
|
||||||
|
"#{request_count} requests and #{activity_count} activities"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verify!(context, request_count, activity_count, samples) do
|
||||||
|
assert_counts!(request_count, activity_count)
|
||||||
|
viewer = Repo.get_by!(User, email: @viewer_email)
|
||||||
|
scope = Scope.for_user(viewer)
|
||||||
|
|
||||||
|
viewports = %{
|
||||||
|
kyiv: viewport!(30.30, 50.25, 30.80, 50.65, 10),
|
||||||
|
europe: viewport!(-12.0, 34.0, 35.0, 61.0, 4),
|
||||||
|
world: viewport!(-179.99, -80.0, 179.99, 80.0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
measurements =
|
||||||
|
Map.new(viewports, fn {name, viewport} ->
|
||||||
|
request_page =
|
||||||
|
timed_samples(
|
||||||
|
fn -> Help.paginate_open_requests(scope, %{}, viewport: viewport) end,
|
||||||
|
samples
|
||||||
|
)
|
||||||
|
|
||||||
|
request_map =
|
||||||
|
timed_samples(fn -> Help.map_discovery_items(scope, %{}, viewport) end, samples)
|
||||||
|
|
||||||
|
activity_page =
|
||||||
|
timed_samples(
|
||||||
|
fn -> Activities.paginate_open_activities(scope, %{}, viewport: viewport) end,
|
||||||
|
samples
|
||||||
|
)
|
||||||
|
|
||||||
|
activity_map =
|
||||||
|
timed_samples(fn -> Activities.map_discovery_items(scope, %{}, viewport) end, samples)
|
||||||
|
|
||||||
|
{name,
|
||||||
|
%{
|
||||||
|
request_page: page_measurement(request_page),
|
||||||
|
request_map: map_measurement(request_map),
|
||||||
|
activity_page: page_measurement(activity_page),
|
||||||
|
activity_map: map_measurement(activity_map)
|
||||||
|
}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
write_summary!(context, "verified", request_count, activity_count, %{
|
||||||
|
measurement_samples: samples,
|
||||||
|
viewport_measurements: measurements
|
||||||
|
})
|
||||||
|
|
||||||
|
Mix.shell().info("scale verification written to #{context.output}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp seed_categories! do
|
||||||
|
Catalog.seed_defaults()
|
||||||
|
|
||||||
|
categories = Catalog.list_all_categories()
|
||||||
|
|
||||||
|
%{
|
||||||
|
help: category_ids!(categories, :help),
|
||||||
|
activity: category_ids!(categories, :activity)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp category_ids!(categories, mode) do
|
||||||
|
leaves =
|
||||||
|
Enum.filter(categories, fn category ->
|
||||||
|
category.mode == mode and not is_nil(category.parent_id)
|
||||||
|
end)
|
||||||
|
|
||||||
|
ids =
|
||||||
|
case leaves do
|
||||||
|
[] -> categories |> Enum.filter(&(&1.mode == mode)) |> Enum.map(& &1.id)
|
||||||
|
values -> Enum.map(values, & &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
if ids == [], do: Mix.raise("no #{mode} categories were seeded"), else: ids
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_viewer! do
|
||||||
|
password = required_env!("SCALE_VIEWER_PASSWORD")
|
||||||
|
|
||||||
|
unless byte_size(password) in 12..72 do
|
||||||
|
Mix.raise("SCALE_VIEWER_PASSWORD must contain between 12 and 72 bytes")
|
||||||
|
end
|
||||||
|
|
||||||
|
case Repo.get_by(User, email: @viewer_email) do
|
||||||
|
nil ->
|
||||||
|
now = DateTime.utc_now(:second)
|
||||||
|
|
||||||
|
%User{
|
||||||
|
email: @viewer_email,
|
||||||
|
display_name: "Scale Viewer",
|
||||||
|
hashed_password: Bcrypt.hash_pwd_salt(password),
|
||||||
|
confirmed_at: now,
|
||||||
|
accepted_terms_at: now,
|
||||||
|
locale: "en"
|
||||||
|
}
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
%User{} ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp seed_users! do
|
||||||
|
Repo.query!(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (
|
||||||
|
id, email, display_name, confirmed_at, accepted_terms_at, locale,
|
||||||
|
inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('wnh-scale-user-' || value)::uuid,
|
||||||
|
'scale-user-' || value || '@example.invalid',
|
||||||
|
'Scale user ' || value,
|
||||||
|
date_trunc('second', now()),
|
||||||
|
date_trunc('second', now()),
|
||||||
|
CASE value % 3 WHEN 0 THEN 'uk' WHEN 1 THEN 'en' ELSE 'ru' END,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(1, $1) AS value
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""",
|
||||||
|
[@synthetic_users],
|
||||||
|
timeout: :infinity,
|
||||||
|
log: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp seed_requests!(rows, category_ids) do
|
||||||
|
Repo.query!(
|
||||||
|
"""
|
||||||
|
INSERT INTO help_requests (
|
||||||
|
id, title, description, pickup_instructions, structured_data,
|
||||||
|
location_label, location, status, urgency, location_visibility,
|
||||||
|
location_radius_meters, expires_at, requester_id, category_id,
|
||||||
|
inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('wnh-scale-request-v1-' || value)::uuid,
|
||||||
|
'Scale help request ' || value,
|
||||||
|
'Persistent synthetic local scale request used to test viewport discovery and UI.',
|
||||||
|
'Synthetic data only; no real-world action is requested.',
|
||||||
|
'{"scale_fixture":true}'::jsonb,
|
||||||
|
city.name || ' scale area',
|
||||||
|
CASE WHEN value % 97 = 0 THEN NULL ELSE ST_SetSRID(
|
||||||
|
ST_MakePoint(
|
||||||
|
city.longitude + (((value * 97) % 1001) - 500) / 100000.0,
|
||||||
|
city.latitude + (((value * 193) % 1001) - 500) / 100000.0
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
) END,
|
||||||
|
CASE WHEN value % 10 < 8 THEN 'open'
|
||||||
|
WHEN value % 10 = 8 THEN 'completed'
|
||||||
|
ELSE 'cancelled' END,
|
||||||
|
CASE value % 3 WHEN 0 THEN 'now' WHEN 1 THEN 'today' ELSE 'scheduled' END,
|
||||||
|
CASE WHEN value % 97 = 0 THEN 'hidden'
|
||||||
|
WHEN value % 4 = 0 THEN 'exact_public'
|
||||||
|
WHEN value % 4 = 1 THEN 'exact_for_active_match'
|
||||||
|
ELSE 'approximate_public' END,
|
||||||
|
CASE WHEN value % 97 = 0 OR value % 4 = 0 THEN NULL
|
||||||
|
WHEN value % 3 = 0 THEN 500
|
||||||
|
WHEN value % 3 = 1 THEN 1000
|
||||||
|
ELSE 2000 END,
|
||||||
|
date_trunc('second', now()) + ((value % 30) + 1) * interval '1 day',
|
||||||
|
md5('wnh-scale-user-' || ((value % $2) + 1))::uuid,
|
||||||
|
(($3::text[])[((value - 1) % cardinality($3::text[])) + 1])::uuid,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(1, $1) AS value
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT *
|
||||||
|
FROM (
|
||||||
|
VALUES
|
||||||
|
(0, 'Kyiv', 30.5234::double precision, 50.4501::double precision),
|
||||||
|
(1, 'Frankfurt', 8.6821, 50.1109),
|
||||||
|
(2, 'Berlin', 13.4050, 52.5200),
|
||||||
|
(3, 'Warsaw', 21.0122, 52.2297),
|
||||||
|
(4, 'Prague', 14.4378, 50.0755),
|
||||||
|
(5, 'London', -0.1276, 51.5072),
|
||||||
|
(6, 'Paris', 2.3522, 48.8566),
|
||||||
|
(7, 'Madrid', -3.7038, 40.4168),
|
||||||
|
(8, 'Rome', 12.4964, 41.9028),
|
||||||
|
(9, 'New York', -74.0060, 40.7128),
|
||||||
|
(10, 'Toronto', -79.3832, 43.6532),
|
||||||
|
(11, 'Mexico City', -99.1332, 19.4326),
|
||||||
|
(12, 'São Paulo', -46.6333, -23.5505),
|
||||||
|
(13, 'Cape Town', 18.4241, -33.9249),
|
||||||
|
(14, 'Nairobi', 36.8219, -1.2921),
|
||||||
|
(15, 'Delhi', 77.1025, 28.7041),
|
||||||
|
(16, 'Bangkok', 100.5018, 13.7563),
|
||||||
|
(17, 'Tokyo', 139.6917, 35.6895),
|
||||||
|
(18, 'Sydney', 151.2093, -33.8688),
|
||||||
|
(19, 'Auckland', 174.7633, -36.8485)
|
||||||
|
) AS cities(index, name, longitude, latitude)
|
||||||
|
WHERE cities.index = value % 20
|
||||||
|
) AS city
|
||||||
|
""",
|
||||||
|
[rows, @synthetic_users, category_ids],
|
||||||
|
timeout: :infinity,
|
||||||
|
log: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp seed_activities!(rows, category_ids) do
|
||||||
|
Repo.query!(
|
||||||
|
"""
|
||||||
|
INSERT INTO activities (
|
||||||
|
id, title, description, structured_data, location_label, location,
|
||||||
|
location_visibility, status, starts_at, join_deadline, capacity,
|
||||||
|
creator_id, category_id, inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('wnh-scale-activity-v1-' || value)::uuid,
|
||||||
|
'Scale community activity ' || value,
|
||||||
|
'Persistent synthetic local scale activity used to test viewport discovery and UI.',
|
||||||
|
'{"scale_fixture":true}'::jsonb,
|
||||||
|
city.name || ' public meeting area',
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_MakePoint(
|
||||||
|
city.longitude + (((value * 89) % 1001) - 500) / 100000.0,
|
||||||
|
city.latitude + (((value * 181) % 1001) - 500) / 100000.0
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
),
|
||||||
|
CASE WHEN value % 97 = 0 THEN 'hidden' ELSE 'approximate_public' END,
|
||||||
|
CASE WHEN value % 10 < 8 THEN 'open'
|
||||||
|
WHEN value % 10 = 8 THEN 'completed'
|
||||||
|
ELSE 'cancelled' END,
|
||||||
|
date_trunc('second', now()) + ((value % 60) + 2) * interval '1 day',
|
||||||
|
date_trunc('second', now()) + ((value % 60) + 1) * interval '1 day',
|
||||||
|
2 + (value % 19),
|
||||||
|
md5('wnh-scale-user-' || ((value % $2) + 1))::uuid,
|
||||||
|
(($3::text[])[((value - 1) % cardinality($3::text[])) + 1])::uuid,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(1, $1) AS value
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT *
|
||||||
|
FROM (
|
||||||
|
VALUES
|
||||||
|
(0, 'Kyiv', 30.5234::double precision, 50.4501::double precision),
|
||||||
|
(1, 'Frankfurt', 8.6821, 50.1109),
|
||||||
|
(2, 'Berlin', 13.4050, 52.5200),
|
||||||
|
(3, 'Warsaw', 21.0122, 52.2297),
|
||||||
|
(4, 'Prague', 14.4378, 50.0755),
|
||||||
|
(5, 'London', -0.1276, 51.5072),
|
||||||
|
(6, 'Paris', 2.3522, 48.8566),
|
||||||
|
(7, 'Madrid', -3.7038, 40.4168),
|
||||||
|
(8, 'Rome', 12.4964, 41.9028),
|
||||||
|
(9, 'New York', -74.0060, 40.7128),
|
||||||
|
(10, 'Toronto', -79.3832, 43.6532),
|
||||||
|
(11, 'Mexico City', -99.1332, 19.4326),
|
||||||
|
(12, 'São Paulo', -46.6333, -23.5505),
|
||||||
|
(13, 'Cape Town', 18.4241, -33.9249),
|
||||||
|
(14, 'Nairobi', 36.8219, -1.2921),
|
||||||
|
(15, 'Delhi', 77.1025, 28.7041),
|
||||||
|
(16, 'Bangkok', 100.5018, 13.7563),
|
||||||
|
(17, 'Tokyo', 139.6917, 35.6895),
|
||||||
|
(18, 'Sydney', 151.2093, -33.8688),
|
||||||
|
(19, 'Auckland', 174.7633, -36.8485)
|
||||||
|
) AS cities(index, name, longitude, latitude)
|
||||||
|
WHERE cities.index = value % 20
|
||||||
|
) AS city
|
||||||
|
""",
|
||||||
|
[rows, @synthetic_users, category_ids],
|
||||||
|
timeout: :infinity,
|
||||||
|
log: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp viewport!(west, south, east, north, zoom) do
|
||||||
|
{:ok, viewport} =
|
||||||
|
DiscoveryViewport.cast(%{
|
||||||
|
"west" => west,
|
||||||
|
"south" => south,
|
||||||
|
"east" => east,
|
||||||
|
"north" => north,
|
||||||
|
"zoom" => zoom,
|
||||||
|
"width" => 1_280,
|
||||||
|
"height" => 720
|
||||||
|
})
|
||||||
|
|
||||||
|
viewport
|
||||||
|
end
|
||||||
|
|
||||||
|
defp timed_samples(callback, samples) do
|
||||||
|
_warm_result = callback.()
|
||||||
|
|
||||||
|
measurements =
|
||||||
|
Enum.map(1..samples, fn _sample ->
|
||||||
|
{microseconds, result} = :timer.tc(callback)
|
||||||
|
%{elapsed_ms: Float.round(microseconds / 1_000, 3), result: result}
|
||||||
|
end)
|
||||||
|
|
||||||
|
elapsed = measurements |> Enum.map(& &1.elapsed_ms) |> Enum.sort()
|
||||||
|
|
||||||
|
%{
|
||||||
|
elapsed_ms: median(elapsed),
|
||||||
|
min_elapsed_ms: hd(elapsed),
|
||||||
|
max_elapsed_ms: List.last(elapsed),
|
||||||
|
samples_ms: elapsed,
|
||||||
|
result: measurements |> List.last() |> Map.fetch!(:result)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp median(values) do
|
||||||
|
middle = div(length(values), 2)
|
||||||
|
|
||||||
|
if rem(length(values), 2) == 1 do
|
||||||
|
Enum.at(values, middle)
|
||||||
|
else
|
||||||
|
Float.round((Enum.at(values, middle - 1) + Enum.at(values, middle)) / 2, 3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp page_measurement(%{result: page} = measurement) do
|
||||||
|
measurement
|
||||||
|
|> timing_measurement()
|
||||||
|
|> Map.merge(%{
|
||||||
|
returned_rows: length(page.entries),
|
||||||
|
has_next_page: not is_nil(page.next_cursor)
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp map_measurement(%{result: items} = measurement) do
|
||||||
|
measurement
|
||||||
|
|> timing_measurement()
|
||||||
|
|> Map.merge(%{
|
||||||
|
returned_rows: length(items),
|
||||||
|
represented_records: Enum.reduce(items, 0, &(Map.get(&1, :count, 1) + &2)),
|
||||||
|
json_bytes: items |> Jason.encode!() |> byte_size()
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp timing_measurement(measurement) do
|
||||||
|
Map.take(measurement, [:elapsed_ms, :min_elapsed_ms, :max_elapsed_ms, :samples_ms])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assert_counts!(request_count, activity_count) do
|
||||||
|
actual = discovery_counts()
|
||||||
|
|
||||||
|
unless actual.help_requests == request_count and actual.activities == activity_count do
|
||||||
|
Mix.raise(
|
||||||
|
"expected #{request_count} requests and #{activity_count} activities, " <>
|
||||||
|
"found #{actual.help_requests} and #{actual.activities}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp discovery_counts do
|
||||||
|
%{
|
||||||
|
help_requests: scalar!("SELECT count(*) FROM help_requests"),
|
||||||
|
activities: scalar!("SELECT count(*) FROM activities")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp write_summary!(context, status, request_count, activity_count, extra) do
|
||||||
|
File.mkdir_p!(Path.dirname(context.output))
|
||||||
|
|
||||||
|
summary =
|
||||||
|
Map.merge(
|
||||||
|
%{
|
||||||
|
schema_version: 1,
|
||||||
|
status: status,
|
||||||
|
database: context.database,
|
||||||
|
expected: %{help_requests: request_count, activities: activity_count},
|
||||||
|
counts: discovery_counts(),
|
||||||
|
database_bytes: scalar!("SELECT pg_database_size(current_database())"),
|
||||||
|
relation_bytes: relation_sizes(),
|
||||||
|
captured_at: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||||||
|
},
|
||||||
|
extra
|
||||||
|
)
|
||||||
|
|
||||||
|
File.write!(context.output, Jason.encode_to_iodata!(summary, pretty: true))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp relation_sizes do
|
||||||
|
Repo.query!(
|
||||||
|
"""
|
||||||
|
SELECT relation, pg_total_relation_size(relation::regclass)
|
||||||
|
FROM unnest(ARRAY['users', 'categories', 'help_requests', 'activities']) AS relation
|
||||||
|
ORDER BY relation
|
||||||
|
""",
|
||||||
|
[],
|
||||||
|
log: false
|
||||||
|
).rows
|
||||||
|
|> Map.new(fn [name, bytes] -> {name, bytes} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp scalar!(sql) do
|
||||||
|
Repo.query!(sql, [], timeout: :infinity, log: false).rows |> hd() |> hd()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp required_env!(name) do
|
||||||
|
case System.get_env(name) do
|
||||||
|
value when is_binary(value) and value != "" -> value
|
||||||
|
_missing -> Mix.raise("#{name} is required")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -88,12 +88,14 @@ defmodule WhoNeedHelp.Activities do
|
||||||
location_label: fragment("CASE WHEN count(*) = 1 THEN min(?) END", point.location_label),
|
location_label: fragment("CASE WHEN count(*) = 1 THEN min(?) END", point.location_label),
|
||||||
longitude:
|
longitude:
|
||||||
fragment(
|
fragment(
|
||||||
"ST_X(ST_Transform(ST_Centroid(ST_Collect(ST_Transform(?, 3857))), 4326))",
|
"CASE WHEN count(*) = 1 THEN min(ST_X(?)) ELSE avg(ST_X(?)) END",
|
||||||
|
point.public_location,
|
||||||
point.public_location
|
point.public_location
|
||||||
),
|
),
|
||||||
latitude:
|
latitude:
|
||||||
fragment(
|
fragment(
|
||||||
"ST_Y(ST_Transform(ST_Centroid(ST_Collect(ST_Transform(?, 3857))), 4326))",
|
"CASE WHEN count(*) = 1 THEN min(ST_Y(?)) ELSE avg(ST_Y(?)) END",
|
||||||
|
point.public_location,
|
||||||
point.public_location
|
point.public_location
|
||||||
),
|
),
|
||||||
cell_x:
|
cell_x:
|
||||||
|
|
@ -817,7 +819,28 @@ defmodule WhoNeedHelp.Activities do
|
||||||
defp filter_discovery_viewport(query, nil, _filters), do: query
|
defp filter_discovery_viewport(query, nil, _filters), do: query
|
||||||
|
|
||||||
defp filter_discovery_viewport(query, %DiscoveryViewport{} = viewport, filters) do
|
defp filter_discovery_viewport(query, %DiscoveryViewport{} = viewport, filters) do
|
||||||
spatial_condition =
|
candidate_condition =
|
||||||
|
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
|
||||||
|
{west, south, east, north}, condition ->
|
||||||
|
dynamic(
|
||||||
|
[activity],
|
||||||
|
^condition or
|
||||||
|
fragment(
|
||||||
|
"""
|
||||||
|
(
|
||||||
|
? && ST_MakeEnvelope(?, ?, ?, ?, 4326)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
activity.location,
|
||||||
|
^(west - 0.005),
|
||||||
|
^(south - 0.005),
|
||||||
|
^(east + 0.005),
|
||||||
|
^(north + 0.005)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
exact_condition =
|
||||||
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
|
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
|
||||||
{west, south, east, north}, condition ->
|
{west, south, east, north}, condition ->
|
||||||
dynamic(
|
dynamic(
|
||||||
|
|
@ -846,6 +869,8 @@ defmodule WhoNeedHelp.Activities do
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
spatial_condition = dynamic([activity], ^candidate_condition and ^exact_condition)
|
||||||
|
|
||||||
area = filters["area"] || filters[:area]
|
area = filters["area"] || filters[:area]
|
||||||
|
|
||||||
condition =
|
condition =
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ defmodule WhoNeedHelp.Application do
|
||||||
:web ->
|
:web ->
|
||||||
[
|
[
|
||||||
{Oban, oban_client_config},
|
{Oban, oban_client_config},
|
||||||
|
{Task.Supervisor, name: WhoNeedHelp.DiscoveryQueryTaskSupervisor},
|
||||||
|
WhoNeedHelp.DiscoveryQueryCache,
|
||||||
WhoNeedHelpWeb.Presence,
|
WhoNeedHelpWeb.Presence,
|
||||||
WhoNeedHelp.TrackingPresenceCleanup,
|
WhoNeedHelp.TrackingPresenceCleanup,
|
||||||
WhoNeedHelpWeb.Endpoint
|
WhoNeedHelpWeb.Endpoint
|
||||||
|
|
@ -43,6 +45,8 @@ defmodule WhoNeedHelp.Application do
|
||||||
:combined ->
|
:combined ->
|
||||||
[
|
[
|
||||||
{Oban, oban_config},
|
{Oban, oban_config},
|
||||||
|
{Task.Supervisor, name: WhoNeedHelp.DiscoveryQueryTaskSupervisor},
|
||||||
|
WhoNeedHelp.DiscoveryQueryCache,
|
||||||
WhoNeedHelpWeb.Presence,
|
WhoNeedHelpWeb.Presence,
|
||||||
WhoNeedHelp.TrackingPresenceCleanup,
|
WhoNeedHelp.TrackingPresenceCleanup,
|
||||||
WhoNeedHelpWeb.Endpoint
|
WhoNeedHelpWeb.Endpoint
|
||||||
|
|
|
||||||
123
lib/who_need_help/discovery_query_cache.ex
Normal file
123
lib/who_need_help/discovery_query_cache.ex
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
defmodule WhoNeedHelp.DiscoveryQueryCache do
|
||||||
|
@moduledoc """
|
||||||
|
Coalesces identical discovery reads on one web node.
|
||||||
|
|
||||||
|
LiveView reconnects may briefly overlap. Without coalescing, every mount can
|
||||||
|
run the same broad spatial aggregation against PostgreSQL. Completed results
|
||||||
|
are retained only long enough to bridge the reconnect intervals measured by
|
||||||
|
the persistent local scale profile. PubSub-driven refreshes invalidate the
|
||||||
|
exact key before starting a replacement read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
@cache_ttl_ms 10_000
|
||||||
|
@task_supervisor WhoNeedHelp.DiscoveryQueryTaskSupervisor
|
||||||
|
|
||||||
|
def start_link(options) do
|
||||||
|
GenServer.start_link(__MODULE__, options, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch(key, function) when is_function(function, 0) do
|
||||||
|
case GenServer.call(__MODULE__, {:fetch, key, function}, :infinity) do
|
||||||
|
{:ok, value} ->
|
||||||
|
value
|
||||||
|
|
||||||
|
{:error, {kind, reason, stacktrace}} ->
|
||||||
|
:erlang.raise(kind, reason, stacktrace)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def invalidate(key), do: GenServer.call(__MODULE__, {:invalidate, key})
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_options) do
|
||||||
|
{:ok, %{entries: %{}, pending: %{}, refs: %{}}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:fetch, key, function}, from, state) do
|
||||||
|
now = System.monotonic_time(:millisecond)
|
||||||
|
|
||||||
|
case state.entries do
|
||||||
|
%{^key => {expires_at, value}} when expires_at > now ->
|
||||||
|
{:reply, {:ok, value}, state}
|
||||||
|
|
||||||
|
_expired_or_missing ->
|
||||||
|
state = update_in(state.entries, &Map.delete(&1, key))
|
||||||
|
|
||||||
|
case state.pending do
|
||||||
|
%{^key => pending} ->
|
||||||
|
pending = update_in(pending.waiters, &[from | &1])
|
||||||
|
{:noreply, put_in(state.pending[key], pending)}
|
||||||
|
|
||||||
|
_missing ->
|
||||||
|
task =
|
||||||
|
Task.Supervisor.async_nolink(@task_supervisor, fn ->
|
||||||
|
try do
|
||||||
|
{:ok, function.()}
|
||||||
|
catch
|
||||||
|
kind, reason -> {:error, {kind, reason, __STACKTRACE__}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
pending = %{ref: task.ref, waiters: [from]}
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
state
|
||||||
|
|> put_in([:pending, key], pending)
|
||||||
|
|> put_in([:refs, task.ref], key)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:invalidate, key}, _from, state) do
|
||||||
|
{:reply, :ok, update_in(state.entries, &Map.delete(&1, key))}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({ref, result}, state) when is_reference(ref) do
|
||||||
|
case Map.fetch(state.refs, ref) do
|
||||||
|
{:ok, key} ->
|
||||||
|
Process.demonitor(ref, [:flush])
|
||||||
|
%{waiters: waiters} = state.pending[key]
|
||||||
|
Enum.each(waiters, &GenServer.reply(&1, result))
|
||||||
|
|
||||||
|
state =
|
||||||
|
state
|
||||||
|
|> update_in([:pending], &Map.delete(&1, key))
|
||||||
|
|> update_in([:refs], &Map.delete(&1, ref))
|
||||||
|
|> maybe_store(key, result)
|
||||||
|
|
||||||
|
{:noreply, state}
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:DOWN, ref, :process, _pid, reason}, state) do
|
||||||
|
case Map.fetch(state.refs, ref) do
|
||||||
|
{:ok, key} ->
|
||||||
|
%{waiters: waiters} = state.pending[key]
|
||||||
|
result = {:error, {:exit, reason, []}}
|
||||||
|
Enum.each(waiters, &GenServer.reply(&1, result))
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
state
|
||||||
|
|> update_in([:pending], &Map.delete(&1, key))
|
||||||
|
|> update_in([:refs], &Map.delete(&1, ref))}
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_store(state, key, {:ok, value}) do
|
||||||
|
expires_at = System.monotonic_time(:millisecond) + @cache_ttl_ms
|
||||||
|
put_in(state.entries[key], {expires_at, value})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_store(state, _key, {:error, _reason}), do: state
|
||||||
|
end
|
||||||
|
|
@ -68,8 +68,9 @@ defmodule WhoNeedHelp.Help do
|
||||||
|
|
||||||
points =
|
points =
|
||||||
user
|
user
|
||||||
|> open_requests_query(filters, viewport)
|
|> open_requests_query(filters, nil)
|
||||||
|> where([request], not is_nil(request.location))
|
|> where([request], not is_nil(request.location))
|
||||||
|
|> filter_request_map_viewport(viewport)
|
||||||
|> select([request], %{
|
|> select([request], %{
|
||||||
id: request.id,
|
id: request.id,
|
||||||
title: request.title,
|
title: request.title,
|
||||||
|
|
@ -111,12 +112,14 @@ defmodule WhoNeedHelp.Help do
|
||||||
radius_meters: fragment("CASE WHEN count(*) = 1 THEN max(?) END", point.radius_meters),
|
radius_meters: fragment("CASE WHEN count(*) = 1 THEN max(?) END", point.radius_meters),
|
||||||
longitude:
|
longitude:
|
||||||
fragment(
|
fragment(
|
||||||
"ST_X(ST_Transform(ST_Centroid(ST_Collect(ST_Transform(?, 3857))), 4326))",
|
"CASE WHEN count(*) = 1 THEN min(ST_X(?)) ELSE avg(ST_X(?)) END",
|
||||||
|
point.public_location,
|
||||||
point.public_location
|
point.public_location
|
||||||
),
|
),
|
||||||
latitude:
|
latitude:
|
||||||
fragment(
|
fragment(
|
||||||
"ST_Y(ST_Transform(ST_Centroid(ST_Collect(ST_Transform(?, 3857))), 4326))",
|
"CASE WHEN count(*) = 1 THEN min(ST_Y(?)) ELSE avg(ST_Y(?)) END",
|
||||||
|
point.public_location,
|
||||||
point.public_location
|
point.public_location
|
||||||
),
|
),
|
||||||
cell_x:
|
cell_x:
|
||||||
|
|
@ -151,6 +154,58 @@ defmodule WhoNeedHelp.Help do
|
||||||
|> Enum.map(&map_discovery_item(&1, viewport.zoom))
|
|> Enum.map(&map_discovery_item(&1, viewport.zoom))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Discovery lists include a privacy area's intersection with the viewport.
|
||||||
|
# Map markers, however, are loaded by their displayed center. This both
|
||||||
|
# matches normal map panning behaviour and keeps the map query indexable
|
||||||
|
# without evaluating a geography distance against every privacy circle.
|
||||||
|
defp filter_request_map_viewport(query, %DiscoveryViewport{} = viewport) do
|
||||||
|
spatial_condition =
|
||||||
|
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
|
||||||
|
{west, south, east, north}, condition ->
|
||||||
|
dynamic(
|
||||||
|
[request],
|
||||||
|
^condition or
|
||||||
|
fragment(
|
||||||
|
"""
|
||||||
|
(
|
||||||
|
? && ST_MakeEnvelope(?, ?, ?, ?, 4326)
|
||||||
|
AND
|
||||||
|
ST_Intersects(
|
||||||
|
CASE
|
||||||
|
WHEN ? = 'exact_for_active_match' THEN
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_MakePoint(
|
||||||
|
round(ST_X(?)::numeric, 2)::double precision,
|
||||||
|
round(ST_Y(?)::numeric, 2)::double precision
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
)
|
||||||
|
ELSE ?
|
||||||
|
END,
|
||||||
|
ST_MakeEnvelope(?, ?, ?, ?, 4326)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
request.location,
|
||||||
|
^(west - 0.005),
|
||||||
|
^(south - 0.005),
|
||||||
|
^(east + 0.005),
|
||||||
|
^(north + 0.005),
|
||||||
|
request.location_visibility,
|
||||||
|
request.location,
|
||||||
|
request.location,
|
||||||
|
request.location,
|
||||||
|
^west,
|
||||||
|
^south,
|
||||||
|
^east,
|
||||||
|
^north
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
where(query, ^spatial_condition)
|
||||||
|
end
|
||||||
|
|
||||||
def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required")
|
def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required")
|
||||||
|
|
||||||
def visible_open_request?(%Scope{user: user}, %HelpRequest{} = request, filters \\ %{}) do
|
def visible_open_request?(%Scope{user: user}, %HelpRequest{} = request, filters \\ %{}) do
|
||||||
|
|
@ -1127,20 +1182,49 @@ defmodule WhoNeedHelp.Help do
|
||||||
defp filter_discovery_viewport(query, %DiscoveryViewport{} = viewport, filters) do
|
defp filter_discovery_viewport(query, %DiscoveryViewport{} = viewport, filters) do
|
||||||
maximum_public_radius = Enum.max(HelpRequest.allowed_location_radii())
|
maximum_public_radius = Enum.max(HelpRequest.allowed_location_radii())
|
||||||
|
|
||||||
spatial_condition =
|
candidate_condition =
|
||||||
|
Enum.reduce(
|
||||||
|
DiscoveryViewport.padded_envelopes(viewport, maximum_public_radius),
|
||||||
|
dynamic(false),
|
||||||
|
fn {west, south, east, north}, condition ->
|
||||||
|
dynamic(
|
||||||
|
[request],
|
||||||
|
^condition or
|
||||||
|
fragment(
|
||||||
|
"? && ST_MakeEnvelope(?, ?, ?, ?, 4326)",
|
||||||
|
request.location,
|
||||||
|
^west,
|
||||||
|
^south,
|
||||||
|
^east,
|
||||||
|
^north
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
|
exact_condition =
|
||||||
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
|
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
|
||||||
{west, south, east, north}, condition ->
|
{west, south, east, north}, condition ->
|
||||||
dynamic(
|
dynamic(
|
||||||
[request],
|
[request],
|
||||||
^condition or
|
^condition or
|
||||||
fragment(
|
fragment(
|
||||||
"ST_DWithin((?)::geography, ST_MakeEnvelope(?, ?, ?, ?, 4326)::geography, ?) AND ST_DWithin((?)::geography, ST_MakeEnvelope(?, ?, ?, ?, 4326)::geography, COALESCE(?, 0))",
|
"""
|
||||||
|
(
|
||||||
|
ST_Intersects(?, ST_MakeEnvelope(?, ?, ?, ?, 4326))
|
||||||
|
OR
|
||||||
|
ST_DWithin(
|
||||||
|
(?)::geography,
|
||||||
|
ST_MakeEnvelope(?, ?, ?, ?, 4326)::geography,
|
||||||
|
COALESCE(?, 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""",
|
||||||
request.location,
|
request.location,
|
||||||
^west,
|
^west,
|
||||||
^south,
|
^south,
|
||||||
^east,
|
^east,
|
||||||
^north,
|
^north,
|
||||||
^maximum_public_radius,
|
|
||||||
request.location,
|
request.location,
|
||||||
^west,
|
^west,
|
||||||
^south,
|
^south,
|
||||||
|
|
@ -1151,6 +1235,8 @@ defmodule WhoNeedHelp.Help do
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
spatial_condition = dynamic([request], ^candidate_condition and ^exact_condition)
|
||||||
|
|
||||||
area = filters["area"] || filters[:area]
|
area = filters["area"] || filters[:area]
|
||||||
|
|
||||||
condition =
|
condition =
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,47 @@ defmodule WhoNeedHelp.Help.DiscoveryViewport do
|
||||||
[{west, south, 180.0, north}, {-180.0, south, east, north}]
|
[{west, south, 180.0, north}, {-180.0, south, east, north}]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns viewport envelopes expanded by a geodesic point radius.
|
||||||
|
|
||||||
|
The expansion is conservative in longitude at the viewport's highest
|
||||||
|
absolute latitude and preserves antimeridian wrapping. It is intended as an
|
||||||
|
indexable bounding-box prefilter before an exact geography predicate.
|
||||||
|
"""
|
||||||
|
def padded_envelopes(%__MODULE__{} = viewport, radius_meters)
|
||||||
|
when is_number(radius_meters) and radius_meters >= 0 do
|
||||||
|
latitude_delta = radius_meters / 111_320.0
|
||||||
|
|
||||||
|
maximum_latitude =
|
||||||
|
viewport
|
||||||
|
|> then(&max(abs(&1.south), abs(&1.north)))
|
||||||
|
|> min(@max_mercator_latitude)
|
||||||
|
|
||||||
|
longitude_scale = max(:math.cos(degrees_to_radians(maximum_latitude)), 0.01)
|
||||||
|
longitude_delta = radius_meters / (111_320.0 * longitude_scale)
|
||||||
|
|
||||||
|
south =
|
||||||
|
clamp(viewport.south - latitude_delta, -@max_mercator_latitude, @max_mercator_latitude)
|
||||||
|
|
||||||
|
north =
|
||||||
|
clamp(viewport.north + latitude_delta, -@max_mercator_latitude, @max_mercator_latitude)
|
||||||
|
|
||||||
|
viewport
|
||||||
|
|> envelopes()
|
||||||
|
|> Enum.flat_map(fn {west, _south, east, _north} ->
|
||||||
|
longitude_intervals(west - longitude_delta, east + longitude_delta)
|
||||||
|
|> Enum.map(fn {padded_west, padded_east} ->
|
||||||
|
{
|
||||||
|
clamp(padded_west, -180.0, 180.0),
|
||||||
|
south,
|
||||||
|
clamp(padded_east, -180.0, 180.0),
|
||||||
|
north
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|> Enum.uniq()
|
||||||
|
end
|
||||||
|
|
||||||
def cluster_cell_meters(%__MODULE__{} = viewport) do
|
def cluster_cell_meters(%__MODULE__{} = viewport) do
|
||||||
horizontal_cells = max(viewport.width / @cluster_cell_pixels, 1.0)
|
horizontal_cells = max(viewport.width / @cluster_cell_pixels, 1.0)
|
||||||
vertical_cells = max(viewport.height / @cluster_cell_pixels, 1.0)
|
vertical_cells = max(viewport.height / @cluster_cell_pixels, 1.0)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
use WhoNeedHelpWeb, :live_view
|
use WhoNeedHelpWeb, :live_view
|
||||||
|
|
||||||
alias WhoNeedHelp.{Activities, Catalog, Pagination}
|
require Logger
|
||||||
|
|
||||||
|
alias WhoNeedHelp.{Activities, Catalog, DiscoveryQueryCache, Pagination}
|
||||||
alias WhoNeedHelp.Help.DiscoveryViewport
|
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||||
|
|
||||||
@discovery_modes ~w(nearby mine)
|
@discovery_modes ~w(nearby mine)
|
||||||
|
@view_modes ~w(list split map)
|
||||||
@filter_keys ~w(category_id area)
|
@filter_keys ~w(category_id area)
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -18,12 +21,16 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
socket
|
socket
|
||||||
|> assign(:filters, empty_filters())
|
|> assign(:filters, empty_filters())
|
||||||
|> assign(:discovery_mode, "nearby")
|
|> assign(:discovery_mode, "nearby")
|
||||||
|
|> assign(:view_mode, "split")
|
||||||
|
|> assign(:view_explicit, false)
|
||||||
|> assign(:viewport, nil)
|
|> assign(:viewport, nil)
|
||||||
|> assign(:pending_viewport, nil)
|
|> assign(:pending_viewport, nil)
|
||||||
|> assign(:activities, [])
|
|> assign(:activities, [])
|
||||||
|> assign(:activities_cursor, nil)
|
|> assign(:activities_cursor, nil)
|
||||||
|> assign(:map_items, Jason.encode!([]))
|
|> assign(:map_items, Jason.encode!([]))
|
||||||
|> assign(:map_result_count, 0)
|
|> assign(:map_result_count, 0)
|
||||||
|
|> assign(:discovery_loading, true)
|
||||||
|
|> assign(:refresh_timer, nil)
|
||||||
|> assign(:my_activities, [])
|
|> assign(:my_activities, [])
|
||||||
|> assign(:my_activities_cursor, nil)
|
|> assign(:my_activities_cursor, nil)
|
||||||
|> assign(:page_title, gettext("Activities"))
|
|> assign(:page_title, gettext("Activities"))
|
||||||
|
|
@ -35,20 +42,24 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
filters = normalize_filters(params)
|
filters = normalize_filters(params)
|
||||||
viewport = parse_viewport(params)
|
viewport = parse_viewport(params)
|
||||||
discovery_mode = normalize_discovery_mode(params["scope"])
|
discovery_mode = normalize_discovery_mode(params["scope"])
|
||||||
|
view_mode = normalize_view_mode(params["view"])
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:filters, filters)
|
|> assign(:filters, filters)
|
||||||
|> assign(:filter_form, to_form(filters, as: :filters))
|
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||||
|> assign(:discovery_mode, discovery_mode)
|
|> assign(:discovery_mode, discovery_mode)
|
||||||
|
|> assign(:view_mode, view_mode)
|
||||||
|
|> assign(:view_explicit, params["view"] in @view_modes)
|
||||||
|> assign(:viewport, viewport)
|
|> assign(:viewport, viewport)
|
||||||
|> assign(:pending_viewport, nil)
|
|> assign(:pending_viewport, nil)
|
||||||
|> load()}
|
|> load_if_connected()}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({event, activity}, socket)
|
def handle_info({event, activity}, socket)
|
||||||
when event in [:activity_created, :activity_updated] do
|
when event in [:activity_created, :activity_updated] do
|
||||||
|
socket = invalidate_discovery_cache(socket)
|
||||||
user_id = socket.assigns.current_scope.user.id
|
user_id = socket.assigns.current_scope.user.id
|
||||||
already_mine? = Enum.any?(socket.assigns.my_activities, &(&1.id == activity.id))
|
already_mine? = Enum.any?(socket.assigns.my_activities, &(&1.id == activity.id))
|
||||||
|
|
||||||
|
|
@ -76,13 +87,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
socket.assigns.my_activities_cursor
|
socket.assigns.my_activities_cursor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:noreply,
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:activities, activities)
|
|> assign(:activities, activities)
|
||||||
|> assign(:activities_cursor, activities_cursor)
|
|> assign(:activities_cursor, activities_cursor)
|
||||||
|> assign(:my_activities, my_activities)
|
|> assign(:my_activities, my_activities)
|
||||||
|> assign(:my_activities_cursor, my_activities_cursor)
|
|> assign(:my_activities_cursor, my_activities_cursor)
|
||||||
|> refresh_map_items()}
|
|
||||||
|
if socket.assigns.refresh_timer || socket.assigns.view_mode == "list" do
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
timer = Process.send_after(self(), :refresh_discovery, 100)
|
||||||
|
{:noreply, assign(socket, :refresh_timer, timer)}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:my_activity_updated, activity, member?}, socket) do
|
def handle_info({:my_activity_updated, activity, member?}, socket) do
|
||||||
|
|
@ -101,6 +118,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
|> assign(:my_activities_cursor, my_activities_cursor)}
|
|> assign(:my_activities_cursor, my_activities_cursor)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_info(:refresh_discovery, socket) do
|
||||||
|
{:noreply, socket |> assign(:refresh_timer, nil) |> load_if_connected()}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("filter", %{"filters" => filters}, socket) do
|
def handle_event("filter", %{"filters" => filters}, socket) do
|
||||||
{:noreply, push_patch(socket, to: activity_path(socket, filters: normalize_filters(filters)))}
|
{:noreply, push_patch(socket, to: activity_path(socket, filters: normalize_filters(filters)))}
|
||||||
|
|
@ -111,6 +132,20 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
{:noreply, push_patch(socket, to: activity_path(socket, discovery_mode: discovery_mode))}
|
{:noreply, push_patch(socket, to: activity_path(socket, discovery_mode: discovery_mode))}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("set-view", %{"view" => view_mode}, socket)
|
||||||
|
when view_mode in @view_modes do
|
||||||
|
{:noreply, push_patch(socket, to: activity_path(socket, view_mode: view_mode))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("restore-view-mode", %{"view" => view_mode}, socket)
|
||||||
|
when view_mode in @view_modes do
|
||||||
|
if socket.assigns.view_explicit do
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
{:noreply, push_patch(socket, to: activity_path(socket, view_mode: view_mode))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("map-viewport-change", params, socket) do
|
def handle_event("map-viewport-change", params, socket) do
|
||||||
case DiscoveryViewport.cast(params) do
|
case DiscoveryViewport.cast(params) do
|
||||||
{:ok, viewport} ->
|
{:ok, viewport} ->
|
||||||
|
|
@ -160,8 +195,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:activities, activities)
|
|> assign(:activities, activities)
|
||||||
|> assign(:activities_cursor, page.next_cursor)
|
|> assign(:activities_cursor, page.next_cursor)}
|
||||||
|> refresh_map_items()}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("load-more-my-activities", _params, socket) do
|
def handle_event("load-more-my-activities", _params, socket) do
|
||||||
|
|
@ -176,23 +210,36 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
|> assign(:my_activities_cursor, page.next_cursor)}
|
|> assign(:my_activities_cursor, page.next_cursor)}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load(socket) do
|
@impl true
|
||||||
|
def handle_async(:load_discovery, {:ok, result}, socket) do
|
||||||
|
{:noreply, assign(socket, result)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_async(:load_discovery, {:exit, {:shutdown, :cancel}}, socket),
|
||||||
|
do: {:noreply, socket}
|
||||||
|
|
||||||
|
def handle_async(:load_discovery, {:exit, reason}, socket) do
|
||||||
|
Logger.warning("activity discovery load failed: #{Exception.format_exit(reason)}")
|
||||||
|
{:noreply, assign(socket, :discovery_loading, false)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_data(scope, filters, discovery_mode, view_mode, viewport) do
|
||||||
{activities_page, map_items} =
|
{activities_page, map_items} =
|
||||||
case {socket.assigns.discovery_mode, socket.assigns.viewport} do
|
case {discovery_mode, viewport} do
|
||||||
{"nearby", %DiscoveryViewport{} = viewport} ->
|
{"nearby", %DiscoveryViewport{} = viewport} ->
|
||||||
page =
|
page =
|
||||||
Activities.paginate_open_activities(
|
if view_mode == "map" do
|
||||||
socket.assigns.current_scope,
|
%Pagination.Page{}
|
||||||
socket.assigns.filters,
|
else
|
||||||
viewport: viewport
|
Activities.paginate_open_activities(scope, filters, viewport: viewport)
|
||||||
)
|
end
|
||||||
|
|
||||||
items =
|
items =
|
||||||
Activities.map_discovery_items(
|
if view_mode == "list" do
|
||||||
socket.assigns.current_scope,
|
[]
|
||||||
socket.assigns.filters,
|
else
|
||||||
viewport
|
Activities.map_discovery_items(scope, filters, viewport)
|
||||||
)
|
end
|
||||||
|
|
||||||
{page, items}
|
{page, items}
|
||||||
|
|
||||||
|
|
@ -201,22 +248,45 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
my_activities_page =
|
my_activities_page =
|
||||||
if socket.assigns.discovery_mode == "mine" do
|
if discovery_mode == "mine" do
|
||||||
Activities.paginate_my_activities(socket.assigns.current_scope)
|
Activities.paginate_my_activities(scope)
|
||||||
else
|
else
|
||||||
%Pagination.Page{}
|
%Pagination.Page{}
|
||||||
end
|
end
|
||||||
|
|
||||||
activities = activities_page.entries
|
activities = activities_page.entries
|
||||||
|
|
||||||
socket
|
%{
|
||||||
|> assign(:activities, activities)
|
discovery_loading: false,
|
||||||
|> assign(:activities_cursor, activities_page.next_cursor)
|
activities: activities,
|
||||||
|> assign(:my_activities, my_activities_page.entries)
|
activities_cursor: activities_page.next_cursor,
|
||||||
|> assign(:my_activities_cursor, my_activities_page.next_cursor)
|
my_activities: my_activities_page.entries,
|
||||||
|> assign(:filter_form, to_form(socket.assigns.filters, as: :filters))
|
my_activities_cursor: my_activities_page.next_cursor,
|
||||||
|> assign(:map_items, Jason.encode!(map_items))
|
map_items: Jason.encode!(map_items),
|
||||||
|> assign(:map_result_count, Enum.reduce(map_items, 0, &(map_item_count(&1) + &2)))
|
map_result_count: Enum.reduce(map_items, 0, &(map_item_count(&1) + &2))
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_if_connected(socket) do
|
||||||
|
if connected?(socket) do
|
||||||
|
scope = socket.assigns.current_scope
|
||||||
|
filters = socket.assigns.filters
|
||||||
|
discovery_mode = socket.assigns.discovery_mode
|
||||||
|
view_mode = socket.assigns.view_mode
|
||||||
|
viewport = socket.assigns.viewport
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> cancel_async(:load_discovery)
|
||||||
|
|> assign(:discovery_loading, true)
|
||||||
|
|> start_async(:load_discovery, fn ->
|
||||||
|
DiscoveryQueryCache.fetch(
|
||||||
|
discovery_cache_key(scope, filters, discovery_mode, view_mode, viewport),
|
||||||
|
fn -> load_data(scope, filters, discovery_mode, view_mode, viewport) end
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
assign(socket, :discovery_loading, true)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp append_unique(existing, incoming) do
|
defp append_unique(existing, incoming) do
|
||||||
|
|
@ -243,24 +313,27 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
{entries, cursor}
|
{entries, cursor}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp refresh_map_items(%{assigns: %{viewport: %DiscoveryViewport{} = viewport}} = socket) do
|
defp map_item_count(%{type: "cluster", count: count}), do: count
|
||||||
map_items =
|
defp map_item_count(_activity), do: 1
|
||||||
Activities.map_discovery_items(
|
|
||||||
socket.assigns.current_scope,
|
defp invalidate_discovery_cache(socket) do
|
||||||
socket.assigns.filters,
|
:ok =
|
||||||
viewport
|
DiscoveryQueryCache.invalidate(
|
||||||
|
discovery_cache_key(
|
||||||
|
socket.assigns.current_scope,
|
||||||
|
socket.assigns.filters,
|
||||||
|
socket.assigns.discovery_mode,
|
||||||
|
socket.assigns.view_mode,
|
||||||
|
socket.assigns.viewport
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:map_items, Jason.encode!(map_items))
|
|
||||||
|> assign(:map_result_count, Enum.reduce(map_items, 0, &(map_item_count(&1) + &2)))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp refresh_map_items(socket),
|
defp discovery_cache_key(scope, filters, discovery_mode, view_mode, viewport) do
|
||||||
do: socket |> assign(:map_items, Jason.encode!([])) |> assign(:map_result_count, 0)
|
{:activities, scope.user.id, filters, discovery_mode, view_mode, viewport}
|
||||||
|
end
|
||||||
defp map_item_count(%{type: "cluster", count: count}), do: count
|
|
||||||
defp map_item_count(_activity), do: 1
|
|
||||||
|
|
||||||
defp empty_filters, do: %{"category_id" => "", "area" => ""}
|
defp empty_filters, do: %{"category_id" => "", "area" => ""}
|
||||||
|
|
||||||
|
|
@ -282,14 +355,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
|
|
||||||
defp normalize_discovery_mode(_discovery_mode), do: "nearby"
|
defp normalize_discovery_mode(_discovery_mode), do: "nearby"
|
||||||
|
|
||||||
|
defp normalize_view_mode(view_mode) when view_mode in @view_modes, do: view_mode
|
||||||
|
defp normalize_view_mode(_view_mode), do: "split"
|
||||||
|
|
||||||
defp activity_path(socket, overrides) do
|
defp activity_path(socket, overrides) do
|
||||||
filters = Keyword.get(overrides, :filters, socket.assigns.filters)
|
filters = Keyword.get(overrides, :filters, socket.assigns.filters)
|
||||||
viewport = Keyword.get(overrides, :viewport, socket.assigns.viewport)
|
viewport = Keyword.get(overrides, :viewport, socket.assigns.viewport)
|
||||||
discovery_mode = Keyword.get(overrides, :discovery_mode, socket.assigns.discovery_mode)
|
discovery_mode = Keyword.get(overrides, :discovery_mode, socket.assigns.discovery_mode)
|
||||||
|
view_mode = Keyword.get(overrides, :view_mode, socket.assigns.view_mode)
|
||||||
|
|
||||||
params =
|
params =
|
||||||
filters
|
filters
|
||||||
|> Map.reject(fn {_key, value} -> value == "" end)
|
|> Map.reject(fn {_key, value} -> value == "" end)
|
||||||
|
|> Map.put("view", view_mode)
|
||||||
|> then(fn params ->
|
|> then(fn params ->
|
||||||
if discovery_mode == "mine", do: Map.put(params, "scope", "mine"), else: params
|
if discovery_mode == "mine", do: Map.put(params, "scope", "mine"), else: params
|
||||||
end)
|
end)
|
||||||
|
|
@ -399,129 +477,202 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
/>
|
/>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
||||||
<div
|
<section
|
||||||
:if={@discovery_mode == "nearby"}
|
:if={@discovery_mode == "nearby"}
|
||||||
class="mt-7 grid gap-7 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,.8fr)]"
|
id="activity-discovery"
|
||||||
|
phx-hook="RequestDiscovery"
|
||||||
|
data-storage-key="wnh:activity-discovery-view"
|
||||||
|
data-map-target="#activity-map"
|
||||||
|
data-view-mode={@view_mode}
|
||||||
|
data-view-explicit={to_string(@view_explicit)}
|
||||||
|
data-has-viewport={to_string(not is_nil(@viewport))}
|
||||||
|
aria-busy={to_string(@discovery_loading)}
|
||||||
|
class="request-discovery mt-6"
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="request-discovery-toolbar">
|
||||||
<div
|
<div :if={@view_mode != "list" && @discovery_loading}>
|
||||||
:if={is_nil(@viewport)}
|
<strong>{gettext("Loading results…")}</strong>
|
||||||
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
|
</div>
|
||||||
>
|
|
||||||
<.icon name="hero-map" class="mx-auto size-8 text-info" />
|
<div :if={@view_mode != "list" && !@discovery_loading}>
|
||||||
<h2 class="mt-3 text-xl font-bold">{gettext("Choose an area on the map")}</h2>
|
<strong>
|
||||||
<p class="mt-2 text-base-content/60">
|
{ngettext(
|
||||||
{gettext("Only activities from the selected map area will be loaded.")}
|
"%{count} activity in this map area",
|
||||||
|
"%{count} activities in this map area",
|
||||||
|
@map_result_count,
|
||||||
|
count: @map_result_count
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
<p class="text-sm text-base-content/60">
|
||||||
|
{gettext("Move the map, then search the newly visible area.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="request-view-switcher" role="group" aria-label={gettext("Results layout")}>
|
||||||
:if={@viewport && @activities == []}
|
|
||||||
class="rounded-3xl border border-dashed border-base-300 p-12 text-center"
|
|
||||||
>
|
|
||||||
<h2 class="text-xl font-bold">{gettext("No open activities in this area")}</h2>
|
|
||||||
<p class="mt-2 text-base-content/60">
|
|
||||||
{gettext("Move or zoom the map to search somewhere else.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<.link
|
|
||||||
:for={activity <- @activities}
|
|
||||||
id={"open-activity-#{activity.id}"}
|
|
||||||
navigate={~p"/activities/#{activity.id}"}
|
|
||||||
class="block rounded-3xl border border-base-300 bg-base-100 p-6 transition hover:border-info/50"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<div class="mb-3 flex flex-wrap gap-2">
|
|
||||||
<span class="badge badge-info badge-outline">
|
|
||||||
{Catalog.category_path(
|
|
||||||
activity.category,
|
|
||||||
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span class="badge badge-outline">
|
|
||||||
{activity.approved_participant_count}/{activity.capacity}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-xl font-bold">{activity.title}</h2>
|
|
||||||
<p class="mt-2 line-clamp-2 text-sm text-base-content/65">{activity.description}</p>
|
|
||||||
</div>
|
|
||||||
<.icon name="hero-chevron-right" class="mt-2 size-5 shrink-0" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-5 flex flex-wrap gap-x-5 gap-y-2 border-t border-base-300 pt-4 text-xs text-base-content/65">
|
|
||||||
<span>📍 {activity.location_label}</span>
|
|
||||||
<span class="inline-flex items-center gap-1">
|
|
||||||
<.icon name="hero-clock" class="size-4" />
|
|
||||||
<.local_time
|
|
||||||
id={"activity-start-#{activity.id}"}
|
|
||||||
value={activity.starts_at}
|
|
||||||
date_style="medium"
|
|
||||||
time_style="short"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{gettext("by %{name}",
|
|
||||||
name: activity.creator.display_name || gettext("Community member")
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</.link>
|
|
||||||
<button
|
|
||||||
:if={@activities_cursor}
|
|
||||||
type="button"
|
|
||||||
phx-click="load-more-activities"
|
|
||||||
class="btn btn-outline w-full"
|
|
||||||
>
|
|
||||||
{gettext("Load more")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside>
|
|
||||||
<div class="request-discovery-map-shell">
|
|
||||||
<div
|
|
||||||
id="activity-map"
|
|
||||||
phx-hook="AidMap"
|
|
||||||
phx-update="ignore"
|
|
||||||
data-discovery-map="true"
|
|
||||||
data-auto-fit="false"
|
|
||||||
data-items={@map_items}
|
|
||||||
data-viewport={viewport_json(@viewport)}
|
|
||||||
data-item-path-prefix="/activities"
|
|
||||||
data-item-label={gettext("Open activity")}
|
|
||||||
data-cluster-label={gettext("%{count} activities", count: "%{count}")}
|
|
||||||
data-map-unavailable-label={
|
|
||||||
gettext("The map is unavailable in this browser. Activity details remain usable.")
|
|
||||||
}
|
|
||||||
data-map-retry-label={gettext("Try map again")}
|
|
||||||
data-map-recenter-label={gettext("Show location on map")}
|
|
||||||
class="aid-map"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
:if={@pending_viewport}
|
|
||||||
id="search-activity-map-area"
|
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="search-map-area"
|
phx-click="set-view"
|
||||||
class="btn btn-info request-map-search-button"
|
phx-value-view="list"
|
||||||
|
aria-pressed={to_string(@view_mode == "list")}
|
||||||
|
class={["btn btn-sm", @view_mode == "list" && "btn-active"]}
|
||||||
>
|
>
|
||||||
<.icon name="hero-magnifying-glass" class="size-4" />
|
<.icon name="hero-list-bullet" class="size-4" />
|
||||||
{gettext("Search this area")}
|
<span>{gettext("List")}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="set-view"
|
||||||
|
phx-value-view="split"
|
||||||
|
aria-pressed={to_string(@view_mode == "split")}
|
||||||
|
class={["btn btn-sm request-view-split", @view_mode == "split" && "btn-active"]}
|
||||||
|
>
|
||||||
|
<.icon name="hero-squares-2x2" class="size-4" />
|
||||||
|
<span>{gettext("Together")}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="set-view"
|
||||||
|
phx-value-view="map"
|
||||||
|
aria-pressed={to_string(@view_mode == "map")}
|
||||||
|
class={["btn btn-sm", @view_mode == "map" && "btn-active"]}
|
||||||
|
>
|
||||||
|
<.icon name="hero-map" class="size-4" />
|
||||||
|
<span>{gettext("Map")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-sm text-base-content/60">
|
</div>
|
||||||
{ngettext(
|
|
||||||
"%{count} activity in this map area",
|
<div class="request-discovery-content">
|
||||||
"%{count} activities in this map area",
|
<div id="activity-results-list" class="request-results-list space-y-4">
|
||||||
@map_result_count,
|
<div
|
||||||
count: @map_result_count
|
:if={@discovery_loading}
|
||||||
)}
|
class="flex min-h-32 items-center justify-center rounded-3xl border border-base-300 bg-base-100"
|
||||||
</p>
|
role="status"
|
||||||
</aside>
|
>
|
||||||
</div>
|
<span class="loading loading-spinner loading-md text-info" aria-hidden="true" />
|
||||||
|
<span class="sr-only">{gettext("Loading results…")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:if={!@discovery_loading && is_nil(@viewport)}
|
||||||
|
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
|
||||||
|
>
|
||||||
|
<.icon name="hero-map" class="mx-auto size-8 text-info" />
|
||||||
|
<h2 class="mt-3 text-xl font-bold">{gettext("Choose an area on the map")}</h2>
|
||||||
|
<p class="mt-2 text-base-content/60">
|
||||||
|
{gettext("Only activities from the selected map area will be loaded.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:if={!@discovery_loading && @viewport && @activities == []}
|
||||||
|
class="rounded-3xl border border-dashed border-base-300 p-12 text-center"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-bold">{gettext("No open activities in this area")}</h2>
|
||||||
|
<p class="mt-2 text-base-content/60">
|
||||||
|
{gettext("Move or zoom the map to search somewhere else.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.link
|
||||||
|
:for={activity <- @activities}
|
||||||
|
id={"open-activity-#{activity.id}"}
|
||||||
|
navigate={~p"/activities/#{activity.id}"}
|
||||||
|
class="block rounded-3xl border border-base-300 bg-base-100 p-6 transition hover:border-info/50"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="mb-3 flex flex-wrap gap-2">
|
||||||
|
<span class="badge badge-info badge-outline">
|
||||||
|
{Catalog.category_path(
|
||||||
|
activity.category,
|
||||||
|
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-outline">
|
||||||
|
{activity.approved_participant_count}/{activity.capacity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-xl font-bold">{activity.title}</h2>
|
||||||
|
<p class="mt-2 line-clamp-2 text-sm text-base-content/65">{activity.description}</p>
|
||||||
|
</div>
|
||||||
|
<.icon name="hero-chevron-right" class="mt-2 size-5 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex flex-wrap gap-x-5 gap-y-2 border-t border-base-300 pt-4 text-xs text-base-content/65">
|
||||||
|
<span>📍 {activity.location_label}</span>
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<.icon name="hero-clock" class="size-4" />
|
||||||
|
<.local_time
|
||||||
|
id={"activity-start-#{activity.id}"}
|
||||||
|
value={activity.starts_at}
|
||||||
|
date_style="medium"
|
||||||
|
time_style="short"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{gettext("by %{name}",
|
||||||
|
name: activity.creator.display_name || gettext("Community member")
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</.link>
|
||||||
|
<button
|
||||||
|
:if={@activities_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-activities"
|
||||||
|
class="btn btn-outline w-full"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="request-discovery-map-panel">
|
||||||
|
<div class="request-discovery-map-shell">
|
||||||
|
<div
|
||||||
|
id="activity-map"
|
||||||
|
phx-hook="AidMap"
|
||||||
|
phx-update="ignore"
|
||||||
|
data-discovery-map="true"
|
||||||
|
data-auto-fit="false"
|
||||||
|
data-items={@map_items}
|
||||||
|
data-viewport={viewport_json(@viewport)}
|
||||||
|
data-item-path-prefix="/activities"
|
||||||
|
data-item-label={gettext("Open activity")}
|
||||||
|
data-cluster-label={gettext("%{count} activities", count: "%{count}")}
|
||||||
|
data-map-unavailable-label={
|
||||||
|
gettext("The map is unavailable in this browser. Activity details remain usable.")
|
||||||
|
}
|
||||||
|
data-map-retry-label={gettext("Try map again")}
|
||||||
|
data-map-recenter-label={gettext("Show location on map")}
|
||||||
|
class="aid-map request-discovery-map"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
:if={@pending_viewport}
|
||||||
|
id="search-activity-map-area"
|
||||||
|
type="button"
|
||||||
|
phx-click="search-map-area"
|
||||||
|
class="btn btn-info request-map-search-button"
|
||||||
|
>
|
||||||
|
<.icon name="hero-magnifying-glass" class="size-4" />
|
||||||
|
{gettext("Search this area")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section :if={@discovery_mode == "mine"} id="my-activities" class="mt-7">
|
<section :if={@discovery_mode == "mine"} id="my-activities" class="mt-7">
|
||||||
<div
|
<div
|
||||||
:if={@my_activities == []}
|
:if={@discovery_loading}
|
||||||
|
class="flex min-h-32 items-center justify-center rounded-3xl border border-base-300 bg-base-100"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span class="loading loading-spinner loading-md text-info" aria-hidden="true" />
|
||||||
|
<span class="sr-only">{gettext("Loading results…")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:if={!@discovery_loading && @my_activities == []}
|
||||||
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
|
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
|
||||||
>
|
>
|
||||||
<.icon name="hero-user-group" class="mx-auto size-8 text-info" />
|
<.icon name="hero-user-group" class="mx-auto size-8 text-info" />
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
defmodule WhoNeedHelpWeb.RequestLive.Index do
|
defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
use WhoNeedHelpWeb, :live_view
|
use WhoNeedHelpWeb, :live_view
|
||||||
|
|
||||||
alias WhoNeedHelp.{Help, Pagination, Trust}
|
require Logger
|
||||||
|
|
||||||
|
alias WhoNeedHelp.{DiscoveryQueryCache, Help, Pagination, Trust}
|
||||||
alias WhoNeedHelp.Help.DiscoveryViewport
|
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||||
|
|
||||||
@discovery_modes ~w(nearby mine)
|
@discovery_modes ~w(nearby mine)
|
||||||
@view_modes ~w(list split map)
|
@view_modes ~w(list split map)
|
||||||
@filter_keys ~w(category_id urgency area)
|
@filter_keys ~w(category_id urgency area)
|
||||||
|
@empty_reputation %{
|
||||||
|
completed: 0,
|
||||||
|
unique_people: 0,
|
||||||
|
verified_handovers: 0,
|
||||||
|
location_supported: 0,
|
||||||
|
rating: nil
|
||||||
|
}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
|
@ -27,8 +36,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
|> assign(:query_params, %{})
|
|> assign(:query_params, %{})
|
||||||
|> assign(:requests, [])
|
|> assign(:requests, [])
|
||||||
|> assign(:requests_cursor, nil)
|
|> assign(:requests_cursor, nil)
|
||||||
|
|> assign(:my_requests, [])
|
||||||
|
|> assign(:my_requests_cursor, nil)
|
||||||
|
|> assign(:reputation, @empty_reputation)
|
||||||
|> assign(:map_items, Jason.encode!([]))
|
|> assign(:map_items, Jason.encode!([]))
|
||||||
|> assign(:map_result_count, 0)
|
|> assign(:map_result_count, 0)
|
||||||
|
|> assign(:discovery_loading, true)
|
||||||
|> assign(:refresh_timer, nil)
|
|> assign(:refresh_timer, nil)
|
||||||
|> assign(:page_title, gettext("Nearby help"))
|
|> assign(:page_title, gettext("Nearby help"))
|
||||||
|> assign(:categories, WhoNeedHelp.Catalog.list_categories())}
|
|> assign(:categories, WhoNeedHelp.Catalog.list_categories())}
|
||||||
|
|
@ -55,7 +68,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
normalize_query_params(filters, viewport, view_mode, discovery_mode)
|
normalize_query_params(filters, viewport, view_mode, discovery_mode)
|
||||||
)
|
)
|
||||||
|> refresh_discovery_subscription(viewport, filters, discovery_mode)
|
|> refresh_discovery_subscription(viewport, filters, discovery_mode)
|
||||||
|> load()
|
|> load_if_connected()
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
@ -150,7 +163,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({event, request}, socket) when event in [:request_created, :request_updated] do
|
def handle_info({event, request}, socket) when event in [:request_created, :request_updated] do
|
||||||
socket = maybe_refresh_reputation(socket, request, socket.assigns.current_scope.user.id)
|
socket =
|
||||||
|
socket
|
||||||
|
|> invalidate_discovery_cache()
|
||||||
|
|> maybe_refresh_reputation(request, socket.assigns.current_scope.user.id)
|
||||||
|
|
||||||
if socket.assigns.refresh_timer do
|
if socket.assigns.refresh_timer do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
@ -161,27 +177,64 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info(:refresh_discovery, socket) do
|
def handle_info(:refresh_discovery, socket) do
|
||||||
{:noreply, socket |> assign(:refresh_timer, nil) |> load()}
|
{:noreply, socket |> assign(:refresh_timer, nil) |> load_if_connected()}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load(socket) do
|
@impl true
|
||||||
|
def handle_async(:load_discovery, {:ok, result}, socket) do
|
||||||
|
{:noreply, assign(socket, result)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_async(:load_discovery, {:exit, {:shutdown, :cancel}}, socket),
|
||||||
|
do: {:noreply, socket}
|
||||||
|
|
||||||
|
def handle_async(:load_discovery, {:exit, reason}, socket) do
|
||||||
|
Logger.warning("request discovery load failed: #{Exception.format_exit(reason)}")
|
||||||
|
{:noreply, assign(socket, :discovery_loading, false)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_if_connected(socket) do
|
||||||
|
if connected?(socket) do
|
||||||
|
scope = socket.assigns.current_scope
|
||||||
|
filters = socket.assigns.filters
|
||||||
|
discovery_mode = socket.assigns.discovery_mode
|
||||||
|
view_mode = socket.assigns.view_mode
|
||||||
|
viewport = socket.assigns.viewport
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> cancel_async(:load_discovery)
|
||||||
|
|> assign(:discovery_loading, true)
|
||||||
|
|> start_async(:load_discovery, fn ->
|
||||||
|
DiscoveryQueryCache.fetch(
|
||||||
|
discovery_cache_key(scope, filters, discovery_mode, view_mode, viewport),
|
||||||
|
fn -> load_data(scope, filters, discovery_mode, view_mode, viewport) end
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
assign(socket, :discovery_loading, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_data(scope, filters, discovery_mode, view_mode, viewport) do
|
||||||
{requests_page, map_items} =
|
{requests_page, map_items} =
|
||||||
case {socket.assigns.discovery_mode, socket.assigns.viewport} do
|
case {discovery_mode, viewport} do
|
||||||
{"mine", _viewport} ->
|
{"mine", _viewport} ->
|
||||||
{%Pagination.Page{}, []}
|
{%Pagination.Page{}, []}
|
||||||
|
|
||||||
{"nearby", %DiscoveryViewport{} = viewport} ->
|
{"nearby", %DiscoveryViewport{} = viewport} ->
|
||||||
page =
|
page =
|
||||||
Help.paginate_open_requests(socket.assigns.current_scope, socket.assigns.filters,
|
if view_mode == "map" do
|
||||||
viewport: viewport
|
%Pagination.Page{}
|
||||||
)
|
else
|
||||||
|
Help.paginate_open_requests(scope, filters, viewport: viewport)
|
||||||
|
end
|
||||||
|
|
||||||
items =
|
items =
|
||||||
Help.map_discovery_items(
|
if view_mode == "list" do
|
||||||
socket.assigns.current_scope,
|
[]
|
||||||
socket.assigns.filters,
|
else
|
||||||
viewport
|
Help.map_discovery_items(scope, filters, viewport)
|
||||||
)
|
end
|
||||||
|
|
||||||
{page, items}
|
{page, items}
|
||||||
|
|
||||||
|
|
@ -190,22 +243,22 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
my_requests_page =
|
my_requests_page =
|
||||||
if socket.assigns.discovery_mode == "mine" do
|
if discovery_mode == "mine" do
|
||||||
Help.paginate_my_requests(socket.assigns.current_scope)
|
Help.paginate_my_requests(scope)
|
||||||
else
|
else
|
||||||
%Pagination.Page{}
|
%Pagination.Page{}
|
||||||
end
|
end
|
||||||
|
|
||||||
user = socket.assigns.current_scope.user
|
%{
|
||||||
|
discovery_loading: false,
|
||||||
socket
|
requests: requests_page.entries,
|
||||||
|> assign(:requests, requests_page.entries)
|
requests_cursor: requests_page.next_cursor,
|
||||||
|> assign(:requests_cursor, requests_page.next_cursor)
|
my_requests: my_requests_page.entries,
|
||||||
|> assign(:my_requests, my_requests_page.entries)
|
my_requests_cursor: my_requests_page.next_cursor,
|
||||||
|> assign(:my_requests_cursor, my_requests_page.next_cursor)
|
reputation: Trust.reputation(scope.user.id),
|
||||||
|> assign(:reputation, Trust.reputation(user.id))
|
map_items: Jason.encode!(map_items),
|
||||||
|> assign(:map_items, Jason.encode!(map_items))
|
map_result_count: Enum.reduce(map_items, 0, &(map_item_count(&1) + &2))
|
||||||
|> assign(:map_result_count, Enum.reduce(map_items, 0, &(map_item_count(&1) + &2)))
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp refresh_discovery_subscription(socket, viewport, filters, discovery_mode) do
|
defp refresh_discovery_subscription(socket, viewport, filters, discovery_mode) do
|
||||||
|
|
@ -246,6 +299,25 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
defp map_item_count(%{type: "cluster", count: count}), do: count
|
defp map_item_count(%{type: "cluster", count: count}), do: count
|
||||||
defp map_item_count(_request), do: 1
|
defp map_item_count(_request), do: 1
|
||||||
|
|
||||||
|
defp invalidate_discovery_cache(socket) do
|
||||||
|
:ok =
|
||||||
|
DiscoveryQueryCache.invalidate(
|
||||||
|
discovery_cache_key(
|
||||||
|
socket.assigns.current_scope,
|
||||||
|
socket.assigns.filters,
|
||||||
|
socket.assigns.discovery_mode,
|
||||||
|
socket.assigns.view_mode,
|
||||||
|
socket.assigns.viewport
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
defp discovery_cache_key(scope, filters, discovery_mode, view_mode, viewport) do
|
||||||
|
{:requests, scope.user.id, filters, discovery_mode, view_mode, viewport}
|
||||||
|
end
|
||||||
|
|
||||||
defp empty_filters, do: %{"category_id" => "", "urgency" => "", "area" => ""}
|
defp empty_filters, do: %{"category_id" => "", "urgency" => "", "area" => ""}
|
||||||
|
|
||||||
defp normalize_filters(params) do
|
defp normalize_filters(params) do
|
||||||
|
|
@ -426,10 +498,15 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
data-view-mode={@view_mode}
|
data-view-mode={@view_mode}
|
||||||
data-view-explicit={to_string(@view_explicit)}
|
data-view-explicit={to_string(@view_explicit)}
|
||||||
data-has-viewport={to_string(not is_nil(@viewport))}
|
data-has-viewport={to_string(not is_nil(@viewport))}
|
||||||
|
aria-busy={to_string(@discovery_loading)}
|
||||||
class="request-discovery mt-6"
|
class="request-discovery mt-6"
|
||||||
>
|
>
|
||||||
<div class="request-discovery-toolbar">
|
<div class="request-discovery-toolbar">
|
||||||
<div>
|
<div :if={@view_mode != "list" && @discovery_loading}>
|
||||||
|
<strong>{gettext("Loading results…")}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :if={@view_mode != "list" && !@discovery_loading}>
|
||||||
<strong>
|
<strong>
|
||||||
{ngettext(
|
{ngettext(
|
||||||
"%{count} request in this map area",
|
"%{count} request in this map area",
|
||||||
|
|
@ -480,7 +557,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
<div class="request-discovery-content">
|
<div class="request-discovery-content">
|
||||||
<div id="request-results-list" class="request-results-list space-y-4">
|
<div id="request-results-list" class="request-results-list space-y-4">
|
||||||
<div
|
<div
|
||||||
:if={is_nil(@viewport)}
|
:if={@discovery_loading}
|
||||||
|
class="flex min-h-32 items-center justify-center rounded-3xl border border-base-300 bg-base-100"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span class="loading loading-spinner loading-md text-success" aria-hidden="true" />
|
||||||
|
<span class="sr-only">{gettext("Loading results…")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:if={!@discovery_loading && is_nil(@viewport)}
|
||||||
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
|
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
|
||||||
>
|
>
|
||||||
<.icon name="hero-map" class="mx-auto size-8 text-success" />
|
<.icon name="hero-map" class="mx-auto size-8 text-success" />
|
||||||
|
|
@ -491,7 +577,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
:if={@viewport && @requests == []}
|
:if={!@discovery_loading && @viewport && @requests == []}
|
||||||
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
|
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
|
||||||
>
|
>
|
||||||
<h2 class="text-xl font-bold">{gettext("No open requests in this area")}</h2>
|
<h2 class="text-xl font-bold">{gettext("No open requests in this area")}</h2>
|
||||||
|
|
@ -633,7 +719,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
:if={@my_requests == []}
|
:if={@discovery_loading}
|
||||||
|
class="mt-4 flex min-h-32 items-center justify-center rounded-3xl border border-base-300 bg-base-100"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span class="loading loading-spinner loading-md text-success" aria-hidden="true" />
|
||||||
|
<span class="sr-only">{gettext("Loading results…")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:if={!@discovery_loading && @my_requests == []}
|
||||||
class="mt-4 rounded-3xl border border-dashed border-base-300 p-8 text-center sm:p-10"
|
class="mt-4 rounded-3xl border border-dashed border-base-300 p-8 text-center sm:p-10"
|
||||||
>
|
>
|
||||||
<.icon name="hero-clipboard-document-list" class="mx-auto size-9 text-success" />
|
<.icon name="hero-clipboard-document-list" class="mx-auto size-9 text-success" />
|
||||||
|
|
@ -646,7 +741,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :if={@my_requests != []} class="mt-4 grid gap-3 md:grid-cols-2">
|
<div
|
||||||
|
:if={!@discovery_loading && @my_requests != []}
|
||||||
|
class="mt-4 grid gap-3 md:grid-cols-2"
|
||||||
|
>
|
||||||
<.link
|
<.link
|
||||||
:for={request <- @my_requests}
|
:for={request <- @my_requests}
|
||||||
id={"my-request-#{request.id}"}
|
id={"my-request-#{request.id}"}
|
||||||
|
|
|
||||||
125
load/k6/scale-readonly.js
Normal file
125
load/k6/scale-readonly.js
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { check, sleep } from "k6";
|
||||||
|
import http from "k6/http";
|
||||||
|
import exec from "k6/execution";
|
||||||
|
import { Trend } from "k6/metrics";
|
||||||
|
|
||||||
|
function required(name) {
|
||||||
|
const value = __ENV[name];
|
||||||
|
|
||||||
|
if (value === undefined || value === "") {
|
||||||
|
throw new Error(`${name} is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(name) {
|
||||||
|
const value = Number(required(name));
|
||||||
|
|
||||||
|
if (!Number.isInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`${name} must be a positive integer`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegativeNumber(name) {
|
||||||
|
const value = Number(required(name));
|
||||||
|
|
||||||
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
throw new Error(`${name} must be a non-negative number`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = required("BASE_URL").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
if (baseUrl !== "http://web:4000") {
|
||||||
|
throw new Error("The scale read-only profile is pinned to http://web:4000");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCookie = required("SESSION_COOKIE");
|
||||||
|
const vus = positiveInteger("VUS");
|
||||||
|
const duration = required("DURATION");
|
||||||
|
const thinkSeconds = nonNegativeNumber("THINK_SECONDS");
|
||||||
|
|
||||||
|
const viewports = [
|
||||||
|
{
|
||||||
|
path:
|
||||||
|
"/requests?west=22.096886&south=45.034261&east=38.949914&north=55.310025&zoom=5&width=767&height=734&view=split",
|
||||||
|
endpoint: "requests_europe",
|
||||||
|
heading: "Who needs help?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path:
|
||||||
|
"/activities?west=22.096886&south=45.034261&east=38.949914&north=55.310025&zoom=5&width=767&height=734&view=split",
|
||||||
|
endpoint: "activities_europe",
|
||||||
|
heading: "Nearby activities",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path:
|
||||||
|
"/requests?west=-179.9&south=-80&east=179.9&north=80&zoom=1&width=1280&height=720&view=map",
|
||||||
|
endpoint: "requests_world",
|
||||||
|
heading: "Who needs help?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path:
|
||||||
|
"/activities?west=-179.9&south=-80&east=179.9&north=80&zoom=1&width=1280&height=720&view=map",
|
||||||
|
endpoint: "activities_world",
|
||||||
|
heading: "Nearby activities",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const endpointDurations = {
|
||||||
|
requests_europe: new Trend("wnh_requests_europe_duration", true),
|
||||||
|
activities_europe: new Trend("wnh_activities_europe_duration", true),
|
||||||
|
requests_world: new Trend("wnh_requests_world_duration", true),
|
||||||
|
activities_world: new Trend("wnh_activities_world_duration", true),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
scale_readonly_http: {
|
||||||
|
executor: "constant-vus",
|
||||||
|
exec: "scaleReadonlyHttp",
|
||||||
|
vus,
|
||||||
|
duration,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
checks: [{ threshold: "rate==1", abortOnFail: true }],
|
||||||
|
http_req_failed: [{ threshold: "rate==0", abortOnFail: true }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function scaleReadonlyHttp() {
|
||||||
|
const viewport = viewports[exec.scenario.iterationInTest % viewports.length];
|
||||||
|
const response = http.get(`${baseUrl}${viewport.path}`, {
|
||||||
|
redirects: 0,
|
||||||
|
tags: {
|
||||||
|
endpoint: viewport.endpoint,
|
||||||
|
safety_scope: "persistent_local_scale_readonly",
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Cookie: sessionCookie,
|
||||||
|
Host: "scale.localhost",
|
||||||
|
"X-Forwarded-Host": "scale.localhost",
|
||||||
|
"X-Forwarded-Port": "4121",
|
||||||
|
"X-Forwarded-Proto": "https",
|
||||||
|
"User-Agent": "WhoNeedHelp-persistent-scale-readonly/1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
endpointDurations[viewport.endpoint].add(response.timings.duration);
|
||||||
|
|
||||||
|
check(response, {
|
||||||
|
[`GET ${viewport.endpoint} returned 200`]: (candidate) =>
|
||||||
|
candidate.status === 200,
|
||||||
|
[`GET ${viewport.endpoint} rendered its loading shell`]: (candidate) =>
|
||||||
|
candidate.body.includes(viewport.heading) &&
|
||||||
|
candidate.body.includes("Loading results"),
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(thinkSeconds);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -57,6 +57,29 @@ set_env_value APP_IMAGE "who-need-help:load-$safe_id"
|
||||||
set_env_value SOCKET_PROXY_IMAGE "who-need-help:socket-proxy-load-$safe_id"
|
set_env_value SOCKET_PROXY_IMAGE "who-need-help:socket-proxy-load-$safe_id"
|
||||||
set_env_value POSTGIS_IMAGE "who-need-help:postgis-load-$safe_id"
|
set_env_value POSTGIS_IMAGE "who-need-help:postgis-load-$safe_id"
|
||||||
|
|
||||||
|
apply_positive_integer_override() {
|
||||||
|
local override_name=$1
|
||||||
|
local target_name=$2
|
||||||
|
local value=${!override_name:-}
|
||||||
|
|
||||||
|
if [[ -z "$value" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
|
echo "$override_name must be a positive integer." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set_env_value "$target_name" "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_positive_integer_override WNH_LOAD_WEB_REPLICAS_OVERRIDE LOAD_WEB_REPLICAS
|
||||||
|
apply_positive_integer_override WNH_LOAD_WORKER_REPLICAS_OVERRIDE LOAD_WORKER_REPLICAS
|
||||||
|
apply_positive_integer_override WNH_LOAD_HTTP_VUS_OVERRIDE LOAD_HTTP_VUS
|
||||||
|
apply_positive_integer_override WNH_LOAD_WS_VUS_OVERRIDE LOAD_WS_VUS
|
||||||
|
apply_positive_integer_override WNH_LOAD_AUTH_VUS_OVERRIDE LOAD_AUTH_VUS
|
||||||
|
|
||||||
set -a
|
set -a
|
||||||
# shellcheck source=/dev/null
|
# shellcheck source=/dev/null
|
||||||
. "$temporary_env"
|
. "$temporary_env"
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,18 @@ trap cleanup_on_exit EXIT HUP INT TERM
|
||||||
printf 'fixture_count=%s\n' "$fixture_count"
|
printf 'fixture_count=%s\n' "$fixture_count"
|
||||||
printf 'authenticated_websocket_timeout_ms=%s\n' "$LOAD_AUTH_WS_TIMEOUT_MS"
|
printf 'authenticated_websocket_timeout_ms=%s\n' "$LOAD_AUTH_WS_TIMEOUT_MS"
|
||||||
printf 'authenticated_think_seconds=%s\n' "$LOAD_AUTH_THINK_SECONDS"
|
printf 'authenticated_think_seconds=%s\n' "$LOAD_AUTH_THINK_SECONDS"
|
||||||
|
printf 'cpu_replay=%s\n' "${WNH_LOAD_CPU_REPLAY:-false}"
|
||||||
|
printf 'cpu_replay_cpuset=%s\n' "${CPU_REPLAY_CPUSET:-unconstrained}"
|
||||||
|
printf 'cpu_replay_web_cpus=%s\n' "${CPU_REPLAY_WEB_CPUS:-unconstrained}"
|
||||||
|
printf 'cpu_replay_worker_cpus=%s\n' "${CPU_REPLAY_WORKER_CPUS:-unconstrained}"
|
||||||
|
printf 'cpu_replay_web_schedulers=%s\n' \
|
||||||
|
"${CPU_REPLAY_WEB_SCHEDULERS:-unconstrained}"
|
||||||
|
printf 'cpu_replay_worker_schedulers=%s\n' \
|
||||||
|
"${CPU_REPLAY_WORKER_SCHEDULERS:-unconstrained}"
|
||||||
docker info --format 'docker_cpus={{.NCPU}} docker_memory_bytes={{.MemTotal}} docker_server={{.ServerVersion}}'
|
docker info --format 'docker_cpus={{.NCPU}} docker_memory_bytes={{.MemTotal}} docker_server={{.ServerVersion}}'
|
||||||
|
docker inspect \
|
||||||
|
--format 'container={{.Name}} nano_cpus={{.HostConfig.NanoCpus}} cpuset={{.HostConfig.CpusetCpus}}' \
|
||||||
|
"${measured_containers[@]}"
|
||||||
docker compose version
|
docker compose version
|
||||||
uname -a
|
uname -a
|
||||||
} >"$output_dir/environment.txt"
|
} >"$output_dir/environment.txt"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ set +a
|
||||||
: "${LOAD_HOST:?LOAD_HOST is missing from the generated load runtime environment}"
|
: "${LOAD_HOST:?LOAD_HOST is missing from the generated load runtime environment}"
|
||||||
: "${LOAD_WEB_REPLICAS:?LOAD_WEB_REPLICAS is missing from the generated load runtime environment}"
|
: "${LOAD_WEB_REPLICAS:?LOAD_WEB_REPLICAS is missing from the generated load runtime environment}"
|
||||||
: "${LOAD_WORKER_REPLICAS:?LOAD_WORKER_REPLICAS is missing from the generated load runtime environment}"
|
: "${LOAD_WORKER_REPLICAS:?LOAD_WORKER_REPLICAS is missing from the generated load runtime environment}"
|
||||||
|
: "${LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS:?LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS is missing from the generated load runtime environment}"
|
||||||
|
: "${LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS:?LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS is missing from the generated load runtime environment}"
|
||||||
|
|
||||||
if [ "$LOAD_PROJECT" = who_need_help ]; then
|
if [ "$LOAD_PROJECT" = who_need_help ]; then
|
||||||
echo "The load profile must not use the staging Compose project." >&2
|
echo "The load profile must not use the staging Compose project." >&2
|
||||||
|
|
@ -36,12 +38,22 @@ esac
|
||||||
|
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
compose() {
|
compose() {
|
||||||
docker compose \
|
if [ "${WNH_LOAD_CPU_REPLAY:-false}" = true ]; then
|
||||||
--env-file "$ENV_FILE" \
|
docker compose \
|
||||||
-p "$LOAD_PROJECT" \
|
--env-file "$ENV_FILE" \
|
||||||
-f compose.yaml \
|
-p "$LOAD_PROJECT" \
|
||||||
-f compose.load.yaml \
|
-f compose.yaml \
|
||||||
"$@"
|
-f compose.load.yaml \
|
||||||
|
-f compose.cpu-replay.yaml \
|
||||||
|
"$@"
|
||||||
|
else
|
||||||
|
docker compose \
|
||||||
|
--env-file "$ENV_FILE" \
|
||||||
|
-p "$LOAD_PROJECT" \
|
||||||
|
-f compose.yaml \
|
||||||
|
-f compose.load.yaml \
|
||||||
|
"$@"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup_failed_start() {
|
cleanup_failed_start() {
|
||||||
|
|
@ -94,13 +106,26 @@ compose up -d --build --wait \
|
||||||
|
|
||||||
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-realtime-cluster.sh" compose
|
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-realtime-cluster.sh" compose
|
||||||
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-beam-runtime.sh" compose
|
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-beam-runtime.sh" compose
|
||||||
status=$(
|
deadline=$(( $(date +%s) + LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS ))
|
||||||
curl --silent --show-error \
|
status=000
|
||||||
--header "Host: $LOAD_HOST" \
|
|
||||||
--output /dev/null \
|
while [ "$(date +%s)" -le "$deadline" ]; do
|
||||||
--write-out '%{http_code}' \
|
if ! status=$(
|
||||||
"http://localhost:$HTTP_PORT/healthz/ready"
|
curl --silent \
|
||||||
)
|
--header "Host: $LOAD_HOST" \
|
||||||
|
--output /dev/null \
|
||||||
|
--write-out '%{http_code}' \
|
||||||
|
"http://localhost:$HTTP_PORT/healthz/ready"
|
||||||
|
); then
|
||||||
|
status=000
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$status" = 200 ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep "$LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS"
|
||||||
|
done
|
||||||
|
|
||||||
if [ "$status" != 200 ]; then
|
if [ "$status" != 200 ]; then
|
||||||
echo "Load-profile readiness returned HTTP $status instead of 200." >&2
|
echo "Load-profile readiness returned HTTP $status instead of 200." >&2
|
||||||
|
|
|
||||||
|
|
@ -1126,6 +1126,7 @@ WEB_REPLICAS=1 WORKER_REPLICAS=1 \
|
||||||
.services.web.deploy.replicas == 1 and
|
.services.web.deploy.replicas == 1 and
|
||||||
.services.worker.deploy.replicas == 1
|
.services.worker.deploy.replicas == 1
|
||||||
' >/dev/null
|
' >/dev/null
|
||||||
|
CPU_REPLAY_CPUSET=0 \
|
||||||
CPU_REPLAY_WEB_CPUS=1 \
|
CPU_REPLAY_WEB_CPUS=1 \
|
||||||
CPU_REPLAY_WORKER_CPUS=1 \
|
CPU_REPLAY_WORKER_CPUS=1 \
|
||||||
CPU_REPLAY_WEB_SCHEDULERS=1 \
|
CPU_REPLAY_WEB_SCHEDULERS=1 \
|
||||||
|
|
|
||||||
62
scripts/scale-common.sh
Executable file
62
scripts/scale-common.sh
Executable file
|
|
@ -0,0 +1,62 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Variables assigned here are exported for Docker Compose and child containers.
|
||||||
|
# The literal backticks belong to Traefik's rule syntax.
|
||||||
|
# shellcheck disable=SC2034,SC2016
|
||||||
|
|
||||||
|
scale_load_environment() {
|
||||||
|
SCALE_ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
SCALE_ENV_FILE=${WNH_SCALE_ENV_FILE:-"$SCALE_ROOT/output/runtime/scale.env"}
|
||||||
|
|
||||||
|
WNH_LOAD_ENV_FILE=$SCALE_ENV_FILE "$SCALE_ROOT/scripts/ensure-local-load-env.sh"
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$SCALE_ENV_FILE"
|
||||||
|
|
||||||
|
LOAD_PROJECT=who_need_help_scale
|
||||||
|
COMPOSE_PROJECT_NAME=$LOAD_PROJECT
|
||||||
|
LOAD_HOST=scale.localhost
|
||||||
|
HTTP_BIND_ADDRESS=127.0.0.1
|
||||||
|
HTTP_PORT=${WNH_SCALE_HTTP_PORT:-4120}
|
||||||
|
SCALE_HTTPS_BIND_ADDRESS=127.0.0.1
|
||||||
|
SCALE_HTTPS_PORT=${WNH_SCALE_HTTPS_PORT:-4121}
|
||||||
|
MAILPIT_BIND_ADDRESS=127.0.0.1
|
||||||
|
MAILPIT_PORT=${WNH_SCALE_MAILPIT_PORT:-8129}
|
||||||
|
PHX_HOST=$LOAD_HOST
|
||||||
|
PHX_SCHEME=https
|
||||||
|
PHX_URL_PORT=$SCALE_HTTPS_PORT
|
||||||
|
POSTGRES_DB=who_need_help_scale
|
||||||
|
POSTGRES_USER=wnh_scale
|
||||||
|
DATABASE_URL="ecto://$POSTGRES_USER:$POSTGRES_PASSWORD@db/$POSTGRES_DB"
|
||||||
|
APP_IMAGE=who-need-help:scale
|
||||||
|
SOCKET_PROXY_IMAGE=who-need-help:socket-proxy-scale
|
||||||
|
POSTGIS_IMAGE=who-need-help:postgis-scale
|
||||||
|
TRAEFIK_PROJECT_CONSTRAINT=$LOAD_PROJECT
|
||||||
|
TRAEFIK_APP_NAME=who-need-help-scale
|
||||||
|
TRAEFIK_DOCKER_NETWORK="${LOAD_PROJECT}_ingress"
|
||||||
|
TRAEFIK_ROUTER_RULE='Host(`scale.localhost`)'
|
||||||
|
WEB_REPLICAS=1
|
||||||
|
WORKER_REPLICAS=1
|
||||||
|
LOAD_WEB_REPLICAS=1
|
||||||
|
LOAD_WORKER_REPLICAS=1
|
||||||
|
CPU_REPLAY_CPUSET=${WNH_SCALE_CPUSET:-0}
|
||||||
|
CPU_REPLAY_WEB_CPUS=1
|
||||||
|
CPU_REPLAY_WORKER_CPUS=1
|
||||||
|
CPU_REPLAY_WEB_SCHEDULERS=1
|
||||||
|
CPU_REPLAY_WORKER_SCHEDULERS=1
|
||||||
|
SCALE_VIEWER_PASSWORD=${SCALE_VIEWER_PASSWORD:-$LOAD_FIXTURE_PASSWORD}
|
||||||
|
WNH_SCALE_EXPECTED_DATABASE=$POSTGRES_DB
|
||||||
|
WNH_SCALE_FIXTURE_CONFIRM=persistent-local-scale
|
||||||
|
set +a
|
||||||
|
}
|
||||||
|
|
||||||
|
scale_compose() {
|
||||||
|
docker compose \
|
||||||
|
--env-file "$SCALE_ENV_FILE" \
|
||||||
|
-p "$LOAD_PROJECT" \
|
||||||
|
-f "$SCALE_ROOT/compose.yaml" \
|
||||||
|
-f "$SCALE_ROOT/compose.load.yaml" \
|
||||||
|
-f "$SCALE_ROOT/compose.scale.yaml" \
|
||||||
|
-f "$SCALE_ROOT/compose.cpu-replay.yaml" \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
161
scripts/scale-readonly-load.sh
Executable file
161
scripts/scale-readonly-load.sh
Executable file
|
|
@ -0,0 +1,161 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
# shellcheck source=scripts/scale-common.sh
|
||||||
|
. "$ROOT/scripts/scale-common.sh"
|
||||||
|
scale_load_environment
|
||||||
|
|
||||||
|
K6_IMAGE="grafana/k6@sha256:65c920dc067d5e2e00befbf982af6ad6ad0117034e8b1c65817c7975c52d4669"
|
||||||
|
STATE_FILE=${WNH_SCALE_BROWSER_STATE:-"$ROOT/output/runtime/scale-browser-state.json"}
|
||||||
|
VUS=${WNH_SCALE_READONLY_VUS:?WNH_SCALE_READONLY_VUS is required}
|
||||||
|
DURATION=${WNH_SCALE_READONLY_DURATION:?WNH_SCALE_READONLY_DURATION is required}
|
||||||
|
THINK_SECONDS=${WNH_SCALE_READONLY_THINK_SECONDS:?WNH_SCALE_READONLY_THINK_SECONDS is required}
|
||||||
|
LABEL=${WNH_SCALE_READONLY_LABEL:-"scale-readonly-$(date -u +%Y%m%dT%H%M%SZ)"}
|
||||||
|
OUTPUT_DIR="$ROOT/output/performance/$LABEL"
|
||||||
|
|
||||||
|
case "$VUS" in
|
||||||
|
''|*[!0-9]*|0)
|
||||||
|
echo "WNH_SCALE_READONLY_VUS must be a positive integer." >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! printf '%s\n' "$DURATION" | grep -Eq '^[1-9][0-9]*(ms|s|m|h)$'; then
|
||||||
|
echo "WNH_SCALE_READONLY_DURATION must be a positive k6 duration." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! printf '%s\n' "$THINK_SECONDS" |
|
||||||
|
grep -Eq '^([0-9]+([.][0-9]+)?|[.][0-9]+)$'; then
|
||||||
|
echo "WNH_SCALE_READONLY_THINK_SECONDS must be a non-negative number." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$LABEL" in
|
||||||
|
''|*[!A-Za-z0-9._-]*)
|
||||||
|
echo "WNH_SCALE_READONLY_LABEL contains unsupported characters." >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ! -f "$STATE_FILE" ]; then
|
||||||
|
echo "Missing authenticated scale browser state: $STATE_FILE" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
session_cookie=$(
|
||||||
|
jq -r '
|
||||||
|
.cookies[]
|
||||||
|
| select(.domain == "scale.localhost" and .name == "_who_need_help_key")
|
||||||
|
| "\(.name)=\(.value)"
|
||||||
|
' "$STATE_FILE"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ -z "$session_cookie" ]; then
|
||||||
|
echo "The browser state does not contain the scale session cookie." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
web_id=$(scale_compose ps -q web)
|
||||||
|
worker_id=$(scale_compose ps -q worker)
|
||||||
|
db_id=$(scale_compose ps -q db)
|
||||||
|
|
||||||
|
for pair in "web:$web_id" "worker:$worker_id" "db:$db_id"; do
|
||||||
|
service=${pair%%:*}
|
||||||
|
container=${pair#*:}
|
||||||
|
|
||||||
|
if [ -z "$container" ] || [ "$(docker inspect --format '{{.State.Status}}' "$container")" != "running" ]; then
|
||||||
|
echo "Scale service $service is not running." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
actual_database=$(
|
||||||
|
scale_compose exec -T db \
|
||||||
|
psql -XAt -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
|
||||||
|
-c 'SELECT current_database()'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "$actual_database" != "$WNH_SCALE_EXPECTED_DATABASE" ]; then
|
||||||
|
echo "Refusing unexpected database: $actual_database" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -e "$OUTPUT_DIR" ]; then
|
||||||
|
echo "Output path already exists: $OUTPUT_DIR" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
chmod 700 "$ROOT/output" "$ROOT/output/performance" "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
snapshot_counts() {
|
||||||
|
destination=$1
|
||||||
|
|
||||||
|
scale_compose exec -T db \
|
||||||
|
psql -XAt -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
|
||||||
|
-c "
|
||||||
|
SELECT json_build_object(
|
||||||
|
'captured_at', clock_timestamp(),
|
||||||
|
'database', current_database(),
|
||||||
|
'users', (SELECT count(*) FROM users),
|
||||||
|
'users_tokens', (SELECT count(*) FROM users_tokens),
|
||||||
|
'help_requests', (SELECT count(*) FROM help_requests),
|
||||||
|
'activities', (SELECT count(*) FROM activities),
|
||||||
|
'help_assignments', (SELECT count(*) FROM help_assignments),
|
||||||
|
'activity_participants', (SELECT count(*) FROM activity_participants),
|
||||||
|
'messages', (SELECT count(*) FROM messages),
|
||||||
|
'activity_messages', (SELECT count(*) FROM activity_messages),
|
||||||
|
'rate_limit_buckets', (SELECT count(*) FROM rate_limit_buckets)
|
||||||
|
)
|
||||||
|
" >"$destination"
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot_counts "$OUTPUT_DIR/counts-before.json"
|
||||||
|
|
||||||
|
cat >"$OUTPUT_DIR/scope.txt" <<EOF
|
||||||
|
target=http://web:4000
|
||||||
|
host_header=scale.localhost
|
||||||
|
compose_project=$LOAD_PROJECT
|
||||||
|
database=$actual_database
|
||||||
|
database_volume=${LOAD_PROJECT}_postgres_data
|
||||||
|
cpuset=$CPU_REPLAY_CPUSET
|
||||||
|
vus=$VUS
|
||||||
|
duration=$DURATION
|
||||||
|
think_seconds=$THINK_SECONDS
|
||||||
|
scope=authenticated GET-only static LiveView shells; discovery SQL is measured separately by scripts/scale-verify.sh
|
||||||
|
mutating_routes=false
|
||||||
|
test_or_production_targets=false
|
||||||
|
latency_or_capacity_thresholds_applied=false
|
||||||
|
EOF
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--cpuset-cpus "$CPU_REPLAY_CPUSET" \
|
||||||
|
--network "${LOAD_PROJECT}_internal" \
|
||||||
|
--env BASE_URL=http://web:4000 \
|
||||||
|
--env "SESSION_COOKIE=$session_cookie" \
|
||||||
|
--env "VUS=$VUS" \
|
||||||
|
--env "DURATION=$DURATION" \
|
||||||
|
--env "THINK_SECONDS=$THINK_SECONDS" \
|
||||||
|
--volume "$ROOT/load/k6:/scripts:ro" \
|
||||||
|
--volume "$OUTPUT_DIR:/output" \
|
||||||
|
"$K6_IMAGE" run \
|
||||||
|
--summary-export /output/k6-summary.json \
|
||||||
|
/scripts/scale-readonly.js
|
||||||
|
|
||||||
|
snapshot_counts "$OUTPUT_DIR/counts-after.json"
|
||||||
|
|
||||||
|
jq -e '
|
||||||
|
del(.captured_at) as $before
|
||||||
|
| $before == (input | del(.captured_at))
|
||||||
|
' "$OUTPUT_DIR/counts-before.json" "$OUTPUT_DIR/counts-after.json" >/dev/null
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--volume "$OUTPUT_DIR:/output" \
|
||||||
|
alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 \
|
||||||
|
chown -R "$(id -u):$(id -g)" /output
|
||||||
|
|
||||||
|
printf 'Read-only scale result: %s\n' "$OUTPUT_DIR"
|
||||||
58
scripts/scale-seed.sh
Executable file
58
scripts/scale-seed.sh
Executable file
|
|
@ -0,0 +1,58 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
# shellcheck source=scripts/scale-common.sh
|
||||||
|
. "$ROOT/scripts/scale-common.sh"
|
||||||
|
scale_load_environment
|
||||||
|
|
||||||
|
REQUESTS=${WNH_SCALE_REQUESTS:-1000000}
|
||||||
|
ACTIVITIES=${WNH_SCALE_ACTIVITIES:-1000000}
|
||||||
|
OUTPUT_DIR="$ROOT/output/scale"
|
||||||
|
SUMMARY="$OUTPUT_DIR/summary.json"
|
||||||
|
TOOLS_IMAGE=who-need-help:scale-tools
|
||||||
|
|
||||||
|
for value in "$REQUESTS" "$ACTIVITIES"; do
|
||||||
|
case "$value" in
|
||||||
|
''|*[!0-9]*|0)
|
||||||
|
echo "WNH_SCALE_REQUESTS and WNH_SCALE_ACTIVITIES must be positive integers." >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
docker build --target load_tools --tag "$TOOLS_IMAGE" "$ROOT"
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--cpuset-cpus "$CPU_REPLAY_CPUSET" \
|
||||||
|
--network "${LOAD_PROJECT}_internal" \
|
||||||
|
--env APP_ROLE=migrate \
|
||||||
|
--env DATABASE_URL \
|
||||||
|
--env POOL_SIZE=2 \
|
||||||
|
--env "ERL_ZFLAGS=+Q ${ERLANG_PORT_LIMIT:-65536} +S 1:1" \
|
||||||
|
--env SECRET_KEY_BASE \
|
||||||
|
--env HANDOVER_SECRET \
|
||||||
|
--env RELEASE_COOKIE \
|
||||||
|
--env METRICS_TOKEN \
|
||||||
|
--env PHX_HOST \
|
||||||
|
--env PHX_SCHEME \
|
||||||
|
--env PHX_URL_PORT \
|
||||||
|
--env EMAIL_FROM_NAME="Who Need Help scale profile" \
|
||||||
|
--env EMAIL_FROM_ADDRESS=scale@example.invalid \
|
||||||
|
--env WNH_SCALE_EXPECTED_DATABASE \
|
||||||
|
--env WNH_SCALE_FIXTURE_CONFIRM \
|
||||||
|
--env SCALE_VIEWER_PASSWORD \
|
||||||
|
--volume "$OUTPUT_DIR:/output" \
|
||||||
|
"$TOOLS_IMAGE" \
|
||||||
|
mix wnh.scale_fixtures prepare \
|
||||||
|
--requests "$REQUESTS" \
|
||||||
|
--activities "$ACTIVITIES" \
|
||||||
|
--output /output/summary.json
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--volume "$OUTPUT_DIR:/output" \
|
||||||
|
alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 \
|
||||||
|
chown -R "$(id -u):$(id -g)" /output
|
||||||
|
|
||||||
|
printf 'Persistent scale summary: %s\n' "$SUMMARY"
|
||||||
10
scripts/scale-stack-stop.sh
Executable file
10
scripts/scale-stack-stop.sh
Executable file
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
# shellcheck source=scripts/scale-common.sh
|
||||||
|
. "$ROOT/scripts/scale-common.sh"
|
||||||
|
scale_load_environment
|
||||||
|
|
||||||
|
scale_compose stop
|
||||||
|
printf 'Stopped %s; its PostgreSQL volume was preserved.\n' "$LOAD_PROJECT"
|
||||||
50
scripts/scale-stack-up.sh
Executable file
50
scripts/scale-stack-up.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
# shellcheck source=scripts/scale-common.sh
|
||||||
|
. "$ROOT/scripts/scale-common.sh"
|
||||||
|
scale_load_environment
|
||||||
|
|
||||||
|
case "$CPU_REPLAY_CPUSET" in
|
||||||
|
''|*[!0-9,-]*)
|
||||||
|
echo "WNH_SCALE_CPUSET must be a Docker cpuset expression." >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
scale_compose up -d --build --wait --scale web=1 --scale worker=1
|
||||||
|
|
||||||
|
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-realtime-cluster.sh" compose
|
||||||
|
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-beam-runtime.sh" compose
|
||||||
|
|
||||||
|
deadline=$(( $(date +%s) + LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS ))
|
||||||
|
status=000
|
||||||
|
|
||||||
|
while [ "$(date +%s)" -le "$deadline" ]; do
|
||||||
|
if ! status=$(
|
||||||
|
curl --insecure --silent \
|
||||||
|
--resolve "$LOAD_HOST:$SCALE_HTTPS_PORT:127.0.0.1" \
|
||||||
|
--output /dev/null \
|
||||||
|
--write-out '%{http_code}' \
|
||||||
|
"https://$LOAD_HOST:$SCALE_HTTPS_PORT/healthz/ready"
|
||||||
|
); then
|
||||||
|
status=000
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$status" = 200 ] && break
|
||||||
|
sleep "$LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$status" != 200 ]; then
|
||||||
|
echo "Scale-profile readiness returned HTTP $status instead of 200." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl --fail --silent --show-error --insecure \
|
||||||
|
--resolve "$LOAD_HOST:$SCALE_HTTPS_PORT:127.0.0.1" \
|
||||||
|
"https://$LOAD_HOST:$SCALE_HTTPS_PORT/healthz/ready"
|
||||||
|
|
||||||
|
printf '\nPersistent scale stack: https://%s:%s\n' "$LOAD_HOST" "$SCALE_HTTPS_PORT"
|
||||||
|
printf 'Mailpit: http://127.0.0.1:%s\n' "$MAILPIT_PORT"
|
||||||
59
scripts/scale-verify.sh
Executable file
59
scripts/scale-verify.sh
Executable file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
# shellcheck source=scripts/scale-common.sh
|
||||||
|
. "$ROOT/scripts/scale-common.sh"
|
||||||
|
scale_load_environment
|
||||||
|
|
||||||
|
REQUESTS=${WNH_SCALE_REQUESTS:-1000000}
|
||||||
|
ACTIVITIES=${WNH_SCALE_ACTIVITIES:-1000000}
|
||||||
|
SAMPLES=${WNH_SCALE_SAMPLES:-3}
|
||||||
|
OUTPUT_DIR="$ROOT/output/scale"
|
||||||
|
TOOLS_IMAGE=who-need-help:scale-tools
|
||||||
|
|
||||||
|
for value in "$REQUESTS" "$ACTIVITIES" "$SAMPLES"; do
|
||||||
|
case "$value" in
|
||||||
|
''|*[!0-9]*|0)
|
||||||
|
echo "WNH_SCALE_REQUESTS, WNH_SCALE_ACTIVITIES and WNH_SCALE_SAMPLES must be positive integers." >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
docker build --target load_tools --tag "$TOOLS_IMAGE" "$ROOT"
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--cpuset-cpus "$CPU_REPLAY_CPUSET" \
|
||||||
|
--network "${LOAD_PROJECT}_internal" \
|
||||||
|
--env APP_ROLE=migrate \
|
||||||
|
--env DATABASE_URL \
|
||||||
|
--env POOL_SIZE=2 \
|
||||||
|
--env "ERL_ZFLAGS=+Q ${ERLANG_PORT_LIMIT:-65536} +S 1:1" \
|
||||||
|
--env SECRET_KEY_BASE \
|
||||||
|
--env HANDOVER_SECRET \
|
||||||
|
--env RELEASE_COOKIE \
|
||||||
|
--env METRICS_TOKEN \
|
||||||
|
--env PHX_HOST \
|
||||||
|
--env PHX_SCHEME \
|
||||||
|
--env PHX_URL_PORT \
|
||||||
|
--env EMAIL_FROM_NAME="Who Need Help scale profile" \
|
||||||
|
--env EMAIL_FROM_ADDRESS=scale@example.invalid \
|
||||||
|
--env WNH_SCALE_EXPECTED_DATABASE \
|
||||||
|
--env WNH_SCALE_FIXTURE_CONFIRM \
|
||||||
|
--env SCALE_VIEWER_PASSWORD \
|
||||||
|
--volume "$OUTPUT_DIR:/output" \
|
||||||
|
"$TOOLS_IMAGE" \
|
||||||
|
mix wnh.scale_fixtures verify \
|
||||||
|
--requests "$REQUESTS" \
|
||||||
|
--activities "$ACTIVITIES" \
|
||||||
|
--samples "$SAMPLES" \
|
||||||
|
--output /output/verification.json
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--volume "$OUTPUT_DIR:/output" \
|
||||||
|
alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 \
|
||||||
|
chown -R "$(id -u):$(id -g)" /output
|
||||||
|
|
||||||
|
printf 'Scale verification: %s/output/scale/verification.json\n' "$ROOT"
|
||||||
|
|
@ -60,7 +60,7 @@ case "$MODE" in
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
live_flags=$(
|
mapfile -t live_flag_values < <(
|
||||||
for target in "${targets[@]}"; do
|
for target in "${targets[@]}"; do
|
||||||
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \
|
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||||
"$target" |
|
"$target" |
|
||||||
|
|
@ -69,12 +69,19 @@ case "$MODE" in
|
||||||
sort -u
|
sort -u
|
||||||
)
|
)
|
||||||
|
|
||||||
if [[ ! "$live_flags" =~ ^\+Q[[:space:]]+([0-9]+)$ ]]; then
|
if [[ "${#live_flag_values[@]}" -ne 1 ]]; then
|
||||||
echo "The live Compose replicas do not expose one unambiguous +Q limit." >&2
|
echo "The live Compose replicas do not expose one unambiguous +Q limit." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
expected=${BASH_REMATCH[1]}
|
live_flags=${live_flag_values[0]}
|
||||||
|
|
||||||
|
if [[ ! "$live_flags" =~ (^|[[:space:]])\+Q[[:space:]]+([0-9]+)($|[[:space:]]) ]]; then
|
||||||
|
echo "The live Compose replicas do not expose one unambiguous +Q limit." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
expected=${BASH_REMATCH[2]}
|
||||||
|
|
||||||
for target in "${targets[@]}"; do
|
for target in "${targets[@]}"; do
|
||||||
component=$(
|
component=$(
|
||||||
|
|
|
||||||
|
|
@ -446,7 +446,10 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
||||||
viewport!(30.0, 50.0, 31.0, 51.0, 9)
|
viewport!(30.0, 50.0, 31.0, 51.0, 9)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert Enum.any?(items, &match?(%{type: "cluster", count: 3}, &1))
|
cluster = Enum.find(items, &match?(%{type: "cluster", count: 3}, &1))
|
||||||
|
assert cluster
|
||||||
|
assert_in_delta cluster.latitude, 50.45, 0.000_001
|
||||||
|
assert_in_delta cluster.longitude, 30.52, 0.000_001
|
||||||
|
|
||||||
assert Enum.any?(items, fn
|
assert Enum.any?(items, fn
|
||||||
%{type: "activity", id: id} -> id == sparse.id
|
%{type: "activity", id: id} -> id == sparse.id
|
||||||
|
|
|
||||||
36
test/who_need_help/discovery_query_cache_test.exs
Normal file
36
test/who_need_help/discovery_query_cache_test.exs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
defmodule WhoNeedHelp.DiscoveryQueryCacheTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias WhoNeedHelp.DiscoveryQueryCache
|
||||||
|
|
||||||
|
test "coalesces concurrent reads, briefly reuses the result, and supports invalidation" do
|
||||||
|
key = {:test, make_ref()}
|
||||||
|
parent = self()
|
||||||
|
|
||||||
|
fetch = fn ->
|
||||||
|
DiscoveryQueryCache.fetch(key, fn ->
|
||||||
|
send(parent, {:query_started, self()})
|
||||||
|
|
||||||
|
receive do
|
||||||
|
:finish_query -> :result
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
first = Task.async(fetch)
|
||||||
|
assert_receive {:query_started, query_pid}
|
||||||
|
|
||||||
|
second = Task.async(fetch)
|
||||||
|
refute_receive {:query_started, _other_pid}, 50
|
||||||
|
|
||||||
|
send(query_pid, :finish_query)
|
||||||
|
assert Task.await(first) == :result
|
||||||
|
assert Task.await(second) == :result
|
||||||
|
|
||||||
|
assert DiscoveryQueryCache.fetch(key, fn -> flunk("cached read ran twice") end) == :result
|
||||||
|
|
||||||
|
assert :ok = DiscoveryQueryCache.invalidate(key)
|
||||||
|
|
||||||
|
assert DiscoveryQueryCache.fetch(key, fn -> :fresh_result end) == :fresh_result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -69,6 +69,46 @@ defmodule WhoNeedHelp.Help.DiscoveryViewportTest do
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "pads spatial-index envelopes without losing antimeridian coverage" do
|
||||||
|
{:ok, city} =
|
||||||
|
DiscoveryViewport.cast(%{
|
||||||
|
west: 30,
|
||||||
|
south: 50,
|
||||||
|
east: 31,
|
||||||
|
north: 51,
|
||||||
|
zoom: 10,
|
||||||
|
width: 800,
|
||||||
|
height: 600
|
||||||
|
})
|
||||||
|
|
||||||
|
assert [{west, south, east, north}] = DiscoveryViewport.padded_envelopes(city, 2_000)
|
||||||
|
assert west < city.west
|
||||||
|
assert south < city.south
|
||||||
|
assert east > city.east
|
||||||
|
assert north > city.north
|
||||||
|
|
||||||
|
{:ok, crossing} =
|
||||||
|
DiscoveryViewport.cast(%{
|
||||||
|
west: 170,
|
||||||
|
south: -10,
|
||||||
|
east: -170,
|
||||||
|
north: 10,
|
||||||
|
zoom: 3,
|
||||||
|
width: 800,
|
||||||
|
height: 600
|
||||||
|
})
|
||||||
|
|
||||||
|
padded = DiscoveryViewport.padded_envelopes(crossing, 2_000)
|
||||||
|
|
||||||
|
assert Enum.any?(padded, fn {padded_west, _, padded_east, _} ->
|
||||||
|
padded_west < 170 and padded_east == 180.0
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert Enum.any?(padded, fn {padded_west, _, padded_east, _} ->
|
||||||
|
padded_west == -180.0 and padded_east > -170
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
test "derives cluster detail from both geographic bounds and rendered pixels" do
|
test "derives cluster detail from both geographic bounds and rendered pixels" do
|
||||||
{:ok, city} =
|
{:ok, city} =
|
||||||
DiscoveryViewport.cast(%{
|
DiscoveryViewport.cast(%{
|
||||||
|
|
|
||||||
|
|
@ -562,10 +562,10 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
||||||
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 9)
|
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 9)
|
||||||
items = Help.map_discovery_items(context.helper_scope, %{}, viewport)
|
items = Help.map_discovery_items(context.helper_scope, %{}, viewport)
|
||||||
|
|
||||||
assert Enum.any?(items, fn
|
cluster = Enum.find(items, &match?(%{type: "cluster", count: 3}, &1))
|
||||||
%{type: "cluster", count: 3} -> true
|
assert cluster
|
||||||
_item -> false
|
assert_in_delta cluster.latitude, 50.4501, 0.000_001
|
||||||
end)
|
assert_in_delta cluster.longitude, 30.52345, 0.000_001
|
||||||
|
|
||||||
assert Enum.any?(items, fn
|
assert Enum.any?(items, fn
|
||||||
%{type: "request", id: id, exact: true} -> id == sparse_request.id
|
%{type: "request", id: id, exact: true} -> id == sparse_request.id
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
{:ok, request} = Help.create_request(scope, request_attrs(category))
|
{:ok, request} = Help.create_request(scope, request_attrs(category))
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/requests")
|
{:ok, view, _html} = live(conn, ~p"/requests")
|
||||||
|
render_async(view)
|
||||||
assert has_element?(view, "#request-discovery")
|
assert has_element?(view, "#request-discovery")
|
||||||
refute has_element?(view, "#my-requests")
|
refute has_element?(view, "#my-requests")
|
||||||
|
|
||||||
|
|
@ -250,8 +251,9 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
|> element("button[phx-click='set-scope'][phx-value-scope='mine']")
|
|> element("button[phx-click='set-scope'][phx-value-scope='mine']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
|
render_async(view)
|
||||||
assert has_element?(view, "#my-requests")
|
assert has_element?(view, "#my-requests")
|
||||||
assert has_element?(view, "#my-request-#{request.id}")
|
assert_eventually(fn -> has_element?(view, "#my-request-#{request.id}") end)
|
||||||
refute has_element?(view, "#request-discovery")
|
refute has_element?(view, "#request-discovery")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -268,6 +270,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
{:ok, activity} = Activities.create_activity(scope, activity_attrs(category))
|
{:ok, activity} = Activities.create_activity(scope, activity_attrs(category))
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/activities")
|
{:ok, view, _html} = live(conn, ~p"/activities")
|
||||||
|
render_async(view)
|
||||||
assert has_element?(view, "#activity-map")
|
assert has_element?(view, "#activity-map")
|
||||||
refute has_element?(view, "#my-activities")
|
refute has_element?(view, "#my-activities")
|
||||||
|
|
||||||
|
|
@ -275,11 +278,86 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
|> element("button[phx-click='set-scope'][phx-value-scope='mine']")
|
|> element("button[phx-click='set-scope'][phx-value-scope='mine']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
|
render_async(view)
|
||||||
assert has_element?(view, "#my-activities")
|
assert has_element?(view, "#my-activities")
|
||||||
assert has_element?(view, "#my-activity-#{activity.id}")
|
assert_eventually(fn -> has_element?(view, "#my-activity-#{activity.id}") end)
|
||||||
refute has_element?(view, "#activity-map")
|
refute has_element?(view, "#activity-map")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "request discovery loads only the data needed by the selected layout", %{
|
||||||
|
conn: conn,
|
||||||
|
scope: scope
|
||||||
|
} do
|
||||||
|
category = Catalog.seed_defaults()
|
||||||
|
{:ok, request} = Help.create_request(scope, request_attrs(category))
|
||||||
|
path = requests_in_kyiv_path("map")
|
||||||
|
|
||||||
|
static_html = conn |> get(path) |> html_response(200)
|
||||||
|
assert static_html =~ "Loading results"
|
||||||
|
assert static_html =~ "Your trust summary"
|
||||||
|
refute static_html =~ request.title
|
||||||
|
|
||||||
|
mine_html =
|
||||||
|
conn
|
||||||
|
|> get(~p"/requests?scope=mine")
|
||||||
|
|> html_response(200)
|
||||||
|
|
||||||
|
assert mine_html =~ "My requests"
|
||||||
|
assert mine_html =~ "Loading results"
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, path)
|
||||||
|
|
||||||
|
render_async(view)
|
||||||
|
assert has_element?(view, "#request-discovery[data-view-mode='map']")
|
||||||
|
refute has_element?(view, "#open-request-#{request.id}")
|
||||||
|
refute has_element?(view, "#request-map[data-items='[]']")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("button[phx-click='set-view'][phx-value-view='list']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
render_async(view)
|
||||||
|
assert_patch(view, requests_in_kyiv_path("list"))
|
||||||
|
assert has_element?(view, "#request-discovery[data-view-mode='list']")
|
||||||
|
assert has_element?(view, "#open-request-#{request.id}")
|
||||||
|
assert has_element?(view, "#request-map[data-items='[]']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "activity discovery offers list, split, and map without loading hidden results", %{
|
||||||
|
conn: conn,
|
||||||
|
scope: scope
|
||||||
|
} do
|
||||||
|
Catalog.seed_defaults()
|
||||||
|
|
||||||
|
category =
|
||||||
|
Catalog.list_categories(:activity)
|
||||||
|
|> Enum.find(&(&1.slug == "coffee-meetup"))
|
||||||
|
|
||||||
|
{:ok, activity} = Activities.create_activity(scope, activity_attrs(category))
|
||||||
|
path = activities_in_kyiv_path("map")
|
||||||
|
|
||||||
|
static_html = conn |> get(path) |> html_response(200)
|
||||||
|
assert static_html =~ "Loading results"
|
||||||
|
refute static_html =~ activity.title
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, path)
|
||||||
|
|
||||||
|
render_async(view)
|
||||||
|
assert has_element?(view, "#activity-discovery[data-view-mode='map']")
|
||||||
|
refute has_element?(view, "#open-activity-#{activity.id}")
|
||||||
|
refute has_element?(view, "#activity-map[data-items='[]']")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("button[phx-click='set-view'][phx-value-view='list']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
render_async(view)
|
||||||
|
assert_patch(view, activities_in_kyiv_path("list"))
|
||||||
|
assert has_element?(view, "#activity-discovery[data-view-mode='list']")
|
||||||
|
assert has_element?(view, "#open-activity-#{activity.id}")
|
||||||
|
assert has_element?(view, "#activity-map[data-items='[]']")
|
||||||
|
end
|
||||||
|
|
||||||
test "profile social-link creation uses the shared action limiter", %{conn: conn} do
|
test "profile social-link creation uses the shared action limiter", %{conn: conn} do
|
||||||
previous = Application.get_env(:who_need_help, :rate_limit_policies)
|
previous = Application.get_env(:who_need_help, :rate_limit_policies)
|
||||||
|
|
||||||
|
|
@ -1665,12 +1743,12 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp requests_in_kyiv_path do
|
defp requests_in_kyiv_path(view \\ "split") do
|
||||||
~p"/requests?#{%{west: 30.0, south: 50.0, east: 31.0, north: 51.0, zoom: 10, width: 1200, height: 720, view: "split"}}"
|
~p"/requests?#{%{west: 30, south: 50, east: 31, north: 51, zoom: 10, width: 1200, height: 720, view: view}}"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp activities_in_kyiv_path do
|
defp activities_in_kyiv_path(view \\ "split") do
|
||||||
~p"/activities?#{%{west: 30.0, south: 50.0, east: 31.0, north: 51.0, zoom: 10, width: 1200, height: 720}}"
|
~p"/activities?#{%{west: 30, south: 50, east: 31, north: 51, zoom: 10, width: 1200, height: 720, view: view}}"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp assert_eventually(fun, attempts \\ 50)
|
defp assert_eventually(fun, attempts \\ 50)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user