Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

db: add LazyValue for a value that may not be stored in-place with th… #2012

Merged
merged 1 commit into from
Nov 2, 2022

Conversation

sumeerbhola
Copy link
Collaborator

@sumeerbhola sumeerbhola commented Oct 14, 2022

…e key

The changes here are plumbing for the read path. The actual write and read path for values stored in value blocks will happen in future PRs.

A ShortAttribute (3 bits) is also introduced, for user-defined attributes about a value. These will be included in the 1 byte prefix of the value in the key-value pairs in the sstable (the other 5 bits are for internal use). In CockroachDB, the ShortAttribute will initially be used for marking MVCC tombstones (for which 1 bit is sufficient).

LazyValue can represent either an in-place value (stored with the key) or a value stored in the value section. The interface is general enough for the future where values may be stored in separate files.

LazyValue is used in the InternalIterator interface, such that all positioning calls return (*InternalKey, LazyValue). It is also exposed via the public Iterator for callers that need to remember a recent but not necessarily latest LazyValue, in case they need the actual value in the future. An example is a caller that is iterating in reverse and looking for the latest MVCC version for a key -- it cannot identify the latest MVCC version without stepping to the previous key-value pair e.g. storage.pebbleMVCCScanner in CockroachDB.

There is subtlety in memory management:

  • A LazyValue returned by an InternalIterator or Iterator is unstable in that repositioning the iterator will invalidate the memory inside it. A caller wishing to maintain that LazyValue needs to call LazyValue.Clone. Note that this does not fetch the value if it is not in-place.

  • A user of an iterator that calls LazyValue.Value wants as much as possible for the returned value byte-slice to point to iterator owned memory.

    • [P1] The underlying iterator that owns that memory also needs a promise from that user that at any time there is at most one value byte-slice that the caller is expecting it to maintain. Otherwise, the underlying iterator has to maintain multiple such byte-slices which results in more complicated and inefficient code. Note that pre-LazyValue this promise was trivially provided by only asking for the value at the current iterator position (so it could certainly point inside the underlying ssblock). For other cases, the user would make a copy of the byte-slice. We are generalizing this promise which allows for more memory allocation optimization.
    • [P2] The underlying iterator, in order to make the promise that it is maintaining the one value byte-slice, also needs a way to know when it is relieved of that promise. It is relieved of that promise by being told that it is being repositioned (this behavior is unchanged by this PR). Specifically, the owner of the value byte-slice is a sstable iterator. Consider the case where the caller has used LazyValue.Clone and repositioned the iterator. In this case the sstable iterator may not even be open. LazyValue.Value will still work (at a higher cost), but since the sstable iterator is not open, it cannot satisfy P2. For this reason, the LazyValue.Value method accepts a caller owned buffer, that the callee will use if needed.

Informs #1170

@sumeerbhola sumeerbhola requested review from jbowens and a team October 14, 2022 20:31
@cockroach-teamcity
Copy link
Member

This change is Reviewable

@sumeerbhola
Copy link
Collaborator Author

The Pebble benchmark numbers are slightly slower, but they are a bit noisy in that another run on my gceworker had master slower than this PR. The only consistently slower one is IteratorScan/keys=1000,r-amp=1,key-types=points-and-ranges-24 85.1µs ± 1% 107.6µs ±24% +26.45% (p=0.008 n=5+5) which I will investigate.

