[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-index":3},[4,197,340,494,582,690,794,906,1018,1136],{"name":5,"created_at":6,"published_at":7,"updated_at":8,"id":9,"uuid":10,"content":11,"slug":186,"full_slug":187,"sort_by_date":188,"position":189,"tag_list":190,"is_startpage":191,"parent_id":192,"meta_data":188,"group_id":193,"first_published_at":194,"release_id":188,"lang":195,"path":188,"alternates":196,"default_full_slug":188,"translated_slugs":188},"The robots.txt you did not write","2026-09-03T12:39:48.565Z","2026-09-03T12:59:33.165Z","2026-09-03T12:59:33.178Z",216062314778581,"d3d25fc7-3b63-43db-b05d-a1e9fe919f67",{"_uid":12,"body":13,"title":5,"excerpt":183,"component":184,"publish_at":185},"d23817ac-a382-4789-bd92-97c198c3e1a1",{"type":14,"content":15},"doc",[16,29,33,40,44,49,65,69,74,78,82,87,97,121,131,135,145,150,154,158,162,167,171,175,179],{"type":17,"content":18},"paragraph",[19,22,27],{"text":20,"type":21},"I was auditing my own site for AI search visibility. Part of that is boring: check that nothing is telling crawlers to go away. I opened ","text",{"text":23,"type":21,"marks":24},"public\u002Frobots.txt",[25],{"type":26},"code",{"text":28,"type":21}," in the repository, saw four permissive lines, and nearly moved on.",{"type":17,"content":30},[31],{"text":32,"type":21},"Then I fetched the live file instead. It was not the same file.",{"type":34,"attrs":35,"content":37},"heading",{"level":36},2,[38],{"text":39,"type":21},"What was actually being served",{"type":17,"content":41},[42],{"text":43,"type":21},"The version on the edge carried a block of rules that exist nowhere in the project:",{"type":45,"content":46},"code_block",[47],{"text":48,"type":21},"User-agent: ClaudeBot\nDisallow: \u002F\n\nUser-agent: GPTBot\nDisallow: \u002F\n\nUser-agent: Google-Extended\nDisallow: \u002F\n\nUser-agent: CCBot\nDisallow: \u002F",{"type":17,"content":50},[51,53,57,59,63],{"text":52,"type":21},"The full list ran to nine crawlers: ClaudeBot, GPTBot, Google-Extended, CCBot, Bytespider, Amazonbot, Applebot-Extended, meta-externalagent and CloudflareBrowserRenderingCrawler. There was also a ",{"text":54,"type":21,"marks":55},"Content-Signal",[56],{"type":26},{"text":58,"type":21}," header declaring ",{"text":60,"type":21,"marks":61},"search=yes,ai-train=no,use=reference",[62],{"type":26},{"text":64,"type":21},".",{"type":17,"content":66},[67],{"text":68,"type":21},"I had not written any of it. Cloudflare had, and it was on by default.",{"type":34,"attrs":70,"content":71},{"level":36},[72],{"text":73,"type":21},"Why this one is easy to miss",{"type":17,"content":75},[76],{"text":77,"type":21},"Every instinct says robots.txt is a static file. It is in the repository, it is in the deploy, and if you want to know what it says you open it. That instinct is exactly what hides this.",{"type":17,"content":79},[80],{"text":81,"type":21},"The rewrite happens at the edge, after your origin has already returned the correct file. Your build is fine. Your deploy is fine. Nothing errors, nothing warns, and no log line anywhere mentions it. The only way to see it is to ask the internet rather than your editor.",{"type":34,"attrs":83,"content":84},{"level":36},[85],{"text":86,"type":21},"The two switches",{"type":17,"content":88},[89,91,95],{"text":90,"type":21},"Both live on the same zone endpoint, ",{"text":92,"type":21,"marks":93},"GET\u002FPUT \u002Fzones\u002F{zone}\u002Fbot_management",[94],{"type":26},{"text":96,"type":21},":",{"type":98,"content":99},"bullet_list",[100,111],{"type":101,"content":102},"list_item",[103],{"type":17,"content":104},[105,109],{"text":106,"type":21,"marks":107},"ai_bots_protection: \"block\"",[108],{"type":26},{"text":110,"type":21}," turns on the AI bot blocking and the Disallow rules that advertise it.",{"type":101,"content":112},[113],{"type":17,"content":114},[115,119],{"text":116,"type":21,"marks":117},"is_robots_txt_managed: true",[118],{"type":26},{"text":120,"type":21}," lets Cloudflare rewrite robots.txt at the edge.",{"type":17,"content":122},[123,125,129],{"text":124,"type":21},"Turning off the first one is not enough, which cost me a round trip. With ",{"text":126,"type":21,"marks":127},"ai_bots_protection",[128],{"type":26},{"text":130,"type":21}," disabled the served file was unchanged. Both settings have to go off, and then the cached copy has to be purged:",{"type":45,"content":132},[133],{"text":134,"type":21},"curl -X POST \"https:\u002F\u002Fapi.cloudflare.com\u002Fclient\u002Fv4\u002Fzones\u002F$ZONE\u002Fpurge_cache\" \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  --data '{\"files\":[\"https:\u002F\u002Fexample.com\u002Frobots.txt\"]}'",{"type":17,"content":136},[137,139,143],{"text":138,"type":21},"With ",{"text":140,"type":21,"marks":141},"is_robots_txt_managed",[142],{"type":26},{"text":144,"type":21}," set to false, the file from your repository is served verbatim again. That is the state worth keeping. Robots policy belongs in git, where it can be reviewed, not in a dashboard toggle somebody flipped once.",{"type":34,"attrs":146,"content":147},{"level":36},[148],{"text":149,"type":21},"This is a real choice, not just a bug",{"type":17,"content":151},[152],{"text":153,"type":21},"Blocking AI crawlers is a legitimate position. If your content is the product, you may not want it in a training set, and Cloudflare turning it on by default is defensible.",{"type":17,"content":155},[156],{"text":157,"type":21},"The problem is that it is silently incompatible with the opposite goal. If you want to be the answer when somebody asks ChatGPT or Claude or Perplexity about your niche, you have to be readable. Those two goals are in direct conflict, and the default picks one for you without telling you.",{"type":17,"content":159},[160],{"text":161,"type":21},"That is fine when it is a decision. It is not fine when nobody in the team knows it happened.",{"type":34,"attrs":163,"content":164},{"level":36},[165],{"text":166,"type":21},"The two minute check",{"type":17,"content":168},[169],{"text":170,"type":21},"Fetch your own robots.txt and compare it to the one in your repository:",{"type":45,"content":172},[173],{"text":174,"type":21},"curl -s https:\u002F\u002Fyour-domain.com\u002Frobots.txt",{"type":17,"content":176},[177],{"text":178,"type":21},"If the live file has rules your repository does not, something in front of your origin is writing them. On Cloudflare it is almost certainly these two settings. Check it on every site where being found by AI search matters, because nothing in the codebase will ever reveal it.",{"type":17,"content":180},[181],{"text":182,"type":21},"On this site the two files match again. That took a settings change and a cache purge, and no amount of reading the repository would have told me it was needed.","Cloudflare rewrites robots.txt at the edge and blocks AI crawlers by default. None of it lives in your repository, so reading the file in git tells you nothing.","blog_post","2026-08-31 09:00","robots-txt-you-did-not-write","blog\u002Frobots-txt-you-did-not-write",null,0,[],false,215738016545577,"bee8d07e-97a9-4bc3-ac23-2a7bb249e1df","2026-09-03T12:39:48.600Z","default",[],{"name":198,"created_at":199,"published_at":200,"updated_at":201,"id":202,"uuid":203,"content":204,"slug":333,"full_slug":334,"sort_by_date":188,"position":335,"tag_list":336,"is_startpage":191,"parent_id":192,"meta_data":188,"group_id":337,"first_published_at":338,"release_id":188,"lang":195,"path":188,"alternates":339,"default_full_slug":188,"translated_slugs":188},"I got the Adobe Commerce licence number wrong","2026-09-03T12:39:48.730Z","2026-09-03T12:59:32.303Z","2026-09-03T12:59:32.318Z",216062315466711,"9da501bb-1039-43df-8c69-782370693699",{"_uid":205,"body":206,"title":198,"excerpt":331,"component":184,"publish_at":332},"5db0d669-9cd2-40e2-9181-ce8b154d6540",{"type":14,"content":207},[208,212,216,221,225,229,233,237,242,246,266,270,275,279,283,287,292,318,323,327],{"type":17,"content":209},[210],{"text":211,"type":21},"In August I wrote that Adobe Commerce licensing runs to something like ten thousand euros a month, and used it to argue that a lot of merchants should look at Open Source instead.",{"type":17,"content":213},[214],{"text":215,"type":21},"It was the most read thing I have published. It was also, on the number itself, wrong. Two people who work with these contracts far more often than I do said so publicly, in the comments, with their names on it.",{"type":34,"attrs":217,"content":218},{"level":36},[219],{"text":220,"type":21},"What they said",{"type":17,"content":222},[223],{"text":224,"type":21},"Ryan Hoerr of ParadoxLabs, one of the founders of Mage-OS, put it in the region of two to three thousand a month, scaling with revenue rather than sitting at a flat number.",{"type":17,"content":226},[227],{"text":228,"type":21},"Kostas Tsiapalis gave a different shape for the same thing: roughly twelve to twenty two thousand a year, per installation.",{"type":17,"content":230},[231],{"text":232,"type":21},"Zach Stein, an engineering manager on the merchant side, described renewing after negotiating, which is the detail that makes both of the above make sense.",{"type":17,"content":234},[235],{"text":236,"type":21},"I am paraphrasing all three from memory of the thread rather than quoting them. The correction stands either way, and it is theirs, not mine.",{"type":34,"attrs":238,"content":239},{"level":36},[240],{"text":241,"type":21},"Why the range is so wide",{"type":17,"content":243},[244],{"text":245,"type":21},"Three things make a single figure close to meaningless.",{"type":98,"content":247},[248,254,260],{"type":101,"content":249},[250],{"type":17,"content":251},[252],{"text":253,"type":21},"The licence is banded on revenue. A merchant doing two million and one doing forty million are not being quoted the same thing, so any single number is only true for one band.",{"type":101,"content":255},[256],{"type":17,"content":257},[258],{"text":259,"type":21},"It is per installation. If you run several storefronts as separate installations, you are having a different conversation than somebody running several store views on one.",{"type":101,"content":261},[262],{"type":17,"content":263},[264],{"text":265,"type":21},"It is negotiated. The number on the first quote is not the number people end up paying, particularly at renewal, and particularly if leaving is visibly on the table.",{"type":17,"content":267},[268],{"text":269,"type":21},"So when I quoted one figure as though it were the price, I was describing the top of a range as the middle of it. That is the kind of mistake that makes an argument feel stronger than it is.",{"type":34,"attrs":271,"content":272},{"level":36},[273],{"text":274,"type":21},"The argument I actually wanted to make",{"type":17,"content":276},[277],{"text":278,"type":21},"Here is the part I still stand behind, stated properly.",{"type":17,"content":280},[281],{"text":282,"type":21},"The licence is rarely the interesting cost. The interesting question is whether you are using the thing you are paying for. Adobe Commerce earns its money on B2B contract pricing, company accounts, shared catalogs, advanced staging and the rest. Merchants who lean on those are usually getting value. Merchants who bought it because it was the safe choice, and who then run a fairly ordinary B2C catalog on it, are paying a real premium for features nobody in the building has opened.",{"type":17,"content":284},[285],{"text":286,"type":21},"That is a question you can answer without knowing anyone else's licence figure. Look at what your team actually touches in the admin. If the answer is products, orders and CMS pages, the platform is not the reason your shop works and you are free to price the alternative honestly.",{"type":34,"attrs":288,"content":289},{"level":36},[290],{"text":291,"type":21},"What to do before your renewal",{"type":98,"content":293},[294,300,306,312],{"type":101,"content":295},[296],{"type":17,"content":297},[298],{"text":299,"type":21},"Get your own number. Not a benchmark, not a figure from a blog post, including this one. Your quote is the only figure that describes your situation.",{"type":101,"content":301},[302],{"type":17,"content":303},[304],{"text":305,"type":21},"List the Commerce-only features you genuinely use, and be strict. \"We might use it next year\" is not use.",{"type":101,"content":307},[308],{"type":17,"content":309},[310],{"text":311,"type":21},"Price the alternative properly, including the migration, the extensions you would replace, and the year of instability that follows any replatform. A migration that saves licence fees and costs you a peak season has not saved anything.",{"type":101,"content":313},[314],{"type":17,"content":315},[316],{"text":317,"type":21},"Then negotiate. Everyone who has been through it says the first number moves.",{"type":34,"attrs":319,"content":320},{"level":36},[321],{"text":322,"type":21},"Why I am writing this down",{"type":17,"content":324},[325],{"text":326,"type":21},"The post did well precisely because people disagreed with it in public, which is worth more than a post nobody argues with. Quietly reusing a number after two credible people have corrected it is how you lose the benefit of that.",{"type":17,"content":328},[329],{"text":330,"type":21},"So: two to three thousand a month scaling with revenue, or twelve to twenty two thousand a year per installation, from people who see more of these contracts than I do. Not ten thousand a month. If your own quote says something different again, I would genuinely like to hear it.","I put a number on Adobe Commerce licensing in public. Two people who deal with those contracts weekly told me it was too high. Here is the better answer, and why the range is so wide.","2026-08-19 09:00","adobe-commerce-licence-cost-correction","blog\u002Fadobe-commerce-licence-cost-correction",-10,[],"38a6aa2f-9751-439d-8b3e-7787b26bf5ba","2026-09-03T12:39:48.767Z",[],{"name":341,"created_at":342,"published_at":343,"updated_at":344,"id":345,"uuid":346,"content":347,"slug":488,"full_slug":489,"sort_by_date":188,"position":490,"tag_list":491,"is_startpage":191,"parent_id":192,"meta_data":188,"group_id":492,"first_published_at":343,"release_id":188,"lang":195,"path":188,"alternates":493,"default_full_slug":188,"translated_slugs":188},"npm pruned your lockfile to your laptop","2026-09-03T12:59:31.419Z","2026-09-03T12:59:31.459Z","2026-09-03T12:59:31.473Z",216067159759048,"548636cd-226f-4bcf-b35a-b1e385ae90b2",{"_uid":348,"body":349,"title":341,"excerpt":486,"component":184,"publish_at":487},"b1f845bf-4f20-44f8-a648-a59b362f3188",{"type":14,"content":350},[351,355,359,369,374,384,388,393,397,423,427,432,442,447,463,467,471],{"type":17,"content":352},[353],{"text":354,"type":21},"A green build, one dependency added, and then this on CI:",{"type":45,"content":356},[357],{"text":358,"type":21},"npm error `npm ci` can only install packages when your package.json\nnpm error and package-lock.json are in sync.\nnpm error Missing: @emnapi\u002Fcore@1.11.3 from lock file\nnpm error Missing: @emnapi\u002Fruntime@1.11.3 from lock file",{"type":17,"content":360},[361,363,367],{"text":362,"type":21},"I had never heard of ",{"text":364,"type":21,"marks":365},"@emnapi\u002Fcore",[366],{"type":26},{"text":368,"type":21},". I had not installed it. It is a transitive dependency of a WebAssembly build of a native tool, several levels down.",{"type":34,"attrs":370,"content":371},{"level":36},[372],{"text":373,"type":21},"What npm had done",{"type":17,"content":375},[376,378,382],{"text":377,"type":21},"Native packages ship one build per platform as optional dependencies. Your lockfile is supposed to list all of them, so an install on any machine can pick the right one. When ",{"text":379,"type":21,"marks":380},"npm install",[381],{"type":26},{"text":383,"type":21}," runs on a Mac it resolves the tree for a Mac, and it writes that pruned tree back to the lockfile. The Linux and Windows branches are dropped.",{"type":17,"content":385},[386],{"text":387,"type":21},"Commit it, and CI on Linux asks for a package the lockfile no longer mentions.",{"type":34,"attrs":389,"content":390},{"level":36},[391],{"text":392,"type":21},"The damage is cumulative",{"type":17,"content":394},[395],{"text":396,"type":21},"This is the part that surprised me. I counted the platform-specific entries for one package as I went:",{"type":98,"content":398},[399,405,411,417],{"type":101,"content":400},[401],{"type":17,"content":402},[403],{"text":404,"type":21},"The last green build had 18.",{"type":101,"content":406},[407],{"type":17,"content":408},[409],{"text":410,"type":21},"After a couple of ordinary installs on my Mac, 16.",{"type":101,"content":412},[413],{"type":17,"content":414},[415],{"text":416,"type":21},"After running the fix everybody recommends, still 16.",{"type":101,"content":418},[419],{"type":17,"content":420},[421],{"text":422,"type":21},"After deleting the lockfile and regenerating it from scratch, 1.",{"type":17,"content":424},[425],{"text":426,"type":21},"Deleting the lockfile is the worst available option, not the best one. It resolves cleanly for the machine you are sitting at and discards everything else.",{"type":34,"attrs":428,"content":429},{"level":36},[430],{"text":431,"type":21},"The fix that everyone documents does not work",{"type":17,"content":433},[434,436,440],{"text":435,"type":21},"The standard advice is ",{"text":437,"type":21,"marks":438},"npm install --package-lock-only",[439],{"type":26},{"text":441,"type":21},", on the grounds that it resolves from registry metadata rather than from your installed tree. I had this written down in my own notes as the answer. It did not restore anything.",{"type":34,"attrs":443,"content":444},{"level":36},[445],{"text":446,"type":21},"What actually worked",{"type":17,"content":448},[449,451,455,457,461],{"text":450,"type":21},"Generate the lockfile on the platform CI uses. Mount a temporary directory containing only ",{"text":452,"type":21,"marks":453},"package.json",[454],{"type":26},{"text":456,"type":21}," and the last known good lockfile, so your real ",{"text":458,"type":21,"marks":459},"node_modules",[460],{"type":26},{"text":462,"type":21}," is not touched:",{"type":45,"content":464},[465],{"text":466,"type":21},"T=$(mktemp -d); cp package.json \"$T\"\u002F\ngit show \u003Clast-green-commit>:package-lock.json > \"$T\"\u002Fpackage-lock.json\ndocker run --rm --platform linux\u002Famd64 -v \"$T\":\u002Fwork -w \u002Fwork node:24 \\\n  bash -lc 'npm install --package-lock-only && npm ci --ignore-scripts'\ncp \"$T\"\u002Fpackage-lock.json .",{"type":17,"content":468},[469],{"text":470,"type":21},"That restored the full set. Then verify on your own machine too, because a lockfile that fixes CI can just as easily break the laptop, and only running both proves it.",{"type":17,"content":472},[473,475,479,481,484],{"text":474,"type":21},"One warning worth the paragraph. Do not mount your project directory into that container. ",{"text":476,"type":21,"marks":477},"npm ci",[478],{"type":26},{"text":480,"type":21}," wipes and rebuilds ",{"text":458,"type":21,"marks":482},[483],{"type":26},{"text":485,"type":21},", and it will happily fill your working copy with Linux binaries. I did that, and spent the next ten minutes wondering why nothing would build locally.","CI died on a package nobody had heard of. npm had quietly removed every platform variant except the one my Mac needed, and the fix everybody documents makes it worse.","2026-08-12 09:00","npm-pruned-your-lockfile-to-your-laptop","blog\u002Fnpm-pruned-your-lockfile-to-your-laptop",-90,[],"7d903312-de86-4759-9ae9-fcb14c6c50e9",[],{"name":495,"created_at":496,"published_at":497,"updated_at":498,"id":499,"uuid":500,"content":501,"slug":576,"full_slug":577,"sort_by_date":188,"position":578,"tag_list":579,"is_startpage":191,"parent_id":192,"meta_data":188,"group_id":580,"first_published_at":497,"release_id":188,"lang":195,"path":188,"alternates":581,"default_full_slug":188,"translated_slugs":188},"The import that exits 0 and imports nothing","2026-09-03T12:59:30.836Z","2026-09-03T12:59:30.876Z","2026-09-03T12:59:30.890Z",216067157379233,"6a2d7f42-8bff-43a2-8d07-b18d3ff4d707",{"_uid":502,"body":503,"title":495,"excerpt":574,"component":184,"publish_at":575},"f6b91829-83f5-4a45-8720-ae3b6a99105b",{"type":14,"content":504},[505,509,513,518,522,526,531,535,539,544,570],{"type":17,"content":506},[507],{"text":508,"type":21},"A full product import normally takes most of a day on the install I am thinking of. Roughly 190,000 products. One run finished in eighty seconds, exited 0, wrote a clean job row, and moved both timestamps.",{"type":17,"content":510},[511],{"text":512,"type":21},"It had imported one family out of about 1,600.",{"type":34,"attrs":514,"content":515},{"level":36},[516],{"text":517,"type":21},"Failure one: it refuses, and still exits 0",{"type":17,"content":519},[520],{"text":521,"type":21},"If a previous job is still marked as processing, the import command prints a message saying it cannot run two jobs at once, and then exits with status 0. Any wrapper, cron entry or deploy script that checks the exit code records a twelve hour import as having completed successfully in under a second.",{"type":17,"content":523},[524],{"text":525,"type":21},"The lock is a status column on the job table. A crashed run leaves it set, and every subsequent import politely declines while reporting success.",{"type":34,"attrs":527,"content":528},{"level":36},[529],{"text":530,"type":21},"Failure two: a leftover filter scopes the whole run",{"type":17,"content":532},[533],{"text":534,"type":21},"The connector supports an advanced filter, which is how you import one family while testing. The filter is configuration, so it persists. Nobody clears it.",{"type":17,"content":536},[537],{"text":538,"type":21},"A later \"full\" import honours that filter. It exits 0, sets the job status to success, updates the timestamps and logs a clean run. Nothing in the exit code, the job row or the log distinguishes it from a real full import. The only tell is the duration, and only if you know what the duration is supposed to be.",{"type":34,"attrs":540,"content":541},{"level":36},[542],{"text":543,"type":21},"What to check instead",{"type":98,"content":545},[546,552,558,564],{"type":101,"content":547},[548],{"type":17,"content":549},[550],{"text":551,"type":21},"Never trust the exit code on this command. It is not lying about the command, it is telling you the command ran, which is a different question from whether the import happened.",{"type":101,"content":553},[554],{"type":17,"content":555},[556],{"text":557,"type":21},"Compare the duration against a known-good run. Eighty seconds against eighteen hours is not a performance improvement.",{"type":101,"content":559},[560],{"type":17,"content":561},[562],{"text":563,"type":21},"Clear the advanced filter after any scoped test, and check it before any run you intend to be complete.",{"type":101,"content":565},[566],{"type":17,"content":567},[568],{"text":569,"type":21},"Check the job status before starting, and know which value means processing, because that is what silently blocks the next run.",{"type":17,"content":571},[572],{"text":573,"type":21},"The theme, and it is the same theme as most things I end up writing down: the failure is quiet, the reporting is confidently wrong, and the only defence is knowing what the numbers should look like when it works.","Two different ways a product import reports complete success while doing none of the work, and neither of them shows up in the exit code or the job table.","2026-07-22 09:00","the-import-that-exits-zero-and-imports-nothing","blog\u002Fthe-import-that-exits-zero-and-imports-nothing",-80,[],"14a3ca0a-ed7b-4f68-b92c-8f1885e9173e",[],{"name":583,"created_at":584,"published_at":585,"updated_at":586,"id":587,"uuid":588,"content":589,"slug":684,"full_slug":685,"sort_by_date":188,"position":686,"tag_list":687,"is_startpage":191,"parent_id":192,"meta_data":188,"group_id":688,"first_published_at":585,"release_id":188,"lang":195,"path":188,"alternates":689,"default_full_slug":188,"translated_slugs":188},"The SOAP client with no timeout","2026-09-03T12:59:30.269Z","2026-09-03T12:59:30.303Z","2026-09-03T12:59:30.314Z",216067155044512,"9cad4585-849e-4eba-974c-34c5e7022ce0",{"_uid":590,"body":591,"title":583,"excerpt":682,"component":184,"publish_at":683},"c2b95134-bc8b-452d-9bff-e2d8d6687d64",{"type":14,"content":592},[593,597,601,606,628,638,643,647,651,655,660,664,678],{"type":17,"content":594},[595],{"text":596,"type":21},"The reports were the vague kind. The cart is slow. Sometimes it times out. Sometimes a 503, sometimes a 524. Nothing in the logs.",{"type":17,"content":598},[599],{"text":600,"type":21},"That last part was the useful bit, though it took me a while to treat it as evidence rather than as an absence of evidence.",{"type":34,"attrs":602,"content":603},{"level":36},[604],{"text":605,"type":21},"Nothing capped the request",{"type":17,"content":607},[608,610,614,616,620,622,626],{"text":609,"type":21},"The integration client called the ERP through Guzzle with no request options at all, and nothing in the dependency injection configured the HTTP client either. Guzzle defaults ",{"text":611,"type":21,"marks":612},"timeout",[613],{"type":26},{"text":615,"type":21}," and ",{"text":617,"type":21,"marks":618},"connect_timeout",[619],{"type":26},{"text":621,"type":21}," to ",{"text":623,"type":21,"marks":624},"0",[625],{"type":26},{"text":627,"type":21},", which means wait forever.",{"type":17,"content":629},[630,632,636],{"text":631,"type":21},"Nothing further up cared either. The PHP-FPM pool had ",{"text":633,"type":21,"marks":634},"max_execution_time = 0",[635],{"type":26},{"text":637,"type":21}," and a terminate timeout of four hours. So a single stalled call from the ERP occupied a worker for as long as the ERP felt like being quiet.",{"type":34,"attrs":639,"content":640},{"level":36},[641],{"text":642,"type":21},"Why the logs were empty",{"type":17,"content":644},[645],{"text":646,"type":21},"This is the part I want to underline, because it inverts a normal instinct.",{"type":17,"content":648},[649],{"text":650,"type":21},"Guzzle never timed out. Because it never timed out, it never threw. Because it never threw, nothing was ever logged. There was not a single cURL or Guzzle error anywhere, and the ERP looked healthy in every dashboard because it was not returning errors, it was returning nothing at all, slowly.",{"type":17,"content":652},[653],{"text":654,"type":21},"An empty error log is not proof that the dependency was fine. Sometimes it is the symptom.",{"type":34,"attrs":656,"content":657},{"level":36},[658],{"text":659,"type":21},"The signature that identifies it",{"type":17,"content":661},[662],{"text":663,"type":21},"Two things made this diagnosable without access to the ERP.",{"type":98,"content":665},[666,672],{"type":101,"content":667},[668],{"type":17,"content":669},[670],{"text":671,"type":21},"Only the queries that touched the pricing backend hung. Everything else in the same page load stayed under about 400 milliseconds. A sick server slows everything down; a stalled dependency slows exactly the calls that use it. That split is the diagnosis.",{"type":101,"content":673},[674],{"type":17,"content":675},[676],{"text":677,"type":21},"The 503 at around sixty seconds and the 524 at a hundred were not two faults. They were two different proxies giving up on the same hung worker at their own configured limits.",{"type":17,"content":679},[680],{"text":681,"type":21},"Once you see it that way the fix is unglamorous. Set an explicit timeout on the client, decide what the page should do when the dependency is slow, and make sure that decision produces a log line. A dependency that can hang forever will eventually hang forever, usually during a sale.","Slow carts, 503s, 524s, and not one error in any log. The integration client was configured to wait forever, and nothing else in the stack was willing to stop it.","2026-07-08 09:00","the-soap-client-with-no-timeout","blog\u002Fthe-soap-client-with-no-timeout",-70,[],"62d4b50d-85d0-45ef-adb8-4f7b35871603",[],{"name":691,"created_at":692,"published_at":693,"updated_at":694,"id":695,"uuid":696,"content":697,"slug":788,"full_slug":789,"sort_by_date":188,"position":790,"tag_list":791,"is_startpage":191,"parent_id":192,"meta_data":188,"group_id":792,"first_published_at":693,"release_id":188,"lang":195,"path":188,"alternates":793,"default_full_slug":188,"translated_slugs":188},"You cannot disable a product on one website","2026-09-03T12:59:29.668Z","2026-09-03T12:59:29.725Z","2026-09-03T12:59:29.739Z",216067152586910,"a8686b64-4407-4c98-aa8b-1dd4f9e8363c",{"_uid":698,"body":699,"title":691,"excerpt":786,"component":184,"publish_at":787},"7bb426bc-90b8-48f0-9e4d-ad2b349040ab",{"type":14,"content":700},[701,705,709,714,740,744,749,753,757,762,766,770],{"type":17,"content":702},[703],{"text":704,"type":21},"The request was ordinary: this product should be visible on one website and not the other. Same catalog, several storefronts, disable it on one of them.",{"type":17,"content":706},[707],{"text":708,"type":21},"It could not be stored. Not \"it was overwritten later\" or \"the import undid it\". There was nowhere to put the value.",{"type":34,"attrs":710,"content":711},{"level":36},[712],{"text":713,"type":21},"status had been made global",{"type":17,"content":715},[716,718,722,724,727,728,732,734,738],{"text":717,"type":21},"Stock Magento ships ",{"text":719,"type":21,"marks":720},"status",[721],{"type":26},{"text":723,"type":21}," as website-scoped, which is what makes the usual answer work. On this install both ",{"text":719,"type":21,"marks":725},[726],{"type":26},{"text":615,"type":21},{"text":729,"type":21,"marks":730},"visibility",[731],{"type":26},{"text":733,"type":21}," had ",{"text":735,"type":21,"marks":736},"is_global = 1",[737],{"type":26},{"text":739,"type":21},". Nothing in the project code did that, so it was changed at some point years ago and nobody recorded why.",{"type":17,"content":741},[742],{"text":743,"type":21},"A global attribute stores one value on store 0. Setting a different value for store 3 has nowhere to live, so the admin appears to accept the change and the value is simply not persisted anywhere it can be read back.",{"type":34,"attrs":745,"content":746},{"level":36},[747],{"text":748,"type":21},"The import enforces it too",{"type":17,"content":750},[751],{"text":752,"type":21},"Reading the connector made it worse in an interesting way. Because status is global, the import takes a branch that reads only the unscoped column. And it calls a cleanup routine that deletes every status row with a non-zero store id for imported products. So even if you did get a store-scoped value in there, the next import would remove it deliberately.",{"type":17,"content":754},[755],{"text":756,"type":21},"On the install I checked, every one of the roughly 190,000 status rows sat on store 0 and there were zero store-scoped rows. Not \"few\". Zero. Which is at least consistent.",{"type":34,"attrs":758,"content":759},{"level":36},[760],{"text":761,"type":21},"What this changes about the estimate",{"type":17,"content":763},[764],{"text":765,"type":21},"This is the part worth generalising. The request sounded like a content task. Somebody toggles a switch on a product. It was actually a schema change: convert the attribute to website scope, verify the import does not fight it, and confirm nothing downstream assumes a single global value.",{"type":17,"content":767},[768],{"text":769,"type":21},"The saving grace was that there was nothing to migrate. With only store 0 rows in existence, changing the scope does not have to reconcile conflicting values, because none exist.",{"type":17,"content":771},[772,774,778,780,784],{"text":773,"type":21},"Before promising a per-website behaviour on any attribute, check ",{"text":775,"type":21,"marks":776},"is_global",[777],{"type":26},{"text":779,"type":21}," in ",{"text":781,"type":21,"marks":782},"catalog_eav_attribute",[783],{"type":26},{"text":785,"type":21},". It takes one query, and it is the difference between a config change and a data migration.","A request to hide a product on one of several websites turned out to be unstorable. status and visibility had been changed to global scope, and the import deletes store-scoped values anyway.","2026-06-24 09:00","you-cannot-disable-a-product-on-one-website","blog\u002Fyou-cannot-disable-a-product-on-one-website",-60,[],"69f515d7-4020-4458-9dc0-512ba3d9864a",[],{"name":795,"created_at":796,"published_at":797,"updated_at":798,"id":799,"uuid":800,"content":801,"slug":900,"full_slug":901,"sort_by_date":188,"position":902,"tag_list":903,"is_startpage":191,"parent_id":192,"meta_data":188,"group_id":904,"first_published_at":797,"release_id":188,"lang":195,"path":188,"alternates":905,"default_full_slug":188,"translated_slugs":188},"Every attribute label says Unknown","2026-09-03T12:59:29.084Z","2026-09-03T12:59:29.120Z","2026-09-03T12:59:29.133Z",216067150190749,"84009083-671f-4c81-8f5c-053a899ede86",{"_uid":802,"body":803,"title":795,"excerpt":898,"component":184,"publish_at":899},"3edc2ca1-eadc-46b2-b76b-e9c09f5d5cd3",{"type":14,"content":804},[805,821,826,836,840,845,867,872,876,885,889,894],{"type":17,"content":806},[807,809,813,815,819],{"text":808,"type":21},"The admin attribute grid was full of a product named ",{"text":810,"type":21,"marks":811},"Unknown",[812],{"type":26},{"text":814,"type":21},". Not blank, not the attribute code. The literal string. On production, 1,943 rows had ",{"text":816,"type":21,"marks":817},"frontend_label = 'Unknown'",[818],{"type":26},{"text":820,"type":21},", and some of them were being rendered to the storefront.",{"type":34,"attrs":822,"content":823},{"level":36},[824],{"text":825,"type":21},"Where the real labels were",{"type":17,"content":827},[828,830,834],{"text":829,"type":21},"They were never missing. They were in ",{"text":831,"type":21,"marks":832},"eav_attribute_label",[833],{"type":26},{"text":835,"type":21},", the store-scoped label table, correctly filled in for every store view. Only the default admin label was wrong.",{"type":17,"content":837},[838],{"text":839,"type":21},"The cause is a locale mismatch in the PIM connector. When it imports an attribute it looks for a label in the *admin* locale, and writes a translated \"Unknown\" when it does not find one. The admin locale at default scope was English. The PIM only carried Dutch labels. So every single attribute got the fallback, every single time.",{"type":34,"attrs":841,"content":842},{"level":36},[843],{"text":844,"type":21},"The fix is one method name",{"type":17,"content":846},[847,849,853,855,859,861,865],{"text":848,"type":21},"Anything rendering an attribute label to a customer should call ",{"text":850,"type":21,"marks":851},"getStoreLabel()",[852],{"type":26},{"text":854,"type":21},", not ",{"text":856,"type":21,"marks":857},"getFrontendLabel()",[858],{"type":26},{"text":860,"type":21},". It resolves the store-scoped label and falls back to ",{"text":862,"type":21,"marks":863},"frontend_label",[864],{"type":26},{"text":866,"type":21}," on its own, so it is strictly better in every case. One resolver in the codebase did this correctly and had never had a complaint. Another did not, and that was the bug report.",{"type":34,"attrs":868,"content":869},{"level":36},[870],{"text":871,"type":21},"The tempting fix that does not last",{"type":17,"content":873},[874],{"text":875,"type":21},"The obvious move is an UPDATE. Copy the Dutch labels over the \"Unknown\" values and the admin grid is readable again by lunchtime.",{"type":17,"content":877},[878,880,883],{"text":879,"type":21},"It will survive until the next attribute import, which rewrites ",{"text":862,"type":21,"marks":881},[882],{"type":26},{"text":884,"type":21}," from the PIM and puts every \"Unknown\" straight back. Anybody looking at it a month later sees a bug that was supposedly fixed and is somehow back, with a commit history that says nothing useful.",{"type":17,"content":886},[887],{"text":888,"type":21},"The durable options are a code fix, which is small, or filling in the English labels in the PIM, which is tedious but removes the cause. A database fix is neither.",{"type":34,"attrs":890,"content":891},{"level":36},[892],{"text":893,"type":21},"The general version",{"type":17,"content":895},[896],{"text":897,"type":21},"When a connector owns a column, you do not own that column. Any repair you apply to it is a countdown, not a fix. The question to ask before writing the UPDATE is simply: what writes this value, and when does it next run?","Nearly two thousand attributes had the literal admin label \"Unknown\". The real labels existed the whole time, one table over, and fixing it in the database would have lasted until the next import.","2026-06-10 09:00","every-attribute-label-says-unknown","blog\u002Fevery-attribute-label-says-unknown",-50,[],"a2506578-7554-4caa-a7a3-f1dea98f2af1",[],{"name":907,"created_at":908,"published_at":909,"updated_at":910,"id":911,"uuid":912,"content":913,"slug":1012,"full_slug":1013,"sort_by_date":188,"position":1014,"tag_list":1015,"is_startpage":191,"parent_id":192,"meta_data":188,"group_id":1016,"first_published_at":909,"release_id":188,"lang":195,"path":188,"alternates":1017,"default_full_slug":188,"translated_slugs":188},"Your foreign keys are not protecting your EAV data","2026-09-03T12:59:28.488Z","2026-09-03T12:59:28.537Z","2026-09-03T12:59:28.551Z",216067147757723,"fc72325e-7747-4998-aa5d-e07a8e39d852",{"_uid":914,"body":915,"title":907,"excerpt":1010,"component":184,"publish_at":1011},"e5e6e0e9-675c-4b01-952a-42d68273fc79",{"type":14,"content":916},[917,921,925,929,934,961,965,970,974,979,983,987,991,996,1006],{"type":17,"content":918},[919],{"text":920,"type":21},"A data patch removed nine customer attributes. The patch's own DocBlock said the setup helper \"cascades the stored values\", which sounded right, because every EAV value table does declare the constraint:",{"type":45,"content":922},[923],{"text":924,"type":21},"FOREIGN KEY (attribute_id) REFERENCES eav_attribute (attribute_id)\n  ON DELETE CASCADE",{"type":17,"content":926},[927],{"text":928,"type":21},"After it ran in production there were 922,986 orphaned rows across three tables.",{"type":34,"attrs":930,"content":931},{"level":36},[932],{"text":933,"type":21},"Why the constraint did nothing",{"type":17,"content":935},[936,938,942,944,947,949,953,955,959],{"text":937,"type":21},"The patch runs inside ",{"text":939,"type":21,"marks":940},"setup:upgrade",[941],{"type":26},{"text":943,"type":21},", and ",{"text":939,"type":21,"marks":945},[946],{"type":26},{"text":948,"type":21}," disables ",{"text":950,"type":21,"marks":951},"FOREIGN_KEY_CHECKS",[952],{"type":26},{"text":954,"type":21}," for the duration. With checks off, the cascade is not enforced. The parent rows in ",{"text":956,"type":21,"marks":957},"eav_attribute",[958],{"type":26},{"text":960,"type":21}," were deleted, the value rows were not, and MySQL raised nothing because it had been told not to look.",{"type":17,"content":962},[963],{"text":964,"type":21},"The constraints were present and unchanged the entire time. They were simply not in force at the one moment they mattered.",{"type":34,"attrs":966,"content":967},{"level":36},[968],{"text":969,"type":21},"The shape of the damage",{"type":17,"content":971},[972],{"text":973,"type":21},"It was not spread evenly. The entity-id side was clean, product value tables were clean, address value tables were clean. Only the attribute side of the customer value tables was affected, and in one of them well over half the rows were orphans. That lopsidedness is a clue in itself: this is specifically what attribute removal leaves behind, not general database rot.",{"type":34,"attrs":975,"content":976},{"level":36},[977],{"text":978,"type":21},"Count them, do not reason about them",{"type":17,"content":980},[981],{"text":982,"type":21},"The lesson I would want someone to take from this is narrow. Do not argue from the presence of a constraint. Go and count:",{"type":45,"content":984},[985],{"text":986,"type":21},"SELECT COUNT(*) FROM customer_entity_int v\nWHERE NOT EXISTS (\n  SELECT 1 FROM eav_attribute a\n  WHERE a.attribute_id = v.attribute_id\n);",{"type":17,"content":988},[989],{"text":990,"type":21},"Run it per value table. It takes a minute and it either returns zero or it does not.",{"type":34,"attrs":992,"content":993},{"level":36},[994],{"text":995,"type":21},"And the ticket is not done when the patch runs",{"type":17,"content":997},[998,1000,1004],{"text":999,"type":21},"An attribute removal needs the value cleanup written into the release instructions as well, plus an ",{"text":1001,"type":21,"marks":1002},"OPTIMIZE TABLE",[1003],{"type":26},{"text":1005,"type":21}," afterwards. Deleting the rows logically does not give the space back: InnoDB reported only a few megabytes of free space per table even after nearly a million rows were gone.",{"type":17,"content":1007},[1008],{"text":1009,"type":21},"None of this is exotic. It is just that \"there is a foreign key\" feels like an answer, and here it was not one.","The constraint says ON DELETE CASCADE. Removing nine attributes still left nearly a million orphaned rows, because setup:upgrade turns foreign key checks off.","2026-05-27 09:00","your-foreign-keys-are-not-protecting-your-eav-data","blog\u002Fyour-foreign-keys-are-not-protecting-your-eav-data",-40,[],"5979825c-a234-4724-a788-921f11ccdcda",[],{"name":1019,"created_at":1020,"published_at":1021,"updated_at":1022,"id":1023,"uuid":1024,"content":1025,"slug":1130,"full_slug":1131,"sort_by_date":188,"position":1132,"tag_list":1133,"is_startpage":191,"parent_id":192,"meta_data":188,"group_id":1134,"first_published_at":1021,"release_id":188,"lang":195,"path":188,"alternates":1135,"default_full_slug":188,"translated_slugs":188},"Two saves, one cart, and one of them is lost","2026-09-03T12:59:10.653Z","2026-09-03T12:59:10.694Z","2026-09-03T12:59:10.709Z",216067074713746,"320d9737-1972-435c-8d06-b5d4c2139490",{"_uid":1026,"body":1027,"title":1019,"excerpt":1128,"component":184,"publish_at":1129},"88ebe47b-8b1e-4059-96fe-02aa7d53a8f1",{"type":14,"content":1028},[1029,1033,1043,1048,1052,1066,1092,1096,1101,1111,1115,1120,1124],{"type":17,"content":1030},[1031],{"text":1032,"type":21},"A field on the cart kept ending up empty. Not always. Maybe one time in three, and never when anyone was watching closely enough to catch it.",{"type":17,"content":1034},[1035,1037,1041],{"text":1036,"type":21},"The front end fired two mutations at once, in a single ",{"text":1038,"type":21,"marks":1039},"Promise.all",[1040],{"type":26},{"text":1042,"type":21},". One set an identifier on the quote. The other saved some custom field data on the same quote. Different fields, different mutations, no obvious reason for them to interfere.",{"type":34,"attrs":1044,"content":1045},{"level":36},[1046],{"text":1047,"type":21},"The cause",{"type":17,"content":1049},[1050],{"text":1051,"type":21},"Magento does not write the column you changed. It writes all of them.",{"type":17,"content":1053},[1054,1058,1060,1064],{"text":1055,"type":21,"marks":1056},"cartRepository->save($quote)",[1057],{"type":26},{"text":1059,"type":21}," builds its UPDATE from the whole in-memory object, because ",{"text":1061,"type":21,"marks":1062},"AbstractDb::prepareDataForUpdate",[1063],{"type":26},{"text":1065,"type":21}," serialises every field it is holding rather than tracking which ones you touched. So the sequence is the classic lost update:",{"type":98,"content":1067},[1068,1074,1080,1086],{"type":101,"content":1069},[1070],{"type":17,"content":1071},[1072],{"text":1073,"type":21},"Request A loads the quote. Field X is null, field Y is null.",{"type":101,"content":1075},[1076],{"type":17,"content":1077},[1078],{"text":1079,"type":21},"Request B loads the quote. Same state.",{"type":101,"content":1081},[1082],{"type":17,"content":1083},[1084],{"text":1085,"type":21},"Request A sets X and saves. The row now has X, and Y is still null.",{"type":101,"content":1087},[1088],{"type":17,"content":1089},[1090],{"text":1091,"type":21},"Request B sets Y and saves, writing the whole row it loaded, in which X was null. X is gone.",{"type":17,"content":1093},[1094],{"text":1095,"type":21},"Whichever commits last wins, and it wins with data it read before the other one committed. Nothing errors. Both mutations return success, because from each request's point of view the save worked perfectly.",{"type":34,"attrs":1097,"content":1098},{"level":36},[1099],{"text":1100,"type":21},"The part that made it hard to reproduce",{"type":17,"content":1102},[1103,1105,1109],{"text":1104,"type":21},"I tried to write a test for it in a single process and could not make it fail. The repository caches loaded quotes by id, so calling ",{"text":1106,"type":21,"marks":1107},"get()",[1108],{"type":26},{"text":1110,"type":21}," twice hands you the same object, and both \"requests\" then mutate one shared instance. The bug vanishes precisely because the test is not doing what production does.",{"type":17,"content":1112},[1113],{"text":1114,"type":21},"You have to force two separate repository instances to see it. That is worth knowing generally: an object cache in a repository will hide every concurrency bug you try to reproduce through that repository.",{"type":34,"attrs":1116,"content":1117},{"level":36},[1118],{"text":1119,"type":21},"What to do about it",{"type":17,"content":1121},[1122],{"text":1123,"type":21},"The reflex is a lock or a retry. Usually there is something simpler available. In this case the two writes did not need to be concurrent at all. One of them could happen earlier, on the same row, before the other request existed, which made it atomic by construction rather than by coordination.",{"type":17,"content":1125},[1126],{"text":1127,"type":21},"When that is not available, the options are the ordinary ones: serialise the calls, write both fields in one mutation, or take a row lock. What you cannot do is assume that writing different fields of the same row is safe. In Magento it is not.","Two API calls fired in parallel, each writing a different field on the same cart. One field kept coming back empty, and the cause is in how Magento builds its UPDATE.","2026-05-06 09:00","two-saves-one-cart-and-one-of-them-is-lost","blog\u002Ftwo-saves-one-cart-and-one-of-them-is-lost",-30,[],"8241179b-48ad-4999-8710-545e7a9ddea8",[],{"name":1137,"created_at":1138,"published_at":1139,"updated_at":1140,"id":1141,"uuid":1142,"content":1143,"slug":1234,"full_slug":1235,"sort_by_date":188,"position":1236,"tag_list":1237,"is_startpage":191,"parent_id":192,"meta_data":188,"group_id":1238,"first_published_at":1139,"release_id":188,"lang":195,"path":188,"alternates":1239,"default_full_slug":188,"translated_slugs":188},"The cache lifetime your config.xml is lying about","2026-09-03T12:59:10.469Z","2026-09-03T12:59:10.508Z","2026-09-03T12:59:10.521Z",216067073951889,"573dce0a-ddad-42ee-a263-8e2461885bba",{"_uid":1144,"body":1145,"title":1137,"excerpt":1232,"component":184,"publish_at":1233},"7adec49a-0e8f-44ef-b468-384290c82647",{"type":14,"content":1146},[1147,1157,1161,1166,1181,1185,1189,1194,1203,1208,1228],{"type":17,"content":1148},[1149,1151,1155],{"text":1150,"type":21},"Somebody asked a reasonable question about a shop I work on: how fresh are the prices on the product page? The module had a cache, the cache had a lifetime, and the lifetime was in the module's own ",{"text":1152,"type":21,"marks":1153},"config.xml",[1154],{"type":26},{"text":1156,"type":21}," as 600 seconds. Ten minutes. That was the answer I nearly gave.",{"type":17,"content":1158},[1159],{"text":1160,"type":21},"The real answer was up to twenty four hours.",{"type":34,"attrs":1162,"content":1163},{"level":36},[1164],{"text":1165,"type":21},"Two things were wrong",{"type":17,"content":1167},[1168,1170,1173,1175,1179],{"text":1169,"type":21},"The first is ordinary and everyone knows it in the abstract. A value in ",{"text":1152,"type":21,"marks":1171},[1172],{"type":26},{"text":1174,"type":21}," is a default, and defaults lose to whatever is in the database. Somebody had set the lifetime to 86400 in ",{"text":1176,"type":21,"marks":1177},"core_config_data",[1178],{"type":26},{"text":1180,"type":21}," long enough ago that nobody remembered. The XML said one thing, production did another, and reading the module told you nothing useful.",{"type":45,"content":1182},[1183],{"text":1184,"type":21},"SELECT path, value FROM core_config_data\nWHERE path LIKE 'yourmodule\u002Fcache%';",{"type":17,"content":1186},[1187],{"text":1188,"type":21},"The second is worse. I went looking for the code that clears this cache when a price import runs. There is none. The cache type was referenced in exactly two places, both inside the caching classes themselves, and the import never touched either. So a price change from the ERP did not reach the storefront when the import finished. It reached the storefront when the day rolled over, or when somebody flushed that cache type by hand.",{"type":34,"attrs":1190,"content":1191},{"level":36},[1192],{"text":1193,"type":21},"Why nobody noticed",{"type":17,"content":1195},[1196,1198,1201],{"text":1197,"type":21},"Because it mostly looks fine. Prices do update, just later than anyone thinks. On a shop where prices move once a week, a twenty four hour lag is invisible. On a shop where somebody is on the phone asking why the site still shows the old price, it is the whole problem, and the person debugging it will read the same ",{"text":1152,"type":21,"marks":1199},[1200],{"type":26},{"text":1202,"type":21}," I did and conclude the cache cannot possibly be the cause.",{"type":34,"attrs":1204,"content":1205},{"level":36},[1206],{"text":1207,"type":21},"What to actually check",{"type":98,"content":1209},[1210,1216,1222],{"type":101,"content":1211},[1212],{"type":17,"content":1213},[1214],{"text":1215,"type":21},"Read the value from the database, never from the XML. The XML is the default, not the setting.",{"type":101,"content":1217},[1218],{"type":17,"content":1219},[1220],{"text":1221,"type":21},"Grep for the cache type identifier across the whole codebase, not just your module. If the only hits are the class that writes the cache and the class that reads it, nothing invalidates it.",{"type":101,"content":1223},[1224],{"type":17,"content":1225},[1226],{"text":1227,"type":21},"Then ask what is supposed to invalidate it, and go and find that code. If you cannot find it, it does not exist, and your cache lifetime is your data freshness guarantee.",{"type":17,"content":1229},[1230],{"text":1231,"type":21},"The honest answer to \"how fresh are our prices\" turned out to be \"up to a day, and we cannot tell you when it last changed\". That is a fine answer. It just was not the one anybody expected, and it changed the design decision it was blocking.","A module documented a ten minute price cache. It was really running at twenty four hours, and nothing invalidated it when prices changed.","2026-04-15 09:00","the-cache-lifetime-your-config-xml-is-lying-about","blog\u002Fthe-cache-lifetime-your-config-xml-is-lying-about",-20,[],"16dc5803-0067-4758-b0f7-8cc1de84073d",[]]