Skip to content

Commit c06511b

Browse files
authored
feat: deferred writes (#154)
1 parent 080b3bf commit c06511b

File tree

11 files changed

+774
-58
lines changed

11 files changed

+774
-58
lines changed

docs/configuration.md

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ Example:
2626
public $handlers = ['database'];
2727
```
2828

29+
### Deferred writes
30+
31+
Handlers like `database` and `file` support deferred writes. When `deferWrites` is enabled, multiple `set()` and `forget()` calls
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.
34+
2935
### Multiple handlers
3036

3137
Example:
@@ -44,6 +50,8 @@ This configuration will:
4450

4551
Only handlers marked as `writeable => true` will be used when calling `set()`, `forget()`, or `flush()` methods.
4652

53+
---
54+
4755
## DatabaseHandler
4856

4957
This handler stores settings in a database table and is production-ready for high-traffic applications.
@@ -54,21 +62,38 @@ This handler stores settings in a database table and is production-ready for hig
5462
* `table` - The database table name for storing settings. Default: `'settings'`
5563
* `group` - The database connection group to use. Default: `null` (uses default connection)
5664
* `writeable` - Whether this handler supports write operations. Default: `true`
65+
* `deferWrites` - Whether to defer writes until the end of request (`post_system` event). Default: `false`
5766

5867
Example:
5968

6069
```php
6170
public $database = [
62-
'class' => DatabaseHandler::class,
63-
'table' => 'settings',
64-
'group' => null,
65-
'writeable' => true,
71+
'class' => DatabaseHandler::class,
72+
'table' => 'settings',
73+
'group' => null,
74+
'writeable' => true,
75+
'deferWrites' => false,
6676
];
6777
```
6878

6979
!!! note
7080
You need to run migrations to create the settings table: `php spark migrate -n CodeIgniter\\Settings`
7181

82+
**Deferred Writes**
83+
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:
85+
86+
```php
87+
// With deferWrites = false: 1 SELECT (hydrates all settings for context) + 3 separate INSERT/UPDATE queries
88+
$settings->set('Example.prop1', 'value1');
89+
$settings->set('Example.prop2', 'value2');
90+
$settings->set('Example.prop3', 'value3');
91+
92+
// With deferWrites = true: 1 SELECT + 1 INSERT batch + 1 UPDATE batch (all batched at end of request)
93+
```
94+
95+
The deferred approach is especially beneficial when updating existing records or performing many operations in a single request.
96+
7297
---
7398

7499
## FileHandler
@@ -80,20 +105,37 @@ This handler stores settings as PHP files and is optimized for production use wi
80105
* `class` - The handler class. Default: `FileHandler::class`
81106
* `path` - The directory path where settings files are stored. Default: `WRITEPATH . 'settings'`
82107
* `writeable` - Whether this handler supports write operations. Default: `true`
108+
* `deferWrites` - Whether to defer writes until the end of request (`post_system` event). Default: `false`
83109

84110
Example:
85111

86112
```php
87113
public $file = [
88-
'class' => FileHandler::class,
89-
'path' => WRITEPATH . 'settings',
90-
'writeable' => true,
114+
'class' => FileHandler::class,
115+
'path' => WRITEPATH . 'settings',
116+
'writeable' => true,
117+
'deferWrites' => false,
91118
];
92119
```
93120

94121
!!! note
95122
The `FileHandler` automatically creates the directory if it doesn't exist and checks write permissions on instantiation.
96123

124+
**Deferred Writes**
125+
126+
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:
127+
128+
```php
129+
// With deferWrites = false: 1 file read (hydrates all settings for class) + 3 separate file writes
130+
$settings->set('Example.prop1', 'value1');
131+
$settings->set('Example.prop2', 'value2');
132+
$settings->set('Example.prop3', 'value3');
133+
134+
// With deferWrites = true: 1 file read + 1 file write (all changes batched at end of request)
135+
```
136+
137+
The deferred approach is especially beneficial when updating multiple properties in the same class.
138+
97139
---
98140

99141
## ArrayHandler

docs/limitations.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,19 @@
22

33
The following are known limitations of the library:
44

5-
1. You can currently only store a single setting at a time. While the `DatabaseHandler` and `FileHandler`
6-
uses a local cache to keep performance as high as possible for reads, writes must be done one at a time.
7-
2. You can only access the first level within a property directly. In most config classes this is a non-issue,
8-
since the properties are simple values. Some config files, like the `database` file, contain properties that
9-
are arrays.
5+
1. **Immediate writes (`deferWrites => false`)**: Each setting is written to storage immediately when you call `set()` or `forget()`.
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.
9+
10+
2. **Deferred writes (`deferWrites => true`)**: All settings are batched and written to storage at the end of the request
11+
(during the `post_system` event). This minimizes the number of database queries and file writes, improving performance.
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.
16+
17+
3. **First-level property access only**: You can only access the first level of a config property directly. In most config classes
18+
this is not an issue, since properties are simple values. However, some config files (like `Database`) contain properties that
19+
are nested arrays. For example, you cannot directly access `$config->database['default']['hostname']` - you would need to
20+
get the entire `database` property and then access the nested value.

rector.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use Rector\CodingStyle\Rector\FuncCall\CountArrayToEmptyArrayComparisonRector;
2929
use Rector\Config\RectorConfig;
3030
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPromotedPropertyRector;
31+
use Rector\DeadCode\Rector\MethodCall\RemoveNullArgOnNullDefaultParamRector;
3132
use Rector\EarlyReturn\Rector\Foreach_\ChangeNestedForeachIfsToEarlyContinueRector;
3233
use Rector\EarlyReturn\Rector\If_\ChangeIfElseValueAssignToEarlyReturnRector;
3334
use Rector\EarlyReturn\Rector\If_\RemoveAlwaysElseRector;
@@ -80,6 +81,8 @@
8081

8182
// May load view files directly when detecting classes
8283
StringClassNameToClassConstantRector::class,
84+
85+
RemoveNullArgOnNullDefaultParamRector::class,
8386
]);
8487
$rectorConfig->rule(SimplifyUselessVariableRector::class);
8588
$rectorConfig->rule(RemoveAlwaysElseRector::class);

src/Config/Settings.php

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,20 @@ class Settings extends BaseConfig
3030
* Database handler settings.
3131
*/
3232
public $database = [
33-
'class' => DatabaseHandler::class,
34-
'table' => 'settings',
35-
'group' => null,
36-
'writeable' => true,
33+
'class' => DatabaseHandler::class,
34+
'table' => 'settings',
35+
'group' => null,
36+
'writeable' => true,
37+
'deferWrites' => false,
3738
];
3839

3940
/**
4041
* File handler settings.
4142
*/
4243
public $file = [
43-
'class' => FileHandler::class,
44-
'path' => WRITEPATH . 'settings',
45-
'writeable' => true,
44+
'class' => FileHandler::class,
45+
'path' => WRITEPATH . 'settings',
46+
'writeable' => true,
47+
'deferWrites' => false,
4648
];
4749
}

src/Handlers/ArrayHandler.php

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace CodeIgniter\Settings\Handlers;
44

5+
use CodeIgniter\Events\Events;
6+
57
/**
68
* Array Settings Handler
79
*
@@ -15,18 +17,33 @@ class ArrayHandler extends BaseHandler
1517
* Storage for general settings.
1618
* Format: ['class' => ['property' => ['value', 'type']]]
1719
*
18-
* @var array<string,array<string,array>>
20+
* @var array<string, array<string, array{mixed, string}>>
1921
*/
2022
private array $general = [];
2123

2224
/**
2325
* Storage for context settings.
2426
* Format: ['context' => ['class' => ['property' => ['value', 'type']]]]
2527
*
26-
* @var array<string,array|null>
28+
* @var array<string, array<string, array<string, array{mixed, string}>>>
2729
*/
2830
private array $contexts = [];
2931

32+
/**
33+
* Whether to defer writes until the end of request.
34+
* Used by handlers that support deferred writes.
35+
*/
36+
protected bool $deferWrites = false;
37+
38+
/**
39+
* Array of properties that have been modified but not persisted.
40+
* Used by handlers that support deferred writes.
41+
* Format: ['key' => ['class' => ..., 'property' => ..., 'value' => ..., 'context' => ..., 'delete' => ...]]
42+
*
43+
* @var array<string, array{class: string, property: string, value: mixed, context: string|null, delete: bool}>
44+
*/
45+
protected array $pendingProperties = [];
46+
3047
public function has(string $class, string $property, ?string $context = null): bool
3148
{
3249
return $this->hasStored($class, $property, $context);
@@ -117,16 +134,62 @@ protected function forgetStored(string $class, string $property, ?string $contex
117134
}
118135

119136
/**
120-
* Retrieves all stored properties for a specific class and context.
137+
* Marks a property as pending (needs to be persisted).
138+
* Used by handlers that support deferred writes.
121139
*
122-
* @return array<string,array> Format: ['property' => ['value', 'type']]
140+
* @param mixed $value
123141
*/
124-
protected function getAllStored(string $class, ?string $context): array
142+
protected function markPending(string $class, string $property, $value, ?string $context, bool $isDelete = false): void
125143
{
126-
if ($context === null) {
127-
return $this->general[$class] ?? [];
144+
$key = $class . '::' . $property . ($context === null ? '' : '::' . $context);
145+
$this->pendingProperties[$key] = [
146+
'class' => $class,
147+
'property' => $property,
148+
'value' => $value,
149+
'context' => $context,
150+
'delete' => $isDelete,
151+
];
152+
}
153+
154+
/**
155+
* Groups pending properties by class+context combination.
156+
* Useful for handlers that need to persist changes on a per-class basis.
157+
* Format: ['key' => ['class' => ..., 'context' => ..., 'changes' => [...]]]
158+
*
159+
* @return array<string, array{class: string, context: string|null, changes: list<array{class: string, property: string, value: mixed, context: string|null, delete: bool}>}>
160+
*/
161+
protected function getPendingPropertiesGrouped(): array
162+
{
163+
$grouped = [];
164+
165+
foreach ($this->pendingProperties as $info) {
166+
$key = $info['class'] . ($info['context'] === null ? '' : '::' . $info['context']);
167+
168+
if (! isset($grouped[$key])) {
169+
$grouped[$key] = [
170+
'class' => $info['class'],
171+
'context' => $info['context'],
172+
'changes' => [],
173+
];
174+
}
175+
176+
$grouped[$key]['changes'][] = $info;
128177
}
129178

130-
return $this->contexts[$context][$class] ?? [];
179+
return $grouped;
180+
}
181+
182+
/**
183+
* Sets up deferred writes for handlers that support it.
184+
*
185+
* @param bool $enabled Whether deferred writes should be enabled
186+
*/
187+
protected function setupDeferredWrites(bool $enabled): void
188+
{
189+
$this->deferWrites = $enabled;
190+
191+
if ($this->deferWrites) {
192+
Events::on('post_system', [$this, 'persistPendingProperties']);
193+
}
131194
}
132195
}

src/Handlers/BaseHandler.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ public function flush()
6262
throw new RuntimeException('Flush method not implemented for current Settings handler.');
6363
}
6464

65+
/**
66+
* All handlers that support deferWrites MUST support this method.
67+
*
68+
* @return void
69+
*
70+
* @throws RuntimeException
71+
*/
72+
public function persistPendingProperties()
73+
{
74+
throw new RuntimeException('PersistPendingProperties method not implemented for current Settings handler.');
75+
}
76+
6577
/**
6678
* Takes care of converting some item types so they can be safely
6779
* stored and re-hydrated into the config files.

0 commit comments

Comments
 (0)