Posts in Backend (20 found)
Chris Coyier 2 days ago

Archived Tweets

I finally got around to importing my whole historical Twitter archive (just my own original tweets, not replies or retweets) into this WordPress site as a proper Custom Post Type: https://chriscoyier.net/tweets I haven’t added it to the navigation or anything else on the site yet. Not sure what I want to do there. Just feels good having my own data. The Tweets show up in the Jetpack-powered search too, which I’ve found nice. I used this and hacked it up a bit to fit my needs, and so I didn’t have to leave it as a permanently installed plugin. It’s coming at a weird time, with quite a few people “going back” to Twitter. It’s actually called X now; I’m aware. It’s named after the hand gesture you do when Elon Musk asks if you’d like to come over for a dinner party: 🙅‍♂️ Casey Newton just saying, “Look, I tried, but X is where the action is,” and John Gruber being like, “Take a whiff; the Elon stench is gone.” I understand both of those things. I honestly really like both Mastodon and Bluesky, but after years and years, the fun and engagement have never reached old Twitter norms. And I miss that! But it also feels like you have to ignore a lot of bad behavior to use a platform that Elon Musk not only owns but literally bought to be his megaphone and power center. Do people not remember the horrible shit he would do on a near-daily basis? Tasteless, dangerous, and often both. I can remind you! Here’s just one straw from a haystack. Elon loves him some Trump. They are big buddies! Elon shouldered up to Trump for the power, and Trump gave it to him. Elon decided to take this power (granted to him by a scepter touch? apparently?) and created a real government organization called DOGE, which is a joke name, named after a joke cryptocurrency that is inherently corrupt. DOGE’s job was to make the government “more efficient” by eliminating funding for tons of stuff. One of the things they did was entirely get rid of the USDS, which was the organization that did incredible work, ya know, making governmental websites far more efficient. Now, our government makes racist video games on the web instead. Not kidding. That’s a fucking game on our own government’s website, where you play a white dude who walks around a page collecting brown people to deport. I find it more than a little hard to swallow.

0 views
Nick Khami 1 weeks ago

The STT-LLM-TTS voice stack is dead

I went to get a skin analysis done thru @layersbio and it came back with a bunch of areas for improvement that i wanted to action on which required getting some prescriptions. I started booking appointments to get those and was immediately frustrated that my agents couldn't call to do it for me. So, naturally, I tweeted asking for a product to solve this. To my immense disappointment, there wasn't anyone who had built this yet. I got a few replies from devtool style companies that had made voice AI APIs, but I really just wanted a managed solution and couldn't find one. I figured that there were enough other people similar to me who use a lot of agents but didn't like making phone calls that I decided it was actually worth building the tool myself and launched call4me . Based on the revenue, I feel like that instinct was correct! You add one MCP URL to your agent. Then you say something like "call my dentist and move my cleaning to next week, any morning works" and the agent does the rest. The important design decision is that the caller can only say what it was given . A phone call is a terrible place to discover you don't know the patient's date of birth. So before dialing, the agent has to collect everything the call will need: If something is missing, refuses with "Not calling yet" and lists exactly what to ask the user. The agent asks, retries, and only then does the phone ring. During the call the agent polls for progress. If the business asks something the caller doesn't know, the question shows up for the agent, the agent asks me, and my answer gets spoken on the call. If the business insists on talking to me, my phone rings, I press 1 to join, and I press star to hand the call back. After hangup, a recap written from the transcript lands in the agent's context. Everything runs on Cloudflare. The site, MCP server, API and webhooks are one Worker. Calls run in a separate Worker, where every live call is its own Durable Object. D1 holds accounts, calls and an append-only credits ledger. Stripe handles payments. The part I'm happiest with is how little the audio path does. Telnyx Call Control places the call and opens a two-way media stream over a WebSocket. GPT-Live accepts audio in G.711 μ-law at 8 kHz, which is exactly what a phone line already carries. So the Durable Object just relays frames in both directions, untouched. There's no speech-to-text step, no text-to-speech step, and no resampling. Every stage you skip is latency you don't pay and a failure mode you don't have. The voice model hears the phone line exactly as it is, hold music and all. One small detail that mattered: the GPT-Live session starts when Telnyx says the call was answered, not when we dial. Otherwise the model sits there listening to ringback and gets confused about whether someone is on the line. Most voice agents are cascaded: speech-to-text transcribes the other side, an LLM writes a reply, and text-to-speech reads it out. call4me isn't. There is exactly one model on the audio path, GPT-Live, and it hears audio and speaks audio directly. The text model further down this post only makes decisions. It never sits between the business and the voice. Cascades are popular because every piece is swappable and the LLM in the middle is a normal text model that's good at tools. But each hop adds latency, and turning speech into text throws away everything that isn't words: tone, hesitation, someone talking over you, the difference between hold music and silence. On a phone call those are most of the signal. Going speech-to-speech has worked out well. Interruptions mostly handle themselves. GPT-Live stops talking when it's talked over, and the only thing I had to add was flushing audio that was already queued at Telnyx, so the business doesn't hear the end of a sentence the model already abandoned. Cost isn't a blocker either: GPT-Live costs about 5¢ a minute, a small slice of the 25¢ a minute call4me charges. The real tradeoffs are the ones the rest of this post is about. A realtime model is worse at acting reliably than a text model, so it doesn't get tools. It only speaks when it hears something, so silence while it waits on me is my problem to fill. If you're weighing a switch away from a cascade, those are the two things to plan for. GPT-Live is great at conversation and bad at being trusted with side effects. So it doesn't get any tools. Instead it delegates to a "back office": a regular Responses API model that owns everything that changes the world. The session config looks like this: The voice keeps talking while the back office thinks. When the business says "can I get the account holder's zip code?" and the caller doesn't have it, the voice says something natural, and the back office calls . That question goes out over MCP to my agent, my agent asks me, and my answer gets injected straight back into the voice model to say out loud. This split is the whole product. The fast model handles the conversation and the smart model handles decisions. Most calls to a big company never reach a person without getting through a phone menu first. This was by far the hardest part to get right, and most of the code in the voice folder exists because of it. The first bug was funny. A menu says "for billing, press 2" and GPT-Live confidently says "2." out loud. A phone menu only hears the keypad, so it repeats itself, the model says "2." again, and you get a loop that runs until the menu hangs up. The voice model is supposed to delegate to the back office, which calls . Sometimes it just doesn't. No delegation event, no error, nothing runs. Same thing with "let me check on that real quick" followed by silence, or "bye!" followed by never actually hanging up. I couldn't make the model reliable, so the session watches the transcript itself. A set of patterns spots phone menus and spoken promises: If a menu finishes speaking (3 seconds of quiet) or the caller makes a promise (1.5 seconds) and no hand-off follows, the session starts the back office on its own and tells it what was missed. Getting the first key right isn't enough. Menus lead to dead ends: "no input was received", "that selection is not valid", or worst of all, "please visit our website" and a hangup. A class tracks fresh menu speech separately from the transcript, remembers every key the carrier accepted and what prompt it was answering, and watches for failure phrases. When a menu fails, the back office gets the full key history and one firm rule: never repeat a route that didn't work. Back out to the main menu, try "other questions", try for a representative. Speech-driven menus were their own problem. FPL and UPS both looped for about 8 minutes before I taught the caller the magic words: say "representative", and if that doesn't work, say "agent". Telnyx's sends keypresses as out-of-band RFC 2833 events. That works for US phone menus. On a call to Dubai Opera, the menu just kept repeating, because the UAE route was stripping those events. The fix was to stop asking the carrier to press keys and press them ourselves. For any number outside +1, the caller now plays real keypad tones into its own audio: two sine waves per key, 120 ms of tone and 80 ms of gap, encoded to μ-law and spliced into the outgoing frames. A tone inside the voice audio survives any route that carries the voice. A Durable Object can be reset when you deploy. On launch day, every call that went deaf was one of those resets. The fixes stacked up: a 20 second heartbeat that asks Telnyx to reattach the stream, resuming GPT-Live with the transcript so far so it doesn't greet the business twice, and treating "no audio 4 seconds after pickup" as a lost stream. The real fix came after a blog deploy cut off a user's demo call mid-sentence. Calls now live in their own Worker, and the deploy script for that Worker only ships when the bundle actually changed and no call is live. The first version dialed me in as a Telnyx supervisor with full speaking rights. My voicemail picked up, its greeting played onto the business's line, the voicemail hanging up looked like me handing the call back, and the system rang me again. In a loop. Now my phone rings listen-only and I have to press 1 to join. If 30 seconds pass with no keypress, it's voicemail. Once I'm on, my own keypresses get forwarded to the business too. I found that one the hard way when the Experian menu asked for my SSN and my keypad did nothing. The voice model only speaks when it hears something. So when it asked me for a one-time code and was waiting on my answer, the line went dead. A Spectrum rep sat through 41 seconds of silence. Now, every 15 seconds or so, the caller says "sorry, still checking on that, one sec" until the answer arrives. You can try it at call4.me . Calls are 25¢ per minute, and unanswered calls are free. A saved profile with the stuff businesses always ask for: legal name, date of birth, phone, address, insurance, car. Per-call requirements by category. There are 12 of them (medical, dental, restaurant, vet, auto service, internet, flight change, and so on), and each lists the fields that kind of business will ask for. Skip every audio conversion you can. Matching the carrier's codec end to end made the audio path the most boring part of the system, which is exactly what you want. Split the voice from the decisions. A realtime model with tools will press the wrong button eventually. A realtime model that delegates to a reasoning model is much easier to control. Don't trust the model to act. Verify. The most valuable code in the repo is a few regexes that notice when the voice said it would do something and didn't. Gather everything before you dial. The caller can only say what it was given.

0 views
Jason Scheirer 1 weeks ago

Oh, Goddammit: I'm Back on C++ in 2026

The title for my autobiography would probably be I Thought I Was a Child Prodigy But I Am Actually a Late Bloomer . Going from being the youngest guy in the room to the oldest guy in the room happens suddenly, surprisingly, and permanently. As such, the programming languages and technologies I learned long ago are esoteric knowledge but the systems they run on still exist. Everything’s moved up a level of abstraction or six and there’s a lot of low-hanging value in plain old systems programming again. So for the first time since 2014 or so I found myself writing C++ starting March 2026 and carrying over to today in late 2026, both dates in 2026. 2026. From the start, quite simply: Windows APIs. Relatively obscure ones. They come with C++ headers and opaque files and COM interfaces that fall well outside of the coverage footprint of . I’m writing RDP plugins , which with some gymnastics I could do in Rust but it would be a lot more work to do the cooler thing. It’s less friction to just say “okay” and do it the way they tell you to do it. Spend those innovation tokens on cooler shit than this. And I’m doing Citrix channels , which are straight up C++ or Go Fuck Yourself (I believe the SDK docs explicitly use this language). Also, there are old features that aren’t exposed to moderns stacks that give us easy wins that exist on users’ systems without additional upgrades or installs. The RDP client bridge binary I wrote is under 50k. It builds in 3 seconds. It runs on every Windows machine. Hot fuck on balls, that beats the 50 minute Typescript BDSM sessions I engage with on CI at work. After the C++ APIs on Windows, doing systems programming at the lowest common denominator let us bend the computer to our own will with less supply chain debt. We are superpowered by C++ in a world where nobody knows C/C++ anymore. I’m writing little tiny bits of NodeJS C++ libraries to integrate new features Electron lacks, and this has worked well. I can add things Electron Just Doesn’t Have. The process is a lot quicker than asking someone on the Electron project to write it for me, or writing it myself and spending my nights and weekends shepherding the itch-scratch PR through the bureaucracy of Electron. I can add value now, goddammit and make our Electron app something that should be an Electron app, because it does actual desktop shit . Here are some good things about C++: LLMs I guess. Fear? Nothing good or defensible as a reason. “Oh it’s dangerous” DRIVING IS DANGEROUS. EATING FOOD IS DANGEROUS. GOING OUT IN PUBLIC IS DANGEROUS. LIFE IS DANGER. You can easily link in legacy C and C++ code There is a lot of C and C++ code you can integrate quickly and well. I chose miniaudio as a lowest common denominator for audio processing (and miniaudio is not ’legacy’, it is a modern project on a conservative stack). Cross platform means it took 2 hours to get a working Linux build out of the code I had originally written for Windows. Notion on Linux works better than Electron would have done the job on Linux because I accidentally got cross-platform by default by writing my code like this. It’s easy to write node.js bridges in it: I even use Objective-C++ for my macOS integrations “Modern” C++ has a lot of features that make it less awful to use – I’m pinned on C++20 and and gives me structural pattern matching. is one of the cleanest JSON libraries I have used in any language and it’s just a header . Compile times are blink-of-an-eye fast. Build stacks are just there on every system. It’s fun to spite people who uncritically adopt shiny things like Rust.

