<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Mark Sayson</title>
    <description>Notes on privacy engineering and distributed system verification.</description>
    <link>https://www.marksayson.com/</link>
    <atom:link href="https://www.marksayson.com/feed.xml" rel="self" type="application/rss+xml"/>
    <pubDate>Thu, 01 Oct 2026 04:36:51 +0000</pubDate>
    <lastBuildDate>Thu, 01 Oct 2026 04:36:51 +0000</lastBuildDate>
    <generator>Jekyll v3.10.0</generator>
    
      <item>
        <title>Model a deletion graph, but don&apos;t execute it</title>
        <description>&lt;p&gt;Modeling deletion as a dependency graph sounds good in theory: identify which systems are upstream of others and determine the order in which to delete customer data.&lt;/p&gt;

&lt;p&gt;Trying to enforce that ordering in real-world workflows quickly becomes a nightmare. It adds unnecessary complexity and creates points of failure throughout the dependency graph.&lt;/p&gt;

&lt;p&gt;However, mapping the graph is still useful: it exposes failure modes, highlights where deletion-aware access controls are required, and helps prioritize next steps for your deletion program.&lt;/p&gt;

&lt;h2 id=&quot;the-naive-solution&quot;&gt;The naive solution&lt;/h2&gt;

&lt;p&gt;Imagine a system where customer personal data flows through several services.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20260926_WherePersonalDataFlows.svg&quot; alt=&quot;Diagram illustrating flow of personal data across services&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Customer Accounts shares personal data with Ordering and Notifications. Ordering also passes personal data to Search and Notifications, while Analytics consumes data from Ordering, Search, and Notifications.&lt;/p&gt;

&lt;p&gt;When a customer asks us to delete their data, what order should be followed for deletion?&lt;/p&gt;

&lt;p&gt;We could follow the flow of data:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Delete the Customer Account&lt;/li&gt;
  &lt;li&gt;Then delete from Ordering&lt;/li&gt;
  &lt;li&gt;Then delete from Search and Notifications&lt;/li&gt;
  &lt;li&gt;Finally from Analytics after all its upstreams have deleted the customer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It sounds reasonable, and it stops personal data from flowing back into downstream systems after they’ve fulfilled the deletion requests.&lt;/p&gt;

&lt;p&gt;But there’s an important assumption hiding here: &lt;strong&gt;data-flow dependencies aren’t deletion-order dependencies&lt;/strong&gt;. Sometimes they share direction; sometimes deletion must run opposite to the data flow.&lt;/p&gt;

&lt;p&gt;Analytics consumes data from multiple upstream systems, but does it actually have to wait for all of them to finish deleting before it can start? The graph shows the relationships exist, but doesn’t show which ones require ordering.&lt;/p&gt;

&lt;p&gt;And even if we knew, enforcing the order across a live graph runs into two problems: keeping the graph accurate, and keeping one failure from blocking everything else.&lt;/p&gt;

&lt;h2 id=&quot;the-graph-is-never-stable&quot;&gt;The graph is never stable&lt;/h2&gt;

&lt;p&gt;In a large organization, the graph becomes stale almost immediately. Teams add data sources and consumers, split services, and introduce caches and derived data sets. None of these changes necessarily show up in the deletion workflow.&lt;/p&gt;

&lt;p&gt;Deprecations are particularly problematic. If a decommissioned service is still in the graph, an ordered workflow can wait indefinitely for a completion signal that will never arrive, silently blocking every downstream deletion.&lt;/p&gt;

&lt;p&gt;Strictly ordered execution needs every one of those changes to be reflected in the graph immediately. Automation helps, but the inventory then becomes another system that must be maintained, validated, and trusted.&lt;/p&gt;

&lt;p&gt;A map that’s 90% accurate still produces a useful list of remediation work. An orchestrator that’s 90% accurate can stall every request that routes through the stale 10%.&lt;/p&gt;

&lt;h2 id=&quot;one-failure-shouldnt-block-everything-else&quot;&gt;One failure shouldn’t block everything else&lt;/h2&gt;

&lt;p&gt;Suppose Customer Accounts has a deletion problem. Under the naive plan, Ordering can’t start until Customer Accounts succeeds, Search can’t start until Ordering succeeds, and so on.&lt;/p&gt;

&lt;p&gt;One team’s outage shouldn’t block deletion for hundreds of other services.&lt;/p&gt;

&lt;p&gt;A better model is to treat deletion as asynchronous. The request propagates to every system, and each does its own work. Some delete immediately. Others take longer, fail temporarily, and retry.&lt;/p&gt;

&lt;p&gt;Deletion doesn’t need to behave like a single distributed transaction where every system commits together. Systems should make progress independently where they safely can.&lt;/p&gt;

&lt;h2 id=&quot;when-strict-deletion-ordering-is-actually-required&quot;&gt;When strict deletion ordering is actually required&lt;/h2&gt;

&lt;p&gt;Ordering is required when deleting in the wrong order would break an &lt;strong&gt;invariant&lt;/strong&gt;: a rule the system enforces to stay consistent, such as “every order must reference an existing customer.” Two patterns come up most often.&lt;/p&gt;

&lt;h3 id=&quot;referential-integrity&quot;&gt;Referential integrity&lt;/h3&gt;

&lt;p&gt;Within a single database, this is familiar: if orders reference a customer and there’s no cascading delete, the orders must be removed first.&lt;/p&gt;

&lt;p&gt;The same pattern exists across services. Suppose Ordering holds a hard reference to each customer account, and Customer Accounts refuses to delete an account while orders still reference it. Data flows from Customer Accounts to Ordering, but Ordering must delete its records before the account becomes eligible for deletion.&lt;/p&gt;

&lt;p&gt;Represent this explicitly as an ordering constraint. Ordering deletes asynchronously, and the account becomes eligible for deletion once those references are gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ordering exists because of the invariant, not because of the data flow.&lt;/strong&gt;&lt;/p&gt;

&lt;h3 id=&quot;in-flight-work&quot;&gt;In-flight work&lt;/h3&gt;

&lt;p&gt;A different class of ordering constraint occurs when in-flight work requires the source state to remain available while it executes.&lt;/p&gt;

&lt;p&gt;The required sequence might be:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Stop new jobs or orders&lt;/li&gt;
  &lt;li&gt;Complete or cancel in-flight work&lt;/li&gt;
  &lt;li&gt;Delete source data&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without that ordering, a queued job could execute after the source has been deleted and recreate data that was supposed to be removed, or fail because the referenced state no longer exists.&lt;/p&gt;

&lt;h2 id=&quot;model-the-constraints-enforce-them-locally&quot;&gt;Model the constraints, enforce them locally&lt;/h2&gt;

&lt;p&gt;A graph used for orchestration should identify the smallest set of dependencies that require deletion ordering.&lt;/p&gt;

&lt;p&gt;In the example, customer data reaches every system, but the only real constraint may be that Ordering deletes before Customer Accounts.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20260926_WhereDeletionOrderMatters.svg&quot; alt=&quot;Diagram illustrating where deletion order matters&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Every edge that does impose ordering should record why: the invariant that breaks if deletion happens in the wrong order.&lt;/p&gt;

&lt;p&gt;When strict ordering is required, enforce it as close as possible to the system that owns the invariant. That may be cascading deletes in a database, or a service that refuses to delete a parent while dependents exist.&lt;/p&gt;

&lt;p&gt;A central orchestrator can coordinate genuine cross-system ordering constraints, but it shouldn’t turn every dependency into a global workflow.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Enforce ordering where the invariant lives. Keep everything else asynchronous.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;prevent-deleted-data-from-coming-back&quot;&gt;Prevent deleted data from coming back&lt;/h2&gt;

&lt;p&gt;Asynchronous deletion introduces race conditions, which I cover in more detail in &lt;a href=&quot;/blog/mitigating-personal-data-resurrection/&quot;&gt;“Deleted and back again: mitigating unintended data resurrection”&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Search deletes a customer’s data, but Ordering may not have processed the deletion yet, so it continues working normally and sends that data back to Search, which stores it again.&lt;/p&gt;

&lt;p&gt;Strict ordering could help prevent this by making Search wait for Ordering. The more robust fix is to make Ordering deletion-aware. Once a customer is subject to deletion, Ordering can filter its exports and reject API requests for that customer.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Downstream systems shouldn’t have to wait for upstream systems to delete. Upstream systems should stop sending data that’s subject to deletion.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That changes the question from “What order should these systems delete in?” to “What guarantees does each system need while deletion is in progress?”&lt;/p&gt;

&lt;h2 id=&quot;asynchronous-doesnt-mean-fire-and-forget&quot;&gt;Asynchronous doesn’t mean fire-and-forget&lt;/h2&gt;

&lt;p&gt;Once deletion is asynchronous, the same request can be finished in one system and pending in another. That’s expected, but the program needs to distinguish deletion that’s in progress and on track for its deadline from deletion that’s stalled, failed, or unverified. That requires every system to report where each request stands:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Accepted: the system received the request&lt;/li&gt;
  &lt;li&gt;Processed: the system ran its deletion&lt;/li&gt;
  &lt;li&gt;Verified: there is evidence of expected deletion or de-identification state&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This distinction matters because a service responding “success” to a request isn’t necessarily enough evidence for an audit. For privacy deletion programs, I’d consider a system verified when we have evidence that personal data is no longer operationally available except where there are documented retention exemptions.&lt;/p&gt;

&lt;h2 id=&quot;use-the-graph-to-find-the-work&quot;&gt;Use the graph to find the work&lt;/h2&gt;

&lt;p&gt;Most privacy programs already have most of this graph through records of processing activities. What they usually lack is what deletion depends on: which flows impose ordering, where data can flow back after deletion, which retention exceptions apply to each system, and how each system proves it deleted.&lt;/p&gt;

&lt;p&gt;For each dependency, ask:&lt;/p&gt;

&lt;table class=&quot;table-small-bordered top-bottom-padded&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Question&lt;/th&gt;
      &lt;th&gt;Gap it reveals&lt;/th&gt;
      &lt;th&gt;Typical fix&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Does this dependency impose a deletion invariant?&lt;/td&gt;
      &lt;td&gt;Ordering constraints&lt;/td&gt;
      &lt;td&gt;Enforce locally, or model an explicit constraint&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Can data flow after deletion begins?&lt;/td&gt;
      &lt;td&gt;Resurrection paths&lt;/td&gt;
      &lt;td&gt;Deletion-aware transfers and durable deletion state&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Can queued or in-flight work outlive the source data?&lt;/td&gt;
      &lt;td&gt;In-flight races&lt;/td&gt;
      &lt;td&gt;Drain queues or reject stale work&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;How do we know deletion completed?&lt;/td&gt;
      &lt;td&gt;Verification gaps&lt;/td&gt;
      &lt;td&gt;Per-system accepted, processed, and verified states&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Who owns the dependency?&lt;/td&gt;
      &lt;td&gt;Operational gaps&lt;/td&gt;
      &lt;td&gt;Assign owners; build shared tooling for common gaps&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Is data shared with a processor or third party?&lt;/td&gt;
      &lt;td&gt;Recipient gaps&lt;/td&gt;
      &lt;td&gt;Propagate deletion requests and track confirmation&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Make deletion asynchronous by default.&lt;/li&gt;
  &lt;li&gt;Add ordering only where a concrete correctness constraint requires it, and enforce that ordering as close to the constraint as possible.&lt;/li&gt;
  &lt;li&gt;Make systems robust to deletion happening somewhere else at the same time.&lt;/li&gt;
  &lt;li&gt;Model the deletion graph to find engineering work. Don’t execute it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;posts-in-this-series&quot;&gt;Posts in this series&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/why-data-deletion-is-still-unsolved/&quot;&gt;Why data deletion is still an unsolved infrastructure problem&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/why-deletion-means-different-things/&quot;&gt;Why deletion means different things in different systems&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/gaps-in-data-deletion-verification-auditability/&quot;&gt;Gaps in data deletion verification and auditability&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/deletion-is-not-always-deletion/&quot;&gt;Deletion is not always deletion: retention exceptions and competing obligations&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/mitigating-personal-data-resurrection/&quot;&gt;Deleted and back again: mitigating unintended data resurrection&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;(Current post) Model a deletion graph, but don’t execute it&lt;/li&gt;
&lt;/ol&gt;
</description>
        <pubDate>Sun, 27 Sep 2026 04:30:00 +0000</pubDate>
        <link>https://www.marksayson.com/blog/modeling-the-deletion-graph/</link>
        <guid isPermaLink="true">https://www.marksayson.com/blog/modeling-the-deletion-graph/</guid>
        
        
        <category>privacy-engineering</category>
        
        <category>right-to-be-forgotten</category>
        
      </item>
    
      <item>
        <title>Deleted and back again: mitigating unintended data resurrection</title>
        <description>&lt;p&gt;Kathy submits a personal data deletion request.  Ordering, marketing, and personalization systems remove her records, and yet, a week later, her data is back as if nothing had happened.&lt;/p&gt;

&lt;p&gt;In complex data architectures, keeping deleted personal data &lt;em&gt;deleted&lt;/em&gt; is a persistent engineering challenge.  Systems re-ingest, recompute, replay, and repopulate data long after a deletion request has been processed.&lt;/p&gt;

&lt;p&gt;A single missed dataset can re-propagate a user’s personal data across an organization’s interconnected systems, undermining the best attempts at achieving deletion.&lt;/p&gt;

&lt;h2 id=&quot;what-is-data-resurrection&quot;&gt;What is data resurrection?&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Data resurrection&lt;/strong&gt; is the unintended reintroduction of personal data into a system after that data has been deleted or should no longer be processed.&lt;/p&gt;

