Skip to content

Commit 786166b

Browse files
committed
feat(command): concurrency control
database locking per vocab type
1 parent a6a6134 commit 786166b

File tree

8 files changed

+391
-48
lines changed

8 files changed

+391
-48
lines changed

CONCURRENCY.md

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Database Locking for Concurrent Import Prevention
2+
3+
## Overview
4+
5+
The OpenEMR Code Import CLI tool now includes database locking to prevent concurrent imports of the same vocabulary type, which could otherwise result in data corruption or incomplete imports.
6+
7+
## How It Works
8+
9+
### Database Named Locks
10+
11+
The system uses MySQL's `GET_LOCK()` and `RELEASE_LOCK()` functions to implement named locks:
12+
13+
- **Lock Name Format**: `openemr_vocab_import_{CODE_TYPE}`
14+
- **Timeout**: 30 seconds (configurable)
15+
- **Scope**: Per code type (RXNORM, SNOMED, ICD9, ICD10, CQM_VALUESET)
16+
17+
### Lock Behavior
18+
19+
1. **Before Import**: Attempts to acquire a database lock for the specific code type
20+
2. **Retry Logic**: If lock is held, retries with exponential backoff (configurable)
21+
3. **During Import**: Holds the lock for the entire duration of the import process
22+
4. **After Import**: Automatically releases the lock (even if import fails)
23+
5. **Cleanup**: Destructor ensures locks are released if the process exits unexpectedly
24+
25+
### Concurrent Access Scenarios
26+
27+
| Scenario | Behavior |
28+
|----------|----------|
29+
| Same code type (e.g., two RXNORM imports) | Second process retries with exponential backoff (default: 10 attempts over ~8 minutes) |
30+
| Different code types (e.g., RXNORM + SNOMED) | Both processes run concurrently without interference |
31+
| Process crash/kill | Lock automatically released by MySQL when connection closes |
32+
| No-wait mode (`--lock-retry-delay=0`) | Second process fails immediately without waiting |
33+
34+
## Error Messages
35+
36+
### Lock Acquisition Failure (after retries)
37+
```
38+
Failed to acquire database lock for RXNORM import after 10 attempts (510 seconds total). Another import may still be in progress.
39+
```
40+
41+
### No-wait Mode Failure
42+
```
43+
Failed to acquire database lock for RXNORM import - another import is in progress and no-wait mode is enabled.
44+
```
45+
46+
### Database Error
47+
```
48+
Database lock acquisition failed for RXNORM import due to a database error.
49+
```
50+
51+
## Benefits
52+
53+
1. **Data Integrity**: Prevents table corruption from concurrent DROP/CREATE operations
54+
2. **Import Consistency**: Ensures complete, atomic imports
55+
3. **Resource Protection**: Prevents conflicts in temporary directories and tracking tables
56+
4. **Graceful Degradation**: Clear error messages when conflicts occur
57+
58+
## Technical Implementation
59+
60+
### CodeImporter.php Changes
61+
62+
- Added `$currentLockName` property to track active locks
63+
- `acquireLock()` method uses `GET_LOCK()` with 30-second timeout
64+
- `releaseLock()` method uses `RELEASE_LOCK()` with error handling
65+
- `__destruct()` ensures cleanup on object destruction
66+
- `import()` method wrapped with try/finally for guaranteed lock release
67+
68+
### Lock Names by Code Type
69+
70+
- RXNORM: `openemr_vocab_import_RXNORM`
71+
- SNOMED: `openemr_vocab_import_SNOMED`
72+
- SNOMED_RF2: `openemr_vocab_import_SNOMED_RF2`
73+
- ICD9: `openemr_vocab_import_ICD9`
74+
- ICD10: `openemr_vocab_import_ICD10`
75+
- CQM_VALUESET: `openemr_vocab_import_CQM_VALUESET`
76+
77+
## CLI Configuration Options
78+
79+
### Lock Retry Configuration
80+
81+
- `--lock-retry-attempts=N`: Number of retry attempts (default: 10)
82+
- `--lock-retry-delay=N`: Initial delay between retries in seconds (default: 30)
83+
- Set to 0 for no-wait mode (fail immediately)
84+
- Uses exponential backoff with jitter (caps at 5 minutes per attempt)
85+
86+
### Examples
87+
88+
**Default behavior** (10 retries with exponential backoff):
89+
```bash
90+
php oce-import-codes import /path/to/rxnorm.zip
91+
```
92+
93+
**Quick failure mode** (no waiting):
94+
```bash
95+
php oce-import-codes import /path/to/rxnorm.zip --lock-retry-delay=0
96+
```
97+
98+
**Custom retry behavior** (3 attempts, 60-second initial delay):
99+
```bash
100+
php oce-import-codes import /path/to/rxnorm.zip --lock-retry-attempts=3 --lock-retry-delay=60
101+
```
102+
103+
## Testing Concurrent Access
104+
105+
To test the locking mechanism:
106+
107+
1. Start an import process:
108+
```bash
109+
php oce-import-codes import /path/to/rxnorm.zip
110+
```
111+
112+
2. While the first is running, start a second import of the same type:
113+
```bash
114+
php oce-import-codes import /path/to/another-rxnorm.zip
115+
```
116+
117+
3. The second process should retry for several minutes with exponential backoff, displaying progress messages.
118+
119+
## Monitoring Active Locks
120+
121+
You can check for active import locks in MySQL:
122+
123+
```sql
124+
SELECT * FROM performance_schema.metadata_locks
125+
WHERE OBJECT_NAME LIKE 'openemr_vocab_import_%';
126+
```
127+
128+
Or check specific locks:
129+
130+
```sql
131+
SELECT IS_USED_LOCK('openemr_vocab_import_RXNORM') as lock_status;
132+
```
133+
134+
## Troubleshooting
135+
136+
### Stuck Locks
137+
If a lock appears stuck (process crashed without cleanup):
138+
139+
```sql
140+
SELECT RELEASE_LOCK('openemr_vocab_import_RXNORM');
141+
```
142+
143+
### Lock Timeout Issues
144+
If 30 seconds isn't enough for your environment, modify the timeout in `CodeImporter.php`:
145+
146+
```php
147+
$result = sqlQuery("SELECT GET_LOCK(?, 60) as lock_result", [$lockName]); // 60 seconds
148+
```
149+
150+
## Backward Compatibility
151+
152+
This change is fully backward compatible:
153+
- Single imports work exactly as before
154+
- No changes to command-line interface
155+
- No additional dependencies required
156+
- Falls back gracefully if database functions unavailable