0 views
Andrew Atkinson 2 weeks ago

Let's Get Down to Business with Rails and PostgreSQL

I’m happy to share that I was a guest on the Rails Business podcast and the episode is now live. Here’s a recap of the topics we discussed. We spent most of the time discussing engineering work last year related to scaling the database workload for peak traffic at Aura where we use Ruby on Rails and PostgreSQL. I’m presenting this information in two places this fall, Rails at Scale Summit in Austin, TX, and the Postgres Summit in NYC. Besides the podcast, you can get more details in these posts: If you’re not able to make those events or prefer to listen to a discussion of the content, then the podcast will have a lot of the same information. Besides the work at Aura, we also checked in on my book High Performance PostgreSQL for Rails , which has now been available in print for two years. Ryan asked about what’s next for the book, and it’s a difficult question to answer. With the availability and quality of AI tooling for software engineers, I don’t see developers buying books as much. With fewer prospective readers, the ROI is less attractive for authors and publishers to commit to the resource-intensive process of creating a book. I’m grateful to have had the opportunity to write a book before the rise of the AI tools. I’m still getting positive feedback from my book which is very fulfilling. Recently I met Madison Sites who’s a reader, and they shared feedback that they’re reading the book as part of the WNB.rb book club . Madison said the book has been helpful for building vocabulary and familiarity with database concepts and implementation details, and that’s helped with database work and better comprehension for conference talks about similar topics. I appreciated hearing about that! We segued into how we’re all using AI tools like Claude in our jobs as software developers, whether it’s writing or reviewing code, doing architectural planning, or myriad other tasks. One of my favorite use cases has been generating infrastructure as code with tools like Terraform. One of the main challenges I’m experiencing is on the review side, what’s expected when it’s AI-generated code in terms of my own comprehension, and how to still provide a quality review given the ambiguity around how much comprehension is needed from me. I also find it challenging to navigate team communication dynamics with AI-generated comments. One concept that’s resonated with me is: don’t be a meat proxy , make sure to do self-reviews and add some value in the chain. I hope you can take a listen to the episode, and please let us know if you have feedback. Thank you! Listen to the Episode 👉 Andrew Atkinson on Rails Business Podcast From Christmas Outage to #1 App Store Ranking: An Aura Frames Postgres Scaling Retrospective Scaling Rails at Aura Frames: Splitting to 8 Primaries and Reaching #1 in the App Store

0 views
Jack Vanlightly 1 months ago

The LogDrive: Flexible Composition Through Abstraction in Shared Logs