&lt;p&gt;This is distinct from:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Re-identification&lt;/strong&gt;, where anonymized data becomes identifiable through inference or aggregation.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Retention exceptions&lt;/strong&gt;, where data is intentionally preserved for legal or operational reasons.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;common-sources-of-unintended-data-resurrection&quot;&gt;Common sources of unintended data resurrection&lt;/h2&gt;

&lt;h3 id=&quot;source-1-upstream-systems-not-onboarded-to-deletion&quot;&gt;Source 1: Upstream systems not onboarded to deletion&lt;/h3&gt;

&lt;p&gt;Upstream systems that still hold personal data continue emitting events, syncing records, or serving APIs that downstream systems consume.  Even if downstream systems correctly delete a customer’s personal data upon request, they will re-ingest whatever their upstream dependencies continue to publish.&lt;/p&gt;

&lt;h3 id=&quot;source-2-out-of-order-deletion&quot;&gt;Source 2: Out-of-order deletion&lt;/h3&gt;

&lt;p&gt;Many deletion workflows have a hidden assumption that upstream systems delete first and downstream systems follow.  When this ordering isn’t enforced, downstream systems delete and re-ingest a customer’s personal data from upstream systems that haven’t deleted yet.&lt;/p&gt;

&lt;p&gt;Even if deletion requests are sent to upstream systems first, workflows are often asynchronous.  Some services delete immediately, while others batch deletions into daily or weekly jobs during periods of low customer traffic.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20260831_OutOfOrderDeletion.svg&quot; alt=&quot;Diagram illustrating how out-of-order deletion results in resurrection of deleted personal data&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;source-3-soft-deletion-across-system-boundaries&quot;&gt;Source 3: Soft deletion across system boundaries&lt;/h3&gt;

&lt;p&gt;When an upstream system soft-deletes a record by setting a “deleted” flag and continues propagating it, the record carries its deletion state in a field that downstream consumers must be aware of.  Consumers may not act on that flag, especially if it was added after the integration was built.  The result is that soft-deleted records can still be treated as active downstream.&lt;/p&gt;

&lt;h3 id=&quot;source-4-event-replay-and-cached-data&quot;&gt;Source 4: Event replay and cached data&lt;/h3&gt;

&lt;p&gt;Systems that replay events, cache data, or ingest historical data snapshots from upstream services can inadvertently maintain or reintroduce personal data that was deleted from primary data stores.&lt;/p&gt;

&lt;p&gt;Replay and sync workflows need to propagate deletion markers or tombstones.  Caches and search indexes also need an explicit deletion path.&lt;/p&gt;

&lt;h3 id=&quot;source-5-restored-backups&quot;&gt;Source 5: Restored backups&lt;/h3&gt;

&lt;p&gt;Disaster recovery workflows restore data from backups that predate the deletion request.  Unless backup recovery and data sharing workflows are deletion-aware, restoring a single upstream backup can repopulate dozens of downstream systems with previously deleted personal data.&lt;/p&gt;

&lt;p&gt;Organizations often do not remove customer data from backups, but recovery processes must account for deletion requests since the backup was taken.  Centralized resurrection detection and re-deletion workflows can reduce service-specific recovery logic, at the cost of temporarily propagating resurrected data before it’s caught.&lt;/p&gt;

&lt;h3 id=&quot;source-6-silent-deletion-failures&quot;&gt;Source 6: Silent deletion failures&lt;/h3&gt;

&lt;p&gt;When systems report “success” after receiving or processing a deletion request but silently fail to delete or anonymize all records, they can continue to be sources of unredacted personal data long after a deletion request.&lt;/p&gt;

&lt;p&gt;Silent failures can stem from schema drift or deletion logic that does not cover all secondary or derived datasets.&lt;/p&gt;

&lt;h2 id=&quot;controls-to-mitigate-data-resurrection&quot;&gt;Controls to mitigate data resurrection&lt;/h2&gt;

&lt;p&gt;It’s tempting to say that every system should simply implement deletion and that orchestrators should enforce a global deletion order across the dependency graph.&lt;/p&gt;

&lt;p&gt;In practice, this is not feasible in large organizations.  You cannot guarantee deletion order across a large number of systems with different semantics, asynchronous workflows, independent scheduling, bidirectional data flows, and varied failure modes.&lt;/p&gt;

&lt;p&gt;The practical goal is not perfect ordering of deletion.  It is &lt;em&gt;eventual deletion&lt;/em&gt;: every system should converge to its expected deletion state within the applicable deletion window, and the organization should detect those that do not.&lt;/p&gt;

&lt;h3 id=&quot;control-1-automatic-detection-and-deletion-replay&quot;&gt;Control 1: Automatic detection and deletion replay&lt;/h3&gt;

&lt;p&gt;Periodic checks for deleted identifiers can help find data that was missed or later introduced, and trigger a notification to onboard to deletion workflows or replay deletion if the service was already onboarded.&lt;/p&gt;

&lt;p&gt;However, replaying deletion requests isn’t a silver bullet.  Continually polling for re-emergence of deleted data and retriggering deletion requests creates churn, increased service load, and does not scale with high numbers of accumulated deletion requests.  When the underlying resurrection pathways remain open, deletion replays simply repeat the same work indefinitely.&lt;/p&gt;

&lt;h3 id=&quot;control-2-deletion-aware-access-controls&quot;&gt;Control 2: Deletion-aware access controls&lt;/h3&gt;

&lt;p&gt;Deletion-aware access controls prevent services from accessing or propagating data for customers whose data has been deleted.  A platform team should build these once for reuse, rather than each service implementing its own.&lt;/p&gt;

&lt;p&gt;The simplest implementation is a query layer that checks a central deletion registry before returning a record, so that data subject to deletion is blocked from processing.  Where low latency is critical, services can maintain a locally replicated set of deleted identifiers with a defined maximum delay between a deletion decision and its availability to the service.  The tradeoff is between enforcement latency and query-time overhead.&lt;/p&gt;

&lt;p&gt;These controls create deletion boundaries.  Even if some upstream systems have not yet onboarded to deletion, data propagation stops at the boundary of any system that enforces deletion-aware access controls.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20260831_HowDeletionControlsContainDeletedData.svg&quot; alt=&quot;Diagram illustrating how deletion-aware controls contain deleted data to consumers with retention exceptions&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Deletion-aware controls do not eliminate the need for deletion replay, since data can still reach systems that do not enforce these controls before it reaches a deletion boundary.  Those systems remain in scope for detection and remediation, but the boundary limits how far the resurrected data can propagate.&lt;/p&gt;

&lt;h3 id=&quot;control-3-automatic-expiry-of-data&quot;&gt;Control 3: Automatic expiry of data&lt;/h3&gt;

&lt;p&gt;Some systems only need recent data to operate.  These can automatically delete records after a defined time period shorter than the applicable deletion window, using time-to-live (TTL) or object expiry lifecycles.&lt;/p&gt;

&lt;p&gt;These services may not need explicit deletion onboarding if they can demonstrate that data is automatically removed within the deletion window.&lt;/p&gt;

&lt;h3 id=&quot;control-4-data-minimization&quot;&gt;Control 4: Data minimization&lt;/h3&gt;

&lt;p&gt;Where possible, the simplest option is often to stop processing personal data in the first place.&lt;/p&gt;

&lt;p&gt;Given the choice to invest weeks of effort on deletion workflows, or spend a day dropping fields and stale datasets that are no longer needed, many teams may pick the latter, reducing compliance risk and storage costs in the process.&lt;/p&gt;

&lt;h2 id=&quot;a-deletion-resilience-maturity-model&quot;&gt;A deletion resilience maturity model&lt;/h2&gt;

&lt;p&gt;The following maturity model is a conceptual framework for thinking about deletion resilience.  Levels are defined by who owns deletion logic, and how effectively resurrection is caught and mitigated.&lt;/p&gt;

&lt;table class=&quot;table-small-bordered top-bottom-padded&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Level&lt;/th&gt;
      &lt;th&gt;How deletion is implemented&lt;/th&gt;
      &lt;th&gt;What resurrection looks like&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;1. Service-owned&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Each system builds its own deletion logic, often inconsistently.  Service owners self-identify when they need to onboard.&lt;/td&gt;
      &lt;td&gt;Common, and usually undetected.  Upstream systems may not delete, downstream systems re-ingest.  Deletion replays and manual clean-up are frequent and expensive, or the risk is accepted.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;2. Platform-assisted&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Deletion is treated as privacy infrastructure rather than a cost each service absorbs.  Shared tooling offers hard deletion, TTL expiry, and deletion-aware access controls with common semantics.&lt;/td&gt;
      &lt;td&gt;Still occurs, but contained and easier to remediate.  Deletion replays run periodically across services where resurrection is detected.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;3. Systemically resilient&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Controls are built into infrastructure by default, with centralized deletion state, deletion-aware boundaries, and minimal service team context needed to implement or validate.&lt;/td&gt;
      &lt;td&gt;Rare, localized, and quickly corrected.  Automated detection and reconciliation cover replay and recovery paths.&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Many organizations can have characteristics of both Levels 1 and 2: tooling may exist, while onboarding costs enough that a long tail never fully completes it, leaving resurrection a systemic risk.  Moving from Level 1 to Level 2 is mostly a tooling problem.  Level 3 requires deletion state to be treated as an organization-level architectural concern rather than solely as service-local logic.&lt;/p&gt;

&lt;h2 id=&quot;deletion-resilience-is-a-systems-property-not-a-per-service-outcome&quot;&gt;Deletion resilience is a systems property, not a per-service outcome&lt;/h2&gt;

&lt;p&gt;A successful deletion response does not prove that personal data has stopped being processed across systems with deletion expectations.&lt;/p&gt;

&lt;p&gt;Organizations need to understand how personal data subject to deletion can continue to propagate.  They should enforce containment controls in systems with legitimate retention exceptions, and build verification mechanisms that test both successful deletion and reintroduction of previously deleted data.&lt;/p&gt;

&lt;p&gt;Evidence should show which systems are subject to deletion, which have compensating measures such as automatic expiry, and which retain data under approved exceptions.  Retained data should be blocked from use for unapproved purposes.&lt;/p&gt;

&lt;p&gt;Deletion resilience is not achieved by making every system delete perfectly or by enforcing a single global deletion order.&lt;/p&gt;

&lt;p&gt;It comes from making deletion state durable, containing deleted data at system boundaries, allowing deletion state to converge within the applicable window, and continuously detecting and correcting resurrection.&lt;/p&gt;

&lt;h2 id=&quot;posts-in-this-series&quot;&gt;Posts in this series&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/why-data-deletion-is-still-unsolved/&quot;&gt;Why data deletion is still an unsolved infrastructure problem&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/why-deletion-means-different-things/&quot;&gt;Why deletion means different things in different systems&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/gaps-in-data-deletion-verification-auditability/&quot;&gt;Gaps in data deletion verification and auditability&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/deletion-is-not-always-deletion/&quot;&gt;Deletion is not always deletion: retention exceptions and competing obligations&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;(Current post) Deleted and back again: mitigating unintended data resurrection&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/modeling-the-deletion-graph/&quot;&gt;Model a deletion graph, but don’t execute it&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
</description>
        <pubDate>Mon, 31 Aug 2026 04:30:00 +0000</pubDate>
        <link>https://www.marksayson.com/blog/mitigating-personal-data-resurrection/</link>
        <guid isPermaLink="true">https://www.marksayson.com/blog/mitigating-personal-data-resurrection/</guid>
        
        
        <category>privacy-engineering</category>
        
        <category>right-to-be-forgotten</category>
        
      </item>
    
      <item>
        <title>Deletion is not always deletion: retention exceptions and competing obligations</title>
        <description>&lt;p&gt;One of the more surprising aspects of privacy engineering is how many different deletion and retention requirements can apply to the same customer data. A single deletion request may result in immediate deletion in some systems, multi-year retention in others, and event-driven retention in cases such as legal holds.&lt;/p&gt;

&lt;p&gt;In other words, deletion is not always deletion. While the intent of a customer’s deletion request may be straightforward, legal, financial, fraud prevention, security, and operational obligations often require organizations to retain specific categories of data. Understanding when data should be deleted, when it must be retained, and how retained data must be restricted is one of the more challenging aspects of building effective deletion programs.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20260620_OneDeletionRequestMultipleRetentionOutcomes.svg&quot; alt=&quot;Diagram illustrating differing retention use cases for same deletion request&quot; /&gt;&lt;/p&gt;

&lt;p&gt;For many organizations, a single deletion request can produce different outcomes across systems depending on the purpose of processing personal data, applicable retention requirements, and the justification for continued retention or processing.&lt;/p&gt;

&lt;table class=&quot;table-small-bordered top-bottom-padded&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Use Case&lt;/th&gt;
      &lt;th&gt;Example Retention Policy&lt;/th&gt;
      &lt;th&gt;Potential Justification&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Marketing and advertising&lt;/td&gt;
      &lt;td&gt;Delete upon deletion request&lt;/td&gt;
      &lt;td&gt;None for marketing use&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Customer accounts&lt;/td&gt;
      &lt;td&gt;Retain 30 days - 18 months after account closure&lt;/td&gt;
      &lt;td&gt;Legitimate interest (account recovery, service quality, dispute resolution)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Security audit logs&lt;/td&gt;
      &lt;td&gt;Retain 1-2 years after log timestamp&lt;/td&gt;
      &lt;td&gt;Legitimate interest (security); legal obligation in some jurisdictions&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Analytical datasets&lt;/td&gt;
      &lt;td&gt;Anonymize upon deletion request&lt;/td&gt;
      &lt;td&gt;No longer personal data if anonymization is effective&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Financial transaction records&lt;/td&gt;
      &lt;td&gt;Retain 3-7 years after account closure&lt;/td&gt;
      &lt;td&gt;Legal obligation (tax, accounting regulations)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Legal hold records&lt;/td&gt;
      &lt;td&gt;Retain until hold lifted&lt;/td&gt;
      &lt;td&gt;Legal obligation (litigation, regulatory investigation)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ML/AI training datasets&lt;/td&gt;
      &lt;td&gt;Exclude deleted/suppressed records; retrain periodically from upstream sources&lt;/td&gt;
      &lt;td&gt;Legitimate interest (model improvement), subject to data minimization&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Additional considerations:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Already trained ML/AI models may retain implicit knowledge about personal data subject to deletion requests.&lt;/li&gt;
  &lt;li&gt;Deletion obligations extend to third-party data processors, and organizations must propagate deletion requests to them. However, it may be difficult or impossible to prove deletion by external vendors.&lt;/li&gt;
  &lt;li&gt;There is rarely a clean way to wipe single records from data archives or rolling backups. A common pattern is to configure lifecycle rules to automatically delete data over an age threshold. Preventing resurrection of personal data is a larger problem we will discuss in a future article.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;retention-does-not-allow-unrestricted-processing&quot;&gt;Retention does not allow unrestricted processing&lt;/h2&gt;

