Skip to content

Commit d4a9ebd

Browse files
committed
tests: Extend to run into OCC timestamp failure
Follow-up to #38322, so currently based on top of it
1 parent d914c9c commit d4a9ebd

3 files changed

Lines changed: 217 additions & 20 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Copyright Materialize, Inc. and contributors. All rights reserved.
2+
#
3+
# Use of this software is governed by the Business Source License
4+
# included in the LICENSE file at the root of this repository.
5+
#
6+
# As of the Change Date specified in that file, in accordance with
7+
# the Business Source License, use of this software will be governed
8+
# by the Apache License, Version 2.0.
9+
from textwrap import dedent
10+
11+
from materialize.checks.actions import Testdrive
12+
from materialize.checks.checks import Check
13+
14+
# NOTE: Dedicated schema. Until its near refresh the view below is unreadable,
15+
# and a transaction's timedomain spans every collection in the queried schemas,
16+
# so in the default schema that window would block unrelated checks.
17+
18+
19+
class ReadThenWriteFarFrontier(Check):
20+
"""Read-then-writes whose selection reads a far-future write frontier.
21+
22+
A REFRESH materialized view settles until its next refresh, so its frontier
23+
legitimately sits far out while the target table's upper is near the clock.
24+
The write timestamp must still come from the timeline's oracle. Taking it
25+
from the frontier ratchets the oracle into the future, where it is monotone
26+
and durable, so every later write and strict-serializable read blocks until
27+
the clock catches up, restarts included.
28+
29+
NOTE: that failure is environment-wide. A run where this check passes its
30+
write and every other check then times out is this check's finding.
31+
32+
`INSERT ... SELECT` isolates it, since the target is written but not read.
33+
The `UPDATE` and `DELETE` read their target too, which pulls the frontier
34+
back to the clock: a far-future input must change neither the timestamp nor
35+
the answer. `serializable` never consults the oracle, so there the write is
36+
invisible rather than slow, which is what the read-back pins.
37+
"""
38+
39+
def initialize(self) -> Testdrive:
40+
return Testdrive(dedent("""
41+
> CREATE SCHEMA rtw_frontier_schema
42+
43+
> CREATE TABLE rtw_frontier_schema.source (f1 INTEGER)
44+
> INSERT INTO rtw_frontier_schema.source VALUES (1), (2), (3)
45+
46+
> CREATE TABLE rtw_frontier_schema.destination (f1 INTEGER, phase TEXT)
47+
48+
> CREATE MATERIALIZED VIEW rtw_frontier_schema.frozen_mv
49+
WITH (REFRESH AT mz_now()::text::int8 + 2000, REFRESH AT '3000-01-01')
50+
AS SELECT f1 FROM rtw_frontier_schema.source
51+
52+
# Parks until the near refresh, after which the contents are fixed
53+
# at these three rows: the only later refresh is in the year 3000.
54+
> SELECT count(*) FROM rtw_frontier_schema.frozen_mv
55+
3
56+
57+
> INSERT INTO rtw_frontier_schema.destination SELECT f1, 'initialize' FROM rtw_frontier_schema.frozen_mv
58+
"""))
59+
60+
def manipulate(self) -> list[Testdrive]:
61+
return [
62+
Testdrive(dedent(s))
63+
for s in [
64+
"""
65+
> INSERT INTO rtw_frontier_schema.source VALUES (4), (5)
66+
67+
> INSERT INTO rtw_frontier_schema.destination SELECT f1, 'manipulate1' FROM rtw_frontier_schema.frozen_mv
68+
69+
# An UPDATE reads its target too, so the table pulls this
70+
# selection's frontier back to the clock.
71+
> UPDATE rtw_frontier_schema.destination SET f1 = f1 + 10
72+
WHERE phase = 'initialize' AND f1 IN (SELECT f1 FROM rtw_frontier_schema.frozen_mv)
73+
""",
74+
"""
75+
> INSERT INTO rtw_frontier_schema.source VALUES (6), (7)
76+
77+
> INSERT INTO rtw_frontier_schema.destination SELECT f1, 'manipulate2' FROM rtw_frontier_schema.frozen_mv
78+
79+
> DELETE FROM rtw_frontier_schema.destination
80+
WHERE phase = 'manipulate1' AND f1 IN (SELECT f1 FROM rtw_frontier_schema.frozen_mv)
81+
82+
# A serializable read picks a timestamp near the clock, so it
83+
# sees this write back only if the write landed near it too.
84+
> SET transaction_isolation = 'serializable'
85+
86+
> INSERT INTO rtw_frontier_schema.destination SELECT f1, 'serializable' FROM rtw_frontier_schema.frozen_mv
87+
88+
> SELECT count(*) FROM rtw_frontier_schema.destination WHERE phase = 'serializable'
89+
3
90+
91+
> RESET transaction_isolation
92+
""",
93+
]
94+
]
95+
96+
def validate(self) -> Testdrive:
97+
return Testdrive(dedent("""
98+
> SELECT phase, count(*), sum(f1) FROM rtw_frontier_schema.destination GROUP BY phase ORDER BY phase
99+
initialize 3 36
100+
manipulate2 3 6
101+
serializable 3 6
102+
103+
# A write committed at the view's frontier leaves the oracle in the
104+
# year 3000, where this read blocks: a timeout is the same finding.
105+
> SELECT mz_now()::text::bigint - (extract(epoch FROM now()) * 1000)::bigint < 60000
106+
true
107+
108+
# TEMPORARY so a second validate() repeats rather than accumulates.
109+
> CREATE TEMPORARY TABLE rtw_frontier_probe (f1 INTEGER)
110+
111+
> INSERT INTO rtw_frontier_probe SELECT f1 FROM rtw_frontier_schema.frozen_mv
112+
113+
> SELECT count(*), sum(f1) FROM rtw_frontier_probe
114+
3 6
115+
116+
# The timeline still takes a blind write.
117+
> INSERT INTO rtw_frontier_probe VALUES (100)
118+
119+
> SELECT count(*) FROM rtw_frontier_probe
120+
4
121+
122+
> DROP TABLE rtw_frontier_probe
123+
"""))