In July, several Confluent colleagues and I published The LogDrive: Composable Durability for Cloud-Based Shared Logs . The paper evolves the concepts of Virtual Consensus in Delos by decomposing the Loglet abstraction into AtomicLog and LogDrive, moving composition below sequencing to a new durability abstraction. This post describes these new abstractions in the context of Virtual Consensus, focusing on how they support RAID-like composability for shared logs. The ideas of the paper came out of a research project led by Mahesh Balakrishnan, Gardner Vickers, and Lucas Bradstreet. The project itself was to build a Kafka-on-S3 service to run in Confluent Cloud and the project ultimately became K2 or Kora 2, the next evolution of the Kora engine (which powers Confluent Cloud’s Kafka service). The first product built on K2 was Freight Clusters (Kafka on S3). The research that led to the LogDrive paper originated in Conflux, the scalable metadata service in K2 which acts as sequencer and metadata database for the fleet of leaderless brokers, akin to WarpStream’s agents. Fig 1. Leaderless broker fleet writes Kafka batches to S3, with Conflux shards as sequencers and metadata databases for S3-stored Kafka data. Conflux is a multi-master state-machine replication service built over a shared log based on Virtual Consensus which uses cloud services such as S3 and DynamoDB as the backing storage service. But this post isn’t about K2, nor even Conflux, its about extending the shared log protocol Virtual Consensus for better composability, specifically, being able to use RAID-like semantics to build logs with striping and quorums over diverse backing storage without having to rewrite the stack. I recommend reading my previous posts on Virtual Consensus to better understand this post: An Introduction to Virtual Consensus in Delos   Steady on! Separating Failure-Free Ordering from Fault-Tolerant Consensus But in any case, I will do a quick recap to set the scene for describing the new abstractions introduced by the LogDrive paper. Traditional replicated-log protocols tend to combine sequencing, durable storage, failure handling, and membership changes. Virtual Consensus separates these responsibilities between a VirtualLog and a sequence of Loglets. Fig 2. VirtualLog abstracts the log as a whole, with a virtual address space. Each Loglet abstracts a log segment, with one active segment. The VirtualLog exposes one logical address space over a chain of independent Loglets. One Loglet is active, while its predecessors are sealed. The active Loglet provides the steady-state data path: it accepts appends and establishes a durable order within a fixed configuration—failure-free ordering. Basically the happy steady state where everything is running fine. The VirtualLog provides the control plane. When the active Loglet experiences a failure and must be replaced, the VirtualLog seals it, records its final tail, and extends the chain with a new Loglet. Fig 3. When a Loglet must be replaced, it is sealed, a new active Loglet added and the log metadata committed. Virtual Consensus separates the protocol into failure-free ordering (Loglet) and fault-tolerant consensus (VirtualLog). A Loglet does not need to implement recovery from partial failure, such as leader elections or membership changes. In that sense, it can be much simpler to implement than Raft. It only needs to order entries with a fixed configuration and provide fault-tolerant and operations so that the VirtualLog can terminate the segment safely. Fig 4. Reconfiguration moves the system from one steady state configuration to another in response to failures, policy triggers or other factors such as load balancing. There is a limitation to the Loglet abstraction when it comes to Loglet composition. By composition I mean building Loglets that are composed of other Loglets. For example, building a generic QuorumLoglet over a set of child Loglets, or a StripingLoglet that stripes writes across a set of child Loglets. The fundamental write operation of the Loglet is . An append both assigns a position and stores the value . Striping works despite this coupling of sequencing with storage. A StripedLoglet routes each to one child Loglet and translates the returned child address into its own address space. This works provided that each child allocates contiguous addresses (for address translation between child and parent address space). Fig 5. StripedLoglet But while can work for striping, it does not compose cleanly for a QuorumLoglet. A parent can forward an append to several child Loglets, but each child independently assigns the address. Partial failures and retries may place corresponding values at different addresses across the child Loglets, creating divergence. An external sequencer could assign positions first, but the Loglet API has no operation for storing a value at a caller-selected address (it only has ). The parent would have to ignore the children’s ordering and somehow add its own sequencing and reordering machinery. Doesn’t sound like much fun. In Delos, quorum replication was therefore implemented inside the NativeLoglet. The NativeLoglet consists of a sequencer and a set of Log Servers. The sequencer assigns an address to each append and then writes the value at that address to a quorum of Log Servers. Crucially, the Log Servers expose , not Fig 6. The NativeLoglet, a quorum-replicated Loglet. Loglet composition via is the problem. This now brings us to the new abstractions: AtomicLog and LogDrive. Fig 7. The Loglet is decomposed into the AtomicLog and LogDrive abstractions. The AtomicLog together with the LogDrive is an implementation of the Loglet API. From the VirtualLog’s perspective, AtomicLog is simply another Loglet: it supports , , , and , and it can be replaced through the normal Virtual Consensus reconfiguration mechanism. The further decomposition of the Loglet is as follows: The AtomicLog is responsible for sequencing and general log semantics but delegates durability. Sequencing/addressing is achieved via a soft-state sequencer (which we can consider is part of the AtomicLog). The LogDrive (sitting below the AtomicLog) is concerned with durability rather than sequencing. It exposes a flat numbered address space of durable single-value registers together with , an operation that lets AtomicLog reconstruct the tail from backing storage after the sequencer has disappeared.  Composition into stripes and quorums exists at the LogDrive level, below sequencing . That is, composition is via the LogDrive API, not the AtomicLog (Loglet) API. Fig 8. Depicts the AtomicLog and LogDrive abstractions in two Conflux instances The LogDrive API does not include append, as composition requires writes to use caller-defined addresses: An AtomicLog append consists of a three step process: Acquire the next free slot from the sequencer (the slot is the address) Write the append value to the acquired address via the LogDrive Complete the slot The sequencer is essentially a map of: address -> {FREE, ACQUIRED, COMPLETED}, though the sequencer uses the term slot instead of address.  Multiple addresses can be written concurrently using a windowed write discipline: the sequencer permits up to (K) appends to be in flight ahead of the contiguous log tail T. The contiguous tail is the first unwritten address and the non-contiguous tail is the lowest unwritten address after which all addresses are unwritten (or alternatively the last written address + 1). Fig 9. A snapshot of sequencer state compared to LogDrive state On a completeSlot(slot) call, the sequencer blocks until all prior slots are also completed. In the figure above, we see that the window of four addresses which is being written to concurrently has holes in addresses 3 and 5. The LogDrive writes for 4 and 6 can finish out of order but their sequencer calls cannot complete until all preceding slots have completed. Only once address 3 has been written to and completeSlot(3) is called, can address 4 complete and the contiguous tail advance to slot 5. So while writes to the LogDrive can complete out-of-order, the appends at the AtomicLog are strictly completed in address order. In this post we’re going to ignore how comes into play, we’ll look at that in a subsequent post. When a Loglet is unsealed, simply asks the sequencer what the tail is. This is the fast-path as the sequencer keeps all its state in memory. Should the sequencer become unavailable, then the AtomicLog can only discover the tail by inspecting log storage (slow-path). The LogDrive offers the command for this purpose. Fig 10. Fast and slow path checkTail While the AtomicLog checkTail returns a scalar contiguous tail (T) address, the LogDrive weakTail returns: N : the non-contiguous tail (first unwritten address after which all addresses are unwritten, or alternatively, the highest written address + 1) H : The holeset, the unwritten addresses within the write window. As seen in Fig 9, the write window can create a Swiss cheese of holes at the tail of the log. The weakTail result implicitly describes the state of the write window. The write discipline maintains . Therefore, every hole lies in the address range , while all addresses below that range are guaranteed to be written. The following can be computed from a weakTail result:  Addr written == Addr unwritten == The window provides concurrency while bounding the number of holes that incomplete or slow writes can create. Limiting the window size allows the tail to be recovered or checked efficiently by examining at most the last addresses rather than scanning an unbounded address space.  It’s worth noting that the sequencer’s view and the backing storage’s view of the contiguous tail regularly diverge. From the example earlier, once address 3 is written, if you call weakTail on the LogDrive, it will return thus . However, until is called, the sequencer sees . This has correctness implications. For this reason, the slow-path is only used once the AtomicLog is sealed (via a linearizable register). Thus once the slow-path has been invoked once, the fast-path will never be invoked again (avoiding diverging results between callers of fast and slow path). Why does the LogDrive weakTail return instead of simply ? And why is it called a weak tail? We cover the former in this post (it's needed for LogDrive composition) and the latter in the next post (it’s about weak semantics). There are three main types of LogDrive: Primitive LogDrive : A thin interface over remote storage, such as a cloud database, KV store or object storage. For example, one might implement a DynamoDBLogDrive, or an S3LogDrive, which are thin shims. StripedLogDrive : A log drive with a set of child log drives where each child is a stripe. Maps addresses to stripes and performs address translation between its own address space and that of its children. QuorumLogDrive : Also has a set of child log drives. Each read, write, weakTail is a quorum operation over its child logdrives. Fig 11. Singleton primitive LogDrive, StripedLogDrive over primitives, QuorumLogDrive over primitives. Striped and Quorum LogDrives call the LogDrive API of their children—the LogDrive API is the compositional interface. So we can compose LogDrives arbitrarily: quorums over stripes over primitives, or stripes over quorums over primitives and even over heterogenous primitives. The base of the tree must ultimately consist of primitive implementations. Fig 12. The root LogDrive is a QuorumLogDrive over three regions, where each region is striped across two DynamoDB tables. A call to in the root LogDrive will flow down as calls to in child LogDrives, according to the type LogDrive. For example, with a StripedLogDrive of 3 child PrimitiveLogDrives, any given read/write call is mapped to a read/write call of the correct child LogDrive, with address translation between the parent address space and the child address space, both of which are always contiguous. Fig 13. Depicts the routing of writes and the mapping of the root LogDrive address space to its children. A QuorumLogDrive forwards to its child LogDrives and waits for a write quorum (Qw). Flexible quorums apply here, where the read-quorum (Qr) is computed as . So if , , then the . If , , then . Because the parent supplies a , every child receives the same value for the same address. Partial success can leave an address unwritten on some children, but it cannot cause their logical address assignments to diverge as independent child append calls can. The Loglet API command returns , the contiguous tail (plus a boolean whether the Loglet is sealed). However, this is not enough for quorum composition. A single scalar value per child tail does not provide enough information for a QuorumLogDrive to compute its tail value. The write window of each child LogDrive may individually resemble Swiss cheese of holes but when unioned together form a contiguous slice of quorum-written addresses. Fig 14. Swiss cheese individually, but globally contiguous quorum-written A scalar child tail is insufficient because it loses information about writes above the child’s first hole. For example: Fig 15. Left and write return the same child T values, but correspond to different global T value. Each child may have a different pattern of holes within its write window, and the QuorumLogDrive must determine the global status of each address based on the richer N, H result of its children. The specific algorithm that the QuorumLogDrive uses to merge the weakTail results of its children into its own combined weakTail is detailed in the paper and also in my TLA+ specification . One motivation behind this work was to make it easier and faster to adapt to changing cloud services and, just as importantly, changing cloud service pricing. A Primitive LogDrive is intended to be a thin adapter over some backing storage service such as DynamoDB, S3, or a KV store. Because it only needs to implement the small LogDrive API, a new primitive can be relatively simple, on the order of a few hundred lines of code rather than a new shared-log implementation. This lowers the cost of adopting a new storage service. If a cloud provider introduces a cheaper, faster, or otherwise more suitable storage primitive, supporting it does not require reimplementing sequencing, replication, striping, or the rest of the shared-log stack. Striping and quorum replication live in generic StripedLogDrive and QuorumLogDrive implementations. These operate over the LogDrive API and therefore do not care whether their children are backed by DynamoDB, S3, S3 Express One Zone, or something else. The same composition machinery can therefore be reused over different primitives and nested arbitrarily: quorums over stripes, stripes over quorums, and even compositions involving heterogeneous backing stores. As long as a Primitive LogDrive satisfies the LogDrive API, it can participate in these higher-level compositions without those layers needing to know anything about the underlying storage service. Virtual Consensus adds another useful property: different Loglets in the same VirtualLog can use different LogDrive configurations. A log might initially use an AtomicLog backed by a singleton DynamoDBLogDrive. A later reconfiguration could extend the VirtualLog with a new AtomicLog backed by a QuorumLogDrive over several S3 Express One Zone LogDrives, perhaps spread across availability zones or regions. The old configuration remains responsible for its existing segment while new appends move to the new one, and the old segment can eventually age out as the log prefix is trimmed. The combination is powerful: LogDrive provides a way to construct different durability configurations from reusable building blocks, while Virtual Consensus provides a way to transition between those configurations over time. This makes the storage layer adaptable to changes in cloud services, performance characteristics, failure requirements, and pricing without having to rewrite the shared-log stack. You might be thinking that you’ve seen all these patterns before and there’s nothing groundbreaking here, and in some ways you’d be right. But what the paper contributes are the formalized abstractions . Murat Demirbas just wrote a great piece on Modularity abstraction versus Modeling abstraction where he compares and contrasts abstraction in terms of modularity and abstraction in terms of reducing something to its very core behavior. A key heading in that post was titled: Modularity abstraction hides. Modeling abstraction reduces . Dominik Tournow recently tweeted something along similar lines about modeling abstraction: “ Systems design is the process of reduction: reduce a problem and its solution to their very essence. When you've found the right abstraction, there are no transformations, no translations, no mappings, no workarounds. “ I think this is a useful lense through which to view the LogDrive paper. Having worked on Apache Pulsar and Apache BookKeeper, I can tell you that BookKeeper has both striping and quorums built in. But what it doesn’t have is a set of formalized abstractions that allow for arbitrary composition based on composable building blocks over a diverse set of backing storage that the AtomicLog and LogDrive give you. BookKeeper is akin to the NativeLoglet. The contribution of LogDrive is instead the abstraction: reducing durability to a numbered collection of single-value registers plus , while moving sequencing above it into AtomicLog. The interesting part is that the weaker abstraction is the more composable one. By reducing durability to its essential behavior, LogDrive provides a better building block for constructing shared logs. An Introduction to Virtual Consensus in Delos   Steady on! Separating Failure-Free Ordering from Fault-Tolerant Consensus The AtomicLog is responsible for sequencing and general log semantics but delegates durability. Sequencing/addressing is achieved via a soft-state sequencer (which we can consider is part of the AtomicLog). The LogDrive (sitting below the AtomicLog) is concerned with durability rather than sequencing. It exposes a flat numbered address space of durable single-value registers together with , an operation that lets AtomicLog reconstruct the tail from backing storage after the sequencer has disappeared.  Acquire the next free slot from the sequencer (the slot is the address) Write the append value to the acquired address via the LogDrive Complete the slot N : the non-contiguous tail (first unwritten address after which all addresses are unwritten, or alternatively, the highest written address + 1) H : The holeset, the unwritten addresses within the write window. Addr written == Addr unwritten == Primitive LogDrive : A thin interface over remote storage, such as a cloud database, KV store or object storage. For example, one might implement a DynamoDBLogDrive, or an S3LogDrive, which are thin shims. StripedLogDrive : A log drive with a set of child log drives where each child is a stripe. Maps addresses to stripes and performs address translation between its own address space and that of its children. QuorumLogDrive : Also has a set of child log drives. Each read, write, weakTail is a quorum operation over its child logdrives.

0 views
James Stanley 1 months ago

Foiling a Protohackers email spam bot

I've been receiving lots of "Undeliverable Mail Returned to Sender" lately for Protohackers signup attempts. Protohackers login is via a "magic link", so attempting to sign up or log in results in sending an email. But none of the email body is user-controlled, so I don't really see the logic in abusing this form to spam people. One email address has been put in over 100 times over the last 3 days, and I received "Undeliverable Mail Returned to Sender" each time, because it is a GMail address that doesn't exist. What's the logic in this? Most of the email addresses did exist however and presumably the spam either reached them or was filtered by GMail. Some ideas I can think of: someone griefing particular users by bombarding them with signup spam for hundreds of services they don't use someone trying to get me specifically banned from GMail by making me send lots of unsolicited emails to GMail addresses some grey-hat chaos-monkey type operation trying to nudge all website operators into locking down forms that can cause email sending a weird botnet communicates internally by triggering Protohackers signup emails to itself, and the timestamp of the email allows them to reliably communicate about 10 bits at a time?? I think the first one is the best idea but I still don't really see why you would do this. Although it's a better reason than the others, it still doesn't seem like a good enough reason to actually bother. I did already have a rate limit of 60 emails per recipient per day, and a burst limit of 5 per recipient per minute. I don't really want to stop people from being able to log in as many times as they need to, but getting 60 spam emails per day for a service you don't use is obviously too much. I did tighten the rate limits to 10 per day and 2 per minute, but really we don't want to be sending any spam. At any rate, I wanted to stop this. The goal is to stop whatever bot is sending these emails, without impacting legitimate users ( be they man or machine ). I noticed that all of these signup attempts were originating from the same netblock: 169.58.0.0/17 , apparently operated by Contabo . I'd rather not specifically discriminate against particular netblocks, both because legitimate users could be using the same netblock, and because a bot can easily change its hosting or use proxies. But the fact that it always used the same netblock makes it easy to identify, and the fact that it comes back every few minutes makes it easy to investigate. So my first mitigation was to add a tiny JavaScript proof-of-work, on the basis that a simple bot is probably not executing JavaScript. I was surprised to find that this actually didn't help. As an experiment, I kept ramping up the difficulty on the proof-of-work, and the bot was still successfully submitting the form even when it was taking over a minute to calculate the proof-of-work. So I've left the proof-of-work in place, but back down to a trivial level so as not to inconvenience real users. The next thing I did was selectively put the Bot Forensics collector script on the page only for clients within 169.58.0.0/17, with the idea that this would quickly reveal identifying features of this particular bot that I might be able to filter on without causing collateral damage. I was disappointed to learn that the bot never posted off the Bot Forensics beacon. If you were using Bot Forensics as general-purpose bot detection, this would kind of be the ideal case. If you refuse to send emails for any session that has not posted a good beacon, then this misbehaving bot is blocked and you don't really care what the beacon would have contained. But Bot Forensics is a bit too invasive for me to want to put it on the page for every user, and by this stage I was mainly motivated to learn more about this particular bot. And in any event, I don't actually have a problem with bots using the form in principle, I only have a problem with abuse of the form, whether by bot or by human. I wondered if the reason the bot wasn't sending the beacon was simply because the proof-of-work blocked the page so it couldn't compute the beacon. So my next experiment was to put a 10-second timeout between completing the proof-of-work and sending off the email. The idea was that the page would then have a good 10 seconds in which to send off the Bot Forensics beacon. Surprisingly, the 10-second timeout inhibited sending the email! Even though it previously spent over a minute calculating the proof-of-work. The bot must be waiting for inactivity and then closing the page after something less than 10 seconds. A 10-second delay is still a bit much to be imposing on legitimate users though, so I tried reducing it to 3 seconds, and then the bot was back to successfully sending emails. Although we weren't getting the full beacon content from Bot Forensics, we could still see: the bot is fetching the collector HTML for the iframe it's fetching other resources included inline in the HTML it's fetching resources requested by the JavaScript code it is able to send POST requests for exception logging but it is not POSTing the full beacon (The exception that we log is expected, it's just a failure trying to fetch a resource which doesn't exist.) I have a list of all of the User-Agent headers seen from the Contabo netblock . I'm not saying all of these are the malicious bot, but I suspect the majority are. ChatGPT points out that this list is probably from UserAgentString.com , good find ChatGPT. Despite seemingly choosing a User-Agent at random from that list, the sec-ch-ua header always lists "HeadlessChrome", example: So this does give us one way to block this bot with extremely low chance of causing collateral damage to legitimate users: we refuse to send email for any request that has "HeadlessChrome" in the sec-ch-ua header but not in the User-Agent header. That way we still don't block legitimate users even if they are using headless Chrome, as long as they're not messing with the User-Agent header. Let's keep that one in our back pocket, I'd really like to get a bit more of a smoking gun. I made the Bot Forensics collector send back a much smaller beacon, synchronously, and discovered: timezone is set to Europe/Berlin screen size is 1280x720 it doesn't have any custom functions injected into the page, that's disappointing, they're normally my favourite thing to look at navigator.platform is "Linux x86_64" but navigator.userAgentData.platform is edited to suit the User-Agent header So apart from having "HeadlessChrome" in sec-ch-ua but not User-Agent , the mismatch between navigator.platform and navigator.userAgentData.platform is another thing we could filter on. This bot does sometimes use a real headless Chrome User-Agent , and it is its most common one, but the vast majority of requests use the other weird ones. At this point I noticed one other bizarre behaviour from this bot: shortly after sending the signup email, it tries to load the user profile page, even though that page is not linked from the signup page. What's the angle there? Maybe this is some kind of automated vulnerability scanner that thinks it might be able to access random people's accounts simply by sending the email and speculatively browsing to the profile page? I literally don't understand why you would even check this. Even if it worked, which it doesn't, even if they click on the link, because that only authenticates the session that clicked the link and not the one that sent the email... but even if it worked, what benefit do you get from hacking someone's Protohackers account? Anyway, I'm out of time and stopping for now. So changes in response to this bot are: email sending now requires a (tiny) proof-of-work, and the JavaScript code includes a 1-second sleep; this doesn't stop this bot but might stop others rate limits reduced from 60/day and 5/minute to 10/day and 2/minute backend now refuses to send email for clients who have inconsistent "HeadlessChrome" and "Linux x86_64"; this blocks almost all emails from this particular bot And the Bot Forensics collector is now removed, even for clients from Contabo. If you find you now have trouble logging in to Protohackers, I'm sorry, please let me know. Also if you can work out what this bot is actually trying to achieve I'd be really interested to know. If we have to do any more on this, I think I might try a proof-of-work system that starts out easy but drastically ramps up in difficulty based on how many emails have been sent to that recipient, or from that client netblock, in the past day. someone griefing particular users by bombarding them with signup spam for hundreds of services they don't use someone trying to get me specifically banned from GMail by making me send lots of unsolicited emails to GMail addresses some grey-hat chaos-monkey type operation trying to nudge all website operators into locking down forms that can cause email sending a weird botnet communicates internally by triggering Protohackers signup emails to itself, and the timestamp of the email allows them to reliably communicate about 10 bits at a time?? the bot is fetching the collector HTML for the iframe it's fetching other resources included inline in the HTML it's fetching resources requested by the JavaScript code it is able to send POST requests for exception logging but it is not POSTing the full beacon timezone is set to Europe/Berlin screen size is 1280x720 it doesn't have any custom functions injected into the page, that's disappointing, they're normally my favourite thing to look at navigator.platform is "Linux x86_64" but navigator.userAgentData.platform is edited to suit the User-Agent header email sending now requires a (tiny) proof-of-work, and the JavaScript code includes a 1-second sleep; this doesn't stop this bot but might stop others rate limits reduced from 60/day and 5/minute to 10/day and 2/minute backend now refuses to send email for clients who have inconsistent "HeadlessChrome" and "Linux x86_64"; this blocks almost all emails from this particular bot

0 views
Himanshu Anand 2 months ago

Two Parsers, One JSON and a Flag: Intigriti's July 2026 Challenge

Intigriti’s July challenge is called “Canonically Yours” and the whole bug is one silly question: what happens when you put the same key in a JSON object twice? Turns out two different bits of code answer that differently and that gap give you the flag. Its a mini package registry called Registry Observatory . You register you get a private namespace like and you can generate signed “compatibility reports” for packages you own. The flag lives in a report for a package you do not own. A quick read of the app “Observatory archive” endpoints tells you the target for free: there is a package in platform scope called the treasure is . The manifest looks like this: The sign step is the bouncer It only signs manifests for packages you actually own swapping to gets you a flat The title says it: canonicalization it kinda mostly means two pieces of code read the same input differently. The reproduction steps even mention your namespace going in the “first” package object which means there can be a second one. So send the key twice : The bet: the signer reads the first (mine, so it approves) and the report generator reads the last ( it builds that report). Same bytes, two readers with two answers. you cannot use , it silently drops the duplicate key, So You have to build the raw JSON as a plain string and Base64 that yourself. The sign step returns , the report comes back for and its release notes are the flag. Two quick dead ends that pin down exactly how the bug works: putting first gets rejected at signing (the bouncer reads the first key and bounces you) and duplicating inside one package object also fails the two parsers split at the level not deeper. The same signed blob was read by two JSON parsers that disagree on duplicate keys. One kept the first value and the other kept the last. The signature was valid it just covered the wrong half of the message. This class of bug is a parser differential (JSON duplicate-key confusion) and it has caused real auth bypasses and signature-smuggling in the wild. The fix is boring: use one parser, reject duplicate keys instead of guessing and act on the exact bytes you signed. Thanks to Intigriti and @silent_web3_ for a clever one. Go read your JSON parsers, they might not agree with each other. Build a small JSON “manifest” describing the package. Base64 it and send it to . The server checks it and, if it likes it, signs it. Send the same manifest plus the approval to . Read the report at .

0 views
Farid Zakaria 2 months ago

Seriously, what is the large code-model even for?

I have been working on making massive binaries possible at . One of the Hail Marys that you should be able to rely on is the large code-model ( ) as it makes no assumptions about size and distance of relocations. Large code model : The large code model makes no assumptions about addresses and sizes of sections. [ cite ] In a previous post , I documented how I hit simple performance bottlenecks that made me believe that the code-model is largely theoretical in practice. In those cases, the fixes were evident and relatively small; however their omission was a hint at how no one uses the code-model because without them the performance penalty was a non-starter. I hinted at some other ways I found the large code-model to still be lacking however I had not yet fully understood the pain of those failure modes. I left a small teaser near the bottom about Thread Local Storage ( / ) being one of the “fun failure modes”. Turns out that it is worse than I thought. The instruction sequences the compiler emits for TLS are 32-bit by construction , and has nothing to swap them for. 🤦🏻‍♂️ To put that very bluntly, is incapable of building complex large binaries despite its stated goal. The annoying part of this whole line of research is producing a test subject. Emitting 2GiB of real instructions into is genuinely painful, you’d have to generate and assemble billions of instructions, and the object file is enormous. Beyond that the cardinality of the problem space grows when you consider whether the code is position-independent, do we use a procedure-linkage-table (PLT), TLS, GOT and all the various nuances each compiler brings with how they layout and order sections in their default linker scripts. But relocation overflow isn’t about how many bytes are on disk, it’s about virtual-address distance between a reference and its target. So we have a few options. Uninitialized globals land in , which is a section: it occupies virtual address space but zero bytes on disk (the same sparse-file trick I wrote about in massively huge fake files ). So a few thousand 1MiB arrays gives us gigabytes of address space essentially for free. We can synthetically produce this pretty easily with the following script. Link it with the (default) small code-model and it falls over exactly where you’d expect, at the 2GiB signed-32-bit boundary: sits at = precisely 2GiB. Recompile the same source with and it links cleanly: The large model did its job: it replaced the 32-bit references with 64-bit sequences: a of a 64-bit offset + an . The trick is great for exercising the linker, but it’s a little unsatisfying and overly synthetic. I often want to mimic relocation failures as they traverse a large segment. We can leverage the assembler’s directive to repeat instructions. We can generate a tiny source of N functions spaced 1MiB apart with a sea between them, plus one dispatcher that s every function. This requires each to have a relocation and the calls to functions past the 2GiB mark overflow. 🔥 Now let’s do the exact same thing, but using . When you access a thread-local variable, the compiler doesn’t just load an address, it emits one of four access models , from fastest/least-flexible to slowest/most-flexible: Notice the pattern: the value that reaches the thread pointer can be 64-bit (i.e. it lives in a GOT slot), but every instruction that participates in a TLS access uses a 32-bit field . , , , are all 32-bit. 4GiB of , still tiny on disk. Now look at the relocations the large code-model generated: Every single TLS access is which is 32-bit , even under . 🫣 The exact same large code-model that just happily linked 4GiB of ordinary fails on 4GiB of . This isn’t a GNU quirk either. LLVM ( & ) does exactly the same thing, albeit with ’s diagnostic being a bit friendly by printing the actual offset and the window it has to fit in: is exactly , sitting at the far bottom of the TLS block, and is the signed 32-bit window has to live in. even parks large-model code in a section. I was a little confused at first: exists . Why doesn’t the compiler use it, especially with ? A relocation type is glued to the specific field it patches and is determined by the code-sequence emitted by the compiler. Here’s the local-exec sequence emits: The offset is applied with . The x86-64 encoding for that instruction has only a 32-bit immediate field . There is no 64-bit form of it. The only relocation that can physically patch that field is a 32-bit one: . So where is used? It’s not in the code ( ) at all. It lives as a dynamic relocation on the GOT slot used by initial-exec . The code side only holds the 32-bit that points at the slot: patches the 8-byte (64bit) data word in the GOT. The instruction that reaches that word ( ) is a 32-bit PC-relative relocation. So even initial-exec has a 32-bit link in the chain: the GOT slot must sit within 2GiB of the code. 😭 local-exec is the mode you want for a big statically-linked executable’s own thread-locals. It’s the fast path: the thread-pointer offset is a link-time constant, so the access is a direct with no memory load, no GOT, no indirection. Unfortunately, is limited to 32bits and overflows. I would argue it’s also the mode most likely to need a large binary, since it’s what a giant static executable uses for its own TLS. What if we fall back to a slower mode? Unfortunately, each one of them has a 32-bit field too: The use of seems especially confusing. The large code-model already reaches the GOT at 64 bits everywhere else: GOTPC64 for the base, GOT64 for the slot, so the omission seems surprising. The 64-bit machinery is right there. TLS just doesn’t use it. 🥲 After having dug into it, it’s not simply a bug in GCC or LLVM. Look again at what emits for a single local-exec access under : It’s a plain . There is nothing stopping clang from emitting a 64-bit form instead: could patch that immediate, exactly the way already patches a immediate for ordinary data. Oddly, the relocation type is not the missing piece. What’s missing is a code sequence in the x86-64 psABI that uses a 64-bit TLS offset as an instruction immediate. The ABI simply never defined a large code-model TLS access model, so / only ever appear in GOT slots, never in code, and no toolchain can emit what the spec doesn’t describe. The large code-model “exists”, but it doesn’t actually deliver arbitrarily-large binaries. For thread-local storage it can’t, because the specification never defined how to. This is one more reason I’m working toward making massive binaries possible. If you want to follow along, the discussion is over in the x86-64-abi google-group where I’ve posted an RFC, and we have started an LLVM Massive Binaries working group and hold monthly meetings. initial-exec is 32-bit RIP-relative reach to the GOT slot. general-dynamic is 32-bit RIP-relative.

0 views
Unsung 2 months ago

“Is this acceptable? Not really. Is it understandable? Absolutely.”

When you visit parts of the website of the Driver & Vehicle Licensing Agency in the U.K. outside of standard office hours, you will see this: = 2x) and (width >= 700px)" srcset="https://unsung.aresluna.org/_media/is-this-acceptable-not-really-is-it-understandable-absolutely/1.2096w.avif" type="image/avif"> = 3x) or (width >= 700px)" srcset="https://unsung.aresluna.org/_media/is-this-acceptable-not-really-is-it-understandable-absolutely/1.1600w.avif" type="image/avif"> It seems ridiculous, almost like the famous 500-mile email bug , but Dafydd Vaughan explains how it came to be . It won’t be a surprise that the story starts with an old computer system, built around a day/night cadence where people would visit the agency during the day, the information would be collected, and all the data processing would happen overnight: At the time, many of DVLA’s services – particularly those relating to driving licences were still backed by an old IBM mainframe from the 1980s – fondly known as Drivers-90 (or D90 for short). D90 was your typical mainframe – code written in COBOL using the ADABAS database package. Most data processing happened “offline” – through batch jobs which ran during an overnight window. The team upgrading it in 2013 decided to not attack too many things at once, and swap the innards but keep the day/night system intact. I’ll let you read some details inside if you are interested, and if you want to learn why the 2013 decision is still a 2026 (or at least 2025) reality. Like many of these , it is a story of heavy financial and technical constraints. But I will excerpt this bit, because I think many teams in any company – design systems, security, or “core” (whatever that happens to be) – would relate to it: It’s difficult for an organisation to keep its focus and attention on a complex upgrade – particularly without getting noticeable benefits along the way. The tricky part is that in many places you do end up in a legacy situation that is exactly as absurd as “an internet service having opening hours,” but you might be too close to the problem to even realize it. #maintenance #software evolution