src/Command/ImportCodesCommand.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use OpenCoreEMR\CLI\ImportCodes\Service\CodeImporter;
1717
use OpenCoreEMR\CLI\ImportCodes\Service\OpenEMRConnector;
1818
use OpenCoreEMR\CLI\ImportCodes\Service\MetadataDetector;
19+
use OpenCoreEMR\CLI\ImportCodes\Exception\CodeImportException;
1920
use Symfony\Component\Console\Command\Command;
2021
use Symfony\Component\Console\Input\InputDefinition;
2122
use Symfony\Component\Console\Input\InputInterface;
@@ -55,6 +56,8 @@ protected function configure()
5556
->addOption('cleanup', null, InputOption::VALUE_NONE, 'Clean up temporary files after import')
5657
->addOption('temp-dir', null, InputOption::VALUE_REQUIRED, 'Custom temporary directory path')
5758
->addOption('force', null, InputOption::VALUE_NONE, 'Force import even if the same version appears to be already loaded')
59+
->addOption('lock-retry-attempts', null, InputOption::VALUE_REQUIRED, 'Number of times to retry lock acquisition (default: 10)', 10)
60+
->addOption('lock-retry-delay', null, InputOption::VALUE_REQUIRED, 'Initial delay between lock retries in seconds (default: 30, set to 0 for no retries)', 30)
5861
->addUsage('/path/to/RxNorm_full_01012024.zip --openemr-path=/var/www/openemr')
5962
->addUsage('/path/to/SnomedCT_USEditionRF2_PRODUCTION_20240301T120000Z.zip')
6063
->addUsage('/path/to/icd10cm_order_2024.txt.zip --cleanup');
@@ -72,6 +75,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
7275
$cleanup = $input->getOption('cleanup');
7376
$tempDir = $input->getOption('temp-dir');
7477
$force = $input->getOption('force');
78+
$lockRetryAttempts = (int) $input->getOption('lock-retry-attempts');
79+
$lockRetryDelay = (int) $input->getOption('lock-retry-delay');
7580

7681
// Resolve relative paths to absolute paths
7782
if (!$this->is_absolute_path($filePath)) {
@@ -168,6 +173,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
168173
$this->importer->setTempDir($tempDir);
169174
}
170175

176+
// Configure lock retry behavior
177+
$this->importer->setLockRetryConfig($lockRetryAttempts, $lockRetryDelay);
178+
171179
// Check if already loaded (unless force flag is set)
172180
if (!$force && !$dryRun && $metadata['supported'] && $metadata['revision_date'] && $metadata['version']) {
173181
$trackingCodeType = ($codeType === 'SNOMED_RF2') ? 'SNOMED' : $codeType;
@@ -252,7 +260,16 @@ private function performImport(SymfonyStyle $io, OutputInterface $output, string
252260
$io->info("Starting import of $codeType data...");
253261

254262
if (!$dryRun) {
255-
$this->importer->import($codeType, $isWindows, $usExtension, $filePath);
263+
try {
264+
$this->importer->import($codeType, $isWindows, $usExtension, $filePath);
265+
} catch (\Exception $e) {
266+
// Check if this is a lock acquisition failure
267+
if (strpos($e->getMessage(), 'Failed to acquire database lock') !== false) {
268+
throw new CodeImportException("Import failed: " . $e->getMessage());
269+
}
270+
// Re-throw other exceptions as-is
271+
throw $e;
272+
}
256273
}
257274

258275
$io->info("$codeType data imported successfully");
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenCoreEMR\CLI\ImportCodes\Exception;
6+
7+
class CodeImportException extends \RuntimeException
8+
{
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenCoreEMR\CLI\ImportCodes\Exception;
6+
7+
class DatabaseLockException extends \RuntimeException
8+
{
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenCoreEMR\CLI\ImportCodes\Exception;
6+
7+
class FileSystemException extends \RuntimeException
8+
{
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenCoreEMR\CLI\ImportCodes\Exception;
6+
7+
class OpenEMRConnectorException extends \RuntimeException
8+
{
9+
}

0 commit comments

Comments
 (0)