Method caching
#[Cacheable] remembers what a method returned: the first call runs the body, later
calls with the same key get the stored value without a trip to the database. The set
of attributes, their parameters and their behaviour come from Spring Cache.
What this is and why
Method caching keeps what a method returned, so the next call with the same arguments gets the ready answer instead of computing it again.
The problem. Some data is read far more often than it changes: a list of countries, a product card, the settings of a plan. Every request still goes to the database for it, and the load grows with traffic although the answer is the same every time. A cache written by hand is a “is it cached” check, a write after the computation and a removal on change, smeared across every method of the service. A forgotten removal goes unnoticed until a user sees stale data.
The solution. Mark the method with an attribute, and the framework checks the cache, runs the body on a miss and stores the result. Removal on change is declared the same way, with an attribute on the method that changes the data.
Ancestry
| Winter | Spring counterpart |
|---|---|
#[Cacheable] |
@Cacheable |
#[CachePut] |
@CachePut |
#[CacheEvict] |
@CacheEvict |
#[CacheConfig] |
@CacheConfig |
#[EnableCaching] |
@EnableCaching |
CacheConfigurer |
a CacheManager bean |
Where Winter differs from Spring
The key is a template, not an expression. Instead of SpEL, parameters are
substituted: '{id}', '{filter.region}:{page}'. The template is read once, at
build time, and a misspelt parameter name stops the build instead of turning into a
key that matches nothing. There is no condition or unless.
The lifetime is set right here. In Spring a TTL is the concern of a particular
store; in Winter it is the ttl parameter of the cache and of the attribute.
A call to a sibling method is cached too. In Spring @Cacheable does not apply
when the method is called from the same class through this. In Winter the proxy is
a subclass of your service, so a call through $this goes through the cache.
Every hit is a copy. The store keeps the serialized value, and each call gets its own instance. Changing an object taken from the cache and then seeing the change in another request is not possible.
Quick start
Three steps: enable it, declare a cache, mark a method.
use Flytachi\Winter\Kernel\App\Attribute\EnableCaching;
use Flytachi\Winter\Kernel\App\Attribute\EnableWeb;
#[EnableWeb]
#[EnableCaching]
final class Application extends WinterApplication
{
public static function main(array $argv): never
{
parent::run($argv);
}
}namespace Main\Config;
use Flytachi\Winter\Kernel\Cache\CacheConfigurer;
use Flytachi\Winter\Kernel\Cache\Caches;
final class CacheConfig implements CacheConfigurer
{
public function configureCaches(Caches $caches): void
{
$caches->table('countries'); // the defaults: 64 entries up to 16 KB, ≈1.6 MB
}
}namespace Main\Service;
use Flytachi\Winter\Kernel\Cache\Attribute\Cacheable;
class CountryService
{
#[Cacheable('countries', key: '{code}')]
public function find(string $code): ?Country
{
return $this->repository->find($code); // runs once per code
}
}The service is injected as usual — the container hands out the cached version:
#[Inject]
private CountryService $countries;
$this->countries->find('UZ'); // the body ran, the result is stored
$this->countries->find('UZ'); // from the cache, no database queryWithout `#[EnableCaching]` the attributes are silent
As in Spring, the attributes do nothing without the enabling one: the class is not proxied and every call runs the body. There is no error.
Stores
Each cache is declared in a CacheConfigurer and lives in one of four stores:
table and memory in the server’s memory, file and redis outside
it — so console commands see them and a restart does not wipe them.
table |
memory |
file |
redis |
|
|---|---|---|---|---|
| Where it lives | a Swoole\Table in shared memory |
a PHP array in the worker | files in storage/cache |
a Redis server |
| Server workers that see it | all | its own only | all | all |
Seen by call … commands |
no | no | yes | yes |
| Seen by other instances | no | no | only with a shared disk | yes |
#[CacheEvict] |
in every worker | in its own worker only | in every worker | in every instance |
| Restart and deploy | loses it | loses it | survives | survives |
| A read | ~0.4 µs | ~0.2 µs | ~17 µs | a network round trip |
| Memory | reserved at startup | grows as entries are written | none | none |
| Value size | limited by valueSize |
any | any | any |
| What to configure | rows, valueSize — or the defaults |
maxItems |
nothing | the connection class |
| What it needs | ext-swoole | — | — | the winter-redis package |
Which one to choose
| Task | Store | Why |
|---|---|---|
| An external API token | table |
One token for every worker and process: the auth service gets no extra requests, issue limits are not spent |
A token that must survive a restart or is needed by call commands |
file |
It is on disk: a restart does not force a new one, a command sees the same token |
| Constants and settings from the database that change now and then | table |
A removal after a change is seen by every worker at once |
| Settings changed by a console command | file |
The command resets the cache, and the server sees it at once |
| Several instances of the application — replicas, servers | redis |
The only store they all share: a removal on one server is seen by the rest |
| A reference list that does not change until the next deploy | memory |
Nothing to plan, and a copy per worker gets in no one’s way |
| Small data that goes stale fast — rates, stock levels | memory |
Expires on its own by ttl, shared memory is not needed |
Thousands of entities by id |
table with rows and valueSize set |
Predictable memory and one cache for all |
| An application under FPM | file or redis |
A process lives for one request — table and memory are useless there |
In short: one server and changing data — table; must survive a restart or be seen
from the console — file; several instances — redis; data that does not change or
goes stale on its own by ttl — memory.
`#[CacheEvict]` on `memory` cleans its own worker only
The changing method ran in one worker — and the entry was removed in that one only.
The other workers keep serving the old value until the ttl runs out. With one
worker (the default) this goes unnoticed, but raise SERVER_WORKERS and users start
seeing the new value one moment and the old one the next. For data that changes, use
table, or give memory a short ttl.
table — shared by every process
The table is created in the master process before the workers are forked from it,
which is why it is shared: a value computed by one worker is served by all the
others at once, and a removal in one worker removes the entry for all. Processes
beside the server, #[EnableProcess], #[EnableDaemon], #[EnableScheduler], see it too.
| Parameter | Default | What it sets |
|---|---|---|
rows |
64 |
How many entries the table is guaranteed to hold |
valueSize |
16 * 1024 |
The largest serialized value, in bytes |
ttl |
— | Default lifetime of an entry, in seconds |
The defaults are sized for the most common case — a handful of values: a token, settings, a short reference list. Such a table takes ≈1.6 MB.
By default there is one worker
Unless you changed SERVER_WORKERS, the server has one worker. Then the main gain of
table over memory is not sharing between workers but that the cache survives a
planned worker restart and is visible to the processes beside the server.
Ready-made table settings
// 1. A handful of values — a token, settings, a short list. ≈1.6 MB
$caches->table('api-token');
// 2. A reference list per item: up to a thousand 2 KB entries. ≈3.2 MB
$caches->table('countries', rows: 1_024, valueSize: 2 * 1024);
// 3. Entities by id: ten thousand 2 KB cards. ≈51 MB
$caches->table('products', rows: 10_000, valueSize: 2 * 1024, ttl: 300);| Variant | What it holds | Memory |
|---|---|---|
table('api-token') |
up to 64 values of 16 KB | ≈1.6 MB |
rows: 1_024, valueSize: 2 * 1024 |
up to a thousand entries of 2 KB | ≈3.2 MB |
rows: 10_000, valueSize: 2 * 1024 |
up to ten thousand entries of 2 KB | ≈51 MB |
The memory figures are measured on a live process. What matters is not how many entries there will be but how many there may become. When the table fills up, the entry is not stored, and a warning appears in the log.
What fits in a value
valueSize limits the value after serialize(). Measurements, so there is no guessing:
| Value | Size | In 16 KB |
|---|---|---|
| an OAuth token, 40 characters | 48 B | ✅ |
| a JWT, ~800 characters | 0.8 KB | ✅ |
| a whole OAuth response: token, expiry, type, scope | 0.9 KB | ✅ |
20 settings name => value |
0.9 KB | ✅ |
| one entity — 7 fields, a ~120-character description | 0.35 KB | ✅ |
| a list of 10 such entities | 3.4 KB | ✅ |
| a list of 45 entities | 15.4 KB | ✅ |
| a list of 50 entities | 17.1 KB | ❌ |
100 countries code => name |
3.2 KB | ✅ |
450 countries code => name |
15.2 KB | ✅ |
A rule of thumb: one entity record takes about 350 bytes, and an object and an array of the same shape weigh almost the same. The exact number for your own data:
echo strlen(serialize($service->all())); // bytes — compare with valueSizeHow much memory a table takes
The memory is reserved in full at startup — however many entries appear later. Swoole rounds the number of rows up to a power of two and adds room for collisions, so the estimate is:
power of two ≥ rows × 1.5 × (valueSize + 120) bytes
rows: 64, valueSize: 16384 → 64 × 1.5 × 16504 ≈ 1.6 MB
rows: 10_000, valueSize: 4096 → 16 384 × 1.5 × 4216 ≈ 99 MBThe total shows in the startup banner:
web http://0.0.0.0:8000
cache 2 table · 1 memory · ≈53 MB sharedWhen a value does not fit
A value longer than valueSize, or a write into a full table, is not stored: the
method still returns the right result, just without the cache. Swoole itself
silently truncates a string that is too long, and a truncated value can no longer be
read — so such a write never reaches the table, and the old entry under the same key
is removed so nothing stale is served.
Before refusing, the table drops its expired entries once. If there is still no room, a warning is logged — at most once a minute per cache, with a count of skipped writes:
[WARN ] Cache 'settings' skipped a write: a value of 21480 bytes does not fit valueSize 16384;
raise valueSize in the CacheConfigurer. The value was returned uncached; 12 write(s) skipped
since the last warning.Such a message means the cache stopped working for part of the data and the load on
the database came back. Raise what the message names: valueSize or rows.
memory — one per worker
A plain PHP array: nothing is reserved up front, and a value of any size fits. The price is that every worker has its own copy and computes it itself.
| Parameter | Default | What it sets |
|---|---|---|
maxItems |
1000 |
How many entries each worker keeps |
ttl |
— | Default lifetime of an entry, in seconds |
When there are more than maxItems entries, the one read least recently goes. That
is ordinary work for such a cache, and it logs no warnings.
Memory grows as entries are written: each entry costs its value plus about 370 bytes. A thousand entities of ~350 bytes is about 0.7 MB per worker, five thousand about 3.4 MB.
Ready-made memory settings
// 1. A reference list that does not change until a deploy: lives as long as the worker.
$caches->memory('currencies');
// 2. Rates: a hundred pairs, each lives a minute.
$caches->memory('rates', maxItems: 100, ttl: 60);
// 3. Stock levels: thousands of items, half a minute.
$caches->memory('stock', maxItems: 5_000, ttl: 30);| Variant | What for | Memory per worker |
|---|---|---|
memory('currencies') |
the data does not change, nothing to remove | as it fills, up to 1000 entries |
maxItems: 100, ttl: 60 |
the value goes stale fast and on its own | under 0.1 MB |
maxItems: 5_000, ttl: 30 |
many small hot entries | up to ~3.4 MB |
file — on disk
Entries are files in storage/cache/winter-cache/<name>/. Every worker and process
reads them, call commands see them, and a restart or a deploy does not wipe them.
A write is atomic — a temporary file and a rename — so no one ever reads half a
value.
| Parameter | Default | What it sets |
|---|---|---|
ttl |
— | Default lifetime of an entry, in seconds |
version |
— | Generation of the entries — a subfolder; change it and older entries are not found |
A read takes about 17 µs, a write about 150 µs: dozens of times slower than table,
but hundreds of times faster than a database query. Expired files go when read and in
one sweep at server start; call storage clean empties the folder as well.
// A token that survives a restart and is seen by call commands.
$caches->file('api-token', ttl: 3300);
// Settings a console command resets.
$caches->file('settings', version: 'v2');Under FPM, where a process lives for one request, this store still caches.
redis — shared by every instance
The only store several instances of the application share: replicas in Kubernetes,
servers behind a load balancer. A removal on one server is seen by all of them.
It needs the winter-redis package — without it a redis()
declaration stops the start with a hint on what to install.
$caches->redis('products', MainRedisConfig::class, ttl: 300);| Parameter | Default | What it sets |
|---|---|---|
config |
— | The connection class — the same one winter-redis stores use |
ttl |
— | Lifetime of an entry, in seconds; Redis expires it itself |
prefix |
cache:<name>: |
The key prefix; set your own if several applications share one database |
version |
— | Generation of the entries — part of the prefix |
Keys in Redis are visible as they are: #[Cacheable('products', key: '{id}')] with
version v2 writes to cache:products:v2:42. allEntries and call cache clear
remove only keys with this cache’s prefix — the rest of the database is left alone.
When a store is unreachable
A broken cache must not break the application. If Redis does not answer or a file cannot be written:
- a read counts as a miss — the method runs and returns the right result;
- a write is skipped;
- a
warningis logged — at most once a minute per cache, with a count; - the worker leaves the store alone for 5 seconds and runs without the cache, then tries again.
The pause matters. An unreachable Redis does not always refuse at once: an attempt may wait out the connect timeout — about three seconds with the winter-redis defaults. Without the pause every call would pay that twice, to read and to write, and an application that “did not fall over” would in fact stand still.
A failed removal is an `error`
If #[CacheEvict] could not remove an entry, it stays stale until its ttl runs
out — users see old data. That is why such a failure is logged as error, not
warning. Keep a ttl on data in Redis: it bounds how long an entry that could not
be removed lives on.
Attributes
#[Cacheable] — return from the cache or compute
#[Cacheable('products', key: '{id}', ttl: 300)]
public function find(int $id): ?Product| Parameter | Default | What it sets |
|---|---|---|
cache |
from #[CacheConfig] |
The cache name; the first positional argument |
key |
every argument | Key template |
ttl |
from #[CacheConfig], then the cache’s |
Lifetime in seconds; 0 never expires |
sync |
false |
Concurrent misses on one key wait for one computation |
cacheNull |
true |
Whether null is stored |
If the entry exists, the body does not run. If not, it runs and the result is stored. An exception from the body is not cached: the next call tries again.
#[CachePut] — always run and refresh
For methods that change data and return the new state. The body always runs, and the result is written to the cache — the next read finds it.
#[CachePut('products', key: '{result.id}')]
public function save(Product $product): ProductThe parameters are those of #[Cacheable], except sync. The key may use
{result} — the returned value. With cacheNull: false a null result removes the
entry instead of keeping the old one.
#[CacheEvict] — remove
#[CacheEvict('products', key: '{id}')]
#[CacheEvict('product-lists', allEntries: true)]
public function delete(int $id): void| Parameter | Default | What it sets |
|---|---|---|
cache |
from #[CacheConfig] |
The cache name |
key |
every argument | Which entry to remove |
allEntries |
false |
Empty the whole cache |
beforeInvocation |
false |
Remove before the body rather than after |
The attribute is repeatable: one method can clean several caches. It combines with
#[Cacheable] or #[CachePut] on the same method.
By default the entry is removed after the body completes successfully. If the
body throws, the change did not happen and the entry stays — it is still valid.
With beforeInvocation: true the entry is removed before the call, whatever
happens next.
#[CacheConfig] — class defaults
#[CacheConfig('products', ttl: 300)]
class ProductService
{
#[Cacheable(key: '{id}')] // cache products, 300 seconds
public function find(int $id): ?Product
#[Cacheable(key: 'all', ttl: 60)] // cache products, 60 seconds
public function all(): array
}A value on the method always wins over the one on the class.
Keys
The key decides which entry a call belongs to. It is a template over the method’s parameter names:
| Template | Key for the call |
|---|---|
'{id}' |
find(42) → 42 |
'product:{id}' |
find(42) → product:42 |
'{category}:{page}' |
list('books', 2) → books:2 |
'{filter.region}' |
a public property of an object parameter |
'{result.id}' |
the returned value — only in #[CachePut] and in #[CacheEvict] after the body |
'all' |
a constant key — one entry per method |
A key can hold strings, numbers, bool, null, enums, arrays and objects with
__toString(). A whole object does not go into a key — name its property:
'{order.id}'.
Without key the key is built from every argument in order. A method without
parameters has a constant key — a single entry.
The method name is not part of the key
Two methods of one cache with the same key share an entry. That is deliberate: it is
how #[CachePut] and #[CacheEvict] on other methods reach the entry #[Cacheable]
stored. But keep two methods that mean different things apart with a prefix:
'by-id:{id}' and 'by-sku:{sku}'.
Lifetime
ttl is in seconds. The value comes from the first level that sets one:
- the attribute parameter on the method;
#[CacheConfig]on the class;- the cache’s
ttlin theCacheConfigurer; - none — the entry lives until it is removed or the application restarts.
ttl: 0 means “never expires” and overrides the level above — this is how one method
of a class with #[CacheConfig(ttl: 60)] is made permanent. An expired entry counts
as a miss when read: the method runs again and its result overwrites the entry.
null is cached too
By default, as in Spring, null is stored like any other value: “there is no such
record” is an answer too, and the database should not get the same empty query on
every call.
The consequence: if the record appears later, the cache keeps returning null
until it is removed. The method that creates it has to tell the cache:
#[Cacheable('users', key: '{email}')]
public function findByEmail(string $email): ?User
#[CacheEvict('users', key: '{user.email}')]
public function register(User $user): UserIf that is inconvenient, turn off storing an empty result: cacheNull: false.
sync — one computation per miss
When an entry expires under load, dozens of requests miss at once and go to the
database for the same thing simultaneously. With sync: true the first request
computes the value, the others wait for it and get the ready result.
#[Cacheable('reports', key: 'daily', ttl: 600, sync: true)]
public function dailyReport(): ReportWaiting happens within one worker
Coroutines of one worker wait for each other. With several workers, each computes the value itself — that is, at most one computation per worker, not one per server.
Build-time checks
Everything that can be checked from the declaration is checked by call di build
and at application startup — not on the first call in production:
| Error | Sample message |
|---|---|
| The cache is not declared | undeclared cache 'prodcuts' used by ProductService::find() |
| The attribute names no cache | #[Cacheable] names no cache |
| An unknown parameter in the template | key refers to {idd}, but the method has no parameter $idd |
| A parameter cannot become a key | parameter $order of type Order cannot be part of one |
| Nothing to store | #[Cacheable] needs a value to store, but the method returns void |
| A by-reference parameter | parameter $rows is passed by reference |
#[Async] and a cache on one method |
the method carries both #[Async] and #[Cacheable]/… |
A misspelt cache name on #[CacheEvict] is the dangerous one: such a method would
simply remove nothing, and users would see stale data. That is why the name is
checked before startup.
The error shows with `DEBUG=false` too
A failed start always writes one line to stderr — docker logs shows it — and
exits with code 1; with logging on, the same error goes to the log as CRIT.
The stack trace is added only with DEBUG=true.
Class and method requirements
The same as for #[Async],
because the same mechanism is at work — a generated subclass:
- the class is not
final; the method is notfinal,staticorprivate; - the service comes from the container, not from
new— otherwise the proxy does not apply, andcall di buildwarns about it; #[Cacheable]and#[CachePut]sit on a method that returns a storable value: notvoid,never,Generator,Closure,callableoriterable;- the value has to pass through
serialize(): a closure inside an object will not be stored — the method returns it uncached and logs a warning.
One class can mix #[Async] methods and cached methods — it gets one shared proxy.
They cannot be combined on one method.
What the cache does not see
- Console commands —
tableandmemory.call …is a separate process with its own memory: a command that changes data will not clear such a cache of the server. If you need that, usefileorredis— both a#[CacheEvict]in the command andcall cache clearreach them. - Other instances of the application — everything but
redis. Each container holds its own table and its own folder; a change in one does not remove the entry in another. - FPM —
tableandmemory. A process lives for one request, and such a cache with it.
Deploys and old data
table and memory are empty after a restart. file and redis survive a deploy —
and with it the entries written by the old code. If the class of a value changed, say
a new typed property appeared, the object from such an entry comes back
incomplete.
Three measures deal with it:
- an entry that no longer restores — a class that is gone, corrupted bytes — counts as a miss: it is removed and computed again;
versionon the cache — change it and older entries are simply not found: inredisthey expire on their own, infilethe sweep removes them;call cache clear— clear the cache explicitly, for instance in a deploy script.
php call cache clear --all # every file and redis cache
php call runOnly the top level is checked
An incomplete object nested in an array is not caught: from outside the value restored.
If you change classes that sit in the cache inside lists, change the version or clear
the cache on deploy.
Examples
An external API token for an hour
The most common case. The auth service issues a token for an hour, and requesting it on every call is an extra delay and a risk of hitting the issue limit.
use Flytachi\Winter\Kernel\Cache\Attribute\CacheEvict;
use Flytachi\Winter\Kernel\Cache\Attribute\Cacheable;
class PaymentGateway
{
#[Cacheable('api-token', key: 'payment', ttl: 3300, sync: true, cacheNull: false)]
public function token(): string
{
$response = $this->http->post('https://auth.example.com/oauth/token', [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'grant_type' => 'client_credentials',
]);
return $response['access_token'];
}
#[CacheEvict('api-token', key: 'payment')]
public function forgetToken(): void
{
}
public function charge(Order $order): array
{
$response = $this->send($order, $this->token());
// The token was revoked early: forget it, get a new one, retry once.
if ($response['status'] === 401) {
$this->forgetToken();
$response = $this->send($order, $this->token());
}
return $response;
}
}$caches->table('api-token'); // the defaults: ≈1.6 MB, room for dozens of tokensWhat matters here:
ttl: 3300— 55 minutes for an hour-long token. The margin keeps the cache from handing out a token with a second left: a request with it goes out and comes back401.sync: true— when the token expires, dozens of simultaneous requests do not go for a new one at once: one gets it, the others wait for it.cacheNull: false— if the auth service returned an empty response, it is not remembered for 55 minutes. An exception is not cached in any case.key: 'payment'— oneapi-tokencache can hold the tokens of several services under different keys.$this->token()insidecharge()goes through the cache: the proxy is a subclass of the service, so a call to its own method is cached too.table, notmemory: one token for every worker and process, andforgetToken()resets it for all of them at once. To survive a restart —file('api-token'); with several instances —redis('api-token', …). The attributes stay the same.
Constants from the database
Settings, tariffs, texts — read on every request, changed once a week.
#[CacheConfig('settings')]
class SettingsService
{
#[Cacheable(key: 'all')]
public function all(): array
{
return $this->repository->pairs(); // ['name' => 'value', ...]
}
public function get(string $name, ?string $default = null): ?string
{
return $this->all()[$name] ?? $default; // from the cache
}
#[CacheEvict(key: 'all')]
public function set(string $name, string $value): void
{
$this->repository->save($name, $value);
}
}$caches->table('settings'); // up to 16 KB of settings — hundreds of pairsAll the settings are one value under the key 'all': one database query for
everyone, and set() resets it in every worker at once. No ttl is needed — the entry
lives until it is changed. If the settings are changed bypassing the service (by hand
in the database), add a ttl, say 600: the change is then picked up within ten minutes.
A reference list
The data hardly ever changes, the key is a code, there is nothing to remove.
#[CacheConfig('currencies')]
class CurrencyService
{
#[Cacheable(key: '{code}')]
public function find(string $code): ?Currency
{
return $this->repository->find($code);
}
#[Cacheable(key: 'all')]
public function all(): array
{
return $this->repository->findAll();
}
}$caches->memory('currencies'); // changes only with a deploy — memory is enoughAn entity: read, change, remove
#[CacheConfig('products', ttl: 300)]
class ProductService
{
#[Cacheable(key: '{id}')]
public function find(int $id): ?Product
{
return $this->repository->find($id);
}
// The new state goes straight into the cache, the lists are reset.
#[CachePut(key: '{result.id}')]
#[CacheEvict('product-lists', allEntries: true)]
public function save(Product $product): Product
{
return $this->repository->save($product);
}
#[CacheEvict(key: '{id}')]
#[CacheEvict('product-lists', allEntries: true)]
public function delete(int $id): void
{
$this->repository->delete($id);
}
#[Cacheable('product-lists', key: '{category}:{page}', ttl: 60)]
public function byCategory(string $category, int $page = 1): array
{
return $this->repository->byCategory($category, $page);
}
}$caches
->table('products', rows: 10_000, valueSize: 2 * 1024) // ≈51 MB
->table('product-lists', rows: 512, valueSize: 16 * 1024); // a page of up to 45 cards, ≈12 MBStock levels — change often, go stale fast
The value lives half a minute, no shared memory needed: each worker keeps its copy.
#[Cacheable('stock', key: '{sku}', ttl: 30)]
public function available(string $sku): int
// CacheConfig:
$caches->memory('stock', maxItems: 5_000);Next
- Asynchronous calls — the same proxy mechanism for
#[Async] - The cache command — inspecting and clearing
fileandrediscaches - The di command — building proxies and checking cache names
- Dependency injection — why the service is injected, not constructed
- Web layer configuration —
SERVER_WORKERSand worker restarts