0 views
daniel.haxx.se 3 months ago

Workshop Basel day three

See also: day one, day two . There is only one thing that is better than two days of HTTP workshop, and that is of course three days of HTTP workshop. The final day of this edition of the series started out with us again shuffling around where we parked ourselves around the big table. Except Mr captain of course who once again got to herd us forward through another day from the same seat. MOQ ( Media over QUIC transport ) is not HTTP, but it uses QUIC so it is at least tangentially interesting and it involves a lot of the same people so this status update still felt welcome and suitable. Compared to existing HTTP based solutions, MOQ is supposed to offer less complexity and lower latency. The moon landing was broadcasted with less latency than current live-streamed TV and maybe MOQ can make us come close to those numbers again. In MOQ clients subscribe to a track that then contains a lot of objects that are delivered. It’s not the request + response approach of HTTP. The fact that this is not HTTP of course brings a lot of questions and well, doubts, and we lingered on various aspects of this topic for quite a while. My prize for the best slides of the HTTP workshop 2026 goes to [redacted] for the excellent use of potato images in their presentation. PTTH is HTTP spelled backwards, commonly pronounced as PoTaToH. A client sets up the connection but the actual HTTP request is sent from the server to the client. One of the intended use cases for this, is to allow an origin server to connect to the CDN proxy and then be able to deliver traffic to the world, rather than to have the CDN connect to the origin the way they usually do. Apparently most CDNs already have custom and proprietary solutions for exactly this kind of feature, so maybe doing it in a standard way instead makes sense? The draft explains the new proposed way to continue a previously interrupted upload over HTTP. The upload request gets a Location: header back for the resource being uploaded, and if it gets stopped prematurely, a client can then HEAD that resource, figure out the size and then do a second upload (using the PATCH method) request that tells the server that this transfer should start at offset X. Exactly how this should be supported in browser’ upload forms seemed a little bit uncertain . For my own sake I can see a challenge to implement this nicely for curl in particular when the upload is using formpost upload (curl’s -F flag) which after all still is a very common way to do uploads on the current web. I’ll return to this topic at a later time when I written an implementation to test… io_uring is a Linux asynchronous I/O framework that avoids the overhead of traditional system calls. It uses two shared ring buffers between user space and the kernel, allowing applications to batch I/O operations with zero-copy efficiency. The feature is disabled by Google in ChromeOS, Android and in production Google servers which certainly holds back some use of it. io_uring can be helpful to speed up things, but might be complicated to use in existing software architectures and the presentation went into some details on why this is so. A walk-through of some of the recent developments and improvements in Firefox’s UDP networking stack . Going from single datagrams to the modern ways to ship large chunks of data offloaded to the kernel to speed things up. Upload throughput in Firefox is up 60-90% over the last 11 releases. Lots of fun graphs and metrics were shown. This work is based on the quinn-udp stack. Happy Eyeballs v3 is coming and Firefox is implementing it . It now takes into account many more data sources than before, including alt-svc and HTTPS-RR and races connections against each other to use the one that connects first. There are some recommended timers in the specification and parts of the discussion was around how maybe the timers could instead be tightened a bit, and maybe the delay between the subsequent attempts could then use an exponential backoff instead sticking to a fixed interval? (I know I’ll discuss some of these details with my curl hacker friends and see what we should adjust… curl already supports most of the Happy Eyeballs v3 specification.) As we approached the end of the day a few shorter topics were ventilated to give us a little more to consider before going home: With this, the seventh HTTP workshop had ended. Again a very fine event. This time graciously sponsored and arranged by Adobe. Thank you everyone! The general idea is to continue with these events roughly every second year and I support this. The HTTP workshops are definitely one of my favorite events. The top image on this post was used in the final presentation and the author told me he is aware of the AI errors in there, “of which there are at least two”. Why is there no UTF8 in URIs? “If we would do it again, we would have allowed UTF8 in there” was said by someone who was there in the mid 1990s… Optimistic DNS is a draft. Use stale DNS cache data while getting the new. Connection remains alive for 120 seconds while DNS data is often not cached for even 30 seconds. No one in the room seemed to hate it. Let’s do this! The journey to QUERY. One of the primary authors of the RFC took us through what it took to make it happen. It was sixteen years since the most previous registered HTTP method and maybe this was the last one ever?

