470 lines
15 KiB
Elixir
470 lines
15 KiB
Elixir
defmodule WhoNeedHelp.Repo.Migrations.AddDiscoveryRollups do
|
|
use Ecto.Migration
|
|
|
|
@maximum_rollup_level 12
|
|
|
|
def up do
|
|
create table(:help_discovery_rollups, primary_key: false) do
|
|
add(:level, :smallint, null: false)
|
|
add(:cell_x, :integer, null: false)
|
|
add(:cell_y, :integer, null: false)
|
|
add(:category_id, :binary_id, null: false)
|
|
add(:urgency, :string, null: false)
|
|
add(:visible_until_hour, :utc_datetime, null: false)
|
|
add(:point_count, :bigint, null: false)
|
|
end
|
|
|
|
create(
|
|
constraint(:help_discovery_rollups, :help_discovery_rollups_level_range,
|
|
check: "level BETWEEN 0 AND #{@maximum_rollup_level}"
|
|
)
|
|
)
|
|
|
|
create(
|
|
constraint(:help_discovery_rollups, :help_discovery_rollups_non_negative_count,
|
|
check: "point_count >= 0"
|
|
)
|
|
)
|
|
|
|
create table(:activity_discovery_rollups, primary_key: false) do
|
|
add(:level, :smallint, null: false)
|
|
add(:cell_x, :integer, null: false)
|
|
add(:cell_y, :integer, null: false)
|
|
add(:category_id, :binary_id, null: false)
|
|
add(:visible_until_hour, :utc_datetime, null: false)
|
|
add(:point_count, :bigint, null: false)
|
|
end
|
|
|
|
create(
|
|
constraint(:activity_discovery_rollups, :activity_discovery_rollups_level_range,
|
|
check: "level BETWEEN 0 AND #{@maximum_rollup_level}"
|
|
)
|
|
)
|
|
|
|
create(
|
|
constraint(
|
|
:activity_discovery_rollups,
|
|
:activity_discovery_rollups_non_negative_count,
|
|
check: "point_count >= 0"
|
|
)
|
|
)
|
|
|
|
# The initial snapshot and trigger installation must describe one database
|
|
# state. Reads remain available, while writes to either discovery source wait
|
|
# until this migration transaction commits.
|
|
execute("LOCK TABLE help_requests, activities IN SHARE ROW EXCLUSIVE MODE")
|
|
|
|
execute(rebuild_help_rollups_sql())
|
|
execute(rebuild_activity_rollups_sql())
|
|
execute(validate_initial_rollups_sql())
|
|
|
|
create(
|
|
unique_index(
|
|
:help_discovery_rollups,
|
|
[:level, :cell_x, :cell_y, :category_id, :urgency, :visible_until_hour],
|
|
name: :help_discovery_rollups_identity
|
|
)
|
|
)
|
|
|
|
create(
|
|
index(
|
|
:help_discovery_rollups,
|
|
[:level, :cell_x, :cell_y, :visible_until_hour],
|
|
include: [:category_id, :urgency, :point_count],
|
|
name: :help_discovery_rollups_viewport
|
|
)
|
|
)
|
|
|
|
create(
|
|
index(
|
|
:help_discovery_rollups,
|
|
[:level, :category_id, :urgency, :cell_x, :cell_y, :visible_until_hour],
|
|
include: [:point_count],
|
|
name: :help_discovery_rollups_filtered_viewport
|
|
)
|
|
)
|
|
|
|
create(
|
|
unique_index(
|
|
:activity_discovery_rollups,
|
|
[:level, :cell_x, :cell_y, :category_id, :visible_until_hour],
|
|
name: :activity_discovery_rollups_identity
|
|
)
|
|
)
|
|
|
|
create(
|
|
index(
|
|
:activity_discovery_rollups,
|
|
[:level, :cell_x, :cell_y, :visible_until_hour],
|
|
include: [:category_id, :point_count],
|
|
name: :activity_discovery_rollups_viewport
|
|
)
|
|
)
|
|
|
|
create(
|
|
index(
|
|
:activity_discovery_rollups,
|
|
[:level, :category_id, :cell_x, :cell_y, :visible_until_hour],
|
|
include: [:point_count],
|
|
name: :activity_discovery_rollups_filtered_viewport
|
|
)
|
|
)
|
|
|
|
execute(help_trigger_function("insert", help_change_source("new_rows", 1)))
|
|
execute(help_trigger_function("delete", help_change_source("old_rows", -1)))
|
|
|
|
execute(
|
|
help_trigger_function(
|
|
"update",
|
|
help_change_source("new_rows", 1) <>
|
|
" UNION ALL " <>
|
|
help_change_source("old_rows", -1)
|
|
)
|
|
)
|
|
|
|
execute(activity_trigger_function("insert", activity_change_source("new_rows", 1)))
|
|
execute(activity_trigger_function("delete", activity_change_source("old_rows", -1)))
|
|
|
|
execute(
|
|
activity_trigger_function(
|
|
"update",
|
|
activity_change_source("new_rows", 1) <>
|
|
" UNION ALL " <>
|
|
activity_change_source("old_rows", -1)
|
|
)
|
|
)
|
|
|
|
execute("""
|
|
CREATE TRIGGER help_discovery_rollups_after_insert
|
|
AFTER INSERT ON help_requests
|
|
REFERENCING NEW TABLE AS new_rows
|
|
FOR EACH STATEMENT EXECUTE FUNCTION wnh_help_discovery_rollups_insert()
|
|
""")
|
|
|
|
execute("""
|
|
CREATE TRIGGER help_discovery_rollups_after_delete
|
|
AFTER DELETE ON help_requests
|
|
REFERENCING OLD TABLE AS old_rows
|
|
FOR EACH STATEMENT EXECUTE FUNCTION wnh_help_discovery_rollups_delete()
|
|
""")
|
|
|
|
execute("""
|
|
CREATE TRIGGER help_discovery_rollups_after_update
|
|
AFTER UPDATE ON help_requests
|
|
REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
|
|
FOR EACH STATEMENT EXECUTE FUNCTION wnh_help_discovery_rollups_update()
|
|
""")
|
|
|
|
execute("""
|
|
CREATE TRIGGER activity_discovery_rollups_after_insert
|
|
AFTER INSERT ON activities
|
|
REFERENCING NEW TABLE AS new_rows
|
|
FOR EACH STATEMENT EXECUTE FUNCTION wnh_activity_discovery_rollups_insert()
|
|
""")
|
|
|
|
execute("""
|
|
CREATE TRIGGER activity_discovery_rollups_after_delete
|
|
AFTER DELETE ON activities
|
|
REFERENCING OLD TABLE AS old_rows
|
|
FOR EACH STATEMENT EXECUTE FUNCTION wnh_activity_discovery_rollups_delete()
|
|
""")
|
|
|
|
execute("""
|
|
CREATE TRIGGER activity_discovery_rollups_after_update
|
|
AFTER UPDATE ON activities
|
|
REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
|
|
FOR EACH STATEMENT EXECUTE FUNCTION wnh_activity_discovery_rollups_update()
|
|
""")
|
|
end
|
|
|
|
def down do
|
|
execute("DROP TRIGGER IF EXISTS activity_discovery_rollups_after_update ON activities")
|
|
execute("DROP TRIGGER IF EXISTS activity_discovery_rollups_after_delete ON activities")
|
|
execute("DROP TRIGGER IF EXISTS activity_discovery_rollups_after_insert ON activities")
|
|
execute("DROP TRIGGER IF EXISTS help_discovery_rollups_after_update ON help_requests")
|
|
execute("DROP TRIGGER IF EXISTS help_discovery_rollups_after_delete ON help_requests")
|
|
execute("DROP TRIGGER IF EXISTS help_discovery_rollups_after_insert ON help_requests")
|
|
|
|
for operation <- ~w(insert delete update) do
|
|
execute("DROP FUNCTION IF EXISTS wnh_activity_discovery_rollups_#{operation}()")
|
|
execute("DROP FUNCTION IF EXISTS wnh_help_discovery_rollups_#{operation}()")
|
|
end
|
|
|
|
drop(table(:activity_discovery_rollups))
|
|
drop(table(:help_discovery_rollups))
|
|
end
|
|
|
|
defp rebuild_help_rollups_sql do
|
|
"""
|
|
INSERT INTO help_discovery_rollups
|
|
(level, cell_x, cell_y, category_id, urgency, visible_until_hour, point_count)
|
|
SELECT
|
|
level,
|
|
#{cell_x_sql("request.public_longitude", "level")},
|
|
#{cell_y_sql("request.public_latitude", "level")},
|
|
request.category_id,
|
|
request.urgency,
|
|
date_trunc('hour', request.expires_at),
|
|
count(*)::bigint
|
|
FROM help_requests AS request
|
|
CROSS JOIN generate_series(0, #{@maximum_rollup_level}) AS level
|
|
WHERE #{eligible_help_sql("request")}
|
|
GROUP BY level, cell_x, cell_y, request.category_id, request.urgency,
|
|
date_trunc('hour', request.expires_at)
|
|
"""
|
|
end
|
|
|
|
defp rebuild_activity_rollups_sql do
|
|
"""
|
|
INSERT INTO activity_discovery_rollups
|
|
(level, cell_x, cell_y, category_id, visible_until_hour, point_count)
|
|
SELECT
|
|
level,
|
|
#{cell_x_sql("activity.public_longitude", "level")},
|
|
#{cell_y_sql("activity.public_latitude", "level")},
|
|
activity.category_id,
|
|
date_trunc('hour', least(activity.starts_at, activity.join_deadline)),
|
|
count(*)::bigint
|
|
FROM activities AS activity
|
|
CROSS JOIN generate_series(0, #{@maximum_rollup_level}) AS level
|
|
WHERE #{eligible_activity_sql("activity")}
|
|
GROUP BY level, cell_x, cell_y, activity.category_id,
|
|
date_trunc('hour', least(activity.starts_at, activity.join_deadline))
|
|
"""
|
|
end
|
|
|
|
defp help_change_source(relation, delta) do
|
|
"""
|
|
SELECT
|
|
#{delta}::bigint AS delta,
|
|
request.public_longitude,
|
|
request.public_latitude,
|
|
request.category_id,
|
|
request.urgency,
|
|
date_trunc('hour', request.expires_at) AS visible_until_hour
|
|
FROM #{relation} AS request
|
|
WHERE #{eligible_help_sql("request")}
|
|
"""
|
|
end
|
|
|
|
defp activity_change_source(relation, delta) do
|
|
"""
|
|
SELECT
|
|
#{delta}::bigint AS delta,
|
|
activity.public_longitude,
|
|
activity.public_latitude,
|
|
activity.category_id,
|
|
date_trunc('hour', least(activity.starts_at, activity.join_deadline)) AS visible_until_hour
|
|
FROM #{relation} AS activity
|
|
WHERE #{eligible_activity_sql("activity")}
|
|
"""
|
|
end
|
|
|
|
defp help_trigger_function(operation, changes_sql) do
|
|
"""
|
|
CREATE FUNCTION wnh_help_discovery_rollups_#{operation}()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $function$
|
|
BEGIN
|
|
WITH changes AS (
|
|
#{changes_sql}
|
|
), expanded AS (
|
|
SELECT
|
|
changes.delta,
|
|
level,
|
|
#{cell_x_sql("changes.public_longitude", "level")},
|
|
#{cell_y_sql("changes.public_latitude", "level")},
|
|
changes.category_id,
|
|
changes.urgency,
|
|
changes.visible_until_hour
|
|
FROM changes
|
|
CROSS JOIN generate_series(0, #{@maximum_rollup_level}) AS level
|
|
), deltas AS (
|
|
SELECT
|
|
level,
|
|
cell_x,
|
|
cell_y,
|
|
category_id,
|
|
urgency,
|
|
visible_until_hour,
|
|
sum(delta)::bigint AS point_count
|
|
FROM expanded
|
|
GROUP BY level, cell_x, cell_y, category_id, urgency, visible_until_hour
|
|
HAVING sum(delta) <> 0
|
|
)
|
|
MERGE INTO help_discovery_rollups AS target
|
|
USING deltas AS source
|
|
ON target.level = source.level
|
|
AND target.cell_x = source.cell_x
|
|
AND target.cell_y = source.cell_y
|
|
AND target.category_id = source.category_id
|
|
AND target.urgency = source.urgency
|
|
AND target.visible_until_hour = source.visible_until_hour
|
|
WHEN MATCHED AND target.point_count + source.point_count > 0 THEN
|
|
UPDATE SET point_count = target.point_count + source.point_count
|
|
WHEN MATCHED THEN DELETE
|
|
WHEN NOT MATCHED AND source.point_count > 0 THEN
|
|
INSERT
|
|
(level, cell_x, cell_y, category_id, urgency, visible_until_hour, point_count)
|
|
VALUES
|
|
(source.level, source.cell_x, source.cell_y, source.category_id,
|
|
source.urgency, source.visible_until_hour, source.point_count);
|
|
|
|
RETURN NULL;
|
|
END
|
|
$function$
|
|
"""
|
|
end
|
|
|
|
defp activity_trigger_function(operation, changes_sql) do
|
|
"""
|
|
CREATE FUNCTION wnh_activity_discovery_rollups_#{operation}()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $function$
|
|
BEGIN
|
|
WITH changes AS (
|
|
#{changes_sql}
|
|
), expanded AS (
|
|
SELECT
|
|
changes.delta,
|
|
level,
|
|
#{cell_x_sql("changes.public_longitude", "level")},
|
|
#{cell_y_sql("changes.public_latitude", "level")},
|
|
changes.category_id,
|
|
changes.visible_until_hour
|
|
FROM changes
|
|
CROSS JOIN generate_series(0, #{@maximum_rollup_level}) AS level
|
|
), deltas AS (
|
|
SELECT
|
|
level,
|
|
cell_x,
|
|
cell_y,
|
|
category_id,
|
|
visible_until_hour,
|
|
sum(delta)::bigint AS point_count
|
|
FROM expanded
|
|
GROUP BY level, cell_x, cell_y, category_id, visible_until_hour
|
|
HAVING sum(delta) <> 0
|
|
)
|
|
MERGE INTO activity_discovery_rollups AS target
|
|
USING deltas AS source
|
|
ON target.level = source.level
|
|
AND target.cell_x = source.cell_x
|
|
AND target.cell_y = source.cell_y
|
|
AND target.category_id = source.category_id
|
|
AND target.visible_until_hour = source.visible_until_hour
|
|
WHEN MATCHED AND target.point_count + source.point_count > 0 THEN
|
|
UPDATE SET point_count = target.point_count + source.point_count
|
|
WHEN MATCHED THEN DELETE
|
|
WHEN NOT MATCHED AND source.point_count > 0 THEN
|
|
INSERT (level, cell_x, cell_y, category_id, visible_until_hour, point_count)
|
|
VALUES
|
|
(source.level, source.cell_x, source.cell_y, source.category_id,
|
|
source.visible_until_hour, source.point_count);
|
|
|
|
RETURN NULL;
|
|
END
|
|
$function$
|
|
"""
|
|
end
|
|
|
|
defp eligible_help_sql(alias_name) do
|
|
"""
|
|
#{alias_name}.status = 'open'
|
|
AND #{alias_name}.hidden_at IS NULL
|
|
AND #{alias_name}.location_visibility <> 'hidden'
|
|
AND #{alias_name}.public_longitude IS NOT NULL
|
|
AND #{alias_name}.public_latitude IS NOT NULL
|
|
AND #{alias_name}.category_id IS NOT NULL
|
|
AND #{alias_name}.urgency IS NOT NULL
|
|
AND #{alias_name}.expires_at IS NOT NULL
|
|
"""
|
|
end
|
|
|
|
defp eligible_activity_sql(alias_name) do
|
|
"""
|
|
#{alias_name}.status = 'open'
|
|
AND #{alias_name}.hidden_at IS NULL
|
|
AND #{alias_name}.location_visibility = 'approximate_public'
|
|
AND #{alias_name}.public_longitude IS NOT NULL
|
|
AND #{alias_name}.public_latitude IS NOT NULL
|
|
AND #{alias_name}.category_id IS NOT NULL
|
|
AND #{alias_name}.starts_at IS NOT NULL
|
|
AND #{alias_name}.join_deadline IS NOT NULL
|
|
"""
|
|
end
|
|
|
|
defp validate_initial_rollups_sql do
|
|
"""
|
|
DO $validation$
|
|
DECLARE
|
|
expected_help bigint;
|
|
actual_help bigint;
|
|
expected_activities bigint;
|
|
actual_activities bigint;
|
|
BEGIN
|
|
SELECT count(*) INTO expected_help
|
|
FROM help_requests AS request
|
|
WHERE #{eligible_help_sql("request")};
|
|
|
|
SELECT coalesce(sum(point_count), 0) INTO actual_help
|
|
FROM help_discovery_rollups
|
|
WHERE level = 0;
|
|
|
|
IF expected_help <> actual_help THEN
|
|
RAISE EXCEPTION
|
|
'help discovery rollup validation failed: expected %, got %',
|
|
expected_help, actual_help;
|
|
END IF;
|
|
|
|
SELECT count(*) INTO expected_activities
|
|
FROM activities AS activity
|
|
WHERE #{eligible_activity_sql("activity")};
|
|
|
|
SELECT coalesce(sum(point_count), 0) INTO actual_activities
|
|
FROM activity_discovery_rollups
|
|
WHERE level = 0;
|
|
|
|
IF expected_activities <> actual_activities THEN
|
|
RAISE EXCEPTION
|
|
'activity discovery rollup validation failed: expected %, got %',
|
|
expected_activities, actual_activities;
|
|
END IF;
|
|
END
|
|
$validation$
|
|
"""
|
|
end
|
|
|
|
defp cell_x_sql(longitude, level) do
|
|
"""
|
|
least(
|
|
power(2, #{level})::bigint - 1,
|
|
greatest(0, floor(((#{longitude} + 180.0) / 360.0) * power(2, #{level})))::bigint
|
|
) AS cell_x
|
|
"""
|
|
end
|
|
|
|
defp cell_y_sql(latitude, level) do
|
|
"""
|
|
least(
|
|
power(2, #{level})::bigint - 1,
|
|
greatest(
|
|
0,
|
|
floor(
|
|
(
|
|
ln(
|
|
tan(
|
|
pi() / 4.0 +
|
|
radians(least(85.05112878, greatest(-85.05112878, #{latitude}))) / 2.0
|
|
)
|
|
) / pi() + 1.0
|
|
) / 2.0 * power(2, #{level})
|
|
)::bigint
|
|
)
|
|
) AS cell_y
|
|
"""
|
|
end
|
|
end
|