&lt;p&gt;Retention does not provide a blanket justification for continued use of personal data.&lt;/p&gt;

&lt;p&gt;Even when there is a legal basis to retain records for purposes such as financial reporting or fraud prevention, other systems may be obligated to stop processing the same personal data.&lt;/p&gt;

&lt;p&gt;This is where many deletion programs break down. Services storing personal data often share that data with other systems through APIs, data exports, and event streams. Once a deletion request has been processed, organizations need to ensure that downstream systems are not continuing to use retained data for purposes that no longer have a valid legal basis.&lt;/p&gt;

&lt;p&gt;Organizations often address this through segregated datasets, filtered exports, or other technical controls that enforce purpose-limited access to retained personal data.&lt;/p&gt;

&lt;p&gt;For example, financial systems may need continued access to records associated with deleted customers, while marketing and personalization systems should only have access to records of active, consenting customers.&lt;/p&gt;

&lt;h2 id=&quot;scheduling-deletion-after-retention-requirements-expire&quot;&gt;Scheduling deletion after retention requirements expire&lt;/h2&gt;

&lt;p&gt;In practice, many systems define retention periods but lack reliable mechanisms to act when those periods end, causing data to persist indefinitely by default.&lt;/p&gt;

&lt;p&gt;To operationalize retention, organizations need to translate policy into &lt;strong&gt;explicit deletion triggers&lt;/strong&gt; tied to either time or business events.&lt;/p&gt;

&lt;p&gt;Common patterns include:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Time-based expiry&lt;/strong&gt; (eg. 7 years after account closure, 18 months after ticket resolution)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Event-based expiry&lt;/strong&gt; (eg. account closure, transaction finalization, legal hold removal)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;System lifecycle rules&lt;/strong&gt; that automatically purge data over a certain age&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At a minimum, retention must be treated as an executable rule, not just a policy definition. Without this, retention expiration becomes unreliable and enforcement depends on ad-hoc cleanup rather than predictable system behavior.&lt;/p&gt;

&lt;h2 id=&quot;demonstrating-compliance&quot;&gt;Demonstrating compliance&lt;/h2&gt;

&lt;p&gt;Once deletion and retention behavior is defined and implemented across systems, an equally difficult problem is proving it actually happened. In practice, many compliance gaps stem from the inability to consistently verify outcomes across distributed systems.&lt;/p&gt;

&lt;p&gt;Demonstrating compliance requires more than a single “deletion successful” response. Different systems produce different evidence, and that evidence needs to be traceable back to the original request and retention requirement. In many organizations, that evidence is generated by different teams, stored in different systems, and retained for different periods of time.&lt;/p&gt;

&lt;p&gt;At a minimum, organizations need to maintain:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Request traceability&lt;/strong&gt;: a link between the deletion request and every system that was expected to act on it.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Execution evidence&lt;/strong&gt;: logs or records showing what action each system took (delete, suppress, restrict, or retain).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Retention justification&lt;/strong&gt;: explicit recording of why data was retained where deletion did not occur (eg. legal obligation, audit requirement, or active legal hold).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Expiry verification&lt;/strong&gt;: proof that time-based or event-based retention rules were eventually enforced.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This evidence is often distributed across APIs, data pipelines, and storage systems that were not designed with coordinated auditability in mind. As a result, compliance verification tends to rely on aggregation layers rather than individual system logs.&lt;/p&gt;

&lt;p&gt;In more mature implementations, organizations treat deletion requests as &lt;strong&gt;auditable workflows rather than point-in-time operations&lt;/strong&gt;. Each step — propagation, enforcement, and retention exception handling — is recorded in a way that can be reconstructed later for audit, regulatory inquiry, or internal validation.&lt;/p&gt;

&lt;p&gt;Even when audit trails demonstrate that systems acted on a deletion request and reported success, this does not prove that personal data is absent or no longer subject to processing. Proving absence is a substantially harder problem because it requires validating that personal data cannot be found in any systems where it should have been removed, while distinguishing those systems from others where retention is justified.&lt;/p&gt;

&lt;p&gt;Ultimately, compliance in deletion programs is less about proving that data was deleted everywhere at once, and more about demonstrating that each system behaved consistently with its defined retention and processing obligations over time.&lt;/p&gt;

&lt;h2 id=&quot;posts-in-this-series&quot;&gt;Posts in this series&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/why-data-deletion-is-still-unsolved/&quot;&gt;Why data deletion is still an unsolved infrastructure problem&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/why-deletion-means-different-things/&quot;&gt;Why deletion means different things in different systems&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/gaps-in-data-deletion-verification-auditability/&quot;&gt;Gaps in data deletion verification and auditability&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;(Current post) Deletion is not always deletion: retention exceptions and competing obligations&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/mitigating-personal-data-resurrection/&quot;&gt;Deleted and back again: mitigating unintended data resurrection&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/modeling-the-deletion-graph/&quot;&gt;Model a deletion graph, but don’t execute it&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
</description>
        <pubDate>Sun, 21 Jun 2026 04:30:00 +0000</pubDate>
        <link>https://www.marksayson.com/blog/deletion-is-not-always-deletion/</link>
        <guid isPermaLink="true">https://www.marksayson.com/blog/deletion-is-not-always-deletion/</guid>
        
        
        <category>privacy-engineering</category>
        
        <category>right-to-be-forgotten</category>
        
      </item>
    
      <item>
        <title>Gaps in data deletion verification and auditability</title>
        <description>&lt;p&gt;All systems reported “Deletion succeeded” after Kathy submitted a data deletion request on the company website last month. Yet she is still receiving personalized marketing emails, internal services are still passing around her data, and her order history still appears in business analytics reports.&lt;/p&gt;

&lt;p&gt;Something has clearly gone wrong.&lt;/p&gt;

&lt;p&gt;A deletion request is often treated as complete once a workflow reports success, but that signal doesn’t mean data has been removed from all infrastructure or excluded from all processing activities.&lt;/p&gt;

&lt;p&gt;Most systems can confirm they executed a deletion request. Very few can confirm whether all personal data associated with a customer was actually removed everywhere it existed and remained absent over time.&lt;/p&gt;

&lt;p&gt;That gap between “operation completed” and “data is fully removed from processing” is where most deletion guarantees fail in practice.&lt;/p&gt;

&lt;h2 id=&quot;what-was-tracked-as-success&quot;&gt;What was tracked as success&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;/images/20260612_WhatDeletionSuccessActuallyMeasures.svg&quot; alt=&quot;Diagram illustrating what deletion succeeded actually measures&quot; /&gt;&lt;/p&gt;

&lt;p&gt;In distributed systems, a deletion request typically passes through an orchestrated workflow:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;a central service receives the request&lt;/li&gt;
  &lt;li&gt;tasks are fanned out to onboarded backend systems&lt;/li&gt;
  &lt;li&gt;each system executes local deletion logic&lt;/li&gt;
  &lt;li&gt;success/failure signals are reported back to the orchestrator&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At the end of this flow, the system reports success, but with a narrow interpretation:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;the request was accepted&lt;/li&gt;
  &lt;li&gt;all tasks were executed or acknowledged&lt;/li&gt;
  &lt;li&gt;no immediate errors were returned&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This doesn’t mean that the customer’s data has been removed from processing across all systems.&lt;/p&gt;

&lt;p&gt;Deletion is treated as an operation, not a system-wide, verifiable state.&lt;/p&gt;

&lt;h2 id=&quot;an-idealized-system-model&quot;&gt;An idealized system model&lt;/h2&gt;

&lt;p&gt;A fully verifiable deletion program would require:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A complete and continuously updated inventory of systems storing or processing personal data&lt;/li&gt;
  &lt;li&gt;Lifecycle state of each system’s deletion capabilities and onboarding status&lt;/li&gt;
  &lt;li&gt;End-to-end visibility into deletion requests across all systems&lt;/li&gt;
  &lt;li&gt;Backfill of prior deletion requests for onboarded systems&lt;/li&gt;
  &lt;li&gt;System-wide prevention of reintroduction of deleted identities&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;why-this-model-does-not-exist-in-practice&quot;&gt;Why this model does not exist in practice&lt;/h2&gt;

&lt;p&gt;Each component is reasonable in isolation, and many organizations already have partial implementations: system catalogs, deletion workflows, onboarding processes, and audit logs. However, end-to-end provability remains unsolved, with structural gaps in environments where infrastructure is continuously evolving.&lt;/p&gt;

&lt;h3 id=&quot;1-system-inventories-drift-from-reality&quot;&gt;1. System inventories drift from reality&lt;/h3&gt;

&lt;p&gt;System inventories are frequently incomplete and not automatically maintained, especially those specific to privacy expectations. New services appear without formal registration with privacy workflows, new data flows emerge through API integrations and ad-hoc data pipelines, and third-party integrations bypass internal catalogs.&lt;/p&gt;

&lt;p&gt;As a result, system coverage both lags reality and becomes stale even for existing services. Teams may accurately attest to their current state and start processing new types of customer data tomorrow.&lt;/p&gt;

&lt;h3 id=&quot;2-deletion-capabilities-are-inconsistently-deployed&quot;&gt;2. Deletion capabilities are inconsistently deployed&lt;/h3&gt;

&lt;p&gt;Deletion support is typically added incrementally across services, often focused on primary data storage while excluding derived datasets and downstream data pipelines.&lt;/p&gt;

&lt;p&gt;This creates a persistent mismatch between documented and actual enforcement.&lt;/p&gt;

&lt;h3 id=&quot;3-deletion-state-is-local-not-system-wide&quot;&gt;3. Deletion state is local, not system-wide&lt;/h3&gt;

&lt;p&gt;Even where deletion workflows exist, state is local. A system can confirm it executed a deletion request, but has no visibility into whether upstream and downstream systems have done the same.&lt;/p&gt;

&lt;p&gt;This is a direct architectural outcome of how distributed systems are designed for local correctness and independent development.&lt;/p&gt;

&lt;h3 id=&quot;4-deletion-history-is-fragmented&quot;&gt;4. Deletion history is fragmented&lt;/h3&gt;

&lt;p&gt;Backfilling prior deletion requests after onboarding assumes a replayable deletion request record and a complete history of request fulfillment across systems.&lt;/p&gt;

&lt;p&gt;Without this history, newly onboarded systems may have no reliable mechanism to identify customers whose data should already have been deleted.&lt;/p&gt;

&lt;p&gt;The deletion request record is often easier to centralize than fulfillment evidence, which is often fragmented across systems due to inconsistent logging, deletion semantics, and retention policies.&lt;/p&gt;

&lt;h3 id=&quot;5-deletion-does-not-prevent-future-processing&quot;&gt;5. Deletion does not prevent future processing&lt;/h3&gt;

&lt;p&gt;Preventing reintroduction of deleted data is not centrally enforceable. It must hold across all platforms, pipelines, warehouses, event systems, and API integrations, each with different semantics for processing personal data.&lt;/p&gt;

&lt;p&gt;A customer deleted today can reappear tomorrow through historical imports, replicated datasets, replayed event streams, or integration with systems not onboarded to deletion.&lt;/p&gt;

&lt;h2 id=&quot;typical-verification-approaches&quot;&gt;Typical verification approaches&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Sampling-based checks&lt;/li&gt;
  &lt;li&gt;Per-system logs that may differ in format, granularity, and completeness&lt;/li&gt;
  &lt;li&gt;Best-effort reconciliation reports&lt;/li&gt;
  &lt;li&gt;Manual audits during compliance review&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None provide systemic guarantees. More fundamentally, proving the absence of data is often harder than proving that a deletion workflow executed successfully.&lt;/p&gt;

&lt;p&gt;As a result, organizations often rely on process evidence rather than proof of outcome. Auditors and regulators may be shown that deletion workflows executed successfully, even when there is limited visibility into whether personal data was removed from all systems that process it.&lt;/p&gt;

&lt;p&gt;When a customer finds evidence that their data was not actually deleted, this can trigger regulatory scrutiny, corrective actions, and potentially significant financial penalties.&lt;/p&gt;

&lt;h2 id=&quot;toward-a-real-verification-model&quot;&gt;Toward a real verification model&lt;/h2&gt;

&lt;h3 id=&quot;automated-inventories-and-compliance-tracking&quot;&gt;Automated inventories and compliance tracking&lt;/h3&gt;

&lt;p&gt;We need continuous discovery of systems and datasets containing personal data, and unified deletion state tracking. This is an incremental process that may initially only cover a few technologies where automation is easiest.&lt;/p&gt;

&lt;p&gt;Many organizations may rely on periodic manual processes where service owners attest to new personal data processing use cases and follow guidance on privacy expectations.&lt;/p&gt;

