Skip to content

Commit 5fdf51d

Browse files
danielfrankcomsarthak-dalmia
authored andcommitted
Add fsyncUnlock command tests (documentdb#641)
Signed-off-by: Daniel Frankcom <frankcom@amazon.com> Signed-off-by: Sarthak Dalmia <sadalmia@microsoft.com>
1 parent 8e620fc commit 5fdf51d

5 files changed

Lines changed: 418 additions & 1 deletion

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Shared fixtures for fsyncUnlock tests.
2+
3+
Re-exports the autouse fsync-lock baseline fixture so it applies to every test
4+
in this directory without each test file importing it.
5+
"""
6+
7+
from documentdb_tests.compatibility.tests.system.administration.utils.fsync_lock import (
8+
unlocked_baseline,
9+
)
10+
11+
__all__ = ["unlocked_baseline"]
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
"""Tests for fsyncUnlock command behavior."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
7+
import pytest
8+
from bson import Int64
9+
from pymongo import MongoClient
10+
11+
from documentdb_tests.compatibility.tests.core.utils.command_test_case import (
12+
CommandTestCase,
13+
)
14+
from documentdb_tests.framework.assertions import (
15+
assertFailureCode,
16+
assertResult,
17+
assertSuccessPartial,
18+
)
19+
from documentdb_tests.framework.error_codes import (
20+
API_STRICT_ERROR,
21+
ILLEGAL_OPERATION_ERROR,
22+
INVALID_OPTIONS_ERROR,
23+
OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR,
24+
UNAUTHORIZED_ERROR,
25+
)
26+
from documentdb_tests.framework.executor import (
27+
execute_admin_command,
28+
execute_command,
29+
)
30+
from documentdb_tests.framework.parametrize import pytest_params
31+
from documentdb_tests.framework.property_checks import Eq, IsType, NotExists
32+
from documentdb_tests.framework.test_constants import (
33+
BSON_TYPE_SAMPLES,
34+
DOUBLE_ZERO,
35+
INT64_ZERO,
36+
)
37+
38+
# fsyncUnlock decrements a server-global lock count, so these tests must never
39+
# run in parallel with anything that takes or releases the fsync lock.
40+
pytestmark = pytest.mark.no_parallel
41+
42+
43+
# Sentinel marking an FsyncUnlockCase field that the caller must set. Every
44+
# field inherited from CommandTestCase has a default, so the dataclass cannot
45+
# make these positionally required; instead they default to this sentinel and
46+
# __post_init__ rejects it, forcing each case to state its lock-state
47+
# preconditions explicitly rather than inheriting a hidden default.
48+
_REQUIRED = object()
49+
50+
51+
@dataclass(frozen=True)
52+
class FsyncUnlockCase(CommandTestCase):
53+
"""A command-test case carrying fsyncUnlock's lock-state preconditions.
54+
55+
fsyncUnlock's success path depends on the current lock count, which is not
56+
derivable from the command itself, so each case declares how many fsync
57+
locks to take and how many unlocks to issue before the command under test
58+
runs. Both are required: no default lock state is assumed.
59+
"""
60+
61+
locks_taken: int = _REQUIRED # type: ignore[assignment]
62+
unlocks_before: int = _REQUIRED # type: ignore[assignment]
63+
64+
def __post_init__(self) -> None:
65+
super().__post_init__()
66+
if self.locks_taken is _REQUIRED or self.unlocks_before is _REQUIRED:
67+
raise ValueError(
68+
f"FsyncUnlockCase '{self.id}' must set locks_taken and unlocks_before explicitly"
69+
)
70+
71+
72+
# Property [Response Shape and Return Types]: lockCount is a BSON Int64 holding
73+
# the remaining lock count (not a hardcoded 0) and seeAlso is absent (it belongs
74+
# only to the fsync lock response).
75+
FSYNCUNLOCK_RESPONSE_SHAPE_TESTS: list[FsyncUnlockCase] = [
76+
FsyncUnlockCase(
77+
"response_shape",
78+
# Lock twice so the unlock leaves a nonzero remaining count; this proves
79+
# lockCount reflects the locks remaining rather than a hardcoded 0.
80+
locks_taken=2,
81+
unlocks_before=0,
82+
command={"fsyncUnlock": 1},
83+
expected={
84+
"info": Eq("fsyncUnlock completed"),
85+
"lockCount": [IsType("long"), Eq(Int64(1))],
86+
"ok": [IsType("double"), Eq(1.0)],
87+
"seeAlso": NotExists(),
88+
},
89+
msg="fsyncUnlock should return info, an Int64 lockCount of the remaining "
90+
"locks, a double ok of 1.0, and no seeAlso field",
91+
),
92+
]
93+
94+
# Property [Command-Key Value Handling]: the command-key value is ignored across
95+
# every BSON type, so each still succeeds and decrements normally.
96+
FSYNCUNLOCK_COMMAND_KEY_TESTS: list[FsyncUnlockCase] = [
97+
FsyncUnlockCase(
98+
f"command_key_{bson_type.value}",
99+
locks_taken=1,
100+
unlocks_before=0,
101+
command={"fsyncUnlock": val},
102+
expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)},
103+
msg="fsyncUnlock should ignore its command-key value and decrement the "
104+
"lock count by exactly 1 on the locked path",
105+
)
106+
for bson_type, val in BSON_TYPE_SAMPLES.items()
107+
]
108+
109+
# Property [Comment Field Handling]: a comment of any BSON type is accepted
110+
# untyped, succeeds, and is never echoed in the reply.
111+
FSYNCUNLOCK_COMMENT_TESTS: list[FsyncUnlockCase] = [
112+
FsyncUnlockCase(
113+
f"comment_{bson_type.value}",
114+
locks_taken=1,
115+
unlocks_before=0,
116+
command={"fsyncUnlock": 1, "comment": val},
117+
expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0), "comment": NotExists()},
118+
msg="fsyncUnlock should accept any comment value, decrement the lock "
119+
"count by exactly 1, and never echo the comment in the reply",
120+
)
121+
for bson_type, val in BSON_TYPE_SAMPLES.items()
122+
]
123+
124+
# Property [Generic Command Options Accepted]: generic command-envelope options
125+
# fall through to the normal path, leaving the success+decrement outcome
126+
# unchanged.
127+
FSYNCUNLOCK_GENERIC_OPTION_TESTS: list[FsyncUnlockCase] = [
128+
FsyncUnlockCase(
129+
"generic_read_concern_local",
130+
locks_taken=1,
131+
unlocks_before=0,
132+
command={"fsyncUnlock": 1, "readConcern": {"level": "local"}},
133+
expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)},
134+
msg="fsyncUnlock should accept the generic command option and decrement "
135+
"the lock count by exactly 1 on the locked path",
136+
),
137+
FsyncUnlockCase(
138+
"generic_read_preference_primary",
139+
locks_taken=1,
140+
unlocks_before=0,
141+
command={"fsyncUnlock": 1, "$readPreference": {"mode": "primary"}},
142+
expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)},
143+
msg="fsyncUnlock should accept the generic command option and decrement "
144+
"the lock count by exactly 1 on the locked path",
145+
),
146+
FsyncUnlockCase(
147+
"generic_read_preference_secondary_preferred",
148+
locks_taken=1,
149+
unlocks_before=0,
150+
command={"fsyncUnlock": 1, "$readPreference": {"mode": "secondaryPreferred"}},
151+
expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)},
152+
msg="fsyncUnlock should accept the generic command option and decrement "
153+
"the lock count by exactly 1 on the locked path",
154+
),
155+
FsyncUnlockCase(
156+
"generic_max_time_ms_zero",
157+
locks_taken=1,
158+
unlocks_before=0,
159+
command={"fsyncUnlock": 1, "maxTimeMS": 0},
160+
expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)},
161+
msg="fsyncUnlock should accept the generic command option and decrement "
162+
"the lock count by exactly 1 on the locked path",
163+
),
164+
FsyncUnlockCase(
165+
"generic_max_time_ms_zero_float",
166+
locks_taken=1,
167+
unlocks_before=0,
168+
command={"fsyncUnlock": 1, "maxTimeMS": DOUBLE_ZERO},
169+
expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)},
170+
msg="fsyncUnlock should accept the generic command option and decrement "
171+
"the lock count by exactly 1 on the locked path",
172+
),
173+
FsyncUnlockCase(
174+
"generic_unknown_extra_field",
175+
locks_taken=1,
176+
unlocks_before=0,
177+
command={"fsyncUnlock": 1, "someUnknownField": 1},
178+
expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)},
179+
msg="fsyncUnlock should accept the generic command option and decrement "
180+
"the lock count by exactly 1 on the locked path",
181+
),
182+
]
183+
184+
# Property [Error: Instance Not Locked]: unlocking at lock count 0 errors with
185+
# IllegalOperation rather than no-opping, whether never raised or driven back to
186+
# 0 by over-unlocking.
187+
FSYNCUNLOCK_NOT_LOCKED_TESTS: list[FsyncUnlockCase] = [
188+
FsyncUnlockCase(
189+
"not_locked_never_raised",
190+
locks_taken=0,
191+
unlocks_before=0,
192+
command={"fsyncUnlock": 1},
193+
error_code=ILLEGAL_OPERATION_ERROR,
194+
msg="fsyncUnlock should error as not-locked when the lock count is "
195+
"already 0 instead of silently no-opping",
196+
),
197+
FsyncUnlockCase(
198+
"over_unlock_below_zero",
199+
locks_taken=1,
200+
unlocks_before=1,
201+
command={"fsyncUnlock": 1},
202+
error_code=ILLEGAL_OPERATION_ERROR,
203+
msg="fsyncUnlock should error as not-locked when the lock count is "
204+
"already 0 instead of silently no-opping",
205+
),
206+
]
207+
208+
# Property [Error: writeConcern Not Supported]: a writeConcern envelope errors
209+
# with InvalidOptions even with a lock held; the command does not support
210+
# writeConcern.
211+
FSYNCUNLOCK_WRITE_CONCERN_TESTS: list[FsyncUnlockCase] = [
212+
FsyncUnlockCase(
213+
"write_concern_w1",
214+
locks_taken=1,
215+
unlocks_before=0,
216+
command={"fsyncUnlock": 1, "writeConcern": {"w": 1}},
217+
error_code=INVALID_OPTIONS_ERROR,
218+
msg="fsyncUnlock should reject a writeConcern envelope while a lock is held",
219+
),
220+
]
221+
222+
# Property [Error: readConcern Non-Local Levels]: a non-local readConcern level
223+
# errors with InvalidOptions even with a lock held; only the local level is
224+
# supported.
225+
FSYNCUNLOCK_READ_CONCERN_TESTS: list[FsyncUnlockCase] = [
226+
FsyncUnlockCase(
227+
f"read_concern_{level}",
228+
locks_taken=1,
229+
unlocks_before=0,
230+
command={"fsyncUnlock": 1, "readConcern": {"level": level}},
231+
error_code=INVALID_OPTIONS_ERROR,
232+
msg="fsyncUnlock should reject a non-local readConcern level while a lock is held",
233+
)
234+
for level in ("majority", "linearizable", "available", "snapshot")
235+
]
236+
237+
# Property [Error: Stable API Rejection]: under apiVersion 1 + apiStrict the
238+
# command errors with APIStrictError even with a lock held; it is not in API
239+
# Version 1.
240+
FSYNCUNLOCK_API_STRICT_TESTS: list[FsyncUnlockCase] = [
241+
FsyncUnlockCase(
242+
"api_strict",
243+
locks_taken=1,
244+
unlocks_before=0,
245+
command={"fsyncUnlock": 1, "apiVersion": "1", "apiStrict": True},
246+
error_code=API_STRICT_ERROR,
247+
msg="fsyncUnlock should be rejected under apiStrict true while a lock is held",
248+
),
249+
]
250+
251+
FSYNCUNLOCK_TESTS = (
252+
FSYNCUNLOCK_RESPONSE_SHAPE_TESTS
253+
+ FSYNCUNLOCK_COMMAND_KEY_TESTS
254+
+ FSYNCUNLOCK_COMMENT_TESTS
255+
+ FSYNCUNLOCK_GENERIC_OPTION_TESTS
256+
+ FSYNCUNLOCK_NOT_LOCKED_TESTS
257+
+ FSYNCUNLOCK_WRITE_CONCERN_TESTS
258+
+ FSYNCUNLOCK_READ_CONCERN_TESTS
259+
+ FSYNCUNLOCK_API_STRICT_TESTS
260+
)
261+
262+
263+
@pytest.mark.parametrize("test", pytest_params(FSYNCUNLOCK_TESTS))
264+
def test_fsyncUnlock_cases(collection, test):
265+
"""Test fsyncUnlock cases against its response contract on the admin database."""
266+
for _ in range(test.locks_taken):
267+
execute_admin_command(collection, {"fsync": 1, "lock": True})
268+
for _ in range(test.unlocks_before):
269+
execute_admin_command(collection, {"fsyncUnlock": 1})
270+
result = execute_admin_command(collection, test.command)
271+
assertResult(
272+
result,
273+
expected=test.expected,
274+
error_code=test.error_code,
275+
msg=test.msg,
276+
raw_res=True,
277+
)
278+
279+
280+
# Property [Error: Non-Admin Database]: a non-admin database errors with
281+
# Unauthorized even with a lock held - the admin-scope dispatch check, not an
282+
# auth-privilege failure.
283+
def test_fsyncUnlock_rejects_non_admin_database(collection):
284+
"""Test fsyncUnlock rejects a non-admin database while a lock is held."""
285+
execute_admin_command(collection, {"fsync": 1, "lock": True})
286+
result = execute_command(collection, {"fsyncUnlock": 1})
287+
assertFailureCode(
288+
result,
289+
UNAUTHORIZED_ERROR,
290+
msg="fsyncUnlock should reject a non-admin database while a lock is held",
291+
)
292+
293+
294+
# Property [Cross-Connection Lock Sharing]: the lock count is server-global, so
295+
# an unlock on one connection releases a lock taken on another.
296+
def test_fsyncUnlock_releases_lock_taken_on_another_connection(collection, connection_string):
297+
"""Test fsyncUnlock releases a lock that was taken on a different connection."""
298+
other_client: MongoClient = MongoClient(connection_string)
299+
try:
300+
# Take the lock on a separate connection and pool.
301+
other_client.admin.command({"fsync": 1, "lock": True})
302+
# Release it from the primary connection; if the count were
303+
# per-connection this unlock would instead error as not-locked.
304+
result = execute_admin_command(collection, {"fsyncUnlock": 1})
305+
assertSuccessPartial(
306+
result,
307+
{"lockCount": INT64_ZERO, "ok": 1.0},
308+
msg="fsyncUnlock should release a lock taken on another connection "
309+
"and report the shared count at 0",
310+
)
311+
finally:
312+
other_client.close()
313+
314+
315+
# Property [Explicit Session Accepted]: an explicit client session is accepted
316+
# and behaves identically to a sessionless invocation.
317+
def test_fsyncUnlock_accepts_explicit_session(collection):
318+
"""Test fsyncUnlock runs under an explicit client session and behaves identically."""
319+
execute_admin_command(collection, {"fsync": 1, "lock": True})
320+
session = collection.database.client.start_session()
321+
try:
322+
result = execute_admin_command(collection, {"fsyncUnlock": 1}, session=session)
323+
assertSuccessPartial(
324+
result,
325+
{"lockCount": INT64_ZERO, "ok": 1.0},
326+
msg="fsyncUnlock should run under an explicit session and decrement the "
327+
"lock count by exactly 1",
328+
)
329+
finally:
330+
session.end_session()
331+
332+
333+
# Property [Error: Non-Admin Database Consumes No Lock]: a non-admin rejection
334+
# does not consume a held lock, so a following admin fsyncUnlock still decrements
335+
# the count to 0.
336+
def test_fsyncUnlock_non_admin_consumes_no_lock(collection):
337+
"""Test fsyncUnlock non-admin rejection does not consume a held lock."""
338+
execute_admin_command(collection, {"fsync": 1, "lock": True})
339+
execute_command(collection, {"fsyncUnlock": 1})
340+
result = execute_admin_command(collection, {"fsyncUnlock": 1})
341+
assertSuccessPartial(
342+
result,
343+
{"lockCount": INT64_ZERO, "ok": 1.0},
344+
msg="fsyncUnlock non-admin rejection should not consume a held lock, so a "
345+
"following real unlock still decrements the count to 0",
346+
)
347+
348+
349+
# Property [Error: Multi-Document Transaction]: inside a multi-document
350+
# transaction fsyncUnlock errors with OperationNotSupportedInTransaction.
351+
@pytest.mark.requires(transactions=True)
352+
def test_fsyncUnlock_rejects_multi_document_transaction(collection):
353+
"""Test fsyncUnlock errors when issued inside a multi-document transaction."""
354+
client = collection.database.client
355+
with client.start_session() as session:
356+
session.start_transaction()
357+
try:
358+
result = execute_admin_command(collection, {"fsyncUnlock": 1}, session=session)
359+
finally:
360+
session.abort_transaction()
361+
assertFailureCode(
362+
result,
363+
OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR,
364+
msg="fsyncUnlock should error as not supported when issued inside a "
365+
"multi-document transaction",
366+
)

documentdb_tests/compatibility/tests/system/administration/utils/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)