Skip to content

Commit c9f89a7

Browse files
committed
fix deferred writes for DatabaseHandler
1 parent 0726942 commit c9f89a7

9 files changed

Lines changed: 256 additions & 113 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ config classes for CodeIgniter 4 framework.
99
[![Coverage Status](https://coveralls.io/repos/github/codeigniter4/settings/badge.svg?branch=develop)](https://coveralls.io/github/codeigniter4/settings?branch=develop)
1010

1111
![PHP](https://img.shields.io/badge/PHP-%5E8.1-blue)
12-
![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.3-blue)
12+
![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.2.3-blue)
1313
![License](https://img.shields.io/badge/License-MIT-blue)
1414

1515
## Installation

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
},
2222
"require-dev": {
2323
"codeigniter4/devkit": "^1.3",
24-
"codeigniter4/framework": "^4.3.0"
24+
"codeigniter4/framework": "^4.2.3"
2525
},
2626
"minimum-stability": "dev",
2727
"prefer-stable": true,

docs/configuration.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ public $handlers = ['database'];
2929
### Deferred writes
3030

3131
Handlers like `database` and `file` support deferred writes. When `deferWrites` is enabled, multiple `set()` and `forget()` calls
32-
are batched into the minimum number of database calls or file writes. The actual changes happen during the `post_system` event.
32+
are batched and persisted efficiently at the end of the request during the `post_system` event. This minimizes the number of
33+
database queries or file I/O operations, improving performance for write-heavy operations.
3334

3435
### Multiple handlers
3536

@@ -80,17 +81,19 @@ public $database = [
8081

8182
**Deferred Writes**
8283

83-
When `deferWrites` is enabled, multiple `set()` or `forget()` calls are batched into a single database transaction at the end of the request. This significantly reduces database queries:
84+
When `deferWrites` is enabled, multiple `set()` or `forget()` calls are batched and persisted in a single transaction at the end of the request. This significantly reduces database queries:
8485

8586
```php
86-
// With deferWrites = false: 3 separate queries (INSERT/UPDATE)
87+
// With deferWrites = false: 1 SELECT (hydrates all settings for context) + 3 separate INSERT/UPDATE queries
8788
$settings->set('Example.prop1', 'value1');
8889
$settings->set('Example.prop2', 'value2');
8990
$settings->set('Example.prop3', 'value3');
9091

91-
// With deferWrites = true: 1 query updating 3 properties at the end of the request
92+
// With deferWrites = true: 1 SELECT + 1 INSERT batch + 1 UPDATE batch (all batched at end of request)
9293
```
9394

95+
The deferred approach is especially beneficial when updating existing records or performing many operations in a single request.
96+
9497
---
9598

9699
## FileHandler
@@ -123,14 +126,16 @@ public $file = [
123126
When `deferWrites` is enabled, multiple `set()` or `forget()` calls to the same class are batched into a single file write at the end of the request. This significantly reduces I/O operations:
124127

125128
```php
126-
// With deferWrites = false: 3 file writes
129+
// With deferWrites = false: 1 file read (hydrates all settings for class) + 3 separate file writes
127130
$settings->set('Example.prop1', 'value1');
128131
$settings->set('Example.prop2', 'value2');
129132
$settings->set('Example.prop3', 'value3');
130133

131-
// With deferWrites = true: 1 file write at end of request
134+
// With deferWrites = true: 1 file read + 1 file write (all changes batched at end of request)
132135
```
133136

137+
The deferred approach is especially beneficial when updating multiple properties in the same class.
138+
134139
---
135140

136141
## ArrayHandler

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ service('settings')->forget('App.siteName');
3030
### Requirements
3131

3232
![PHP](https://img.shields.io/badge/PHP-%5E8.1-red)
33-
![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.3-red)
33+
![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.2.3-red)
3434

3535
### Acknowledgements
3636

docs/limitations.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
The following are known limitations of the library:
44

55
1. **Immediate writes (`deferWrites => false`)**: Each setting is written to storage immediately when you call `set()` or `forget()`.
6-
While `DatabaseHandler` and `FileHandler` use an in-memory cache to maintain fast reads, write operations happen one at a time,
7-
which may result in multiple database queries or file writes per request.
6+
The first operation hydrates all settings for that context (1 SELECT query), then each subsequent write performs a separate
7+
INSERT or UPDATE. While `DatabaseHandler` and `FileHandler` use an in-memory cache to maintain fast reads, individual write
8+
operations may result in multiple database queries or file writes per request.
89

910
2. **Deferred writes (`deferWrites => true`)**: All settings are batched and written to storage at the end of the request
1011
(during the `post_system` event). This minimizes the number of database queries and file writes, improving performance.
11-
However, this means write operations will not appear in CodeIgniter's Debug Toolbar, since the `post_system` event
12-
executes after the toolbar data is collected.
12+
However, there are important considerations:
13+
- Write operations will not appear in CodeIgniter's Debug Toolbar, since the `post_system` event executes after toolbar data collection.
14+
- If the request terminates early (fatal error, `exit()`, etc.) before `post_system`, pending writes are lost.
15+
- Write failures are logged but handled silently - no exceptions are thrown back to the calling code.
1316

1417
3. **First-level property access only**: You can only access the first level of a config property directly. In most config classes
1518
this is not an issue, since properties are simple values. However, some config files (like `Database`) contain properties that

src/Database/Migrations/2025-10-25-194218_AddUniqueKey.php

Lines changed: 0 additions & 35 deletions
This file was deleted.

src/Handlers/DatabaseHandler.php

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -239,14 +239,20 @@ public function persistPendingProperties()
239239

240240
$time = Time::now()->format('Y-m-d H:i:s');
241241

242-
// Separate deletes from upserts
242+
// Separate deletes from upserts and prepare for database operations
243243
$deletes = [];
244244
$upserts = [];
245245

246246
foreach ($this->pendingProperties as $info) {
247247
if ($info['delete']) {
248-
$deletes[] = $info;
248+
// Prepare delete row with correct database column names
249+
$deletes[] = [
250+
'class' => $info['class'],
251+
'key' => $info['property'],
252+
'context' => $info['context'],
253+
];
249254
} else {
255+
// Prepare upsert row with correct database column names
250256
$upserts[] = [
251257
'class' => $info['class'],
252258
'key' => $info['property'],
@@ -262,31 +268,56 @@ public function persistPendingProperties()
262268
try {
263269
$this->db->transStart();
264270

265-
// Batch upsert all non-delete operations
271+
// Handle upserts: fetch existing records matching our pending data
266272
if ($upserts !== []) {
267-
$this->builder->upsertBatch($upserts);
268-
}
273+
// Build query to fetch only the specific records we need
274+
$this->buildOrWhereConditions($upserts, 'class', 'key', 'context');
269275

270-
// Batch delete all delete operations
271-
if ($deletes !== []) {
272-
$this->builder->groupStart();
276+
$existing = $this->builder->get()->getResultArray();
277+
278+
// Build a map of existing records for quick lookup
279+
$existingMap = [];
280+
281+
foreach ($existing as $row) {
282+
$key = $this->buildCompositeKey($row['class'], $row['key'], $row['context']);
283+
$existingMap[$key] = $row['id'];
284+
}
273285

274-
foreach ($deletes as $i => $info) {
275-
if ($i > 0) {
276-
$this->builder->orGroupStart();
286+
// Separate into inserts and updates
287+
$inserts = [];
288+
$updates = [];
289+
290+
foreach ($upserts as $row) {
291+
$key = $this->buildCompositeKey($row['class'], $row['key'], $row['context']);
292+
293+
if (isset($existingMap[$key])) {
294+
// Record exists - prepare for update
295+
$updates[] = [
296+
'id' => $existingMap[$key],
297+
'value' => $row['value'],
298+
'type' => $row['type'],
299+
'updated_at' => $row['updated_at'],
300+
];
277301
} else {
278-
$this->builder->groupStart();
302+
// New record - prepare for insert
303+
$inserts[] = $row;
279304
}
305+
}
280306

281-
$this->builder
282-
->where('class', $info['class'])
283-
->where('key', $info['property'])
284-
->where('context', $info['context']);
307+
// Batch insert new records
308+
if ($inserts !== []) {
309+
$this->builder->insertBatch($inserts);
310+
}
285311

286-
$this->builder->groupEnd();
312+
// Batch update existing records
313+
if ($updates !== []) {
314+
$this->builder->updateBatch($updates, 'id');
287315
}
316+
}
288317

289-
$this->builder->groupEnd();
318+
// Batch delete all delete operations
319+
if ($deletes !== []) {
320+
$this->buildOrWhereConditions($deletes, 'class', 'key', 'context');
290321

291322
$this->builder->delete();
292323
}
@@ -295,13 +326,38 @@ public function persistPendingProperties()
295326

296327
if ($this->db->transStatus() === false) {
297328
log_message('error', 'Failed to persist pending properties to database.');
298-
299-
return;
300329
}
301330

302331
$this->pendingProperties = [];
303332
} catch (DatabaseException $e) {
304333
log_message('error', 'Failed to persist pending properties: ' . $e->getMessage());
334+
335+
$this->pendingProperties = [];
336+
}
337+
}
338+
339+
/**
340+
* Builds a composite key for lookup purposes.
341+
*/
342+
private function buildCompositeKey(string $class, string $key, ?string $context): string
343+
{
344+
return $class . '::' . $key . ($context === null ? '' : '::' . $context);
345+
}
346+
347+
/**
348+
* Builds OR WHERE conditions for multiple rows.
349+
*/
350+
private function buildOrWhereConditions(array $rows, string $classKey, string $keyKey, string $contextKey): void
351+
{
352+
foreach ($rows as $row) {
353+
$this->builder->orGroupStart();
354+
355+
$this->builder
356+
->where($classKey, $row[$classKey])
357+
->where($keyKey, $row[$keyKey])
358+
->where($contextKey, $row[$contextKey]);
359+
360+
$this->builder->groupEnd();
305361
}
306362
}
307363
}

0 commit comments

Comments
 (0)