&lt;h3 id=&quot;lowering-compliance-effort-through-automation-and-tooling&quot;&gt;Lowering compliance effort through automation and tooling&lt;/h3&gt;

&lt;p&gt;Compliance outcomes improve when we have automatic tracking and accountability for deletion expectations, and low-effort mechanisms for onboarding datasets to service-appropriate deletion mechanisms.&lt;/p&gt;

&lt;p&gt;This is not one-size-fits-all, as each system may have different deletion semantics, such as hard deletion, retention but suppression from downstream processing, scheduled archival and eventual deletion, or anonymization.&lt;/p&gt;

&lt;p&gt;Organizations can achieve more consistent deletion outcomes when reusable tooling and patterns exist for common deletion strategies.&lt;/p&gt;

&lt;h3 id=&quot;standardizing-proof-of-deletion&quot;&gt;Standardizing proof of deletion&lt;/h3&gt;

&lt;p&gt;Global deletion state requires thorough, continuous detection and tracking of all datasets that store personal data, and per-system-and-request deletion statuses.&lt;/p&gt;

&lt;p&gt;Verification also requires clearly defined deletion semantics for each system, since the expected outcome may be hard deletion, anonymization, suppression from downstream processing, or retention under a legal exception.&lt;/p&gt;

&lt;p&gt;Standardized traceability of per-system request fulfillment and deletion semantics provides a stronger foundation for demonstrating deletion outcomes.&lt;/p&gt;

&lt;h3 id=&quot;enforcing-exclusion-of-data-from-processing&quot;&gt;Enforcing exclusion of data from processing&lt;/h3&gt;

&lt;p&gt;There is no simple, universal solution to enforce that data subject to deletion is never processed outside of legally permitted exceptions.&lt;/p&gt;

&lt;p&gt;However, we can get closer to the expected state when all systems that store personal data subscribe to and backfill deletion requests, and when systems that retain personal data that is subject to deletion implement access controls to suppress use by downstream services.&lt;/p&gt;

&lt;h2 id=&quot;closing-thoughts&quot;&gt;Closing thoughts&lt;/h2&gt;

&lt;p&gt;Privacy tooling often focuses on answering where personal data lives, and whether a deletion request was executed across the owning services. A larger challenge is determining whether personal data remains absent from all systems where it should no longer be processed.&lt;/p&gt;

&lt;p&gt;As architectures evolve, new systems, integrations, and data flows continually expand the scope of deletion obligations. Verification must therefore be continuous rather than point-in-time.&lt;/p&gt;

&lt;p&gt;Without system-wide visibility into deletion state and enforcement outcomes, organizations are left validating workflow execution rather than deletion itself, leaving them unable to determine whether customers like Kathy are still being processed.&lt;/p&gt;

&lt;h2 id=&quot;posts-in-this-series&quot;&gt;Posts in this series&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/why-data-deletion-is-still-unsolved/&quot;&gt;Why data deletion is still an unsolved infrastructure problem&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/why-deletion-means-different-things/&quot;&gt;Why deletion means different things in different systems&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;(Current post) Gaps in data deletion verification and auditability&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/deletion-is-not-always-deletion/&quot;&gt;Deletion is not always deletion: retention exceptions and competing obligations&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/mitigating-personal-data-resurrection/&quot;&gt;Deleted and back again: mitigating unintended data resurrection&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/modeling-the-deletion-graph/&quot;&gt;Model a deletion graph, but don’t execute it&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
</description>
        <pubDate>Sat, 13 Jun 2026 04:30:00 +0000</pubDate>
        <link>https://www.marksayson.com/blog/gaps-in-data-deletion-verification-auditability/</link>
        <guid isPermaLink="true">https://www.marksayson.com/blog/gaps-in-data-deletion-verification-auditability/</guid>
        
        
        <category>privacy-engineering</category>
        
        <category>right-to-be-forgotten</category>
        
      </item>
    
      <item>
        <title>Why deletion means different things in different systems</title>
        <description>&lt;p&gt;A customer submits a data deletion request. The deletion workflow executes across dozens of systems, and every system reports success.&lt;/p&gt;

&lt;p&gt;Yet weeks later, the customer’s records still exist in a data warehouse, a search index, database backups, and a fraud investigation system.&lt;/p&gt;

&lt;p&gt;At first glance, this looks like a deletion failure. However, every system may have behaved exactly as designed. The problem is that “delete” meant something different in each environment, and the organization may struggle to explain the discrepancy during an audit.&lt;/p&gt;

&lt;h2 id=&quot;deletion-is-not-a-single-operation&quot;&gt;Deletion is not a single operation&lt;/h2&gt;

&lt;p&gt;Privacy requirements are typically expressed as a desired outcome, such as removing personal data that should no longer be retained or processed.&lt;/p&gt;

&lt;p&gt;Translating this into technical requirements is difficult because organizations store data across systems designed for different purposes, such as:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Transaction processing&lt;/li&gt;
  &lt;li&gt;Search&lt;/li&gt;
  &lt;li&gt;Analytics and machine learning&lt;/li&gt;
  &lt;li&gt;Logging and observability&lt;/li&gt;
  &lt;li&gt;Archival storage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A transactional database may physically remove records or mark them as inactive to preserve referential integrity. A search index may remove documents asynchronously and update indexes over time. A data warehouse may rewrite partitions or de-identify records containing personal data. An event stream may represent deletion through tombstone events and downstream compaction.&lt;/p&gt;

&lt;p&gt;As a result, the same deletion request can produce different outcomes across systems, depending on how each system interprets what deletion should do.&lt;/p&gt;

&lt;h2 id=&quot;common-deletion-patterns&quot;&gt;Common deletion patterns&lt;/h2&gt;

&lt;p&gt;These outcomes typically fall into a small number of recurring deletion patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hard deletion&lt;/strong&gt;: Data is physically removed from the system.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Most aligned with intuitive expectation of deletion&lt;/li&gt;
  &lt;li&gt;Conceptually simple&lt;/li&gt;
  &lt;li&gt;Can introduce referential integrity or recovery challenges&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Soft deletion&lt;/strong&gt;: Data remains in the system but is marked as inactive or deleted.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Common in transactional systems&lt;/li&gt;
  &lt;li&gt;Supports recovery&lt;/li&gt;
  &lt;li&gt;Data still physically exists&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Soft deletes can become problematic if the personal data is still readily available to internal teams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tombstoning&lt;/strong&gt;: A deletion marker replaces the original record to signal removal.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Common in event streams and distributed databases&lt;/li&gt;
  &lt;li&gt;Supports synchronization across eventually consistent systems&lt;/li&gt;
  &lt;li&gt;Original data may persist until compaction processes run&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Anonymization or de-identification&lt;/strong&gt;: Identifiers are removed or transformed so that records can no longer be linked back to the user.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Original identity no longer recoverable&lt;/li&gt;
  &lt;li&gt;Data may remain useful for analytics and reporting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Organizations may use anonymization or de-identification when full deletion is not required or feasible. However, preserving analytical utility while removing all identifying information can be difficult. Re-identification risks increase when joining datasets across systems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key destruction&lt;/strong&gt; (crypto-shredding): Encryption keys are deleted so that data can no longer be decrypted.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Common in cloud architectures&lt;/li&gt;
  &lt;li&gt;Encrypted data remains stored&lt;/li&gt;
  &lt;li&gt;Access is prevented via inability to decrypt the data rather than through physical deletion&lt;/li&gt;
  &lt;li&gt;Potentially acceptable only where resulting data is beyond recovery&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Suppression&lt;/strong&gt;: Data is retained but excluded from operational processing.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Common in marketing and advertising&lt;/li&gt;
  &lt;li&gt;Typically implemented via fine-grained access controls&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;comparison-of-deletion-patterns-and-their-desired-outcomes&quot;&gt;Comparison of deletion patterns and their desired outcomes&lt;/h3&gt;

&lt;table class=&quot;table-small-bordered top-bottom-padded&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Deletion pattern&lt;/th&gt;
      &lt;th&gt;Original data retained?&lt;/th&gt;
      &lt;th&gt;Recoverable?&lt;/th&gt;
      &lt;th&gt;Desired outcome&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Hard delete&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
      &lt;td&gt;Data should no longer exist&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Soft delete&lt;/td&gt;
      &lt;td&gt;Yes&lt;/td&gt;
      &lt;td&gt;Yes&lt;/td&gt;
      &lt;td&gt;Data may need to be restored&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Tombstone&lt;/td&gt;
      &lt;td&gt;Temporarily&lt;/td&gt;
      &lt;td&gt;Sometimes&lt;/td&gt;
      &lt;td&gt;Deletion must be communicated to other systems&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Anonymization&lt;/td&gt;
      &lt;td&gt;Partially&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
      &lt;td&gt;Identity should be removed while preserving analytical value&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Key destruction&lt;/td&gt;
      &lt;td&gt;Yes&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
      &lt;td&gt;Data should become permanently inaccessible&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Suppression&lt;/td&gt;
      &lt;td&gt;Yes&lt;/td&gt;
      &lt;td&gt;Yes&lt;/td&gt;
      &lt;td&gt;Data must be retained but not actively used&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;why-inconsistent-deletion-semantics-create-operational-problems&quot;&gt;Why inconsistent deletion semantics create operational problems&lt;/h2&gt;

&lt;p&gt;The existence of multiple deletion models is not inherently problematic.&lt;/p&gt;

&lt;p&gt;The problem arises when organizations assume they are equivalent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example 1: Mismatched hard vs soft deletion expectation&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Privacy team expects deletion.&lt;/li&gt;
  &lt;li&gt;System performs soft delete.&lt;/li&gt;
  &lt;li&gt;Downstream systems continue to process soft-deleted data due to inconsistent enforcement of deletion semantics.&lt;/li&gt;
  &lt;li&gt;Result: Data remains accessible internally, against intended policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;/images/20260608_SoftDeletionComplianceGapExcalidraw.svg&quot; alt=&quot;Diagram illustrating compliance gaps from soft deletion mismatching expectation&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example 2: Suppression inconsistently applied across data use cases&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Customer requests deletion.&lt;/li&gt;
  &lt;li&gt;Marketing platform suppresses customer data from email campaigns.&lt;/li&gt;
  &lt;li&gt;Personal data remains accessible to other operational systems.&lt;/li&gt;
  &lt;li&gt;Result: Teams disagree on whether preventing their system’s use of data is equivalent to deleting personal data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Example 3: Anonymized data re-identified in derived datasets&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Privacy team expects customer personal data to be removed.&lt;/li&gt;
  &lt;li&gt;Analytics platform de-identifies records rather than deleting.&lt;/li&gt;
  &lt;li&gt;Data scientists continue querying aggregated datasets.&lt;/li&gt;
  &lt;li&gt;Privacy reviewers discover that individuals become re-identified when datasets are joined.&lt;/li&gt;
  &lt;li&gt;Result: Teams disagree on cross-system anonymization requirements.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Example 4: Data reappearance via event replays&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Source system deletes data.&lt;/li&gt;
  &lt;li&gt;Replaying historical pipeline events rehydrates AKA recreates deleted records.&lt;/li&gt;
  &lt;li&gt;Systems lack mechanisms to prevent or remove rehydrated data after replay.&lt;/li&gt;
  &lt;li&gt;Result: Systems differ on whether deletion should prevent historical state from being reintroduced, resulting in an enforcement gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;/images/20260608_RestoredDeletedDataExcalidraw.svg&quot; alt=&quot;Diagram illustrating compliance gaps from deleted data being rehydrated&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Key insight: Many deletion failures are semantic mismatches rather than execution failures.&lt;/p&gt;

&lt;h2 id=&quot;why-this-matters-for-automation&quot;&gt;Why this matters for automation&lt;/h2&gt;

&lt;p&gt;Organizations often want standardized workflows and automated orchestration and reporting.&lt;/p&gt;

&lt;p&gt;However, in order for a deletion platform to answer “Did deletion succeed?”, it must first answer: “What was the expected outcome for this system?”&lt;/p&gt;

&lt;p&gt;Instead of treating deletion as a collection of service-specific scripts, organizations may need a way to declare:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;What deletion means for each system&lt;/li&gt;
  &lt;li&gt;Which deletion model applies&lt;/li&gt;
  &lt;li&gt;How success is verified&lt;/li&gt;
  &lt;li&gt;What evidence should be collected&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The challenge is explicitly defining deletion semantics, ensuring each system enforces and verifies the intended deletion mechanism, and validating that personal data has been removed across the organization.&lt;/p&gt;

&lt;h2 id=&quot;posts-in-this-series&quot;&gt;Posts in this series&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/why-data-deletion-is-still-unsolved/&quot;&gt;Why data deletion is still an unsolved infrastructure problem&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;(Current post) Why deletion means different things in different systems&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/gaps-in-data-deletion-verification-auditability/&quot;&gt;Gaps in data deletion verification and auditability&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/deletion-is-not-always-deletion/&quot;&gt;Deletion is not always deletion: retention exceptions and competing obligations&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/mitigating-personal-data-resurrection/&quot;&gt;Deleted and back again: mitigating unintended data resurrection&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/modeling-the-deletion-graph/&quot;&gt;Model a deletion graph, but don’t execute it&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
</description>
        <pubDate>Thu, 04 Jun 2026 04:30:00 +0000</pubDate>
        <link>https://www.marksayson.com/blog/why-deletion-means-different-things/</link>
        <guid isPermaLink="true">https://www.marksayson.com/blog/why-deletion-means-different-things/</guid>
        
        
        <category>privacy-engineering</category>
        
        <category>right-to-be-forgotten</category>
        
      </item>
    
      <item>
        <title>Why data deletion is still an unsolved infrastructure problem</title>
        <description>&lt;p&gt;A customer submits a data deletion request that propagates across backend services. One service only partially deletes the customer’s records due to a bug. Another misses the request because it was down for maintenance, and several analytics datasets were never integrated into the deletion process.&lt;/p&gt;

