From 1e5f84fd88ed951f7da4b5044b724dd56b59d1af Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Dec 2025 12:21:00 +0100 Subject: [PATCH] fix: make cycle regeneration atomic on type change Make cycle regeneration synchronous in the same transaction as the member update to ensure atomicity. --- lib/membership/member.ex | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 8aa3dd7..5862a25 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -191,33 +191,23 @@ defmodule Mv.Membership.Member do # Trigger cycle regeneration when membership_fee_type_id changes # This deletes future unpaid cycles and regenerates them with the new type/amount - # Note: Cycle regeneration runs asynchronously to not block the action, - # but in test environment it runs synchronously for DB sandbox compatibility + # Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity + # CycleGenerator uses advisory locks and transactions internally to prevent race conditions change after_action(fn changeset, member, _context -> fee_type_changed = Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id) if fee_type_changed && member.membership_fee_type_id && member.join_date do - regenerate_fn = fn -> - case regenerate_cycles_on_type_change(member) do - :ok -> - :ok + case regenerate_cycles_on_type_change(member) do + :ok -> + :ok - {:error, reason} -> - require Logger + {:error, reason} -> + require Logger - Logger.warning( - "Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}" - ) - end - end - - if Application.get_env(:mv, :sql_sandbox, false) do - # Run synchronously in test environment for DB sandbox compatibility - regenerate_fn.() - else - # Run asynchronously in other environments - Task.start(regenerate_fn) + Logger.warning( + "Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}" + ) end end