Add persistent million-row discovery scale profile

This commit is contained in:
SimpleTest 2026-07-28 18:11:12 +03:00
parent b639db157c
commit ebaaeaf517
34 changed files with 3149 additions and 1111 deletions

View File

@ -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 = () => {

View File

@ -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
View 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

View File

@ -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:

View File

@ -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

View 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

View File

@ -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 =

View File

@ -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

View 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

View File

@ -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 =

View File

@ -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)

View File

@ -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" />

View File

@ -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
View 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

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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"

View File

@ -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=$(

View File

@ -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

View 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

View File

@ -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(%{

View File

@ -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

View File

@ -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)