&lt;p&gt;Months later, the customer continues receiving highly personalized marketing emails from the company. She submits a data access request and discovers that the company still has her name, address, phone number, purchase history, and detailed behavioral data from years of interactions with their services.&lt;/p&gt;

&lt;p&gt;What began as a routine privacy request has now become a regulatory problem. A complaint to a data protection authority leads to an investigation, revealing an inability to demonstrate consistent deletion across the company’s systems. The company now faces corrective action and potential penalties.&lt;/p&gt;

&lt;p&gt;Many of these failures were invisible to the company until the compliance team had to prove that deletion had actually occurred everywhere.&lt;/p&gt;

&lt;h2 id=&quot;a-typical-deletion-request-path&quot;&gt;A typical deletion request path&lt;/h2&gt;

&lt;p&gt;A single request initiated from a customer-facing application may propagate across multiple services, storage systems, analytical platforms, and downstream consumers. Each system may implement deletion differently, operate on different schedules, or fail independently.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20260530_TypicalDataFlowBehindDeletionRequestExcalidraw.svg&quot; alt=&quot;Diagram illustrating data flow behind a deletion request&quot; /&gt;&lt;/p&gt;

&lt;p&gt;No single team owns the entire lifecycle. Application teams own services, platform teams operate infrastructure, analytics teams maintain data pipelines and data warehouses, and additional systems produce or consume derived datasets.&lt;/p&gt;

&lt;p&gt;The problem becomes significantly harder at enterprise scale. Large organizations may operate hundreds of independently managed services and thousands of datasets while continuously adding new data flows and third-party integrations.&lt;/p&gt;

&lt;p&gt;Over time, data lineage becomes more difficult to track, inventories become harder to maintain, and deletion workflows diverge across teams. What begins as a straightforward compliance requirement gradually becomes a coordination problem spanning large portions of an organization’s infrastructure.&lt;/p&gt;

&lt;h2 id=&quot;the-hidden-complexity-behind-a-simple-deletion-request&quot;&gt;The hidden complexity behind a “simple” deletion request&lt;/h2&gt;
&lt;p&gt;From a user’s perspective, deleting personal data sounds simple.&lt;/p&gt;

&lt;p&gt;Inside a modern organization, the same request can require teams to:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Locate all systems containing relevant customer data&lt;/li&gt;
  &lt;li&gt;Resolve identifiers across different schemas and services&lt;/li&gt;
  &lt;li&gt;Execute system-specific deletion logic&lt;/li&gt;
  &lt;li&gt;Verify completion across distributed components&lt;/li&gt;
  &lt;li&gt;Handle retries and partial failures&lt;/li&gt;
  &lt;li&gt;Produce audit evidence&lt;/li&gt;
  &lt;li&gt;Prevent downstream recreation of deleted data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Customer data rarely exists in a single form. The same individual may appear as a primary key in a transactional database, an attribute in an analytics platform, or within large object-store datasets shared across many users. Deletion often requires system-specific logic tailored to how each platform stores and processes data.&lt;/p&gt;

&lt;p&gt;The core difficulty is coordinating consistent execution across systems with different deletion semantics, retention policies, operational constraints, and failure modes.&lt;/p&gt;

&lt;h2 id=&quot;the-execution-gap-in-privacy-tooling&quot;&gt;The execution gap in privacy tooling&lt;/h2&gt;
&lt;p&gt;The privacy technology ecosystem has matured significantly. Organizations now have access to tools for privacy request management, governance, and data discovery.&lt;/p&gt;

&lt;p&gt;These tools address important parts of the problem, but they do not solve execution.&lt;/p&gt;

&lt;p&gt;Governance platforms help define what should happen. Cloud-native capabilities help execute deletion within individual systems. Between those layers is the challenge of executing and validating deletion across production systems.&lt;/p&gt;

&lt;p&gt;Organizations often fill this gap with scripts, service-specific workflows, and operational procedures that become increasingly difficult to maintain, validate, and audit as independently managed services proliferate and coordination requirements compound across teams.&lt;/p&gt;

&lt;h2 id=&quot;why-right-to-erasure-is-uniquely-challenging&quot;&gt;Why right-to-erasure is uniquely challenging&lt;/h2&gt;
&lt;p&gt;Among privacy requirements, right-to-erasure (“the right to be forgotten”) places some of the greatest demands on an organization’s architecture.&lt;/p&gt;

&lt;p&gt;Unlike many privacy obligations, deletion cannot be satisfied through policy alone. Organizations must coordinate actions across all systems where customer data resides and demonstrate that those actions completed as intended.&lt;/p&gt;

&lt;p&gt;This exposes architectural inconsistencies that might otherwise be missed:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Systems that interpret deletion differently&lt;/li&gt;
  &lt;li&gt;Incomplete or unverifiable audit trails&lt;/li&gt;
  &lt;li&gt;Service-specific workflows that are difficult to maintain&lt;/li&gt;
  &lt;li&gt;Fragile operational dependencies&lt;/li&gt;
  &lt;li&gt;Manual processes that don’t scale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Right-to-erasure is therefore more than a privacy requirement. It is a stress test of an organization’s ability to manage data consistently across its infrastructure.&lt;/p&gt;

&lt;h2 id=&quot;privacy-as-an-architectural-capability&quot;&gt;Privacy as an architectural capability&lt;/h2&gt;
&lt;p&gt;The industry has largely approached privacy as a governance problem.&lt;/p&gt;

&lt;p&gt;However, governance alone does not execute deletion, validate outcomes, recover from failures, or provide consistent guarantees across distributed systems.&lt;/p&gt;

&lt;p&gt;Privacy obligations increasingly depend on infrastructure capabilities that most organizations were not built with.&lt;/p&gt;

&lt;p&gt;That means treating privacy as a first-class architectural concern rather than a collection of scripts and workflows. Organizations need a way to define, execute, and verify deletion consistently across systems.&lt;/p&gt;

&lt;h2 id=&quot;what-comes-next&quot;&gt;What comes next&lt;/h2&gt;
&lt;p&gt;If deletion is fundamentally an infrastructure problem, the next step is understanding how and why it fails in practice at scale.&lt;/p&gt;

&lt;p&gt;In upcoming posts, we’ll explore common failure modes in deletion programs, including inconsistent deletion semantics, coverage gaps as architectures evolve, resurrection of previously deleted data, and gaps in verification and auditability.&lt;/p&gt;

&lt;p&gt;After that, we’ll examine the structural properties of deletion programs that allow organizations to consistently orchestrate, execute, and verify deletion across distributed systems.&lt;/p&gt;

&lt;h2 id=&quot;posts-in-this-series&quot;&gt;Posts in this series&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;(Current post) Why data deletion is still an unsolved infrastructure problem&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/why-deletion-means-different-things/&quot;&gt;Why deletion means different things in different systems&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/gaps-in-data-deletion-verification-auditability/&quot;&gt;Gaps in data deletion verification and auditability&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/deletion-is-not-always-deletion/&quot;&gt;Deletion is not always deletion: retention exceptions and competing obligations&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/mitigating-personal-data-resurrection/&quot;&gt;Deleted and back again: mitigating unintended data resurrection&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/modeling-the-deletion-graph/&quot;&gt;Model a deletion graph, but don’t execute it&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
</description>
        <pubDate>Sun, 31 May 2026 04:30:00 +0000</pubDate>
        <link>https://www.marksayson.com/blog/why-data-deletion-is-still-unsolved/</link>
        <guid isPermaLink="true">https://www.marksayson.com/blog/why-data-deletion-is-still-unsolved/</guid>
        
        
        <category>privacy-engineering</category>
        
        <category>right-to-be-forgotten</category>
        
      </item>
    
      <item>
        <title>Polymorphic JSON deserialization with Java sealed interfaces and Jackson</title>
        <description>&lt;p&gt;When building a JSON-based API that accepts multiple request types, you need a strategy for mapping incoming payloads to the appropriate data model.  Java sealed interfaces combined with Jackson’s polymorphic type annotations provide a clean way to support multiple strongly typed backend data models.&lt;/p&gt;

&lt;h2 id=&quot;problem&quot;&gt;Problem&lt;/h2&gt;
&lt;p&gt;Consider an API that receives data processing requests.  Each request provides a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;requestType&lt;/code&gt; field that determines which fields are relevant.&lt;/p&gt;

&lt;p&gt;All requests share the same lifecycle and processing steps, but each request type corresponds to a different data model with different required and optional fields.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Account deletion: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{&quot;requestId&quot;: &quot;request-1234&quot;, &quot;requestType&quot;: &quot;deleteAccount&quot;, &quot;accountId&quot;: &quot;1234&quot;}&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Scoped data deletion: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{&quot;requestId&quot;: &quot;request-1234&quot;, &quot;requestType&quot;: &quot;deleteScopedData&quot;, &quot;accountId&quot;: &quot;1234&quot;, &quot;scope&quot;: {&quot;deletePurchaseHistory&quot;: true, &quot;deleteSubscriptions&quot;: false, &quot;deletePreferences&quot;: false}}&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;approach-sealed-interface--jackson-annotations&quot;&gt;Approach: Sealed interface + Jackson annotations&lt;/h2&gt;

&lt;h3 id=&quot;step-1-define-sealed-interface-with-annotations-mapping-request-types-to-implementations&quot;&gt;Step 1: Define sealed interface with annotations mapping request types to implementations&lt;/h3&gt;

&lt;div class=&quot;language-java highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;com.fasterxml.jackson.annotation.JsonSubTypes&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;com.fasterxml.jackson.annotation.JsonTypeInfo&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;cm&quot;&gt;/**
 * Polymorphic deletion request interface that routes to request-type-specific data models.
 */&lt;/span&gt;
&lt;span class=&quot;nd&quot;&gt;@JsonTypeInfo&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;use&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;JsonTypeInfo&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;Id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;NAME&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;property&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;requestType&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;nd&quot;&gt;@JsonSubTypes&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;({&lt;/span&gt;
    &lt;span class=&quot;nd&quot;&gt;@JsonSubTypes&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;Type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;AccountDeletionRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;deleteAccount&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;nd&quot;&gt;@JsonSubTypes&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;Type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ScopedDataDeletionRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;deleteScopedData&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;})&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sealed&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;interface&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;DeletionRequest&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;permits&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;AccountDeletionRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ScopedDataDeletionRequest&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// Define common method declarations and static methods here.&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Java interfaces allow us to keep core business logic independent of child implementation details, making the system easier to test and extend.  Sealed interfaces, available in Java 17+, restrict which classes are allowed to implement the interface via a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;permits&lt;/code&gt; clause.&lt;/p&gt;

&lt;p&gt;When combined with Jackson &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@JsonTypeInfo&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@JsonSubTypes&lt;/code&gt; annotations which wire the JSON discriminator field (here, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;requestType&lt;/code&gt;) to the appropriate implementation, this enables a polymorphic contract with automatic, compile-time-checked JSON routing to strongly typed implementations.&lt;/p&gt;

&lt;h3 id=&quot;step-2-define-concrete-implementations-as-classes-or-records&quot;&gt;Step 2: Define concrete implementations as classes or records&lt;/h3&gt;

&lt;p&gt;In the following implementations of DeletionRequest, we set &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@JsonIgnoreProperties(ignoreUnknown = true)&lt;/code&gt; to ignore unmentioned attributes when deserializing JSON strings matching known request types.&lt;/p&gt;

&lt;p&gt;This allows us to maintain strong typing for attributes relevant to this service while being agnostic of other upstream attributes our service doesn’t use.&lt;/p&gt;

&lt;p&gt;You can drop &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@JsonIgnoreProperties&lt;/code&gt; if you prefer to enforce the complete possible schema of input JSON strings, which will throw a runtime exception when receiving any new attribute you haven’t defined.&lt;/p&gt;

&lt;div class=&quot;language-java highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;com.fasterxml.jackson.annotation.JsonIgnoreProperties&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;cm&quot;&gt;/**
 * Data model for account deletion requests.
 */&lt;/span&gt;
&lt;span class=&quot;nd&quot;&gt;@JsonIgnoreProperties&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ignoreUnknown&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;record&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;AccountDeletionRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;accountId&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;requestId&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;implements&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;DeletionRequest&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;cm&quot;&gt;/**
 * Data model for scoped data deletion requests
 */&lt;/span&gt;
&lt;span class=&quot;nd&quot;&gt;@JsonIgnoreProperties&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ignoreUnknown&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;record&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;ScopedDataDeletionRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;accountId&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;requestId&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;DeletionScope&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;scope&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;implements&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;DeletionRequest&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;cm&quot;&gt;/**
 * Data model for data deletion scope.
 *
 * Used when user requests deleting specific categories of data.
 */&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;record&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;DeletionScope&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;boolean&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;deletePreferences&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;boolean&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;deletePurchaseHistory&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;boolean&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;deleteSubscriptions&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;step-3-deserialize-input-json-strings-to-strongly-typed-data-models&quot;&gt;Step 3: Deserialize input JSON strings to strongly typed data models&lt;/h3&gt;

&lt;div class=&quot;language-java highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;com.fasterxml.jackson.core.JsonProcessingException&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;com.fasterxml.jackson.databind.ObjectMapper&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;com.fasterxml.jackson.databind.exc.InvalidTypeIdException&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Note: Prefer to instantiate ObjectMapper once as static final field or singleton and reuse across classes.&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;// ObjectMapper is thread-safe, and it&apos;s expensive to create new instances.&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ObjectMapper&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mapper&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ObjectMapper&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;();&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;DeletionRequest&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;deletionRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;deletionRequest&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mapper&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;readValue&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inputJson&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;DeletionRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;catch&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;InvalidTypeIdException&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// Replace BadRequestException in catch statements with locally relevant class for 400 Bad Request exceptions&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;BadRequestException&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Unsupported request type&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;catch&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;JsonProcessingException&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;BadRequestException&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Invalid request payload&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;With Java 21+, the compiler enforces that every permitted subtype in switch statements on sealed interface instances are handled, removing a class of potential runtime errors.&lt;/p&gt;