0 views
daniel.haxx.se 3 months ago

Workshop Basel day two

If you missed it. I already described day one . Caffeinated and ready, we all gathered in the same spacious room as yesterday, but seated in new places as “suggested” by our captain. Some of us even remembered to move over the name tags we wrote yesterday to our new seats. No time was wasted on introductions today. We dove straight in at the deep end. Is the future of software that we check-in the AI prompts in the git repository and trust it to generate the correct code? Are specifications the new level o f abstraction for source code? These questions triggered long discussions with a huge mix of opinions and experiences getting shared about how AI is used, should be used and could be used now and in the future. The Common Crawl spidering upgraded to using HTTP/2 for their scan and as an end result, I believe 61% of the responses used HTTP/2 and the entire round ended a few percent faster than before, which when you traverse a few billion URLs really makes a difference. They apparently use a locally patched version of Apache Nutch for this. The HTTP probe project runs a lot of tests on HTTP/1 servers and compares how they behave in a lot of different aspects and then generates these awesome tables. Looks like something for every server implementer team to have a look at and decide what of these red boxes that should rather be converted into green alternatives. HTTP Zoll is a new test suite for intermediaries that tests intermediaries (what we often call proxies) for a large amount of request and response smuggling issues. Some real world problems found were discussed and as this project aims at going Open Source words were expressed on what kind of precautions and checks that maybe should be done first. I hope we get to hear more about this project soon. The HTTP Arena is another project that does performance and measurements. They test HTTP server frameworks and present the results in various ways on their site. In this presentation , we were presented with different HTTP/3 deployment numbers from different sources and the associated reasoning around why they differ but then more importantly. what can and should be done to increase HTTP/3 usage.  Anti-virus interceptions, enterprise blocks and server-side performance not yet on par with TCP were mentioned as reasons for holding back the numbers. Reasons for using HTTP/3 include use cases that encourage QUIC adoption: WebTransport, Media over QUIC and MASQUE (HTTP/3 proxies and HTTP/3 proxies over older HTTP proxies).  Using HTTPS-RR for upgrade was promoted , as every alt-svc response that is returned with an ALPN using h3 should perhaps also offer h3 over DNS. Why doesn’t your server announce its h3 support over HTTPS-RR? QUIC v2 is deployed on an amazing 0.003% of all QUIC v1 domains and there was a discussion why this is so and the common sentiment in the room seemed to be that very few saw a reason for deploying v2 and several expressed a concern that doing so might in fact introduce issues. Someone (you can probably guess who) in the room increased that number a lot by quietly mentioning that haxproxy.org certainly supports it. QUIC multiplexing over bi-directional streams is a proposal on how to do QUIC-style multiplexing over TLS (or anything else really). It has been adopted by the IETF QUIC working group and there was a somewhat extended discussion about what the HTTPbis group should or should not do with it. The biggest interest might be for data center use, but is that then something IETF should bother about? This is not the first time I blog about this, and even if there did not seem to be a strong demand or need for this, it also did not seem to be completely dead. I bet we will hear more about this later. Doing a TLS terminating MITM proxy has its challenges and we were given some insights and experiences on the challenges of doing HTTP/2 and HTTP/3 to the server. The browsers refuse to do HTTP/3 when they detect custom CA certs installed, which apparently is mostly because of lots of past bad experiences with anti-virus software that in particular seems to break QUIC and for users it is not obvious where the blame should go. This then makes browsers not do HTTP/3 over any MITM proxy. Some time was spent on how allowing different clients to the proxy uses a shared h2 connection to the target server is complicated and not used, even though in theory it should be possible. An argument was made that it could even lead to worse performance than when using HTTP/1 but I could not quite follow that reasoning. I’m sure I missed some subtle detail in that explanation. When the afternoon is running late and we have been promised beer and snacks after the final talk, what is better than a hard core technical presentation with lots of graphs and numbers showing how QUIC performance can be improved by tweaking the congestion control algorithm and send more data in the startup phase of a new QUIC connections? This new approach is called Rapid Start and it looks like a promising and yet simple improvement. According to experiments done on real world traffic, the time to last byte was reduced by 14.7% on average. Not bad at all. Our meeting sponsor Adobe graciously sponsored drinks and food so we got to linger around for a few extra hours and talk even more HTTP and networking until the personal firmly insistent they needed us to leave the room and we instead continued solving world problems elsewhere. Topics around the table included the famous HTTP/2 spec coin flip, the QUIC spin bit, the SCONE situation for QUIC, the timeline behind the QUERY method and many more great stories. Thanks for the beer! Now we can’t wait for day three.

0 views
Karboosx 3 months ago

How to Create Your Own Decentralized Messenger Protocol

Ever wondered how to build a decentralized messenger without any central servers? It's all about federation - just like email! In this post, I'll show you how to design a simple protocol from scratch, from server discovery using .well-known files to handling end-to-end encryption.

0 views
iDiallo 4 months ago

Debugging on Prod

The worst type of bug is one that only happens on prod. And only on prod. If you checked this blog in the past few weeks, you might have encountered a big fat 500 error. I'd had the same design for 10 years, and I wanted something fresh. But who can redesign without also improving the underlying code? I deleted a whole bunch of things: old templates that were never used, , a pile of unused CSS. I just had to. I deployed a first version and all the pages worked just fine. But then I got cocky. I decided to also improve the underlying code using GitHub Copilot. I was vigilant at first, reviewing every single line of generated code. None of it was complex really, just refactoring functions and the like. But along the way, I got lazy. I let the AI update deprecated functions on its own. The next time I deployed, the website returned a 500 error. When I checked the logs, nothing came back. No errors. I looked at running processes and noticed several PHP processes pinned at 100%. I reverted the code, but the server was still stuck. I restarted the web server, restarted PHP-FPM, and neither helped. The only thing that worked was restarting the whole machine. I ran the same code on my own machine and it worked fine. That's when I noticed I was running an older version of PHP on prod: PHP 8.3 vs. PHP 8.4 locally. No problem, I thought, and upgraded prod, which of course failed to fix anything. I waited for nighttime, redeployed the broken code, and debugged line by line until I found that Copilot had gone out of its way to "update" code in the Markdown library I use. If you know anything about Markdown, you know it's complex. This particular change was causing infinite recursion while parsing Markdown. I had no intention of reading through all that code to figure out exactly how it was failing, so I just reverted it. I redeployed and the problem seemed solved. Then I got an email: "Your website is down," a reader wrote in the middle of the night. While my American readers are asleep, Europeans are up bright and early reading my blog, for some reason (thank you, really). So debugging live on production was not an option. I reverted to the old code again. But how was the website still failing after I'd fixed the Markdown issue? And worse, it still worked fine locally. Just in case, I upgraded that very old Markdown library to something cooler and more modern: Parsedown . That didn't solve it either. The moment I deployed, the entire website failed, including pages that don't even use Markdown. Now it was personal. How do you debug a website that only fails in prod? I had a few tricks up my sleeve. First, I wrote a bash script to quickly switch between versions of the website. All it really did was flip a symlink between the "latest" folder and another folder I chose arbitrarily. Since I run PHP and every request is short-lived, I could switch to the broken version, debug, then switch back to the working version almost instantly. It's not like I have millions of readers hammering my server. This method worked, but it was slow, and it exposed internal information to the thousands of RSS readers scouring my website. Between 30,000 and 60,000 RSS reader requests hit the site daily. I couldn't afford to expose debugging code to that much traffic. So I used a second method: an even better way to debug live on prod without breaking URLs or throwing 500 errors at unsuspecting RSS readers. What if I ran both versions of the site simultaneously? Visit the regular domain and you'd get the latest working version. Visit a custom subdomain and you'd get the broken version. I achieved this by creating a new Apache configuration pointing to the latest (broken) path. This way, I had all the time in the world to debug the issue right on prod, without interfering with regular traffic. I eventually found the root cause. It was an orchestrated failure. Locally, I ran PHP directly. On prod, I ran PHP-FPM. Why the difference? Because Apache on prod runs HTTP/2 that requires an SSL connection, which I didn't need locally, and serving PHP over HTTP/2 requires PHP-FPM. PHP-FPM is essentially a process manager for your PHP instances. That explained the difference between the two setups, but not the actual cause of the bug. The real issue was in my caching mechanism. When a page is served from cache, I set the header: That's just a custom header. When the page isn't from cache, I set the value to . Here's the code that sets the headers: Now, what can go wrong here? When a page isn't served from cache, is set to . You see it now, don't you? evaluates to in PHP. So whenever a page wasn't served from cache, or the first time a page was hit after a deployment cleared the cache, this code ran instead: That's an invalid header. So why did it fail on prod but not locally? Because Apache silently ignores invalid headers, but PHP-FPM doesn't. It throws a 500 error: Headers need to follow the key-value rules defined in the internet standards (RFC 9110). Removing the condition and always using solved the problem. The blog engine runs on multiple machines I own locally. I never had to worry about the setup because both apache and php are tolerant to mistakes. In a talk, Rasmus Lerdorf once said that PHP works better when you don't know what you are doing. The header condition has its uses. For example, if you want to set that a page is 404 you can return: But I don't use this in my case. While copilot was of some help, it's a reminder that LLM generated code is to be treated with scrutiny. It reinforces my belief that I can never truly become a 10x engineer , because the more code I generate the more I have to review. And the more I trust it, the more likely it will bite my behind.

0 views
Jack Vanlightly 4 months ago

Kafka Share Groups and Parallelizing Consumption - Part 3: Client-local parallelism

