ceremonyclient/pebble/docs/range_deletions.md
Cassandra Heart 2e2a1e4789
v1.2.0 (#31)
2024-01-03 01:31:42 -06:00

20 KiB

Range Deletions

TODO: The following explanation of range deletions does not take into account the recent change to prohibit splitting of a user key between sstables. This change simplifies the logic, removing 'improperly truncated range tombstones.'

TODO: The following explanation of range deletions ignores the kind/trailer that appears at the end of keys after the sequence number. This should be harmless but need to add a justification on why it is harmless.

Background and Notation

Range deletions are represented as [start, end)#seqnum. Points (set/merge/...) are represented as key#seqnum. A range delete [s, e)#n1 deletes every point k#n2 where k \in [s, e) and n2 < n1. The inequality n2 < n1 is to handle the case where a range delete and a point have the same sequence number -- this happens during sstable ingestion where the whole sstable is assigned a single sequence number that applies to all the data in it.

There is additionally an infinity sequence number, represented as inf, which is not used for any point, that we can use for reasoning about range deletes.

It has been asked why range deletes use an exclusive end key instead of an inclusive end key. For string keys, one can convert a desired range delete on [s, e] into a range delete on [s, ImmediateSuccessor(e)). For strings, the immediate successor of a key is that key with a \0 appended to it. However one cannot go in the other direction: if one could represent only inclusive end keys in a range delete and one desires to delete a range with an exclusive end key [s, e)#n, one needs to compute ImmediatePredecessor(e) which is an infinite length string. For example, ImmediatePredecessor("ab") is "aa\xff\xff....". Additionally, regardless of user needs, the exclusive end key helps with splitting a range delete as we will see later.

We will sometimes use ImmediatePredecessor and ImmediateSuccessor in the following for illustrating an idea, but we do not rely on them as something that is viable to produce for a particular kind of key. And even if viable, these functions are not currently provided to RockDB/Pebble.

Visualization

If we consider a 2 dimensional space with increasing keys on the X axis (with every possible user key represented) and increasing sequence numbers on the Y axis, range deletes apply to a rectangle whose bottom edge sits on the X axis.

The actual space represented by the ordering in our sstables is a one dimensional space where k1#n1 is less than k2#n2 if either of the following holds:

  • k1 < k2

  • k1 = k2 and n1 > n2 (under the assumption that no two points with the same key have the same sequence number).

  ^
  |   .       > .        > .        > yy
  |   .      >  .       >  .       >  .
  |   .     >   .      >   .      >   .
n |   V    >    xx    >    .     >    V
  |   .   >     x.   >     x.   >     . 
  |   .  >      x.  >      x.  >      .
  |   . >       x. >       x. >       .
  |   .>        x.>        x.>        .
  ------------------------------------------>
                k        IS(k)    IS(IS(k))

The above figure uses . to represent points and the X axis is dense in that it represents all possible keys. xx represents the start of a range delete and x. are the points which it deletes. The arrows V and > represent the ordering of the points in the one dimensional space. IS is shorthand for ImmediateSuccessor and the range delete represented there is [k, IS(IS(k)))#n. Ignore yy for now.

The one dimensional space works fine in a world with only points. But issues arise when storing range deletes, that represent an action in 2 dimensional space, into this one dimensional space.

Range Delete Boundaries and the Simplest World

RocksDB/Pebble store the inclusive bounds of each sstable in one dimensional space. The range deletes two dimensional behavior and exclusive end key needs to be adapted to this requirement. For a range delete [s, e)#n, the smallest key it acts on is s#(n-1) and the largest key it acts on is ImmediatePredecessor(e)#0. So if we position the range delete immediately before the smallest key it acts on and immediately after the largest key it acts on we can give it a tight inclusive bound of [s#n, e#inf].

Note again that this range delete does not delete everything in its inclusive bound. For example, range delete ["c", "h")#10 has a tight inclusive bound of ["c"#10, "h"#inf] but does not delete "d"#11 which lies in that bound. Going back to our earlier diagram, the one dimensional inclusive bounds go from the xx to yy but there are .s in between, in the one dimensional order, that are not deleted.

This is the reason why one cannot in general use a range delete to seek over all points within its bounds. The one exception to this seeking behaviour is that when we can order sstables from new to old, one can "blindly" use this range delete in a newer sstable to seek to "h" in all older sstables since we know those older sstables must only have point keys with sequence numbers < 10 for the keys in interval ["c", "h"). This partial order across sstables exists in RocksDB/Pebble between memtable, L0 sstables (where it is a total order) and across sstables in different levels.

Coming back to the inclusive bounds of the range delete, [s#n, e#inf]: these bounds participate in deciding the bounds of the sstable. In this world, one can read all the entries in an sstable and compute its bounds. However being able to construct these bounds by reading an sstable is not essential -- RocksDB/Pebble store these bounds in the MANIFEST. This latter fact has been exploited to construct a real world (later section) where the bounds of an sstable are not computable by reading all its keys.

If we had a system with one sstable per level, for each level lower than L0, we are effectively done. We have represented the tight bounds of each range delete and it is within the bounds of the sstable. This works even with L0 => L0 compactions assuming they output exactly one sstable.

The Mostly Simple World

Here we have multiple files for levels lower than L0 that are non overlapping in the file bounds. These multiple files occur because compactions produce multiple files. This introduces the need to split a range delete across the files being produced by a compaction.

There is a clean way to split a range delete [s, e)#n into 2 parts (which can be recursively applied to split into arbitrarily many parts): split into [s, m)#n and [m, e)#n. These range deletes apply to non-overlapping points and their tight bounds are [s#m, m#inf], [m#n, e#inf] which are also non-overlapping.

Consider the following example of an input range delete ["c", "h")#10 and the following two output files from a compaction:

          sst1            sst2
last point is "e"#7 | first point is "f"#20

The range delete can be split into ["c", "f")#10 and ["f", "h")#10, by using the first point key of sst2 as the split point. Then the bounds of sst1 and sst2 will be [..., "f"#inf] and ["f"#20, ...] which are non-overlapping. It is still possible to compute the sstable bounds by looking at all the entries in the sstable.

The Real World

Continuing with the same range delete ["c", "h")#10, we can have the following sstables produced during a compaction:

         sst1       sst2         sst3        sst4     sst5
points: "e"#7 | "f"#12 "f"#7 | "f"#4 "f"#3 | "f"#1 | "g"#15

The range deletes written to these ssts are

      sst1           sst2            sst3           sst4          sst5
["c", "h")#10 | ["f", "h")#10 | ["f", "h")#10 | ["f", "h")#10 | ["g", "h")#10

The Pebble code that achieves this effect is in rangedel.Fragmenter. It is a code structuring artifact that sst1 does not contain a range delete equal to ["c", "f")#10 and sst4 does not contain ["f", "g")#10. However for the range deletes in sst2 and sst3 we cannot do any better because we don't know what the key following "f" will be (the compaction cannot look ahead) and because we don't have an ImmediateSuccessor function (otherwise we could have written ["f", ImmediateSuccessor("f"))#10 to sst2, sst3). But the code artifacts are not the ones introducing the real complexity.

The range delete bounds are

      sst1        sst2, sst3, sst4          sst5
["c"#10, "h"#inf] ["f"#10, "h"#inf]   ["g"#10, "h"#inf]

We note the following:

  • The bounds of range deletes are overlapping since we have been unable to split the range deletes. If these decide the sstable bounds, the sstables will have overlapping bounds. This is not permissible.

  • The range deletes included in each sstable result in that sstable being "self-sufficient" wrt having the range delete that deletes some of the points in the sstable (let us assume that the points in this example have not been dropped from that sstable because of a snapshot).

  • The transitions from sst1 to sst2 and sst4 to sst5 are clean in that we can pretend that the range deletes in those files are actually:

      sst1           sst2            sst3           sst4          sst5
["c", "f")#10 | ["f", "g")#10 | ["f", "g")#10 | ["f", "g")#10 | ["g", "h")#10

We could achieve some of these clean transitions (but not all) with a code change. Also note that these better range deletes maintain the "self-sufficient" property.

Making Non-overlapping SSTable bounds

We force the sstable bounds to be non-overlapping by setting them to:

      sst1              sst2           sst3            sst4              sst5
["c"#10, "f"#inf] ["f"#12, "f"#7] ["f"#4, "f"#3] ["f"#1, "g"#inf] ["g"#15, "h"#inf]

Note that for sst1...sst4 the sstable bounds are smaller than the bounds of the range deletes contained in them. The code that accomplishes this is Pebble is in compaction.go -- we will not discuss the details of that logic here but note that it is placing an inf sequence number for a clean transition and for an unclean transition it is using the point keys as the bounds.

Associated with these narrower bounds, we add the following requirement: a range delete in an sstable must act-within the bounds of the sstable it is contained in. In the above example:

  • sst1: range delete ["c", "h")#10 must act-within the bound ["c"#10, "f"#inf]

  • sst2: range delete ["f", "h")#10 must act-within the bound ["f"#12, "f"#7]

  • sst3: range delete ["f", "h")#10 must act-within the bound ["f"#4, "f"#3]

  • sst4: range delete ["f", "h")#10 must act-within the bound ["f"#1, "g"#inf]

  • And so on.

The intuitive reason for the act-within requirement is that sst5 can be compacted and moved down to a lower level independent of sst1-sst4, since it was at a clean boundary. We do not want the range delete ["f", "h")#10 sitting in sst1...sst4 at the higher level to act on "g"#15 that has been moved to the lower level. Note that this incorrect action can happen due to 2 reasons:

  1. the invariant that lower levels have older data for keys that also exist in higher levels means we can (a) seek a lower level sstable to the end of a range delete from a higher level, (b) for a key lookup, stop searching in lower levels once a range delete is encountered for that key in a higher level.

  2. Sequence number zeroing logic can change the sequence number of "g"#15 to "g"#0 (for better compression) once it realizes that there are no older versions of "g". It would be incorrect for this "g"#0 to be deleted.

Loss of Power

This act-within behavior introduces some "loss of power" for the original range delete ["c", "h")#10. By acting within sst2...sst4 it can no longer delete keys "f"#6, "f"#5, "f"#2.

Luckily for us, this is harmless since these keys cannot have existed in the system due to the levelling behavior: we cannot be writing sst2...sst4 to level i if versions of "f" younger than "f"#4 are already in level i or version older than "f"#7 have been left in level i - 1. There is some trickery possible to prevent this "loss of power" for queries (see the "Putting it together" section), but given the history of bugs in this area, we should be cautious.

Improperly truncated Range Deletes

We refer to range deletes that have experienced this "loss of power" as improper. In the above example the range deletions in sst2, sst3, sst4 are improper. The problem with improper range deletions occurs when they need to participate in a future compaction: even though we have restricted them to act-within their current sstable boundary, we don't have a way of "writing" this restriction to a new sstable, since they still need to be written in the [s, e)#n format.

For example, sst2 has delete ["f", "h")#10 that must act-within the bound ["f"#12, "f"#7]. If sst2 was compacted down to the next level into a new sstable (whose bounds we cannot predict because they depend on other data written to that sstable) we need to be able to write a range delete entry that follows the original restriction. But the narrowest we can write is ["f", ImmediateSuccessor("f"))#10. This is an expansion of the act-within restriction with potentially unintended consequences. In this case the expansion happened in the suffix. For sst4, the range deletion ["f", "h")#10 must act-within ["f"#1, "g"#inf], and we can precisely represent the constraint on the suffix by writing ["f", "g")#10 but it does not precisely represent that this range delete should not apply to "f"#9..."f"#2.

In comparison, the sst1 range delete ["c", "h")#10 that must act-within the bound ["c"#10, "f"#inf] is not improper. This restriction can be applied precisely to get a range delete ["c", "f")#10.

The solution to this is to note that while individual sstables have improper range deletes, if we look at a collection of sstables we can restore the improper range deletes spread across them to their proper self (and their full power). To accurately find these improper range deletes would require looking into the contents of a file, which is expensive. But we can construct a pessimistic set based on looking at the sequence of all files in a level and partitioning them: adjacent files f1, f2 with largest and smallest bound k1#n1, k2#n2 must be in the same partition if

k1 = k2 and n1 != inf

In the above example sst2, sst3, sst4 are one partition. The spanning bound of this partition is ["f"#12, "g"#inf] and the range delete ["f", "h")#10 when constrained to act-within this spanning bound is precisely the range delete ["f", "g")#10. Intuitively, the "loss of power" of this range delete has been restored for the sake of making it proper, so it can be accurately "written" in the output of the compaction (it may be improperly fragmented again in the output, but we have already discussed that). Such partitions are called "atomic compaction groups" and must participate as a whole in a compaction (and a compaction can use multiple atomic compaction groups as input).

Consider another example:

          sst1              sst2
points:  "e"#12         |  "e"#10
delete: ["c", "g")#8    | ["c", "g")#8
bounds  ["c"#8, "e"#12] | ["e"#10, "g"#inf]

sst1, sst2 are an atomic compaction group. Say we violated the requirement that both be inputs in a compaction and only compacted sst2 down to level i + 1 and then down to level i + 2. Then we add sst3 with bounds ["h"#10, "j"#5] to level i and sst1 and sst3 are compacted to level i + 1 into a single sstable. This new sstable will have bounds ["c"#8, "j"#5] so these bounds do not help with the original apply-witin constraint on ["c", "g")#8 (that it should apply-within ["c"#8, "e"#12]). The narrowest we can construct (if we had ImmediateSuccessor) would be ["c", ImmediateSuccessor("e"))#8. Now we can incorrectly apply this range delete that is in level i + 1 to "e"#10 sitting in level i + 2. Note that this example can be made worse using sequence number zeroing -- "e"#10 may have been rewritten to "e"#0.

If a range delete [s, e)#n is in an atomic compaction group with spanning bounds [k1#n1, k2#n2] our construction above guarantees the following properties

  • k1#n1 <= s#n, so the bounds do not constrain the start of the range delete.

  • k2 >= e or n2 = inf, so if k2 is constraining the range delete it will properly truncate the range delete.

New sstable at sequence number 0

A new sstable can be assigned sequence number 0 (and be written to L0) if the keys in the sstable are not in any other sstable. This comparison uses the keys and not key#seqnum, so the loss and restoration of power does not cause problems since that occurs within the versions of a single key.

Flawed optimizations

For the case where the atomic compaction group correspond to the lower level of a compaction, it may initially seem to be correct to use only a prefix or suffix of that group in a compaction. In this case the prefix (suffix) will correspond to the largest key (smallest key) in the input sstables in the compaction and so can continue to constrain the range delete. For example, sst1 and sst2 are in the same atomic compaction group

          sst1               sst2
points: "c"#10 "e"#12    |  "e"#10
delete: ["c", "g")#8     | ["c", "g")#8
bounds  ["c"#10, "e"#12] | ["e"#10, "g"#inf]

and this is the lower level of a compaction with

          sst3
points: "a"#14 "d"#15
bounds  ["a"#14, "d"#15]

we could allow for a compaction involving sst1 and sst3 which would produce

          sst4
points: "a"#14 "c"#10 "d"#15 "e"#12
delete: ["c", "g")#8
bounds  ["a"#14, "e"#12]

and the range delete is still improper but its act-within constraint has not expanded.

But we have to be very careful to not have a more significant loss of power of this range delete. Consider a situation where sst3 had a single delete "e"#16. It still does not overlap in bounds with sst2 and we again pick sst1 and sst3 for compaction. This single delete will cause "e"#12 to be deleted and sst4 bounds would be (unless we had complicated code preventing it):

          sst4
points: "a"#14 "c"#10 "d"#15
delete: ["c", "g")#8
bounds  ["a"#14, "d"#15]

Now this delete cannot delete "dd"#6 and we have lost the ability to know that sst4 and sst2 are in the same atomic compaction group.

Putting it together

Summarizing the above, we have:

  • SStable bounds logic that ensures sstables are not overlapping. These sstables contain range deletes that extend outside these bounds. But these range deletes should apply-within the sstable bounds.

  • Compactions: they need to constrain the range deletes in the inputs to apply-within, but this can create problems with writing the improper range deletes. The solution is to include the full atomic compaction group in a compaction so we can restore the improper range deletes to their proper self and then apply the constraints of the atomic compaction group.

  • Queries: We need to act-within the file bound constraint on the range delete. Say the range delete is [s, e)#n and the file bound is [b1#n1, b2#n2]. We are guaranteed that b1#n1 <= s#n so the only constraint can come from b2#n2.

    • Deciding whether a range delete covers a key in the same or lower levels.

      • b2 >= e: there is no act-within constraint.
      • b2 < e: to be precise we cannot let it delete b2#n2-1 or later keys. But it is likely that allowing it to delete up to b2#0 would be ok due to the atomic compaction group. This would prevent the so-called "loss of power" discussed earlier if one also includes the argument that the gap in the file bounds that also represents the loss of power is harmless (the gap exists within versions of key, and anyone doing a query for that key will start from the sstable to the left of the gap). But it may be better to be cautious.
    • For using the range delete to seek sstables at lower levels.

      • b2 >= e: seek to e since there is no act-within constraint.
      • b2 < e: seek to b2. We are ignoring that this range delete is allowed to delete some versions of b2 since this is just a performance optimization.