&lt;h2 id=&quot;when-to-use-a-polymorphic-api&quot;&gt;When to use a polymorphic API&lt;/h2&gt;

&lt;p&gt;A single endpoint accepting multiple JSON schemas can make sense when all request types share the same lifecycle and infrastructure (authorization, queues, auditing) and only diverge in execution logic.&lt;/p&gt;

&lt;p&gt;Prefer separate endpoints when request types have different authorization rules, rate limits, or lifecycle rules, or when you need clean OpenAPI/Swagger/Smithy documentation, which don’t work well with polymorphic schemas.&lt;/p&gt;

&lt;p&gt;Polymorphic APIs are more difficult for clients to consume and understand compared to stand-alone APIs, so they are a better fit for internal APIs between services your team controls.&lt;/p&gt;

&lt;h2 id=&quot;when-to-use-sealed-interfaces-to-implement-polymorphic-apis&quot;&gt;When to use sealed interfaces to implement polymorphic APIs&lt;/h2&gt;

&lt;p&gt;Sealed interfaces and Jackson polymorphic type annotations are a good fit for implementing polymorphic schemas when all the following are true:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Your JSON payloads share a discriminator field but have different schemas per type.&lt;/li&gt;
  &lt;li&gt;You want a closed set of permitted subtypes.&lt;/li&gt;
  &lt;li&gt;You want subtypes to be strongly typed, each potentially requiring its own validation logic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They are a poor fit when either of the following is true, in which case abstract classes may be a better replacement for sealed interfaces:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Other teams need to be able to add subtypes without modifying your code (sealed interface subtypes must be defined in the same module).&lt;/li&gt;
  &lt;li&gt;You cannot use Java 17+, the first Long Term Support version supporting sealed interfaces.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;pros-and-cons-vs-common-alternatives&quot;&gt;Pros and cons vs common alternatives&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Strategy&lt;/th&gt;
      &lt;th&gt;Pros&lt;/th&gt;
      &lt;th&gt;Cons&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;b&gt;Sealed interface + Jackson polymorphism&lt;/b&gt;&lt;/td&gt;
      &lt;td&gt;
        &lt;ul&gt;
          &lt;li&gt;Supports strongly typed subtypes with different required and allowed fields&lt;/li&gt;
          &lt;li&gt;Easily add subtypes with minimal code changes&lt;/li&gt;
          &lt;li&gt;Compile-time rejection of unsupported subtypes&lt;/li&gt;
          &lt;li&gt;Clean pattern matching without casting&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/td&gt;
      &lt;td&gt;
        &lt;ul&gt;
          &lt;li&gt;Requires all subtypes to be defined in same module&lt;/li&gt;
          &lt;li&gt;Requires Java 17+&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;b&gt;Single class with nullable fields&lt;/b&gt;&lt;/td&gt;
      &lt;td&gt;
        &lt;ul&gt;
          &lt;li&gt;Simple&lt;/li&gt;
          &lt;li&gt;Works pre-Java 17&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/td&gt;
      &lt;td&gt;
        &lt;ul&gt;
          &lt;li&gt;Runtime null checks everywhere&lt;/li&gt;
          &lt;li&gt;No compile-time safety&lt;/li&gt;
          &lt;li&gt;Unclear which fields apply to each type&lt;/li&gt;
          &lt;li&gt;More messy to enforce validations on subtypes&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;b&gt;Inheritance with abstract class&lt;/b&gt;&lt;/td&gt;
      &lt;td&gt;
        &lt;ul&gt;
          &lt;li&gt;Familiar OOP pattern&lt;/li&gt;
          &lt;li&gt;Subclasses can live anywhere&lt;/li&gt;
          &lt;li&gt;Works pre-Java 17&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/td&gt;
      &lt;td&gt;
        &lt;ul&gt;
          &lt;li&gt;Anyone can subclass, no compile-time restriction on subtypes&lt;/li&gt;
          &lt;li&gt;Compiler does not enforce exhaustive handling of subtypes&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;b&gt;&lt;code&gt;@JsonAnySetter&lt;/code&gt; with &lt;code&gt;Map&amp;lt;String, Object&amp;gt;&lt;/code&gt;&lt;/b&gt;&lt;/td&gt;
      &lt;td&gt;
        &lt;ul&gt;
          &lt;li&gt;Maximum flexibility&lt;/li&gt;
          &lt;li&gt;Allows for unknown schemas&lt;/li&gt;
          &lt;li&gt;Works pre-Java 17&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/td&gt;
      &lt;td&gt;
        &lt;ul&gt;
          &lt;li&gt;Zero type safety, accepts all inputs&lt;/li&gt;
          &lt;li&gt;Validation is manual and error-prone&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;b&gt;Enum and factory method&lt;/b&gt;&lt;/td&gt;
      &lt;td&gt;
        &lt;ul&gt;
          &lt;li&gt;Explicit type registry (as with sealed interface)&lt;/li&gt;
          &lt;li&gt;More flexible, full control over deserialization&lt;/li&gt;
          &lt;li&gt;Works pre-Java 17&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/td&gt;
      &lt;td&gt;
        &lt;ul&gt;
          &lt;li&gt;More boilerplate code than sealed interface + annotations&lt;/li&gt;
          &lt;li&gt;No compile-time validation of correct/complete routing&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;key-takeaways&quot;&gt;Key takeaways&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sealed&lt;/code&gt; provides a closed type hierarchy so the compiler knows every possible implementation.&lt;/li&gt;
  &lt;li&gt;Jackson’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@JsonTypeInfo&lt;/code&gt; simplifies routing deserialization to the appropriate type based on a discriminator field.&lt;/li&gt;
  &lt;li&gt;Java records minimize boilerplate for immutable, validated data models.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In practice, this pattern has reduced the effort to support new data processing types from roughly a week per type to a few hours.  The sealed interface and Jackson annotations handle the routing, leaving only type-specific models and logic to implement.&lt;/p&gt;
</description>
        <pubDate>Sat, 16 May 2026 04:30:00 +0000</pubDate>
        <link>https://www.marksayson.com/blog/polymorphic-json-deserialization-java-sealed-interfaces-jackson/</link>
        <guid isPermaLink="true">https://www.marksayson.com/blog/polymorphic-json-deserialization-java-sealed-interfaces-jackson/</guid>
        
        
        <category>programming-languages</category>
        
      </item>
    
      <item>
        <title>Enabling ad hoc runs of queue-based ECS services</title>
        <description>&lt;h2 id=&quot;background&quot;&gt;Background&lt;/h2&gt;

&lt;p&gt;A common ECS use case is to continually poll a queue for new events to process, with auto-scaling based on CPU or memory utilization, or the size of the queue backlog.  These services can be tricky to build reliable integration tests for, as queue backlogs may result in long delays before a new message is processed.&lt;/p&gt;

&lt;p&gt;A simple way to enable efficient integration tests for these services is to support an optional environment variable that signals the service to immediately process the message without long-polling the queue, as if it were the content of a single SQS message body.&lt;/p&gt;

&lt;h2 id=&quot;example-code&quot;&gt;Example code&lt;/h2&gt;

&lt;div class=&quot;language-java highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;[]&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;args&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;jsonRequest&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;System&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;getenv&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;ADHOC_REQUEST&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jsonRequest&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jsonRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;isEmpty&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;())&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// Execute the single JSON request&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;executeRequestFromJson&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jsonRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// Continually poll a queue to process pending requests&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;longPollQueue&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;();&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;executeRequestFromJson&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;jsonRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;MyParsedRequest&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;request&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parseRequestFromJson&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;jsonRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;executeRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;longPollQueue&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;while&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;Optional&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;MyQueueMessage&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;message&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;getMessageFromQueue&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;();&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;isPresent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;())&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nc&quot;&gt;MyParsedRequest&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;request&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parseRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;());&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;executeRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;acknowledgeSqsMessage&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;());&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;example-execution-via-aws-cli&quot;&gt;Example execution via AWS CLI&lt;/h2&gt;

&lt;p&gt;After setting variables for the AWS region and ECS cluster, service, and task definition ARNs, you can run a variation of the following to run a new ECS task immediately, without waiting for a new message to be processed in a queue that may have a large backlog of pending messages.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YOUR_ECS_CONTAINER_NAME&lt;/code&gt; should be replaced by the name field value in the task definition’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;containerDefinitions&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The ECS container overrides allow you to provide specific environment variable overrides while maintaining other existing environment variables from the ECS task definition.&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;networkConfig&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;$(&lt;/span&gt;aws ecs describe-services &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$awsRegion&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--cluster&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$ecsClusterArn&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--services&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$ecsServiceArn&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--query&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;services[0].networkConfiguration&apos;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--output&lt;/span&gt; json&lt;span class=&quot;si&quot;&gt;)&lt;/span&gt;
aws ecs run-task &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$awsRegion&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--cluster&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$ecsClusterArn&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--task-definition&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$ecsTaskDefinitionArn&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--launch-type&lt;/span&gt; FARGATE &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--network-configuration&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$networkConfig&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--overrides&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;{
        &quot;containerOverrides&quot;: [{
            &quot;name&quot;: &quot;YOUR_ECS_CONTAINER_NAME&quot;,
            &quot;environment&quot;: [{
                &quot;name&quot;: &quot;ADHOC_REQUEST&quot;,
                &quot;value&quot;: &quot;{\&quot;yourRequestKey\&quot;:\&quot;yourRequestValue\&quot;}&quot;
            }]
        }]
    }&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Depending on the integ test environment, you can also use the &lt;a href=&quot;https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_RunTask.html&quot;&gt;ECS RunTask API&lt;/a&gt; in a similar way as the above CLI command.&lt;/p&gt;

&lt;p&gt;After implementing this pattern in your service code, your integration tests can run ad hoc ECS tasks with specific test requests and validate the expected output - for example, periodically calling another API to validate a record has been successfully created or updated after a wait time, with a maximum number of retries.&lt;/p&gt;

&lt;p&gt;Since the task processes only the injected request, tests can use a much shorter max duration than they would need if waiting for a large queue backlog to drain.&lt;/p&gt;
</description>
        <pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://www.marksayson.com/blog/enabling-adhoc-ecs-task/</link>
        <guid isPermaLink="true">https://www.marksayson.com/blog/enabling-adhoc-ecs-task/</guid>
        
        
        <category>aws</category>
        
      </item>
    
      <item>
        <title>Strategies for querying periodic S3 data snapshots</title>
        <description>&lt;h2 id=&quot;background&quot;&gt;Background&lt;/h2&gt;

&lt;p&gt;A common AWS analytics use case is making aggregate queries across multiple data sets stored in S3.  For example, one partner team may store product metadata, while another team stores purchase order metadata, and we may want to join these data sets to determine which products are most popular across each marketplace.&lt;/p&gt;

&lt;p&gt;In this post I’ll cover a few options for syncing data to S3 and retrieving data snapshots for use in aggregate queries, and specifically discuss the use case where we need to maintain access patterns to complete, recent data snapshots without partial unavailability during data syncs.&lt;/p&gt;

&lt;h2 id=&quot;a-few-options-for-syncing-data-to-s3&quot;&gt;A few options for syncing data to S3&lt;/h2&gt;

&lt;h3 id=&quot;1-syncing-each-record-to-a-unique-stable-file-path&quot;&gt;1. Syncing each record to a unique, stable file path&lt;/h3&gt;
&lt;p&gt;Pros:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Provides a stable “latest” data set that always represents all records.&lt;/li&gt;
  &lt;li&gt;Enables O(1) look-ups of specific records if data consumers query by S3 key (“filepath”).&lt;/li&gt;
  &lt;li&gt;Low storage costs since only a single copy of each record is stored.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cons:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Poor performance and higher costs for aggregate queries at scale. Retrieving tens of thousands of small files is much slower than retrieving a few multi-MB files.&lt;/li&gt;
  &lt;li&gt;Limits design choices for data providers and may increase complexity of their implementation (eg. AWS Glue defaults to writing aggregated partial result files).&lt;/li&gt;
  &lt;li&gt;No historical data is retained unless explicitly backed up elsewhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is best suited to when data consumers retrieve specific records, only need their latest state, and don’t make aggregated queries across a large number of records.&lt;/p&gt;

&lt;h3 id=&quot;2-appending-all-events-as-new-data-without-overwriting-prior-events&quot;&gt;2. Appending all events as new data without overwriting prior events&lt;/h3&gt;
&lt;p&gt;Pros:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Provides a complete history of events, allowing for time-series analysis.&lt;/li&gt;
  &lt;li&gt;If partition data by time, supports efficient queries of specific time periods.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cons:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Increase storage costs as accumulate historical data.&lt;/li&gt;
  &lt;li&gt;Increased complexity, latency, and cost to retrieve the latest version of each record.&lt;/li&gt;
  &lt;li&gt;If data isn’t partitioned in a way that aligns with query use cases, may have very inefficient scans.&lt;/li&gt;
  &lt;li&gt;If upstream workflows fail, may not have a way to recover data, and have missing records.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is best suited to queries of time-partitioned events, rather than needing the latest state of records.&lt;/p&gt;