All tests were executed against Kafka 4.3.0 using Dimster.  In the last post Broker-Visible vs Client-Local Parallelism we looked at two ways of scaling Kafka consumption. The final unit of parallelism can be visible to the broker, as consumers, or it can be local to the client, as threads, virtual threads, async tasks, or some other execution mechanism hidden behind a smaller number of consumers.  Broker-visible parallelism is simple to reason about: if each consumer processes records serially, we add more consumers to increase parallelism. But each consumer adds overhead to the brokers: broker-side protocol state, TCP connections, group membership, fetch state, and participation in the consumer or share group protocol. With long processing times and/or high throughput, the required number of parallel workers can easily exceed what is practical to model as broker-visible consumers. That is where client-local parallelism becomes important. Instead of scaling by adding more consumers, each consumer application can poll records and process them concurrently inside the client. This allows a smaller number of Kafka consumers to drive a much larger amount of parallel work. In this post, we’ll compare client-local parallelism with consumer groups and share groups using the Apache Kafka clients, by way of Dimster, the benchmarking tool used throughout this series. Dimster uses the official Apache Kafka clients under the hood. The main comparison is between two styles of client-local parallelism: blocking and continuous styles. At the API level, applications obtain records by calling , then later record their progress by committing offsets or acknowledging records. Under the covers, the client sends fetch requests and commit/acknowledgment requests to the brokers. There is some indirection between API calls and network requests, but every parallel processing style has to fit into this general poll/commit cycle. Consumer-group consumers commit offsets (one offset per partition) whereas share-group consumers commit a set of per-record acknowledgments. Any parallel processing style must fit into this fetch/commit style. We can classify parallel processing within this fetch/commit style into two main methods: Blocking and Continuous Poll -> kick off parallel processing -> block on completion of all -> commit -> (repeat) There are many implementation options for decoupling polling, processing and commits, but the general pattern can be classified into two main mechanisms: Poll -> Dispatch loop : each record submitted for background processing. Keep polling independently of processing (though implement backpressure by limiting the number of inflight records, i.e. stop polling when your processing buffer is full). Accumulate -> Commit loop : Accumulate completed records to commit opportunistically. If you are rolling your own logic using the Apache Kafka clients (rather than choosing a parallel processing library), blocking is by far the simplest to code, but the most inferior in terms of performance profile. The Kafka clients support both blocking and continuous styles with consumer groups, but only blocking with share groups . Why don’t share groups support continuous mode with the Apache Kafka clients? It’s simple. You can only acknowledge a record from the current poll batch. If you try and acknowledge a record from the previous poll, it throws an exception. This may or may not change in the future, but it’s worth knowing this if you were planning on implementing a continuous parallel processing style with the AK clients and share groups. Dimster supports parallel processing simulation with both blocking and continuous mode, but share groups only support blocking mode. Dimster doesn’t actually process records (except for recording metrics), instead it simulates processing time by calculating how long each record would take, based on randomized processing times between the min/max. In Blocking mode , it figures out how long the processing would take to process a poll batch (based on the level of parallelism requested in the workload file) and performs one sleep per poll-loop-iteration for the aggregate processing time. In Continuous mode , it feeds each record, along with its randomized processing time, into an in-memory delay queue (accounting for how much parallel processing is requested). Separately it polls, drains completed records from the delay queue and commits continuously. Let’s run some benchmarks with Dimster in blocking vs continuous modes. We’ll use an example workload with: A long processing time of 1-5 seconds (3 second average) A moderate rate of 1,000 records a second.  Each consumer application is capable of processing around 300 records concurrently. The aggregate parallelism is 3,000 ( ) which puts us in the territory where serial consumers are not a great choice. Firstly, share groups only allow groups of up to 1000 members, and regardless, 3000 consumers would create more than 9000 TCP connections (in a three broker cluster), which is excessive for one use case of this size. We need to parallel process inside the clients. We’ll run 3 tests: Consumer group blocking style Consumer group continuous style Share group blocking style The workload file (single scenario with three test points): In the test analysis we’ll cover the configurations in this workload. All three tests resulted in the same 1000 records/s throughput. But end-to-end latency differed a lot, with consumer group continuous style easily winning. The latency distributions: The latency distribution of only cg-continous: Continuous is the clear winner here. You can download the Dimster result tarball here . Let’s dig into the results and why the workload was configured the way it was. With blocking mode, consumption is a factor of the poll rate and the number of records per poll: The poll rate is determined by how long the application blocks waiting for all records to complete. The number of records per poll is bounded by (though it is a soft cap). When estimating the poll rate in blocking mode, the average processing time is the wrong choice as we’ll really block for the longest processing time of a poll batch (not the average). With 100s of records per poll, we’ll likely hit or get close to the upper bound 5 seconds (assuming uniform distribution). More likely in the real world, we’d see a non-uniform distribution where, for example, p95 might be 500ms, but p99.9 be significantly higher at say, 5 seconds.  The workload we’re using has a rate of 1,000 records/s. Each consumer is capable of processing around 300 records concurrently so we set . With a poll rate of 0.2 (one every five seconds), the consumption throughput per consumer is 60 records/s. To reach 1000 records per second we need at least 17 consumers (and partitions), so I configured 18. The effective workload of test point 1 : The consumers managed the 1000 record/s but for some reason, the max end-to-end latency (processing-start-timestamp - publish-timestamp) was double the worst processing time. It turns out that this is a natural effect of Blocking mode with consumer groups. The highest e2e latency will be at least the blocking time of the previous poll iteration (as records kept arriving in the partition throughout the blocking time). However, you may note that the above e2e latency numbers are p50 is 7.5s and max is 10s. This can occur in blocking mode due to the way polls return buffered records and trigger an asynchronous fetch (pre-fetch) to fill the buffer before the next poll. Think of the consumer as having a two-step delivery path: first Kafka records are fetched asynchronously into the consumer’s internal buffer, and poll() returns buffered records to the application. In the above diagram, we see the application spending 5 seconds processing a batch it just received, but that batch had already spent 5s in the buffer as it was filled by a fetch triggered by the previous poll (5s before this one). This kind of e2e latency might not be a big deal, considering the long processing times. If we want to lower the e2e latency significantly, then we need the continuous style. With continuous style, we have decoupled polling from processing and we can use the average processing time of 3 seconds to calculate the consumption rate per consumer (we are not constrained by the max processing time). Parallelism is not defined by the number of records per poll but the total inflight capacity of parallel work (threads, virtual threads, async tasks). We can feed that capacity with a constant stream of small polls and stop polling once that capacity has been reached (polling again once there is free capacity again). Because the application can poll at a high frequency, buffered records remain in the buffer for only a few milliseconds before being submitted for parallel processing.  In continuous mode, worker throughput is approximately: In Dimster the inflight capacity is set by the workload field . With a capacity of 300 and average processing time of 3s, each consumer can process 100 records/s. To reach 1000 records per second, we need 10 consumers and partitions. I set the capacity to 400 to add some wiggle room. The effective workload of test point 2 : This time we see that e2e latency remains very low, as we don’t block on the longest processing time. Again with the Blocking style, so: The per-consumer poll rate is determined by the highest processing time per batch. The aggregate parallelism is 5000 ( ) Share groups introduce a new constraint, the per-partition inflight budget (aka ). The aggregate inflight budget must exceed the aggregate parallelism of 5000. The default is 2000 per partition and so just three partitions gives us an aggregate inflight budget of 6000. Another difference is that we can have multiple share consumers per partition. If we use then each partition needs parallelism of 1666 ( ). With , we need 6 consumers per partition to cover it. The effective workload of test point 3 : You might expect end-to-end latency to be lower than the blocking consumer group test and you’d be right! Each partition has six consumers so the time period between fetches is lower (records spend less time in the partition before being fetched). We are also using so there is no pre-fetching which inflated the e2e latency in the consumer group test. But it’s still higher than you might expect. Per-partition, we have 6 consumers with a fetch/poll rate of 1.2 per second and 333 records arrive per second. We might expect the worst e2e latency to be 277 ms (333 / 1.2). So what’s going on? The above calculation assumes each fetch arrives evenly spread over time. But fetches cluster to a greater or lesser degree, there is no coordination between the consumers. If a long period passes with no fetches, then the first fetch that arrives can drain the accumulated lag, and subsequent fetches just return the handful of records that arrived since the prior fetch a few milliseconds before. The only way for the consumers in this workload to reach the 1000 records/s is if each fetch returns around 277 records per fetch on average. With fetch request clustering, the only way fetches can be filled to this extent is if lag has built up. If 6 consumers attempt to fetch 300 records at exactly the same time, only if lag has reached 1800 on that partition will all those fetches return full. So the consumers settle into a stable amount of lag that is high enough such that fetches return with enough records to keep up. If consumers catch up and lag goes to 0, consumption throughput will naturally drop down until lag builds up to allow for the full consumption rate. Client-local parallelism is often the only practical way to handle long record processing times. But how that client-local parallelism is implemented in Kafka fetch/commit cycle has a big impact on latency. A blocking poll → process → commit loop is simple, but it couples consumption progress to the slowest record in each batch, which lowers poll frequency and can inflate e2e latency even when there is plenty of processing capacity. Continuous polling decouples polling, processing, and committing, allowing the client to keep records flowing into a processing pool while applying backpressure through an in-flight limit. For consumer groups, this provides much better latency and usually requires many fewer consumers and partitions for the same workload. Share groups improve the broker-visible side of the problem by allowing multiple consumers per partition, but the current Apache Kafka clients still constrain client-local parallelism to the blocking style. If your goal is highly parallel, low-latency processing, consumer groups remain the better fit. Removing the same-batch acknowledgment constraint from the Kafka share consumer would make that style possible with share groups as well. In the next post, I’ll look at some pathological share-group workloads with some gotchas to watch out for. Poll -> Dispatch loop : each record submitted for background processing. Keep polling independently of processing (though implement backpressure by limiting the number of inflight records, i.e. stop polling when your processing buffer is full). Accumulate -> Commit loop : Accumulate completed records to commit opportunistically. In Blocking mode , it figures out how long the processing would take to process a poll batch (based on the level of parallelism requested in the workload file) and performs one sleep per poll-loop-iteration for the aggregate processing time. In Continuous mode , it feeds each record, along with its randomized processing time, into an in-memory delay queue (accounting for how much parallel processing is requested). Separately it polls, drains completed records from the delay queue and commits continuously. A long processing time of 1-5 seconds (3 second average) A moderate rate of 1,000 records a second.  Each consumer application is capable of processing around 300 records concurrently. Consumer group blocking style Consumer group continuous style Share group blocking style The per-consumer poll rate is determined by the highest processing time per batch. The aggregate parallelism is 5000 ( )

0 views
Giles's blog 4 months ago

JAX backends and devices

There's nothing like writing your own code with a framework to clarify how things fit together! Continuing with my port of my PyTorch LLM code to JAX , I wanted to load up a large dataset: the 10,248,871,837 16-bit unsigned integers in the split of . That's just over 19GiB of data. When I ran that, I got a CUDA out-of-memory error: That makes sense! The allocation it was trying to do is exactly the size of the data I was trying to load. I have an RTX 3090 with 24 GiB, but some is already used up by the OS, various apps, and a model that the code creates earlier on. But in PyTorch land, I was used to things being loaded into RAM by default, and only moved over to the GPU when I asked it to do that. JAX was clearly loading to the GPU by default. How could I stop it from doing that for this case? The load into the GPU was happening inside Safetensors, in code I couldn't directly control. Understanding how to do it helped me understand a little bit more about JAX. JAX has a function that looks relevant: . Without reading the docs, let's try running it. In my virtualenv, with the package installed, I get this: That seems a bit weird! I do indeed have a CUDA device, but I also have a CPU, obviously. Why isn't it showing up? Running the same code in another virtualenv, with just installed -- no CUDA -- gets this: OK, so it did recognise it this time. Feels like it might be time to RTFM. The docs explain things a bit: Returns a list of all devices for a given backend. If is , returns all the devices from the default backend. The default backend is generally or if available, otherwise . OK. So JAX has multiple backends -- named that because they're classes of backend hardware that XLA (the compiler behind the JIT) targets. There is a default one, which is essentially going to be the "best" one available given the hardware configuration and the parts of JAX that are installed. When I had the CUDA version installed, it made the backend default, but when I didn't, it defaulted to (and warned me). And because it only shows the devices on the default backend, when that was , I didn't see the CPU. However, you can specify which backend you want to use with that parameter, so let's go back to the virtualenv with CUDA: Great! So is there some way to list which backends are available? Apparently not -- the recommended way appears to be to try loading devices for the different possibilities, and catch to see which ones aren't available. Yuck. But maybe that's not such a big deal. In PyTorch-land I was very much used to putting code like this near the start of my code: ...then moving models to the device: ...and then moving data to the model's device as needed: What I actually wanted was essentially what JAX does -- have everything on the fastest device available at all times -- but with specific exceptions. In particular, the one that started off this investigation: how would I put this huge array of training data on the CPU's RAM rather than the GPU's VRAM? I had a bit of a false start when I spotted that the function in the Safetensors FLAX API has a parameter, but that appears to be more to do with how it loads up the file -- a backend in a different sense. And anyway, backend is not the right concept in JAX-land, as the backend means just something generic like -- for what we're trying to do, we want to load it onto a specific device . After some digging around, I discovered that JAX has a concept of a default device , which is the one used when it doesn't have any indication of where to put something. It makes sense that this will be on the default backend -- indeed, it looks like it's essentially "the first device in the list that returns for the default backend". There is a config option which you can use to set it; you'd normally use or an environment variable to change it. But what if you only want to change it temporarily? I found this documentation for . The docs are more than a little confusing: Context manager for config option. Configure the default device for JAX operations. Set to a Device object (e.g. ) to use that Device as the default device for JAX operations and jit’d function calls (there is no effect on multi-device computations, e.g. pmapped function calls). Set to None to use the system default device. That near the start tripped me up, as I missed the words "Context manager" just below, and the odd type, and tried this: I still got the CUDA OOM, though, so I reread the docs, spotted the "context manager" bit, swore violently, and tried this: ...which works. It looks like the equals sign in the docs is being used to mean something very different to what you'd normally use it for, and they decided not to actually document the signature of the context manager. Heigh ho. I guess documentation is hard . Still, at least now I have a solution. And as I said earlier, doc grumbles aside, the shape of the code might wind up being a little less fiddly than PyTorch. The default location of things I create is the fastest hardware I have, which is what I want. And for the rare exceptions when I don't want to use that, there is a reasonably simple (now that I know it) way to say where I want things to go. I'll call that a win :-) The only thing I'll need to remember is that when, in my training loop, I want to use subsets of that in-RAM tensor, I'll need to move them to the GPU. looks like the right tool for that.

0 views
Xe Iaso 4 months ago

IPv6 zones in URLs are a mistake

