|
| 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 | + ) |
0 commit comments