&lt;h3 id=&quot;3-overwriting-one-or-more-files-that-include-multiple-records&quot;&gt;3. Overwriting one or more files that include multiple records&lt;/h3&gt;
&lt;p&gt;Pros:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Consumers always access the most up-to-date version of the dataset.&lt;/li&gt;
  &lt;li&gt;More efficient aggregate queries than retrieving thousands of single-record files.&lt;/li&gt;
  &lt;li&gt;Low storage costs since only a single copy of each record is stored.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cons:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Risk of data loss if a failure occurs during the overwrite process.&lt;/li&gt;
  &lt;li&gt;Data consumers querying during a data sync may receive incomplete or duplicate data.&lt;/li&gt;
  &lt;li&gt;No historical data is retained unless explicitly backed up elsewhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This can work for aggregated queries of the latest records, but if we can’t accept the risk of corrupt/partial/duplicate data during sync failures or read/write race conditions, we may prefer writing to separate snapshot partitions.&lt;/p&gt;

&lt;h3 id=&quot;4-periodically-writing-complete-data-to-time-based-partitions&quot;&gt;4. Periodically writing complete data to time-based partitions&lt;/h3&gt;
&lt;p&gt;Pros:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Consumers always access a complete dataset when querying a completed/past partition.&lt;/li&gt;
  &lt;li&gt;More efficient aggregate queries than retrieving thousands of single-record files.&lt;/li&gt;
  &lt;li&gt;Maintain historical data for as long as prior time partitions are kept in storage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cons:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;More complex to identify the latest complete partition.&lt;/li&gt;
  &lt;li&gt;Data consumers querying the most recent partition during a data sync may receive incomplete data.  Need some type of completeness signal to mitigate.&lt;/li&gt;
  &lt;li&gt;Increase storage costs as accumulate historical data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We can mitigate storage costs by setting a lifecycle rule to automatically delete S3 files past a certain age, eg. auto-delete files over 2 weeks old, if we only need to query recent snapshots.&lt;/p&gt;

&lt;h2 id=&quot;case-study-aggregate-queries-where-completeness-is-important&quot;&gt;Case study: Aggregate queries where completeness is important&lt;/h2&gt;
&lt;p&gt;For this post, we’ll consider the case where we want to optimize for aggregated queries against hundreds of thousands of records, with filter criteria applied across all records.  Our business requirements are that data consumers must always have access to complete and accurate data (no duplicates or missing records), but data does not need to be real-time as long as it’s up to date within a few hours.&lt;/p&gt;

&lt;p&gt;In this case, single-record files and time-series events are not a good fit due to increased latency to query latest state across this number of records.  We may prefer time-based partitions with complete data written to a new partition every N hours, eg. hourly, to mitigate partial/duplicate data issues during concurrent read/write operations.&lt;/p&gt;

&lt;h2 id=&quot;a-few-options-for-querying-time-partitioned-snapshots&quot;&gt;A few options for querying time-partitioned snapshots&lt;/h2&gt;

&lt;p&gt;A common challenge for time-partitioned partitions is identifying the latest complete partition, especially if upstream data syncs are not 100% successful.&lt;/p&gt;

&lt;h3 id=&quot;1-retrieve-a-specific-snapshot-relative-to-the-current-time&quot;&gt;1. Retrieve a specific snapshot relative to the current time&lt;/h3&gt;

&lt;p&gt;Assuming we have hourly snapshots that are partitioned by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;snapshot_date&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;snapshot_hour&lt;/code&gt;, where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;snapshot_date&lt;/code&gt; is formatted as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;2025-01-31&quot;&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;snapshot_hour&lt;/code&gt; is formatted as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;01&quot;&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;23&quot;&lt;/code&gt; to support consistent string comparisons, the following Athena SQL query retrieves data from the last hour’s time partition:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;CAST&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DATE_FORMAT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;CURRENT_TIMESTAMP&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;1&apos;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HOUR&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;%Y-%m-%d&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;VARCHAR&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;CAST&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DATE_FORMAT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;CURRENT_TIMESTAMP&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;1&apos;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HOUR&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;%H&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;VARCHAR&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Pros:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Simple, easy to understand.&lt;/li&gt;
  &lt;li&gt;Very efficient since only querying data from a single partition, and it’s O(1) to locate that partition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cons:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;May fail to get any data if there were upstream sync issues for the selected partition. &lt;strong&gt;This is a blocker for us.&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;May get partial data if query the most recent partition during an ongoing sync.&lt;/li&gt;
  &lt;li&gt;May need to query older and more out-of-date partitions to avoid the above race condition.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;2-retrieve-most-recent-snapshot&quot;&gt;2. Retrieve most recent snapshot&lt;/h3&gt;

&lt;p&gt;We can improve on the prior option by using an initial query to identify the most recent snapshot partition that has data.&lt;/p&gt;

&lt;p&gt;Since it could be expensive to search across all partitions, we can set a maximum look-back period, for example, the last 7 days, to allow for occasional upstream failures that may take a few days to resolve.&lt;/p&gt;

&lt;p&gt;Example Athena SQL query:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;WITH&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MostRecentSnapshotPartition&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DISTINCT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;-- Set a maximum look-back period to reduce query search space while allowing a few days of upstream failures&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;CAST&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;CURRENT_DATE&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;7&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DAY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;VARCHAR&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;LIMIT&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MostRecentSnapshotPartition&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MostRecentSnapshotPartition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MostRecentSnapshotPartition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Pros:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Relatively simple and easy to understand.&lt;/li&gt;
  &lt;li&gt;Guaranteed to get most recent snapshot with data within the given look-back period.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cons:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;May get partial data if query the most recent partition during an ongoing sync.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;3-retrieve-the-second-most-recent-snapshot&quot;&gt;3. Retrieve the second most recent snapshot&lt;/h3&gt;

&lt;p&gt;If we need to mitigate race conditions where the latest partition may have partial data, we could always query for the second most recent partition.&lt;/p&gt;

&lt;p&gt;We can maintain the same maximum look-back period to limit the query search space for performance reasons, while accounting for a few days of upstream failures.&lt;/p&gt;

&lt;p&gt;Example Athena SQL query:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;WITH&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RecentSnapshotPartitions&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DISTINCT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;-- Set a maximum look-back period to reduce query search space while allowing a few days of upstream failures&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;CAST&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;CURRENT_DATE&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;7&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DAY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;VARCHAR&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;-- Get the two most recent partitions with data&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;LIMIT&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;RankedSnapshotPartitions&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;-- Set a row number so we can select the second most recent partition&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;ROW_NUMBER&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;OVER&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;recency_rank&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RecentSnapshotPartitions&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RankedSnapshotPartitions&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RankedSnapshotPartitions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RankedSnapshotPartitions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RankedSnapshotPartitions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;recency_rank&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Pros:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;If the second most recent partition is always complete, this guarantees complete data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cons:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;More complex and difficult to understand than other options.&lt;/li&gt;
  &lt;li&gt;Less efficient than other options.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;4-retrieve-the-most-recent-snapshot-older-than-the-period-between-data-syncs&quot;&gt;4. Retrieve the most recent snapshot older than the period between data syncs&lt;/h3&gt;

&lt;p&gt;If we need to account for temporary upstream failures and cannot accept race conditions where the most recent partition may sometimes be complete, we can simplify from Option 1 by querying for the most recent partition older than the time between syncs, eg. the most recent partition more than 1 hour old.&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;WITH&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RecentCompleteSnapshotPartition&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DISTINCT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;-- Get snapshots from more than 1 hour ago, to avoid querying a partition with an ongoing sync if have race conditions near the start of an hour&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;CAST&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DATE_FORMAT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;CURRENT_TIMESTAMP&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;1&apos;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HOUR&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;%Y-%m-%d&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;VARCHAR&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;CAST&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DATE_FORMAT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;CURRENT_TIMESTAMP&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;1&apos;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HOUR&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;%H&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;VARCHAR&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;-- Set a maximum look-back period to reduce query search space while allowing a few days of upstream failures&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;CAST&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;CURRENT_DATE&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;7&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DAY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;VARCHAR&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;LIMIT&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RecentCompleteSnapshotPartition&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RecentCompleteSnapshotPartition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RecentCompleteSnapshotPartition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Pros:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;If populated partitions that are older than the period between syncs are always complete, guarantees complete data.&lt;/li&gt;
  &lt;li&gt;Simpler and more efficient than Option 3, while covering the flaws of Options 1-2.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cons:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Slightly more complex query than Options 1-2.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;5-send-data-completeness-events-to-trigger-downstream-queries&quot;&gt;5. Send data completeness events to trigger downstream queries&lt;/h3&gt;

&lt;p&gt;If we can request that our data provider pushes a data completeness signal file such as an empty file named &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_SUCCESS&lt;/code&gt; to the latest S3 directory after completing a data sync, we can set up S3 PutObject notifications that filter for this specific filename and trigger downstream workflows whenever this signal is received.&lt;/p&gt;

&lt;p&gt;This is ideal for event-based workflows that only need to listen for a single trigger, while it may not be sufficient if there are multiple upstream datasets that we need to query that have different schedules and may not all be complete at the same time.&lt;/p&gt;

&lt;p&gt;Pros:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Guarantees completeness of the given data set at the time we receive the event.&lt;/li&gt;
  &lt;li&gt;Allows downstream workflows to run with the most recent data possible, as soon as data is received.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cons:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;More complex to support multiple upstream data sets, best for single-dependency workflows.&lt;/li&gt;
  &lt;li&gt;May not work with workflows that are strictly schedule-based and cannot be easily triggered by an event.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;6-leverage-aws-glue-catalog-trigger-glue-crawler-from-glue-job-event&quot;&gt;6. Leverage AWS Glue Catalog, trigger Glue Crawler from Glue job event&lt;/h3&gt;

&lt;p&gt;If we populate our S3 bucket with an AWS Glue job, we can set up a Glue Catalog and a Glue Crawler that updates the catalog with an abstracted representation of the source data and its available partitions for downstream services such as AWS Athena to query.&lt;/p&gt;

&lt;p&gt;If we set up the Glue Catalog Table as the data source for our downstream queries, we will only query partitions that it has crawled.&lt;/p&gt;

&lt;p&gt;We can set up a &lt;a href=&quot;https://docs.aws.amazon.com/glue/latest/dg/about-triggers.html&quot;&gt;Glue trigger&lt;/a&gt; to automatically run the Crawler after the upstream Glue job that populates new S3 partitions has succeeded, or after &lt;a href=&quot;https://docs.aws.amazon.com/glue/latest/dg/starting-workflow-eventbridge.html&quot;&gt;some event is received via EventBridge&lt;/a&gt;, such as creation of a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_SUCCESS&lt;/code&gt; file, to automatically make new partitions available only after their sync jobs have completed.&lt;/p&gt;

&lt;p&gt;We could then use the Athena SQL query from Option 2 but with the Glue Table as our source, to simplify querying the most recent complete partition.&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;WITH&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MostRecentSnapshotPartition&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DISTINCT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;-- Set a maximum look-back period to reduce query search space while allowing a few days of upstream failures&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;CAST&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;CURRENT_DATE&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;7&apos;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DAY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;VARCHAR&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;LIMIT&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MostRecentSnapshotPartition&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MostRecentSnapshotPartition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_date&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MostRecentSnapshotPartition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;snapshot_hour&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Pros:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Abstracts logic for how to identify the most recent partition from data consumers.&lt;/li&gt;
  &lt;li&gt;Enables always querying the most recent complete partition.&lt;/li&gt;
  &lt;li&gt;Avoids the incomplete/duplicate data issues from Options 1-2 and the query complexity from Options 3-4.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cons:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Requires additional infrastructure set-up and hardware costs for Glue resources and event-based triggers.&lt;/li&gt;
  &lt;li&gt;May add several minutes of data latency from events -&amp;gt; Glue trigger -&amp;gt; Glue crawler -&amp;gt; Glue catalog update, compared to directly querying S3 for the most recent partition more than N hours old.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;7-leverage-aws-glue-catalog-use-aws-lambda-to-update-a-latest-table-after-completeness-events&quot;&gt;7. Leverage AWS Glue Catalog, use AWS Lambda to update a “latest” table after completeness events&lt;/h3&gt;

&lt;p&gt;If we have multiple upstream data sets which rules out Option 5, and we’re willing to invest a few developer weeks to optimize and simplify queries for data consumers, we could create a custom Lambda function that programmatically updates Glue resources to point to a “latest” S3 partition whenever a data sync completes.&lt;/p&gt;

&lt;p&gt;We could require data providers to write an empty &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_SUCCESS&lt;/code&gt; file to the new partition after a successful sync.  We can then set up a S3 PutObject notification that filters for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_SUCCESS&lt;/code&gt; files, and make this a trigger to our Lambda function.&lt;/p&gt;

&lt;p&gt;The Lambda function will then:&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;Query the S3 directory for files in the new partition and scan them to infer their partitions and table schema.&lt;/li&gt;
  &lt;li&gt;Query Glue’s &lt;a href=&quot;https://docs.aws.amazon.com/glue/latest/webapi/API_UpdateTable.html&quot;&gt;UpdateTable API&lt;/a&gt; to update the Glue Catalog Table that represents the latest version of the snapshot-based data set, to have the latest data schema and point to the new partition.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Data consumers can then simplify their queries to the following, without needing to select snapshot partition attributes:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upstream_dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Pros:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Significantly simplifies queries for data consumers.&lt;/li&gt;
  &lt;li&gt;Enables always querying the most recent complete partition.&lt;/li&gt;
  &lt;li&gt;Avoids the incomplete/duplicate data issues from Options 1-2 and the query complexity from Options 3-6.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cons:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Adds multiple weeks of developer effort to replicate AWS Glue’s functionality for inferring schemas and updating Glue Catalog Tables.&lt;/li&gt;
  &lt;li&gt;Increases backend architecture and code complexity, with more points of failures that need to be maintained and supported.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If this works, the end result may be great from the data consumer side, but in many cases this developer time is more valuable spent on other problems, and we’re willing to accept either slightly more complex but well-documented queries for data consumers, or occasional race conditions where recent snapshots may be incomplete if we happen to be reading at the same time that a data sync is occurring.&lt;/p&gt;

