Skip to content

Commit ae8c3ac

Browse files
authored
Allow DML in REPEATABLE READ transactions but perform safety checks (#8561)
- In conflicts.py, implement analysis for what inserts and updates affect a cross-table constraint - Plumb that information out into the QueryUnit and to the server - Report the conflicts as part of CommandDataDescription in an annotation called `unsafe_isolation_dangers`, so clients can potentially make proactive decisions. - Raise an error when attempting to execute something with isolation dangers in REPEATABLE READ mode. There are still a few follow-ups before I can merge, but this is reviewable now: - [x] Write tests - [x] Follow-up on some possible bugs I found in the conflict mechanism that might have wider scope once this is implemented. - [x] Improve some documentation Advances #8458.
1 parent 5939b97 commit ae8c3ac

File tree

17 files changed

+378
-55
lines changed

17 files changed

+378
-55
lines changed

edb/api/errors.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
0x_03_04_00_00 CapabilityError
4545
0x_03_04_01_00 UnsupportedCapabilityError
4646
0x_03_04_02_00 DisabledCapabilityError
47+
0x_03_04_03_00 UnsafeIsolationLevelError
4748

4849

4950
####

edb/edgeql/compiler/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ def compile_ast_fragment_to_ir(
366366
singletons=[],
367367
triggers=(),
368368
warnings=tuple(ctx.env.warnings),
369+
unsafe_isolation_dangers=tuple(ctx.env.unsafe_isolation_dangers),
369370
)
370371

371372

edb/edgeql/compiler/conflicts.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,3 +887,100 @@ def compile_inheritance_conflict_checks(
887887
)
888888

889889
return conflicters or None
890+
891+
892+
def check_for_isolation_conflicts(
893+
stmt: irast.MutatingStmt,
894+
typ: s_objtypes.ObjectType,
895+
update_typ: Optional[s_objtypes.ObjectType] = None,
896+
*, ctx: context.ContextLevel,
897+
) -> None:
898+
"""Check for conflicts on a DML stmt that cause isolation dangers.
899+
900+
Cross-table exclusive constraints are implemented by triggers that
901+
read the other tables looking for conflicting rows. This works
902+
fine in SERIALIZABLE mode, but in REPEATABLE READ mode, this can
903+
miss two concurrent transactions creating conflicting objects.
904+
905+
Analyze the type involved in `stmt` to see if there are isolation
906+
dangers, and log them if so. These will be reported to the client
907+
and will generate an error if the query is executed in REPEATABLE
908+
READ mode.
909+
910+
This function is called for every subtype in an UPDATE. In that
911+
case, `typ` is the subtype and `update_typ` is the base type being
912+
UDPATEd.
913+
"""
914+
915+
schema = ctx.env.schema
916+
917+
entries = _get_type_conflict_constraint_entries(stmt, typ, ctx=ctx)
918+
constrs = [cnstr for cnstr, _ in entries]
919+
920+
op = 'INSERT' if isinstance(stmt, irast.InsertStmt) else 'UPDATE'
921+
base_msg = f'{op} to {typ.get_verbosename(schema)} '
922+
923+
for constr in constrs:
924+
subject = constr.get_subject(schema)
925+
assert subject
926+
927+
# Find the origin type; if we are the only origin type and we
928+
# don't have children, then we are good.
929+
all_objs = []
930+
match constr.get_constraint_origins(schema):
931+
case []:
932+
continue
933+
case [root_constr, *_]:
934+
root_subject = root_constr.get_subject(schema)
935+
if isinstance(root_subject, s_pointers.Pointer):
936+
root_subject_obj = root_subject.get_source(schema)
937+
else:
938+
root_subject_obj = root_subject
939+
940+
if isinstance(root_subject_obj, s_objtypes.ObjectType):
941+
all_objs = list(
942+
schemactx.get_all_concrete(root_subject_obj, ctx=ctx)
943+
)
944+
if root_subject_obj == typ and len(all_objs) == 1:
945+
continue
946+
if root_subject_obj == typ and constr.get_delegated(schema):
947+
continue
948+
# If this is an UPDATE and we are processing some
949+
# subtype, and the actual type being updated is
950+
# covered by this same constraint, don't report it for
951+
# the child: it will be reported for an ancestor,
952+
# which is less noisy.
953+
if (
954+
update_typ
955+
and update_typ != typ
956+
and update_typ in all_objs
957+
):
958+
continue
959+
960+
subj_vn = subject.get_verbosename(schema, with_parent=True)
961+
vn = f'{base_msg}affects an exclusive constraint on {subj_vn}'
962+
if expr := constr.get_subjectexpr(schema):
963+
vn += f" with expression '{expr.text}'"
964+
965+
if not root_subject_obj:
966+
msg = (
967+
f"{vn} that is defined on "
968+
f"{root_subject.get_verbosename(schema)}"
969+
)
970+
elif root_subject_obj != typ:
971+
msg = (
972+
f"{vn} that is defined in ancestor "
973+
f"{root_subject_obj.get_verbosename(schema)}"
974+
)
975+
else:
976+
all_objs_s = ', '.join(sorted(
977+
f"'{o.get_displayname(schema)}'" for o in all_objs if o != typ
978+
))
979+
msg = (
980+
f"{vn} that is shared with "
981+
f"descendant types: {all_objs_s}"
982+
)
983+
984+
ctx.log_repeatable_read_danger(
985+
errors.UnsafeIsolationLevelError(msg, span=stmt.span)
986+
)

edb/edgeql/compiler/context.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,9 @@ class Environment:
308308
warnings: list[errors.EdgeDBError]
309309
"""List of warnings to emit"""
310310

311+
unsafe_isolation_dangers: list[errors.UnsafeIsolationLevelError]
312+
"""List of repeatable read DML dangers"""
313+
311314
def __init__(
312315
self,
313316
*,
@@ -358,6 +361,7 @@ def __init__(
358361
self.path_scope_map = {}
359362
self.dml_rewrites = {}
360363
self.warnings = []
364+
self.unsafe_isolation_dangers = []
361365

362366
def add_schema_ref(
363367
self, sobj: s_obj.Object, expr: Optional[qlast.Base]
@@ -836,6 +840,11 @@ def get_security_context(self) -> object:
836840
def log_warning(self, warning: errors.EdgeDBError) -> None:
837841
self.env.warnings.append(warning)
838842

843+
def log_repeatable_read_danger(
844+
self, d: errors.UnsafeIsolationLevelError
845+
) -> None:
846+
self.env.unsafe_isolation_dangers.append(d)
847+
839848
def allow_factoring(self) -> None:
840849
self.no_factoring = self.warn_factoring = False
841850

edb/edgeql/compiler/stmt.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,9 @@ def compile_InsertQuery(
544544
stmt.on_conflict = conflicts.compile_insert_unless_conflict(
545545
stmt, stmt_subject_stype, ctx=ictx)
546546

547+
conflicts.check_for_isolation_conflicts(
548+
stmt, stmt_subject_stype, ctx=ictx)
549+
547550
mat_stype = schemactx.get_material_type(stmt_subject_stype, ctx=ctx)
548551
result = setgen.class_set(
549552
mat_stype, path_id=stmt.subject.path_id, ctx=ctx
@@ -705,6 +708,9 @@ def compile_UpdateQuery(
705708
):
706709
stmt.write_policies[dtype.id] = write_pol
707710

711+
conflicts.check_for_isolation_conflicts(
712+
stmt, dtype, mat_stype, ctx=ictx)
713+
708714
stmt.conflict_checks = conflicts.compile_inheritance_conflict_checks(
709715
stmt, mat_stype, ctx=ictx)
710716

edb/edgeql/compiler/stmtctx.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ def fini_expression(
360360
singletons=ctx.env.singletons,
361361
triggers=ir_triggers,
362362
warnings=tuple(ctx.env.warnings),
363+
unsafe_isolation_dangers=tuple(ctx.env.unsafe_isolation_dangers),
363364
)
364365
return result
365366

edb/errors/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
'CapabilityError',
2424
'UnsupportedCapabilityError',
2525
'DisabledCapabilityError',
26+
'UnsafeIsolationLevelError',
2627
'QueryError',
2728
'InvalidSyntaxError',
2829
'EdgeQLSyntaxError',
@@ -158,6 +159,10 @@ class DisabledCapabilityError(CapabilityError):
158159
_code = 0x_03_04_02_00
159160

160161

162+
class UnsafeIsolationLevelError(CapabilityError):
163+
_code = 0x_03_04_03_00
164+
165+
161166
class QueryError(EdgeDBError):
162167
_code = 0x_04_00_00_00
163168

edb/ir/ast.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,7 @@ class Statement(Command):
806806
singletons: list[PathId]
807807
triggers: tuple[tuple[Trigger, ...], ...]
808808
warnings: tuple[errors.EdgeDBError, ...]
809+
unsafe_isolation_dangers: tuple[errors.UnsafeIsolationLevelError, ...]
809810

810811

811812
class TypeIntrospection(ImmutableExpr):

edb/server/compiler/compiler.py

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2005,6 +2005,7 @@ def _compile_ql_query(
20052005
has_dml=bool(ir.dml_exprs),
20062006
query_asts=query_asts,
20072007
warnings=ir.warnings,
2008+
unsafe_isolation_dangers=ir.unsafe_isolation_dangers,
20082009
)
20092010

20102011

@@ -2165,6 +2166,7 @@ def _compile_ql_transaction(
21652166
final_global_schema: Optional[s_schema.Schema] = None
21662167
sp_name = None
21672168
sp_id = None
2169+
iso = None
21682170

21692171
if ctx.expect_rollback and not isinstance(
21702172
ql, (qlast.RollbackTransaction, qlast.RollbackToSavepoint)
@@ -2190,25 +2192,10 @@ def _compile_ql_transaction(
21902192
# Compute the effective access mode
21912193
access = ql.access
21922194
if access is None:
2193-
if default_iso is qltypes.TransactionIsolationLevel.SERIALIZABLE:
2194-
access_mode: statypes.TransactionAccessMode = _get_config_val(
2195-
ctx, "default_transaction_access_mode"
2196-
)
2197-
access = access_mode.to_qltypes()
2198-
else:
2199-
access = qltypes.TransactionAccessMode.READ_ONLY
2200-
2201-
# Guard against unsupported isolation + access combinations
2202-
if (
2203-
iso is not qltypes.TransactionIsolationLevel.SERIALIZABLE
2204-
and access is not qltypes.TransactionAccessMode.READ_ONLY
2205-
):
2206-
raise errors.TransactionError(
2207-
f"{iso.value} transaction isolation level is only "
2208-
"supported in read-only transactions",
2209-
span=ql.span,
2210-
hint=f"specify READ ONLY access mode",
2195+
access_mode: statypes.TransactionAccessMode = _get_config_val(
2196+
ctx, "default_transaction_access_mode"
22112197
)
2198+
access = access_mode.to_qltypes()
22122199

22132200
sqls = f'START TRANSACTION ISOLATION LEVEL {iso.value} {access.value}'
22142201
if ql.deferrable is not None:
@@ -2284,6 +2271,7 @@ def _compile_ql_transaction(
22842271
global_schema=final_global_schema,
22852272
sp_name=sp_name,
22862273
sp_id=sp_id,
2274+
isolation_level=iso,
22872275
feature_used_metrics=(
22882276
ddl.produce_feature_used_metrics(
22892277
ctx.compiler_state, final_user_schema
@@ -3003,6 +2991,7 @@ def _make_query_unit(
30032991
cache_key=ctx.cache_key,
30042992
user_schema_version=schema_version,
30052993
warnings=comp.warnings,
2994+
unsafe_isolation_dangers=comp.unsafe_isolation_dangers,
30062995
)
30072996

30082997
if not comp.is_transactional:
@@ -3093,6 +3082,7 @@ def _make_query_unit(
30933082
)
30943083
unit.sql = comp.sql
30953084
unit.cacheable = comp.cacheable
3085+
unit.tx_isolation_level = comp.isolation_level
30963086

30973087
if not ctx.dump_restore_mode:
30983088
if comp.user_schema is not None:
@@ -3226,6 +3216,9 @@ def _make_query_unit(
32263216
if unit.warnings:
32273217
for warning in unit.warnings:
32283218
warning.__traceback__ = None
3219+
if unit.unsafe_isolation_dangers:
3220+
for warning in unit.unsafe_isolation_dangers:
3221+
warning.__traceback__ = None
32293222

32303223
return unit, final_user_schema
32313224

edb/server/compiler/dbstate.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ class BaseQuery:
8989
warnings: tuple[errors.EdgeDBError, ...] = dataclasses.field(
9090
kw_only=True, default=()
9191
)
92+
unsafe_isolation_dangers: tuple[errors.UnsafeIsolationLevelError, ...] = (
93+
dataclasses.field(kw_only=True, default=())
94+
)
9295

9396

9497
@dataclasses.dataclass(frozen=True, kw_only=True)
@@ -179,6 +182,8 @@ class TxControlQuery(BaseQuery):
179182

180183
modaliases: Optional[immutables.Map[Optional[str], str]]
181184

185+
isolation_level: Optional[qltypes.TransactionIsolationLevel] = None
186+
182187
user_schema: Optional[s_schema.Schema] = None
183188
global_schema: Optional[s_schema.Schema] = None
184189
cached_reflection: Any = None
@@ -255,6 +260,9 @@ class QueryUnit:
255260
# starts a new transaction.
256261
tx_id: Optional[int] = None
257262

263+
# If this is the start of the transaction, the isolation level of it.
264+
tx_isolation_level: Optional[qltypes.TransactionIsolationLevel] = None
265+
258266
# True if this unit is single 'COMMIT' command.
259267
# 'COMMIT' is always compiled to a separate QueryUnit.
260268
tx_commit: bool = False
@@ -316,6 +324,7 @@ class QueryUnit:
316324
server_param_conversions: Optional[list[ServerParamConversion]] = None
317325

318326
warnings: tuple[errors.EdgeDBError, ...] = ()
327+
unsafe_isolation_dangers: tuple[errors.UnsafeIsolationLevelError, ...] = ()
319328

320329
# Set only when this unit contains a CONFIGURE INSTANCE command.
321330
system_config: bool = False
@@ -430,6 +439,9 @@ class QueryUnitGroup:
430439
unit_converted_param_indexes: Optional[dict[int, list[int]]] = None
431440

432441
warnings: Optional[list[errors.EdgeDBError]] = None
442+
unsafe_isolation_dangers: (
443+
Optional[list[errors.UnsafeIsolationLevelError]]
444+
) = None
433445

434446
# Cacheable QueryUnit is serialized in the compiler, so that the I/O server
435447
# doesn't need to serialize it again for persistence.
@@ -522,6 +534,11 @@ def append(
522534
if self.warnings is None:
523535
self.warnings = []
524536
self.warnings.extend(query_unit.warnings)
537+
if query_unit.unsafe_isolation_dangers is not None:
538+
if self.unsafe_isolation_dangers is None:
539+
self.unsafe_isolation_dangers = []
540+
self.unsafe_isolation_dangers.extend(
541+
query_unit.unsafe_isolation_dangers)
525542

526543
if not serialize or query_unit.cache_sql is None:
527544
self._units.append(query_unit)

0 commit comments

Comments
 (0)