dploydb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dploydb/migration.py ADDED
@@ -0,0 +1,575 @@
1
+ """Migration rehearsal against a disposable copy of a verified SQLite snapshot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import stat
8
+ import threading
9
+ import time
10
+ from collections.abc import Callable, Iterator, Mapping
11
+ from contextlib import contextmanager
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Final
15
+
16
+ from dploydb.backup import calculate_sha256, create_verified_backup
17
+ from dploydb.config import LoadedConfiguration, configuration_fingerprint
18
+ from dploydb.errors import (
19
+ DployDBError,
20
+ ExternalCommandError,
21
+ OperationFailedError,
22
+ RecoveryRequiredError,
23
+ SafetyCheckError,
24
+ )
25
+ from dploydb.locking import DeploymentLock
26
+ from dploydb.models import (
27
+ BackupArtifact,
28
+ BackupPurpose,
29
+ CapturedCommandOutput,
30
+ FailureRecord,
31
+ LockOwnerState,
32
+ MigrationCommandEvidence,
33
+ MigrationRehearsalResult,
34
+ OperationStatus,
35
+ SafetyFacts,
36
+ utc_now,
37
+ )
38
+ from dploydb.sqlite_checks import verify_sqlite_database
39
+ from dploydb.state import DIRECTORY_MODE, StateStore
40
+ from dploydb.storage.local import LocalBackupStorage
41
+ from dploydb.subprocesses import CommandOutcome, CommandResult, SubprocessRunner
42
+
43
+ WORKSPACE_DIRECTORY_NAME: Final = "rehearsals"
44
+ WORKSPACE_DATABASE_NAME: Final = "rehearsal.db"
45
+ WORKSPACE_FILE_MODE: Final = 0o600
46
+ WORKSPACE_COPY_TIMEOUT_SECONDS: Final = 120.0
47
+ WORKSPACE_COPY_CHUNK_BYTES: Final = 1024 * 1024
48
+ MIGRATION_MAX_OUTPUT_BYTES: Final = 256 * 1024
49
+ _OPERATION_ID = re.compile(r"^op_[0-9a-f]{32}$")
50
+
51
+ CommandEvidenceSink = Callable[[MigrationCommandEvidence], None]
52
+
53
+
54
+ class MigrationWorkspaceCleanupError(OperationFailedError):
55
+ """A private rehearsal workspace could not be proven clean."""
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class ActiveMigrationRehearsal:
60
+ """A verified migrated database that exists only inside its context."""
61
+
62
+ database_path: Path
63
+ result: MigrationRehearsalResult
64
+
65
+
66
+ def rehearse_configured_migration(
67
+ loaded: LoadedConfiguration,
68
+ *,
69
+ config_path: Path,
70
+ command_environment: Mapping[str, str] | None = None,
71
+ command_runner: SubprocessRunner | None = None,
72
+ cancellation_event: threading.Event | None = None,
73
+ ) -> MigrationRehearsalResult:
74
+ """Run one exclusive, durable rehearsal without modifying production."""
75
+ config = loaded.config
76
+ secrets = loaded.secrets
77
+ store = StateStore(config.state_directory, secrets=secrets)
78
+ lock = DeploymentLock(config.state_directory, secrets=secrets)
79
+
80
+ with lock:
81
+ require_clean_operation_state(lock, store)
82
+ operation = store.create_operation(
83
+ operation_type="rehearsal",
84
+ project=config.project,
85
+ configuration_fingerprint=configuration_fingerprint(config, secrets=secrets),
86
+ evidence={"database_path": str(config.database.path)},
87
+ )
88
+ lock.record_owner(operation_id=operation.operation_id, operation_type="rehearsal")
89
+ operation_log = store.operation_paths(operation.operation_id).events
90
+ try:
91
+ preflight = verify_sqlite_database(config.database.path)
92
+ store.transition(
93
+ operation.operation_id,
94
+ status=OperationStatus.IN_PROGRESS,
95
+ stage="preflight_passed",
96
+ message="Production SQLite preflight passed without mutation.",
97
+ evidence=preflight.model_dump(mode="json"),
98
+ )
99
+ snapshot = create_verified_backup(
100
+ config.database.path,
101
+ project=secrets.redact_text(config.project),
102
+ purpose=BackupPurpose.REHEARSAL,
103
+ storage=LocalBackupStorage(config.backup.local_directory),
104
+ operation_id=operation.operation_id,
105
+ metadata_source_path=_safe_metadata_path(config.database.path, loaded),
106
+ )
107
+ store.transition(
108
+ operation.operation_id,
109
+ status=OperationStatus.IN_PROGRESS,
110
+ stage="snapshot_verified",
111
+ message="Verified rehearsal snapshot completed before migration execution.",
112
+ evidence={
113
+ "backup_id": snapshot.metadata.backup_id,
114
+ "backup_path": str(snapshot.database_path),
115
+ "sha256": snapshot.metadata.sha256,
116
+ "size_bytes": snapshot.metadata.size_bytes,
117
+ },
118
+ )
119
+
120
+ def record_command(evidence: MigrationCommandEvidence) -> None:
121
+ store.append_event(
122
+ operation.operation_id,
123
+ message="Migration command reached a terminal outcome.",
124
+ evidence={"migration_command": evidence.model_dump(mode="json")},
125
+ )
126
+
127
+ runner = command_runner or SubprocessRunner(
128
+ secrets=secrets,
129
+ max_output_bytes=MIGRATION_MAX_OUTPUT_BYTES,
130
+ )
131
+ environment = dict(os.environ if command_environment is None else command_environment)
132
+ working_directory = config_path.resolve().parent
133
+ with migration_rehearsal(
134
+ snapshot,
135
+ operation_id=operation.operation_id,
136
+ command=config.migration.command,
137
+ database_environment_name=config.database.path_env,
138
+ timeout_seconds=config.migration.timeout_seconds,
139
+ workspace_root=config.state_directory / WORKSPACE_DIRECTORY_NAME,
140
+ working_directory=working_directory,
141
+ environment=environment,
142
+ runner=runner,
143
+ command_evidence_sink=record_command,
144
+ cancellation_event=cancellation_event,
145
+ log_path=operation_log,
146
+ ) as active:
147
+ result = active.result
148
+
149
+ store.transition(
150
+ operation.operation_id,
151
+ status=OperationStatus.SUCCEEDED,
152
+ stage="rehearsal_passed",
153
+ message="Migration rehearsal and disposable-workspace cleanup passed.",
154
+ evidence={
155
+ "backup_id": result.backup_id,
156
+ "backup_sha256": result.backup_sha256,
157
+ "database_size_bytes": result.database_size_bytes,
158
+ "database_sha256": result.database_sha256,
159
+ "migration_outcome": result.command.outcome,
160
+ "migration_duration_seconds": result.command.duration_seconds,
161
+ "sqlite": result.sqlite.model_dump(mode="json"),
162
+ },
163
+ safety=SafetyFacts(
164
+ production_changed=False,
165
+ previous_application_running=None,
166
+ recovery_required=False,
167
+ ),
168
+ )
169
+ return result
170
+ except DployDBError as error:
171
+ _finish_failure(store, operation.operation_id, error)
172
+ raise
173
+ except Exception as raw_error:
174
+ failure = OperationFailedError(
175
+ f"migration rehearsal failed safely: {secrets.redact_text(str(raw_error))}",
176
+ production_changed=False,
177
+ previous_application_running=None,
178
+ log_path=operation_log,
179
+ next_safe_action=(
180
+ "Production was not changed; inspect the rehearsal log, correct the "
181
+ "failure, and retry."
182
+ ),
183
+ )
184
+ _finish_failure(store, operation.operation_id, failure)
185
+ raise failure from None
186
+
187
+
188
+ @contextmanager
189
+ def migration_rehearsal(
190
+ snapshot: BackupArtifact,
191
+ *,
192
+ operation_id: str,
193
+ command: tuple[str, ...],
194
+ database_environment_name: str,
195
+ timeout_seconds: float,
196
+ workspace_root: Path,
197
+ working_directory: Path,
198
+ environment: Mapping[str, str],
199
+ runner: SubprocessRunner,
200
+ command_evidence_sink: CommandEvidenceSink,
201
+ cancellation_event: threading.Event | None,
202
+ log_path: Path,
203
+ ) -> Iterator[ActiveMigrationRehearsal]:
204
+ """Yield a checked migrated copy, then remove its workspace idempotently."""
205
+ workspace: Path | None = None
206
+ primary_error: BaseException | None = None
207
+ try:
208
+ _verify_snapshot(snapshot)
209
+ workspace = _create_workspace(workspace_root, operation_id)
210
+ database_path = _materialize_snapshot(snapshot, workspace)
211
+ child_environment = dict(environment)
212
+ child_environment[database_environment_name] = str(database_path)
213
+ command_result = runner.run(
214
+ command,
215
+ timeout_seconds=timeout_seconds,
216
+ environment=child_environment,
217
+ working_directory=working_directory,
218
+ cancellation_event=cancellation_event,
219
+ )
220
+ command_evidence = migration_command_evidence(command_result)
221
+ command_evidence_sink(command_evidence)
222
+ _require_usable_command_result(command_result, log_path)
223
+ try:
224
+ sqlite_evidence = verify_sqlite_database(database_path)
225
+ except DployDBError as error:
226
+ raise OperationFailedError(
227
+ "post-migration SQLite verification failed: " + error.payload.what_failed,
228
+ production_changed=False,
229
+ previous_application_running=None,
230
+ log_path=log_path,
231
+ next_safe_action=(
232
+ "Production was not changed; correct the migration so the rehearsed "
233
+ "database passes every SQLite check."
234
+ ),
235
+ ) from None
236
+ size_bytes, sha256 = calculate_sha256(database_path)
237
+ result = MigrationRehearsalResult(
238
+ operation_id=operation_id,
239
+ backup_id=snapshot.metadata.backup_id,
240
+ backup_sha256=snapshot.metadata.sha256,
241
+ database_size_bytes=size_bytes,
242
+ database_sha256=sha256,
243
+ command=command_evidence,
244
+ sqlite=sqlite_evidence,
245
+ completed_at=utc_now(),
246
+ )
247
+ yield ActiveMigrationRehearsal(database_path=database_path, result=result)
248
+ except BaseException as error:
249
+ primary_error = error
250
+ raise
251
+ finally:
252
+ cleanup_error = _cleanup_workspace(workspace)
253
+ if cleanup_error is not None:
254
+ if primary_error is None:
255
+ raise _workspace_cleanup_error(cleanup_error, log_path) from None
256
+ if isinstance(primary_error, RecoveryRequiredError):
257
+ raise RecoveryRequiredError(
258
+ primary_error.payload.what_failed
259
+ + f"; rehearsal workspace cleanup also failed: {cleanup_error}",
260
+ production_changed=False,
261
+ previous_application_running=None,
262
+ log_path=log_path,
263
+ next_safe_action=(
264
+ "Preserve the operation evidence, confirm the migration process group "
265
+ "is gone, and clean the private rehearsal workspace before recovery."
266
+ ),
267
+ ) from None
268
+ if isinstance(primary_error, Exception):
269
+ detail = (
270
+ primary_error.payload.what_failed
271
+ if isinstance(primary_error, DployDBError)
272
+ else str(primary_error)
273
+ )
274
+ raise MigrationWorkspaceCleanupError(
275
+ detail + f"; rehearsal workspace cleanup also failed: {cleanup_error}",
276
+ production_changed=False,
277
+ previous_application_running=None,
278
+ log_path=log_path,
279
+ next_safe_action=(
280
+ "Production was not changed; inspect the log and remove only the "
281
+ "recorded private rehearsal workspace before retrying."
282
+ ),
283
+ ) from None
284
+ primary_error.add_note(f"Rehearsal workspace cleanup also failed: {cleanup_error}")
285
+
286
+
287
+ def migration_command_evidence(result: CommandResult) -> MigrationCommandEvidence:
288
+ """Convert one bounded subprocess result into durable migration evidence."""
289
+ return MigrationCommandEvidence(
290
+ command=result.command,
291
+ working_directory=result.working_directory,
292
+ environment_keys=result.environment_keys,
293
+ outcome=result.outcome.value,
294
+ exit_code=result.exit_code,
295
+ stdout=CapturedCommandOutput(
296
+ text=result.stdout.text,
297
+ total_bytes=result.stdout.total_bytes,
298
+ retained_bytes=result.stdout.retained_bytes,
299
+ truncated=result.stdout.truncated,
300
+ ),
301
+ stderr=CapturedCommandOutput(
302
+ text=result.stderr.text,
303
+ total_bytes=result.stderr.total_bytes,
304
+ retained_bytes=result.stderr.retained_bytes,
305
+ truncated=result.stderr.truncated,
306
+ ),
307
+ duration_seconds=result.duration_seconds,
308
+ termination_reason=(
309
+ None if result.termination_reason is None else result.termination_reason.value
310
+ ),
311
+ termination_attempted=result.termination_attempted,
312
+ forced_kill=result.forced_kill,
313
+ start_error=result.start_error,
314
+ cleanup_error=result.cleanup_error,
315
+ )
316
+
317
+
318
+ def _require_usable_command_result(result: CommandResult, log_path: Path) -> None:
319
+ if result.stdout.truncated or result.stderr.truncated:
320
+ raise OperationFailedError(
321
+ "migration output exceeded the complete-capture safety bound",
322
+ production_changed=False,
323
+ previous_application_running=None,
324
+ log_path=log_path,
325
+ next_safe_action=(
326
+ "Production was not changed; reduce migration output and retry so complete "
327
+ "stdout and stderr can be preserved."
328
+ ),
329
+ )
330
+ if result.outcome is CommandOutcome.SUCCEEDED:
331
+ return
332
+ if result.outcome is CommandOutcome.CLEANUP_FAILED:
333
+ raise RecoveryRequiredError(
334
+ "migration process cleanup could not be proven: "
335
+ + (result.cleanup_error or "unknown cleanup failure"),
336
+ production_changed=False,
337
+ previous_application_running=None,
338
+ log_path=log_path,
339
+ next_safe_action=(
340
+ "Production was not changed. Preserve the evidence and confirm the complete "
341
+ "migration process group is stopped before running recovery."
342
+ ),
343
+ )
344
+ if result.outcome is CommandOutcome.NONZERO_EXIT:
345
+ detail = f"migration command exited with status {result.exit_code}"
346
+ elif result.outcome is CommandOutcome.TIMED_OUT:
347
+ detail = "migration command timed out and its process group was terminated"
348
+ elif result.outcome is CommandOutcome.CANCELLED:
349
+ detail = "migration command was cancelled and its process group was terminated"
350
+ else:
351
+ detail = "migration command could not start: " + (result.start_error or "unknown error")
352
+ raise ExternalCommandError(
353
+ detail,
354
+ production_changed=False,
355
+ previous_application_running=None,
356
+ log_path=log_path,
357
+ next_safe_action=(
358
+ "Production was not changed; inspect the captured migration output, correct the "
359
+ "command, and retry."
360
+ ),
361
+ )
362
+
363
+
364
+ def _verify_snapshot(snapshot: BackupArtifact) -> None:
365
+ before = calculate_sha256(snapshot.database_path)
366
+ if before != (snapshot.metadata.size_bytes, snapshot.metadata.sha256):
367
+ raise SafetyCheckError(
368
+ "rehearsal snapshot no longer matches its verified metadata",
369
+ production_changed=False,
370
+ previous_application_running=None,
371
+ log_path=snapshot.database_path,
372
+ next_safe_action="Create a new verified rehearsal snapshot before retrying.",
373
+ )
374
+ verify_sqlite_database(snapshot.database_path)
375
+ after = calculate_sha256(snapshot.database_path)
376
+ if after != before:
377
+ raise SafetyCheckError(
378
+ "rehearsal snapshot changed while it was reverified",
379
+ production_changed=False,
380
+ previous_application_running=None,
381
+ log_path=snapshot.database_path,
382
+ next_safe_action="Create a new verified rehearsal snapshot before retrying.",
383
+ )
384
+
385
+
386
+ def _create_workspace(root: Path, operation_id: str) -> Path:
387
+ if not root.is_absolute() or root == Path(root.anchor):
388
+ raise ValueError("rehearsal workspace root must be an absolute non-root path")
389
+ if _OPERATION_ID.fullmatch(operation_id) is None:
390
+ raise ValueError("operation ID is invalid or unsafe for a rehearsal workspace")
391
+ if root.is_symlink():
392
+ raise OSError("rehearsal workspace root must not be a symlink")
393
+ root.mkdir(mode=DIRECTORY_MODE, parents=False, exist_ok=True)
394
+ details = root.stat()
395
+ if not stat.S_ISDIR(details.st_mode) or stat.S_IMODE(details.st_mode) != DIRECTORY_MODE:
396
+ raise OSError("rehearsal workspace root must be a mode-0700 directory")
397
+ workspace = root / operation_id
398
+ workspace_created = False
399
+ try:
400
+ workspace.mkdir(mode=DIRECTORY_MODE, exist_ok=False)
401
+ workspace_created = True
402
+ _fsync_directory(root)
403
+ except OSError:
404
+ if workspace_created:
405
+ try:
406
+ workspace.rmdir()
407
+ except OSError:
408
+ pass
409
+ raise
410
+ return workspace
411
+
412
+
413
+ def _materialize_snapshot(snapshot: BackupArtifact, workspace: Path) -> Path:
414
+ target = workspace / WORKSPACE_DATABASE_NAME
415
+ source_descriptor = -1
416
+ target_descriptor = -1
417
+ deadline = time.monotonic() + WORKSPACE_COPY_TIMEOUT_SECONDS
418
+ source_flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
419
+ target_flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
420
+ target_flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
421
+ try:
422
+ source_descriptor = os.open(snapshot.database_path, source_flags)
423
+ source_details = os.fstat(source_descriptor)
424
+ if not stat.S_ISREG(source_details.st_mode):
425
+ raise OSError("verified snapshot is not a regular file")
426
+ target_descriptor = os.open(target, target_flags, WORKSPACE_FILE_MODE)
427
+ os.fchmod(target_descriptor, WORKSPACE_FILE_MODE)
428
+ while True:
429
+ if time.monotonic() >= deadline:
430
+ raise TimeoutError(
431
+ f"rehearsal copy timed out after {WORKSPACE_COPY_TIMEOUT_SECONDS:g} seconds"
432
+ )
433
+ chunk = os.read(source_descriptor, WORKSPACE_COPY_CHUNK_BYTES)
434
+ if not chunk:
435
+ break
436
+ _write_all(target_descriptor, chunk)
437
+ os.fsync(target_descriptor)
438
+ except Exception:
439
+ try:
440
+ target.unlink(missing_ok=True)
441
+ except OSError:
442
+ pass
443
+ raise
444
+ finally:
445
+ if target_descriptor >= 0:
446
+ os.close(target_descriptor)
447
+ if source_descriptor >= 0:
448
+ os.close(source_descriptor)
449
+ _fsync_directory(workspace)
450
+ size_bytes, sha256 = calculate_sha256(target)
451
+ if size_bytes != snapshot.metadata.size_bytes or sha256 != snapshot.metadata.sha256:
452
+ raise SafetyCheckError(
453
+ "disposable rehearsal database does not match the verified snapshot",
454
+ production_changed=False,
455
+ previous_application_running=None,
456
+ log_path=target,
457
+ next_safe_action="Discard the rehearsal workspace and create a new verified snapshot.",
458
+ )
459
+ verify_sqlite_database(target)
460
+ return target
461
+
462
+
463
+ def _cleanup_workspace(workspace: Path | None) -> str | None:
464
+ if workspace is None:
465
+ return None
466
+ try:
467
+ if not workspace.exists():
468
+ return None
469
+ if workspace.is_symlink():
470
+ raise OSError("refusing a symlinked rehearsal workspace")
471
+ for name in (
472
+ f"{WORKSPACE_DATABASE_NAME}-wal",
473
+ f"{WORKSPACE_DATABASE_NAME}-shm",
474
+ f"{WORKSPACE_DATABASE_NAME}-journal",
475
+ WORKSPACE_DATABASE_NAME,
476
+ ):
477
+ path = workspace / name
478
+ if not path.exists() and not path.is_symlink():
479
+ continue
480
+ details = path.lstat()
481
+ if path.is_symlink() or not stat.S_ISREG(details.st_mode):
482
+ raise OSError(f"refusing unsafe rehearsal artifact: {path.name}")
483
+ path.unlink()
484
+ unexpected = list(workspace.iterdir())
485
+ if unexpected:
486
+ raise OSError(f"rehearsal workspace contains unexpected entry: {unexpected[0].name}")
487
+ workspace.rmdir()
488
+ _fsync_directory(workspace.parent)
489
+ except OSError as error:
490
+ return str(error)
491
+ return None
492
+
493
+
494
+ def _workspace_cleanup_error(detail: str, log_path: Path) -> MigrationWorkspaceCleanupError:
495
+ return MigrationWorkspaceCleanupError(
496
+ f"rehearsal workspace cleanup failed: {detail}",
497
+ production_changed=False,
498
+ previous_application_running=None,
499
+ log_path=log_path,
500
+ next_safe_action=(
501
+ "Production was not changed; inspect the log and remove only the recorded private "
502
+ "rehearsal workspace before retrying."
503
+ ),
504
+ )
505
+
506
+
507
+ def require_clean_operation_state(lock: DeploymentLock, store: StateStore) -> None:
508
+ """Refuse new work while durable evidence requires diagnosis or recovery."""
509
+ if lock.previous_owner is not None and lock.previous_owner.state is LockOwnerState.ACTIVE:
510
+ raise RecoveryRequiredError(
511
+ "A prior operation left active lock-owner evidence.",
512
+ production_changed=False,
513
+ previous_application_running=None,
514
+ log_path=lock.owner_path,
515
+ next_safe_action="Run dploydb status and preserve the interrupted evidence.",
516
+ )
517
+ latest = store.latest_operation()
518
+ if latest is not None and latest.status in {
519
+ OperationStatus.IN_PROGRESS,
520
+ OperationStatus.RECOVERY_REQUIRED,
521
+ }:
522
+ raise RecoveryRequiredError(
523
+ "An unfinished or recovery-required operation blocks migration rehearsal.",
524
+ production_changed=latest.safety.production_changed,
525
+ previous_application_running=latest.safety.previous_application_running,
526
+ log_path=store.operation_paths(latest.operation_id).events,
527
+ next_safe_action="Run dploydb status and resolve the recorded operation first.",
528
+ )
529
+
530
+
531
+ def _finish_failure(store: StateStore, operation_id: str, error: DployDBError) -> None:
532
+ status = (
533
+ OperationStatus.RECOVERY_REQUIRED
534
+ if error.payload.recovery_required
535
+ else OperationStatus.FAILED_SAFE
536
+ )
537
+ store.transition(
538
+ operation_id,
539
+ status=status,
540
+ stage=status.value,
541
+ message="Migration rehearsal did not complete.",
542
+ safety=SafetyFacts(
543
+ production_changed=error.payload.production_changed,
544
+ previous_application_running=error.payload.previous_application_running,
545
+ recovery_required=error.payload.recovery_required,
546
+ ),
547
+ failure=FailureRecord(
548
+ error_code=error.payload.error_code,
549
+ what_failed=error.payload.what_failed,
550
+ log_path=error.payload.log_path,
551
+ next_safe_action=error.payload.next_safe_action,
552
+ ),
553
+ )
554
+
555
+
556
+ def _safe_metadata_path(path: Path, loaded: LoadedConfiguration) -> Path:
557
+ safe = Path(loaded.secrets.redact_text(str(path)))
558
+ return safe if safe.is_absolute() else Path("/[REDACTED]")
559
+
560
+
561
+ def _write_all(descriptor: int, payload: bytes) -> None:
562
+ written = 0
563
+ while written < len(payload):
564
+ count = os.write(descriptor, payload[written:])
565
+ if count <= 0:
566
+ raise OSError("rehearsal copy made no progress")
567
+ written += count
568
+
569
+
570
+ def _fsync_directory(path: Path) -> None:
571
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
572
+ try:
573
+ os.fsync(descriptor)
574
+ finally:
575
+ os.close(descriptor)