IPv6 is weird. One of the more strange parts of the standard is that every interface's link local addresses are in . If you have a machine with two network interfaces, both of them will be in , so if you have a packet destined to , how do you disambiguate it? The answer is you use IPv6 scopes/zones . The exact format of what goes into a zone is OS dependent, but on Linux it's the interface name and on Windows it's the interface ID. This lets the kernel's routing table know how to handle an address range conflict. On my tower, this would be represented like this: Where is the name of my tower's ethernet device. When you create a host:port bindhost, you normally separate the hostname and port with a colon. IPv6 uses colons to separate hex groups. In order to disambiguate what's the host and what's the port, you typically format the IPv6 address in square brackets, so on port 80 would look like this: And with the right scope it looks like this: Now let's get URL encoding into the mix. From high orbit, you can imagine a URL's format as being something like this: An IPv6 zone would then be part of the hostname, just like with that port 80 example from earlier. So you'd think the URL would be something like this: But if you try to parse this as a URL in Go, you get an error: This happens because URLs can't represent all Unicode values, so any values that don't fit into the grammar of a URL become percent-encoded . This is why sometimes you'll see a in URLs in the wild; that's encoding the ascii space key, which is invalid in URLs. In order to work around this, you need to percent-encode the percent sign in the IPv6 zone: In theory, there is guidance for how to properly handle IPv6 zones in user interfaces in RFC 9844 , but there's no such guidance for URLs . Go also does not seem to follow this RFC in net/url . EDIT: It seems that this behaviour is compliant with RFC 6874 and that this is in fact how it is meant to be done. Our industry confounds me. So in the meantime in order for Anubis to point to IPv6 zoned addresses, you need to encode the with percent encoding. This is horrible, but it seems that this is an edge case that applies to other frameworks, programming languages, and libraries: Maybe some day in the future there will be a better option here. In the meantime my policy of not forking the Go standard library means that this somewhat terrible UX for an edge case is acceptable. I hate it, but what can you do? TL;DR: computers were a mistake. https://trac.nginx.org/nginx/ticket/623 https://github.com/psf/requests/issues/6808 https://datatracker.ietf.org/doc/html/draft-schinazi-httpbis-link-local-uri-bcp-03 -- Browsers don't currently support IPv6 zones because it breaks the concept of an "origin" which is used for many subtle things, this RFC draft attempts to define an zone origin in IPv6 so that browsers have a leg to stand on

0 views
Jack Vanlightly 4 months ago

Broker-Visible vs Client-Local Parallelism

This post is a little side-quest from my “Kafka Share Groups and Parallelizing Consumption” series. My “Kafka Share Groups and Parallelizing Consumption” series ( part 1 , part 2 ) has been laser focused on how different configurations and behaviors affect parallel consumption in share groups (Queues for Kafka). So far I’ve shown that you most definitely can hold share groups wrong . You could quite easily and inadvertently create a work queue and with the right combination of things going against you, see a small number of consumers dominate, leaving most consumers starved of messages. All the while lag builds and builds. You need to know the settings and what they do. Don’t just rely on the defaults. But it’s worth asking the question: is parallelizing consumption what share groups are for? The answer is no. If your only concern is parallel consumption, then there are other options. Chuck Larrieu Casias wrote a good post on LinkedIn pointing out that people shouldn’t be thinking of share groups as THE solution to parallelizing work (without exploding the partition count). Share groups exist to expose queue-like semantics over a log. Unlike a normal consumer group, a share group lets you accept one record and reject another for retry. A consumer group tracks one committed offset per partition. A share group has to track many individual records independently: which records are available, which have been delivered (to whom), which have been acknowledged, and which should become available again. But just because share groups don’t exist primarily to parallelize work doesn’t mean it’s not a tool that can be used for that purpose. If your messages are independent or you are otherwise ok with loose ordering then share groups could be a simple choice for breaking away from partition count as the unit of parallelism. The central theme I took from Chuck’s post is that parallelism has to be accounted for somewhere . The unit of parallelism can be broker-visible and broker-managed, or client-local and client-managed. Broker-visible/managed can only take you so far. When you need to process 1,000 messages in parallel to cope with the producer rate, what represents those 1,000 parallel units of work? Is it partitions, consumers, virtual threads/async tasks? If the unit of parallelism is the consumer itself then we must scale out serial consumers to scale the parallel processing (with a matching partition count with consumers groups). Every parallel unit of work (consumer) becomes visible to the broker as protocol interactions and state plus one or more TCP connections. If parallelism comes in part from the client itself, the unit of parallelism could be a virtual thread, an async task or even an OS thread. This is invisible to the broker. You need fewer consumers, fewer TCP connections, and less broker-visible protocol interaction/state.  This split of where the unit of parallelism is accounted for, broker-side vs client-side, exists across all messaging systems. It’s not specific to Kafka. A simple calculation for aggregate parallelism is easy: 60000 msg/s * 1s = 60000 60000 msg/s * 5s = 300000 100 msg/s * 20s = 2000 10000 msg/s * 0.5s = 5000 50 msg/s * 5s = 250 Once you know how many messages must be processed in parallel, you can figure out your tactics. The formula tells you how much parallelism you need, then it’s up to you to figure out where that parallelism should live. Let’s use our 60,000 messages per second workload from the share group series. If it takes 1 second to process each message, then we need to support 60,000 messages being processed at any given moment. If each unit of parallelism is a serial consumer, then that means 60,000 consumers! That’s a lot of connections, a lot of protocol state, and a really big consumer group. What if it takes 10 seconds on average to process a message, you’d need 600,000 consumers, and well over 1 million TCP connections! If most of the work is I/O, and the CPU spends a lot of time waiting around then can’t we make a single client do more work? What if one client can handle processing 1000 messages in parallel? Then we’d only need 60 consumers for the “60K msg/s + 1 second processing time” example.  Fig 1. Left: Parallel work across N serial consumers. Right: Parallel work across N parallel-capable consumers. If the ultimate unit of parallelism is visible to the broker as something it must manage, it can get really expensive in resources for highly parallel workloads (no matter which messaging system you use). Managing virtual threads, or even OS threads, is much cheaper than managing one or more TCP connections + metadata per unit of parallelism. This is true of all messaging systems I have ever used. The cost is greater complexity on the client, but if you don’t want to roll your own logic, there are libraries to help here (see Chuck’s post for some). Unfortunately, the ParallelConsumer library is no longer being maintained (though a fork might be in the future). This library not only added internal client-side parallel processing but queue semantics as well (on top of consumer groups). Now that we have share groups, perhaps we need a new library that adds client-side parallelism to share groups. I’m going back to writing Part 3 of my parallelism in share groups series. We’ll be comparing broker-managed vs client-managed parallelism with share groups and consumer groups. 60000 msg/s * 1s = 60000 60000 msg/s * 5s = 300000 100 msg/s * 20s = 2000 10000 msg/s * 0.5s = 5000 50 msg/s * 5s = 250

0 views
Matthias Endler 4 months ago

How Other Link Checkers Do Recursion