name                                                                        old time/op    new time/op    delta
pkg:github.com/cockroachdb/pebble goos:linux goarch:amd64
IteratorSeekGE-24                                                              986ns ± 2%    1011ns ± 8%     ~     (p=0.690 n=5+5)
IteratorNext-24                                                               33.1ns ± 2%    34.5ns ± 1%   +4.15%  (p=0.008 n=5+5)
IteratorPrev-24                                                               45.8ns ± 1%    43.6ns ± 1%   -4.77%  (p=0.008 n=5+5)
IteratorSeqSeekPrefixGENotFound/skip=1/bloom=false/with-tombstone=false-24     694ns ± 2%     712ns ± 1%   +2.70%  (p=0.016 n=5+5)
IteratorSeqSeekPrefixGENotFound/skip=1/bloom=false/with-tombstone=true-24      452ns ± 1%     458ns ± 0%   +1.35%  (p=0.008 n=5+5)
IteratorSeqSeekPrefixGENotFound/skip=1/bloom=true/with-tombstone=false-24     1.32µs ± 0%    1.35µs ± 0%   +1.62%  (p=0.008 n=5+5)
IteratorSeqSeekPrefixGENotFound/skip=1/bloom=true/with-tombstone=true-24      1.98µs ± 1%    1.99µs ± 1%     ~     (p=0.548 n=5+5)
IteratorSeqSeekPrefixGENotFound/skip=2/bloom=false/with-tombstone=false-24     703ns ± 1%     720ns ± 1%   +2.42%  (p=0.008 n=5+5)
IteratorSeqSeekPrefixGENotFound/skip=2/bloom=false/with-tombstone=true-24      454ns ± 1%     459ns ± 2%     ~     (p=0.056 n=5+5)
IteratorSeqSeekPrefixGENotFound/skip=2/bloom=true/with-tombstone=false-24     1.33µs ± 1%    1.36µs ± 1%   +2.52%  (p=0.008 n=5+5)
IteratorSeqSeekPrefixGENotFound/skip=2/bloom=true/with-tombstone=true-24      1.98µs ± 1%    2.02µs ± 1%   +2.10%  (p=0.008 n=5+5)
IteratorSeqSeekPrefixGENotFound/skip=4/bloom=false/with-tombstone=false-24     709ns ± 1%     725ns ± 1%   +2.32%  (p=0.008 n=5+5)
IteratorSeqSeekPrefixGENotFound/skip=4/bloom=false/with-tombstone=true-24      453ns ± 3%     460ns ± 3%     ~     (p=0.151 n=5+5)
IteratorSeqSeekPrefixGENotFound/skip=4/bloom=true/with-tombstone=false-24     1.34µs ± 1%    1.36µs ± 1%   +1.31%  (p=0.040 n=5+5)
IteratorSeqSeekPrefixGENotFound/skip=4/bloom=true/with-tombstone=true-24      2.01µs ± 1%    2.02µs ± 1%     ~     (p=0.206 n=5+5)
IteratorSeqSeekGEWithBounds-24                                                 741ns ± 1%     753ns ± 0%   +1.64%  (p=0.008 n=5+5)
IteratorSeekGENoop/withLimit=false-24                                         55.0ns ± 3%    57.9ns ± 1%   +5.24%  (p=0.008 n=5+5)
IteratorSeekGENoop/withLimit=true-24                                          96.6ns ± 0%    98.0ns ± 0%   +1.41%  (p=0.016 n=4+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@10/forward-24                   5.03ms ± 2%    5.14ms ± 5%     ~     (p=0.222 n=5+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@10/backward-24                  6.35ms ± 4%    6.49ms ± 2%     ~     (p=0.222 n=5+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@50/forward-24                   4.56ms ± 2%    4.66ms ± 2%     ~     (p=0.056 n=5+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@50/backward-24                  5.58ms ± 1%    5.77ms ± 3%   +3.49%  (p=0.016 n=5+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@75/forward-24                   2.47ms ± 2%    2.52ms ± 2%     ~     (p=0.056 n=5+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@75/backward-24                  3.15ms ± 3%    3.24ms ± 4%     ~     (p=0.151 n=5+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@100/forward-24                   342µs ± 2%     351µs ± 2%   +2.80%  (p=0.032 n=5+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@100/backward-24                  669µs ± 2%     689µs ± 2%     ~     (p=0.056 n=5+5)
IteratorScan/keys=100,r-amp=1,key-types=points-only-24                        10.6µs ± 3%    10.7µs ± 2%     ~     (p=0.310 n=5+5)
IteratorScan/keys=100,r-amp=1,key-types=points-and-ranges-24                  11.3µs ±13%    11.4µs ± 3%     ~     (p=0.151 n=5+5)
IteratorScan/keys=100,r-amp=3,key-types=points-only-24                        19.7µs ±12%    20.0µs ± 5%     ~     (p=0.421 n=5+5)
IteratorScan/keys=100,r-amp=3,key-types=points-and-ranges-24                  20.0µs ±14%    20.9µs ±10%     ~     (p=0.095 n=5+5)
IteratorScan/keys=100,r-amp=7,key-types=points-only-24                        27.9µs ± 8%    27.6µs ± 1%     ~     (p=0.690 n=5+5)
IteratorScan/keys=100,r-amp=7,key-types=points-and-ranges-24                  29.3µs ±10%    29.2µs ± 8%     ~     (p=1.000 n=5+5)
IteratorScan/keys=100,r-amp=10,key-types=points-only-24                       35.4µs ± 3%    33.8µs ± 6%     ~     (p=0.095 n=5+5)
IteratorScan/keys=100,r-amp=10,key-types=points-and-ranges-24                 33.3µs ± 7%    35.4µs ± 5%     ~     (p=0.056 n=5+5)
IteratorScan/keys=1000,r-amp=1,key-types=points-only-24                       82.4µs ± 1%    85.5µs ± 2%   +3.74%  (p=0.016 n=5+4)
IteratorScan/keys=1000,r-amp=1,key-types=points-and-ranges-24                 85.1µs ± 1%   107.6µs ±24%  +26.45%  (p=0.008 n=5+5)
IteratorScan/keys=1000,r-amp=3,key-types=points-only-24                        142µs ± 2%     151µs ± 3%   +6.25%  (p=0.008 n=5+5)
IteratorScan/keys=1000,r-amp=3,key-types=points-and-ranges-24                  146µs ± 2%     154µs ± 1%   +5.83%  (p=0.008 n=5+5)
IteratorScan/keys=1000,r-amp=7,key-types=points-only-24                        187µs ± 1%     196µs ± 2%   +4.46%  (p=0.008 n=5+5)
IteratorScan/keys=1000,r-amp=7,key-types=points-and-ranges-24                  193µs ± 5%     205µs ± 7%     ~     (p=0.056 n=5+5)
IteratorScan/keys=1000,r-amp=10,key-types=points-only-24                       206µs ± 3%     214µs ± 6%     ~     (p=0.095 n=5+5)
IteratorScan/keys=1000,r-amp=10,key-types=points-and-ranges-24                 207µs ± 2%     221µs ± 2%   +6.68%  (p=0.008 n=5+5)
IteratorScan/keys=10000,r-amp=1,key-types=points-only-24                       805µs ± 2%     838µs ± 1%   +4.16%  (p=0.008 n=5+5)
IteratorScan/keys=10000,r-amp=1,key-types=points-and-ranges-24                 844µs ± 1%     902µs ± 2%   +6.77%  (p=0.008 n=5+5)
IteratorScan/keys=10000,r-amp=3,key-types=points-only-24                      1.38ms ± 2%    1.44ms ± 3%   +4.56%  (p=0.008 n=5+5)
IteratorScan/keys=10000,r-amp=3,key-types=points-and-ranges-24                1.41ms ± 5%    1.50ms ± 1%   +6.27%  (p=0.008 n=5+5)
IteratorScan/keys=10000,r-amp=7,key-types=points-only-24                      1.75ms ± 3%    1.81ms ± 2%   +3.37%  (p=0.032 n=5+5)
IteratorScan/keys=10000,r-amp=7,key-types=points-and-ranges-24                1.79ms ± 4%    1.89ms ± 0%   +5.69%  (p=0.008 n=5+5)
IteratorScan/keys=10000,r-amp=10,key-types=points-only-24                     1.90ms ± 1%    1.98ms ± 3%   +4.56%  (p=0.008 n=5+5)
IteratorScan/keys=10000,r-amp=10,key-types=points-and-ranges-24               1.94ms ± 1%    2.04ms ± 1%   +4.74%  (p=0.008 n=5+5)
CombinedIteratorSeek/range-key=false/batch=false-24                           5.48µs ± 2%    5.74µs ± 1%   +4.80%  (p=0.008 n=5+5)
CombinedIteratorSeek/range-key=false/batch=true-24                            6.14µs ± 3%    6.46µs ± 1%   +5.16%  (p=0.008 n=5+5)
CombinedIteratorSeek/range-key=true/batch=false-24                            13.3µs ± 2%    13.6µs ± 1%   +2.17%  (p=0.032 n=5+5)
CombinedIteratorSeek/range-key=true/batch=true-24                             14.1µs ± 1%    14.5µs ± 1%   +2.59%  (p=0.008 n=5+5)
CombinedIteratorSeek_Bounded-24                                               94.4µs ± 1%    94.3µs ± 3%     ~     (p=1.000 n=5+5)
CombinedIteratorSeekPrefix-24                                                  184µs ± 4%     188µs ± 2%     ~     (p=0.056 n=5+5)
LevelIterSeekGE/restart=16/count=5-24                                         1.74µs ± 4%    1.70µs ± 1%     ~     (p=0.056 n=5+5)
LevelIterSeqSeekGEWithBounds/restart=16/count=5-24                             118ns ± 0%     121ns ± 0%   +2.64%  (p=0.008 n=5+5)
LevelIterSeqSeekPrefixGE/skip=1/use-next=false-24                              667ns ± 5%     658ns ± 1%     ~     (p=0.548 n=5+5)
LevelIterSeqSeekPrefixGE/skip=1/use-next=true-24                               131ns ± 0%     130ns ± 1%   -0.83%  (p=0.032 n=5+5)
LevelIterSeqSeekPrefixGE/skip=2/use-next=false-24                              687ns ± 2%     669ns ± 1%   -2.65%  (p=0.016 n=5+5)
LevelIterSeqSeekPrefixGE/skip=2/use-next=true-24                               167ns ± 1%     167ns ± 1%     ~     (p=0.730 n=5+5)
pkg:github.com/cockroachdb/pebble/sstable goos:linux goarch:amd64
BlockIterSeekGE/restart=16-24                                                  456ns ± 1%     456ns ± 0%     ~     (p=0.651 n=5+5)
BlockIterSeekLT/restart=16-24                                                  515ns ± 2%     519ns ± 5%     ~     (p=0.548 n=5+5)
BlockIterNext/restart=16-24                                                   19.2ns ± 0%    17.6ns ± 3%   -8.19%  (p=0.008 n=5+5)
BlockIterPrev/restart=16-24                                                   36.2ns ± 3%    36.7ns ± 1%     ~     (p=0.841 n=5+5)
TableIterSeekGE/restart=16,compression=Snappy-24                              1.46µs ± 1%    1.47µs ± 5%     ~     (p=0.794 n=5+5)
TableIterSeekGE/restart=16,compression=ZSTD-24                                1.51µs ± 6%    1.46µs ± 3%     ~     (p=0.151 n=5+5)
TableIterSeekLT/restart=16,compression=Snappy-24                              1.54µs ± 4%    1.54µs ± 5%     ~     (p=1.000 n=5+5)
TableIterSeekLT/restart=16,compression=ZSTD-24                                1.53µs ± 3%    1.54µs ± 3%     ~     (p=0.794 n=5+5)
TableIterNext/restart=16,compression=Snappy-24                                22.6ns ± 1%    22.3ns ± 2%     ~     (p=0.111 n=5+5)
TableIterNext/restart=16,compression=ZSTD-24                                  22.6ns ± 3%    22.4ns ± 2%     ~     (p=0.421 n=5+5)
TableIterPrev/restart=16,compression=Snappy-24                                42.9ns ± 1%    42.3ns ± 1%   -1.33%  (p=0.008 n=5+5)
TableIterPrev/restart=16,compression=ZSTD-24                                  43.1ns ± 1%    42.4ns ± 0%   -1.57%  (p=0.008 n=5+5)

name                                                                        old alloc/op   new alloc/op   delta
pkg:github.com/cockroachdb/pebble goos:linux goarch:amd64
IteratorSeekGE-24                                                              0.00B          0.00B          ~     (all equal)
IteratorNext-24                                                                0.00B          0.00B          ~     (all equal)
IteratorPrev-24                                                                0.00B          0.00B          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=1/bloom=false/with-tombstone=false-24     0.00B          0.00B          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=1/bloom=false/with-tombstone=true-24      0.00B          0.00B          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=1/bloom=true/with-tombstone=false-24      0.00B          0.00B          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=1/bloom=true/with-tombstone=true-24        495B ± 0%      495B ± 0%     ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=2/bloom=false/with-tombstone=false-24     0.00B          0.00B          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=2/bloom=false/with-tombstone=true-24      0.00B          0.00B          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=2/bloom=true/with-tombstone=false-24      0.00B          0.00B          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=2/bloom=true/with-tombstone=true-24        495B ± 0%      495B ± 0%     ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=4/bloom=false/with-tombstone=false-24     0.00B          0.00B          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=4/bloom=false/with-tombstone=true-24      0.00B          0.00B          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=4/bloom=true/with-tombstone=false-24      0.00B          0.00B          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=4/bloom=true/with-tombstone=true-24        495B ± 0%      495B ± 0%     ~     (all equal)
IteratorSeqSeekGEWithBounds-24                                                 0.00B          0.00B          ~     (all equal)
IteratorSeekGENoop/withLimit=false-24                                          0.00B          0.00B          ~     (all equal)
IteratorSeekGENoop/withLimit=true-24                                           0.00B          0.00B          ~     (all equal)
Iterator_RangeKeyMasking/range-keys-suffixes=@10/forward-24                     621B ± 8%      655B ±10%     ~     (p=0.127 n=5+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@10/backward-24                  1.70kB ± 4%    1.72kB ± 4%     ~     (p=0.222 n=5+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@50/forward-24                     643B ± 1%      646B ± 8%     ~     (p=0.222 n=5+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@50/backward-24                  1.72kB ± 0%    1.70kB ± 3%     ~     (p=1.000 n=4+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@75/forward-24                     558B ± 4%      572B ± 4%     ~     (p=0.222 n=5+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@75/backward-24                  1.61kB ± 2%    1.65kB ± 0%   +2.30%  (p=0.008 n=5+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@100/forward-24                    504B ± 1%      519B ± 1%   +3.02%  (p=0.008 n=5+5)
Iterator_RangeKeyMasking/range-keys-suffixes=@100/backward-24                 1.57kB ± 0%    1.58kB ± 0%   +1.05%  (p=0.008 n=5+5)
IteratorScan/keys=100,r-amp=1,key-types=points-only-24                         64.0B ± 0%     80.0B ± 0%  +25.00%  (p=0.008 n=5+5)
IteratorScan/keys=100,r-amp=1,key-types=points-and-ranges-24                   64.0B ± 0%     80.0B ± 0%  +25.00%  (p=0.008 n=5+5)
IteratorScan/keys=100,r-amp=3,key-types=points-only-24                          192B ± 0%      224B ± 0%  +16.87%  (p=0.008 n=5+5)
IteratorScan/keys=100,r-amp=3,key-types=points-and-ranges-24                    192B ± 0%      224B ± 0%  +16.67%  (p=0.016 n=5+4)
IteratorScan/keys=100,r-amp=7,key-types=points-only-24                          449B ± 0%      512B ± 0%  +14.22%  (p=0.008 n=5+5)
IteratorScan/keys=100,r-amp=7,key-types=points-and-ranges-24                    449B ± 0%      513B ± 0%  +14.25%  (p=0.016 n=5+4)
IteratorScan/keys=100,r-amp=10,key-types=points-only-24                         642B ± 0%      771B ± 0%  +20.09%  (p=0.016 n=5+4)
IteratorScan/keys=100,r-amp=10,key-types=points-and-ranges-24                   642B ± 0%      771B ± 0%  +20.03%  (p=0.016 n=4+5)
IteratorScan/keys=1000,r-amp=1,key-types=points-only-24                        65.0B ± 0%     81.0B ± 0%  +24.62%  (p=0.016 n=4+5)
IteratorScan/keys=1000,r-amp=1,key-types=points-and-ranges-24                  65.0B ± 0%     80.6B ± 1%  +24.00%  (p=0.008 n=5+5)
IteratorScan/keys=1000,r-amp=3,key-types=points-only-24                         194B ± 1%      227B ± 0%  +16.92%  (p=0.008 n=5+5)
IteratorScan/keys=1000,r-amp=3,key-types=points-and-ranges-24                   194B ± 0%      225B ± 1%  +16.08%  (p=0.016 n=4+5)
IteratorScan/keys=1000,r-amp=7,key-types=points-only-24                         450B ± 0%      513B ± 0%  +14.14%  (p=0.008 n=5+5)
IteratorScan/keys=1000,r-amp=7,key-types=points-and-ranges-24                   450B ± 0%      514B ± 0%  +14.17%  (p=0.008 n=5+5)
IteratorScan/keys=1000,r-amp=10,key-types=points-only-24                        644B ± 0%      772B ± 0%  +19.84%  (p=0.008 n=5+5)
IteratorScan/keys=1000,r-amp=10,key-types=points-and-ranges-24                  643B ± 0%      771B ± 0%  +19.94%  (p=0.008 n=5+5)
IteratorScan/keys=10000,r-amp=1,key-types=points-only-24                       73.0B ± 8%     91.0B ± 0%  +24.66%  (p=0.016 n=5+4)
IteratorScan/keys=10000,r-amp=1,key-types=points-and-ranges-24                 75.0B ± 0%     88.4B ± 5%  +17.87%  (p=0.016 n=4+5)
IteratorScan/keys=10000,r-amp=3,key-types=points-only-24                        210B ± 0%      244B ± 0%  +15.97%  (p=0.008 n=5+5)
IteratorScan/keys=10000,r-amp=3,key-types=points-and-ranges-24                  206B ± 3%      244B ± 0%  +18.57%  (p=0.016 n=5+4)
IteratorScan/keys=10000,r-amp=7,key-types=points-only-24                        472B ± 0%      537B ± 0%  +13.82%  (p=0.029 n=4+4)
IteratorScan/keys=10000,r-amp=7,key-types=points-and-ranges-24                  463B ± 2%      534B ± 2%  +15.47%  (p=0.008 n=5+5)
IteratorScan/keys=10000,r-amp=10,key-types=points-only-24                       667B ± 0%      796B ± 0%  +19.39%  (p=0.016 n=4+5)
IteratorScan/keys=10000,r-amp=10,key-types=points-and-ranges-24                 667B ± 0%      789B ± 1%  +18.26%  (p=0.016 n=4+5)
CombinedIteratorSeek/range-key=false/batch=false-24                            64.0B ± 0%     80.0B ± 0%  +25.00%  (p=0.008 n=5+5)
CombinedIteratorSeek/range-key=false/batch=true-24                              128B ± 0%      144B ± 0%  +12.50%  (p=0.008 n=5+5)
CombinedIteratorSeek/range-key=true/batch=false-24                              377B ± 0%      393B ± 0%   +4.24%  (p=0.016 n=5+4)
CombinedIteratorSeek/range-key=true/batch=true-24                               441B ± 0%      457B ± 0%   +3.72%  (p=0.016 n=4+5)
CombinedIteratorSeek_Bounded-24                                                 368B ± 0%      384B ± 0%   +4.40%  (p=0.008 n=5+5)
CombinedIteratorSeekPrefix-24                                                   585B ± 0%      602B ± 0%   +2.91%  (p=0.008 n=5+5)
LevelIterSeekGE/restart=16/count=5-24                                          0.00B          0.00B          ~     (all equal)
LevelIterSeqSeekGEWithBounds/restart=16/count=5-24                             0.00B          0.00B          ~     (all equal)
LevelIterSeqSeekPrefixGE/skip=1/use-next=false-24                              0.00B          0.00B          ~     (all equal)
LevelIterSeqSeekPrefixGE/skip=1/use-next=true-24                               0.00B          0.00B          ~     (all equal)
LevelIterSeqSeekPrefixGE/skip=2/use-next=false-24                              0.00B          0.00B          ~     (all equal)
LevelIterSeqSeekPrefixGE/skip=2/use-next=true-24                               0.00B          0.00B          ~     (all equal)
pkg:github.com/cockroachdb/pebble/sstable goos:linux goarch:amd64
BlockIterSeekGE/restart=16-24                                                  0.00B          0.00B          ~     (all equal)
BlockIterSeekLT/restart=16-24                                                  0.00B          0.00B          ~     (all equal)
BlockIterNext/restart=16-24                                                    0.00B          0.00B          ~     (all equal)
BlockIterPrev/restart=16-24                                                    0.00B          0.00B          ~     (all equal)
TableIterSeekGE/restart=16,compression=Snappy-24                               0.00B          0.00B          ~     (all equal)
TableIterSeekGE/restart=16,compression=ZSTD-24                                 0.00B          0.00B          ~     (all equal)
TableIterSeekLT/restart=16,compression=Snappy-24                               0.00B          0.00B          ~     (all equal)
TableIterSeekLT/restart=16,compression=ZSTD-24                                 0.00B          0.00B          ~     (all equal)
TableIterNext/restart=16,compression=Snappy-24                                 0.00B          0.00B          ~     (all equal)
TableIterNext/restart=16,compression=ZSTD-24                                   0.00B          0.00B          ~     (all equal)
TableIterPrev/restart=16,compression=Snappy-24                                 0.00B          0.00B          ~     (all equal)
TableIterPrev/restart=16,compression=ZSTD-24                                   0.00B          0.00B          ~     (all equal)

name                                                                        old allocs/op  new allocs/op  delta
pkg:github.com/cockroachdb/pebble goos:linux goarch:amd64
IteratorSeekGE-24                                                               0.00           0.00          ~     (all equal)
IteratorNext-24                                                                 0.00           0.00          ~     (all equal)
IteratorPrev-24                                                                 0.00           0.00          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=1/bloom=false/with-tombstone=false-24      0.00           0.00          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=1/bloom=false/with-tombstone=true-24       0.00           0.00          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=1/bloom=true/with-tombstone=false-24       0.00           0.00          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=1/bloom=true/with-tombstone=true-24        1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=2/bloom=false/with-tombstone=false-24      0.00           0.00          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=2/bloom=false/with-tombstone=true-24       0.00           0.00          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=2/bloom=true/with-tombstone=false-24       0.00           0.00          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=2/bloom=true/with-tombstone=true-24        1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=4/bloom=false/with-tombstone=false-24      0.00           0.00          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=4/bloom=false/with-tombstone=true-24       0.00           0.00          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=4/bloom=true/with-tombstone=false-24       0.00           0.00          ~     (all equal)
IteratorSeqSeekPrefixGENotFound/skip=4/bloom=true/with-tombstone=true-24        1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorSeqSeekGEWithBounds-24                                                  0.00           0.00          ~     (all equal)
IteratorSeekGENoop/withLimit=false-24                                           0.00           0.00          ~     (all equal)
IteratorSeekGENoop/withLimit=true-24                                            0.00           0.00          ~     (all equal)
Iterator_RangeKeyMasking/range-keys-suffixes=@10/forward-24                     19.0 ± 0%      19.0 ± 0%     ~     (all equal)
Iterator_RangeKeyMasking/range-keys-suffixes=@10/backward-24                    21.0 ± 0%      21.0 ± 0%     ~     (all equal)
Iterator_RangeKeyMasking/range-keys-suffixes=@50/forward-24                     19.0 ± 0%      19.0 ± 0%     ~     (all equal)
Iterator_RangeKeyMasking/range-keys-suffixes=@50/backward-24                    21.0 ± 0%      21.0 ± 0%     ~     (all equal)
Iterator_RangeKeyMasking/range-keys-suffixes=@75/forward-24                     19.0 ± 0%      19.0 ± 0%     ~     (all equal)
Iterator_RangeKeyMasking/range-keys-suffixes=@75/backward-24                    21.0 ± 0%      21.0 ± 0%     ~     (all equal)
Iterator_RangeKeyMasking/range-keys-suffixes=@100/forward-24                    19.0 ± 0%      19.0 ± 0%     ~     (all equal)
Iterator_RangeKeyMasking/range-keys-suffixes=@100/backward-24                   22.0 ± 0%      22.0 ± 0%     ~     (all equal)
IteratorScan/keys=100,r-amp=1,key-types=points-only-24                          1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=100,r-amp=1,key-types=points-and-ranges-24                    1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=100,r-amp=3,key-types=points-only-24                          1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=100,r-amp=3,key-types=points-and-ranges-24                    1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=100,r-amp=7,key-types=points-only-24                          1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=100,r-amp=7,key-types=points-and-ranges-24                    1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=100,r-amp=10,key-types=points-only-24                         1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=100,r-amp=10,key-types=points-and-ranges-24                   1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=1000,r-amp=1,key-types=points-only-24                         1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=1000,r-amp=1,key-types=points-and-ranges-24                   1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=1000,r-amp=3,key-types=points-only-24                         1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=1000,r-amp=3,key-types=points-and-ranges-24                   1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=1000,r-amp=7,key-types=points-only-24                         1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=1000,r-amp=7,key-types=points-and-ranges-24                   1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=1000,r-amp=10,key-types=points-only-24                        1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=1000,r-amp=10,key-types=points-and-ranges-24                  1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=10000,r-amp=1,key-types=points-only-24                        1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=10000,r-amp=1,key-types=points-and-ranges-24                  1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=10000,r-amp=3,key-types=points-only-24                        1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=10000,r-amp=3,key-types=points-and-ranges-24                  1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=10000,r-amp=7,key-types=points-only-24                        1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=10000,r-amp=7,key-types=points-and-ranges-24                  1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=10000,r-amp=10,key-types=points-only-24                       1.00 ± 0%      1.00 ± 0%     ~     (all equal)
IteratorScan/keys=10000,r-amp=10,key-types=points-and-ranges-24                 1.00 ± 0%      1.00 ± 0%     ~     (all equal)
CombinedIteratorSeek/range-key=false/batch=false-24                             1.00 ± 0%      1.00 ± 0%     ~     (all equal)
CombinedIteratorSeek/range-key=false/batch=true-24                              1.00 ± 0%      1.00 ± 0%     ~     (all equal)
CombinedIteratorSeek/range-key=true/batch=false-24                              9.00 ± 0%      9.00 ± 0%     ~     (all equal)
CombinedIteratorSeek/range-key=true/batch=true-24                               9.00 ± 0%      9.00 ± 0%     ~     (all equal)
CombinedIteratorSeek_Bounded-24                                                 10.0 ± 0%      10.0 ± 0%     ~     (all equal)
CombinedIteratorSeekPrefix-24                                                   35.0 ± 0%      35.0 ± 0%     ~     (all equal)
LevelIterSeekGE/restart=16/count=5-24                                           0.00           0.00          ~     (all equal)
LevelIterSeqSeekGEWithBounds/restart=16/count=5-24                              0.00           0.00          ~     (all equal)
LevelIterSeqSeekPrefixGE/skip=1/use-next=false-24                               0.00           0.00          ~     (all equal)
LevelIterSeqSeekPrefixGE/skip=1/use-next=true-24                                0.00           0.00          ~     (all equal)
LevelIterSeqSeekPrefixGE/skip=2/use-next=false-24                               0.00           0.00          ~     (all equal)
LevelIterSeqSeekPrefixGE/skip=2/use-next=true-24                                0.00           0.00          ~     (all equal)
pkg:github.com/cockroachdb/pebble/sstable goos:linux goarch:amd64
BlockIterSeekGE/restart=16-24                                                   0.00           0.00          ~     (all equal)
BlockIterSeekLT/restart=16-24                                                   0.00           0.00          ~     (all equal)
BlockIterNext/restart=16-24                                                     0.00           0.00          ~     (all equal)
BlockIterPrev/restart=16-24                                                     0.00           0.00          ~     (all equal)
TableIterSeekGE/restart=16,compression=Snappy-24                                0.00           0.00          ~     (all equal)
TableIterSeekGE/restart=16,compression=ZSTD-24                                  0.00           0.00          ~     (all equal)
TableIterSeekLT/restart=16,compression=Snappy-24                                0.00           0.00          ~     (all equal)
TableIterSeekLT/restart=16,compression=ZSTD-24                                  0.00           0.00          ~     (all equal)
TableIterNext/restart=16,compression=Snappy-24                                  0.00           0.00          ~     (all equal)
TableIterNext/restart=16,compression=ZSTD-24                                    0.00           0.00          ~     (all equal)
TableIterPrev/restart=16,compression=Snappy-24                                  0.00           0.00          ~     (all equal)
TableIterPrev/restart=16,compression=ZSTD-24                                    0.00           0.00          ~     (all equal)

@sumeerbhola
Copy link
Collaborator Author

That +26% was noise. There's nothing odd in the cpu profile. Rerunning on gceworker

name                                                           old time/op    new time/op    delta
IteratorScan/keys=1000,r-amp=1,key-types=points-and-ranges-24    85.4µs ± 1%    90.8µs ± 2%   +6.26%  (p=0.001 n=10+5)

name                                                           old alloc/op   new alloc/op   delta
IteratorScan/keys=1000,r-amp=1,key-types=points-and-ranges-24     64.7B ± 1%     80.6B ± 1%  +24.57%  (p=0.001 n=10+5)

name                                                           old allocs/op  new allocs/op  delta
IteratorScan/keys=1000,r-amp=1,key-types=points-and-ranges-24      1.00 ± 0%      1.00 ± 0%     ~     (all equal)

And on my macbook

name                                                           old time/op    new time/op    delta
IteratorScan/keys=1000,r-amp=1,key-types=points-and-ranges-10    43.5µs ± 1%    44.1µs ± 0%   +1.32%  (p=0.002 n=6+6)

name                                                           old alloc/op   new alloc/op   delta
IteratorScan/keys=1000,r-amp=1,key-types=points-and-ranges-10     64.0B ± 0%     80.0B ± 0%  +25.00%  (p=0.002 n=6+6)

name                                                           old allocs/op  new allocs/op  delta
IteratorScan/keys=1000,r-amp=1,key-types=points-and-ranges-10      1.00 ± 0%      1.00 ± 0%     ~     (all equal)

Copy link
Collaborator

@jbowens jbowens left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still wrapping my head around the LazyValue interface. left some comments that are just verbalized attempts at under understanding

Reviewed all commit messages.
Reviewable status: 0 of 41 files reviewed, 5 unresolved discussions (waiting on @sumeerbhola)


-- commits line 11 at r2:
This is necessary because of the new extended MVCC Value encoding, where a non-zero length value no longer guarantees it's not a tombstone?


-- commits line 44 at r2:
Nice.


internal/base/lazy_value.go line 100 at r2 (raw file):

//     sstable iterator may not even be open. LazyValue.Value() will still
//     work (at a higher cost), but since the sstable iterator is not open, it
//     cannot satisfy P2. For this reason, the LazyValue.Value() method

what does "satisfy P2" mean here? does it mean 'it has no mechanism to know when the retrieved value is no longer in use?' since the cloned LazyValue has an infinite lifetime? I see the general problem described under P2, but I don't think I understand the definition of P2.


internal/base/lazy_value.go line 237 at r2 (raw file):

	}
	bufLen := len(buf)
	buf = append(buf, lv.ValueOrHandle...)

Is buf ever-growing then? And if the caller doesn't want that, they're free to reslice the returned buf before supplying it to Clone again?

Could this copy be prevented by with an alternative interface? In a reverse iteration case where the current value is inlined, the copy is unnecessary if the underlying block doesn't end up being released. Could this copy be done lazily, if the iterator is going to step to the previous block but the caller has currently 'pinned' the value that they currently care about (P1) to a value in the previous block?

Is the passing around of LazyValue as values with indefinite lifetimes in conflict with P1?

The following isn't an earnest interface suggestion, but talking about it might help me understand LazyValue and its relationship to P1 & P2:

type InternalIterator interface {
    ValuePin
    // ...
}

type ValuePin interface {
    // Pin pins the current value, ensuring that subsequent calls to Value return
    // the value of the key-value pair under the current iterator position until
    // either Unpin or Pin is called again.
    //
    // If the iterator must copy the value to preserve access, the provided byte
    // slice will be used.
    Pin(buf []byte)
    // Unpin unpins the previous pin. Subsequent calls to Value will return the value at
    // the current iterator position.
    Unpin()
    // Value returns the pinned value, if currently pinned, or the value at the current iterator
    // position if currently unpinned. The return LazyValue is only valid until unpinned, if
    // if pinned, or the next iterator operation if currently unpinned.
    Value() LazyValue
}

type LazyValue struct { /* ... */}
func (lv LazyValue) Len() int {}
func (lv LazyValue) Attributes() ShortAttributes {}
func (lv LazyValue) Get() []byte {}

Does the above codify [P1] & [P2] at the expense of not being able to lazily retrieve values from multiple previous iterator positions?


sstable/block_property.go line 82 at r2 (raw file):

// stored together with the key, and may not even be retrieved during
// compactions. If Pebble is configured with such value separation, block
// properties must only apply to the key.

Is this saying that when a value is separated, the block property collector won't be supplied with the value at all?

Copy link
Collaborator Author

@sumeerbhola sumeerbhola left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TFTR!

Reviewable status: 0 of 41 files reviewed, 5 unresolved discussions (waiting on @jbowens)


-- commits line 11 at r2:

Previously, jbowens (Jackson Owens) wrote…

This is necessary because of the new extended MVCC Value encoding, where a non-zero length value no longer guarantees it's not a tombstone?

Correct


-- commits line 44 at r2:

Previously, jbowens (Jackson Owens) wrote…

Nice.

This mostly fell out of the design.
It was getting hard to reason about when a copy should be made, pebble.Iterator might have stepped the InternalIterator but pebbleMVCCScanner with a LazyValue should not have to know. Earlier pebbleMVCCScanner did not care whether the unstable value was backed by a slice in pebble.Iterator or in InternalIterator, and we want something analogous now.


internal/base/lazy_value.go line 100 at r2 (raw file):

what does "satisfy P2" mean here? does it mean 'it has no mechanism to know when the retrieved value is no longer in use?'

Yes, it is 'it has no mechanism to know when the retrieved value is no longer in use'. I've changed the phrasing in the comment to say this.

since the cloned LazyValue has an infinite lifetime? I see the general problem described under P2, but I don't think I understand the definition of P2.

Yes the cloned LazyValue has an infinite lifetime, but that is not quite the issue -- cloning a LazyValue only makes the LazyValue lifetime infinite and says nothing about the underlying value. And we've said that the the caller, by calling LazyValue.Value, is relieving the iterator tree of the promise regarding stability of the value returned from a previous LazyValue.Value. The problem is that we can't transmit this "relief of the promise" to a iterator that is not open. In the implementation, what actually retrieves the value in this case is a valueBlockReader that has temporarily "reopened" itself, and it will "close" itself after doing the retrieval. We could make that valueBlockReader do an allocation, but I am wary of allocations. Since the caller is the one who is long-lived, it seemed better to have it provide a pre-allocated buffer.


internal/base/lazy_value.go line 237 at r2 (raw file):

Is buf ever-growing then? And if the caller doesn't want that, they're free to reslice the returned buf before supplying it to Clone again?

Yes, the caller should reslice to buf[:0] before supplying again. The only reason to do it this way was to support callers that are packing multiple things into the same buffer. Specifically, pebbleMVCCScanner does:

		p.savedBuf = append(p.savedBuf[:0], p.curRawKey...)
		p.savedBuf = append(p.savedBuf, p.curRawValue...)

Regarding the ValuePin interface, it seems that if one moves the iterator after doing a Pin one can't look at the LazyValue at the current iterator position without calling Unpin. So even the attributes are not available for both. That is narrower than what is in this PR. Also, with the PR one can keep a sequence of k LazyValues (k > 2, but small, though we don't currently have a use case for this) and sequentially call LazyValue.Value on each.

In a reverse iteration case where the current value is inlined, the copy is unnecessary if the underlying block doesn't end up being released. Could this copy be done lazily, if the iterator is going to step to the previous block but the caller has currently 'pinned' the value that they currently care about (P1) to a value in the previous block?

I assume the optimization was not the primary goal behind ValuePin, so I will ignore that part for now. I think what this is trying to do is make pinning/unpinning more explicit via the interface, vs the scheme in this PR.
The PR keeps LazyValue mostly self-contained in order to avoid traversing the iterator tree again when manipulating the value. In addition to the cost of that traversal, doing such a traversal makes my head hurt because the iterator tree may have changed. So the only reference to the iterator tree is via the opaque ValueFetcher which in practice will point directly to an open valueBlockReader (the closed valueBlockReader will retain enough state that it can open the sstable again). It seems to me that ValuePin will need to traverse the iterator tree, so I am not sure how that will work under the covers.

btw, there is a deficiency in the Clone()/Value() combo in that it does not make it clear whether the value is already owned: say Alice has called Clone() previously and now calls Value() and gets callerOwned=false, and the value is in-place, then in fact it is already caller owned because this in-place value was previously cloned. I don't think we have a use case that would benefit from this, which is why I didn't add support to introspect whether a LazyValue is in-place or not.


sstable/block_property.go line 82 at r2 (raw file):

Previously, jbowens (Jackson Owens) wrote…

Is this saying that when a value is separated, the block property collector won't be supplied with the value at all?

Yes, I think to make this clean if a Pebble instance has enabled value separation (initially via Options) we could use that knowledge and always pass nil as the value (regardless of whether it is separated or not).

Copy link
Collaborator

@jbowens jbowens left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:lgtm:

nice

Reviewed 27 of 41 files at r1, 11 of 12 files at r2, 3 of 3 files at r3, all commit messages.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on @sumeerbhola)

…e key

The changes here are plumbing for the read path. The actual write and
read path for values stored in value blocks will happen in future PRs.

A ShortAttribute (3 bits) is also introduced, for user-defined attributes
about a value. These will be included in the 1 byte prefix of the value
in the key-value pairs in the sstable (the other 5 bits are for internal
use). In CockroachDB, the ShortAttribute will initially be used for
marking MVCC tombstones (for which 1 bit is sufficient).

LazyValue can represent either an in-place value (stored with the key)
or a value stored in the value section. The interface is general enough
for the future where values may be stored in separate files.

LazyValue is used in the InternalIterator interface, such that all
positioning calls return (*InternalKey, LazyValue). It is also exposed via
the public Iterator for callers that need to remember a recent but not
necessarily latest LazyValue, in case they need the actual value in the
future. An example is a caller that is iterating in reverse and looking for
the latest MVCC version for a key -- it cannot identify the latest MVCC
version without stepping to the previous key-value pair e.g.
storage.pebbleMVCCScanner in CockroachDB.

There is subtlety in memory management:
- A LazyValue returned by an InternalIterator or Iterator is unstable in
  that repositioning the iterator will invalidate the memory inside it. A
  caller wishing to maintain that LazyValue needs to call
  LazyValue.Clone. Note that this does not fetch the value if it is not
  in-place.

- A user of an iterator that calls LazyValue.Value wants as much as
  possible for the returned value byte-slice to point to iterator owned memory.
  - [P1] The underlying iterator that owns that memory also needs a promise
    from that user that at any time there is at most one value byte-slice
    that the caller is expecting it to maintain. Otherwise, the underlying
    iterator has to maintain multiple such byte-slices which results in
    more complicated and inefficient code.
    Note that pre-LazyValue this promise was trivially provided by only
    asking for the value at the current iterator position (so it could
    certainly point inside the underlying ssblock). For other cases, the
    user would make a copy of the byte-slice. We are generalizing this
    promise which allows for more memory allocation optimization.
  - [P2] The underlying iterator, in order to make the promise that it is
    maintaining the one value byte-slice, also needs a way to know when
    it is relieved of that promise. It is relieved of that promise by being
    told that it is being repositioned (this behavior is unchanged by this
    PR). Specifically, the owner of the value byte-slice is a sstable
    iterator. Consider the case where the caller has used LazyValue.Clone
    and repositioned the iterator. In this case the sstable iterator may
    not even be open. LazyValue.Value will still work (at a higher cost),
    but since the sstable iterator is not open, it cannot satisfy P2. For
    this reason, the LazyValue.Value method accepts a caller owned buffer,
    that the callee will use if needed.
Copy link
Collaborator Author

@sumeerbhola sumeerbhola left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased. Will merge when CI is green.

Reviewable status: 28 of 41 files reviewed, all discussions resolved (waiting on @jbowens)

@sumeerbhola
Copy link
Collaborator Author

Two test failures, both ignorable:

@sumeerbhola sumeerbhola merged commit 418733a into cockroachdb:master Nov 2, 2022
@sumeerbhola sumeerbhola deleted the val_sep1 branch December 15, 2022 23:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants