feat: Enhance user group management and transaction handling in stored procedures
- Improved user group deletion logic to accurately report success or failure. - Updated user group renaming checks to prevent unintended inserts or updates. - Added documentation on transaction blocking risks during full resets in stored procedures. - Removed unnecessary login scripts from database project.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-16
|
||||
applyTo:
|
||||
- "Fuchs_Database/dbo/Stored Procedures/mfr__updt__*.sql"
|
||||
- "Fuchs_DataService/FdsMfrClient.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0022 — MFR sync transaction blocking during full resets is an accepted risk
|
||||
|
||||
## Context
|
||||
Every `mfr__updt__*` stored procedure (25 of them, one per synced mfr entity table) wraps its
|
||||
DELETE-stale-references + MERGE-upsert logic in a single explicit `BEGIN TRANSACTION` /
|
||||
`COMMIT TRANSACTION`, with `SET XACT_ABORT ON` and a `TRY/CATCH` that rolls back and logs to
|
||||
`fds__admin_dberrors` on failure. This gives all-or-nothing atomicity for one entity's sync
|
||||
batch: either every stale side-table row is cleaned up and the target table is upserted, or
|
||||
none of it is, so a mid-batch failure can never leave orphaned side-table rows alongside a
|
||||
stale/missing target row.
|
||||
|
||||
`FdsMfrClient.cs`'s `CommandAfter` (which invokes the `mfr__updt__*` proc) and `CommandBefore`/
|
||||
staging `SqlBulkCopy` both use a 600s command timeout (`MergeCommandTimeoutSeconds`), raised
|
||||
from `SqlCommand`'s 30s default specifically so a **full reset** of a large entity table (not
|
||||
the normal incremental sync, which only carries the rows that changed since the last run) has
|
||||
room to complete. Before that timeout was raised, a slow full-reset batch would self-abort at
|
||||
30s — the client cancellation would cancel the batch and roll back the open transaction —
|
||||
capping how long the transaction (and the locks it holds on the target table and the affected
|
||||
side tables) could stay open. With the timeout at 600s, a genuinely large full-reset batch can
|
||||
now hold that same transaction, and therefore those same locks, for up to ten minutes, during
|
||||
which other readers of the live `mfr__*` tables (e.g. intranet pages querying companies,
|
||||
contacts, invoices, …) can block waiting for the write transaction to release its locks —
|
||||
instead of blocking, at most, on one DELETE or one MERGE statement individually.
|
||||
|
||||
## Decision
|
||||
Accept this as a known, documented risk rather than changing behavior. Full resets are rare
|
||||
(a manual/administrative operation, not the routine incremental sync path) and are expected to
|
||||
run outside of business hours. The atomicity the transaction provides — no orphaned side-table
|
||||
rows left behind by a failed sync — is worth more than shortening the worst-case blocking
|
||||
window for an operation this infrequent.
|
||||
|
||||
## Consequences
|
||||
- A full reset of a large `mfr__*` entity table can block ordinary intranet reads of that
|
||||
table (and the side tables `mfr__*PartnerSet`, `mfr__#customvalues`, `mfr__#locations`,
|
||||
`mfr__comments`) for as long as that reset's DELETE+MERGE takes to execute, up to the 600s
|
||||
command timeout.
|
||||
- Anyone scheduling or manually triggering a full reset of a large entity table should do so
|
||||
during low-traffic hours, same as any other maintenance operation with table-level locking.
|
||||
- If full resets become frequent enough, or large enough, for this blocking window to matter in
|
||||
practice, revisit this decision — options considered and rejected here (see below) remain
|
||||
available.
|
||||
|
||||
## Alternatives considered
|
||||
- **Drop the explicit transaction**, letting each DELETE/MERGE statement auto-commit
|
||||
individually (the behavior before the transaction wrapping was added). Shortens the lock
|
||||
window back to per-statement, but reintroduces the exact orphaned-data risk on mid-batch
|
||||
failure that the transaction wrapping was added to prevent. Rejected — full-batch atomicity
|
||||
was a deliberate improvement and giving it up trades a rare, low-traffic-hours blocking issue
|
||||
for a correctness regression on every sync.
|
||||
- **Enable Read Committed Snapshot Isolation (RCSI)** (`ALTER DATABASE ... SET
|
||||
READ_COMMITTED_SNAPSHOT ON`) so readers use row versioning instead of blocking on the writer's
|
||||
locks, keeping the transaction's atomicity intact while removing reader blocking entirely.
|
||||
This is the architecturally cleanest fix, but it is a database-wide isolation behavior change
|
||||
(affects every table, every query, tempdb version-store load) that needs its own testing and
|
||||
sign-off rather than being folded into this change. Left as the preferred follow-up if the
|
||||
accepted risk above turns out not to be acceptable in practice.
|
||||
@@ -49,14 +49,18 @@ let $usr = {
|
||||
},
|
||||
/* Re-fetches a single user and swaps its grid row in place, keeping selection + ribbon
|
||||
(used after changes that only affect that one row: email, group membership). Falls back
|
||||
to a full reload when the row is no longer in the grid. */
|
||||
to a full reload when the row is no longer in the grid. Looks up the current row node by
|
||||
id inside the response handler (not before the request) so two overlapping calls — e.g.
|
||||
two quick checkbox toggles in groupsDlg — each replace whatever row is live at the time
|
||||
their own response arrives, instead of the second call operating on a node the first
|
||||
already detached via replaceWith. */
|
||||
refreshRow: function (rw) {
|
||||
let old = $('#usr' + rw.useraccount_id);
|
||||
if (old.length === 0) { $usr.renderLst(); return; }
|
||||
if ($('#usr' + rw.useraccount_id).length === 0) { $usr.renderLst(); return; }
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('usr/list'), data: { useraccount_id: rw.useraccount_id }, success: function (r) {
|
||||
let fresh = (r.users || [])[0];
|
||||
if (!fresh) { $usr.renderLst(); return; }
|
||||
let old = $('#usr' + rw.useraccount_id);
|
||||
if (!fresh || old.length === 0) { $usr.renderLst(); return; }
|
||||
let selected = old.is('.selected');
|
||||
let row = $usr.buildRow(fresh).toggleClass('selected', selected);
|
||||
old.replaceWith(row);
|
||||
|
||||
Reference in New Issue
Block a user