After I published Five Years of Trying to Add Recursion to lychee , one reply I got was a very fair question: If recursion is so hard, how do other link checkers do it? Plenty of them already crawl websites! This sent me down a rabbit hole of reading the code of other link checkers. The key takeaway is: they didn’t find a clever trick we missed. They were built as crawlers from the very first commit, and I initially built lychee as a stream. I went and read the source of the recursive checkers we list in lychee’s README : muffet (Go), LinkChecker (Python), linkinator (TypeScript), and broken-link-checker (JavaScript). This post is a teardown of how each one actually handles recursion, what it costs them, and what it means for lychee. If you haven’t read the first post , the summary is that lychee was architected as a one-shot, unidirectional pipeline ( ). Recursion needs a cycle (responses create new inputs), and cycles in an async, channel-based pipeline are where the dragons live . 🐲 Five years and four attempts later, the pieces we’ll need to do it properly only just landed. DAGs vs. cycles Every recursive checker I looked at is built from the same three parts: Diagrammatically, lychee is different from the others: Crawlers have a back-edge baked in. Our pipeline doesn’t, and every one of my failed attempts was an effort to bend that back-edge into a graph that was never designed for it. Let’s look at that graph design more closely: Note that the visited check happens in the enqueue step, atomically with the mark, before the worker ever touches the network. That ordering is the entire fix to the deduplication race that haunted lychee’s attempts 1–4, where the cache was written after checking. Each tool uses a variation on it. muffet (Go): a WaitGroup and a Set muffet is closest in spirit to lychee: a fast, single-binary, concurrent website checker. The dedup + scheduling decision lives in one method ( ): is a (a mutex-guarded ). returns whether the URL was already present, so a page is only scheduled the first time it’s seen. Dedup happens at enqueue, synchronized by the set’s mutex. This is basically a line-by-line translation of the diagram above. Checking a page fetches all of its links concurrently, and feeds qualifying ones back into , the back-edge: How muffet knows it’s done muffet’s answer to termination is a little built around a ( ): Every scheduled page increments the group; every completed page decrements it; returns when the count hits zero. The whole crawl bootstraps with a single before , so the counter is positive before anyone waits on it. This is the same counter I tried (and failed with) in Attempt 1 and Attempt 4 . The difference is the invariant: is only ever called from inside an already-running daemon that holds the count above zero (or from the bootstrap). There is no window where the counter briefly reads zero while work is still pending. Go’s enforces this invariant so naturally that it doesn’t feel like distributed termination detection at all, but that’s exactly what it is. It’s the moral equivalent of the primitive Kait contributed to lychee in 2026 . Where the tradeoffs are Concurrency isn’t bounded by the daemon manager. does for every task, spawning unbounded goroutines. The actual limiting happens downstream in a (a buffered-channel counting semaphore) and a per-host throttler pool. muffet separates “the frontier” from “the rate limiter,” which is exactly the separation lychee lacked when it tried to use one bounded channel as both in the past. Cheap goroutines do a lot of heavy lifting. Spawning a goroutine per link is “fine” in Go. The equivalent in Rust ( per link, each needing state) is what pushed me toward and the ownership pain I wrote about . On extensibility, muffet is a focused CLI, not a library. There’s no plugin surface; you get what the flags give you. lychee deliberately ships as a reusable crate, which raises the bar, since every architectural choice has to uphold the standards of a public API. On scalability, unbounded goroutines plus an in-memory visited set scale comfortably to large sites, but there’s no disk-backed frontier, so a truly enormous crawl is bounded by RAM. Same as lychee. Takeaways: muffet LinkChecker (Python): a joinable unbounded queue LinkChecker has existed since the year 2000. It’s a synchronous, thread-pool crawler. Its frontier is a hand-written ( ), a clone of Python’s with / . Look at the very first design comment: It’s explicit about the exact deadlock that bit me. That comment is our Attempt 4 backpressure deadlock , called out and designed around. lychee tried to push discovered URLs into a bounded channel; when it filled, the response handler blocked, no responses drained, no slots freed. Deadlock. 💥 LinkChecker’s answer is brutalist in nature: the frontier is unbounded . Backpressure is enforced elsewhere (a fixed thread count and per-host throttling), never by blocking a producer that is also a consumer. Termination by counter, done right blocks until hits zero ( ): Again: a counter. But the increment in and the decrement in are both inside the queue’s lock, and a worker calls only after fully processing an item including enqueuing its children . So children are counted before the parent is marked done, with no premature zero. It’s semantics implemented with a mutex and a condition variable. Deduplication, before the request LinkChecker writes the URL into its result cache at enqueue time ( ): That sentinel is a “fix” that’s missing in lychee’s attempts. By the time any worker thread checks the URL, the cache already says “mine,” so concurrent discovery from another page is a no-op. Per-host politeness and termination guards The ( ) throttles per host: and calls so a stuck crawl can’t hang forever. Where the tradeoffs are Blocking threads instead of async. Each of the (default 10–100) threads does blocking I/O via . Simple and battle-tested, but the concurrency ceiling is the thread count, and each thread carries a full stack. lychee’s Tokio model reaches thousands of concurrent in-flight requests on a handful of OS threads; LinkChecker can’t, and doesn’t try. The unbounded frontier trades a deadlock for unbounded memory. The explicit “no max size” decision means RAM growth on huge sites. There’s a cap and a periodic to mitigate it. Extensibility is excellent. LinkChecker has a real plugin system ( : anchor checks, SSL, virus scanning, and more) and many output loggers. This is the most extensible of the bunch, and it pays for that with a large, mature, somewhat old-fashioned codebase. On scalability, it’s GIL-bound and thread-limited, so raw throughput is the lowest here, but correctness and feature coverage are high. Takeaways: LinkChecker linkinator (TypeScript): Single-Threaded linkinator is a Node.js checker, and it benefits from something neither Go nor Rust provides: a single-threaded event loop . Check-and-insert into the visited set is atomic for free , because no two callbacks run simultaneously. The frontier is a concurrency-limited (a p-queue-style structure). Termination is one line in ( ): is the library’s termination detection: it resolves when the queue is empty and no task is in flight. Same idea as muffet’s and LinkChecker’s , just expressed as a promise and backed by a single-threaded runtime, so no Mutex is needed to protect the visited set. The back-edge and the race-free dedup When crawling, GETs the page, extracts links, and for each new URL re-enters the queue ( ): Because JavaScript is single-threaded, the entire thing executes without interruption. In Rust or Go, that’s a critical section you must guard with a mutex (and get the ordering right); in Node it’s just three statements. This is the single biggest reason recursion is easier in Node than in Rust. It’s just a language feature. linkinator also keeps a of keys, and a map so it can wait on an in-flight check and still report a duplicate broken link against every parent that references it. Those reuse-operations are themselves pushed onto the same queue, so correctly waits for them too. HEAD vs GET linkinator uses for leaf links but when it needs to crawl, because recursion needs the response body to find more links : This is precisely lychee’s remaining open problem : you can only recurse into pages you fetched with a body. linkinator just always GETs when crawling; lychee plans to reuse the body it already has in cache from the check it just performed. Where the tradeoffs are Single-threaded is both a blessing and a ceiling. No data races, trivially correct dedup, but HTML parsing is CPU work that blocks the one event loop. For thousands of pages, you’re bound by a single core. lychee’s multi-threaded runtime parses and checks in parallel. It suffers from in-memory result inflation. The source explicitly comments on “massive result inflation for heavily interlinked sites”: the array, , and all grow with the crawl. Fine for a docs site, heavy for a giant one. Rate limiting is reactive, not proactive. There’s a that backs off per host on a with , but no general per-host concurrency cap like lychee’s . linkinator can hammer a host until it complains; lychee now paces before the complaint. For extensibility, it’s an ( , , and so on), so it’s embeddable and scriptable, which is nice. It’s a library first, like lychee. Takeaways: linkinator broken-link-checker (JavaScript): event-driven, using two queues broken-link-checker (BLC) takes the event-driven model furthest. It’s built on , a queue with (concurrency) and , and it nests two of them: a site-level queue feeding a page-level . The frontier and dedup live in ( ). Visited pages are tracked in a , written at enqueue time: Recursion is governed by a filter that decides whether a discovered link becomes a crawled page: Termination by event cascade BLC has no counter and no . It rides the queue’s drain events. When the page-level queue empties it fires , which makes emit and call the site queue’s callback; when the site queue drains, it fires . That’s the public : That’s their termination detection, expressed as “the request queue reported empty.” And in classic Node.js fashion, the callback is what actually tells the site queue to free up a slot for another site. So the termination of one site is what allows another to start, and the termination of the whole crawl is what allows the process to exit. It’s a cascade of events that propagates from the page queue to the site queue to the process. Where the tradeoffs are It’s the best web citizen of the bunch. robots.txt is honored ( , ), is respected, and plus are first-class. This is a crawler that’s polite by default. Event cascades are powerful but fiddly. Termination is spread across half a dozen event handlers and two nested queues. It works, but the control flow is much harder to follow than . This is the JS cousin of the “leaky abstraction” problem I described, where recursion-awareness ends up sprinkled across many handlers. It’s single-threaded, the same ceiling as linkinator, plus the in-memory per site. On maturity versus momentum, it’s very widely used (it powers a lot of tooling), but development has slowed. The architecture is still sound and worth studying. Takeaways: broken-link-checker A note on markdown-link-check and the “industrial” crawlers Our README marks markdown-link-check as supporting recursion, but there’s some nuance there: it recurses over Markdown files , not by spidering a live website. There’s no HTTP frontier and no termination problem in the sense above. Worth a mention so the comparison is honest, not worth a teardown. If you want to see the pattern at full industrial scale, look at Scrapy (Python/Twisted) or Colly (Go). Both use the same approach: a scheduler (frontier) with a pluggable, optionally disk-backed queue, a dupefilter (often a Bloom filter rather than a ), a bounded downloader pool, and explicit “engine idle → close spider” termination. They solve exactly the problems lychee struggled with ( distributed termination detection , backpressure, dedup), just with years of dedicated crawler engineering behind them. The takeaway isn’t “lychee should be Scrapy”: it’s that crawling is a well-trodden architecture, and lychee is simply standing on a different one right now. Side-by-side Tool Lang / runtime Concurrency model Frontier “Done?” signal Dedup point Per-host limiting muffet Go, goroutines goroutine pool + semaphore + host throttler mutex-guarded set + daemon channel visited set at enqueue host throttler pool LinkChecker Python, threads fixed blocking thread pool unbounded joinable-queue counter ( ) result cache at (req/s) linkinator Node, event loop single-thread + p-queue ( ) p-queue at enqueue (race-free) reactive broken-link-checker Node, event loop ( ) nested request queues queue-drain events at enqueue + lychee (2026) Rust, Tokio tasks + channels + per-host pool lychee in 2026 finally has a column-for-column match. The is muffet’s and LinkChecker’s . The is BLC’s / and LinkChecker’s . The per-URI mutex is everyone’s enqueue-time dedup. So Why Couldn’t We Just Copy Them? Three reasons, in increasing order of how much they’re actually lychee’s fault. They started as crawlers; lychee started as a stream. Every tool above has a back-edge in its core data structure. lychee’s core was a DAG optimized for the 99% case (a list of files/URLs, checked once, fast). Retrofitting a cycle onto a pipeline is much harder than having one from the start. The problem is architectural in nature. The frontier and the rate-limiter must be different objects. muffet (set + semaphore), LinkChecker (unbounded queue + thread count), linkinator (p-queue + delayCache), BLC (request queue + maxSockets) all keep “what to do next” separate from “how fast to go.” lychee’s early attempts tried to make one bounded channel serve both roles, and a cycle through a bounded channel deadlocks. The fix (lychee’s plus a over an unbounded work source) is the same separation we’re aiming for now. Single-threaded runtimes get dedup for free. Both Node tools dedup with a plain and zero locking, because the event loop serializes access. Go and Python pay a mutex. Rust pays a mutex and fights the borrow checker about who owns the shared state across . That’s the ~30% “Rust tax” I estimated last time : not the algorithm, but the friction of expressing shared mutable frontier state under . None of this is a knock on lychee’s design. A unidirectional stream is the right call for the common, non-recursive case: it’s why lychee is fast and why the 30% channel regression from Attempt 2 was a dealbreaker. The other tools pay for their back-edge on every run, recursive or not. lychee refused to, and that principle is exactly why recursion took five years and why, when it lands, it won’t slow down the path everyone actually uses. I believe that we can have our cake and eat it too: a crawler architecture that supports recursion without sacrificing the speed of a one-shot pipeline. But it’s a harder problem than just “copy what they do,” because most link checkers didn’t start with uncompromising performance as their top goal. Key takeaways So when someone asks “how do other link checkers do recursion?”, the real answer is: they made it a part of the architecture from the beginning, and they leaned on a runtime (providing conveniences like a , a joinable queue, an idle promise) that solved termination without solving “distributed termination detection.” Thanks to the maintainers of muffet, LinkChecker, linkinator, and broken-link-checker: reading your source is the clearest way to learn about crawler architecture out there and we’re all in this together, just with a different set of tradeoffs. A mutable work queue (let’s call it “frontier”), not a fixed input stream. Discovered URLs go back into the same queue they came from. A visited set that’s updated at enqueue time (before the request completes), so two pages discovering the same link can’t both submit it. A primitive that answers “is everything done?”: a , a joinable-queue counter, an promise, or a queue-drain event. Concurrency isn’t bounded by the daemon manager. does for every task, spawning unbounded goroutines. The actual limiting happens downstream in a (a buffered-channel counting semaphore) and a per-host throttler pool. muffet separates “the frontier” from “the rate limiter,” which is exactly the separation lychee lacked when it tried to use one bounded channel as both in the past. Cheap goroutines do a lot of heavy lifting. Spawning a goroutine per link is “fine” in Go. The equivalent in Rust ( per link, each needing state) is what pushed me toward and the ownership pain I wrote about . On extensibility, muffet is a focused CLI, not a library. There’s no plugin surface; you get what the flags give you. lychee deliberately ships as a reusable crate, which raises the bar, since every architectural choice has to uphold the standards of a public API. On scalability, unbounded goroutines plus an in-memory visited set scale comfortably to large sites, but there’s no disk-backed frontier, so a truly enormous crawl is bounded by RAM. Same as lychee. muffet’s termination is a , full stop. It’s the design lychee converged on after five years; muffet got it for free from Go’s standard library on day one. The frontier and the concurrency limiter are separate things. A mutex-guarded set is the frontier; a semaphore plus host throttler bounds concurrency. Conflating them is what deadlocked lychee. Goroutines hide the cost that Rust makes you pay explicitly. The same per-task model that’s trivial in Go is where Rust’s /ownership friction shows up. Blocking threads instead of async. Each of the (default 10–100) threads does blocking I/O via . Simple and battle-tested, but the concurrency ceiling is the thread count, and each thread carries a full stack. lychee’s Tokio model reaches thousands of concurrent in-flight requests on a handful of OS threads; LinkChecker can’t, and doesn’t try. The unbounded frontier trades a deadlock for unbounded memory. The explicit “no max size” decision means RAM growth on huge sites. There’s a cap and a periodic to mitigate it. Extensibility is excellent. LinkChecker has a real plugin system ( : anchor checks, SSL, virus scanning, and more) and many output loggers. This is the most extensible of the bunch, and it pays for that with a large, mature, somewhat old-fashioned codebase. On scalability, it’s GIL-bound and thread-limited, so raw throughput is the lowest here, but correctness and feature coverage are high. The unbounded frontier is a deliberate anti-deadlock choice, documented in a one-line comment. It describes the exact problem we hit in lychee in attempt 4. Dedup at time (a placeholder in the cache) is their synchronization mechanism. The cache must claim the URL before the request, not after. Threads buy simplicity at the cost of throughput. A blocking thread pool is the easiest correct model… and the slowest one. Single-threaded is both a blessing and a ceiling. No data races, trivially correct dedup, but HTML parsing is CPU work that blocks the one event loop. For thousands of pages, you’re bound by a single core. lychee’s multi-threaded runtime parses and checks in parallel. It suffers from in-memory result inflation. The source explicitly comments on “massive result inflation for heavily interlinked sites”: the array, , and all grow with the crawl. Fine for a docs site, heavy for a giant one. Rate limiting is reactive, not proactive. There’s a that backs off per host on a with , but no general per-host concurrency cap like lychee’s . linkinator can hammer a host until it complains; lychee now paces before the complaint. For extensibility, it’s an ( , , and so on), so it’s embeddable and scriptable, which is nice. It’s a library first, like lychee. is the termination mechanism. Simple and provided by the JS runtime. A single-threaded event loop makes request deduplication pretty much free. This is the biggest structural reason recursion is easier in that case. Reactive 429 backoff is not the same as proactive per-host pacing. lychee’s aims higher, at the cost of more machinery. It’s the best web citizen of the bunch. robots.txt is honored ( , ), is respected, and plus are first-class. This is a crawler that’s polite by default. Event cascades are powerful but fiddly. Termination is spread across half a dozen event handlers and two nested queues. It works, but the control flow is much harder to follow than . This is the JS cousin of the “leaky abstraction” problem I described, where recursion-awareness ends up sprinkled across many handlers. It’s single-threaded, the same ceiling as linkinator, plus the in-memory per site. On maturity versus momentum, it’s very widely used (it powers a lot of tooling), but development has slowed. The architecture is still sound and worth studying. Termination is a cascade of queue-drain events, not a counter. Same idea, different syntax. Politeness is built in. robots.txt, , and make it the most server-friendly recursive checker by default. Event-driven control flow is the cost. Distributing recursion logic across many handlers is exactly the kind of spread-out complexity that makes the feature hard to reason about. There is no secret sauce. Every recursive checker is a worklist plus a visited set plus a quiescence detector. The “trick” is being shaped like a crawler from commit one. Termination is always the same idea wearing different clothes: (muffet), joinable-queue counter (LinkChecker), (linkinator), queue-drain events (BLC), (lychee 2026). All of them are distributed termination detection. Dedup belongs at enqueue, before the request. Marking a URL visited after checking it (what lychee did for four attempts) is the bug. Everyone else claims the URL the moment it enters the frontier. Separate the frontier from the rate limiter. A bounded channel that is both your queue and your backpressure will deadlock the instant you add a cycle. There is no free lunch. Node’s single thread makes dedup trivial at the cost of performance; Go’s goroutines and make termination trivial at the cost of a runtime; Rust gives you neither for free but hands you a compiler that refuses to let the races compile and you can get the network card to glow if you know exactly what you are doing.

0 views
Frederik Braun 4 months ago

The S in interoperability

This is a blog post about standards, their proliferation and the issues that may arise. My first involvement with standards was just as a reader. To better understand complicated code or unexpected behavior in a protocol. After a while, I also got involved and helped clarify certain things to ensure …

0 views