44Status
55******
66
7- **Draft ** - *2026-04-09 *
7+ **Draft ** - *2026-04-13 *
88
99Context
1010*******
1111
1212The system is transitioning from the legacy permissions model (``CourseAccessRole ``)
1313to the new openedx-authz system.
1414
15- Currently, migrations between both systems are performed manually using Django management commands:
15+ Currently, migrations between the two systems are performed manually using Django
16+ management commands:
1617
1718- ``authz_migrate_course_authoring `` (forward migration)
1819- ``authz_rollback_course_authoring `` (rollback migration)
1920
20- In `ADR 0011 `_ and `ADR 0010 `_ it was established that migration must occur automatically when
21+ In `ADR 0010 `_ and `ADR 0011 `_ it was established that migrations must occur automatically when
2122the feature flag ``authz.enable_course_authoring `` changes state, but the definition of
2223the specific mechanism was deferred. This ADR addresses that gap.
2324
24- The current manual approach presents the following risks:
25-
26- - **Inconsistency **: If an operator enables or disables the flag without running the migration
27- command, the permission data in both systems will diverge.
28- - **No status tracking **: There is no visibility into whether a migration is in progress,
29- completed, or failed.
25+ The current manual approach has the following problems:
26+
27+ - **Access disparity **: Many users have access to Django Admin and can toggle the flag, while
28+ significantly fewer have permission to run management commands. This creates an operational
29+ gap where the flag state can change independently of the migration process. As a result,
30+ coordination is required between different roles (those managing flags vs. those executing
31+ migrations), increasing the risk of delays, misalignment, and inconsistent system state.
32+ - **Outage window **: When a flag change and the corresponding migration command are not executed
33+ atomically, there is a period where the flag points to one system but the permission data
34+ still lives in the other. Any permission check made during this window will fail, causing
35+ real outages for affected courses or organizations.
36+ - **No user feedback **: Users have no way to know the result of a migration without
37+ inspecting logs manually.
3038- **No concurrency protection **: Nothing prevents operators from running the migration command
3139 multiple times simultaneously, which can lead to race conditions and data corruption.
32- - **No user feedback **: Operators have no way to know the result of a migration without
33- inspecting logs manually.
3440
3541Decision
3642********
3743
38- We will implement an automatic and asynchronous migration mechanism triggered by changes in the
44+ We will implement an automatic and synchronous migration mechanism triggered by changes in the
3945``authz.enable_course_authoring `` feature flag. The solution consists of:
4046
41- #. Django signal handler to detect flag state changes.
42- #. Celery tasks to execute migrations asynchronously.
47+ #. A ``post_save `` signal handler that detects flag changes and executes the migration.
4348#. A tracking model to record migration status and errors.
44- #. A locking mechanism to prevent concurrent migrations on the same scope.
49+ #. A database-level constraint to prevent concurrent migrations on the same scope.
4550
4651.. note ::
4752
@@ -54,7 +59,7 @@ We will implement an automatic and asynchronous migration mechanism triggered by
5459 who explicitly accept the performance implications.
5560
5661Operator Safety and Opt-in Design
57- ==================================
62+ =================================
5863
5964A concern was raised about the risks of triggering data migrations on a live instance. Data
6065migrations are typically executed under controlled conditions (e.g., during maintenance windows)
@@ -77,46 +82,51 @@ This setting:
7782- Is **disabled by default **.
7883- Must be explicitly set to ``True `` by a site operator who understands the migration risks.
7984- Acts as a prerequisite check inside the signal handler: if it is not enabled, the signal
80- detects the flag change but does ** not ** dispatch the Celery task . The operator must then
85+ detects the flag change but does not execute the migration . The operator must then
8186 run the migration manually using the existing management commands.
8287
83- This design preserves the automated behavior for operators who opt in while keeping the system
84- safe for deployments where uncontrolled migrations are unacceptable.
85-
8688Detailed Design
8789===============
8890
89- 1. Utility Function Updates
90- ---------------------------
91+ 1. Migration Trigger (Django Signals)
92+ -------------------------------------
9193
92- The existing utility functions ``migrate_legacy_course_roles_to_authz `` and
93- ``migrate_authz_to_legacy_course_roles `` will be modified to incorporate the locking strategy
94- (see **Concurrency Control ** below) and the tracking logic (see **Migration Tracking Model ** below)
95- as integral steps of their execution.
94+ A ``post_save `` handler is attached to ``WaffleFlagCourseOverrideModel `` and
95+ ``WaffleFlagOrgOverrideModel `` for the ``authz.enable_course_authoring `` flag.
9696
97- This approach ensures that both the Celery task and the management commands go through the same
98- tracking and locking path .
97+ The handler fires after the record is committed to the database, so the new flag value is
98+ the authoritative and durable state of the system when the migration begins .
9999
100- 2. Migration Trigger (Django Signals)
101- -------------------------------------
100+ **Retrieving the previous state from the same model **
101+
102+ Both ``WaffleFlagCourseOverrideModel `` and ``WaffleFlagOrgOverrideModel `` extend
103+ ``ConfigurationModel ``, which **creates a new row on every save ** instead of updating the
104+ existing record. This means the full change history for each scope is preserved in the
105+ table. The previous override value is therefore always available as the most recent record
106+ for the same scope that is not the one just saved.
107+
108+ If no previous record exists for the scope (this is the first override ever created for
109+ it), the migration runs unconditionally based on the current ``enabled `` value, without
110+ comparing against a previous state.
111+
112+ **``post_save`` execution **
102113
103- ``pre_save `` signal handlers are attached to ``WaffleFlagCourseOverrideModel `` and
104- ``WaffleFlagOrgOverrideModel ``. When a save is detected for the ``authz.enable_course_authoring ``
105- flag, the handler:
114+ The ``post_save `` handler:
106115
107- #. Compares the previous and new flag state to determine the transition direction:
116+ #. Queries the same flag override model for the previous record as described above.
117+ #. If no previous record exists, runs the migration based on the current ``enabled `` value
118+ without further comparison.
119+ #. If a previous record exists, compares its ``enabled `` value with the saved one to
120+ determine whether an effective transition occurred:
108121
109122 - ``False → True ``: triggers a **forward migration ** (Legacy → openedx-authz)
110123 - ``True → False ``: triggers a **rollback migration ** (openedx-authz → Legacy)
124+ - No change: the handler does nothing. No tracking record is created and no migration runs.
111125
112126#. Determines the scope (course or organization) from the model being saved.
113- #. Dispatches an asynchronous Celery task with the migration parameters.
127+ #. Calls the utility function synchronously with the migration parameters.
114128
115- .. note ::
116- If no effective change is detected (i.e., the flag state is the same as the previous state),
117- the signal handler does nothing.
118-
119- 3. Migration Tracking Model
129+ 2. Migration Tracking Model
120130---------------------------
121131
122132A new model is introduced to track the lifecycle of each migration operation:
@@ -127,88 +137,127 @@ A new model is introduced to track the lifecycle of each migration operation:
127137 migration_type = models.CharField(max_length = 20 ) # forward / rollback
128138 scope_type = models.CharField(max_length = 20 ) # course / org
129139 scope_key = models.CharField(max_length = 255 )
130- status = models.CharField(max_length = 20 ) # pending, running, completed, skipped
140+ status = models.CharField(max_length = 20 ) # running, completed, partial_success, failed , skipped
131141 created_at = models.DateTimeField(auto_now_add = True )
132142 updated_at = models.DateTimeField(auto_now = True )
133143 completed_at = models.DateTimeField(null = True , blank = True )
134144 metadata = models.JSONField(default = dict )
135145
136- This model is registered in Django Admin so operators can inspect migration history and
146+ This model is registered in Django Admin so users can inspect migration history and
137147diagnose failures without needing to access logs directly.
138148
139- 4. Asynchronous Execution
140- -------------------------
141-
142- The Celery task acts strictly as a thin dispatcher. All core logic, including locking,
143- tracking, and migration execution, is implemented in the utility functions (see
144- **Utility Function Updates ** above).
145-
146- All database operations within the migration itself execute inside an atomic transaction.
147- If the migration fails, no data is deleted from either system, preserving consistency.
148-
149- 5. Concurrency Control (Locking Strategy)
150- -----------------------------------------
151-
152- To prevent race conditions caused by rapid or concurrent flag changes on the same scope, a
153- distributed lock is implemented using the Django cache backend (Redis):
149+ A higher-level orchestration layer (separate from the existing utility functions) will be
150+ responsible for creating and updating ``AuthzCourseAuthoringMigrationRun `` records. This
151+ layer wraps the core migration logic, ensuring that lifecycle tracking (opening a
152+ ``running `` record, handling errors, and writing the final status) is applied consistently
153+ regardless of whether the migration is triggered by the signal handler or a management
154+ command.
155+
156+ Migration Outcome Semantics
157+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
158+
159+ The ``status `` field reflects the precise outcome of each run. The possible values are:
160+
161+ - ``running ``: the migration is actively executing.
162+ - ``completed ``: all records were migrated successfully.
163+ - ``partial_success ``: the migration process ran to completion, but one or more individual
164+ records failed and were skipped. The ``metadata `` field contains details about the
165+ failures.
166+ - ``failed ``: a critical error prevented the migration from completing (e.g., an unhandled
167+ exception or infrastructure problem). The ``metadata `` field contains the exception details.
168+ - ``skipped ``: the migration was not attempted because another run for the same scope was
169+ already active.
170+
171+ 3. Concurrency Control
172+ ----------------------
173+
174+ To prevent overlapping migrations on the same scope, the tracking model enforces a
175+ conditional ``UniqueConstraint `` on ``(scope_type, scope_key) `` filtered to
176+ ``status="running" ``. This guarantees that no second active migration record can be
177+ inserted for the same scope regardless of how many processes attempt to do so concurrently.
178+ Any attempt raises an ``IntegrityError ``, which the caller handles by recording a
179+ ``skipped `` run and aborting.
154180
155181.. code :: python
156182
157- lock_key = f " authz_migration: { scope_type} : { scope_key} "
158-
159- The lock is acquired using ``cache.add() ``, which is an atomic operation. The default TTL
160- is **1 hour **. If a lock already exists for the given scope, the migration is skipped
161- and a new tracking record is created with that status. This ensures that only one
162- migration runs at a time for the same scope.
163-
164- 6. Execution Flow
165- ------------------
166-
167- 1. An operator changes the ``authz.enable_course_authoring `` flag for a course or
168- organization via Django Admin or a management command.
169- 2. The ``pre_save `` signal handler detects the state transition.
170- 3. A Celery task is dispatched asynchronously.
171- 4. The task calls the utility function, which acquires the lock, creates and updates the
172- ``AuthzCourseAuthoringMigrationRun `` record, and executes the migration.
173- 5. The operator can check the migration status via Django Admin on the ``AuthzCourseAuthoringMigrationRun ``
174- model.
183+ class Meta :
184+ constraints = [
185+ models.UniqueConstraint(
186+ fields = [" scope_type" , " scope_key" ],
187+ condition = models.Q(status = " running" ),
188+ name = " unique_active_migration_per_scope" ,
189+ )
190+ ]
191+
192+ 4. Execution Flow
193+ -----------------
194+
195+ 1. The user changes the ``authz.enable_course_authoring `` flag for a course or
196+ organization and saves the record. A new row is created in the override table.
197+ 2. The ``post_save `` handler queries the same override model for the previous record
198+ (most recent row for the same scope, excluding the one just saved) to obtain the
199+ previous ``enabled `` value.
200+ 3. The handler compares the previous value with the current ``enabled `` value. If no
201+ effective change occurred, it does nothing.
202+ 4. If a transition is detected, the handler calls the utility function synchronously. The
203+ function creates an ``AuthzCourseAuthoringMigrationRun `` record with
204+ ``status="running" `` (the database constraint prevents this if another run for the
205+ same scope is already active) and executes the migration.
206+ 5. The record is updated to its final status (``completed ``, ``partial_success ``, ``failed ``,
207+ or ``skipped ``) before the ``post_save `` handler returns.
208+ 6. The user can review the migration outcome via Django Admin on the
209+ ``AuthzCourseAuthoringMigrationRun `` model.
175210
176211Consequences
177212************
178213
179214Positive consequences
180215=====================
181216
182- - **Migration is decoupled from the request cycle **: the flag change returns immediately and
183- migration happens in the background.
184217- **Full observability **: every migration run is recorded with its status, scope, and metadata
185218 in the tracking model.
186- - **Concurrency-safe **: the lock strategy prevents overlapping migrations on the same scope.
187- - **No manual intervention required **: for course-level or organization-level flag changes. Operators
188- who have opted in do not need to remember to run management commands.
219+ - **Concurrency-safe **: the database-level constraint prevents overlapping migrations on the same
220+ scope, regardless of cache availability or worker failures.
221+ - **No manual intervention required ** for course-level or organization-level flag changes. Operators
222+ or users who have opted in do not need to remember to run management commands.
189223- **Safe by default **: the opt-in guard flag ensures that automatic migration is never triggered
190224 unexpectedly on instances where operators have not explicitly accepted the risks.
191225
192226Negative consequences / risks
193- ==============================
227+ =============================
194228
195229- **Global flag changes are not covered **: operators must still run management commands
196230 manually when enabling or disabling the flag at the instance level. This is a deliberate
197231 trade-off to avoid performance risks.
198- - **Celery dependency **: the system now requires a functioning Celery worker for automatic
199- migration. If workers are down, migrations will be queued but not executed until workers
200- recover.
201- - **Lock TTL edge cases **: if a migration takes longer than 1 hour (unlikely but possible
202- for very large organizations), the lock will expire and a new migration for the same scope
203- could start concurrently for the same scope.
232+ - **Blocks the request **: the migration runs synchronously inside the ``post_save `` signal,
233+ so the HTTP request that triggered the flag change does not return until the migration
234+ finishes. For large organization-level scopes this can cause noticeable latency or
235+ timeouts. This is an accepted trade-off given that automatic migration is scoped to
236+ course-level and organization-level overrides only (never global), and is opt-in.
237+ - **Runtime execution trade-offs **: Unlike management commands typically executed during
238+ maintenance windows, this migration runs in a live production environment as part of
239+ normal system operation. This means it executes under concurrent load, with active
240+ requests and database activity, which introduces variability in execution conditions.
241+ This trade-off is inherent to enabling the feature flag to act as a real-time source
242+ of truth. The design prioritizes consistency between flag state and permission data
243+ over strictly controlled execution environments, while providing observability and
244+ recovery mechanisms to mitigate operational risk.
204245
205246Rejected Alternatives
206247*********************
207248
208- **Synchronous execution in the signal handler **
209- Executing the migration directly inside the ``pre_save `` signal would block the HTTP
210- request that triggered the flag change, leading to timeouts for large scopes and poor
211- operator experience.
249+ **Using ``pre_save`` to trigger the migration **
250+ A ``pre_save `` handler could detect the transition direction and execute the migration
251+ before the flag change is written. This approach violates ACID principles: at the moment
252+ ``pre_save `` fires, the new flag value has not yet been committed to the database. If the
253+ subsequent ``save() `` were to fail (e.g., a validation error, a database constraint
254+ violation, or a network issue), the migration would have already run against a state that
255+ was never persisted, leaving the permission data inconsistent with the actual flag value.
256+
257+ **Asynchronous execution via Celery **
258+ Given that automatic migration is scoped to course-level and organization-level
259+ overrides where migration volumes are bounded, synchronous execution is simpler
260+ and provides stronger consistency guarantees.
212261
213262**Manual migration **
214263 Error-prone, not scalable, and inconsistent. The flag is the source of truth, but manual
0 commit comments