misc/python/materialize/parallel_workload/action.py

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,44 @@ def insert_batch_size(self, exe: Executor, table: Table) -> int:
262262
share = max(1, MAX_ROWS // exe.db.num_threads)
263263
return self.rng.randint(1, min(available, share))
264264

265+
def view_predicate(self, exe: Executor, table: Table) -> str | None:
266+
"""A predicate over a view, adding a second input to a read-then-write's
267+
selection.
268+
269+
The selection's frontier is the minimum over its inputs, and an UPDATE
270+
or DELETE reads its target too, so the table holds it near the wall
271+
clock while a REFRESH view is free to sit far ahead. Neither may reach
272+
the write timestamp, which comes from the timeline's oracle.
273+
`InsertSelectAction` covers the view-only case. None when no view offers
274+
a comparable column. Views reaching a source are excluded: the adapter
275+
refuses such a selection outright, which would make this vacuous."""
276+
views = [
277+
view
278+
for view in exe.db.views
279+
if view.read_then_write_input
280+
and (not view.temp or view in exe.temp_objects)
281+
]
282+
self.rng.shuffle(views)
283+
for view in views:
284+
pairs = [
285+
(table_column, view_column)
286+
for table_column in table.columns
287+
for view_column in view.columns
288+
# A map has no equality operator, so it cannot drive an IN.
289+
if table_column.data_type == view_column.data_type
290+
and table_column.data_type != TextTextMap
291+
]
292+
if not pairs:
293+
continue
294+
table_column, view_column = self.rng.choice(pairs)
295+
# The alias keeps the inner reference off the outer target, the
296+
# LIMIT bounds an expensive view body.
297+
return (
298+
f"{table_column.name(True)} IN (SELECT rtw_src.{view_column.name(True)}"
299+
f" FROM {view} AS rtw_src LIMIT 100)"
300+
)
301+
return None
302+
265303
def create_system_connection(
266304
self, exe: Executor, num_attempts: int = 10
267305
) -> Connection:
@@ -1171,9 +1209,17 @@ def run(self, exe: Executor) -> bool:
11711209
if not tables:
11721210
return False
11731211
table = self.rng.choice(tables)
1174-
# Reading the insert target itself makes the target a read dependency
1175-
# too, the most contended shape a read-then-write can have.
1176-
source = table if self.rng.choice([True, False]) else self.rng.choice(tables)
1212+
# Reading the insert target itself is the most contended shape a
1213+
# read-then-write can have. A view is the opposite: the target is
1214+
# written but not read, so a REFRESH view alone pins the selection's
1215+
# frontier, see `Action.view_predicate`.
1216+
sources = tables + [
1217+
view
1218+
for view in exe.db.views
1219+
if view.read_then_write_input
1220+
and (not view.temp or view in exe.temp_objects)
1221+
]
1222+
source = table if self.rng.choice([True, False]) else self.rng.choice(sources)
11771223

11781224
column_names = ", ".join(column.name(True) for column in table.columns)
11791225
# The cast is an identity cast: `expression` returns the requested type
@@ -1464,7 +1510,12 @@ def run(self, exe: Executor) -> bool:
14641510
f"{c.name(True)} = {expression(c.data_type, table.columns, self.rng, kind=ExprKind.WRITE)}"
14651511
for c in set_columns
14661512
)
1467-
query = f"UPDATE {table} SET {set_clause} WHERE {expression(Boolean, table.columns, self.rng, kind=ExprKind.WRITE)}"
1513+
predicate = expression(Boolean, table.columns, self.rng, kind=ExprKind.WRITE)
1514+
if self.rng.random() < 0.2:
1515+
view_predicate = self.view_predicate(exe, table)
1516+
if view_predicate:
1517+
predicate = f"({predicate}) AND {view_predicate}"
1518+
query = f"UPDATE {table} SET {set_clause} WHERE {predicate}"
14681519
if self.rng.choice([True, False]):
14691520
self.stmt_id += 1
14701521
self.exe_prepared(query, f"update{self.stmt_id}", exe)
@@ -1516,7 +1567,8 @@ def errors_to_ignore(self, exe: Executor) -> list[str]:
15161567
"canceling statement due to statement timeout",
15171568
OCC_CONTENTION_EXHAUSTED_ERROR,
15181569
] + super().errors_to_ignore(exe)
1519-
if exe.db.scenario == Scenario.Rename:
1570+
# The predicate can name a view, which DDL drops concurrently.
1571+
if exe.db.complexity == Complexity.DDL or exe.db.scenario == Scenario.Rename:
15201572
errors += ["does not exist"]
15211573
return errors
15221574

