Skip to content

Commit 2d1eca4

Browse files
perf: use caching and indexing to increase object relation performance
This commit implements model and query caching/indexing that will help reduce the number of objects that need to be reloaded unnecessarily. This should provide a big performance improvement for instances with large pfSense configurations.
1 parent 1d15ead commit 2d1eca4

File tree

5 files changed

+448
-57
lines changed

5 files changed

+448
-57
lines changed

pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1992,11 +1992,11 @@ class Model {
19921992
}
19931993

19941994
# Load the ModelSet with all obtained Model objects
1995-
$modelset = new ModelSet(model_objects: $model_objects);
1995+
$modelset = new ModelSet(model_objects: $model_objects, signature: $model_name);
19961996

19971997
# For many models, cache the ModelSet if not exempt
19981998
if ($model->many and !$cache_exempt) {
1999-
self::get_model_cache()::cache_modelset($model_name, $modelset);
1999+
self::get_model_cache()::cache_modelset($modelset);
20002000
}
20012001

20022002
# For many enabled Models return a ModelSet, otherwise return a single Model object

pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc

Lines changed: 169 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ class ModelCache {
2020
*/
2121
public static ?self $instance = null;
2222

23+
/**
24+
* @var array $index An associative array that contains cache Model objects that have been loaded and
25+
* indexed by specific fields for quick lookup. Structured as:
26+
*
27+
* [Model class name] => [
28+
* [index field name] => [
29+
* [index field value] => Model object
30+
* ]
31+
* ]
32+
*/
33+
public static array $index = [];
34+
2335
/**
2436
* @var array $cache An associative array that contains cached ModelSet objects that have been loaded into memory.
2537
* This includes root ModelSets that contain all Model objects of a given Model class as well as indexed queries
@@ -41,43 +53,185 @@ class ModelCache {
4153
}
4254

4355
/**
44-
* Checks if a given Model class has a cached ModelSet.
45-
* @param string $model_class The Model class name to check in the cache.
46-
* @return bool True if a cached ModelSet exists for the specified Model class, false otherwise.
56+
* Checks if a cached ModelSet exists for a specified signature.
57+
* @param string $signature The signature of the ModelSet to check for in the cache.
58+
* @return bool True if a cached ModelSet exists with the specified signature, false otherwise.
4759
*/
48-
public static function has_modelset(string $model_class): bool {
49-
return isset(self::$cache[$model_class]);
60+
public static function has_modelset(string $signature): bool {
61+
return isset(self::$cache[$signature]);
5062
}
5163

5264
/**
53-
* Caches a ModelSet in the ModelCache.
54-
* @param string $model_class The Model class name of the ModelSet to cache.
65+
* Caches a ModelSet in the ModelCache by its signature.
5566
* @param ModelSet $model_set The ModelSet object to cache.
5667
*/
57-
public static function cache_modelset(string $model_class, ModelSet $model_set): void {
58-
self::$cache[$model_class] = $model_set;
68+
public static function cache_modelset(ModelSet $model_set): void {
69+
# Do not allow ModelSets to be cached without a valid signature
70+
if (!$model_set->signature) {
71+
throw new ServerError(
72+
message: 'ModelSet cannot be cached without a valid signature.',
73+
response_id: 'MODEL_CACHE_MODELSET_MISSING_SIGNATURE',
74+
);
75+
}
76+
77+
# Index the ModelSet in the cache by its signature
78+
self::$cache[$model_set->signature] = $model_set;
5979
}
6080

6181
/**
6282
* Fetches a cached ModelSet by its Model class name.
63-
* @param string $model_class The Model class name to load from the cache.
83+
* @param string $signature The signature of the ModelSet to fetch from the cache.
6484
* @return ModelSet The cached ModelSet
65-
* @throws NotFoundError If no cached ModelSet exists for the specified Model class.
85+
* @throws NotFoundError If no cached ModelSet exists for the specified signature.
6686
*/
67-
public static function fetch_modelset(string $model_class): ModelSet {
68-
if (!self::has_modelset($model_class)) {
87+
public static function fetch_modelset(string $signature): ModelSet {
88+
if (!self::has_modelset($signature)) {
6989
throw new NotFoundError(
70-
message: "No cached ModelSet found for Model class '$model_class'.",
90+
message: "No cached ModelSet found with signature '$signature'.",
7191
response_id: 'MODEL_CACHE_MODELSET_NOT_FOUND',
7292
);
7393
}
74-
return self::$cache[$model_class];
94+
return self::$cache[$signature];
95+
}
96+
97+
/**
98+
* Raises an error if the given Model object does not support being indexed by the specified field.
99+
* @param Model $model The Model object to check for uniqueness.
100+
* @param string $index_field The index field name to check for uniqueness.
101+
* @throws ServerError If the Model is attempting to be indexed by a non-unique field.
102+
* @throws ServerError If the Model is not many-enabled.
103+
*/
104+
private static function ensure_model_supports_indexing(Model $model, string $index_field): void {
105+
# Only many-enabled Models can be indexed
106+
if (!$model->many) {
107+
throw new ServerError(
108+
message: "Cannot index Model class '" . $model->get_class_fqn() . 'because it is not many-enabled.',
109+
response_id: 'MODEL_CACHE_INDEX_FIELD_ON_NON_MANY_MODEL',
110+
);
111+
}
112+
113+
# Models with parent model classes cannot be indexed
114+
if ($model->parent_model_class) {
115+
throw new ServerError(
116+
message: "Cannot index Model class '" .
117+
$model->get_class_fqn() .
118+
"' because it has a parent model class '" .
119+
$model->parent_model_class .
120+
"'.",
121+
response_id: 'MODEL_CACHE_INDEX_FIELD_ON_PARENTED_MODEL',
122+
);
123+
}
124+
125+
# If indexing by 'id', it's always unique
126+
if ($index_field === 'id') {
127+
return;
128+
}
129+
130+
# Check if the index field is unique on the Model object
131+
if (!$model->$index_field->unique) {
132+
throw new ServerError(
133+
message: "Cannot index Model class '" .
134+
$model->get_class_fqn() .
135+
"' by non-unique field " .
136+
"'$index_field'.",
137+
response_id: 'MODEL_CACHE_INDEX_FIELD_NOT_UNIQUE',
138+
);
139+
}
140+
}
141+
142+
/**
143+
* Indexes the ModelSet cache for a given Model class by a specified field. This method will populate
144+
* the $index array for the specified Model class and index field.
145+
* @param string $model_class The Model class name whose ModelSet is to be indexed. If no cached ModelSet exists,
146+
* one will be created by reading all Model objects from the data source.
147+
* @param string $index_field The field name to index the Model objects by.
148+
* @return array An associative array of indexed Model objects.
149+
*/
150+
public static function index_modelset_by_field(string $model_class, string $index_field): array {
151+
# First, check if this Model class has already been indexed by this field
152+
if (isset(self::$index[$model_class][$index_field])) {
153+
return self::$index[$model_class][$index_field];
154+
}
155+
156+
# Ensure the Model can be indexed by the specified field
157+
$model = new $model_class();
158+
self::ensure_model_supports_indexing($model, $index_field);
159+
160+
# Fetch or create the ModelSet for this Model class
161+
$model_set = $model->read_all();
162+
163+
# Create an associative array to hold the indexed Model objects
164+
$indexed_models = [];
165+
166+
# Index each Model object in the ModelSet by the specified field
167+
foreach ($model_set->model_objects as $model) {
168+
$index_value = $index_field === 'id' ? $model->id : $model->$index_field->value;
169+
$indexed_models[$index_value] = $model;
170+
}
171+
172+
# Store the indexed models in the ModelCache index and return them
173+
self::$index[$model_class][$index_field] = $indexed_models;
174+
return $indexed_models;
175+
}
176+
177+
/**
178+
* Checks if a given Model class has a cached Model object by its index field/value.
179+
* @param string $model_class The Model class name to check in the cache.
180+
* @param string $index_field The index field name to look up the Model object by
181+
* @param mixed $index_value The index field value to look up the Model object by
182+
* @return bool True if a cached Model object exists for the specified Model class and index
183+
*/
184+
public static function has_model(string $model_class, string $index_field = 'id', mixed $index_value = null): bool {
185+
# Cache is always a miss if the Model class is not indexed
186+
if (!self::$index[$model_class]) {
187+
return false;
188+
}
189+
190+
# Cache is a hit for non-many enabled models if a single Model object is indexed under the Model class
191+
if (self::$index[$model_class] instanceof Model) {
192+
return true;
193+
}
194+
195+
# Otherwise, cache is only a hit for many enabled models if the index field/value exists in the index
196+
return isset(self::$index[$model_class][$index_field][$index_value]);
197+
}
198+
199+
/**
200+
* Fetches a cached Model object by its Model class name and index field/value.
201+
* @param string $model_class The Model class name to load from the cache.
202+
* @param string $index_field The index field name to look up the Model object by. Only for many enabled Models.
203+
* @param mixed $index_value The index field value to look up the Model object by. Only for many enabled Models.
204+
* @return Model The cached Model object
205+
* @throws NotFoundError If no cached Model object exists for the specified Model class and index.
206+
*/
207+
public static function fetch_model(
208+
string $model_class,
209+
string $index_field = 'id',
210+
mixed $index_value = null,
211+
): Model {
212+
if (!self::has_model($model_class, $index_field, $index_value)) {
213+
throw new NotFoundError(
214+
message: "No cached Model found for Model class '$model_class' with " .
215+
"index field '$index_field' and index value '$index_value'.",
216+
response_id: 'MODEL_CACHE_MODEL_NOT_FOUND',
217+
);
218+
}
219+
220+
# For many enabled models, return the Model object indexed under the specified index field and value
221+
if (isset(self::$index[$model_class][$index_field][$index_value])) {
222+
return self::$index[$model_class][$index_field][$index_value];
223+
}
224+
225+
# Otherwise, return the Model object indexed under the Model class only
226+
return self::$index[$model_class];
75227
}
76228

229+
77230
/**
78231
* Clears the ModelCache of all cached ModelSets and indexed Model objects.
79232
*/
80233
public static function clear(): void {
81234
self::$cache = [];
235+
self::$index = [];
82236
}
83237
}

pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class ModelSet {
1919
* a series of query signatures that represent each query that has been applied to this ModelSet to this point
2020
* (separated by a colon). Example 'RESTAPI\Models\User:queryhash1:queryhash2'
2121
*/
22-
public string $signature = '';
22+
public string $signature;
2323

2424
/**
2525
* @var array $model_objects An array of Model objects contained by this ModelSet.
@@ -30,7 +30,10 @@ class ModelSet {
3030
* Creates a ModelSet object that contains multiple Model objects.
3131
* @param array $model_objects An array of model objects to include in this model set
3232
*/
33-
public function __construct(array $model_objects = []) {
33+
public function __construct(array $model_objects = [], string $signature = '') {
34+
# Assign the signature if provided
35+
$this->signature = $signature;
36+
3437
# Throw an error if any Models are not a Model object
3538
foreach ($model_objects as $model_object) {
3639
if (!is_object($model_object) or !in_array('RESTAPI\Core\Model', class_parents($model_object))) {
@@ -180,6 +183,38 @@ class ModelSet {
180183
return $results;
181184
}
182185

186+
/**
187+
* Provides access to the ModelCache singleton instance.
188+
* @return ModelCache The ModelCache singleton instance.
189+
*/
190+
public static function get_model_cache(): ModelCache {
191+
return ModelCache::get_instance();
192+
}
193+
194+
/**
195+
* Determines the signature for a given query. This signature is used to index queried ModelSets in the
196+
* ModelCache while retaining all query chains.
197+
* @param array $query_params An associative array of query parameters to use to generate the signature.
198+
* @return string The signature for the given query parameters.
199+
*/
200+
public function get_query_signature(array $query_params = []): string
201+
{
202+
# ModelSet is cache exempt if there is no existing signature
203+
if (!$this->signature) {
204+
return '';
205+
}
206+
207+
# Sort the query parameters by key to ensure consistent signatures
208+
ksort($query_params);
209+
210+
# Encode the query parameters as JSON and generate an xxh hash
211+
$query_json = json_encode($query_params);
212+
$query_hash = hash(algo: 'xxh64', data: $query_json);
213+
214+
# Append the query hash to the existing signature to retain query chains
215+
return "$this->signature:$query_hash";
216+
}
217+
183218
/**
184219
* Filters the ModelSet to only include Model objects that match a specific query.
185220
* @param array $query_params An associative array of query targets and values. The array key will be the query
@@ -193,9 +228,14 @@ class ModelSet {
193228
public function query(array $query_params = [], array $excluded = [], ...$vl_query_params): ModelSet {
194229
# Variables
195230
$queried_model_set = [];
196-
197-
# Merge the $query_params and any provided variable-length arguments into a single variable
198231
$query_params = array_merge($query_params, $vl_query_params);
232+
$query_signature = $this->get_query_signature($query_params);
233+
$cache_exempt = !$query_signature;
234+
235+
# Fetch the cached query if it exists and this ModelSet is not cache exempt
236+
if (!$cache_exempt and self::get_model_cache()::has_modelset($query_signature)) {
237+
return self::get_model_cache()::fetch_modelset($query_signature);
238+
}
199239

200240
# Loop through each model object in the provided model set and check it against the query parameters
201241
foreach ($this->model_objects as $model_object) {
@@ -230,7 +270,16 @@ class ModelSet {
230270
$queried_model_set[] = $model_object;
231271
}
232272
}
233-
return new ModelSet($queried_model_set);
273+
274+
# Create a new ModelSet with the queried model objects
275+
$modelset = new ModelSet($queried_model_set, signature: $query_signature);
276+
277+
# Cache the queried ModelSet if this ModelSet is not cache exempt
278+
if (!$cache_exempt) {
279+
self::get_model_cache()::cache_modelset($modelset);
280+
}
281+
282+
return $modelset;
234283
}
235284

236285
/**

0 commit comments

Comments
 (0)