Hot-Reloading Kubernetes Secrets and ConfigMaps in Go With mamori (No Pod Restarts)
One Database, Two Models: Building AI-Friendly Java Applications With MySQL JSON Duality Views
Agentic AI Threat Intelligence Essentials
Getting Started With Agentic AI for SecOps
Google Cloud Secret Manager has a clean, opinionated model: a secret is a container, and inside it live immutable, numbered versions. You never edit a version — you add a new one (1, 2, 3, …) and, when it's ready, you move traffic to it, usually by resolving the latest alias (or by disabling the old version). It's a genuinely good design. But it collides with how most Go services consume secrets: you call AccessSecretVersion("latest") exactly once, at startup, and cache the result. From that moment your process is pinned to whatever latest meant at boot. Add version 4, disable version 3, and your service happily keeps using 3 until someone restarts it — which, at best, is a scheduled redeploy and, at worst, an incident when the disabled version stops resolving. mamori is a Go library I built to make config and secrets watchable. Point it at a GCP secret and it notices when latest moves, pulls the new version, re-validates your whole config, and swaps it in atomically — while your service keeps running. This is the GCP-focused companion to the launch article, Introducing mamori: Typed, Watchable Config & Secrets for Go. That one covers the full model; here we focus on Secret Manager's version semantics. The gcp-sm Scheme The providers/gcp module registers the gcp-sm scheme. The grammar is gcp-sm://<project>/<secret>[#json-key][?version=<v>]: Plain Text gcp-sm://my-project/db-password # resolves the "latest" enabled version gcp-sm://my-project/api-key?version=3 # pin to version 3, forever gcp-sm://my-project/creds#password # one key out of a JSON secret payload That ?version= option is the important knob, and it maps directly onto GCP's version model: No ?version → mamori tracks latest. When you add a new version and it becomes latest, mamori reconciles it in. This is what you want for rotating credentials.?version=N → mamori pins to an immutable version N. It will never move. This is what you want for config you want to promote deliberately and reproducibly (think: "this deploy runs config version 7, full stop"). Setup Shell go get github.com/xavidop/mamori go get github.com/xavidop/mamori/providers/gcp Register with a blank import; it uses Application Default Credentials, so on GKE it picks up Workload Identity automatically, and locally it uses your gcloud auth application-default login: Go import _ "github.com/xavidop/mamori/providers/gcp" The service account you bind needs roles/secretmanager.secretAccessor on the secrets it reads (and the ability to see version metadata to detect a new latest). Scope it to the specific secrets, not the whole project. Load, then watch latest move Go package main import ( "context" "log" "time" "github.com/xavidop/mamori" "github.com/xavidop/mamori/secret" _ "github.com/xavidop/mamori/providers/gcp" ) type Config struct { // Tracks "latest": rotates in when you add a new enabled version. DBPassword secret.String `source:"gcp-sm://my-project/db-password"` // Pinned to an immutable version: deliberate, reproducible. StripeKey secret.String `source:"gcp-sm://my-project/stripe-key?version=7"` // One field out of a JSON secret. APIToken secret.String `source:"gcp-sm://my-project/creds#api_token"` } func main() { ctx := context.Background() w, err := mamori.Watch[Config](ctx, mamori.WithPollInterval(60*time.Second), mamori.OnChange(func(ev mamori.Change[Config]) { if ev.Changed("DBPassword") { pool.Rotate(ev.New.DBPassword.Reveal()) // live, no restart log.Println("db-password: new version reconciled") } }), mamori.OnError(func(err error) { metrics.Inc("config_error") }), ) if err != nil { log.Fatal(err) } defer w.Close() cfg := w.Get() // last VALID config, lock-free _ = cfg } Every secret field here is a secret.String, which redacts itself in fmt, slog, and JSON — so nothing leaks into logs — and only reveals plaintext through an explicit .Reveal(). mamori's go vet analyzer will even fail your build if you store a gcp-sm:// value in a plain string. What Reconciliation Actually Does Here When you rotate — add version 4 to db-password and let it become latest — mamori's poll cycle notices that the resolved version changed, fetches it, and then: Re-validates the entire struct. If the new secret somehow fails a validate rule, the update is rejected: Get() keeps returning the last good value and OnError gets a *ValidationError. A bad version can't take your service down.Swaps atomically. w.Get() transitions from old to new in one lock-free store — no reader ever sees a partially-updated config.Fires a diff-aware callback. OnChange tells you which fields moved (ev.Fields) with old/new versions, so you only rebuild what actually changed. Because Secret Manager has no push channel for "latest moved", mamori polls — and it polls politely: the interval is jittered by ±20% so a large fleet doesn't stampede the API on the same tick, and change detection compares version metadata before pulling the payload, so an unchanged secret is cheap. Structured Secrets With flatten Teams often store a whole connection blob as one JSON secret. Decode it straight into a nested struct: Go type Config struct { DB DBConfig `source:"gcp-sm://my-project/db-conn" flatten:"json"` } type DBConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` Password secret.String `mapstructure:"password"` } One AccessSecretVersion call, one JSON document, cleanly typed — with the password still redacting itself. Version Pinning as a Deploy Strategy The ?version= toggle enables a genuinely nice workflow. Keep operational secrets on latest so rotations flow in automatically, but pin config that changes behavior to explicit versions so a rollout is deterministic and rollback is trivial: Go type Config struct { DBPassword secret.String `source:"gcp-sm://my-project/db-password"` // latest: auto-rotate FeatureFlags []byte `source:"gcp-sm://my-project/flags?version=12"` // pinned: promote on purpose } Bumping ?version=12 to ?version=13 becomes a reviewable, revertible change in your code — while credentials keep rotating themselves in the background. Wrapping Up GCP Secret Manager's immutable-versions model is great, but only if your app actually follows latest at runtime. mamori makes it do that: it watches the secret, reconciles a new version into your running Go process, validated and redacted, and lets you pin the fields you want frozen. Shell go get github.com/xavidop/mamori/providers/gcp Full introduction: Introducing mamoriDocs: mamorigo.dev/docsSource: github.com/xavidop/mamori mamori is MIT-licensed and at v0.1.0 — early and moving fast, so issues and feedback are very welcome. Happy coding!
Why Every TLS Termination Is a Separate Migration Boundary A cloud application rarely has one TLS connection. A single API request can be decrypted and re-encrypted at a CDN, a web application firewall, an API gateway, a load balancer, a service-mesh proxy, an application runtime, and a managed cloud endpoint. Each termination negotiates a fresh session. Upgrading only the public edge can leave every downstream hop protected by classical key exchange. That architectural fact changes how teams should approach post-quantum TLS. The right unit of migration is not the hostname, service, cluster, or application. It is the directional TLS link between two adjacent termination points, complete with an owner, a policy, a negotiated group, and runtime evidence. Figure 1. Hybrid negotiation must be configured and proved independently on every TLS link. An edge-only upgrade does not protect downstream hops. The Unit of Migration Is the TLS Link Treat the request path as a graph. The nodes are clients, gateways, proxies, workloads, and managed services. Every edge is an independently negotiated TLS connection. For each edge, record the source and destination, the team or provider that owns it, the data's confidentiality lifetime, the full-handshake rate, the selected key-establishment group, the authentication algorithm, the policy that produced the configuration, and the evidence that proves the result. This link-level model prevents an attractive but misleading label such as "PQC enabled." A client can advertise a hybrid group without the server selecting it. A gateway can negotiate hybrid on ingress and classical TLS to the backend. A service mesh can provide pervasive mTLS while its certificates still use classical signatures. Readiness must be measured from observed behavior, not configuration intent. Design rule: Count a link as post-quantum protected only when telemetry proves that it successfully negotiated an approved hybrid or post-quantum group. What X25519MLKEM768 Protects, and What It Does Not TLS 1.3 makes three largely independent cryptographic choices: a symmetric record cipher and hash, a key-establishment group, and an authentication mechanism. X25519MLKEM768 changes the second choice. It combines classical X25519 with NIST-standardized ML-KEM-768 and feeds both 32-byte shared secrets into the TLS 1.3 key schedule. The hybrid construction is designed so the session secret remains protected if either component remains secure, subject to the combiner and transcript assumptions. That is useful against a harvest-now, decrypt-later adversary recording traffic today. It does not make an RSA- or ECDSA-signed certificate quantum resistant, and it does not change AES-GCM record protection. TLS PROPERTY WHAT HYBRID PROVIDES WHAT STILL REMAINS Recorded-traffic confidentiality A combined X25519 and ML-KEM session secret Every link carrying the data must negotiate hybrid Server authentication Normal TLS transcript authentication Classical RSA or ECDSA remains quantum vulnerable Client authentication in mTLS Conventional certificate-based mTLS Client certificate and trust chain need a separate signature migration Bulk encryption Unchanged TLS 1.3 record protection Key establishment changes; AES-GCM does not Fallback Classical peers may still connect when policy allows Fallback must be visible, bounded, and eventually retired Resumption A PSK can inherit security from the original session PSK-only resumption has no fresh key exchange Use honest labels: Publish post-quantum key-establishment coverage and post-quantum authentication coverage as separate fields. A hybrid key exchange is not complete post-quantum TLS. The Benchmark: Modest Compute, Much Larger First Flights The research used OpenSSL 3.6.3 and paired in-memory BIOs to isolate protocol encoding, allocation, signature work, and key-establishment CPU from network latency. Across 120,000 successful full TLS 1.3 handshakes on an Apple M3 Pro, the only intended variable was X25519 versus X25519MLKEM768. Session tickets and caches were disabled, and each configuration used the same ECDSA P-256 certificate and TLS_AES_128_GCM_SHA256. SINGLE-WORKER MEASURE X25519 X25519MLKEM768 CHANGE TLS-record bytes 1,172 3,438 +2,266 (+193.3%) Process CPU per handshake 332.5 us 416.8 us +84.3 us (+25.3%) Throughput 2,977 hs/s 2,389 hs/s -19.7% Median elapsed time 320.3 us 405.0 us +84.7 us The raw hybrid key shares add 2,272 bytes: a 1,184-byte ML-KEM encapsulation key in the ClientHello and a 1,088-byte ML-KEM ciphertext in the ServerHello. In the measured handshake, surrounding encoding reduced the observed TLS-record delta slightly to 2,266 bytes. The important deployment effect is that a previously small ClientHello can cross packet boundaries and expose brittle middleboxes, parsers, or firewalls before cryptographic CPU becomes the bottleneck. Figure 2. Throughput plateaus beyond 16 workers on the 11-core host. At saturation, hybrid capacity is about 13.4 thousand full handshakes per second versus 16.0 thousand for X25519, while tail latency rises under oversubscription. Connection Reuse Determines the Real Cost The isolated benchmark is intentionally a worst case for connection churn: every request creates a fresh connection. In real cloud systems, HTTP/2, long-lived gRPC streams, SDK connection pools, and keep-alive amortize the handshake across many application requests. Aggressive idle timeouts, autoscaling bursts, DNS churn, unbalanced pools, serverless cold starts, and per-request clients can erase that advantage. REQUESTS PER CONNECTION FULL HANDSHAKES/S AT 50,000 REQUESTS/S ESTIMATED EXTRA CPU ESTIMATED EXTRA BANDWIDTH 1 50,000 4.214 cores 906.4 Mb/s 10 5,000 0.421 core 90.6 Mb/s 100 500 0.042 core 9.1 Mb/s 1,000 50 0.004 core 0.9 Mb/s Operational implication: Measure requests per connection by source-destination pair. Capacity tests should include cold starts, failover, and connection-pool disruption, not only steady-state request traffic. Cloud Controls Are Directional, Versioned, and Explicit The implementation landscape is uneven. A product may support the hybrid group on one direction, behind one policy name, or only when linked with a recent cryptographic library. The control plane therefore needs a capability registry that records provider, product version, binary build, direction, supported group names, policy provenance, and verification method. Managed Gateways and Edges Amazon API Gateway documents enhanced security policies with PQ for incoming REST API connections, while its integration egress remains a separate TLS link. Treat ingress and backend connectivity as different inventory records.Application Load Balancer policies can expose hybrid key exchange and log the selected group. Set the listener policy explicitly in infrastructure as code because console and automation defaults can differ.Cloudflare can extend hybrid key establishment from its edge to the origin, but the origin connection remains a distinct boundary that must be tested and observed. Service Meshes Envoy and Istio provide central levers for internal mTLS, but a recent proxy image alone is not evidence that ordinary mesh traffic negotiated X25519MLKEM768. Default curve lists, BoringSSL builds, FIPS profiles, and rolling-upgrade revisions can differ. Canary a preferred policy by namespace or proxy revision, inspect configuration dumps, and probe both inbound and outbound paths. Plain Text meshConfig: meshMTLS: minProtocolVersion: TLSV1_3 ecdhCurves: - X25519MLKEM768 - X25519 Application and Egress TLS Database drivers, message brokers, SDKs, and application-managed HTTPS clients may bypass the mesh or establish TLS after a CONNECT tunnel. Their linked runtime matters. OpenSSL 3.5 and later support ML-KEM and hybrid groups, while NGINX passes group configuration to its linked OpenSSL library. Pin patched versions, record the provider build, and observe selected-group distributions after upgrades. Infrastructure rule: Every managed listener and every application-controlled TLS client must receive an explicit, versioned policy. An omitted default should fail review. Roll Out Hybrid TLS as an Observable State Machine A Boolean flag such as pqc_enabled hides peer compatibility, mixed proxy versions, middlebox failures, classical fallback, and policy drift. Use explicit states whose transitions depend on evidence and whose rollback changes policy rather than rebuilding software. Figure 3. Each state transition is gated by interoperability, performance, capacity, and rollback evidence. A failed promotion returns the scope to the last approved policy without removing hybrid support from the software image. Observe. Upgrade clients, proxies, and libraries so they can advertise the hybrid group without requiring it. Inventory directional links and record offered groups, selected group, TLS version, resumption state, certificate signature algorithm, failure stage, build, and policy ID. Prefer. Put X25519MLKEM768 first while retaining time-bounded X25519 fallback. Roll out by listener, namespace, mesh revision, client cohort, or traffic percentage. Compare the canary with a matched classical baseline. Require. Move only capability-complete scopes to hybrid-only. Internal mTLS segments and controlled partner endpoints are usually better candidates than broad public APIs with unknown older clients. Retire. Remove classical groups from required scopes, deny legacy policy creation, close exceptions, and verify that health checks, direct pod paths, backup regions, and administrative endpoints do not bypass the policy. Promote on Evidence, Not Configuration The minimum useful dashboard describes both the security result and the operational cost. Every measurement must be attributable to a directional link, proxy or library build, and policy digest so the team can separate peer incapability from middlebox failure, local misconfiguration, or capacity exhaustion. SIGNAL REQUIRED DIMENSIONS EXAMPLE PROMOTION GATE Negotiated group Source class, destination, listener, build, policy ID >= 99.99% hybrid in a controlled scope; every remainder explained Handshake outcome Error stage, alert, timeout, HRR, retransmit, path class No statistically significant success regression Resource cost Full/resumed, CPU, queue time, p50/p95/p99, saturation p95 within budget and at least 30% CPU headroom Connection reuse Requests/connection, connection age, idle-close reason Reuse ratio meets a workload-specific target Authentication state Server/client certificate algorithm, chain, issuer Authentication coverage reported separately Configuration provenance Canonical policy, provider translation, digest, deploy ID 100% of observed links map to an approved digest Exception state Owner, reason, risk weight, expiry, compensating control No expired exception; weighted coverage increases each release Choose the First Rollout by Risk, Not Convenience Three rollout patterns are useful, and most programs will combine them. Order the work by confidentiality lifetime and capture feasibility so that long-lived, internet-facing health data, credentials, source code, and secrets outrank low-risk endpoints that are merely easy to upgrade. Edge-first reaches the most capturable public segment quickly and produces compatibility data, but downstream re-encryption can remain classical.Mesh-first can move a large controlled workload population through one proxy fleet and provide consistent telemetry, but proxy revisions can diverge, and workload certificates may still use classical signatures.SDK-first protects direct calls to capable cloud services, including selected secrets and key-management paths, but support varies by language runtime and connection pooling can hide whether fresh negotiations occur. Preproduction Checklist Every in-scope TLS link has a source, destination, owner, provider, current group, certificate algorithm, and replacement path.Active probes and runtime telemetry confirm the selected group; capability is not inferred from a library version or policy name.Hybrid key establishment and post-quantum authentication are reported separately.ClientHello fragmentation, HelloRetryRequest behavior, retransmits, and representative middlebox paths have been tested.Handshake success, fallback, p95/p99 latency, full-handshake rate, connection reuse, and CPU headroom gates are defined before rollout.Rollback restores the previous approved policy state and has been exercised in the same environment.Every classical fallback or provider exception has an owner, reason, blast radius, compensating control, and expiration date.Infrastructure as code sets an explicit TLS policy and blocks omitted or legacy defaults.The full trust path - leaf, intermediate, root, and distributed trust bundle - has been validated independently of the key-establishment rollout. Conclusion Post-quantum TLS for cloud APIs is deployable, but it is not an application checkbox. X25519MLKEM768 adds modest compute on modern hardware and a much larger first flight. Connection pooling can reduce the per-request cost by orders of magnitude, while churn, autoscaling, and regional failover can expose it again. The decisive engineering problem is TLS termination. A credible migration inventories every directional link, separates key establishment from authentication, prefers hybrid with observable fallback, tests packet-size and middlebox behavior, enforces explicit policy, and advances only when SLO and rollback gates pass. That operating model turns post-quantum TLS from a library experiment into a measurable cloud security control. Practical milestone: The first definition of readiness is not that every workload is hybrid-only. It is that every important TLS link is visible, owned, risk-ranked, measurable, and reachable through a controlled migration path. Research Basis and Further Reading NIST FIPS 203: Module-Lattice-Based Key-Encapsulation Mechanism StandardNIST SP 800-227: Recommendations for Key-Encapsulation MechanismsIETF draft: Post-quantum hybrid ECDHE-MLKEM Key Agreement for TLS 1.3OpenSSL 3.6 supported-group configurationAWS post-quantum confidentiality readinessCloudflare: Post-quantum between Cloudflare and origin servers.
Distributed systems rarely fail as a single unit. More often, one dependency slows down, one shard becomes unavailable, one region loses capacity, or one pool saturates while neighboring components remain healthy. The engineering challenge is not simply availability; it is preventing a local defect from becoming a system-wide event. Google’s SRE guidance describes cascading failure as positive feedback in which lost capacity shifts load onto surviving capacity until additional components fail. Resilience design must break that loop early, cheaply, and at a boundary narrow enough to preserve useful work elsewhere. Containment Starts With Explicit Failure Domains A failure-containment framework begins by defining what is allowed to fail independently. Service names are usually too coarse. A single service may contain independent regions, tenants, shards, endpoint families, connection pools, or workload classes. Circuit breakers, concurrency limits, queues, and caches should align with those real failure domains rather than an arbitrary process boundary. Microsoft’s circuit-breaker guidance warns against combining independent resources, such as multiple data shards, behind one breaker because failure in one resource can incorrectly block healthy resources. This changes the design question from whether a service is healthy to which partition, operation, or dependency is unhealthy and what scope must be isolated. A breaker keyed by destination shard can contain a hot partition. A bulkhead keyed by traffic class can prevent batch work from consuming interactive capacity. A separate connection pool for an optional dependency can stop socket exhaustion there from starving critical calls. Resilience4j reflects this separation: circuit breakers control whether calls are permitted based on recent failures or slow calls, while bulkheads independently cap concurrent executions. Time Is the First Resource Budget Partial failure often presents as latency before an explicit error. A caller that waits indefinitely converts remote slowness into local resource retention: threads remain occupied, queues grow, memory pressure increases, and upstream deadlines begin expiring. gRPC recommends explicit realistic deadlines, and Google SRE recommends propagating the original request deadline through downstream RPCs so deeper services do not continue work after the result has become useless. A downstream timeout should fit inside the remaining end-to-end latency budget rather than being configured independently at every hop. For a request with 800 milliseconds remaining, a dependency call cannot safely claim a fresh two-second timeout. Deadline propagation preserves the original budget and cancellation propagation stops abandoned fan-out work. A concise Java boundary can make the budget explicit: Java public Inventory reserve(ReserveRequest request, long remainingMillis) { return inventoryStub .withDeadlineAfter(Math.min(remainingMillis, 250), TimeUnit.MILLISECONDS) .reserve(request); } The 250-millisecond cap is dependency-specific rather than universal. The containment rule is broader: downstream work must not outlive the request that justified it. Retries Must Consume a Bounded Failure Budget Retries are useful only for failures likely to be transient. Unbounded or synchronized retries create extra traffic precisely when capacity is impaired. Google documents how retries can amplify overload into cascading failure and recommends randomized exponential backoff, per-request retry limits, and retry budgets. AWS similarly recommends backoff for throttling, temporary network loss, and temporary service unavailability, while warning that retried operations should be idempotent to avoid duplicated state changes. A retry policy belongs inside the same time and load budget as the original call. It should distinguish transient statuses from permanent failures, leave enough deadline for another attempt, and stop when aggregate retry pressure becomes unsafe. gRPC implements configurable attempt limits, exponential backoff, jitter, retryable status codes, and retry throttling. Java @Retry(name = "catalog") @CircuitBreaker(name = "catalog", fallbackMethod = "catalogFallback") public ProductView loadProduct(String productId) { return catalogClient.fetch(productId); } private ProductView catalogFallback(String productId, Exception ex) { return ProductView.withoutCatalogEnrichment(productId); } The fallback is intentionally degraded rather than deceptive. Noncritical enrichment can disappear while the core response remains valid. A failed payment authorization, by contrast, should not become a fabricated success. Failure containment preserves service by removing optional work, not by weakening correctness. Resilience4j’s Spring integration supports composed retry, circuit-breaker, bulkhead, and fallback annotations for this boundary pattern. Admission Control Protects the Healthy Remainder Circuit breakers answer whether a dependency should receive calls; bulkheads answer how many calls may enter concurrently. Those controls solve different failure modes and should often be composed. Resilience4j notes that a closed circuit breaker does not limit concurrency, while its semaphore and fixed-thread-pool bulkheads provide explicit concurrency isolation. The next containment layer is admission control at saturation. Google SRE recommends early rejection, small queues where appropriate, load shedding, and graceful degradation because overloaded servers should preserve useful throughput rather than accept work until latency and resource exhaustion collapse the instance set. An HTTP 503 or equivalent overload signal can be healthier than a long queue that guarantees deadline misses. This creates a deliberate sequence under stress: optional work is removed, excess work is rejected, unhealthy dependencies are short-circuited, and concurrency remains partitioned. Capacity degrades in controlled steps instead of falling off a cliff. The design target is not zero errors, but a bounded error surface with stable latency and preserved capacity for higher-value operations. Recovery Behavior Matters as Much as Failure Detection Containment mechanisms can cause a second outage during recovery if suppressed traffic returns at once. The half-open circuit-breaker state probes recovery with limited traffic rather than immediately restoring full load. Microsoft describes this as protection against flooding a recovering service, while Resilience4j permits a configurable number of half-open trial calls before closing the breaker. Recovery also requires warm capacity. Google notes that restarted processes and cold caches can be more expensive than steady-state instances, so traffic should often be restored gradually. The same principle applies after regional failover, database restart, or large deployment: recovery is a controlled ramp, not a binary switch. Observability should expose breaker transitions, rejected bulkhead calls, timeout rates, retries, queue depth, saturation, and degraded responses. Microsoft recommends circuit-breaker monitoring and distributed tracing, while gRPC exposes retry-attempt metrics. Containment Is Proven Under Pressure A failure-containment framework is incomplete until overload and dependency failure are tested beyond the normal operating envelope. Google recommends load testing components until failure, observing whether excess demand produces bounded rejection instead of crashes, and testing recovery after load is reduced. It also recommends testing abrupt capacity loss and backend blackholing because gradual load tests alone can miss cascade behavior. The strongest distributed systems are not those that assume every dependency remains available; they are those that define exactly how much time, concurrency, retry traffic, and functional degradation each dependency may consume when it fails. Explicit failure domains, propagated deadlines, bounded retries, circuit breakers, bulkheads, load shedding, truthful fallbacks, controlled recovery, and failure testing form a single containment discipline. Partial failure then remains partial: damage stops at designed boundaries, healthy capacity continues doing useful work, and recovery proceeds without recreating the conditions that caused the incident.
https://dzone.com/articles/apache-flink-1Most teams working with Apache Flink think about stream processing: filter events, aggregate values, enrich records, join streams. This covers the majority of real-time data problems. But there is a specific class of problems that stream processing handles poorly: detecting meaningful sequences of events across time. Did this specific chain of things happen, in this order, within this time window? Complex event processing (CEP) is the tool designed for exactly this, and it works fundamentally differently from stream processing. CEP is a built-in capability of Apache Flink, available as open source and deployable at scales the old proprietary platforms could never reach. Yet it remains underused and misunderstood. This article is a practical guide to what CEP is, when to use it, how Flink implements it, and where it fits in modern data architectures, including agentic AI, which is covered in depth in the companion article. What Is Complex Event Processing? The simplest way to understand CEP is through a contrast with regular stream processing. Stream processing handles events continuously, one at a time or in windows. It answers questions like: What is the average transaction value over the last five minutes? How many login attempts has this user made in the last hour? These are stateful, real-time computations, but they operate on individual events or aggregates. They do not look for sequences. CEP answers a different class of question: did event A happen, followed by event B within 30 seconds, but not preceded by event C in the last 10 minutes? It detects patterns across multiple events in a defined order within a defined time window. Think of it as a regular expression engine applied to event streams instead of text strings. You define the pattern once. Flink evaluates every incoming event against every active pattern continuously. The moment a complete match is found, a result is emitted. The architectural implication is significant. In a traditional database, queries run against stored data. In CEP, data runs against stored queries. Events that match no active pattern are discarded immediately. This makes CEP extremely efficient at high volumes because irrelevant data never accumulates. One distinction worth making explicit: CEP uses static, predefined patterns. You specify exactly what sequence you are looking for. This is the right approach when the business logic is known and deterministic. The alternative is dynamic anomaly detection, where stream processing with statistical or ML functions builds a baseline from historical data and flags deviations without needing explicit rules. Both approaches solve real problems, and both run on Flink. The choice depends on whether you know the pattern you are hunting for. More on this in the section on when not to use Flink CEP. The Missing Event Problem One of the most valuable and least understood CEP use cases deserves its own section: detecting events that do NOT happen. This surprises most practitioners because the instinct is to think of event processing as reacting to events that arrive. But some of the most important business signals are absences. A machine sensor that should emit a heartbeat every 60 seconds goes silent. A delivery confirmation that should follow a shipment event within four hours never arrives. A payment settlement that should complete a transaction chain is missing. In each case, the business needs to know immediately, not after a human notices the gap. The instinct when facing this problem is to reach for the CEP Pattern API or MATCH_RECOGNIZE. But for missing event detection, a LEFT JOIN in Flink SQL is often the more appropriate and more memory-efficient approach. The query asks: give me all cases where event A arrived but the expected event B never followed within the time window. This is cleaner and more predictable for state management than expressing absence as a negative pattern. Memory management matters more here than most teams expect. Flink maintains state for every active pattern it is evaluating. A pattern with a seven-day lookback window across high-cardinality keys requires far more memory than one with a 60-minute window. Always use the WITHIN clause in pattern definitions to bound the time window explicitly. Without it, state grows unbounded in long-running streaming jobs, which is one of the most common operational problems in production Flink CEP deployments. Vendor Landscape: From Legacy Proprietary Platforms to Apache Flink Before looking at the vendors, one distinction is worth clarifying: the CEP market and the stream processing market were historically separate categories with separate products. The diagram below illustrates why: stream processing outputs on every window; CEP outputs only when a complete pattern match is confirmed. Understanding this separation explains both the fragmentation of the old market and why Flink's ability to handle both in one platform represents such a significant shift. TIBCO: BusinessEvents (CEP) and StreamBase (Stream Processing) A dominant middleware vendor of that era, TIBCO had two separate products. BusinessEvents, launched in 2004, was a pure CEP engine: rule-based inference, stateful event correlation, strong particularly in telco and insurance. StreamBase, acquired in 2013, was a completely separate pure stream processing platform designed for high-throughput financial trading. Software AG: Apama (CEP and Stream Processing in One) Software AG Apama was the notable exception: a single platform combining CEP and event stream processing in one product. This was its competitive advantage against TIBCO, which needed two separate tools to cover the same ground. Apama was strong in capital markets and algorithmic trading and competed directly in the financial services segment. Software AG acquired it in 2011. IBM and Oracle: Niche Adoption IBM WebSphere Business Events and Oracle CEP rounded out the market, though neither achieved the adoption depth that TIBCO or Apama built in their core verticals. All of the above platforms are effectively legacy today. They were expensive, required deep specialist expertise, and could not scale to the event volumes that modern architectures demand. Apache Flink arrived as an open-source framework that absorbed the core capabilities of all of them into a single unified platform running at far greater scale and at a fraction of the cost. Open Source: Apache Flink The foundation of Apache Flink remains open source. Self-managed, fully featured, and the basis on which all commercial distributions are built. The right choice for teams with strong engineering capabilities who want maximum control and flexibility. IBM: Confluent Cloud and Confluent Platform Confluent delivers Flink as a fully managed serverless service on Confluent Cloud as well as a self-managed distribution via Confluent Platform, both with Apache Kafka tightly integrated. CEP via MATCH_RECOGNIZE in Flink SQL is a first-class capability across both. Confluent Platform additionally supports the DataStream API, giving teams on-premises or in private cloud environments access to the full programmatic CEP feature set. Confluent was recently acquired by IBM, a move that positions the combined portfolio as a core pillar of IBM's hybrid cloud strategy: enterprises can run Confluent Cloud as a fully managed service or deploy Confluent Platform on-premises and across private clouds, with IBM's enterprise reach and support behind both. Ververica: VERA Engine Ververica, founded by the original creators of Apache Flink, offers the VERA engine. VERA follows an open-core model: 100% compatible with open-source Apache Flink, with proprietary performance optimizations on top. Key capabilities include updating pattern matching rules in a running job without restart, the Gemini state storage engine, which replaces the standard RocksDB backend with a cloud-native alternative delivering significantly faster snapshots, compute-storage separation, and streaming join optimizations that can reach up to twice the performance of vanilla Apache Flink for demanding workloads. Ververica is available in three deployment models: self-managed on-premises via Ververica Platform, fully managed on Ververica's public cloud, and BYOC where Ververica manages the platform on the customer's own cloud infrastructure. The managed cloud offering provides pay-as-you-go pricing billed hourly per compute unit, or reserved capacity for predictable pricing. It is not fully serverless in the scale-to-zero sense, making it better suited for teams with stable, continuous workloads than for highly variable or intermittent processing patterns. Amazon: Managed Service for Apache Flink Amazon Managed Service for Apache Flink is AWS's fully managed Flink offering and a natural choice for organizations already running on AWS infrastructure. The service handles cluster provisioning, auto-scaling, checkpointing, and multi-AZ availability, and it supports Java, Python, and SQL APIs, including MATCH_RECOGNIZE for CEP use cases. One important caveat on the "fully managed" positioning: like Amazon MSK, the service is not truly serverless. Billing is based on allocated KPUs (compute units) rather than actual consumption, meaning you pay for provisioned capacity regardless of whether your jobs are actively processing data. This is the same marketing gap that caught many MSK users by surprise. Teams deeply committed to the AWS stack who want Flink without managing cluster infrastructure will find it a reasonable option. Those needing a consumption-based, fully managed platform with deeper CEP and SQL capabilities will find Confluent Cloud or Ververica the stronger choices. For a complete map of the data streaming vendor landscape including deployment models and adoption rankings, the Data Streaming Landscape 2026 is the reference. How Flink Implements CEP Flink provides two complementary approaches to CEP. The right choice depends on pattern complexity and the technical profile of the team. One important note on distribution coverage: the Pattern API via the DataStream API is available in self-managed Apache Flink, Confluent Platform, and Ververica, but not in Confluent Cloud, which exposes Flink exclusively through the SQL and Table API. For teams on Confluent Cloud, MATCH_RECOGNIZE is the CEP interface available. The diagram below shows the core concept at a glance before diving into the technical details of each approach. The Pattern API via the DataStream API The programmatic approach, available in Java and Python. You define a Pattern object specifying the event sequence to detect, chaining conditions with operators: .next() for strict contiguity where events must follow immediately, .followedBy() for non-strict contiguity where other events can appear in between, and .within() for time constraints on the entire pattern. The Pattern is applied to a DataStream to create a PatternStream, from which matches are extracted using a PatternProcessFunction. This gives the most complete access to CEP capabilities and the most flexibility for complex logic. The tradeoff is that DataStream jobs require stronger engineering skills and can become difficult to maintain as complexity grows. MATCH_RECOGNIZE via the SQL and Table API The SQL approach is based on the ISO standard from December 2016 and available in Flink since version 1.7. Under the hood, it uses the same FlinkCEP library as the programmatic API. PARTITION BY handles per-entity pattern matching, ORDER BY ensures correct event ordering, PATTERN defines the sequence using regular-expression-style syntax, and DEFINE maps events to pattern variables. Flink adds the non-standard WITHIN clause for time-bounded patterns, which is essential for production memory management. The SQL approach is accessible to a broader range of developers, data engineers, and analysts. Its limitations relative to the full Pattern API are in practice a useful constraint: they prevent patterns from becoming so complex they are impossible to debug or maintain. For the majority of business-level CEP requirements, MATCH_RECOGNIZE is sufficient. The UI/UX Gap: CEP Is Still Largely an Engineering Discipline This is an honest limitation that most Flink CEP articles skip, and it deserves a direct discussion. Pattern Authoring Is an Engineering Task Pattern authoring in Apache Flink is fundamentally an engineering task. Writing DataStream API code in Java or Python requires a developer. Writing MATCH_RECOGNIZE SQL lowers the bar significantly but still assumes familiarity with SQL syntax, event time semantics, and the specific constraints of streaming pattern matching. A fraud operations analyst, a supply chain manager, or a compliance officer cannot walk up to a Flink cluster and define their own detection rules without technical support. This was not always the accepted norm in the CEP world. TIBCO BusinessEvents shipped with a visual rule editor and decision table support that allowed business analysts to author and maintain CEP rules without engineering involvement. Apama had a graphical development studio. These visual interfaces were real strengths of the proprietary platforms, enabling the domain experts who best understood the business patterns to directly encode them without going through an engineering ticket queue. The open-source Flink ecosystem has no mature equivalent today. The tradeoff is real but not entirely negative: engineering-based CEP is more powerful, more flexible, more testable, and more maintainable at scale than visual rule editors, which tend to produce opaque logic that is hard to version-control or audit. But it does gate adoption behind developer availability and creates a bottleneck when business rules need to change quickly. GenAI Is Closing the Gap, Partially The gap is starting to close, partially, through a different route than visual tooling. Current LLMs are capable of generating correct MATCH_RECOGNIZE SQL from a natural language description of a pattern. A fraud analyst describing "flag any user who makes more than three transactions over 500 euros within 10 minutes across different merchants" can get a working SQL pattern from a code-capable LLM today. This lowers the barrier for SQL-expressible patterns and gives non-engineers a practical path to prototyping detection logic before handing it to engineering for review and hardening. The limits are equally real. Generated DataStream API code for complex stateful patterns still requires engineering review before it goes anywhere near production. LLMs can produce syntactically correct Flink code that contains subtle logical errors in time semantics, state handling, or watermark behavior that are not obvious without deep Flink expertise. Generating and deploying without human review is not production-safe for mission-critical CEP logic today. The forward-looking picture is that GenAI will reduce but not eliminate the engineering dependency. Business analysts and domain experts working with well-defined, SQL-expressible patterns will be increasingly served by natural language interfaces, whether embedded in platforms like Confluent Cloud, Ververica, or third-party tooling built on top of Flink. Complex pattern engineering involving custom DataStream operators, iterative conditions, or deeply stateful logic will remain a developer discipline for the foreseeable future. The right platform architecture accounts for both populations: an abstraction layer exposing simple pattern authoring to business users, with full engineering-level access available for the cases that require it. Flink CEP Industry Use Cases CEP is not a niche capability. The following use cases represent industries where sequential pattern detection delivers business value that general stream processing cannot replicate. Financial Services: Fraud Detection Payment fraud is the most mature CEP application in production globally. Modern fraud is almost never a single suspicious event. It is a chain: a card used in Berlin, then two minutes later a contactless payment attempt in London, followed immediately by a high-value online transaction. CEP detects that sequence the moment it completes, calculates whether the travel time between locations is physically possible, and triggers a step-up authentication or account freeze before money moves. More sophisticated patterns chain five or more events: rapid card enumeration, velocity checks across channels, account takeover sequences. The ability to update detection rules in a running job without restart is particularly valuable in this domain. Fraud patterns evolve continuously with attacker behavior, and reducing the window between identifying a new attack pattern and deploying a rule to detect it is a direct business impact measure. Manufacturing and IoT: Predictive Maintenance A machine about to fail does not fail suddenly. It emits a multi-sensor signature in the period before failure: a subtle temperature rise, then increasing vibration frequency, then a pressure deviation. A single-sensor threshold alert would either miss the pattern entirely or produce too many false positives to be actionable. CEP detects the full multi-event signature reliably and triggers a maintenance workflow while there is still time to intervene, turning reactive maintenance into predictive maintenance. Event-driven architectures built on Apache Kafka and Apache Flink are already in production across automotive and industrial manufacturing environments for real-time operational intelligence. CEP-based predictive maintenance is a natural and high-value application layer on top of that existing streaming foundation, applying sequential pattern detection to the sensor streams that are already flowing. Supply Chain and ERP: Process Monitoring Business process events flowing from SAP and other ERP systems, B2B integration platforms, and EDI networks create a continuous stream representing the state of operations across the enterprise. A production order moving through its lifecycle generates events at every stage: order created, inventory checked, production started, quality inspected, goods dispatched. In B2B scenarios, the same logic applies across trading partner boundaries: a purchase order triggers an expected sequence of EDI messages including order acknowledgment, advance ship notice, and invoice. If any step is skipped, delayed beyond an SLA, or occurs out of order, the downstream business impact is real regardless of whether the event originates inside the enterprise or from an external partner. CEP monitors the entire process flow in real time across thousands of concurrent orders and trading partner transactions simultaneously. The moment a single order or EDI exchange deviates from its expected sequence, a structured alert is published to a downstream consumer and the appropriate workflow is triggered. This is categorically more valuable than a batch report that surfaces the deviation 24 hours after it occurred, when the window to intervene has already closed. Telecommunications: Network and Fraud Monitoring Telco was one of the original CEP industries and remains one of the strongest fits. Network intrusion detection, SLA breach monitoring across millions of simultaneous connections, and sequential call detail record analysis for subscription fraud all match the CEP pattern precisely. The combination of high event volumes, strict latency requirements, and the sequential nature of the fraud and failure patterns makes this a domain where CEP consistently delivers value that simpler stream processing cannot replicate. E-Commerce: Customer Journey Detection Sequential behavioral patterns are where MATCH_RECOGNIZE in Flink SQL particularly shines, because the patterns are expressible in clean SQL logic that sits close to the business domain. A customer who held a premium subscription, renewed twice, then downgraded to basic is a churn risk signal. A user who visited the same product page four times within a week without purchasing is a conversion opportunity. These patterns are straightforward to express in MATCH_RECOGNIZE and can be authored and maintained by analysts without requiring deep DataStream API expertise. The output feeds real-time personalization engines, retention workflow triggers, or conversion campaign systems. When NOT to Use Flink CEP CEP is a powerful tool for the right problems. For the wrong problems, it adds operational complexity with no benefit. Three situations where you should reach for something else: The pattern is undefined or too dynamic to specify explicitly. If you do not know what sequence you are looking for, or if the relevant patterns change faster than rules can be written and deployed, ML-based anomaly detection running on Flink's stream processing capabilities is the better approach. Let the model learn what normal behavior looks like from historical data and flag statistical deviations, rather than attempting to enumerate every possible bad pattern upfront. CEP and ML anomaly detection are complementary: CEP for known patterns, ML for unknown ones, both running on the same Flink platform feeding the same downstream systems. The problem does not actually require sequential pattern detection. If the pattern is expressible as a windowed aggregation rather than a strict event sequence, a standard Flink windowed stream processing query is simpler, cheaper, and easier to maintain than a CEP job. More than five failed logins within a 10-minute window, for example, is a count within a time window. It does not require CEP unless the ordering and interleaving of specific event types matters. CEP adds value specifically when sequence and ordering are part of what makes the pattern significant. If they are not, a tumbling or sliding window in Flink SQL handles it cleanly without the overhead of CEP state machines. And if the business action can wait for a scheduled report or nightly batch job, do not build a streaming architecture at all. The lookback window is impractically long relative to event volume. A pattern that requires tracking state across seven days of high-cardinality event data consumes significant Flink cluster memory. If the time window is very long and both event volume and key cardinality are high, the state management cost may outweigh the benefit. Either constrain the window more aggressively using the WITHIN clause, reduce key cardinality through pre-aggregation upstream, or reconsider whether a batch or lambda architecture is more appropriate for that specific pattern. Flink CEP Reference Architecture and Best Practices The reference architecture below shows the full picture: source systems feeding an event ingestion layer, Flink CEP as the pattern detector, and downstream consumers reacting independently to the structured output. The best practices that follow are derived directly from this architecture and from production deployments across industries. Keep patterns focused and always bound them with WITHIN. Every active pattern consumes Flink state proportional to its complexity and time window. Start with the simplest pattern that captures the business intent. Add the WITHIN clause in every pattern definition without exception. State that cannot be pruned will cause operational problems in production, often in ways that are difficult to diagnose under load. Decouple detection from response via a downstream consumer. The output of a CEP job should be a structured event published to a Kafka topic, a message broker, or whatever downstream system the architecture uses, not a direct action or API call embedded in the job itself. Downstream consumers react independently: alerting systems, workflow engines, AI agents. This makes the CEP job simpler and more stable, allows multiple consumers to react to the same signal independently, and lets response logic evolve without touching detection logic. It is the most important architectural principle for production CEP deployments. Avoid monolithic Flink jobs. A single large job combining pattern detection, enrichment, response logic, and downstream routing is an anti-pattern. A fraud detection job that also calls an external risk scoring API, writes to a database, and sends notifications is doing too many things. When any one component needs to change or fails, the entire job is affected. Separate concerns into focused jobs: CEP detection in one job, enrichment in another, AI inference in a third. Each job does one thing well and can be updated, scaled, and debugged independently. CEP Is Underused, Practical, and More Relevant Than Ever The decision framework is simple. CEP is the right tool when you know the pattern and need to detect it in real time. Windowed stream processing is simpler when ordering does not matter. ML-based anomaly detection is the right tool when the pattern is too dynamic to define explicitly. All three approaches are complementary and run on the same Flink platform. The legacy CEP market is gone. Apache Flink, delivered through Confluent (IBM), Ververica, and Amazon, has absorbed everything those platforms offered at scales and economics they could never reach. The remaining gap is the UI/UX barrier, which GenAI is beginning to close for SQL-expressible patterns but has not eliminated for complex logic. Detecting the pattern is only half the problem. The other half is what happens next: which system acts, with what context, and with how much autonomy. A follow-up article covers exactly that: how Flink CEP connects to Agentic AI architectures and why CEP is the layer that makes AI agents trustworthy rather than merely powerful. Stay tuned.
Model capability is moving in that direction quickly. In its February – March 2026 Frontier Risk Report, METR estimated how long a software task could take before a frontier model’s chance of completing it successfully fell to 50%. Claude Opus 4.6 reached about 17.8 hours on cleaner tasks but only 6.4 hours on messier ones involving changing environments, constrained resources, expensive feedback, or mistakes that could not easily be undone. The difference suggests that task length alone is not the main challenge. Long-running work also requires the system to preserve progress, adapt when conditions change, and recover when earlier assumptions no longer hold. Recent benchmarks show how difficult that remains. Microsoft’s WeaveBench evaluates realistic workflows spanning graphical interfaces, command-line tools, and code, yet its strongest evaluated model-runtime pairing reached only a 41.2% pass rate. These failures are not simply a matter of running out of context. Long-Horizon Is Not the Same as Long-Context The August 2026 survey The Horizon Gap reviewed more than 1,500 papers and draws an important distinction between three concepts that are often conflated: Long-horizon – the task requires many dependent steps.Long-context – the model can process a large amount of information in a single invocation.Long-term memory – the system can preserve useful information across steps or sessions. These capabilities solve different problems. A larger context window lets the model see more at once but does not ensure that it knows what has been completed, what remains, or which earlier decisions still matter. Long-term memory can preserve information across time, but remembering more does not guarantee that the right information will be available for the next decision. For long-running work, the system therefore needs an explicit representation of task state — what has been completed, what remains, what assumptions are active, and what it is waiting on. That state can persist outside the model, while each model call receives only the information needed for the next step. A useful way to think about a long-running AI task is as a sequence of model calls connected by persistent state. At each step, the system loads where the task stands, gives the model the information needed for the next decision, takes an action, and records what changed. Once that process stretches over minutes, hours, or days, several additional problems appear. 1. Make Progress Explicit Consider an AI system given this task: Plan and book a three-day Tokyo trip under $2,500, including flights, a hotel, activities and dinner reservations. After several steps, the useful state is not a transcript containing every hotel considered and every restaurant rejected. It is something closer to: Goal: Plan and book a three-day Tokyo trip under $2,500. Completed: Flights bookedHotel reserved in ShinjukuTeamLab tickets purchasedFirst two days planned Remaining: Plan the final dayBook Saturday dinnerArrange airport transportation Constraints: Total budget - $2,500Vegetarian-friendly restaurantsNo activities before 10 AM Current spend: $1,940 Known issue: The preferred Saturday restaurant is fully booked. This gives the next model invocation a compact representation of the task rather than asking it to reconstruct the trip from its own history. The 2026 paper Remember When It Matters describes a related failure mode as behavioral state decay — requirements, previous attempts, and open goals can remain somewhere in a long trajectory but stop influencing the model when they matter. The important idea is not simply to remember more. It is to maintain a representation of where the task currently stands. 2. Make Progress Recoverable Now suppose the system books the hotel and then the process crashes. When execution resumes, it needs to know that the hotel has already been booked. This is where checkpoints and durable execution enter the picture. A checkpoint might preserve the current task state, completed actions, booking IDs, intermediate artifacts, pending work, and the external state last observed by the system. Production runtimes are already being built around this requirement. LangChain’s runtime for long-running agents uses durable execution and checkpointing so work can resume after interruptions rather than restarting from the beginning. Recovery also changes how actions have to be designed. If the system crashes immediately after making a reservation, retrying the previous step should not accidentally make a second reservation. Long-running AI therefore inherits familiar distributed-systems concerns - retries, idempotency, resumability, and careful handling of side effects. A useful question when designing state for any long-running step is: if execution stopped immediately after this action, could the system safely figure out what happened and continue? 3. Handle Waiting as Part of the Task Not every unfinished task has an action the system can take. Suppose the user says, "Book Saturday dinner after my wife confirms whether she wants sushi or Italian." The trip is not complete, but the system cannot make progress on that step yet. It has to wait. Microsoft’s SentinelBench studies long-running tasks where systems have to remain responsive to changes over time while avoiding unnecessary work. For practitioners, the important distinction is between active work and waiting. A long-running system needs to know when progress is possible through its own actions and when progress depends on something outside the system. Waiting is also part of the task state. 4. Revalidate What May Have Changed Six hours later, the user replies in Italian. The system can resume the dinner step, but should it reuse the restaurant availability it found earlier? Probably not. The fact that Saturday dinner still needs to be booked is a durable task state. The fact that Restaurant A had a 7 PM table available six hours ago is an observation about the external world. Those two types of information should not be treated the same way. Before continuing, the system may need to refresh the restaurant’s availability and price. The same problem appears with inventory, account balances, permissions, software deployments, ticket availability, or any other state that can change while a task is paused. A long-running execution therefore starts to look like: Load task state → identify the next step → refresh information that may be stale → act → record what changed The challenge here is identifying which assumptions remain safe to reuse and which should be checked again because time has passed. 5. Verify That the Task Is Actually Done Eventually the system announces: Your Tokyo trip is all set. Before the task is closed, the system needs to check that against what the user actually asked for. For our Tokyo trip, that might look like: Plain Text Flights - confirmed Hotel - confirmed Activities - confirmed Saturday dinner - confirmed Airport transportation - confirmed Total spend - $2,380 / $2,500 This matters more as tasks get longer. With many intermediate steps, it is easy to complete most of the work while missing one requirement. Completion should therefore be tied to the task’s actual requirements and observable outcomes. For a code migration, that could mean verifying that the required files were changed and the tests pass. For the Tokyo trip, it means checking that everything requested was booked and that the total remains within budget. The same task state used to track progress can help here — anything still marked as remaining means the task is not finished. Putting the Pieces Together At this point, the lifecycle of a long-running task becomes clearer: Plain Text Load task state ↓ Determine the next unfinished step ↓ Build the context needed for that step ↓ Refresh external state if needed ↓ Decide and act ↓ Record what changed ↓ Wait, recover or replan when necessary ↓ Verify completion The model is only one part of this loop — task state records where the system is, context gives the model what it needs for the current decision, checkpoints make progress recoverable, waiting allows execution to span external events, revalidation prevents stale information from driving new actions, completion checks determine whether the requested outcome actually happened. This is why long-horizon AI needs more than longer context windows. What Is Still Unsolved The pieces are becoming clearer, but several design questions remain open. What should survive over time? Saving the full trajectory creates noise and cost. Summarizing too aggressively can discard information that becomes important later. Systems still need to decide what belongs in structured task state, memory, searchable history, or transient context. When should a system replan? Replanning after every change wastes work and can destabilize a good plan. Never replanning allows stale assumptions to persist. When should external information be revalidated? There is no universal expiration time for a fact. Some observations remain valid for months while others can change in seconds. How should partial progress be evaluated? Binary success becomes increasingly crude as tasks get longer. A system may accomplish useful work without reaching the final goal, and understanding where it failed matters for both recovery and evaluation. These are increasingly systems questions rather than model questions. Larger context windows will help. Better models will help. Neither removes the need to manage what happens between model calls. Building for long-running tasks means thinking beyond each individual model call. The system must preserve enough state to continue, handle interruptions and changes along the way, and reliably know when the work is done.
The Prometheus I was responsible for had grown past 900 GB of time series data, and at that size it had every disease that comes with it. Queries timed out unless you narrowed them down to an hour. Cardinality kept growing, because teams added labels "for debugging" and then sometimes forgot to remove them. On top of that, we were shipping a copy of fractions of our metric data to an external SaaS vendor, just in case, and that bill grew every quarter. And the fleet under all of this ran latency-sensitive trading services, so monitoring itself was not allowed to eat an extra CPU core at a wrong moment. We moved it all to VictoriaMetrics. I am not going to argue here about storage engines; there is plenty written about which one wins. What nobody warned me about is how much the order of the steps matters. The standard way to migrate is a cutover weekend: you stand up the new system, you pick a Saturday, you switch, and then you spend three weeks finding out what broke. I think this order is backward. What worked for us was to delete first, then dual-write for a long time, then rebuild the dashboards and compare them panel by panel, and move the alerts at the very end. And most of the alerts should not move at all, but about that below. The Inventory Nobody Has Before touching anything, I wanted to know what anyone actually reads out of those 900+ GB. It sounds like a trivial question, and it is not, because the data you have does not answer it directly. The answer lives in three places. The TSDB status endpoint knows which metrics are the biggest. The Grafana dashboards know what humans look at. And the rule files know what the machines query, because alerts and recording rules read plenty of metrics which no human ever opens. The status endpoint reports on the in-memory head block, so strictly speaking it shows what is filling the disk going forward, and not the composition of the historical 900+ GB. As a proxy for the biggest offenders, it was good enough: Shell curl -s 'http://prometheus:9090/api/v1/status/tsdb?limit=15' \ | jq '.data.seriesCountByMetricName' For the second list, dashboards are JSON, so every expression can be pulled out with jq. I tokenized the expressions instead of parsing them, which means the output is polluted with PromQL function names and label names. The pollution disappears later, at the intersection step: Shell find dashboards/ -name '*.json' \ -exec jq -r '.. | objects | .expr? // empty' {} + \ | grep -oE '[a-zA-Z_:][a-zA-Z0-9_:]+' | sort -u > tokens_dashboards.txt The same jq pass over the exported alerting and recording rules gave the third list, and it caught a family of recording rules that fed an SLO report that no dashboard mentioned anywhere. (There is also mimirtool analyze, which builds this inventory for you. I wanted the raw lists.) Then you intersect the TSDB metric names with the union of the other two lists, and what falls out at the bottom is the set which nobody reads. For us that set covered a large share of the 900+ GB, and most of it was high-cardinality out of habit: per-pod histograms for services whose one dashboard had a single aggregate panel. Delete at the Source Before Anything Moves The instinct is to migrate everything and clean up later. Later is a bad deal here. Dead series which you keep get dual-written for months; they get baked into the sizing of the new cluster, and they pollute every panel diff which you will run later in the project. All of that in service of data which nobody reads. So the first change of the whole project landed on the old system, and not on the new one: YAML metric_relabel_configs: - source_labels: [__name__] regex: 'go_gc_duration_seconds.*|.*_bucket_experimental_.*' action: drop - source_labels: [__name__, pod] regex: 'legacy_worker_queue_depth;.+' action: drop The obvious worry is that you will need a deleted metric later. That came up twice over the following weeks, and both times the fix was to remove one relabel rule and reload the config with a SIGHUP or a POST to /-/reload. Do not restart a 900 GB Prometheus for this; the WAL replay will leave you without monitoring for longer than you expect. And one caveat which you should agree on before deleting anything: the reversal only works going forward. The rule drops samples before ingestion, so the gap for the dropped period never backfills. In our case nobody ever missed those gaps. The Dual-Write Months With the estate slimmed down, the old Prometheus started writing into the new storage in parallel with its own: YAML remote_write: - url: http://vminsert.internal:8480/insert/0/prometheus/api/v1/write queue_config: max_samples_per_send: 10000 capacity: 50000 max_shards: 12 Those numbers were not the first ones we tried. With the defaults, the send queue was too shallow for our sample rate, and within the first hour the log was full of this: Plain Text ts=... caller=queue_manager.go level=warn component=remote msg="Remote storage falling behind" pending_samples=48231 shards=4 So max_samples_per_send went up, capacity followed at five times that, which is what the tuning guidance says, and shards were raised until the queue stopped falling behind at the evening peak. And because the trading fleet was the whole point of being careful, we measured what dual-writing costs the old server before trusting it. CPU rose by roughly a tenth. Memory rose by about what the queue capacity implies. Scrape latencies did not move. The abort criterion was written down in advance: if scrape or rule evaluation starts lagging, the remote_write block comes out that same day. It never triggered. Then, deliberately, nothing else happened for roughly a quarter. The old system stayed the source of truth for every dashboard and every page, and the new one filled up in the background, until every window which people habitually query existed on both sides, from the last six hours out to the month-over-month capacity view. This is the whole point of dual-writing: at no moment did the project depend on one switch being flipped correctly on one Saturday. The one incident which the phase produced was self-inflicted. A relabel change on the old side renamed a label on its way into the new storage only, and the affected series split in two. A panel diff caught it about a week later, and that is exactly the place where such things should get caught. Dashboards Are the Test Suite We rebuilt the dashboards against the new engine while the old ones kept running, and then compared them panel by panel over the same time ranges. Every discrepancy meant something, but not always that the new panel was wrong. The classic one is increase(). Prometheus extrapolates it, so a counter which grew by exactly 300 over the window renders as 299.4 or 301.7, and everyone has learned to not see it anymore. MetricsQL does not extrapolate, so the same panel suddenly shows round integers: Plain Text increase(http_requests_total{service="order-gateway"}[1h]) # old panel: 299.4 new panel: 300 Some panels had been quietly misleading for years, and the diffs surfaced that. Other panels were wrong on my side: a rate window that did not match the scrape interval, or a sum that collapsed a label that the legend needed. The comparison caught both kinds. The work was tedious. It was also the cheapest reliability work of the whole project, because whatever you catch at the desk at this stage, nobody has to chase later in the middle of an incident. Alerts Move Last, and Most Should Not Move Alerting stayed on the old system until everything above was finished. A bad page wakes somebody up at night, so pages moved in the last phase, group by group, and every rule was re-run against the new engine and watched for a while before its old copy retired. The stronger claim, and the one I get pushback on: a migration is the wrong moment to preserve your alert rules, because most of them do not deserve the trip. Ours had accumulated over years; they fired constantly, and they meant little. Copying them onto the new engine would carry the same noise across intact, just on faster storage. So the rules were rebuilt around a single test instead: does this page fire on something which a user or a dependent service can feel. YAML # Retired with the old system: - alert: HighCPU expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90 for: 10m # What replaced it: - alert: OrderGatewayP99LatencyHigh expr: histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{service="order-gateway"}[5m])) by (le) ) > 0.4 for: 5m CPU sitting at 91 percent has never made anyone I know roll a deploy back. The rollbacks which I have actually watched were all triggered by latency which a client could feel, and that is what the replacement rule fires on. The rest — the CPU levels, the queue depths, the disk growth — went into dashboards and recording rules for capacity planning. And outside of a migration, deleting a single alert rule means an argument with whoever added it. The migration made bulk deletion politically possible for once, and roughly a hundred stale rules did not make the trip. What Fell Out of It After the source-side deletes, the estate which actually needed a new home was well under half of the original 900+ GB. The SaaS dependence went away, and the bill went away with it. Queries stopped timing out. And the pages dropped from dozens on a loud week to a handful, so the ones which did fire got taken seriously, and incident response got faster off the back of that. The migration ran for months under a live trading workload and produced no cutover incidents, because at every step the previous state was still there, and we could fall back to it. If you are staring at your own 900 GB, the short version is this. Run the inventory and believe it, even when it embarrasses the dashboards which produced it. Delete at the source before you move a byte. Dual-write across the longest window that people actually query. And when the time comes to move the alerts, ask about every rule whether a user can feel the thing that it fires on, because a migration is the only moment when your organization will agree to delete alert rules in bulk. A cutover weekend takes two days, and this took months; that is true. But the weekend does not remove any of the work that is described above. It just moves the increase() mismatches and the stale rules into production, and there they surface one at a time, usually at night.
Most gains in a supervised learning project don't come from switching algorithms; they come from what you feed the algorithm. Feature engineering is the process of transforming raw data into representations that make patterns easier for a model to learn. Done well, it routinely closes more of the accuracy gap than hyperparameter tuning or trying a fancier model. This article walks through the feature engineering techniques that consistently move the needle in real projects, with working Python code (pandas + scikit-learn) and a visual look at where the gains actually come from. Figure 1: Where feature engineering sits in a typical supervised ML pipeline. Why Feature Engineering Beats Model Selection (Often) A gradient-boosted tree with well-engineered features will usually outperform a deep neural network fed raw, unprocessed columns. That's because feature engineering directly encodes domain knowledge and statistical structure that the model would otherwise have to infer from data alone — often with far more examples than you have available. Figure 2: Cumulative accuracy gains on a churn-prediction task as each feature engineering step is added. In this example (a customer churn classifier trained with gradient-boosted trees), accuracy climbs from 0.712 on raw features to 0.851 after five feature engineering passes, a 13.9-point improvement without changing the model or its hyperparameters. Techniques That Actually Move the Needle 1. Missing Value Imputation (With an Indicator Flag) Dropping rows with missing values throws away signal. Simple mean/median imputation is a safe default but pairing it with a binary "was this missing" flag preserves the fact that the value was absent, which is often itself predictive (e.g., a missing income field can correlate with churn). Python import pandas as pd from sklearn.impute import SimpleImputer # Flag missingness before imputing — the flag is a feature in its own right df['income_missing'] = df['income'].isna().astype(int) imputer = SimpleImputer(strategy='median') df['income'] = imputer.fit_transform(df[['income']]) 2. Categorical Encoding (Target/Frequency, Not Just One-Hot) One-hot encoding works fine for low-cardinality columns, but it explodes dimensionality for high-cardinality categoricals (zip codes, product IDs). Target encoding, replacing a category with the mean target value for that category, keeps dimensionality flat and often outperforms one-hot for tree-based models. Always fit encodings on the training fold only to avoid leakage. Python from category_encoders import TargetEncoder from sklearn.model_selection import KFold encoder = TargetEncoder(cols=['zip_code'], smoothing=0.3) # Fit only on training data, then apply to validation/test encoder.fit(X_train['zip_code'], y_train) X_train['zip_code_enc'] = encoder.transform(X_train['zip_code']) X_valid['zip_code_enc'] = encoder.transform(X_valid['zip_code']) 3. Scaling and Skew Correction Distance-based and linear models (KNN, SVM, logistic regression) are sensitive to feature scale; tree-based models are not, but still benefit from skew correction. A log transform on heavily right-skewed features (income, transaction amounts) tightens the distribution and reduces the influence of outliers. Python import numpy as np from sklearn.preprocessing import StandardScaler # Log-transform skewed monetary features (add 1 to handle zeros) df['txn_amount_log'] = np.log1p(df['txn_amount']) scaler = StandardScaler() num_cols = ['age', 'tenure_months', 'txn_amount_log'] df[num_cols] = scaler.fit_transform(df[num_cols]) 4. Interaction and Ratio Features Models capture linear or split-based patterns per feature; they don't automatically discover that the ratio of two columns is what matters. Manually creating interaction terms and ratios spend-per-visit, tenure-to-age ratio gives the model shortcuts to patterns it would otherwise need many more splits or much more data to approximate. Python # Ratio feature: average spend per visit is often more predictive # than either 'total_spend' or 'visit_count' alone df['spend_per_visit'] = df['total_spend'] / df['visit_count'].replace(0, 1) # Interaction: tenure relative to customer age df['tenure_ratio'] = df['tenure_months'] / (df['age'] * 12) 5. Date/Time Decomposition A raw timestamp is nearly useless to a model. Decomposing it into cyclical and calendar components day of week, month, is-weekend, sine/cosine encodings for cyclical features exposes seasonality and recurring patterns that the model can actually split on. Python df['signup_date'] = pd.to_datetime(df['signup_date']) df['signup_dow'] = df['signup_date'].dt.dayofweek df['signup_month'] = df['signup_date'].dt.month df['is_weekend'] = (df['signup_dow'] >= 5).astype(int) # Cyclical encoding so December and January are 'close' to the model df['month_sin'] = np.sin(2 * np.pi * df['signup_month'] / 12) df['month_cos'] = np.cos(2 * np.pi * df['signup_month'] / 12) 6. Feature Selection (Prune After You Expand) Adding interaction and encoded features inflates dimensionality, which can reintroduce noise and overfitting. Closing the loop with feature selection, dropping low-importance or highly correlated features, is what typically delivers the last, and sometimes largest, accuracy gain in the pipeline. Python from sklearn.feature_selection import SelectFromModel from sklearn.ensemble import GradientBoostingClassifier base_model = GradientBoostingClassifier(random_state=42) base_model.fit(X_train, y_train) # Keep only features with above-median importance selector = SelectFromModel(base_model, threshold='median', prefit=True) X_train_selected = selector.transform(X_train) X_valid_selected = selector.transform(X_valid) print(f'Kept {X_train_selected.shape[1]} of {X_train.shape[1]} features') Common pitfall: fitting imputers, encoders, and scalers on the full dataset before splitting into train/test. This leaks information from the validation set into training and inflates your reported accuracy. Always fit transformations on the training fold only, inside a cross-validation loop or an sklearn Pipeline. Putting It Together: Before vs. After The table below summarizes the cumulative effect of each technique from the churn-prediction example, holding the model (gradient boosted trees, default hyperparameters) constant throughout. steptechnique addedaccuracyΔ vs. Baseline 0 Baseline (raw features) 0.712 — 1 Missing value imputation + flag 0.741 +2.9 pts 2 Target encoding for categoricals 0.768 +5.6 pts 3 Log transform + scaling 0.781 +6.9 pts 4 Interaction/ratio features 0.824 +11.2 pts 5 Feature selection (prune to median importance) 0.851 +13.9 pts Wrapping It in a Reproducible Pipeline Scattered feature engineering code is a common source of train/serve skew. Wrapping the steps in an sklearn Pipeline (or ColumnTransformer) keeps fitting and transforming consistent between training and inference, and makes the whole thing a single object you can version and deploy. Python from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler from category_encoders import TargetEncoder from sklearn.ensemble import GradientBoostingClassifier numeric_features = ['age', 'tenure_months', 'txn_amount_log'] categorical_features = ['zip_code'] preprocessor = ColumnTransformer(transformers=[ ('num', Pipeline([ ('impute', SimpleImputer(strategy='median')), ('scale', StandardScaler()), ]), numeric_features), ('cat', TargetEncoder(), categorical_features), ]) full_pipeline = Pipeline([('preprocess', preprocessor), ('model', GradientBoostingClassifier(random_state=42)), ]) full_pipeline.fit(X_train, y_train) print(full_pipeline.score(X_valid, y_valid)) Best Practices Checklist Always fit transformers (imputers, encoders, scalers) on the training fold only, never on the full dataset before splitting.Flag missingness explicitly instead of silently imputing; the flag itself often carries signal. Prefer target/frequency encoding over one-hot for high-cardinality categorical columns. Log-transform heavily skewed numeric features before scaling. Create interaction and ratio features that reflect domain knowledge, not random combinations. Decompose timestamps into calendar and cyclical components rather than using raw dates. Re-run feature selection after adding new engineered features to control dimensionality and overfitting. Wrap the whole process in a pipeline/column transformer so training and inference stay in sync. Conclusion Feature engineering is unglamorous compared to trying the newest architecture, but it's usually the highest-leverage work in a supervised learning project. Handle missing data deliberately, encode categoricals to fit their cardinality, correct skew, build interactions that reflect real relationships in your domain, decompose dates, and prune what you don't need. In the example walked through here, that sequence alone was responsible for nearly 14 points of accuracy, with the model and hyperparameters held constant the entire time.
The City Traffic Analogy Imagine a city's traffic system during rush hour. A minor accident blocks one lane on a major highway. Traffic slows down, but the road remains partially functional. Now imagine every driver, upon encountering the slowdown, decides to: Take an alternate route through residential streetsCircle back and try the highway againCall friends to warn them, who then also reroute What happens? The residential streets become congested. Side roads jam up. Intersections gridlock. The alternate routes become slower than the original highway. Drivers circle back, creating even more congestion. What started as a single blocked lane cascades into city-wide traffic paralysis. The problem isn't the initial accident. The problem is the collective response to it. This is exactly what happens in distributed systems when retry logic becomes aggressive during degradation. A slow downstream service is like a partially blocked highway. Blind retries are like drivers flooding alternate routes without coordination. The recovery mechanism — designed to help — amplifies the original problem. Traffic engineers solve this with adaptive signals, ramp metering to control highway entry, real-time routing that reflects current conditions, and occasionally closing certain routes entirely to let the system recover. Distributed systems need the same discipline: not just a mechanism to retry, but a policy for when to hold back. The Illusion of "Retry Until It Works" Distributed systems plan for failure using timeouts, circuit breakers, message queues, and dead-letter topics. Among these tools, retries are the most common because the logic seems simple: A call fails.Wait a moment.Try again. For an isolated network drop or server reboot, this works well. But when a dependency struggles under heavy load, a retry is never free. Every retry: Sends another request over the wire.Consumes worker or execution capacity while the request is in flight.Consumes connection-pool capacity while the request remains in flight.Adds another item to process in the queue. When thousands of clients hit the same hiccup and retry together, they create an accidental traffic storm. The recovery code stops being a safety net and becomes the main driver of the outage. How Small Glitches Snowball Consider a hypothetical event platform delivering millions of webhook notifications every hour. Suppose a downstream API slows down under sudden pressure. Instead of returning in 50 milliseconds, responses stretch to 4 seconds. Some calls time out; others return 503 errors. In this scenario, if 100,000 requests fail and each client is configured for 4 retries, the recovery mechanism can generate up to 400,000 additional retry attempts against a target that is already drowning. Plain Text Downstream slows down │ ▼ Requests time out │ ▼ Clients retry │ ▼ Extra inbound traffic │ ▼ Higher downstream latency │ ▼ Widespread failure │ ▼ Retry storm This creates a self-reinforcing loop: the system reacts to failure by generating more work, adding latency, triggering more timeouts, and generating even more retries. The Six-Layer Amplification Framework Most engineers view retry amplification as simple request volume—turning one failed request into several. In reality, the blast radius cuts across multiple infrastructure layers: Plain Text Request Amplification ──► 1 failed call multiplies into 3–5x outbound requests Worker Amplification ──► Saturated execution capacity reduces fresh-work capacity Connection Amplification ──► In-flight requests consume connection-pool capacity Queue Amplification ──► Repeated redelivery increases queue pressure and lag Latency Amplification ──► Shared resource contention slows healthy paths Neighbor Amplification ──► Failing targets starve unrelated platform tenants Requests: One failing transaction multiplies into multiple outgoing calls.Workers: Long-running retries tie up execution capacity, reducing throughput for fresh work.Connections: In-flight requests consume connection-pool capacity while waiting on responses.Queues: Repeated processing and redelivery increase queue pressure and can drive consumer lag.Latency: Resource contention across shared CPU and network paths slows down healthy endpoints.Neighbors: A single failing downstream target hogs shared platform resources, degrading unrelated tenants. The important insight is that retry amplification is not merely a traffic problem; it is a resource-propagation problem. Why Queues Cannot Prevent the Storm Message queues like Kafka, RabbitMQ, and AWS SQS provide durable buffering and asynchronous decoupling, helping systems preserve messages while consumers are unavailable or degraded. However, a queue answers one specific question: "Is the message saved safely in persistent storage?" It cannot answer: "Is it operationally safe to deliver this message right now?" If workers blindly pull messages off a topic and blast them against an overloaded API, the queue acts like a firehose pointed at a fragile target. Queues provide buffering; they do not determine whether delivery should happen now. Failure Is Not Just "Up" or "Down" Basic retry policies assume a binary world: a service is either working or offline. Real production outages are rarely that clean. A downstream dependency might be: Returning 200s, but running ten times slower than normal.Dropping 20% of requests because its database connection pool is full.Rejecting calls because memory crossed a critical limit.Throttling traffic because a tenant hit their API rate limit. If your system treats a slow, memory-starved server like a brief network blip, it will keep hammering that server until it crashes. Matching the Recovery to the Failure Different failure modes require completely different responses: Transient Blips: Dropped packets or DNS timeouts. Retry with exponential backoff and randomized jitter so clients do not hit the server simultaneously.Rate Limits (HTTP 429): The service asks you to slow down. Read Retry-After, back off, cut concurrency, and resume gradually.Downstream Overload: Requests time out under load. Trip circuit breakers, lower concurrency, and pause retries until latency recovers.Non-Retryable Client Errors: Invalid payloads, unsupported operations, or permanent business-rule rejections should not be retried. Route them directly to a dead-letter queue (DLQ) for inspection. Knowing When to Stop Resilient architectures know when doing nothing is safer than trying again. SituationUnsafe ResponseSafe ResponseHTTP 429 Rate LimitImmediate retry loopThrottle affected traffic and respect the limit Retry-AfterCircuit Breaker OpenKeep pinging targetFast-fail or park messages in DLQHigh Queue BacklogRetry all failed jobsDrop or delay low-priority retriesInvalid Message SchemaRetry 5 timesRoute directly to DLQ without retryingDownstream Latency > SLAFire aggressive retriesCut concurrency and shed non-critical load Adaptive Recovery: Check System State First A static policy like retries = 3, backoff = 2s has no awareness of the surrounding environment. Consider two situations handling the same error: Scenario A: Queue depth is 150, CPU is at 20%, downstream latency is 40ms. Retrying is safe.Scenario B: Queue depth is 700,000, workers are 98% busy, downstream latency is 9s. Retrying will trigger an outage. A resilient system checks platform health before firing another request: Plain Text Request Fails │ ▼ Check Failure Type ──► Permanent error? ──► Send to DLQ │ (Transient) ▼ Check Downstream Health (Circuit breaker / Latency) │ (Degraded) ▼ Check Local Load (Queue depth / Worker pool) │ ├─► System healthy ──► Exponential backoff + Jitter ──► Retry │ └─► System stressed ─► Park message / Throttle rate ─► Delay Protecting Shared Resources To keep one broken dependency from pulling down the entire platform: Enforce a Retry Budget: Limit the capacity recovery traffic can consume based on normal workload traffic and available system headroom. When exhausted, delay, throttle, or park retries.Isolate Worker Pools: Process retries on dedicated worker pools so they cannot starve fresh incoming requests.Partition by Target: Apply distinct concurrency limits per downstream endpoint so healthy routes run unaffected.Prioritize Critical Work: When queues back up, retry critical jobs (payments, security alerts) and defer low-priority ones (analytics).Warm Up Slowly: When a failed service recovers, release queued traffic in controlled batches rather than dumping the backlog all at once. What to Measure in Production Standard metrics like error rate and average CPU hide retry problems until damage is done. Track these indicators instead: Retry-to-Primary Ratio: Track how much outgoing capacity is consumed by retries. A sustained increase signals that recovery traffic is becoming a significant source of system load.Retries per Recovery: How many attempts does a message need before succeeding? Rising numbers mean wasted compute.Worker Occupancy: How much worker capacity is spent processing retries instead of fresh work?Circuit Breaker Trip Rate: How often and how long breakers stay open across each downstream target. Summary: Recovery Has a Cost Retrying is not just an error-handling convenience — it is a resource allocation decision. Every recovery attempt consumes bandwidth, threads, memory, and downstream capacity. When systems fail under load, retrying harder often becomes the primary mechanism of collapse. To build genuinely resilient distributed architectures: Treat recovery actions as operations with real infrastructure costs.Filter errors early so permanent failures are never retried.Respect downstream throttling signals and use circuit breakers when defined failure or latency thresholds are reached.Use randomized jitter and exponential backoff to prevent self-inflicted traffic spikes.Check operational system state before deciding whether another attempt will help or harm. True system resilience is not about trying indefinitely until a call succeeds. It is about recognizing that recovery consumes resources, and ensuring every recovery decision accounts for system state before taking action.
A pod stuck in Pending is one of the most misleading states in Kubernetes. Nothing looks wrong. There's no error banner, no red text screaming at you. The scheduler just quietly gave up finding a home for it and is waiting for the world to change. I've stared at that status more times than I'd like to admit, usually at the worst possible hour, usually while someone in a chat window is asking why traffic isn't going through. Eleven minutes is about how long it took once, on a cluster that had close to 40% CPU sitting free the entire time. That gap between "there's capacity somewhere" and "this pod actually got scheduled" is what this article is really about. This is capacity spillover: demand outgrows what the current set of nodes can absorb, and the cluster needs somewhere for the overflow to land. Handle that well and it's a blip nobody notices. Handle it badly, and it's an outage that shows up in a customer complaint before it shows up in your dashboard. I'll be honest, a few years ago I would have called this an edge case worth a paragraph in the docs and moved on. Not anymore. Ask around, and you'll find most people running workloads in the cloud right now have a story about a specific instance type just not being there when they needed it, GPU-backed machines most visibly, but I've seen it happen with fairly ordinary compute SKUs too, usually right in the middle of a demand spike nobody scheduled around. That's really the whole case for spillover, once you strip away the architecture diagrams. Without it, what you get during a capacity crunch is close to random: whichever pod happens to hit the scheduler first grabs whatever's left, and everyone else waits and hopes. With it, the randomness mostly goes away. Workloads land where you decided they should, in the order you decided matters, instead of wherever the scheduler happened to find room first. New pod needs scheduling -> primary node pool checked -> if room is available, schedule normally; if the pool is full, spill to the secondary pool (overflow capacity). Why Scheduling Failures Happen in the First Place The scheduler's job sounds almost too simple: find a node with enough free CPU and memory for the pod's requests. In practice, a pod can fail to schedule for reasons that have nothing to do with the cluster actually running out of room. Here's the part that trips people up the first time they see it. A cluster can have plenty of free CPU in aggregate and still fail to place a memory-heavy pod, because no single node has enough memory available on its own. Resource requests get evaluated per node, not pooled across the whole cluster. I've had a coworker insist this was a bug the first time they saw it happen. It isn't. It's just how bin-packing works when nobody told you that's what you were looking at. Taints, tolerations, and node affinity narrow the field further. A pod that needs a GPU node, or needs to steer clear of a specific zone, only ever sees the slice of the cluster that matches. Spare capacity can be sitting right there, and the pod simply isn't allowed near it. And then there's the timing problem, which is the one that actually pages people. Even when the cluster genuinely needs more nodes and the autoscaler agrees, provisioning a node isn't instant. There's a real window between "we need capacity" and "capacity exists," and whatever's waiting in that window experiences it as a failure, not as a system doing exactly what it was designed to do. What Spillover Actually Looks Like "Spillover" gets used loosely, but it covers a few genuinely different situations, and I think that's where a lot of confusion comes from. People nod along in a meeting assuming everyone means the same thing, and they don't. The first one, and honestly the one most teams reach for first, is spilling to a different node pool entirely. You run a primary pool sized for steady-state load, and a secondary pool, usually spot or preemptible, that only spins up once the primary's full. Interruption-tolerant stuff gets pushed there when things get tight, which keeps the expensive, stable pool free for whatever actually can't afford to get evicted. It's an easy sell to whoever owns the cloud bill, which is probably why it's the version everyone sets up first. Then there's spilling across zones, or in the worst cases, regions. A single zone running out of a specific instance type during a demand spike isn't some rare event you plan a tabletop exercise around; it's just a Tuesday at this point. So you spread the workload wider. Fine in theory. I've watched a "let's just spread it across zones" fix get shipped on a Friday and turn into a cross-region latency ticket by the following Wednesday, because nobody had checked whether the workload actually tolerated that. And there's a third flavor that's less about running out of room and more about not wanting to spend the money. Technically, there's room on the expensive reserved instances. The better default is letting things spill onto cheap infrastructure first, and only reaching for the premium stuff when nothing cheaper is left over. Build it the other way, and you won't find out until the next billing cycle, which is a genuinely bad time to find out. Designing for It Instead of Reacting to It The cluster autoscaler, or something like Karpenter, handles the mechanics of adding nodes once pods can't be placed anywhere. That's useful, but it's also reactive by definition, closer to a fire alarm than a smoke detector. By the time it fires, everyone already knows something's wrong. A few things actually close the gap between "capacity ran out" and "someone got paged," in my experience. Pod priority and preemption are the ones people underestimate. Not every workload deserves an equal claim on scarce capacity, and treating them as if they do just means everything fails to schedule at once, in whatever order the scheduler happened to try them, which helps nobody. Give the stuff that actually matters a higher PriorityClass and let it evict lower-priority pods when things get tight. Over-provisioning on purpose sounds wasteful until you sit with it for a second. A handful of low-priority placeholder pods, doing nothing except holding a spot, get evicted the instant real demand shows up, and the real workload schedules immediately instead of waiting on a node to boot. Small constant cost, much faster response when it actually counts. Provisioning speed is the one that surprised me. Tools that can add nodes directly, without needing to fit some pre-defined pool shape first, shrink that window between "pod can't schedule" and "a node exists to put it on" a lot more than I expected going in. That window is basically where the pager lives. And testing failure on purpose, which is the step almost everyone skips. It's easy to assume spillover works because it's configured correctly on paper. Deliberately draining a node pool in a test environment and watching whether pods actually land where they're supposed to, in the order they're supposed to, finds a category of misconfiguration you will never catch by reading YAML, however carefully you read it. The Part That's Easy to Skip Past Spillover design tends to get treated as a purely technical exercise. Pick the autoscaler, define the pools, set the priorities, done. The harder question usually isn't technical at all: who decides which workloads are allowed to spill onto cheaper or less reliable capacity, and who's on the hook when a decision made for cost reasons turns into someone else's latency problem three sprints later. Getting the mechanics right is necessary. It's not enough on its own, because nobody's actually agreed on the policy those mechanics are supposed to enforce, and that conversation almost always happens after the first bad incident instead of before it. It's happened to every team I've worked with in some form. I'd rather have that conversation early, even if it's a little awkward and nobody wants to own the answer yet. It beats the alternative meeting, the one scheduled at 9 am the morning after something broke.
All database administrators have experienced something like this at one point. A query that was running well suddenly starts to create problems. No error, failure, or warning appears on the dashboard, and the only noticeable sign is users complaining that the application is running slowly today. Now the DBA must determine which of the countless queries is causing the problem. The best solution here is the Query Store tool. Since its implementation in 2016, this special feature has been storing data about SQL query text, execution plans, and statistics. In addition, unlike the plan cache, it retains this information even after a server restart. However, the trick is to get this data in a user-friendly format. Looking is easy. Automating is the hard part. When you launch SSMS, you will see the built-in "Regressed Queries" report — a GUI pane you open, filter by a metric and time window, and read. It's genuinely useful for firefighting: when something's slow right now, it's a fast way to see what changed. There are scriptable options too. Erik Darling's free, open-source sp_QuickieStore is a stored procedure that digs into Query Store and hands back result sets you can filter in ways the GUI won't let you. It's excellent, and if you're comfortable in T-SQL, it's well worth knowing. But notice what both leave on the table. The built-in report is something you open and stare at — fine for one database in the moment, useless for checking 150 of them every morning unattended. And even a scriptable stored procedure returns a result set for you to look at, not an object you can pipe onward. What most of us actually want is a job that runs autonomously, checks the whole estate overnight, and hands the result straight to a report or an alert without anyone pressing buttons. This is the gap. Plenty of tools let you see regressions; a stored procedure lets you query them. Far fewer let you script them as objects — things you can filter, sort, pipe, and schedule as part of a pipeline. Once you're working with objects instead of screens or result sets, a lot becomes possible. What Actually Counts as a Regression Before initiating the automation process, it's wise to be truthful about what you're measuring, since the simple way is often misleading. The obvious approach is "compare yesterday's average duration to today's." Two things are wrong with that. First, a plain average hides spikes — a query that's usually fast but occasionally awful gets smoothed into looking fine. Second, and worse, it treats every query as equal. A query that ran twice yesterday and a query that ran fifty thousand times yesterday both get one vote, so a query that happened to be slow on its two executions shows up as a giant "regression" next to nothing, and drowns out the query that quietly got 30% slower across fifty thousand real executions and is actually hurting you. The fix is to weight by how often a query actually runs. Give the fifty-thousand-execution query the influence its volume earns, and filter out the low-count noise that produces false alarms. You're not asking "did the average change," you're asking "did something people actually run a lot get meaningfully slower." That's the difference between a signal you trust and an inbox full of cries of wolf. One should also consider the lower limit of the two parameters chosen for the comparison. One might think it is enough to set the criterion of "servicing at least N times not long ago." However, even if the baseline was executed only a few times, it is still not a reliable indicator for comparison. Doing It in PowerShell This is the basis for Find-SqlQueryStoreRegression — the command in SqlReliabilityKit, a small command-line module available for free under the MIT License that provides a mechanism for reliability analysis. The command processes Query Store DMVs, executes a weighted baseline, removes any irrelevant noise, and produces output in the form of results. PowerShell Install-Module SqlReliabilityKit -Scope CurrentUser Queries that ran meaningfully slower in the recent window vs. the baseline window: PowerShell Find-SqlQueryStoreRegression -SqlInstance localhost -Database QsTest It is not editable. It only reads the Query Store and generates results; it neither changes plans nor performs any modifications of its settings. What you do with that information is up to you, which is the whole point. As the results are presented as various objects, the most interesting part is the pipe. The ten worst regressions, by how many times slower they got: PowerShell Find-SqlQueryStoreRegression -SqlInstance localhost -Database QSTest | Sort-Object SlowdownFactor -Descending | Select-Object -First 10 Only the serious ones, on queries that actually run a lot: PowerShell Find-SqlQueryStoreRegression ` -SqlInstance localhost ` -Database QSTest ` -SlowdownThreshold 2.0 ` -MinExecutionCount 50 That second one reads as plainly as it runs: show me queries that at least doubled, and only ones that ran fifty-plus times so I'm not chasing noise. Schedule that, pipe the result to an email or a Teams webhook, and you've turned "stare at a dashboard" into "get told when something's wrong." Watching It Actually Catch One One is justified in doubting tools that claim to spot issues. A tool that always fails to go off in trials is simply a tool that has not had its lies caught yet. So here is how you can ensure that your device will catch regressions that you will cause intentionally — and that is also the only honest way for you to trust any monitoring device. The trick is to take a query that's fast because it uses an index, then pull the index out from under it: Baseline: Run a query many times while it's fast (index present). PowerShell 1..300 | ForEach-Object { Invoke-DbaQuery -SqlInstance localhost -Database QSTest -Query "SELECT COUNT(*) FROM dbo.Orders WHERE CustomerId = $($_ % 5000)" } Now break it: Drop the index so the same query scans the whole table. PowerShell Invoke-DbaQuery -SqlInstance localhost -Database QSTest -Query "DROP INDEX IX_Orders_CustomerId ON dbo.Orders" Run it slow, flush Query Store, then check 300 more executions, then: PowerShell Find-SqlQueryStoreRegression ` -SqlInstance localhost ` -Database QSTest ` -WindowUnit Minute ` -BaselineStart 15 ` -BaselineEnd 3 ` -MinExecutionCount 10 ` -SlowdownThreshold 1.5 | Format-Table -AutoSize The query text is the same. Only the cost varies now because we are using the same plan, which now scans a couple hundred thousand rows instead of seeking. Therefore, we see the same query in the output, along with a baseline of the milliseconds it would take to complete the same request compared with the current milliseconds spent. This is exaggerated, for sure, since in real life we see no more than 2 or 10 times. However, this proves that the detection works for the appropriate query. One more detail in the output is worth noting. It shows whether the plan changed during the process. In this case, it did not change, as there was no need. All that we did was drop the index; it did not change the plan, but it made it more expensive in terms of cost. This is why it is important to note that the two situations differ. The Point The Query Store is already familiar with your performance history. The problem has never been data. The issue has always been finding a way to convert data into actionable information that can be used without human intervention. The first step is giving weights according to executions so that the volume matters, making sure both windows are floored so that the noise does not matter. Next, return objects instead of a downloadable report. This makes the difference between investigating which query failed at 2 o’clock in the morning and adding this process to a scheduled job. Find-SqlQueryStoreRegression is one command in an early, actively developed module, so feedback and issues are genuinely welcome — SqlReliabilityKit on the PowerShell Gallery, source on GitHub.
Agile
Career Development
Methodologies
Team Management
The Jobs Matrix for AI: Four Boxes Instead of 40 Tools
October 12, 2026
by Stefan Wolpers
CORE
Why Your GPU Fleet Is Both Full and Idle: Building a Capacity Orchestration Layer
October 9, 2026 by Ankit Sinha
Kill the Worker, Keep the Research: Build a Recoverable LangGraph Agent on Temporal
October 5, 2026
by Akhil Madineni
CORE
AI/ML
Big Data
Databases
IoT
Why 200 OK Is the Most Dangerous Lie in Enterprise Integration
October 19, 2026 by Manjeera Chanda
5 Identity Patterns Your AI Agents Need Before They Touch Production
October 19, 2026 by Rodrigo Martinez Pinto
A Reference Architecture for Constrained AI-Assisted Design
October 19, 2026 by Mahesh S
Cloud Architecture
Integration
Microservices
Performance
Why 200 OK Is the Most Dangerous Lie in Enterprise Integration
October 19, 2026 by Manjeera Chanda
A Reference Architecture for Constrained AI-Assisted Design
October 19, 2026 by Mahesh S
GenAI Security Gateway: An End-to-End Architecture
October 19, 2026
by Ram Ghadiyaram
CORE
Frameworks
Java
JavaScript
Languages
Tools
5 Identity Patterns Your AI Agents Need Before They Touch Production
October 19, 2026 by Rodrigo Martinez Pinto
One Database, Two Models: Building AI-Friendly Java Applications With MySQL JSON Duality Views
October 19, 2026
by A N M Bazlur Rahman
CORE
Hot-Reloading Kubernetes Secrets and ConfigMaps in Go With mamori (No Pod Restarts)
October 19, 2026
by Xavier Portilla Edo
CORE
AI/ML
Java
JavaScript
Open Source
5 Identity Patterns Your AI Agents Need Before They Touch Production
October 19, 2026 by Rodrigo Martinez Pinto
A Reference Architecture for Constrained AI-Assisted Design
October 19, 2026 by Mahesh S