Skip to content

Commit e816afb

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

3 files changed

Lines changed: 200 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: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,40 @@ 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."""
275+
views = [
276+
view for view in exe.db.views if not view.temp or view in exe.temp_objects
277+
]
278+
self.rng.shuffle(views)
279+
for view in views:
280+
pairs = [
281+
(table_column, view_column)
282+
for table_column in table.columns
283+
for view_column in view.columns
284+
# A map has no equality operator, so it cannot drive an IN.
285+
if table_column.data_type == view_column.data_type
286+
and table_column.data_type != TextTextMap
287+
]
288+
if not pairs:
289+
continue
290+
table_column, view_column = self.rng.choice(pairs)
291+
# The alias keeps the inner reference off the outer target, the
292+
# LIMIT bounds an expensive view body.
293+
return (
294+
f"{table_column.name(True)} IN (SELECT rtw_src.{view_column.name(True)}"
295+
f" FROM {view} AS rtw_src LIMIT 100)"
296+
)
297+
return None
298+
265299
def create_system_connection(
266300
self, exe: Executor, num_attempts: int = 10
267301
) -> Connection:
@@ -1171,9 +1205,14 @@ def run(self, exe: Executor) -> bool:
11711205
if not tables:
11721206
return False
11731207
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)
1208+
# Reading the insert target itself is the most contended shape a
1209+
# read-then-write can have. A view is the opposite: the target is
1210+
# written but not read, so a REFRESH view alone pins the selection's
1211+
# frontier, see `Action.view_predicate`.
1212+
sources = tables + [
1213+
view for view in exe.db.views if not view.temp or view in exe.temp_objects
1214+
]
1215+
source = table if self.rng.choice([True, False]) else self.rng.choice(sources)
11771216

11781217
column_names = ", ".join(column.name(True) for column in table.columns)
11791218
# The cast is an identity cast: `expression` returns the requested type
@@ -1464,7 +1503,12 @@ def run(self, exe: Executor) -> bool:
14641503
f"{c.name(True)} = {expression(c.data_type, table.columns, self.rng, kind=ExprKind.WRITE)}"
14651504
for c in set_columns
14661505
)
1467-
query = f"UPDATE {table} SET {set_clause} WHERE {expression(Boolean, table.columns, self.rng, kind=ExprKind.WRITE)}"
1506+
predicate = expression(Boolean, table.columns, self.rng, kind=ExprKind.WRITE)
1507+
if self.rng.random() < 0.2:
1508+
view_predicate = self.view_predicate(exe, table)
1509+
if view_predicate:
1510+
predicate = f"({predicate}) AND {view_predicate}"
1511+
query = f"UPDATE {table} SET {set_clause} WHERE {predicate}"
14681512
if self.rng.choice([True, False]):
14691513
self.stmt_id += 1
14701514
self.exe_prepared(query, f"update{self.stmt_id}", exe)
@@ -1516,7 +1560,8 @@ def errors_to_ignore(self, exe: Executor) -> list[str]:
15161560
"canceling statement due to statement timeout",
15171561
OCC_CONTENTION_EXHAUSTED_ERROR,
15181562
] + super().errors_to_ignore(exe)
1519-
if exe.db.scenario == Scenario.Rename:
1563+
# The predicate can name a view, which DDL drops concurrently.
1564+
if exe.db.complexity == Complexity.DDL or exe.db.scenario == Scenario.Rename:
15201565
errors += ["does not exist"]
15211566
return errors
15221567

@@ -1553,7 +1598,14 @@ def run(self, exe: Executor) -> bool:
15531598
query += f" USING {using_table}"
15541599
query += f" WHERE {expression(Boolean, all_columns, self.rng, kind=ExprKind.WRITE)}"
15551600
elif self.rng.random() < 0.95:
1556-
query += f" WHERE {expression(Boolean, table.columns, self.rng, kind=ExprKind.WRITE)}"
1601+
predicate = expression(
1602+
Boolean, table.columns, self.rng, kind=ExprKind.WRITE
1603+
)
1604+
if self.rng.random() < 0.2:
1605+
view_predicate = self.view_predicate(exe, table)
1606+
if view_predicate:
1607+
predicate = f"({predicate}) AND {view_predicate}"
1608+
query += f" WHERE {predicate}"
15571609
if self.rng.choice([True, False]):
15581610
self.stmt_id += 1
15591611
self.exe_prepared(query, f"delete{self.stmt_id}", exe)

misc/python/materialize/parallel_workload/database.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -309,19 +309,24 @@ def __init__(
309309

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

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

327332
# A materialized view's shard seals (its write frontier advances to the
@@ -333,7 +338,7 @@ def __init__(
333338
# shards. Unmaterialized views have no shard, but a dataflow reading
334339
# one inlines its inputs, so sealing must propagate through them too.
335340
self.can_seal = (
336-
(self.refresh or "").startswith("AT")
341+
refresh_seals
337342
or self.repeat_row_const
338343
or base_object.can_seal
339344
or (base_object2 is not None and base_object2.can_seal)

0 commit comments

Comments
 (0)