@@ -1553,7 +1605,14 @@ def run(self, exe: Executor) -> bool:
15531605
query += f" USING {using_table}"
15541606
query += f" WHERE {expression(Boolean, all_columns, self.rng, kind=ExprKind.WRITE)}"
15551607
elif self.rng.random() < 0.95:
1556-
query += f" WHERE {expression(Boolean, table.columns, self.rng, kind=ExprKind.WRITE)}"
1608+
predicate = expression(
1609+
Boolean, table.columns, self.rng, kind=ExprKind.WRITE
1610+
)
1611+
if self.rng.random() < 0.2:
1612+
view_predicate = self.view_predicate(exe, table)
1613+
if view_predicate:
1614+
predicate = f"({predicate}) AND {view_predicate}"
1615+
query += f" WHERE {predicate}"
15571616
if self.rng.choice([True, False]):
15581617
self.stmt_id += 1
15591618
self.exe_prepared(query, f"delete{self.stmt_id}", exe)

misc/python/materialize/parallel_workload/database.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ class DBObject:
195195
# Bounded (UP TO) load generators seal once they finish, and views
196196
# propagate sealing from their inputs, materialized or not.
197197
can_seal: bool = False
198+
# Whether a read-then-write's selection may (transitively) read this
199+
# object. The adapter refuses one that reaches a source or a source-export
200+
# table, so only plain tables and views over them qualify.
201+
read_then_write_input: bool = False
198202

199203
def __init__(self):
200204
self.lock = threading.Lock()
@@ -207,6 +211,8 @@ def create(self, exe: Executor) -> None:
207211

208212

209213
class Table(DBObject):
214+
read_then_write_input = True
215+
210216
table_id: int
211217
rename: int
212218
num_rows: int
@@ -309,19 +315,24 @@ def __init__(
309315

310316
self.materialized = not self.temp and rng.choice([True, False])
311317

312-
self.refresh = (
313-
rng.choice(
314-
[
315-
"ON COMMIT",
316-
# TODO: Restore minute-scale intervals when CPU-196 is fixed
317-
f"EVERY '{rng.randint(1, 15)} seconds'",
318-
f"EVERY '{rng.randint(1, 15)} seconds' ALIGNED TO (mz_now())",
319-
# Always in the future of all refreshes of previously generated MVs
320-
"AT mz_now()::string::int8 + 1000",
321-
]
322-
)
323-
if self.materialized
324-
else None
318+
# (SQL, whether the view's shard seals during a run): a REFRESH AT view
319+
# seals once its last refresh has passed.
320+
refresh_options = [
321+
("ON COMMIT", False),
322+
# TODO: Restore minute-scale intervals when CPU-196 is fixed
323+
(f"EVERY '{rng.randint(1, 15)} seconds'", False),
324+
(f"EVERY '{rng.randint(1, 15)} seconds' ALIGNED TO (mz_now())", False),
325+
# Always in the future of all refreshes of previously generated MVs
326+
("AT mz_now()::string::int8 + 1000", True),
327+
# The near refresh makes the view readable, the far one parks its
328+
# write frontier a millennium out without ever sealing it. That is
329+
# what separates a read-then-write's write timestamp taken from the
330+
# oracle from one taken from the selection's frontier, which drags
331+
# the monotone, durable oracle into the future with it.
332+
("AT mz_now()::string::int8 + 1000, REFRESH AT '3000-01-01'", False),
333+
]
334+
self.refresh, refresh_seals = (
335+
rng.choice(refresh_options) if self.materialized else (None, False)
325336
)
326337

327338
# A materialized view's shard seals (its write frontier advances to the
@@ -333,12 +344,16 @@ def __init__(
333344
# shards. Unmaterialized views have no shard, but a dataflow reading
334345
# one inlines its inputs, so sealing must propagate through them too.
335346
self.can_seal = (
336-
(self.refresh or "").startswith("AT")
347+
refresh_seals
337348
or self.repeat_row_const
338349
or base_object.can_seal
339350
or (base_object2 is not None and base_object2.can_seal)
340351
)
341352

353+
self.read_then_write_input = base_object.read_then_write_input and (
354+
base_object2 is None or base_object2.read_then_write_input
355+
)
356+
342357
if base_object2:
343358
# The random boolean alone references an arbitrary subset of the
344359
# columns, so it usually names only one side or neither, and the

0 commit comments

Comments
 (0)