Cursor pagination is part of your API contract

Someone inserts a row while the client walks the pages, and the whole window shifts. Page two shows an item the client already saw on page one. Or an item falls between pages and the client never sees it. For a feed this is annoying. For an export or a sync endpoint this is a data loss bug that nobody can reproduce. I used to think keyset pagination is a performance trick. Deep OFFSET is slow, keyset is fast, end of story. Now I think the performance part is the boring half. Offset pagination over a changing dataset lies to the client. That is the interesting half. ...

January 8, 2020 · 2 min · Murat Useinov

Static state outlives the job

One static array, from one of our projects: class Settings { private static $cache = []; public static function get($tenantId, $key) { if (!isset(self::$cache[$tenantId])) { self::$cache[$tenantId] = self::load($tenantId); } return self::$cache[$tenantId][$key] ?? null; } } A reasonable per-request cache. Under PHP-FPM it died with the process. In a queue worker it lives forever. PHP had one great architectural feature nobody put in the manual: the process died after every request. Leak memory, cache nonsense in a static, forget to close things. Did not matter. The dying process forgave everything. ...

December 22, 2019 · 2 min · Murat Useinov

PHP 7.4: typed properties first, preloading later

Two lines of a class, and half of my docblocks can be deleted: class Money { private int $amount; private string $currency; } PHP 7.4 came out yesterday, and this is the part I start using on Monday. Those docblocks existed only to say @var int. Now the language says it, and unlike the docblock, it checks. Assign a string, get a TypeError at the assignment, not a strange bug three layers later. There is one new state to learn. A typed property without a default is uninitialized, and reading it before the first write throws. This is the feature. “Object exists but is not filled yet” finally fails loudly instead of pretending to be null. ...

November 29, 2019 · 2 min · Murat Useinov

PostgreSQL 12 inlines your CTE

grep -rn "WITH " src/ was the first thing I ran after upgrading to PostgreSQL 12 this month. I was looking for a trick that had stopped working. For years WITH was an optimization fence. The planner materialized the CTE first and only then ran the outer query. Everyone used this both ways. As a bug: you wrap a subquery into a CTE for readability, the planner stops pushing conditions inside, a fast query becomes a scan of half a table. As a feature: you write a CTE on purpose, to pin evaluation order and stop the planner from being creative. Half of the CTE advice on the internet is really advice about the fence. ...

October 18, 2019 · 2 min · Murat Useinov

Cleaning up before Symfony 5

Symfony 5.0 is 4.4 minus everything deprecated. That one sentence is the whole upgrade plan. Both land in November, and if the application runs on 4.4 with zero deprecation warnings, the major is a version bump. Two months is enough to get there in small chunks, with no freeze and no heroic branch that lives for six weeks. The order that works for me. First, get to 4.3 and make the deprecation report visible. The PHPUnit bridge prints the summary after the test run. In dev, the profiler collects the same warnings per request. You cannot clean what you do not see. The first report will be depressing. Fine. It is a todo list, not a verdict. ...

September 25, 2019 · 2 min · Murat Useinov

200 OK is not health

Route /health, returns ok, everyone happy. That is the first version of every health endpoint on the project we are moving to Kubernetes. Then somebody pastes the same path into livenessProbe and readinessProbe, and a thirty second database hiccup becomes a long evening. The two probes ask different questions. Readiness asks: should this pod get traffic right now. Here it is correct to check dependencies. Database unreachable, cache cold, migrations still running: answer no. Kubernetes takes the pod out of the Service, traffic goes to the others, the pod returns when the world improves. Failing readiness is cheap and reversible. A polite “not now”. ...

August 7, 2019 · 2 min · Murat Useinov

N+1 hides on your laptop

Ten rows in the dev database, on the same machine, 0.1 ms per query. Ten extra queries is one millisecond. The page feels instant, the code ships. Production has a thousand rows and the database one network hop away. Round trip is about a millisecond even in a good datacenter. A thousand queries is a second of pure network waiting. Not slow SQL. Each query is fast. The plural is slow. ...

July 5, 2019 · 2 min · Murat Useinov

One use case, one class

Four hundred lines in a checkout action, half of them inside one try. That is one way a project dies. The other is OrderManager: thirty methods, six injected services, and nobody can say what the class is for, because it is for everything about orders. Both diseases have the same cure and it is embarrassingly simple. One use case, one class, one public method. final class PlaceOrder { public function __construct( OrderRepository $orders, PaymentGateway $payments, EventDispatcher $events ) { ... } public function handle(PlaceOrderCommand $command): OrderId { // the whole story, top to bottom } } The shape answers three questions by itself. What comes in: one command object, a thing you can log, validate, put in a queue. What it needs: the constructor lists dependencies of this use case, not of orders in general, so when PlaceOrder suddenly needs the mailer you see it and can ask why. What comes out: one result. Reading handle() from top to bottom tells the whole story of placing an order. No chapter hidden in a base class or a trait. ...

June 26, 2019 · 2 min · Murat Useinov

Messenger retries and the failure transport

A log full of the same stack trace, every second, all night. That is what a queue looks like when a handler throws on a malformed payload and the retry policy is “forever”. Symfony 4.3 is out this week, and Messenger in it finally has an answer for that night. Retry is good for transient errors. Network blinked, deadlock, remote API returned 503. Wait, try again, it passes. But some errors are permanent. Malformed payload, entity deleted, a bug in the handler. Retrying those forever means a worker grinding the same poison message until morning. Infinite retry is not persistence, it is denial. ...

May 30, 2019 · 2 min · Murat Useinov

What Redis does when memory ends

Users logged out at random. Not all, not always. Only sometimes, only in the afternoon. Afternoon is when traffic peaks. Traffic peaks fill the cache. The cache lived in the same Redis as the sessions, the instance hit maxmemory, and the eviction policy was allkeys-lru. Redis did exactly what we asked: threw away the least recently used keys, and some of them were sessions of people who went for lunch. ...

April 2, 2019 · 2 min · Murat Useinov