&lt;h2 id=&quot;case-study-retrieving-recent-data-when-completeness-is-critical-and-developer-time-is-limited&quot;&gt;Case study: Retrieving recent data when completeness is critical and developer time is limited&lt;/h2&gt;

&lt;p&gt;Take the case where we are limited to a few days of developer time to set up associated infrastructure, while we have the business requirements that:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Data consumers must always be able to query complete and accurate data that is no more than a few hours old.  Missing or duplicate data due to read/write race conditions is not acceptable.&lt;/li&gt;
  &lt;li&gt;Data consumers must be able to aggregate data from multiple upstream sources while satisfying the above conditions for completeness without partial data if querying during data syncs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In this scenario, Option 6 may be an acceptable tradeoff, where we leverage existing AWS Glue functionality to automatically surface new partitions and schemas after receiving a data sync completion signal, and point data consumers to the Glue Catalog Table to query partitions that have completed syncs.&lt;/p&gt;

&lt;p&gt;Data consumers will then need to be aware of snapshot partition attributes to select data from the most recent partition, but they can simply query for the most recent partition since we will only surface partitions after receiving a data sync completion signal.&lt;/p&gt;
</description>
        <pubDate>Sat, 31 May 2025 00:00:00 +0000</pubDate>
        <link>https://www.marksayson.com/blog/strategies-for-querying-snapshot-s3-data/</link>
        <guid isPermaLink="true">https://www.marksayson.com/blog/strategies-for-querying-snapshot-s3-data/</guid>
        
        
        <category>aws</category>
        
      </item>
    
      <item>
        <title>Granting AWS Organization member accounts access to Cost Explorer</title>
        <description>&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;By default, adding accounts to an AWS Organization results in consolidated billing and cost management in the Organization management account, and Organization member accounts lose access to Cost Explorer, Billing, and other cost management services unless access is explicitly enabled from the Organization management account.&lt;/p&gt;

&lt;p&gt;This post walks through how to allow member accounts to access cost management services, to enable each team to review and manage their AWS spending.&lt;/p&gt;

&lt;h2 id=&quot;enabling-cost-explorer-and-cost-optimization-hub&quot;&gt;Enabling Cost Explorer and Cost Optimization Hub&lt;/h2&gt;

&lt;p&gt;Cost Explorer allows you to analyze AWS service costs and usage changes over time, and is free to access through the AWS UI.  You can filter and group costs and usage across multiple dimensions including AWS service, resource, usage type, region, and tag.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_CostExplorer.png&quot; alt=&quot;Cost Explorer Screenshot&quot; /&gt;&lt;/p&gt;

&lt;p&gt;After logging into the Organization management account using the root user, navigate to the Cost Explorer service to automatically enable it from the current time onwards.  You will need to do this once per account in your Organization, logging in as the root user for each account.&lt;/p&gt;

&lt;p&gt;Cost Optimization Hub is a free service that provides cost optimization recommendations across multiple services.  To enable member accounts access, we’ll need to enable the services from the AWS Organizations management account, and explicitly add permissions to the Permission Sets granting them access.&lt;/p&gt;

&lt;p&gt;To enable Cost Optimization Hub, navigate to the Cost Optimization Hub landing page, scroll down, select “Enable Cost Optimization Hub for this account and all member accounts”, and click “Enable”.&lt;/p&gt;

&lt;p&gt;As indicated by the prompt, to fully benefit from this service you’ll also need to opt into the free AWS Compute Optimizer service to import service rightsizing recommendations.  Follow the link to navigate to AWS Compute Optimizer, and click “Get started”.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_CostOptimizationHub.png&quot; alt=&quot;Prompt to enable Compute Optimizer&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_ComputeOptimizerEnablePage.png&quot; alt=&quot;Compute Optimizer Enable Link&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_ComputeOptimizerEnablePage2.png&quot; alt=&quot;Compute Optimizer Enable Page&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Select to opt in all member accounts, and click “Opt in”.&lt;/p&gt;

&lt;h2 id=&quot;enabling-access-for-member-accounts&quot;&gt;Enabling access for member accounts&lt;/h2&gt;

&lt;p&gt;On the top right hand of the AWS console, select your account name to open the account dropdown, and click “Account”, or directly navigate to &lt;a href=&quot;https://us-east-1.console.aws.amazon.com/billing/home?region=us-east-1#/account&quot;&gt;https://us-east-1.console.aws.amazon.com/billing/home?region=us-east-1#/account&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_AwsAccountDropdown.png&quot; alt=&quot;AWS account dropdown&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Scroll down to “IAM user and role access to Billing information” section and click “Edit”.  Select “Activate IAM Access” and click “Update”.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_AccountSettings_EnableIamAccessToBilling.png&quot; alt=&quot;Enable IAM access to Billing&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Navigate to “Billing and Cost Management” &amp;gt; “Cost Management Preferences”.&lt;/p&gt;

&lt;p&gt;Under the “General” tab, enable the options below, then select “Save preferences” at the bottom of the page and confirm changes.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Linked account access&lt;/li&gt;
  &lt;li&gt;Linked account refunds and credits&lt;/li&gt;
  &lt;li&gt;Linked account discounts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_CostManagementPrefs_GeneralPrefs.png&quot; alt=&quot;Enable linked accounts access to cost management services&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Under the “Cost Explorer” tab, enable “Granular data” with “Resource-level data at daily granularity”, and select “All services”, then click “Save preferences” at the bottom of the page and confirm changes.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_CostManagementPrefs_CostExplorerPrefs.png&quot; alt=&quot;Enable Cost Explorer data at daily granularity&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Under the “Cost Optimization Hub” tab, under “Organization and member account settings”, select “Enable Cost Optimization Hub for all member accounts” and “Allow member account discount visibility”, then click “Save preferences” and confirm changes.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_CostManagementPrefs_CostOptimizationHubPrefs.png&quot; alt=&quot;Enable linked accounts access to Cost Optimization Hub&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;accessing-cost-explorer-from-member-accounts&quot;&gt;Accessing Cost Explorer from member accounts&lt;/h2&gt;

&lt;p&gt;At this point, team members logging into Organization member accounts to view Cost Explorer will still see access denied errors across all widgets, even if their IAM permissions grant access to all Cost Explorer actions.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_CostExplorerAccessDenied.png&quot; alt=&quot;Cost Explorer Access Denied&quot; /&gt;&lt;/p&gt;

&lt;p&gt;When clicking on one of the errors, we can see the following message:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;You don’t have permission to [Cost Explorer:]. To request access, copy the following text and send it to your AWS administrator. Learn more about troubleshooting access denied errors&lt;/p&gt;

  &lt;p&gt;User: [REDACTED_ACCOUNT_ID]&lt;/p&gt;

  &lt;p&gt;Service: [Cost Explorer]&lt;/p&gt;

  &lt;p&gt;Name: [AccessDeniedException]&lt;/p&gt;

  &lt;p&gt;HTTP status code: [400]&lt;/p&gt;

  &lt;p&gt;Context: [IAM user access not activated]&lt;/p&gt;

  &lt;p&gt;Request ID: [REDACTED_REQUEST_ID]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To resolve this error, we need to:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Log into the Organization member account as the root user.&lt;/li&gt;
  &lt;li&gt;Visit AWS Cost Explorer once to enable this service if we haven’t done this before.&lt;/li&gt;
  &lt;li&gt;Navigate to Account settings via the top right hand account dropdown &amp;gt; “Account”.&lt;/li&gt;
  &lt;li&gt;Scroll down to “IAM user and role access to Billing information” section and click “Edit”.&lt;/li&gt;
  &lt;li&gt;Select “Activate IAM Access” and click “Update”.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_AccountSettings_EnableIamAccessToBilling_EditPage.png&quot; alt=&quot;Enable IAM access to billing&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Non-root users with billing permissions should now have access to Cost Explorer, while the page may initially show zero costs and only show correct spending for the current time going forward.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_CostExplorerZeroCosts.png&quot; alt=&quot;Cost Explorer page&quot; /&gt;&lt;/p&gt;

&lt;p&gt;After waiting for a few days, I noticed that the default view still shows zero spending, while after updating the time period to the last 7 days and updating the granularity to daily, I see correct costs for the past few days up to the date when Cost Explorer was enabled from the Organization management account.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_CostExplorerLast7Days.png&quot; alt=&quot;Cost Explorer filtered to last 7 days&quot; /&gt;&lt;/p&gt;

&lt;p&gt;We can now add any filters or groupings we like, such as grouping costs by usage type to see what specify AWS service usages are contributing to our daily spending.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_CostExplorerLast7DaysGroupByUsageType.png&quot; alt=&quot;Cost Explorer filtered to last 7 days, grouped by usage type&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;granting-cross-account-user-groups-access-to-cost-explorer&quot;&gt;Granting cross-account user groups access to Cost Explorer&lt;/h2&gt;

&lt;p&gt;We need to explicitly grant user groups access to Cost Explorer, otherwise they will be denied by default.&lt;/p&gt;

&lt;p&gt;Users with full read permissions across all AWS services will be able to view Cost Explorer after the earlier steps in this post, but we may want to allow additional user roles access as well.&lt;/p&gt;

&lt;p&gt;See my last post’s &lt;a href=&quot;/blog/aws-organizations/&quot;&gt;“Creating cross-account role-based permission groups”&lt;/a&gt; section for how to create a Permission Set and assign user groups these permissions on specific linked accounts.&lt;/p&gt;

&lt;p&gt;To manage your Permission Sets, navigate to “IAM Identity Center” &amp;gt; “Multi-account permissions” &amp;gt; “Permission sets”.&lt;/p&gt;

&lt;p&gt;Assuming you have created a Permission Set that does not have explicit permissions to Cost Explorer, and you want that group to have read access to all cost management services, select that Permission Set.&lt;/p&gt;

&lt;p&gt;In this case, I have a SupportUser Permission Set where I want my support team to be able to view cost and usage data for their assigned accounts.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_SupportUserPermissionSetWithoutBillingAccess.png&quot; alt=&quot;Enable linked accounts access to Cost Optimization Hub&quot; /&gt;&lt;/p&gt;

&lt;p&gt;While this Permission Set only has the SupportUser policy assigned, users logging in with this role will be denied access to all Cost Explorer widgets with the error:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;User: [REDACTED_ACCOUNT_ID]&lt;/p&gt;

  &lt;p&gt;Service: [Cost Explorer]&lt;/p&gt;

  &lt;p&gt;Name: [AccessDeniedException]&lt;/p&gt;

  &lt;p&gt;HTTP status code: [400]&lt;/p&gt;

  &lt;p&gt;Context: [User: arn:aws:sts::REDACTED_ACCOUNT_ID:assumed-role/REDACTED_ROLE_ID is not authorized to perform: ce:GetCostAndUsage on resource: arn:aws:ce:us-east-1:REDACTED_ACCOUNT_ID:/GetCostAndUsage because no identity-based policy allows the ce:GetCostAndUsage action]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Under the Permission Set’s “Permissions” &amp;gt; “Inline policy” section, select “Edit”.&lt;/p&gt;

&lt;p&gt;Enter the following IAM policy document, or the scoped permissions you want to grant:&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;Version&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;2012-10-17&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;Statement&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
            &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;Sid&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;CostManagementViewAccess&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
            &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;Effect&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Allow&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
            &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;Action&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;account:GetAccountInformation&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;aws-portal:ViewBilling&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;billing:Get*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;billing:List*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;budgets:ViewBudget&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;budgets:Describe*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;ce:Describe*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;ce:Get*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;ce:List*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;consolidatedbilling:Get*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;consolidatedbilling:List*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;cur:Describe*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;cur:Get*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;freetier:Get*&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
                &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;sustainability:GetCarbonFootprintSummary&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
            &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
            &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;Resource&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;*&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Scroll to the bottom of the page and save your changes.&lt;/p&gt;

&lt;p&gt;Validate that users assigned this Permission Set on a given account now have read access to Billing and Cost Management pages.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/20250510_CostExplorerAccessAsSupportUser.png&quot; alt=&quot;Support user now able to view Cost Explorer pages&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;posts-in-this-series&quot;&gt;Posts in this series&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/blog/aws-organizations/&quot;&gt;Using AWS Organizations to standardize security controls across AWS accounts&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;(Current post) Granting AWS Organization member accounts access to Cost Explorer&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;p&gt;AWS Organizations User Guide: &lt;a href=&quot;https://docs.aws.amazon.com/organizations/latest/userguide/orgs_introduction.html&quot;&gt;https://docs.aws.amazon.com/organizations/latest/userguide/orgs_introduction.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AWS Cost Explorer Access User Guide: &lt;a href=&quot;https://docs.aws.amazon.com/cost-management/latest/userguide/ce-access.html&quot;&gt;https://docs.aws.amazon.com/cost-management/latest/userguide/ce-access.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AWS IAM Identity Center Permission Sets User Guide: &lt;a href=&quot;https://docs.aws.amazon.com/singlesignon/latest/userguide/permissionsetsconcept.html&quot;&gt;https://docs.aws.amazon.com/singlesignon/latest/userguide/permissionsetsconcept.html&lt;/a&gt;&lt;/p&gt;
</description>
        <pubDate>Sat, 10 May 2025 00:00:00 +0000</pubDate>
        <link>https://www.marksayson.com/blog/aws-organization-members-cost-explorer-access/</link>
        <guid isPermaLink="true">https://www.marksayson.com/blog/aws-organization-members-cost-explorer-access/</guid>
        
        
        <category>aws</category>
        
      </item>
    
  </channel>
</rss>
