Prevent lost updates with optimistic concurrency checks

This commit is contained in:
Mikhail Chusavitin
2026-02-27 16:49:44 +03:00
parent 20d96306e4
commit b830522014
5 changed files with 70 additions and 8 deletions

View File

@@ -173,15 +173,17 @@ $app->post('/api/table/update', function (Request $request, Response $response)
$schema = $payload['schema'];
$table = $payload['table'];
$row = $payload['row'];
$originalRow = $payload['originalRow'] ?? null;
$metaArr = $meta->getTableMeta($schema, $table);
$result = $ds->updateRow($schema, $table, $row, $metaArr['columns'], $metaArr['primaryKey']);
$result = $ds->updateRow($schema, $table, $row, $metaArr['columns'], $metaArr['primaryKey'], $originalRow);
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
} catch (\Exception $e) {
$error = ['error' => true, 'message' => $e->getMessage()];
$response->getBody()->write(json_encode($error));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
$status = ((int)$e->getCode() === 409 || str_starts_with($e->getMessage(), 'CONFLICT:')) ? 409 : 400;
return $response->withHeader('Content-Type', 'application/json')->withStatus($status);
}
});

View File

@@ -27,7 +27,9 @@ async function api(url, method = 'GET', body) {
if (!res.ok) {
// Сервер вернул JSON с ошибкой
throw new Error(data.message || data.error || 'Неизвестная ошибка');
const rawMessage = data.message || data.error || 'Неизвестная ошибка';
const cleanMessage = String(rawMessage).replace(/^CONFLICT:\s*/i, '');
throw new Error(cleanMessage);
}
// Проверяем на ошибку в успешном ответе

View File

@@ -806,7 +806,8 @@ async function showEditModal(selectedRows) {
await api('/api/table/update', 'POST', {
schema: currentSchema,
table: currentTable,
row: updatedRow
row: updatedRow,
originalRow: row
});
updated++;
} catch (err) {

View File

@@ -566,7 +566,7 @@ async function selectTable(schema, tableName, restoreState = false) {
});
// Функция сохранения строки
async function saveRow(rowPos, rowData, rowElement) {
async function saveRow(rowPos, rowData, rowElement, originalRowData = null) {
console.log('💾 === СОХРАНЕНИЕ ===');
console.log(' Data:', rowData);
@@ -581,7 +581,8 @@ async function selectTable(schema, tableName, restoreState = false) {
const result = await api('/api/table/update', 'POST', {
schema: currentSchema,
table: currentTable,
row: rowData
row: rowData,
originalRow: originalRowData || rowData
});
console.log('✅ СОХРАНЕНО:', result);
@@ -694,9 +695,11 @@ async function selectTable(schema, tableName, restoreState = false) {
clearTimeout(dirtyRows.get(rowPos).timeout);
}
const originalRowData = { ...rowData, [cell.getField()]: oldValue };
const timeout = setTimeout(async () => {
console.log('⏰ Автосохранение...');
await saveRow(rowPos, rowData, row);
await saveRow(rowPos, rowData, row, originalRowData);
}, 2000);
dirtyRows.set(rowPos, { data: rowData, element: row, timeout: timeout });

View File

@@ -209,7 +209,7 @@ private function formatPDOError(\PDOException $e, string $schema, string $table,
}
public function updateRow(string $schema, string $table, array $row, array $columns, array $pk): array
public function updateRow(string $schema, string $table, array $row, array $columns, array $pk, ?array $originalRow = null): array
{
$schema = $this->validateIdentifier($schema, 'schema');
$table = $this->validateIdentifier($table, 'table');
@@ -248,6 +248,22 @@ public function updateRow(string $schema, string $table, array $row, array $colu
$params[":pk_$name"] = $row[$name];
}
$trackedColumns = [];
if (is_array($originalRow)) {
foreach ($columns as $c) {
$name = $this->validateIdentifier((string)$c['COLUMN_NAME'], 'column');
if (in_array($name, $pk, true)) {
continue;
}
if (!array_key_exists($name, $originalRow)) {
continue;
}
$whereParts[] = "`$name` <=> :orig_$name";
$params[":orig_$name"] = $originalRow[$name];
$trackedColumns[] = $name;
}
}
$sql = sprintf(
"UPDATE `%s`.`%s` SET %s WHERE %s",
$schema, $table,
@@ -262,6 +278,44 @@ public function updateRow(string $schema, string $table, array $row, array $colu
$stmt->execute($params);
$rowCount = $stmt->rowCount();
error_log("Rows updated: $rowCount");
if ($rowCount === 0 && !empty($trackedColumns)) {
$pkWhere = [];
$selectParams = [];
foreach ($pk as $name) {
$name = $this->validateIdentifier((string)$name, 'column');
$pkWhere[] = "`$name` = :pk_$name";
$selectParams[":pk_$name"] = $row[$name];
}
$selectedColumns = array_merge($pk, $trackedColumns);
$selectSql = sprintf(
"SELECT %s FROM `%s`.`%s` WHERE %s LIMIT 1",
implode(', ', array_map(fn($col) => "`$col`", $selectedColumns)),
$schema,
$table,
implode(' AND ', $pkWhere)
);
$selectStmt = $this->pdo->prepare($selectSql);
$selectStmt->execute($selectParams);
$currentRow = $selectStmt->fetch(PDO::FETCH_ASSOC);
if ($currentRow === false) {
throw new \RuntimeException('CONFLICT: Запись была удалена другим пользователем.', 409);
}
foreach ($trackedColumns as $name) {
$expected = $originalRow[$name] ?? null;
$actual = $currentRow[$name] ?? null;
if ($expected != $actual) {
throw new \RuntimeException(
'CONFLICT: Запись была изменена в другой сессии. Обновите таблицу и повторите.',
409
);
}
}
}
return ['updated' => $rowCount];
} catch (\PDOException $e) {
error_log("UPDATE FAILED: " . $e->getMessage());